Compare commits

..

2 Commits

Author SHA1 Message Date
fc514b2d73 WIP: asset engine support 2016-03-31 18:26:09 +02:00
ba7734aaa5 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-03-31 18:26:09 +02:00
11 changed files with 652 additions and 337 deletions

View File

@@ -19,26 +19,6 @@ 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
-------------------------------- --------------------------------
@@ -60,6 +40,38 @@ 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,19 +19,26 @@
# <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": (0, 2, 0),
'blender': (2, 77, 0), "blender": (2, 77, 0),
'location': 'Ctrl+Shift+Alt+A anywhere', "location": "TO BE DETERMINED",
'description': 'Allows downloading of textures from the Blender Cloud. Requires ' "description": "Allows downloading of textures from the Blender Cloud. Requires "
'the Blender ID addon and Blender 2.77a or newer.', "the Blender ID addon.",
'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
@@ -66,14 +73,16 @@ def register():
blender = reload_mod('blender') blender = reload_mod('blender')
gui = reload_mod('gui') gui = reload_mod('gui')
async_loop = reload_mod('async_loop') async_loop = reload_mod('async_loop')
asset_engine = reload_mod('asset_engine')
else: else:
from . import blender, gui, async_loop from . import blender, gui, async_loop, asset_engine
async_loop.setup_asyncio_executor() async_loop.setup_asyncio_executor()
async_loop.register() async_loop.register()
blender.register() blender.register()
gui.register() gui.register()
asset_engine.register()
def unregister(): def unregister():

View File

