From 8b49c5505eef984ff9732d3dd1e84b49a74b59d9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sybren=20A=2E=20St=C3=BCvel?= Date: Tue, 16 Feb 2021 11:21:06 +0100 Subject: [PATCH] Reformat with Black No functional changes. --- blender_cloud/__init__.py | 76 +- blender_cloud/appdirs.py | 126 +-- blender_cloud/async_loop.py | 101 ++- blender_cloud/attract/__init__.py | 550 ++++++------ blender_cloud/attract/draw.py | 78 +- blender_cloud/attract/draw_27.py | 33 +- blender_cloud/blender.py | 541 ++++++------ blender_cloud/blendfile.py | 405 +++++---- blender_cloud/cache.py | 19 +- blender_cloud/compatibility.py | 13 +- blender_cloud/flamenco/__init__.py | 927 ++++++++++++--------- blender_cloud/flamenco/bat_interface.py | 111 +-- blender_cloud/flamenco/sdk.py | 53 +- blender_cloud/home_project.py | 21 +- blender_cloud/image_sharing.py | 257 +++--- blender_cloud/pillar.py | 691 +++++++++------ blender_cloud/project_specific.py | 95 ++- blender_cloud/settings_sync.py | 445 +++++----- blender_cloud/texture_browser/__init__.py | 515 +++++++----- blender_cloud/texture_browser/draw.py | 33 +- blender_cloud/texture_browser/draw_27.py | 15 +- blender_cloud/texture_browser/menu_item.py | 83 +- blender_cloud/texture_browser/nodes.py | 16 +- blender_cloud/utils.py | 14 +- blender_cloud/wheels/__init__.py | 25 +- tests/test_path_replacement.py | 125 +-- tests/test_utils.py | 14 +- 27 files changed, 3094 insertions(+), 2288 deletions(-) diff --git a/blender_cloud/__init__.py b/blender_cloud/__init__.py index 4a25e1f..c6922a7 100644 --- a/blender_cloud/__init__.py +++ b/blender_cloud/__init__.py @@ -19,22 +19,22 @@ # bl_info = { - 'name': 'Blender Cloud', + "name": "Blender Cloud", "author": "Sybren A. Stüvel, Francesco Siddi, Inês Almeida, Antony Riakiotakis", - 'version': (1, 17), - 'blender': (2, 80, 0), - 'location': 'Addon Preferences panel, and Ctrl+Shift+Alt+A anywhere for texture browser', - 'description': 'Texture library browser and Blender Sync. Requires the Blender ID addon ' - 'and Blender 2.80 or newer.', - 'wiki_url': 'https://wiki.blender.org/index.php/Extensions:2.6/Py/' - 'Scripts/System/BlenderCloud', - 'category': 'System', + "version": (1, 17), + "blender": (2, 80, 0), + "location": "Addon Preferences panel, and Ctrl+Shift+Alt+A anywhere for texture browser", + "description": "Texture library browser and Blender Sync. Requires the Blender ID addon " + "and Blender 2.80 or newer.", + "wiki_url": "https://wiki.blender.org/index.php/Extensions:2.6/Py/" + "Scripts/System/BlenderCloud", + "category": "System", } import logging # Support reloading -if 'pillar' in locals(): +if "pillar" in locals(): import importlib wheels = importlib.reload(wheels) @@ -60,11 +60,11 @@ def register(): _monkey_patch_requests() # Support reloading - if '%s.blender' % __name__ in sys.modules: + if "%s.blender" % __name__ in sys.modules: import importlib def reload_mod(name): - modname = '%s.%s' % (__name__, name) + modname = "%s.%s" % (__name__, name) try: old_module = sys.modules[modname] except KeyError: @@ -76,22 +76,32 @@ def register(): sys.modules[modname] = new_module return new_module - reload_mod('blendfile') - reload_mod('home_project') - reload_mod('utils') - reload_mod('pillar') + reload_mod("blendfile") + reload_mod("home_project") + reload_mod("utils") + reload_mod("pillar") - async_loop = reload_mod('async_loop') - flamenco = reload_mod('flamenco') - attract = reload_mod('attract') - texture_browser = reload_mod('texture_browser') - settings_sync = reload_mod('settings_sync') - image_sharing = reload_mod('image_sharing') - blender = reload_mod('blender') - project_specific = reload_mod('project_specific') + async_loop = reload_mod("async_loop") + flamenco = reload_mod("flamenco") + attract = reload_mod("attract") + texture_browser = reload_mod("texture_browser") + settings_sync = reload_mod("settings_sync") + image_sharing = reload_mod("image_sharing") + blender = reload_mod("blender") + project_specific = reload_mod("project_specific") else: - from . import (blender, texture_browser, async_loop, settings_sync, blendfile, home_project, - image_sharing, attract, flamenco, project_specific) + from . import ( + blender, + texture_browser, + async_loop, + settings_sync, + blendfile, + home_project, + image_sharing, + attract, + flamenco, + project_specific, + ) async_loop.setup_asyncio_executor() async_loop.register() @@ -117,15 +127,23 @@ def _monkey_patch_requests(): if requests.__build__ >= 0x020601: return - log.info('Monkey-patching requests version %s', requests.__version__) + log.info("Monkey-patching requests version %s", requests.__version__) from requests.packages.urllib3.response import HTTPResponse + HTTPResponse.chunked = False HTTPResponse.chunk_left = None def unregister(): - from . import (blender, texture_browser, async_loop, settings_sync, image_sharing, attract, - flamenco) + from . import ( + blender, + texture_browser, + async_loop, + settings_sync, + image_sharing, + attract, + flamenco, + ) image_sharing.unregister() attract.unregister() diff --git a/blender_cloud/appdirs.py b/blender_cloud/appdirs.py index d1033e6..f1e56d9 100644 --- a/blender_cloud/appdirs.py +++ b/blender_cloud/appdirs.py @@ -14,7 +14,7 @@ See for details and usage. # - XDG spec for Un*x: http://standards.freedesktop.org/basedir-spec/basedir-spec-latest.html __version_info__ = (1, 4, 0) -__version__ = '.'.join(map(str, __version_info__)) +__version__ = ".".join(map(str, __version_info__)) import sys @@ -25,23 +25,23 @@ PY3 = sys.version_info[0] == 3 if PY3: unicode = str -if sys.platform.startswith('java'): +if sys.platform.startswith("java"): import platform + os_name = platform.java_ver()[3][0] - if os_name.startswith('Windows'): # "Windows XP", "Windows 7", etc. - system = 'win32' - elif os_name.startswith('Mac'): # "Mac OS X", etc. - system = 'darwin' - else: # "Linux", "SunOS", "FreeBSD", etc. + if os_name.startswith("Windows"): # "Windows XP", "Windows 7", etc. + system = "win32" + elif os_name.startswith("Mac"): # "Mac OS X", etc. + system = "darwin" + else: # "Linux", "SunOS", "FreeBSD", etc. # Setting this to "linux2" is not ideal, but only Windows or Mac # are actually checked for and the rest of the module expects # *sys.platform* style strings. - system = 'linux2' + system = "linux2" else: system = sys.platform - def user_data_dir(appname=None, appauthor=None, version=None, roaming=False): r"""Return full path to the user-specific data dir for this application. @@ -84,12 +84,12 @@ def user_data_dir(appname=None, appauthor=None, version=None, roaming=False): path = os.path.join(path, appauthor, appname) else: path = os.path.join(path, appname) - elif system == 'darwin': - path = os.path.expanduser('~/Library/Application Support/') + elif system == "darwin": + path = os.path.expanduser("~/Library/Application Support/") if appname: path = os.path.join(path, appname) else: - path = os.getenv('XDG_DATA_HOME', os.path.expanduser("~/.local/share")) + path = os.getenv("XDG_DATA_HOME", os.path.expanduser("~/.local/share")) if appname: path = os.path.join(path, appname) if appname and version: @@ -137,16 +137,19 @@ def site_data_dir(appname=None, appauthor=None, version=None, multipath=False): path = os.path.join(path, appauthor, appname) else: path = os.path.join(path, appname) - elif system == 'darwin': - path = os.path.expanduser('/Library/Application Support') + elif system == "darwin": + path = os.path.expanduser("/Library/Application Support") if appname: path = os.path.join(path, appname) else: # XDG default for $XDG_DATA_DIRS # only first, if multipath is False - path = os.getenv('XDG_DATA_DIRS', - os.pathsep.join(['/usr/local/share', '/usr/share'])) - pathlist = [os.path.expanduser(x.rstrip(os.sep)) for x in path.split(os.pathsep)] + path = os.getenv( + "XDG_DATA_DIRS", os.pathsep.join(["/usr/local/share", "/usr/share"]) + ) + pathlist = [ + os.path.expanduser(x.rstrip(os.sep)) for x in path.split(os.pathsep) + ] if appname: if version: appname = os.path.join(appname, version) @@ -195,7 +198,7 @@ def user_config_dir(appname=None, appauthor=None, version=None, roaming=False): if system in ["win32", "darwin"]: path = user_data_dir(appname, appauthor, None, roaming) else: - path = os.getenv('XDG_CONFIG_HOME', os.path.expanduser("~/.config")) + path = os.getenv("XDG_CONFIG_HOME", os.path.expanduser("~/.config")) if appname: path = os.path.join(path, appname) if appname and version: @@ -240,8 +243,10 @@ def site_config_dir(appname=None, appauthor=None, version=None, multipath=False) else: # XDG default for $XDG_CONFIG_DIRS # only first, if multipath is False - path = os.getenv('XDG_CONFIG_DIRS', '/etc/xdg') - pathlist = [os.path.expanduser(x.rstrip(os.sep)) for x in path.split(os.pathsep)] + path = os.getenv("XDG_CONFIG_DIRS", "/etc/xdg") + pathlist = [ + os.path.expanduser(x.rstrip(os.sep)) for x in path.split(os.pathsep) + ] if appname: if version: appname = os.path.join(appname, version) @@ -298,14 +303,14 @@ def user_cache_dir(appname=None, appauthor=None, version=None, opinion=True): path = os.path.join(path, appname) if opinion: path = os.path.join(path, "Cache") - elif system == 'darwin': - path = os.path.expanduser('~/Library/Caches') + elif system == "darwin": + path = os.path.expanduser("~/Library/Caches") if appname: path = os.path.join(path, appname) else: - path = os.getenv('XDG_CACHE_HOME', os.path.expanduser('~/.cache')) + path = os.getenv("XDG_CACHE_HOME", os.path.expanduser("~/.cache")) if appname: - path = os.path.join(path, appname.lower().replace(' ', '-')) + path = os.path.join(path, appname.lower().replace(" ", "-")) if appname and version: path = os.path.join(path, version) return path @@ -344,9 +349,7 @@ def user_log_dir(appname=None, appauthor=None, version=None, opinion=True): This can be disabled with the `opinion=False` option. """ if system == "darwin": - path = os.path.join( - os.path.expanduser('~/Library/Logs'), - appname) + path = os.path.join(os.path.expanduser("~/Library/Logs"), appname) elif system == "win32": path = user_data_dir(appname, appauthor, version) version = False @@ -364,8 +367,10 @@ def user_log_dir(appname=None, appauthor=None, version=None, opinion=True): class AppDirs(object): """Convenience wrapper for getting application dirs.""" - def __init__(self, appname, appauthor=None, version=None, roaming=False, - multipath=False): + + def __init__( + self, appname, appauthor=None, version=None, roaming=False, multipath=False + ): self.appname = appname self.appauthor = appauthor self.version = version @@ -374,36 +379,39 @@ class AppDirs(object): @property def user_data_dir(self): - return user_data_dir(self.appname, self.appauthor, - version=self.version, roaming=self.roaming) + return user_data_dir( + self.appname, self.appauthor, version=self.version, roaming=self.roaming + ) @property def site_data_dir(self): - return site_data_dir(self.appname, self.appauthor, - version=self.version, multipath=self.multipath) + return site_data_dir( + self.appname, self.appauthor, version=self.version, multipath=self.multipath + ) @property def user_config_dir(self): - return user_config_dir(self.appname, self.appauthor, - version=self.version, roaming=self.roaming) + return user_config_dir( + self.appname, self.appauthor, version=self.version, roaming=self.roaming + ) @property def site_config_dir(self): - return site_config_dir(self.appname, self.appauthor, - version=self.version, multipath=self.multipath) + return site_config_dir( + self.appname, self.appauthor, version=self.version, multipath=self.multipath + ) @property def user_cache_dir(self): - return user_cache_dir(self.appname, self.appauthor, - version=self.version) + return user_cache_dir(self.appname, self.appauthor, version=self.version) @property def user_log_dir(self): - return user_log_dir(self.appname, self.appauthor, - version=self.version) + return user_log_dir(self.appname, self.appauthor, version=self.version) -#---- internal support stuff +# ---- internal support stuff + def _get_win_folder_from_registry(csidl_name): """This is a fallback technique at best. I'm not sure if using the @@ -420,7 +428,7 @@ def _get_win_folder_from_registry(csidl_name): key = _winreg.OpenKey( _winreg.HKEY_CURRENT_USER, - r"Software\Microsoft\Windows\CurrentVersion\Explorer\Shell Folders" + r"Software\Microsoft\Windows\CurrentVersion\Explorer\Shell Folders", ) dir, type = _winreg.QueryValueEx(key, shell_folder_name) return dir @@ -428,6 +436,7 @@ def _get_win_folder_from_registry(csidl_name): def _get_win_folder_with_pywin32(csidl_name): from win32com.shell import shellcon, shell + dir = shell.SHGetFolderPath(0, getattr(shellcon, csidl_name), 0, 0) # Try to make this a unicode path because SHGetFolderPath does # not return unicode strings when there is unicode data in the @@ -445,6 +454,7 @@ def _get_win_folder_with_pywin32(csidl_name): if has_high_char: try: import win32api + dir = win32api.GetShortPathName(dir) except ImportError: pass @@ -479,15 +489,22 @@ def _get_win_folder_with_ctypes(csidl_name): return buf.value + def _get_win_folder_with_jna(csidl_name): import array from com.sun import jna from com.sun.jna.platform import win32 buf_size = win32.WinDef.MAX_PATH * 2 - buf = array.zeros('c', buf_size) + buf = array.zeros("c", buf_size) shell = win32.Shell32.INSTANCE - shell.SHGetFolderPath(None, getattr(win32.ShlObj, csidl_name), None, win32.ShlObj.SHGFP_TYPE_CURRENT, buf) + shell.SHGetFolderPath( + None, + getattr(win32.ShlObj, csidl_name), + None, + win32.ShlObj.SHGFP_TYPE_CURRENT, + buf, + ) dir = jna.Native.toString(buf.tostring()).rstrip("\0") # Downgrade to short path name if have highbit chars. See @@ -498,38 +515,47 @@ def _get_win_folder_with_jna(csidl_name): has_high_char = True break if has_high_char: - buf = array.zeros('c', buf_size) + buf = array.zeros("c", buf_size) kernel = win32.Kernel32.INSTANCE if kernal.GetShortPathName(dir, buf, buf_size): dir = jna.Native.toString(buf.tostring()).rstrip("\0") return dir + if system == "win32": try: import win32com.shell + _get_win_folder = _get_win_folder_with_pywin32 except ImportError: try: from ctypes import windll # type: ignore + _get_win_folder = _get_win_folder_with_ctypes except ImportError: try: import com.sun.jna + _get_win_folder = _get_win_folder_with_jna except ImportError: _get_win_folder = _get_win_folder_from_registry -#---- self test code +# ---- self test code if __name__ == "__main__": appname = "MyApp" appauthor = "MyCompany" - props = ("user_data_dir", "site_data_dir", - "user_config_dir", "site_config_dir", - "user_cache_dir", "user_log_dir") + props = ( + "user_data_dir", + "site_data_dir", + "user_config_dir", + "site_config_dir", + "user_cache_dir", + "user_log_dir", + ) print("-- app dirs (with optional 'version')") dirs = AppDirs(appname, appauthor, version="1.0") diff --git a/blender_cloud/async_loop.py b/blender_cloud/async_loop.py index 995c4f1..7ad1ee9 100644 --- a/blender_cloud/async_loop.py +++ b/blender_cloud/async_loop.py @@ -38,7 +38,7 @@ def setup_asyncio_executor(): import sys - if sys.platform == 'win32': + if sys.platform == "win32": asyncio.get_event_loop().close() # On Windows, the default event loop is SelectorEventLoop, which does # not support subprocesses. ProactorEventLoop should be used instead. @@ -55,6 +55,7 @@ def setup_asyncio_executor(): # loop.set_debug(True) from . import pillar + # No more than this many Pillar calls should be made simultaneously pillar.pillar_semaphore = asyncio.Semaphore(3, loop=loop) @@ -72,7 +73,7 @@ def kick_async_loop(*args) -> bool: stop_after_this_kick = False if loop.is_closed(): - log.warning('loop closed, stopping immediately.') + log.warning("loop closed, stopping immediately.") return True # Passing an explicit loop is required. Without it, the function uses @@ -81,12 +82,14 @@ def kick_async_loop(*args) -> bool: all_tasks = asyncio.all_tasks(loop=loop) if not len(all_tasks): - log.debug('no more scheduled tasks, stopping after this kick.') + log.debug("no more scheduled tasks, stopping after this kick.") stop_after_this_kick = True elif all(task.done() for task in all_tasks): - log.debug('all %i tasks are done, fetching results and stopping after this kick.', - len(all_tasks)) + log.debug( + "all %i tasks are done, fetching results and stopping after this kick.", + len(all_tasks), + ) stop_after_this_kick = True # Clean up circular references between tasks. @@ -99,12 +102,12 @@ def kick_async_loop(*args) -> bool: # noinspection PyBroadException try: res = task.result() - log.debug(' task #%i: result=%r', task_idx, res) + log.debug(" task #%i: result=%r", task_idx, res) except asyncio.CancelledError: # No problem, we want to stop anyway. - log.debug(' task #%i: cancelled', task_idx) + log.debug(" task #%i: cancelled", task_idx) except Exception: - print('{}: resulted in exception'.format(task)) + print("{}: resulted in exception".format(task)) traceback.print_exc() # for ref in gc.get_referrers(task): @@ -117,26 +120,26 @@ def kick_async_loop(*args) -> bool: def ensure_async_loop(): - log.debug('Starting asyncio loop') + log.debug("Starting asyncio loop") result = bpy.ops.asyncio.loop() - log.debug('Result of starting modal operator is %r', result) + log.debug("Result of starting modal operator is %r", result) def erase_async_loop(): global _loop_kicking_operator_running - log.debug('Erasing async loop') + log.debug("Erasing async loop") loop = asyncio.get_event_loop() loop.stop() class AsyncLoopModalOperator(bpy.types.Operator): - bl_idname = 'asyncio.loop' - bl_label = 'Runs the asyncio main loop' + bl_idname = "asyncio.loop" + bl_label = "Runs the asyncio main loop" timer = None - log = logging.getLogger(__name__ + '.AsyncLoopModalOperator') + log = logging.getLogger(__name__ + ".AsyncLoopModalOperator") def __del__(self): global _loop_kicking_operator_running @@ -153,8 +156,8 @@ class AsyncLoopModalOperator(bpy.types.Operator): global _loop_kicking_operator_running if _loop_kicking_operator_running: - self.log.debug('Another loop-kicking operator is already running.') - return {'PASS_THROUGH'} + self.log.debug("Another loop-kicking operator is already running.") + return {"PASS_THROUGH"} context.window_manager.modal_handler_add(self) _loop_kicking_operator_running = True @@ -162,7 +165,7 @@ class AsyncLoopModalOperator(bpy.types.Operator): wm = context.window_manager self.timer = wm.event_timer_add(0.00001, window=context.window) - return {'RUNNING_MODAL'} + return {"RUNNING_MODAL"} def modal(self, context, event): global _loop_kicking_operator_running @@ -171,10 +174,10 @@ class AsyncLoopModalOperator(bpy.types.Operator): # erase_async_loop(). This is a signal that we really should stop # running. if not _loop_kicking_operator_running: - return {'FINISHED'} + return {"FINISHED"} - if event.type != 'TIMER': - return {'PASS_THROUGH'} + if event.type != "TIMER": + return {"PASS_THROUGH"} # self.log.debug('KICKING LOOP') stop_after_this_kick = kick_async_loop() @@ -182,29 +185,33 @@ class AsyncLoopModalOperator(bpy.types.Operator): context.window_manager.event_timer_remove(self.timer) _loop_kicking_operator_running = False - self.log.debug('Stopped asyncio loop kicking') - return {'FINISHED'} + self.log.debug("Stopped asyncio loop kicking") + return {"FINISHED"} - return {'RUNNING_MODAL'} + return {"RUNNING_MODAL"} # noinspection PyAttributeOutsideInit class AsyncModalOperatorMixin: async_task = None # asyncio task for fetching thumbnails - signalling_future = None # asyncio future for signalling that we want to cancel everything. - log = logging.getLogger('%s.AsyncModalOperatorMixin' % __name__) + signalling_future = ( + None # asyncio future for signalling that we want to cancel everything. + ) + log = logging.getLogger("%s.AsyncModalOperatorMixin" % __name__) - _state = 'INITIALIZING' + _state = "INITIALIZING" stop_upon_exception = False def invoke(self, context, event): context.window_manager.modal_handler_add(self) - self.timer = context.window_manager.event_timer_add(1 / 15, window=context.window) + self.timer = context.window_manager.event_timer_add( + 1 / 15, window=context.window + ) - self.log.info('Starting') + self.log.info("Starting") self._new_async_task(self.async_execute(context)) - return {'RUNNING_MODAL'} + return {"RUNNING_MODAL"} async def async_execute(self, context): """Entry point of the asynchronous operator. @@ -215,7 +222,7 @@ class AsyncModalOperatorMixin: def quit(self): """Signals the state machine to stop this operator from running.""" - self._state = 'QUIT' + self._state = "QUIT" def execute(self, context): return self.invoke(context, None) @@ -223,46 +230,50 @@ class AsyncModalOperatorMixin: def modal(self, context, event): task = self.async_task - if self._state != 'EXCEPTION' and task and task.done() and not task.cancelled(): + if self._state != "EXCEPTION" and task and task.done() and not task.cancelled(): ex = task.exception() if ex is not None: - self._state = 'EXCEPTION' - self.log.error('Exception while running task: %s', ex) + self._state = "EXCEPTION" + self.log.error("Exception while running task: %s", ex) if self.stop_upon_exception: self.quit() self._finish(context) - return {'FINISHED'} + return {"FINISHED"} - return {'RUNNING_MODAL'} + return {"RUNNING_MODAL"} - if self._state == 'QUIT': + if self._state == "QUIT": self._finish(context) - return {'FINISHED'} + return {"FINISHED"} - return {'PASS_THROUGH'} + return {"PASS_THROUGH"} def _finish(self, context): self._stop_async_task() context.window_manager.event_timer_remove(self.timer) - def _new_async_task(self, async_task: typing.Coroutine, future: asyncio.Future = None): + def _new_async_task( + self, async_task: typing.Coroutine, future: asyncio.Future = None + ): """Stops the currently running async task, and starts another one.""" - self.log.debug('Setting up a new task %r, so any existing task must be stopped', async_task) + self.log.debug( + "Setting up a new task %r, so any existing task must be stopped", async_task + ) self._stop_async_task() # Download the previews asynchronously. self.signalling_future = future or asyncio.Future() self.async_task = asyncio.ensure_future(async_task) - self.log.debug('Created new task %r', self.async_task) + self.log.debug("Created new task %r", self.async_task) # Start the async manager so everything happens. ensure_async_loop() def _stop_async_task(self): - self.log.debug('Stopping async task') + self.log.debug("Stopping async task") if self.async_task is None: - self.log.debug('No async task, trivially stopped') + self.log.debug("No async task, trivially stopped") return # Signal that we want to stop. @@ -278,14 +289,14 @@ class AsyncModalOperatorMixin: try: loop.run_until_complete(self.async_task) except asyncio.CancelledError: - self.log.info('Asynchronous task was cancelled') + self.log.info("Asynchronous task was cancelled") return # noinspection PyBroadException try: self.async_task.result() # This re-raises any exception of the task. except asyncio.CancelledError: - self.log.info('Asynchronous task was cancelled') + self.log.info("Asynchronous task was cancelled") except Exception: self.log.exception("Exception from asynchronous task") diff --git a/blender_cloud/attract/__init__.py b/blender_cloud/attract/__init__.py index 0e4a760..d603956 100644 --- a/blender_cloud/attract/__init__.py +++ b/blender_cloud/attract/__init__.py @@ -87,7 +87,7 @@ def selected_shots(context): return for strip in selected_sequences: - atc_object_id = getattr(strip, 'atc_object_id') + atc_object_id = getattr(strip, "atc_object_id") if not atc_object_id: continue @@ -103,7 +103,7 @@ def all_shots(context): return [] for strip in context.scene.sequence_editor.sequences_all: - atc_object_id = getattr(strip, 'atc_object_id') + atc_object_id = getattr(strip, "atc_object_id") if not atc_object_id: continue @@ -139,7 +139,7 @@ def shot_id_use(strips): # Count the number of uses per Object ID, so that we can highlight double use. ids_in_use = collections.defaultdict(list) for strip in strips: - if not getattr(strip, 'atc_is_synced', False): + if not getattr(strip, "atc_is_synced", False): continue ids_in_use[strip.atc_object_id].append(strip) @@ -153,7 +153,11 @@ def compute_strip_conflicts(scene): if not attract_is_active: return - if not scene or not scene.sequence_editor or not scene.sequence_editor.sequences_all: + if ( + not scene + or not scene.sequence_editor + or not scene.sequence_editor.sequences_all + ): return tag_redraw = False @@ -182,34 +186,36 @@ class AttractPollMixin: class ATTRACT_PT_tools(AttractPollMixin, Panel): - bl_label = 'Attract' - bl_space_type = 'SEQUENCE_EDITOR' - bl_region_type = 'UI' - bl_category = 'Strip' + bl_label = "Attract" + bl_space_type = "SEQUENCE_EDITOR" + bl_region_type = "UI" + bl_category = "Strip" def draw_header(self, context): strip = active_strip(context) if strip and strip.atc_object_id: - self.layout.prop(strip, 'atc_is_synced', text='') + self.layout.prop(strip, "atc_is_synced", text="") def draw(self, context): strip = active_strip(context) layout = self.layout - strip_types = {'MOVIE', 'IMAGE', 'META', 'COLOR'} + strip_types = {"MOVIE", "IMAGE", "META", "COLOR"} if strip and strip.type in strip_types and strip.atc_object_id: self._draw_attractstrip_buttons(context, strip) elif context.selected_sequences: if len(context.selected_sequences) > 1: - noun = 'Selected Strips' + noun = "Selected Strips" else: - noun = 'This Strip' - layout.operator(ATTRACT_OT_submit_selected.bl_idname, - text='Submit %s as New Shot' % noun) - layout.operator('attract.shot_relink') + noun = "This Strip" + layout.operator( + ATTRACT_OT_submit_selected.bl_idname, + text="Submit %s as New Shot" % noun, + ) + layout.operator("attract.shot_relink") else: layout.operator(ATTRACT_OT_submit_all.bl_idname) - layout.operator(ATTRACT_OT_project_open_in_browser.bl_idname, icon='WORLD') + layout.operator(ATTRACT_OT_project_open_in_browser.bl_idname, icon="WORLD") def _draw_attractstrip_buttons(self, context, strip): """Draw buttons when selected strips are Attract shots.""" @@ -218,56 +224,63 @@ class ATTRACT_PT_tools(AttractPollMixin, Panel): selshots = list(selected_shots(context)) if len(selshots) > 1: - noun = '%i Shots' % len(selshots) + noun = "%i Shots" % len(selshots) else: - noun = 'This Shot' + noun = "This Shot" if strip.atc_object_id_conflict: warnbox = layout.box() warnbox.alert = True - warnbox.label(text='Warning: This shot is linked to multiple sequencer strips.', - icon='ERROR') - layout.prop(strip, 'atc_name', text='Name') - layout.prop(strip, 'atc_status', text='Status') + warnbox.label( + text="Warning: This shot is linked to multiple sequencer strips.", + icon="ERROR", + ) + layout.prop(strip, "atc_name", text="Name") + layout.prop(strip, "atc_status", text="Status") # Create a special sub-layout for read-only properties. ro_sub = layout.column(align=True) ro_sub.enabled = False - ro_sub.prop(strip, 'atc_description', text='Description') - ro_sub.prop(strip, 'atc_notes', text='Notes') + ro_sub.prop(strip, "atc_description", text="Description") + ro_sub.prop(strip, "atc_notes", text="Notes") if strip.atc_is_synced: sub = layout.column(align=True) row = sub.row(align=True) if bpy.ops.attract.submit_selected.poll(): - row.operator('attract.submit_selected', - text='Submit %s' % noun, - icon='TRIA_UP') + row.operator( + "attract.submit_selected", text="Submit %s" % noun, icon="TRIA_UP" + ) else: row.operator(ATTRACT_OT_submit_all.bl_idname) - row.operator(ATTRACT_OT_shot_fetch_update.bl_idname, - text='', icon='FILE_REFRESH') - row.operator(ATTRACT_OT_shot_open_in_browser.bl_idname, - text='', icon='WORLD') - row.operator(ATTRACT_OT_copy_id_to_clipboard.bl_idname, - text='', icon='COPYDOWN') - sub.operator(ATTRACT_OT_make_shot_thumbnail.bl_idname, - text='Render Thumbnail for %s' % noun, - icon='RENDER_STILL') + row.operator( + ATTRACT_OT_shot_fetch_update.bl_idname, text="", icon="FILE_REFRESH" + ) + row.operator( + ATTRACT_OT_shot_open_in_browser.bl_idname, text="", icon="WORLD" + ) + row.operator( + ATTRACT_OT_copy_id_to_clipboard.bl_idname, text="", icon="COPYDOWN" + ) + sub.operator( + ATTRACT_OT_make_shot_thumbnail.bl_idname, + text="Render Thumbnail for %s" % noun, + icon="RENDER_STILL", + ) # Group more dangerous operations. dangerous_sub = layout.split(**compatibility.factor(0.6), align=True) - dangerous_sub.operator('attract.strip_unlink', - text='Unlink %s' % noun, - icon='PANEL_CLOSE') - dangerous_sub.operator(ATTRACT_OT_shot_delete.bl_idname, - text='Delete %s' % noun, - icon='CANCEL') + dangerous_sub.operator( + "attract.strip_unlink", text="Unlink %s" % noun, icon="PANEL_CLOSE" + ) + dangerous_sub.operator( + ATTRACT_OT_shot_delete.bl_idname, text="Delete %s" % noun, icon="CANCEL" + ) class AttractOperatorMixin(AttractPollMixin): """Mix-in class for all Attract operators.""" def _project_needs_setup_error(self): - self.report({'ERROR'}, 'Your Blender Cloud project is not set up for Attract.') - return {'CANCELLED'} + self.report({"ERROR"}, "Your Blender Cloud project is not set up for Attract.") + return {"CANCELLED"} @functools.lru_cache() def find_project(self, project_uuid: str) -> Project: @@ -278,7 +291,7 @@ class AttractOperatorMixin(AttractPollMixin): from .. import pillar - project = pillar.sync_call(Project.find_one, {'where': {'_id': project_uuid}}) + project = pillar.sync_call(Project.find_one, {"where": {"_id": project_uuid}}) return project def find_node_type(self, node_type_name: str) -> dict: @@ -290,8 +303,10 @@ class AttractOperatorMixin(AttractPollMixin): # FIXME: Eve doesn't seem to handle the $elemMatch projection properly, # even though it works fine in MongoDB itself. As a result, we have to # search for the node type. - node_type_list = project['node_types'] - node_type = next((nt for nt in node_type_list if nt['name'] == node_type_name), None) + node_type_list = project["node_types"] + node_type = next( + (nt for nt in node_type_list if nt["name"] == node_type_name), None + ) if not node_type: return self._project_needs_setup_error() @@ -304,23 +319,29 @@ class AttractOperatorMixin(AttractPollMixin): # Define the shot properties user_uuid = pillar.pillar_user_uuid() if not user_uuid: - self.report({'ERROR'}, 'Your Blender Cloud user ID is not known, ' - 'update your credentials.') - return {'CANCELLED'} + self.report( + {"ERROR"}, + "Your Blender Cloud user ID is not known, " "update your credentials.", + ) + return {"CANCELLED"} - prop = {'name': strip.name, - 'description': '', - 'properties': {'status': 'todo', - 'notes': '', - 'used_in_edit': True, - 'trim_start_in_frames': strip.frame_offset_start, - 'trim_end_in_frames': strip.frame_offset_end, - 'duration_in_edit_in_frames': strip.frame_final_duration, - 'cut_in_timeline_in_frames': strip.frame_final_start}, - 'order': 0, - 'node_type': 'attract_shot', - 'project': blender.preferences().project.project, - 'user': user_uuid} + prop = { + "name": strip.name, + "description": "", + "properties": { + "status": "todo", + "notes": "", + "used_in_edit": True, + "trim_start_in_frames": strip.frame_offset_start, + "trim_end_in_frames": strip.frame_offset_end, + "duration_in_edit_in_frames": strip.frame_final_duration, + "cut_in_timeline_in_frames": strip.frame_final_start, + }, + "order": 0, + "node_type": "attract_shot", + "project": blender.preferences().project.project, + "user": user_uuid, + } # Create a Node item with the attract API node = Node(prop) @@ -328,15 +349,15 @@ class AttractOperatorMixin(AttractPollMixin): # Populate the strip with the freshly generated ObjectID and info if not post: - self.report({'ERROR'}, 'Error creating node! Check the console for now.') - return {'CANCELLED'} + self.report({"ERROR"}, "Error creating node! Check the console for now.") + return {"CANCELLED"} - strip.atc_object_id = node['_id'] + strip.atc_object_id = node["_id"] strip.atc_is_synced = True - strip.atc_name = node['name'] - strip.atc_description = node['description'] - strip.atc_notes = node['properties']['notes'] - strip.atc_status = node['properties']['status'] + strip.atc_name = node["name"] + strip.atc_description = node["description"] + strip.atc_notes = node["properties"]["notes"] + strip.atc_status = node["properties"]["status"] draw.tag_redraw_all_sequencer_editors() @@ -345,55 +366,58 @@ class AttractOperatorMixin(AttractPollMixin): from .. import pillar patch = { - 'op': 'from-blender', - '$set': { - 'name': strip.atc_name, - 'properties.trim_start_in_frames': strip.frame_offset_start, - 'properties.trim_end_in_frames': strip.frame_offset_end, - 'properties.duration_in_edit_in_frames': strip.frame_final_duration, - 'properties.cut_in_timeline_in_frames': strip.frame_final_start, - 'properties.status': strip.atc_status, - 'properties.used_in_edit': True, - } + "op": "from-blender", + "$set": { + "name": strip.atc_name, + "properties.trim_start_in_frames": strip.frame_offset_start, + "properties.trim_end_in_frames": strip.frame_offset_end, + "properties.duration_in_edit_in_frames": strip.frame_final_duration, + "properties.cut_in_timeline_in_frames": strip.frame_final_start, + "properties.status": strip.atc_status, + "properties.used_in_edit": True, + }, } - node = pillarsdk.Node({'_id': strip.atc_object_id}) + node = pillarsdk.Node({"_id": strip.atc_object_id}) result = pillar.sync_call(node.patch, patch) - log.info('PATCH result: %s', result) + log.info("PATCH result: %s", result) def relink(self, strip, atc_object_id, *, refresh=False): from .. import pillar # The node may have been deleted, so we need to send a 'relink' before we try # to fetch the node itself. - node = Node({'_id': atc_object_id}) - pillar.sync_call(node.patch, {'op': 'relink'}) + node = Node({"_id": atc_object_id}) + pillar.sync_call(node.patch, {"op": "relink"}) try: node = pillar.sync_call(Node.find, atc_object_id, caching=False) except (sdk_exceptions.ResourceNotFound, sdk_exceptions.MethodNotAllowed): - verb = 'refresh' if refresh else 'relink' - self.report({'ERROR'}, 'Shot %r not found on the Attract server, unable to %s.' - % (atc_object_id, verb)) + verb = "refresh" if refresh else "relink" + self.report( + {"ERROR"}, + "Shot %r not found on the Attract server, unable to %s." + % (atc_object_id, verb), + ) strip.atc_is_synced = False - return {'CANCELLED'} + return {"CANCELLED"} strip.atc_is_synced = True if not refresh: strip.atc_name = node.name - strip.atc_object_id = node['_id'] + strip.atc_object_id = node["_id"] # We do NOT set the position/cuts of the shot, that always has to come from Blender. strip.atc_status = node.properties.status - strip.atc_notes = node.properties.notes or '' - strip.atc_description = node.description or '' + strip.atc_notes = node.properties.notes or "" + strip.atc_description = node.description or "" draw.tag_redraw_all_sequencer_editors() class ATTRACT_OT_shot_fetch_update(AttractOperatorMixin, Operator): bl_idname = "attract.shot_fetch_update" bl_label = "Fetch Update From Attract" - bl_description = 'Update status, description & notes from Attract' + bl_description = "Update status, description & notes from Attract" @classmethod def poll(cls, context): @@ -405,8 +429,8 @@ class ATTRACT_OT_shot_fetch_update(AttractOperatorMixin, Operator): # We don't abort when one strip fails. All selected shots should be # refreshed, even if one can't be found (for example). if not isinstance(status, set): - self.report({'INFO'}, "Shot {0} refreshed".format(strip.atc_name)) - return {'FINISHED'} + self.report({"INFO"}, "Shot {0} refreshed".format(strip.atc_name)) + return {"FINISHED"} @compatibility.convert_properties @@ -422,7 +446,7 @@ class ATTRACT_OT_shot_relink(AttractOperatorMixin, Operator): return False strip = active_strip(context) - return strip is not None and not getattr(strip, 'atc_object_id', None) + return strip is not None and not getattr(strip, "atc_object_id", None) def execute(self, context): strip = active_strip(context) @@ -432,9 +456,9 @@ class ATTRACT_OT_shot_relink(AttractOperatorMixin, Operator): return status strip.atc_object_id = self.strip_atc_object_id - self.report({'INFO'}, "Shot {0} relinked".format(strip.atc_name)) + self.report({"INFO"}, "Shot {0} relinked".format(strip.atc_name)) - return {'FINISHED'} + return {"FINISHED"} def invoke(self, context, event): maybe_id = context.window_manager.clipboard @@ -451,18 +475,19 @@ class ATTRACT_OT_shot_relink(AttractOperatorMixin, Operator): def draw(self, context): layout = self.layout col = layout.column() - col.prop(self, 'strip_atc_object_id', text='Shot ID') + col.prop(self, "strip_atc_object_id", text="Shot ID") class ATTRACT_OT_shot_open_in_browser(AttractOperatorMixin, Operator): - bl_idname = 'attract.shot_open_in_browser' - bl_label = 'Open in Browser' - bl_description = 'Opens a webbrowser to show the shot on Attract' + bl_idname = "attract.shot_open_in_browser" + bl_label = "Open in Browser" + bl_description = "Opens a webbrowser to show the shot on Attract" @classmethod def poll(cls, context): - return AttractOperatorMixin.poll(context) and \ - bool(context.selected_sequences and active_strip(context)) + return AttractOperatorMixin.poll(context) and bool( + context.selected_sequences and active_strip(context) + ) def execute(self, context): from ..blender import PILLAR_WEB_SERVER_URL @@ -471,52 +496,56 @@ class ATTRACT_OT_shot_open_in_browser(AttractOperatorMixin, Operator): strip = active_strip(context) - url = urllib.parse.urljoin(PILLAR_WEB_SERVER_URL, - 'nodes/%s/redir' % strip.atc_object_id) + url = urllib.parse.urljoin( + PILLAR_WEB_SERVER_URL, "nodes/%s/redir" % strip.atc_object_id + ) webbrowser.open_new_tab(url) - self.report({'INFO'}, 'Opened a browser at %s' % url) + self.report({"INFO"}, "Opened a browser at %s" % url) - return {'FINISHED'} + return {"FINISHED"} @compatibility.convert_properties class ATTRACT_OT_shot_delete(AttractOperatorMixin, Operator): - bl_idname = 'attract.shot_delete' - bl_label = 'Delete Shot' - bl_description = 'Remove this shot from Attract' + bl_idname = "attract.shot_delete" + bl_label = "Delete Shot" + bl_description = "Remove this shot from Attract" - confirm = bpy.props.BoolProperty(name='confirm') + confirm = bpy.props.BoolProperty(name="confirm") @classmethod def poll(cls, context): - return AttractOperatorMixin.poll(context) and \ - bool(context.selected_sequences) + return AttractOperatorMixin.poll(context) and bool(context.selected_sequences) def execute(self, context): from .. import pillar if not self.confirm: - self.report({'WARNING'}, 'Delete aborted.') - return {'CANCELLED'} + self.report({"WARNING"}, "Delete aborted.") + return {"CANCELLED"} removed = kept = 0 for strip in selected_shots(context): node = pillar.sync_call(Node.find, strip.atc_object_id) if not pillar.sync_call(node.delete): - self.report({'ERROR'}, 'Unable to delete shot %s on Attract.' % strip.atc_name) + self.report( + {"ERROR"}, "Unable to delete shot %s on Attract." % strip.atc_name + ) kept += 1 continue remove_atc_props(strip) removed += 1 if kept: - self.report({'ERROR'}, 'Removed %i shots, but was unable to remove %i' % - (removed, kept)) + self.report( + {"ERROR"}, + "Removed %i shots, but was unable to remove %i" % (removed, kept), + ) else: - self.report({'INFO'}, 'Removed all %i shots from Attract' % removed) + self.report({"INFO"}, "Removed all %i shots from Attract" % removed) draw.tag_redraw_all_sequencer_editors() - return {'FINISHED'} + return {"FINISHED"} def invoke(self, context, event): self.confirm = False @@ -528,29 +557,30 @@ class ATTRACT_OT_shot_delete(AttractOperatorMixin, Operator): selshots = list(selected_shots(context)) if len(selshots) > 1: - noun = '%i shots' % len(selshots) + noun = "%i shots" % len(selshots) else: - noun = 'this shot' + noun = "this shot" - col.prop(self, 'confirm', text="I hereby confirm: delete %s from The Edit." % noun) + col.prop( + self, "confirm", text="I hereby confirm: delete %s from The Edit." % noun + ) class ATTRACT_OT_strip_unlink(AttractOperatorMixin, Operator): - bl_idname = 'attract.strip_unlink' - bl_label = 'Unlink Shot From This Strip' - bl_description = 'Remove Attract props from the selected strip(s)' + bl_idname = "attract.strip_unlink" + bl_label = "Unlink Shot From This Strip" + bl_description = "Remove Attract props from the selected strip(s)" @classmethod def poll(cls, context): - return AttractOperatorMixin.poll(context) and \ - bool(context.selected_sequences) + return AttractOperatorMixin.poll(context) and bool(context.selected_sequences) def execute(self, context): unlinked_ids = set() # First remove the Attract properties from the strips. for strip in context.selected_sequences: - atc_object_id = getattr(strip, 'atc_object_id') + atc_object_id = getattr(strip, "atc_object_id") remove_atc_props(strip) if atc_object_id: @@ -565,33 +595,34 @@ class ATTRACT_OT_strip_unlink(AttractOperatorMixin, Operator): # Still in use continue - node = Node({'_id': oid}) - pillar.sync_call(node.patch, {'op': 'unlink'}) + node = Node({"_id": oid}) + pillar.sync_call(node.patch, {"op": "unlink"}) if len(unlinked_ids) == 1: shot_id = unlinked_ids.pop() context.window_manager.clipboard = shot_id - self.report({'INFO'}, 'Copied unlinked shot ID %s to clipboard' % shot_id) + self.report({"INFO"}, "Copied unlinked shot ID %s to clipboard" % shot_id) else: - self.report({'INFO'}, '%i shots have been marked as Unused.' % len(unlinked_ids)) + self.report( + {"INFO"}, "%i shots have been marked as Unused." % len(unlinked_ids) + ) draw.tag_redraw_all_sequencer_editors() - return {'FINISHED'} + return {"FINISHED"} class ATTRACT_OT_submit_selected(AttractOperatorMixin, Operator): - bl_idname = 'attract.submit_selected' - bl_label = 'Submit All Selected' - bl_description = 'Submits all selected strips to Attract' + bl_idname = "attract.submit_selected" + bl_label = "Submit All Selected" + bl_description = "Submits all selected strips to Attract" @classmethod def poll(cls, context): - return AttractOperatorMixin.poll(context) and \ - bool(context.selected_sequences) + return AttractOperatorMixin.poll(context) and bool(context.selected_sequences) def execute(self, context): # Check that the project is set up for Attract. - maybe_error = self.find_node_type('attract_shot') + maybe_error = self.find_node_type("attract_shot") if isinstance(maybe_error, set): return maybe_error @@ -600,12 +631,12 @@ class ATTRACT_OT_submit_selected(AttractOperatorMixin, Operator): if isinstance(status, set): return status - self.report({'INFO'}, 'All selected strips sent to Attract.') + self.report({"INFO"}, "All selected strips sent to Attract.") - return {'FINISHED'} + return {"FINISHED"} def submit(self, strip): - atc_object_id = getattr(strip, 'atc_object_id', None) + atc_object_id = getattr(strip, "atc_object_id", None) # Submit as new? if not atc_object_id: @@ -616,13 +647,13 @@ class ATTRACT_OT_submit_selected(AttractOperatorMixin, Operator): class ATTRACT_OT_submit_all(AttractOperatorMixin, Operator): - bl_idname = 'attract.submit_all' - bl_label = 'Submit All Shots to Attract' - bl_description = 'Updates Attract with the current state of the edit' + bl_idname = "attract.submit_all" + bl_label = "Submit All Shots to Attract" + bl_description = "Updates Attract with the current state of the edit" def execute(self, context): # Check that the project is set up for Attract. - maybe_error = self.find_node_type('attract_shot') + maybe_error = self.find_node_type("attract_shot") if isinstance(maybe_error, set): return maybe_error @@ -631,19 +662,21 @@ class ATTRACT_OT_submit_all(AttractOperatorMixin, Operator): if isinstance(status, set): return status - self.report({'INFO'}, 'All strips re-sent to Attract.') + self.report({"INFO"}, "All strips re-sent to Attract.") - return {'FINISHED'} + return {"FINISHED"} class ATTRACT_OT_open_meta_blendfile(AttractOperatorMixin, Operator): - bl_idname = 'attract.open_meta_blendfile' - bl_label = 'Open Blendfile' - bl_description = 'Open Blendfile from movie strip metadata' + bl_idname = "attract.open_meta_blendfile" + bl_label = "Open Blendfile" + bl_description = "Open Blendfile from movie strip metadata" @classmethod def poll(cls, context): - return bool(any(cls.filename_from_metadata(s) for s in context.selected_sequences)) + return bool( + any(cls.filename_from_metadata(s) for s in context.selected_sequences) + ) @staticmethod def filename_from_metadata(strip): @@ -656,25 +689,26 @@ class ATTRACT_OT_open_meta_blendfile(AttractOperatorMixin, Operator): # 'FRAME_STEP': '1', # 'START_FRAME': '32'} - meta = strip.get('metadata', None) + meta = strip.get("metadata", None) if not meta: return None - return meta.get('BLEND_FILE', None) or None + return meta.get("BLEND_FILE", None) or None def execute(self, context): for strip in context.selected_sequences: - meta = strip.get('metadata', None) + meta = strip.get("metadata", None) if not meta: continue - fname = meta.get('BLEND_FILE', None) - if not fname: continue + fname = meta.get("BLEND_FILE", None) + if not fname: + continue - scene = meta.get('SCENE', None) + scene = meta.get("SCENE", None) self.open_in_new_blender(fname, scene) - return {'FINISHED'} + return {"FINISHED"} def open_in_new_blender(self, fname, scene): """ @@ -689,22 +723,29 @@ class ATTRACT_OT_open_meta_blendfile(AttractOperatorMixin, Operator): str(fname), ] - cmd[1:1] = [v for v in sys.argv if v.startswith('--enable-')] + cmd[1:1] = [v for v in sys.argv if v.startswith("--enable-")] if scene: - cmd.extend(['--python-expr', - 'import bpy; bpy.context.screen.scene = bpy.data.scenes["%s"]' % scene]) - cmd.extend(['--scene', scene]) + cmd.extend( + [ + "--python-expr", + 'import bpy; bpy.context.screen.scene = bpy.data.scenes["%s"]' + % scene, + ] + ) + cmd.extend(["--scene", scene]) subprocess.Popen(cmd) -class ATTRACT_OT_make_shot_thumbnail(AttractOperatorMixin, - async_loop.AsyncModalOperatorMixin, - Operator): - bl_idname = 'attract.make_shot_thumbnail' - bl_label = 'Render Shot Thumbnail' - bl_description = 'Renders the current frame, and uploads it as thumbnail for the shot' +class ATTRACT_OT_make_shot_thumbnail( + AttractOperatorMixin, async_loop.AsyncModalOperatorMixin, Operator +): + bl_idname = "attract.make_shot_thumbnail" + bl_label = "Render Shot Thumbnail" + bl_description = ( + "Renders the current frame, and uploads it as thumbnail for the shot" + ) stop_upon_exception = True @@ -727,7 +768,7 @@ class ATTRACT_OT_make_shot_thumbnail(AttractOperatorMixin, context.scene.render.resolution_x = thumbnail_width context.scene.render.resolution_y = round(thumbnail_width * factor) context.scene.render.resolution_percentage = 100 - context.scene.render.image_settings.file_format = 'JPEG' + context.scene.render.image_settings.file_format = "JPEG" context.scene.render.image_settings.quality = 85 yield @@ -761,8 +802,10 @@ class ATTRACT_OT_make_shot_thumbnail(AttractOperatorMixin, if do_multishot: context.window_manager.progress_begin(0, nr_of_strips) try: - self.report({'INFO'}, 'Rendering thumbnails for %i selected shots.' % - nr_of_strips) + self.report( + {"INFO"}, + "Rendering thumbnails for %i selected shots." % nr_of_strips, + ) strips = sorted(context.selected_sequences, key=self.by_frame) for idx, strip in enumerate(strips): @@ -776,7 +819,7 @@ class ATTRACT_OT_make_shot_thumbnail(AttractOperatorMixin, context.scene.frame_set(original_curframe) await self.thumbnail_strip(context, strip) - if self._state == 'QUIT': + if self._state == "QUIT": return context.window_manager.progress_update(nr_of_strips) finally: @@ -785,10 +828,16 @@ class ATTRACT_OT_make_shot_thumbnail(AttractOperatorMixin, else: strip = active_strip(context) if not self.strip_contains(strip, original_curframe): - self.report({'WARNING'}, 'Rendering middle frame as thumbnail for active shot.') + self.report( + {"WARNING"}, + "Rendering middle frame as thumbnail for active shot.", + ) self.set_middle_frame(context, strip) else: - self.report({'INFO'}, 'Rendering current frame as thumbnail for active shot.') + self.report( + {"INFO"}, + "Rendering current frame as thumbnail for active shot.", + ) context.window_manager.progress_begin(0, 1) context.window_manager.progress_update(0) @@ -798,10 +847,10 @@ class ATTRACT_OT_make_shot_thumbnail(AttractOperatorMixin, context.window_manager.progress_update(1) context.window_manager.progress_end() - if self._state == 'QUIT': + if self._state == "QUIT": return - self.report({'INFO'}, 'Thumbnail uploaded to Attract') + self.report({"INFO"}, "Thumbnail uploaded to Attract") self.quit() @staticmethod @@ -826,31 +875,33 @@ class ATTRACT_OT_make_shot_thumbnail(AttractOperatorMixin, return sequence_strip.frame_final_start async def thumbnail_strip(self, context, strip): - atc_object_id = getattr(strip, 'atc_object_id', None) + atc_object_id = getattr(strip, "atc_object_id", None) if not atc_object_id: - self.report({'ERROR'}, 'Strip %s not set up for Attract' % strip.name) + self.report({"ERROR"}, "Strip %s not set up for Attract" % strip.name) self.quit() return with self.thumbnail_render_settings(context): bpy.ops.render.render() - file_id = await self.upload_via_tempdir(bpy.data.images['Render Result'], - 'attract_shot_thumbnail.jpg') + file_id = await self.upload_via_tempdir( + bpy.data.images["Render Result"], "attract_shot_thumbnail.jpg" + ) if file_id is None: self.quit() return # Update the shot to include this file as the picture. - node = pillarsdk.Node({'_id': atc_object_id}) + node = pillarsdk.Node({"_id": atc_object_id}) await pillar.pillar_call( node.patch, { - 'op': 'from-blender', - '$set': { - 'picture': file_id, - } - }) + "op": "from-blender", + "$set": { + "picture": file_id, + }, + }, + ) async def upload_via_tempdir(self, datablock, filename_on_cloud) -> pillarsdk.Node: """Saves the datablock to file, and uploads it to the cloud. @@ -864,7 +915,7 @@ class ATTRACT_OT_make_shot_thumbnail(AttractOperatorMixin, with tempfile.TemporaryDirectory() as tmpdir: filepath = os.path.join(tmpdir, filename_on_cloud) - self.log.debug('Saving %s to %s', datablock, filepath) + self.log.debug("Saving %s to %s", datablock, filepath) datablock.save_render(filepath) return await self.upload_file(filepath) @@ -878,53 +929,55 @@ class ATTRACT_OT_make_shot_thumbnail(AttractOperatorMixin, prefs = blender.preferences() project = self.find_project(prefs.project.project) - self.log.info('Uploading file %s', filename) + self.log.info("Uploading file %s", filename) resp = await pillar.pillar_call( pillarsdk.File.upload_to_project, - project['_id'], - 'image/jpeg', + project["_id"], + "image/jpeg", filename, - fileobj=fileobj) + fileobj=fileobj, + ) - self.log.debug('Returned data: %s', resp) + self.log.debug("Returned data: %s", resp) try: - file_id = resp['file_id'] + file_id = resp["file_id"] except KeyError: - self.log.error('Upload did not succeed, response: %s', resp) - self.report({'ERROR'}, 'Unable to upload thumbnail to Attract: %s' % resp) + self.log.error("Upload did not succeed, response: %s", resp) + self.report({"ERROR"}, "Unable to upload thumbnail to Attract: %s" % resp) return None - self.log.info('Created file %s', file_id) - self.report({'INFO'}, 'File succesfully uploaded to the cloud!') + self.log.info("Created file %s", file_id) + self.report({"INFO"}, "File succesfully uploaded to the cloud!") return file_id class ATTRACT_OT_copy_id_to_clipboard(AttractOperatorMixin, Operator): - bl_idname = 'attract.copy_id_to_clipboard' - bl_label = 'Copy shot ID to clipboard' + bl_idname = "attract.copy_id_to_clipboard" + bl_label = "Copy shot ID to clipboard" @classmethod def poll(cls, context): - return AttractOperatorMixin.poll(context) and \ - bool(context.selected_sequences and active_strip(context)) + return AttractOperatorMixin.poll(context) and bool( + context.selected_sequences and active_strip(context) + ) def execute(self, context): strip = active_strip(context) context.window_manager.clipboard = strip.atc_object_id - self.report({'INFO'}, 'Shot ID %s copied to clipboard' % strip.atc_object_id) + self.report({"INFO"}, "Shot ID %s copied to clipboard" % strip.atc_object_id) - return {'FINISHED'} + return {"FINISHED"} @compatibility.convert_properties class ATTRACT_OT_project_open_in_browser(Operator): - bl_idname = 'attract.project_open_in_browser' - bl_label = 'Open Project in Browser' - bl_description = 'Opens a webbrowser to show the project in Attract' + bl_idname = "attract.project_open_in_browser" + bl_label = "Open Project in Browser" + bl_description = "Opens a webbrowser to show the project in Attract" - project_id = bpy.props.StringProperty(name='Project ID', default='') + project_id = bpy.props.StringProperty(name="Project ID", default="") def execute(self, context): import webbrowser @@ -937,54 +990,58 @@ class ATTRACT_OT_project_open_in_browser(Operator): if not self.project_id: self.project_id = preferences().project.project - project = sync_call(pillarsdk.Project.find, self.project_id, {'projection': {'url': True}}) + project = sync_call( + pillarsdk.Project.find, self.project_id, {"projection": {"url": True}} + ) if log.isEnabledFor(logging.DEBUG): import pprint - log.debug('found project: %s', pprint.pformat(project.to_dict())) - url = urllib.parse.urljoin(PILLAR_WEB_SERVER_URL, 'attract/' + project.url) + log.debug("found project: %s", pprint.pformat(project.to_dict())) + + url = urllib.parse.urljoin(PILLAR_WEB_SERVER_URL, "attract/" + project.url) webbrowser.open_new_tab(url) - self.report({'INFO'}, 'Opened a browser at %s' % url) + self.report({"INFO"}, "Opened a browser at %s" % url) - return {'FINISHED'} + return {"FINISHED"} class ATTRACT_PT_strip_metadata(bl_ui.space_sequencer.SequencerButtonsPanel, Panel): bl_label = "Metadata" bl_parent_id = "SEQUENCER_PT_source" bl_category = "Strip" - bl_options = {'DEFAULT_CLOSED'} + bl_options = {"DEFAULT_CLOSED"} def draw(self, context): strip = active_strip(context) if not strip: return - meta = strip.get('metadata', None) + meta = strip.get("metadata", None) if not meta: return None box = self.layout.column(align=True) row = box.row(align=True) - fname = meta.get('BLEND_FILE', None) or None + fname = meta.get("BLEND_FILE", None) or None if fname: - row.label(text='Original Blendfile: %s' % fname) - row.operator(ATTRACT_OT_open_meta_blendfile.bl_idname, - text='', icon='FILE_BLEND') - sfra = meta.get('START_FRAME', '?') - efra = meta.get('END_FRAME', '?') - box.label(text='Original Frame Range: %s-%s' % (sfra, efra)) + row.label(text="Original Blendfile: %s" % fname) + row.operator( + ATTRACT_OT_open_meta_blendfile.bl_idname, text="", icon="FILE_BLEND" + ) + sfra = meta.get("START_FRAME", "?") + efra = meta.get("END_FRAME", "?") + box.label(text="Original Frame Range: %s-%s" % (sfra, efra)) def activate(): global attract_is_active - log.info('Activating Attract') + log.info("Activating Attract") attract_is_active = True # TODO: properly fix 2.8 compatibility; this is just a workaround. - if hasattr(bpy.app.handlers, 'scene_update_post'): + if hasattr(bpy.app.handlers, "scene_update_post"): bpy.app.handlers.scene_update_post.append(scene_update_post_handler) draw.callback_enable() @@ -992,12 +1049,12 @@ def activate(): def deactivate(): global attract_is_active - log.info('Deactivating Attract') + log.info("Deactivating Attract") attract_is_active = False draw.callback_disable() # TODO: properly fix 2.8 compatibility; this is just a workaround. - if hasattr(bpy.app.handlers, 'scene_update_post'): + if hasattr(bpy.app.handlers, "scene_update_post"): try: bpy.app.handlers.scene_update_post.remove(scene_update_post_handler) except ValueError: @@ -1005,31 +1062,40 @@ def deactivate(): pass -_rna_classes = [cls for cls in locals().values() - if isinstance(cls, type) and cls.__name__.startswith('ATTRACT')] +_rna_classes = [ + cls + for cls in locals().values() + if isinstance(cls, type) and cls.__name__.startswith("ATTRACT") +] def register(): bpy.types.Sequence.atc_is_synced = bpy.props.BoolProperty(name="Is Synced") - bpy.types.Sequence.atc_object_id = bpy.props.StringProperty(name="Attract Object ID") + bpy.types.Sequence.atc_object_id = bpy.props.StringProperty( + name="Attract Object ID" + ) bpy.types.Sequence.atc_object_id_conflict = bpy.props.BoolProperty( - name='Object ID Conflict', - description='Attract Object ID used multiple times', - default=False) + name="Object ID Conflict", + description="Attract Object ID used multiple times", + default=False, + ) bpy.types.Sequence.atc_name = bpy.props.StringProperty(name="Shot Name") - bpy.types.Sequence.atc_description = bpy.props.StringProperty(name="Shot Description") + bpy.types.Sequence.atc_description = bpy.props.StringProperty( + name="Shot Description" + ) bpy.types.Sequence.atc_notes = bpy.props.StringProperty(name="Shot Notes") # TODO: get this from the project's node type definition. bpy.types.Sequence.atc_status = bpy.props.EnumProperty( items=[ - ('on_hold', 'On Hold', 'The shot is on hold'), - ('todo', 'Todo', 'Waiting'), - ('in_progress', 'In Progress', 'The show has been assigned'), - ('review', 'Review', ''), - ('final', 'Final', ''), + ("on_hold", "On Hold", "The shot is on hold"), + ("todo", "Todo", "Waiting"), + ("in_progress", "In Progress", "The show has been assigned"), + ("review", "Review", ""), + ("final", "Final", ""), ], - name="Status") + name="Status", + ) bpy.types.Sequence.atc_order = bpy.props.IntProperty(name="Order") for cls in _rna_classes: @@ -1042,7 +1108,9 @@ def unregister(): try: bpy.utils.unregister_class(cls) except RuntimeError: - log.warning('Unable to unregister class %r, probably already unregistered', cls) + log.warning( + "Unable to unregister class %r, probably already unregistered", cls + ) del bpy.types.Sequence.atc_is_synced del bpy.types.Sequence.atc_object_id diff --git a/blender_cloud/attract/draw.py b/blender_cloud/attract/draw.py index 2c1828e..05201a8 100644 --- a/blender_cloud/attract/draw.py +++ b/blender_cloud/attract/draw.py @@ -29,17 +29,17 @@ log = logging.getLogger(__name__) strip_status_colour = { None: (0.7, 0.7, 0.7), - 'approved': (0.6392156862745098, 0.8784313725490196, 0.30196078431372547), - 'final': (0.9058823529411765, 0.9607843137254902, 0.8274509803921568), - 'in_progress': (1.0, 0.7450980392156863, 0.0), - 'on_hold': (0.796078431372549, 0.6196078431372549, 0.08235294117647059), - 'review': (0.8941176470588236, 0.9607843137254902, 0.9764705882352941), - 'todo': (1.0, 0.5019607843137255, 0.5019607843137255) + "approved": (0.6392156862745098, 0.8784313725490196, 0.30196078431372547), + "final": (0.9058823529411765, 0.9607843137254902, 0.8274509803921568), + "in_progress": (1.0, 0.7450980392156863, 0.0), + "on_hold": (0.796078431372549, 0.6196078431372549, 0.08235294117647059), + "review": (0.8941176470588236, 0.9607843137254902, 0.9764705882352941), + "todo": (1.0, 0.5019607843137255, 0.5019607843137255), } CONFLICT_COLOUR = (0.576, 0.118, 0.035, 1.0) # RGBA tuple -gpu_vertex_shader = ''' +gpu_vertex_shader = """ uniform mat4 ModelViewProjectionMatrix; layout (location = 0) in vec2 pos; @@ -52,9 +52,9 @@ void main() gl_Position = ModelViewProjectionMatrix * vec4(pos.x, pos.y, 0.0, 1.0); lineColor = color; } -''' +""" -gpu_fragment_shader = ''' +gpu_fragment_shader = """ out vec4 fragColor; in vec4 lineColor; @@ -62,7 +62,7 @@ void main() { fragColor = lineColor; } -''' +""" Float2 = typing.Tuple[float, float] Float3 = typing.Tuple[float, float, float] @@ -70,25 +70,18 @@ Float4 = typing.Tuple[float, float, float, float] class AttractLineDrawer: - def __init__(self): self._format = gpu.types.GPUVertFormat() self._pos_id = self._format.attr_add( - id="pos", - comp_type="F32", - len=2, - fetch_mode="FLOAT") + id="pos", comp_type="F32", len=2, fetch_mode="FLOAT" + ) self._color_id = self._format.attr_add( - id="color", - comp_type="F32", - len=4, - fetch_mode="FLOAT") + id="color", comp_type="F32", len=4, fetch_mode="FLOAT" + ) self.shader = gpu.types.GPUShader(gpu_vertex_shader, gpu_fragment_shader) - def draw(self, - coords: typing.List[Float2], - colors: typing.List[Float4]): + def draw(self, coords: typing.List[Float2], colors: typing.List[Float4]): if not coords: return @@ -114,11 +107,13 @@ def get_strip_rectf(strip) -> Float4: return x1, y1, x2, y2 -def underline_in_strip(strip_coords: Float4, - pixel_size_x: float, - color: Float4, - out_coords: typing.List[Float2], - out_colors: typing.List[Float4]): +def underline_in_strip( + strip_coords: Float4, + pixel_size_x: float, + color: Float4, + out_coords: typing.List[Float2], + out_colors: typing.List[Float4], +): # Strip coords s_x1, s_y1, s_x2, s_y2 = strip_coords @@ -142,9 +137,11 @@ def underline_in_strip(strip_coords: Float4, out_colors.append(color) -def strip_conflict(strip_coords: Float4, - out_coords: typing.List[Float2], - out_colors: typing.List[Float4]): +def strip_conflict( + strip_coords: Float4, + out_coords: typing.List[Float2], + out_colors: typing.List[Float4], +): """Draws conflicting states between strips.""" s_x1, s_y1, s_x2, s_y2 = strip_coords @@ -191,8 +188,12 @@ def draw_callback_px(line_drawer: AttractLineDrawer): strip_coords = get_strip_rectf(strip) # check if any of the coordinates are out of bounds - if strip_coords[0] > xwin2 or strip_coords[2] < xwin1 or strip_coords[1] > ywin2 or \ - strip_coords[3] < ywin1: + if ( + strip_coords[0] > xwin2 + or strip_coords[2] < xwin1 + or strip_coords[1] > ywin2 + or strip_coords[3] < ywin1 + ): continue # Draw @@ -217,9 +218,9 @@ def tag_redraw_all_sequencer_editors(): # Py cant access notifiers for window in context.window_manager.windows: for area in window.screen.areas: - if area.type == 'SEQUENCE_EDITOR': + if area.type == "SEQUENCE_EDITOR": for region in area.regions: - if region.type == 'WINDOW': + if region.type == "WINDOW": region.tag_redraw() @@ -237,8 +238,11 @@ def callback_enable(): return line_drawer = AttractLineDrawer() - cb_handle[:] = bpy.types.SpaceSequenceEditor.draw_handler_add( - draw_callback_px, (line_drawer,), 'WINDOW', 'POST_VIEW'), + cb_handle[:] = ( + bpy.types.SpaceSequenceEditor.draw_handler_add( + draw_callback_px, (line_drawer,), "WINDOW", "POST_VIEW" + ), + ) tag_redraw_all_sequencer_editors() @@ -248,7 +252,7 @@ def callback_disable(): return try: - bpy.types.SpaceSequenceEditor.draw_handler_remove(cb_handle[0], 'WINDOW') + bpy.types.SpaceSequenceEditor.draw_handler_remove(cb_handle[0], "WINDOW") except ValueError: # Thrown when already removed. pass diff --git a/blender_cloud/attract/draw_27.py b/blender_cloud/attract/draw_27.py index 1150e2d..251d675 100644 --- a/blender_cloud/attract/draw_27.py +++ b/blender_cloud/attract/draw_27.py @@ -26,12 +26,12 @@ log = logging.getLogger(__name__) strip_status_colour = { None: (0.7, 0.7, 0.7), - 'approved': (0.6392156862745098, 0.8784313725490196, 0.30196078431372547), - 'final': (0.9058823529411765, 0.9607843137254902, 0.8274509803921568), - 'in_progress': (1.0, 0.7450980392156863, 0.0), - 'on_hold': (0.796078431372549, 0.6196078431372549, 0.08235294117647059), - 'review': (0.8941176470588236, 0.9607843137254902, 0.9764705882352941), - 'todo': (1.0, 0.5019607843137255, 0.5019607843137255) + "approved": (0.6392156862745098, 0.8784313725490196, 0.30196078431372547), + "final": (0.9058823529411765, 0.9607843137254902, 0.8274509803921568), + "in_progress": (1.0, 0.7450980392156863, 0.0), + "on_hold": (0.796078431372549, 0.6196078431372549, 0.08235294117647059), + "review": (0.8941176470588236, 0.9607843137254902, 0.9764705882352941), + "todo": (1.0, 0.5019607843137255, 0.5019607843137255), } CONFLICT_COLOUR = (0.576, 0.118, 0.035) # RGB tuple @@ -123,8 +123,12 @@ def draw_callback_px(): strip_coords = get_strip_rectf(strip) # check if any of the coordinates are out of bounds - if strip_coords[0] > xwin2 or strip_coords[2] < xwin1 or strip_coords[1] > ywin2 or \ - strip_coords[3] < ywin1: + if ( + strip_coords[0] > xwin2 + or strip_coords[2] < xwin1 + or strip_coords[1] > ywin2 + or strip_coords[3] < ywin1 + ): continue # Draw @@ -147,9 +151,9 @@ def tag_redraw_all_sequencer_editors(): # Py cant access notifiers for window in context.window_manager.windows: for area in window.screen.areas: - if area.type == 'SEQUENCE_EDITOR': + if area.type == "SEQUENCE_EDITOR": for region in area.regions: - if region.type == 'WINDOW': + if region.type == "WINDOW": region.tag_redraw() @@ -162,8 +166,11 @@ def callback_enable(): if cb_handle: return - cb_handle[:] = bpy.types.SpaceSequenceEditor.draw_handler_add( - draw_callback_px, (), 'WINDOW', 'POST_VIEW'), + cb_handle[:] = ( + bpy.types.SpaceSequenceEditor.draw_handler_add( + draw_callback_px, (), "WINDOW", "POST_VIEW" + ), + ) tag_redraw_all_sequencer_editors() @@ -173,7 +180,7 @@ def callback_disable(): return try: - bpy.types.SpaceSequenceEditor.draw_handler_remove(cb_handle[0], 'WINDOW') + bpy.types.SpaceSequenceEditor.draw_handler_remove(cb_handle[0], "WINDOW") except ValueError: # Thrown when already removed. pass diff --git a/blender_cloud/blender.py b/blender_cloud/blender.py index cee3abd..5549bbd 100644 --- a/blender_cloud/blender.py +++ b/blender_cloud/blender.py @@ -27,61 +27,74 @@ import tempfile import bpy from bpy.types import AddonPreferences, Operator, WindowManager, Scene, PropertyGroup -from bpy.props import StringProperty, EnumProperty, PointerProperty, BoolProperty, IntProperty +from bpy.props import ( + StringProperty, + EnumProperty, + PointerProperty, + BoolProperty, + IntProperty, +) import rna_prop_ui from . import compatibility, pillar, async_loop, flamenco, project_specific from .utils import pyside_cache, redraw -PILLAR_WEB_SERVER_URL = os.environ.get('BCLOUD_SERVER', 'https://cloud.blender.org/') -PILLAR_SERVER_URL = '%sapi/' % PILLAR_WEB_SERVER_URL +PILLAR_WEB_SERVER_URL = os.environ.get("BCLOUD_SERVER", "https://cloud.blender.org/") +PILLAR_SERVER_URL = "%sapi/" % PILLAR_WEB_SERVER_URL -ADDON_NAME = 'blender_cloud' +ADDON_NAME = "blender_cloud" log = logging.getLogger(__name__) icons = None -@pyside_cache('version') +@pyside_cache("version") def blender_syncable_versions(self, context): """Returns the list of items used by SyncStatusProperties.version EnumProperty.""" bss = context.window_manager.blender_sync_status versions = bss.available_blender_versions if not versions: - return [('', 'No settings stored in your Blender Cloud', '')] - return [(v, v, '') for v in versions] + return [("", "No settings stored in your Blender Cloud", "")] + return [(v, v, "") for v in versions] @compatibility.convert_properties class SyncStatusProperties(PropertyGroup): status = EnumProperty( items=[ - ('NONE', 'NONE', 'We have done nothing at all yet.'), - ('IDLE', 'IDLE', 'User requested something, which is done, and we are now idle.'), - ('SYNCING', 'SYNCING', 'Synchronising with Blender Cloud.'), + ("NONE", "NONE", "We have done nothing at all yet."), + ( + "IDLE", + "IDLE", + "User requested something, which is done, and we are now idle.", + ), + ("SYNCING", "SYNCING", "Synchronising with Blender Cloud."), ], - name='status', - description='Current status of Blender Sync', - update=redraw) + name="status", + description="Current status of Blender Sync", + update=redraw, + ) version = EnumProperty( items=blender_syncable_versions, - name='Version of Blender from which to pull', - description='Version of Blender from which to pull') + name="Version of Blender from which to pull", + description="Version of Blender from which to pull", + ) - message = StringProperty(name='message', update=redraw) + message = StringProperty(name="message", update=redraw) level = EnumProperty( items=[ - ('INFO', 'INFO', ''), - ('WARNING', 'WARNING', ''), - ('ERROR', 'ERROR', ''), - ('SUBSCRIBE', 'SUBSCRIBE', ''), + ("INFO", "INFO", ""), + ("WARNING", "WARNING", ""), + ("ERROR", "ERROR", ""), + ("SUBSCRIBE", "SUBSCRIBE", ""), ], - name='level', - update=redraw) + name="level", + update=redraw, + ) def report(self, level: set, message: str): - assert len(level) == 1, 'level should be a set of one string, not %r' % level + assert len(level) == 1, "level should be a set of one string, not %r" % level self.level = level.pop() self.message = message @@ -98,21 +111,21 @@ class SyncStatusProperties(PropertyGroup): # because I don't know how to store a variable list of strings in a proper RNA property. @property def available_blender_versions(self) -> list: - return self.get('available_blender_versions', []) + return self.get("available_blender_versions", []) @available_blender_versions.setter def available_blender_versions(self, new_versions): - self['available_blender_versions'] = new_versions + self["available_blender_versions"] = new_versions -@pyside_cache('project') +@pyside_cache("project") def bcloud_available_projects(self, context): """Returns the list of items used by BlenderCloudProjectGroup.project EnumProperty.""" projs = preferences().project.available_projects if not projs: - return [('', 'No projects available in your Blender Cloud', '')] - return [(p['_id'], p['name'], '') for p in projs] + return [("", "No projects available in your Blender Cloud", "")] + return [(p["_id"], p["name"], "") for p in projs] @functools.lru_cache(1) @@ -122,51 +135,55 @@ def project_extensions(project_id) -> set: At the moment of writing these are 'attract' and 'flamenco'. """ - log.debug('Finding extensions for project %s', project_id) + log.debug("Finding extensions for project %s", project_id) # We can't use our @property, since the preferences may be loaded from a # preferences blend file, in which case it is not constructed from Python code. - available_projects = preferences().project.get('available_projects', []) + available_projects = preferences().project.get("available_projects", []) if not available_projects: - log.debug('No projects available.') + log.debug("No projects available.") return set() - proj = next((p for p in available_projects - if p['_id'] == project_id), None) + proj = next((p for p in available_projects if p["_id"] == project_id), None) if proj is None: - log.debug('Project %s not found in available projects.', project_id) + log.debug("Project %s not found in available projects.", project_id) return set() - return set(proj.get('enabled_for', ())) + return set(proj.get("enabled_for", ())) @compatibility.convert_properties class BlenderCloudProjectGroup(PropertyGroup): status = EnumProperty( items=[ - ('NONE', 'NONE', 'We have done nothing at all yet'), - ('IDLE', 'IDLE', 'User requested something, which is done, and we are now idle'), - ('FETCHING', 'FETCHING', 'Fetching available projects from Blender Cloud'), + ("NONE", "NONE", "We have done nothing at all yet"), + ( + "IDLE", + "IDLE", + "User requested something, which is done, and we are now idle", + ), + ("FETCHING", "FETCHING", "Fetching available projects from Blender Cloud"), ], - name='status', - update=redraw) + name="status", + update=redraw, + ) project = EnumProperty( items=bcloud_available_projects, - name='Cloud project', - description='Which Blender Cloud project to work with', - update=project_specific.handle_project_update + name="Cloud project", + description="Which Blender Cloud project to work with", + update=project_specific.handle_project_update, ) # List of projects is stored in 'available_projects' ID property, # because I don't know how to store a variable list of strings in a proper RNA property. @property def available_projects(self) -> list: - return self.get('available_projects', []) + return self.get("available_projects", []) @available_projects.setter def available_projects(self, new_projects): - self['available_projects'] = new_projects + self["available_projects"] = new_projects project_specific.handle_project_update() @@ -177,21 +194,22 @@ class BlenderCloudPreferences(AddonPreferences): # The following property is read-only to limit the scope of the # addon and allow for proper testing within this scope. pillar_server = StringProperty( - name='Blender Cloud Server', - description='URL of the Blender Cloud backend server', + name="Blender Cloud Server", + description="URL of the Blender Cloud backend server", default=PILLAR_SERVER_URL, - get=lambda self: PILLAR_SERVER_URL + get=lambda self: PILLAR_SERVER_URL, ) local_texture_dir = StringProperty( - name='Default Blender Cloud Texture Storage Directory', - subtype='DIR_PATH', - default='//textures') + name="Default Blender Cloud Texture Storage Directory", + subtype="DIR_PATH", + default="//textures", + ) open_browser_after_share = BoolProperty( - name='Open Browser after Sharing File', - description='When enabled, Blender will open a webbrowser', - default=True + name="Open Browser after Sharing File", + description="When enabled, Blender will open a webbrowser", + default=True, ) # TODO: store project-dependent properties with the project, so that people @@ -199,65 +217,65 @@ class BlenderCloudPreferences(AddonPreferences): project = PointerProperty(type=BlenderCloudProjectGroup) cloud_project_local_path = StringProperty( - name='Local Project Path', - description='Local path of your Attract project, used to search for blend files; ' - 'usually best to set to an absolute path', - subtype='DIR_PATH', - default='//../', + name="Local Project Path", + description="Local path of your Attract project, used to search for blend files; " + "usually best to set to an absolute path", + subtype="DIR_PATH", + default="//../", update=project_specific.store, ) flamenco_manager = PointerProperty(type=flamenco.FlamencoManagerGroup) flamenco_exclude_filter = StringProperty( - name='File Exclude Filter', + name="File Exclude Filter", description='Space-separated list of filename filters, like "*.abc *.mkv", to prevent ' - 'matching files from being packed into the output directory', - default='', + "matching files from being packed into the output directory", + default="", update=project_specific.store, ) flamenco_job_file_path = StringProperty( - name='Job Storage Path', - description='Path where to store job files, should be accesible for Workers too', - subtype='DIR_PATH', + name="Job Storage Path", + description="Path where to store job files, should be accesible for Workers too", + subtype="DIR_PATH", default=tempfile.gettempdir(), update=project_specific.store, ) flamenco_job_output_path = StringProperty( - name='Job Output Path', - description='Path where to store output files, should be accessible for Workers', - subtype='DIR_PATH', + name="Job Output Path", + description="Path where to store output files, should be accessible for Workers", + subtype="DIR_PATH", default=tempfile.gettempdir(), update=project_specific.store, ) flamenco_job_output_strip_components = IntProperty( - name='Job Output Path Strip Components', - description='The final output path comprises of the job output path, and the blend file ' - 'path relative to the project with this many path components stripped off ' - 'the front', + name="Job Output Path Strip Components", + description="The final output path comprises of the job output path, and the blend file " + "path relative to the project with this many path components stripped off " + "the front", min=0, default=0, soft_max=4, update=project_specific.store, ) flamenco_relative_only = BoolProperty( - name='Relative Paths Only', - description='When enabled, only assets that are referred to with a relative path are ' - 'packed, and assets referred to by an absolute path are excluded from the ' - 'BAT pack. When disabled, all assets are packed', + name="Relative Paths Only", + description="When enabled, only assets that are referred to with a relative path are " + "packed, and assets referred to by an absolute path are excluded from the " + "BAT pack. When disabled, all assets are packed", default=False, update=project_specific.store, ) flamenco_open_browser_after_submit = BoolProperty( - name='Open Browser after Submitting Job', - description='When enabled, Blender will open a webbrowser', + name="Open Browser after Submitting Job", + description="When enabled, Blender will open a webbrowser", default=True, ) flamenco_show_quit_after_submit_button = BoolProperty( name='Show "Submit & Quit" button', description='When enabled, next to the "Render on Flamenco" button there will be a button ' - '"Submit & Quit" that silently quits Blender after submitting the render job ' - 'to Flamenco', + '"Submit & Quit" that silently quits Blender after submitting the render job ' + "to Flamenco", default=False, ) @@ -276,24 +294,30 @@ class BlenderCloudPreferences(AddonPreferences): blender_id_profile = blender_id.get_active_profile() if blender_id is None: - msg_icon = 'ERROR' - text = 'This add-on requires Blender ID' - help_text = 'Make sure that the Blender ID add-on is installed and activated' + msg_icon = "ERROR" + text = "This add-on requires Blender ID" + help_text = ( + "Make sure that the Blender ID add-on is installed and activated" + ) elif not blender_id_profile: - msg_icon = 'ERROR' - text = 'You are logged out.' - help_text = 'To login, go to the Blender ID add-on preferences.' + msg_icon = "ERROR" + text = "You are logged out." + help_text = "To login, go to the Blender ID add-on preferences." elif bpy.app.debug and pillar.SUBCLIENT_ID not in blender_id_profile.subclients: - msg_icon = 'QUESTION' - text = 'No Blender Cloud credentials.' - help_text = ('You are logged in on Blender ID, but your credentials have not ' - 'been synchronized with Blender Cloud yet. Press the Update ' - 'Credentials button.') + msg_icon = "QUESTION" + text = "No Blender Cloud credentials." + help_text = ( + "You are logged in on Blender ID, but your credentials have not " + "been synchronized with Blender Cloud yet. Press the Update " + "Credentials button." + ) else: - msg_icon = 'WORLD_DATA' - text = 'You are logged in as %s.' % blender_id_profile.username - help_text = ('To logout or change profile, ' - 'go to the Blender ID add-on preferences.') + msg_icon = "WORLD_DATA" + text = "You are logged in as %s." % blender_id_profile.username + help_text = ( + "To logout or change profile, " + "go to the Blender ID add-on preferences." + ) # Authentication stuff auth_box = layout.box() @@ -307,165 +331,175 @@ class BlenderCloudPreferences(AddonPreferences): # Texture browser stuff texture_box = layout.box() - texture_box.enabled = msg_icon != 'ERROR' + texture_box.enabled = msg_icon != "ERROR" sub = texture_box.column() - sub.label(text='Local directory for downloaded textures', icon_value=icon('CLOUD')) - sub.prop(self, "local_texture_dir", text='Default') - sub.prop(context.scene, "local_texture_dir", text='Current scene') + sub.label( + text="Local directory for downloaded textures", icon_value=icon("CLOUD") + ) + sub.prop(self, "local_texture_dir", text="Default") + sub.prop(context.scene, "local_texture_dir", text="Current scene") # Blender Sync stuff bss = context.window_manager.blender_sync_status bsync_box = layout.box() - bsync_box.enabled = msg_icon != 'ERROR' + bsync_box.enabled = msg_icon != "ERROR" row = bsync_box.row().split(**compatibility.factor(0.33)) - row.label(text='Blender Sync with Blender Cloud', icon_value=icon('CLOUD')) + row.label(text="Blender Sync with Blender Cloud", icon_value=icon("CLOUD")) icon_for_level = { - 'INFO': 'NONE', - 'WARNING': 'INFO', - 'ERROR': 'ERROR', - 'SUBSCRIBE': 'ERROR', + "INFO": "NONE", + "WARNING": "INFO", + "ERROR": "ERROR", + "SUBSCRIBE": "ERROR", } - msg_icon = icon_for_level[bss.level] if bss.message else 'NONE' + msg_icon = icon_for_level[bss.level] if bss.message else "NONE" message_container = row.row() message_container.label(text=bss.message, icon=msg_icon) sub = bsync_box.column() - if bss.level == 'SUBSCRIBE': + if bss.level == "SUBSCRIBE": self.draw_subscribe_button(sub) self.draw_sync_buttons(sub, bss) # Image Share stuff share_box = layout.box() - share_box.label(text='Image Sharing on Blender Cloud', icon_value=icon('CLOUD')) - share_box.prop(self, 'open_browser_after_share') + share_box.label(text="Image Sharing on Blender Cloud", icon_value=icon("CLOUD")) + share_box.prop(self, "open_browser_after_share") # Project selector project_box = layout.box() - project_box.enabled = self.project.status in {'NONE', 'IDLE'} + project_box.enabled = self.project.status in {"NONE", "IDLE"} self.draw_project_selector(project_box, self.project) extensions = project_extensions(self.project.project) # Flamenco stuff - if 'flamenco' in extensions: + if "flamenco" in extensions: flamenco_box = project_box.column() self.draw_flamenco_buttons(flamenco_box, self.flamenco_manager, context) def draw_subscribe_button(self, layout): - layout.operator('pillar.subscribe', icon='WORLD') + layout.operator("pillar.subscribe", icon="WORLD") def draw_sync_buttons(self, layout, bss): - layout.enabled = bss.status in {'NONE', 'IDLE'} + layout.enabled = bss.status in {"NONE", "IDLE"} buttons = layout.column() row_buttons = buttons.row().split(**compatibility.factor(0.5)) row_push = row_buttons.row() row_pull = row_buttons.row(align=True) - row_push.operator('pillar.sync', - text='Save %i.%i settings' % bpy.app.version[:2], - icon='TRIA_UP').action = 'PUSH' + row_push.operator( + "pillar.sync", + text="Save %i.%i settings" % bpy.app.version[:2], + icon="TRIA_UP", + ).action = "PUSH" versions = bss.available_blender_versions - if bss.status in {'NONE', 'IDLE'}: + if bss.status in {"NONE", "IDLE"}: if not versions: - row_pull.operator('pillar.sync', - text='Find version to load', - icon='TRIA_DOWN').action = 'REFRESH' + row_pull.operator( + "pillar.sync", text="Find version to load", icon="TRIA_DOWN" + ).action = "REFRESH" else: - props = row_pull.operator('pillar.sync', - text='Load %s settings' % bss.version, - icon='TRIA_DOWN') - props.action = 'PULL' + props = row_pull.operator( + "pillar.sync", + text="Load %s settings" % bss.version, + icon="TRIA_DOWN", + ) + props.action = "PULL" props.blender_version = bss.version - row_pull.operator('pillar.sync', - text='', - icon=compatibility.SYNC_SELECT_VERSION_ICON).action = 'SELECT' + row_pull.operator( + "pillar.sync", text="", icon=compatibility.SYNC_SELECT_VERSION_ICON + ).action = "SELECT" else: - row_pull.label(text='Cloud Sync is running.') + row_pull.label(text="Cloud Sync is running.") def draw_project_selector(self, project_box, bcp: BlenderCloudProjectGroup): project_row = project_box.row(align=True) - project_row.label(text='Project settings', icon_value=icon('CLOUD')) + project_row.label(text="Project settings", icon_value=icon("CLOUD")) row_buttons = project_row.row(align=True) projects = bcp.available_projects project = bcp.project - if bcp.status in {'NONE', 'IDLE'}: + if bcp.status in {"NONE", "IDLE"}: if not projects: - row_buttons.operator('pillar.projects', - text='Find project to load', - icon='FILE_REFRESH') + row_buttons.operator( + "pillar.projects", text="Find project to load", icon="FILE_REFRESH" + ) else: - row_buttons.prop(bcp, 'project') - row_buttons.operator('pillar.projects', - text='', - icon='FILE_REFRESH') - props = row_buttons.operator('pillar.project_open_in_browser', - text='', - icon='WORLD') + row_buttons.prop(bcp, "project") + row_buttons.operator("pillar.projects", text="", icon="FILE_REFRESH") + props = row_buttons.operator( + "pillar.project_open_in_browser", text="", icon="WORLD" + ) props.project_id = project else: - row_buttons.label(text='Fetching available projects.') + row_buttons.label(text="Fetching available projects.") enabled_for = project_extensions(project) if not project: return if not enabled_for: - project_box.label(text='This project is not set up for Attract or Flamenco') + project_box.label(text="This project is not set up for Attract or Flamenco") return - project_box.label(text='This project is set up for: %s' % - ', '.join(sorted(enabled_for))) + project_box.label( + text="This project is set up for: %s" % ", ".join(sorted(enabled_for)) + ) # This is only needed when the project is set up for either Attract or Flamenco. - project_box.prop(self, 'cloud_project_local_path', - text='Local Project Path') + project_box.prop(self, "cloud_project_local_path", text="Local Project Path") - def draw_flamenco_buttons(self, flamenco_box, bcp: flamenco.FlamencoManagerGroup, context): + def draw_flamenco_buttons( + self, flamenco_box, bcp: flamenco.FlamencoManagerGroup, context + ): header_row = flamenco_box.row(align=True) - header_row.label(text='Flamenco:', icon_value=icon('CLOUD')) + header_row.label(text="Flamenco:", icon_value=icon("CLOUD")) manager_split = flamenco_box.split(**compatibility.factor(0.32), align=True) - manager_split.label(text='Manager:') + manager_split.label(text="Manager:") manager_box = manager_split.row(align=True) - if bcp.status in {'NONE', 'IDLE'}: + if bcp.status in {"NONE", "IDLE"}: if not bcp.available_managers: - manager_box.operator('flamenco.managers', - text='Find Flamenco Managers', - icon='FILE_REFRESH') + manager_box.operator( + "flamenco.managers", + text="Find Flamenco Managers", + icon="FILE_REFRESH", + ) else: - manager_box.prop(bcp, 'manager', text='') - manager_box.operator('flamenco.managers', - text='', - icon='FILE_REFRESH') + manager_box.prop(bcp, "manager", text="") + manager_box.operator("flamenco.managers", text="", icon="FILE_REFRESH") else: - manager_box.label(text='Fetching available managers.') + manager_box.label(text="Fetching available managers.") path_split = flamenco_box.split(**compatibility.factor(0.32), align=True) - path_split.label(text='Job File Path:') + path_split.label(text="Job File Path:") path_box = path_split.row(align=True) - path_box.prop(self, 'flamenco_job_file_path', text='') - props = path_box.operator('flamenco.explore_file_path', text='', icon='DISK_DRIVE') + path_box.prop(self, "flamenco_job_file_path", text="") + props = path_box.operator( + "flamenco.explore_file_path", text="", icon="DISK_DRIVE" + ) props.path = self.flamenco_job_file_path job_output_box = flamenco_box.column(align=True) path_split = job_output_box.split(**compatibility.factor(0.32), align=True) - path_split.label(text='Job Output Path:') + path_split.label(text="Job Output Path:") path_box = path_split.row(align=True) - path_box.prop(self, 'flamenco_job_output_path', text='') - props = path_box.operator('flamenco.explore_file_path', text='', icon='DISK_DRIVE') + path_box.prop(self, "flamenco_job_output_path", text="") + props = path_box.operator( + "flamenco.explore_file_path", text="", icon="DISK_DRIVE" + ) props.path = self.flamenco_job_output_path - job_output_box.prop(self, 'flamenco_exclude_filter') + job_output_box.prop(self, "flamenco_exclude_filter") prop_split = job_output_box.split(**compatibility.factor(0.32), align=True) - prop_split.label(text='Strip Components:') - prop_split.prop(self, 'flamenco_job_output_strip_components', text='') + prop_split.label(text="Strip Components:") + prop_split.prop(self, "flamenco_job_output_strip_components", text="") from .flamenco import render_output_path @@ -473,25 +507,29 @@ class BlenderCloudPreferences(AddonPreferences): output_path = render_output_path(context) if output_path: path_box.label(text=str(output_path)) - props = path_box.operator('flamenco.explore_file_path', text='', icon='DISK_DRIVE') + props = path_box.operator( + "flamenco.explore_file_path", text="", icon="DISK_DRIVE" + ) props.path = str(output_path.parent) else: - path_box.label(text='Blend file is not in your project path, ' - 'unable to give output path example.') + path_box.label( + text="Blend file is not in your project path, " + "unable to give output path example." + ) - flamenco_box.prop(self, 'flamenco_relative_only') - flamenco_box.prop(self, 'flamenco_open_browser_after_submit') - flamenco_box.prop(self, 'flamenco_show_quit_after_submit_button') + flamenco_box.prop(self, "flamenco_relative_only") + flamenco_box.prop(self, "flamenco_open_browser_after_submit") + flamenco_box.prop(self, "flamenco_show_quit_after_submit_button") -class PillarCredentialsUpdate(pillar.PillarOperatorMixin, - Operator): +class PillarCredentialsUpdate(pillar.PillarOperatorMixin, Operator): """Updates the Pillar URL and tests the new URL.""" - bl_idname = 'pillar.credentials_update' - bl_label = 'Update credentials' - bl_description = 'Resynchronises your Blender ID login with Blender Cloud' - log = logging.getLogger('bpy.ops.%s' % bl_idname) + bl_idname = "pillar.credentials_update" + bl_label = "Update credentials" + bl_description = "Resynchronises your Blender ID login with Blender Cloud" + + log = logging.getLogger("bpy.ops.%s" % bl_idname) @classmethod def poll(cls, context): @@ -513,51 +551,52 @@ class PillarCredentialsUpdate(pillar.PillarOperatorMixin, # Only allow activation when the user is actually logged in. if not self.is_logged_in(context): - self.report({'ERROR'}, 'No active profile found') - return {'CANCELLED'} + self.report({"ERROR"}, "No active profile found") + return {"CANCELLED"} try: loop = asyncio.get_event_loop() loop.run_until_complete(self.check_credentials(context, set())) except blender_id.BlenderIdCommError as ex: - log.exception('Error sending subclient-specific token to Blender ID') - self.report({'ERROR'}, 'Failed to sync Blender ID to Blender Cloud') - return {'CANCELLED'} + log.exception("Error sending subclient-specific token to Blender ID") + self.report({"ERROR"}, "Failed to sync Blender ID to Blender Cloud") + return {"CANCELLED"} except Exception as ex: - log.exception('Error in test call to Pillar') - self.report({'ERROR'}, 'Failed test connection to Blender Cloud') - return {'CANCELLED'} + log.exception("Error in test call to Pillar") + self.report({"ERROR"}, "Failed test connection to Blender Cloud") + return {"CANCELLED"} - self.report({'INFO'}, 'Blender Cloud credentials & endpoint URL updated.') - return {'FINISHED'} + self.report({"INFO"}, "Blender Cloud credentials & endpoint URL updated.") + return {"FINISHED"} class PILLAR_OT_subscribe(Operator): """Opens a browser to subscribe the user to the Cloud.""" - bl_idname = 'pillar.subscribe' - bl_label = 'Subscribe to the Cloud' + + bl_idname = "pillar.subscribe" + bl_label = "Subscribe to the Cloud" bl_description = "Opens a page in a web browser to subscribe to the Blender Cloud" def execute(self, context): import webbrowser - webbrowser.open_new_tab('https://cloud.blender.org/join') - self.report({'INFO'}, 'We just started a browser for you.') + webbrowser.open_new_tab("https://cloud.blender.org/join") + self.report({"INFO"}, "We just started a browser for you.") - return {'FINISHED'} + return {"FINISHED"} @compatibility.convert_properties class PILLAR_OT_project_open_in_browser(Operator): - bl_idname = 'pillar.project_open_in_browser' - bl_label = 'Open in Browser' - bl_description = 'Opens a webbrowser to show the project' + bl_idname = "pillar.project_open_in_browser" + bl_label = "Open in Browser" + bl_description = "Opens a webbrowser to show the project" - project_id = StringProperty(name='Project ID') + project_id = StringProperty(name="Project ID") def execute(self, context): if not self.project_id: - return {'CANCELLED'} + return {"CANCELLED"} import webbrowser import urllib.parse @@ -565,28 +604,34 @@ class PILLAR_OT_project_open_in_browser(Operator): import pillarsdk from .pillar import sync_call - project = sync_call(pillarsdk.Project.find, self.project_id, {'projection': {'url': True}}) + project = sync_call( + pillarsdk.Project.find, self.project_id, {"projection": {"url": True}} + ) if log.isEnabledFor(logging.DEBUG): import pprint - log.debug('found project: %s', pprint.pformat(project.to_dict())) - url = urllib.parse.urljoin(PILLAR_WEB_SERVER_URL, 'p/' + project.url) + log.debug("found project: %s", pprint.pformat(project.to_dict())) + + url = urllib.parse.urljoin(PILLAR_WEB_SERVER_URL, "p/" + project.url) webbrowser.open_new_tab(url) - self.report({'INFO'}, 'Opened a browser at %s' % url) + self.report({"INFO"}, "Opened a browser at %s" % url) - return {'FINISHED'} + return {"FINISHED"} -class PILLAR_OT_projects(async_loop.AsyncModalOperatorMixin, - pillar.AuthenticatedPillarOperatorMixin, - Operator): +class PILLAR_OT_projects( + async_loop.AsyncModalOperatorMixin, + pillar.AuthenticatedPillarOperatorMixin, + Operator, +): """Fetches the projects available to the user""" - bl_idname = 'pillar.projects' - bl_label = 'Fetch available projects' + + bl_idname = "pillar.projects" + bl_label = "Fetch available projects" stop_upon_exception = True - _log = logging.getLogger('bpy.ops.%s' % bl_idname) + _log = logging.getLogger("bpy.ops.%s" % bl_idname) async def async_execute(self, context): if not await self.authenticate(context): @@ -595,69 +640,71 @@ class PILLAR_OT_projects(async_loop.AsyncModalOperatorMixin, import pillarsdk from .pillar import pillar_call - self.log.info('Going to fetch projects for user %s', self.user_id) + self.log.info("Going to fetch projects for user %s", self.user_id) - preferences().project.status = 'FETCHING' + preferences().project.status = "FETCHING" # Get all projects, except the home project. projects_user = await pillar_call( pillarsdk.Project.all, - {'where': {'user': self.user_id, - 'category': {'$ne': 'home'}}, - 'sort': '-name', - 'projection': {'_id': True, - 'name': True, - 'extension_props': True}, - }) + { + "where": {"user": self.user_id, "category": {"$ne": "home"}}, + "sort": "-name", + "projection": {"_id": True, "name": True, "extension_props": True}, + }, + ) projects_shared = await pillar_call( pillarsdk.Project.all, - {'where': {'user': {'$ne': self.user_id}, - 'permissions.groups.group': {'$in': self.db_user.groups}}, - 'sort': '-name', - 'projection': {'_id': True, - 'name': True, - 'extension_props': True}, - }) + { + "where": { + "user": {"$ne": self.user_id}, + "permissions.groups.group": {"$in": self.db_user.groups}, + }, + "sort": "-name", + "projection": {"_id": True, "name": True, "extension_props": True}, + }, + ) # We need to convert to regular dicts before storing in ID properties. # Also don't store more properties than we need. def reduce_properties(project_list): for p in project_list: p = p.to_dict() - extension_props = p.get('extension_props', {}) + extension_props = p.get("extension_props", {}) enabled_for = list(extension_props.keys()) - self._log.debug('Project %r is enabled for %s', p['name'], enabled_for) + self._log.debug("Project %r is enabled for %s", p["name"], enabled_for) yield { - '_id': p['_id'], - 'name': p['name'], - 'enabled_for': enabled_for, + "_id": p["_id"], + "name": p["name"], + "enabled_for": enabled_for, } - projects = list(reduce_properties(projects_user['_items'])) + \ - list(reduce_properties(projects_shared['_items'])) + projects = list(reduce_properties(projects_user["_items"])) + list( + reduce_properties(projects_shared["_items"]) + ) def proj_sort_key(project): - return project.get('name') + return project.get("name") preferences().project.available_projects = sorted(projects, key=proj_sort_key) self.quit() def quit(self): - preferences().project.status = 'IDLE' + preferences().project.status = "IDLE" super().quit() class PILLAR_PT_image_custom_properties(rna_prop_ui.PropertyPanel, bpy.types.Panel): """Shows custom properties in the image editor.""" - bl_space_type = 'IMAGE_EDITOR' - bl_region_type = 'UI' - bl_label = 'Custom Properties' + bl_space_type = "IMAGE_EDITOR" + bl_region_type = "UI" + bl_label = "Custom Properties" - _context_path = 'edit_image' + _context_path = "edit_image" _property_type = bpy.types.Image @@ -681,9 +728,10 @@ def load_custom_icons(): return import bpy.utils.previews + icons = bpy.utils.previews.new() - my_icons_dir = os.path.join(os.path.dirname(__file__), 'icons') - icons.load('CLOUD', os.path.join(my_icons_dir, 'icon-cloud.png'), 'IMAGE') + my_icons_dir = os.path.join(os.path.dirname(__file__), "icons") + icons.load("CLOUD", os.path.join(my_icons_dir, "icon-cloud.png"), "IMAGE") def unload_custom_icons(): @@ -719,8 +767,8 @@ def register(): addon_prefs = preferences() WindowManager.last_blender_cloud_location = StringProperty( - name="Last Blender Cloud browser location", - default="/") + name="Last Blender Cloud browser location", default="/" + ) def default_if_empty(scene, context): """The scene's local_texture_dir, if empty, reverts to the addon prefs.""" @@ -729,10 +777,11 @@ def register(): scene.local_texture_dir = addon_prefs.local_texture_dir Scene.local_texture_dir = StringProperty( - name='Blender Cloud texture storage directory for current scene', - subtype='DIR_PATH', + name="Blender Cloud texture storage directory for current scene", + subtype="DIR_PATH", default=addon_prefs.local_texture_dir, - update=default_if_empty) + update=default_if_empty, + ) WindowManager.blender_sync_status = PointerProperty(type=SyncStatusProperties) diff --git a/blender_cloud/blendfile.py b/blender_cloud/blendfile.py index 651f549..d5635e1 100644 --- a/blender_cloud/blendfile.py +++ b/blender_cloud/blendfile.py @@ -52,13 +52,13 @@ def open_blend(filename, access="rb"): bfile.is_compressed = False bfile.filepath_orig = filename return bfile - elif magic[:2] == b'\x1f\x8b': + elif magic[:2] == b"\x1f\x8b": log.debug("gzip blendfile detected") handle.close() log.debug("decompressing started") fs = gzip.open(filename, "rb") data = fs.read(FILE_BUFFER_SIZE) - magic = data[:len(magic_test)] + magic = data[: len(magic_test)] if magic == magic_test: handle = tempfile.TemporaryFile() while data: @@ -90,6 +90,7 @@ class BlendFile: """ Blend file. """ + __slots__ = ( # file (result of open()) "handle", @@ -114,7 +115,7 @@ class BlendFile: "is_modified", # bool (is file gzipped) "is_compressed", - ) + ) def __init__(self, handle): log.debug("initializing reading blend-file") @@ -125,11 +126,12 @@ class BlendFile: self.code_index = {} block = BlendFileBlock(handle, self) - while block.code != b'ENDB': - if block.code == b'DNA1': - (self.structs, - self.sdna_index_from_id, - ) = BlendFile.decode_structs(self.header, block, handle) + while block.code != b"ENDB": + if block.code == b"DNA1": + ( + self.structs, + self.sdna_index_from_id, + ) = BlendFile.decode_structs(self.header, block, handle) else: handle.seek(block.size, os.SEEK_CUR) @@ -141,7 +143,9 @@ class BlendFile: self.blocks.append(block) # cache (could lazy init, incase we never use?) - self.block_from_offset = {block.addr_old: block for block in self.blocks if block.code != b'ENDB'} + self.block_from_offset = { + block.addr_old: block for block in self.blocks if block.code != b"ENDB" + } def __enter__(self): return self @@ -150,7 +154,7 @@ class BlendFile: self.close() def find_blocks_from_code(self, code): - assert(type(code) == bytes) + assert type(code) == bytes if code not in self.code_index: return [] return self.code_index[code] @@ -158,7 +162,7 @@ class BlendFile: def find_block_from_offset(self, offset): # same as looking looping over all blocks, # then checking ``block.addr_old == offset`` - assert(type(offset) is int) + assert type(offset) is int return self.block_from_offset.get(offset) def close(self): @@ -185,12 +189,15 @@ class BlendFile: def ensure_subtype_smaller(self, sdna_index_curr, sdna_index_next): # never refine to a smaller type - if (self.structs[sdna_index_curr].size > - self.structs[sdna_index_next].size): + if self.structs[sdna_index_curr].size > self.structs[sdna_index_next].size: - raise RuntimeError("cant refine to smaller type (%s -> %s)" % - (self.structs[sdna_index_curr].dna_type_id.decode('ascii'), - self.structs[sdna_index_next].dna_type_id.decode('ascii'))) + raise RuntimeError( + "cant refine to smaller type (%s -> %s)" + % ( + self.structs[sdna_index_curr].dna_type_id.decode("ascii"), + self.structs[sdna_index_next].dna_type_id.decode("ascii"), + ) + ) @staticmethod def decode_structs(header, block, handle): @@ -199,7 +206,7 @@ class BlendFile: """ log.debug("building DNA catalog") shortstruct = DNA_IO.USHORT[header.endian_index] - shortstruct2 = struct.Struct(header.endian_str + b'HH') + shortstruct2 = struct.Struct(header.endian_str + b"HH") intstruct = DNA_IO.UINT[header.endian_index] data = handle.read(block.size) @@ -281,6 +288,7 @@ class BlendFileBlock: """ Instance of a struct. """ + __slots__ = ( # BlendFile "file", @@ -291,21 +299,25 @@ class BlendFileBlock: "count", "file_offset", "user_data", - ) + ) def __str__(self): - return ("<%s.%s (%s), size=%d at %s>" % - # fields=[%s] - (self.__class__.__name__, - self.dna_type.dna_type_id.decode('ascii'), - self.code.decode(), - self.size, - # b", ".join(f.dna_name.name_only for f in self.dna_type.fields).decode('ascii'), - hex(self.addr_old), - )) + return ( + "<%s.%s (%s), size=%d at %s>" + % + # fields=[%s] + ( + self.__class__.__name__, + self.dna_type.dna_type_id.decode("ascii"), + self.code.decode(), + self.size, + # b", ".join(f.dna_name.name_only for f in self.dna_type.fields).decode('ascii'), + hex(self.addr_old), + ) + ) def __init__(self, handle, bfile): - OLDBLOCK = struct.Struct(b'4sI') + OLDBLOCK = struct.Struct(b"4sI") self.file = bfile self.user_data = None @@ -318,8 +330,8 @@ class BlendFileBlock: if len(data) > 15: blockheader = bfile.block_header_struct.unpack(data) - self.code = blockheader[0].partition(b'\0')[0] - if self.code != b'ENDB': + self.code = blockheader[0].partition(b"\0")[0] + if self.code != b"ENDB": self.size = blockheader[1] self.addr_old = blockheader[2] self.sdna_index = blockheader[3] @@ -333,7 +345,7 @@ class BlendFileBlock: self.file_offset = 0 else: blockheader = OLDBLOCK.unpack(data) - self.code = blockheader[0].partition(b'\0')[0] + self.code = blockheader[0].partition(b"\0")[0] self.code = DNA_IO.read_data0(blockheader[0]) self.size = 0 self.addr_old = 0 @@ -346,28 +358,30 @@ class BlendFileBlock: return self.file.structs[self.sdna_index] def refine_type_from_index(self, sdna_index_next): - assert(type(sdna_index_next) is int) + assert type(sdna_index_next) is int sdna_index_curr = self.sdna_index self.file.ensure_subtype_smaller(sdna_index_curr, sdna_index_next) self.sdna_index = sdna_index_next def refine_type(self, dna_type_id): - assert(type(dna_type_id) is bytes) + assert type(dna_type_id) is bytes self.refine_type_from_index(self.file.sdna_index_from_id[dna_type_id]) - def get_file_offset(self, path, - default=..., - sdna_index_refine=None, - base_index=0, - ): + def get_file_offset( + self, + path, + default=..., + sdna_index_refine=None, + base_index=0, + ): """ Return (offset, length) """ - assert(type(path) is bytes) + assert type(path) is bytes ofs = self.file_offset if base_index != 0: - assert(base_index < self.count) + assert base_index < self.count ofs += (self.size // self.count) * base_index self.file.handle.seek(ofs, os.SEEK_SET) @@ -377,21 +391,23 @@ class BlendFileBlock: self.file.ensure_subtype_smaller(self.sdna_index, sdna_index_refine) dna_struct = self.file.structs[sdna_index_refine] - field = dna_struct.field_from_path( - self.file.header, self.file.handle, path) + field = dna_struct.field_from_path(self.file.header, self.file.handle, path) return (self.file.handle.tell(), field.dna_name.array_size) - def get(self, path, - default=..., - sdna_index_refine=None, - use_nil=True, use_str=True, - base_index=0, - ): + def get( + self, + path, + default=..., + sdna_index_refine=None, + use_nil=True, + use_str=True, + base_index=0, + ): ofs = self.file_offset if base_index != 0: - assert(base_index < self.count) + assert base_index < self.count ofs += (self.size // self.count) * base_index self.file.handle.seek(ofs, os.SEEK_SET) @@ -402,36 +418,55 @@ class BlendFileBlock: dna_struct = self.file.structs[sdna_index_refine] return dna_struct.field_get( - self.file.header, self.file.handle, path, - default=default, - use_nil=use_nil, use_str=use_str, - ) + self.file.header, + self.file.handle, + path, + default=default, + use_nil=use_nil, + use_str=use_str, + ) - def get_recursive_iter(self, path, path_root=b"", - default=..., - sdna_index_refine=None, - use_nil=True, use_str=True, - base_index=0, - ): + def get_recursive_iter( + self, + path, + path_root=b"", + default=..., + sdna_index_refine=None, + use_nil=True, + use_str=True, + base_index=0, + ): if path_root: - path_full = ( - (path_root if type(path_root) is tuple else (path_root, )) + - (path if type(path) is tuple else (path, ))) + path_full = (path_root if type(path_root) is tuple else (path_root,)) + ( + path if type(path) is tuple else (path,) + ) else: path_full = path try: - yield (path_full, self.get(path_full, default, sdna_index_refine, use_nil, use_str, base_index)) + yield ( + path_full, + self.get( + path_full, default, sdna_index_refine, use_nil, use_str, base_index + ), + ) except NotImplementedError as ex: msg, dna_name, dna_type = ex.args struct_index = self.file.sdna_index_from_id.get(dna_type.dna_type_id, None) if struct_index is None: - yield (path_full, "<%s>" % dna_type.dna_type_id.decode('ascii')) + yield (path_full, "<%s>" % dna_type.dna_type_id.decode("ascii")) else: struct = self.file.structs[struct_index] for f in struct.fields: yield from self.get_recursive_iter( - f.dna_name.name_only, path_full, default, None, use_nil, use_str, 0) + f.dna_name.name_only, + path_full, + default, + None, + use_nil, + use_str, + 0, + ) def items_recursive_iter(self): for k in self.keys(): @@ -445,9 +480,13 @@ class BlendFileBlock: # TODO This implementation is most likely far from optimal... and CRC32 is not renown as the best hashing # algo either. But for now does the job! import zlib + def _is_pointer(self, k): - return self.file.structs[self.sdna_index].field_from_path( - self.file.header, self.file.handle, k).dna_name.is_pointer + return ( + self.file.structs[self.sdna_index] + .field_from_path(self.file.header, self.file.handle, k) + .dna_name.is_pointer + ) hsh = 1 for k, v in self.items_recursive_iter(): @@ -455,9 +494,12 @@ class BlendFileBlock: hsh = zlib.adler32(str(v).encode(), hsh) return hsh - def set(self, path, value, - sdna_index_refine=None, - ): + def set( + self, + path, + value, + sdna_index_refine=None, + ): if sdna_index_refine is None: sdna_index_refine = self.sdna_index @@ -467,29 +509,34 @@ class BlendFileBlock: dna_struct = self.file.structs[sdna_index_refine] self.file.handle.seek(self.file_offset, os.SEEK_SET) self.file.is_modified = True - return dna_struct.field_set( - self.file.header, self.file.handle, path, value) + return dna_struct.field_set(self.file.header, self.file.handle, path, value) # --------------- # Utility get/set # # avoid inline pointer casting def get_pointer( - self, path, - default=..., - sdna_index_refine=None, - base_index=0, - ): + self, + path, + default=..., + sdna_index_refine=None, + base_index=0, + ): if sdna_index_refine is None: sdna_index_refine = self.sdna_index - result = self.get(path, default, sdna_index_refine=sdna_index_refine, base_index=base_index) + result = self.get( + path, default, sdna_index_refine=sdna_index_refine, base_index=base_index + ) # default if type(result) is not int: return result - assert(self.file.structs[sdna_index_refine].field_from_path( - self.file.header, self.file.handle, path).dna_name.is_pointer) + assert ( + self.file.structs[sdna_index_refine] + .field_from_path(self.file.header, self.file.handle, path) + .dna_name.is_pointer + ) if result != 0: # possible (but unlikely) # that this fails and returns None @@ -517,7 +564,7 @@ class BlendFileBlock: yield self[k] except NotImplementedError as ex: msg, dna_name, dna_type = ex.args - yield "<%s>" % dna_type.dna_type_id.decode('ascii') + yield "<%s>" % dna_type.dna_type_id.decode("ascii") def items(self): for k in self.keys(): @@ -525,7 +572,7 @@ class BlendFileBlock: yield (k, self[k]) except NotImplementedError as ex: msg, dna_name, dna_type = ex.args - yield (k, "<%s>" % dna_type.dna_type_id.decode('ascii')) + yield (k, "<%s>" % dna_type.dna_type_id.decode("ascii")) # ----------------------------------------------------------------------------- @@ -542,6 +589,7 @@ class BlendFileHeader: BlendFileHeader allocates the first 12 bytes of a blend file it contains information about the hardware architecture """ + __slots__ = ( # str "magic", @@ -555,56 +603,61 @@ class BlendFileHeader: "endian_str", # int, used to index common types "endian_index", - ) + ) def __init__(self, handle): - FILEHEADER = struct.Struct(b'7s1s1s3s') + FILEHEADER = struct.Struct(b"7s1s1s3s") log.debug("reading blend-file-header") values = FILEHEADER.unpack(handle.read(FILEHEADER.size)) self.magic = values[0] pointer_size_id = values[1] - if pointer_size_id == b'-': + if pointer_size_id == b"-": self.pointer_size = 8 - elif pointer_size_id == b'_': + elif pointer_size_id == b"_": self.pointer_size = 4 else: - assert(0) + assert 0 endian_id = values[2] - if endian_id == b'v': + if endian_id == b"v": self.is_little_endian = True - self.endian_str = b'<' + self.endian_str = b"<" self.endian_index = 0 - elif endian_id == b'V': + elif endian_id == b"V": self.is_little_endian = False self.endian_index = 1 - self.endian_str = b'>' + self.endian_str = b">" else: - assert(0) + assert 0 version_id = values[3] self.version = int(version_id) def create_block_header_struct(self): - return struct.Struct(b''.join(( - self.endian_str, - b'4sI', - b'I' if self.pointer_size == 4 else b'Q', - b'II', - ))) + return struct.Struct( + b"".join( + ( + self.endian_str, + b"4sI", + b"I" if self.pointer_size == 4 else b"Q", + b"II", + ) + ) + ) class DNAName: """ DNAName is a C-type name stored in the DNA """ + __slots__ = ( "name_full", "name_only", "is_pointer", "is_method_pointer", "array_size", - ) + ) def __init__(self, name_full): self.name_full = name_full @@ -614,40 +667,40 @@ class DNAName: self.array_size = self.calc_array_size() def __repr__(self): - return '%s(%r)' % (type(self).__qualname__, self.name_full) + return "%s(%r)" % (type(self).__qualname__, self.name_full) def as_reference(self, parent): if parent is None: - result = b'' + result = b"" else: - result = parent + b'.' + result = parent + b"." result = result + self.name_only return result def calc_name_only(self): - result = self.name_full.strip(b'*()') - index = result.find(b'[') + result = self.name_full.strip(b"*()") + index = result.find(b"[") if index != -1: result = result[:index] return result def calc_is_pointer(self): - return (b'*' in self.name_full) + return b"*" in self.name_full def calc_is_method_pointer(self): - return (b'(*' in self.name_full) + return b"(*" in self.name_full def calc_array_size(self): result = 1 temp = self.name_full - index = temp.find(b'[') + index = temp.find(b"[") while index != -1: - index_2 = temp.find(b']') - result *= int(temp[index + 1:index_2]) - temp = temp[index_2 + 1:] - index = temp.find(b'[') + index_2 = temp.find(b"]") + result *= int(temp[index + 1 : index_2]) + temp = temp[index_2 + 1 :] + index = temp.find(b"[") return result @@ -657,6 +710,7 @@ class DNAField: DNAField is a coupled DNAStruct and DNAName and cache offset for reuse """ + __slots__ = ( # DNAName "dna_name", @@ -667,7 +721,7 @@ class DNAField: "dna_size", # cached info (avoid looping over fields each time) "dna_offset", - ) + ) def __init__(self, dna_type, dna_name, dna_size, dna_offset): self.dna_type = dna_type @@ -680,13 +734,14 @@ class DNAStruct: """ DNAStruct is a C-type structure stored in the DNA """ + __slots__ = ( "dna_type_id", "size", "fields", "field_from_name", "user_data", - ) + ) def __init__(self, dna_type_id): self.dna_type_id = dna_type_id @@ -695,7 +750,7 @@ class DNAStruct: self.user_data = None def __repr__(self): - return '%s(%r)' % (type(self).__qualname__, self.dna_type_id) + return "%s(%r)" % (type(self).__qualname__, self.dna_type_id) def field_from_path(self, header, handle, path): """ @@ -709,7 +764,7 @@ class DNAStruct: if len(path) >= 2 and type(path[1]) is not bytes: name_tail = path[2:] index = path[1] - assert(type(index) is int) + assert type(index) is int else: name_tail = path[1:] index = 0 @@ -718,7 +773,7 @@ class DNAStruct: name_tail = None index = 0 - assert(type(name) is bytes) + assert type(name) is bytes field = self.field_from_name.get(name) @@ -729,47 +784,69 @@ class DNAStruct: index_offset = header.pointer_size * index else: index_offset = field.dna_type.size * index - assert(index_offset < field.dna_size) + assert index_offset < field.dna_size handle.seek(index_offset, os.SEEK_CUR) if not name_tail: # None or () return field else: return field.dna_type.field_from_path(header, handle, name_tail) - def field_get(self, header, handle, path, - default=..., - use_nil=True, use_str=True, - ): + def field_get( + self, + header, + handle, + path, + default=..., + use_nil=True, + use_str=True, + ): field = self.field_from_path(header, handle, path) if field is None: if default is not ...: return default else: - raise KeyError("%r not found in %r (%r)" % - (path, [f.dna_name.name_only for f in self.fields], self.dna_type_id)) + raise KeyError( + "%r not found in %r (%r)" + % ( + path, + [f.dna_name.name_only for f in self.fields], + self.dna_type_id, + ) + ) dna_type = field.dna_type dna_name = field.dna_name if dna_name.is_pointer: return DNA_IO.read_pointer(handle, header) - elif dna_type.dna_type_id == b'int': + elif dna_type.dna_type_id == b"int": if dna_name.array_size > 1: - return [DNA_IO.read_int(handle, header) for i in range(dna_name.array_size)] + return [ + DNA_IO.read_int(handle, header) for i in range(dna_name.array_size) + ] return DNA_IO.read_int(handle, header) - elif dna_type.dna_type_id == b'short': + elif dna_type.dna_type_id == b"short": if dna_name.array_size > 1: - return [DNA_IO.read_short(handle, header) for i in range(dna_name.array_size)] + return [ + DNA_IO.read_short(handle, header) + for i in range(dna_name.array_size) + ] return DNA_IO.read_short(handle, header) - elif dna_type.dna_type_id == b'uint64_t': + elif dna_type.dna_type_id == b"uint64_t": if dna_name.array_size > 1: - return [DNA_IO.read_ulong(handle, header) for i in range(dna_name.array_size)] + return [ + DNA_IO.read_ulong(handle, header) + for i in range(dna_name.array_size) + ] return DNA_IO.read_ulong(handle, header) - elif dna_type.dna_type_id == b'float': + elif dna_type.dna_type_id == b"float": if dna_name.array_size > 1: - return [DNA_IO.read_float(handle, header) for i in range(dna_name.array_size)] + return [ + DNA_IO.read_float(handle, header) + for i in range(dna_name.array_size) + ] return DNA_IO.read_float(handle, header) - elif dna_type.dna_type_id == b'char': + elif dna_type.dna_type_id == b"char": if use_str: if use_nil: return DNA_IO.read_string0(handle, dna_name.array_size) @@ -781,30 +858,39 @@ class DNAStruct: else: return DNA_IO.read_bytes(handle, dna_name.array_size) else: - raise NotImplementedError("%r exists but isn't pointer, can't resolve field %r" % - (path, dna_name.name_only), dna_name, dna_type) + raise NotImplementedError( + "%r exists but isn't pointer, can't resolve field %r" + % (path, dna_name.name_only), + dna_name, + dna_type, + ) def field_set(self, header, handle, path, value): - assert(type(path) == bytes) + assert type(path) == bytes field = self.field_from_path(header, handle, path) if field is None: - raise KeyError("%r not found in %r" % - (path, [f.dna_name.name_only for f in self.fields])) + raise KeyError( + "%r not found in %r" + % (path, [f.dna_name.name_only for f in self.fields]) + ) dna_type = field.dna_type dna_name = field.dna_name - if dna_type.dna_type_id == b'char': + if dna_type.dna_type_id == b"char": if type(value) is str: return DNA_IO.write_string(handle, value, dna_name.array_size) else: return DNA_IO.write_bytes(handle, value, dna_name.array_size) - elif dna_type.dna_type_id == b'int': + elif dna_type.dna_type_id == b"int": DNA_IO.write_int(handle, header, value) else: - raise NotImplementedError("Setting %r is not yet supported for %r" % - (dna_type, dna_name), dna_name, dna_type) + raise NotImplementedError( + "Setting %r is not yet supported for %r" % (dna_type, dna_name), + dna_name, + dna_type, + ) class DNA_IO: @@ -821,20 +907,20 @@ class DNA_IO: @staticmethod def write_string(handle, astring, fieldlen): - assert(isinstance(astring, str)) + assert isinstance(astring, str) if len(astring) >= fieldlen: stringw = astring[0:fieldlen] else: - stringw = astring + '\0' - handle.write(stringw.encode('utf-8')) + stringw = astring + "\0" + handle.write(stringw.encode("utf-8")) @staticmethod def write_bytes(handle, astring, fieldlen): - assert(isinstance(astring, (bytes, bytearray))) + assert isinstance(astring, (bytes, bytearray)) if len(astring) >= fieldlen: stringw = astring[0:fieldlen] else: - stringw = astring + b'\0' + stringw = astring + b"\0" handle.write(stringw) @@ -850,44 +936,44 @@ class DNA_IO: @staticmethod def read_string(handle, length): - return DNA_IO.read_bytes(handle, length).decode('utf-8') + return DNA_IO.read_bytes(handle, length).decode("utf-8") @staticmethod def read_string0(handle, length): - return DNA_IO.read_bytes0(handle, length).decode('utf-8') + return DNA_IO.read_bytes0(handle, length).decode("utf-8") @staticmethod def read_data0_offset(data, offset): - add = data.find(b'\0', offset) - offset - return data[offset:offset + add] + add = data.find(b"\0", offset) - offset + return data[offset : offset + add] @staticmethod def read_data0(data): - add = data.find(b'\0') + add = data.find(b"\0") return data[:add] - USHORT = struct.Struct(b'H') + USHORT = struct.Struct(b"H") @staticmethod def read_ushort(handle, fileheader): st = DNA_IO.USHORT[fileheader.endian_index] return st.unpack(handle.read(st.size))[0] - SSHORT = struct.Struct(b'h') + SSHORT = struct.Struct(b"h") @staticmethod def read_short(handle, fileheader): st = DNA_IO.SSHORT[fileheader.endian_index] return st.unpack(handle.read(st.size))[0] - UINT = struct.Struct(b'I') + UINT = struct.Struct(b"I") @staticmethod def read_uint(handle, fileheader): st = DNA_IO.UINT[fileheader.endian_index] return st.unpack(handle.read(st.size))[0] - SINT = struct.Struct(b'i') + SINT = struct.Struct(b"i") @staticmethod def read_int(handle, fileheader): @@ -896,19 +982,22 @@ class DNA_IO: @staticmethod def write_int(handle, fileheader, value): - assert isinstance(value, int), 'value must be int, but is %r: %r' % (type(value), value) + assert isinstance(value, int), "value must be int, but is %r: %r" % ( + type(value), + value, + ) st = DNA_IO.SINT[fileheader.endian_index] to_write = st.pack(value) handle.write(to_write) - FLOAT = struct.Struct(b'f') + FLOAT = struct.Struct(b"f") @staticmethod def read_float(handle, fileheader): st = DNA_IO.FLOAT[fileheader.endian_index] return st.unpack(handle.read(st.size))[0] - ULONG = struct.Struct(b'Q') + ULONG = struct.Struct(b"Q") @staticmethod def read_ulong(handle, fileheader): diff --git a/blender_cloud/cache.py b/blender_cloud/cache.py index c6fced9..8781ed8 100644 --- a/blender_cloud/cache.py +++ b/blender_cloud/cache.py @@ -33,7 +33,9 @@ from cachecontrol.caches import FileCache from . import appdirs log = logging.getLogger(__name__) -_session = None # requests.Session object that's set up for caching by requests_session(). +_session = ( + None # requests.Session object that's set up for caching by requests_session(). +) def cache_directory(*subdirs) -> str: @@ -56,12 +58,12 @@ def cache_directory(*subdirs) -> str: if profile: username = profile.username else: - username = 'anonymous' + username = "anonymous" # TODO: use bpy.utils.user_resource('CACHE', ...) # once https://developer.blender.org/T47684 is finished. - user_cache_dir = appdirs.user_cache_dir(appname='Blender', appauthor=False) - cache_dir = os.path.join(user_cache_dir, 'blender_cloud', username, *subdirs) + user_cache_dir = appdirs.user_cache_dir(appname="Blender", appauthor=False) + cache_dir = os.path.join(user_cache_dir, "blender_cloud", username, *subdirs) os.makedirs(cache_dir, mode=0o700, exist_ok=True) @@ -76,10 +78,11 @@ def requests_session() -> requests.Session: if _session is not None: return _session - cache_name = cache_directory('blender_cloud_http') - log.info('Storing cache in %s' % cache_name) + cache_name = cache_directory("blender_cloud_http") + log.info("Storing cache in %s" % cache_name) - _session = cachecontrol.CacheControl(sess=requests.session(), - cache=FileCache(cache_name)) + _session = cachecontrol.CacheControl( + sess=requests.session(), cache=FileCache(cache_name) + ) return _session diff --git a/blender_cloud/compatibility.py b/blender_cloud/compatibility.py index c89cd03..7ec38ec 100644 --- a/blender_cloud/compatibility.py +++ b/blender_cloud/compatibility.py @@ -5,9 +5,9 @@ import bpy if bpy.app.version < (2, 80): - SYNC_SELECT_VERSION_ICON = 'DOTSDOWN' + SYNC_SELECT_VERSION_ICON = "DOTSDOWN" else: - SYNC_SELECT_VERSION_ICON = 'DOWNARROW_HLT' + SYNC_SELECT_VERSION_ICON = "DOWNARROW_HLT" # Get references to all property definition functions in bpy.props, @@ -16,9 +16,10 @@ else: __all_prop_funcs = { getattr(bpy.props, propname) for propname in dir(bpy.props) - if propname.endswith('Property') + if propname.endswith("Property") } + def convert_properties(class_): """Class decorator to avoid warnings in Blender 2.80+ @@ -36,7 +37,7 @@ def convert_properties(class_): if bpy.app.version < (2, 80): return class_ - if not hasattr(class_, '__annotations__'): + if not hasattr(class_, "__annotations__"): class_.__annotations__ = {} attrs_to_delete = [] @@ -66,5 +67,5 @@ def factor(factor: float) -> dict: {'percentage': factor}. """ if bpy.app.version < (2, 80, 0): - return {'percentage': factor} - return {'factor': factor} + return {"percentage": factor} + return {"factor": factor} diff --git a/blender_cloud/flamenco/__init__.py b/blender_cloud/flamenco/__init__.py index 03be635..a07af83 100644 --- a/blender_cloud/flamenco/__init__.py +++ b/blender_cloud/flamenco/__init__.py @@ -44,7 +44,13 @@ else: import bpy from bpy.types import AddonPreferences, Operator, WindowManager, Scene, PropertyGroup -from bpy.props import StringProperty, EnumProperty, PointerProperty, BoolProperty, IntProperty +from bpy.props import ( + StringProperty, + EnumProperty, + PointerProperty, + BoolProperty, + IntProperty, +) from .. import async_loop, pillar, project_specific, utils from ..utils import pyside_cache, redraw @@ -55,27 +61,27 @@ log = logging.getLogger(__name__) flamenco_is_active = False # 'image' file formats that actually produce a video. -VIDEO_FILE_FORMATS = {'FFMPEG', 'AVI_RAW', 'AVI_JPEG'} +VIDEO_FILE_FORMATS = {"FFMPEG", "AVI_RAW", "AVI_JPEG"} # Video container name (from bpy.context.scene.render.ffmpeg.format) to file # extension mapping. Any container name not listed here will be converted to # lower case and prepended with a period. This is basically copied from # Blender's source, get_file_extensions() in writeffmpeg.c. VIDEO_CONTAINER_TO_EXTENSION = { - 'QUICKTIME': '.mov', - 'MPEG1': '.mpg', - 'MPEG2': '.dvd', - 'MPEG4': '.mp4', - 'OGG': '.ogv', - 'FLASH': '.flv', + "QUICKTIME": ".mov", + "MPEG1": ".mpg", + "MPEG2": ".dvd", + "MPEG4": ".mp4", + "OGG": ".ogv", + "FLASH": ".flv", } -SHAMAN_URL_SCHEMES = {'shaman://', 'shaman+http://', 'shaman+https://'} +SHAMAN_URL_SCHEMES = {"shaman://", "shaman+http://", "shaman+https://"} def scene_sample_count(scene) -> int: """Determine nr of render samples for this scene.""" - if scene.cycles.progressive == 'BRANCHED_PATH': + if scene.cycles.progressive == "BRANCHED_PATH": samples = scene.cycles.aa_samples else: samples = scene.cycles.samples @@ -86,7 +92,7 @@ def scene_sample_count(scene) -> int: return samples -@pyside_cache('manager') +@pyside_cache("manager") def available_managers(self, context): """Returns the list of items used by a manager-selector EnumProperty.""" @@ -94,31 +100,31 @@ def available_managers(self, context): mngrs = preferences().flamenco_manager.available_managers if not mngrs: - return [('', 'No managers available in your Blender Cloud', '')] - return [(p['_id'], p['name'], '') for p in mngrs] + return [("", "No managers available in your Blender Cloud", "")] + return [(p["_id"], p["name"], "") for p in mngrs] -def manager_updated(self: 'FlamencoManagerGroup', context): +def manager_updated(self: "FlamencoManagerGroup", context): from ..blender import preferences flamenco_manager_id = self.manager - log.debug('manager updated to %r', flamenco_manager_id) + log.debug("manager updated to %r", flamenco_manager_id) prefs = preferences() project_id = prefs.project.project - ps = prefs.get('project_settings', {}).get(project_id, {}) + ps = prefs.get("project_settings", {}).get(project_id, {}) # Load per-project, per-manager settings for the current Manager. try: - pppm = ps['flamenco_managers_settings'][flamenco_manager_id] + pppm = ps["flamenco_managers_settings"][flamenco_manager_id] except KeyError: # No settings for this manager, so nothing to do. return with project_specific.mark_as_loading(): - project_specific.update_preferences(prefs, - project_specific.FLAMENCO_PER_PROJECT_PER_MANAGER, - pppm) + project_specific.update_preferences( + prefs, project_specific.FLAMENCO_PER_PROJECT_PER_MANAGER, pppm + ) def silently_quit_blender(): @@ -143,29 +149,38 @@ def silently_quit_blender(): class FlamencoManagerGroup(PropertyGroup): manager = EnumProperty( items=available_managers, - name='Flamenco Manager', - description='Which Flamenco Manager to use for jobs', + name="Flamenco Manager", + description="Which Flamenco Manager to use for jobs", update=manager_updated, ) status = EnumProperty( items=[ - ('NONE', 'NONE', 'We have done nothing at all yet'), - ('IDLE', 'IDLE', 'User requested something, which is done, and we are now idle'), - ('FETCHING', 'FETCHING', 'Fetching available Flamenco managers from Blender Cloud'), + ("NONE", "NONE", "We have done nothing at all yet"), + ( + "IDLE", + "IDLE", + "User requested something, which is done, and we are now idle", + ), + ( + "FETCHING", + "FETCHING", + "Fetching available Flamenco managers from Blender Cloud", + ), ], - name='status', - update=redraw) + name="status", + update=redraw, + ) # List of managers is stored in 'available_managers' ID property, # because I don't know how to store a variable list of strings in a proper RNA property. @property def available_managers(self) -> list: - return self.get('available_managers', []) + return self.get("available_managers", []) @available_managers.setter def available_managers(self, new_managers): - self['available_managers'] = new_managers + self["available_managers"] = new_managers project_specific.store() @@ -175,16 +190,19 @@ class FlamencoPollMixin: return flamenco_is_active -class FLAMENCO_OT_fmanagers(async_loop.AsyncModalOperatorMixin, - pillar.AuthenticatedPillarOperatorMixin, - FlamencoPollMixin, - Operator): +class FLAMENCO_OT_fmanagers( + async_loop.AsyncModalOperatorMixin, + pillar.AuthenticatedPillarOperatorMixin, + FlamencoPollMixin, + Operator, +): """Fetches the Flamenco Managers available to the user""" - bl_idname = 'flamenco.managers' - bl_label = 'Fetch available Flamenco Managers' + + bl_idname = "flamenco.managers" + bl_label = "Fetch available Flamenco Managers" stop_upon_exception = True - log = logging.getLogger('%s.FLAMENCO_OT_fmanagers' % __name__) + log = logging.getLogger("%s.FLAMENCO_OT_fmanagers" % __name__) @property def mypref(self) -> FlamencoManagerGroup: @@ -203,28 +221,29 @@ class FLAMENCO_OT_fmanagers(async_loop.AsyncModalOperatorMixin, prefs = preferences() mypref = self.mypref - self.log.info('Going to fetch managers for user %s', self.user_id) + self.log.info("Going to fetch managers for user %s", self.user_id) - mypref.status = 'FETCHING' - params = {'where': '{"projects" : "%s"}' % prefs.project.project} + mypref.status = "FETCHING" + params = {"where": '{"projects" : "%s"}' % prefs.project.project} managers = await pillar_call(Manager.all, params) # We need to convert to regular dicts before storing in ID properties. # Also don't store more properties than we need. - as_list = [{'_id': man['_id'], 'name': man['name']} - for man in managers['_items']] + as_list = [ + {"_id": man["_id"], "name": man["name"]} for man in managers["_items"] + ] current_manager = mypref.manager mypref.available_managers = as_list # Prevent warnings about the current manager not being in the EnumProperty items. - if as_list and not any(man['_id'] == current_manager for man in as_list): - mypref.manager = as_list[0]['_id'] + if as_list and not any(man["_id"] == current_manager for man in as_list): + mypref.manager = as_list[0]["_id"] self.quit() def quit(self): - self.mypref.status = 'IDLE' + self.mypref.status = "IDLE" super().quit() @@ -237,7 +256,7 @@ def guess_output_file_extension(output_format: str, scene) -> str: try: return VIDEO_CONTAINER_TO_EXTENSION[container] except KeyError: - return '.' + container.lower() + return "." + container.lower() def is_shaman_url(path_or_url: str) -> bool: @@ -275,17 +294,20 @@ def is_file_inside_job_storage(prefs, current_file: typing.Union[str, Path]) -> @compatibility.convert_properties -class FLAMENCO_OT_render(async_loop.AsyncModalOperatorMixin, - pillar.AuthenticatedPillarOperatorMixin, - FlamencoPollMixin, - Operator): +class FLAMENCO_OT_render( + async_loop.AsyncModalOperatorMixin, + pillar.AuthenticatedPillarOperatorMixin, + FlamencoPollMixin, + Operator, +): """Performs a Blender render on Flamenco.""" - bl_idname = 'flamenco.render' - bl_label = 'Render on Flamenco' - bl_description = __doc__.rstrip('.') + + bl_idname = "flamenco.render" + bl_label = "Render on Flamenco" + bl_description = __doc__.rstrip(".") stop_upon_exception = True - log = logging.getLogger('%s.FLAMENCO_OT_render' % __name__) + log = logging.getLogger("%s.FLAMENCO_OT_render" % __name__) quit_after_submit = BoolProperty() @@ -293,8 +315,10 @@ class FLAMENCO_OT_render(async_loop.AsyncModalOperatorMixin, # Refuse to start if the file hasn't been saved. It's okay if # it's dirty, but we do need a filename and a location. if not os.path.exists(context.blend_data.filepath): - self.report({'ERROR'}, 'Please save your Blend file before using ' - 'the Blender Cloud addon.') + self.report( + {"ERROR"}, + "Please save your Blend file before using " "the Blender Cloud addon.", + ) self.quit() return @@ -309,56 +333,63 @@ class FLAMENCO_OT_render(async_loop.AsyncModalOperatorMixin, scene = context.scene # Save to a different file, specifically for Flamenco. - context.window_manager.flamenco_status = 'SAVING' + context.window_manager.flamenco_status = "SAVING" filepath = await self._save_blendfile(context) # Determine where the render output will be stored. render_output = render_output_path(context, filepath) if render_output is None: - self.report({'ERROR'}, 'Current file is outside of project path.') + self.report({"ERROR"}, "Current file is outside of project path.") self.quit() return - self.log.info('Will output render files to %s', render_output) + self.log.info("Will output render files to %s", render_output) # Fetch Manager for doing path replacement. - self.log.info('Going to fetch manager %s', self.user_id) + self.log.info("Going to fetch manager %s", self.user_id) prefs = preferences() manager_id = prefs.flamenco_manager.manager try: manager = await pillar_call(Manager.find, manager_id) except pillarsdk.exceptions.ResourceNotFound: - self.report({'ERROR'}, 'Manager %s not found, refresh your managers in ' - 'the Blender Cloud add-on settings.' % manager_id) + self.report( + {"ERROR"}, + "Manager %s not found, refresh your managers in " + "the Blender Cloud add-on settings." % manager_id, + ) self.quit() return # Construct as much of the job settings as we can before BAT-packing. # Validation should happen as soon as possible (BAT-packing can take minutes). - frame_range = scene.flamenco_render_frame_range.strip() or scene_frame_range(context) - settings = {'blender_cmd': '{blender}', - 'chunk_size': scene.flamenco_render_fchunk_size, - 'frames': frame_range, - 'render_output': manager.replace_path(render_output), - - # Used for FFmpeg combining output frames into a video. - 'fps': scene.render.fps / scene.render.fps_base, - 'extract_audio': scene.render.ffmpeg.audio_codec != 'NONE', - } + frame_range = scene.flamenco_render_frame_range.strip() or scene_frame_range( + context + ) + settings = { + "blender_cmd": "{blender}", + "chunk_size": scene.flamenco_render_fchunk_size, + "frames": frame_range, + "render_output": manager.replace_path(render_output), + # Used for FFmpeg combining output frames into a video. + "fps": scene.render.fps / scene.render.fps_base, + "extract_audio": scene.render.ffmpeg.audio_codec != "NONE", + } # Add extra settings specific to the job type - if scene.flamenco_render_job_type == 'blender-render-progressive': + if scene.flamenco_render_job_type == "blender-render-progressive": samples = scene_sample_count(scene) - settings['cycles_sample_cap'] = scene.flamenco_render_chunk_sample_cap - settings['cycles_sample_count'] = samples - settings['format'] = 'OPEN_EXR' + settings["cycles_sample_cap"] = scene.flamenco_render_chunk_sample_cap + settings["cycles_sample_count"] = samples + settings["format"] = "OPEN_EXR" # Let Flamenco Server know whether we'll output images or video. - output_format = settings.get('format') or scene.render.image_settings.file_format + output_format = ( + settings.get("format") or scene.render.image_settings.file_format + ) if output_format in VIDEO_FILE_FORMATS: - settings['images_or_video'] = 'video' + settings["images_or_video"] = "video" else: - settings['images_or_video'] = 'images' + settings["images_or_video"] = "images" # Always pass the file format, even though it won't be # necessary for the actual render command (the blend file @@ -368,41 +399,45 @@ class FLAMENCO_OT_render(async_loop.AsyncModalOperatorMixin, # # Note that this might be overridden above when the job type # requires a specific file format. - settings.setdefault('format', scene.render.image_settings.file_format) - settings['output_file_extension'] = guess_output_file_extension(output_format, scene) + settings.setdefault("format", scene.render.image_settings.file_format) + settings["output_file_extension"] = guess_output_file_extension( + output_format, scene + ) if not self.validate_job_settings(context, settings): self.quit() return # Create the job at Flamenco Server. - context.window_manager.flamenco_status = 'COMMUNICATING' + context.window_manager.flamenco_status = "COMMUNICATING" project_id = prefs.project.project job_name = self._make_job_name(filepath) try: - job_info = await create_job(self.user_id, - project_id, - manager_id, - scene.flamenco_render_job_type, - settings, - job_name, - priority=scene.flamenco_render_job_priority, - start_paused=scene.flamenco_start_paused) + job_info = await create_job( + self.user_id, + project_id, + manager_id, + scene.flamenco_render_job_type, + settings, + job_name, + priority=scene.flamenco_render_job_priority, + start_paused=scene.flamenco_start_paused, + ) except Exception as ex: message = str(ex) if isinstance(ex, pillarsdk.exceptions.BadRequest): payload = ex.response.json() try: - message = payload['_error']['message'] + message = payload["_error"]["message"] except KeyError: pass - self.log.exception('Error creating Flamenco job') - self.report({'ERROR'}, 'Error creating Flamenco job: %s' % message) + self.log.exception("Error creating Flamenco job") + self.report({"ERROR"}, "Error creating Flamenco job: %s" % message) self.quit() return # BAT-pack the files to the destination directory. - job_id = job_info['_id'] + job_id = job_info["_id"] outdir, outfile, missing_sources = await self.bat_pack(job_id, filepath) if not outfile: return @@ -411,23 +446,24 @@ class FLAMENCO_OT_render(async_loop.AsyncModalOperatorMixin, # TODO: Make it possible to create this file first and then send it to BAT for packing. if outdir is not None: await self._create_jobinfo_json( - outdir, job_info, manager_id, project_id, missing_sources) + outdir, job_info, manager_id, project_id, missing_sources + ) # Now that the files have been transfered, PATCH the job at the Manager # to kick off the job compilation. job_filepath = manager.replace_path(outfile) - self.log.info('Final file path: %s', job_filepath) - new_settings = {'filepath': job_filepath} + self.log.info("Final file path: %s", job_filepath) + new_settings = {"filepath": job_filepath} await self.compile_job(job_id, new_settings) # We can now remove the local copy we made with bpy.ops.wm.save_as_mainfile(). # Strictly speaking we can already remove it after the BAT-pack, but it may come in # handy in case of failures. try: - self.log.info('Removing temporary file %s', filepath) + self.log.info("Removing temporary file %s", filepath) filepath.unlink() except Exception as ex: - self.report({'ERROR'}, 'Unable to remove file: %s' % ex) + self.report({"ERROR"}, "Unable to remove file: %s" % ex) self.quit() return @@ -436,28 +472,36 @@ class FLAMENCO_OT_render(async_loop.AsyncModalOperatorMixin, from urllib.parse import urljoin from ..blender import PILLAR_WEB_SERVER_URL - url = urljoin(PILLAR_WEB_SERVER_URL, '/flamenco/jobs/%s/redir' % job_id) + url = urljoin(PILLAR_WEB_SERVER_URL, "/flamenco/jobs/%s/redir" % job_id) webbrowser.open_new_tab(url) # Do a final report. if missing_sources: names = (ms.name for ms in missing_sources) - self.report({'WARNING'}, 'Flamenco job created with missing files: %s' % - '; '.join(names)) + self.report( + {"WARNING"}, + "Flamenco job created with missing files: %s" % "; ".join(names), + ) else: - self.report({'INFO'}, 'Flamenco job created.') + self.report({"INFO"}, "Flamenco job created.") if self.quit_after_submit: silently_quit_blender() self.quit() - async def _create_jobinfo_json(self, outdir: Path, job_info: dict, - manager_id: str, project_id: str, - missing_sources: typing.List[Path]): + async def _create_jobinfo_json( + self, + outdir: Path, + job_info: dict, + manager_id: str, + project_id: str, + missing_sources: typing.List[Path], + ): from ..blender import preferences + prefs = preferences() - with open(str(outdir / 'jobinfo.json'), 'w', encoding='utf8') as outfile: + with open(str(outdir / "jobinfo.json"), "w", encoding="utf8") as outfile: import json # Version 1: Only the job doc was saved, with 'missing_files' added inside it. @@ -466,21 +510,25 @@ class FLAMENCO_OT_render(async_loop.AsyncModalOperatorMixin, # - 'job' is saved in a 'job' key, 'misssing_files' still top-level key. # - 'exclusion_filter', 'project_settings', and 'flamenco_manager_settings' # keys were added. - project_settings = prefs.get('project_settings', {}).get(project_id, {}) - if hasattr(project_settings, 'to_dict'): + project_settings = prefs.get("project_settings", {}).get(project_id, {}) + if hasattr(project_settings, "to_dict"): project_settings = project_settings.to_dict() # Pop out some settings so that settings of irrelevant Managers are excluded. - flamenco_managers_settings = project_settings.pop('flamenco_managers_settings', {}) - flamenco_manager_settings = flamenco_managers_settings.pop(manager_id, '-unknown-') + flamenco_managers_settings = project_settings.pop( + "flamenco_managers_settings", {} + ) + flamenco_manager_settings = flamenco_managers_settings.pop( + manager_id, "-unknown-" + ) info = { - '_meta': {'version': 2}, - 'job': job_info, - 'missing_files': [str(mf) for mf in missing_sources], - 'exclusion_filter': (prefs.flamenco_exclude_filter or '').strip(), - 'project_settings': project_settings, - 'flamenco_manager_settings': flamenco_manager_settings, + "_meta": {"version": 2}, + "job": job_info, + "missing_files": [str(mf) for mf in missing_sources], + "exclusion_filter": (prefs.flamenco_exclude_filter or "").strip(), + "project_settings": project_settings, + "flamenco_manager_settings": flamenco_manager_settings, } json.dump(info, outfile, sort_keys=True, indent=4, cls=utils.JSONEncoder) @@ -488,9 +536,9 @@ class FLAMENCO_OT_render(async_loop.AsyncModalOperatorMixin, """Turn a file to render into the render job name.""" job_name = filepath.name - if job_name.endswith('.blend'): + if job_name.endswith(".blend"): job_name = job_name[:-6] - if job_name.endswith('.flamenco'): + if job_name.endswith(".flamenco"): job_name = job_name[:-9] return job_name @@ -502,23 +550,28 @@ class FLAMENCO_OT_render(async_loop.AsyncModalOperatorMixin, """ job_type = context.scene.flamenco_render_job_type - if job_type == 'blender-video-chunks': + if job_type == "blender-video-chunks": # This is not really a requirement, but should catch the mistake where it was # left at the default setting (at the moment of writing that's 1 frame per chunk). if context.scene.flamenco_render_fchunk_size < 10: - self.report({'ERROR'}, 'Job type requires chunks of at least 10 frames.') + self.report( + {"ERROR"}, "Job type requires chunks of at least 10 frames." + ) return False - if settings['output_file_extension'] not in {'.mkv', '.mp4', '.mov'}: - self.report({'ERROR'}, 'Job type requires rendering to Matroska or ' - 'MP4 files, not %r.' % settings['output_file_extension']) + if settings["output_file_extension"] not in {".mkv", ".mp4", ".mov"}: + self.report( + {"ERROR"}, + "Job type requires rendering to Matroska or " + "MP4 files, not %r." % settings["output_file_extension"], + ) return False return True def quit(self): - if bpy.context.window_manager.flamenco_status != 'ABORTED': - bpy.context.window_manager.flamenco_status = 'DONE' + if bpy.context.window_manager.flamenco_status != "ABORTED": + bpy.context.window_manager.flamenco_status = "DONE" super().quit() async def _save_blendfile(self, context): @@ -535,11 +588,14 @@ class FLAMENCO_OT_render(async_loop.AsyncModalOperatorMixin, old_use_overwrite = render.use_overwrite old_use_placeholder = render.use_placeholder - disable_denoiser = (context.scene.flamenco_render_job_type == 'blender-render-progressive' - and render.engine == 'CYCLES') + disable_denoiser = ( + context.scene.flamenco_render_job_type == "blender-render-progressive" + and render.engine == "CYCLES" + ) if disable_denoiser: - use_denoising = [layer.cycles.use_denoising - for layer in context.scene.view_layers] + use_denoising = [ + layer.cycles.use_denoising for layer in context.scene.view_layers + ] else: use_denoising = [] @@ -557,11 +613,11 @@ class FLAMENCO_OT_render(async_loop.AsyncModalOperatorMixin, for layer in context.scene.view_layers: layer.cycles.use_denoising = False - filepath = Path(context.blend_data.filepath).with_suffix('.flamenco.blend') - self.log.info('Saving copy to temporary file %s', filepath) - bpy.ops.wm.save_as_mainfile(filepath=str(filepath), - compress=True, - copy=True) + filepath = Path(context.blend_data.filepath).with_suffix(".flamenco.blend") + self.log.info("Saving copy to temporary file %s", filepath) + bpy.ops.wm.save_as_mainfile( + filepath=str(filepath), compress=True, copy=True + ) finally: # Restore the settings we changed, even after an exception. render.use_file_extension = old_use_file_extension @@ -574,8 +630,11 @@ class FLAMENCO_OT_render(async_loop.AsyncModalOperatorMixin, return filepath - async def bat_pack(self, job_id: str, filepath: Path) \ - -> typing.Tuple[typing.Optional[Path], typing.Optional[PurePath], typing.List[Path]]: + async def bat_pack( + self, job_id: str, filepath: Path + ) -> typing.Tuple[ + typing.Optional[Path], typing.Optional[PurePath], typing.List[Path] + ]: """BAT-packs the blendfile to the destination directory. Returns the path of the destination blend file. @@ -597,17 +656,23 @@ class FLAMENCO_OT_render(async_loop.AsyncModalOperatorMixin, proj_abspath = bpy.path.abspath(prefs.cloud_project_local_path) projdir = Path(proj_abspath).resolve() - exclusion_filter = (prefs.flamenco_exclude_filter or '').strip() + exclusion_filter = (prefs.flamenco_exclude_filter or "").strip() relative_only = prefs.flamenco_relative_only - self.log.debug('projdir: %s', projdir) + self.log.debug("projdir: %s", projdir) if is_shaman_url(prefs.flamenco_job_file_path): - endpoint, _ = bat_interface.parse_shaman_endpoint(prefs.flamenco_job_file_path) - self.log.info('Sending BAT pack to Shaman at %s', endpoint) + endpoint, _ = bat_interface.parse_shaman_endpoint( + prefs.flamenco_job_file_path + ) + self.log.info("Sending BAT pack to Shaman at %s", endpoint) try: outfile, missing_sources = await bat_interface.copy( - bpy.context, filepath, projdir, '/', exclusion_filter, + bpy.context, + filepath, + projdir, + "/", + exclusion_filter, packer_class=bat_interface.ShamanPacker, relative_only=relative_only, endpoint=endpoint, @@ -615,19 +680,24 @@ class FLAMENCO_OT_render(async_loop.AsyncModalOperatorMixin, manager_id=prefs.flamenco_manager.manager, ) except bat_interface.FileTransferError as ex: - self.log.error('Could not transfer %d files, starting with %s', - len(ex.files_remaining), ex.files_remaining[0]) - self.report({'ERROR'}, 'Unable to transfer %d files' % len(ex.files_remaining)) + self.log.error( + "Could not transfer %d files, starting with %s", + len(ex.files_remaining), + ex.files_remaining[0], + ) + self.report( + {"ERROR"}, "Unable to transfer %d files" % len(ex.files_remaining) + ) self.quit() return None, None, [] except bat_interface.Aborted: - self.log.warning('BAT Pack was aborted') - self.report({'WARNING'}, 'Aborted Flamenco file packing/transferring') + self.log.warning("BAT Pack was aborted") + self.report({"WARNING"}, "Aborted Flamenco file packing/transferring") self.quit() return None, None, [] - bpy.context.window_manager.flamenco_status = 'DONE' - outfile = PurePath('{shaman}') / outfile + bpy.context.window_manager.flamenco_status = "DONE" + outfile = PurePath("{shaman}") / outfile return None, outfile, missing_sources if is_file_inside_job_storage(prefs, filepath): @@ -637,52 +707,64 @@ class FLAMENCO_OT_render(async_loop.AsyncModalOperatorMixin, # Create a unique directory that is still more or less identifyable. # This should work better than a random ID. - unique_dir = '%s-%s-%s' % (datetime.now().isoformat('-').replace(':', ''), - self.db_user['username'], - filepath.stem) + unique_dir = "%s-%s-%s" % ( + datetime.now().isoformat("-").replace(":", ""), + self.db_user["username"], + filepath.stem, + ) outdir = Path(prefs.flamenco_job_file_path) / unique_dir - self.log.debug('outdir : %s', outdir) + self.log.debug("outdir : %s", outdir) try: outdir.mkdir(parents=True) except Exception as ex: - self.log.exception('Unable to create output path %s', outdir) - self.report({'ERROR'}, 'Unable to create output path: %s' % ex) + self.log.exception("Unable to create output path %s", outdir) + self.report({"ERROR"}, "Unable to create output path: %s" % ex) self.quit() return outdir, None, [] try: outfile, missing_sources = await bat_interface.copy( - bpy.context, filepath, projdir, outdir, exclusion_filter, - relative_only=relative_only) + bpy.context, + filepath, + projdir, + outdir, + exclusion_filter, + relative_only=relative_only, + ) except bat_interface.FileTransferError as ex: - self.log.error('Could not transfer %d files, starting with %s', - len(ex.files_remaining), ex.files_remaining[0]) - self.report({'ERROR'}, 'Unable to transfer %d files' % len(ex.files_remaining)) + self.log.error( + "Could not transfer %d files, starting with %s", + len(ex.files_remaining), + ex.files_remaining[0], + ) + self.report( + {"ERROR"}, "Unable to transfer %d files" % len(ex.files_remaining) + ) self.quit() return outdir, None, [] except bat_interface.Aborted: - self.log.warning('BAT Pack was aborted') - self.report({'WARNING'}, 'Aborted Flamenco file packing/transferring') + self.log.warning("BAT Pack was aborted") + self.report({"WARNING"}, "Aborted Flamenco file packing/transferring") self.quit() return outdir, None, [] - bpy.context.window_manager.flamenco_status = 'DONE' + bpy.context.window_manager.flamenco_status = "DONE" return outdir, outfile, missing_sources async def compile_job(self, job_id: str, new_settings: dict) -> None: """Request Flamenco Server to start compiling the job.""" payload = { - 'op': 'construct', - 'settings': new_settings, + "op": "construct", + "settings": new_settings, } from .sdk import Job from ..pillar import pillar_call - job = Job({'_id': job_id}) + job = Job({"_id": job_id}) await pillar_call(job.patch, payload, caching=False) @@ -690,30 +772,32 @@ def scene_frame_range(context) -> str: """Returns the frame range string for the current scene.""" s = context.scene - return '%i-%i' % (s.frame_start, s.frame_end) + return "%i-%i" % (s.frame_start, s.frame_end) class FLAMENCO_OT_scene_to_frame_range(FlamencoPollMixin, Operator): """Sets the scene frame range as the Flamenco render frame range.""" - bl_idname = 'flamenco.scene_to_frame_range' - bl_label = 'Sets the scene frame range as the Flamenco render frame range' - bl_description = __doc__.rstrip('.') + + bl_idname = "flamenco.scene_to_frame_range" + bl_label = "Sets the scene frame range as the Flamenco render frame range" + bl_description = __doc__.rstrip(".") def execute(self, context): context.scene.flamenco_render_frame_range = scene_frame_range(context) - return {'FINISHED'} + return {"FINISHED"} -class FLAMENCO_OT_copy_files(Operator, - FlamencoPollMixin, - async_loop.AsyncModalOperatorMixin): +class FLAMENCO_OT_copy_files( + Operator, FlamencoPollMixin, async_loop.AsyncModalOperatorMixin +): """Uses BAT to copy the current blendfile + dependencies to the target directory. This operator is not used directly, but can be useful for testing. """ - bl_idname = 'flamenco.copy_files' - bl_label = 'Copy files to target' - bl_description = __doc__.rstrip('.') + + bl_idname = "flamenco.copy_files" + bl_label = "Copy files to target" + bl_description = __doc__.rstrip(".") stop_upon_exception = True @@ -722,7 +806,7 @@ class FLAMENCO_OT_copy_files(Operator, from ..blender import preferences prefs = preferences() - exclusion_filter = (prefs.flamenco_exclude_filter or '').strip() + exclusion_filter = (prefs.flamenco_exclude_filter or "").strip() storage_path = prefs.flamenco_job_file_path # type: str @@ -732,58 +816,68 @@ class FLAMENCO_OT_copy_files(Operator, Path(context.blend_data.filepath), Path(prefs.cloud_project_local_path), Path(storage_path), - exclusion_filter + exclusion_filter, ) except bat_interface.FileTransferError as ex: - self.log.error('Could not transfer %d files, starting with %s', - len(ex.files_remaining), ex.files_remaining[0]) - self.report({'ERROR'}, 'Unable to transfer %d files' % len(ex.files_remaining)) + self.log.error( + "Could not transfer %d files, starting with %s", + len(ex.files_remaining), + ex.files_remaining[0], + ) + self.report( + {"ERROR"}, "Unable to transfer %d files" % len(ex.files_remaining) + ) self.quit() return except bat_interface.Aborted: - self.log.warning('BAT Pack was aborted') - self.report({'WARNING'}, 'Aborted Flamenco file packing/transferring') + self.log.warning("BAT Pack was aborted") + self.report({"WARNING"}, "Aborted Flamenco file packing/transferring") self.quit() return if missing_sources: names = (ms.name for ms in missing_sources) - self.report({'ERROR'}, 'Missing source files: %s' % '; '.join(names)) + self.report({"ERROR"}, "Missing source files: %s" % "; ".join(names)) else: - self.report({'INFO'}, 'Written %s' % outpath) - context.window_manager.flamenco_status = 'DONE' + self.report({"INFO"}, "Written %s" % outpath) + context.window_manager.flamenco_status = "DONE" self.quit() class FLAMENCO_OT_abort(Operator, FlamencoPollMixin): """Aborts a running Flamenco file packing/transfer operation.""" - bl_idname = 'flamenco.abort' - bl_label = 'Abort' - bl_description = __doc__.rstrip('.') + + bl_idname = "flamenco.abort" + bl_label = "Abort" + bl_description = __doc__.rstrip(".") @classmethod def poll(cls, context): - return super().poll(context) and context.window_manager.flamenco_status != 'ABORTING' + return ( + super().poll(context) + and context.window_manager.flamenco_status != "ABORTING" + ) def execute(self, context): - context.window_manager.flamenco_status = 'ABORTING' + context.window_manager.flamenco_status = "ABORTING" bat_interface.abort() - return {'FINISHED'} + return {"FINISHED"} @compatibility.convert_properties -class FLAMENCO_OT_explore_file_path(FlamencoPollMixin, - Operator): +class FLAMENCO_OT_explore_file_path(FlamencoPollMixin, Operator): """Opens the Flamenco job storage path in a file explorer. If the path cannot be found, this operator tries to open its parent. """ - bl_idname = 'flamenco.explore_file_path' - bl_label = 'Open in file explorer' - bl_description = __doc__.rstrip('.') + bl_idname = "flamenco.explore_file_path" + bl_label = "Open in file explorer" + bl_description = __doc__.rstrip(".") - path = StringProperty(name='Path', description='Path to explore', subtype='DIR_PATH') + path = StringProperty( + name="Path", description="Path to explore", subtype="DIR_PATH" + ) def execute(self, context): import platform @@ -796,72 +890,81 @@ class FLAMENCO_OT_explore_file_path(FlamencoPollMixin, break to_open = to_open.parent else: - self.report({'ERROR'}, 'Unable to open %s or any of its parents.' % self.path) - return {'CANCELLED'} + self.report( + {"ERROR"}, "Unable to open %s or any of its parents." % self.path + ) + return {"CANCELLED"} to_open = str(to_open) if platform.system() == "Windows": import os + os.startfile(to_open) elif platform.system() == "Darwin": import subprocess + subprocess.Popen(["open", to_open]) else: import subprocess + subprocess.Popen(["xdg-open", to_open]) - return {'FINISHED'} + return {"FINISHED"} class FLAMENCO_OT_enable_output_path_override(Operator): """Enables the 'override output path' setting.""" - bl_idname = 'flamenco.enable_output_path_override' - bl_label = 'Enable Overriding of Output Path' - bl_description = 'Click to specify a non-default Output Path for this particular job' + bl_idname = "flamenco.enable_output_path_override" + bl_label = "Enable Overriding of Output Path" + bl_description = ( + "Click to specify a non-default Output Path for this particular job" + ) def execute(self, context): context.scene.flamenco_do_override_output_path = True - return {'FINISHED'} + return {"FINISHED"} class FLAMENCO_OT_disable_output_path_override(Operator): """Disables the 'override output path' setting.""" - bl_idname = 'flamenco.disable_output_path_override' - bl_label = 'disable Overriding of Output Path' - bl_description = 'Click to use the default Output Path' + bl_idname = "flamenco.disable_output_path_override" + bl_label = "disable Overriding of Output Path" + bl_description = "Click to use the default Output Path" def execute(self, context): context.scene.flamenco_do_override_output_path = False - return {'FINISHED'} + return {"FINISHED"} @compatibility.convert_properties class FLAMENCO_OT_set_recommended_sample_cap(Operator): - bl_idname = 'flamenco.set_recommended_sample_cap' - bl_label = 'Set Recommended Maximum Sample Count' - bl_description = 'Set the recommended maximum samples per render task' + bl_idname = "flamenco.set_recommended_sample_cap" + bl_label = "Set Recommended Maximum Sample Count" + bl_description = "Set the recommended maximum samples per render task" sample_cap = IntProperty() def execute(self, context): context.scene.flamenco_render_chunk_sample_cap = self.sample_cap - return {'FINISHED'} + return {"FINISHED"} -async def create_job(user_id: str, - project_id: str, - manager_id: str, - job_type: str, - job_settings: dict, - job_name: str = None, - *, - priority: int = 50, - job_description: str = None, - start_paused=False) -> dict: +async def create_job( + user_id: str, + project_id: str, + manager_id: str, + job_type: str, + job_settings: dict, + job_name: str = None, + *, + priority: int = 50, + job_description: str = None, + start_paused=False +) -> dict: """Creates a render job at Flamenco Server, returning the job object as dictionary.""" import json @@ -869,27 +972,29 @@ async def create_job(user_id: str, from ..pillar import pillar_call job_attrs = { - 'status': 'waiting-for-files', - 'priority': priority, - 'name': job_name, - 'settings': job_settings, - 'job_type': job_type, - 'user': user_id, - 'manager': manager_id, - 'project': project_id, + "status": "waiting-for-files", + "priority": priority, + "name": job_name, + "settings": job_settings, + "job_type": job_type, + "user": user_id, + "manager": manager_id, + "project": project_id, } if job_description: - job_attrs['description'] = job_description + job_attrs["description"] = job_description if start_paused: - job_attrs['start_paused'] = True + job_attrs["start_paused"] = True - log.info('Going to create Flamenco job:\n%s', - json.dumps(job_attrs, indent=4, sort_keys=True)) + log.info( + "Going to create Flamenco job:\n%s", + json.dumps(job_attrs, indent=4, sort_keys=True), + ) job = Job(job_attrs) await pillar_call(job.create) - log.info('Job created succesfully: %s', job._id) + log.info("Job created succesfully: %s", job._id) return job.to_dict() @@ -897,21 +1002,22 @@ def is_image_type(render_output_type: str) -> bool: """Determines whether the render output type is an image (True) or video (False).""" # This list is taken from rna_scene.c:273, rna_enum_image_type_items. - video_types = {'AVI_JPEG', 'AVI_RAW', 'FRAMESERVER', 'FFMPEG', 'QUICKTIME'} + video_types = {"AVI_JPEG", "AVI_RAW", "FRAMESERVER", "FFMPEG", "QUICKTIME"} return render_output_type not in video_types @functools.lru_cache(1) def _render_output_path( - local_project_path: str, - blend_filepath: Path, - flamenco_render_job_type: str, - flamenco_job_output_strip_components: int, - flamenco_job_output_path: str, - render_image_format: str, - flamenco_render_frame_range: str, - *, - include_rel_path: bool = True) -> typing.Optional[PurePath]: + local_project_path: str, + blend_filepath: Path, + flamenco_render_job_type: str, + flamenco_job_output_strip_components: int, + flamenco_job_output_path: str, + render_image_format: str, + flamenco_render_frame_range: str, + *, + include_rel_path: bool = True +) -> typing.Optional[PurePath]: """Cached version of render_output_path() This ensures that redraws of the Flamenco Render and Add-on preferences panels @@ -940,11 +1046,11 @@ def _render_output_path( # Strip off '.flamenco' too; we use 'xxx.flamenco.blend' as job file, but # don't want to have all the output paths ending in '.flamenco'. stem = blend_filepath.stem - if stem.endswith('.flamenco'): + if stem.endswith(".flamenco"): stem = stem[:-9] - if flamenco_render_job_type == 'blender-video-chunks': - return output_top / ('YYYY_MM_DD_SEQ-%s.mkv' % stem) + if flamenco_render_job_type == "blender-video-chunks": + return output_top / ("YYYY_MM_DD_SEQ-%s.mkv" % stem) if include_rel_path: rel_parts = proj_rel.parts[flamenco_job_output_strip_components:] @@ -954,7 +1060,7 @@ def _render_output_path( # Blender will have to append the file extensions by itself. if is_image_type(render_image_format): - return dir_components / '######' + return dir_components / "######" return dir_components / flamenco_render_frame_range @@ -994,10 +1100,10 @@ def render_output_path(context, filepath: Path = None) -> typing.Optional[PurePa class FLAMENCO_PT_render(bpy.types.Panel, FlamencoPollMixin): bl_label = "Flamenco Render" - bl_space_type = 'PROPERTIES' - bl_region_type = 'WINDOW' + bl_space_type = "PROPERTIES" + bl_region_type = "WINDOW" bl_context = "render" - bl_options = {'DEFAULT_CLOSED'} + bl_options = {"DEFAULT_CLOSED"} def draw(self, context): layout = self.layout @@ -1007,149 +1113,175 @@ class FLAMENCO_PT_render(bpy.types.Panel, FlamencoPollMixin): prefs = preferences() labeled_row = layout.split(**compatibility.factor(0.25), align=True) - labeled_row.label(text='Manager:') + labeled_row.label(text="Manager:") prop_btn_row = labeled_row.row(align=True) bcp = prefs.flamenco_manager - if bcp.status in {'NONE', 'IDLE'}: + if bcp.status in {"NONE", "IDLE"}: if not bcp.available_managers or not bcp.manager: - prop_btn_row.operator('flamenco.managers', - text='Find Flamenco Managers', - icon='FILE_REFRESH') + prop_btn_row.operator( + "flamenco.managers", + text="Find Flamenco Managers", + icon="FILE_REFRESH", + ) else: - prop_btn_row.prop(bcp, 'manager', text='') - prop_btn_row.operator('flamenco.managers', - text='', - icon='FILE_REFRESH') + prop_btn_row.prop(bcp, "manager", text="") + prop_btn_row.operator("flamenco.managers", text="", icon="FILE_REFRESH") else: - prop_btn_row.label(text='Fetching available managers.') + prop_btn_row.label(text="Fetching available managers.") labeled_row = layout.split(**compatibility.factor(0.25), align=True) - labeled_row.label(text='Job Type:') - labeled_row.prop(context.scene, 'flamenco_render_job_type', text='') + labeled_row.label(text="Job Type:") + labeled_row.prop(context.scene, "flamenco_render_job_type", text="") # Job-type-specific options go directly below the job type selector. box = layout.box() - if getattr(context.scene, 'flamenco_render_job_type', None) == 'blender-render-progressive': + if ( + getattr(context.scene, "flamenco_render_job_type", None) + == "blender-render-progressive" + ): if bpy.app.version < (2, 80): box.alert = True - box.label(text='Progressive rendering requires Blender 2.80 or newer.', - icon='ERROR') + box.label( + text="Progressive rendering requires Blender 2.80 or newer.", + icon="ERROR", + ) # This isn't entirely fair, as Blender 2.79 could hypothetically # be used to submit a job to farm running Blender 2.80. return - if context.scene.render.engine != 'CYCLES': + if context.scene.render.engine != "CYCLES": box.alert = True - box.label(text='Progressive rendering requires Cycles', icon='ERROR') + box.label(text="Progressive rendering requires Cycles", icon="ERROR") return - box.prop(context.scene, 'flamenco_render_chunk_sample_cap') + box.prop(context.scene, "flamenco_render_chunk_sample_cap") sample_count = scene_sample_count(context.scene) recommended_cap = sample_count // 4 split = box.split(**compatibility.factor(0.4)) - split.label(text='Total Sample Count: %d' % sample_count) - props = split.operator('flamenco.set_recommended_sample_cap', - text='Recommended Max Samples per Task: %d' % recommended_cap) + split.label(text="Total Sample Count: %d" % sample_count) + props = split.operator( + "flamenco.set_recommended_sample_cap", + text="Recommended Max Samples per Task: %d" % recommended_cap, + ) props.sample_cap = recommended_cap if any(layer.cycles.use_denoising for layer in context.scene.view_layers): - box.label(text='Progressive Rendering will disable Denoising.', icon='ERROR') + box.label( + text="Progressive Rendering will disable Denoising.", icon="ERROR" + ) - box.prop(context.scene, 'flamenco_render_fchunk_size', - text='Minimum Frames per Task') + box.prop( + context.scene, + "flamenco_render_fchunk_size", + text="Minimum Frames per Task", + ) else: - box.prop(context.scene, 'flamenco_render_fchunk_size') + box.prop(context.scene, "flamenco_render_fchunk_size") labeled_row = layout.split(**compatibility.factor(0.25), align=True) - labeled_row.label(text='Frame Range:') + labeled_row.label(text="Frame Range:") prop_btn_row = labeled_row.row(align=True) - prop_btn_row.prop(context.scene, 'flamenco_render_frame_range', text='') - prop_btn_row.operator('flamenco.scene_to_frame_range', text='', icon='ARROW_LEFTRIGHT') + prop_btn_row.prop(context.scene, "flamenco_render_frame_range", text="") + prop_btn_row.operator( + "flamenco.scene_to_frame_range", text="", icon="ARROW_LEFTRIGHT" + ) - layout.prop(context.scene, 'flamenco_render_job_priority') - layout.prop(context.scene, 'flamenco_start_paused') + layout.prop(context.scene, "flamenco_render_job_priority") + layout.prop(context.scene, "flamenco_start_paused") paths_layout = layout.column(align=True) labeled_row = paths_layout.split(**compatibility.factor(0.25), align=True) - labeled_row.label(text='Storage:') + labeled_row.label(text="Storage:") prop_btn_row = labeled_row.row(align=True) prop_btn_row.label(text=prefs.flamenco_job_file_path) - props = prop_btn_row.operator(FLAMENCO_OT_explore_file_path.bl_idname, - text='', icon='DISK_DRIVE') + props = prop_btn_row.operator( + FLAMENCO_OT_explore_file_path.bl_idname, text="", icon="DISK_DRIVE" + ) props.path = prefs.flamenco_job_file_path - if is_file_inside_job_storage(prefs, context.blend_data.filepath): # File is contained in the job storage path, no need to copy anything. - paths_layout.label(text='Current file already in job storage path; ' - 'not going to create BAT pack.') + paths_layout.label( + text="Current file already in job storage path; " + "not going to create BAT pack." + ) render_output = render_output_path(context) if render_output is None: - paths_layout.label(text='Unable to render with Flamenco, outside of project directory.') + paths_layout.label( + text="Unable to render with Flamenco, outside of project directory." + ) return labeled_row = paths_layout.split(**compatibility.factor(0.25), align=True) - labeled_row.label(text='Output:') + labeled_row.label(text="Output:") prop_btn_row = labeled_row.row(align=True) if context.scene.flamenco_do_override_output_path: - prop_btn_row.prop(context.scene, 'flamenco_override_output_path', text='') + prop_btn_row.prop(context.scene, "flamenco_override_output_path", text="") op = FLAMENCO_OT_disable_output_path_override.bl_idname - icon = 'X' + icon = "X" else: prop_btn_row.label(text=str(render_output)) op = FLAMENCO_OT_enable_output_path_override.bl_idname - icon = 'GREASEPENCIL' - prop_btn_row.operator(op, icon=icon, text='') + icon = "GREASEPENCIL" + prop_btn_row.operator(op, icon=icon, text="") - props = prop_btn_row.operator(FLAMENCO_OT_explore_file_path.bl_idname, - text='', icon='DISK_DRIVE') + props = prop_btn_row.operator( + FLAMENCO_OT_explore_file_path.bl_idname, text="", icon="DISK_DRIVE" + ) props.path = str(render_output.parent) if context.scene.flamenco_do_override_output_path: labeled_row = paths_layout.split(**compatibility.factor(0.25), align=True) - labeled_row.label(text='Effective Output Path:') + labeled_row.label(text="Effective Output Path:") labeled_row.label(text=str(render_output)) self.draw_odd_size_warning(layout, context.scene.render) # Show current status of Flamenco. flamenco_status = context.window_manager.flamenco_status - if flamenco_status in {'IDLE', 'ABORTED', 'DONE'}: + if flamenco_status in {"IDLE", "ABORTED", "DONE"}: if prefs.flamenco_show_quit_after_submit_button: ui = layout.split(**compatibility.factor(0.75), align=True) else: ui = layout - ui.operator(FLAMENCO_OT_render.bl_idname, - text='Render on Flamenco', - icon='RENDER_ANIMATION').quit_after_submit = False + ui.operator( + FLAMENCO_OT_render.bl_idname, + text="Render on Flamenco", + icon="RENDER_ANIMATION", + ).quit_after_submit = False if prefs.flamenco_show_quit_after_submit_button: - ui.operator(FLAMENCO_OT_render.bl_idname, - text='Submit & Quit', - icon='RENDER_ANIMATION').quit_after_submit = True + ui.operator( + FLAMENCO_OT_render.bl_idname, + text="Submit & Quit", + icon="RENDER_ANIMATION", + ).quit_after_submit = True if bpy.app.debug: layout.operator(FLAMENCO_OT_copy_files.bl_idname) - elif flamenco_status == 'INVESTIGATING': + elif flamenco_status == "INVESTIGATING": row = layout.row(align=True) - row.label(text='Investigating your files') - row.operator(FLAMENCO_OT_abort.bl_idname, text='', icon='CANCEL') - elif flamenco_status == 'COMMUNICATING': - layout.label(text='Communicating with Flamenco Server') - elif flamenco_status == 'ABORTING': + row.label(text="Investigating your files") + row.operator(FLAMENCO_OT_abort.bl_idname, text="", icon="CANCEL") + elif flamenco_status == "COMMUNICATING": + layout.label(text="Communicating with Flamenco Server") + elif flamenco_status == "ABORTING": row = layout.row(align=True) - row.label(text='Aborting, please wait.') - row.operator(FLAMENCO_OT_abort.bl_idname, text='', icon='CANCEL') - if flamenco_status == 'TRANSFERRING': + row.label(text="Aborting, please wait.") + row.operator(FLAMENCO_OT_abort.bl_idname, text="", icon="CANCEL") + if flamenco_status == "TRANSFERRING": row = layout.row(align=True) - row.prop(context.window_manager, 'flamenco_progress', - text=context.window_manager.flamenco_status_txt) - row.operator(FLAMENCO_OT_abort.bl_idname, text='', icon='CANCEL') - elif flamenco_status != 'IDLE' and context.window_manager.flamenco_status_txt: + row.prop( + context.window_manager, + "flamenco_progress", + text=context.window_manager.flamenco_status_txt, + ) + row.operator(FLAMENCO_OT_abort.bl_idname, text="", icon="CANCEL") + elif flamenco_status != "IDLE" and context.window_manager.flamenco_status_txt: layout.label(text=context.window_manager.flamenco_status_txt) def draw_odd_size_warning(self, layout, render): @@ -1166,21 +1298,26 @@ class FLAMENCO_PT_render(bpy.types.Panel, FlamencoPollMixin): box.alert = True if odd_width and odd_height: - msg = 'Both X (%d) and Y (%d) resolution are' % (render_width, render_height) + msg = "Both X (%d) and Y (%d) resolution are" % ( + render_width, + render_height, + ) elif odd_width: - msg = 'X resolution (%d) is' % render_width + msg = "X resolution (%d) is" % render_width else: - msg = 'Y resolution (%d) is' % render_height + msg = "Y resolution (%d) is" % render_height - box.label(text=msg + ' not divisible by 2.', icon='ERROR') - box.label(text='Any video rendered from these frames will be padded with black pixels.') + box.label(text=msg + " not divisible by 2.", icon="ERROR") + box.label( + text="Any video rendered from these frames will be padded with black pixels." + ) def activate(): """Activates draw callbacks, menu items etc. for Flamenco.""" global flamenco_is_active - log.info('Activating Flamenco') + log.info("Activating Flamenco") flamenco_is_active = True _render_output_path.cache_clear() @@ -1189,7 +1326,7 @@ def deactivate(): """Deactivates draw callbacks, menu items etc. for Flamenco.""" global flamenco_is_active - log.info('Deactivating Flamenco') + log.info("Deactivating Flamenco") flamenco_is_active = False _render_output_path.cache_clear() @@ -1206,17 +1343,21 @@ def flamenco_do_override_output_path_updated(scene, context): return from ..blender import preferences + scene.flamenco_override_output_path = preferences().flamenco_job_output_path - log.info('Setting Override Output Path to %s', scene.flamenco_override_output_path) + log.info("Setting Override Output Path to %s", scene.flamenco_override_output_path) # FlamencoManagerGroup needs to be registered before classes that use it. _rna_classes = [FlamencoManagerGroup] _rna_classes.extend( - cls for cls in locals().values() - if (isinstance(cls, type) - and cls.__name__.startswith('FLAMENCO') - and cls not in _rna_classes) + cls + for cls in locals().values() + if ( + isinstance(cls, type) + and cls.__name__.startswith("FLAMENCO") + and cls not in _rna_classes + ) ) @@ -1228,95 +1369,105 @@ def register(): scene = bpy.types.Scene scene.flamenco_render_fchunk_size = IntProperty( - name='Frames per Task', - description='Number of frames to render per task. For progressive renders this is used ' - 'when the sample limit is reached -- before that more frames are used', + name="Frames per Task", + description="Number of frames to render per task. For progressive renders this is used " + "when the sample limit is reached -- before that more frames are used", min=1, default=1, ) scene.flamenco_render_chunk_sample_cap = IntProperty( - name='Maximum Samples per Task', - description='Maximum number of samples per render task; a lower number creates more ' - 'shorter-running tasks. Values between 1/10 and 1/4 of the total sample count ' - 'seem sensible', + name="Maximum Samples per Task", + description="Maximum number of samples per render task; a lower number creates more " + "shorter-running tasks. Values between 1/10 and 1/4 of the total sample count " + "seem sensible", min=1, soft_min=5, default=100, soft_max=1000, ) scene.flamenco_render_frame_range = StringProperty( - name='Frame Range', + name="Frame Range", description='Frames to render, in "printer range" notation', ) scene.flamenco_render_job_type = EnumProperty( - name='Job Type', + name="Job Type", items=[ - ('blender-render', 'Simple Render', 'Simple frame-by-frame render'), - ('blender-render-progressive', 'Progressive Render', - 'Each frame is rendered multiple times with different Cycles sample chunks, then combined'), - ('blender-video-chunks', 'Video Chunks', - 'Render each frame chunk to a video file, then concateate those video files') - ] + ("blender-render", "Simple Render", "Simple frame-by-frame render"), + ( + "blender-render-progressive", + "Progressive Render", + "Each frame is rendered multiple times with different Cycles sample chunks, then combined", + ), + ( + "blender-video-chunks", + "Video Chunks", + "Render each frame chunk to a video file, then concateate those video files", + ), + ], ) scene.flamenco_start_paused = BoolProperty( - name='Start Paused', + name="Start Paused", description="When enabled, the job will be created in 'paused' state, rather than" - " 'queued'. The job will need manual queueing before it will start", + " 'queued'. The job will need manual queueing before it will start", default=False, ) scene.flamenco_render_job_priority = IntProperty( - name='Job Priority', + name="Job Priority", min=1, default=50, max=100, - description='Higher numbers mean higher priority' + description="Higher numbers mean higher priority", ) scene.flamenco_do_override_output_path = BoolProperty( - name='Override Output Path for this Job', - description='When enabled, allows you to specify a non-default Output path ' - 'for this particular job', + name="Override Output Path for this Job", + description="When enabled, allows you to specify a non-default Output path " + "for this particular job", default=False, - update=flamenco_do_override_output_path_updated + update=flamenco_do_override_output_path_updated, ) scene.flamenco_override_output_path = StringProperty( - name='Override Output Path', - description='Path where to store output files, should be accessible for Workers', - subtype='DIR_PATH', - default='') + name="Override Output Path", + description="Path where to store output files, should be accessible for Workers", + subtype="DIR_PATH", + default="", + ) bpy.types.WindowManager.flamenco_status = EnumProperty( items=[ - ('IDLE', 'IDLE', 'Not doing anything.'), - ('SAVING', 'SAVING', 'Saving your file.'), - ('INVESTIGATING', 'INVESTIGATING', 'Finding all dependencies.'), - ('TRANSFERRING', 'TRANSFERRING', 'Transferring all dependencies.'), - ('COMMUNICATING', 'COMMUNICATING', 'Communicating with Flamenco Server.'), - ('DONE', 'DONE', 'Not doing anything, but doing something earlier.'), - ('ABORTING', 'ABORTING', 'User requested we stop doing something.'), - ('ABORTED', 'ABORTED', 'We stopped doing something.'), + ("IDLE", "IDLE", "Not doing anything."), + ("SAVING", "SAVING", "Saving your file."), + ("INVESTIGATING", "INVESTIGATING", "Finding all dependencies."), + ("TRANSFERRING", "TRANSFERRING", "Transferring all dependencies."), + ("COMMUNICATING", "COMMUNICATING", "Communicating with Flamenco Server."), + ("DONE", "DONE", "Not doing anything, but doing something earlier."), + ("ABORTING", "ABORTING", "User requested we stop doing something."), + ("ABORTED", "ABORTED", "We stopped doing something."), ], - name='flamenco_status', - default='IDLE', - description='Current status of the Flamenco add-on', - update=redraw) + name="flamenco_status", + default="IDLE", + description="Current status of the Flamenco add-on", + update=redraw, + ) bpy.types.WindowManager.flamenco_status_txt = StringProperty( - name='Flamenco Status', - default='', - description='Textual description of what Flamenco is doing', - update=redraw) + name="Flamenco Status", + default="", + description="Textual description of what Flamenco is doing", + update=redraw, + ) bpy.types.WindowManager.flamenco_progress = IntProperty( - name='Flamenco Progress', + name="Flamenco Progress", default=0, - description='File transfer progress', - subtype='PERCENTAGE', + description="File transfer progress", + subtype="PERCENTAGE", min=0, max=100, - update=redraw) + update=redraw, + ) def unregister(): @@ -1325,16 +1476,20 @@ def unregister(): try: bpy.utils.unregister_class(cls) except RuntimeError: - log.warning('Unable to unregister class %r, probably already unregistered', cls) + log.warning( + "Unable to unregister class %r, probably already unregistered", cls + ) - for name in ('flamenco_render_fchunk_size', - 'flamenco_render_chunk_sample_cap', - 'flamenco_render_frame_range', - 'flamenco_render_job_type', - 'flamenco_start_paused', - 'flamenco_render_job_priority', - 'flamenco_do_override_output_path', - 'flamenco_override_output_path'): + for name in ( + "flamenco_render_fchunk_size", + "flamenco_render_chunk_sample_cap", + "flamenco_render_frame_range", + "flamenco_render_job_type", + "flamenco_start_paused", + "flamenco_render_job_priority", + "flamenco_do_override_output_path", + "flamenco_override_output_path", + ): try: delattr(bpy.types.Scene, name) except AttributeError: diff --git a/blender_cloud/flamenco/bat_interface.py b/blender_cloud/flamenco/bat_interface.py index 3b0154e..cb98e88 100644 --- a/blender_cloud/flamenco/bat_interface.py +++ b/blender_cloud/flamenco/bat_interface.py @@ -36,7 +36,6 @@ class BatProgress(progress.Callback): self.loop = asyncio.get_event_loop() def _set_attr(self, attr: str, value): - async def do_it(): setattr(bpy.context.window_manager, attr, value) @@ -44,48 +43,48 @@ class BatProgress(progress.Callback): def _txt(self, msg: str): """Set a text in a thread-safe way.""" - self._set_attr('flamenco_status_txt', msg) + self._set_attr("flamenco_status_txt", msg) def _status(self, status: str): """Set the flamenco_status property in a thread-safe way.""" - self._set_attr('flamenco_status', status) + self._set_attr("flamenco_status", status) def _progress(self, progress: int): """Set the flamenco_progress property in a thread-safe way.""" - self._set_attr('flamenco_progress', progress) + self._set_attr("flamenco_progress", progress) def pack_start(self) -> None: - self._txt('Starting BAT Pack operation') + self._txt("Starting BAT Pack operation") - def pack_done(self, - output_blendfile: pathlib.Path, - missing_files: typing.Set[pathlib.Path]) -> None: + def pack_done( + self, output_blendfile: pathlib.Path, missing_files: typing.Set[pathlib.Path] + ) -> None: if missing_files: - self._txt('There were %d missing files' % len(missing_files)) + self._txt("There were %d missing files" % len(missing_files)) else: - self._txt('Pack of %s done' % output_blendfile.name) + self._txt("Pack of %s done" % output_blendfile.name) def pack_aborted(self, reason: str): - self._txt('Aborted: %s' % reason) - self._status('ABORTED') + self._txt("Aborted: %s" % reason) + self._status("ABORTED") def trace_blendfile(self, filename: pathlib.Path) -> None: """Called for every blendfile opened when tracing dependencies.""" - self._txt('Inspecting %s' % filename.name) + self._txt("Inspecting %s" % filename.name) def trace_asset(self, filename: pathlib.Path) -> None: - if filename.stem == '.blend': + if filename.stem == ".blend": return - self._txt('Found asset %s' % filename.name) + self._txt("Found asset %s" % filename.name) def rewrite_blendfile(self, orig_filename: pathlib.Path) -> None: - self._txt('Rewriting %s' % orig_filename.name) + self._txt("Rewriting %s" % orig_filename.name) def transfer_file(self, src: pathlib.Path, dst: pathlib.Path) -> None: - self._txt('Transferring %s' % src.name) + self._txt("Transferring %s" % src.name) def transfer_file_skipped(self, src: pathlib.Path, dst: pathlib.Path) -> None: - self._txt('Skipped %s' % src.name) + self._txt("Skipped %s" % src.name) def transfer_progress(self, total_bytes: int, transferred_bytes: int) -> None: self._progress(round(100 * transferred_bytes / total_bytes)) @@ -98,15 +97,17 @@ class BatProgress(progress.Callback): class ShamanPacker(shaman.ShamanPacker): """Packer with support for getting an auth token from Flamenco Server.""" - def __init__(self, - bfile: pathlib.Path, - project: pathlib.Path, - target: str, - endpoint: str, - checkout_id: str, - *, - manager_id: str, - **kwargs) -> None: + def __init__( + self, + bfile: pathlib.Path, + project: pathlib.Path, + target: str, + endpoint: str, + checkout_id: str, + *, + manager_id: str, + **kwargs + ) -> None: self.manager_id = manager_id super().__init__(bfile, project, target, endpoint, checkout_id, **kwargs) @@ -116,25 +117,27 @@ class ShamanPacker(shaman.ShamanPacker): from ..blender import PILLAR_SERVER_URL from ..pillar import blender_id_subclient, uncached_session, SUBCLIENT_ID - url = urllib.parse.urljoin(PILLAR_SERVER_URL, - 'flamenco/jwt/generate-token/%s' % self.manager_id) - auth_token = blender_id_subclient()['token'] + url = urllib.parse.urljoin( + PILLAR_SERVER_URL, "flamenco/jwt/generate-token/%s" % self.manager_id + ) + auth_token = blender_id_subclient()["token"] resp = uncached_session.get(url, auth=(auth_token, SUBCLIENT_ID)) resp.raise_for_status() return resp.text -async def copy(context, - base_blendfile: pathlib.Path, - project: pathlib.Path, - target: str, - exclusion_filter: str, - *, - relative_only: bool, - packer_class=pack.Packer, - **packer_args) \ - -> typing.Tuple[pathlib.Path, typing.Set[pathlib.Path]]: +async def copy( + context, + base_blendfile: pathlib.Path, + project: pathlib.Path, + target: str, + exclusion_filter: str, + *, + relative_only: bool, + packer_class=pack.Packer, + **packer_args +) -> typing.Tuple[pathlib.Path, typing.Set[pathlib.Path]]: """Use BAT🦇 to copy the given file and dependencies to the target location. :raises: FileTransferError if a file couldn't be transferred. @@ -145,30 +148,36 @@ async def copy(context, loop = asyncio.get_event_loop() wm = bpy.context.window_manager - packer = packer_class(base_blendfile, project, target, - compress=True, relative_only=relative_only, **packer_args) + packer = packer_class( + base_blendfile, + project, + target, + compress=True, + relative_only=relative_only, + **packer_args + ) with packer: with _packer_lock: if exclusion_filter: # There was a mistake in an older version of the property tooltip, # showing semicolon-separated instead of space-separated. We now # just handle both. - filter_parts = re.split('[ ;]+', exclusion_filter.strip(' ;')) + filter_parts = re.split("[ ;]+", exclusion_filter.strip(" ;")) packer.exclude(*filter_parts) packer.progress_cb = BatProgress() _running_packer = packer - log.debug('awaiting strategise') - wm.flamenco_status = 'INVESTIGATING' + log.debug("awaiting strategise") + wm.flamenco_status = "INVESTIGATING" await loop.run_in_executor(None, packer.strategise) - log.debug('awaiting execute') - wm.flamenco_status = 'TRANSFERRING' + log.debug("awaiting execute") + wm.flamenco_status = "TRANSFERRING" await loop.run_in_executor(None, packer.execute) - log.debug('done') - wm.flamenco_status = 'DONE' + log.debug("done") + wm.flamenco_status = "DONE" with _packer_lock: _running_packer = None @@ -184,7 +193,7 @@ def abort() -> None: with _packer_lock: if _running_packer is None: - log.debug('No running packer, ignoring call to bat_abort()') + log.debug("No running packer, ignoring call to bat_abort()") return - log.info('Aborting running packer') + log.info("Aborting running packer") _running_packer.abort() diff --git a/blender_cloud/flamenco/sdk.py b/blender_cloud/flamenco/sdk.py index a36b5dd..20ba119 100644 --- a/blender_cloud/flamenco/sdk.py +++ b/blender_cloud/flamenco/sdk.py @@ -7,7 +7,8 @@ from pillarsdk.resource import List, Find, Create class Manager(List, Find): """Manager class wrapping the REST nodes endpoint""" - path = 'flamenco/managers' + + path = "flamenco/managers" PurePlatformPath = pathlib.PurePath @functools.lru_cache(maxsize=1) @@ -18,10 +19,12 @@ class Manager(List, Find): """ settings_version = self.settings_version or 1 try: - settings_func = getattr(self, '_path_replacements_v%d' % settings_version) + settings_func = getattr(self, "_path_replacements_v%d" % settings_version) except AttributeError: - raise RuntimeError('This manager has unsupported settings version %d; ' - 'upgrade Blender Cloud add-on') + raise RuntimeError( + "This manager has unsupported settings version %d; " + "upgrade Blender Cloud add-on" + ) def longest_value_first(item): var_name, var_value = item @@ -40,9 +43,11 @@ class Manager(List, Find): items = self.path_replacement.to_dict().items() this_platform = platform.system().lower() - return [(varname, platform_replacements[this_platform]) - for varname, platform_replacements in items - if this_platform in platform_replacements] + return [ + (varname, platform_replacements[this_platform]) + for varname, platform_replacements in items + if this_platform in platform_replacements + ] def _path_replacements_v2(self) -> typing.List[typing.Tuple[str, str]]: import platform @@ -51,21 +56,21 @@ class Manager(List, Find): return [] this_platform = platform.system().lower() - audiences = {'users', 'all'} + audiences = {"users", "all"} replacements = [] for var_name, variable in self.variables.to_dict().items(): # Path replacement requires bidirectional variables. - if variable.get('direction') != 'twoway': + if variable.get("direction") != "twoway": continue - for var_value in variable.get('values', []): - if var_value.get('audience') not in audiences: + for var_value in variable.get("values", []): + if var_value.get("audience") not in audiences: continue - if var_value.get('platform', '').lower() != this_platform: + if var_value.get("platform", "").lower() != this_platform: continue - replacements.append((var_name, var_value.get('value'))) + replacements.append((var_name, var_value.get("value"))) return replacements def replace_path(self, some_path: pathlib.PurePath) -> str: @@ -74,8 +79,9 @@ class Manager(List, Find): Tries to find platform-specific path prefixes, and replaces them with variables. """ - assert isinstance(some_path, pathlib.PurePath), \ - 'some_path should be a PurePath, not %r' % some_path + assert isinstance(some_path, pathlib.PurePath), ( + "some_path should be a PurePath, not %r" % some_path + ) for varname, path in self._path_replacements(): replacement = self.PurePlatformPath(path) @@ -85,25 +91,26 @@ class Manager(List, Find): # Not relative to each other, so no replacement possible continue - replacement_root = self.PurePlatformPath('{%s}' % varname) + replacement_root = self.PurePlatformPath("{%s}" % varname) return (replacement_root / relpath).as_posix() return some_path.as_posix() class Job(List, Find, Create): - """Job class wrapping the REST nodes endpoint - """ - path = 'flamenco/jobs' - ensure_query_projections = {'project': 1} + """Job class wrapping the REST nodes endpoint""" + + path = "flamenco/jobs" + ensure_query_projections = {"project": 1} def patch(self, payload: dict, api=None): import pillarsdk.utils api = api or self.api - url = pillarsdk.utils.join_url(self.path, str(self['_id'])) - headers = pillarsdk.utils.merge_dict(self.http_headers(), - {'Content-Type': 'application/json'}) + url = pillarsdk.utils.join_url(self.path, str(self["_id"])) + headers = pillarsdk.utils.merge_dict( + self.http_headers(), {"Content-Type": "application/json"} + ) response = api.patch(url, payload, headers=headers) return response diff --git a/blender_cloud/home_project.py b/blender_cloud/home_project.py index c702e44..bbd2148 100644 --- a/blender_cloud/home_project.py +++ b/blender_cloud/home_project.py @@ -23,28 +23,31 @@ from pillarsdk import exceptions as sdk_exceptions from .pillar import pillar_call log = logging.getLogger(__name__) -HOME_PROJECT_ENDPOINT = '/bcloud/home-project' +HOME_PROJECT_ENDPOINT = "/bcloud/home-project" async def get_home_project(params=None) -> pillarsdk.Project: """Returns the home project.""" - log.debug('Getting home project') + log.debug("Getting home project") try: - return await pillar_call(pillarsdk.Project.find_from_endpoint, - HOME_PROJECT_ENDPOINT, params=params) + return await pillar_call( + pillarsdk.Project.find_from_endpoint, HOME_PROJECT_ENDPOINT, params=params + ) except sdk_exceptions.ForbiddenAccess: - log.warning('Access to the home project was denied. ' - 'Double-check that you are logged in with valid BlenderID credentials.') + log.warning( + "Access to the home project was denied. " + "Double-check that you are logged in with valid BlenderID credentials." + ) raise except sdk_exceptions.ResourceNotFound: - log.warning('No home project available.') + log.warning("No home project available.") raise async def get_home_project_id() -> str: """Returns just the ID of the home project.""" - home_proj = await get_home_project({'projection': {'_id': 1}}) - home_proj_id = home_proj['_id'] + home_proj = await get_home_project({"projection": {"_id": 1}}) + home_proj_id = home_proj["_id"] return home_proj_id diff --git a/blender_cloud/image_sharing.py b/blender_cloud/image_sharing.py index 096c21b..2d0faac 100644 --- a/blender_cloud/image_sharing.py +++ b/blender_cloud/image_sharing.py @@ -27,8 +27,8 @@ from pillarsdk import exceptions as sdk_exceptions from .pillar import pillar_call from . import async_loop, compatibility, pillar, home_project, blender -REQUIRES_ROLES_FOR_IMAGE_SHARING = {'subscriber', 'demo'} -IMAGE_SHARING_GROUP_NODE_NAME = 'Image sharing' +REQUIRES_ROLES_FOR_IMAGE_SHARING = {"subscriber", "demo"} +IMAGE_SHARING_GROUP_NODE_NAME = "Image sharing" log = logging.getLogger(__name__) @@ -36,77 +36,85 @@ async def find_image_sharing_group_id(home_project_id, user_id): # Find the top-level image sharing group node. try: share_group, created = await pillar.find_or_create_node( - where={'project': home_project_id, - 'node_type': 'group', - 'parent': None, - 'name': IMAGE_SHARING_GROUP_NODE_NAME}, - additional_create_props={ - 'user': user_id, - 'properties': {}, + where={ + "project": home_project_id, + "node_type": "group", + "parent": None, + "name": IMAGE_SHARING_GROUP_NODE_NAME, }, - projection={'_id': 1}, - may_create=True) + additional_create_props={ + "user": user_id, + "properties": {}, + }, + projection={"_id": 1}, + may_create=True, + ) except pillar.PillarError: - log.exception('Pillar error caught') - raise pillar.PillarError('Unable to find image sharing folder on the Cloud') + log.exception("Pillar error caught") + raise pillar.PillarError("Unable to find image sharing folder on the Cloud") - return share_group['_id'] + return share_group["_id"] @compatibility.convert_properties -class PILLAR_OT_image_share(pillar.PillarOperatorMixin, - async_loop.AsyncModalOperatorMixin, - bpy.types.Operator): - bl_idname = 'pillar.image_share' - bl_label = 'Share an image/screenshot via Blender Cloud' - bl_description = 'Uploads an image for sharing via Blender Cloud' +class PILLAR_OT_image_share( + pillar.PillarOperatorMixin, async_loop.AsyncModalOperatorMixin, bpy.types.Operator +): + bl_idname = "pillar.image_share" + bl_label = "Share an image/screenshot via Blender Cloud" + bl_description = "Uploads an image for sharing via Blender Cloud" - log = logging.getLogger('bpy.ops.%s' % bl_idname) + log = logging.getLogger("bpy.ops.%s" % bl_idname) home_project_id = None - home_project_url = 'home' + home_project_url = "home" share_group_id = None # top-level share group node ID user_id = None target = bpy.props.EnumProperty( items=[ - ('FILE', 'File', 'Share an image file'), - ('DATABLOCK', 'Datablock', 'Share an image datablock'), - ('SCREENSHOT', 'Screenshot', 'Share a screenshot'), + ("FILE", "File", "Share an image file"), + ("DATABLOCK", "Datablock", "Share an image datablock"), + ("SCREENSHOT", "Screenshot", "Share a screenshot"), ], - name='target', - default='SCREENSHOT') + name="target", + default="SCREENSHOT", + ) - name = bpy.props.StringProperty(name='name', - description='File or datablock name to sync') + name = bpy.props.StringProperty( + name="name", description="File or datablock name to sync" + ) screenshot_show_multiview = bpy.props.BoolProperty( - name='screenshot_show_multiview', - description='Enable Multi-View', - default=False) + name="screenshot_show_multiview", description="Enable Multi-View", default=False + ) screenshot_use_multiview = bpy.props.BoolProperty( - name='screenshot_use_multiview', - description='Use Multi-View', - default=False) + name="screenshot_use_multiview", description="Use Multi-View", default=False + ) screenshot_full = bpy.props.BoolProperty( - name='screenshot_full', - description='Full Screen, Capture the whole window (otherwise only capture the active area)', - default=False) + name="screenshot_full", + description="Full Screen, Capture the whole window (otherwise only capture the active area)", + default=False, + ) def invoke(self, context, event): # Do a quick test on datablock dirtyness. If it's not packed and dirty, # the user should save it first. - if self.target == 'DATABLOCK': + if self.target == "DATABLOCK": if not self.name: - self.report({'ERROR'}, 'No name given of the datablock to share.') - return {'CANCELLED'} + self.report({"ERROR"}, "No name given of the datablock to share.") + return {"CANCELLED"} datablock = bpy.data.images[self.name] - if datablock.type == 'IMAGE' and datablock.is_dirty and not datablock.packed_file: - self.report({'ERROR'}, 'Datablock is dirty, save it first.') - return {'CANCELLED'} + if ( + datablock.type == "IMAGE" + and datablock.is_dirty + and not datablock.packed_file + ): + self.report({"ERROR"}, "Datablock is dirty, save it first.") + return {"CANCELLED"} return async_loop.AsyncModalOperatorMixin.invoke(self, context, event) @@ -114,80 +122,87 @@ class PILLAR_OT_image_share(pillar.PillarOperatorMixin, """Entry point of the asynchronous operator.""" # We don't want to influence what is included in the screen shot. - if self.target == 'SCREENSHOT': - print('Blender Cloud add-on is communicating with Blender Cloud') + if self.target == "SCREENSHOT": + print("Blender Cloud add-on is communicating with Blender Cloud") else: - self.report({'INFO'}, 'Communicating with Blender Cloud') + self.report({"INFO"}, "Communicating with Blender Cloud") try: # Refresh credentials try: - db_user = await self.check_credentials(context, REQUIRES_ROLES_FOR_IMAGE_SHARING) - self.user_id = db_user['_id'] - self.log.debug('Found user ID: %s', self.user_id) + db_user = await self.check_credentials( + context, REQUIRES_ROLES_FOR_IMAGE_SHARING + ) + self.user_id = db_user["_id"] + self.log.debug("Found user ID: %s", self.user_id) except pillar.NotSubscribedToCloudError as ex: self._log_subscription_needed(can_renew=ex.can_renew) - self._state = 'QUIT' + self._state = "QUIT" return except pillar.UserNotLoggedInError: - self.log.exception('Error checking/refreshing credentials.') - self.report({'ERROR'}, 'Please log in on Blender ID first.') - self._state = 'QUIT' + self.log.exception("Error checking/refreshing credentials.") + self.report({"ERROR"}, "Please log in on Blender ID first.") + self._state = "QUIT" return # Find the home project. try: - home_proj = await home_project.get_home_project({ - 'projection': {'_id': 1, 'url': 1} - }) + home_proj = await home_project.get_home_project( + {"projection": {"_id": 1, "url": 1}} + ) except sdk_exceptions.ForbiddenAccess: - self.log.exception('Forbidden access to home project.') - self.report({'ERROR'}, 'Did not get access to home project.') - self._state = 'QUIT' + self.log.exception("Forbidden access to home project.") + self.report({"ERROR"}, "Did not get access to home project.") + self._state = "QUIT" return except sdk_exceptions.ResourceNotFound: - self.report({'ERROR'}, 'Home project not found.') - self._state = 'QUIT' + self.report({"ERROR"}, "Home project not found.") + self._state = "QUIT" return - self.home_project_id = home_proj['_id'] - self.home_project_url = home_proj['url'] + self.home_project_id = home_proj["_id"] + self.home_project_url = home_proj["url"] try: - gid = await find_image_sharing_group_id(self.home_project_id, - self.user_id) + gid = await find_image_sharing_group_id( + self.home_project_id, self.user_id + ) self.share_group_id = gid - self.log.debug('Found group node ID: %s', self.share_group_id) + self.log.debug("Found group node ID: %s", self.share_group_id) except sdk_exceptions.ForbiddenAccess: - self.log.exception('Unable to find Group ID') - self.report({'ERROR'}, 'Unable to find sync folder.') - self._state = 'QUIT' + self.log.exception("Unable to find Group ID") + self.report({"ERROR"}, "Unable to find sync folder.") + self._state = "QUIT" return await self.share_image(context) except Exception as ex: - self.log.exception('Unexpected exception caught.') - self.report({'ERROR'}, 'Unexpected error %s: %s' % (type(ex), ex)) + self.log.exception("Unexpected exception caught.") + self.report({"ERROR"}, "Unexpected error %s: %s" % (type(ex), ex)) - self._state = 'QUIT' + self._state = "QUIT" async def share_image(self, context): """Sends files to the Pillar server.""" - if self.target == 'FILE': - self.report({'INFO'}, "Uploading %s '%s'" % (self.target.lower(), self.name)) + if self.target == "FILE": + self.report( + {"INFO"}, "Uploading %s '%s'" % (self.target.lower(), self.name) + ) node = await self.upload_file(self.name) - elif self.target == 'SCREENSHOT': + elif self.target == "SCREENSHOT": node = await self.upload_screenshot(context) else: - self.report({'INFO'}, "Uploading %s '%s'" % (self.target.lower(), self.name)) + self.report( + {"INFO"}, "Uploading %s '%s'" % (self.target.lower(), self.name) + ) node = await self.upload_datablock(context) - self.report({'INFO'}, 'Upload complete, creating link to share.') + self.report({"INFO"}, "Upload complete, creating link to share.") share_info = await pillar_call(node.share) - url = share_info.get('short_link') + url = share_info.get("short_link") context.window_manager.clipboard = url - self.report({'INFO'}, 'The link has been copied to your clipboard: %s' % url) + self.report({"INFO"}, "The link has been copied to your clipboard: %s" % url) await self.maybe_open_browser(url) @@ -197,19 +212,21 @@ class PILLAR_OT_image_share(pillar.PillarOperatorMixin, Returns the node. """ - self.log.info('Uploading file %s', filename) - node = await pillar_call(pillarsdk.Node.create_asset_from_file, - self.home_project_id, - self.share_group_id, - 'image', - filename, - extra_where={'user': self.user_id}, - always_create_new_node=True, - fileobj=fileobj, - caching=False) - node_id = node['_id'] - self.log.info('Created node %s', node_id) - self.report({'INFO'}, 'File succesfully uploaded to the cloud!') + self.log.info("Uploading file %s", filename) + node = await pillar_call( + pillarsdk.Node.create_asset_from_file, + self.home_project_id, + self.share_group_id, + "image", + filename, + extra_where={"user": self.user_id}, + always_create_new_node=True, + fileobj=fileobj, + caching=False, + ) + node_id = node["_id"] + self.log.info("Created node %s", node_id) + self.report({"INFO"}, "File succesfully uploaded to the cloud!") return node @@ -220,7 +237,7 @@ class PILLAR_OT_image_share(pillar.PillarOperatorMixin, import webbrowser - self.log.info('Opening browser at %s', url) + self.log.info("Opening browser at %s", url) webbrowser.open_new_tab(url) async def upload_datablock(self, context) -> pillarsdk.Node: @@ -232,12 +249,13 @@ class PILLAR_OT_image_share(pillar.PillarOperatorMixin, self.log.info("Uploading datablock '%s'" % self.name) datablock = bpy.data.images[self.name] - if datablock.type == 'RENDER_RESULT': + if datablock.type == "RENDER_RESULT": # Construct a sensible name for this render. - filename = '%s-%s-render%s' % ( + filename = "%s-%s-render%s" % ( os.path.splitext(os.path.basename(context.blend_data.filepath))[0], context.scene.name, - context.scene.render.file_extension) + context.scene.render.file_extension, + ) return await self.upload_via_tempdir(datablock, filename) if datablock.packed_file is not None: @@ -266,7 +284,7 @@ class PILLAR_OT_image_share(pillar.PillarOperatorMixin, with tempfile.TemporaryDirectory() as tmpdir: filepath = os.path.join(tmpdir, filename_on_cloud) - self.log.debug('Saving %s to %s', datablock, filepath) + self.log.debug("Saving %s to %s", datablock, filepath) datablock.save_render(filepath) return await self.upload_file(filepath) @@ -278,25 +296,27 @@ class PILLAR_OT_image_share(pillar.PillarOperatorMixin, import io - filename = '%s.%s' % (datablock.name, datablock.file_format.lower()) + filename = "%s.%s" % (datablock.name, datablock.file_format.lower()) fileobj = io.BytesIO(datablock.packed_file.data) fileobj.seek(0) # ensure PillarSDK reads the file from the beginning. - self.log.info('Uploading packed file directly from memory to %r.', filename) + self.log.info("Uploading packed file directly from memory to %r.", filename) return await self.upload_file(filename, fileobj=fileobj) async def upload_screenshot(self, context) -> pillarsdk.Node: """Takes a screenshot, saves it to a temp file, and uploads it.""" - self.name = datetime.datetime.now().strftime('Screenshot-%Y-%m-%d-%H%M%S.png') - self.report({'INFO'}, "Uploading %s '%s'" % (self.target.lower(), self.name)) + self.name = datetime.datetime.now().strftime("Screenshot-%Y-%m-%d-%H%M%S.png") + self.report({"INFO"}, "Uploading %s '%s'" % (self.target.lower(), self.name)) with tempfile.TemporaryDirectory() as tmpdir: filepath = os.path.join(tmpdir, self.name) - self.log.debug('Saving screenshot to %s', filepath) - bpy.ops.screen.screenshot(filepath=filepath, - show_multiview=self.screenshot_show_multiview, - use_multiview=self.screenshot_use_multiview, - full=self.screenshot_full) + self.log.debug("Saving screenshot to %s", filepath) + bpy.ops.screen.screenshot( + filepath=filepath, + show_multiview=self.screenshot_show_multiview, + use_multiview=self.screenshot_use_multiview, + full=self.screenshot_full, + ) return await self.upload_file(filepath) @@ -305,22 +325,25 @@ def image_editor_menu(self, context): box = self.layout.row() if image and image.has_data: - text = 'Share on Blender Cloud' - if image.type == 'IMAGE' and image.is_dirty and not image.packed_file: + text = "Share on Blender Cloud" + if image.type == "IMAGE" and image.is_dirty and not image.packed_file: box.enabled = False - text = 'Save image before sharing on Blender Cloud' + text = "Save image before sharing on Blender Cloud" - props = box.operator(PILLAR_OT_image_share.bl_idname, text=text, - icon_value=blender.icon('CLOUD')) - props.target = 'DATABLOCK' + props = box.operator( + PILLAR_OT_image_share.bl_idname, text=text, icon_value=blender.icon("CLOUD") + ) + props.target = "DATABLOCK" props.name = image.name def window_menu(self, context): - props = self.layout.operator(PILLAR_OT_image_share.bl_idname, - text='Share screenshot via Blender Cloud', - icon_value=blender.icon('CLOUD')) - props.target = 'SCREENSHOT' + props = self.layout.operator( + PILLAR_OT_image_share.bl_idname, + text="Share screenshot via Blender Cloud", + icon_value=blender.icon("CLOUD"), + ) + props.target = "SCREENSHOT" props.screenshot_full = True diff --git a/blender_cloud/pillar.py b/blender_cloud/pillar.py index 01f0d64..4b8a437 100755 --- a/blender_cloud/pillar.py +++ b/blender_cloud/pillar.py @@ -36,12 +36,14 @@ from pillarsdk.utils import sanitize_filename from . import cache -SUBCLIENT_ID = 'PILLAR' -TEXTURE_NODE_TYPES = {'texture', 'hdri'} +SUBCLIENT_ID = "PILLAR" +TEXTURE_NODE_TYPES = {"texture", "hdri"} -RFC1123_DATE_FORMAT = '%a, %d %b %Y %H:%M:%S GMT' +RFC1123_DATE_FORMAT = "%a, %d %b %Y %H:%M:%S GMT" -_pillar_api = {} # will become a mapping from bool (cached/non-cached) to pillarsdk.Api objects. +_pillar_api = ( + {} +) # will become a mapping from bool (cached/non-cached) to pillarsdk.Api objects. log = logging.getLogger(__name__) _retries = requests.packages.urllib3.util.retry.Retry( @@ -50,10 +52,12 @@ _retries = requests.packages.urllib3.util.retry.Retry( ) _http_adapter = requests.adapters.HTTPAdapter(max_retries=_retries) uncached_session = requests.session() -uncached_session.mount('https://', _http_adapter) -uncached_session.mount('http://', _http_adapter) +uncached_session.mount("https://", _http_adapter) +uncached_session.mount("http://", _http_adapter) -_testing_blender_id_profile = None # Just for testing, overrides what is returned by blender_id_profile. +_testing_blender_id_profile = ( + None # Just for testing, overrides what is returned by blender_id_profile. +) _downloaded_urls = set() # URLs we've downloaded this Blender session. @@ -81,7 +85,7 @@ class NotSubscribedToCloudError(UserNotLoggedInError): def __init__(self, can_renew: bool): super().__init__() self.can_renew = can_renew - log.warning('Not subscribed to cloud, can_renew=%s', can_renew) + log.warning("Not subscribed to cloud, can_renew=%s", can_renew) class PillarError(RuntimeError): @@ -104,20 +108,20 @@ class CloudPath(pathlib.PurePosixPath): @property def project_uuid(self) -> str: - assert self.parts[0] == '/' + assert self.parts[0] == "/" if len(self.parts) <= 1: - return '' + return "" return self.parts[1] @property def node_uuids(self) -> tuple: - assert self.parts[0] == '/' + assert self.parts[0] == "/" return self.parts[2:] @property def node_uuid(self) -> str: if len(self.parts) <= 2: - return '' + return "" return self.parts[-1] @@ -128,7 +132,7 @@ def with_existing_dir(filename: str, open_mode: str, encoding=None): directory = os.path.dirname(filename) if not os.path.exists(directory): - log.debug('Creating directory %s', directory) + log.debug("Creating directory %s", directory) os.makedirs(directory, exist_ok=True) with open(filename, open_mode, encoding=encoding) as file_object: yield file_object @@ -137,16 +141,21 @@ def with_existing_dir(filename: str, open_mode: str, encoding=None): def _shorten(somestr: str, maxlen=40) -> str: """Shortens strings for logging""" - return (somestr[:maxlen - 3] + '...') if len(somestr) > maxlen else somestr + return (somestr[: maxlen - 3] + "...") if len(somestr) > maxlen else somestr def save_as_json(pillar_resource, json_filename): - with with_existing_dir(json_filename, 'w') as outfile: - log.debug('Saving metadata to %r' % json_filename) - json.dump(pillar_resource, outfile, sort_keys=True, cls=pillarsdk.utils.PillarJSONEncoder) + with with_existing_dir(json_filename, "w") as outfile: + log.debug("Saving metadata to %r" % json_filename) + json.dump( + pillar_resource, + outfile, + sort_keys=True, + cls=pillarsdk.utils.PillarJSONEncoder, + ) -def blender_id_profile() -> 'blender_id.BlenderIdProfile': +def blender_id_profile() -> "blender_id.BlenderIdProfile": """Returns the Blender ID profile of the currently logged in user.""" # Allow overriding before we import the bpy module. @@ -154,6 +163,7 @@ def blender_id_profile() -> 'blender_id.BlenderIdProfile': return _testing_blender_id_profile import blender_id + return blender_id.get_active_profile() @@ -175,6 +185,7 @@ def pillar_user_uuid() -> str: """Returns the UUID of the Pillar user.""" import blender_id + return blender_id.get_subclient_user_id(SUBCLIENT_ID) @@ -197,25 +208,31 @@ def pillar_api(pillar_endpoint: str = None, caching=True) -> pillarsdk.Api: # Allow overriding the endpoint before importing Blender-specific stuff. if pillar_endpoint is None: from . import blender + pillar_endpoint = blender.preferences().pillar_server - _caching_api = pillarsdk.Api(endpoint=pillar_endpoint, - username=subclient['subclient_user_id'], - password=SUBCLIENT_ID, - token=subclient['token']) + _caching_api = pillarsdk.Api( + endpoint=pillar_endpoint, + username=subclient["subclient_user_id"], + password=SUBCLIENT_ID, + token=subclient["token"], + ) _caching_api.requests_session = cache.requests_session() - _noncaching_api = pillarsdk.Api(endpoint=pillar_endpoint, - username=subclient['subclient_user_id'], - password=SUBCLIENT_ID, - token=subclient['token']) + _noncaching_api = pillarsdk.Api( + endpoint=pillar_endpoint, + username=subclient["subclient_user_id"], + password=SUBCLIENT_ID, + token=subclient["token"], + ) _noncaching_api.requests_session = uncached_session # Send the addon version as HTTP header. from blender_cloud import bl_info - addon_version = '.'.join(str(v) for v in bl_info['version']) - _caching_api.global_headers['Blender-Cloud-Addon'] = addon_version - _noncaching_api.global_headers['Blender-Cloud-Addon'] = addon_version + + addon_version = ".".join(str(v) for v in bl_info["version"]) + _caching_api.global_headers["Blender-Cloud-Addon"] = addon_version + _noncaching_api.global_headers["Blender-Cloud-Addon"] = addon_version _pillar_api = { True: _caching_api, @@ -239,7 +256,9 @@ async def pillar_call(pillar_func, *args, caching=True, **kwargs): calls to Pillar simultaneously. """ - partial = functools.partial(pillar_func, *args, api=pillar_api(caching=caching), **kwargs) + partial = functools.partial( + pillar_func, *args, api=pillar_api(caching=caching), **kwargs + ) loop = asyncio.get_event_loop() # Use explicit calls to acquire() and release() so that we have more control over @@ -247,11 +266,11 @@ async def pillar_call(pillar_func, *args, caching=True, **kwargs): try: await asyncio.wait_for(pillar_semaphore.acquire(), timeout=10, loop=loop) except asyncio.TimeoutError: - log.info('Waiting for semaphore to call %s', pillar_func.__name__) + log.info("Waiting for semaphore to call %s", pillar_func.__name__) try: await asyncio.wait_for(pillar_semaphore.acquire(), timeout=50, loop=loop) except asyncio.TimeoutError: - raise RuntimeError('Timeout waiting for Pillar Semaphore!') + raise RuntimeError("Timeout waiting for Pillar Semaphore!") try: return await loop.run_in_executor(None, partial) @@ -283,24 +302,28 @@ async def check_pillar_credentials(required_roles: set): if not subclient: raise CredentialsNotSyncedError() - pillar_user_id = subclient['subclient_user_id'] + pillar_user_id = subclient["subclient_user_id"] if not pillar_user_id: raise CredentialsNotSyncedError() try: db_user = await pillar_call(pillarsdk.User.me) - except (pillarsdk.UnauthorizedAccess, pillarsdk.ResourceNotFound, pillarsdk.ForbiddenAccess): + except ( + pillarsdk.UnauthorizedAccess, + pillarsdk.ResourceNotFound, + pillarsdk.ForbiddenAccess, + ): raise CredentialsNotSyncedError() roles = set(db_user.roles or set()) - log.getChild('check_pillar_credentials').debug('user has roles %r', roles) + log.getChild("check_pillar_credentials").debug("user has roles %r", roles) if required_roles and not required_roles.intersection(roles): # Delete the subclient info. This forces a re-check later, which can # then pick up on the user's new status. del profile.subclients[SUBCLIENT_ID] profile.save_json() - raise NotSubscribedToCloudError(can_renew='has_subscription' in roles) + raise NotSubscribedToCloudError(can_renew="has_subscription" in roles) return db_user @@ -317,6 +340,7 @@ async def refresh_pillar_credentials(required_roles: set): import blender_id from . import blender + pillar_endpoint = blender.preferences().pillar_server # Create a subclient token and send it to Pillar. @@ -336,20 +360,27 @@ async def get_project_uuid(project_url: str) -> str: """Returns the UUID for the project, given its '/p/' string.""" try: - project = await pillar_call(pillarsdk.Project.find_one, { - 'where': {'url': project_url}, - 'projection': {'permissions': 1}, - }) + project = await pillar_call( + pillarsdk.Project.find_one, + { + "where": {"url": project_url}, + "projection": {"permissions": 1}, + }, + ) except pillarsdk.exceptions.ResourceNotFound: - log.error('Project with URL %r does not exist', project_url) + log.error("Project with URL %r does not exist", project_url) return None - log.info('Found project %r', project) - return project['_id'] + log.info("Found project %r", project) + return project["_id"] -async def get_nodes(project_uuid: str = None, parent_node_uuid: str = None, - node_type=None, max_results=None) -> list: +async def get_nodes( + project_uuid: str = None, + parent_node_uuid: str = None, + node_type=None, + max_results=None, +) -> list: """Gets nodes for either a project or given a parent node. @param project_uuid: the UUID of the project, or None if only querying by parent_node_uuid. @@ -359,40 +390,51 @@ async def get_nodes(project_uuid: str = None, parent_node_uuid: str = None, """ if not project_uuid and not parent_node_uuid: - raise ValueError('get_nodes(): either project_uuid or parent_node_uuid must be given.') + raise ValueError( + "get_nodes(): either project_uuid or parent_node_uuid must be given." + ) - where = {'properties.status': 'published'} + where = {"properties.status": "published"} # Build the parent node where-clause - if parent_node_uuid == '': - where['parent'] = {'$exists': False} + if parent_node_uuid == "": + where["parent"] = {"$exists": False} elif parent_node_uuid is not None: - where['parent'] = parent_node_uuid + where["parent"] = parent_node_uuid # Build the project where-clause if project_uuid: - where['project'] = project_uuid + where["project"] = project_uuid if node_type: if isinstance(node_type, str): - where['node_type'] = node_type + where["node_type"] = node_type else: # Convert set & tuple to list - where['node_type'] = {'$in': list(node_type)} + where["node_type"] = {"$in": list(node_type)} - params = {'projection': {'name': 1, 'parent': 1, 'node_type': 1, 'properties.order': 1, - 'properties.status': 1, 'properties.files': 1, - 'properties.content_type': 1, 'picture': 1}, - 'where': where, - 'embed': ['parent']} + params = { + "projection": { + "name": 1, + "parent": 1, + "node_type": 1, + "properties.order": 1, + "properties.status": 1, + "properties.files": 1, + "properties.content_type": 1, + "picture": 1, + }, + "where": where, + "embed": ["parent"], + } # Pagination if max_results: - params['max_results'] = int(max_results) + params["max_results"] = int(max_results) children = await pillar_call(pillarsdk.Node.all, params) - return children['_items'] + return children["_items"] async def get_texture_projects(max_results=None) -> list: @@ -402,48 +444,65 @@ async def get_texture_projects(max_results=None) -> list: # Pagination if max_results: - params['max_results'] = int(max_results) + params["max_results"] = int(max_results) try: - children = await pillar_call(pillarsdk.Project.all_from_endpoint, - '/bcloud/texture-libraries', - params=params) + children = await pillar_call( + pillarsdk.Project.all_from_endpoint, + "/bcloud/texture-libraries", + params=params, + ) except pillarsdk.ResourceNotFound as ex: - log.warning('Unable to find texture projects: %s', ex) - raise PillarError('Unable to find texture projects: %s' % ex) + log.warning("Unable to find texture projects: %s", ex) + raise PillarError("Unable to find texture projects: %s" % ex) - return children['_items'] + return children["_items"] -async def download_to_file(url, filename, *, - header_store: str, - chunk_size=100 * 1024, - future: asyncio.Future = None): +async def download_to_file( + url, + filename, + *, + header_store: str, + chunk_size=100 * 1024, + future: asyncio.Future = None +): """Downloads a file via HTTP(S) directly to the filesystem.""" stored_headers = {} if os.path.exists(filename) and os.path.exists(header_store): - log.debug('Loading cached headers %r', header_store) + log.debug("Loading cached headers %r", header_store) try: - with open(header_store, 'r') as infile: - stored_headers = requests.structures.CaseInsensitiveDict(json.load(infile)) + with open(header_store, "r") as infile: + stored_headers = requests.structures.CaseInsensitiveDict( + json.load(infile) + ) # Check file length. - expected_content_length = int(stored_headers['Content-Length']) + expected_content_length = int(stored_headers["Content-Length"]) statinfo = os.stat(filename) if expected_content_length == statinfo.st_size: # File exists, and is of the correct length. Don't bother downloading again # if we already downloaded it this session. if url in _downloaded_urls: - log.debug('Already downloaded %s this session, skipping this request.', - url) + log.debug( + "Already downloaded %s this session, skipping this request.", + url, + ) return else: - log.debug('File size should be %i but is %i; ignoring cache.', - expected_content_length, statinfo.st_size) + log.debug( + "File size should be %i but is %i; ignoring cache.", + expected_content_length, + statinfo.st_size, + ) stored_headers = {} except Exception as ex: - log.warning('Unable to load headers from %r, ignoring cache: %s', header_store, str(ex)) + log.warning( + "Unable to load headers from %r, ignoring cache: %s", + header_store, + str(ex), + ) loop = asyncio.get_event_loop() @@ -453,39 +512,39 @@ async def download_to_file(url, filename, *, def perform_get_request() -> requests.Request: headers = {} try: - if stored_headers['Last-Modified']: - headers['If-Modified-Since'] = stored_headers['Last-Modified'] + if stored_headers["Last-Modified"]: + headers["If-Modified-Since"] = stored_headers["Last-Modified"] except KeyError: pass try: - if stored_headers['ETag']: - headers['If-None-Match'] = stored_headers['ETag'] + if stored_headers["ETag"]: + headers["If-None-Match"] = stored_headers["ETag"] except KeyError: pass if is_cancelled(future): - log.debug('Downloading was cancelled before doing the GET.') - raise asyncio.CancelledError('Downloading was cancelled') - log.debug('Performing GET request, waiting for response.') + log.debug("Downloading was cancelled before doing the GET.") + raise asyncio.CancelledError("Downloading was cancelled") + log.debug("Performing GET request, waiting for response.") return uncached_session.get(url, headers=headers, stream=True, verify=True) # Download the file in a different thread. def download_loop(): - with with_existing_dir(filename, 'wb') as outfile: + with with_existing_dir(filename, "wb") as outfile: with closing(response): for block in response.iter_content(chunk_size=chunk_size): if is_cancelled(future): - raise asyncio.CancelledError('Downloading was cancelled') + raise asyncio.CancelledError("Downloading was cancelled") outfile.write(block) # Check for cancellation even before we start our GET request if is_cancelled(future): - log.debug('Downloading was cancelled before doing the GET') - raise asyncio.CancelledError('Downloading was cancelled') + log.debug("Downloading was cancelled before doing the GET") + raise asyncio.CancelledError("Downloading was cancelled") - log.debug('Performing GET %s', _shorten(url)) + log.debug("Performing GET %s", _shorten(url)) response = await loop.run_in_executor(None, perform_get_request) - log.debug('Status %i from GET %s', response.status_code, _shorten(url)) + log.debug("Status %i from GET %s", response.status_code, _shorten(url)) response.raise_for_status() if response.status_code == 304: @@ -496,23 +555,27 @@ async def download_to_file(url, filename, *, # After we performed the GET request, we should check whether we should start # the download at all. if is_cancelled(future): - log.debug('Downloading was cancelled before downloading the GET response') - raise asyncio.CancelledError('Downloading was cancelled') + log.debug("Downloading was cancelled before downloading the GET response") + raise asyncio.CancelledError("Downloading was cancelled") - log.debug('Downloading response of GET %s', _shorten(url)) + log.debug("Downloading response of GET %s", _shorten(url)) await loop.run_in_executor(None, download_loop) - log.debug('Done downloading response of GET %s', _shorten(url)) + log.debug("Done downloading response of GET %s", _shorten(url)) # We're done downloading, now we have something cached we can use. - log.debug('Saving header cache to %s', header_store) + log.debug("Saving header cache to %s", header_store) _downloaded_urls.add(url) - with with_existing_dir(header_store, 'w') as outfile: - json.dump({ - 'ETag': str(response.headers.get('etag', '')), - 'Last-Modified': response.headers.get('Last-Modified'), - 'Content-Length': response.headers.get('Content-Length'), - }, outfile, sort_keys=True) + with with_existing_dir(header_store, "w") as outfile: + json.dump( + { + "ETag": str(response.headers.get("etag", "")), + "Last-Modified": response.headers.get("Last-Modified"), + "Content-Length": response.headers.get("Content-Length"), + }, + outfile, + sort_keys=True, + ) async def fetch_thumbnail_info(file: pillarsdk.File, directory: str, desired_size: str): @@ -529,22 +592,26 @@ async def fetch_thumbnail_info(file: pillarsdk.File, directory: str, desired_siz thumb_link = await pillar_call(file.thumbnail, desired_size) if thumb_link is None: - raise ValueError("File {} has no thumbnail of size {}" - .format(file['_id'], desired_size)) + raise ValueError( + "File {} has no thumbnail of size {}".format(file["_id"], desired_size) + ) - root, ext = os.path.splitext(file['file_path']) - thumb_fname = sanitize_filename('{0}-{1}.jpg'.format(root, desired_size)) + root, ext = os.path.splitext(file["file_path"]) + thumb_fname = sanitize_filename("{0}-{1}.jpg".format(root, desired_size)) thumb_path = os.path.abspath(os.path.join(directory, thumb_fname)) return thumb_link, thumb_path -async def fetch_texture_thumbs(parent_node_uuid: str, desired_size: str, - thumbnail_directory: str, - *, - thumbnail_loading: callable, - thumbnail_loaded: callable, - future: asyncio.Future = None): +async def fetch_texture_thumbs( + parent_node_uuid: str, + desired_size: str, + thumbnail_directory: str, + *, + thumbnail_loading: callable, + thumbnail_loaded: callable, + future: asyncio.Future = None +): """Generator, fetches all texture thumbnails in a certain parent node. @param parent_node_uuid: the UUID of the parent node. All sub-nodes will be downloaded. @@ -560,41 +627,52 @@ async def fetch_texture_thumbs(parent_node_uuid: str, desired_size: str, """ # Download all texture nodes in parallel. - log.debug('Getting child nodes of node %r', parent_node_uuid) - texture_nodes = await get_nodes(parent_node_uuid=parent_node_uuid, - node_type=TEXTURE_NODE_TYPES) + log.debug("Getting child nodes of node %r", parent_node_uuid) + texture_nodes = await get_nodes( + parent_node_uuid=parent_node_uuid, node_type=TEXTURE_NODE_TYPES + ) if is_cancelled(future): - log.warning('fetch_texture_thumbs: Texture downloading cancelled') + log.warning("fetch_texture_thumbs: Texture downloading cancelled") return - coros = (download_texture_thumbnail(texture_node, desired_size, - thumbnail_directory, - thumbnail_loading=thumbnail_loading, - thumbnail_loaded=thumbnail_loaded, - future=future) - for texture_node in texture_nodes) + coros = ( + download_texture_thumbnail( + texture_node, + desired_size, + thumbnail_directory, + thumbnail_loading=thumbnail_loading, + thumbnail_loaded=thumbnail_loaded, + future=future, + ) + for texture_node in texture_nodes + ) # raises any exception from failed handle_texture_node() calls. loop = asyncio.get_event_loop() await asyncio.gather(*coros, loop=loop) - log.info('fetch_texture_thumbs: Done downloading texture thumbnails') + log.info("fetch_texture_thumbs: Done downloading texture thumbnails") -async def download_texture_thumbnail(texture_node, desired_size: str, - thumbnail_directory: str, - *, - thumbnail_loading: callable, - thumbnail_loaded: callable, - future: asyncio.Future = None): +async def download_texture_thumbnail( + texture_node, + desired_size: str, + thumbnail_directory: str, + *, + thumbnail_loading: callable, + thumbnail_loaded: callable, + future: asyncio.Future = None +): # Skip non-texture nodes, as we can't thumbnail them anyway. - if texture_node['node_type'] not in TEXTURE_NODE_TYPES: + if texture_node["node_type"] not in TEXTURE_NODE_TYPES: return if is_cancelled(future): - log.debug('fetch_texture_thumbs cancelled before finding File for texture %r', - texture_node['_id']) + log.debug( + "fetch_texture_thumbs cancelled before finding File for texture %r", + texture_node["_id"], + ) return loop = asyncio.get_event_loop() @@ -603,59 +681,85 @@ async def download_texture_thumbnail(texture_node, desired_size: str, pic_uuid = texture_node.picture if not pic_uuid: # Fall back to the first texture file, if it exists. - log.debug('Node %r does not have a picture, falling back to first file.', - texture_node['_id']) + log.debug( + "Node %r does not have a picture, falling back to first file.", + texture_node["_id"], + ) files = texture_node.properties and texture_node.properties.files if not files: - log.info('Node %r does not have a picture nor files, skipping.', texture_node['_id']) + log.info( + "Node %r does not have a picture nor files, skipping.", + texture_node["_id"], + ) return pic_uuid = files[0].file if not pic_uuid: - log.info('Node %r does not have a picture nor files, skipping.', texture_node['_id']) + log.info( + "Node %r does not have a picture nor files, skipping.", + texture_node["_id"], + ) return # Load the File that belongs to this texture node's picture. loop.call_soon_threadsafe(thumbnail_loading, texture_node, texture_node) - file_desc = await pillar_call(pillarsdk.File.find, pic_uuid, params={ - 'projection': {'filename': 1, 'variations': 1, 'width': 1, 'height': 1, - 'length': 1}, - }) + file_desc = await pillar_call( + pillarsdk.File.find, + pic_uuid, + params={ + "projection": { + "filename": 1, + "variations": 1, + "width": 1, + "height": 1, + "length": 1, + }, + }, + ) if file_desc is None: - log.warning('Unable to find file for texture node %s', pic_uuid) + log.warning("Unable to find file for texture node %s", pic_uuid) thumb_path = None else: if is_cancelled(future): - log.debug('fetch_texture_thumbs cancelled before downloading file %r', - file_desc['_id']) + log.debug( + "fetch_texture_thumbs cancelled before downloading file %r", + file_desc["_id"], + ) return # Get the thumbnail information from Pillar - thumb_url, thumb_path = await fetch_thumbnail_info(file_desc, thumbnail_directory, - desired_size) + thumb_url, thumb_path = await fetch_thumbnail_info( + file_desc, thumbnail_directory, desired_size + ) if thumb_path is None: # The task got cancelled, we should abort too. - log.debug('fetch_texture_thumbs cancelled while downloading file %r', - file_desc['_id']) + log.debug( + "fetch_texture_thumbs cancelled while downloading file %r", + file_desc["_id"], + ) return # Cached headers are stored next to thumbnails in sidecar files. - header_store = '%s.headers' % thumb_path + header_store = "%s.headers" % thumb_path try: - await download_to_file(thumb_url, thumb_path, header_store=header_store, future=future) + await download_to_file( + thumb_url, thumb_path, header_store=header_store, future=future + ) except requests.exceptions.HTTPError as ex: - log.error('Unable to download %s: %s', thumb_url, ex) - thumb_path = 'ERROR' + log.error("Unable to download %s: %s", thumb_url, ex) + thumb_path = "ERROR" loop.call_soon_threadsafe(thumbnail_loaded, texture_node, file_desc, thumb_path) -async def fetch_node_files(node: pillarsdk.Node, - *, - file_doc_loading: callable, - file_doc_loaded: callable, - future: asyncio.Future = None): +async def fetch_node_files( + node: pillarsdk.Node, + *, + file_doc_loading: callable, + file_doc_loaded: callable, + future: asyncio.Future = None +): """Fetches all files of a texture/hdri node. @param node: Node document to fetch all file docs for. @@ -670,96 +774,122 @@ async def fetch_node_files(node: pillarsdk.Node, # Download all thumbnails in parallel. if is_cancelled(future): - log.warning('fetch_texture_thumbs: Texture downloading cancelled') + log.warning("fetch_texture_thumbs: Texture downloading cancelled") return - coros = (download_file_doc(file_ref.file, - file_doc_loading=file_doc_loading, - file_doc_loaded=file_doc_loaded, - future=future) - for file_ref in node.properties.files) + coros = ( + download_file_doc( + file_ref.file, + file_doc_loading=file_doc_loading, + file_doc_loaded=file_doc_loaded, + future=future, + ) + for file_ref in node.properties.files + ) # raises any exception from failed handle_texture_node() calls. await asyncio.gather(*coros) - log.info('fetch_node_files: Done downloading %i files', len(node.properties.files)) + log.info("fetch_node_files: Done downloading %i files", len(node.properties.files)) -async def download_file_doc(file_id, - *, - file_doc_loading: callable, - file_doc_loaded: callable, - future: asyncio.Future = None): +async def download_file_doc( + file_id, + *, + file_doc_loading: callable, + file_doc_loaded: callable, + future: asyncio.Future = None +): if is_cancelled(future): - log.debug('fetch_texture_thumbs cancelled before finding File for file_id %s', file_id) + log.debug( + "fetch_texture_thumbs cancelled before finding File for file_id %s", file_id + ) return loop = asyncio.get_event_loop() # Load the File that belongs to this texture node's picture. loop.call_soon_threadsafe(file_doc_loading, file_id) - file_desc = await pillar_call(pillarsdk.File.find, file_id, params={ - 'projection': {'filename': 1, 'variations': 1, 'width': 1, 'height': 1, - 'length': 1}, - }) + file_desc = await pillar_call( + pillarsdk.File.find, + file_id, + params={ + "projection": { + "filename": 1, + "variations": 1, + "width": 1, + "height": 1, + "length": 1, + }, + }, + ) if file_desc is None: - log.warning('Unable to find File for file_id %s', file_id) + log.warning("Unable to find File for file_id %s", file_id) loop.call_soon_threadsafe(file_doc_loaded, file_id, file_desc) -async def download_file_by_uuid(file_uuid, - target_directory: str, - metadata_directory: str, - *, - filename: str = None, - map_type: str = None, - file_loading: callable = None, - file_loaded: callable = None, - file_loaded_sync: callable = None, - future: asyncio.Future): +async def download_file_by_uuid( + file_uuid, + target_directory: str, + metadata_directory: str, + *, + filename: str = None, + map_type: str = None, + file_loading: callable = None, + file_loaded: callable = None, + file_loaded_sync: callable = None, + future: asyncio.Future +): """Downloads a file from Pillar by its UUID. :param filename: overrules the filename in file_doc['filename'] if given. The extension from file_doc['filename'] is still used, though. """ if is_cancelled(future): - log.debug('download_file_by_uuid(%r) cancelled.', file_uuid) + log.debug("download_file_by_uuid(%r) cancelled.", file_uuid) return loop = asyncio.get_event_loop() # Find the File document. - file_desc = await pillar_call(pillarsdk.File.find, file_uuid, params={ - 'projection': {'link': 1, 'filename': 1, 'length': 1}, - }) + file_desc = await pillar_call( + pillarsdk.File.find, + file_uuid, + params={ + "projection": {"link": 1, "filename": 1, "length": 1}, + }, + ) # Save the file document to disk - metadata_file = os.path.join(metadata_directory, 'files', '%s.json' % file_uuid) + metadata_file = os.path.join(metadata_directory, "files", "%s.json" % file_uuid) save_as_json(file_desc, metadata_file) # Let the caller override the filename root. - root, ext = os.path.splitext(file_desc['filename']) + root, ext = os.path.splitext(file_desc["filename"]) if filename: root, _ = os.path.splitext(filename) if not map_type or root.endswith(map_type): - target_filename = '%s%s' % (root, ext) + target_filename = "%s%s" % (root, ext) else: - target_filename = '%s-%s%s' % (root, map_type, ext) + target_filename = "%s-%s%s" % (root, map_type, ext) file_path = os.path.join(target_directory, sanitize_filename(target_filename)) - file_url = file_desc['link'] + file_url = file_desc["link"] # log.debug('Texture %r:\n%s', file_uuid, pprint.pformat(file_desc.to_dict())) if file_loading is not None: loop.call_soon_threadsafe(file_loading, file_path, file_desc, map_type) # Cached headers are stored in the project space - header_store = os.path.join(metadata_directory, 'files', - sanitize_filename('%s.headers' % file_uuid)) + header_store = os.path.join( + metadata_directory, "files", sanitize_filename("%s.headers" % file_uuid) + ) - await download_to_file(file_url, file_path, header_store=header_store, future=future) + await download_to_file( + file_url, file_path, header_store=header_store, future=future + ) if file_loaded is not None: loop.call_soon_threadsafe(file_loaded, file_path, file_desc, map_type) @@ -767,75 +897,81 @@ async def download_file_by_uuid(file_uuid, await file_loaded_sync(file_path, file_desc, map_type) -async def download_texture(texture_node, - target_directory: str, - metadata_directory: str, - *, - texture_loading: callable, - texture_loaded: callable, - future: asyncio.Future): - node_type_name = texture_node['node_type'] +async def download_texture( + texture_node, + target_directory: str, + metadata_directory: str, + *, + texture_loading: callable, + texture_loaded: callable, + future: asyncio.Future +): + node_type_name = texture_node["node_type"] if node_type_name not in TEXTURE_NODE_TYPES: - raise TypeError("Node type should be in %r, not %r" % - (TEXTURE_NODE_TYPES, node_type_name)) + raise TypeError( + "Node type should be in %r, not %r" % (TEXTURE_NODE_TYPES, node_type_name) + ) - filename = '%s.taken_from_file' % sanitize_filename(texture_node['name']) + filename = "%s.taken_from_file" % sanitize_filename(texture_node["name"]) # Download every file. Eve doesn't support embedding from a list-of-dicts. downloaders = [] - for file_info in texture_node['properties']['files']: - dlr = download_file_by_uuid(file_info['file'], - target_directory, - metadata_directory, - filename=filename, - map_type=file_info.map_type or file_info.resolution, - file_loading=texture_loading, - file_loaded=texture_loaded, - future=future) + for file_info in texture_node["properties"]["files"]: + dlr = download_file_by_uuid( + file_info["file"], + target_directory, + metadata_directory, + filename=filename, + map_type=file_info.map_type or file_info.resolution, + file_loading=texture_loading, + file_loaded=texture_loaded, + future=future, + ) downloaders.append(dlr) loop = asyncio.get_event_loop() return await asyncio.gather(*downloaders, return_exceptions=True, loop=loop) -async def upload_file(project_id: str, file_path: pathlib.Path, *, - future: asyncio.Future) -> str: +async def upload_file( + project_id: str, file_path: pathlib.Path, *, future: asyncio.Future +) -> str: """Uploads a file to the Blender Cloud, returning a file document ID.""" from .blender import PILLAR_SERVER_URL loop = asyncio.get_event_loop() - url = urllib.parse.urljoin(PILLAR_SERVER_URL, '/storage/stream/%s' % project_id) + url = urllib.parse.urljoin(PILLAR_SERVER_URL, "/storage/stream/%s" % project_id) # Upload the file in a different thread. def upload(): - auth_token = blender_id_subclient()['token'] + auth_token = blender_id_subclient()["token"] - with file_path.open(mode='rb') as infile: - return uncached_session.post(url, - files={'file': infile}, - auth=(auth_token, SUBCLIENT_ID)) + with file_path.open(mode="rb") as infile: + return uncached_session.post( + url, files={"file": infile}, auth=(auth_token, SUBCLIENT_ID) + ) # Check for cancellation even before we start our POST request if is_cancelled(future): - log.debug('Uploading was cancelled before doing the POST') - raise asyncio.CancelledError('Uploading was cancelled') + log.debug("Uploading was cancelled before doing the POST") + raise asyncio.CancelledError("Uploading was cancelled") - log.debug('Performing POST %s', _shorten(url)) + log.debug("Performing POST %s", _shorten(url)) response = await loop.run_in_executor(None, upload) - log.debug('Status %i from POST %s', response.status_code, _shorten(url)) + log.debug("Status %i from POST %s", response.status_code, _shorten(url)) response.raise_for_status() resp = response.json() - log.debug('Upload response: %s', resp) + log.debug("Upload response: %s", resp) try: - file_id = resp['file_id'] + file_id = resp["file_id"] except KeyError: - log.error('No file ID in upload response: %s', resp) - raise PillarError('No file ID in upload response: %s' % resp) + log.error("No file ID in upload response: %s", resp) + raise PillarError("No file ID in upload response: %s" % resp) - log.info('Uploaded %s to file ID %s', file_path, file_id) + log.info("Uploaded %s to file ID %s", file_path, file_id) return file_id @@ -860,9 +996,9 @@ class PillarOperatorMixin: except NotSubscribedToCloudError: raise except CredentialsNotSyncedError: - self.log.info('Credentials not synced, re-syncing automatically.') + self.log.info("Credentials not synced, re-syncing automatically.") else: - self.log.info('Credentials okay.') + self.log.info("Credentials okay.") return db_user try: @@ -870,20 +1006,22 @@ class PillarOperatorMixin: except NotSubscribedToCloudError: raise except CredentialsNotSyncedError: - self.log.info('Credentials not synced after refreshing, handling as not logged in.') - raise UserNotLoggedInError('Not logged in.') + self.log.info( + "Credentials not synced after refreshing, handling as not logged in." + ) + raise UserNotLoggedInError("Not logged in.") except UserNotLoggedInError: - self.log.error('User not logged in on Blender ID.') + self.log.error("User not logged in on Blender ID.") raise else: - self.log.info('Credentials refreshed and ok.') + self.log.info("Credentials refreshed and ok.") return db_user - def _log_subscription_needed(self, *, can_renew: bool, level='ERROR'): + def _log_subscription_needed(self, *, can_renew: bool, level="ERROR"): if can_renew: - msg = 'Please renew your Blender Cloud subscription at https://cloud.blender.org/renew' + msg = "Please renew your Blender Cloud subscription at https://cloud.blender.org/renew" else: - msg = 'Please subscribe to the blender cloud at https://cloud.blender.org/join' + msg = "Please subscribe to the blender cloud at https://cloud.blender.org/join" self.log.warning(msg) self.report({level}, msg) @@ -898,33 +1036,36 @@ class AuthenticatedPillarOperatorMixin(PillarOperatorMixin): async def authenticate(self, context) -> bool: from . import pillar - self.log.info('Checking credentials') + self.log.info("Checking credentials") self.user_id = None self.db_user = None try: self.db_user = await self.check_credentials(context, ()) except pillar.UserNotLoggedInError as ex: - self.log.info('Not logged in error raised: %s', ex) - self.report({'ERROR'}, 'Please log in on Blender ID first.') + self.log.info("Not logged in error raised: %s", ex) + self.report({"ERROR"}, "Please log in on Blender ID first.") self.quit() return False except requests.exceptions.ConnectionError: - self.log.exception('Error checking pillar credentials.') - self.report({'ERROR'}, 'Unable to connect to Blender Cloud, ' - 'check your internet connection.') + self.log.exception("Error checking pillar credentials.") + self.report( + {"ERROR"}, + "Unable to connect to Blender Cloud, " + "check your internet connection.", + ) self.quit() return False - - self.user_id = self.db_user['_id'] + self.user_id = self.db_user["_id"] return True - -async def find_or_create_node(where: dict, - additional_create_props: dict = None, - projection: dict = None, - may_create: bool = True) -> (pillarsdk.Node, bool): +async def find_or_create_node( + where: dict, + additional_create_props: dict = None, + projection: dict = None, + may_create: bool = True, +) -> (pillarsdk.Node, bool): """Finds a node by the `filter_props`, creates it using the additional props. :returns: tuple (node, created), where 'created' is a bool indicating whether @@ -932,10 +1073,10 @@ async def find_or_create_node(where: dict, """ params = { - 'where': where, + "where": where, } if projection: - params['projection'] = projection + params["projection"] = projection found_node = await pillar_call(pillarsdk.Node.find_first, params, caching=False) @@ -950,29 +1091,33 @@ async def find_or_create_node(where: dict, if additional_create_props: node_props.update(additional_create_props) - log.debug('Creating new node %s', node_props) + log.debug("Creating new node %s", node_props) created_node = pillarsdk.Node.new(node_props) created_ok = await pillar_call(created_node.create) if not created_ok: - log.error('Blender Cloud addon: unable to create node on the Cloud.') - raise PillarError('Unable to create node on the Cloud') + log.error("Blender Cloud addon: unable to create node on the Cloud.") + raise PillarError("Unable to create node on the Cloud") return created_node, True -async def attach_file_to_group(file_path: pathlib.Path, - home_project_id: str, - group_node_id: str, - user_id: str = None) -> pillarsdk.Node: +async def attach_file_to_group( + file_path: pathlib.Path, + home_project_id: str, + group_node_id: str, + user_id: str = None, +) -> pillarsdk.Node: """Creates an Asset node and attaches a file document to it.""" - node = await pillar_call(pillarsdk.Node.create_asset_from_file, - home_project_id, - group_node_id, - 'file', - str(file_path), - extra_where=user_id and {'user': user_id}, - caching=False) + node = await pillar_call( + pillarsdk.Node.create_asset_from_file, + home_project_id, + group_node_id, + "file", + str(file_path), + extra_where=user_id and {"user": user_id}, + caching=False, + ) return node diff --git a/blender_cloud/project_specific.py b/blender_cloud/project_specific.py index eb1f674..7d91c9d 100644 --- a/blender_cloud/project_specific.py +++ b/blender_cloud/project_specific.py @@ -6,18 +6,16 @@ import typing # Names of BlenderCloudPreferences properties that are both project-specific # and simple enough to store directly in a dict. -PROJECT_SPECIFIC_SIMPLE_PROPS = ( - 'cloud_project_local_path', -) +PROJECT_SPECIFIC_SIMPLE_PROPS = ("cloud_project_local_path",) # Names of BlenderCloudPreferences properties that are project-specific and # Flamenco Manager-specific, and simple enough to store in a dict. FLAMENCO_PER_PROJECT_PER_MANAGER = ( - 'flamenco_exclude_filter', - 'flamenco_job_file_path', - 'flamenco_job_output_path', - 'flamenco_job_output_strip_components', - 'flamenco_relative_only', + "flamenco_exclude_filter", + "flamenco_job_file_path", + "flamenco_job_output_path", + "flamenco_job_output_strip_components", + "flamenco_relative_only", ) log = logging.getLogger(__name__) @@ -38,25 +36,28 @@ def mark_as_loading(): project_settings_loading -= 1 -def update_preferences(prefs, names_to_update: typing.Iterable[str], - new_values: typing.Mapping[str, typing.Any]): +def update_preferences( + prefs, + names_to_update: typing.Iterable[str], + new_values: typing.Mapping[str, typing.Any], +): for name in names_to_update: if not hasattr(prefs, name): - log.debug('not setting %r, property cannot be found', name) + log.debug("not setting %r, property cannot be found", name) continue if name in new_values: - log.debug('setting %r = %r', name, new_values[name]) + log.debug("setting %r = %r", name, new_values[name]) setattr(prefs, name, new_values[name]) else: # The property wasn't stored, so set the default value instead. bl_type, args = getattr(prefs.bl_rna, name) - log.debug('finding default value for %r', name) - if 'default' not in args: - log.debug('no default value for %r, not touching', name) + log.debug("finding default value for %r", name) + if "default" not in args: + log.debug("no default value for %r, not touching", name) continue - log.debug('found default value for %r = %r', name, args['default']) - setattr(prefs, name, args['default']) + log.debug("found default value for %r = %r", name, args["default"]) + setattr(prefs, name, args["default"]) def handle_project_update(_=None, _2=None): @@ -70,27 +71,32 @@ def handle_project_update(_=None, _2=None): with mark_as_loading(): prefs = preferences() project_id = prefs.project.project - log.debug('Updating internal state to reflect extensions enabled on current project %s.', - project_id) + log.debug( + "Updating internal state to reflect extensions enabled on current project %s.", + project_id, + ) project_extensions.cache_clear() from blender_cloud import attract, flamenco + attract.deactivate() flamenco.deactivate() enabled_for = project_extensions(project_id) - log.info('Project extensions: %s', enabled_for) - if 'attract' in enabled_for: + log.info("Project extensions: %s", enabled_for) + if "attract" in enabled_for: attract.activate() - if 'flamenco' in enabled_for: + if "flamenco" in enabled_for: flamenco.activate() # Load project-specific settings from the last time we visited this project. - ps = prefs.get('project_settings', {}).get(project_id, {}) + ps = prefs.get("project_settings", {}).get(project_id, {}) if not ps: - log.debug('no project-specific settings are available, ' - 'only resetting available Flamenco Managers') + log.debug( + "no project-specific settings are available, " + "only resetting available Flamenco Managers" + ) # The Flamenco Manager should really be chosen explicitly out of the available # Managers. prefs.flamenco_manager.available_managers = [] @@ -98,23 +104,31 @@ def handle_project_update(_=None, _2=None): if log.isEnabledFor(logging.DEBUG): from pprint import pformat - log.debug('loading project-specific settings:\n%s', pformat(ps.to_dict())) + + log.debug("loading project-specific settings:\n%s", pformat(ps.to_dict())) # Restore simple properties. update_preferences(prefs, PROJECT_SPECIFIC_SIMPLE_PROPS, ps) # Restore Flamenco settings. - prefs.flamenco_manager.available_managers = ps.get('flamenco_available_managers', []) - flamenco_manager_id = ps.get('flamenco_manager_id') + prefs.flamenco_manager.available_managers = ps.get( + "flamenco_available_managers", [] + ) + flamenco_manager_id = ps.get("flamenco_manager_id") if flamenco_manager_id: - log.debug('setting flamenco manager to %s', flamenco_manager_id) + log.debug("setting flamenco manager to %s", flamenco_manager_id) try: # This will trigger a load of Project+Manager-specfic settings. prefs.flamenco_manager.manager = flamenco_manager_id except TypeError: - log.warning('manager %s for this project could not be found', flamenco_manager_id) + log.warning( + "manager %s for this project could not be found", + flamenco_manager_id, + ) elif prefs.flamenco_manager.available_managers: - prefs.flamenco_manager.manager = prefs.flamenco_manager.available_managers[0]['_id'] + prefs.flamenco_manager.manager = prefs.flamenco_manager.available_managers[ + 0 + ]["_id"] def store(_=None, _2=None): @@ -133,31 +147,34 @@ def store(_=None, _2=None): prefs = preferences() project_id = prefs.project.project - all_settings = prefs.get('project_settings', {}) + all_settings = prefs.get("project_settings", {}) ps = all_settings.get(project_id, {}) # either a dict or bpy.types.IDPropertyGroup for name in PROJECT_SPECIFIC_SIMPLE_PROPS: ps[name] = getattr(prefs, name) # Store project-specific Flamenco settings - ps['flamenco_manager_id'] = prefs.flamenco_manager.manager - ps['flamenco_available_managers'] = prefs.flamenco_manager.available_managers + ps["flamenco_manager_id"] = prefs.flamenco_manager.manager + ps["flamenco_available_managers"] = prefs.flamenco_manager.available_managers # Store per-project, per-manager settings for the current Manager. - pppm = ps.get('flamenco_managers_settings', {}) + pppm = ps.get("flamenco_managers_settings", {}) pppm[prefs.flamenco_manager.manager] = { name: getattr(prefs, name) for name in FLAMENCO_PER_PROJECT_PER_MANAGER } - ps['flamenco_managers_settings'] = pppm # IDPropertyGroup has no setdefault() method. + ps[ + "flamenco_managers_settings" + ] = pppm # IDPropertyGroup has no setdefault() method. # Store this project's settings in the preferences. all_settings[project_id] = ps - prefs['project_settings'] = all_settings + prefs["project_settings"] = all_settings if log.isEnabledFor(logging.DEBUG): from pprint import pformat - if hasattr(all_settings, 'to_dict'): + + if hasattr(all_settings, "to_dict"): to_log = all_settings.to_dict() else: to_log = all_settings - log.debug('Saving project-specific settings:\n%s', pformat(to_log)) + log.debug("Saving project-specific settings:\n%s", pformat(to_log)) diff --git a/blender_cloud/settings_sync.py b/blender_cloud/settings_sync.py index b678da0..ba92e34 100644 --- a/blender_cloud/settings_sync.py +++ b/blender_cloud/settings_sync.py @@ -37,31 +37,33 @@ from pillarsdk import exceptions as sdk_exceptions from .pillar import pillar_call from . import async_loop, blender, compatibility, pillar, cache, blendfile, home_project -SETTINGS_FILES_TO_UPLOAD = ['userpref.blend', 'startup.blend'] +SETTINGS_FILES_TO_UPLOAD = ["userpref.blend", "startup.blend"] # These are RNA keys inside the userpref.blend file, and their # Python properties names. These settings will not be synced. LOCAL_SETTINGS_RNA = [ - (b'dpi', 'system.dpi'), - (b'virtual_pixel', 'system.virtual_pixel_mode'), - (b'compute_device_id', 'system.compute_device'), - (b'compute_device_type', 'system.compute_device_type'), - (b'fontdir', 'filepaths.font_directory'), - (b'textudir', 'filepaths.texture_directory'), - (b'renderdir', 'filepaths.render_output_directory'), - (b'pythondir', 'filepaths.script_directory'), - (b'sounddir', 'filepaths.sound_directory'), - (b'tempdir', 'filepaths.temporary_directory'), - (b'render_cachedir', 'filepaths.render_cache_directory'), - (b'i18ndir', 'filepaths.i18n_branches_directory'), - (b'image_editor', 'filepaths.image_editor'), - (b'anim_player', 'filepaths.animation_player'), + (b"dpi", "system.dpi"), + (b"virtual_pixel", "system.virtual_pixel_mode"), + (b"compute_device_id", "system.compute_device"), + (b"compute_device_type", "system.compute_device_type"), + (b"fontdir", "filepaths.font_directory"), + (b"textudir", "filepaths.texture_directory"), + (b"renderdir", "filepaths.render_output_directory"), + (b"pythondir", "filepaths.script_directory"), + (b"sounddir", "filepaths.sound_directory"), + (b"tempdir", "filepaths.temporary_directory"), + (b"render_cachedir", "filepaths.render_cache_directory"), + (b"i18ndir", "filepaths.i18n_branches_directory"), + (b"image_editor", "filepaths.image_editor"), + (b"anim_player", "filepaths.animation_player"), ] REQUIRES_ROLES_FOR_SYNC = set() # no roles needed. -SYNC_GROUP_NODE_NAME = 'Blender Sync' -SYNC_GROUP_NODE_DESC = 'The [Blender Cloud Addon](https://cloud.blender.org/services' \ - '#blender-addon) will synchronize your Blender settings here.' +SYNC_GROUP_NODE_NAME = "Blender Sync" +SYNC_GROUP_NODE_DESC = ( + "The [Blender Cloud Addon](https://cloud.blender.org/services" + "#blender-addon) will synchronize your Blender settings here." +) log = logging.getLogger(__name__) @@ -74,7 +76,7 @@ def set_blender_sync_status(set_status: str): try: return func(*args, **kwargs) finally: - bss.status = 'IDLE' + bss.status = "IDLE" return wrapper @@ -90,18 +92,16 @@ def async_set_blender_sync_status(set_status: str): try: return await func(*args, **kwargs) finally: - bss.status = 'IDLE' + bss.status = "IDLE" return wrapper return decorator -async def find_sync_group_id(home_project_id: str, - user_id: str, - blender_version: str, - *, - may_create=True) -> typing.Tuple[str, str]: +async def find_sync_group_id( + home_project_id: str, user_id: str, blender_version: str, *, may_create=True +) -> typing.Tuple[str, str]: """Finds the group node in which to store sync assets. If the group node doesn't exist and may_create=True, it creates it. @@ -111,43 +111,52 @@ async def find_sync_group_id(home_project_id: str, # created by Pillar while creating the home project. try: sync_group, created = await pillar.find_or_create_node( - where={'project': home_project_id, - 'node_type': 'group', - 'parent': None, - 'name': SYNC_GROUP_NODE_NAME, - 'user': user_id}, - projection={'_id': 1}, - may_create=False) + where={ + "project": home_project_id, + "node_type": "group", + "parent": None, + "name": SYNC_GROUP_NODE_NAME, + "user": user_id, + }, + projection={"_id": 1}, + may_create=False, + ) except pillar.PillarError: - raise pillar.PillarError('Unable to find sync folder on the Cloud') + raise pillar.PillarError("Unable to find sync folder on the Cloud") if not may_create and sync_group is None: log.info("Sync folder doesn't exist, and not creating it either.") - return '', '' + return "", "" # Find/create the sub-group for the requested Blender version try: sub_sync_group, created = await pillar.find_or_create_node( - where={'project': home_project_id, - 'node_type': 'group', - 'parent': sync_group['_id'], - 'name': blender_version, - 'user': user_id}, - additional_create_props={ - 'description': 'Sync folder for Blender %s' % blender_version, - 'properties': {'status': 'published'}, + where={ + "project": home_project_id, + "node_type": "group", + "parent": sync_group["_id"], + "name": blender_version, + "user": user_id, }, - projection={'_id': 1}, - may_create=may_create) + additional_create_props={ + "description": "Sync folder for Blender %s" % blender_version, + "properties": {"status": "published"}, + }, + projection={"_id": 1}, + may_create=may_create, + ) except pillar.PillarError: - raise pillar.PillarError('Unable to create sync folder on the Cloud') + raise pillar.PillarError("Unable to create sync folder on the Cloud") if not may_create and sub_sync_group is None: - log.info("Sync folder for Blender version %s doesn't exist, " - "and not creating it either.", blender_version) - return sync_group['_id'], '' + log.info( + "Sync folder for Blender version %s doesn't exist, " + "and not creating it either.", + blender_version, + ) + return sync_group["_id"], "" - return sync_group['_id'], sub_sync_group['_id'] + return sync_group["_id"], sub_sync_group["_id"] @functools.lru_cache() @@ -158,83 +167,95 @@ async def available_blender_versions(home_project_id: str, user_id: str) -> list sync_group = await pillar_call( pillarsdk.Node.find_first, params={ - 'where': {'project': home_project_id, - 'node_type': 'group', - 'parent': None, - 'name': SYNC_GROUP_NODE_NAME, - 'user': user_id}, - 'projection': {'_id': 1}, + "where": { + "project": home_project_id, + "node_type": "group", + "parent": None, + "name": SYNC_GROUP_NODE_NAME, + "user": user_id, + }, + "projection": {"_id": 1}, }, - caching=False) + caching=False, + ) if sync_group is None: - bss.report({'ERROR'}, 'No synced Blender settings in your Blender Cloud') - log.debug('-- unable to find sync group for home_project_id=%r and user_id=%r', - home_project_id, user_id) + bss.report({"ERROR"}, "No synced Blender settings in your Blender Cloud") + log.debug( + "-- unable to find sync group for home_project_id=%r and user_id=%r", + home_project_id, + user_id, + ) return [] sync_nodes = await pillar_call( pillarsdk.Node.all, params={ - 'where': {'project': home_project_id, - 'node_type': 'group', - 'parent': sync_group['_id'], - 'user': user_id}, - 'projection': {'_id': 1, 'name': 1}, - 'sort': '-name', + "where": { + "project": home_project_id, + "node_type": "group", + "parent": sync_group["_id"], + "user": user_id, + }, + "projection": {"_id": 1, "name": 1}, + "sort": "-name", }, - caching=False) + caching=False, + ) if not sync_nodes or not sync_nodes._items: - bss.report({'ERROR'}, 'No synced Blender settings in your Blender Cloud.') + bss.report({"ERROR"}, "No synced Blender settings in your Blender Cloud.") return [] versions = [node.name for node in sync_nodes._items] - log.debug('Versions: %s', versions) + log.debug("Versions: %s", versions) return versions # noinspection PyAttributeOutsideInit @compatibility.convert_properties -class PILLAR_OT_sync(pillar.PillarOperatorMixin, - async_loop.AsyncModalOperatorMixin, - bpy.types.Operator): - bl_idname = 'pillar.sync' - bl_label = 'Synchronise with Blender Cloud' - bl_description = 'Synchronises Blender settings with Blender Cloud' +class PILLAR_OT_sync( + pillar.PillarOperatorMixin, async_loop.AsyncModalOperatorMixin, bpy.types.Operator +): + bl_idname = "pillar.sync" + bl_label = "Synchronise with Blender Cloud" + bl_description = "Synchronises Blender settings with Blender Cloud" - log = logging.getLogger('bpy.ops.%s' % bl_idname) - home_project_id = '' - sync_group_id = '' # top-level sync group node ID - sync_group_versioned_id = '' # sync group node ID for the given Blender version. + log = logging.getLogger("bpy.ops.%s" % bl_idname) + home_project_id = "" + sync_group_id = "" # top-level sync group node ID + sync_group_versioned_id = "" # sync group node ID for the given Blender version. action = bpy.props.EnumProperty( items=[ - ('PUSH', 'Push', 'Push settings to the Blender Cloud'), - ('PULL', 'Pull', 'Pull settings from the Blender Cloud'), - ('REFRESH', 'Refresh', 'Refresh available versions'), - ('SELECT', 'Select', 'Select version to sync'), + ("PUSH", "Push", "Push settings to the Blender Cloud"), + ("PULL", "Pull", "Pull settings from the Blender Cloud"), + ("REFRESH", "Refresh", "Refresh available versions"), + ("SELECT", "Select", "Select version to sync"), ], - name='action') + name="action", + ) - CURRENT_BLENDER_VERSION = '%i.%i' % bpy.app.version[:2] - blender_version = bpy.props.StringProperty(name='blender_version', - description='Blender version to sync for', - default=CURRENT_BLENDER_VERSION) + CURRENT_BLENDER_VERSION = "%i.%i" % bpy.app.version[:2] + blender_version = bpy.props.StringProperty( + name="blender_version", + description="Blender version to sync for", + default=CURRENT_BLENDER_VERSION, + ) def bss_report(self, level, message): bss = bpy.context.window_manager.blender_sync_status bss.report(level, message) def invoke(self, context, event): - if self.action == 'SELECT': + if self.action == "SELECT": # Synchronous action return self.action_select(context) - if self.action in {'PUSH', 'PULL'} and not self.blender_version: - self.bss_report({'ERROR'}, 'No Blender version to sync for was given.') - return {'CANCELLED'} + if self.action in {"PUSH", "PULL"} and not self.blender_version: + self.bss_report({"ERROR"}, "No Blender version to sync for was given.") + return {"CANCELLED"} return async_loop.AsyncModalOperatorMixin.invoke(self, context, event) @@ -244,132 +265,146 @@ class PILLAR_OT_sync(pillar.PillarOperatorMixin, This is a synchronous action, as it requires a dialog box. """ - self.log.info('Performing action SELECT') + self.log.info("Performing action SELECT") # Do a refresh before we can show the dropdown. - fut = asyncio.ensure_future(self.async_execute(context, action_override='REFRESH')) + fut = asyncio.ensure_future( + self.async_execute(context, action_override="REFRESH") + ) loop = asyncio.get_event_loop() loop.run_until_complete(fut) - self._state = 'SELECTING' + self._state = "SELECTING" return context.window_manager.invoke_props_dialog(self) def draw(self, context): bss = bpy.context.window_manager.blender_sync_status - self.layout.prop(bss, 'version', text='Blender version') + self.layout.prop(bss, "version", text="Blender version") def execute(self, context): - if self.action != 'SELECT': - log.debug('Ignoring execute() for action %r', self.action) - return {'FINISHED'} + if self.action != "SELECT": + log.debug("Ignoring execute() for action %r", self.action) + return {"FINISHED"} - log.debug('Performing execute() for action %r', self.action) + log.debug("Performing execute() for action %r", self.action) # Perform the sync when the user closes the dialog box. bss = bpy.context.window_manager.blender_sync_status - bpy.ops.pillar.sync('INVOKE_DEFAULT', - action='PULL', - blender_version=bss.version) + bpy.ops.pillar.sync( + "INVOKE_DEFAULT", action="PULL", blender_version=bss.version + ) - return {'FINISHED'} + return {"FINISHED"} - @async_set_blender_sync_status('SYNCING') + @async_set_blender_sync_status("SYNCING") async def async_execute(self, context, *, action_override=None): """Entry point of the asynchronous operator.""" action = action_override or self.action - self.bss_report({'INFO'}, 'Communicating with Blender Cloud') - self.log.info('Performing action %s', action) + self.bss_report({"INFO"}, "Communicating with Blender Cloud") + self.log.info("Performing action %s", action) try: # Refresh credentials try: db_user = await self.check_credentials(context, REQUIRES_ROLES_FOR_SYNC) - self.user_id = db_user['_id'] - log.debug('Found user ID: %s', self.user_id) + self.user_id = db_user["_id"] + log.debug("Found user ID: %s", self.user_id) except pillar.NotSubscribedToCloudError as ex: self._log_subscription_needed(can_renew=ex.can_renew) - self._state = 'QUIT' + self._state = "QUIT" return except pillar.UserNotLoggedInError: - self.log.exception('Error checking/refreshing credentials.') - self.bss_report({'ERROR'}, 'Please log in on Blender ID first.') - self._state = 'QUIT' + self.log.exception("Error checking/refreshing credentials.") + self.bss_report({"ERROR"}, "Please log in on Blender ID first.") + self._state = "QUIT" return # Find the home project. try: self.home_project_id = await home_project.get_home_project_id() except sdk_exceptions.ForbiddenAccess: - self.log.exception('Forbidden access to home project.') - self.bss_report({'ERROR'}, 'Did not get access to home project.') - self._state = 'QUIT' + self.log.exception("Forbidden access to home project.") + self.bss_report({"ERROR"}, "Did not get access to home project.") + self._state = "QUIT" return except sdk_exceptions.ResourceNotFound: - self.bss_report({'ERROR'}, 'Home project not found.') - self._state = 'QUIT' + self.bss_report({"ERROR"}, "Home project not found.") + self._state = "QUIT" return # Only create the folder structure if we're pushing. - may_create = self.action == 'PUSH' + may_create = self.action == "PUSH" try: - gid, subgid = await find_sync_group_id(self.home_project_id, - self.user_id, - self.blender_version, - may_create=may_create) + gid, subgid = await find_sync_group_id( + self.home_project_id, + self.user_id, + self.blender_version, + may_create=may_create, + ) self.sync_group_id = gid self.sync_group_versioned_id = subgid - self.log.debug('Found top-level group node ID: %s', self.sync_group_id) - self.log.debug('Found group node ID for %s: %s', - self.blender_version, self.sync_group_versioned_id) + self.log.debug("Found top-level group node ID: %s", self.sync_group_id) + self.log.debug( + "Found group node ID for %s: %s", + self.blender_version, + self.sync_group_versioned_id, + ) except sdk_exceptions.ForbiddenAccess: - self.log.exception('Unable to find Group ID') - self.bss_report({'ERROR'}, 'Unable to find sync folder.') - self._state = 'QUIT' + self.log.exception("Unable to find Group ID") + self.bss_report({"ERROR"}, "Unable to find sync folder.") + self._state = "QUIT" return # Perform the requested action. action_method = { - 'PUSH': self.action_push, - 'PULL': self.action_pull, - 'REFRESH': self.action_refresh, + "PUSH": self.action_push, + "PULL": self.action_pull, + "REFRESH": self.action_refresh, }[action] await action_method(context) except Exception as ex: - self.log.exception('Unexpected exception caught.') - self.bss_report({'ERROR'}, 'Unexpected error: %s' % ex) + self.log.exception("Unexpected exception caught.") + self.bss_report({"ERROR"}, "Unexpected error: %s" % ex) - self._state = 'QUIT' + self._state = "QUIT" async def action_push(self, context): """Sends files to the Pillar server.""" - self.log.info('Saved user preferences to disk before pushing to cloud.') + self.log.info("Saved user preferences to disk before pushing to cloud.") bpy.ops.wm.save_userpref() - config_dir = pathlib.Path(bpy.utils.user_resource('CONFIG')) + config_dir = pathlib.Path(bpy.utils.user_resource("CONFIG")) for fname in SETTINGS_FILES_TO_UPLOAD: path = config_dir / fname if not path.exists(): - self.log.debug('Skipping non-existing %s', path) + self.log.debug("Skipping non-existing %s", path) continue if self.signalling_future.cancelled(): - self.bss_report({'WARNING'}, 'Upload aborted.') + self.bss_report({"WARNING"}, "Upload aborted.") return - self.bss_report({'INFO'}, 'Uploading %s' % fname) + self.bss_report({"INFO"}, "Uploading %s" % fname) try: - await pillar.attach_file_to_group(path, - self.home_project_id, - self.sync_group_versioned_id, - self.user_id) + await pillar.attach_file_to_group( + path, + self.home_project_id, + self.sync_group_versioned_id, + self.user_id, + ) except sdk_exceptions.RequestEntityTooLarge as ex: - self.log.error('File too big to upload: %s' % ex) - self.log.error('To upload larger files, please subscribe to Blender Cloud.') - self.bss_report({'SUBSCRIBE'}, 'File %s too big to upload. ' - 'Subscribe for unlimited space.' % fname) - self._state = 'QUIT' + self.log.error("File too big to upload: %s" % ex) + self.log.error( + "To upload larger files, please subscribe to Blender Cloud." + ) + self.bss_report( + {"SUBSCRIBE"}, + "File %s too big to upload. " + "Subscribe for unlimited space." % fname, + ) + self._state = "QUIT" return await self.action_refresh(context) @@ -383,31 +418,37 @@ class PILLAR_OT_sync(pillar.PillarOperatorMixin, else: bss.version = max(bss.available_blender_versions) - self.bss_report({'INFO'}, 'Settings pushed to Blender Cloud.') + self.bss_report({"INFO"}, "Settings pushed to Blender Cloud.") async def action_pull(self, context): """Loads files from the Pillar server.""" # If the sync group node doesn't exist, offer a list of groups that do. if not self.sync_group_id: - self.bss_report({'ERROR'}, - 'There are no synced Blender settings in your Blender Cloud.') + self.bss_report( + {"ERROR"}, "There are no synced Blender settings in your Blender Cloud." + ) return if not self.sync_group_versioned_id: - self.bss_report({'ERROR'}, 'Therre are no synced Blender settings for version %s' % - self.blender_version) + self.bss_report( + {"ERROR"}, + "Therre are no synced Blender settings for version %s" + % self.blender_version, + ) return - self.bss_report({'INFO'}, 'Pulling settings from Blender Cloud') - with tempfile.TemporaryDirectory(prefix='bcloud-sync') as tempdir: + self.bss_report({"INFO"}, "Pulling settings from Blender Cloud") + with tempfile.TemporaryDirectory(prefix="bcloud-sync") as tempdir: for fname in SETTINGS_FILES_TO_UPLOAD: await self.download_settings_file(fname, tempdir) - self.bss_report({'WARNING'}, 'Settings pulled from Cloud, restart Blender to load them.') + self.bss_report( + {"WARNING"}, "Settings pulled from Cloud, restart Blender to load them." + ) async def action_refresh(self, context): - self.bss_report({'INFO'}, 'Refreshing available Blender versions.') + self.bss_report({"INFO"}, "Refreshing available Blender versions.") # Clear the LRU cache of available_blender_versions so that we can # obtain new versions (if someone synced from somewhere else, for example) @@ -424,73 +465,82 @@ class PILLAR_OT_sync(pillar.PillarOperatorMixin, return # Prevent warnings that the current value of the EnumProperty isn't valid. - current_version = '%d.%d' % bpy.app.version[:2] + current_version = "%d.%d" % bpy.app.version[:2] if current_version in versions: bss.version = current_version else: bss.version = versions[0] - self.bss_report({'INFO'}, '') + self.bss_report({"INFO"}, "") async def download_settings_file(self, fname: str, temp_dir: str): - config_dir = pathlib.Path(bpy.utils.user_resource('CONFIG')) - meta_path = cache.cache_directory('home-project', 'blender-sync') + config_dir = pathlib.Path(bpy.utils.user_resource("CONFIG")) + meta_path = cache.cache_directory("home-project", "blender-sync") - self.bss_report({'INFO'}, 'Downloading %s from Cloud' % fname) + self.bss_report({"INFO"}, "Downloading %s from Cloud" % fname) # Get the asset node - node_props = {'project': self.home_project_id, - 'node_type': 'asset', - 'parent': self.sync_group_versioned_id, - 'name': fname} - node = await pillar_call(pillarsdk.Node.find_first, { - 'where': node_props, - 'projection': {'_id': 1, 'properties.file': 1} - }, caching=False) + node_props = { + "project": self.home_project_id, + "node_type": "asset", + "parent": self.sync_group_versioned_id, + "name": fname, + } + node = await pillar_call( + pillarsdk.Node.find_first, + {"where": node_props, "projection": {"_id": 1, "properties.file": 1}}, + caching=False, + ) if node is None: - self.bss_report({'INFO'}, 'Unable to find %s on Blender Cloud' % fname) - self.log.info('Unable to find node on Blender Cloud for %s', fname) + self.bss_report({"INFO"}, "Unable to find %s on Blender Cloud" % fname) + self.log.info("Unable to find node on Blender Cloud for %s", fname) return - async def file_downloaded(file_path: str, file_desc: pillarsdk.File, map_type: str): + async def file_downloaded( + file_path: str, file_desc: pillarsdk.File, map_type: str + ): # Allow the caller to adjust the file before we move it into place. - if fname.lower() == 'userpref.blend': + if fname.lower() == "userpref.blend": await self.update_userpref_blend(file_path) # Move the file next to the final location; as it may be on a # different filesystem than the temporary directory, this can # fail, and we don't want to destroy the existing file. - local_temp = config_dir / (fname + '~') + local_temp = config_dir / (fname + "~") local_final = config_dir / fname # Make a backup copy of the file as it was before pulling. if local_final.exists(): - local_bak = config_dir / (fname + '-pre-bcloud-pull') + local_bak = config_dir / (fname + "-pre-bcloud-pull") self.move_file(local_final, local_bak) self.move_file(file_path, local_temp) self.move_file(local_temp, local_final) file_id = node.properties.file - await pillar.download_file_by_uuid(file_id, - temp_dir, - str(meta_path), - file_loaded_sync=file_downloaded, - future=self.signalling_future) + await pillar.download_file_by_uuid( + file_id, + temp_dir, + str(meta_path), + file_loaded_sync=file_downloaded, + future=self.signalling_future, + ) def move_file(self, src, dst): - self.log.info('Moving %s to %s', src, dst) + self.log.info("Moving %s to %s", src, dst) shutil.move(str(src), str(dst)) async def update_userpref_blend(self, file_path: str): - self.log.info('Overriding machine-local settings in %s', file_path) + self.log.info("Overriding machine-local settings in %s", file_path) # Remember some settings that should not be overwritten from the Cloud. prefs = blender.ctx_preferences() remembered = {} for rna_key, python_key in LOCAL_SETTINGS_RNA: - assert '.' in python_key, 'Sorry, this code assumes there is a dot in the Python key' + assert ( + "." in python_key + ), "Sorry, this code assumes there is a dot in the Python key" try: value = prefs.path_resolve(python_key) @@ -500,28 +550,31 @@ class PILLAR_OT_sync(pillar.PillarOperatorMixin, continue # Map enums from strings (in Python) to ints (in DNA). - dot_index = python_key.rindex('.') - parent_key, prop_key = python_key[:dot_index], python_key[dot_index + 1:] + dot_index = python_key.rindex(".") + parent_key, prop_key = python_key[:dot_index], python_key[dot_index + 1 :] parent = prefs.path_resolve(parent_key) prop = parent.bl_rna.properties[prop_key] - if prop.type == 'ENUM': - log.debug('Rewriting %s from %r to %r', - python_key, value, prop.enum_items[value].value) + if prop.type == "ENUM": + log.debug( + "Rewriting %s from %r to %r", + python_key, + value, + prop.enum_items[value].value, + ) value = prop.enum_items[value].value else: - log.debug('Keeping value of %s: %r', python_key, value) + log.debug("Keeping value of %s: %r", python_key, value) remembered[rna_key] = value - log.debug('Overriding values: %s', remembered) + log.debug("Overriding values: %s", remembered) # Rewrite the userprefs.blend file to override the options. - with blendfile.open_blend(file_path, 'rb+') as blend: - prefs = next(block for block in blend.blocks - if block.code == b'USER') + with blendfile.open_blend(file_path, "rb+") as blend: + prefs = next(block for block in blend.blocks if block.code == b"USER") for key, value in remembered.items(): - self.log.debug('prefs[%r] = %r' % (key, prefs[key])) - self.log.debug(' -> setting prefs[%r] = %r' % (key, value)) + self.log.debug("prefs[%r] = %r" % (key, prefs[key])) + self.log.debug(" -> setting prefs[%r] = %r" % (key, value)) prefs[key] = value diff --git a/blender_cloud/texture_browser/__init__.py b/blender_cloud/texture_browser/__init__.py index 94994c3..f499970 100644 --- a/blender_cloud/texture_browser/__init__.py +++ b/blender_cloud/texture_browser/__init__.py @@ -27,7 +27,9 @@ import bgl import pillarsdk from .. import async_loop, compatibility, pillar, cache, blender, utils -from . import menu_item as menu_item_mod # so that we can have menu items called 'menu_item' +from . import ( + menu_item as menu_item_mod, +) # so that we can have menu items called 'menu_item' from . import nodes if bpy.app.version < (2, 80): @@ -35,7 +37,7 @@ if bpy.app.version < (2, 80): else: from . import draw -REQUIRED_ROLES_FOR_TEXTURE_BROWSER = {'subscriber', 'demo'} +REQUIRED_ROLES_FOR_TEXTURE_BROWSER = {"subscriber", "demo"} MOUSE_SCROLL_PIXELS_PER_TICK = 50 TARGET_ITEM_WIDTH = 400 @@ -47,16 +49,16 @@ ITEM_PADDING_X = 5 log = logging.getLogger(__name__) -class BlenderCloudBrowser(pillar.PillarOperatorMixin, - async_loop.AsyncModalOperatorMixin, - bpy.types.Operator): - bl_idname = 'pillar.browser' - bl_label = 'Blender Cloud Texture Browser' +class BlenderCloudBrowser( + pillar.PillarOperatorMixin, async_loop.AsyncModalOperatorMixin, bpy.types.Operator +): + bl_idname = "pillar.browser" + bl_label = "Blender Cloud Texture Browser" _draw_handle = None - current_path = pillar.CloudPath('/') - project_name = '' + current_path = pillar.CloudPath("/") + project_name = "" # This contains a stack of Node objects that lead up to the currently browsed node. path_stack = [] # type: typing.List[pillarsdk.Node] @@ -65,12 +67,12 @@ class BlenderCloudBrowser(pillar.PillarOperatorMixin, menu_item_stack = [] # type: typing.List[menu_item_mod.MenuItem] timer = None - log = logging.getLogger('%s.BlenderCloudBrowser' % __name__) + log = logging.getLogger("%s.BlenderCloudBrowser" % __name__) _menu_item_lock = threading.Lock() current_display_content = [] # type: typing.List[menu_item_mod.MenuItem] loaded_images = set() # type: typing.Set[str] - thumbnails_cache = '' + thumbnails_cache = "" maximized_area = False mouse_x = 0 @@ -84,16 +86,18 @@ class BlenderCloudBrowser(pillar.PillarOperatorMixin, # Refuse to start if the file hasn't been saved. It's okay if # it's dirty, we just need to know where '//' points to. if not os.path.exists(context.blend_data.filepath): - self.report({'ERROR'}, 'Please save your Blend file before using ' - 'the Blender Cloud addon.') - return {'CANCELLED'} + self.report( + {"ERROR"}, + "Please save your Blend file before using " "the Blender Cloud addon.", + ) + return {"CANCELLED"} wm = context.window_manager self.current_path = pillar.CloudPath(wm.last_blender_cloud_location) self.path_stack = [] # list of nodes that make up the current path. - self.thumbnails_cache = cache.cache_directory('thumbnails') + self.thumbnails_cache = cache.cache_directory("thumbnails") self.mouse_x = event.mouse_x self.mouse_y = event.mouse_y @@ -105,91 +109,93 @@ class BlenderCloudBrowser(pillar.PillarOperatorMixin, # Add the region OpenGL drawing callback # draw in view space with 'POST_VIEW' and 'PRE_VIEW' self._draw_handle = context.space_data.draw_handler_add( - self.draw_menu, (context,), 'WINDOW', 'POST_PIXEL') + self.draw_menu, (context,), "WINDOW", "POST_PIXEL" + ) self.current_display_content = [] self.loaded_images = set() self._scroll_reset() - context.window.cursor_modal_set('DEFAULT') + context.window.cursor_modal_set("DEFAULT") return async_loop.AsyncModalOperatorMixin.invoke(self, context, event) def modal(self, context, event): result = async_loop.AsyncModalOperatorMixin.modal(self, context, event) - if not {'PASS_THROUGH', 'RUNNING_MODAL'}.intersection(result): + if not {"PASS_THROUGH", "RUNNING_MODAL"}.intersection(result): return result - if event.type == 'TAB' and event.value == 'RELEASE': - self.log.info('Ensuring async loop is running') + if event.type == "TAB" and event.value == "RELEASE": + self.log.info("Ensuring async loop is running") async_loop.ensure_async_loop() - if event.type == 'TIMER': + if event.type == "TIMER": self._scroll_smooth() context.area.tag_redraw() - return {'RUNNING_MODAL'} + return {"RUNNING_MODAL"} - if 'MOUSE' in event.type: + if "MOUSE" in event.type: context.area.tag_redraw() self.mouse_x = event.mouse_x self.mouse_y = event.mouse_y - left_mouse_release = event.type == 'LEFTMOUSE' and event.value == 'RELEASE' - if left_mouse_release and self._state in {'PLEASE_SUBSCRIBE', 'PLEASE_RENEW'}: - self.open_browser_subscribe(renew=self._state == 'PLEASE_RENEW') + left_mouse_release = event.type == "LEFTMOUSE" and event.value == "RELEASE" + if left_mouse_release and self._state in {"PLEASE_SUBSCRIBE", "PLEASE_RENEW"}: + self.open_browser_subscribe(renew=self._state == "PLEASE_RENEW") self._finish(context) - return {'FINISHED'} + return {"FINISHED"} - if self._state == 'BROWSING': + if self._state == "BROWSING": selected = self.get_clicked() if selected: if selected.is_spinning: - context.window.cursor_set('WAIT') + context.window.cursor_set("WAIT") else: - context.window.cursor_set('HAND') + context.window.cursor_set("HAND") else: - context.window.cursor_set('DEFAULT') + context.window.cursor_set("DEFAULT") # Scrolling - if event.type == 'WHEELUPMOUSE': + if event.type == "WHEELUPMOUSE": self._scroll_by(MOUSE_SCROLL_PIXELS_PER_TICK) context.area.tag_redraw() - elif event.type == 'WHEELDOWNMOUSE': + elif event.type == "WHEELDOWNMOUSE": self._scroll_by(-MOUSE_SCROLL_PIXELS_PER_TICK) context.area.tag_redraw() - elif event.type == 'TRACKPADPAN': - self._scroll_by(event.mouse_prev_y - event.mouse_y, - smooth=False) + elif event.type == "TRACKPADPAN": + self._scroll_by(event.mouse_prev_y - event.mouse_y, smooth=False) context.area.tag_redraw() if left_mouse_release: if selected is None: # No item clicked, ignore it. - return {'RUNNING_MODAL'} + return {"RUNNING_MODAL"} if selected.is_spinning: # This can happen when the thumbnail information isn't loaded yet. - return {'RUNNING_MODAL'} + return {"RUNNING_MODAL"} if selected.is_folder: self.descend_node(selected) else: self.handle_item_selection(context, selected) - if event.type in {'RIGHTMOUSE', 'ESC'}: + if event.type in {"RIGHTMOUSE", "ESC"}: self._finish(context) - return {'CANCELLED'} + return {"CANCELLED"} - return {'RUNNING_MODAL'} + return {"RUNNING_MODAL"} async def async_execute(self, context): - self._state = 'CHECKING_CREDENTIALS' - self.log.debug('Checking credentials') + self._state = "CHECKING_CREDENTIALS" + self.log.debug("Checking credentials") try: - db_user = await self.check_credentials(context, REQUIRED_ROLES_FOR_TEXTURE_BROWSER) + db_user = await self.check_credentials( + context, REQUIRED_ROLES_FOR_TEXTURE_BROWSER + ) except pillar.NotSubscribedToCloudError as ex: - self._log_subscription_needed(can_renew=ex.can_renew, level='INFO') + self._log_subscription_needed(can_renew=ex.can_renew, level="INFO") self._show_subscribe_screen(can_renew=ex.can_renew) return None @@ -202,11 +208,11 @@ class BlenderCloudBrowser(pillar.PillarOperatorMixin, """Shows the "You need to subscribe" screen.""" if can_renew: - self._state = 'PLEASE_RENEW' + self._state = "PLEASE_RENEW" else: - self._state = 'PLEASE_SUBSCRIBE' + self._state = "PLEASE_SUBSCRIBE" - bpy.context.window.cursor_set('HAND') + bpy.context.window.cursor_set("HAND") def descend_node(self, menu_item: menu_item_mod.MenuItem): """Descends the node hierarchy by visiting this menu item's node. @@ -215,25 +221,25 @@ class BlenderCloudBrowser(pillar.PillarOperatorMixin, """ node = menu_item.node - assert isinstance(node, pillarsdk.Node), 'Wrong type %s' % node + assert isinstance(node, pillarsdk.Node), "Wrong type %s" % node if isinstance(node, nodes.UpNode): # Going up. - self.log.debug('Going up to %r', self.current_path) + self.log.debug("Going up to %r", self.current_path) self.current_path = self.current_path.parent if self.path_stack: self.path_stack.pop() if self.menu_item_stack: self.menu_item_stack.pop() if not self.path_stack: - self.project_name = '' + self.project_name = "" else: # Going down, keep track of where we were if isinstance(node, nodes.ProjectNode): - self.project_name = node['name'] + self.project_name = node["name"] - self.current_path /= node['_id'] - self.log.debug('Going down to %r', self.current_path) + self.current_path /= node["_id"] + self.log.debug("Going down to %r", self.current_path) self.path_stack.append(node) self.menu_item_stack.append(menu_item) @@ -246,18 +252,18 @@ class BlenderCloudBrowser(pillar.PillarOperatorMixin, return self.path_stack[-1] def _finish(self, context): - self.log.debug('Finishing the modal operator') + self.log.debug("Finishing the modal operator") async_loop.AsyncModalOperatorMixin._finish(self, context) self.clear_images() - context.space_data.draw_handler_remove(self._draw_handle, 'WINDOW') + context.space_data.draw_handler_remove(self._draw_handle, "WINDOW") context.window.cursor_modal_restore() if self.maximized_area: bpy.ops.screen.screen_full_area(use_hide_panels=True) context.area.tag_redraw() - self.log.debug('Modal operator finished') + self.log.debug("Modal operator finished") def clear_images(self): """Removes all images we loaded from Blender's memory.""" @@ -286,7 +292,7 @@ class BlenderCloudBrowser(pillar.PillarOperatorMixin, return menu_item def update_menu_item(self, node, *args): - node_uuid = node['_id'] + node_uuid = node["_id"] # Just make this thread-safe to be on the safe side. with self._menu_item_lock: @@ -296,7 +302,7 @@ class BlenderCloudBrowser(pillar.PillarOperatorMixin, self.loaded_images.add(menu_item.icon.filepath_raw) break else: - raise ValueError('Unable to find MenuItem(node_uuid=%r)' % node_uuid) + raise ValueError("Unable to find MenuItem(node_uuid=%r)" % node_uuid) self.sort_menu() @@ -310,11 +316,11 @@ class BlenderCloudBrowser(pillar.PillarOperatorMixin, self.current_display_content.sort(key=menu_item_mod.MenuItem.sort_key) async def async_download_previews(self): - self._state = 'BROWSING' + self._state = "BROWSING" thumbnails_directory = self.thumbnails_cache - self.log.info('Asynchronously downloading previews to %r', thumbnails_directory) - self.log.info('Current BCloud path is %r', self.current_path) + self.log.info("Asynchronously downloading previews to %r", thumbnails_directory) + self.log.info("Current BCloud path is %r", self.current_path) self.clear_images() self._scroll_reset() @@ -323,34 +329,41 @@ class BlenderCloudBrowser(pillar.PillarOperatorMixin, if node_uuid: # Query for sub-nodes of this node. - self.log.debug('Getting subnodes for parent node %r', node_uuid) - children = await pillar.get_nodes(parent_node_uuid=node_uuid, - node_type={'group_texture', 'group_hdri'}) + self.log.debug("Getting subnodes for parent node %r", node_uuid) + children = await pillar.get_nodes( + parent_node_uuid=node_uuid, node_type={"group_texture", "group_hdri"} + ) elif project_uuid: # Query for top-level nodes. - self.log.debug('Getting subnodes for project node %r', project_uuid) - children = await pillar.get_nodes(project_uuid=project_uuid, - parent_node_uuid='', - node_type={'group_texture', 'group_hdri'}) + self.log.debug("Getting subnodes for project node %r", project_uuid) + children = await pillar.get_nodes( + project_uuid=project_uuid, + parent_node_uuid="", + node_type={"group_texture", "group_hdri"}, + ) else: # Query for projects - self.log.debug('No node UUID and no project UUID, listing available projects') + self.log.debug( + "No node UUID and no project UUID, listing available projects" + ) children = await pillar.get_texture_projects() for proj_dict in children: - self.add_menu_item(nodes.ProjectNode(proj_dict), None, 'FOLDER', proj_dict['name']) + self.add_menu_item( + nodes.ProjectNode(proj_dict), None, "FOLDER", proj_dict["name"] + ) return # Make sure we can go up again. - self.add_menu_item(nodes.UpNode(), None, 'FOLDER', '.. up ..') + self.add_menu_item(nodes.UpNode(), None, "FOLDER", ".. up ..") # Download all child nodes - self.log.debug('Iterating over child nodes of %r', self.current_path) + self.log.debug("Iterating over child nodes of %r", self.current_path) for child in children: # print(' - %(_id)s = %(name)s' % child) - if child['node_type'] not in menu_item_mod.MenuItem.SUPPORTED_NODE_TYPES: - self.log.debug('Skipping node of type %r', child['node_type']) + if child["node_type"] not in menu_item_mod.MenuItem.SUPPORTED_NODE_TYPES: + self.log.debug("Skipping node of type %r", child["node_type"]) continue - self.add_menu_item(child, None, 'FOLDER', child['name']) + self.add_menu_item(child, None, "FOLDER", child["name"]) # There are only sub-nodes at the project level, no texture nodes, # so we won't have to bother looking for textures. @@ -360,22 +373,26 @@ class BlenderCloudBrowser(pillar.PillarOperatorMixin, directory = os.path.join(thumbnails_directory, project_uuid, node_uuid) os.makedirs(directory, exist_ok=True) - self.log.debug('Fetching texture thumbnails for node %r', node_uuid) + self.log.debug("Fetching texture thumbnails for node %r", node_uuid) def thumbnail_loading(node, texture_node): - self.add_menu_item(node, None, 'SPINNER', texture_node['name']) + self.add_menu_item(node, None, "SPINNER", texture_node["name"]) def thumbnail_loaded(node, file_desc, thumb_path): - self.log.debug('Node %s thumbnail loaded', node['_id']) + self.log.debug("Node %s thumbnail loaded", node["_id"]) self.update_menu_item(node, file_desc, thumb_path) - await pillar.fetch_texture_thumbs(node_uuid, 's', directory, - thumbnail_loading=thumbnail_loading, - thumbnail_loaded=thumbnail_loaded, - future=self.signalling_future) + await pillar.fetch_texture_thumbs( + node_uuid, + "s", + directory, + thumbnail_loading=thumbnail_loading, + thumbnail_loaded=thumbnail_loaded, + future=self.signalling_future, + ) def browse_assets(self): - self.log.debug('Browsing assets at %r', self.current_path) + self.log.debug("Browsing assets at %r", self.current_path) bpy.context.window_manager.last_blender_cloud_location = str(self.current_path) self._new_async_task(self.async_download_previews()) @@ -383,13 +400,13 @@ class BlenderCloudBrowser(pillar.PillarOperatorMixin, """Draws the GUI with OpenGL.""" drawers = { - 'INITIALIZING': self._draw_initializing, - 'CHECKING_CREDENTIALS': self._draw_checking_credentials, - 'BROWSING': self._draw_browser, - 'DOWNLOADING_TEXTURE': self._draw_downloading, - 'EXCEPTION': self._draw_exception, - 'PLEASE_SUBSCRIBE': self._draw_subscribe, - 'PLEASE_RENEW': self._draw_renew, + "INITIALIZING": self._draw_initializing, + "CHECKING_CREDENTIALS": self._draw_checking_credentials, + "BROWSING": self._draw_browser, + "DOWNLOADING_TEXTURE": self._draw_downloading, + "EXCEPTION": self._draw_exception, + "PLEASE_SUBSCRIBE": self._draw_subscribe, + "PLEASE_RENEW": self._draw_renew, } if self._state in drawers: @@ -397,15 +414,18 @@ class BlenderCloudBrowser(pillar.PillarOperatorMixin, drawer(context) # For debugging: draw the state - draw.text((5, 5), - '%s %s' % (self._state, self.project_name), - rgba=(1.0, 1.0, 1.0, 1.0), fsize=12) + draw.text( + (5, 5), + "%s %s" % (self._state, self.project_name), + rgba=(1.0, 1.0, 1.0, 1.0), + fsize=12, + ) @staticmethod def _window_region(context): - window_regions = [region - for region in context.area.regions - if region.type == 'WINDOW'] + window_regions = [ + region for region in context.area.regions if region.type == "WINDOW" + ] return window_regions[0] def _draw_browser(self, context): @@ -413,8 +433,9 @@ class BlenderCloudBrowser(pillar.PillarOperatorMixin, from . import draw if not self.current_display_content: - self._draw_text_on_colour(context, "Communicating with Blender Cloud", - (0.0, 0.0, 0.0, 0.6)) + self._draw_text_on_colour( + context, "Communicating with Blender Cloud", (0.0, 0.0, 0.0, 0.6) + ) return window_region = self._window_region(context) @@ -433,13 +454,16 @@ class BlenderCloudBrowser(pillar.PillarOperatorMixin, block_height = item_height + ITEM_MARGIN_Y bgl.glEnable(bgl.GL_BLEND) - draw.aabox((0, 0), (window_region.width, window_region.height), - (0.0, 0.0, 0.0, 0.6)) + draw.aabox( + (0, 0), (window_region.width, window_region.height), (0.0, 0.0, 0.0, 0.6) + ) - bottom_y = float('inf') + bottom_y = float("inf") # The -1 / +2 are for extra rows that are drawn only half at the top/bottom. - first_item_idx = max(0, int(-self.scroll_offset // block_height - 1) * col_count) + first_item_idx = max( + 0, int(-self.scroll_offset // block_height - 1) * col_count + ) items_per_page = int(content_height // item_height + 2) * col_count last_item_idx = first_item_idx + items_per_page @@ -455,32 +479,30 @@ class BlenderCloudBrowser(pillar.PillarOperatorMixin, bottom_y = min(y, bottom_y) self.scroll_offset_space_left = window_region.height - bottom_y - self.scroll_offset_max = (self.scroll_offset - - self.scroll_offset_space_left + - 0.25 * block_height) + self.scroll_offset_max = ( + self.scroll_offset - self.scroll_offset_space_left + 0.25 * block_height + ) bgl.glDisable(bgl.GL_BLEND) def _draw_downloading(self, context): """OpenGL drawing code for the DOWNLOADING_TEXTURE state.""" - self._draw_text_on_colour(context, - 'Downloading texture from Blender Cloud', - (0.0, 0.0, 0.2, 0.6)) + self._draw_text_on_colour( + context, "Downloading texture from Blender Cloud", (0.0, 0.0, 0.2, 0.6) + ) def _draw_checking_credentials(self, context): """OpenGL drawing code for the CHECKING_CREDENTIALS state.""" - self._draw_text_on_colour(context, - 'Checking login credentials', - (0.0, 0.0, 0.2, 0.6)) + self._draw_text_on_colour( + context, "Checking login credentials", (0.0, 0.0, 0.2, 0.6) + ) def _draw_initializing(self, context): """OpenGL drawing code for the INITIALIZING state.""" - self._draw_text_on_colour(context, - 'Initializing', - (0.0, 0.0, 0.2, 0.6)) + self._draw_text_on_colour(context, "Initializing", (0.0, 0.0, 0.2, 0.6)) def _draw_text_on_colour(self, context, text: str, bgcolour): content_height, content_width = self._window_size(context) @@ -488,8 +510,9 @@ class BlenderCloudBrowser(pillar.PillarOperatorMixin, bgl.glEnable(bgl.GL_BLEND) draw.aabox((0, 0), (content_width, content_height), bgcolour) - draw.text((content_width * 0.5, content_height * 0.7), - text, fsize=20, align='C') + draw.text( + (content_width * 0.5, content_height * 0.7), text, fsize=20, align="C" + ) bgl.glDisable(bgl.GL_BLEND) @@ -511,8 +534,10 @@ class BlenderCloudBrowser(pillar.PillarOperatorMixin, ex = self.async_task.exception() if isinstance(ex, pillar.UserNotLoggedInError): - ex_msg = 'You are not logged in on Blender ID. Please log in at User Preferences, ' \ - 'Add-ons, Blender ID Authentication.' + ex_msg = ( + "You are not logged in on Blender ID. Please log in at User Preferences, " + "Add-ons, Blender ID Authentication." + ) else: ex_msg = str(ex) if not ex_msg: @@ -525,14 +550,16 @@ class BlenderCloudBrowser(pillar.PillarOperatorMixin, bgl.glDisable(bgl.GL_BLEND) def _draw_subscribe(self, context): - self._draw_text_on_colour(context, - 'Click to subscribe to the Blender Cloud', - (0.0, 0.0, 0.2, 0.6)) + self._draw_text_on_colour( + context, "Click to subscribe to the Blender Cloud", (0.0, 0.0, 0.2, 0.6) + ) def _draw_renew(self, context): - self._draw_text_on_colour(context, - 'Click to renew your Blender Cloud subscription', - (0.0, 0.0, 0.2, 0.6)) + self._draw_text_on_colour( + context, + "Click to renew your Blender Cloud subscription", + (0.0, 0.0, 0.2, 0.6), + ) def get_clicked(self) -> typing.Optional[menu_item_mod.MenuItem]: @@ -548,78 +575,89 @@ class BlenderCloudBrowser(pillar.PillarOperatorMixin, from pillarsdk.utils import sanitize_filename self.clear_images() - self._state = 'DOWNLOADING_TEXTURE' + self._state = "DOWNLOADING_TEXTURE" - node_path_components = (node['name'] for node in self.path_stack if node is not None) - local_path_components = [sanitize_filename(comp) for comp in node_path_components] + node_path_components = ( + node["name"] for node in self.path_stack if node is not None + ) + local_path_components = [ + sanitize_filename(comp) for comp in node_path_components + ] top_texture_directory = bpy.path.abspath(context.scene.local_texture_dir) local_path = os.path.join(top_texture_directory, *local_path_components) - meta_path = os.path.join(top_texture_directory, '.blender_cloud') + meta_path = os.path.join(top_texture_directory, ".blender_cloud") - self.log.info('Downloading texture %r to %s', item.node_uuid, local_path) - self.log.debug('Metadata will be stored at %s', meta_path) + self.log.info("Downloading texture %r to %s", item.node_uuid, local_path) + self.log.debug("Metadata will be stored at %s", meta_path) file_paths = [] select_dblock = None node = item.node def texture_downloading(file_path, *_): - self.log.info('Texture downloading to %s', file_path) + self.log.info("Texture downloading to %s", file_path) def texture_downloaded(file_path, file_desc, map_type): nonlocal select_dblock - self.log.info('Texture downloaded to %r.', file_path) + self.log.info("Texture downloaded to %r.", file_path) - if context.scene.local_texture_dir.startswith('//'): + if context.scene.local_texture_dir.startswith("//"): file_path = bpy.path.relpath(file_path) image_dblock = bpy.data.images.load(filepath=file_path) - image_dblock['bcloud_file_uuid'] = file_desc['_id'] - image_dblock['bcloud_node_uuid'] = node['_id'] - image_dblock['bcloud_node_type'] = node['node_type'] - image_dblock['bcloud_node'] = pillar.node_to_id(node) + image_dblock["bcloud_file_uuid"] = file_desc["_id"] + image_dblock["bcloud_node_uuid"] = node["_id"] + image_dblock["bcloud_node_type"] = node["node_type"] + image_dblock["bcloud_node"] = pillar.node_to_id(node) - if node['node_type'] == 'hdri': + if node["node_type"] == "hdri": # All HDRi variations should use the same image datablock, hence once name. - image_dblock.name = node['name'] + image_dblock.name = node["name"] else: # All texture variations are loaded at once, and thus need the map type in the name. - image_dblock.name = '%s-%s' % (node['name'], map_type) + image_dblock.name = "%s-%s" % (node["name"], map_type) # Select the image in the image editor (if the context is right). # Just set the first image we download, - if context.area.type == 'IMAGE_EDITOR': - if select_dblock is None or file_desc.map_type == 'color': + if context.area.type == "IMAGE_EDITOR": + if select_dblock is None or file_desc.map_type == "color": select_dblock = image_dblock context.space_data.image = select_dblock file_paths.append(file_path) def texture_download_completed(_): - self.log.info('Texture download complete, inspect:\n%s', '\n'.join(file_paths)) - self._state = 'QUIT' + self.log.info( + "Texture download complete, inspect:\n%s", "\n".join(file_paths) + ) + self._state = "QUIT" # For HDRi nodes: only download the first file. download_node = pillarsdk.Node.new(node) - if node['node_type'] == 'hdri': + if node["node_type"] == "hdri": download_node.properties.files = [download_node.properties.files[0]] signalling_future = asyncio.Future() - self._new_async_task(pillar.download_texture(download_node, local_path, - metadata_directory=meta_path, - texture_loading=texture_downloading, - texture_loaded=texture_downloaded, - future=signalling_future)) + self._new_async_task( + pillar.download_texture( + download_node, + local_path, + metadata_directory=meta_path, + texture_loading=texture_downloading, + texture_loaded=texture_downloaded, + future=signalling_future, + ) + ) self.async_task.add_done_callback(texture_download_completed) def open_browser_subscribe(self, *, renew: bool): import webbrowser - url = 'renew' if renew else 'join' - webbrowser.open_new_tab('https://cloud.blender.org/%s' % url) - self.report({'INFO'}, 'We just started a browser for you.') + url = "renew" if renew else "join" + webbrowser.open_new_tab("https://cloud.blender.org/%s" % url) + self.report({"INFO"}, "We just started a browser for you.") def _scroll_smooth(self): diff = self.scroll_offset_target - self.scroll_offset @@ -637,9 +675,9 @@ class BlenderCloudBrowser(pillar.PillarOperatorMixin, if smooth and amount < 0 and -amount > self.scroll_offset_space_left / 4: amount = -self.scroll_offset_space_left / 4 - self.scroll_offset_target = min(0, - max(self.scroll_offset_max, - self.scroll_offset_target + amount)) + self.scroll_offset_target = min( + 0, max(self.scroll_offset_max, self.scroll_offset_target + amount) + ) if not smooth: self._scroll_offset = self.scroll_offset_target @@ -649,39 +687,44 @@ class BlenderCloudBrowser(pillar.PillarOperatorMixin, @compatibility.convert_properties -class PILLAR_OT_switch_hdri(pillar.PillarOperatorMixin, - async_loop.AsyncModalOperatorMixin, - bpy.types.Operator): - bl_idname = 'pillar.switch_hdri' - bl_label = 'Switch with another variation' - bl_description = 'Downloads the selected variation of an HDRi, ' \ - 'replacing the current image' +class PILLAR_OT_switch_hdri( + pillar.PillarOperatorMixin, async_loop.AsyncModalOperatorMixin, bpy.types.Operator +): + bl_idname = "pillar.switch_hdri" + bl_label = "Switch with another variation" + bl_description = ( + "Downloads the selected variation of an HDRi, " "replacing the current image" + ) - log = logging.getLogger('bpy.ops.%s' % bl_idname) + log = logging.getLogger("bpy.ops.%s" % bl_idname) - image_name = bpy.props.StringProperty(name='image_name', - description='Name of the image block to replace') + image_name = bpy.props.StringProperty( + name="image_name", description="Name of the image block to replace" + ) - file_uuid = bpy.props.StringProperty(name='file_uuid', - description='File ID to download') + file_uuid = bpy.props.StringProperty( + name="file_uuid", description="File ID to download" + ) async def async_execute(self, context): """Entry point of the asynchronous operator.""" - self.report({'INFO'}, 'Communicating with Blender Cloud') + self.report({"INFO"}, "Communicating with Blender Cloud") try: try: - db_user = await self.check_credentials(context, REQUIRED_ROLES_FOR_TEXTURE_BROWSER) - user_id = db_user['_id'] + db_user = await self.check_credentials( + context, REQUIRED_ROLES_FOR_TEXTURE_BROWSER + ) + user_id = db_user["_id"] except pillar.NotSubscribedToCloudError as ex: self._log_subscription_needed(can_renew=ex.can_renew) - self._state = 'QUIT' + self._state = "QUIT" return except pillar.UserNotLoggedInError: - self.log.exception('Error checking/refreshing credentials.') - self.report({'ERROR'}, 'Please log in on Blender ID first.') - self._state = 'QUIT' + self.log.exception("Error checking/refreshing credentials.") + self.report({"ERROR"}, "Please log in on Blender ID first.") + self._state = "QUIT" return if not user_id: @@ -689,57 +732,67 @@ class PILLAR_OT_switch_hdri(pillar.PillarOperatorMixin, await self.download_and_replace(context) except Exception as ex: - self.log.exception('Unexpected exception caught.') - self.report({'ERROR'}, 'Unexpected error %s: %s' % (type(ex), ex)) + self.log.exception("Unexpected exception caught.") + self.report({"ERROR"}, "Unexpected error %s: %s" % (type(ex), ex)) - self._state = 'QUIT' + self._state = "QUIT" async def download_and_replace(self, context): - self._state = 'DOWNLOADING_TEXTURE' + self._state = "DOWNLOADING_TEXTURE" current_image = bpy.data.images[self.image_name] - node = current_image['bcloud_node'] - filename = '%s.taken_from_file' % pillar.sanitize_filename(node['name']) + node = current_image["bcloud_node"] + filename = "%s.taken_from_file" % pillar.sanitize_filename(node["name"]) local_path = os.path.dirname(bpy.path.abspath(current_image.filepath)) top_texture_directory = bpy.path.abspath(context.scene.local_texture_dir) - meta_path = os.path.join(top_texture_directory, '.blender_cloud') + meta_path = os.path.join(top_texture_directory, ".blender_cloud") file_uuid = self.file_uuid - resolution = next(file_ref['resolution'] for file_ref in node['properties']['files'] - if file_ref['file'] == file_uuid) + resolution = next( + file_ref["resolution"] + for file_ref in node["properties"]["files"] + if file_ref["file"] == file_uuid + ) my_log = self.log - my_log.info('Downloading file %r-%s to %s', file_uuid, resolution, local_path) - my_log.debug('Metadata will be stored at %s', meta_path) + my_log.info("Downloading file %r-%s to %s", file_uuid, resolution, local_path) + my_log.debug("Metadata will be stored at %s", meta_path) def file_loading(file_path, file_desc, map_type): - my_log.info('Texture downloading to %s (%s)', - file_path, utils.sizeof_fmt(file_desc['length'])) + my_log.info( + "Texture downloading to %s (%s)", + file_path, + utils.sizeof_fmt(file_desc["length"]), + ) async def file_loaded(file_path, file_desc, map_type): - if context.scene.local_texture_dir.startswith('//'): + if context.scene.local_texture_dir.startswith("//"): file_path = bpy.path.relpath(file_path) - my_log.info('Texture downloaded to %s', file_path) - current_image['bcloud_file_uuid'] = file_uuid - current_image.filepath = file_path # This automatically reloads the image from disk. + my_log.info("Texture downloaded to %s", file_path) + current_image["bcloud_file_uuid"] = file_uuid + current_image.filepath = ( + file_path # This automatically reloads the image from disk. + ) # This forces users of the image to update. for datablocks in bpy.data.user_map({current_image}).values(): for datablock in datablocks: datablock.update_tag() - await pillar.download_file_by_uuid(file_uuid, - local_path, - meta_path, - filename=filename, - map_type=resolution, - file_loading=file_loading, - file_loaded_sync=file_loaded, - future=self.signalling_future) + await pillar.download_file_by_uuid( + file_uuid, + local_path, + meta_path, + filename=filename, + map_type=resolution, + file_loading=file_loading, + file_loaded_sync=file_loaded, + future=self.signalling_future, + ) - self.report({'INFO'}, 'Image download complete') + self.report({"INFO"}, "Image download complete") # store keymaps here to access after registration @@ -747,9 +800,11 @@ addon_keymaps = [] def image_editor_menu(self, context): - self.layout.operator(BlenderCloudBrowser.bl_idname, - text='Get image from Blender Cloud', - icon_value=blender.icon('CLOUD')) + self.layout.operator( + BlenderCloudBrowser.bl_idname, + text="Get image from Blender Cloud", + icon_value=blender.icon("CLOUD"), + ) def hdri_download_panel__image_editor(self, context): @@ -757,7 +812,7 @@ def hdri_download_panel__image_editor(self, context): def hdri_download_panel__node_editor(self, context): - if context.active_node.type not in {'TEX_ENVIRONMENT', 'TEX_IMAGE'}: + if context.active_node.type not in {"TEX_ENVIRONMENT", "TEX_IMAGE"}: return _hdri_download_panel(self, context.active_node.image) @@ -766,25 +821,27 @@ def hdri_download_panel__node_editor(self, context): def _hdri_download_panel(self, current_image): if not current_image: return - if 'bcloud_node_type' not in current_image: + if "bcloud_node_type" not in current_image: return - if current_image['bcloud_node_type'] != 'hdri': + if current_image["bcloud_node_type"] != "hdri": return try: - current_variation = current_image['bcloud_file_uuid'] + current_variation = current_image["bcloud_file_uuid"] except KeyError: - log.warning('Image %r has a bcloud_node_type but no bcloud_file_uuid property.', - current_image.name) + log.warning( + "Image %r has a bcloud_node_type but no bcloud_file_uuid property.", + current_image.name, + ) return row = self.layout.row(align=True).split(**compatibility.factor(0.3)) - row.label(text='HDRi', icon_value=blender.icon('CLOUD')) - row.prop(current_image, 'hdri_variation', text='') + row.label(text="HDRi", icon_value=blender.icon("CLOUD")) + row.prop(current_image, "hdri_variation", text="") if current_image.hdri_variation != current_variation: - props = row.operator(PILLAR_OT_switch_hdri.bl_idname, - text='Replace', - icon='FILE_REFRESH') + props = row.operator( + PILLAR_OT_switch_hdri.bl_idname, text="Replace", icon="FILE_REFRESH" + ) props.image_name = current_image.name props.file_uuid = current_image.hdri_variation @@ -795,21 +852,21 @@ variation_label_storage = {} def hdri_variation_choices(self, context): - if context.area.type == 'IMAGE_EDITOR': + if context.area.type == "IMAGE_EDITOR": image = context.edit_image - elif context.area.type == 'NODE_EDITOR': + elif context.area.type == "NODE_EDITOR": image = context.active_node.image else: return [] - if 'bcloud_node' not in image: + if "bcloud_node" not in image: return [] choices = [] - for file_doc in image['bcloud_node']['properties']['files']: - label = file_doc['resolution'] + for file_doc in image["bcloud_node"]["properties"]["files"]: + label = file_doc["resolution"] variation_label_storage[label] = label - choices.append((file_doc['file'], label, '')) + choices.append((file_doc["file"], label, "")) return choices @@ -824,20 +881,22 @@ def register(): # HDRi resolution switcher/chooser. # TODO: when an image is selected, switch this property to its current resolution. bpy.types.Image.hdri_variation = bpy.props.EnumProperty( - name='HDRi variations', + name="HDRi variations", items=hdri_variation_choices, - description='Select a variation with which to replace this image' + description="Select a variation with which to replace this image", ) # handle the keymap wm = bpy.context.window_manager kc = wm.keyconfigs.addon if not kc: - print('No addon key configuration space found, so no custom hotkeys added.') + print("No addon key configuration space found, so no custom hotkeys added.") return - km = kc.keymaps.new(name='Screen') - kmi = km.keymap_items.new('pillar.browser', 'A', 'PRESS', ctrl=True, shift=True, alt=True) + km = kc.keymaps.new(name="Screen") + kmi = km.keymap_items.new( + "pillar.browser", "A", "PRESS", ctrl=True, shift=True, alt=True + ) addon_keymaps.append((km, kmi)) @@ -847,7 +906,7 @@ def unregister(): km.keymap_items.remove(kmi) addon_keymaps.clear() - if hasattr(bpy.types.Image, 'hdri_variation'): + if hasattr(bpy.types.Image, "hdri_variation"): del bpy.types.Image.hdri_variation bpy.types.IMAGE_MT_image.remove(image_editor_menu) diff --git a/blender_cloud/texture_browser/draw.py b/blender_cloud/texture_browser/draw.py index 2fe7adb..e830df7 100644 --- a/blender_cloud/texture_browser/draw.py +++ b/blender_cloud/texture_browser/draw.py @@ -15,18 +15,21 @@ if bpy.app.background: shader = None texture_shader = None else: - shader = gpu.shader.from_builtin('2D_UNIFORM_COLOR') - texture_shader = gpu.shader.from_builtin('2D_IMAGE') + shader = gpu.shader.from_builtin("2D_UNIFORM_COLOR") + texture_shader = gpu.shader.from_builtin("2D_IMAGE") Float2 = typing.Tuple[float, float] Float3 = typing.Tuple[float, float, float] Float4 = typing.Tuple[float, float, float, float] -def text(pos2d: Float2, display_text: typing.Union[str, typing.List[str]], - rgba: Float4 = (1.0, 1.0, 1.0, 1.0), - fsize=12, - align='L'): +def text( + pos2d: Float2, + display_text: typing.Union[str, typing.List[str]], + rgba: Float4 = (1.0, 1.0, 1.0, 1.0), + fsize=12, + align="L", +): """Draw text with the top-left corner at 'pos2d'.""" dpi = bpy.context.preferences.system.dpi @@ -49,9 +52,9 @@ def text(pos2d: Float2, display_text: typing.Union[str, typing.List[str]], for idx, line in enumerate(mylines): text_width, text_height = blf.dimensions(font_id, line) - if align == 'C': + if align == "C": newx = x_pos - text_width / 2 - elif align == 'R': + elif align == "R": newx = x_pos - text_width - gap else: newx = x_pos @@ -79,7 +82,7 @@ def aabox(v1: Float2, v2: Float2, rgba: Float4): shader.bind() shader.uniform_float("color", rgba) - batch = batch_for_shader(shader, 'TRI_FAN', {"pos": coords}) + batch = batch_for_shader(shader, "TRI_FAN", {"pos": coords}) batch.draw(shader) @@ -94,10 +97,14 @@ def aabox_with_texture(v1: Float2, v2: Float2): texture_shader.bind() texture_shader.uniform_int("image", 0) - batch = batch_for_shader(texture_shader, 'TRI_FAN', { - "pos": coords, - "texCoord": ((0, 0), (0, 1), (1, 1), (1, 0)), - }) + batch = batch_for_shader( + texture_shader, + "TRI_FAN", + { + "pos": coords, + "texCoord": ((0, 0), (0, 1), (1, 1), (1, 0)), + }, + ) batch.draw(texture_shader) diff --git a/blender_cloud/texture_browser/draw_27.py b/blender_cloud/texture_browser/draw_27.py index de95fe6..1809da3 100644 --- a/blender_cloud/texture_browser/draw_27.py +++ b/blender_cloud/texture_browser/draw_27.py @@ -14,10 +14,13 @@ Float3 = typing.Tuple[float, float, float] Float4 = typing.Tuple[float, float, float, float] -def text(pos2d: Float2, display_text: typing.Union[str, typing.List[str]], - rgba: Float4 = (1.0, 1.0, 1.0, 1.0), - fsize=12, - align='L'): +def text( + pos2d: Float2, + display_text: typing.Union[str, typing.List[str]], + rgba: Float4 = (1.0, 1.0, 1.0, 1.0), + fsize=12, + align="L", +): """Draw text with the top-left corner at 'pos2d'.""" dpi = bpy.context.user_preferences.system.dpi @@ -40,9 +43,9 @@ def text(pos2d: Float2, display_text: typing.Union[str, typing.List[str]], for idx, line in enumerate(mylines): text_width, text_height = blf.dimensions(font_id, line) - if align == 'C': + if align == "C": newx = x_pos - text_width / 2 - elif align == 'R': + elif align == "R": newx = x_pos - text_width - gap else: newx = x_pos diff --git a/blender_cloud/texture_browser/menu_item.py b/blender_cloud/texture_browser/menu_item.py index cac48b4..b730b15 100644 --- a/blender_cloud/texture_browser/menu_item.py +++ b/blender_cloud/texture_browser/menu_item.py @@ -30,31 +30,39 @@ class MenuItem: text_size_small = 10 DEFAULT_ICONS = { - 'FOLDER': os.path.join(library_icons_path, 'folder.png'), - 'SPINNER': os.path.join(library_icons_path, 'spinner.png'), - 'ERROR': os.path.join(library_icons_path, 'error.png'), + "FOLDER": os.path.join(library_icons_path, "folder.png"), + "SPINNER": os.path.join(library_icons_path, "spinner.png"), + "ERROR": os.path.join(library_icons_path, "error.png"), } - FOLDER_NODE_TYPES = {'group_texture', 'group_hdri', - nodes.UpNode.NODE_TYPE, nodes.ProjectNode.NODE_TYPE} - SUPPORTED_NODE_TYPES = {'texture', 'hdri'}.union(FOLDER_NODE_TYPES) + FOLDER_NODE_TYPES = { + "group_texture", + "group_hdri", + nodes.UpNode.NODE_TYPE, + nodes.ProjectNode.NODE_TYPE, + } + SUPPORTED_NODE_TYPES = {"texture", "hdri"}.union(FOLDER_NODE_TYPES) def __init__(self, node, file_desc, thumb_path: str, label_text): - self.log = logging.getLogger('%s.MenuItem' % __name__) - if node['node_type'] not in self.SUPPORTED_NODE_TYPES: - self.log.info('Invalid node type in node: %s', node) - raise TypeError('Node of type %r not supported; supported are %r.' % ( - node['node_type'], self.SUPPORTED_NODE_TYPES)) + self.log = logging.getLogger("%s.MenuItem" % __name__) + if node["node_type"] not in self.SUPPORTED_NODE_TYPES: + self.log.info("Invalid node type in node: %s", node) + raise TypeError( + "Node of type %r not supported; supported are %r." + % (node["node_type"], self.SUPPORTED_NODE_TYPES) + ) - assert isinstance(node, pillarsdk.Node), 'wrong type for node: %r' % type(node) - assert isinstance(node['_id'], str), 'wrong type for node["_id"]: %r' % type(node['_id']) + assert isinstance(node, pillarsdk.Node), "wrong type for node: %r" % type(node) + assert isinstance(node["_id"], str), 'wrong type for node["_id"]: %r' % type( + node["_id"] + ) self.node = node # pillarsdk.Node, contains 'node_type' key to indicate type self.file_desc = file_desc # pillarsdk.File object, or None if a 'folder' node. self.label_text = label_text self.small_text = self._small_text_from_node() - self._thumb_path = '' + self._thumb_path = "" self.icon = None - self._is_folder = node['node_type'] in self.FOLDER_NODE_TYPES + self._is_folder = node["node_type"] in self.FOLDER_NODE_TYPES self._is_spinning = False # Determine sorting order. @@ -75,21 +83,21 @@ class MenuItem: """Return the components of the texture (i.e. which map types are available).""" if not self.node: - return '' + return "" try: node_files = self.node.properties.files except AttributeError: # Happens for nodes that don't have .properties.files. - return '' + return "" if not node_files: - return '' + return "" map_types = {f.map_type for f in node_files if f.map_type} - map_types.discard('color') # all textures have colour + map_types.discard("color") # all textures have colour if not map_types: - return '' - return ', '.join(sorted(map_types)) + return "" + return ", ".join(sorted(map_types)) def sort_key(self): """Key for sorting lists of MenuItems.""" @@ -101,7 +109,7 @@ class MenuItem: @thumb_path.setter def thumb_path(self, new_thumb_path: str): - self._is_spinning = new_thumb_path == 'SPINNER' + self._is_spinning = new_thumb_path == "SPINNER" self._thumb_path = self.DEFAULT_ICONS.get(new_thumb_path, new_thumb_path) if self._thumb_path: @@ -111,20 +119,22 @@ class MenuItem: @property def node_uuid(self) -> str: - return self.node['_id'] + return self.node["_id"] def represents(self, node) -> bool: """Returns True iff this MenuItem represents the given node.""" - node_uuid = node['_id'] + node_uuid = node["_id"] return self.node_uuid == node_uuid def update(self, node, file_desc, thumb_path: str, label_text=None): # We can get updated information about our Node, but a MenuItem should # always represent one node, and it shouldn't be shared between nodes. - if self.node_uuid != node['_id']: - raise ValueError("Don't change the node ID this MenuItem reflects, " - "just create a new one.") + if self.node_uuid != node["_id"]: + raise ValueError( + "Don't change the node ID this MenuItem reflects, " + "just create a new one." + ) self.node = node self.file_desc = file_desc # pillarsdk.File object, or None if a 'folder' node. self.thumb_path = thumb_path @@ -132,8 +142,8 @@ class MenuItem: if label_text is not None: self.label_text = label_text - if thumb_path == 'ERROR': - self.small_text = 'This open is broken' + if thumb_path == "ERROR": + self.small_text = "This open is broken" else: self.small_text = self._small_text_from_node() @@ -165,7 +175,7 @@ class MenuItem: texture = self.icon if texture: err = draw.load_texture(texture) - assert not err, 'OpenGL error: %i' % err + assert not err, "OpenGL error: %i" % err # ------ TEXTURE ---------# if texture: @@ -185,8 +195,15 @@ class MenuItem: text_x = self.x + self.icon_margin_x + ICON_WIDTH + self.text_margin_x text_y = self.y + ICON_HEIGHT * 0.5 - 0.25 * self.text_size draw.text((text_x, text_y), self.label_text, fsize=self.text_size) - draw.text((text_x, self.y + 0.5 * self.text_size_small), self.small_text, - fsize=self.text_size_small, rgba=(1.0, 1.0, 1.0, 0.5)) + draw.text( + (text_x, self.y + 0.5 * self.text_size_small), + self.small_text, + fsize=self.text_size_small, + rgba=(1.0, 1.0, 1.0, 0.5), + ) def hits(self, mouse_x: int, mouse_y: int) -> bool: - return self.x < mouse_x < self.x + self.width and self.y < mouse_y < self.y + self.height + return ( + self.x < mouse_x < self.x + self.width + and self.y < mouse_y < self.y + self.height + ) diff --git a/blender_cloud/texture_browser/nodes.py b/blender_cloud/texture_browser/nodes.py index 257ed72..8faf6ae 100644 --- a/blender_cloud/texture_browser/nodes.py +++ b/blender_cloud/texture_browser/nodes.py @@ -2,25 +2,27 @@ import pillarsdk class SpecialFolderNode(pillarsdk.Node): - NODE_TYPE = 'SPECIAL' + NODE_TYPE = "SPECIAL" class UpNode(SpecialFolderNode): - NODE_TYPE = 'UP' + NODE_TYPE = "UP" def __init__(self): super().__init__() - self['_id'] = 'UP' - self['node_type'] = self.NODE_TYPE + self["_id"] = "UP" + self["node_type"] = self.NODE_TYPE class ProjectNode(SpecialFolderNode): - NODE_TYPE = 'PROJECT' + NODE_TYPE = "PROJECT" def __init__(self, project): super().__init__() - assert isinstance(project, pillarsdk.Project), 'wrong type for project: %r' % type(project) + assert isinstance( + project, pillarsdk.Project + ), "wrong type for project: %r" % type(project) self.merge(project.to_dict()) - self['node_type'] = self.NODE_TYPE + self["node_type"] = self.NODE_TYPE diff --git a/blender_cloud/utils.py b/blender_cloud/utils.py index c454af9..ce2ac06 100644 --- a/blender_cloud/utils.py +++ b/blender_cloud/utils.py @@ -21,18 +21,18 @@ import pathlib import typing -def sizeof_fmt(num: int, suffix='B') -> str: +def sizeof_fmt(num: int, suffix="B") -> str: """Returns a human-readable size. Source: http://stackoverflow.com/a/1094933/875379 """ - for unit in ['', 'Ki', 'Mi', 'Gi', 'Ti', 'Pi', 'Ei', 'Zi']: + for unit in ["", "Ki", "Mi", "Gi", "Ti", "Pi", "Ei", "Zi"]: if abs(num) < 1024: - return '%.1f %s%s' % (num, unit, suffix) + return "%.1f %s%s" % (num, unit, suffix) num //= 1024 - return '%.1f Yi%s' % (num, suffix) + return "%.1f Yi%s" % (num, suffix) def find_in_path(path: pathlib.Path, filename: str) -> typing.Optional[pathlib.Path]: @@ -98,8 +98,10 @@ def pyside_cache(propname): rna_type, rna_info = self.bl_rna.__annotations__[propname] except AttributeError: rna_type, rna_info = getattr(self.bl_rna, propname) - rna_info['_cached_result'] = result + rna_info["_cached_result"] = result + return wrapper + return decorator @@ -113,6 +115,6 @@ class JSONEncoder(json.JSONEncoder): """JSON encoder with support for some Blender types.""" def default(self, o): - if o.__class__.__name__ == 'IDPropertyGroup' and hasattr(o, 'to_dict'): + if o.__class__.__name__ == "IDPropertyGroup" and hasattr(o, "to_dict"): return o.to_dict() return super().default(o) diff --git a/blender_cloud/wheels/__init__.py b/blender_cloud/wheels/__init__.py index 77cfef1..f5eb570 100644 --- a/blender_cloud/wheels/__init__.py +++ b/blender_cloud/wheels/__init__.py @@ -37,23 +37,26 @@ def load_wheel(module_name, fname_prefix): try: module = __import__(module_name) except ImportError as ex: - log.debug('Unable to import %s directly, will try wheel: %s', - module_name, ex) + log.debug("Unable to import %s directly, will try wheel: %s", module_name, ex) else: - log.debug('Was able to load %s from %s, no need to load wheel %s', - module_name, module.__file__, fname_prefix) + log.debug( + "Was able to load %s from %s, no need to load wheel %s", + module_name, + module.__file__, + fname_prefix, + ) return sys.path.append(wheel_filename(fname_prefix)) module = __import__(module_name) - log.debug('Loaded %s from %s', module_name, module.__file__) + log.debug("Loaded %s from %s", module_name, module.__file__) def wheel_filename(fname_prefix: str) -> str: - path_pattern = os.path.join(my_dir, '%s*.whl' % fname_prefix) + path_pattern = os.path.join(my_dir, "%s*.whl" % fname_prefix) wheels = glob.glob(path_pattern) if not wheels: - raise RuntimeError('Unable to find wheel at %r' % path_pattern) + raise RuntimeError("Unable to find wheel at %r" % path_pattern) # If there are multiple wheels that match, load the latest one. wheels.sort() @@ -61,7 +64,7 @@ def wheel_filename(fname_prefix: str) -> str: def load_wheels(): - load_wheel('blender_asset_tracer', 'blender_asset_tracer') - load_wheel('lockfile', 'lockfile') - load_wheel('cachecontrol', 'CacheControl') - load_wheel('pillarsdk', 'pillarsdk') + load_wheel("blender_asset_tracer", "blender_asset_tracer") + load_wheel("lockfile", "lockfile") + load_wheel("cachecontrol", "CacheControl") + load_wheel("pillarsdk", "pillarsdk") diff --git a/tests/test_path_replacement.py b/tests/test_path_replacement.py index fb595d1..db41da9 100644 --- a/tests/test_path_replacement.py +++ b/tests/test_path_replacement.py @@ -15,71 +15,94 @@ from blender_cloud.flamenco import sdk class PathReplacementTest(unittest.TestCase): def setUp(self): - self.test_manager = sdk.Manager({ - '_created': datetime.datetime(2017, 5, 31, 15, 12, 32, tzinfo=pillarsdk.utils.utc), - '_etag': 'c39942ee4bcc4658adcc21e4bcdfb0ae', - '_id': '592edd609837732a2a272c62', - '_updated': datetime.datetime(2017, 6, 8, 14, 51, 3, tzinfo=pillarsdk.utils.utc), - 'description': 'Manager formerly known as "testman"', - 'job_types': {'sleep': {'vars': {}}}, - 'name': '', - 'owner': '592edd609837732a2a272c63', - 'path_replacement': {'job_storage': {'darwin': '/Volume/shared', - 'linux': '/shared', - 'windows': 's:/'}, - 'render': {'darwin': '/Volume/render/', - 'linux': '/render/', - 'windows': 'r:/'}, - 'longrender': {'darwin': '/Volume/render/long', - 'linux': '/render/long', - 'windows': 'r:/long'}, - }, - 'projects': ['58cbdd5698377322d95eb55e'], - 'service_account': '592edd609837732a2a272c60', - 'stats': {'nr_of_workers': 3}, - 'url': 'http://192.168.3.101:8083/', - 'user_groups': ['58cbdd5698377322d95eb55f'], - 'variables': {'blender': {'darwin': '/opt/myblenderbuild/blender', - 'linux': '/home/sybren/workspace/build_linux/bin/blender ' - '--enable-new-depsgraph --factory-startup', - 'windows': 'c:/temp/blender.exe'}}} + self.test_manager = sdk.Manager( + { + "_created": datetime.datetime( + 2017, 5, 31, 15, 12, 32, tzinfo=pillarsdk.utils.utc + ), + "_etag": "c39942ee4bcc4658adcc21e4bcdfb0ae", + "_id": "592edd609837732a2a272c62", + "_updated": datetime.datetime( + 2017, 6, 8, 14, 51, 3, tzinfo=pillarsdk.utils.utc + ), + "description": 'Manager formerly known as "testman"', + "job_types": {"sleep": {"vars": {}}}, + "name": '', + "owner": "592edd609837732a2a272c63", + "path_replacement": { + "job_storage": { + "darwin": "/Volume/shared", + "linux": "/shared", + "windows": "s:/", + }, + "render": { + "darwin": "/Volume/render/", + "linux": "/render/", + "windows": "r:/", + }, + "longrender": { + "darwin": "/Volume/render/long", + "linux": "/render/long", + "windows": "r:/long", + }, + }, + "projects": ["58cbdd5698377322d95eb55e"], + "service_account": "592edd609837732a2a272c60", + "stats": {"nr_of_workers": 3}, + "url": "http://192.168.3.101:8083/", + "user_groups": ["58cbdd5698377322d95eb55f"], + "variables": { + "blender": { + "darwin": "/opt/myblenderbuild/blender", + "linux": "/home/sybren/workspace/build_linux/bin/blender " + "--enable-new-depsgraph --factory-startup", + "windows": "c:/temp/blender.exe", + } + }, + } ) def test_linux(self): # (expected result, input) test_paths = [ - ('/doesnotexistreally', '/doesnotexistreally'), - ('{render}/agent327/scenes/A_01_03_B', '/render/agent327/scenes/A_01_03_B'), - ('{job_storage}/render/agent327/scenes', '/shared/render/agent327/scenes'), - ('{longrender}/agent327/scenes', '/render/long/agent327/scenes'), + ("/doesnotexistreally", "/doesnotexistreally"), + ("{render}/agent327/scenes/A_01_03_B", "/render/agent327/scenes/A_01_03_B"), + ("{job_storage}/render/agent327/scenes", "/shared/render/agent327/scenes"), + ("{longrender}/agent327/scenes", "/render/long/agent327/scenes"), ] - self._do_test(test_paths, 'linux', pathlib.PurePosixPath) + self._do_test(test_paths, "linux", pathlib.PurePosixPath) def test_windows(self): # (expected result, input) test_paths = [ - ('c:/doesnotexistreally', 'c:/doesnotexistreally'), - ('c:/some/path', r'c:\some\path'), - ('{render}/agent327/scenes/A_01_03_B', r'R:\agent327\scenes\A_01_03_B'), - ('{render}/agent327/scenes/A_01_03_B', r'r:\agent327\scenes\A_01_03_B'), - ('{render}/agent327/scenes/A_01_03_B', r'r:/agent327/scenes/A_01_03_B'), - ('{job_storage}/render/agent327/scenes', 's:/render/agent327/scenes'), - ('{longrender}/agent327/scenes', 'r:/long/agent327/scenes'), + ("c:/doesnotexistreally", "c:/doesnotexistreally"), + ("c:/some/path", r"c:\some\path"), + ("{render}/agent327/scenes/A_01_03_B", r"R:\agent327\scenes\A_01_03_B"), + ("{render}/agent327/scenes/A_01_03_B", r"r:\agent327\scenes\A_01_03_B"), + ("{render}/agent327/scenes/A_01_03_B", r"r:/agent327/scenes/A_01_03_B"), + ("{job_storage}/render/agent327/scenes", "s:/render/agent327/scenes"), + ("{longrender}/agent327/scenes", "r:/long/agent327/scenes"), ] - self._do_test(test_paths, 'windows', pathlib.PureWindowsPath) + self._do_test(test_paths, "windows", pathlib.PureWindowsPath) def test_darwin(self): # (expected result, input) test_paths = [ - ('/Volume/doesnotexistreally', '/Volume/doesnotexistreally'), - ('{render}/agent327/scenes/A_01_03_B', r'/Volume/render/agent327/scenes/A_01_03_B'), - ('{job_storage}/render/agent327/scenes', '/Volume/shared/render/agent327/scenes'), - ('{longrender}/agent327/scenes', '/Volume/render/long/agent327/scenes'), + ("/Volume/doesnotexistreally", "/Volume/doesnotexistreally"), + ( + "{render}/agent327/scenes/A_01_03_B", + r"/Volume/render/agent327/scenes/A_01_03_B", + ), + ( + "{job_storage}/render/agent327/scenes", + "/Volume/shared/render/agent327/scenes", + ), + ("{longrender}/agent327/scenes", "/Volume/render/long/agent327/scenes"), ] - self._do_test(test_paths, 'darwin', pathlib.PurePosixPath) + self._do_test(test_paths, "darwin", pathlib.PurePosixPath) def _do_test(self, test_paths, platform, pathclass): self.test_manager.PurePlatformPath = pathclass @@ -87,9 +110,11 @@ class PathReplacementTest(unittest.TestCase): def mocked_system(): return platform - with unittest.mock.patch('platform.system', mocked_system): + with unittest.mock.patch("platform.system", mocked_system): for expected_result, input_path in test_paths: as_path_instance = pathclass(input_path) - self.assertEqual(expected_result, - self.test_manager.replace_path(as_path_instance), - 'for input %r on platform %s' % (as_path_instance, platform)) + self.assertEqual( + expected_result, + self.test_manager.replace_path(as_path_instance), + "for input %r on platform %s" % (as_path_instance, platform), + ) diff --git a/tests/test_utils.py b/tests/test_utils.py index 865aa69..86fccb8 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -8,18 +8,18 @@ from blender_cloud import utils class FindInPathTest(unittest.TestCase): def test_nonexistant_path(self): - path = pathlib.Path('/doesnotexistreally') + path = pathlib.Path("/doesnotexistreally") self.assertFalse(path.exists()) - self.assertIsNone(utils.find_in_path(path, 'jemoeder.blend')) + self.assertIsNone(utils.find_in_path(path, "jemoeder.blend")) def test_really_breadth_first(self): """A depth-first test might find dir_a1/dir_a2/dir_a3/find_me.txt first.""" - path = pathlib.Path(__file__).parent / 'test_really_breadth_first' - found = utils.find_in_path(path, 'find_me.txt') - self.assertEqual(path / 'dir_b1' / 'dir_b2' / 'find_me.txt', found) + path = pathlib.Path(__file__).parent / "test_really_breadth_first" + found = utils.find_in_path(path, "find_me.txt") + self.assertEqual(path / "dir_b1" / "dir_b2" / "find_me.txt", found) def test_nonexistant_file(self): - path = pathlib.Path(__file__).parent / 'test_really_breadth_first' - found = utils.find_in_path(path, 'do_not_find_me.txt') + path = pathlib.Path(__file__).parent / "test_really_breadth_first" + found = utils.find_in_path(path, "do_not_find_me.txt") self.assertEqual(None, found)