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}
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
--------------------------------
@@ -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
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
------

View File

@@ -19,26 +19,19 @@
# <pep8 compliant>
bl_info = {
"name": "Blender Cloud Texture Browser",
"author": "Sybren A. Stüvel and Francesco Siddi",
"version": (0, 2, 0),
"blender": (2, 77, 0),
"location": "TO BE DETERMINED",
"description": "Allows downloading of textures from the Blender Cloud. Requires "
"the Blender ID addon.",
"wiki_url": "http://wiki.blender.org/index.php/Extensions:2.6/Py/"
"Scripts/System/BlenderCloud",
"category": "System",
"support": "TESTING"
'name': 'Blender Cloud Texture Browser',
'author': 'Sybren A. Stüvel and Francesco Siddi',
'version': (1, 0, 1),
'blender': (2, 77, 0),
'location': 'Ctrl+Shift+Alt+A anywhere',
'description': 'Allows downloading of textures from the Blender Cloud. Requires '
'the Blender ID addon and Blender 2.77a or newer.',
'wiki_url': 'http://wiki.blender.org/index.php/Extensions:2.6/Py/'
'Scripts/System/BlenderCloud',
'category': 'System',
'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
if 'pillar' in locals():
import importlib

View File

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

View File

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

View File

@@ -34,12 +34,16 @@ def cache_directory(*subdirs) -> str:
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', ...)
# 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', profile['username'], *subdirs)
cache_dir = os.path.join(user_cache_dir, 'blender_cloud', username, *subdirs)
os.makedirs(cache_dir, mode=0o700, exist_ok=True)

View File

@@ -182,7 +182,7 @@ class BlenderCloudBrowser(bpy.types.Operator):
_draw_handle = None
_state = 'BROWSING'
_state = 'INITIALIZING'
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.
@@ -208,6 +208,12 @@ class BlenderCloudBrowser(bpy.types.Operator):
mouse_y = 0
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
self.project_uuid = wm.blender_cloud_project
self.node_uuid = wm.blender_cloud_node
@@ -229,8 +235,9 @@ class BlenderCloudBrowser(bpy.types.Operator):
self.current_display_content = []
self.loaded_images = set()
self.browse_assets()
self.check_credentials()
context.window.cursor_modal_set('DEFAULT')
context.window_manager.modal_handler_add(self)
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_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()
if selected:
context.window.cursor_set('HAND')
else:
context.window.cursor_set('DEFAULT')
if left_mouse_release:
if selected is None:
# No item clicked, ignore it.
return {'RUNNING_MODAL'}
@@ -279,12 +298,55 @@ class BlenderCloudBrowser(bpy.types.Operator):
return {'RUNNING_MODAL'}
self.handle_item_selection(context, selected)
elif event.type in {'RIGHTMOUSE', 'ESC'}:
if event.type in {'RIGHTMOUSE', 'ESC'}:
self._finish(context)
return {'CANCELLED'}
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):
"""Descends the node hierarchy by visiting this node.
@@ -313,6 +375,7 @@ class BlenderCloudBrowser(bpy.types.Operator):
return
# Signal that we want to stop.
self.async_task.cancel()
if not self.signalling_future.done():
self.log.info("Signalling that we want to cancel anything that's running.")
self.signalling_future.cancel()
@@ -342,6 +405,7 @@ class BlenderCloudBrowser(bpy.types.Operator):
context.space_data.draw_handler_remove(self._draw_handle, 'WINDOW')
context.window_manager.event_timer_remove(self.timer)
context.window.cursor_modal_restore()
if self.maximized_area:
bpy.ops.screen.screen_full_area(use_hide_panels=True)
@@ -385,7 +449,10 @@ class BlenderCloudBrowser(bpy.types.Operator):
else:
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.clear_images()
@@ -395,7 +462,8 @@ class BlenderCloudBrowser(bpy.types.Operator):
def thumbnail_loaded(node, file_desc, thumb_path):
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:
self.log.debug('Getting subnodes for parent node %r', 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)
def browse_assets(self):
self._state = 'BROWSING'
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."""
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."""
drawers = {
'CHECKING_CREDENTIALS': self._draw_checking_credentials,
'BROWSING': self._draw_browser,
'DOWNLOADING_TEXTURE': self._draw_downloading,
'EXCEPTION': self._draw_exception,
'PLEASE_SUBSCRIBE': self._draw_subscribe,
}
if self._state in drawers:
@@ -530,14 +599,24 @@ class BlenderCloudBrowser(bpy.types.Operator):
def _draw_downloading(self, context):
"""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.glColor4f(0.0, 0.0, 0.2, 0.6)
bgl.glColor4f(*bgcolour)
bgl.glRectf(0, 0, content_width, content_height)
font_id = 0
text = "Downloading texture from Blender Cloud"
bgl.glColor4f(1.0, 1.0, 1.0, 1.0)
blf.size(font_id, 20, 72)
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)
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)
bgl.glColor4f(1.0, 1.0, 1.0, 1.0)
@@ -583,6 +670,11 @@ class BlenderCloudBrowser(bpy.types.Operator):
blf.draw(font_id, line)
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:
for item in self.current_display_content:
@@ -631,6 +723,13 @@ class BlenderCloudBrowser(bpy.types.Operator):
future=signalling_future))
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
addon_keymaps = []
@@ -644,7 +743,7 @@ def menu_draw(self, context):
def register():
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
wm = bpy.context.window_manager

