Merge pull request #1115 from pypeclub/sync_server_fix_local_drive

Sync server fix local drive
This commit is contained in:
Milan Kolar 2021-03-12 13:24:41 +01:00 committed by GitHub
commit 80857e83c0
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
13 changed files with 645 additions and 326 deletions

View file

@ -77,7 +77,7 @@ class Anatomy:
root_key_regex = re.compile(r"{(root?[^}]+)}")
root_name_regex = re.compile(r"root\[([^]]+)\]")
def __init__(self, project_name=None):
def __init__(self, project_name=None, site_name=None):
if not project_name:
project_name = os.environ.get("AVALON_PROJECT")
@ -89,7 +89,7 @@ class Anatomy:
self.project_name = project_name
self._data = get_anatomy_settings(project_name)
self._data = get_anatomy_settings(project_name, site_name)
self._templates_obj = Templates(self)
self._roots_obj = Roots(self)

View file

@ -71,3 +71,39 @@ class AbstractProvider(metaclass=ABCMeta):
(list)
"""
pass
@abstractmethod
def create_folder(self, folder_path):
"""
Create all nonexistent folders and subfolders in 'path'.
Args:
path (string): absolute path
Returns:
(string) folder id of lowest subfolder from 'path'
"""
pass
@abstractmethod
def get_tree(self):
"""
Creates folder structure for providers which do not provide
tree folder structure (GDrive has no accessible tree structure,
only parents and their parents)
"""
pass
@abstractmethod
def resolve_path(self, path, root_config, anatomy=None):
"""
Replaces root placeholders with appropriate real value from
'root_configs' (from Settings or Local Settings) or Anatomy
(mainly for 'studio' site)
Args:
path(string): path with '{root[work]}/...'
root_config(dict): from Settings or Local Settings
anatomy (Anatomy): prepared anatomy object for project
"""
pass

View file

@ -678,6 +678,16 @@ 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}
try:
return path.format(**root_config)
except KeyError:
msg = "Error in resolving remote root, unknown key"
log.error(msg)
def _handle_q(self, q, trashed=False):
""" API list call contain trashed and hidden files/folder by default.
Usually we dont want those, must be included in query explicitly.

View file