@@ -0,0 +1,468 @@
# ##### BEGIN GPL LICENSE BLOCK #####
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License
# as published by the Free Software Foundation; either version 2
# of the License, or (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, write to the Free Software Foundation,
# Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
#
# ##### END GPL LICENSE BLOCK #####
# <pep8 compliant>
"""Blender Cloud interface for the Asset Engine."""
import asyncio
import logging
import bpy
import time
from bpy.types import (AssetEngine, AssetList, FileSelectParams,
AssetUUIDList, AssetUUID,
Panel, PropertyGroup, UIList)
from bpy.props import (StringProperty,
BoolProperty,
IntProperty,
FloatProperty,
EnumProperty,
CollectionProperty)
from . import async_loop, pillar, cache
ASSET_ENGINE_ID = 0xc0ffee
def object_id_to_ea_uuid(pillar_object_id: str) -> tuple:
"""Turns a ObjectId string from Pillar to a tuple of 4 ints.
>>> object_id_to_ea_uuid('55f2d0dc2beb33006e43dd7e')
(12648430, 1441976540, 736834304, 1849941374)
>>> object_id_to_ea_uuid('55f2d0dc2beb33006e43dd7e') == \
(ASSET_ENGINE_ID, 0x55f2d0dc, 0x2beb3300, 0x6e43dd7e)
True
The first int is hard-coded to indicate this asset engine.
The other three ints are 32 bit each, and are taken from the 12-byte
ObjectId (see https://docs.mongodb.org/manual/reference/method/ObjectId/)
"""
# Make sure it's a 12-byte number in hex.
pillar_object_id = pillar_object_id.rjust(24, '0')
return (ASSET_ENGINE_ID,
int(pillar_object_id[0:8], 16),
int(pillar_object_id[8:16], 16),
int(pillar_object_id[16:24], 16))
class BCloudAssetEngineDirListJob:
def __init__(self, job_id: int, path: pillar.CloudPath, future: asyncio.Future = None):
self.log = logging.getLogger('%s.%s' % (__name__, BCloudAssetEngineDirListJob.__qualname__))
self.log.debug('Starting new dirlist job (id=%i) for path %r', job_id, path)
self.job_id = job_id
self.status = {'INVALID'}
self.progress = 0.0
self.path = path
# Start a new asynchronous task.
self.signalling_future = future or asyncio.Future()
# self.async_task = asyncio.ensure_future(self.async_download_previews())
# self.log.debug('Created new task %r', self.async_task)
# self.status = {'VALID', 'RUNNING'}
self.async_task = None
self.status = {'VALID'}
def __repr__(self):
return '%s(job_id=%i, path=%s, future=%s)' % (type(self), self.job_id, self.path,
self.signalling_future)
def stop(self):
self.log.debug('Stopping async task')
if self.async_task is None:
self.log.debug('No async task, trivially stopped')
return
# Signal that we want to stop.
if not self.signalling_future.done():
self.log.info("Signalling that we want to cancel anything that's running.")
self.signalling_future.cancel()
# Wait until the asynchronous task is done.
if not self.async_task.done():
# TODO: Should we really block? Or let it disappear into the background?
self.log.info("blocking until async task is done.")
loop = asyncio.get_event_loop()
try:
loop.run_until_complete(self.async_task)
except asyncio.CancelledError:
self.log.info('Asynchronous task was cancelled')
return
# noinspection PyBroadException
try:
self.async_task.result() # This re-raises any exception of the task.
except asyncio.CancelledError:
self.log.info('Asynchronous task was cancelled')
except Exception:
self.log.exception("Exception from asynchronous task")
async def async_download_previews(self):
self.log.info('Asynchronously downloading previews')
def thumbnail_loading(texture_node):
self.log.debug('Thumbnail for node %r loading', texture_node)
# self.add_menu_item(node, None, 'SPINNER', texture_node['name'])
def thumbnail_loaded(texture_node, file_desc, thumb_path):
self.log.debug('Thumbnail for node %r loaded, thumb at %r', texture_node, thumb_path)
# self.update_menu_item(node, file_desc, thumb_path, file_desc['filename'])
node_uuid = self.path.node_uuid
project_uuid = self.path.project_uuid
# Download either by group_texture node or project UUID (showing all top-level nodes)
if node_uuid:
self.log.debug('Getting subnodes for parent node %r', node_uuid)
children = await pillar.get_nodes(parent_node_uuid=node_uuid,
node_type='group_textures')
elif project_uuid:
self.log.debug('Getting subnodes for project node %r', project_uuid)
children = await pillar.get_nodes(project_uuid, '')
else:
# TODO: add "nothing here" icon and trigger re-draw
self.log.warning("Not node UUID and no project UUID, I can't do anything!")
return
# Download all child nodes
self.log.debug('Iterating over child nodes of %r', node_uuid)
for child in children:
self.log.debug(' - %(_id)s = %(name)s' % child)
# self.add_menu_item(child, None, 'FOLDER', child['name'])
# There are only sub-nodes at the project level, no texture nodes,
# so we won't have to bother looking for textures.
if not node_uuid:
return
directory = cache.cache_directory('thumbnails', project_uuid, node_uuid)
self.log.debug('Fetching texture thumbnails for node %r to %r', node_uuid, directory)
await pillar.fetch_texture_thumbs(node_uuid, 's', directory,
thumbnail_loading=thumbnail_loading,
thumbnail_loaded=thumbnail_loaded,
future=self.signalling_future)
def update(self):
self.log.debug('update()')
async_loop.kick_async_loop()
if not self.async_task:
return
if self.async_task.done():
self.status = {'VALID'}
self.status = {'VALID', 'RUNNING'}
class BCloudAssetEngine(AssetEngine):
bl_label = "Blender Cloud"
bl_version = 1
def __init__(self):
self.log = logging.getLogger('%s.%s' % (__name__, BCloudAssetEngine.__qualname__))
self.log.debug('Starting %s asset engine', self.bl_label)
self.jobs = {}
self._next_job_id = 1
self.path = pillar.CloudPath('/5672beecc0261b2005ed1a33')
self.dirs = []
self.sortedfiltered = []
def reset(self):
pass
def _start_dirlist_job(self, path: pillar.CloudPath, job_id: int = None) -> int:
if not job_id:
job_id = self._next_job_id
self._next_job_id += 1
self.jobs[job_id] = BCloudAssetEngineDirListJob(job_id, path)
self.path = path
return job_id
########## PY-API only ##########
# UI header
def draw_header(self, layout, context):
params = context.space_data.params
assert isinstance(params, FileSelectParams)
# self.log.debug('draw_header: params=%r', params)
# can be None when save/reload with a file selector open
if params is None:
return
is_lib_browser = params.use_library_browsing
layout.prop(params, "display_type", expand=True, text="")
layout.prop(params, "sort_method", expand=True, text="")
layout.prop(params, "show_hidden", text="", icon='FILE_HIDDEN')
layout.prop(params, "use_filter", text="", icon='FILTER')
row = layout.row(align=True)
row.active = params.use_filter
if params.filter_glob:
# if st.active_operator and hasattr(st.active_operator, "filter_glob"):
# row.prop(params, "filter_glob", text="")
row.label(params.filter_glob)
else:
row.prop(params, "use_filter_blender", text="")
row.prop(params, "use_filter_backup", text="")
row.prop(params, "use_filter_image", text="")
row.prop(params, "use_filter_movie", text="")
row.prop(params, "use_filter_script", text="")
row.prop(params, "use_filter_font", text="")
row.prop(params, "use_filter_sound", text="")
row.prop(params, "use_filter_text", text="")
if is_lib_browser:
row.prop(params, "use_filter_blendid", text="")
if params.use_filter_blendid:
row.separator()
row.prop(params, "filter_id_category", text="")
row.separator()
row.prop(params, "filter_search", text="", icon='VIEWZOOM')
########## C (RNA) API ##########
def status(self, job_id: int) -> set:
"""Returns either {'VALID'}, {'RUNNING'} or empty set."""
if job_id:
job = self.jobs.get(job_id, None)
return job.status if job is not None else set()
return {'VALID'}
def progress(self, job_id: int) -> float:
if job_id:
job = self.jobs.get(job_id, None)
return job.progress if job is not None else 0.0
progress = 0.0
nbr_jobs = 0
for job in self.jobs.values():
if 'RUNNING' in job.status:
nbr_jobs += 1
progress += job.progress
return progress / nbr_jobs if nbr_jobs else 0.0
def kill(self, job_id: int):
self.log.debug('kill(%i)', job_id)
if not job_id:
for job_id in self.jobs:
self.kill(job_id)
return
job = self.jobs.get(job_id, None)
if job is not None:
job.stop()
def list_dir(self, job_id: int, asset_list: AssetList) -> int:
"""Extends the 'asset_list' object with asset_list for the current dir.
:param job_id: Job ID of a currently running job (to investigate
progress), or zero (0) to start a new job.
:param asset_list: AssetList to store directory asset_list in.
:returns: the job ID, which is the given job ID or a new job ID if a
new job was started.
"""
self.log.debug('list_dir(%i), %i entries already loaded', job_id, len(asset_list.entries))
# TODO: set asset_list.nbr_entries to the total number of entries.
# job = self.jobs.get(job_id, None)
#
# asset_list_path = pillar.CloudPath(asset_list.root_path)
# if job is not None:
# if not isinstance(job, BCloudAssetEngineDirListJob) or job.path != asset_list_path:
# # We moved to another directory, abort what's going on now and start a new job.
# self.reset()
# if not isinstance(job, BCloudAssetEngineDirListJob):
# self.log.warn('Job %r is not a BCloudAssetEngineDirListJob', job)
# else:
# self.log.warn('Job %r is investigating path %r while we want %r', job,
# job.path, asset_list_path)
# return self._start_dirlist_job(pillar.CloudPath(asset_list_path))
#
# # Just asking for an update
# job.update()
# return job_id
#
# # Moved to another directory, but we haven't started any job yet.
# if self.path != asset_list_path:
# self.reset()
# self.log.info('No job yet, and path changed from %r to %r',
# self.path, asset_list_path)
# return self._start_dirlist_job(asset_list_path)
#
# self.log.warn('No job (id=%i), no change in path (%r == %r), nothing to do.', job_id,
# self.path, asset_list_path)
# Just add a fake entry for shits and giggles.
if asset_list.nbr_entries == 0:
asset_list.nbr_entries = 1
# import time
# time.sleep(1)
# The job has been finished; the asset_list is complete.
# return job_id
return -1
def load_pre(self, uuids, asset_list: AssetList) -> bool:
self.log.debug("load_pre(%r, %r)", uuids, asset_list)
return False
def sort_filter(self, use_sort: bool, use_filter: bool, params: FileSelectParams,
asset_list: AssetList) -> bool:
self.log.debug("sort_filter(%s, %s, %r, %i in %r)", use_sort, use_filter, params,
len(asset_list.entries), asset_list)
asset_list.nbr_entries_filtered = asset_list.nbr_entries
return False
def entries_block_get(self, start_index: int, end_index: int, asset_list: AssetList):
self.log.debug("entries_block_get(%i, %i, %r)", start_index, end_index, asset_list)
entry = asset_list.entries.add()
entry.name = 'je moeder'
entry.description = 'hahaha'
entry.type = {'DIR'}
entry.relpath = 'relative'
entry.uuid = (1, 2, 3, 4)
variant = entry.variants.add()
variant.uuid = (2, 3, 4, 5)
variant.name = 'Variant van je moeder'
variant.description = 'Variant van je omschrijving'
entry.variants.active = variant
revision = variant.revisions.add()
revision.uuid = (3, 4, 5, 6)
revision.size = 1024
revision.timestamp = time.time()
variant.revisions.active = revision
return True
def entries_uuid_get(self, uuids: AssetUUIDList, asset_list: AssetList):
self.log.debug("entries_uuid_get(%r, %r)", uuids, asset_list)
for uuid in uuids.uuids:
self.entry_from_uuid(asset_list, uuid)
return True
def entry_from_uuid(self, asset_list: AssetList, uuid: AssetUUID):
"""Adds the ID'd entry to the asset list.
Alternatively, it sets the UUID's 'is_unknown_engine' or
'is_asset_missing' properties.
"""
uuid_asset = tuple(uuid.uuid_asset)
uuid_variant = tuple(uuid.uuid_variant)
uuid_revision = tuple(uuid.uuid_revision)
entry = asset_list.entries.add()
entry.name = 'je moeder'
entry.description = 'hahaha'
entry.type = {'DIR'}
entry.relpath = 'relative'
entry.uuid = uuid_asset
variant = entry.variants.add()
variant.uuid = uuid_variant
variant.name = 'Variant van je moeder'
variant.description = 'Variant van je omschrijving'
entry.variants.active = variant
revision = variant.revisions.add()
revision.uuid = uuid_revision
revision.size = 1024
revision.timestamp = time.time()
variant.revisions.active = revision
class BCloudPanel:
@classmethod
def poll(cls, context):
space = context.space_data
if space and space.type == 'FILE_BROWSER':
ae = space.asset_engine
if ae and space.asset_engine_type == "AssetEngineAmber":
return True
return False
class BCloud_PT_options(Panel, BCloudPanel):
bl_space_type = 'FILE_BROWSER'
bl_region_type = 'TOOLS'
bl_category = "Asset Engine"
bl_label = "Blender Cloud Options"
def draw(self, context):
layout = self.layout
space = context.space_data
ae = space.asset_engine
row = layout.row()
class BCloud_PT_tags(Panel, BCloudPanel):
bl_space_type = 'FILE_BROWSER'
bl_region_type = 'TOOLS'
bl_category = "Filter"
bl_label = "Tags"
def draw(self, context):
ae = context.space_data.asset_engine
# Note: This is *ultra-primitive*!
# A good UI will most likely need new widget option anyway (template).
# Or maybe just some UIList...
# ~ self.layout.props_enum(ae, "tags")
# self.layout.template_list("AMBER_UL_tags_filter", "", ae, "tags", ae, "active_tag_index")
def register():
import sys
import doctest
(failures, tests) = doctest.testmod(sys.modules[__name__])
log = logging.getLogger(__name__)
if failures:
log.warning('There were test failures: %i of %i tests failed.', failures, tests)
else:
log.debug('All %i tests were successful.', tests)
bpy.utils.register_class(BCloudAssetEngine)
bpy.utils.register_class(BCloud_PT_options)
bpy.utils.register_class(BCloud_PT_tags)
def unregister():
bpy.utils.register_class(BCloud_PT_tags)
bpy.utils.register_class(BCloud_PT_options)
bpy.utils.register_class(BCloudAssetEngine)

