From 43759a2c98b714f53262e03f3c8fd327013e87d3 Mon Sep 17 00:00:00 2001 From: Ondrej Samohel Date: Fri, 26 Mar 2021 16:28:05 +0100 Subject: [PATCH 001/148] collect renderer specific nodes --- .../maya/plugins/publish/collect_look.py | 108 +++++++++++++----- .../maya/plugins/publish/extract_look.py | 43 ++++++- 2 files changed, 121 insertions(+), 30 deletions(-) diff --git a/pype/hosts/maya/plugins/publish/collect_look.py b/pype/hosts/maya/plugins/publish/collect_look.py index 35abc5a991..04987b44a9 100644 --- a/pype/hosts/maya/plugins/publish/collect_look.py +++ b/pype/hosts/maya/plugins/publish/collect_look.py @@ -1,3 +1,5 @@ +# -*- coding: utf-8 -*- +"""Maya look collector.""" import re import os import glob @@ -16,6 +18,11 @@ SHAPE_ATTRS = ["castsShadows", "doubleSided", "opposite"] +RENDERER_NODE_TYPES = [ + # redshift + "RedshiftMeshParameters" +] + SHAPE_ATTRS = set(SHAPE_ATTRS) @@ -219,7 +226,6 @@ class CollectLook(pyblish.api.InstancePlugin): with lib.renderlayer(instance.data["renderlayer"]): self.collect(instance) - def collect(self, instance): self.log.info("Looking for look associations " @@ -228,6 +234,7 @@ class CollectLook(pyblish.api.InstancePlugin): # Discover related object sets self.log.info("Gathering sets..") sets = self.collect_sets(instance) + render_nodes = [] # Lookup set (optimization) instance_lookup = set(cmds.ls(instance, long=True)) @@ -235,48 +242,91 @@ class CollectLook(pyblish.api.InstancePlugin): self.log.info("Gathering set relations..") # Ensure iteration happen in a list so we can remove keys from the # dict within the loop - for objset in list(sets): - self.log.debug("From %s.." % objset) + + # skipped types of attribute on render specific nodes + disabled_types = ["message", "TdataCompound"] + + for obj_set in list(sets): + self.log.debug("From {}".format(obj_set)) + + # if node is specified as renderer node type, it will be + # serialized with its attributes. + if cmds.nodeType(obj_set) in RENDERER_NODE_TYPES: + self.log.info("- {} is {}".format( + obj_set, cmds.nodeType(obj_set))) + + node_attrs = [] + + # serialize its attributes so they can be recreated on look + # load. + for attr in cmds.listAttr(obj_set): + # skip publishedNodeInfo attributes as they break + # getAttr() and we don't need them anyway + if attr.startswith("publishedNodeInfo"): + continue + + # skip attributes types defined in 'disabled_type' list + if cmds.getAttr("{}.{}".format(obj_set, attr), type=True) in disabled_types: # noqa + continue + + # self.log.debug("{}: {}".format(attr, cmds.getAttr("{}.{}".format(obj_set, attr), type=True))) # noqa + node_attrs.append(( + attr, + cmds.getAttr("{}.{}".format(obj_set, attr)) + )) + + render_nodes.append( + { + "name": obj_set, + "type": cmds.nodeType(obj_set), + "members": cmds.ls(cmds.sets( + obj_set, query=True), long=True), + "attributes": node_attrs + } + ) # Get all nodes of the current objectSet (shadingEngine) - for member in cmds.ls(cmds.sets(objset, query=True), long=True): + for member in cmds.ls(cmds.sets(obj_set, query=True), long=True): member_data = self.collect_member_data(member, instance_lookup) if not member_data: continue # Add information of the node to the members list - sets[objset]["members"].append(member_data) + sets[obj_set]["members"].append(member_data) # Remove sets that didn't have any members assigned in the end # Thus the data will be limited to only what we need. - self.log.info("objset {}".format(sets[objset])) - if not sets[objset]["members"] or (not objset.endswith("SG")): - self.log.info("Removing redundant set information: " - "%s" % objset) - sets.pop(objset, None) + self.log.info("obj_set {}".format(sets[obj_set])) + if not sets[obj_set]["members"] or (not obj_set.endswith("SG")): + self.log.info( + "Removing redundant set information: {}".format(obj_set)) + sets.pop(obj_set, None) self.log.info("Gathering attribute changes to instance members..") attributes = self.collect_attributes_changed(instance) # Store data on the instance - instance.data["lookData"] = {"attributes": attributes, - "relationships": sets} + instance.data["lookData"] = { + "attributes": attributes, + "relationships": sets, + "render_nodes": render_nodes + } # Collect file nodes used by shading engines (if we have any) - files = list() - looksets = sets.keys() - shaderAttrs = [ - "surfaceShader", - "volumeShader", - "displacementShader", - "aiSurfaceShader", - "aiVolumeShader"] - materials = list() + files = [] + look_sets = sets.keys() + shader_attrs = [ + "surfaceShader", + "volumeShader", + "displacementShader", + "aiSurfaceShader", + "aiVolumeShader"] + if look_sets: + materials = [] - if looksets: - for look in looksets: - for at in shaderAttrs: + for look in look_sets: + for at in shader_attrs: try: con = cmds.listConnections("{}.{}".format(look, at)) except ValueError: @@ -289,10 +339,10 @@ class CollectLook(pyblish.api.InstancePlugin): self.log.info("Found materials:\n{}".format(materials)) - self.log.info("Found the following sets:\n{}".format(looksets)) + self.log.info("Found the following sets:\n{}".format(look_sets)) # Get the entire node chain of the look sets - # history = cmds.listHistory(looksets) - history = list() + # history = cmds.listHistory(look_sets) + history = [] for material in materials: history.extend(cmds.listHistory(material)) files = cmds.ls(history, type="file", long=True) @@ -313,7 +363,7 @@ class CollectLook(pyblish.api.InstancePlugin): # Ensure unique shader sets # Add shader sets to the instance for unify ID validation - instance.extend(shader for shader in looksets if shader + instance.extend(shader for shader in look_sets if shader not in instance_lookup) self.log.info("Collected look for %s" % instance) @@ -331,7 +381,7 @@ class CollectLook(pyblish.api.InstancePlugin): dict """ - sets = dict() + sets = {} for node in instance: related_sets = lib.get_related_sets(node) if not related_sets: diff --git a/pype/hosts/maya/plugins/publish/extract_look.py b/pype/hosts/maya/plugins/publish/extract_look.py index 2c4837b7a7..bddf5599d8 100644 --- a/pype/hosts/maya/plugins/publish/extract_look.py +++ b/pype/hosts/maya/plugins/publish/extract_look.py @@ -118,12 +118,53 @@ class ExtractLook(pype.api.Extractor): hosts = ["maya"] families = ["look"] order = pyblish.api.ExtractorOrder + 0.2 + scene_type = "ma" + + @staticmethod + def get_renderer_name(): + """Get renderer name from Maya. + + Returns: + str: Renderer name. + + """ + renderer = cmds.getAttr( + "defaultRenderGlobals.currentRenderer" + ).lower() + # handle various renderman names + if renderer.startswith("renderman"): + renderer = "renderman" + return renderer + + def get_maya_scene_type(self, instance): + """Get Maya scene type from settings. + + Args: + instance (pyblish.api.Instance): Instance with collected + project settings. + + """ + ext_mapping = ( + instance.context.data["project_settings"]["maya"]["ext_mapping"] + ) + if ext_mapping: + self.log.info("Looking in settings for scene type ...") + # use extension mapping for first family found + for family in self.families: + try: + self.scene_type = ext_mapping[family] + self.log.info( + "Using {} as scene type".format(self.scene_type)) + break + except KeyError: + # no preset found + pass def process(self, instance): # Define extract output file path dir_path = self.staging_dir(instance) - maya_fname = "{0}.ma".format(instance.name) + maya_fname = "{0}.{1}".format(instance.name, self.scene_type) json_fname = "{0}.json".format(instance.name) # Make texture dump folder From 52a5183b7bea4a591ecaa8fe63cf424f4bfc4e6f Mon Sep 17 00:00:00 2001 From: Ondrej Samohel Date: Wed, 31 Mar 2021 21:40:39 +0200 Subject: [PATCH 002/148] add script to download and extract dependencies --- README.md | 6 +- poetry.lock | 32 ++++++- pyproject.toml | 31 +++++- tools/fetch_thirdparty_libs.ps1 | 21 ++++ tools/fetch_thirdparty_libs.py | 165 ++++++++++++++++++++++++++++++++ tools/fetch_thirdparty_libs.sh | 129 +++++++++++++++++++++++++ 6 files changed, 378 insertions(+), 6 deletions(-) create mode 100644 tools/fetch_thirdparty_libs.ps1 create mode 100644 tools/fetch_thirdparty_libs.py create mode 100755 tools/fetch_thirdparty_libs.sh diff --git a/README.md b/README.md index 456655bfb9..d6c98974e0 100644 --- a/README.md +++ b/README.md @@ -50,7 +50,8 @@ git clone --recurse-submodules git@github.com:pypeclub/pype.git #### To build Pype: 1) Run `.\tools\create_env.ps1` to create virtual environment in `.\venv` -2) Run `.\tools\build.ps1` to build pype executables in `.\build\` +2) Run `.\tools\fetch_thirdparty_libs.ps1` to download third-party dependencies like ffmpeg and oiio. Those will be included in build. +3) Run `.\tools\build.ps1` to build pype executables in `.\build\` To create distributable Pype versions, run `./tools/create_zip.ps1` - that will create zip file with name `pype-vx.x.x.zip` parsed from current pype repository and @@ -105,7 +106,8 @@ pyenv local 3.7.9 #### To build Pype: 1) Run `.\tools\create_env.sh` to create virtual environment in `.\venv` -2) Run `.\tools\build.sh` to build Pype executables in `.\build\` +2) Run `.\tools\fetch_thirdparty_libs.sh` to download third-party dependencies like ffmpeg and oiio. Those will be included in build. +3) Run `.\tools\build.sh` to build Pype executables in `.\build\` ### Linux diff --git a/poetry.lock b/poetry.lock index e6c08b8ae9..1a0637dc47 100644 --- a/poetry.lock +++ b/poetry.lock @@ -296,6 +296,18 @@ category = "dev" optional = false python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" +[[package]] +name = "enlighten" +version = "1.9.0" +description = "Enlighten Progress Bar" +category = "main" +optional = false +python-versions = "*" + +[package.dependencies] +blessed = ">=1.17.7" +prefixed = ">=0.3.2" + [[package]] name = "evdev" version = "1.4.0" @@ -637,7 +649,7 @@ view = ["PySide2 (>=5.11,<6.0)"] [package.source] type = "legacy" -url = "https://d.r1.wbsprt.com/pype.club/distribute" +url = "https://distribute.openpype.io/wheels" reference = "pype" [[package]] @@ -696,6 +708,14 @@ importlib-metadata = {version = ">=0.12", markers = "python_version < \"3.8\""} [package.extras] dev = ["pre-commit", "tox"] +[[package]] +name = "prefixed" +version = "0.3.2" +description = "Prefixed alternative numeric library" +category = "main" +optional = false +python-versions = "*" + [[package]] name = "protobuf" version = "3.15.6" @@ -1391,7 +1411,7 @@ testing = ["pytest (>=4.6)", "pytest-checkdocs (>=1.2.3)", "pytest-flake8", "pyt [metadata] lock-version = "1.1" python-versions = "3.7.*" -content-hash = "4905515073ad2bf2a8517d513d68e48669b6a829f24e540b2dd60bc70cbea26b" +content-hash = "b356e327dbaa1aa38dbf1463901f64539f2c8d07be8d8a017e83b8a1554dbff9" [metadata.files] acre = [] @@ -1636,6 +1656,10 @@ docutils = [ {file = "docutils-0.16-py2.py3-none-any.whl", hash = "sha256:0c5b78adfbf7762415433f5515cd5c9e762339e23369dbe8000d84a4bf4ab3af"}, {file = "docutils-0.16.tar.gz", hash = "sha256:c2de3a60e9e7d07be26b7f2b00ca0309c207e06c100f9cc2a94931fc75a478fc"}, ] +enlighten = [ + {file = "enlighten-1.9.0-py2.py3-none-any.whl", hash = "sha256:5c59e41505702243c6b26437403e371d2a146ac72de5f706376f738ea8f32659"}, + {file = "enlighten-1.9.0.tar.gz", hash = "sha256:539cc308ccc0c3bfb50feb1b2da94c1a1ac21e80fe95e984221de8966d48f428"}, +] evdev = [ {file = "evdev-1.4.0.tar.gz", hash = "sha256:8782740eb1a86b187334c07feb5127d3faa0b236e113206dfe3ae8f77fb1aaf1"}, ] @@ -1896,6 +1920,10 @@ pluggy = [ {file = "pluggy-0.13.1-py2.py3-none-any.whl", hash = "sha256:966c145cd83c96502c3c3868f50408687b38434af77734af1e9ca461a4081d2d"}, {file = "pluggy-0.13.1.tar.gz", hash = "sha256:15b2acde666561e1298d71b523007ed7364de07029219b604cf808bfa1c765b0"}, ] +prefixed = [ + {file = "prefixed-0.3.2-py2.py3-none-any.whl", hash = "sha256:5e107306462d63f2f03c529dbf11b0026fdfec621a9a008ca639d71de22995c3"}, + {file = "prefixed-0.3.2.tar.gz", hash = "sha256:ca48277ba5fa8346dd4b760847da930c7b84416387c39e93affef086add2c029"}, +] protobuf = [ {file = "protobuf-3.15.6-cp27-cp27m-macosx_10_9_x86_64.whl", hash = "sha256:1771ef20e88759c4d81db213e89b7a1fc53937968e12af6603c658ee4bcbfa38"}, {file = "protobuf-3.15.6-cp27-cp27mu-manylinux1_x86_64.whl", hash = "sha256:1a66261a402d05c8ad8c1fde8631837307bf8d7e7740a4f3941fc3277c2e1528"}, diff --git a/pyproject.toml b/pyproject.toml index b6ca6574c4..589342da05 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -42,6 +42,7 @@ jinxed = [ { version = "^1.0.1", markers = "sys_platform == 'linux'" } ] python3-xlib = { version="*", markers = "sys_platform == 'linux'"} +enlighten = "^1.9.0" [tool.poetry.dev-dependencies] flake8 = "^3.7" @@ -61,8 +62,8 @@ sphinx-rtd-theme = "*" sphinxcontrib-websupport = "*" sphinx-qt-documentation = "*" recommonmark = "*" -tqdm = "*" wheel = "*" +enlighten = "*" # cool terminal progress bars [tool.poetry.urls] "Bug Tracker" = "https://github.com/pypeclub/pype/issues" @@ -70,8 +71,34 @@ wheel = "*" [[tool.poetry.source]] name = "pype" -url = "https://d.r1.wbsprt.com/pype.club/distribute/" +url = "https://distribute.openpype.io/wheels/" [build-system] requires = ["poetry-core>=1.0.0"] build-backend = "poetry.core.masonry.api" + +[pype] + +[pype.thirdparty.ffmpeg.windows] +url = "https://distribute.openpype.io/thirdparty/ffmpeg-4.13-windows.zip" +hash = "43988ebcba98313635f06f2ca7e2dd52670710ebceefaa77107321b1def30472" + +[pype.thirdparty.ffmpeg.linux] +url = "https://distribute.openpype.io/thirdparty/ffmpeg-20200504-linux.tgz" +hash = "sha256:..." + +[pype.thirdparty.ffmpeg.darwin] +url = "https://distribute.openpype.io/thirdparty/ffmpeg-20200504-darwin.tgz" +hash = "sha256:..." + +[pype.thirdparty.oiio.windows] +url = "https://distribute.openpype.io/thirdparty/oiio_tools-2.2.0-windows.zip" +hash = "fd2e00278e01e85dcee7b4a6969d1a16f13016ec16700fb0366dbb1b1f3c37ad" + +[pype.thirdparty.oiio.linux] +url = "https://distribute.openpype.io/thirdparty/oiio-2.2.0-linux.tgz" +hash = "sha256:..." + +[pype.thirdparty.oiio.darwin] +url = "https://distribute.openpype.io/thirdparty/oiio-2.2.0-darwin.tgz" +hash = "sha256:..." \ No newline at end of file diff --git a/tools/fetch_thirdparty_libs.ps1 b/tools/fetch_thirdparty_libs.ps1 new file mode 100644 index 0000000000..7eed5a22db --- /dev/null +++ b/tools/fetch_thirdparty_libs.ps1 @@ -0,0 +1,21 @@ +<# +.SYNOPSIS + Download and extract third-party dependencies for Pype. + +.DESCRIPTION + This will download third-party dependencies specified in pyproject.toml + and extract them to vendor/bin folder. + #> + +.EXAMPLE + +PS> .\fetch_thirdparty_libs.ps1 + +#> +$current_dir = Get-Location +$script_dir = Split-Path -Path $MyInvocation.MyCommand.Definition -Parent +$pype_root = (Get-Item $script_dir).parent.FullName +Set-Location -Path $pype_root + +& poetry run python "$($pype_root)\tools\fetch_thirdparty_libs.py" +Set-Location -Path $current_dir diff --git a/tools/fetch_thirdparty_libs.py b/tools/fetch_thirdparty_libs.py new file mode 100644 index 0000000000..cda4d3a6fd --- /dev/null +++ b/tools/fetch_thirdparty_libs.py @@ -0,0 +1,165 @@ +# -*- coding: utf-8 -*- +"""Fetch, verify and process third-party dependencies of Pype. + +Those should be defined in `pyproject.toml` in Pype sources root. + +""" +import os +import sys +import toml +import shutil +from pathlib import Path +from urllib.parse import urlparse +import requests +import enlighten +import platform +import blessed +import tempfile +import math +import hashlib +import tarfile +import zipfile +import time + + +term = blessed.Terminal() +manager = enlighten.get_manager() +hash_buffer_size = 65536 + + +def sha256_sum(filename: Path): + """Calculate sha256 hash for given file. + + Args: + filename (Path): path to file. + + Returns: + str: hex hash. + + """ + _hash = hashlib.sha256() + with open(filename, 'rb', buffering=0) as f: + buffer = bytearray(128*1024) + mv = memoryview(buffer) + for n in iter(lambda: f.readinto(mv), 0): + _hash.update(mv[:n]) + return _hash.hexdigest() + + +def _print(msg: str, message_type: int = 0) -> None: + """Print message to console. + + Args: + msg (str): message to print + message_type (int): type of message (0 info, 1 error, 2 note) + + """ + if message_type == 0: + header = term.aquamarine3(">>> ") + elif message_type == 1: + header = term.orangered2("!!! ") + elif message_type == 2: + header = term.tan1("... ") + else: + header = term.darkolivegreen3("--- ") + + print("{}{}".format(header, msg)) + + +_print("Processing third-party dependencies ...") +start_time = time.time_ns() +pype_root = Path(os.path.dirname(__file__)).parent +pyproject = toml.load(pype_root / "pyproject.toml") +platform_name = platform.system().lower() + +try: + thirdparty = pyproject["pype"]["thirdparty"] +except AttributeError: + _print("No third-party libraries specified in pyproject.toml", 1) + sys.exit(1) + +for k, v in thirdparty.items(): + _print(f"processing {k}") + destination_path = pype_root / "vendor" / "bin" / k / platform_name + url = v.get(platform_name).get("url") + + + if not v.get(platform_name): + _print(("missing definition for current " + f"platform [ {platform_name} ]"), 1) + sys.exit(1) + + parsed_url = urlparse(url) + + # check if file is already extracted in /vendor/bin + if destination_path.exists(): + _print("destination path already exists, deleting ...", 2) + if destination_path.is_dir(): + try: + shutil.rmtree(destination_path) + except OSError as e: + _print("cannot delete folder.", 1) + raise SystemExit(e) + + # download file + _print(f"Downloading {url} ...") + with tempfile.TemporaryDirectory() as temp_dir: + temp_file = Path(temp_dir) / Path(parsed_url.path).name + + r = requests.get(url, stream=True) + content_len = int(r.headers.get('Content-Length', '0')) or None + with manager.counter(color='green', + total=content_len and math.ceil(content_len / 2 ** 20), # noqa: E501 + unit='MiB', leave=False) as counter: + with open(temp_file, 'wb', buffering=2 ** 24) as file_handle: + for chunk in r.iter_content(chunk_size=2 ** 20): + file_handle.write(chunk) + counter.update() + + # get file with checksum + _print("Calculating sha256 ...", 2) + calc_checksum = sha256_sum(temp_file) + if v.get(platform_name).get("hash") != calc_checksum: + _print("Downloaded files checksum invalid.") + sys.exit(1) + + _print("File OK", 3) + if not destination_path.exists(): + destination_path.mkdir(parents=True) + + # extract to destination + archive_type = temp_file.suffix.lstrip(".") + _print(f"Extracting {archive_type} file to {destination_path}") + if archive_type in ['zip']: + zip_file = zipfile.ZipFile(temp_file) + zip_file.extractall(destination_path) + zip_file.close() + + elif archive_type in [ + 'tar', 'tgz', 'tar.gz', 'tar.xz', 'tar.bz2' + ]: + if archive_type == 'tar': + tar_type = 'r:' + elif archive_type.endswith('xz'): + tar_type = 'r:xz' + elif archive_type.endswith('gz'): + tar_type = 'r:gz' + elif archive_type.endswith('bz2'): + tar_type = 'r:bz2' + else: + tar_type = 'r:*' + try: + tar_file = tarfile.open(temp_file, tar_type) + except tarfile.ReadError: + raise SystemExit( + "corrupted archive: also consider to download the " + "archive manually, add its path to the url, run " + "`./pype deploy`" + ) + tar_file.extractall(destination_path) + tar_file.close() + _print("Extraction OK", 3) + +end_time = time.time_ns() +total_time = (end_time - start_time) / 1000000000 +_print(f"Downloading and extracting took {total_time} secs.") diff --git a/tools/fetch_thirdparty_libs.sh b/tools/fetch_thirdparty_libs.sh new file mode 100755 index 0000000000..e305b4b3e4 --- /dev/null +++ b/tools/fetch_thirdparty_libs.sh @@ -0,0 +1,129 @@ +#!/usr/bin/env bash + +# Run Pype Tray + + +art () { + cat <<-EOF + ____________ + /\\ ___ \\ + \\ \\ \\/_\\ \\ + \\ \\ _____/ ______ ___ ___ ___ + \\ \\ \\___/ /\\ \\ \\ \\\\ \\\\ \\ + \\ \\____\\ \\ \\_____\\ \\__\\\\__\\\\__\\ + \\/____/ \\/_____/ . PYPE Club . + +EOF +} + +# Colors for terminal + +RST='\033[0m' # Text Reset + +# Regular Colors +Black='\033[0;30m' # Black +Red='\033[0;31m' # Red +Green='\033[0;32m' # Green +Yellow='\033[0;33m' # Yellow +Blue='\033[0;34m' # Blue +Purple='\033[0;35m' # Purple +Cyan='\033[0;36m' # Cyan +White='\033[0;37m' # White + +# Bold +BBlack='\033[1;30m' # Black +BRed='\033[1;31m' # Red +BGreen='\033[1;32m' # Green +BYellow='\033[1;33m' # Yellow +BBlue='\033[1;34m' # Blue +BPurple='\033[1;35m' # Purple +BCyan='\033[1;36m' # Cyan +BWhite='\033[1;37m' # White + +# Bold High Intensity +BIBlack='\033[1;90m' # Black +BIRed='\033[1;91m' # Red +BIGreen='\033[1;92m' # Green +BIYellow='\033[1;93m' # Yellow +BIBlue='\033[1;94m' # Blue +BIPurple='\033[1;95m' # Purple +BICyan='\033[1;96m' # Cyan +BIWhite='\033[1;97m' # White + + +############################################################################## +# Detect required version of python +# Globals: +# colors +# PYTHON +# Arguments: +# None +# Returns: +# None +############################################################################### +detect_python () { + echo -e "${BIGreen}>>>${RST} Using python \c" + local version_command="import sys;print('{0}.{1}'.format(sys.version_info[0], sys.version_info[1]))" + local python_version="$(python3 <<< ${version_command})" + oIFS="$IFS" + IFS=. + set -- $python_version + IFS="$oIFS" + if [ "$1" -ge "3" ] && [ "$2" -ge "6" ] ; then + if [ "$2" -gt "7" ] ; then + echo -e "${BIWhite}[${RST} ${BIRed}$1.$2 ${BIWhite}]${RST} - ${BIRed}FAILED${RST} ${BIYellow}Version is new and unsupported, use${RST} ${BIPurple}3.7.x${RST}"; return 1; + else + echo -e "${BIWhite}[${RST} ${BIGreen}$1.$2${RST} ${BIWhite}]${RST}" + fi + PYTHON="python3" + else + command -v python3 >/dev/null 2>&1 || { echo -e "${BIRed}$1.$2$ - ${BIRed}FAILED${RST} ${BIYellow}Version is old and unsupported${RST}"; return 1; } + fi +} + +############################################################################## +# Clean pyc files in specified directory +# Globals: +# None +# Arguments: +# Optional path to clean +# Returns: +# None +############################################################################### +clean_pyc () { + local path + path=$pype_root + echo -e "${BIGreen}>>>${RST} Cleaning pyc at [ ${BIWhite}$path${RST} ] ... \c" + find "$path" -regex '^.*\(__pycache__\|\.py[co]\)$' -delete + echo -e "${BIGreen}DONE${RST}" +} + +############################################################################## +# Return absolute path +# Globals: +# None +# Arguments: +# Path to resolve +# Returns: +# None +############################################################################### +realpath () { + echo $(cd $(dirname "$1"); pwd)/$(basename "$1") +} + +# Main +main () { + echo -e "${BGreen}" + art + echo -e "${RST}" + detect_python || return 1 + + # Directories + pype_root=$(realpath $(dirname $(dirname "${BASH_SOURCE[0]}"))) + pushd "$pype_root" > /dev/null || return > /dev/null + + echo -e "${BIGreen}>>>${RST} Running Pype tool ..." + poetry run python3 "$pype_root/tools/fetch_thirdparty_libs.py" +} + +main \ No newline at end of file From 91e8bfc941693d5ec84b5339658869b9cbcab38e Mon Sep 17 00:00:00 2001 From: Ondrej Samohel Date: Wed, 31 Mar 2021 21:50:07 +0200 Subject: [PATCH 003/148] hound --- tools/fetch_thirdparty_libs.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/tools/fetch_thirdparty_libs.py b/tools/fetch_thirdparty_libs.py index cda4d3a6fd..5d38d69767 100644 --- a/tools/fetch_thirdparty_libs.py +++ b/tools/fetch_thirdparty_libs.py @@ -39,7 +39,7 @@ def sha256_sum(filename: Path): """ _hash = hashlib.sha256() with open(filename, 'rb', buffering=0) as f: - buffer = bytearray(128*1024) + buffer = bytearray(128 * 1024) mv = memoryview(buffer) for n in iter(lambda: f.readinto(mv), 0): _hash.update(mv[:n]) @@ -83,7 +83,6 @@ for k, v in thirdparty.items(): destination_path = pype_root / "vendor" / "bin" / k / platform_name url = v.get(platform_name).get("url") - if not v.get(platform_name): _print(("missing definition for current " f"platform [ {platform_name} ]"), 1) From 3254e55144bbd2ce48ddf896e99941aac0f3ab7e Mon Sep 17 00:00:00 2001 From: Ondrej Samohel Date: Tue, 6 Apr 2021 11:41:57 +0200 Subject: [PATCH 004/148] merge develop --- openpype/modules/ftrack/python2_vendor/arrow | 1 + openpype/modules/ftrack/python2_vendor/ftrack-python-api | 1 + repos/avalon-core | 2 +- 3 files changed, 3 insertions(+), 1 deletion(-) create mode 160000 openpype/modules/ftrack/python2_vendor/arrow create mode 160000 openpype/modules/ftrack/python2_vendor/ftrack-python-api diff --git a/openpype/modules/ftrack/python2_vendor/arrow b/openpype/modules/ftrack/python2_vendor/arrow new file mode 160000 index 0000000000..b746fedf72 --- /dev/null +++ b/openpype/modules/ftrack/python2_vendor/arrow @@ -0,0 +1 @@ +Subproject commit b746fedf7286c3755a46f07ab72f4c414cd41fc0 diff --git a/openpype/modules/ftrack/python2_vendor/ftrack-python-api b/openpype/modules/ftrack/python2_vendor/ftrack-python-api new file mode 160000 index 0000000000..d277f474ab --- /dev/null +++ b/openpype/modules/ftrack/python2_vendor/ftrack-python-api @@ -0,0 +1 @@ +Subproject commit d277f474ab016e7b53479c36af87cb861d0cc53e diff --git a/repos/avalon-core b/repos/avalon-core index 911a29a44d..bbba8765c4 160000 --- a/repos/avalon-core +++ b/repos/avalon-core @@ -1 +1 @@ -Subproject commit 911a29a44d5e6a128f4326deb1155184fe811fd7 +Subproject commit bbba8765c431ee124590e4f12d2e56db4d62eacd From 51b8b61365524e1702838db799a20ca2197e51c0 Mon Sep 17 00:00:00 2001 From: Ondrej Samohel Date: Tue, 6 Apr 2021 11:46:38 +0200 Subject: [PATCH 005/148] rebrand to OpenPype --- pyproject.toml | 14 +++++++------- tools/fetch_thirdparty_libs.ps1 | 8 ++++---- tools/fetch_thirdparty_libs.py | 18 +++++++----------- 3 files changed, 18 insertions(+), 22 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 589342da05..bc808b5b9f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -77,28 +77,28 @@ url = "https://distribute.openpype.io/wheels/" requires = ["poetry-core>=1.0.0"] build-backend = "poetry.core.masonry.api" -[pype] +[openpype] -[pype.thirdparty.ffmpeg.windows] +[openpype.thirdparty.ffmpeg.windows] url = "https://distribute.openpype.io/thirdparty/ffmpeg-4.13-windows.zip" hash = "43988ebcba98313635f06f2ca7e2dd52670710ebceefaa77107321b1def30472" -[pype.thirdparty.ffmpeg.linux] +[openpype.thirdparty.ffmpeg.linux] url = "https://distribute.openpype.io/thirdparty/ffmpeg-20200504-linux.tgz" hash = "sha256:..." -[pype.thirdparty.ffmpeg.darwin] +[openpype.thirdparty.ffmpeg.darwin] url = "https://distribute.openpype.io/thirdparty/ffmpeg-20200504-darwin.tgz" hash = "sha256:..." -[pype.thirdparty.oiio.windows] +[openpype.thirdparty.oiio.windows] url = "https://distribute.openpype.io/thirdparty/oiio_tools-2.2.0-windows.zip" hash = "fd2e00278e01e85dcee7b4a6969d1a16f13016ec16700fb0366dbb1b1f3c37ad" -[pype.thirdparty.oiio.linux] +[openpype.thirdparty.oiio.linux] url = "https://distribute.openpype.io/thirdparty/oiio-2.2.0-linux.tgz" hash = "sha256:..." -[pype.thirdparty.oiio.darwin] +[openpype.thirdparty.oiio.darwin] url = "https://distribute.openpype.io/thirdparty/oiio-2.2.0-darwin.tgz" hash = "sha256:..." \ No newline at end of file diff --git a/tools/fetch_thirdparty_libs.ps1 b/tools/fetch_thirdparty_libs.ps1 index 7eed5a22db..f79cfdd267 100644 --- a/tools/fetch_thirdparty_libs.ps1 +++ b/tools/fetch_thirdparty_libs.ps1 @@ -1,6 +1,6 @@ <# .SYNOPSIS - Download and extract third-party dependencies for Pype. + Download and extract third-party dependencies for OpenPype. .DESCRIPTION This will download third-party dependencies specified in pyproject.toml @@ -14,8 +14,8 @@ PS> .\fetch_thirdparty_libs.ps1 #> $current_dir = Get-Location $script_dir = Split-Path -Path $MyInvocation.MyCommand.Definition -Parent -$pype_root = (Get-Item $script_dir).parent.FullName -Set-Location -Path $pype_root +$openpype_root = (Get-Item $script_dir).parent.FullName +Set-Location -Path $openpype_root -& poetry run python "$($pype_root)\tools\fetch_thirdparty_libs.py" +& poetry run python "$($openpype_root)\tools\fetch_thirdparty_libs.py" Set-Location -Path $current_dir diff --git a/tools/fetch_thirdparty_libs.py b/tools/fetch_thirdparty_libs.py index 5d38d69767..75ee052950 100644 --- a/tools/fetch_thirdparty_libs.py +++ b/tools/fetch_thirdparty_libs.py @@ -1,7 +1,7 @@ # -*- coding: utf-8 -*- -"""Fetch, verify and process third-party dependencies of Pype. +"""Fetch, verify and process third-party dependencies of OpenPype. -Those should be defined in `pyproject.toml` in Pype sources root. +Those should be defined in `pyproject.toml` in OpenPype sources root. """ import os @@ -68,19 +68,19 @@ def _print(msg: str, message_type: int = 0) -> None: _print("Processing third-party dependencies ...") start_time = time.time_ns() -pype_root = Path(os.path.dirname(__file__)).parent -pyproject = toml.load(pype_root / "pyproject.toml") +openpype_root = Path(os.path.dirname(__file__)).parent +pyproject = toml.load(openpype_root / "pyproject.toml") platform_name = platform.system().lower() try: - thirdparty = pyproject["pype"]["thirdparty"] + thirdparty = pyproject["openpype"]["thirdparty"] except AttributeError: _print("No third-party libraries specified in pyproject.toml", 1) sys.exit(1) for k, v in thirdparty.items(): _print(f"processing {k}") - destination_path = pype_root / "vendor" / "bin" / k / platform_name + destination_path = openpype_root / "vendor" / "bin" / k / platform_name url = v.get(platform_name).get("url") if not v.get(platform_name): @@ -150,11 +150,7 @@ for k, v in thirdparty.items(): try: tar_file = tarfile.open(temp_file, tar_type) except tarfile.ReadError: - raise SystemExit( - "corrupted archive: also consider to download the " - "archive manually, add its path to the url, run " - "`./pype deploy`" - ) + raise SystemExit("corrupted archive") tar_file.extractall(destination_path) tar_file.close() _print("Extraction OK", 3) From 399f9bd059a190c8b1c640bb950e070e1b6dec5e Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Tue, 6 Apr 2021 12:27:49 +0200 Subject: [PATCH 006/148] SyncServer adding functionality to Loader In one big commit as PR wasnt merged before rebranding and merge exploded --- openpype/modules/__init__.py | 4 +- openpype/modules/sync_server/__init__.py | 4 +- .../modules/sync_server/providers/__init__.py | 0 .../providers/abstract_provider.py | 109 +- .../modules/sync_server/providers/gdrive.py | 336 ++-- openpype/modules/sync_server/providers/lib.py | 8 +- .../sync_server/providers/local_drive.py | 60 +- openpype/modules/sync_server/sync_server.py | 1567 +++-------------- .../modules/sync_server/sync_server_module.py | 1194 +++++++++++++ openpype/modules/sync_server/tray/app.py | 298 +++- openpype/modules/sync_server/utils.py | 8 +- openpype/plugins/load/add_site.py | 33 + openpype/plugins/load/delete_old_versions.py | 6 +- openpype/plugins/load/remove_site.py | 33 + 14 files changed, 1986 insertions(+), 1674 deletions(-) create mode 100644 openpype/modules/sync_server/providers/__init__.py create mode 100644 openpype/modules/sync_server/sync_server_module.py create mode 100644 openpype/plugins/load/add_site.py create mode 100644 openpype/plugins/load/remove_site.py diff --git a/openpype/modules/__init__.py b/openpype/modules/__init__.py index 4b120647e1..d7c6d99fe6 100644 --- a/openpype/modules/__init__.py +++ b/openpype/modules/__init__.py @@ -41,7 +41,7 @@ from .log_viewer import LogViewModule from .muster import MusterModule from .deadline import DeadlineModule from .standalonepublish_action import StandAlonePublishAction -from .sync_server import SyncServer +from .sync_server import SyncServerModule __all__ = ( @@ -82,5 +82,5 @@ __all__ = ( "DeadlineModule", "StandAlonePublishAction", - "SyncServer" + "SyncServerModule" ) diff --git a/openpype/modules/sync_server/__init__.py b/openpype/modules/sync_server/__init__.py index 7123536fcf..a814f0db62 100644 --- a/openpype/modules/sync_server/__init__.py +++ b/openpype/modules/sync_server/__init__.py @@ -1,5 +1,5 @@ -from openpype.modules.sync_server.sync_server import SyncServer +from openpype.modules.sync_server.sync_server_module import SyncServerModule def tray_init(tray_widget, main_widget): - return SyncServer() + return SyncServerModule() diff --git a/openpype/modules/sync_server/providers/__init__.py b/openpype/modules/sync_server/providers/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/openpype/modules/sync_server/providers/abstract_provider.py b/openpype/modules/sync_server/providers/abstract_provider.py index 001d4c4d50..35dca87acf 100644 --- a/openpype/modules/sync_server/providers/abstract_provider.py +++ b/openpype/modules/sync_server/providers/abstract_provider.py @@ -1,16 +1,22 @@ -from abc import ABCMeta, abstractmethod +import abc, six +from openpype.api import Anatomy, Logger + +log = Logger().get_logger("SyncServer") -class AbstractProvider(metaclass=ABCMeta): +@six.add_metaclass(abc.ABCMeta) +class AbstractProvider: - def __init__(self, site_name, tree=None, presets=None): + def __init__(self, project_name, site_name, tree=None, presets=None): self.presets = None self.active = False self.site_name = site_name self.presets = presets - @abstractmethod + super(AbstractProvider, self).__init__() + + @abc.abstractmethod def is_active(self): """ Returns True if provider is activated, eg. has working credentials. @@ -18,36 +24,54 @@ class AbstractProvider(metaclass=ABCMeta): (boolean) """ - @abstractmethod - def upload_file(self, source_path, target_path, overwrite=True): + @abc.abstractmethod + def upload_file(self, source_path, path, + server, collection, file, representation, site, + overwrite=False): """ Copy file from 'source_path' to 'target_path' on provider. Use 'overwrite' boolean to rewrite existing file on provider Args: - source_path (string): absolute path on local system - target_path (string): absolute path on provider (GDrive etc.) - overwrite (boolean): True if overwite existing + source_path (string): + path (string): absolute path with or without name of the file + overwrite (boolean): replace existing file + + arguments for saving progress: + server (SyncServer): server instance to call update_db on + collection (str): name of collection + file (dict): info about uploaded file (matches structure from db) + representation (dict): complete repre containing 'file' + site (str): site name Returns: (string) file_id of created file, raises exception """ pass - @abstractmethod - def download_file(self, source_path, local_path, overwrite=True): + @abc.abstractmethod + def download_file(self, source_path, local_path, + server, collection, file, representation, site, + overwrite=False): """ Download file from provider into local system Args: source_path (string): absolute path on provider - local_path (string): absolute path on local - overwrite (bool): default set to True + local_path (string): absolute path with or without name of the file + overwrite (boolean): replace existing file + + arguments for saving progress: + server (SyncServer): server instance to call update_db on + collection (str): name of collection + file (dict): info about uploaded file (matches structure from db) + representation (dict): complete repre containing 'file' + site (str): site name Returns: None """ pass - @abstractmethod + @abc.abstractmethod def delete_file(self, path): """ Deletes file from 'path'. Expects path to specific file. @@ -60,7 +84,7 @@ class AbstractProvider(metaclass=ABCMeta): """ pass - @abstractmethod + @abc.abstractmethod def list_folder(self, folder_path): """ List all files and subfolders of particular path non-recursively. @@ -72,7 +96,7 @@ class AbstractProvider(metaclass=ABCMeta): """ pass - @abstractmethod + @abc.abstractmethod def create_folder(self, folder_path): """ Create all nonexistent folders and subfolders in 'path'. @@ -85,7 +109,7 @@ class AbstractProvider(metaclass=ABCMeta): """ pass - @abstractmethod + @abc.abstractmethod def get_tree(self): """ Creates folder structure for providers which do not provide @@ -94,16 +118,49 @@ class AbstractProvider(metaclass=ABCMeta): """ pass - @abstractmethod - def resolve_path(self, path, root_config, anatomy=None): + @abc.abstractmethod + def get_roots_config(self, anatomy=None): """ - Replaces root placeholders with appropriate real value from - 'root_configs' (from Settings or Local Settings) or Anatomy - (mainly for 'studio' site) + Returns root values for path resolving - Args: - path(string): path with '{root[work]}/...' - root_config(dict): from Settings or Local Settings - anatomy (Anatomy): prepared anatomy object for project + Takes value from Anatomy which takes values from Settings + overridden by Local Settings + + Returns: + (dict) - {"root": {"root": "/My Drive"}} + OR + {"root": {"root_ONE": "value", "root_TWO":"value}} + Format is importing for usage of python's format ** approach """ pass + + def resolve_path(self, path, root_config=None, anatomy=None): + """ + Replaces all root placeholders with proper values + + Args: + path(string): root[work]/folder... + root_config (dict): {'work': "c:/..."...} + anatomy (Anatomy): object of Anatomy + Returns: + (string): proper url + """ + if root_config and not root_config.get("root"): + root_config = {"root": root_config} + else: + root_config = self.get_roots_config(anatomy) + + try: + if not root_config: + raise KeyError + + path = path.format(**root_config) + except KeyError: + try: + path = anatomy.fill_root(path) + except KeyError: + msg = "Error in resolving local root from anatomy" + log.error(msg) + raise ValueError(msg) + + return path diff --git a/openpype/modules/sync_server/providers/gdrive.py b/openpype/modules/sync_server/providers/gdrive.py index 6c01bc4e6f..b6ece5263b 100644 --- a/openpype/modules/sync_server/providers/gdrive.py +++ b/openpype/modules/sync_server/providers/gdrive.py @@ -6,10 +6,11 @@ from googleapiclient import errors from .abstract_provider import AbstractProvider from googleapiclient.http import MediaFileUpload, MediaIoBaseDownload from openpype.api import Logger -from openpype.api import get_system_settings +from openpype.api import get_system_settings, Anatomy from ..utils import time_function import time + 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 @@ -45,9 +46,10 @@ class GDriveHandler(AbstractProvider): MY_DRIVE_STR = 'My Drive' # name of root folder of regular Google drive CHUNK_SIZE = 2097152 # must be divisible by 256! - def __init__(self, site_name, tree=None, presets=None): + 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.presets = presets @@ -65,137 +67,6 @@ class GDriveHandler(AbstractProvider): self._tree = tree self.active = True - def _get_gd_service(self): - """ - Authorize client with 'credentials.json', uses service account. - Service account needs to have target folder shared with. - Produces service that communicates with GDrive API. - - 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) - return service - - def _prepare_root_info(self): - """ - Prepare info about roots and theirs folder ids from 'presets'. - Configuration might be for single or multiroot projects. - Regular My Drive and Shared drives are implemented, their root - folder ids need to be queried in slightly different way. - - Returns: - (dicts) of dicts where root folders are keys - """ - roots = {} - for path in self.get_roots_config().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 - - 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() - - return roots - - @time_function - def _build_tree(self, folders): - """ - Create in-memory structure resolving paths to folder id as - recursive querying might be slower. - Initialized in the time of class initialization. - Maybe should be persisted - Tree is structure of path to id: - '/ROOT': {'id': '1234567'} - '/ROOT/PROJECT_FOLDER': {'id':'222222'} - '/ROOT/PROJECT_FOLDER/Assets': {'id': '3434545'} - Args: - folders (list): list of dictionaries with folder metadata - Returns: - (dictionary) path as a key, folder id as a value - """ - log.debug("build_tree len {}".format(len(folders))) - root_ids = [] - default_root_id = None - tree = {} - ending_by = {} - for root_name, root in self.root.items(): # might be multiple roots - if root["id"] not in root_ids: - tree["/" + root_name] = {"id": root["id"]} - ending_by[root["id"]] = "/" + root_name - root_ids.append(root["id"]) - - if self.MY_DRIVE_STR == root_name: - default_root_id = root["id"] - - no_parents_yet = {} - while folders: - folder = folders.pop(0) - parents = folder.get("parents", []) - # weird cases, shared folders, etc, parent under root - if not parents: - parent = default_root_id - else: - parent = parents[0] - - if folder["id"] in root_ids: # do not process root - continue - - if parent in ending_by: - path_key = ending_by[parent] + "/" + folder["name"] - ending_by[folder["id"]] = path_key - tree[path_key] = {"id": folder["id"]} - else: - no_parents_yet.setdefault(parent, []).append((folder["id"], - folder["name"])) - loop_cnt = 0 - # break if looped more then X times - safety against infinite loop - while no_parents_yet and loop_cnt < 20: - - keys = list(no_parents_yet.keys()) - for parent in keys: - if parent in ending_by.keys(): - subfolders = no_parents_yet.pop(parent) - for folder_id, folder_name in subfolders: - path_key = ending_by[parent] + "/" + folder_name - ending_by[folder_id] = path_key - tree[path_key] = {"id": folder_id} - loop_cnt += 1 - - if len(no_parents_yet) > 0: - log.debug("Some folders path are not resolved {}". - format(no_parents_yet)) - log.debug("Remove deleted folders from trash.") - - return tree - def is_active(self): """ Returns True if provider is activated, eg. has working credentials. @@ -204,6 +75,21 @@ class GDriveHandler(AbstractProvider): """ return self.active + def get_roots_config(self, anatomy=None): + """ + Returns root values for path resolving + + Use only Settings as GDrive cannot be modified by Local Settings + + Returns: + (dict) - {"root": {"root": "/My Drive"}} + OR + {"root": {"root_ONE": "value", "root_TWO":"value}} + Format is importing for usage of python's format ** approach + """ + # GDrive roots cannot be locally overridden + return self.presets['root'] + def get_tree(self): """ Building of the folder tree could be potentially expensive, @@ -217,26 +103,6 @@ class GDriveHandler(AbstractProvider): self._tree = self._build_tree(self.list_folders()) return self._tree - def get_roots_config(self): - """ - Returns value from presets of roots. It calculates with multi - roots. Config should be simple key value, or dictionary. - - Examples: - "root": "/My Drive" - OR - "root": {"root_ONE": "value", "root_TWO":"value} - Returns: - (dict) - {"root": {"root": "/My Drive"}} - OR - {"root": {"root_ONE": "value", "root_TWO":"value}} - Format is importing for usage of python's format ** approach - """ - roots = self.presets["root"] - if isinstance(roots, str): - roots = {"root": roots} - return roots - def create_folder(self, path): """ Create all nonexistent folders and subfolders in 'path'. @@ -510,20 +376,6 @@ class GDriveHandler(AbstractProvider): self.service.files().delete(fileId=file["id"], supportsAllDrives=True).execute() - def _get_folder_metadata(self, path): - """ - Get info about folder with 'path' - Args: - path (string): - - Returns: - (dictionary) with metadata or raises ValueError - """ - try: - return self.get_tree()[path] - except Exception: - raise ValueError("Uknown folder id {}".format(id)) - def list_folder(self, folder_path): """ List all files and subfolders of particular path non-recursively. @@ -678,15 +530,151 @@ class GDriveHandler(AbstractProvider): return return provider_presets - def resolve_path(self, path, root_config, anatomy=None): - if not root_config.get("root"): - root_config = {"root": root_config} + def _get_gd_service(self): + """ + Authorize client with 'credentials.json', uses service account. + Service account needs to have target folder shared with. + Produces service that communicates with GDrive API. + 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) + return service + + def _prepare_root_info(self): + """ + Prepare info about roots and theirs folder ids from 'presets'. + Configuration might be for single or multiroot projects. + Regular My Drive and Shared drives are implemented, their root + folder ids need to be queried in slightly different way. + + Returns: + (dicts) of dicts where root folders are keys + """ + 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 + + 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)) + + 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() + + return roots + + @time_function + def _build_tree(self, folders): + """ + Create in-memory structure resolving paths to folder id as + recursive querying might be slower. + Initialized in the time of class initialization. + Maybe should be persisted + Tree is structure of path to id: + '/ROOT': {'id': '1234567'} + '/ROOT/PROJECT_FOLDER': {'id':'222222'} + '/ROOT/PROJECT_FOLDER/Assets': {'id': '3434545'} + Args: + folders (list): list of dictionaries with folder metadata + Returns: + (dictionary) path as a key, folder id as a value + """ + log.debug("build_tree len {}".format(len(folders))) + root_ids = [] + default_root_id = None + tree = {} + ending_by = {} + for root_name, root in self.root.items(): # might be multiple roots + if root["id"] not in root_ids: + tree["/" + root_name] = {"id": root["id"]} + ending_by[root["id"]] = "/" + root_name + root_ids.append(root["id"]) + + if self.MY_DRIVE_STR == root_name: + default_root_id = root["id"] + + no_parents_yet = {} + while folders: + folder = folders.pop(0) + parents = folder.get("parents", []) + # weird cases, shared folders, etc, parent under root + if not parents: + parent = default_root_id + else: + parent = parents[0] + + if folder["id"] in root_ids: # do not process root + continue + + if parent in ending_by: + path_key = ending_by[parent] + "/" + folder["name"] + ending_by[folder["id"]] = path_key + tree[path_key] = {"id": folder["id"]} + else: + no_parents_yet.setdefault(parent, []).append((folder["id"], + folder["name"])) + loop_cnt = 0 + # break if looped more then X times - safety against infinite loop + while no_parents_yet and loop_cnt < 20: + + keys = list(no_parents_yet.keys()) + for parent in keys: + if parent in ending_by.keys(): + subfolders = no_parents_yet.pop(parent) + for folder_id, folder_name in subfolders: + path_key = ending_by[parent] + "/" + folder_name + ending_by[folder_id] = path_key + tree[path_key] = {"id": folder_id} + loop_cnt += 1 + + if len(no_parents_yet) > 0: + log.debug("Some folders path are not resolved {}". + format(no_parents_yet)) + log.debug("Remove deleted folders from trash.") + + return tree + + def _get_folder_metadata(self, path): + """ + Get info about folder with 'path' + Args: + path (string): + + Returns: + (dictionary) with metadata or raises ValueError + """ try: - return path.format(**root_config) - except KeyError: - msg = "Error in resolving remote root, unknown key" - log.error(msg) + return self.get_tree()[path] + except Exception: + raise ValueError("Uknown folder id {}".format(id)) def _handle_q(self, q, trashed=False): """ API list call contain trashed and hidden files/folder by default. diff --git a/openpype/modules/sync_server/providers/lib.py b/openpype/modules/sync_server/providers/lib.py index 144594ecbe..58947e115d 100644 --- a/openpype/modules/sync_server/providers/lib.py +++ b/openpype/modules/sync_server/providers/lib.py @@ -1,4 +1,3 @@ -from enum import Enum from .gdrive import GDriveHandler from .local_drive import LocalDriveHandler @@ -25,7 +24,8 @@ class ProviderFactory: """ self.providers[provider] = (creator, batch_limit) - def get_provider(self, provider, site_name, tree=None, presets=None): + def get_provider(self, provider, project_name, site_name, + tree=None, presets=None): """ Returns new instance of provider client for specific site. One provider could have multiple sites. @@ -37,6 +37,7 @@ class ProviderFactory: provider (string): 'gdrive','S3' site_name (string): descriptor of site, different service accounts must have different site name + project_name (string): different projects could have diff. sites tree (dictionary): - folder paths to folder id structure presets (dictionary): config for provider and site (eg. "credentials_url"..) @@ -44,7 +45,8 @@ class ProviderFactory: (implementation of AbstractProvider) """ creator_info = self._get_creator_info(provider) - site = creator_info[0](site_name, tree, presets) # call init + # call init + site = creator_info[0](project_name, site_name, tree, presets) return site diff --git a/openpype/modules/sync_server/providers/local_drive.py b/openpype/modules/sync_server/providers/local_drive.py index fa8dd4c183..1f4fca80eb 100644 --- a/openpype/modules/sync_server/providers/local_drive.py +++ b/openpype/modules/sync_server/providers/local_drive.py @@ -4,7 +4,7 @@ import shutil import threading import time -from openpype.api import Logger +from openpype.api import Logger, Anatomy from .abstract_provider import AbstractProvider log = Logger().get_logger("SyncServer") @@ -12,6 +12,14 @@ log = Logger().get_logger("SyncServer") class LocalDriveHandler(AbstractProvider): """ 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.active = self.is_active() + def is_active(self): return True @@ -82,27 +90,37 @@ class LocalDriveHandler(AbstractProvider): os.makedirs(folder_path, exist_ok=True) return folder_path + def get_roots_config(self, anatomy=None): + """ + Returns root values for path resolving + + Takes value from Anatomy which takes values from Settings + overridden by Local Settings + + Returns: + (dict) - {"root": {"root": "/My Drive"}} + OR + {"root": {"root_ONE": "value", "root_TWO":"value}} + Format is importing for usage of python's format ** approach + """ + if not anatomy: + anatomy = Anatomy(self.project_name, + self._normalize_site_name(self.site_name)) + + return {'root': anatomy.roots} + def get_tree(self): return - def resolve_path(self, path, root_config, anatomy=None): - if root_config and not root_config.get("root"): - root_config = {"root": root_config} + def get_configurable_items_for_site(self): + """ + Returns list of items that should be configurable by User - try: - if not root_config: - raise KeyError - - path = path.format(**root_config) - except KeyError: - try: - path = anatomy.fill_root(path) - except KeyError: - msg = "Error in resolving local root from anatomy" - log.error(msg) - raise ValueError(msg) - - return path + Returns: + (list of dict) + [{key:"root", label:"root", value:"valueFromSettings"}] + """ + pass def _copy(self, source_path, target_path): print("copying {}->{}".format(source_path, target_path)) @@ -133,3 +151,9 @@ class LocalDriveHandler(AbstractProvider): ) target_file_size = os.path.getsize(target_path) time.sleep(0.5) + + def _normalize_site_name(self, site_name): + """Transform user id to 'local' for Local settings""" + if site_name != 'studio': + return 'local' + return site_name diff --git a/openpype/modules/sync_server/sync_server.py b/openpype/modules/sync_server/sync_server.py index 62a5dc675c..e97c0e8844 100644 --- a/openpype/modules/sync_server/sync_server.py +++ b/openpype/modules/sync_server/sync_server.py @@ -1,1391 +1,225 @@ -from openpype.api import ( - Anatomy, - get_project_settings, - get_local_site_id) - +"""Python 3 only implementation.""" +import os +import asyncio import threading import concurrent.futures from concurrent.futures._base import CancelledError -from enum import Enum -from datetime import datetime - from .providers import lib -import os -from bson.objectid import ObjectId - -from avalon.api import AvalonMongoDB -from .utils import time_function - -import six from openpype.lib import PypeLogger -from .. import PypeModule, ITrayModule -from .providers.local_drive import LocalDriveHandler -if six.PY2: - web = asyncio = STATIC_DIR = WebSocketAsync = None -else: - import asyncio +from .utils import SyncStatus + log = PypeLogger().get_logger("SyncServer") -class SyncStatus(Enum): - DO_NOTHING = 0 - DO_UPLOAD = 1 - DO_DOWNLOAD = 2 - - -class SyncServer(PypeModule, ITrayModule): +async def upload(module, collection, file, representation, provider_name, + remote_site_name, tree=None, preset=None): """ - Synchronization server that is syncing published files from local to - any of implemented providers (like GDrive, S3 etc.) - Runs in the background and checks all representations, looks for files - that are marked to be in different location than 'studio' (temporary), - checks if 'created_dt' field is present denoting successful sync - with provider destination. - Sites structure is created during publish OR by calling 'add_site' - method. + Upload single 'file' of a 'representation' to 'provider'. + Source url is taken from 'file' portion, where {root} placeholder + is replaced by 'representation.Context.root' + Provider could be one of implemented in provider.py. - By default it will always contain 1 record with - "name" == self.presets["active_site"] and - filled "created_dt" AND 1 or multiple records for all defined - remote sites, where "created_dt" is not present. - This highlights that file should be uploaded to - remote destination + Updates MongoDB, fills in id of file from provider (ie. file_id + from GDrive), 'created_dt' - time of upload - ''' - example of synced file test_Cylinder_lookMain_v010.ma to GDrive - "files" : [ - { - "path" : "{root}/Test/Assets/Cylinder/publish/look/lookMain/v010/ - test_Cylinder_lookMain_v010.ma", - "_id" : ObjectId("5eeb25e411e06a16209ab78f"), - "hash" : "test_Cylinder_lookMain_v010,ma|1592468963,24|4822", - "size" : NumberLong(4822), - "sites" : [ - { - "name": "john_local_XD4345", - "created_dt" : ISODate("2020-05-22T08:05:44.000Z") - }, - { - "id" : ObjectId("5eeb25e411e06a16209ab78f"), - "name": "gdrive", - "created_dt" : ISODate("2020-05-55T08:54:35.833Z") - ] - } - }, - ''' - Each Tray app has assigned its own self.presets["local_id"] - used in sites as a name. - Tray is searching only for records where name matches its - self.presets["active_site"] + self.presets["remote_site"]. - "active_site" could be storage in studio ('studio'), or specific - "local_id" when user is working disconnected from home. - If the local record has its "created_dt" filled, it is a source and - process will try to upload the file to all defined remote sites. + 'provider_name' doesn't have to match to 'site_name', single + provider (GDrive) might have multiple sites ('projectA', + 'projectB') - Remote files "id" is real id that could be used in appropriate API. - Local files have "id" too, for conformity, contains just file name. - It is expected that multiple providers will be implemented in separate - classes and registered in 'providers.py'. + Args: + module(SyncServerModule): object to run SyncServerModule API + collection (str): source collection + file (dictionary): of file from representation in Mongo + representation (dictionary): of representation + provider_name (string): gdrive, gdc etc. + site_name (string): site on provider, single provider(gdrive) could + have multiple sites (different accounts, credentials) + tree (dictionary): injected memory structure for performance + preset (dictionary): site config ('credentials_url', 'root'...) """ - # limit querying DB to look for X number of representations that should - # be sync, we try to run more loops with less records - # actual number of files synced could be lower as providers can have - # different limits imposed by its API - # set 0 to no limit - REPRESENTATION_LIMIT = 100 - DEFAULT_SITE = 'studio' - LOCAL_SITE = 'local' - LOG_PROGRESS_SEC = 5 # how often log progress to DB + # create ids sequentially, upload file in parallel later + with module.lock: + # this part modifies structure on 'remote_site', only single + # thread can do that at a time, upload/download to prepared + # structure should be run in parallel + remote_handler = lib.factory.get_provider(provider_name, + collection, + remote_site_name, + tree=tree, + presets=preset) - name = "sync_server" - label = "Sync Server" - - def initialize(self, module_settings): - """ - Called during Module Manager creation. - - Collects needed data, checks asyncio presence. - Sets 'enabled' according to global settings for the module. - Shouldnt be doing any initialization, thats a job for 'tray_init' - """ - self.enabled = module_settings[self.name]["enabled"] - if asyncio is None: - raise AssertionError( - "SyncServer module requires Python 3.5 or higher." + file_path = file.get("path", "") + try: + local_file_path, remote_file_path = resolve_paths(module, + file_path, collection, remote_site_name, remote_handler ) - # some parts of code need to run sequentially, not in async - self.lock = None - self.connection = None # connection to avalon DB to update state - # settings for all enabled projects for sync - self.sync_project_settings = None - self.sync_server_thread = None # asyncio requires new thread - - self.action_show_widget = None - self._paused = False - self._paused_projects = set() - self._paused_representations = set() - self._anatomies = {} - - """ Start of Public API """ - def add_site(self, collection, representation_id, site_name=None): - """ - Adds new site to representation to be synced. - - 'collection' must have synchronization enabled (globally or - project only) - - Used as a API endpoint from outside applications (Loader etc) - - Args: - collection (string): project name (must match DB) - representation_id (string): MongoDB _id value - site_name (string): name of configured and active site - - Returns: - throws ValueError if any issue - """ - if not self.get_sync_project_setting(collection): - raise ValueError("Project not configured") - - if not site_name: - site_name = self.DEFAULT_SITE - - self.reset_provider_for_file(collection, - representation_id, - site_name=site_name) - - # public facing API - def remove_site(self, collection, representation_id, site_name, - remove_local_files=False): - """ - Removes 'site_name' for particular 'representation_id' on - 'collection' - - Args: - collection (string): project name (must match DB) - representation_id (string): MongoDB _id value - site_name (string): name of configured and active site - remove_local_files (bool): remove only files for 'local_id' - site - - Returns: - throws ValueError if any issue - """ - if not self.get_sync_project_setting(collection): - raise ValueError("Project not configured") - - self.reset_provider_for_file(collection, - representation_id, - site_name=site_name, - remove=True) - if remove_local_files: - self._remove_local_file(collection, representation_id, site_name) - - def clear_project(self, collection, site_name): - """ - Clear 'collection' of 'site_name' and its local files - - Works only on real local sites, not on 'studio' - """ - query = { - "type": "representation", - "files.sites.name": site_name - } - - representations = list( - self.connection.database[collection].find(query)) - if not representations: - self.log.debug("No repre found") - return - - for repre in representations: - self.remove_site(collection, repre.get("_id"), site_name, True) - - def pause_representation(self, collection, representation_id, site_name): - """ - Sets 'representation_id' as paused, eg. no syncing should be - happening on it. - - Args: - collection (string): project name - representation_id (string): MongoDB objectId value - site_name (string): 'gdrive', 'studio' etc. - """ - log.info("Pausing SyncServer for {}".format(representation_id)) - self._paused_representations.add(representation_id) - self.reset_provider_for_file(collection, representation_id, - site_name=site_name, pause=True) - - def unpause_representation(self, collection, representation_id, site_name): - """ - Sets 'representation_id' as unpaused. - - Does not fail or warn if repre wasn't paused. - - Args: - collection (string): project name - representation_id (string): MongoDB objectId value - site_name (string): 'gdrive', 'studio' etc. - """ - log.info("Unpausing SyncServer for {}".format(representation_id)) - try: - self._paused_representations.remove(representation_id) - except KeyError: - pass - # self.paused_representations is not persistent - self.reset_provider_for_file(collection, representation_id, - site_name=site_name, pause=False) - - def is_representation_paused(self, representation_id, - check_parents=False, project_name=None): - """ - Returns if 'representation_id' is paused or not. - - Args: - representation_id (string): MongoDB objectId value - check_parents (bool): check if parent project or server itself - are not paused - project_name (string): project to check if paused - - if 'check_parents', 'project_name' should be set too - Returns: - (bool) - """ - condition = representation_id in self._paused_representations - if check_parents and project_name: - condition = condition or \ - self.is_project_paused(project_name) or \ - self.is_paused() - return condition - - def pause_project(self, project_name): - """ - Sets 'project_name' as paused, eg. no syncing should be - happening on all representation inside. - - Args: - project_name (string): collection name - """ - log.info("Pausing SyncServer for {}".format(project_name)) - self._paused_projects.add(project_name) - - def unpause_project(self, project_name): - """ - Sets 'project_name' as unpaused - - Does not fail or warn if project wasn't paused. - - Args: - project_name (string): collection name - """ - log.info("Unpausing SyncServer for {}".format(project_name)) - try: - self._paused_projects.remove(project_name) - except KeyError: - pass - - def is_project_paused(self, project_name, check_parents=False): - """ - Returns if 'project_name' is paused or not. - - Args: - project_name (string): collection name - check_parents (bool): check if server itself - is not paused - Returns: - (bool) - """ - condition = project_name in self._paused_projects - if check_parents: - condition = condition or self.is_paused() - return condition - - def pause_server(self): - """ - Pause sync server - - It won't check anything, not uploading/downloading... - """ - log.info("Pausing SyncServer") - self._paused = True - - def unpause_server(self): - """ - Unpause server - """ - log.info("Unpausing SyncServer") - self._paused = False - - def is_paused(self): - """ Is server paused """ - return self._paused - - def get_active_sites(self, project_name): - """ - Returns list of active sites for 'project_name'. - - By default it returns ['studio'], this site is default - and always present even if SyncServer is not enabled. (for publish) - - Used mainly for Local settings for user override. - - Args: - project_name (string): - - Returns: - (list) of strings - """ - return self.get_active_sites_from_settings( - get_project_settings(project_name)) - - def get_active_sites_from_settings(self, settings): - """ - List available active sites from incoming 'settings'. Used for - returning 'default' values for Local Settings - - Args: - settings (dict): full settings (global + project) - Returns: - (list) of strings - """ - sync_settings = self._parse_sync_settings_from_settings(settings) - - return self._get_active_sites_from_settings(sync_settings) - - def get_active_site(self, project_name): - """ - Returns active (mine) site for 'project_name' from settings - - Returns: - (string) - """ - active_site = self.get_sync_project_setting( - project_name)['config']['active_site'] - if active_site == self.LOCAL_SITE: - return get_local_site_id() - return active_site - - # remote sites - def get_remote_sites(self, project_name): - """ - Returns all remote sites configured on 'project_name'. - - If 'project_name' is not enabled for syncing returns []. - - Used by Local setting to allow user choose remote site. - - Args: - project_name (string): - - Returns: - (list) of strings - """ - return self.get_remote_sites_from_settings( - get_project_settings(project_name)) - - def get_remote_sites_from_settings(self, settings): - """ - Get remote sites for returning 'default' values for Local Settings - """ - sync_settings = self._parse_sync_settings_from_settings(settings) - - return self._get_remote_sites_from_settings(sync_settings) - - def get_remote_site(self, project_name): - """ - Returns remote (theirs) site for 'project_name' from settings - """ - remote_site = self.get_sync_project_setting( - project_name)['config']['remote_site'] - if remote_site == self.LOCAL_SITE: - return get_local_site_id() - - return remote_site - - """ End of Public API """ - - def get_local_file_path(self, collection, file_path): - """ - Externalized for app - """ - local_file_path, _ = self._resolve_paths(file_path, collection) - - return local_file_path - - def _get_remote_sites_from_settings(self, sync_settings): - if not self.enabled or not sync_settings['enabled']: - return [] - - remote_sites = [self.DEFAULT_SITE, self.LOCAL_SITE] - if sync_settings: - remote_sites.extend(sync_settings.get("sites").keys()) - - return list(set(remote_sites)) - - def _get_active_sites_from_settings(self, sync_settings): - sites = [self.DEFAULT_SITE] - if self.enabled and sync_settings['enabled']: - sites.append(self.LOCAL_SITE) - - return sites - - def connect_with_modules(self, *_a, **kw): - return - - def tray_init(self): - """ - Actual initialization of Sync Server. - - Called when tray is initialized, it checks if module should be - enabled. If not, no initialization necessary. - """ - if not self.enabled: - return - - self.sync_project_settings = None - self.lock = threading.Lock() - - self.connection = AvalonMongoDB() - self.connection.install() - - try: - self.set_sync_project_settings() - self.sync_server_thread = SyncServerThread(self) - from .tray.app import SyncServerWindow - self.widget = SyncServerWindow(self) - except ValueError: - log.info("No system setting for sync. Not syncing.", exc_info=True) - self.enabled = False - except KeyError: - log.info(( - "There are not set presets for SyncServer OR " - "Credentials provided are invalid, " - "no syncing possible"). - format(str(self.sync_project_settings)), exc_info=True) - self.enabled = False - - def tray_start(self): - """ - Triggered when Tray is started. - - Checks if configuration presets are available and if there is - any provider ('gdrive', 'S3') that is activated - (eg. has valid credentials). + except Exception as exp: + print(exp) + + target_folder = os.path.dirname(remote_file_path) + folder_id = remote_handler.create_folder(target_folder) + + if not folder_id: + err = "Folder {} wasn't created. Check permissions.". \ + format(target_folder) + raise NotADirectoryError(err) + + loop = asyncio.get_running_loop() + file_id = await loop.run_in_executor(None, + remote_handler.upload_file, + local_file_path, + remote_file_path, + module, + collection, + file, + representation, + remote_site_name, + True + ) + return file_id + + +async def download(module, collection, file, representation, provider_name, + remote_site_name, tree=None, preset=None): + """ + Downloads file to local folder denoted in representation.Context. + + Args: + module(SyncServerModule): object to run SyncServerModule API + collection (str): source collection + file (dictionary) : info about processed file + representation (dictionary): repr that 'file' belongs to + provider_name (string): 'gdrive' etc + site_name (string): site on provider, single provider(gdrive) could + have multiple sites (different accounts, credentials) + tree (dictionary): injected memory structure for performance + preset (dictionary): site config ('credentials_url', 'root'...) Returns: - None - """ - if self.sync_project_settings and self.enabled: - self.sync_server_thread.start() - else: - log.info("No presets or active providers. " + - "Synchronization not possible.") + (string) - 'name' of local file + """ + with module.lock: + remote_handler = lib.factory.get_provider(provider_name, + collection, + remote_site_name, + tree=tree, + presets=preset) - def tray_exit(self): - """ - Stops sync thread if running. + file_path = file.get("path", "") + local_file_path, remote_file_path = resolve_paths( + module, file_path, collection, remote_site_name, remote_handler + ) - Called from Module Manager - """ - if not self.sync_server_thread: - return + local_folder = os.path.dirname(local_file_path) + os.makedirs(local_folder, exist_ok=True) - if not self.is_running: - return - try: - log.info("Stopping sync server server") - self.sync_server_thread.is_running = False - self.sync_server_thread.stop() - except Exception: - log.warning( - "Error has happened during Killing sync server", - exc_info=True - ) + local_site = module.get_active_site(collection) - def tray_menu(self, parent_menu): - if not self.enabled: - return + loop = asyncio.get_running_loop() + file_id = await loop.run_in_executor(None, + remote_handler.download_file, + remote_file_path, + local_file_path, + module, + collection, + file, + representation, + local_site, + True + ) + return file_id - from Qt import QtWidgets - """Add menu or action to Tray(or parent)'s menu""" - action = QtWidgets.QAction("SyncServer", parent_menu) - action.triggered.connect(self.show_widget) - parent_menu.addAction(action) - parent_menu.addSeparator() - self.action_show_widget = action +def resolve_paths(module, file_path, collection, + remote_site_name=None, remote_handler=None): + """ + Returns tuple of local and remote file paths with {root} + placeholders replaced with proper values from Settings or Anatomy - @property - def is_running(self): - return self.sync_server_thread.is_running + Ejected here because of Python 2 hosts (GDriveHandler is an issue) - def get_anatomy(self, project_name): - """ - Get already created or newly created anatomy for project - - Args: - project_name (string): - - Return: - (Anatomy) - """ - return self._anatomies.get('project_name') or Anatomy(project_name) - - def set_sync_project_settings(self): - """ - Set sync_project_settings for all projects (caching) - - For performance - """ - sync_project_settings = {} - if not self.connection: - self.connection = AvalonMongoDB() - self.connection.install() - - 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.") - - self.sync_project_settings = sync_project_settings - - def get_sync_project_settings(self, refresh=False): - """ - Collects all projects which have enabled syncing and their settings Args: - refresh (bool): refresh presets from settings - used when user - changes site in Local Settings or any time up-to-date values - are necessary + module(SyncServerModule): object to run SyncServerModule API + file_path(string): path with {root} + collection(string): project name + remote_site_name(string): remote site + remote_handler(AbstractProvider): implementation Returns: - (dict): of settings, keys are project names - {'projectA':{enabled: True, sites:{}...} - """ - # presets set already, do not call again and again - if refresh or not self.sync_project_settings: - self.set_sync_project_settings() + (string, string) - proper absolute paths, remote path is optional + """ + remote_file_path = '' + if remote_handler: + remote_file_path = remote_handler.resolve_path(file_path) - return self.sync_project_settings + local_handler = lib.factory.get_provider( + 'local_drive', collection, module.get_active_site(collection)) + local_file_path = local_handler.resolve_path(file_path) - def get_sync_project_setting(self, project_name): - """ Handles pulling sync_server's settings for enabled 'project_name' + return local_file_path, remote_file_path - Args: - project_name (str): used in project settings - 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) - settings = get_project_settings(project_name) - return self._parse_sync_settings_from_settings(settings) +def site_is_working(module, project_name, site_name): + """ + Confirm that 'site_name' is configured correctly for 'project_name'. - def site_is_working(self, project_name, site_name): - """ - Confirm that 'site_name' is configured correctly for 'project_name' - Args: - project_name(string): - site_name(string): - Returns - (bool) - """ - if self._get_configured_sites(project_name).get(site_name): - return True - return False + Must be here as lib.factory access doesn't work in Python 2 hosts. - 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 + Args: + module (SyncServerModule) + project_name(string): + site_name(string): + Returns + (bool) + """ + if _get_configured_sites(module, project_name).get(site_name): + return True + return False + +def _get_configured_sites(module, project_name): + """ + Loops through settings and looks for configured sites and checks + its handlers for particular 'project_name'. + + Args: + project_setting(dict): dictionary from Settings + only_project_name(string, optional): only interested in + particular project + Returns: + (dict of dict) + {'ProjectA': {'studio':True, 'gdrive':False}} + """ + settings = module.get_sync_project_setting(project_name) + return _get_configured_sites_from_setting(module, project_name, settings) + + +def _get_configured_sites_from_setting(module, project_name, project_setting): + if not project_setting.get("enabled"): return {} - def _get_configured_sites(self, project_name): - """ - Loops through settings and looks for configured sites and checks - its handlers for particular 'project_name'. - - Args: - project_setting(dict): dictionary from Settings - only_project_name(string, optional): only interested in - particular project - Returns: - (dict of dict) - {'ProjectA': {'studio':True, 'gdrive':False}} - """ - settings = self.get_sync_project_setting(project_name) - return self._get_configured_sites_from_setting(settings) - - def _get_configured_sites_from_setting(self, project_setting): - if not project_setting.get("enabled"): - return {} - - initiated_handlers = {} - configured_sites = {} - all_sites = self._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)) - if not handler: - handler = lib.factory.get_provider(config["provider"], - site_name, - presets=config) - initiated_handlers[(config["provider"], site_name)] = \ - handler - - if handler.is_active(): - configured_sites[site_name] = True - - return configured_sites - - def _get_default_site_configs(self): - """ - Returns skeleton settings for 'studio' and user's local site - """ - default_config = {'provider': 'local_drive'} - all_sites = {self.DEFAULT_SITE: default_config, - get_local_site_id(): default_config} - return all_sites - - def get_provider_for_site(self, project_name, site): - """ - Return provider name for site. - """ - site_preset = self.get_sync_project_setting(project_name)["sites"].\ - get(site) - if site_preset: - return site_preset["provider"] - - return "NA" - - @time_function - def get_sync_representations(self, collection, active_site, remote_site): - """ - Get representations that should be synced, these could be - recognised by presence of document in 'files.sites', where key is - a provider (GDrive, S3) and value is empty document or document - without 'created_dt' field. (Don't put null to 'created_dt'!). - - Querying of 'to-be-synched' files is offloaded to Mongod for - better performance. Goal is to get as few representations as - possible. - Args: - collection (string): name of collection (in most cases matches - project name - active_site (string): identifier of current active site (could be - 'local_0' when working from home, 'studio' when working in the - studio (default) - remote_site (string): identifier of remote site I want to sync to - - Returns: - (list) of dictionaries - """ - log.debug("Check representations for : {}".format(collection)) - self.connection.Session["AVALON_PROJECT"] = collection - # retry_cnt - number of attempts to sync specific file before giving up - retries_arr = self._get_retries_arr(collection) - query = { - "type": "representation", - "$or": [ - {"$and": [ - { - "files.sites": { - "$elemMatch": { - "name": active_site, - "created_dt": {"$exists": True} - } - }}, { - "files.sites": { - "$elemMatch": { - "name": {"$in": [remote_site]}, - "created_dt": {"$exists": False}, - "tries": {"$in": retries_arr} - } - } - }]}, - {"$and": [ - { - "files.sites": { - "$elemMatch": { - "name": active_site, - "created_dt": {"$exists": False}, - "tries": {"$in": retries_arr} - } - }}, { - "files.sites": { - "$elemMatch": { - "name": {"$in": [remote_site]}, - "created_dt": {"$exists": True} - } - } - } - ]} - ] - } - log.debug("active_site:{} - remote_site:{}".format(active_site, - remote_site)) - log.debug("query: {}".format(query)) - representations = self.connection.find(query) - - return representations - - def check_status(self, file, local_site, remote_site, config_preset): - """ - Check synchronization status for single 'file' of single - 'representation' by single 'provider'. - (Eg. check if 'scene.ma' of lookdev.v10 should be synced to GDrive - - Always is comparing local record, eg. site with - 'name' == self.presets[PROJECT_NAME]['config']["active_site"] - - Args: - file (dictionary): of file from representation in Mongo - local_site (string): - local side of compare (usually 'studio') - remote_site (string): - gdrive etc. - config_preset (dict): config about active site, retries - Returns: - (string) - one of SyncStatus - """ - sites = file.get("sites") or [] - # if isinstance(sites, list): # temporary, old format of 'sites' - # 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") - if not created_dt: - tries = self._get_tries_count_from_rec(remote_rec) - # file will be skipped if unsuccessfully tried over threshold - # error metadata needs to be purged manually in DB to reset - if tries < int(config_preset["retry_cnt"]): - return SyncStatus.DO_UPLOAD - else: - _, local_rec = self._get_site_rec(sites, local_site) or {} - if not local_rec or not local_rec.get("created_dt"): - tries = self._get_tries_count_from_rec(local_rec) - # file will be skipped if unsuccessfully tried over - # threshold times, error metadata needs to be purged - # manually in DB to reset - if tries < int(config_preset["retry_cnt"]): - return SyncStatus.DO_DOWNLOAD - - return SyncStatus.DO_NOTHING - - async def upload(self, collection, file, representation, provider_name, - remote_site_name, tree=None, preset=None): - """ - Upload single 'file' of a 'representation' to 'provider'. - Source url is taken from 'file' portion, where {root} placeholder - is replaced by 'representation.Context.root' - Provider could be one of implemented in provider.py. - - Updates MongoDB, fills in id of file from provider (ie. file_id - from GDrive), 'created_dt' - time of upload - - 'provider_name' doesn't have to match to 'site_name', single - provider (GDrive) might have multiple sites ('projectA', - 'projectB') - - Args: - collection (str): source collection - file (dictionary): of file from representation in Mongo - representation (dictionary): of representation - provider_name (string): gdrive, gdc etc. - site_name (string): site on provider, single provider(gdrive) could - have multiple sites (different accounts, credentials) - tree (dictionary): injected memory structure for performance - preset (dictionary): site config ('credentials_url', 'root'...) - - """ - # create ids sequentially, upload file in parallel later - with self.lock: - # this part modifies structure on 'remote_site', only single - # thread can do that at a time, upload/download to prepared - # structure should be run in parallel - remote_handler = lib.factory.get_provider(provider_name, - remote_site_name, - tree=tree, - presets=preset) - - file_path = file.get("path", "") - local_file_path, remote_file_path = self._resolve_paths( - file_path, collection, remote_site_name, remote_handler - ) - - target_folder = os.path.dirname(remote_file_path) - folder_id = remote_handler.create_folder(target_folder) - - if not folder_id: - err = "Folder {} wasn't created. Check permissions.".\ - format(target_folder) - raise NotADirectoryError(err) - - loop = asyncio.get_running_loop() - file_id = await loop.run_in_executor(None, - remote_handler.upload_file, - local_file_path, - remote_file_path, - self, - collection, - file, - representation, - remote_site_name, - True - ) - return file_id - - async def download(self, collection, file, representation, provider_name, - remote_site_name, tree=None, preset=None): - """ - Downloads file to local folder denoted in representation.Context. - - Args: - collection (str): source collection - file (dictionary) : info about processed file - representation (dictionary): repr that 'file' belongs to - provider_name (string): 'gdrive' etc - site_name (string): site on provider, single provider(gdrive) could - have multiple sites (different accounts, credentials) - tree (dictionary): injected memory structure for performance - preset (dictionary): site config ('credentials_url', 'root'...) - - Returns: - (string) - 'name' of local file - """ - with self.lock: - remote_handler = lib.factory.get_provider(provider_name, - remote_site_name, - tree=tree, - presets=preset) - - file_path = file.get("path", "") - local_file_path, remote_file_path = self._resolve_paths( - file_path, collection, remote_site_name, remote_handler - ) - - local_folder = os.path.dirname(local_file_path) - os.makedirs(local_folder, exist_ok=True) - - local_site = self.get_active_site(collection) - - loop = asyncio.get_running_loop() - file_id = await loop.run_in_executor(None, - remote_handler.download_file, - remote_file_path, - local_file_path, - self, - collection, - file, - representation, - local_site, - True - ) - return file_id - - def update_db(self, collection, new_file_id, file, representation, - site, error=None, progress=None): - """ - Update 'provider' portion of records in DB with success (file_id) - or error (exception) - - Args: - collection (string): name of project - force to db connection as - each file might come from different collection - new_file_id (string): - file (dictionary): info about processed file (pulled from DB) - representation (dictionary): parent repr of file (from DB) - site (string): label ('gdrive', 'S3') - error (string): exception message - progress (float): 0-1 of progress of upload/download - - Returns: - None - """ - representation_id = representation.get("_id") - file_id = file.get("_id") - query = { - "_id": representation_id - } - - update = {} - if new_file_id: - update["$set"] = self._get_success_dict(new_file_id) - # reset previous errors if any - update["$unset"] = self._get_error_dict("", "", "") - elif progress is not None: - update["$set"] = self._get_progress_dict(progress) - else: - tries = self._get_tries_count(file, site) - tries += 1 - - update["$set"] = self._get_error_dict(error, tries) - - arr_filter = [ - {'s.name': site}, - {'f._id': ObjectId(file_id)} - ] - - self.connection.database[collection].update_one( - query, - update, - upsert=True, - array_filters=arr_filter - ) - - if progress is not None: - return - - status = 'failed' - error_str = 'with error {}'.format(error) - if new_file_id: - status = 'succeeded with id {}'.format(new_file_id) - error_str = '' - - source_file = file.get("path", "") - log.debug("File for {} - {source_file} process {status} {error_str}". - format(representation_id, - status=status, - source_file=source_file, - error_str=error_str)) - - def _get_file_info(self, files, _id): - """ - Return record from list of records which name matches to 'provider' - Could be possibly refactored with '_get_provider_rec' together. - - Args: - files (list): of dictionaries with info about published files - _id (string): _id of specific file - - Returns: - (int, dictionary): index from list and record with metadata - about site (if/when created, errors..) - OR (-1, None) if not present - """ - for index, rec in enumerate(files): - if rec.get("_id") == _id: - return index, rec - - return -1, None - - def _get_site_rec(self, sites, site_name): - """ - Return record from list of records which name matches to - 'remote_site_name' - - Args: - sites (list): of dictionaries - site_name (string): 'local_XXX', 'gdrive' - - Returns: - (int, dictionary): index from list and record with metadata - about site (if/when created, errors..) - OR (-1, None) if not present - """ - for index, rec in enumerate(sites): - if rec.get("name") == site_name: - return index, rec - - return -1, None - - def reset_provider_for_file(self, collection, representation_id, - side=None, file_id=None, site_name=None, - remove=False, pause=None): - """ - Reset information about synchronization for particular 'file_id' - and provider. - Useful for testing or forcing file to be reuploaded. - - 'side' and 'site_name' are disjunctive. - - 'side' is used for resetting local or remote side for - current user for repre. - - 'site_name' is used to set synchronization for particular site. - Should be used when repre should be synced to new site. - - Args: - collection (string): name of project (eg. collection) in DB - representation_id(string): _id of representation - file_id (string): file _id in representation - side (string): local or remote side - site_name (string): for adding new site - remove (bool): if True remove site altogether - pause (bool or None): if True - pause, False - unpause - - Returns: - throws ValueError - """ - query = { - "_id": ObjectId(representation_id) - } - - representation = list(self.connection.database[collection].find(query)) - if not representation: - raise ValueError("Representation {} not found in {}". - format(representation_id, collection)) - if side and site_name: - raise ValueError("Misconfiguration, only one of side and " + - "site_name arguments should be passed.") - - local_site = self.get_active_site(collection) - remote_site = self.get_remote_site(collection) - - if side: - if side == 'local': - site_name = local_site - else: - site_name = remote_site - - elem = {"name": site_name} - - if file_id: # reset site for particular file - self._reset_site_for_file(collection, query, - elem, file_id, site_name) - elif side: # reset site for whole representation - self._reset_site(collection, query, elem, site_name) - elif remove: # remove site for whole representation - self._remove_site(collection, query, representation, site_name) - elif pause is not None: - self._pause_unpause_site(collection, query, - representation, site_name, pause) - else: # add new site to all files for representation - self._add_site(collection, query, representation, elem, site_name) - - def _update_site(self, collection, query, update, arr_filter): - """ - Auxiliary method to call update_one function on DB - - Used for refactoring ugly reset_provider_for_file - """ - self.connection.database[collection].update_one( - query, - update, - upsert=True, - array_filters=arr_filter - ) - - def _reset_site_for_file(self, collection, query, - elem, file_id, site_name): - """ - Resets 'site_name' for 'file_id' on representation in 'query' on - 'collection' - """ - update = { - "$set": {"files.$[f].sites.$[s]": elem} - } - arr_filter = [ - {'s.name': site_name}, - {'f._id': ObjectId(file_id)} - ] - - self._update_site(collection, query, update, arr_filter) - - def _reset_site(self, collection, query, elem, site_name): - """ - Resets 'site_name' for all files of representation in 'query' - """ - update = { - "$set": {"files.$[].sites.$[s]": elem} - } - - arr_filter = [ - {'s.name': site_name} - ] - - self._update_site(collection, query, update, arr_filter) - - def _remove_site(self, collection, query, representation, site_name): - """ - Removes 'site_name' for 'representation' in 'query' - - Throws ValueError if 'site_name' not found on 'representation' - """ - found = False - for file in representation.pop().get("files"): - for site in file.get("sites"): - if site["name"] == site_name: - found = True - break - if not found: - msg = "Site {} not found".format(site_name) - log.info(msg) - raise ValueError(msg) - - update = { - "$pull": {"files.$[].sites": {"name": site_name}} - } - arr_filter = [] - - self._update_site(collection, query, update, arr_filter) - - def _pause_unpause_site(self, collection, query, - representation, site_name, pause): - """ - Pauses/unpauses all files for 'representation' based on 'pause' - - Throws ValueError if 'site_name' not found on 'representation' - """ - found = False - site = None - for file in representation.pop().get("files"): - for site in file.get("sites"): - if site["name"] == site_name: - found = True - break - if not found: - msg = "Site {} not found".format(site_name) - log.info(msg) - raise ValueError(msg) - - if pause: - site['paused'] = pause - else: - if site.get('paused'): - site.pop('paused') - - update = { - "$set": {"files.$[].sites.$[s]": site} - } - - arr_filter = [ - {'s.name': site_name} - ] - - self._update_site(collection, query, update, arr_filter) - - def _add_site(self, collection, query, representation, elem, site_name): - """ - Adds 'site_name' to 'representation' on 'collection' - - Throws ValueError if already present - """ - for file in representation.pop().get("files"): - for site in file.get("sites"): - if site["name"] == site_name: - msg = "Site {} already present".format(site_name) - log.info(msg) - raise ValueError(msg) - - update = { - "$push": {"files.$[].sites": elem} - } - - arr_filter = [] - - self._update_site(collection, query, update, arr_filter) - - def _remove_local_file(self, collection, representation_id, site_name): - """ - Removes all local files for 'site_name' of 'representation_id' - - Args: - collection (string): project name (must match DB) - representation_id (string): MongoDB _id value - site_name (string): name of configured and active site - - Returns: - only logs, catches IndexError and OSError - """ - my_local_site = get_local_site_id() - if my_local_site != site_name: - self.log.warning("Cannot remove non local file for {}". - format(site_name)) - return - - provider_name = self.get_provider_for_site(collection, site_name) - handler = lib.factory.get_provider(provider_name, site_name) - - if handler and isinstance(handler, LocalDriveHandler): - query = { - "_id": ObjectId(representation_id) - } - - representation = list( - self.connection.database[collection].find(query)) - if not representation: - self.log.debug("No repre {} found".format( - representation_id)) - return - - representation = representation.pop() - local_file_path = '' - for file in representation.get("files"): - local_file_path, _ = self._resolve_paths(file.get("path", ""), - collection - ) - try: - self.log.debug("Removing {}".format(local_file_path)) - os.remove(local_file_path) - except IndexError: - msg = "No file set for {}".format(representation_id) - self.log.debug(msg) - raise ValueError(msg) - except OSError: - msg = "File {} cannot be removed".format(file["path"]) - self.log.warning(msg) - raise ValueError(msg) - - try: - folder = os.path.dirname(local_file_path) - os.rmdir(folder) - except OSError: - msg = "folder {} cannot be removed".format(folder) - self.log.warning(msg) - raise ValueError(msg) - - def get_loop_delay(self, project_name): - """ - Return count of seconds before next synchronization loop starts - after finish of previous loop. - Returns: - (int): in seconds - """ - ld = self.sync_project_settings[project_name]["config"]["loop_delay"] - return int(ld) - - def show_widget(self): - """Show dialog to enter credentials""" - self.widget.show() - - def _get_success_dict(self, new_file_id): - """ - Provide success metadata ("id", "created_dt") to be stored in Db. - Used in $set: "DICT" part of query. - Sites are array inside of array(file), so real indexes for both - file and site are needed for upgrade in DB. - Args: - new_file_id: id of created file - Returns: - (dictionary) - """ - val = {"files.$[f].sites.$[s].id": new_file_id, - "files.$[f].sites.$[s].created_dt": datetime.now()} - return val - - def _get_error_dict(self, error="", tries="", progress=""): - """ - Provide error metadata to be stored in Db. - Used for set (error and tries provided) or unset mode. - Args: - error: (string) - message - tries: how many times failed - Returns: - (dictionary) - """ - val = {"files.$[f].sites.$[s].last_failed_dt": datetime.now(), - "files.$[f].sites.$[s].error": error, - "files.$[f].sites.$[s].tries": tries, - "files.$[f].sites.$[s].progress": progress - } - return val - - def _get_tries_count_from_rec(self, rec): - """ - Get number of failed attempts to sync from site record - Args: - rec (dictionary): info about specific site record - Returns: - (int) - number of failed attempts - """ - if not rec: - return 0 - return rec.get("tries", 0) - - def _get_tries_count(self, file, provider): - """ - Get number of failed attempts to sync - Args: - file (dictionary): info about specific file - provider (string): name of site ('gdrive' or specific user site) - Returns: - (int) - number of failed attempts - """ - _, rec = self._get_site_rec(file.get("sites", []), provider) - return rec.get("tries", 0) - - def _get_progress_dict(self, progress): - """ - Provide progress metadata to be stored in Db. - Used during upload/download for GUI to show. - Args: - progress: (float) - 0-1 progress of upload/download - Returns: - (dictionary) - """ - val = {"files.$[f].sites.$[s].progress": progress} - return val - - def _resolve_paths(self, file_path, collection, - remote_site_name=None, remote_handler=None): - """ - Returns tuple of local and remote file paths with {root} - placeholders replaced with proper values from Settings or Anatomy - - Args: - file_path(string): path with {root} - collection(string): project name - remote_site_name(string): remote site - remote_handler(AbstractProvider): implementation - Returns: - (string, string) - proper absolute paths - """ - remote_file_path = '' - if remote_handler: - root_configs = self._get_roots_config(self.sync_project_settings, - collection, - remote_site_name) - - remote_file_path = remote_handler.resolve_path(file_path, - root_configs) - - local_handler = lib.factory.get_provider( - 'local_drive', self.get_active_site(collection)) - local_file_path = local_handler.resolve_path( - file_path, None, self.get_anatomy(collection)) - - return local_file_path, remote_file_path - - def _get_retries_arr(self, project_name): - """ - Returns array with allowed values in 'tries' field. If repre - contains these values, it means it was tried to be synchronized - but failed. We try up to 'self.presets["retry_cnt"]' times before - giving up and skipping representation. - Returns: - (list) - """ - retry_cnt = self.sync_project_settings[project_name].\ - get("config")["retry_cnt"] - arr = [i for i in range(int(retry_cnt))] - arr.append(None) - - return arr - - def _get_roots_config(self, presets, project_name, site_name): - """ - Returns configured root(s) for 'project_name' and 'site_name' from - settings ('presets') - """ - return presets[project_name]['sites'][site_name]['root'] - + initiated_handlers = {} + configured_sites = {} + 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)) + if not handler: + handler = lib.factory.get_provider(config["provider"], + project_name, + site_name, + presets=config) + initiated_handlers[(config["provider"], site_name)] = \ + handler + + if handler.is_active(): + configured_sites[site_name] = True + + return configured_sites class SyncServerThread(threading.Thread): """ @@ -1437,7 +271,7 @@ class SyncServerThread(threading.Thread): import time start_time = None self.module.set_sync_project_settings() # clean cache - for collection, preset in self.module.get_sync_project_settings().\ + for collection, preset in self.module.sync_project_settings.\ items(): start_time = time.time() local_site, remote_site = self._working_sites(collection) @@ -1462,6 +296,7 @@ class SyncServerThread(threading.Thread): site_preset = preset.get('sites')[remote_site] remote_provider = site_preset['provider'] handler = lib.factory.get_provider(remote_provider, + collection, remote_site, presets=site_preset) limit = lib.factory.get_provider_batch_limit( @@ -1491,13 +326,14 @@ class SyncServerThread(threading.Thread): tree = handler.get_tree() limit -= 1 task = asyncio.create_task( - self.module.upload(collection, - file, - sync, - remote_provider, - remote_site, - tree, - site_preset)) + upload(self.module, + collection, + file, + sync, + remote_provider, + remote_site, + tree, + site_preset)) task_files_to_process.append(task) # store info for exception handlingy files_processed_info.append((file, @@ -1510,13 +346,14 @@ class SyncServerThread(threading.Thread): tree = handler.get_tree() limit -= 1 task = asyncio.create_task( - self.module.download(collection, - file, - sync, - remote_provider, - remote_site, - tree, - site_preset)) + download(self.module, + collection, + file, + sync, + remote_provider, + remote_site, + tree, + site_preset)) task_files_to_process.append(task) files_processed_info.append((file, @@ -1592,8 +429,8 @@ class SyncServerThread(threading.Thread): remote_site)) return None, None - if not all([self.module.site_is_working(collection, local_site), - self.module.site_is_working(collection, remote_site)]): + if not all([site_is_working(self.module, collection, local_site), + site_is_working(self.module, collection, remote_site)]): 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 new file mode 100644 index 0000000000..4b4b3517ee --- /dev/null +++ b/openpype/modules/sync_server/sync_server_module.py @@ -0,0 +1,1194 @@ +import os +from bson.objectid import ObjectId +from datetime import datetime +import threading + +from avalon.api import AvalonMongoDB + +from .. import PypeModule, ITrayModule +from openpype.api import ( + Anatomy, + get_project_settings, + get_local_site_id) +from openpype.lib import PypeLogger + +from .providers.local_drive import LocalDriveHandler + +from .utils import time_function, SyncStatus + + +log = PypeLogger().get_logger("SyncServer") + + +class SyncServerModule(PypeModule, ITrayModule): + """ + Synchronization server that is syncing published files from local to + any of implemented providers (like GDrive, S3 etc.) + Runs in the background and checks all representations, looks for files + that are marked to be in different location than 'studio' (temporary), + checks if 'created_dt' field is present denoting successful sync + with provider destination. + Sites structure is created during publish OR by calling 'add_site' + method. + + By default it will always contain 1 record with + "name" == self.presets["active_site"] and + filled "created_dt" AND 1 or multiple records for all defined + remote sites, where "created_dt" is not present. + This highlights that file should be uploaded to + remote destination + + ''' - example of synced file test_Cylinder_lookMain_v010.ma to GDrive + "files" : [ + { + "path" : "{root}/Test/Assets/Cylinder/publish/look/lookMain/v010/ + test_Cylinder_lookMain_v010.ma", + "_id" : ObjectId("5eeb25e411e06a16209ab78f"), + "hash" : "test_Cylinder_lookMain_v010,ma|1592468963,24|4822", + "size" : NumberLong(4822), + "sites" : [ + { + "name": "john_local_XD4345", + "created_dt" : ISODate("2020-05-22T08:05:44.000Z") + }, + { + "id" : ObjectId("5eeb25e411e06a16209ab78f"), + "name": "gdrive", + "created_dt" : ISODate("2020-05-55T08:54:35.833Z") + ] + } + }, + ''' + Each Tray app has assigned its own self.presets["local_id"] + used in sites as a name. + Tray is searching only for records where name matches its + self.presets["active_site"] + self.presets["remote_site"]. + "active_site" could be storage in studio ('studio'), or specific + "local_id" when user is working disconnected from home. + If the local record has its "created_dt" filled, it is a source and + process will try to upload the file to all defined remote sites. + + Remote files "id" is real id that could be used in appropriate API. + Local files have "id" too, for conformity, contains just file name. + It is expected that multiple providers will be implemented in separate + classes and registered in 'providers.py'. + + """ + # limit querying DB to look for X number of representations that should + # be sync, we try to run more loops with less records + # actual number of files synced could be lower as providers can have + # different limits imposed by its API + # set 0 to no limit + REPRESENTATION_LIMIT = 100 + DEFAULT_SITE = 'studio' + LOCAL_SITE = 'local' + LOG_PROGRESS_SEC = 5 # how often log progress to DB + + name = "sync_server" + label = "Sync Server" + + def initialize(self, module_settings): + """ + Called during Module Manager creation. + + Collects needed data, checks asyncio presence. + Sets 'enabled' according to global settings for the module. + Shouldnt be doing any initialization, thats a job for 'tray_init' + """ + self.enabled = module_settings[self.name]["enabled"] + + # some parts of code need to run sequentially, not in async + self.lock = None + # settings for all enabled projects for sync + self._sync_project_settings = None + self.sync_server_thread = None # asyncio requires new thread + + self.action_show_widget = None + self._paused = False + self._paused_projects = set() + self._paused_representations = set() + self._anatomies = {} + + self._connection = None + + """ Start of Public API """ + def add_site(self, collection, representation_id, site_name=None, + force=False): + """ + Adds new site to representation to be synced. + + 'collection' must have synchronization enabled (globally or + project only) + + Used as a API endpoint from outside applications (Loader etc) + + Args: + collection (string): project name (must match DB) + representation_id (string): MongoDB _id value + site_name (string): name of configured and active site + force (bool): reset site if exists + + Returns: + throws ValueError if any issue + """ + if not self.get_sync_project_setting(collection): + raise ValueError("Project not configured") + + if not site_name: + site_name = self.DEFAULT_SITE + + self.reset_provider_for_file(collection, + representation_id, + site_name=site_name, force=force) + + # public facing API + def remove_site(self, collection, representation_id, site_name, + remove_local_files=False): + """ + Removes 'site_name' for particular 'representation_id' on + 'collection' + + Args: + collection (string): project name (must match DB) + representation_id (string): MongoDB _id value + site_name (string): name of configured and active site + remove_local_files (bool): remove only files for 'local_id' + site + + Returns: + throws ValueError if any issue + """ + if not self.get_sync_project_setting(collection): + raise ValueError("Project not configured") + + self.reset_provider_for_file(collection, + representation_id, + site_name=site_name, + remove=True) + if remove_local_files: + self._remove_local_file(collection, representation_id, site_name) + + def clear_project(self, collection, site_name): + """ + Clear 'collection' of 'site_name' and its local files + + Works only on real local sites, not on 'studio' + """ + query = { + "type": "representation", + "files.sites.name": site_name + } + + representations = list( + self.connection.database[collection].find(query)) + if not representations: + self.log.debug("No repre found") + return + + for repre in representations: + self.remove_site(collection, repre.get("_id"), site_name, True) + + def pause_representation(self, collection, representation_id, site_name): + """ + Sets 'representation_id' as paused, eg. no syncing should be + happening on it. + + Args: + collection (string): project name + representation_id (string): MongoDB objectId value + site_name (string): 'gdrive', 'studio' etc. + """ + log.info("Pausing SyncServer for {}".format(representation_id)) + self._paused_representations.add(representation_id) + self.reset_provider_for_file(collection, representation_id, + site_name=site_name, pause=True) + + def unpause_representation(self, collection, representation_id, site_name): + """ + Sets 'representation_id' as unpaused. + + Does not fail or warn if repre wasn't paused. + + Args: + collection (string): project name + representation_id (string): MongoDB objectId value + site_name (string): 'gdrive', 'studio' etc. + """ + log.info("Unpausing SyncServer for {}".format(representation_id)) + try: + self._paused_representations.remove(representation_id) + except KeyError: + pass + # self.paused_representations is not persistent + self.reset_provider_for_file(collection, representation_id, + site_name=site_name, pause=False) + + def is_representation_paused(self, representation_id, + check_parents=False, project_name=None): + """ + Returns if 'representation_id' is paused or not. + + Args: + representation_id (string): MongoDB objectId value + check_parents (bool): check if parent project or server itself + are not paused + project_name (string): project to check if paused + + if 'check_parents', 'project_name' should be set too + Returns: + (bool) + """ + condition = representation_id in self._paused_representations + if check_parents and project_name: + condition = condition or \ + self.is_project_paused(project_name) or \ + self.is_paused() + return condition + + def pause_project(self, project_name): + """ + Sets 'project_name' as paused, eg. no syncing should be + happening on all representation inside. + + Args: + project_name (string): collection name + """ + log.info("Pausing SyncServer for {}".format(project_name)) + self._paused_projects.add(project_name) + + def unpause_project(self, project_name): + """ + Sets 'project_name' as unpaused + + Does not fail or warn if project wasn't paused. + + Args: + project_name (string): collection name + """ + log.info("Unpausing SyncServer for {}".format(project_name)) + try: + self._paused_projects.remove(project_name) + except KeyError: + pass + + def is_project_paused(self, project_name, check_parents=False): + """ + Returns if 'project_name' is paused or not. + + Args: + project_name (string): collection name + check_parents (bool): check if server itself + is not paused + Returns: + (bool) + """ + condition = project_name in self._paused_projects + if check_parents: + condition = condition or self.is_paused() + return condition + + def pause_server(self): + """ + Pause sync server + + It won't check anything, not uploading/downloading... + """ + log.info("Pausing SyncServer") + self._paused = True + + def unpause_server(self): + """ + Unpause server + """ + log.info("Unpausing SyncServer") + self._paused = False + + def is_paused(self): + """ Is server paused """ + return self._paused + + def get_active_sites(self, project_name): + """ + Returns list of active sites for 'project_name'. + + By default it returns ['studio'], this site is default + and always present even if SyncServer is not enabled. (for publish) + + Used mainly for Local settings for user override. + + Args: + project_name (string): + + Returns: + (list) of strings + """ + return self.get_active_sites_from_settings( + get_project_settings(project_name)) + + def get_active_sites_from_settings(self, settings): + """ + List available active sites from incoming 'settings'. Used for + returning 'default' values for Local Settings + + Args: + settings (dict): full settings (global + project) + Returns: + (list) of strings + """ + sync_settings = self._parse_sync_settings_from_settings(settings) + + 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 + + Returns: + (string) + """ + active_site = self.get_sync_project_setting( + project_name)['config']['active_site'] + if active_site == self.LOCAL_SITE: + return get_local_site_id() + return active_site + + # remote sites + def get_remote_sites(self, project_name): + """ + Returns all remote sites configured on 'project_name'. + + If 'project_name' is not enabled for syncing returns []. + + Used by Local setting to allow user choose remote site. + + Args: + project_name (string): + + Returns: + (list) of strings + """ + return self.get_remote_sites_from_settings( + get_project_settings(project_name)) + + def get_remote_sites_from_settings(self, settings): + """ + Get remote sites for returning 'default' values for Local Settings + """ + sync_settings = self._parse_sync_settings_from_settings(settings) + + return self._get_remote_sites_from_settings(sync_settings) + + def get_remote_site(self, project_name): + """ + Returns remote (theirs) site for 'project_name' from settings + """ + remote_site = self.get_sync_project_setting( + project_name)['config']['remote_site'] + if remote_site == self.LOCAL_SITE: + return get_local_site_id() + + return remote_site + + """ End of Public API """ + + def get_local_file_path(self, collection, site_name, file_path): + """ + Externalized for app + """ + handler = LocalDriveHandler(collection, site_name) + local_file_path = handler.resolve_path(file_path) + + return local_file_path + + def _get_remote_sites_from_settings(self, sync_settings): + if not self.enabled or not sync_settings['enabled']: + return [] + + remote_sites = [self.DEFAULT_SITE, self.LOCAL_SITE] + if sync_settings: + remote_sites.extend(sync_settings.get("sites").keys()) + + return list(set(remote_sites)) + + def _get_enabled_sites_from_settings(self, sync_settings): + sites = [self.DEFAULT_SITE] + if self.enabled and sync_settings['enabled']: + sites.append(self.LOCAL_SITE) + + return sites + + def connect_with_modules(self, *_a, **kw): + return + + def tray_init(self): + """ + Actual initialization of Sync Server. + + Called when tray is initialized, it checks if module should be + enabled. If not, no initialization necessary. + """ + # import only in tray, because of Python2 hosts + from .sync_server import SyncServerThread + + if not self.enabled: + return + + self.lock = threading.Lock() + + try: + self.sync_server_thread = SyncServerThread(self) + from .tray.app import SyncServerWindow + self.widget = SyncServerWindow(self) + except ValueError: + log.info("No system setting for sync. Not syncing.", exc_info=True) + self.enabled = False + except KeyError: + log.info(( + "There are not set presets for SyncServer OR " + "Credentials provided are invalid, " + "no syncing possible"). + format(str(self.sync_project_settings)), exc_info=True) + self.enabled = False + + def tray_start(self): + """ + Triggered when Tray is started. + + Checks if configuration presets are available and if there is + any provider ('gdrive', 'S3') that is activated + (eg. has valid credentials). + + Returns: + None + """ + if self.sync_project_settings and self.enabled: + self.sync_server_thread.start() + else: + log.info("No presets or active providers. " + + "Synchronization not possible.") + + def tray_exit(self): + """ + Stops sync thread if running. + + Called from Module Manager + """ + if not self.sync_server_thread: + return + + if not self.is_running: + return + try: + log.info("Stopping sync server server") + self.sync_server_thread.is_running = False + self.sync_server_thread.stop() + except Exception: + log.warning( + "Error has happened during Killing sync server", + exc_info=True + ) + + def tray_menu(self, parent_menu): + if not self.enabled: + return + + from Qt import QtWidgets + """Add menu or action to Tray(or parent)'s menu""" + action = QtWidgets.QAction("SyncServer", parent_menu) + action.triggered.connect(self.show_widget) + parent_menu.addAction(action) + parent_menu.addSeparator() + + self.action_show_widget = action + + @property + def is_running(self): + return self.sync_server_thread.is_running + + def get_anatomy(self, project_name): + """ + Get already created or newly created anatomy for project + + Args: + project_name (string): + + Return: + (Anatomy) + """ + return self._anatomies.get('project_name') or Anatomy(project_name) + + @property + def connection(self): + if self._connection is None: + self._connection = AvalonMongoDB() + + return self._connection + + @property + def sync_project_settings(self): + if self._sync_project_settings is None: + self.set_sync_project_settings() + + return self._sync_project_settings + + def set_sync_project_settings(self): + """ + Set sync_project_settings for all projects (caching) + + 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.") + + self._sync_project_settings = sync_project_settings + + def get_sync_project_setting(self, project_name): + """ Handles pulling sync_server's settings for enabled 'project_name' + + Args: + project_name (str): used in project settings + 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) + + settings = get_project_settings(project_name) + return self._parse_sync_settings_from_settings(settings) + + 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 {} + + def _get_default_site_configs(self): + """ + Returns skeleton settings for 'studio' and user's local site + """ + default_config = {'provider': 'local_drive'} + all_sites = {self.DEFAULT_SITE: default_config, + get_local_site_id(): default_config} + return all_sites + + def get_provider_for_site(self, project_name, site): + """ + Return provider name for site. + """ + site_preset = self.get_sync_project_setting(project_name)["sites"].\ + get(site) + if site_preset: + return site_preset["provider"] + + return "NA" + + @time_function + def get_sync_representations(self, collection, active_site, remote_site): + """ + Get representations that should be synced, these could be + recognised by presence of document in 'files.sites', where key is + a provider (GDrive, S3) and value is empty document or document + without 'created_dt' field. (Don't put null to 'created_dt'!). + + Querying of 'to-be-synched' files is offloaded to Mongod for + better performance. Goal is to get as few representations as + possible. + Args: + collection (string): name of collection (in most cases matches + project name + active_site (string): identifier of current active site (could be + 'local_0' when working from home, 'studio' when working in the + studio (default) + remote_site (string): identifier of remote site I want to sync to + + Returns: + (list) of dictionaries + """ + log.debug("Check representations for : {}".format(collection)) + self.connection.Session["AVALON_PROJECT"] = collection + # retry_cnt - number of attempts to sync specific file before giving up + retries_arr = self._get_retries_arr(collection) + query = { + "type": "representation", + "$or": [ + {"$and": [ + { + "files.sites": { + "$elemMatch": { + "name": active_site, + "created_dt": {"$exists": True} + } + }}, { + "files.sites": { + "$elemMatch": { + "name": {"$in": [remote_site]}, + "created_dt": {"$exists": False}, + "tries": {"$in": retries_arr} + } + } + }]}, + {"$and": [ + { + "files.sites": { + "$elemMatch": { + "name": active_site, + "created_dt": {"$exists": False}, + "tries": {"$in": retries_arr} + } + }}, { + "files.sites": { + "$elemMatch": { + "name": {"$in": [remote_site]}, + "created_dt": {"$exists": True} + } + } + } + ]} + ] + } + log.debug("active_site:{} - remote_site:{}".format(active_site, + remote_site)) + log.debug("query: {}".format(query)) + representations = self.connection.find(query) + + return representations + + def check_status(self, file, local_site, remote_site, config_preset): + """ + Check synchronization status for single 'file' of single + 'representation' by single 'provider'. + (Eg. check if 'scene.ma' of lookdev.v10 should be synced to GDrive + + Always is comparing local record, eg. site with + 'name' == self.presets[PROJECT_NAME]['config']["active_site"] + + Args: + file (dictionary): of file from representation in Mongo + local_site (string): - local side of compare (usually 'studio') + remote_site (string): - gdrive etc. + config_preset (dict): config about active site, retries + Returns: + (string) - one of SyncStatus + """ + sites = file.get("sites") or [] + # if isinstance(sites, list): # temporary, old format of 'sites' + # 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") + if not created_dt: + tries = self._get_tries_count_from_rec(remote_rec) + # file will be skipped if unsuccessfully tried over threshold + # error metadata needs to be purged manually in DB to reset + if tries < int(config_preset["retry_cnt"]): + return SyncStatus.DO_UPLOAD + else: + _, local_rec = self._get_site_rec(sites, local_site) or {} + if not local_rec or not local_rec.get("created_dt"): + tries = self._get_tries_count_from_rec(local_rec) + # file will be skipped if unsuccessfully tried over + # threshold times, error metadata needs to be purged + # manually in DB to reset + if tries < int(config_preset["retry_cnt"]): + return SyncStatus.DO_DOWNLOAD + + return SyncStatus.DO_NOTHING + + def update_db(self, collection, new_file_id, file, representation, + site, error=None, progress=None): + """ + Update 'provider' portion of records in DB with success (file_id) + or error (exception) + + Args: + collection (string): name of project - force to db connection as + each file might come from different collection + new_file_id (string): + file (dictionary): info about processed file (pulled from DB) + representation (dictionary): parent repr of file (from DB) + site (string): label ('gdrive', 'S3') + error (string): exception message + progress (float): 0-1 of progress of upload/download + + Returns: + None + """ + representation_id = representation.get("_id") + file_id = file.get("_id") + query = { + "_id": representation_id + } + + update = {} + if new_file_id: + update["$set"] = self._get_success_dict(new_file_id) + # reset previous errors if any + update["$unset"] = self._get_error_dict("", "", "") + elif progress is not None: + update["$set"] = self._get_progress_dict(progress) + else: + tries = self._get_tries_count(file, site) + tries += 1 + + update["$set"] = self._get_error_dict(error, tries) + + arr_filter = [ + {'s.name': site}, + {'f._id': ObjectId(file_id)} + ] + + self.connection.database[collection].update_one( + query, + update, + upsert=True, + array_filters=arr_filter + ) + + if progress is not None: + return + + status = 'failed' + error_str = 'with error {}'.format(error) + if new_file_id: + status = 'succeeded with id {}'.format(new_file_id) + error_str = '' + + source_file = file.get("path", "") + log.debug("File for {} - {source_file} process {status} {error_str}". + format(representation_id, + status=status, + source_file=source_file, + error_str=error_str)) + + def _get_file_info(self, files, _id): + """ + Return record from list of records which name matches to 'provider' + Could be possibly refactored with '_get_provider_rec' together. + + Args: + files (list): of dictionaries with info about published files + _id (string): _id of specific file + + Returns: + (int, dictionary): index from list and record with metadata + about site (if/when created, errors..) + OR (-1, None) if not present + """ + for index, rec in enumerate(files): + if rec.get("_id") == _id: + return index, rec + + return -1, None + + def _get_site_rec(self, sites, site_name): + """ + Return record from list of records which name matches to + 'remote_site_name' + + Args: + sites (list): of dictionaries + site_name (string): 'local_XXX', 'gdrive' + + Returns: + (int, dictionary): index from list and record with metadata + about site (if/when created, errors..) + OR (-1, None) if not present + """ + for index, rec in enumerate(sites): + if rec.get("name") == site_name: + return index, rec + + return -1, None + + def reset_provider_for_file(self, collection, representation_id, + side=None, file_id=None, site_name=None, + remove=False, pause=None, force=False): + """ + Reset information about synchronization for particular 'file_id' + and provider. + Useful for testing or forcing file to be reuploaded. + + 'side' and 'site_name' are disjunctive. + + 'side' is used for resetting local or remote side for + current user for repre. + + 'site_name' is used to set synchronization for particular site. + Should be used when repre should be synced to new site. + + Args: + collection (string): name of project (eg. collection) in DB + representation_id(string): _id of representation + file_id (string): file _id in representation + side (string): local or remote side + site_name (string): for adding new site + remove (bool): if True remove site altogether + pause (bool or None): if True - pause, False - unpause + force (bool): hard reset - currently only for add_site + + Returns: + throws ValueError + """ + query = { + "_id": ObjectId(representation_id) + } + + representation = list(self.connection.database[collection].find(query)) + if not representation: + raise ValueError("Representation {} not found in {}". + format(representation_id, collection)) + if side and site_name: + raise ValueError("Misconfiguration, only one of side and " + + "site_name arguments should be passed.") + + local_site = self.get_active_site(collection) + remote_site = self.get_remote_site(collection) + + if side: + if side == 'local': + site_name = local_site + else: + site_name = remote_site + + elem = {"name": site_name} + + if file_id: # reset site for particular file + self._reset_site_for_file(collection, query, + elem, file_id, site_name) + elif side: # reset site for whole representation + self._reset_site(collection, query, elem, site_name) + elif remove: # remove site for whole representation + self._remove_site(collection, query, representation, site_name) + elif pause is not None: + self._pause_unpause_site(collection, query, + representation, site_name, pause) + else: # add new site to all files for representation + self._add_site(collection, query, representation, elem, site_name, + force) + + def _update_site(self, collection, query, update, arr_filter): + """ + Auxiliary method to call update_one function on DB + + Used for refactoring ugly reset_provider_for_file + """ + self.connection.database[collection].update_one( + query, + update, + upsert=True, + array_filters=arr_filter + ) + + def _reset_site_for_file(self, collection, query, + elem, file_id, site_name): + """ + Resets 'site_name' for 'file_id' on representation in 'query' on + 'collection' + """ + update = { + "$set": {"files.$[f].sites.$[s]": elem} + } + arr_filter = [ + {'s.name': site_name}, + {'f._id': ObjectId(file_id)} + ] + + self._update_site(collection, query, update, arr_filter) + + def _reset_site(self, collection, query, elem, site_name): + """ + Resets 'site_name' for all files of representation in 'query' + """ + update = { + "$set": {"files.$[].sites.$[s]": elem} + } + + arr_filter = [ + {'s.name': site_name} + ] + + self._update_site(collection, query, update, arr_filter) + + def _remove_site(self, collection, query, representation, site_name): + """ + Removes 'site_name' for 'representation' in 'query' + + Throws ValueError if 'site_name' not found on 'representation' + """ + found = False + for repre_file in representation.pop().get("files"): + for site in repre_file.get("sites"): + if site["name"] == site_name: + found = True + break + if not found: + msg = "Site {} not found".format(site_name) + log.info(msg) + raise ValueError(msg) + + update = { + "$pull": {"files.$[].sites": {"name": site_name}} + } + arr_filter = [] + + self._update_site(collection, query, update, arr_filter) + + def _pause_unpause_site(self, collection, query, + representation, site_name, pause): + """ + Pauses/unpauses all files for 'representation' based on 'pause' + + Throws ValueError if 'site_name' not found on 'representation' + """ + found = False + site = None + for repre_file in representation.pop().get("files"): + for site in repre_file.get("sites"): + if site["name"] == site_name: + found = True + break + if not found: + msg = "Site {} not found".format(site_name) + log.info(msg) + raise ValueError(msg) + + if pause: + site['paused'] = pause + else: + if site.get('paused'): + site.pop('paused') + + update = { + "$set": {"files.$[].sites.$[s]": site} + } + + arr_filter = [ + {'s.name': site_name} + ] + + self._update_site(collection, query, update, arr_filter) + + def _add_site(self, collection, query, representation, elem, site_name, + force=False): + """ + Adds 'site_name' to 'representation' on 'collection' + + Use 'force' to remove existing or raises ValueError + """ + for repre_file in representation.pop().get("files"): + for site in repre_file.get("sites"): + if site["name"] == site_name: + if force: + self._reset_site_for_file(collection, query, + elem, repre_file["_id"], + site_name) + return + else: + msg = "Site {} already present".format(site_name) + log.info(msg) + raise ValueError(msg) + + update = { + "$push": {"files.$[].sites": elem} + } + + arr_filter = [] + + self._update_site(collection, query, update, arr_filter) + + def _remove_local_file(self, collection, representation_id, site_name): + """ + Removes all local files for 'site_name' of 'representation_id' + + Args: + collection (string): project name (must match DB) + representation_id (string): MongoDB _id value + site_name (string): name of configured and active site + + Returns: + only logs, catches IndexError and OSError + """ + my_local_site = get_local_site_id() + if my_local_site != site_name: + self.log.warning("Cannot remove non local file for {}". + format(site_name)) + return + + provider_name = self.get_provider_for_site(collection, site_name) + + if provider_name == 'local_drive': + handler = LocalDriveHandler(collection, site_name) + query = { + "_id": ObjectId(representation_id) + } + + representation = list( + self.connection.database[collection].find(query)) + if not representation: + self.log.debug("No repre {} found".format( + representation_id)) + return + + representation = representation.pop() + local_file_path = '' + for file in representation.get("files"): + local_file_path = self.get_local_file_path(collection, + site_name, + file.get("path", "") + ) + try: + self.log.debug("Removing {}".format(local_file_path)) + os.remove(local_file_path) + except IndexError: + msg = "No file set for {}".format(representation_id) + self.log.debug(msg) + raise ValueError(msg) + except OSError: + msg = "File {} cannot be removed".format(file["path"]) + self.log.warning(msg) + raise ValueError(msg) + + folder = None + try: + folder = os.path.dirname(local_file_path) + os.rmdir(folder) + except OSError: + msg = "folder {} cannot be removed".format(folder) + self.log.warning(msg) + raise ValueError(msg) + + def get_loop_delay(self, project_name): + """ + Return count of seconds before next synchronization loop starts + after finish of previous loop. + Returns: + (int): in seconds + """ + ld = self.sync_project_settings[project_name]["config"]["loop_delay"] + return int(ld) + + def show_widget(self): + """Show dialog to enter credentials""" + self.widget.show() + + def _get_success_dict(self, new_file_id): + """ + Provide success metadata ("id", "created_dt") to be stored in Db. + Used in $set: "DICT" part of query. + Sites are array inside of array(file), so real indexes for both + file and site are needed for upgrade in DB. + Args: + new_file_id: id of created file + Returns: + (dictionary) + """ + val = {"files.$[f].sites.$[s].id": new_file_id, + "files.$[f].sites.$[s].created_dt": datetime.now()} + return val + + def _get_error_dict(self, error="", tries="", progress=""): + """ + Provide error metadata to be stored in Db. + Used for set (error and tries provided) or unset mode. + Args: + error: (string) - message + tries: how many times failed + Returns: + (dictionary) + """ + val = {"files.$[f].sites.$[s].last_failed_dt": datetime.now(), + "files.$[f].sites.$[s].error": error, + "files.$[f].sites.$[s].tries": tries, + "files.$[f].sites.$[s].progress": progress + } + return val + + def _get_tries_count_from_rec(self, rec): + """ + Get number of failed attempts to sync from site record + Args: + rec (dictionary): info about specific site record + Returns: + (int) - number of failed attempts + """ + if not rec: + return 0 + return rec.get("tries", 0) + + def _get_tries_count(self, file, provider): + """ + Get number of failed attempts to sync + Args: + file (dictionary): info about specific file + provider (string): name of site ('gdrive' or specific user site) + Returns: + (int) - number of failed attempts + """ + _, rec = self._get_site_rec(file.get("sites", []), provider) + return rec.get("tries", 0) + + def _get_progress_dict(self, progress): + """ + Provide progress metadata to be stored in Db. + Used during upload/download for GUI to show. + Args: + progress: (float) - 0-1 progress of upload/download + Returns: + (dictionary) + """ + val = {"files.$[f].sites.$[s].progress": progress} + return val + + def _get_retries_arr(self, project_name): + """ + Returns array with allowed values in 'tries' field. If repre + contains these values, it means it was tried to be synchronized + but failed. We try up to 'self.presets["retry_cnt"]' times before + giving up and skipping representation. + Returns: + (list) + """ + retry_cnt = self.sync_project_settings[project_name].\ + get("config")["retry_cnt"] + arr = [i for i in range(int(retry_cnt))] + arr.append(None) + + return arr + + def _get_roots_config(self, presets, project_name, site_name): + """ + Returns configured root(s) for 'project_name' and 'site_name' from + settings ('presets') + """ + return presets[project_name]['sites'][site_name]['root'] diff --git a/openpype/modules/sync_server/tray/app.py b/openpype/modules/sync_server/tray/app.py index 476e9d16e8..41a0f84afb 100644 --- a/openpype/modules/sync_server/tray/app.py +++ b/openpype/modules/sync_server/tray/app.py @@ -159,7 +159,7 @@ class SyncProjectListWidget(ProjectListWidget): model.clear() project_name = None - for project_name in self.sync_server.get_sync_project_settings().\ + for project_name in self.sync_server.sync_project_settings.\ keys(): if self.sync_server.is_paused() or \ self.sync_server.is_project_paused(project_name): @@ -169,7 +169,7 @@ class SyncProjectListWidget(ProjectListWidget): model.appendRow(QtGui.QStandardItem(icon, project_name)) - if len(self.sync_server.get_sync_project_settings().keys()) == 0: + if len(self.sync_server.sync_project_settings.keys()) == 0: model.appendRow(QtGui.QStandardItem(DUMMY_PROJECT)) self.current_project = self.project_list.currentIndex().data( @@ -271,15 +271,29 @@ class SyncRepresentationWidget(QtWidgets.QWidget): ("subset", 190), ("version", 10), ("representation", 90), - ("created_dt", 100), - ("sync_dt", 100), - ("local_site", 60), - ("remote_site", 70), - ("files_count", 70), - ("files_size", 70), + ("created_dt", 105), + ("sync_dt", 105), + ("local_site", 80), + ("remote_site", 80), + ("files_count", 50), + ("files_size", 60), ("priority", 20), ("state", 50) ) + column_labels = ( + ("asset", "Asset"), + ("subset", "Subset"), + ("version", "Version"), + ("representation", "Representation"), + ("created_dt", "Created"), + ("sync_dt", "Synced"), + ("local_site", "Active site"), + ("remote_site", "Remote site"), + ("files_count", "Files"), + ("files_size", "Size"), + ("priority", "Priority"), + ("state", "Status") + ) def __init__(self, sync_server, project=None, parent=None): super(SyncRepresentationWidget, self).__init__(parent) @@ -298,8 +312,10 @@ class SyncRepresentationWidget(QtWidgets.QWidget): self.table_view = QtWidgets.QTableView() headers = [item[0] for item in self.default_widths] + header_labels = [item[1] for item in self.column_labels] - model = SyncRepresentationModel(sync_server, headers, project) + model = SyncRepresentationModel(sync_server, headers, + project, header_labels) self.table_view.setModel(model) self.table_view.setContextMenuPolicy(QtCore.Qt.CustomContextMenu) self.table_view.setSelectionMode( @@ -376,7 +392,7 @@ class SyncRepresentationWidget(QtWidgets.QWidget): """ _id = self.table_view.model().data(index, Qt.UserRole) detail_window = SyncServerDetailWindow( - self.sync_server, _id, self.table_view.model()._project) + self.sync_server, _id, self.table_view.model().project) detail_window.exec() def _on_context_menu(self, point): @@ -394,15 +410,28 @@ class SyncRepresentationWidget(QtWidgets.QWidget): menu = QtWidgets.QMenu() actions_mapping = {} + actions_kwargs_mapping = {} - action = QtWidgets.QAction("Open in explorer") - actions_mapping[action] = self._open_in_explorer - menu.addAction(action) + local_site = self.item.local_site + local_progress = self.item.local_progress + remote_site = self.item.remote_site + remote_progress = self.item.remote_progress - local_site, local_progress = self.item.local_site.split() - remote_site, remote_progress = self.item.remote_site.split() - local_progress = float(local_progress) - remote_progress = float(remote_progress) + for site, progress in {local_site: local_progress, + remote_site: remote_progress}.items(): + project = self.table_view.model().project + provider = self.sync_server.get_provider_for_site(project, + site) + if provider == 'local_drive': + if 'studio' in site: + txt = " studio version" + else: + txt = " local version" + action = QtWidgets.QAction("Open in explorer" + txt) + if progress == 1.0: + actions_mapping[action] = self._open_in_explorer + actions_kwargs_mapping[action] = {'site': site} + menu.addAction(action) # progress smaller then 1.0 --> in progress or queued if local_progress < 1.0: @@ -452,13 +481,14 @@ class SyncRepresentationWidget(QtWidgets.QWidget): result = menu.exec_(QtGui.QCursor.pos()) if result: to_run = actions_mapping[result] + to_run_kwargs = actions_kwargs_mapping.get(result, {}) if to_run: - to_run() + to_run(**to_run_kwargs) self.table_view.model().refresh() def _pause(self): - self.sync_server.pause_representation(self.table_view.model()._project, + self.sync_server.pause_representation(self.table_view.model().project, self.representation_id, self.site_name) self.site_name = None @@ -466,7 +496,7 @@ class SyncRepresentationWidget(QtWidgets.QWidget): def _unpause(self): self.sync_server.unpause_representation( - self.table_view.model()._project, + self.table_view.model().project, self.representation_id, self.site_name) self.site_name = None @@ -476,7 +506,7 @@ class SyncRepresentationWidget(QtWidgets.QWidget): # temporary here for testing, will be removed TODO def _add_site(self): log.info(self.representation_id) - project_name = self.table_view.model()._project + project_name = self.table_view.model().project local_site_name = self.sync_server.get_my_local_site() try: self.sync_server.add_site( @@ -504,7 +534,7 @@ class SyncRepresentationWidget(QtWidgets.QWidget): try: local_site = get_local_site_id() self.sync_server.remove_site( - self.table_view.model()._project, + self.table_view.model().project, self.representation_id, local_site, True @@ -519,7 +549,7 @@ class SyncRepresentationWidget(QtWidgets.QWidget): redo of upload/download """ self.sync_server.reset_provider_for_file( - self.table_view.model()._project, + self.table_view.model().project, self.representation_id, 'local' ) @@ -530,18 +560,20 @@ class SyncRepresentationWidget(QtWidgets.QWidget): redo of upload/download """ self.sync_server.reset_provider_for_file( - self.table_view.model()._project, + self.table_view.model().project, self.representation_id, 'remote' ) - def _open_in_explorer(self): + def _open_in_explorer(self, site): if not self.item: return fpath = self.item.path - project = self.table_view.model()._project - fpath = self.sync_server.get_local_file_path(project, fpath) + project = self.table_view.model().project + fpath = self.sync_server.get_local_file_path(project, + site, + fpath) fpath = os.path.normpath(os.path.dirname(fpath)) if os.path.isdir(fpath): @@ -556,6 +588,10 @@ class SyncRepresentationWidget(QtWidgets.QWidget): raise OSError('unsupported xdg-open call??') +ProviderRole = QtCore.Qt.UserRole + 2 +ProgressRole = QtCore.Qt.UserRole + 4 + + class SyncRepresentationModel(QtCore.QAbstractTableModel): """ Model for summary of representations. @@ -612,15 +648,20 @@ class SyncRepresentationModel(QtCore.QAbstractTableModel): sync_dt = attr.ib(default=None) local_site = attr.ib(default=None) remote_site = attr.ib(default=None) + local_provider = attr.ib(default=None) + remote_provider = attr.ib(default=None) + local_progress = attr.ib(default=None) + remote_progress = attr.ib(default=None) files_count = attr.ib(default=None) files_size = attr.ib(default=None) priority = attr.ib(default=None) state = attr.ib(default=None) path = attr.ib(default=None) - def __init__(self, sync_server, header, project=None): + def __init__(self, sync_server, header, project=None, header_labels=None): super(SyncRepresentationModel, self).__init__() self._header = header + self._header_labels = header_labels self._data = [] self._project = project self._rec_loaded = 0 @@ -634,8 +675,8 @@ class SyncRepresentationModel(QtCore.QAbstractTableModel): self.sync_server = sync_server # TODO think about admin mode # this is for regular user, always only single local and single remote - self.local_site = self.sync_server.get_active_site(self._project) - self.remote_site = self.sync_server.get_remote_site(self._project) + self.local_site = self.sync_server.get_active_site(self.project) + self.remote_site = self.sync_server.get_remote_site(self.project) self.projection = self.get_default_projection() @@ -659,26 +700,46 @@ class SyncRepresentationModel(QtCore.QAbstractTableModel): All queries should go through this (because of collection). """ - return self.sync_server.connection.database[self._project] + return self.sync_server.connection.database[self.project] + + @property + def project(self): + """Returns project""" + return self._project def data(self, index, role): item = self._data[index.row()] + if role == ProviderRole: + if self._header[index.column()] == 'local_site': + return item.local_provider + if self._header[index.column()] == 'remote_site': + return item.remote_provider + + if role == ProgressRole: + if self._header[index.column()] == 'local_site': + return item.local_progress + if self._header[index.column()] == 'remote_site': + return item.remote_progress + if role == Qt.DisplayRole: return attr.asdict(item)[self._header[index.column()]] if role == Qt.UserRole: return item._id - def rowCount(self, index): + def rowCount(self, _index): return len(self._data) - def columnCount(self, index): + def columnCount(self, _index): return len(self._header) def headerData(self, section, orientation, role): if role == Qt.DisplayRole: if orientation == Qt.Horizontal: - return str(self._header[section]) + if self._header_labels: + return str(self._header_labels[section]) + else: + return str(self._header[section]) def tick(self): """ @@ -718,7 +779,7 @@ class SyncRepresentationModel(QtCore.QAbstractTableModel): than single page of records) """ if self.sync_server.is_paused() or \ - self.sync_server.is_project_paused(self._project): + self.sync_server.is_project_paused(self.project): return self.beginResetModel() @@ -751,10 +812,10 @@ class SyncRepresentationModel(QtCore.QAbstractTableModel): self._total_records = count local_provider = _translate_provider_for_icon(self.sync_server, - self._project, + self.project, local_site) remote_provider = _translate_provider_for_icon(self.sync_server, - self._project, + self.project, remote_site) for repre in result.get("paginatedResults"): @@ -784,7 +845,7 @@ class SyncRepresentationModel(QtCore.QAbstractTableModel): if context.get("version"): version = "v{:0>3d}".format(context.get("version")) else: - version = "hero" + version = "master" item = self.SyncRepresentation( repre.get("_id"), @@ -794,8 +855,12 @@ class SyncRepresentationModel(QtCore.QAbstractTableModel): context.get("representation"), local_updated, remote_updated, - '{} {}'.format(local_provider, avg_progress_local), - '{} {}'.format(remote_provider, avg_progress_remote), + local_site, + remote_site, + local_provider, + remote_provider, + avg_progress_local, + avg_progress_remote, repre.get("files_count", 1), repre.get("files_size", 0), 1, @@ -806,7 +871,7 @@ class SyncRepresentationModel(QtCore.QAbstractTableModel): self._data.append(item) self._rec_loaded += 1 - def canFetchMore(self, index): + def canFetchMore(self, _index): """ Check if there are more records than currently loaded """ @@ -858,7 +923,8 @@ class SyncRepresentationModel(QtCore.QAbstractTableModel): self.sort = {self.SORT_BY_COLUMN[index]: order, '_id': 1} self.query = self.get_default_query() # import json - # log.debug(json.dumps(self.query, indent=4).replace('False', 'false').\ + # log.debug(json.dumps(self.query, indent=4).\ + # replace('False', 'false').\ # replace('True', 'true').replace('None', 'null')) representations = self.dbcon.aggregate(self.query) @@ -883,8 +949,8 @@ class SyncRepresentationModel(QtCore.QAbstractTableModel): """ self._project = project self.sync_server.set_sync_project_settings() - self.local_site = self.sync_server.get_active_site(self._project) - self.remote_site = self.sync_server.get_remote_site(self._project) + self.local_site = self.sync_server.get_active_site(self.project) + self.remote_site = self.sync_server.get_remote_site(self.project) self.refresh() def get_index(self, id): @@ -1206,15 +1272,26 @@ class SyncRepresentationDetailWidget(QtWidgets.QWidget): default_widths = ( ("file", 290), - ("created_dt", 120), - ("sync_dt", 120), - ("local_site", 60), - ("remote_site", 60), + ("created_dt", 105), + ("sync_dt", 105), + ("local_site", 80), + ("remote_site", 80), ("size", 60), ("priority", 20), ("state", 90) ) + column_labels = ( + ("file", "File name"), + ("created_dt", "Created"), + ("sync_dt", "Synced"), + ("local_site", "Active site"), + ("remote_site", "Remote site"), + ("files_size", "Size"), + ("priority", "Priority"), + ("state", "Status") + ) + def __init__(self, sync_server, _id=None, project=None, parent=None): super(SyncRepresentationDetailWidget, self).__init__(parent) @@ -1235,9 +1312,10 @@ class SyncRepresentationDetailWidget(QtWidgets.QWidget): self.table_view = QtWidgets.QTableView() headers = [item[0] for item in self.default_widths] + header_labels = [item[1] for item in self.column_labels] model = SyncRepresentationDetailModel(sync_server, headers, _id, - project) + project, header_labels) self.table_view.setModel(model) self.table_view.setContextMenuPolicy(QtCore.Qt.CustomContextMenu) self.table_view.setSelectionMode( @@ -1330,23 +1408,39 @@ class SyncRepresentationDetailWidget(QtWidgets.QWidget): menu = QtWidgets.QMenu() actions_mapping = {} + actions_kwargs_mapping = {} - action = QtWidgets.QAction("Open in explorer") - actions_mapping[action] = self._open_in_explorer - menu.addAction(action) + local_site = self.item.local_site + local_progress = self.item.local_progress + remote_site = self.item.remote_site + remote_progress = self.item.remote_progress + + for site, progress in {local_site: local_progress, + remote_site: remote_progress}.items(): + project = self.table_view.model().project + provider = self.sync_server.get_provider_for_site(project, + site) + if provider == 'local_drive': + if 'studio' in site: + txt = " studio version" + else: + txt = " local version" + action = QtWidgets.QAction("Open in explorer" + txt) + if progress == 1: + actions_mapping[action] = self._open_in_explorer + actions_kwargs_mapping[action] = {'site': site} + menu.addAction(action) if self.item.state == STATUS[1]: action = QtWidgets.QAction("Open error detail") actions_mapping[action] = self._show_detail menu.addAction(action) - remote_site, remote_progress = self.item.remote_site.split() if float(remote_progress) == 1.0: action = QtWidgets.QAction("Reset local site") actions_mapping[action] = self._reset_local_site menu.addAction(action) - local_site, local_progress = self.item.local_site.split() if float(local_progress) == 1.0: action = QtWidgets.QAction("Reset remote site") actions_mapping[action] = self._reset_remote_site @@ -1360,8 +1454,9 @@ class SyncRepresentationDetailWidget(QtWidgets.QWidget): result = menu.exec_(QtGui.QCursor.pos()) if result: to_run = actions_mapping[result] + to_run_kwargs = actions_kwargs_mapping.get(result, {}) if to_run: - to_run() + to_run(**to_run_kwargs) def _reset_local_site(self): """ @@ -1369,7 +1464,7 @@ class SyncRepresentationDetailWidget(QtWidgets.QWidget): redo of upload/download """ self.sync_server.reset_provider_for_file( - self.table_view.model()._project, + self.table_view.model().project, self.representation_id, 'local', self.item._id) @@ -1381,19 +1476,19 @@ class SyncRepresentationDetailWidget(QtWidgets.QWidget): redo of upload/download """ self.sync_server.reset_provider_for_file( - self.table_view.model()._project, + self.table_view.model().project, self.representation_id, 'remote', self.item._id) self.table_view.model().refresh() - def _open_in_explorer(self): + def _open_in_explorer(self, site): if not self.item: return fpath = self.item.path - project = self.table_view.model()._project - fpath = self.sync_server.get_local_file_path(project, fpath) + project = self.project + fpath = self.sync_server.get_local_file_path(project, site, fpath) fpath = os.path.normpath(os.path.dirname(fpath)) if os.path.isdir(fpath): @@ -1415,6 +1510,8 @@ class SyncRepresentationDetailModel(QtCore.QAbstractTableModel): Used in detail window accessible after clicking on single repre in the summary. + TODO refactor - merge with SyncRepresentationModel if possible + Args: sync_server (SyncServer) - object to call server operations (update db status, set site status...) @@ -1424,7 +1521,6 @@ class SyncRepresentationDetailModel(QtCore.QAbstractTableModel): a specific collection """ PAGE_SIZE = 30 - # TODO add filter filename DEFAULT_SORT = { "files.path": 1 } @@ -1452,6 +1548,10 @@ class SyncRepresentationDetailModel(QtCore.QAbstractTableModel): sync_dt = attr.ib(default=None) local_site = attr.ib(default=None) remote_site = attr.ib(default=None) + local_provider = attr.ib(default=None) + remote_provider = attr.ib(default=None) + local_progress = attr.ib(default=None) + remote_progress = attr.ib(default=None) size = attr.ib(default=None) priority = attr.ib(default=None) state = attr.ib(default=None) @@ -1459,9 +1559,11 @@ class SyncRepresentationDetailModel(QtCore.QAbstractTableModel): error = attr.ib(default=None) path = attr.ib(default=None) - def __init__(self, sync_server, header, _id, project=None): + def __init__(self, sync_server, header, _id, + project=None, header_labels=None): super(SyncRepresentationDetailModel, self).__init__() self._header = header + self._header_labels = header_labels self._data = [] self._project = project self._rec_loaded = 0 @@ -1473,8 +1575,8 @@ class SyncRepresentationDetailModel(QtCore.QAbstractTableModel): self.sync_server = sync_server # TODO think about admin mode # this is for regular user, always only single local and single remote - self.local_site = self.sync_server.get_active_site(self._project) - self.remote_site = self.sync_server.get_remote_site(self._project) + self.local_site = self.sync_server.get_active_site(self.project) + self.remote_site = self.sync_server.get_remote_site(self.project) self.sort = self.DEFAULT_SORT @@ -1491,9 +1593,26 @@ class SyncRepresentationDetailModel(QtCore.QAbstractTableModel): @property def dbcon(self): - return self.sync_server.connection.database[self._project] + """ + Database object with preselected project (collection) to run DB + operations (find, aggregate). + + All queries should go through this (because of collection). + """ + return self.sync_server.connection.database[self.project] + + @property + def project(self): + """Returns project""" + return self.project def tick(self): + """ + Triggers refresh of model. + + Because of pagination, prepared (sorting, filtering) query needs + to be run on DB every X seconds. + """ self.refresh(representations=None, load_records=self._rec_loaded) self.timer.start(SyncRepresentationModel.REFRESH_SEC) @@ -1510,21 +1629,37 @@ class SyncRepresentationDetailModel(QtCore.QAbstractTableModel): def data(self, index, role): item = self._data[index.row()] + + if role == ProviderRole: + if self._header[index.column()] == 'local_site': + return item.local_provider + if self._header[index.column()] == 'remote_site': + return item.remote_provider + + if role == ProgressRole: + if self._header[index.column()] == 'local_site': + return item.local_progress + if self._header[index.column()] == 'remote_site': + return item.remote_progress + if role == Qt.DisplayRole: return attr.asdict(item)[self._header[index.column()]] if role == Qt.UserRole: return item._id - def rowCount(self, index): + def rowCount(self, _index): return len(self._data) - def columnCount(self, index): + def columnCount(self, _index): return len(self._header) def headerData(self, section, orientation, role): if role == Qt.DisplayRole: if orientation == Qt.Horizontal: - return str(self._header[section]) + if self._header_labels: + return str(self._header_labels[section]) + else: + return str(self._header[section]) def refresh(self, representations=None, load_records=0): if self.sync_server.is_paused(): @@ -1561,10 +1696,10 @@ class SyncRepresentationDetailModel(QtCore.QAbstractTableModel): self._total_records = count local_provider = _translate_provider_for_icon(self.sync_server, - self._project, + self.project, local_site) remote_provider = _translate_provider_for_icon(self.sync_server, - self._project, + self.project, remote_site) for repre in result.get("paginatedResults"): @@ -1585,9 +1720,9 @@ class SyncRepresentationDetailModel(QtCore.QAbstractTableModel): repre.get('updated_dt_remote').strftime( "%Y%m%dT%H%M%SZ") - progress_remote = _convert_progress( + remote_progress = _convert_progress( repre.get('progress_remote', '0')) - progress_local = _convert_progress( + local_progress = _convert_progress( repre.get('progress_local', '0')) errors = [] @@ -1601,8 +1736,12 @@ class SyncRepresentationDetailModel(QtCore.QAbstractTableModel): os.path.basename(file["path"]), local_updated, remote_updated, - '{} {}'.format(local_provider, progress_local), - '{} {}'.format(remote_provider, progress_remote), + local_site, + remote_site, + local_provider, + remote_provider, + local_progress, + remote_progress, file.get('size', 0), 1, STATUS[repre.get("status", -1)], @@ -1614,7 +1753,7 @@ class SyncRepresentationDetailModel(QtCore.QAbstractTableModel): self._data.append(item) self._rec_loaded += 1 - def canFetchMore(self, index): + def canFetchMore(self, _index): """ Check if there are more records than currently loaded """ @@ -1918,11 +2057,8 @@ class ImageDelegate(QtWidgets.QStyledItemDelegate): option.palette.highlight()) painter.setOpacity(1) - d = index.data(QtCore.Qt.DisplayRole) - if d: - provider, value = d.split() - else: - return + provider = index.data(ProviderRole) + value = index.data(ProgressRole) if not self.icons.get(provider): resource_path = os.path.dirname(__file__) @@ -2008,7 +2144,7 @@ class SizeDelegate(QtWidgets.QStyledItemDelegate): def __init__(self, parent=None): super(SizeDelegate, self).__init__(parent) - def displayText(self, value, locale): + def displayText(self, value, _locale): if value is None: # Ignore None value return diff --git a/openpype/modules/sync_server/utils.py b/openpype/modules/sync_server/utils.py index 0762766783..36f3444399 100644 --- a/openpype/modules/sync_server/utils.py +++ b/openpype/modules/sync_server/utils.py @@ -1,8 +1,14 @@ import time -from openpype.api import Logger +from openpype.api import Logger log = Logger().get_logger("SyncServer") +class SyncStatus: + DO_NOTHING = 0 + DO_UPLOAD = 1 + DO_DOWNLOAD = 2 + + def time_function(method): """ Decorator to print how much time function took. For debugging. diff --git a/openpype/plugins/load/add_site.py b/openpype/plugins/load/add_site.py new file mode 100644 index 0000000000..09448d553c --- /dev/null +++ b/openpype/plugins/load/add_site.py @@ -0,0 +1,33 @@ +from avalon import api +from openpype.modules import ModulesManager + + +class AddSyncSite(api.Loader): + """Add sync site to representation""" + representations = ["*"] + families = ["*"] + + label = "Add Sync Site" + order = 2 # lower means better + icon = "download" + color = "#999999" + + def load(self, context, name=None, namespace=None, data=None): + self.log.info("Adding {} to representation: {}".format( + data["site_name"], data["_id"])) + self.add_site_to_representation(data["project_name"], + data["_id"], + data["site_name"]) + self.log.debug("Site added.") + + @staticmethod + def add_site_to_representation(project_name, representation_id, site_name): + """Adds new site to representation_id, resets if exists""" + manager = ModulesManager() + sync_server = manager.modules_by_name["sync_server"] + sync_server.add_site(project_name, representation_id, site_name, + force=True) + + def filepath_from_context(self, context): + """No real file loading""" + return "" diff --git a/openpype/plugins/load/delete_old_versions.py b/openpype/plugins/load/delete_old_versions.py index e5132e0f8a..8e3999e9c4 100644 --- a/openpype/plugins/load/delete_old_versions.py +++ b/openpype/plugins/load/delete_old_versions.py @@ -15,11 +15,12 @@ from openpype.api import Anatomy class DeleteOldVersions(api.Loader): - + """Deletes specific number of old version""" representations = ["*"] families = ["*"] label = "Delete Old Versions" + order = 35 icon = "trash" color = "#d8d8d8" @@ -421,8 +422,9 @@ class DeleteOldVersions(api.Loader): class CalculateOldVersions(DeleteOldVersions): - + """Calculate file size of old versions""" label = "Calculate Old Versions" + order = 30 options = [ qargparse.Integer( diff --git a/openpype/plugins/load/remove_site.py b/openpype/plugins/load/remove_site.py new file mode 100644 index 0000000000..aedb5d1f2f --- /dev/null +++ b/openpype/plugins/load/remove_site.py @@ -0,0 +1,33 @@ +from avalon import api +from openpype.modules import ModulesManager + + +class RemoveSyncSite(api.Loader): + """Remove sync site and its files on representation""" + representations = ["*"] + families = ["*"] + + label = "Remove Sync Site" + order = 4 + icon = "download" + color = "#999999" + + def load(self, context, name=None, namespace=None, data=None): + self.log.info("Removing {} on representation: {}".format( + data["site_name"], data["_id"])) + self.remove_site_on_representation(data["project_name"], + data["_id"], + data["site_name"]) + self.log.debug("Site added.") + + @staticmethod + def remove_site_on_representation(project_name, representation_id, + site_name): + manager = ModulesManager() + sync_server = manager.modules_by_name["sync_server"] + sync_server.remove_site(project_name, representation_id, + site_name, True) + + def filepath_from_context(self, context): + """No real file loading""" + return "" From 79e5a55719e1eabf15bedd29968429af63638941 Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Tue, 6 Apr 2021 12:41:04 +0200 Subject: [PATCH 007/148] Merge after rebranding --- repos/avalon-core | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/repos/avalon-core b/repos/avalon-core index bbba8765c4..c3dc49184a 160000 --- a/repos/avalon-core +++ b/repos/avalon-core @@ -1 +1 @@ -Subproject commit bbba8765c431ee124590e4f12d2e56db4d62eacd +Subproject commit c3dc49184ab14e2590a51dde55695cf27ab23510 From e3ef24098522b2c56953e558ea85b4c41df7d834 Mon Sep 17 00:00:00 2001 From: Ondrej Samohel Date: Tue, 6 Apr 2021 18:59:06 +0200 Subject: [PATCH 008/148] rebrand to OpenPype --- .../maya/plugins/publish/collect_look.py | 108 ++++++++++---- .../maya/plugins/publish/extract_look.py | 140 +++++++++++++----- 2 files changed, 185 insertions(+), 63 deletions(-) diff --git a/openpype/hosts/maya/plugins/publish/collect_look.py b/openpype/hosts/maya/plugins/publish/collect_look.py index acc6d8f128..c51b00c523 100644 --- a/openpype/hosts/maya/plugins/publish/collect_look.py +++ b/openpype/hosts/maya/plugins/publish/collect_look.py @@ -1,3 +1,5 @@ +# -*- coding: utf-8 -*- +"""Maya look collector.""" import re import os import glob @@ -16,6 +18,11 @@ SHAPE_ATTRS = ["castsShadows", "doubleSided", "opposite"] +RENDERER_NODE_TYPES = [ + # redshift + "RedshiftMeshParameters" +] + SHAPE_ATTRS = set(SHAPE_ATTRS) @@ -219,7 +226,6 @@ class CollectLook(pyblish.api.InstancePlugin): with lib.renderlayer(instance.data["renderlayer"]): self.collect(instance) - def collect(self, instance): self.log.info("Looking for look associations " @@ -228,6 +234,7 @@ class CollectLook(pyblish.api.InstancePlugin): # Discover related object sets self.log.info("Gathering sets..") sets = self.collect_sets(instance) + render_nodes = [] # Lookup set (optimization) instance_lookup = set(cmds.ls(instance, long=True)) @@ -235,48 +242,91 @@ class CollectLook(pyblish.api.InstancePlugin): self.log.info("Gathering set relations..") # Ensure iteration happen in a list so we can remove keys from the # dict within the loop - for objset in list(sets): - self.log.debug("From %s.." % objset) + + # skipped types of attribute on render specific nodes + disabled_types = ["message", "TdataCompound"] + + for obj_set in list(sets): + self.log.debug("From {}".format(obj_set)) + + # if node is specified as renderer node type, it will be + # serialized with its attributes. + if cmds.nodeType(obj_set) in RENDERER_NODE_TYPES: + self.log.info("- {} is {}".format( + obj_set, cmds.nodeType(obj_set))) + + node_attrs = [] + + # serialize its attributes so they can be recreated on look + # load. + for attr in cmds.listAttr(obj_set): + # skip publishedNodeInfo attributes as they break + # getAttr() and we don't need them anyway + if attr.startswith("publishedNodeInfo"): + continue + + # skip attributes types defined in 'disabled_type' list + if cmds.getAttr("{}.{}".format(obj_set, attr), type=True) in disabled_types: # noqa + continue + + # self.log.debug("{}: {}".format(attr, cmds.getAttr("{}.{}".format(obj_set, attr), type=True))) # noqa + node_attrs.append(( + attr, + cmds.getAttr("{}.{}".format(obj_set, attr)) + )) + + render_nodes.append( + { + "name": obj_set, + "type": cmds.nodeType(obj_set), + "members": cmds.ls(cmds.sets( + obj_set, query=True), long=True), + "attributes": node_attrs + } + ) # Get all nodes of the current objectSet (shadingEngine) - for member in cmds.ls(cmds.sets(objset, query=True), long=True): + for member in cmds.ls(cmds.sets(obj_set, query=True), long=True): member_data = self.collect_member_data(member, instance_lookup) if not member_data: continue # Add information of the node to the members list - sets[objset]["members"].append(member_data) + sets[obj_set]["members"].append(member_data) # Remove sets that didn't have any members assigned in the end # Thus the data will be limited to only what we need. - self.log.info("objset {}".format(sets[objset])) - if not sets[objset]["members"] or (not objset.endswith("SG")): - self.log.info("Removing redundant set information: " - "%s" % objset) - sets.pop(objset, None) + self.log.info("obj_set {}".format(sets[obj_set])) + if not sets[obj_set]["members"] or (not obj_set.endswith("SG")): + self.log.info( + "Removing redundant set information: {}".format(obj_set)) + sets.pop(obj_set, None) self.log.info("Gathering attribute changes to instance members..") attributes = self.collect_attributes_changed(instance) # Store data on the instance - instance.data["lookData"] = {"attributes": attributes, - "relationships": sets} + instance.data["lookData"] = { + "attributes": attributes, + "relationships": sets, + "render_nodes": render_nodes + } # Collect file nodes used by shading engines (if we have any) - files = list() - looksets = sets.keys() - shaderAttrs = [ - "surfaceShader", - "volumeShader", - "displacementShader", - "aiSurfaceShader", - "aiVolumeShader"] - materials = list() + files = [] + look_sets = sets.keys() + shader_attrs = [ + "surfaceShader", + "volumeShader", + "displacementShader", + "aiSurfaceShader", + "aiVolumeShader"] + if look_sets: + materials = [] - if looksets: - for look in looksets: - for at in shaderAttrs: + for look in look_sets: + for at in shader_attrs: try: con = cmds.listConnections("{}.{}".format(look, at)) except ValueError: @@ -289,10 +339,10 @@ class CollectLook(pyblish.api.InstancePlugin): self.log.info("Found materials:\n{}".format(materials)) - self.log.info("Found the following sets:\n{}".format(looksets)) + self.log.info("Found the following sets:\n{}".format(look_sets)) # Get the entire node chain of the look sets - # history = cmds.listHistory(looksets) - history = list() + # history = cmds.listHistory(look_sets) + history = [] for material in materials: history.extend(cmds.listHistory(material)) files = cmds.ls(history, type="file", long=True) @@ -313,7 +363,7 @@ class CollectLook(pyblish.api.InstancePlugin): # Ensure unique shader sets # Add shader sets to the instance for unify ID validation - instance.extend(shader for shader in looksets if shader + instance.extend(shader for shader in look_sets if shader not in instance_lookup) self.log.info("Collected look for %s" % instance) @@ -331,7 +381,7 @@ class CollectLook(pyblish.api.InstancePlugin): dict """ - sets = dict() + sets = {} for node in instance: related_sets = lib.get_related_sets(node) if not related_sets: diff --git a/openpype/hosts/maya/plugins/publish/extract_look.py b/openpype/hosts/maya/plugins/publish/extract_look.py index 79488a372c..bdd061578e 100644 --- a/openpype/hosts/maya/plugins/publish/extract_look.py +++ b/openpype/hosts/maya/plugins/publish/extract_look.py @@ -1,13 +1,14 @@ +# -*- coding: utf-8 -*- +"""Maya look extractor.""" import os import sys import json -import copy import tempfile import contextlib import subprocess from collections import OrderedDict -from maya import cmds +from maya import cmds # noqa import pyblish.api import avalon.maya @@ -22,23 +23,38 @@ HARDLINK = 2 def find_paths_by_hash(texture_hash): - # Find the texture hash key in the dictionary and all paths that - # originate from it. + """Find the texture hash key in the dictionary. + + All paths that originate from it. + + Args: + texture_hash (str): Hash of the texture. + + Return: + str: path to texture if found. + + """ key = "data.sourceHashes.{0}".format(texture_hash) return io.distinct(key, {"type": "version"}) def maketx(source, destination, *args): - """Make .tx using maketx with some default settings. + """Make `.tx` using `maketx` with some default settings. + The settings are based on default as used in Arnold's txManager in the scene. This function requires the `maketx` executable to be on the `PATH`. + Args: source (str): Path to source file. destination (str): Writing destination path. - """ + *args: Additional arguments for `maketx`. + Returns: + str: Output of `maketx` command. + + """ cmd = [ "maketx", "-v", # verbose @@ -56,7 +72,7 @@ def maketx(source, destination, *args): cmd = " ".join(cmd) - CREATE_NO_WINDOW = 0x08000000 + CREATE_NO_WINDOW = 0x08000000 # noqa kwargs = dict(args=cmd, stderr=subprocess.STDOUT) if sys.platform == "win32": @@ -118,12 +134,58 @@ class ExtractLook(openpype.api.Extractor): hosts = ["maya"] families = ["look"] order = pyblish.api.ExtractorOrder + 0.2 + scene_type = "ma" + + @staticmethod + def get_renderer_name(): + """Get renderer name from Maya. + + Returns: + str: Renderer name. + + """ + renderer = cmds.getAttr( + "defaultRenderGlobals.currentRenderer" + ).lower() + # handle various renderman names + if renderer.startswith("renderman"): + renderer = "renderman" + return renderer + + def get_maya_scene_type(self, instance): + """Get Maya scene type from settings. + + Args: + instance (pyblish.api.Instance): Instance with collected + project settings. + + """ + ext_mapping = ( + instance.context.data["project_settings"]["maya"]["ext_mapping"] + ) + if ext_mapping: + self.log.info("Looking in settings for scene type ...") + # use extension mapping for first family found + for family in self.families: + try: + self.scene_type = ext_mapping[family] + self.log.info( + "Using {} as scene type".format(self.scene_type)) + break + except KeyError: + # no preset found + pass def process(self, instance): + """Plugin entry point. + Args: + instance: Instance to process. + + """ # Define extract output file path dir_path = self.staging_dir(instance) - maya_fname = "{0}.ma".format(instance.name) + maya_fname = "{0}.{1}".format(instance.name, self.scene_type) json_fname = "{0}.json".format(instance.name) # Make texture dump folder @@ -148,7 +210,7 @@ class ExtractLook(openpype.api.Extractor): # Collect all unique files used in the resources files = set() - files_metadata = dict() + files_metadata = {} for resource in resources: # Preserve color space values (force value after filepath change) # This will also trigger in the same order at end of context to @@ -162,35 +224,33 @@ class ExtractLook(openpype.api.Extractor): # files.update(os.path.normpath(f)) # Process the resource files - transfers = list() - hardlinks = list() - hashes = dict() - forceCopy = instance.data.get("forceCopy", False) + transfers = [] + hardlinks = [] + hashes = {} + force_copy = instance.data.get("forceCopy", False) self.log.info(files) for filepath in files_metadata: - cspace = files_metadata[filepath]["color_space"] - linearise = False - if do_maketx: - if cspace == "sRGB": - linearise = True - # set its file node to 'raw' as tx will be linearized - files_metadata[filepath]["color_space"] = "raw" + linearize = False + if do_maketx and files_metadata[filepath]["color_space"] == "sRGB": # noqa: E501 + linearize = True + # set its file node to 'raw' as tx will be linearized + files_metadata[filepath]["color_space"] = "raw" - source, mode, hash = self._process_texture( + source, mode, texture_hash = self._process_texture( filepath, do_maketx, staging=dir_path, - linearise=linearise, - force=forceCopy + linearize=linearize, + force=force_copy ) destination = self.resource_destination(instance, source, do_maketx) # Force copy is specified. - if forceCopy: + if force_copy: mode = COPY if mode == COPY: @@ -202,10 +262,10 @@ class ExtractLook(openpype.api.Extractor): # Store the hashes from hash to destination to include in the # database - hashes[hash] = destination + hashes[texture_hash] = destination # Remap the resources to the destination path (change node attributes) - destinations = dict() + destinations = {} remap = OrderedDict() # needs to be ordered, see color space values for resource in resources: source = os.path.normpath(resource["source"]) @@ -222,7 +282,7 @@ class ExtractLook(openpype.api.Extractor): color_space_attr = resource["node"] + ".colorSpace" color_space = cmds.getAttr(color_space_attr) if files_metadata[source]["color_space"] == "raw": - # set colorpsace to raw if we linearized it + # set color space to raw if we linearized it color_space = "Raw" # Remap file node filename to destination attr = resource["attribute"] @@ -267,11 +327,11 @@ class ExtractLook(openpype.api.Extractor): json.dump(data, f) if "files" not in instance.data: - instance.data["files"] = list() + instance.data["files"] = [] if "hardlinks" not in instance.data: - instance.data["hardlinks"] = list() + instance.data["hardlinks"] = [] if "transfers" not in instance.data: - instance.data["transfers"] = list() + instance.data["transfers"] = [] instance.data["files"].append(maya_fname) instance.data["files"].append(json_fname) @@ -311,14 +371,26 @@ class ExtractLook(openpype.api.Extractor): maya_path)) def resource_destination(self, instance, filepath, do_maketx): - anatomy = instance.context.data["anatomy"] + """Get resource destination path. + This is utility function to change path if resource file name is + changed by some external tool like `maketx`. + + Args: + instance: Current Instance. + filepath (str): Resource path + do_maketx (bool): Flag if resource is processed by `maketx`. + + Returns: + str: Path to resource file + + """ resources_dir = instance.data["resourcesDir"] # Compute destination location basename, ext = os.path.splitext(os.path.basename(filepath)) - # If maketx then the texture will always end with .tx + # If `maketx` then the texture will always end with .tx if do_maketx: ext = ".tx" @@ -326,7 +398,7 @@ class ExtractLook(openpype.api.Extractor): resources_dir, basename + ext ) - def _process_texture(self, filepath, do_maketx, staging, linearise, force): + def _process_texture(self, filepath, do_maketx, staging, linearize, force): """Process a single texture file on disk for publishing. This will: 1. Check whether it's already published, if so it will do hardlink @@ -363,7 +435,7 @@ class ExtractLook(openpype.api.Extractor): # Produce .tx file in staging if source file is not .tx converted = os.path.join(staging, "resources", fname + ".tx") - if linearise: + if linearize: self.log.info("tx: converting sRGB -> linear") colorconvert = "--colorconvert sRGB linear" else: From e0eeec5e456cdc43621e405a62e03eca7aa12e87 Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Wed, 7 Apr 2021 16:44:57 +0200 Subject: [PATCH 009/148] SyncServer - fix typo resulting in infinitive recursion --- openpype/modules/sync_server/tray/app.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openpype/modules/sync_server/tray/app.py b/openpype/modules/sync_server/tray/app.py index 41a0f84afb..63103a6f0b 100644 --- a/openpype/modules/sync_server/tray/app.py +++ b/openpype/modules/sync_server/tray/app.py @@ -1604,7 +1604,7 @@ class SyncRepresentationDetailModel(QtCore.QAbstractTableModel): @property def project(self): """Returns project""" - return self.project + return self._project def tick(self): """ From 1c4d1f0346fe190c8b3c3deae13723d7e66ba0e9 Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Wed, 7 Apr 2021 17:04:12 +0200 Subject: [PATCH 010/148] SyncServer - fix wrong method for local site --- openpype/modules/sync_server/tray/app.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openpype/modules/sync_server/tray/app.py b/openpype/modules/sync_server/tray/app.py index 63103a6f0b..a73d674b0c 100644 --- a/openpype/modules/sync_server/tray/app.py +++ b/openpype/modules/sync_server/tray/app.py @@ -507,7 +507,7 @@ class SyncRepresentationWidget(QtWidgets.QWidget): def _add_site(self): log.info(self.representation_id) project_name = self.table_view.model().project - local_site_name = self.sync_server.get_my_local_site() + local_site_name = get_local_site_id() try: self.sync_server.add_site( project_name, From 264025554eb440f9f0b7cc8569632a00bda384be Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Wed, 7 Apr 2021 17:14:53 +0200 Subject: [PATCH 011/148] SyncServer - fix label from local to active --- openpype/modules/sync_server/tray/app.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openpype/modules/sync_server/tray/app.py b/openpype/modules/sync_server/tray/app.py index a73d674b0c..8a8ddc014a 100644 --- a/openpype/modules/sync_server/tray/app.py +++ b/openpype/modules/sync_server/tray/app.py @@ -1437,7 +1437,7 @@ class SyncRepresentationDetailWidget(QtWidgets.QWidget): menu.addAction(action) if float(remote_progress) == 1.0: - action = QtWidgets.QAction("Reset local site") + action = QtWidgets.QAction("Reset active site") actions_mapping[action] = self._reset_local_site menu.addAction(action) From 8a341996f69fccc0b4ca3a7c009fa6517215de0b Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Wed, 7 Apr 2021 17:17:33 +0200 Subject: [PATCH 012/148] SyncServer - fix wrong commit in avalon-core --- repos/avalon-core | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/repos/avalon-core b/repos/avalon-core index c3dc49184a..83f545b6b5 160000 --- a/repos/avalon-core +++ b/repos/avalon-core @@ -1 +1 @@ -Subproject commit c3dc49184ab14e2590a51dde55695cf27ab23510 +Subproject commit 83f545b6b551c018f03e1a2c7abe3284ba843610 From cc832d0156bb5101c10fc2be31288030af70a5e0 Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Wed, 7 Apr 2021 17:39:58 +0200 Subject: [PATCH 013/148] SyncServer - fix roots must have root key --- openpype/modules/sync_server/providers/abstract_provider.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/openpype/modules/sync_server/providers/abstract_provider.py b/openpype/modules/sync_server/providers/abstract_provider.py index 35dca87acf..2e6e97ebf9 100644 --- a/openpype/modules/sync_server/providers/abstract_provider.py +++ b/openpype/modules/sync_server/providers/abstract_provider.py @@ -145,10 +145,11 @@ class AbstractProvider: Returns: (string): proper url """ + if not root_config: + root_config = self.get_roots_config(anatomy) + if root_config and not root_config.get("root"): root_config = {"root": root_config} - else: - root_config = self.get_roots_config(anatomy) try: if not root_config: From 72e0c425434fd278d81ff343be6b3680d59fec68 Mon Sep 17 00:00:00 2001 From: Ondrej Samohel Date: Thu, 8 Apr 2021 14:27:30 +0200 Subject: [PATCH 014/148] small refactors --- .../hosts/maya/plugins/publish/collect_look.py | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/openpype/hosts/maya/plugins/publish/collect_look.py b/openpype/hosts/maya/plugins/publish/collect_look.py index c51b00c523..bd8d2f78d1 100644 --- a/openpype/hosts/maya/plugins/publish/collect_look.py +++ b/openpype/hosts/maya/plugins/publish/collect_look.py @@ -4,7 +4,7 @@ import re import os import glob -from maya import cmds +from maya import cmds # noqa import pyblish.api from openpype.hosts.maya.api import lib @@ -36,7 +36,6 @@ def get_look_attrs(node): list: Attribute names to extract """ - # When referenced get only attributes that are "changed since file open" # which includes any reference edits, otherwise take *all* user defined # attributes @@ -227,7 +226,12 @@ class CollectLook(pyblish.api.InstancePlugin): self.collect(instance) def collect(self, instance): + """Collect looks. + Args: + instance: Instance to collect. + + """ self.log.info("Looking for look associations " "for %s" % instance.data['name']) @@ -477,6 +481,11 @@ class CollectLook(pyblish.api.InstancePlugin): """ self.log.debug("processing: {}".format(node)) + if cmds.nodeType(node) not in ["file", "aiImage"]: + self.log.error( + "Unsupported file node: {}".format(cmds.nodeType(node))) + raise AssertionError("Unsupported file node") + if cmds.nodeType(node) == 'file': self.log.debug(" - file node") attribute = "{}.fileTextureName".format(node) @@ -485,6 +494,7 @@ class CollectLook(pyblish.api.InstancePlugin): self.log.debug("aiImage node") attribute = "{}.filename".format(node) computed_attribute = attribute + source = cmds.getAttr(attribute) self.log.info(" - file source: {}".format(source)) color_space_attr = "{}.colorSpace".format(node) From 331b80d6fe2cd2da3a7db725439f3dac51caf4df Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Thu, 8 Apr 2021 15:15:47 +0200 Subject: [PATCH 015/148] SyncServer - fix scrolling --- openpype/modules/sync_server/tray/app.py | 47 ++++++++++++++++++++++-- 1 file changed, 43 insertions(+), 4 deletions(-) diff --git a/openpype/modules/sync_server/tray/app.py b/openpype/modules/sync_server/tray/app.py index 8a8ddc014a..66ba58ae63 100644 --- a/openpype/modules/sync_server/tray/app.py +++ b/openpype/modules/sync_server/tray/app.py @@ -307,6 +307,8 @@ class SyncRepresentationWidget(QtWidgets.QWidget): self.filter = QtWidgets.QLineEdit() self.filter.setPlaceholderText("Filter representations..") + self._scrollbar_pos = None + top_bar_layout = QtWidgets.QHBoxLayout() top_bar_layout.addWidget(self.filter) @@ -361,6 +363,8 @@ class SyncRepresentationWidget(QtWidgets.QWidget): self.table_view.customContextMenuRequested.connect( self._on_context_menu) + model.refresh_started.connect(self._save_scrollbar) + model.refresh_finished.connect(self._set_scrollbar) self.table_view.model().modelReset.connect(self._set_selection) self.selection_model = self.table_view.selectionModel() @@ -542,6 +546,8 @@ class SyncRepresentationWidget(QtWidgets.QWidget): self.message_generated.emit("Site {} removed".format(local_site)) except ValueError as exp: self.message_generated.emit("Error {}".format(str(exp))) + self.table_view.model().refresh( + load_records=self.table_view.model()._rec_loaded) def _reset_local_site(self): """ @@ -553,6 +559,8 @@ class SyncRepresentationWidget(QtWidgets.QWidget): self.representation_id, 'local' ) + self.table_view.model().refresh( + load_records=self.table_view.model()._rec_loaded) def _reset_remote_site(self): """ @@ -564,6 +572,8 @@ class SyncRepresentationWidget(QtWidgets.QWidget): self.representation_id, 'remote' ) + self.table_view.model().refresh( + load_records=self.table_view.model()._rec_loaded) def _open_in_explorer(self, site): if not self.item: @@ -587,6 +597,13 @@ class SyncRepresentationWidget(QtWidgets.QWidget): except OSError: raise OSError('unsupported xdg-open call??') + def _save_scrollbar(self): + self._scrollbar_pos = self.table_view.verticalScrollBar().value() + + def _set_scrollbar(self): + if self._scrollbar_pos: + self.table_view.verticalScrollBar().setValue(self._scrollbar_pos) + ProviderRole = QtCore.Qt.UserRole + 2 ProgressRole = QtCore.Qt.UserRole + 4 @@ -632,6 +649,9 @@ class SyncRepresentationModel(QtCore.QAbstractTableModel): "status" # state ] + refresh_started = QtCore.Signal() + refresh_finished = QtCore.Signal() + @attr.s class SyncRepresentation: """ @@ -781,7 +801,7 @@ class SyncRepresentationModel(QtCore.QAbstractTableModel): if self.sync_server.is_paused() or \ self.sync_server.is_project_paused(self.project): return - + self.refresh_started.emit() self.beginResetModel() self._data = [] self._rec_loaded = 0 @@ -793,6 +813,7 @@ class SyncRepresentationModel(QtCore.QAbstractTableModel): self._add_page_records(self.local_site, self.remote_site, representations) self.endResetModel() + self.refresh_finished.emit() def _add_page_records(self, local_site, remote_site, representations): """ @@ -1307,6 +1328,8 @@ class SyncRepresentationDetailWidget(QtWidgets.QWidget): self.filter = QtWidgets.QLineEdit() self.filter.setPlaceholderText("Filter representation..") + self._scrollbar_pos = None + top_bar_layout = QtWidgets.QHBoxLayout() top_bar_layout.addWidget(self.filter) @@ -1360,6 +1383,8 @@ class SyncRepresentationDetailWidget(QtWidgets.QWidget): self.table_view.customContextMenuRequested.connect( self._on_context_menu) + model.refresh_started.connect(self._save_scrollbar) + model.refresh_finished.connect(self._set_scrollbar) self.table_view.model().modelReset.connect(self._set_selection) self.selection_model = self.table_view.selectionModel() @@ -1377,7 +1402,7 @@ class SyncRepresentationDetailWidget(QtWidgets.QWidget): """ if self._selected_id: index = self.table_view.model().get_index(self._selected_id) - if index.isValid(): + if index and index.isValid(): mode = QtCore.QItemSelectionModel.Select | \ QtCore.QItemSelectionModel.Rows self.selection_model.setCurrentIndex(index, mode) @@ -1468,7 +1493,8 @@ class SyncRepresentationDetailWidget(QtWidgets.QWidget): self.representation_id, 'local', self.item._id) - self.table_view.model().refresh() + self.table_view.model().refresh( + load_records=self.table_view.model()._rec_loaded) def _reset_remote_site(self): """ @@ -1480,7 +1506,8 @@ class SyncRepresentationDetailWidget(QtWidgets.QWidget): self.representation_id, 'remote', self.item._id) - self.table_view.model().refresh() + self.table_view.model().refresh( + load_records=self.table_view.model()._rec_loaded) def _open_in_explorer(self, site): if not self.item: @@ -1502,6 +1529,13 @@ class SyncRepresentationDetailWidget(QtWidgets.QWidget): except OSError: raise OSError('unsupported xdg-open call??') + def _save_scrollbar(self): + self._scrollbar_pos = self.table_view.verticalScrollBar().value() + + def _set_scrollbar(self): + if self._scrollbar_pos: + self.table_view.verticalScrollBar().setValue(self._scrollbar_pos) + class SyncRepresentationDetailModel(QtCore.QAbstractTableModel): """ @@ -1535,6 +1569,9 @@ class SyncRepresentationDetailModel(QtCore.QAbstractTableModel): "status" # state ] + refresh_started = QtCore.Signal() + refresh_finished = QtCore.Signal() + @attr.s class SyncRepresentationDetail: """ @@ -1665,6 +1702,7 @@ class SyncRepresentationDetailModel(QtCore.QAbstractTableModel): if self.sync_server.is_paused(): return + self.refresh_started.emit() self.beginResetModel() self._data = [] self._rec_loaded = 0 @@ -1676,6 +1714,7 @@ class SyncRepresentationDetailModel(QtCore.QAbstractTableModel): self._add_page_records(self.local_site, self.remote_site, representations) self.endResetModel() + self.refresh_finished.emit() def _add_page_records(self, local_site, remote_site, representations): """ From 89a49d9d1279f0b71c921b29e84285d7b3069362 Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Thu, 8 Apr 2021 15:59:22 +0200 Subject: [PATCH 016/148] Settings cleaned up icons, replaced by standard from resources --- openpype/modules/sync_server/tray/app.py | 5 ++++- .../tools/settings/settings/style/__init__.py | 3 ++- .../tools/settings/settings/style/pype_icon.png | Bin 3793 -> 0 bytes 3 files changed, 6 insertions(+), 2 deletions(-) delete mode 100644 openpype/tools/settings/settings/style/pype_icon.png diff --git a/openpype/modules/sync_server/tray/app.py b/openpype/modules/sync_server/tray/app.py index 66ba58ae63..e0b7c20631 100644 --- a/openpype/modules/sync_server/tray/app.py +++ b/openpype/modules/sync_server/tray/app.py @@ -16,6 +16,7 @@ from bson.objectid import ObjectId from openpype.lib import PypeLogger from openpype.api import get_local_site_id +from openpype import resources log = PypeLogger().get_logger("SyncServer") @@ -44,7 +45,7 @@ class SyncServerWindow(QtWidgets.QDialog): self.setFocusPolicy(QtCore.Qt.StrongFocus) self.setStyleSheet(style.load_stylesheet()) - self.setWindowIcon(QtGui.QIcon(style.app_icon_path())) + self.setWindowIcon(QtGui.QIcon(resources.pype_icon_filepath())) self.resize(1400, 800) self.timer = QtCore.QTimer() @@ -327,6 +328,7 @@ class SyncRepresentationWidget(QtWidgets.QWidget): self.table_view.horizontalHeader().setSortIndicator( -1, Qt.AscendingOrder) self.table_view.setSortingEnabled(True) + self.table_view.horizontalHeader().setSortIndicatorShown(True) self.table_view.setAlternatingRowColors(True) self.table_view.verticalHeader().hide() @@ -1348,6 +1350,7 @@ class SyncRepresentationDetailWidget(QtWidgets.QWidget): self.table_view.horizontalHeader().setSortIndicator(-1, Qt.AscendingOrder) self.table_view.setSortingEnabled(True) + self.table_view.horizontalHeader().setSortIndicatorShown(True) self.table_view.setAlternatingRowColors(True) self.table_view.verticalHeader().hide() diff --git a/openpype/tools/settings/settings/style/__init__.py b/openpype/tools/settings/settings/style/__init__.py index 9bb5e851b4..5a57642ee1 100644 --- a/openpype/tools/settings/settings/style/__init__.py +++ b/openpype/tools/settings/settings/style/__init__.py @@ -1,4 +1,5 @@ import os +from openpype import resources def load_stylesheet(): @@ -9,4 +10,4 @@ def load_stylesheet(): def app_icon_path(): - return os.path.join(os.path.dirname(__file__), "openpype_icon.png") + return resources.pype_icon_filepath() diff --git a/openpype/tools/settings/settings/style/pype_icon.png b/openpype/tools/settings/settings/style/pype_icon.png deleted file mode 100644 index bfacf6eeedc753db0d77e661843a78b0eb13ce38..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 3793 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 b;3u>qRjE!lxug$XMKJrL$8D;P_+R@UX<7IA From 0693d13fbd4258cb255b5cbabb879daf9a9a7b83 Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Thu, 8 Apr 2021 19:21:28 +0200 Subject: [PATCH 017/148] SyncServer fix duplication of document skeleton if same sites MongoDB contained duplicated skeletons --- openpype/plugins/publish/integrate_new.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/openpype/plugins/publish/integrate_new.py b/openpype/plugins/publish/integrate_new.py index 0d36828ccf..ea90f284b2 100644 --- a/openpype/plugins/publish/integrate_new.py +++ b/openpype/plugins/publish/integrate_new.py @@ -976,6 +976,9 @@ class IntegrateAssetNew(pyblish.api.InstancePlugin): local_site = local_site_id remote_site = sync_server_presets["config"].get("remote_site") + if remote_site == local_site: + remote_site = None + if remote_site == 'local': remote_site = local_site_id From 21719047943b7f56c4b1ed828b79af57d6ffe64c Mon Sep 17 00:00:00 2001 From: kalisp Date: Fri, 9 Apr 2021 10:00:31 +0000 Subject: [PATCH 018/148] Create draft PR for #1294 From ae7aeab2f75933a152c0c7499d65c420f31f88b7 Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Fri, 9 Apr 2021 12:13:05 +0200 Subject: [PATCH 019/148] SyncServer - renamed label in Tray --- openpype/modules/sync_server/sync_server_module.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openpype/modules/sync_server/sync_server_module.py b/openpype/modules/sync_server/sync_server_module.py index 4b4b3517ee..177544723e 100644 --- a/openpype/modules/sync_server/sync_server_module.py +++ b/openpype/modules/sync_server/sync_server_module.py @@ -85,7 +85,7 @@ class SyncServerModule(PypeModule, ITrayModule): LOG_PROGRESS_SEC = 5 # how often log progress to DB name = "sync_server" - label = "Sync Server" + label = "Sync Queue" def initialize(self, module_settings): """ From 0a7ca6bed48762bde218e726d6135155305528af Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Fri, 9 Apr 2021 12:20:37 +0200 Subject: [PATCH 020/148] SyncServer - changed order in Status --- openpype/modules/sync_server/tray/app.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/openpype/modules/sync_server/tray/app.py b/openpype/modules/sync_server/tray/app.py index e0b7c20631..c8f0c78906 100644 --- a/openpype/modules/sync_server/tray/app.py +++ b/openpype/modules/sync_server/tray/app.py @@ -22,8 +22,8 @@ log = PypeLogger().get_logger("SyncServer") STATUS = { 0: 'In Progress', - 1: 'Failed', - 2: 'Queued', + 1: 'Queued', + 2: 'Failed', 3: 'Paused', 4: 'Synced OK', -1: 'Not available' @@ -445,7 +445,7 @@ class SyncRepresentationWidget(QtWidgets.QWidget): else: self.site_name = remote_site - if self.item.state in [STATUS[0], STATUS[2]]: + if self.item.state in [STATUS[0], STATUS[1]]: action = QtWidgets.QAction("Pause") actions_mapping[action] = self._pause menu.addAction(action) @@ -1212,12 +1212,12 @@ class SyncRepresentationModel(QtCore.QAbstractTableModel): {'$gte': ['$failed_local_tries', 3]}, {'$gte': ['$failed_remote_tries', 3]} ]}, - 'then': 1}, + 'then': 2}, # Failed { 'case': { '$or': [{'$eq': ['$avg_progress_remote', 0]}, {'$eq': ['$avg_progress_local', 0]}]}, - 'then': 2 # Queued + 'then': 1 # Queued }, { 'case': {'$or': [{'$and': [ @@ -1459,7 +1459,7 @@ class SyncRepresentationDetailWidget(QtWidgets.QWidget): actions_kwargs_mapping[action] = {'site': site} menu.addAction(action) - if self.item.state == STATUS[1]: + if self.item.state == STATUS[2]: action = QtWidgets.QAction("Open error detail") actions_mapping[action] = self._show_detail menu.addAction(action) From 69f8d670bb5595fdaae1048dee635ab7a770f8bf Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Fri, 9 Apr 2021 12:25:35 +0200 Subject: [PATCH 021/148] SyncServer - resize columns, window --- openpype/modules/sync_server/tray/app.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/openpype/modules/sync_server/tray/app.py b/openpype/modules/sync_server/tray/app.py index c8f0c78906..6418afbf16 100644 --- a/openpype/modules/sync_server/tray/app.py +++ b/openpype/modules/sync_server/tray/app.py @@ -46,7 +46,7 @@ class SyncServerWindow(QtWidgets.QDialog): self.setStyleSheet(style.load_stylesheet()) self.setWindowIcon(QtGui.QIcon(resources.pype_icon_filepath())) - self.resize(1400, 800) + self.resize(1450, 700) self.timer = QtCore.QTimer() self.timer.timeout.connect(self._hide_message) @@ -270,8 +270,8 @@ class SyncRepresentationWidget(QtWidgets.QWidget): default_widths = ( ("asset", 210), ("subset", 190), - ("version", 10), - ("representation", 90), + ("version", 15), + ("representation", 95), ("created_dt", 105), ("sync_dt", 105), ("local_site", 80), @@ -279,7 +279,7 @@ class SyncRepresentationWidget(QtWidgets.QWidget): ("files_count", 50), ("files_size", 60), ("priority", 20), - ("state", 50) + ("state", 110) ) column_labels = ( ("asset", "Asset"), @@ -1300,8 +1300,8 @@ class SyncRepresentationDetailWidget(QtWidgets.QWidget): ("local_site", 80), ("remote_site", 80), ("size", 60), - ("priority", 20), - ("state", 90) + ("priority", 25), + ("state", 110) ) column_labels = ( From 93c2c2b9fe7aa6e85a1dff14ed899a24e800b72f Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Fri, 9 Apr 2021 12:40:01 +0200 Subject: [PATCH 022/148] SyncServer - changed labels in context menu --- openpype/modules/sync_server/tray/app.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/openpype/modules/sync_server/tray/app.py b/openpype/modules/sync_server/tray/app.py index 6418afbf16..82a6935471 100644 --- a/openpype/modules/sync_server/tray/app.py +++ b/openpype/modules/sync_server/tray/app.py @@ -203,6 +203,7 @@ class SyncProjectListWidget(ProjectListWidget): self.project_name = point_index.data(QtCore.Qt.DisplayRole) menu = QtWidgets.QMenu() + menu.setStyleSheet(style.load_stylesheet()) actions_mapping = {} if self.sync_server.is_project_paused(self.project_name): @@ -415,6 +416,7 @@ class SyncRepresentationWidget(QtWidgets.QWidget): format(self.representation_id)) menu = QtWidgets.QMenu() + menu.setStyleSheet(style.load_stylesheet()) actions_mapping = {} actions_kwargs_mapping = {} @@ -461,12 +463,12 @@ class SyncRepresentationWidget(QtWidgets.QWidget): # menu.addAction(action) if remote_progress == 1.0: - action = QtWidgets.QAction("Reset local site") + action = QtWidgets.QAction("Re-sync Active site") actions_mapping[action] = self._reset_local_site menu.addAction(action) if local_progress == 1.0: - action = QtWidgets.QAction("Reset remote site") + action = QtWidgets.QAction("Re-sync Remote site") actions_mapping[action] = self._reset_remote_site menu.addAction(action) @@ -1435,6 +1437,7 @@ class SyncRepresentationDetailWidget(QtWidgets.QWidget): self.item = self.table_view.model()._data[point_index.row()] menu = QtWidgets.QMenu() + menu.setStyleSheet(style.load_stylesheet()) actions_mapping = {} actions_kwargs_mapping = {} From 86a2ee47834d0c20b56fcfdeabcd3365cfbe6218 Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Fri, 9 Apr 2021 14:53:56 +0200 Subject: [PATCH 023/148] SyncServer - merged created_dt and progress columns --- openpype/modules/sync_server/tray/app.py | 158 +++++++++++------------ 1 file changed, 79 insertions(+), 79 deletions(-) diff --git a/openpype/modules/sync_server/tray/app.py b/openpype/modules/sync_server/tray/app.py index 82a6935471..b6550b78ba 100644 --- a/openpype/modules/sync_server/tray/app.py +++ b/openpype/modules/sync_server/tray/app.py @@ -273,10 +273,8 @@ class SyncRepresentationWidget(QtWidgets.QWidget): ("subset", 190), ("version", 15), ("representation", 95), - ("created_dt", 105), - ("sync_dt", 105), - ("local_site", 80), - ("remote_site", 80), + ("local_site", 185), + ("remote_site", 185), ("files_count", 50), ("files_size", 60), ("priority", 20), @@ -287,8 +285,6 @@ class SyncRepresentationWidget(QtWidgets.QWidget): ("subset", "Subset"), ("version", "Version"), ("representation", "Representation"), - ("created_dt", "Created"), - ("sync_dt", "Synced"), ("local_site", "Active site"), ("remote_site", "Remote site"), ("files_count", "Files"), @@ -333,11 +329,11 @@ class SyncRepresentationWidget(QtWidgets.QWidget): self.table_view.setAlternatingRowColors(True) self.table_view.verticalHeader().hide() - time_delegate = PrettyTimeDelegate(self) - column = self.table_view.model().get_header_index("created_dt") - self.table_view.setItemDelegateForColumn(column, time_delegate) - column = self.table_view.model().get_header_index("sync_dt") - self.table_view.setItemDelegateForColumn(column, time_delegate) + # time_delegate = PrettyTimeDelegate(self) + # column = self.table_view.model().get_header_index("created_dt") + # self.table_view.setItemDelegateForColumn(column, time_delegate) + # column = self.table_view.model().get_header_index("sync_dt") + # self.table_view.setItemDelegateForColumn(column, time_delegate) column = self.table_view.model().get_header_index("local_site") delegate = ImageDelegate(self) @@ -347,10 +343,6 @@ class SyncRepresentationWidget(QtWidgets.QWidget): delegate = ImageDelegate(self) self.table_view.setItemDelegateForColumn(column, delegate) - column = self.table_view.model().get_header_index("files_size") - delegate = SizeDelegate(self) - self.table_view.setItemDelegateForColumn(column, delegate) - for column_name, width in self.default_widths: idx = model.get_header_index(column_name) self.table_view.setColumnWidth(idx, width) @@ -611,6 +603,8 @@ class SyncRepresentationWidget(QtWidgets.QWidget): ProviderRole = QtCore.Qt.UserRole + 2 ProgressRole = QtCore.Qt.UserRole + 4 +DateRole = QtCore.Qt.UserRole + 6 +FailedRole = QtCore.Qt.UserRole + 8 class SyncRepresentationModel(QtCore.QAbstractTableModel): @@ -645,8 +639,6 @@ class SyncRepresentationModel(QtCore.QAbstractTableModel): "context.representation", # representation "updated_dt_local", # local created_dt "updated_dt_remote", # remote created_dt - "avg_progress_local", # local progress - "avg_progress_remote", # remote progress "files_count", # count of files "files_size", # file size of all files "context.asset", # priority TODO @@ -734,19 +726,38 @@ class SyncRepresentationModel(QtCore.QAbstractTableModel): def data(self, index, role): item = self._data[index.row()] + header_value = self._header[index.column()] if role == ProviderRole: - if self._header[index.column()] == 'local_site': + if header_value == 'local_site': return item.local_provider - if self._header[index.column()] == 'remote_site': + if header_value == 'remote_site': return item.remote_provider if role == ProgressRole: - if self._header[index.column()] == 'local_site': + if header_value == 'local_site': return item.local_progress - if self._header[index.column()] == 'remote_site': + if header_value == 'remote_site': return item.remote_progress + if role == DateRole: + if header_value == 'local_site': + if item.created_dt: + return pretty_timestamp(item.created_dt) + if header_value == 'remote_site': + if item.sync_dt: + return pretty_timestamp(item.sync_dt) + + if role == FailedRole: + if header_value == 'local_site': + return item.state == STATUS[2] and item.local_progress < 1 + if header_value == 'remote_site': + return item.state == STATUS[2] and item.remote_progress < 1 + if role == Qt.DisplayRole: + # because of ImageDelegate + if header_value in ['remote_site', 'local_site']: + return "" + return attr.asdict(item)[self._header[index.column()]] if role == Qt.UserRole: return item._id @@ -887,7 +898,7 @@ class SyncRepresentationModel(QtCore.QAbstractTableModel): avg_progress_local, avg_progress_remote, repre.get("files_count", 1), - repre.get("files_size", 0), + _pretty_size(repre.get("files_size", 0)), 1, STATUS[repre.get("status", -1)], files[0].get('path') @@ -1297,10 +1308,8 @@ class SyncRepresentationDetailWidget(QtWidgets.QWidget): default_widths = ( ("file", 290), - ("created_dt", 105), - ("sync_dt", 105), - ("local_site", 80), - ("remote_site", 80), + ("local_site", 185), + ("remote_site", 185), ("size", 60), ("priority", 25), ("state", 110) @@ -1308,8 +1317,6 @@ class SyncRepresentationDetailWidget(QtWidgets.QWidget): column_labels = ( ("file", "File name"), - ("created_dt", "Created"), - ("sync_dt", "Synced"), ("local_site", "Active site"), ("remote_site", "Remote site"), ("files_size", "Size"), @@ -1356,12 +1363,6 @@ class SyncRepresentationDetailWidget(QtWidgets.QWidget): self.table_view.setAlternatingRowColors(True) self.table_view.verticalHeader().hide() - time_delegate = PrettyTimeDelegate(self) - column = self.table_view.model().get_header_index("created_dt") - self.table_view.setItemDelegateForColumn(column, time_delegate) - column = self.table_view.model().get_header_index("sync_dt") - self.table_view.setItemDelegateForColumn(column, time_delegate) - column = self.table_view.model().get_header_index("local_site") delegate = ImageDelegate(self) self.table_view.setItemDelegateForColumn(column, delegate) @@ -1370,10 +1371,6 @@ class SyncRepresentationDetailWidget(QtWidgets.QWidget): delegate = ImageDelegate(self) self.table_view.setItemDelegateForColumn(column, delegate) - column = self.table_view.model().get_header_index("size") - delegate = SizeDelegate(self) - self.table_view.setItemDelegateForColumn(column, delegate) - for column_name, width in self.default_widths: idx = model.get_header_index(column_name) self.table_view.setColumnWidth(idx, width) @@ -1568,8 +1565,6 @@ class SyncRepresentationDetailModel(QtCore.QAbstractTableModel): "files.path", "updated_dt_local", # local created_dt "updated_dt_remote", # remote created_dt - "progress_local", # local progress - "progress_remote", # remote progress "size", # remote progress "context.asset", # priority TODO "status" # state @@ -1673,19 +1668,37 @@ class SyncRepresentationDetailModel(QtCore.QAbstractTableModel): def data(self, index, role): item = self._data[index.row()] + header_value = self._header[index.column()] if role == ProviderRole: - if self._header[index.column()] == 'local_site': + if header_value == 'local_site': return item.local_provider - if self._header[index.column()] == 'remote_site': + if header_value == 'remote_site': return item.remote_provider if role == ProgressRole: - if self._header[index.column()] == 'local_site': + if header_value == 'local_site': return item.local_progress - if self._header[index.column()] == 'remote_site': + if header_value == 'remote_site': return item.remote_progress + if role == DateRole: + if header_value == 'local_site': + if item.created_dt: + return pretty_timestamp(item.created_dt) + if header_value == 'remote_site': + if item.sync_dt: + return pretty_timestamp(item.sync_dt) + + if role == FailedRole: + if header_value == 'local_site': + return item.state == STATUS[2] and item.local_progress < 1 + if header_value == 'remote_site': + return item.state == STATUS[2] and item.remote_progress < 1 + if role == Qt.DisplayRole: + # because of ImageDelegate + if header_value in ['remote_site', 'local_site']: + return "" return attr.asdict(item)[self._header[index.column()]] if role == Qt.UserRole: return item._id @@ -1787,7 +1800,7 @@ class SyncRepresentationDetailModel(QtCore.QAbstractTableModel): remote_provider, local_progress, remote_progress, - file.get('size', 0), + _pretty_size(file.get('size', 0)), 1, STATUS[repre.get("status", -1)], repre.get("tries"), @@ -2092,18 +2105,14 @@ class ImageDelegate(QtWidgets.QStyledItemDelegate): self.icons = {} def paint(self, painter, option, index): + super(ImageDelegate, self).paint(painter, option, index) option = QtWidgets.QStyleOptionViewItem(option) option.showDecorationSelected = True - if (option.showDecorationSelected and - (option.state & QtWidgets.QStyle.State_Selected)): - painter.setOpacity(0.20) # highlight color is a bit off - painter.fillRect(option.rect, - option.palette.highlight()) - painter.setOpacity(1) - provider = index.data(ProviderRole) value = index.data(ProgressRole) + date_value = index.data(DateRole) + is_failed = index.data(FailedRole) if not self.icons.get(provider): resource_path = os.path.dirname(__file__) @@ -2115,18 +2124,24 @@ class ImageDelegate(QtWidgets.QStyledItemDelegate): else: pixmap = self.icons[provider] - point = QtCore.QPoint(option.rect.x() + - (option.rect.width() - pixmap.width()) / 2, + padding = 10 + point = QtCore.QPoint(option.rect.x() + padding, option.rect.y() + (option.rect.height() - pixmap.height()) / 2) painter.drawPixmap(point, pixmap) - painter.setOpacity(0.5) - overlay_rect = option.rect + overlay_rect = option.rect.translated(0, 0) overlay_rect.setHeight(overlay_rect.height() * (1.0 - float(value))) painter.fillRect(overlay_rect, - QtGui.QBrush(QtGui.QColor(0, 0, 0, 200))) - painter.setOpacity(1) + QtGui.QBrush(QtGui.QColor(0, 0, 0, 100))) + painter.drawText(option.rect, + QtCore.Qt.AlignCenter, + date_value) + + if is_failed: + overlay_rect = option.rect.translated(0, 0) + painter.fillRect(overlay_rect, + QtGui.QBrush(QtGui.QColor(255, 0, 0, 35))) class SyncRepresentationErrorWindow(QtWidgets.QDialog): @@ -2181,27 +2196,12 @@ class SyncRepresentationErrorWidget(QtWidgets.QWidget): QtWidgets.QLabel(msg)) -class SizeDelegate(QtWidgets.QStyledItemDelegate): - """ - Pretty print for file size - """ - - def __init__(self, parent=None): - super(SizeDelegate, self).__init__(parent) - - def displayText(self, value, _locale): - if value is None: - # Ignore None value - return - - return self._pretty_size(value) - - def _pretty_size(self, value, suffix='B'): - for unit in ['', 'Ki', 'Mi', 'Gi', 'Ti', 'Pi', 'Ei', 'Zi']: - if abs(value) < 1024.0: - return "%3.1f%s%s" % (value, unit, suffix) - value /= 1024.0 - return "%.1f%s%s" % (value, 'Yi', suffix) +def _pretty_size(value, suffix='B'): + for unit in ['', 'Ki', 'Mi', 'Gi', 'Ti', 'Pi', 'Ei', 'Zi']: + if abs(value) < 1024.0: + return "%3.1f%s%s" % (value, unit, suffix) + value /= 1024.0 + return "%.1f%s%s" % (value, 'Yi', suffix) def _convert_progress(value): From e98d313eb287b9b875d8c6fa6994ea8565c0e143 Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Fri, 9 Apr 2021 14:59:45 +0200 Subject: [PATCH 024/148] Hound --- openpype/modules/sync_server/providers/abstract_provider.py | 5 +++-- openpype/modules/sync_server/providers/gdrive.py | 2 +- openpype/modules/sync_server/sync_server_module.py | 1 - 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/openpype/modules/sync_server/providers/abstract_provider.py b/openpype/modules/sync_server/providers/abstract_provider.py index 2e6e97ebf9..a60595ba93 100644 --- a/openpype/modules/sync_server/providers/abstract_provider.py +++ b/openpype/modules/sync_server/providers/abstract_provider.py @@ -1,5 +1,6 @@ -import abc, six -from openpype.api import Anatomy, Logger +import abc +import six +from openpype.api import Logger log = Logger().get_logger("SyncServer") diff --git a/openpype/modules/sync_server/providers/gdrive.py b/openpype/modules/sync_server/providers/gdrive.py index b6ece5263b..f1ea24f601 100644 --- a/openpype/modules/sync_server/providers/gdrive.py +++ b/openpype/modules/sync_server/providers/gdrive.py @@ -6,7 +6,7 @@ from googleapiclient import errors from .abstract_provider import AbstractProvider from googleapiclient.http import MediaFileUpload, MediaIoBaseDownload from openpype.api import Logger -from openpype.api import get_system_settings, Anatomy +from openpype.api import get_system_settings from ..utils import time_function import time diff --git a/openpype/modules/sync_server/sync_server_module.py b/openpype/modules/sync_server/sync_server_module.py index 177544723e..b8820c8dd9 100644 --- a/openpype/modules/sync_server/sync_server_module.py +++ b/openpype/modules/sync_server/sync_server_module.py @@ -1048,7 +1048,6 @@ class SyncServerModule(PypeModule, ITrayModule): provider_name = self.get_provider_for_site(collection, site_name) if provider_name == 'local_drive': - handler = LocalDriveHandler(collection, site_name) query = { "_id": ObjectId(representation_id) } From e9b14490eaf25ae74b61f97ffc0b5b5881f61be3 Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Fri, 9 Apr 2021 16:13:30 +0200 Subject: [PATCH 025/148] SyncServer - fix label in Tray --- openpype/modules/sync_server/sync_server_module.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openpype/modules/sync_server/sync_server_module.py b/openpype/modules/sync_server/sync_server_module.py index b8820c8dd9..59c3787789 100644 --- a/openpype/modules/sync_server/sync_server_module.py +++ b/openpype/modules/sync_server/sync_server_module.py @@ -506,7 +506,7 @@ class SyncServerModule(PypeModule, ITrayModule): from Qt import QtWidgets """Add menu or action to Tray(or parent)'s menu""" - action = QtWidgets.QAction("SyncServer", parent_menu) + action = QtWidgets.QAction(self.label, parent_menu) action.triggered.connect(self.show_widget) parent_menu.addAction(action) parent_menu.addSeparator() From cf45201670e205f95762f515931ab177d553be79 Mon Sep 17 00:00:00 2001 From: Simone Barbieri Date: Mon, 12 Apr 2021 11:03:08 +0100 Subject: [PATCH 026/148] Added documentation on Modelling and Setting Scene Data --- website/docs/artist_hosts_blender.md | 153 ++++++++++++++++++ .../assets/blender-model_create_instance.jpg | Bin 0 -> 25916 bytes .../assets/blender-model_error_details.jpg | Bin 0 -> 42731 bytes website/docs/assets/blender-model_example.jpg | Bin 0 -> 48903 bytes .../docs/assets/blender-model_pre_publish.jpg | Bin 0 -> 38852 bytes .../assets/blender-model_publish_error.jpg | Bin 0 -> 36965 bytes .../assets/blender-save_modelling_file.jpg | Bin 0 -> 57937 bytes 7 files changed, 153 insertions(+) create mode 100644 website/docs/artist_hosts_blender.md create mode 100644 website/docs/assets/blender-model_create_instance.jpg create mode 100644 website/docs/assets/blender-model_error_details.jpg create mode 100644 website/docs/assets/blender-model_example.jpg create mode 100644 website/docs/assets/blender-model_pre_publish.jpg create mode 100644 website/docs/assets/blender-model_publish_error.jpg create mode 100644 website/docs/assets/blender-save_modelling_file.jpg diff --git a/website/docs/artist_hosts_blender.md b/website/docs/artist_hosts_blender.md new file mode 100644 index 0000000000..b319a4c61f --- /dev/null +++ b/website/docs/artist_hosts_blender.md @@ -0,0 +1,153 @@ +--- +id: artist_hosts_blender +title: Blender +sidebar_label: Blender +--- + +## OpenPype global tools + +- [Set Context](artist_tools.md#set-context) +- [Work Files](artist_tools.md#workfiles) +- [Create](artist_tools.md#creator) +- [Load](artist_tools.md#loader) +- [Manage (Inventory)](artist_tools.md#inventory) +- [Publish](artist_tools.md#publisher) +- [Library Loader](artist_tools.md#library-loader) + +## Working with OpenPype in Blender + +OpenPype is here to ease you the burden of working on project with lots of +collaborators, worrying about naming, setting stuff, browsing through endless +directories, loading and exporting and so on. To achieve that, OpenPype is using +concept of being _"data driven"_. This means that what happens when publishing +is influenced by data in scene. This can by slightly confusing so let's get to +it with few examples. + +## Publishing models + +### Intro + +Publishing models in Blender is pretty straightforward. Create your model as you +need. You might need to adhere to specifications of your studio that can be different +between studios and projects but by default your geometry does not need any +other convention. + +![Model example](assets/blender-model_example.jpg) + +### Creating instance + +Now create **Model instance** from it to let OpenPype know what in the scene you want to +publish. Go **OpenPype → Create... → Model**. + +![Model create instance](assets/blender-model_create_instance.jpg) + +`Asset` field is a name of asset you are working on - it should be already filled +with correct name as you've started Blender or switched context to specific asset. You +can edit that field to change it to different asset (but that one must already exists). + +`Subset` field is a name you can decide on. It should describe what kind of data you +have in the model. For example, you can name it `Proxy` to indicate that this is +low resolution stuff. See [Subset](artist_concepts#subset). + + + +Read-only field just under it show final subset name, adding subset field to +name of the group you have selected. + +`Use selection` checkbox will use whatever you have selected in Outliner to be +wrapped in Model instance. This is usually what you want. Click on **Create** button. + +You'll notice then after you've created new Model instance, there is a new +collection in Outliner called after your asset and subset, in our case it is +`character1_modelDefault`. The assets selected when creating the Model instance +are linked in the new collection. + +And that's it, you have your first model ready to publish. + +Now save your scene (if you didn't do it already). You will notice that path +in Save dialog is already set to place where scenes related to modeling task on +your asset should reside. As in our case we are working on asset called +**character1** and on task **modeling**, path relative to your project directory will be +`project_XY/assets/character1/work/modeling`. The default name for the file will +be `project_XY_asset_task_version`, so in our case +`simonetest_character1_modeling_v001.blend`. Let's save it. + +![Model create instance](assets/blender-save_modelling_file.jpg) + +### Publishing models + +Now let's publish it. Go **OpenPype → Publish...**. You will be presented with following window: + +![Model publish](assets/blender-model_pre_publish.jpg) + +Note that content of this window can differs by your pipeline configuration. +For more detail see [Publisher](artist_tools#publisher). + +Items in left column are instances you will be publishing. You can disable them +by clicking on square next to them. White filled square indicate they are ready for +publishing, red means something went wrong either during collection phase +or publishing phase. Empty one with gray text is disabled. + +See that in this case we are publishing from the scene file +`simonetest_character1_modeling_v001.blend` the Blender model named +`character1_modelDefault`. + +Right column lists all tasks that are run during collection, validation, +extraction and integration phase. White items are optional and you can disable +them by clicking on them. + +Lets do dry-run on publishing to see if we pass all validators. Click on flask +icon at the bottom. Validators are run. Ideally you will end up with everything +green in validator section. + +### Fixing problems + +For the sake of demonstration, I intentionally kept the model in Edit Mode, to +trigger the validator designed to check just this. + +![Failed Model Validator](assets/blender-model_publish_error.jpg) + +You can see our model is now marked red in left column and in right we have +red box next to `Mesh is in Object Mode` validator. + +You can click on arrow next to it to see more details: + +![Failed Model Validator details](assets/blender-model_error_details.jpg) + +From there you can see in **Records** entry that there is problem with the +object `Suzanne`. Some validators have option to fix problem for you or just +select objects that cause trouble. This is the case with our failed validator. + +In main overview you can notice little up arrow in a circle next to validator +name. Right click on it and you can see menu item `select invalid`. This +will select offending object in Maya. + +Fix is easy. Without closing Publisher window we just turn back the Object Mode. +Then we need to reset it to make it notice changes we've made. Click on arrow +circle button at the bottom and it will reset Publisher to initial state. Run +validators again (flask icon) to see if everything is ok. + +It should be now. Write some comment if you want and click play icon button +when ready. + +Publish process will now take its course. Depending on data you are publishing +it can take a while. You should end up with everything green and message +**Finished successfully ...** You can now close publisher window. + +To check for yourself that model is published, open +[Asset Loader](artist_tools#loader) - **OpenPype → Load...**. +There you should see your model, named `modelDefault`. + + +## Setting scene data + +Blender settings concerning framerate, resolution and frame range are handled +by OpenPype. If set correctly in Ftrack, Blender will automatically set the +values for you. diff --git a/website/docs/assets/blender-model_create_instance.jpg b/website/docs/assets/blender-model_create_instance.jpg new file mode 100644 index 0000000000000000000000000000000000000000..d0891c5d051ce782c5808b8e84c8137c874cb8a6 GIT binary patch literal 25916 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