Compare commits

...

29 Commits

Author SHA1 Message Date
2bb859efd9 Increased pillarsdk required version 2016-05-10 15:04:49 +02:00
ac3943fe6c Bumped version to 1.0.1 2016-05-10 15:01:15 +02:00
5eaee872bf Added check for user's roles -- disallow usage by non-subscribers.
This makes it clear from the get-go that users need to subscribe. Otherwise
they'll get unexpected errors once they try to download something.
2016-05-10 14:52:51 +02:00
6ce4399407 Show default mouse cursor, instead of the one belonging to the editor. 2016-05-10 14:33:02 +02:00
bfa375fed0 Bumped pillarsdk requirement to 1.0.0 2016-05-04 14:39:59 +02:00
6d7428c16e Bumped version to 1.0.0 2016-05-04 14:38:44 +02:00
ef7c82666f Updated README 2016-05-04 14:35:01 +02:00
92e27914d1 Refuse to start if the file hasn't been saved. 2016-05-04 14:30:54 +02:00
1d662a0314 Automatic refresh of subclient token. 2016-05-04 14:30:47 +02:00
f3699f651a Removed unused code. 2016-05-04 11:00:26 +02:00
602260329e Don't show the addon in the add->mesh menu 2016-05-03 18:30:06 +02:00
58bae5b3a0 Word-wrap help text in addon prefs 2016-05-03 18:29:43 +02:00
0ccd5cbf97 Remove logging configuration, as this should be done globally.
logging.basicConfig() shouldn't be called by individual addons.
2016-05-03 14:58:46 +02:00
61b8667f3b Include icon PNG files in the bdist zip 2016-05-03 14:11:25 +02:00
ad7e9acb5d Using pillarsdk from pypi 2016-05-03 14:04:15 +02:00
329d830f63 Demoted version to 0.9.0, to give us some incremental releases before 1.0 2016-05-03 13:41:44 +02:00
6be1e4ced9 Read-only Pillar server URL and project_uuid.
These properties have also been removed from the UI. This is to limit the
scope of the addon for the first release, allowing us to test properly.
2016-04-19 11:37:46 +02:00
090a9bc5c6 Added TODO in source code 2016-04-19 11:36:52 +02:00
d77022ee1f Added shortcut to location field in bl_info 2016-04-19 11:36:48 +02:00
b36a5178ba Clarified some self.report({'INFO'}, ...) messages 2016-04-19 10:35:22 +02:00
6450db9c9e Added CloudPath class for easier cloud browsing.
This allows us to have a structured, well-defined way to point at a node
in Pillar (and store its parent nodes).
2016-04-15 10:13:28 +02:00
b70ab9678b BlenderID addon stores subclient token in different key now 2016-04-13 15:37:41 +02:00
5ffeddebd1 Better logging 2016-04-13 15:37:19 +02:00
a6256bd47b Switched to using subclient-specific authentication tokens. 2016-04-12 16:59:34 +02:00
23540f931f Set default URL to actual Pillar URL 2016-04-04 14:52:18 +02:00
29230f09e7 More streamlined interface with Pillar.
Using a semaphore to ensure requests to Pillar aren't too parallel,
so that we can cancel requests faster.
2016-04-01 18:47:06 +02:00
2c4c102302 Use the new Blender ID API 2016-04-01 17:16:29 +02:00
5396fd765d Better reporting when the user is not logged in. 2016-04-01 14:11:30 +02:00
1bb32033b6 Using new Blender ID addon public API 2016-04-01 14:11:12 +02:00
10 changed files with 436 additions and 197 deletions

View File