View File

@@ -4,6 +4,7 @@ import os
import functools
import logging
from contextlib import closing, contextmanager
import pathlib
import requests
import requests.structures
@@ -14,6 +15,8 @@ from pillarsdk.utils import sanitize_filename
from . import cache
SUBCLIENT_ID = 'PILLAR'
_pillar_api = None # will become a pillarsdk.Api object.
log = logging.getLogger(__name__)
uncached_session = requests.session()
@@ -27,6 +30,17 @@ class UserNotLoggedInError(RuntimeError):
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):
"""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
def with_existing_dir(filename: str, open_mode: str, encoding=None):
"""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)
def blender_id_profile() -> dict:
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.
if _testing_blender_id_profile is not None:
return _testing_blender_id_profile
import bpy
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()
import blender_id
return blender_id.get_active_profile()
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:
raise UserNotLoggedInError()
subclient = profile.subclients.get(SUBCLIENT_ID)
if not subclient:
raise CredentialsNotSyncedError()
if _pillar_api is None:
# Allow overriding the endpoint before importing Blender-specific stuff.
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()
_pillar_api = pillarsdk.Api(endpoint=pillar_endpoint,
username=profile['username'],
password=None,
token=profile['token'])
username=subclient['subclient_user_id'],
password=SUBCLIENT_ID,
token=subclient['token'])
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:
"""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},
'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:
log.error('Project with URL %r does not exist', project_url)
return None
@@ -151,17 +258,14 @@ async def get_nodes(project_uuid: str = None, parent_node_uuid: str = None,
if 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,
'properties.order': 1, 'properties.status': 1,
'properties.files': 1,
'properties.content_type': 1, 'picture': 1},
'where': where,
'sort': 'properties.order',
'embed': ['parent']}, api=pillar_api())
loop = asyncio.get_event_loop()
children = await loop.run_in_executor(None, node_all)
'embed': ['parent']})
return children['_items']
@@ -277,11 +381,7 @@ async def fetch_thumbnail_info(file: pillarsdk.File, directory: str, desired_siz
finished.
"""
api = pillar_api()
loop = asyncio.get_event_loop()
thumb_link = await loop.run_in_executor(None, functools.partial(
file.thumbnail_file, desired_size, api=api))
thumb_link = await pillar_call(file.thumbnail_file, desired_size)
if thumb_link is None:
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')
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,
thumbnail_directory,
thumbnail_loading=thumbnail_loading,
thumbnail_loaded=thumbnail_loaded,
future=future)
for texture_node in chunk)
for texture_node in texture_nodes)
# raises any exception from failed handle_texture_node() calls.
await asyncio.gather(*coros)
@@ -360,17 +451,14 @@ async def download_texture_thumbnail(texture_node, desired_size: str,
texture_node['_id'])
return
api = pillar_api()
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
pic_uuid = texture_node['picture']
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:
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,
metadata_directory: str,
*,
map_type: str=None,
map_type: str = None,
file_loading: callable,
file_loaded: callable,
future: asyncio.Future):
@@ -413,11 +501,9 @@ async def download_file_by_uuid(file_uuid,
loop = asyncio.get_event_loop()
# Find the File document.
api = pillar_api()
file_find = functools.partial(pillarsdk.File.find, params={
file_desc = await pillar_call(pillarsdk.File.find, file_uuid, params={
'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
metadata_file = os.path.join(metadata_directory, 'files', '%s.json' % file_uuid)
@@ -458,7 +544,7 @@ async def download_texture(texture_node,
future=future)
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:

View File

@@ -38,4 +38,4 @@ def load_wheel(module_name, fname_prefix):
def load_wheels():
load_wheel('lockfile', 'lockfile')
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
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
import glob
import sys
import shutil
import subprocess
import re
import pathlib
from glob import glob
from distutils import log
from distutils.core import Command
@@ -16,6 +16,15 @@ from setuptools import setup, find_packages
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):
"""Builds or downloads the dependencies as wheel files."""
@@ -23,30 +32,21 @@ class BuildWheels(Command):
user_options = [
('wheels-path=', None, "wheel file installation path"),
('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"),
]
def initialize_options(self):
self.wheels_path = None # path that will contain the installed wheels.
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
def finalize_options(self):
self.my_path = pathlib.Path(__file__).resolve().parent
package_path = self.my_path / self.distribution.get_name()
def set_default(var, default):
if var is None:
return default
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.wheels_path = set_default_path(self.wheels_path, package_path / 'wheels')
self.deps_path = set_default_path(self.deps_path, self.my_path / 'build/deps')
self.cachecontrol_path = set_default_path(self.cachecontrol_path,
self.deps_path / 'cachecontrol')
def run(self):
@@ -73,16 +73,12 @@ class BuildWheels(Command):
# Download lockfile, as there is a suitable wheel on pypi.
if not list(self.wheels_path.glob('lockfile*.whl')):
log.info('Downloading lockfile wheel')
subprocess.check_call([
'pip', 'download', '--dest', str(self.wheels_path), requirements['lockfile'][0]
])
self.download_wheel(requirements['lockfile'])
# Build Pillar Python SDK.
if not list(self.wheels_path.glob('pillar-python-sdk*.whl')):
log.info('Building Pillar Python SDK in %s', self.pillar_sdk_path)
self.git_clone(self.pillar_sdk_path,
'https://github.com/armadillica/pillar-python-sdk.git')
self.build_copy_wheel(self.pillar_sdk_path)
# Download Pillar Python SDK from pypi.
if not list(self.wheels_path.glob('pillarsdk*.whl')):
log.info('Downloading Pillar Python SDK wheel')
self.download_wheel(requirements['pillarsdk'])
# Build CacheControl.
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')))
)
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):
if workdir.exists():
# 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)
shutil.copy(str(wheel), str(self.wheels_path))
# noinspection PyAttributeOutsideInit
class BlenderAddonBdist(bdist):
"""Ensures that 'python setup.py bdist' creates a zip file."""
@@ -137,6 +145,7 @@ class BlenderAddonBdist(bdist):
super().run()
# noinspection PyAttributeOutsideInit
class BlenderAddonInstall(install):
"""Ensures the module is placed at the root of the zip file."""
@@ -164,11 +173,12 @@ setup(
'wheels': BuildWheels},
name='blender_cloud',
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_email='sybren@stuvel.eu',
packages=find_packages('.'),
data_files=[('blender_cloud', ['README.md'])],
data_files=[('blender_cloud', ['README.md']),
('blender_cloud/icons', glob.glob('blender_cloud/icons/*'))],
scripts=[],
url='https://developer.blender.org/diffusion/BCA/',
license='GNU General Public License v2 or later (GPLv2+)',