Nicer UI, and Blender Sync all in one operator.

This commit is contained in:
Sybren A. Stüvel 2016-06-23 19:00:47 +02:00
parent 6de026c8e2
commit 671e9f31fa
3 changed files with 193 additions and 148 deletions

View File

@ -6,8 +6,8 @@ Separated from __init__.py so that we can import & run from non-Blender environm
import logging import logging
import bpy import bpy
from bpy.types import AddonPreferences, Operator, WindowManager, Scene from bpy.types import AddonPreferences, Operator, WindowManager, Scene, PropertyGroup
from bpy.props import StringProperty from bpy.props import StringProperty, EnumProperty, PointerProperty
from . import pillar, gui from . import pillar, gui
@ -18,12 +18,44 @@ ADDON_NAME = 'blender_cloud'
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
def redraw(self, context):
log.debug('SyncStatusProperties.status = %s', self.status)
context.area.tag_redraw()
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.'),
],
name='status',
description='Current status of Blender Sync.',
update=redraw)
message = StringProperty(name='message', update=redraw)
level = EnumProperty(
items=[
('INFO', 'INFO', ''),
('WARNING', 'WARNING', ''),
('ERROR', 'ERROR', ''),
],
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
self.level = level.pop()
self.message = message
log.error('REPORT %s: %s / %s', self, self.level, self.message)
class BlenderCloudPreferences(AddonPreferences): class BlenderCloudPreferences(AddonPreferences):
bl_idname = ADDON_NAME bl_idname = ADDON_NAME
# The following two properties are read-only to limit the scope of the # The following two properties are read-only to limit the scope of the
# addon and allow for proper testing within this scope. # addon and allow for proper testing within this scope.
pillar_server = bpy.props.StringProperty( pillar_server = StringProperty(
name='Blender Cloud Server', name='Blender Cloud Server',
description='URL of the Blender Cloud backend server', description='URL of the Blender Cloud backend server',
default=PILLAR_SERVER_URL, default=PILLAR_SERVER_URL,
@ -91,6 +123,37 @@ class BlenderCloudPreferences(AddonPreferences):
# sub.prop(self, "project_uuid") # sub.prop(self, "project_uuid")
sub.operator("pillar.credentials_update") sub.operator("pillar.credentials_update")
bss = context.window_manager.blender_sync_status
col = layout.column()
row = col.row()
row.label('Blender Sync')
icon_for_level = {
'INFO': 'NONE',
'WARNING': 'INFO',
'ERROR': 'ERROR',
}
message_container = row.row()
message_container.label(bss.message or '-idle-', icon=icon_for_level[bss.level])
# message_container.enabled = bool(bss.message)
message_container.alert = True # bss.level in {'WARNING', 'ERROR'}
sub = col.column()
sub.enabled = bss.status in {'NONE', 'IDLE'}
row = sub.row()
row.operator('pillar.sync', text='Refresh', icon='FILE_REFRESH').action = 'REFRESH'
row.operator('pillar.sync', text='To Cloud').action = 'PUSH'
if 'available_blender_versions' in bss:
for version in bss['available_blender_versions']:
props = sub.operator('pillar.sync', icon='FILE_REFRESH',
text='From Cloud %s' % version)
props.action = 'PULL'
props.blender_version = version
# sub.prop(bss, 'level')
class PillarCredentialsUpdate(Operator): class PillarCredentialsUpdate(Operator):
"""Updates the Pillar URL and tests the new URL.""" """Updates the Pillar URL and tests the new URL."""
@ -143,6 +206,7 @@ def preferences() -> BlenderCloudPreferences:
def register(): def register():
bpy.utils.register_class(BlenderCloudPreferences) bpy.utils.register_class(BlenderCloudPreferences)
bpy.utils.register_class(PillarCredentialsUpdate) bpy.utils.register_class(PillarCredentialsUpdate)
bpy.utils.register_class(SyncStatusProperties)
addon_prefs = preferences() addon_prefs = preferences()
@ -162,13 +226,15 @@ def register():
default=addon_prefs.local_texture_dir, default=addon_prefs.local_texture_dir,
update=default_if_empty) update=default_if_empty)
WindowManager.blender_sync_status = PointerProperty(type=SyncStatusProperties)
def unregister(): def unregister():
gui.unregister() gui.unregister()
bpy.utils.unregister_class(PillarCredentialsUpdate) bpy.utils.unregister_class(PillarCredentialsUpdate)
bpy.utils.unregister_class(BlenderCloudPreferences) bpy.utils.unregister_class(BlenderCloudPreferences)
bpy.utils.unregister_class(SyncStatusProperties)
del WindowManager.blender_cloud_project del WindowManager.last_blender_cloud_location
del WindowManager.blender_cloud_node del WindowManager.blender_sync_status
del WindowManager.blender_cloud_thumbnails

View File

@ -669,7 +669,7 @@ class PillarOperatorMixin:
Returns None if the user cannot be found, or if the user is not a Cloud subscriber. Returns None if the user cannot be found, or if the user is not a Cloud subscriber.
""" """
self.report({'INFO'}, 'Checking Blender Cloud credentials') # self.report({'INFO'}, 'Checking Blender Cloud credentials')
try: try:
user_id = await check_pillar_credentials() user_id = await check_pillar_credentials()

View File

@ -1,5 +1,5 @@
"""Synchronises settings & startup file with the Cloud.
"""Synchronises settings & startup file with the Cloud.
Caching is disabled on many PillarSDK calls, as synchronisation can happen Caching is disabled on many PillarSDK calls, as synchronisation can happen
rapidly between multiple machines. This means that information can be outdated rapidly between multiple machines. This means that information can be outdated
in seconds, rather than the minutes the cache system assumes. in seconds, rather than the minutes the cache system assumes.
@ -38,6 +38,37 @@ SYNC_GROUP_NODE_DESC = 'The [Blender Cloud Addon](https://cloud.blender.org/serv
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
def set_blender_sync_status(set_status: str):
def decorator(func):
@functools.wraps(func)
def wrapper(*args, **kwargs):
bss = bpy.context.window_manager.blender_sync_status
bss.status = set_status
try:
return func(*args, **kwargs)
finally:
bss.status = 'IDLE'
return wrapper
return decorator
def async_set_blender_sync_status(set_status: str):
def decorator(func):
@functools.wraps(func)
async def wrapper(*args, **kwargs):
bss = bpy.context.window_manager.blender_sync_status
bss.status = set_status
try:
return await func(*args, **kwargs)
finally:
bss.status = 'IDLE'
return wrapper
return decorator
async def get_home_project(params=None) -> pillarsdk.Project: async def get_home_project(params=None) -> pillarsdk.Project:
"""Returns the home project.""" """Returns the home project."""
@ -190,6 +221,53 @@ async def attach_file_to_group(file_path: pathlib.Path,
return node return node
@functools.lru_cache()
async def available_blender_versions(home_project_id: str, user_id: str) -> list:
bss = bpy.context.window_manager.blender_sync_status
# Get the available Blender versions.
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},
},
caching=False)
if sync_group is None:
bss.report({'ERROR'}, 'No synced Blender settings in your home project')
log.warning('No synced Blender settings in your home project')
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',
},
caching=False)
if not sync_nodes or not sync_nodes._items:
bss.report({'ERROR'}, 'No synced Blender settings in your home project')
log.warning('No synced Blender settings in your home project')
return []
versions = sync_nodes._items
log.info('Versions: %s', versions)
return [node.name for node in versions]
# noinspection PyAttributeOutsideInit # noinspection PyAttributeOutsideInit
class PILLAR_OT_sync(pillar.PillarOperatorMixin, class PILLAR_OT_sync(pillar.PillarOperatorMixin,
async_loop.AsyncModalOperatorMixin, async_loop.AsyncModalOperatorMixin,
@ -206,29 +284,37 @@ class PILLAR_OT_sync(pillar.PillarOperatorMixin,
items=[ items=[
('PUSH', 'Push', 'Push settings to the Blender Cloud'), ('PUSH', 'Push', 'Push settings to the Blender Cloud'),
('PULL', 'Pull', 'Pull settings from the Blender Cloud'), ('PULL', 'Pull', 'Pull settings from the Blender Cloud'),
('REFRESH', 'Refresh', 'Refresh available versions'),
], ],
name='action', name='action')
description='Synchronises settings with the Blender Cloud.')
blender_version = bpy.props.StringProperty(name='blender_version', blender_version = bpy.props.StringProperty(name='blender_version',
description='Blender version to sync for', description='Blender version to sync for',
default='%i.%i' % bpy.app.version[:2]) default='%i.%i' % bpy.app.version[:2])
def bss_report(self, level, message):
bss = bpy.context.window_manager.blender_sync_status
bss.report(level, message)
def invoke(self, context, event): def invoke(self, context, event):
self.log.info('at invoke: self = %r', self)
self.log.info('Pulling from Blender %s', self.blender_version)
if not self.blender_version: if not self.blender_version:
self.report({'ERROR'}, 'No Blender version to sync for was given.') self.bss_report({'ERROR'}, 'No Blender version to sync for was given.')
return {'CANCELLED'} return {'CANCELLED'}
async_loop.AsyncModalOperatorMixin.invoke(self, context, event) async_loop.AsyncModalOperatorMixin.invoke(self, context, event)
log.info('Starting synchronisation') self.log.info('Starting synchronisation')
self._new_async_task(self.async_execute(context)) self._new_async_task(self.async_execute(context))
return {'RUNNING_MODAL'} return {'RUNNING_MODAL'}
@async_set_blender_sync_status('SYNCING')
async def async_execute(self, context): async def async_execute(self, context):
"""Entry point of the asynchronous operator.""" """Entry point of the asynchronous operator."""
self.report({'INFO'}, 'Synchronizing settings %s with Blender Cloud' % self.action) self.bss_report({'INFO'}, 'Synchronizing settings %s with Blender Cloud' % self.action)
try: try:
self.user_id = await self.check_credentials(context) self.user_id = await self.check_credentials(context)
@ -236,7 +322,7 @@ class PILLAR_OT_sync(pillar.PillarOperatorMixin,
self.home_project_id = await get_home_project_id() self.home_project_id = await get_home_project_id()
except sdk_exceptions.ForbiddenAccess: except sdk_exceptions.ForbiddenAccess:
self.log.exception('Forbidden access to home project.') self.log.exception('Forbidden access to home project.')
self.report({'ERROR'}, 'Did not get access to home project.') self.bss_report({'ERROR'}, 'Did not get access to home project.')
self._state = 'QUIT' self._state = 'QUIT'
return return
@ -254,7 +340,7 @@ class PILLAR_OT_sync(pillar.PillarOperatorMixin,
self.blender_version, self.sync_group_versioned_id) self.blender_version, self.sync_group_versioned_id)
except sdk_exceptions.ForbiddenAccess: except sdk_exceptions.ForbiddenAccess:
self.log.exception('Unable to find Group ID') self.log.exception('Unable to find Group ID')
self.report({'ERROR'}, 'Unable to find sync folder.') self.bss_report({'ERROR'}, 'Unable to find sync folder.')
self._state = 'QUIT' self._state = 'QUIT'
return return
@ -262,11 +348,12 @@ class PILLAR_OT_sync(pillar.PillarOperatorMixin,
action = { action = {
'PUSH': self.action_push, 'PUSH': self.action_push,
'PULL': self.action_pull, 'PULL': self.action_pull,
'REFRESH': self.action_refresh,
}[self.action] }[self.action]
await action(context) await action(context)
except Exception as ex: except Exception as ex:
self.log.exception('Unexpected exception caught.') self.log.exception('Unexpected exception caught.')
self.report({'ERROR'}, 'Unexpected error: %s' % ex) self.bss_report({'ERROR'}, 'Unexpected error: %s' % ex)
self._state = 'QUIT' self._state = 'QUIT'
@ -284,46 +371,61 @@ class PILLAR_OT_sync(pillar.PillarOperatorMixin,
self.log.debug('Skipping non-existing %s', path) self.log.debug('Skipping non-existing %s', path)
continue continue
self.report({'INFO'}, 'Uploading %s' % fname) self.bss_report({'INFO'}, 'Uploading %s' % fname)
await attach_file_to_group(path, await attach_file_to_group(path,
self.home_project_id, self.home_project_id,
self.sync_group_versioned_id, self.sync_group_versioned_id,
self.user_id, self.user_id,
future=self.signalling_future) future=self.signalling_future)
self.report({'INFO'}, 'Settings pushed to Blender Cloud.') self.bss_report({'INFO'}, 'Settings pushed to Blender Cloud.')
async def action_pull(self, context): async def action_pull(self, context):
"""Loads files from the Pillar server.""" """Loads files from the Pillar server."""
# Refuse to start if the file hasn't been saved. # Refuse to start if the file hasn't been saved.
if context.blend_data.is_dirty: if context.blend_data.is_dirty:
self.report({'ERROR'}, 'Please save your Blend file before pulling' self.bss_report({'ERROR'}, 'Please save your Blend file before pulling'
' settings from the Blender Cloud.') ' settings from the Blender Cloud.')
return return
# If the sync group node doesn't exist, offer a list of groups that do. # If the sync group node doesn't exist, offer a list of groups that do.
if self.sync_group_id is None: if self.sync_group_id is None:
self.report({'ERROR'}, 'There are no synced Blender settings in your home project.') self.bss_report({'ERROR'}, 'There are no synced Blender settings in your home project.')
return return
if self.sync_group_versioned_id is None: if self.sync_group_versioned_id is None:
self.report({'ERROR'}, 'Therre are no synced Blender settings for version %s' % self.bss_report({'ERROR'}, 'Therre are no synced Blender settings for version %s' %
self.blender_version) self.blender_version)
return return
self.report({'INFO'}, 'Pulling settings from Blender Cloud') self.bss_report({'INFO'}, 'Pulling settings from Blender Cloud')
with tempfile.TemporaryDirectory(prefix='bcloud-sync') as tempdir: with tempfile.TemporaryDirectory(prefix='bcloud-sync') as tempdir:
for fname in SETTINGS_FILES_TO_UPLOAD: for fname in SETTINGS_FILES_TO_UPLOAD:
await self.download_settings_file(fname, tempdir) await self.download_settings_file(fname, tempdir)
self.report({'WARNING'}, 'Settings pulled from Cloud, restart Blender to load them.') self.bss_report({'WARNING'}, 'Settings pulled from Cloud, restart Blender to load them.')
self.log.info('at end: self = %r', self)
async def action_refresh(self, context):
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)
available_blender_versions.cache_clear()
versions = await available_blender_versions(self.home_project_id, self.user_id)
bss = bpy.context.window_manager.blender_sync_status
bss['available_blender_versions'] = versions
self.bss_report({'INFO'}, '')
async def download_settings_file(self, fname: str, temp_dir: str): async def download_settings_file(self, fname: str, temp_dir: str):
config_dir = pathlib.Path(bpy.utils.user_resource('CONFIG')) config_dir = pathlib.Path(bpy.utils.user_resource('CONFIG'))
meta_path = cache.cache_directory('home-project', 'blender-sync') meta_path = cache.cache_directory('home-project', 'blender-sync')
self.report({'INFO'}, 'Downloading %s from Cloud' % fname) self.bss_report({'INFO'}, 'Downloading %s from Cloud' % fname)
# Get the asset node # Get the asset node
node_props = {'project': self.home_project_id, node_props = {'project': self.home_project_id,
@ -335,7 +437,7 @@ class PILLAR_OT_sync(pillar.PillarOperatorMixin,
'projection': {'_id': 1, 'properties.file': 1} 'projection': {'_id': 1, 'properties.file': 1}
}, caching=False) }, caching=False)
if node is None: if node is None:
self.report({'INFO'}, 'Unable to find %s on Blender Cloud' % 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) self.log.info('Unable to find node on Blender Cloud for %s', fname)
return return
@ -412,132 +514,9 @@ class PILLAR_OT_sync(pillar.PillarOperatorMixin,
prefs[key] = value prefs[key] = value
@functools.lru_cache()
def available_blender_versions(home_project_id: str, user_id: str) -> list:
# Get the available Blender versions.
api = pillar.pillar_api(caching=False)
sync_group = 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},
},
api=api)
if sync_group is None:
# self.report({'ERROR'}, 'No synced Blender settings in your home project')
log.warning('No synced Blender settings in your home project')
log.debug('-- unable to find sync group for home_project_id=%r and user_id=%r',
home_project_id, user_id)
return []
sync_nodes = 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',
},
api=api)
if not sync_nodes or not sync_nodes._items:
# self.report({'ERROR'}, 'No synced Blender settings in your home project')
log.warning('No synced Blender settings in your home project')
return []
versions = sync_nodes._items
log.info('Versions: %s', versions)
return [(node.name, node.name, '')
for node in versions]
class PILLAR_OT_syncable_versions(pillar.PillarOperatorMixin,
bpy.types.Operator):
"""For now, this operator runs synchronously, because it has to be nice
with Blender's UI drawing code.
"""
bl_idname = 'pillar.syncable_versions'
bl_label = 'Synchronise with Blender Cloud from other Blender version'
log = logging.getLogger('bpy.ops.%s' % bl_idname)
home_project_id = None
user_id = None
def _get_available_blender_versions(self, context):
# Work around bug T48715
home_project_id = context.window_manager['home_project_id']
user_id = context.window_manager['user_id']
if home_project_id is None or user_id is None:
log.debug('_get_available_blender_versions() called before invoke()')
return []
return available_blender_versions(home_project_id, user_id)
blender_version = bpy.props.EnumProperty(
name='available_versions',
description='Available Blender versions',
items=_get_available_blender_versions
)
def invoke(self, context, event):
loop = asyncio.get_event_loop()
# Check credentials.
future = asyncio.ensure_future(self.check_credentials(context))
loop.run_until_complete(future)
self.user_id = future.result()
if self.user_id is None:
return {'CANCELLED'}
# Get the home project.
future = asyncio.ensure_future(get_home_project_id())
loop.run_until_complete(future)
self.home_project_id = future.result()
self.log.info('Home project ID: %s', self.home_project_id)
# Clear the LRU cache of available_blender_versions so that we can
# obtain new versions (if someone synced from somewhere else, for example)
available_blender_versions.cache_clear()
# Work around bug T48715
context.window_manager['home_project_id'] = self.home_project_id
context.window_manager['user_id'] = self.user_id
return context.window_manager.invoke_props_dialog(self)
def execute(self, context):
self.report({'INFO'},
'Going to pull settings from Blender version %s' % self.blender_version)
bpy.ops.pillar.sync('INVOKE_DEFAULT', action='PULL', blender_version=self.blender_version)
return {'FINISHED'}
def draw_userpref_header(self: bpy.types.USERPREF_HT_header, context):
"""Adds some buttons to the userprefs header."""
layout = self.layout
layout.operator('pillar.sync', icon='FILE_REFRESH',
text='Push to Cloud').action = 'PUSH'
layout.operator('pillar.sync', icon='FILE_REFRESH',
text='Pull from Cloud').action = 'PULL'
layout.operator('pillar.syncable_versions', text='Pull other version')
def register(): def register():
bpy.utils.register_class(PILLAR_OT_sync) bpy.utils.register_class(PILLAR_OT_sync)
bpy.utils.register_class(PILLAR_OT_syncable_versions)
bpy.types.USERPREF_HT_header.append(draw_userpref_header)
def unregister(): def unregister():
bpy.utils.unregister_class(PILLAR_OT_sync) bpy.utils.unregister_class(PILLAR_OT_sync)
bpy.utils.unregister_class(PILLAR_OT_syncable_versions)
bpy.types.USERPREF_HT_header.remove(draw_userpref_header)