Compare commits
2 Commits
version-1.
...
asset-engi
Author | SHA1 | Date | |
---|---|---|---|
fc514b2d73 | |||
ba7734aaa5 |
52
README.md
52
README.md
@@ -19,26 +19,6 @@ 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
|
||||
--------------------------------
|
||||
@@ -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
|
||||
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
|
||||
------
|
||||
|
@@ -19,19 +19,26 @@
|
||||
# <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': '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'
|
||||
"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"
|
||||
}
|
||||
|
||||
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
|
||||
@@ -66,14 +73,16 @@ def register():
|
||||
blender = reload_mod('blender')
|
||||
gui = reload_mod('gui')
|
||||
async_loop = reload_mod('async_loop')
|
||||
asset_engine = reload_mod('asset_engine')
|
||||
else:
|
||||
from . import blender, gui, async_loop
|
||||
from . import blender, gui, async_loop, asset_engine
|
||||
|
||||
async_loop.setup_asyncio_executor()
|
||||
async_loop.register()
|
||||
|
||||
blender.register()
|
||||
gui.register()
|
||||
asset_engine.register()
|
||||
|
||||
|
||||
def unregister():
|
||||
|
468
blender_cloud/asset_engine.py
Normal file
468
blender_cloud/asset_engine.py
Normal 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)
|
@@ -4,7 +4,6 @@ import asyncio
|
||||
import traceback
|
||||
import concurrent.futures
|
||||
import logging
|
||||
import gc
|
||||
|
||||
import bpy
|
||||
|
||||
@@ -54,9 +53,6 @@ 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
|
||||
@@ -72,9 +68,6 @@ 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()
|
||||
|
||||
|
@@ -3,7 +3,7 @@
|
||||
Separated from __init__.py so that we can import & run from non-Blender environments.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import os.path
|
||||
|
||||
import bpy
|
||||
from bpy.types import AddonPreferences, Operator, WindowManager, Scene
|
||||
@@ -11,31 +11,16 @@ 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=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'
|
||||
default='https://pillar.blender.org:5000/'
|
||||
)
|
||||
|
||||
local_texture_dir = StringProperty(
|
||||
@@ -44,45 +29,34 @@ 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
|
||||
import blender_id.profiles as blender_id_profiles
|
||||
except ImportError:
|
||||
blender_id = None
|
||||
blender_id_profiles = None
|
||||
blender_id_profile = None
|
||||
else:
|
||||
blender_id_profile = blender_id.get_active_profile()
|
||||
blender_id_profile = blender_id_profiles.get_active_profile()
|
||||
|
||||
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'
|
||||
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"
|
||||
elif not blender_id_profile:
|
||||
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.')
|
||||
blender_id_icon = 'ERROR'
|
||||
blender_id_text = "You are logged out."
|
||||
blender_id_help = "To login, go to the Blender ID add-on preferences."
|
||||
else:
|
||||
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.')
|
||||
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."
|
||||
|
||||
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=blender_id_text, icon=blender_id_icon)
|
||||
sub.label(text="* " + blender_id_help)
|
||||
|
||||
sub = layout.column()
|
||||
sub.label(text='Local directory for downloaded textures')
|
||||
@@ -91,19 +65,15 @@ class BlenderCloudPreferences(AddonPreferences):
|
||||
|
||||
# options for Pillar
|
||||
sub = layout.column()
|
||||
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.enabled = blender_id_icon != 'ERROR'
|
||||
sub.prop(self, "pillar_server")
|
||||
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):
|
||||
@@ -112,35 +82,26 @@ class PillarCredentialsUpdate(Operator):
|
||||
|
||||
@classmethod
|
||||
def is_logged_in(cls, context):
|
||||
try:
|
||||
import blender_id
|
||||
except ImportError:
|
||||
return False
|
||||
|
||||
return blender_id.is_logged_in()
|
||||
active_user_id = getattr(context.window_manager, 'blender_id_active_profile', None)
|
||||
return bool(active_user_id)
|
||||
|
||||
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:
|
||||
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'}
|
||||
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'}
|
||||
|
||||
self.report({'INFO'}, 'Blender Cloud credentials & endpoint URL updated.')
|
||||
self.report({'INFO'}, 'Updated cloud server address to %s' % endpoint)
|
||||
return {'FINISHED'}
|
||||
|
||||
|
||||
|
@@ -34,16 +34,12 @@ def cache_directory(*subdirs) -> str:
|
||||
|
||||
from . import pillar
|
||||
|
||||
profile = pillar.blender_id_profile()
|
||||
if profile:
|
||||
username = profile.username
|
||||
else:
|
||||
username = 'anonymous'
|
||||
profile = pillar.blender_id_profile() or {'username': 'anonymous'}
|
||||
|
||||
# TODO: use bpy.utils.user_resource('CACHE', ...)
|
||||
# once https://developer.blender.org/T47684 is finished.
|
||||
user_cache_dir = appdirs.user_cache_dir(appname='Blender', appauthor=False)
|
||||
cache_dir = os.path.join(user_cache_dir, 'blender_cloud', username, *subdirs)
|
||||
cache_dir = os.path.join(user_cache_dir, 'blender_cloud', profile['username'], *subdirs)
|
||||
|
||||
os.makedirs(cache_dir, mode=0o700, exist_ok=True)
|
||||
|
||||
|
@@ -182,7 +182,7 @@ class BlenderCloudBrowser(bpy.types.Operator):
|
||||
|
||||
_draw_handle = None
|
||||
|
||||
_state = 'INITIALIZING'
|
||||
_state = 'BROWSING'
|
||||
|
||||
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,12 +208,6 @@ 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
|
||||
@@ -235,7 +229,7 @@ class BlenderCloudBrowser(bpy.types.Operator):
|
||||
|
||||
self.current_display_content = []
|
||||
self.loaded_images = set()
|
||||
self.check_credentials()
|
||||
self.browse_assets()
|
||||
|
||||
context.window_manager.modal_handler_add(self)
|
||||
self.timer = context.window_manager.event_timer_add(1 / 30, context.window)
|
||||
@@ -291,35 +285,6 @@ class BlenderCloudBrowser(bpy.types.Operator):
|
||||
|
||||
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):
|
||||
"""Descends the node hierarchy by visiting this node.
|
||||
|
||||
@@ -348,7 +313,6 @@ 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()
|
||||
@@ -421,10 +385,7 @@ class BlenderCloudBrowser(bpy.types.Operator):
|
||||
else:
|
||||
raise ValueError('Unable to find MenuItem(node_uuid=%r)' % node_uuid)
|
||||
|
||||
async def async_download_previews(self):
|
||||
self._state = 'BROWSING'
|
||||
|
||||
thumbnails_directory = self.thumbnails_cache
|
||||
async def async_download_previews(self, thumbnails_directory):
|
||||
self.log.info('Asynchronously downloading previews to %r', thumbnails_directory)
|
||||
self.clear_images()
|
||||
|
||||
@@ -434,8 +395,7 @@ 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,
|
||||
@@ -474,8 +434,9 @@ 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._new_async_task(self.async_download_previews(self.thumbnails_cache))
|
||||
|
||||
def _new_async_task(self, async_task: asyncio.coroutine, future: asyncio.Future=None):
|
||||
"""Stops the currently running async task, and starts another one."""
|
||||
@@ -495,7 +456,6 @@ 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,
|
||||
@@ -570,24 +530,14 @@ class BlenderCloudBrowser(bpy.types.Operator):
|
||||
def _draw_downloading(self, context):
|
||||
"""OpenGL drawing code for the DOWNLOADING_TEXTURE state."""
|
||||
|
||||
self._draw_text_on_colour(context,
|
||||
'Downloading texture from Blender Cloud',
|
||||
(0.0, 0.0, 0.2, 0.6))
|
||||
|
||||
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(*bgcolour)
|
||||
bgl.glColor4f(0.0, 0.0, 0.2, 0.6)
|
||||
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)
|
||||
@@ -616,15 +566,7 @@ class BlenderCloudBrowser(bpy.types.Operator):
|
||||
bgl.glRectf(0, 0, content_width, content_height)
|
||||
|
||||
font_id = 0
|
||||
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
|
||||
text = "An error occurred:\n%s" % self.async_task.exception()
|
||||
lines = textwrap.wrap(text)
|
||||
|
||||
bgl.glColor4f(1.0, 1.0, 1.0, 1.0)
|
||||
@@ -702,7 +644,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
|
||||
|
@@ -15,8 +15,6 @@ 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()
|
||||
@@ -30,16 +28,6 @@ class UserNotLoggedInError(RuntimeError):
|
||||
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):
|
||||
"""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)
|
||||
|
||||
|
||||
def blender_id_profile() -> 'blender_id.BlenderIdProfile':
|
||||
def blender_id_profile() -> dict:
|
||||
"""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 blender_id
|
||||
return blender_id.get_active_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()
|
||||
|
||||
|
||||
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:
|
||||
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:
|
||||
@@ -136,78 +126,24 @@ def pillar_api(pillar_endpoint: str = None) -> pillarsdk.Api:
|
||||
pillarsdk.Api.requests_session = cache.requests_session()
|
||||
|
||||
_pillar_api = pillarsdk.Api(endpoint=pillar_endpoint,
|
||||
username=subclient['subclient_user_id'],
|
||||
password=SUBCLIENT_ID,
|
||||
token=subclient['token'])
|
||||
username=profile['username'],
|
||||
password=None,
|
||||
token=profile['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()
|
||||
|
||||
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:
|
||||
"""Returns the UUID for the project, given its '/p/<project_url>' string."""
|
||||
|
||||
find_one = functools.partial(pillarsdk.Project.find_one, {
|
||||
'where': {'url': project_url},
|
||||
'projection': {'permissions': 1},
|
||||
}, api=pillar_api())
|
||||
|
||||
loop = asyncio.get_event_loop()
|
||||
try:
|
||||
project = await pillar_call(pillarsdk.Project.find_one, {
|
||||
'where': {'url': project_url},
|
||||
'projection': {'permissions': 1},
|
||||
})
|
||||
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
|
||||
@@ -244,14 +180,17 @@ async def get_nodes(project_uuid: str = None, parent_node_uuid: str = None,
|
||||
if 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,
|
||||
'properties.order': 1, 'properties.status': 1,
|
||||
'properties.files': 1,
|
||||
'properties.content_type': 1, 'picture': 1},
|
||||
'where': where,
|
||||
'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']
|
||||
|
||||
@@ -367,7 +306,11 @@ async def fetch_thumbnail_info(file: pillarsdk.File, directory: str, desired_siz
|
||||
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:
|
||||
raise ValueError("File {} has no thumbnail of size {}"
|
||||
@@ -409,15 +352,24 @@ async def fetch_texture_thumbs(parent_node_uuid: str, desired_size: str,
|
||||
log.warning('fetch_texture_thumbs: Texture downloading cancelled')
|
||||
return
|
||||
|
||||
coros = (download_texture_thumbnail(texture_node, desired_size,
|
||||
thumbnail_directory,
|
||||
thumbnail_loading=thumbnail_loading,
|
||||
thumbnail_loaded=thumbnail_loaded,
|
||||
future=future)
|
||||
for texture_node in texture_nodes)
|
||||
# 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]
|
||||
|
||||
# raises any exception from failed handle_texture_node() calls.
|
||||
await asyncio.gather(*coros)
|
||||
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)
|
||||
|
||||
# raises any exception from failed handle_texture_node() calls.
|
||||
await asyncio.gather(*coros)
|
||||
|
||||
log.info('fetch_texture_thumbs: Done downloading texture thumbnails')
|
||||
|
||||
@@ -437,14 +389,17 @@ 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 pillar_call(pillarsdk.File.find, pic_uuid, params={
|
||||
'projection': {'filename': 1, 'variations': 1, 'width': 1, 'height': 1},
|
||||
})
|
||||
file_desc = await loop.run_in_executor(None, file_find, pic_uuid)
|
||||
|
||||
if file_desc is None:
|
||||
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,
|
||||
metadata_directory: str,
|
||||
*,
|
||||
map_type: str = None,
|
||||
map_type: str=None,
|
||||
file_loading: callable,
|
||||
file_loaded: callable,
|
||||
future: asyncio.Future):
|
||||
@@ -487,9 +442,11 @@ async def download_file_by_uuid(file_uuid,
|
||||
loop = asyncio.get_event_loop()
|
||||
|
||||
# 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},
|
||||
})
|
||||
}, 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)
|
||||
@@ -530,7 +487,7 @@ async def download_texture(texture_node,
|
||||
future=future)
|
||||
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:
|
||||
|
@@ -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', 'pillarsdk')
|
||||
load_wheel('pillarsdk', 'pillar_sdk')
|
||||
|
@@ -1,15 +1,2 @@
|
||||
# Primary requirements:
|
||||
CacheControl==0.11.6
|
||||
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
|
||||
|
58
setup.py
58
setup.py
@@ -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,15 +16,6 @@ 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."""
|
||||
|
||||
@@ -32,22 +23,31 @@ 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()
|
||||
|
||||
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 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.deps_path / 'cachecontrol')
|
||||
|
||||
def run(self):
|
||||
log.info('Storing wheels in %s', self.wheels_path)
|
||||
@@ -73,12 +73,16 @@ 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')
|
||||
self.download_wheel(requirements['lockfile'])
|
||||
subprocess.check_call([
|
||||
'pip', 'download', '--dest', str(self.wheels_path), requirements['lockfile'][0]
|
||||
])
|
||||
|
||||
# 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 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)
|
||||
|
||||
# Build CacheControl.
|
||||
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')))
|
||||
)
|
||||
|
||||
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.
|
||||
@@ -130,8 +124,6 @@ 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."""
|
||||
|
||||
@@ -145,7 +137,6 @@ class BlenderAddonBdist(bdist):
|
||||
super().run()
|
||||
|
||||
|
||||
# noinspection PyAttributeOutsideInit
|
||||
class BlenderAddonInstall(install):
|
||||
"""Ensures the module is placed at the root of the zip file."""
|
||||
|
||||
@@ -177,8 +168,7 @@ setup(
|
||||
author='Sybren A. Stüvel',
|
||||
author_email='sybren@stuvel.eu',
|
||||
packages=find_packages('.'),
|
||||
data_files=[('blender_cloud', ['README.md']),
|
||||
('blender_cloud/icons', glob.glob('blender_cloud/icons/*'))],
|
||||
data_files=[('blender_cloud', ['README.md'])],
|
||||
scripts=[],
|
||||
url='https://developer.blender.org/diffusion/BCA/',
|
||||
license='GNU General Public License v2 or later (GPLv2+)',
|
||||
|
Reference in New Issue
Block a user