View File

@@ -4,7 +4,6 @@ import asyncio
import traceback import traceback
import concurrent.futures import concurrent.futures
import logging import logging
import gc
import bpy import bpy
@@ -54,9 +53,6 @@ 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
@@ -72,9 +68,6 @@ 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 logging import os.path
import bpy import bpy
from bpy.types import AddonPreferences, Operator, WindowManager, Scene from bpy.types import AddonPreferences, Operator, WindowManager, Scene
@@ -11,31 +11,16 @@ 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=PILLAR_SERVER_URL, default='https://pillar.blender.org:5000/'
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(
@@ -44,45 +29,34 @@ 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 import blender_id.profiles as blender_id_profiles
except ImportError: except ImportError:
blender_id = None blender_id_profiles = None
blender_id_profile = None blender_id_profile = None
else: else:
blender_id_profile = blender_id.get_active_profile() blender_id_profile = blender_id_profiles.get_active_profile()
if blender_id is None: if blender_id_profiles is None:
icon = 'ERROR' blender_id_icon = 'ERROR'
text = 'This add-on requires Blender ID' blender_id_text = "This add-on requires Blender ID"
help_text = 'Make sure that the Blender ID add-on is installed and activated' blender_id_help = "Make sure that the Blender ID add-on is installed and activated"
elif not blender_id_profile: elif not blender_id_profile:
icon = 'ERROR' blender_id_icon = 'ERROR'
text = 'You are logged out.' blender_id_text = "You are logged out."
help_text = 'To login, go to the Blender ID add-on preferences.' blender_id_help = "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:
icon = 'WORLD_DATA' blender_id_icon = 'WORLD_DATA'
text = 'You are logged in as %s.' % blender_id_profile.username blender_id_text = "You are logged in as %s." % blender_id_profile['username']
help_text = ('To logout or change profile, ' blender_id_help = "To logout or change profile, " \
'go to the Blender ID add-on preferences.') "go to the Blender ID add-on preferences."
sub = layout.column(align=True) sub = layout.column()
sub.label(text=text, icon=icon) sub.label(text=blender_id_text, icon=blender_id_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')
@@ -91,19 +65,15 @@ class BlenderCloudPreferences(AddonPreferences):
# options for Pillar # options for Pillar
sub = layout.column() sub = layout.column()
sub.enabled = icon != 'ERROR' sub.enabled = blender_id_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):
@@ -112,35 +82,26 @@ class PillarCredentialsUpdate(Operator):
@classmethod @classmethod
def is_logged_in(cls, context): def is_logged_in(cls, context):
try: active_user_id = getattr(context.window_manager, 'blender_id_active_profile', None)
import blender_id return bool(active_user_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:
loop = asyncio.get_event_loop() pillar.get_project_uuid('textures') # Just any query will do.
loop.run_until_complete(pillar.refresh_pillar_credentials()) except Exception as e:
except blender_id.BlenderIdCommError as ex: print(e)
log.exception('Error sending subclient-specific token to Blender ID') self.report({'ERROR'}, 'Failed connection to %s' % endpoint)
self.report({'ERROR'}, 'Failed to sync Blender ID to Blender Cloud') return {'FINISHED'}
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'}, 'Blender Cloud credentials & endpoint URL updated.') self.report({'INFO'}, 'Updated cloud server address to %s' % endpoint)
return {'FINISHED'} return {'FINISHED'}

View File

@@ -34,16 +34,12 @@ def cache_directory(*subdirs) -> str:
from . import pillar from . import pillar
profile = pillar.blender_id_profile() profile = pillar.blender_id_profile() or {'username': 'anonymous'}
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', username, *subdirs) cache_dir = os.path.join(user_cache_dir, 'blender_cloud', profile['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 = 'INITIALIZING' _state = 'BROWSING'
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,12 +208,6 @@ 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
@@ -235,7 +229,7 @@ class BlenderCloudBrowser(bpy.types.Operator):
self.current_display_content = [] self.current_display_content = []
self.loaded_images = set() self.loaded_images = set()
self.check_credentials() self.browse_assets()
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)
@@ -291,35 +285,6 @@ class BlenderCloudBrowser(bpy.types.Operator):
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.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.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 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.
@@ -348,7 +313,6 @@ 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()
@@ -421,10 +385,7 @@ 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): async def async_download_previews(self, thumbnails_directory):
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()
@@ -434,8 +395,7 @@ 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 # Download either by group_texture node UUID or by project UUID (which shows all top-level nodes)
# 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,
@@ -474,8 +434,9 @@ 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._new_async_task(self.async_download_previews(self.thumbnails_cache))
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."""
@@ -495,7 +456,6 @@ 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,
@@ -570,24 +530,14 @@ 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."""
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) content_height, content_width = self._window_size(context)
bgl.glEnable(bgl.GL_BLEND) bgl.glEnable(bgl.GL_BLEND)
bgl.glColor4f(*bgcolour) bgl.glColor4f(0.0, 0.0, 0.2, 0.6)
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)
@@ -616,15 +566,7 @@ 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
ex = self.async_task.exception() text = "An error occurred:\n%s" % 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)
@@ -702,7 +644,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