@ -1,6 +1,8 @@
from __future__ import print_function
import os.path
import shutil
import threading
import time
from pype.api import Logger
from .abstract_provider import AbstractProvider
@ -13,29 +15,37 @@ class LocalDriveHandler(AbstractProvider):
def is_active(self):
return True
def upload_file(self, source_path, target_path, overwrite=True):
def upload_file(self, source_path, target_path,
server, collection, file, representation, site,
overwrite=False, direction="Upload"):
"""
Copies file from 'source_path' to 'target_path'
"""
if os.path.exists(source_path):
if overwrite:
shutil.copy(source_path, target_path)
else:
if os.path.exists(target_path):
raise ValueError("File {} exists, set overwrite".
format(target_path))
if not os.path.isfile(source_path):
raise FileNotFoundError("Source file {} doesn't exist."
.format(source_path))
if overwrite:
thread = threading.Thread(target=self._copy,
args=(source_path, target_path))
thread.start()
self._mark_progress(collection, file, representation, server,
site, source_path, target_path, direction)
else:
if os.path.exists(target_path):
raise ValueError("File {} exists, set overwrite".
format(target_path))
def download_file(self, source_path, local_path, overwrite=True):
return os.path.basename(target_path)
def download_file(self, source_path, local_path,
server, collection, file, representation, site,
overwrite=False):
"""
Download a file form 'source_path' to 'local_path'
"""
if os.path.exists(source_path):
if overwrite:
shutil.copy(source_path, local_path)
else:
if os.path.exists(local_path):
raise ValueError("File {} exists, set overwrite".
format(local_path))
return self.upload_file(source_path, local_path,
server, collection, file, representation, site,
overwrite, direction="Download")
def delete_file(self, path):
"""
@ -57,3 +67,69 @@ class LocalDriveHandler(AbstractProvider):
lst.append(os.path.join(dir_path, name))
return lst
def create_folder(self, folder_path):
"""
Creates 'folder_path' on local system
Args:
folder_path (string): absolute path on local (and mounted) disk
Returns:
(string) - sends back folder_path to denote folder(s) was
created
"""
os.makedirs(folder_path, exist_ok=True)
return folder_path
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}
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
def _copy(self, source_path, target_path):
print("copying {}->{}".format(source_path, target_path))
shutil.copy(source_path, target_path)
def _mark_progress(self, collection, file, representation, server, site,
source_path, target_path, direction):
"""
Updates progress field in DB by values 0-1.
Compares file sizes of source and target.
"""
source_file_size = os.path.getsize(source_path)
target_file_size = 0
last_tick = status_val = None
while source_file_size != target_file_size:
if not last_tick or \
time.time() - last_tick >= server.LOG_PROGRESS_SEC:
status_val = target_file_size / source_file_size
last_tick = time.time()
log.debug(direction + "ed %d%%." % int(status_val * 100))
server.update_db(collection=collection,
new_file_id=None,
file=file,
representation=representation,
site=site,
progress=status_val
)
target_file_size = os.path.getsize(target_path)
time.sleep(0.5)

View file

@ -1,7 +1,7 @@
from pype.api import (
Anatomy,
get_project_settings,
get_current_project_settings)
get_local_site_id)
import threading
import concurrent.futures
@ -97,6 +97,7 @@ class SyncServer(PypeModule, ITrayModule):
# 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"
@ -110,8 +111,7 @@ class SyncServer(PypeModule, ITrayModule):
Sets 'enabled' according to global settings for the module.
Shouldnt be doing any initialization, thats a job for 'tray_init'
"""
sync_server_settings = module_settings[self.name]
self.enabled = sync_server_settings["enabled"]
self.enabled = module_settings[self.name]["enabled"]
if asyncio is None:
raise AssertionError(
"SyncServer module requires Python 3.5 or higher."
@ -119,7 +119,8 @@ class SyncServer(PypeModule, ITrayModule):
# some parts of code need to run sequentially, not in async
self.lock = None
self.connection = None # connection to avalon DB to update state
self.presets = None # settings for all enabled projects for sync
# 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
@ -128,7 +129,7 @@ class SyncServer(PypeModule, ITrayModule):
self._paused_representations = set()
self._anatomies = {}
# public facing API
""" Start of Public API """
def add_site(self, collection, representation_id, site_name=None):
"""
Adds new site to representation to be synced.
@ -146,7 +147,7 @@ class SyncServer(PypeModule, ITrayModule):
Returns:
throws ValueError if any issue
"""
if not self.get_synced_preset(collection):
if not self.get_sync_project_setting(collection):
raise ValueError("Project not configured")
if not site_name:
@ -173,7 +174,7 @@ class SyncServer(PypeModule, ITrayModule):
Returns:
throws ValueError if any issue
"""
if not self.get_synced_preset(collection):
if not self.get_sync_project_setting(collection):
raise ValueError("Project not configured")
self.reset_provider_for_file(collection,
@ -322,6 +323,115 @@ class SyncServer(PypeModule, ITrayModule):
""" 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
@ -335,15 +445,14 @@ class SyncServer(PypeModule, ITrayModule):
if not self.enabled:
return
self.presets = None
self.sync_project_settings = None
self.lock = threading.Lock()
self.connection = AvalonMongoDB()
self.connection.install()
try:
self.presets = self.get_synced_presets()
self.set_active_sites(self.presets)
self.set_sync_project_settings()
self.sync_server_thread = SyncServerThread(self)
from .tray.app import SyncServerWindow
self.widget = SyncServerWindow(self)
@ -355,7 +464,7 @@ class SyncServer(PypeModule, ITrayModule):
"There are not set presets for SyncServer OR "
"Credentials provided are invalid, "
"no syncing possible").
format(str(self.presets)), exc_info=True)
format(str(self.sync_project_settings)), exc_info=True)
self.enabled = False
def tray_start(self):
@ -369,7 +478,7 @@ class SyncServer(PypeModule, ITrayModule):
Returns:
None
"""
if self.presets and self.active_sites:
if self.sync_project_settings and self.enabled:
self.sync_server_thread.start()
else:
log.info("No presets or active providers. " +
@ -425,31 +534,48 @@ class SyncServer(PypeModule, ITrayModule):
"""
return self._anatomies.get('project_name') or Anatomy(project_name)
def get_synced_presets(self):
def set_sync_project_settings(self):
"""
Collects all projects which have enabled syncing and their settings
Returns:
(dict): of settings, keys are project names
"""
if self.presets: # presets set already, do not call again and again
return self.presets
Set sync_project_settings for all projects (caching)
sync_presets = {}
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.get_synced_preset(collection)
sync_settings = self._parse_sync_settings_from_settings(
get_project_settings(collection))
if sync_settings:
sync_presets[collection] = sync_settings
default_sites = self._get_default_site_configs()
sync_settings['sites'].update(default_sites)
sync_project_settings[collection] = sync_settings
if not sync_presets:
if not sync_project_settings:
log.info("No enabled and configured projects for sync.")
return sync_presets
self.sync_project_settings = sync_project_settings
def get_synced_preset(self, project_name):
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
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()
return self.sync_project_settings
def get_sync_project_setting(self, project_name):
""" Handles pulling sync_server's settings for enabled 'project_name'
Args:
@ -460,83 +586,91 @@ class SyncServer(PypeModule, ITrayModule):
"""
# presets set already, do not call again and again
# self.log.debug("project preset {}".format(self.presets))
if self.presets and self.presets.get(project_name):
return self.presets.get(project_name)
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)
sync_settings = settings.get("global")["sync_server"]
return self._parse_sync_settings_from_settings(settings)
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
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 for {}, not syncing.".
format(project_name))
log.info("No project setting not syncing.")
return {}
if sync_settings.get("enabled"):
return sync_settings
return {}
def set_active_sites(self, settings):
def _get_configured_sites(self, project_name):
"""
Sets 'self.active_sites' as a dictionary from provided 'settings'
Format:
{ 'project_name' : ('provider_name', 'site_name') }
Args:
settings (dict): all enabled project sync setting (sites labesl,
retries count etc.)
"""
self.active_sites = {}
initiated_handlers = {}
for project_name, project_setting in settings.items():
for site_name, config in project_setting.get("sites").items():
handler = initiated_handlers.get(config["provider"])
if not handler:
handler = lib.factory.get_provider(config["provider"],
site_name,
presets=config)
initiated_handlers[config["provider"]] = handler
if handler.is_active():
if not self.active_sites.get('project_name'):
self.active_sites[project_name] = []
self.active_sites[project_name].append(
(config["provider"], site_name))
if not self.active_sites:
log.info("No sync sites active, no working credentials provided")
def get_active_sites(self, project_name):
"""
Returns active sites (provider configured and able to connect) per
project.
Loops through settings and looks for configured sites and checks
its handlers for particular 'project_name'.
Args:
project_name (str): used as a key in dict
project_setting(dict): dictionary from Settings
only_project_name(string, optional): only interested in
particular project
Returns:
(dict):
Format:
{ 'project_name' : ('provider_name', 'site_name') }
(dict of dict)
{'ProjectA': {'studio':True, 'gdrive':False}}
"""
return self.active_sites[project_name]
settings = self.get_sync_project_setting(project_name)
return self._get_configured_sites_from_setting(settings)
def get_local_site(self, project_name):
"""
Returns active (mine) site for 'project_name' from settings
"""
return self.get_synced_preset(project_name)['config']['active_site']
def _get_configured_sites_from_setting(self, project_setting):
if not project_setting.get("enabled"):
return {}
def get_remote_site(self, project_name):
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 remote (theirs) site for 'project_name' from settings
Returns skeleton settings for 'studio' and user's local site
"""
return self.get_synced_preset(project_name)['config']['remote_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_synced_preset(project_name)["sites"].get(site)
site_preset = self.get_sync_project_setting(project_name)["sites"].\
get(site)
if site_preset:
return site_preset["provider"]
@ -606,8 +740,9 @@ class SyncServer(PypeModule, ITrayModule):
]}
]
}
log.debug("get_sync_representations.query: {}".format(query))
log.debug("active_site:{} - remote_site:{}".format(active_site,
remote_site))
log.debug("query: {}".format(query))
representations = self.connection.find(query)
return representations
@ -654,7 +789,7 @@ class SyncServer(PypeModule, ITrayModule):
return SyncStatus.DO_NOTHING
async def upload(self, collection, file, representation, provider_name,
site_name, tree=None, preset=None):
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
@ -684,40 +819,40 @@ class SyncServer(PypeModule, ITrayModule):
# 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
handler = lib.factory.get_provider(provider_name, site_name,
tree=tree, presets=preset)
remote_file = self._get_remote_file_path(file,
handler.get_roots_config()
)
remote_handler = lib.factory.get_provider(provider_name,
remote_site_name,
tree=tree,
presets=preset)
local_file = self.get_local_file_path(collection,
file.get("path", ""))
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)
folder_id = handler.create_folder(target_folder)
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)
remote_site = self.get_remote_site(collection)
loop = asyncio.get_running_loop()
file_id = await loop.run_in_executor(None,
handler.upload_file,
local_file,
remote_file,
remote_handler.upload_file,
local_file_path,
remote_file_path,
self,
collection,
file,
representation,
remote_site,
remote_site_name,
True
)
return file_id
async def download(self, collection, file, representation, provider_name,
site_name, tree=None, preset=None):
remote_site_name, tree=None, preset=None):
"""
Downloads file to local folder denoted in representation.Context.
@ -735,21 +870,24 @@ class SyncServer(PypeModule, ITrayModule):
(string) - 'name' of local file
"""
with self.lock:
handler = lib.factory.get_provider(provider_name, site_name,
tree=tree, presets=preset)
remote_file_path = self._get_remote_file_path(
file, handler.get_roots_config())
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_file_path = self.get_local_file_path(collection,
file.get("path", ""))
local_folder = os.path.dirname(local_file_path)
os.makedirs(local_folder, exist_ok=True)
local_site = self.get_local_site(collection)
local_site = self.get_active_site(collection)
loop = asyncio.get_running_loop()
file_id = await loop.run_in_executor(None,
handler.download_file,
remote_handler.download_file,
remote_file_path,
local_file_path,
self,
@ -907,7 +1045,7 @@ class SyncServer(PypeModule, ITrayModule):
raise ValueError("Misconfiguration, only one of side and " +
"site_name arguments should be passed.")
local_site = self.get_local_site(collection)
local_site = self.get_active_site(collection)
remote_site = self.get_remote_site(collection)
if side:
@ -1066,19 +1204,14 @@ class SyncServer(PypeModule, ITrayModule):
Returns:
only logs, catches IndexError and OSError
"""
my_local_site = self.get_my_local_site(collection)
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
handler = None
sites = self.get_active_sites(collection)
for provider_name, provider_site_name in sites:
if provider_site_name == site_name:
handler = lib.factory.get_provider(provider_name,
site_name)
break
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 = {
@ -1093,12 +1226,14 @@ class SyncServer(PypeModule, ITrayModule):
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(file["path"]))
local_file = self.get_local_file_path(collection,
file.get("path", ""))
os.remove(local_file)
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)
@ -1109,31 +1244,13 @@ class SyncServer(PypeModule, ITrayModule):
raise ValueError(msg)
try:
folder = os.path.dirname(local_file)
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_my_local_site(self, project_name=None):
"""
Returns name of current user local_site
Args:
project_name (string):
Returns:
(string)
"""
if project_name:
settings = get_project_settings(project_name)
else:
settings = get_current_project_settings()
sync_server_presets = settings["global"]["sync_server"]["config"]
return sync_server_presets.get("local_id")
def get_loop_delay(self, project_name):
"""
Return count of seconds before next synchronization loop starts
@ -1141,7 +1258,8 @@ class SyncServer(PypeModule, ITrayModule):
Returns:
(int): in seconds
"""
return int(self.presets[project_name]["config"]["loop_delay"])
ld = self.sync_project_settings[project_name]["config"]["loop_delay"]
return int(ld)
def show_widget(self):
"""Show dialog to enter credentials"""
@ -1215,59 +1333,35 @@ class SyncServer(PypeModule, ITrayModule):
val = {"files.$[f].sites.$[s].progress": progress}
return val
def get_local_file_path(self, collection, path):
def _resolve_paths(self, file_path, collection,
remote_site_name=None, remote_handler=None):
"""
Auxiliary function for replacing rootless path with real path
Returns tuple of local and remote file paths with {root}
placeholders replaced with proper values from Settings or Anatomy
Works with multi roots.
If root definition is not found in Settings, anatomy is used
Args:
collection (string): project name
path (dictionary): 'path' to file with {root}
Returns:
(string) - absolute path on local system
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
"""
local_active_site = self.get_local_site(collection)
sites = self.get_synced_preset(collection)["sites"]
root_config = sites[local_active_site]["root"]
remote_file_path = ''
if remote_handler:
root_configs = self._get_roots_config(self.sync_project_settings,
collection,
remote_site_name)
if not root_config.get("root"):
root_config = {"root": root_config}
remote_file_path = remote_handler.resolve_path(file_path,
root_configs)
try:
path = path.format(**root_config)
except KeyError:
try:
anatomy = self.get_anatomy(collection)
path = anatomy.fill_root(path)
except KeyError:
msg = "Error in resolving local root from anatomy"
self.log.error(msg)
raise ValueError(msg)
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 path
def _get_remote_file_path(self, file, root_config):
"""
Auxiliary function for replacing rootless path with real path
Args:
file (dictionary): file info, get 'path' to file with {root}
root_config (dict): value of {root} for remote location
Returns:
(string) - absolute path on remote location
"""
path = file.get("path", "")
if not root_config.get("root"):
root_config = {"root": root_config}
try:
return path.format(**root_config)
except KeyError:
msg = "Error in resolving remote root, unknown key"
self.log.error(msg)
return local_file_path, remote_file_path
def _get_retries_arr(self, project_name):
"""
@ -1278,12 +1372,20 @@ class SyncServer(PypeModule, ITrayModule):
Returns:
(list)
"""
retry_cnt = self.presets[project_name].get("config")["retry_cnt"]
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']
class SyncServerThread(threading.Thread):
"""
@ -1334,20 +1436,20 @@ class SyncServerThread(threading.Thread):
while self.is_running and not self.module.is_paused():
import time
start_time = None
for collection, preset in self.module.get_synced_presets().\
self.module.set_sync_project_settings() # clean cache
for collection, preset in self.module.get_sync_project_settings().\
items():
if self.module.is_project_paused(collection):
start_time = time.time()
local_site, remote_site = self._working_sites(collection)
if not all([local_site, remote_site]):
continue
start_time = time.time()
sync_repres = self.module.get_sync_representations(
collection,
preset.get('config')["active_site"],
preset.get('config')["remote_site"]
local_site,
remote_site
)
local_site = preset.get('config')["active_site"]
remote_site = preset.get('config')["remote_site"]
task_files_to_process = []
files_processed_info = []
# process only unique file paths in one batch
@ -1477,3 +1579,24 @@ class SyncServerThread(threading.Thread):
self.executor.shutdown(wait=True)
await asyncio.sleep(0.07)
self.loop.stop()
def _working_sites(self, collection):
if self.module.is_project_paused(collection):
log.debug("Both sites same, skipping")
return None, None
local_site = self.module.get_active_site(collection)
remote_site = self.module.get_remote_site(collection)
if local_site == remote_site:
log.debug("{}-{} sites same, skipping".format(local_site,
remote_site))
return None, None
if not all([self.module.site_is_working(collection, local_site),
self.module.site_is_working(collection, remote_site)]):
log.debug("Some of the sites {} - {} is not ".format(local_site,
remote_site) +
"working properly")
return None, None
return local_site, remote_site

View file

@ -15,8 +15,7 @@ from avalon.tools.delegates import PrettyTimeDelegate, pretty_timestamp
from bson.objectid import ObjectId
from pype.lib import PypeLogger
import json
from pype.api import get_local_site_id
log = PypeLogger().get_logger("SyncServer")
@ -29,6 +28,8 @@ STATUS = {
-1: 'Not available'
}
DUMMY_PROJECT = "No project configured"
class SyncServerWindow(QtWidgets.QDialog):
"""
@ -157,7 +158,9 @@ class SyncProjectListWidget(ProjectListWidget):
model = self.project_list.model()
model.clear()
for project_name in self.sync_server.get_synced_presets().keys():
project_name = None
for project_name in self.sync_server.get_sync_project_settings().\
keys():
if self.sync_server.is_paused() or \
self.sync_server.is_project_paused(project_name):
icon = self._get_icon("paused")
@ -166,8 +169,8 @@ class SyncProjectListWidget(ProjectListWidget):
model.appendRow(QtGui.QStandardItem(icon, project_name))
if len(self.sync_server.get_synced_presets().keys()) == 0:
model.appendRow(QtGui.QStandardItem("No project configured"))
if len(self.sync_server.get_sync_project_settings().keys()) == 0:
model.appendRow(QtGui.QStandardItem(DUMMY_PROJECT))
self.current_project = self.project_list.currentIndex().data(
QtCore.Qt.DisplayRole
@ -176,7 +179,8 @@ class SyncProjectListWidget(ProjectListWidget):
self.current_project = self.project_list.model().item(0). \
data(QtCore.Qt.DisplayRole)
self.local_site = self.sync_server.get_local_site(project_name)
if project_name:
self.local_site = self.sync_server.get_active_site(project_name)
def _get_icon(self, status):
if not self.icons.get(status):
@ -200,7 +204,6 @@ class SyncProjectListWidget(ProjectListWidget):
menu = QtWidgets.QMenu()
actions_mapping = {}
action = None
if self.sync_server.is_project_paused(self.project_name):
action = QtWidgets.QAction("Unpause")
actions_mapping[action] = self._unpause
@ -209,8 +212,7 @@ class SyncProjectListWidget(ProjectListWidget):
actions_mapping[action] = self._pause
menu.addAction(action)
if self.local_site == self.sync_server.get_my_local_site(
self.project_name):
if self.local_site == get_local_site_id():
action = QtWidgets.QAction("Clear local project")
actions_mapping[action] = self._clear_project
menu.addAction(action)
@ -239,6 +241,7 @@ class SyncProjectListWidget(ProjectListWidget):
self.project_name = None
self.refresh()
class ProjectModel(QtCore.QAbstractListModel):
def __init__(self, *args, projects=None, **kwargs):
super(ProjectModel, self).__init__(*args, **kwargs)
@ -254,6 +257,7 @@ class ProjectModel(QtCore.QAbstractListModel):
def rowCount(self, index):
return len(self.todos)
class SyncRepresentationWidget(QtWidgets.QWidget):
"""
Summary dialog with list of representations that matches current
@ -473,10 +477,10 @@ 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(project_name)
local_site_name = self.sync_server.get_my_local_site()
try:
self.sync_server.add_site(
self.table_view.model()._project,
project_name,
self.representation_id,
local_site_name
)
@ -498,13 +502,14 @@ class SyncRepresentationWidget(QtWidgets.QWidget):
"""
log.info("Removing {}".format(self.representation_id))
try:
local_site = get_local_site_id()
self.sync_server.remove_site(
self.table_view.model()._project,
self.representation_id,
'local_0',
local_site,
True
)
self.message_generated.emit("Site local_0 removed")
self.message_generated.emit("Site {} removed".format(local_site))
except ValueError as exp:
self.message_generated.emit("Error {}".format(str(exp)))
@ -535,6 +540,9 @@ class SyncRepresentationWidget(QtWidgets.QWidget):
return
fpath = self.item.path
project = self.table_view.model()._project
fpath = self.sync_server.get_local_file_path(project, fpath)
fpath = os.path.normpath(os.path.dirname(fpath))
if os.path.isdir(fpath):
if 'win' in sys.platform: # windows
@ -620,11 +628,13 @@ class SyncRepresentationModel(QtCore.QAbstractTableModel):
self.filter = None
self._initialized = False
if not self._project or self._project == DUMMY_PROJECT:
return
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_local_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()
@ -790,14 +800,12 @@ class SyncRepresentationModel(QtCore.QAbstractTableModel):
repre.get("files_size", 0),
1,
STATUS[repre.get("status", -1)],
self.sync_server.get_local_file_path(self._project,
files[0].get('path'))
files[0].get('path')
)
self._data.append(item)
self._rec_loaded += 1
def canFetchMore(self, index):
"""
Check if there are more records than currently loaded
@ -849,6 +857,9 @@ 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').\
# replace('True', 'true').replace('None', 'null'))
representations = self.dbcon.aggregate(self.query)
self.refresh(representations)
@ -871,7 +882,8 @@ class SyncRepresentationModel(QtCore.QAbstractTableModel):
project (str): name of project
"""
self._project = project
self.local_site = self.sync_server.get_local_site(self._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.refresh()
@ -886,7 +898,6 @@ class SyncRepresentationModel(QtCore.QAbstractTableModel):
Returns:
(QModelIndex)
"""
index = None
for i in range(self.rowCount(None)):
index = self.index(i, 0)
value = self.data(index, Qt.UserRole)
@ -995,7 +1006,7 @@ class SyncRepresentationModel(QtCore.QAbstractTableModel):
0]},
'failed_remote_tries': {
'$cond': [{'$size': '$order_remote.tries'},
{'$first': '$order_local.tries'},
{'$first': '$order_remote.tries'},
0]},
'paused_remote': {
'$cond': [{'$size': "$order_remote.paused"},
@ -1022,9 +1033,9 @@ class SyncRepresentationModel(QtCore.QAbstractTableModel):
# select last touch of file
'updated_dt_remote': {'$max': "$updated_dt_remote"},
'failed_remote': {'$sum': '$failed_remote'},
'failed_local': {'$sum': '$paused_remote'},
'failed_local_tries': {'$sum': '$failed_local_tries'},
'failed_local': {'$sum': '$failed_local'},
'failed_remote_tries': {'$sum': '$failed_remote_tries'},
'failed_local_tries': {'$sum': '$failed_local_tries'},
'paused_remote': {'$sum': '$paused_remote'},
'paused_local': {'$sum': '$paused_local'},
'updated_dt_local': {'$max': "$updated_dt_local"}
@ -1381,8 +1392,10 @@ class SyncRepresentationDetailWidget(QtWidgets.QWidget):
return
fpath = self.item.path
fpath = os.path.normpath(os.path.dirname(fpath))
project = self.table_view.model()._project
fpath = self.sync_server.get_local_file_path(project, fpath)
fpath = os.path.normpath(os.path.dirname(fpath))
if os.path.isdir(fpath):
if 'win' in sys.platform: # windows
subprocess.Popen('explorer "%s"' % fpath)
@ -1460,7 +1473,7 @@ 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_local_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
@ -1595,8 +1608,7 @@ class SyncRepresentationDetailModel(QtCore.QAbstractTableModel):
STATUS[repre.get("status", -1)],
repre.get("tries"),
'\n'.join(errors),
self.sync_server.get_local_file_path(self._project,
file.get('path'))
file.get('path')
)
self._data.append(item)
@ -1664,7 +1676,6 @@ class SyncRepresentationDetailModel(QtCore.QAbstractTableModel):
Returns:
(QModelIndex)
"""
index = None
for i in range(self.rowCount(None)):
index = self.index(i, 0)
value = self.data(index, Qt.UserRole)
@ -1772,14 +1783,15 @@ class SyncRepresentationDetailModel(QtCore.QAbstractTableModel):
"$order_local.error",
[""]]}},
'tries': {'$first': {
'$cond': [{'$size': "$order_local.tries"},
"$order_local.tries",
{'$cond': [
{'$size': "$order_remote.tries"},
"$order_remote.tries",
[]
]}
]}}
'$cond': [
{'$size': "$order_local.tries"},
"$order_local.tries",
{'$cond': [
{'$size': "$order_remote.tries"},
"$order_remote.tries",
[]
]}
]}}
}},
{"$project": self.projection},
{"$sort": self.sort},
@ -2010,6 +2022,7 @@ class SizeDelegate(QtWidgets.QStyledItemDelegate):
value /= 1024.0
return "%.1f%s%s" % (value, 'Yi', suffix)
def _convert_progress(value):
try:
progress = float(value)