@@ -19,6 +19,26 @@ This addon is a *proof of concept* demonstrating the following features:
{F299745} {F299745}
Installing the addon
--------------------
* If you don't have one already, sign up for an account at
the [Blender ID site](https://www.blender.org/id/).
* Install and log in with the
[Blender ID addon](https://developer.blender.org/diffusion/BIA/).
* Install the Blender Cloud addon in Blender (User Preferences →
Addons → Install from file...) by pointing it to
`blender_cloud*.addon.zip`.
* Enable the addon in User Preferences → Addons → System.
Running the addon
-----------------
After installing the Blender Cloud addon, press Ctrl+Alt+Shift+A to
activate it (yes, this needs work). Downloaded textures are loaded into
image datablocks. The download location can be configured in the addon
preferences.
Building an installable ZIP file Building an installable ZIP file
-------------------------------- --------------------------------
@@ -40,38 +60,6 @@ can find them, or be bundled as wheel files in `blender_cloud/wheels`.
The `python setup.py bdist` command gathers the dependencies and bundles The `python setup.py bdist` command gathers the dependencies and bundles
them as wheel files. them as wheel files.
Installing the addon
--------------------
* To build the addon, run `python setup.py bdist` as described above.
* If you don't have one already, sign up for an account at
the [Blender ID site](https://www.blender.org/id/).
* As a final step, install and log in with the
[Blender ID addon](https://developer.blender.org/diffusion/BIA/).
* Install the Blender Cloud addon in Blender (User Preferences →
Addons → Install from file...) by pointing it to
`dist/blender_cloud*.addon.zip`.
* Enable the addon in User Preferences → Addons → System.
NOTE: The addon requires HTTPS connections, and thus is dependent on
[D1845](https://developer.blender.org/D1845). You can do either of
these:
* Build Blender yourself
* Get a recent copy from the buildbot
* Copy certificate authority certificate PEM file to
`blender/2.77/python/lib/python3.5/site-packages/requests/cacert.pem`.
You can use the same file from your local requests installation, or
use `/etc/ssl/certs/ca-certificates.crt`.
Running the addon
-----------------
After installing the Blender Cloud addon, press Ctrl+Alt+Shift+A to
activate it (yes, this needs work). Downloaded textures are loaded into
image datablocks. The download location can be configured in the addon
preferences.
Design Design
------ ------

View File

@@ -19,26 +19,19 @@
# <pep8 compliant> # <pep8 compliant>
bl_info = { bl_info = {
"name": "Blender Cloud Texture Browser", 'name': 'Blender Cloud Texture Browser',
"author": "Sybren A. Stüvel and Francesco Siddi", 'author': 'Sybren A. Stüvel and Francesco Siddi',
"version": (0, 2, 0), 'version': (1, 0, 1),
"blender": (2, 77, 0), 'blender': (2, 77, 0),
"location": "TO BE DETERMINED", 'location': 'Ctrl+Shift+Alt+A anywhere',
"description": "Allows downloading of textures from the Blender Cloud. Requires " 'description': 'Allows downloading of textures from the Blender Cloud. Requires '
"the Blender ID addon.", 'the Blender ID addon and Blender 2.77a or newer.',
"wiki_url": "http://wiki.blender.org/index.php/Extensions:2.6/Py/" 'wiki_url': 'http://wiki.blender.org/index.php/Extensions:2.6/Py/'
"Scripts/System/BlenderCloud", 'Scripts/System/BlenderCloud',
"category": "System", 'category': 'System',
"support": "TESTING" 'support': 'TESTING'
} }
import logging
logging.basicConfig(level=logging.INFO,
format='%(asctime)-15s %(levelname)8s %(name)s %(message)s')
logging.getLogger('cachecontrol').setLevel(logging.DEBUG)
logging.getLogger(__name__).setLevel(logging.DEBUG)
# Support reloading # Support reloading
if 'pillar' in locals(): if 'pillar' in locals():
import importlib import importlib

View File

@@ -4,6 +4,7 @@ import asyncio
import traceback import traceback
import concurrent.futures import concurrent.futures
import logging import logging
import gc
import bpy import bpy
@@ -53,6 +54,9 @@ def kick_async_loop(*args) -> bool:
len(all_tasks)) len(all_tasks))
stop_after_this_kick = True stop_after_this_kick = True
# Clean up circular references between tasks.
gc.collect()
for task_idx, task in enumerate(all_tasks): for task_idx, task in enumerate(all_tasks):
if not task.done(): if not task.done():
continue continue
@@ -68,6 +72,9 @@ def kick_async_loop(*args) -> bool:
print('{}: resulted in exception'.format(task)) print('{}: resulted in exception'.format(task))
traceback.print_exc() traceback.print_exc()
# for ref in gc.get_referrers(task):
# log.debug(' - referred by %s', ref)
loop.stop() loop.stop()
loop.run_forever() loop.run_forever()

View File

@@ -3,7 +3,7 @@
Separated from __init__.py so that we can import & run from non-Blender environments. Separated from __init__.py so that we can import & run from non-Blender environments.
""" """
import os.path import logging
import bpy import bpy
from bpy.types import AddonPreferences, Operator, WindowManager, Scene from bpy.types import AddonPreferences, Operator, WindowManager, Scene
@@ -11,16 +11,31 @@ from bpy.props import StringProperty
from . import pillar, gui from . import pillar, gui
PILLAR_SERVER_URL = 'https://cloudapi.blender.org/'
# PILLAR_SERVER_URL = 'http://localhost:5000/'
ADDON_NAME = 'blender_cloud' ADDON_NAME = 'blender_cloud'
log = logging.getLogger(__name__)
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
# addon and allow for proper testing within this scope.
pillar_server = bpy.props.StringProperty( pillar_server = bpy.props.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='https://pillar.blender.org:5000/' default=PILLAR_SERVER_URL,
get=lambda self: PILLAR_SERVER_URL
)
# TODO: Move to the Scene properties?
project_uuid = bpy.props.StringProperty(
name='Project UUID',
description='UUID of the current Blender Cloud project',
default='5672beecc0261b2005ed1a33',
get=lambda self: '5672beecc0261b2005ed1a33'
) )
local_texture_dir = StringProperty( local_texture_dir = StringProperty(
@@ -29,34 +44,45 @@ class BlenderCloudPreferences(AddonPreferences):
default='//textures') default='//textures')
def draw(self, context): def draw(self, context):
import textwrap
layout = self.layout layout = self.layout
# Carefully try and import the Blender ID addon # Carefully try and import the Blender ID addon
try: try:
import blender_id.profiles as blender_id_profiles import blender_id
except ImportError: except ImportError:
blender_id_profiles = None blender_id = None
blender_id_profile = None blender_id_profile = None
else: else:
blender_id_profile = blender_id_profiles.get_active_profile() blender_id_profile = blender_id.get_active_profile()
if blender_id_profiles is None: if blender_id is None:
blender_id_icon = 'ERROR' icon = 'ERROR'
blender_id_text = "This add-on requires Blender ID" text = 'This add-on requires Blender ID'
blender_id_help = "Make sure that the Blender ID add-on is installed and activated" help_text = 'Make sure that the Blender ID add-on is installed and activated'
elif not blender_id_profile: elif not blender_id_profile:
blender_id_icon = 'ERROR' icon = 'ERROR'
blender_id_text = "You are logged out." text = 'You are logged out.'
blender_id_help = "To login, go to the Blender ID add-on preferences." help_text = 'To login, go to the Blender ID add-on preferences.'
elif pillar.SUBCLIENT_ID not in blender_id_profile.subclients:
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: else:
blender_id_icon = 'WORLD_DATA' icon = 'WORLD_DATA'
blender_id_text = "You are logged in as %s." % blender_id_profile['username'] text = 'You are logged in as %s.' % blender_id_profile.username
blender_id_help = "To logout or change profile, " \ help_text = ('To logout or change profile, '
"go to the Blender ID add-on preferences." 'go to the Blender ID add-on preferences.')
sub = layout.column() sub = layout.column(align=True)
sub.label(text=blender_id_text, icon=blender_id_icon) sub.label(text=text, icon=icon)
sub.label(text="* " + blender_id_help)
help_lines = textwrap.wrap(help_text, 80)
for line in help_lines:
sub.label(text=line)
sub = layout.column() sub = layout.column()
sub.label(text='Local directory for downloaded textures') sub.label(text='Local directory for downloaded textures')
@@ -65,15 +91,19 @@ class BlenderCloudPreferences(AddonPreferences):
# options for Pillar # options for Pillar
sub = layout.column() sub = layout.column()
sub.enabled = blender_id_icon != 'ERROR' sub.enabled = icon != 'ERROR'
sub.prop(self, "pillar_server")
# TODO: let users easily pick a project. For now, we just use the
# hard-coded server URL and UUID of the textures project.
# sub.prop(self, "pillar_server")
# sub.prop(self, "project_uuid")
sub.operator("pillar.credentials_update") sub.operator("pillar.credentials_update")
class PillarCredentialsUpdate(Operator): class PillarCredentialsUpdate(Operator):
"""Updates the Pillar URL and tests the new URL.""" """Updates the Pillar URL and tests the new URL."""
bl_idname = "pillar.credentials_update" bl_idname = 'pillar.credentials_update'
bl_label = "Update credentials" bl_label = 'Update credentials'
@classmethod @classmethod
def poll(cls, context): def poll(cls, context):
@@ -82,26 +112,35 @@ class PillarCredentialsUpdate(Operator):
@classmethod @classmethod
def is_logged_in(cls, context): def is_logged_in(cls, context):
active_user_id = getattr(context.window_manager, 'blender_id_active_profile', None) try:
return bool(active_user_id) import blender_id
except ImportError:
return False
return blender_id.is_logged_in()
def execute(self, context): def execute(self, context):
import blender_id
import asyncio
# Only allow activation when the user is actually logged in. # Only allow activation when the user is actually logged in.
if not self.is_logged_in(context): if not self.is_logged_in(context):
self.report({'ERROR'}, "No active profile found") self.report({'ERROR'}, 'No active profile found')
return {'CANCELLED'} return {'CANCELLED'}
# Test the new URL
endpoint = bpy.context.user_preferences.addons[ADDON_NAME].preferences.pillar_server
pillar._pillar_api = None
try: try:
pillar.get_project_uuid('textures') # Just any query will do. loop = asyncio.get_event_loop()
except Exception as e: loop.run_until_complete(pillar.refresh_pillar_credentials())
print(e) except blender_id.BlenderIdCommError as ex:
self.report({'ERROR'}, 'Failed connection to %s' % endpoint) log.exception('Error sending subclient-specific token to Blender ID')
return {'FINISHED'} 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'}
self.report({'INFO'}, 'Updated cloud server address to %s' % endpoint) self.report({'INFO'}, 'Blender Cloud credentials & endpoint URL updated.')
return {'FINISHED'} return {'FINISHED'}

View File

@@ -34,12 +34,16 @@ def cache_directory(*subdirs) -> str:
from . import pillar from . import pillar
profile = pillar.blender_id_profile() or {'username': 'anonymous'} profile = pillar.blender_id_profile()
if profile:
username = profile.username
else:
username = 'anonymous'
# TODO: use bpy.utils.user_resource('CACHE', ...) # TODO: use bpy.utils.user_resource('CACHE', ...)
# once https://developer.blender.org/T47684 is finished. # once https://developer.blender.org/T47684 is finished.
user_cache_dir = appdirs.user_cache_dir(appname='Blender', appauthor=False) user_cache_dir = appdirs.user_cache_dir(appname='Blender', appauthor=False)
cache_dir = os.path.join(user_cache_dir, 'blender_cloud', profile['username'], *subdirs) cache_dir = os.path.join(user_cache_dir, 'blender_cloud', username, *subdirs)
os.makedirs(cache_dir, mode=0o700, exist_ok=True) os.makedirs(cache_dir, mode=0o700, exist_ok=True)

View File

@@ -182,7 +182,7 @@ class BlenderCloudBrowser(bpy.types.Operator):
_draw_handle = None _draw_handle = None
_state = 'BROWSING' _state = 'INITIALIZING'
project_uuid = '5672beecc0261b2005ed1a33' # Blender Cloud project UUID project_uuid = '5672beecc0261b2005ed1a33' # Blender Cloud project UUID
node = None # The Node object we're currently showing, or None if we're at the project top. node = None # The Node object we're currently showing, or None if we're at the project top.
@@ -208,6 +208,12 @@ class BlenderCloudBrowser(bpy.types.Operator):
mouse_y = 0 mouse_y = 0
def invoke(self, context, event): def invoke(self, context, event):
# Refuse to start if the file hasn't been saved.
if not context.blend_data.is_saved:
self.report({'ERROR'}, 'Please save your Blend file before using '
'the Blender Cloud addon.')
return {'CANCELLED'}
wm = context.window_manager wm = context.window_manager
self.project_uuid = wm.blender_cloud_project self.project_uuid = wm.blender_cloud_project
self.node_uuid = wm.blender_cloud_node self.node_uuid = wm.blender_cloud_node
@@ -229,8 +235,9 @@ class BlenderCloudBrowser(bpy.types.Operator):
self.current_display_content = [] self.current_display_content = []
self.loaded_images = set() self.loaded_images = set()
self.browse_assets() self.check_credentials()
context.window.cursor_modal_set('DEFAULT')
context.window_manager.modal_handler_add(self) context.window_manager.modal_handler_add(self)
self.timer = context.window_manager.event_timer_add(1 / 30, context.window) self.timer = context.window_manager.event_timer_add(1 / 30, context.window)
@@ -262,9 +269,21 @@ class BlenderCloudBrowser(bpy.types.Operator):
self.mouse_x = event.mouse_x self.mouse_x = event.mouse_x
self.mouse_y = event.mouse_y self.mouse_y = event.mouse_y
if self._state == 'BROWSING' and event.type == 'LEFTMOUSE' and event.value == 'RELEASE': left_mouse_release = event.type == 'LEFTMOUSE' and event.value == 'RELEASE'
if self._state == 'PLEASE_SUBSCRIBE' and left_mouse_release:
self.open_browser_subscribe()
self._finish(context)
return {'FINISHED'}
if self._state == 'BROWSING':
selected = self.get_clicked() selected = self.get_clicked()
if selected:
context.window.cursor_set('HAND')
else:
context.window.cursor_set('DEFAULT')
if left_mouse_release:
if selected is None: if selected is None:
# No item clicked, ignore it. # No item clicked, ignore it.
return {'RUNNING_MODAL'} return {'RUNNING_MODAL'}
@@ -279,12 +298,55 @@ class BlenderCloudBrowser(bpy.types.Operator):
return {'RUNNING_MODAL'} return {'RUNNING_MODAL'}
self.handle_item_selection(context, selected) self.handle_item_selection(context, selected)
elif event.type in {'RIGHTMOUSE', 'ESC'}: if event.type in {'RIGHTMOUSE', 'ESC'}:
self._finish(context) self._finish(context)
return {'CANCELLED'} return {'CANCELLED'}
return {'RUNNING_MODAL'} return {'RUNNING_MODAL'}
def check_credentials(self):
self._state = 'CHECKING_CREDENTIALS'
self.log.debug('Checking credentials')
self._new_async_task(self._check_credentials())
async def _check_credentials(self):
"""Checks credentials with Pillar, and if ok goes to the BROWSING state."""
try:
await pillar.check_pillar_credentials()
except pillar.NotSubscribedToCloudError:
self.log.info('User not subscribed to Blender Cloud.')
self._show_subscribe_screen()
return
except pillar.CredentialsNotSyncedError:
self.log.info('Credentials not synced, re-syncing automatically.')
else:
self.log.info('Credentials okay, browsing assets.')
await self.async_download_previews()
return
try:
await pillar.refresh_pillar_credentials()
except pillar.NotSubscribedToCloudError:
self.log.info('User is not a Blender Cloud subscriber.')
self._show_subscribe_screen()
return
except pillar.UserNotLoggedInError:
self.error('User not logged in on Blender ID.')
else:
self.log.info('Credentials refreshed and ok, browsing assets.')
await self.async_download_previews()
return
raise pillar.UserNotLoggedInError()
# self._new_async_task(self._check_credentials())
def _show_subscribe_screen(self):
"""Shows the "You need to subscribe" screen."""
self._state = 'PLEASE_SUBSCRIBE'
bpy.context.window.cursor_set('HAND')
def descend_node(self, node): def descend_node(self, node):
"""Descends the node hierarchy by visiting this node. """Descends the node hierarchy by visiting this node.
@@ -313,6 +375,7 @@ class BlenderCloudBrowser(bpy.types.Operator):
return return
# Signal that we want to stop. # Signal that we want to stop.
self.async_task.cancel()
if not self.signalling_future.done(): if not self.signalling_future.done():
self.log.info("Signalling that we want to cancel anything that's running.") self.log.info("Signalling that we want to cancel anything that's running.")
self.signalling_future.cancel() self.signalling_future.cancel()
@@ -342,6 +405,7 @@ class BlenderCloudBrowser(bpy.types.Operator):
context.space_data.draw_handler_remove(self._draw_handle, 'WINDOW') context.space_data.draw_handler_remove(self._draw_handle, 'WINDOW')
context.window_manager.event_timer_remove(self.timer) context.window_manager.event_timer_remove(self.timer)
context.window.cursor_modal_restore()
if self.maximized_area: if self.maximized_area:
bpy.ops.screen.screen_full_area(use_hide_panels=True) bpy.ops.screen.screen_full_area(use_hide_panels=True)
@@ -385,7 +449,10 @@ class BlenderCloudBrowser(bpy.types.Operator):
else: else:
raise ValueError('Unable to find MenuItem(node_uuid=%r)' % node_uuid) raise ValueError('Unable to find MenuItem(node_uuid=%r)' % node_uuid)
async def async_download_previews(self, thumbnails_directory): async def async_download_previews(self):
self._state = 'BROWSING'
thumbnails_directory = self.thumbnails_cache
self.log.info('Asynchronously downloading previews to %r', thumbnails_directory) self.log.info('Asynchronously downloading previews to %r', thumbnails_directory)
self.clear_images() self.clear_images()
@@ -395,7 +462,8 @@ class BlenderCloudBrowser(bpy.types.Operator):
def thumbnail_loaded(node, file_desc, thumb_path): def thumbnail_loaded(node, file_desc, thumb_path):
self.update_menu_item(node, file_desc, thumb_path, file_desc['filename']) self.update_menu_item(node, file_desc, thumb_path, file_desc['filename'])
# Download either by group_texture node UUID or by project UUID (which shows all top-level nodes) # Download either by group_texture node UUID or by project UUID (which
# shows all top-level nodes)
if self.node_uuid: if self.node_uuid:
self.log.debug('Getting subnodes for parent node %r', self.node_uuid) self.log.debug('Getting subnodes for parent node %r', self.node_uuid)
children = await pillar.get_nodes(parent_node_uuid=self.node_uuid, children = await pillar.get_nodes(parent_node_uuid=self.node_uuid,
@@ -434,11 +502,10 @@ class BlenderCloudBrowser(bpy.types.Operator):
future=self.signalling_future) future=self.signalling_future)
def browse_assets(self): def browse_assets(self):
self._state = 'BROWSING'
self.log.debug('Browsing assets at project %r node %r', self.project_uuid, self.node_uuid) self.log.debug('Browsing assets at project %r node %r', self.project_uuid, self.node_uuid)
self._new_async_task(self.async_download_previews(self.thumbnails_cache)) self._new_async_task(self.async_download_previews())
def _new_async_task(self, async_task: asyncio.coroutine, future: asyncio.Future=None): def _new_async_task(self, async_task: asyncio.coroutine, future: asyncio.Future = None):
"""Stops the currently running async task, and starts another one.""" """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)
@@ -456,9 +523,11 @@ class BlenderCloudBrowser(bpy.types.Operator):
"""Draws the GUI with OpenGL.""" """Draws the GUI with OpenGL."""
drawers = { drawers = {
'CHECKING_CREDENTIALS': self._draw_checking_credentials,
'BROWSING': self._draw_browser, 'BROWSING': self._draw_browser,
'DOWNLOADING_TEXTURE': self._draw_downloading, 'DOWNLOADING_TEXTURE': self._draw_downloading,
'EXCEPTION': self._draw_exception, 'EXCEPTION': self._draw_exception,
'PLEASE_SUBSCRIBE': self._draw_subscribe,
} }
if self._state in drawers: if self._state in drawers:
@@ -530,14 +599,24 @@ class BlenderCloudBrowser(bpy.types.Operator):
def _draw_downloading(self, context): def _draw_downloading(self, context):
"""OpenGL drawing code for the DOWNLOADING_TEXTURE state.""" """OpenGL drawing code for the DOWNLOADING_TEXTURE state."""
content_height, content_width = self._window_size(context) 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))
def _draw_text_on_colour(self, context, text, bgcolour):
content_height, content_width = self._window_size(context)
bgl.glEnable(bgl.GL_BLEND) bgl.glEnable(bgl.GL_BLEND)
bgl.glColor4f(0.0, 0.0, 0.2, 0.6) bgl.glColor4f(*bgcolour)
bgl.glRectf(0, 0, content_width, content_height) bgl.glRectf(0, 0, content_width, content_height)
font_id = 0 font_id = 0
text = "Downloading texture from Blender Cloud"
bgl.glColor4f(1.0, 1.0, 1.0, 1.0) bgl.glColor4f(1.0, 1.0, 1.0, 1.0)
blf.size(font_id, 20, 72) blf.size(font_id, 20, 72)
text_width, text_height = blf.dimensions(font_id, text) text_width, text_height = blf.dimensions(font_id, text)
@@ -566,7 +645,15 @@ class BlenderCloudBrowser(bpy.types.Operator):
bgl.glRectf(0, 0, content_width, content_height) bgl.glRectf(0, 0, content_width, content_height)
font_id = 0 font_id = 0
text = "An error occurred:\n%s" % self.async_task.exception() 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, ' \
'System, Blender ID.'
else:
ex_msg = str(ex)
if not ex_msg:
ex_msg = str(type(ex))
text = "An error occurred:\n%s" % ex_msg
lines = textwrap.wrap(text) lines = textwrap.wrap(text)
bgl.glColor4f(1.0, 1.0, 1.0, 1.0) bgl.glColor4f(1.0, 1.0, 1.0, 1.0)
@@ -583,6 +670,11 @@ class BlenderCloudBrowser(bpy.types.Operator):
blf.draw(font_id, line) blf.draw(font_id, line)
bgl.glDisable(bgl.GL_BLEND) 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))
def get_clicked(self) -> MenuItem: def get_clicked(self) -> MenuItem:
for item in self.current_display_content: for item in self.current_display_content:
@@ -631,6 +723,13 @@ class BlenderCloudBrowser(bpy.types.Operator):
future=signalling_future)) future=signalling_future))
self.async_task.add_done_callback(texture_download_completed) self.async_task.add_done_callback(texture_download_completed)
def open_browser_subscribe(self):
import webbrowser
webbrowser.open_new_tab('https://cloud.blender.org/join')
self.report({'INFO'}, 'We just started a browser for you.')
# store keymaps here to access after registration # store keymaps here to access after registration
addon_keymaps = [] addon_keymaps = []
@@ -644,7 +743,7 @@ def menu_draw(self, context):
def register(): def register():
bpy.utils.register_class(BlenderCloudBrowser) bpy.utils.register_class(BlenderCloudBrowser)
bpy.types.INFO_MT_mesh_add.append(menu_draw) # bpy.types.INFO_MT_mesh_add.append(menu_draw)
# handle the keymap # handle the keymap
wm = bpy.context.window_manager wm = bpy.context.window_manager

View File

@@ -4,6 +4,7 @@ import os
import functools import functools
import logging import logging
from contextlib import closing, contextmanager from contextlib import closing, contextmanager
import pathlib
import requests import requests
import requests.structures import requests.structures
@@ -14,6 +15,8 @@ from pillarsdk.utils import sanitize_filename
from . import cache from . import cache
SUBCLIENT_ID = 'PILLAR'
_pillar_api = None # will become a pillarsdk.Api object. _pillar_api = None # will become a pillarsdk.Api object.
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
uncached_session = requests.session() uncached_session = requests.session()
@@ -27,6 +30,17 @@ class UserNotLoggedInError(RuntimeError):
This is basically for every interaction with Pillar. This is basically for every interaction with Pillar.
""" """
def __str__(self):
return self.__class__.__name__
class CredentialsNotSyncedError(UserNotLoggedInError):
"""Raised when the user may be logged in on Blender ID, but has no Blender Cloud token."""
class NotSubscribedToCloudError(UserNotLoggedInError):
"""Raised when the user may be logged in on Blender ID, but has no Blender Cloud token."""
class PillarError(RuntimeError): class PillarError(RuntimeError):
"""Raised when there is some issue with the communication with Pillar. """Raised when there is some issue with the communication with Pillar.
@@ -37,6 +51,34 @@ class PillarError(RuntimeError):
""" """
class CloudPath(pathlib.PurePosixPath):
"""Cloud path, in the form of /project uuid/node uuid/node uuid/...
The components are:
- the root '/'
- the project UUID
- zero or more node UUIDs.
"""
@property
def project_uuid(self) -> str:
assert self.parts[0] == '/'
return self.parts[1]
@property
def node_uuids(self) -> list:
assert self.parts[0] == '/'
return self.parts[2:]
@property
def node_uuid(self) -> str:
node_uuids = self.node_uuids
if not node_uuids:
return None
return node_uuids[-1]
@contextmanager @contextmanager
def with_existing_dir(filename: str, open_mode: str, encoding=None): def with_existing_dir(filename: str, open_mode: str, encoding=None):
"""Opens a file, ensuring its directory exists.""" """Opens a file, ensuring its directory exists."""
@@ -55,21 +97,15 @@ def save_as_json(pillar_resource, json_filename):
json.dump(pillar_resource, outfile, sort_keys=True, cls=pillarsdk.utils.PillarJSONEncoder) json.dump(pillar_resource, outfile, sort_keys=True, cls=pillarsdk.utils.PillarJSONEncoder)
def blender_id_profile() -> dict: def blender_id_profile() -> 'blender_id.BlenderIdProfile':
"""Returns the Blender ID profile of the currently logged in user.""" """Returns the Blender ID profile of the currently logged in user."""
# Allow overriding before we import the bpy module. # Allow overriding before we import the bpy module.
if _testing_blender_id_profile is not None: if _testing_blender_id_profile is not None:
return _testing_blender_id_profile return _testing_blender_id_profile
import bpy import blender_id
return blender_id.get_active_profile()
active_user_id = getattr(bpy.context.window_manager, 'blender_id_active_profile', None)
if not active_user_id:
return None
import blender_id.profiles
return blender_id.profiles.get_active_profile()
def pillar_api(pillar_endpoint: str = None) -> pillarsdk.Api: def pillar_api(pillar_endpoint: str = None) -> pillarsdk.Api:
@@ -88,6 +124,10 @@ def pillar_api(pillar_endpoint: str = None) -> pillarsdk.Api:
if not profile: if not profile:
raise UserNotLoggedInError() raise UserNotLoggedInError()
subclient = profile.subclients.get(SUBCLIENT_ID)
if not subclient:
raise CredentialsNotSyncedError()
if _pillar_api is None: if _pillar_api is None:
# Allow overriding the endpoint before importing Blender-specific stuff. # Allow overriding the endpoint before importing Blender-specific stuff.
if pillar_endpoint is None: if pillar_endpoint is None:
@@ -97,24 +137,91 @@ def pillar_api(pillar_endpoint: str = None) -> pillarsdk.Api:
pillarsdk.Api.requests_session = cache.requests_session() pillarsdk.Api.requests_session = cache.requests_session()
_pillar_api = pillarsdk.Api(endpoint=pillar_endpoint, _pillar_api = pillarsdk.Api(endpoint=pillar_endpoint,
username=profile['username'], username=subclient['subclient_user_id'],
password=None, password=SUBCLIENT_ID,
token=profile['token']) token=subclient['token'])
return _pillar_api return _pillar_api
# No more than this many Pillar calls should be made simultaneously
pillar_semaphore = asyncio.Semaphore(3)
async def pillar_call(pillar_func, *args, **kwargs):
partial = functools.partial(pillar_func, *args, api=pillar_api(), **kwargs)
loop = asyncio.get_event_loop()
async with pillar_semaphore:
return await loop.run_in_executor(None, partial)
async def check_pillar_credentials():
"""Tries to obtain the user at Pillar using the user's credentials.
:raises UserNotLoggedInError: when the user is not logged in on Blender ID.
:raises CredentialsNotSyncedError: when the user is logged in on Blender ID but
doesn't have a valid subclient token for Pillar.
"""
profile = blender_id_profile()
if not profile:
raise UserNotLoggedInError()
subclient = profile.subclients.get(SUBCLIENT_ID)
if not subclient:
raise CredentialsNotSyncedError()
pillar_user_id = subclient['subclient_user_id']
if not pillar_user_id:
raise CredentialsNotSyncedError()
try:
db_user = await pillar_call(pillarsdk.User.find, pillar_user_id)
except pillarsdk.UnauthorizedAccess:
raise CredentialsNotSyncedError()
roles = db_user.roles
log.debug('User has roles %r', roles)
if not roles or not {'subscriber', 'demo'}.intersection(set(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()
async def refresh_pillar_credentials():
"""Refreshes the authentication token on Pillar.
:raises blender_id.BlenderIdCommError: when Blender ID refuses to send a token to Pillar.
:raises Exception: when the Pillar credential check fails.
"""
global _pillar_api
import blender_id
from . import blender
pillar_endpoint = blender.preferences().pillar_server.rstrip('/')
# Create a subclient token and send it to Pillar.
# May raise a blender_id.BlenderIdCommError
blender_id.create_subclient_token(SUBCLIENT_ID, pillar_endpoint)
# Test the new URL
_pillar_api = None
await check_pillar_credentials()
async def get_project_uuid(project_url: str) -> str: async def get_project_uuid(project_url: str) -> str:
"""Returns the UUID for the project, given its '/p/<project_url>' string.""" """Returns the UUID for the project, given its '/p/<project_url>' string."""
find_one = functools.partial(pillarsdk.Project.find_one, { try:
project = await pillar_call(pillarsdk.Project.find_one, {
'where': {'url': project_url}, 'where': {'url': project_url},
'projection': {'permissions': 1}, 'projection': {'permissions': 1},
}, api=pillar_api()) })
loop = asyncio.get_event_loop()
try:
project = await loop.run_in_executor(None, find_one)
except pillarsdk.exceptions.ResourceNotFound: 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 return None
@@ -151,17 +258,14 @@ async def get_nodes(project_uuid: str = None, parent_node_uuid: str = None,
if node_type: if node_type:
where['node_type'] = node_type where['node_type'] = node_type
node_all = functools.partial(pillarsdk.Node.all, { children = await pillar_call(pillarsdk.Node.all, {
'projection': {'name': 1, 'parent': 1, 'node_type': 1, 'projection': {'name': 1, 'parent': 1, 'node_type': 1,
'properties.order': 1, 'properties.status': 1, 'properties.order': 1, 'properties.status': 1,
'properties.files': 1, 'properties.files': 1,
'properties.content_type': 1, 'picture': 1}, 'properties.content_type': 1, 'picture': 1},
'where': where, 'where': where,
'sort': 'properties.order', 'sort': 'properties.order',
'embed': ['parent']}, api=pillar_api()) 'embed': ['parent']})
loop = asyncio.get_event_loop()
children = await loop.run_in_executor(None, node_all)
return children['_items'] return children['_items']
@@ -277,11 +381,7 @@ async def fetch_thumbnail_info(file: pillarsdk.File, directory: str, desired_siz
finished. finished.
""" """
api = pillar_api() thumb_link = await pillar_call(file.thumbnail_file, desired_size)
loop = asyncio.get_event_loop()
thumb_link = await loop.run_in_executor(None, functools.partial(
file.thumbnail_file, desired_size, api=api))
if thumb_link is None: if thumb_link is None:
raise ValueError("File {} has no thumbnail of size {}" raise ValueError("File {} has no thumbnail of size {}"
@@ -323,21 +423,12 @@ async def fetch_texture_thumbs(parent_node_uuid: str, desired_size: str,
log.warning('fetch_texture_thumbs: Texture downloading cancelled') log.warning('fetch_texture_thumbs: Texture downloading cancelled')
return return
# We don't want to gather too much in parallel, as it will make cancelling take more time.
# This is caused by HTTP requests going out in parallel, and once the socket is open and
# the GET request is sent, we can't cancel until the server starts streaming the response.
chunk_size = 2
for i in range(0, len(texture_nodes), chunk_size):
chunk = texture_nodes[i:i + chunk_size]
log.debug('fetch_texture_thumbs: Gathering texture[%i:%i] for parent node %r',
i, i + chunk_size, parent_node_uuid)
coros = (download_texture_thumbnail(texture_node, desired_size, coros = (download_texture_thumbnail(texture_node, desired_size,
thumbnail_directory, thumbnail_directory,
thumbnail_loading=thumbnail_loading, thumbnail_loading=thumbnail_loading,
thumbnail_loaded=thumbnail_loaded, thumbnail_loaded=thumbnail_loaded,
future=future) future=future)
for texture_node in chunk) for texture_node in texture_nodes)
# raises any exception from failed handle_texture_node() calls. # raises any exception from failed handle_texture_node() calls.
await asyncio.gather(*coros) await asyncio.gather(*coros)
@@ -360,17 +451,14 @@ async def download_texture_thumbnail(texture_node, desired_size: str,
texture_node['_id']) texture_node['_id'])
return return
api = pillar_api()
loop = asyncio.get_event_loop() loop = asyncio.get_event_loop()
file_find = functools.partial(pillarsdk.File.find, params={
'projection': {'filename': 1, 'variations': 1, 'width': 1, 'height': 1},
}, api=api)
# Find the File that belongs to this texture node # Find the File that belongs to this texture node
pic_uuid = texture_node['picture'] pic_uuid = texture_node['picture']
loop.call_soon_threadsafe(thumbnail_loading, texture_node, texture_node) loop.call_soon_threadsafe(thumbnail_loading, texture_node, texture_node)
file_desc = await loop.run_in_executor(None, file_find, pic_uuid) file_desc = await pillar_call(pillarsdk.File.find, pic_uuid, params={
'projection': {'filename': 1, 'variations': 1, 'width': 1, 'height': 1},
})
if file_desc is None: 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)
@@ -402,7 +490,7 @@ async def download_file_by_uuid(file_uuid,
target_directory: str, target_directory: str,
metadata_directory: str, metadata_directory: str,
*, *,
map_type: str=None, map_type: str = None,
file_loading: callable, file_loading: callable,
file_loaded: callable, file_loaded: callable,
future: asyncio.Future): future: asyncio.Future):
@@ -413,11 +501,9 @@ async def download_file_by_uuid(file_uuid,
loop = asyncio.get_event_loop() loop = asyncio.get_event_loop()
# Find the File document. # Find the File document.
api = pillar_api() file_desc = await pillar_call(pillarsdk.File.find, file_uuid, params={
file_find = functools.partial(pillarsdk.File.find, params={
'projection': {'link': 1, 'filename': 1}, 'projection': {'link': 1, 'filename': 1},
}, api=api) })
file_desc = await loop.run_in_executor(None, file_find, file_uuid)
# Save the file document to disk # 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)
@@ -458,7 +544,7 @@ async def download_texture(texture_node,
future=future) future=future)
for file_info in texture_node['properties']['files']) for file_info in texture_node['properties']['files'])
return await asyncio.gather(*downloaders) return await asyncio.gather(*downloaders, return_exceptions=True)
def is_cancelled(future: asyncio.Future) -> bool: def is_cancelled(future: asyncio.Future) -> bool:

View File

@@ -38,4 +38,4 @@ def load_wheel(module_name, fname_prefix):
def load_wheels(): def load_wheels():
load_wheel('lockfile', 'lockfile') load_wheel('lockfile', 'lockfile')
load_wheel('cachecontrol', 'CacheControl') load_wheel('cachecontrol', 'CacheControl')
load_wheel('pillarsdk', 'pillar_sdk') load_wheel('pillarsdk', 'pillarsdk')

View File

@@ -1,2 +1,15 @@
# Primary requirements:
CacheControl==0.11.6 CacheControl==0.11.6
lockfile==0.12.2 lockfile==0.12.2
pillarsdk==1.1.2
wheel==0.29.0
# Secondary requirements:
cffi==1.6.0
cryptography==1.3.1
idna==2.1
pyasn1==0.1.9
pycparser==2.14
pyOpenSSL==16.0.0
requests==2.10.0
six==1.10.0

View File

@@ -1,10 +1,10 @@
#!/usr/bin/env python #!/usr/bin/env python
import glob
import sys import sys
import shutil import shutil
import subprocess import subprocess
import re import re
import pathlib import pathlib
from glob import glob
from distutils import log from distutils import log
from distutils.core import Command from distutils.core import Command
@@ -16,6 +16,15 @@ from setuptools import setup, find_packages
requirement_re = re.compile('[><=]+') requirement_re = re.compile('[><=]+')
def set_default_path(var, default):
"""convert CLI-arguments (string) to Paths"""
if var is None:
return default
return pathlib.Path(var)
# noinspection PyAttributeOutsideInit
class BuildWheels(Command): class BuildWheels(Command):
"""Builds or downloads the dependencies as wheel files.""" """Builds or downloads the dependencies as wheel files."""
@@ -23,30 +32,21 @@ class BuildWheels(Command):
user_options = [ user_options = [
('wheels-path=', None, "wheel file installation path"), ('wheels-path=', None, "wheel file installation path"),
('deps-path=', None, "path in which dependencies are built"), ('deps-path=', None, "path in which dependencies are built"),
('pillar-sdk-path=', None, "subdir of deps-path containing the Pillar Python SDK"),
('cachecontrol-path=', None, "subdir of deps-path containing CacheControl"), ('cachecontrol-path=', None, "subdir of deps-path containing CacheControl"),
] ]
def initialize_options(self): def initialize_options(self):
self.wheels_path = None # path that will contain the installed wheels. self.wheels_path = None # path that will contain the installed wheels.
self.deps_path = None # path in which dependencies are built. self.deps_path = None # path in which dependencies are built.
self.pillar_sdk_path = None # subdir of deps_path containing the Pillar Python SDK
self.cachecontrol_path = None # subdir of deps_path containing CacheControl self.cachecontrol_path = None # subdir of deps_path containing CacheControl
def finalize_options(self): def finalize_options(self):
self.my_path = pathlib.Path(__file__).resolve().parent self.my_path = pathlib.Path(__file__).resolve().parent
package_path = self.my_path / self.distribution.get_name() package_path = self.my_path / self.distribution.get_name()
def set_default(var, default): self.wheels_path = set_default_path(self.wheels_path, package_path / 'wheels')
if var is None: self.deps_path = set_default_path(self.deps_path, self.my_path / 'build/deps')
return default self.cachecontrol_path = set_default_path(self.cachecontrol_path,
return pathlib.Path(var) # convert CLI-arguments (string) to Paths.
self.wheels_path = set_default(self.wheels_path, package_path / 'wheels')
self.deps_path = set_default(self.deps_path, self.my_path / 'build/deps')
self.pillar_sdk_path = set_default(self.pillar_sdk_path,
self.deps_path / 'pillar-python-sdk')
self.cachecontrol_path = set_default(self.cachecontrol_path,
self.deps_path / 'cachecontrol') self.deps_path / 'cachecontrol')
def run(self): def run(self):
@@ -73,16 +73,12 @@ class BuildWheels(Command):
# Download lockfile, as there is a suitable wheel on pypi. # Download lockfile, as there is a suitable wheel on pypi.
if not list(self.wheels_path.glob('lockfile*.whl')): if not list(self.wheels_path.glob('lockfile*.whl')):
log.info('Downloading lockfile wheel') log.info('Downloading lockfile wheel')
subprocess.check_call([ self.download_wheel(requirements['lockfile'])
'pip', 'download', '--dest', str(self.wheels_path), requirements['lockfile'][0]
])
# Build Pillar Python SDK. # Download Pillar Python SDK from pypi.
if not list(self.wheels_path.glob('pillar-python-sdk*.whl')): if not list(self.wheels_path.glob('pillarsdk*.whl')):
log.info('Building Pillar Python SDK in %s', self.pillar_sdk_path) log.info('Downloading Pillar Python SDK wheel')
self.git_clone(self.pillar_sdk_path, self.download_wheel(requirements['pillarsdk'])
'https://github.com/armadillica/pillar-python-sdk.git')
self.build_copy_wheel(self.pillar_sdk_path)
# Build CacheControl. # Build CacheControl.
if not list(self.wheels_path.glob('CacheControl*.whl')): if not list(self.wheels_path.glob('CacheControl*.whl')):
@@ -97,6 +93,16 @@ class BuildWheels(Command):
('blender_cloud/wheels', (str(p) for p in self.wheels_path.glob('*.whl'))) ('blender_cloud/wheels', (str(p) for p in self.wheels_path.glob('*.whl')))
) )
def download_wheel(self, requirement):
"""Downloads a wheel from PyPI and saves it in self.wheels_path."""
subprocess.check_call([
'pip', 'download',
'--no-deps',
'--dest', str(self.wheels_path),
requirement[0]
])
def git_clone(self, workdir: pathlib.Path, git_url: str, checkout: str = None): def git_clone(self, workdir: pathlib.Path, git_url: str, checkout: str = None):
if workdir.exists(): if workdir.exists():
# Directory exists, expect it to be set up correctly. # Directory exists, expect it to be set up correctly.
@@ -124,6 +130,8 @@ class BuildWheels(Command):
log.info('copying %s to %s', wheel, self.wheels_path) log.info('copying %s to %s', wheel, self.wheels_path)
shutil.copy(str(wheel), str(self.wheels_path)) shutil.copy(str(wheel), str(self.wheels_path))
# noinspection PyAttributeOutsideInit
class BlenderAddonBdist(bdist): class BlenderAddonBdist(bdist):
"""Ensures that 'python setup.py bdist' creates a zip file.""" """Ensures that 'python setup.py bdist' creates a zip file."""
@@ -137,6 +145,7 @@ class BlenderAddonBdist(bdist):
super().run() super().run()
# noinspection PyAttributeOutsideInit
class BlenderAddonInstall(install): class BlenderAddonInstall(install):
"""Ensures the module is placed at the root of the zip file.""" """Ensures the module is placed at the root of the zip file."""
@@ -164,11 +173,12 @@ setup(
'wheels': BuildWheels}, 'wheels': BuildWheels},
name='blender_cloud', name='blender_cloud',
description='The Blender Cloud addon allows browsing the Blender Cloud from Blender.', description='The Blender Cloud addon allows browsing the Blender Cloud from Blender.',
version='1.0.0', version='1.0.1',
author='Sybren A. Stüvel', author='Sybren A. Stüvel',
author_email='sybren@stuvel.eu', author_email='sybren@stuvel.eu',
packages=find_packages('.'), packages=find_packages('.'),
data_files=[('blender_cloud', ['README.md'])], data_files=[('blender_cloud', ['README.md']),
('blender_cloud/icons', glob.glob('blender_cloud/icons/*'))],
scripts=[], scripts=[],
url='https://developer.blender.org/diffusion/BCA/', url='https://developer.blender.org/diffusion/BCA/',
license='GNU General Public License v2 or later (GPLv2+)', license='GNU General Public License v2 or later (GPLv2+)',