@@ -15,8 +15,6 @@ 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()
@@ -30,16 +28,6 @@ class UserNotLoggedInError(RuntimeError):
This is basically for every interaction with Pillar. This is basically for every interaction with Pillar.
""" """
def __str__(self):
return 'UserNotLoggedInError'
class CredentialsNotSyncedError(UserNotLoggedInError):
"""Raised when the user may be logged in on Blender ID, but has no Blender Cloud token."""
def __str__(self):
return 'CredentialsNotSyncedError'
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.
@@ -96,15 +84,21 @@ 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() -> 'blender_id.BlenderIdProfile': def blender_id_profile() -> dict:
"""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 blender_id import bpy
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:
@@ -123,10 +117,6 @@ 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:
@@ -136,78 +126,24 @@ 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=subclient['subclient_user_id'], username=profile['username'],
password=SUBCLIENT_ID, password=None,
token=subclient['token']) token=profile['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()
try:
await get_project_uuid('textures') # Any query will do.
except pillarsdk.UnauthorizedAccess:
raise CredentialsNotSyncedError()
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 get_project_uuid('textures') # Any query will do.
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."""
try: find_one = functools.partial(pillarsdk.Project.find_one, {
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
@@ -244,14 +180,17 @@ 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
children = await pillar_call(pillarsdk.Node.all, { node_all = functools.partial(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']}) 'embed': ['parent']}, api=pillar_api())
loop = asyncio.get_event_loop()
children = await loop.run_in_executor(None, node_all)
return children['_items'] return children['_items']
@@ -367,7 +306,11 @@ async def fetch_thumbnail_info(file: pillarsdk.File, directory: str, desired_siz
finished. finished.
""" """
thumb_link = await pillar_call(file.thumbnail_file, desired_size) 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))
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 {}"
@@ -409,12 +352,21 @@ 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 texture_nodes) for texture_node in chunk)
# 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)
@@ -437,14 +389,17 @@ 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 pillar_call(pillarsdk.File.find, pic_uuid, params={ file_desc = await loop.run_in_executor(None, file_find, pic_uuid)
'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)
@@ -476,7 +431,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):
@@ -487,9 +442,11 @@ 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.
file_desc = await pillar_call(pillarsdk.File.find, file_uuid, params={ api = pillar_api()
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)
@@ -530,7 +487,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_exceptions=True) return await asyncio.gather(*downloaders)
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', 'pillarsdk') load_wheel('pillarsdk', 'pillar_sdk')

View File

@@ -1,15 +1,2 @@
# Primary requirements:
CacheControl==0.11.6 CacheControl==0.11.6
lockfile==0.12.2 lockfile==0.12.2
pillarsdk==1.0.0
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,15 +16,6 @@ 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."""
@@ -32,21 +23,30 @@ 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()
self.wheels_path = set_default_path(self.wheels_path, package_path / 'wheels') def set_default(var, default):
self.deps_path = set_default_path(self.deps_path, self.my_path / 'build/deps') if var is None:
self.cachecontrol_path = set_default_path(self.cachecontrol_path, 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.deps_path / 'cachecontrol') self.deps_path / 'cachecontrol')
def run(self): def run(self):
@@ -73,12 +73,16 @@ 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')
self.download_wheel(requirements['lockfile']) subprocess.check_call([
'pip', 'download', '--dest', str(self.wheels_path), requirements['lockfile'][0]
])
# Download Pillar Python SDK from pypi. # Build Pillar Python SDK.
if not list(self.wheels_path.glob('pillarsdk*.whl')): if not list(self.wheels_path.glob('pillar-python-sdk*.whl')):
log.info('Downloading Pillar Python SDK wheel') log.info('Building Pillar Python SDK in %s', self.pillar_sdk_path)
self.download_wheel(requirements['pillarsdk']) self.git_clone(self.pillar_sdk_path,
'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')):
@@ -93,16 +97,6 @@ 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.
@@ -130,8 +124,6 @@ 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."""
@@ -145,7 +137,6 @@ 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."""
@@ -177,8 +168,7 @@ setup(
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+)',