Merge branch 'develop' into feature/royalrender-integration

This commit is contained in:
Ondřej Samohel 2021-11-12 21:17:38 +01:00 committed by GitHub
commit 65a98071ef
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
36 changed files with 685 additions and 403 deletions

View file

@ -57,6 +57,17 @@ def tray(debug=False):
PypeCommands().launch_tray(debug)
@PypeCommands.add_modules
@main.group(help="Run command line arguments of OpenPype modules")
@click.pass_context
def module(ctx):
"""Module specific commands created dynamically.
These commands are generated dynamically by currently loaded addon/modules.
"""
pass
@main.command()
@click.option("-d", "--debug", is_flag=True, help="Print debug messages")
@click.option("--ftrack-url", envvar="FTRACK_SERVER",
@ -349,8 +360,27 @@ def runtests(folder, mark, pyargs):
"""Run all automatic tests after proper initialization via start.py"""
PypeCommands().run_tests(folder, mark, pyargs)
@main.command()
@click.option("-d", "--debug",
is_flag=True, help=("Run process in debug mode"))
@click.option("-a", "--active_site", required=True,
help="Name of active stie")
def syncserver(debug, active_site):
"""Run sync site server in background.
@main.command()
def context_selector():
"""Show widget to show context selector."""
PypeCommands().show_context_selector()
Some Site Sync use cases need to expose site to another one.
For example if majority of artists work in studio, they are not using
SS at all, but if you want to expose published assets to 'studio' site
to SFTP for only a couple of artists, some background process must
mark published assets to live on multiple sites (they might be
physically in same location - mounted shared disk).
Process mimics OP Tray with specific 'active_site' name, all
configuration for this "dummy" user comes from Setting or Local
Settings (configured by starting OP Tray with env
var OPENPYPE_LOCAL_ID set to 'active_site'.
"""
if debug:
os.environ['OPENPYPE_DEBUG'] = '3'
PypeCommands().syncserver(active_site)

View file

@ -20,11 +20,16 @@ log = PypeLogger.get_logger("WebServer")
class RestApiResource:
"""Resource carrying needed info and Avalon DB connection for publish."""
def __init__(self, server_manager, executable, upload_dir):
def __init__(self, server_manager, executable, upload_dir,
studio_task_queue=None):
self.server_manager = server_manager
self.upload_dir = upload_dir
self.executable = executable
if studio_task_queue is None:
studio_task_queue = collections.deque().dequeu
self.studio_task_queue = studio_task_queue
self.dbcon = AvalonMongoDB()
self.dbcon.install()
@ -182,8 +187,6 @@ class WebpublisherBatchPublishEndpoint(_RestApiEndpoint):
msg = "Non existent OpenPype executable {}".format(openpype_app)
raise RuntimeError(msg)
# for postprocessing in host, currently only PS
output = {}
log.info("WebpublisherBatchPublishEndpoint called")
content = await request.json()
@ -203,7 +206,10 @@ class WebpublisherBatchPublishEndpoint(_RestApiEndpoint):
# would change
# - targets argument is not used in 'remotepublishfromapp'
"targets": None
}
},
# does publish need to be handled by a queue, eg. only
# single process running concurrently?
"add_to_queue": True
}
]
@ -219,19 +225,20 @@ class WebpublisherBatchPublishEndpoint(_RestApiEndpoint):
"targets": ["filespublish"]
}
add_to_queue = False
if content.get("studio_processing"):
log.info("Post processing called")
batch_data = parse_json(os.path.join(batch_path, "manifest.json"))
if not batch_data:
raise ValueError(
"Cannot parse batch meta in {} folder".format(batch_path))
"Cannot parse batch manifest in {}".format(batch_path))
task_dir_name = batch_data["tasks"][0]
task_data = parse_json(os.path.join(batch_path, task_dir_name,
"manifest.json"))
if not task_data:
raise ValueError(
"Cannot parse batch meta in {} folder".format(task_data))
"Cannot parse task manifest in {}".format(task_data))
for process_filter in studio_processing_filters:
filter_extensions = process_filter.get("extensions") or []
@ -244,6 +251,7 @@ class WebpublisherBatchPublishEndpoint(_RestApiEndpoint):
add_args.update(
process_filter.get("arguments") or {}
)
add_to_queue = process_filter["add_to_queue"]
break
args = [
@ -263,11 +271,14 @@ class WebpublisherBatchPublishEndpoint(_RestApiEndpoint):
args.append(value)
log.info("args:: {}".format(args))
if add_to_queue:
log.debug("Adding to queue")
self.resource.studio_task_queue.append(args)
else:
subprocess.call(args)
subprocess.call(args)
return Response(
status=200,
body=self.resource.encode(output),
content_type="application/json"
)

View file

@ -1,8 +1,10 @@
import collections
import time
import os
from datetime import datetime
import requests
import json
import subprocess
from openpype.lib import PypeLogger
@ -31,10 +33,13 @@ def run_webserver(*args, **kwargs):
port = kwargs.get("port") or 8079
server_manager = webserver_module.create_new_server_manager(port, host)
webserver_url = server_manager.url
# queue for remotepublishfromapp tasks
studio_task_queue = collections.deque()
resource = RestApiResource(server_manager,
upload_dir=kwargs["upload_dir"],
executable=kwargs["executable"])
executable=kwargs["executable"],
studio_task_queue=studio_task_queue)
projects_endpoint = WebpublisherProjectsEndpoint(resource)
server_manager.add_route(
"GET",
@ -88,6 +93,10 @@ def run_webserver(*args, **kwargs):
if time.time() - last_reprocessed > 20:
reprocess_failed(kwargs["upload_dir"], webserver_url)
last_reprocessed = time.time()
if studio_task_queue:
args = studio_task_queue.popleft()
subprocess.call(args) # blocking call
time.sleep(1.0)

View file

@ -522,6 +522,11 @@ def get_local_site_id():
Identifier is created if does not exists yet.
"""
# override local id from environment
# used for background syncing
if os.environ.get("OPENPYPE_LOCAL_ID"):
return os.environ["OPENPYPE_LOCAL_ID"]
registry = OpenPypeSettingsRegistry()
try:
return registry.get_item("localId")

View file

@ -22,6 +22,9 @@ def import_filepath(filepath, module_name=None):
if module_name is None:
module_name = os.path.splitext(os.path.basename(filepath))[0]
# Make sure it is not 'unicode' in Python 2
module_name = str(module_name)
# Prepare module object where content of file will be parsed
module = types.ModuleType(module_name)

View file

@ -22,6 +22,10 @@ OpenPype modules should contain separated logic of specific kind of implementati
- `__init__` should not be overridden and `initialize` should not do time consuming part but only prepare base data about module
- also keep in mind that they may be initialized in headless mode
- connection with other modules is made with help of interfaces
- `cli` method - add cli commands specific for the module
- command line arguments are handled using `click` python module
- `cli` method should expect single argument which is click group on which can be called any group specific methods (e.g. `add_command` to add another click group as children see `ExampleAddon`)
- it is possible to add trigger cli commands using `./openpype_console module <module_name> <command> *args`
## Addon class `OpenPypeAddOn`
- inherits from `OpenPypeModule` but is enabled by default and doesn't have to implement `initialize` and `connect_with_modules` methods
@ -140,4 +144,4 @@ class ClockifyModule(
### TrayModulesManager
- inherits from `ModulesManager`
- has specific implementation for Pype Tray tool and handle `ITrayModule` methods
- has specific implementation for Pype Tray tool and handle `ITrayModule` methods

View file

@ -107,12 +107,9 @@ class _InterfacesClass(_ModuleClass):
if attr_name in ("__path__", "__file__"):
return None
# Fake Interface if is not missing
self.__attributes__[attr_name] = type(
attr_name,
(MissingInteface, ),
{}
)
raise ImportError((
"cannot import name '{}' from 'openpype_interfaces'"
).format(attr_name))
return self.__attributes__[attr_name]
@ -212,54 +209,17 @@ def _load_interfaces():
_InterfacesClass(modules_key)
)
log = PypeLogger.get_logger("InterfacesLoader")
from . import interfaces
dirpaths = get_module_dirs()
interface_paths = []
interface_paths.append(
os.path.join(get_default_modules_dir(), "interfaces.py")
)
for dirpath in dirpaths:
if not os.path.exists(dirpath):
for attr_name in dir(interfaces):
attr = getattr(interfaces, attr_name)
if (
not inspect.isclass(attr)
or attr is OpenPypeInterface
or not issubclass(attr, OpenPypeInterface)
):
continue
for filename in os.listdir(dirpath):
if filename in ("__pycache__", ):
continue
full_path = os.path.join(dirpath, filename)
if not os.path.isdir(full_path):
continue
interfaces_path = os.path.join(full_path, "interfaces.py")
if os.path.exists(interfaces_path):
interface_paths.append(interfaces_path)
for full_path in interface_paths:
if not os.path.exists(full_path):
continue
try:
# Prepare module object where content of file will be parsed
module = import_filepath(full_path)
except Exception:
log.warning(
"Failed to load path: \"{0}\"".format(full_path),
exc_info=True
)
continue
for attr_name in dir(module):
attr = getattr(module, attr_name)
if (
not inspect.isclass(attr)
or attr is OpenPypeInterface
or not issubclass(attr, OpenPypeInterface)
):
continue
setattr(openpype_interfaces, attr_name, attr)
setattr(openpype_interfaces, attr_name, attr)
def load_modules(force=False):
@ -333,6 +293,15 @@ def _load_modules():
# - check manifest and content of manifest
try:
if os.path.isdir(fullpath):
# Module without init file can't be used as OpenPype module
# because the module class could not be imported
init_file = os.path.join(fullpath, "__init__.py")
if not os.path.exists(init_file):
log.info((
"Skipping module directory because of"
" missing \"__init__.py\" file. \"{}\""
).format(fullpath))
continue
import_module_from_dirpath(dirpath, filename, modules_key)
elif ext in (".py", ):
@ -369,14 +338,6 @@ class OpenPypeInterface:
pass
class MissingInteface(OpenPypeInterface):
"""Class representing missing interface class.
Used when interface is not available from currently registered paths.
"""
pass
@six.add_metaclass(ABCMeta)
class OpenPypeModule:
"""Base class of pype module.
@ -431,6 +392,28 @@ class OpenPypeModule:
"""
return {}
def cli(self, module_click_group):
"""Add commands to click group.
The best practise is to create click group for whole module which is
used to separate commands.
class MyPlugin(OpenPypeModule):
...
def cli(self, module_click_group):
module_click_group.add_command(cli_main)
@click.group(<module name>, help="<Any help shown in cmd>")
def cli_main():
pass
@cli_main.command()
def mycommand():
print("my_command")
"""
pass
class OpenPypeAddOn(OpenPypeModule):
# Enable Addon by default

View file

@ -1,8 +1,10 @@
import os
import json
import collections
from openpype.modules import OpenPypeModule
import click
from openpype.modules import OpenPypeModule
from openpype_interfaces import (
ITrayModule,
IPluginPaths,
@ -417,3 +419,55 @@ class FtrackModule(
cred = credentials.get_credentials(self.ftrack_url)
return cred.get("username"), cred.get("api_key")
def cli(self, click_group):
click_group.add_command(cli_main)
@click.group(FtrackModule.name, help="Ftrack module related commands.")
def cli_main():
pass
@cli_main.command()
@click.option("-d", "--debug", is_flag=True, help="Print debug messages")
@click.option("--ftrack-url", envvar="FTRACK_SERVER",
help="Ftrack server url")
@click.option("--ftrack-user", envvar="FTRACK_API_USER",
help="Ftrack api user")
@click.option("--ftrack-api-key", envvar="FTRACK_API_KEY",
help="Ftrack api key")
@click.option("--legacy", is_flag=True,
help="run event server without mongo storing")
@click.option("--clockify-api-key", envvar="CLOCKIFY_API_KEY",
help="Clockify API key.")
@click.option("--clockify-workspace", envvar="CLOCKIFY_WORKSPACE",
help="Clockify workspace")
def eventserver(
debug,
ftrack_url,
ftrack_user,
ftrack_api_key,
legacy,
clockify_api_key,
clockify_workspace
):
"""Launch ftrack event server.
This should be ideally used by system service (such us systemd or upstart
on linux and window service).
"""
if debug:
os.environ["OPENPYPE_DEBUG"] = "3"
from .ftrack_server.event_server_cli import run_event_server
return run_event_server(
ftrack_url,
ftrack_user,
ftrack_api_key,
legacy,
clockify_api_key,
clockify_workspace
)

View file

@ -570,9 +570,15 @@ class BaseHandler(object):
if low_entity_type == "assetversion":
asset = entity["asset"]
parent = None
if asset:
parent = asset["parent"]
if parent:
if parent:
if parent.entity_type.lower() == "project":
return parent
if "project" in parent:
return parent["project"]
project_data = entity["link"][0]

View file

@ -1,30 +0,0 @@
from abc import abstractmethod
from openpype.modules import OpenPypeInterface
class ISettingsChangeListener(OpenPypeInterface):
"""Module has plugin paths to return.
Expected result is dictionary with keys "publish", "create", "load" or
"actions" and values as list or string.
{
"publish": ["path/to/publish_plugins"]
}
"""
@abstractmethod
def on_system_settings_save(
self, old_value, new_value, changes, new_value_metadata
):
pass
@abstractmethod
def on_project_settings_save(
self, old_value, new_value, changes, project_name, new_value_metadata
):
pass
@abstractmethod
def on_project_anatomy_save(
self, old_value, new_value, changes, project_name, new_value_metadata
):
pass

View file

@ -24,25 +24,19 @@ class DropboxHandler(AbstractProvider):
)
return
provider_presets = self.presets.get(self.CODE)
if not provider_presets:
msg = "Sync Server: No provider presets for {}".format(self.CODE)
log.info(msg)
return
token = self.presets[self.CODE].get("token", "")
token = self.presets.get("token", "")
if not token:
msg = "Sync Server: No access token for dropbox provider"
log.info(msg)
return
team_folder_name = self.presets[self.CODE].get("team_folder_name", "")
team_folder_name = self.presets.get("team_folder_name", "")
if not team_folder_name:
msg = "Sync Server: No team folder name for dropbox provider"
log.info(msg)
return
acting_as_member = self.presets[self.CODE].get("acting_as_member", "")
acting_as_member = self.presets.get("acting_as_member", "")
if not acting_as_member:
msg = (
"Sync Server: No acting member for dropbox provider"
@ -51,13 +45,15 @@ class DropboxHandler(AbstractProvider):
return
self.dbx = None
try:
self.dbx = self._get_service(
token, acting_as_member, team_folder_name
)
except Exception as e:
log.info("Could not establish dropbox object: {}".format(e))
return
if self.presets["enabled"]:
try:
self.dbx = self._get_service(
token, acting_as_member, team_folder_name
)
except Exception as e:
log.info("Could not establish dropbox object: {}".format(e))
return
super(AbstractProvider, self).__init__()
@ -106,7 +102,7 @@ class DropboxHandler(AbstractProvider):
"type": "dict-roots",
"object_type": {
"type": "path",
"multiplatform": True,
"multiplatform": False,
"multipath": False
}
}
@ -169,7 +165,7 @@ class DropboxHandler(AbstractProvider):
Returns:
(boolean)
"""
return self.dbx is not None
return self.presets["enabled"] and self.dbx is not None
@classmethod
def get_configurable_items(cls):
@ -393,7 +389,7 @@ class DropboxHandler(AbstractProvider):
{"root": {"root_ONE": "value", "root_TWO":"value}}
Format is importing for usage of python's format ** approach
"""
return self.presets['root']
return self.presets['roots']
def resolve_path(self, path, root_config=None, anatomy=None):
"""

View file

@ -73,13 +73,7 @@ class GDriveHandler(AbstractProvider):
format(site_name))
return
provider_presets = self.presets.get(self.CODE)
if not provider_presets:
msg = "Sync Server: No provider presets for {}".format(self.CODE)
log.info(msg)
return
cred_path = self.presets[self.CODE].get("credentials_url", {}).\
cred_path = self.presets.get("credentials_url", {}).\
get(platform.system().lower()) or ''
if not os.path.exists(cred_path):
msg = "Sync Server: No credentials for gdrive provider " + \
@ -87,10 +81,12 @@ class GDriveHandler(AbstractProvider):
log.info(msg)
return
self.service = self._get_gd_service(cred_path)
self.service = None
if self.presets["enabled"]:
self.service = self._get_gd_service(cred_path)
self._tree = tree
self.active = True
self._tree = tree
self.active = True
def is_active(self):
"""
@ -98,7 +94,7 @@ class GDriveHandler(AbstractProvider):
Returns:
(boolean)
"""
return self.service is not None
return self.presets["enabled"] and self.service is not None
@classmethod
def get_system_settings_schema(cls):
@ -125,9 +121,11 @@ class GDriveHandler(AbstractProvider):
editable = [
# credentials could be overriden on Project or User level
{
'key': "credentials_url",
'label': "Credentials url",
'type': 'text'
"type": "path",
"key": "credentials_url",
"label": "Credentials url",
"multiplatform": True,
"placeholder": "Credentials url"
},
# roots could be overriden only on Project leve, User cannot
{
@ -136,7 +134,7 @@ class GDriveHandler(AbstractProvider):
"type": "dict-roots",
"object_type": {
"type": "path",
"multiplatform": True,
"multiplatform": False,
"multipath": False
}
}
@ -176,7 +174,7 @@ class GDriveHandler(AbstractProvider):
Format is importing for usage of python's format ** approach
"""
# GDrive roots cannot be locally overridden
return self.presets['root']
return self.presets['roots']
def get_tree(self):
"""

View file

@ -89,6 +89,7 @@ class LocalDriveHandler(AbstractProvider):
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))
@ -181,7 +182,10 @@ class LocalDriveHandler(AbstractProvider):
def _copy(self, source_path, target_path):
print("copying {}->{}".format(source_path, target_path))
shutil.copy(source_path, target_path)
try:
shutil.copy(source_path, target_path)
except shutil.SameFileError:
print("same files, skipping")
def _mark_progress(self, collection, file, representation, server, site,
source_path, target_path, direction):

View file

@ -1,8 +1,6 @@
import os
import os.path
import time
import sys
import six
import threading
import platform
@ -14,6 +12,7 @@ log = Logger().get_logger("SyncServer")
pysftp = None
try:
import pysftp
import paramiko
except (ImportError, SyntaxError):
pass
@ -37,7 +36,6 @@ class SFTPHandler(AbstractProvider):
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.root = None
@ -49,22 +47,15 @@ class SFTPHandler(AbstractProvider):
format(site_name))
return
provider_presets = self.presets.get(self.CODE)
if not provider_presets:
msg = "Sync Server: No provider presets for {}".format(self.CODE)
log.warning(msg)
return
# store to instance for reconnect
self.sftp_host = provider_presets["sftp_host"]
self.sftp_port = provider_presets["sftp_port"]
self.sftp_user = provider_presets["sftp_user"]
self.sftp_pass = provider_presets["sftp_pass"]
self.sftp_key = provider_presets["sftp_key"]
self.sftp_key_pass = provider_presets["sftp_key_pass"]
self.sftp_host = presets["sftp_host"]
self.sftp_port = presets["sftp_port"]
self.sftp_user = presets["sftp_user"]
self.sftp_pass = presets["sftp_pass"]
self.sftp_key = presets["sftp_key"]
self.sftp_key_pass = presets["sftp_key_pass"]
self._tree = None
self.active = True
@property
def conn(self):
@ -80,7 +71,7 @@ class SFTPHandler(AbstractProvider):
Returns:
(boolean)
"""
return self.conn is not None
return self.presets["enabled"] and self.conn is not None
@classmethod
def get_system_settings_schema(cls):
@ -108,7 +99,7 @@ class SFTPHandler(AbstractProvider):
editable = [
# credentials could be overriden on Project or User level
{
'key': "sftp_server",
'key': "sftp_host",
'label': "SFTP host name",
'type': 'text'
},
@ -130,7 +121,8 @@ class SFTPHandler(AbstractProvider):
{
'key': "sftp_key",
'label': "SFTP user ssh key",
'type': 'path'
'type': 'path',
"multiplatform": True
},
{
'key': "sftp_key_pass",
@ -144,7 +136,7 @@ class SFTPHandler(AbstractProvider):
"type": "dict-roots",
"object_type": {
"type": "path",
"multiplatform": True,
"multiplatform": False,
"multipath": False
}
}
@ -176,7 +168,8 @@ class SFTPHandler(AbstractProvider):
{
'key': "sftp_key",
'label': "SFTP user ssh key",
'type': 'path'
'type': 'path',
"multiplatform": True
},
{
'key': "sftp_key_pass",
@ -199,7 +192,7 @@ class SFTPHandler(AbstractProvider):
Format is importing for usage of python's format ** approach
"""
# roots cannot be locally overridden
return self.presets['root']
return self.presets['roots']
def get_tree(self):
"""
@ -426,7 +419,10 @@ class SFTPHandler(AbstractProvider):
if self.sftp_key_pass:
conn_params['private_key_pass'] = self.sftp_key_pass
return pysftp.Connection(**conn_params)
try:
return pysftp.Connection(**conn_params)
except paramiko.ssh_exception.SSHException:
log.warning("Couldn't connect", exc_info=True)
def _mark_progress(self, collection, file, representation, server, site,
source_path, target_path, direction):

View file

@ -80,6 +80,10 @@ async def upload(module, collection, file, representation, provider_name,
remote_site_name,
True
)
module.handle_alternate_site(collection, representation, remote_site_name,
file["_id"], file_id)
return file_id
@ -131,6 +135,10 @@ async def download(module, collection, file, representation, provider_name,
local_site,
True
)
module.handle_alternate_site(collection, representation, local_site,
file["_id"], file_id)
return file_id
@ -246,6 +254,7 @@ class SyncServerThread(threading.Thread):
asyncio.ensure_future(self.check_shutdown(), loop=self.loop)
asyncio.ensure_future(self.sync_loop(), loop=self.loop)
log.info("Sync Server Started")
self.loop.run_forever()
except Exception:
log.warning(

View file

@ -109,6 +109,7 @@ class SyncServerModule(OpenPypeModule, ITrayModule):
# some parts of code need to run sequentially, not in async
self.lock = None
self._sync_system_settings = None
# settings for all enabled projects for sync
self._sync_project_settings = None
self.sync_server_thread = None # asyncio requires new thread
@ -152,9 +153,9 @@ class SyncServerModule(OpenPypeModule, ITrayModule):
if not site_name:
site_name = self.DEFAULT_SITE
self.reset_provider_for_file(collection,
representation_id,
site_name=site_name, force=force)
self.reset_site_on_representation(collection,
representation_id,
site_name=site_name, force=force)
# public facing API
def remove_site(self, collection, representation_id, site_name,
@ -176,10 +177,10 @@ class SyncServerModule(OpenPypeModule, ITrayModule):
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)
self.reset_site_on_representation(collection,
representation_id,
site_name=site_name,
remove=True)
if remove_local_files:
self._remove_local_file(collection, representation_id, site_name)
@ -314,8 +315,8 @@ class SyncServerModule(OpenPypeModule, ITrayModule):
"""
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)
self.reset_site_on_representation(collection, representation_id,
site_name=site_name, pause=True)
def unpause_representation(self, collection, representation_id, site_name):
"""
@ -334,8 +335,8 @@ class SyncServerModule(OpenPypeModule, ITrayModule):
except KeyError:
pass
# self.paused_representations is not persistent
self.reset_provider_for_file(collection, representation_id,
site_name=site_name, pause=False)
self.reset_site_on_representation(collection, representation_id,
site_name=site_name, pause=False)
def is_representation_paused(self, representation_id,
check_parents=False, project_name=None):
@ -769,6 +770,58 @@ class SyncServerModule(OpenPypeModule, ITrayModule):
enabled_projects.append(project_name)
return enabled_projects
def handle_alternate_site(self, collection, representation, processed_site,
file_id, synced_file_id):
"""
For special use cases where one site vendors another.
Current use case is sftp site vendoring (exposing) same data as
regular site (studio). Each site is accessible for different
audience. 'studio' for artists in a studio, 'sftp' for externals.
Change of file status on one site actually means same change on
'alternate' site. (eg. artists publish to 'studio', 'sftp' is using
same location >> file is accesible on 'sftp' site right away.
Args:
collection (str): name of project
representation (dict)
processed_site (str): real site_name of published/uploaded file
file_id (ObjectId): DB id of file handled
synced_file_id (str): id of the created file returned
by provider
"""
sites = self.sync_system_settings.get("sites", {})
sites[self.DEFAULT_SITE] = {"provider": "local_drive",
"alternative_sites": []}
alternate_sites = []
for site_name, site_info in sites.items():
conf_alternative_sites = site_info.get("alternative_sites", [])
if processed_site in conf_alternative_sites:
alternate_sites.append(site_name)
continue
if processed_site == site_name and conf_alternative_sites:
alternate_sites.extend(conf_alternative_sites)
continue
alternate_sites = set(alternate_sites)
for alt_site in alternate_sites:
query = {
"_id": representation["_id"]
}
elem = {"name": alt_site,
"created_dt": datetime.now(),
"id": synced_file_id}
self.log.debug("Adding alternate {} to {}".format(
alt_site, representation["_id"]))
self._add_site(collection, query,
[representation], elem,
alt_site, file_id=file_id, force=True)
""" End of Public API """
def get_local_file_path(self, collection, site_name, file_path):
@ -799,12 +852,19 @@ class SyncServerModule(OpenPypeModule, ITrayModule):
def tray_init(self):
"""
Actual initialization of Sync Server.
Actual initialization of Sync Server for Tray.
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
self.server_init()
from .tray.app import SyncServerWindow
self.widget = SyncServerWindow(self)
def server_init(self):
"""Actual initialization of Sync Server."""
# import only in tray or Python3, because of Python2 hosts
from .sync_server import SyncServerThread
if not self.enabled:
@ -816,6 +876,7 @@ class SyncServerModule(OpenPypeModule, ITrayModule):
return
self.lock = threading.Lock()
self.sync_server_thread = SyncServerThread(self)
def tray_start(self):
@ -829,6 +890,9 @@ class SyncServerModule(OpenPypeModule, ITrayModule):
Returns:
None
"""
self.server_start()
def server_start(self):
if self.sync_project_settings and self.enabled:
self.sync_server_thread.start()
else:
@ -841,6 +905,9 @@ class SyncServerModule(OpenPypeModule, ITrayModule):
Called from Module Manager
"""
self.server_exit()
def server_exit(self):
if not self.sync_server_thread:
return
@ -850,6 +917,7 @@ class SyncServerModule(OpenPypeModule, ITrayModule):
log.info("Stopping sync server server")
self.sync_server_thread.is_running = False
self.sync_server_thread.stop()
log.info("Sync server stopped")
except Exception:
log.warning(
"Error has happened during Killing sync server",
@ -892,6 +960,14 @@ class SyncServerModule(OpenPypeModule, ITrayModule):
return self._connection
@property
def sync_system_settings(self):
if self._sync_system_settings is None:
self._sync_system_settings = get_system_settings()["modules"].\
get("sync_server")
return self._sync_system_settings
@property
def sync_project_settings(self):
if self._sync_project_settings is None:
@ -977,9 +1053,7 @@ class SyncServerModule(OpenPypeModule, ITrayModule):
(dict): {'studio': {'provider':'local_drive'...},
'MY_LOCAL': {'provider':....}}
"""
sys_sett = get_system_settings()
sync_sett = sys_sett["modules"].get("sync_server")
sync_sett = self.sync_system_settings
project_enabled = True
if project_name:
project_enabled = project_name in self.get_enabled_projects()
@ -1037,10 +1111,9 @@ class SyncServerModule(OpenPypeModule, ITrayModule):
if provider:
return provider
sys_sett = get_system_settings()
sync_sett = sys_sett["modules"].get("sync_server")
for site, detail in sync_sett.get("sites", {}).items():
sites[site] = detail.get("provider")
sync_sett = self.sync_system_settings
for conf_site, detail in sync_sett.get("sites", {}).items():
sites[conf_site] = detail.get("provider")
return sites.get(site, 'N/A')
@ -1319,9 +1392,9 @@ class SyncServerModule(OpenPypeModule, ITrayModule):
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):
def reset_site_on_representation(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.
@ -1407,9 +1480,12 @@ class SyncServerModule(OpenPypeModule, ITrayModule):
update = {
"$set": {"files.$[f].sites.$[s]": elem}
}
if not isinstance(file_id, ObjectId):
file_id = ObjectId(file_id)
arr_filter = [
{'s.name': site_name},
{'f._id': ObjectId(file_id)}
{'f._id': file_id}
]
self._update_site(collection, query, update, arr_filter)

View file

@ -154,7 +154,7 @@ class SyncProjectListWidget(QtWidgets.QWidget):
selected_index.isValid() and \
not self._selection_changed:
mode = QtCore.QItemSelectionModel.Select | \
QtCore.QItemSelectionModel.Rows
QtCore.QItemSelectionModel.Rows
self.project_list.selectionModel().select(selected_index, mode)
if self.current_project:
@ -489,7 +489,7 @@ class _SyncRepresentationWidget(QtWidgets.QWidget):
format(check_progress))
continue
self.sync_server.reset_provider_for_file(
self.sync_server.reset_site_on_representation(
self.model.project,
representation_id,
site_name=site_name,
@ -872,7 +872,7 @@ class SyncRepresentationDetailWidget(_SyncRepresentationWidget):
format(check_progress))
continue
self.sync_server.reset_provider_for_file(
self.sync_server.reset_site_on_representation(
self.model.project,
self.representation_id,
site_name=site_name,

View file

@ -1,10 +1,7 @@
import os
import platform
from openpype.modules import OpenPypeModule
from openpype_interfaces import (
ITimersManager,
ITrayService
)
from openpype_interfaces import ITrayService
from avalon.api import AvalonMongoDB

View file

@ -8,14 +8,15 @@ in global space here until are required or used.
"""
import os
import click
from openpype.modules import (
JsonFilesSettingsDef,
OpenPypeAddOn
OpenPypeAddOn,
ModulesManager
)
# Import interface defined by this addon to be able find other addons using it
from openpype_interfaces import (
IExampleInterface,
IPluginPaths,
ITrayAction
)
@ -75,19 +76,6 @@ class ExampleAddon(OpenPypeAddOn, IPluginPaths, ITrayAction):
self._create_dialog()
def connect_with_modules(self, enabled_modules):
"""Method where you should find connected modules.
It is triggered by OpenPype modules manager at the best possible time.
Some addons and modules may required to connect with other modules
before their main logic is executed so changes would require to restart
whole process.
"""
self._connected_modules = []
for module in enabled_modules:
if isinstance(module, IExampleInterface):
self._connected_modules.append(module)
def _create_dialog(self):
# Don't recreate dialog if already exists
if self._dialog is not None:
@ -106,8 +94,6 @@ class ExampleAddon(OpenPypeAddOn, IPluginPaths, ITrayAction):
"""
# Make sure dialog is created
self._create_dialog()
# Change value of dialog by current state
self._dialog.set_connected_modules(self.get_connected_modules())
# Show dialog
self._dialog.open()
@ -130,3 +116,32 @@ class ExampleAddon(OpenPypeAddOn, IPluginPaths, ITrayAction):
return {
"publish": [os.path.join(current_dir, "plugins", "publish")]
}
def cli(self, click_group):
click_group.add_command(cli_main)
@click.group(ExampleAddon.name, help="Example addon dynamic cli commands.")
def cli_main():
pass
@cli_main.command()
def nothing():
"""Does nothing but print a message."""
print("You've triggered \"nothing\" command.")
@cli_main.command()
def show_dialog():
"""Show ExampleAddon dialog.
We don't have access to addon directly through cli so we have to create
it again.
"""
from openpype.tools.utils.lib import qt_app_context
manager = ModulesManager()
example_addon = manager.modules_by_name[ExampleAddon.name]
with qt_app_context():
example_addon.show_dialog()

View file

@ -1,28 +0,0 @@
""" Using interfaces is one way of connecting multiple OpenPype Addons/Modules.
Interfaces must be in `interfaces.py` file (or folder). Interfaces should not
import module logic or other module in global namespace. That is because
all of them must be imported before all OpenPype AddOns and Modules.
Ideally they should just define abstract and helper methods. If interface
require any logic or connection it should be defined in module.
Keep in mind that attributes and methods will be added to other addon
attributes and methods so they should be unique and ideally contain
addon name in it's name.
"""
from abc import abstractmethod
from openpype.modules import OpenPypeInterface
class IExampleInterface(OpenPypeInterface):
"""Example interface of addon."""
_example_module = None
def get_example_module(self):
return self._example_module
@abstractmethod
def example_method_of_example_interface(self):
pass

View file

@ -9,7 +9,8 @@ class MyExampleDialog(QtWidgets.QDialog):
self.setWindowTitle("Connected modules")
label_widget = QtWidgets.QLabel(self)
msg = "This is example dialog of example addon."
label_widget = QtWidgets.QLabel(msg, self)
ok_btn = QtWidgets.QPushButton("OK", self)
btns_layout = QtWidgets.QHBoxLayout()
@ -28,12 +29,3 @@ class MyExampleDialog(QtWidgets.QDialog):
def _on_ok_clicked(self):
self.done(1)
def set_connected_modules(self, connected_modules):
if connected_modules:
message = "\n".join(connected_modules)
else:
message = (
"Other enabled modules/addons are not using my interface."
)
self._label_widget.setText(message)

View file

@ -263,3 +263,31 @@ class ITrayService(ITrayModule):
"""Change icon of an QAction to orange circle."""
if self.menu_action:
self.menu_action.setIcon(self.get_icon_idle())
class ISettingsChangeListener(OpenPypeInterface):
"""Module has plugin paths to return.
Expected result is dictionary with keys "publish", "create", "load" or
"actions" and values as list or string.
{
"publish": ["path/to/publish_plugins"]
}
"""
@abstractmethod
def on_system_settings_save(
self, old_value, new_value, changes, new_value_metadata
):
pass
@abstractmethod
def on_project_settings_save(
self, old_value, new_value, changes, project_name, new_value_metadata
):
pass
@abstractmethod
def on_project_anatomy_save(
self, old_value, new_value, changes, project_name, new_value_metadata
):
pass

View file

@ -38,6 +38,8 @@ class CollectAnatomyInstanceData(pyblish.api.ContextPlugin):
order = pyblish.api.CollectorOrder + 0.49
label = "Collect Anatomy Instance data"
follow_workfile_version = False
def process(self, context):
self.log.info("Collecting anatomy data for all instances.")
@ -213,7 +215,10 @@ class CollectAnatomyInstanceData(pyblish.api.ContextPlugin):
context_asset_doc = context.data["assetEntity"]
for instance in context:
version_number = instance.data.get("version")
if self.follow_workfile_version:
version_number = context.data('version')
else:
version_number = instance.data.get("version")
# If version is not specified for instance or context
if version_number is None:
# TODO we should be able to change default version by studio

View file

@ -1029,29 +1029,8 @@ class IntegrateAssetNew(pyblish.api.InstancePlugin):
"""
local_site = 'studio' # default
remote_site = None
sync_server_presets = None
if (instance.context.data["system_settings"]
["modules"]
["sync_server"]
["enabled"]):
sync_server_presets = (instance.context.data["project_settings"]
["global"]
["sync_server"])
local_site_id = openpype.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_site:
remote_site = None
if remote_site == 'local':
remote_site = local_site_id
always_accesible = []
sync_project_presets = None
rec = {
"_id": io.ObjectId(),
@ -1066,12 +1045,93 @@ class IntegrateAssetNew(pyblish.api.InstancePlugin):
if sites:
rec["sites"] = sites
else:
system_sync_server_presets = (
instance.context.data["system_settings"]
["modules"]
["sync_server"])
log.debug("system_sett:: {}".format(system_sync_server_presets))
if system_sync_server_presets["enabled"]:
sync_project_presets = (
instance.context.data["project_settings"]
["global"]
["sync_server"])
if sync_project_presets and sync_project_presets["enabled"]:
local_site, remote_site = self._get_sites(sync_project_presets)
always_accesible = sync_project_presets["config"]. \
get("always_accessible_on", [])
already_attached_sites = {}
meta = {"name": local_site, "created_dt": datetime.now()}
rec["sites"] = [meta]
already_attached_sites[meta["name"]] = meta["created_dt"]
if remote_site:
if sync_project_presets and sync_project_presets["enabled"]:
# add remote
meta = {"name": remote_site.strip()}
rec["sites"].append(meta)
already_attached_sites[meta["name"]] = None
# add skeleton for site where it should be always synced to
for always_on_site in always_accesible:
if always_on_site not in already_attached_sites.keys():
meta = {"name": always_on_site.strip()}
rec["sites"].append(meta)
already_attached_sites[meta["name"]] = None
# add alternative sites
rec = self._add_alternative_sites(system_sync_server_presets,
already_attached_sites,
rec)
log.debug("final sites:: {}".format(rec["sites"]))
return rec
def _get_sites(self, sync_project_presets):
"""Returns tuple (local_site, remote_site)"""
local_site_id = openpype.api.get_local_site_id()
local_site = sync_project_presets["config"]. \
get("active_site", "studio").strip()
if local_site == 'local':
local_site = local_site_id
remote_site = sync_project_presets["config"].get("remote_site")
if remote_site == local_site:
remote_site = None
if remote_site == 'local':
remote_site = local_site_id
return local_site, remote_site
def _add_alternative_sites(self,
system_sync_server_presets,
already_attached_sites,
rec):
"""Loop through all configured sites and add alternatives.
See SyncServerModule.handle_alternate_site
"""
conf_sites = system_sync_server_presets.get("sites", {})
for site_name, site_info in conf_sites.items():
alt_sites = set(site_info.get("alternative_sites", []))
already_attached_keys = list(already_attached_sites.keys())
for added_site in already_attached_keys:
if added_site in alt_sites:
if site_name in already_attached_keys:
continue
meta = {"name": site_name}
real_created = already_attached_sites[added_site]
# alt site inherits state of 'created_dt'
if real_created:
meta["created_dt"] = real_created
rec["sites"].append(meta)
already_attached_sites[meta["name"]] = real_created
return rec

View file

@ -21,8 +21,9 @@ class ValidateVersion(pyblish.api.InstancePlugin):
if latest_version is not None:
msg = (
"Version `{0}` that you are trying to publish, already exists"
" in the database. Version in database: `{1}`. Please version "
"up your workfile to a higher version number than: `{1}`."
).format(version, latest_version)
"Version `{0}` from instance `{1}` that you are trying to"
" publish, already exists in the database. Version in"
" database: `{2}`. Please version up your workfile to a higher"
" version number than: `{2}`."
).format(version, instance.data["name"], latest_version)
assert (int(version) > int(latest_version)), msg

View file

@ -41,6 +41,25 @@ class PypeCommands:
user_role = "manager"
settings.main(user_role)
@staticmethod
def add_modules(click_func):
"""Modules/Addons can add their cli commands dynamically."""
from openpype.modules import ModulesManager
manager = ModulesManager()
log = PypeLogger.get_logger("AddModulesCLI")
for module in manager.modules:
try:
module.cli(click_func)
except Exception:
log.warning(
"Failed to add cli command for module \"{}\"".format(
module.name
)
)
return click_func
@staticmethod
def launch_eventservercli(*args):
from openpype_modules.ftrack.ftrack_server.event_server_cli import (
@ -361,3 +380,28 @@ class PypeCommands:
cmd = "pytest {} {} {}".format(folder, mark_str, pyargs_str)
print("Running {}".format(cmd))
subprocess.run(cmd)
def syncserver(self, active_site):
"""Start running sync_server in background."""
import signal
os.environ["OPENPYPE_LOCAL_ID"] = active_site
def signal_handler(sig, frame):
print("You pressed Ctrl+C. Process ended.")
sync_server_module.server_exit()
sys.exit(0)
signal.signal(signal.SIGINT, signal_handler)
signal.signal(signal.SIGTERM, signal_handler)
from openpype.modules import ModulesManager
manager = ModulesManager()
sync_server_module = manager.modules_by_name["sync_server"]
sync_server_module.server_init()
sync_server_module.server_start()
import time
while True:
time.sleep(1.0)

View file

@ -1,5 +1,8 @@
{
"publish": {
"CollectAnatomyInstanceData": {
"follow_workfile_version": false
},
"ValidateEditorialAssetName": {
"enabled": true,
"optional": false
@ -315,10 +318,11 @@
},
"project_folder_structure": "{\"__project_root__\": {\"prod\": {}, \"resources\": {\"footage\": {\"plates\": {}, \"offline\": {}}, \"audio\": {}, \"art_dept\": {}}, \"editorial\": {}, \"assets[ftrack.Library]\": {\"characters[ftrack]\": {}, \"locations[ftrack]\": {}}, \"shots[ftrack.Sequence]\": {\"scripts\": {}, \"editorial[ftrack.Folder]\": {}}}}",
"sync_server": {
"enabled": true,
"enabled": false,
"config": {
"retry_cnt": "3",
"loop_delay": "60",
"always_accessible_on": [],
"active_site": "studio",
"remote_site": "studio"
},

View file

@ -30,8 +30,7 @@
},
"ExtractReview": {
"jpg_options": {
"tags": [
]
"tags": []
},
"mov_options": {
"tags": [

View file

@ -762,6 +762,17 @@ class SyncServerProviders(DictConditionalEntity):
enum_children = []
for provider_code, configurables in system_settings_schema.items():
# any site could be exposed or vendorized by different site
# eg studio site content could be mapped on sftp site, single file
# accessible via 2 different protocols (sites)
configurables.append(
{
"type": "list",
"key": "alternative_sites",
"label": "Alternative sites",
"object_type": "text"
}
)
label = provider_code_to_label.get(provider_code) or provider_code
enum_children.append({

View file

@ -67,7 +67,17 @@
"type": "list",
"key": "color_code",
"label": "Color codes for layers",
"object_type": "text"
"type": "enum",
"multiselection": true,
"enum_items": [
{ "red": "red" },
{ "orange": "orange" },
{ "yellowColor": "yellow" },
{ "grain": "green" },
{ "blue": "blue" },
{ "violet": "violet" },
{ "gray": "gray" }
]
},
{
"type": "list",

View file

@ -26,113 +26,33 @@
"key": "loop_delay",
"label": "Loop Delay"
},
{
"type": "list",
"key": "always_accessible_on",
"label": "Always accessible on sites",
"object_type": "text"
},
{
"type": "splitter"
},
{
"type": "text",
"key": "active_site",
"label": "Active Site"
"label": "User Default Active Site"
},
{
"type": "text",
"key": "remote_site",
"label": "Remote Site"
"label": "User Default Remote Site"
}
]
},
{
"type": "dict-modifiable",
"type": "sync-server-sites",
"collapsible": true,
"key": "sites",
"label": "Sites",
"collapsible_key": false,
"object_type": {
"type": "dict",
"children": [
{
"type": "dict",
"key": "gdrive",
"label": "Google Drive",
"collapsible": true,
"children": [
{
"type": "path",
"key": "credentials_url",
"label": "Credentials url",
"multiplatform": true
}
]
},
{
"type": "dict",
"key": "dropbox",
"label": "Dropbox",
"collapsible": true,
"children": [
{
"type": "text",
"key": "token",
"label": "Access Token"
},
{
"type": "text",
"key": "team_folder_name",
"label": "Team Folder Name"
},
{
"type": "text",
"key": "acting_as_member",
"label": "Acting As Member"
}
]
},
{
"type": "dict",
"key": "sftp",
"label": "SFTP",
"collapsible": true,
"children": [
{
"type": "text",
"key": "sftp_host",
"label": "SFTP host"
},
{
"type": "number",
"key": "sftp_port",
"label": "SFTP port"
},
{
"type": "text",
"key": "sftp_user",
"label": "SFTP user"
},
{
"type": "text",
"key": "sftp_pass",
"label": "SFTP pass"
},
{
"type": "path",
"key": "sftp_key",
"label": "SFTP user ssh key",
"multiplatform": true
},
{
"type": "text",
"key": "sftp_key_pass",
"label": "SFTP user ssh key password"
}
]
},
{
"type": "dict-modifiable",
"key": "root",
"label": "Roots",
"collapsable": false,
"collapsable_key": false,
"object_type": "text"
}
]
}
"collapsible_key": false
}
]
}

View file

@ -4,6 +4,20 @@
"key": "publish",
"label": "Publish plugins",
"children": [
{
"type": "dict",
"collapsible": true,
"key": "CollectAnatomyInstanceData",
"label": "Collect Anatomy Instance Data",
"is_group": true,
"children": [
{
"type": "boolean",
"key": "follow_workfile_version",
"label": "Follow workfile version"
}
]
},
{
"type": "dict",
"collapsible": true,

View file

@ -104,8 +104,8 @@ class TestPerformance():
"name": "mb",
"parent": {"oid": '{}'.format(id)},
"data": {
"path": "C:\\projects\\test_performance\\Assets\\Cylinder\\publish\\workfile\\workfileLookdev\\{}\\{}".format(version_str, file_name), # noqa
"template": "{root[work]}\\{project[name]}\\{hierarchy}\\{asset}\\publish\\{family}\\{subset}\\v{version:0>3}\\{project[code]}_{asset}_{subset}_v{version:0>3}<_{output}><.{frame:0>4}>.{representation}" # noqa
"path": "C:\\projects\\test_performance\\Assets\\Cylinder\\publish\\workfile\\workfileLookdev\\{}\\{}".format(version_str, file_name), # noqa: E501
"template": "{root[work]}\\{project[name]}\\{hierarchy}\\{asset}\\publish\\{family}\\{subset}\\v{version:0>3}\\{project[code]}_{asset}_{subset}_v{version:0>3}<_{output}><.{frame:0>4}>.{representation}" # noqa: E501
},
"type": "representation",
"schema": "openpype:representation-2.0"
@ -188,21 +188,21 @@ class TestPerformance():
create_files=False):
ret = [
{
"path": "{root[work]}" + "{root[work]}/test_performance/Assets/Cylinder/publish/workfile/workfileLookdev/v{:03d}/test_Cylinder_A_workfileLookdev_v{:03d}.dat".format(i, i), #noqa
"path": "{root[work]}" + "{root[work]}/test_performance/Assets/Cylinder/publish/workfile/workfileLookdev/v{:03d}/test_Cylinder_A_workfileLookdev_v{:03d}.dat".format(i, i), # noqa: E501
"_id": '{}'.format(file_id),
"hash": "temphash",
"sites": self.get_sites(self.MAX_NUMBER_OF_SITES),
"size": random.randint(0, self.MAX_FILE_SIZE_B)
},
{
"path": "{root[work]}" + "/test_performance/Assets/Cylinder/publish/workfile/workfileLookdev/v{:03d}/test_Cylinder_B_workfileLookdev_v{:03d}.dat".format(i, i), #noqa
"path": "{root[work]}" + "/test_performance/Assets/Cylinder/publish/workfile/workfileLookdev/v{:03d}/test_Cylinder_B_workfileLookdev_v{:03d}.dat".format(i, i), # noqa: E501
"_id": '{}'.format(file_id2),
"hash": "temphash",
"sites": self.get_sites(self.MAX_NUMBER_OF_SITES),
"size": random.randint(0, self.MAX_FILE_SIZE_B)
},
{
"path": "{root[work]}" + "/test_performance/Assets/Cylinder/publish/workfile/workfileLookdev/v{:03d}/test_Cylinder_C_workfileLookdev_v{:03d}.dat".format(i, i), #noqa
"path": "{root[work]}" + "/test_performance/Assets/Cylinder/publish/workfile/workfileLookdev/v{:03d}/test_Cylinder_C_workfileLookdev_v{:03d}.dat".format(i, i), # noqa: E501
"_id": '{}'.format(file_id3),
"hash": "temphash",
"sites": self.get_sites(self.MAX_NUMBER_OF_SITES),
@ -223,8 +223,8 @@ class TestPerformance():
ret = {}
ret['{}'.format(file_id)] = {
"path": "{root[work]}" +
"/test_performance/Assets/Cylinder/publish/workfile/workfileLookdev/" #noqa
"v{:03d}/test_CylinderA_workfileLookdev_v{:03d}.mb".format(i, i), # noqa
"/test_performance/Assets/Cylinder/publish/workfile/workfileLookdev/" # noqa: E501
"v{:03d}/test_CylinderA_workfileLookdev_v{:03d}.mb".format(i, i), # noqa: E501
"hash": "temphash",
"sites": ["studio"],
"size": 87236
@ -232,16 +232,16 @@ class TestPerformance():
ret['{}'.format(file_id2)] = {
"path": "{root[work]}" +
"/test_performance/Assets/Cylinder/publish/workfile/workfileLookdev/" #noqa
"v{:03d}/test_CylinderB_workfileLookdev_v{:03d}.mb".format(i, i), # noqa
"/test_performance/Assets/Cylinder/publish/workfile/workfileLookdev/" # noqa: E501
"v{:03d}/test_CylinderB_workfileLookdev_v{:03d}.mb".format(i, i), # noqa: E501
"hash": "temphash",
"sites": ["studio"],
"size": 87236
}
ret['{}'.format(file_id3)] = {
"path": "{root[work]}" +
"/test_performance/Assets/Cylinder/publish/workfile/workfileLookdev/" #noqa
"v{:03d}/test_CylinderC_workfileLookdev_v{:03d}.mb".format(i, i), # noqa
"/test_performance/Assets/Cylinder/publish/workfile/workfileLookdev/" # noqa: E501
"v{:03d}/test_CylinderC_workfileLookdev_v{:03d}.mb".format(i, i), # noqa: E501
"hash": "temphash",
"sites": ["studio"],
"size": 87236

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

View file

@ -27,6 +27,38 @@ To use synchronization, *Site Sync* needs to be enabled globally in **OpenPype S
![Configure module](assets/site_sync_system.png)
### Sites
By default there are two sites created for each OpenPype installation:
- **studio** - default site - usually a centralized mounted disk accessible to all artists. Studio site is used if Site Sync is disabled.
- **local** - each workstation or server running OpenPype Tray receives its own with unique site name. Workstation refers to itself as "local"however all other sites will see it under it's unique ID.
Artists can explore their site ID by opening OpenPype Info tool by clicking on a version number in the tray app.
Many different sites can be created and configured on the system level, and some or all can be assigned to each project.
Each OpenPype Tray app works with two sites at one time. (Sites can be the same, and no synching is done in this setup).
Sites could be configured differently per project basis.
Each new site needs to be created first in `System Settings`. Most important feature of site is its Provider, select one from already prepared Providers.
#### Alternative sites
This attribute is meant for special use cases only.
One of the use cases is sftp site vendoring (exposing) same data as regular site (studio). Each site is accessible for different audience. 'studio' for artists in a studio via shared disk, 'sftp' for externals via sftp server with mounted 'studio' drive.
Change of file status on one site actually means same change on 'alternate' site occured too. (eg. artists publish to 'studio', 'sftp' is using
same location >> file is accessible on 'sftp' site right away, no need to sync it anyhow.)
##### Example
![Configure module](assets/site_sync_system_sites.png)
Admin created new `sftp` site which is handled by `SFTP` provider. Somewhere in the studio SFTP server is deployed on a machine that has access to `studio` drive.
Alternative sites work both way:
- everything published to `studio` is accessible on a `sftp` site too
- everything published to `sftp` (most probably via artist's local disk - artists publishes locally, representation is marked to be synced to `sftp`. Immediately after it is synced, it is marked to be available on `studio` too for artists in the studio to use.)
## Project Settings
@ -45,21 +77,6 @@ Artists can also override which site they use as active and remote if need be.
![Local overrides](assets/site_sync_local_setting.png)
## Sites
By default there are two sites created for each OpenPype installation:
- **studio** - default site - usually a centralized mounted disk accessible to all artists. Studio site is used if Site Sync is disabled.
- **local** - each workstation or server running OpenPype Tray receives its own with unique site name. Workstation refers to itself as "local"however all other sites will see it under it's unique ID.
Artists can explore their site ID by opening OpenPype Info tool by clicking on a version number in the tray app.
Many different sites can be created and configured on the system level, and some or all can be assigned to each project.
Each OpenPype Tray app works with two sites at one time. (Sites can be the same, and no synching is done in this setup).
Sites could be configured differently per project basis.
## Providers
Each site implements a so called `provider` which handles most common operations (list files, copy files etc.) and provides interface with a particular type of storage. (disk, gdrive, aws, etc.)
@ -140,3 +157,42 @@ Beware that ssh key expects OpenSSH format (`.pem`) not a Putty format (`.ppk`)!
If a studio needs to use other services for cloud storage, or want to implement totally different storage providers, they can do so by writing their own provider plugin. We're working on a developer documentation, however, for now we recommend looking at `abstract_provider.py`and `gdrive.py` inside `openpype/modules/sync_server/providers` and using it as a template.
### Running Site Sync in background
Site Sync server synchronizes new published files from artist machine into configured remote location by default.
There might be a use case where you need to synchronize between "non-artist" sites, for example between studio site and cloud. In this case
you need to run Site Sync as a background process from a command line (via service etc) 24/7.
To configure all sites where all published files should be synced eventually you need to configure `project_settings/global/sync_server/config/always_accessible_on` property in Settins (per project) first.
![Set another non artist remote site](assets/site_sync_always_on.png)
This is an example of:
- Site Sync is enabled for a project
- default active and remote sites are set to `studio` - eg. standard process: everyone is working in a studio, publishing to shared location etc.
- (but this also allows any of the artists to work remotely, they would change their active site in their own Local Settings to `local` and configure local root.
This would result in everything artist publishes is saved first onto his local folder AND synchronized to `studio` site eventually.)
- everything exported must also be eventually uploaded to `sftp` site
This eventual synchronization between `studio` and `sftp` sites must be physically handled by background process.
As current implementation relies heavily on Settings and Local Settings, background process for a specific site ('studio' for example) must be configured via Tray first to `syncserver` command to work.
To do this:
- run OP `Tray` with environment variable OPENPYPE_LOCAL_ID set to name of active (source) site. In most use cases it would be studio (for cases of backups of everything published to studio site to different cloud site etc.)
- start `Tray`
- check `Local ID` in information dialog after clicking on version number in the Tray
- open `Local Settings` in the `Tray`
- configure for each project necessary active site and remote site
- close `Tray`
- run OP from a command line with `syncserver` and `--active_site` arguments
This is an example how to trigger background synching process where active (source) site is `studio`.
(It is expected that OP is installed on a machine, `openpype_console` is on PATH. If not, add full path to executable.
)
```shell
openpype_console syncserver --active_site studio
```