View file

@ -966,10 +966,16 @@ class IntegrateAssetNew(pyblish.api.InstancePlugin):
["global"]
["sync_server"])
local_site_id = pype.api.get_local_site_id()
if sync_server_presets["enabled"]:
local_site = sync_server_presets["config"].\
get("active_site", "studio").strip()
if local_site == 'local':
local_site = local_site_id
remote_site = sync_server_presets["config"].get("remote_site")
if remote_site == 'local':
remote_site = local_site_id
rec = {
"_id": io.ObjectId(),

View file

@ -198,11 +198,10 @@
"sync_server": {
"enabled": true,
"config": {
"local_id": "local_0",
"retry_cnt": "3",
"loop_delay": "60",
"active_site": "studio",
"remote_site": "gdrive"
"remote_site": "studio"
},
"sites": {
"gdrive": {
@ -211,20 +210,6 @@
"root": {
"work": ""
}
},
"studio": {
"provider": "local_drive",
"credentials_url": "",
"root": {
"work": ""
}
},
"local_0": {
"provider": "local_drive",
"credentials_url": "",
"root": {
"work": ""
}
}
}
}

View file

@ -17,12 +17,6 @@
"label": "Config",
"collapsible": true,
"children": [
{
"type": "text",
"key": "local_id",
"label": "Local ID"
},
{
"type": "text",
"key": "retry_cnt",

View file

@ -373,7 +373,7 @@ def apply_local_settings_on_system_settings(system_settings, local_settings):
def apply_local_settings_on_anatomy_settings(
anatomy_settings, local_settings, project_name
anatomy_settings, local_settings, project_name, site_name=None
):
"""Apply local settings on anatomy settings.
@ -398,36 +398,40 @@ def apply_local_settings_on_anatomy_settings(
if not local_settings:
return
local_project_settings = local_settings.get("projects")
if not local_project_settings:
local_project_settings = local_settings.get("projects") or {}
# Check for roots existence in local settings first
roots_project_locals = (
local_project_settings
.get(project_name, {})
.get("roots", {})
)
roots_default_locals = (
local_project_settings
.get(DEFAULT_PROJECT_KEY, {})
.get("roots", {})
)
# Skip rest of processing if roots are not set
if not roots_project_locals and not roots_default_locals:
return
project_locals = local_project_settings.get(project_name) or {}
default_locals = local_project_settings.get(DEFAULT_PROJECT_KEY) or {}
active_site = project_locals.get("active_site")
if not active_site:
active_site = default_locals.get("active_site")
if not active_site:
# Get active site from settings
if site_name is None:
project_settings = get_project_settings(project_name)
active_site = (
project_settings
["global"]
["sync_server"]
["config"]
["active_site"]
site_name = (
project_settings["global"]["sync_server"]["config"]["active_site"]
)
# QUESTION should raise an exception?
if not active_site:
if not site_name:
return
roots_locals = default_locals.get("roots", {}).get(active_site, {})
if project_name != DEFAULT_PROJECT_KEY:
roots_locals.update(
project_locals.get("roots", {}).get(active_site, {})
)
# Combine roots from local settings
roots_locals = roots_default_locals.get(site_name) or {}
roots_locals.update(roots_project_locals.get(site_name) or {})
# Skip processing if roots for current active site are not available in
# local settings
if not roots_locals:
return
@ -442,6 +446,44 @@ def apply_local_settings_on_anatomy_settings(
)
def apply_local_settings_on_project_settings(
project_settings, local_settings, project_name
):
"""Apply local settings on project settings.
Currently is modifying active site and remote site in sync server.
Args:
project_settings (dict): Data for project settings.
local_settings (dict): Data of local settings.
project_name (str): Name of project for which settings data are.
"""
if not local_settings:
return
local_project_settings = local_settings.get("projects")
if not local_project_settings:
return
project_locals = local_project_settings.get(project_name) or {}
default_locals = local_project_settings.get(DEFAULT_PROJECT_KEY) or {}
active_site = (
project_locals.get("active_site")
or default_locals.get("active_site")
)
remote_site = (
project_locals.get("remote_site")
or default_locals.get("remote_site")
)
sync_server_config = project_settings["global"]["sync_server"]["config"]
if active_site:
sync_server_config["active_site"] = active_site
if remote_site:
sync_server_config["remote_site"] = remote_site
def get_system_settings(clear_metadata=True):
"""System settings with applied studio overrides."""
default_values = get_default_settings()[SYSTEM_SETTINGS_KEY]
@ -463,6 +505,8 @@ def get_default_project_settings(clear_metadata=True):
result = apply_overrides(default_values, studio_values)
if clear_metadata:
clear_metadata_from_settings(result)
local_settings = get_local_settings()
apply_local_settings_on_project_settings(result, local_settings, None)
return result
@ -485,7 +529,7 @@ def get_default_anatomy_settings(clear_metadata=True):
return result
def get_anatomy_settings(project_name, clear_metadata=True):
def get_anatomy_settings(project_name, site_name=None, exclude_locals=False):
"""Project anatomy data with applied studio and project overrides."""
if not project_name:
raise ValueError(
@ -498,23 +542,19 @@ def get_anatomy_settings(project_name, clear_metadata=True):
project_name
)
# TODO uncomment and remove hotfix result when overrides of anatomy
# are stored correctly.
# result = apply_overrides(studio_overrides, project_overrides)
result = copy.deepcopy(studio_overrides)
if project_overrides:
for key, value in project_overrides.items():
result[key] = value
if clear_metadata:
clear_metadata_from_settings(result)
result = apply_overrides(studio_overrides, project_overrides)
clear_metadata_from_settings(result)
if not exclude_locals:
local_settings = get_local_settings()
apply_local_settings_on_anatomy_settings(
result, local_settings, project_name
result, local_settings, project_name, site_name
)
return result
def get_project_settings(project_name, clear_metadata=True):
def get_project_settings(project_name, exclude_locals=False):
"""Project settings with applied studio and project overrides."""
if not project_name:
raise ValueError(
@ -528,8 +568,14 @@ def get_project_settings(project_name, clear_metadata=True):
)
result = apply_overrides(studio_overrides, project_overrides)
if clear_metadata:
clear_metadata_from_settings(result)
clear_metadata_from_settings(result)
if not exclude_locals:
local_settings = get_local_settings()
apply_local_settings_on_project_settings(
result, local_settings, project_name
)
return result

View file

@ -22,12 +22,6 @@ from .constants import (
NOT_SET = type("NOT_SET", (), {})()
def get_active_sites(project_settings):
global_entity = project_settings["project_settings"]["global"]
sites_entity = global_entity["sync_server"]["sites"]
return tuple(sites_entity.keys())
class _ProjectListWidget(ProjectListWidget):
def on_item_clicked(self, new_index):
new_project_name = new_index.data(QtCore.Qt.DisplayRole)
@ -223,9 +217,10 @@ class RootInputWidget(QtWidgets.QWidget):
class RootsWidget(QtWidgets.QWidget):
def __init__(self, project_settings, parent):
def __init__(self, modules_manager, project_settings, parent):
super(RootsWidget, self).__init__(parent)
self.modules_manager = modules_manager
self.project_settings = project_settings
self.site_widgets = []
self.local_project_settings = None
@ -241,6 +236,15 @@ class RootsWidget(QtWidgets.QWidget):
self.content_layout.removeItem(item)
self.site_widgets = []
def _get_active_sites(self):
sync_server_module = (
self.modules_manager.modules_by_name["sync_server"]
)
return sync_server_module.get_active_sites_from_settings(
self.project_settings["project_settings"].value
)
def refresh(self):
self._clear_widgets()
@ -251,7 +255,7 @@ class RootsWidget(QtWidgets.QWidget):
self.project_settings[PROJECT_ANATOMY_KEY][LOCAL_ROOTS_KEY]
)
# Site label
for site_name in get_active_sites(self.project_settings):
for site_name in self._get_active_sites():
site_widget = QtWidgets.QWidget(self)
site_layout = QtWidgets.QVBoxLayout(site_widget)
@ -294,10 +298,12 @@ class RootsWidget(QtWidgets.QWidget):
class _SiteCombobox(QtWidgets.QWidget):
input_label = None
def __init__(self, project_settings, parent):
def __init__(self, modules_manager, project_settings, parent):
super(_SiteCombobox, self).__init__(parent)
self.project_settings = project_settings
self.modules_manager = modules_manager
self.local_project_settings = None
self.local_project_settings_orig = None
self.project_name = None
@ -547,7 +553,14 @@ class AciveSiteCombo(_SiteCombobox):
input_label = "Active site"
def _get_project_sites(self):
return get_active_sites(self.project_settings)
sync_server_module = (
self.modules_manager.modules_by_name["sync_server"]
)
if self.project_name is None:
return sync_server_module.get_active_sites_from_settings(
self.project_settings["project_settings"].value
)
return sync_server_module.get_active_sites(self.project_name)
def _get_local_settings_item(self, project_name=None, data=None):
if project_name is None:
@ -575,9 +588,14 @@ class RemoteSiteCombo(_SiteCombobox):
input_label = "Remote site"
def _get_project_sites(self):
global_entity = self.project_settings["project_settings"]["global"]
sites_entity = global_entity["sync_server"]["sites"]
return tuple(sites_entity.keys())
sync_server_module = (
self.modules_manager.modules_by_name["sync_server"]
)
if self.project_name is None:
return sync_server_module.get_remote_sites_from_settings(
self.project_settings["project_settings"].value
)
return sync_server_module.get_remote_sites(self.project_name)
def _get_local_settings_item(self, project_name=None, data=None):
if project_name is None:
@ -601,17 +619,22 @@ class RemoteSiteCombo(_SiteCombobox):
class RootSiteWidget(QtWidgets.QWidget):
def __init__(self, project_settings, parent):
def __init__(self, modules_manager, project_settings, parent):
self._parent_widget = parent
super(RootSiteWidget, self).__init__(parent)
self.modules_manager = modules_manager
self.project_settings = project_settings
self._project_name = None
sites_widget = QtWidgets.QWidget(self)
active_site_widget = AciveSiteCombo(project_settings, sites_widget)
remote_site_widget = RemoteSiteCombo(project_settings, sites_widget)
active_site_widget = AciveSiteCombo(
modules_manager, project_settings, sites_widget
)
remote_site_widget = RemoteSiteCombo(
modules_manager, project_settings, sites_widget
)
sites_layout = QtWidgets.QHBoxLayout(sites_widget)
sites_layout.setContentsMargins(0, 0, 0, 0)
@ -619,7 +642,7 @@ class RootSiteWidget(QtWidgets.QWidget):
sites_layout.addWidget(remote_site_widget)
sites_layout.addWidget(SpacerWidget(self), 1)
roots_widget = RootsWidget(project_settings, self)
roots_widget = RootsWidget(modules_manager, project_settings, self)
main_layout = QtWidgets.QVBoxLayout(self)
main_layout.addWidget(sites_widget)
@ -669,13 +692,17 @@ class ProjectValue(dict):
class ProjectSettingsWidget(QtWidgets.QWidget):
def __init__(self, project_settings, parent):
def __init__(self, modules_manager, project_settings, parent):
super(ProjectSettingsWidget, self).__init__(parent)
self.local_project_settings = {}
self.modules_manager = modules_manager
projects_widget = _ProjectListWidget(self)
roos_site_widget = RootSiteWidget(project_settings, self)
roos_site_widget = RootSiteWidget(
modules_manager, project_settings, self
)
main_layout = QtWidgets.QHBoxLayout(self)
main_layout.setContentsMargins(0, 0, 0, 0)

View file

@ -11,6 +11,7 @@ from pype.api import (
SystemSettings,
ProjectSettings
)
from pype.modules import ModulesManager
from .widgets import (
SpacerWidget,
@ -37,6 +38,7 @@ class LocalSettingsWidget(QtWidgets.QWidget):
self.system_settings = SystemSettings()
self.project_settings = ProjectSettings()
self.modules_manager = ModulesManager()
self.main_layout = QtWidgets.QVBoxLayout(self)
@ -108,7 +110,9 @@ class LocalSettingsWidget(QtWidgets.QWidget):
project_layout.setContentsMargins(CHILD_OFFSET, 5, 0, 0)
project_expand_widget.set_content_widget(project_content)
projects_widget = ProjectSettingsWidget(self.project_settings, self)
projects_widget = ProjectSettingsWidget(
self.modules_manager, self.project_settings, self
)
project_layout.addWidget(projects_widget)
self.main_layout.addWidget(project_expand_widget)

@ -1 +0,0 @@
Subproject commit 7adabe8f0e6858bfe5b6bf0b39bd428ed72d0452