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 732 additions and 564 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': (1, 1, 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,23 +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
) )
local_texture_dir = StringProperty( local_texture_dir = StringProperty(
@@ -36,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')
@@ -83,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):
@@ -104,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'}
@@ -144,11 +113,15 @@ def register():
bpy.utils.register_class(BlenderCloudPreferences) bpy.utils.register_class(BlenderCloudPreferences)
bpy.utils.register_class(PillarCredentialsUpdate) bpy.utils.register_class(PillarCredentialsUpdate)
addon_prefs = preferences() WindowManager.blender_cloud_project = StringProperty(
name="Blender Cloud project UUID",
default='5672beecc0261b2005ed1a33') # TODO: don't hard-code this
WindowManager.last_blender_cloud_location = StringProperty( WindowManager.blender_cloud_node = StringProperty(
name="Last Blender Cloud browser location", name="Blender Cloud node UUID",
default="/") default='') # empty == top-level of project
addon_prefs = preferences()
def default_if_empty(scene, context): def default_if_empty(scene, context):
"""The scene's local_texture_dir, if empty, reverts to the addon prefs.""" """The scene's local_texture_dir, if empty, reverts to the addon prefs."""

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

@@ -44,27 +44,13 @@ library_path = '/tmp'
library_icons_path = os.path.join(os.path.dirname(__file__), "icons") library_icons_path = os.path.join(os.path.dirname(__file__), "icons")
class SpecialFolderNode(pillarsdk.Node): class UpNode(pillarsdk.Node):
pass
class UpNode(SpecialFolderNode):
def __init__(self): def __init__(self):
super().__init__() super().__init__()
self['_id'] = 'UP' self['_id'] = 'UP'
self['node_type'] = 'UP' self['node_type'] = 'UP'
class ProjectNode(SpecialFolderNode):
def __init__(self, project):
super().__init__()
assert isinstance(project, pillarsdk.Project), 'wrong type for project: %r' % type(project)
self.merge(project.to_dict())
self['node_type'] = 'PROJECT'
class MenuItem: class MenuItem:
"""GUI menu item for the 3D View GUI.""" """GUI menu item for the 3D View GUI."""
@@ -80,30 +66,19 @@ class MenuItem:
'SPINNER': os.path.join(library_icons_path, 'spinner.png'), 'SPINNER': os.path.join(library_icons_path, 'spinner.png'),
} }
SUPPORTED_NODE_TYPES = {'UP', 'PROJECT', 'group_texture', 'texture'} SUPPORTED_NODE_TYPES = {'UP', 'group_texture', 'texture'}
def __init__(self, node, file_desc, thumb_path: str, label_text): def __init__(self, node, file_desc, thumb_path: str, label_text):
self.log = logging.getLogger('%s.MenuItem' % __name__)
if node['node_type'] not in self.SUPPORTED_NODE_TYPES: if node['node_type'] not in self.SUPPORTED_NODE_TYPES:
self.log.info('Invalid node type in node: %s', node)
raise TypeError('Node of type %r not supported; supported are %r.' % ( raise TypeError('Node of type %r not supported; supported are %r.' % (
node['node_type'], self.SUPPORTED_NODE_TYPES)) node.group_texture, self.SUPPORTED_NODE_TYPES))
assert isinstance(node, pillarsdk.Node), 'wrong type for node: %r' % type(node)
assert isinstance(node['_id'], str), 'wrong type for node["_id"]: %r' % type(node['_id'])
self.node = node # pillarsdk.Node, contains 'node_type' key to indicate type self.node = node # pillarsdk.Node, contains 'node_type' key to indicate type
self.file_desc = file_desc # pillarsdk.File object, or None if a 'folder' node. self.file_desc = file_desc # pillarsdk.File object, or None if a 'folder' node.
self.label_text = label_text self.label_text = label_text
self._thumb_path = '' self._thumb_path = ''
self.icon = None self.icon = None
self._is_folder = (node['node_type'] == 'group_texture' or self._is_folder = node['node_type'] == 'group_texture' or isinstance(node, UpNode)
isinstance(node, SpecialFolderNode))
# Determine sorting order.
# by default, sort all the way at the end and folders first.
self._order = 0 if self._is_folder else 10000
if node and node.properties and node.properties.order is not None:
self._order = node.properties.order
self.thumb_path = thumb_path self.thumb_path = thumb_path
@@ -113,10 +88,6 @@ class MenuItem:
self.width = 0 self.width = 0
self.height = 0 self.height = 0
def sort_key(self):
"""Key for sorting lists of MenuItems."""
return self._order, self.label_text
@property @property
def thumb_path(self) -> str: def thumb_path(self) -> str:
return self._thumb_path return self._thumb_path
@@ -211,12 +182,14 @@ class BlenderCloudBrowser(bpy.types.Operator):
_draw_handle = None _draw_handle = None
_state = 'INITIALIZING' _state = 'BROWSING'
current_path = pillar.CloudPath('/') project_uuid = '5672beecc0261b2005ed1a33' # Blender Cloud project UUID
project_name = '' node = None # The Node object we're currently showing, or None if we're at the project top.
node_uuid = '' # Blender Cloud node UUID we're currently showing, i.e. None-safe self.node['_id']
# This contains a stack of Node objects that lead up to the currently browsed node. # This contains a stack of Node objects that lead up to the currently browsed node.
# This allows us to display the "up" item.
path_stack = [] path_stack = []
async_task = None # asyncio task for fetching thumbnails async_task = None # asyncio task for fetching thumbnails
@@ -225,6 +198,7 @@ class BlenderCloudBrowser(bpy.types.Operator):
log = logging.getLogger('%s.BlenderCloudBrowser' % __name__) log = logging.getLogger('%s.BlenderCloudBrowser' % __name__)
_menu_item_lock = threading.Lock() _menu_item_lock = threading.Lock()
current_path = ''
current_display_content = [] current_display_content = []
loaded_images = set() loaded_images = set()
thumbnails_cache = '' thumbnails_cache = ''
@@ -234,16 +208,10 @@ 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.current_path = pillar.CloudPath(wm.last_blender_cloud_location) self.node_uuid = wm.blender_cloud_node
self.path_stack = [] # list of nodes that make up the current path. self.path_stack = []
self.thumbnails_cache = cache.cache_directory('thumbnails') self.thumbnails_cache = cache.cache_directory('thumbnails')
self.mouse_x = event.mouse_x self.mouse_x = event.mouse_x
@@ -261,11 +229,10 @@ 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.cursor_modal_set('DEFAULT')
context.window_manager.modal_handler_add(self) context.window_manager.modal_handler_add(self)
self.timer = context.window_manager.event_timer_add(1 / 15, context.window) self.timer = context.window_manager.event_timer_add(1 / 30, context.window)
return {'RUNNING_MODAL'} return {'RUNNING_MODAL'}
@@ -295,21 +262,9 @@ class BlenderCloudBrowser(bpy.types.Operator):
self.mouse_x = event.mouse_x self.mouse_x = event.mouse_x
self.mouse_y = event.mouse_y self.mouse_y = event.mouse_y
left_mouse_release = event.type == 'LEFTMOUSE' and event.value == 'RELEASE' if self._state == 'BROWSING' and event.type == 'LEFTMOUSE' and event.value == 'RELEASE':
if self._state == 'PLEASE_SUBSCRIBE' and left_mouse_release:
self.open_browser_subscribe()
self._finish(context)
return {'FINISHED'}
if self._state == 'BROWSING':
selected = self.get_clicked() selected = self.get_clicked()
if selected:
context.window.cursor_set('HAND')
else:
context.window.cursor_set('DEFAULT')
if left_mouse_release:
if selected is None: if selected is None:
# No item clicked, ignore it. # No item clicked, ignore it.
return {'RUNNING_MODAL'} return {'RUNNING_MODAL'}
@@ -321,92 +276,36 @@ class BlenderCloudBrowser(bpy.types.Operator):
# This can happen when the thumbnail information isn't loaded yet. # This can happen when the thumbnail information isn't loaded yet.
# Just ignore the click for now. # Just ignore the click for now.
# TODO: think of a way to handle this properly. # TODO: think of a way to handle this properly.
self.log.debug('Selected item %r has no file_desc', selected)
return {'RUNNING_MODAL'} return {'RUNNING_MODAL'}
self.handle_item_selection(context, selected) self.handle_item_selection(context, selected)
if event.type in {'RIGHTMOUSE', 'ESC'}: elif event.type in {'RIGHTMOUSE', 'ESC'}:
self._finish(context) self._finish(context)
return {'CANCELLED'} return {'CANCELLED'}
return {'RUNNING_MODAL'} return {'RUNNING_MODAL'}
def check_credentials(self):
self._state = 'CHECKING_CREDENTIALS'
self.log.debug('Checking credentials')
self._new_async_task(self._check_credentials())
async def _check_credentials(self):
"""Checks credentials with Pillar, and if ok goes to the BROWSING state."""
try:
await pillar.check_pillar_credentials()
except pillar.NotSubscribedToCloudError:
self.log.info('User not subscribed to Blender Cloud.')
self._show_subscribe_screen()
return
except pillar.CredentialsNotSyncedError:
self.log.info('Credentials not synced, re-syncing automatically.')
else:
self.log.info('Credentials okay, browsing assets.')
await self.async_download_previews()
return
try:
await pillar.refresh_pillar_credentials()
except pillar.NotSubscribedToCloudError:
self.log.info('User is not a Blender Cloud subscriber.')
self._show_subscribe_screen()
return
except pillar.UserNotLoggedInError:
self.log.error('User not logged in on Blender ID.')
else:
self.log.info('Credentials refreshed and ok, browsing assets.')
await self.async_download_previews()
return
raise pillar.UserNotLoggedInError()
# self._new_async_task(self._check_credentials())
def _show_subscribe_screen(self):
"""Shows the "You need to subscribe" screen."""
self._state = 'PLEASE_SUBSCRIBE'
bpy.context.window.cursor_set('HAND')
def descend_node(self, node): def descend_node(self, node):
"""Descends the node hierarchy by visiting this node. """Descends the node hierarchy by visiting this node.
Also keeps track of the current node, so that we know where the "up" button should go. Also keeps track of the current node, so that we know where the "up" button should go.
""" """
assert isinstance(node, pillarsdk.Node), 'Wrong type %s' % node # Going up or down?
if self.path_stack and isinstance(node, UpNode):
self.log.debug('Going up, pop the stack; pre-pop stack is %r', self.path_stack)
node = self.path_stack.pop()
if isinstance(node, UpNode):
# Going up.
self.log.debug('Going up to %r', self.current_path)
self.current_path = self.current_path.parent
if self.path_stack:
self.path_stack.pop()
if not self.path_stack:
self.project_name = ''
else: else:
# Going down, keep track of where we were # Going down, keep track of where we were (project top-level is None)
if isinstance(node, ProjectNode): self.path_stack.append(self.node)
self.project_name = node['name'] self.log.debug('Going up, push the stack; post-push stack is %r', self.path_stack)
self.current_path /= node['_id']
self.log.debug('Going down to %r', self.current_path)
self.path_stack.append(node)
# Set 'current' to the given node
self.node_uuid = node['_id'] if node else None
self.node = node
self.browse_assets() self.browse_assets()
@property
def node(self):
if not self.path_stack:
return None
return self.path_stack[-1]
def _stop_async_task(self): def _stop_async_task(self):
self.log.debug('Stopping async task') self.log.debug('Stopping async task')
if self.async_task is None: if self.async_task is None:
@@ -414,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()
@@ -444,7 +342,6 @@ class BlenderCloudBrowser(bpy.types.Operator):
context.space_data.draw_handler_remove(self._draw_handle, 'WINDOW') context.space_data.draw_handler_remove(self._draw_handle, 'WINDOW')
context.window_manager.event_timer_remove(self.timer) context.window_manager.event_timer_remove(self.timer)
context.window.cursor_modal_restore()
if self.maximized_area: if self.maximized_area:
bpy.ops.screen.screen_full_area(use_hide_panels=True) bpy.ops.screen.screen_full_area(use_hide_panels=True)
@@ -473,8 +370,6 @@ class BlenderCloudBrowser(bpy.types.Operator):
self.current_display_content.append(menu_item) self.current_display_content.append(menu_item)
self.loaded_images.add(menu_item.icon.filepath_raw) self.loaded_images.add(menu_item.icon.filepath_raw)
self.sort_menu()
return menu_item return menu_item
def update_menu_item(self, node, *args) -> MenuItem: def update_menu_item(self, node, *args) -> MenuItem:
@@ -490,23 +385,8 @@ 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)
self.sort_menu() async def async_download_previews(self, thumbnails_directory):
def sort_menu(self):
"""Sorts the self.current_display_content list."""
if not self.current_display_content:
return
with self._menu_item_lock:
self.current_display_content.sort(key=MenuItem.sort_key)
async def async_download_previews(self):
self._state = 'BROWSING'
thumbnails_directory = self.thumbnails_cache
self.log.info('Asynchronously downloading previews to %r', thumbnails_directory) self.log.info('Asynchronously downloading previews to %r', thumbnails_directory)
self.log.info('Current BCloud path is %r', self.current_path)
self.clear_images() self.clear_images()
def thumbnail_loading(node, texture_node): def thumbnail_loading(node, texture_node):
@@ -515,57 +395,48 @@ 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'])
project_uuid = self.current_path.project_uuid # Download either by group_texture node UUID or by project UUID (which shows all top-level nodes)
node_uuid = self.current_path.node_uuid if self.node_uuid:
self.log.debug('Getting subnodes for parent node %r', self.node_uuid)
if node_uuid: children = await pillar.get_nodes(parent_node_uuid=self.node_uuid,
# Query for sub-nodes of this node. node_type='group_textures')
self.log.debug('Getting subnodes for parent node %r', node_uuid)
children = await pillar.get_nodes(parent_node_uuid=node_uuid,
node_type='group_texture')
elif project_uuid:
# Query for top-level nodes.
self.log.debug('Getting subnodes for project node %r', project_uuid)
children = await pillar.get_nodes(project_uuid=project_uuid,
parent_node_uuid='',
node_type='group_texture')
else:
# Query for projects
self.log.debug('No node UUID and no project UUID, listing all projects')
children = await pillar.get_texture_projects()
for proj_dict in children:
self.add_menu_item(ProjectNode(proj_dict), None, 'FOLDER', proj_dict['name'])
return
# Make sure we can go up again. # Make sure we can go up again.
if self.path_stack:
self.add_menu_item(UpNode(), None, 'FOLDER', '.. up ..') self.add_menu_item(UpNode(), None, 'FOLDER', '.. up ..')
elif self.project_uuid:
self.log.debug('Getting subnodes for project node %r', self.project_uuid)
children = await pillar.get_nodes(self.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 # Download all child nodes
self.log.debug('Iterating over child nodes of %r', self.current_path) self.log.debug('Iterating over child nodes of %r', self.node_uuid)
for child in children: for child in children:
# print(' - %(_id)s = %(name)s' % child) # print(' - %(_id)s = %(name)s' % child)
if child['node_type'] not in MenuItem.SUPPORTED_NODE_TYPES:
self.log.debug('Skipping node of type %r', child['node_type'])
continue
self.add_menu_item(child, None, 'FOLDER', child['name']) self.add_menu_item(child, None, 'FOLDER', child['name'])
# There are only sub-nodes at the project level, no texture nodes, # There are only sub-nodes at the project level, no texture nodes,
# so we won't have to bother looking for textures. # so we won't have to bother looking for textures.
if not node_uuid: if not self.node_uuid:
return return
directory = os.path.join(thumbnails_directory, project_uuid, node_uuid) directory = os.path.join(thumbnails_directory, self.project_uuid, self.node_uuid)
os.makedirs(directory, exist_ok=True) os.makedirs(directory, exist_ok=True)
self.log.debug('Fetching texture thumbnails for node %r', node_uuid) self.log.debug('Fetching texture thumbnails for node %r', self.node_uuid)
await pillar.fetch_texture_thumbs(node_uuid, 's', directory, await pillar.fetch_texture_thumbs(self.node_uuid, 's', directory,
thumbnail_loading=thumbnail_loading, thumbnail_loading=thumbnail_loading,
thumbnail_loaded=thumbnail_loaded, thumbnail_loaded=thumbnail_loaded,
future=self.signalling_future) future=self.signalling_future)
def browse_assets(self): def browse_assets(self):
self.log.debug('Browsing assets at %r', self.current_path) self._state = 'BROWSING'
self._new_async_task(self.async_download_previews()) self.log.debug('Browsing assets at project %r node %r', self.project_uuid, self.node_uuid)
self._new_async_task(self.async_download_previews(self.thumbnails_cache))
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."""
@@ -585,11 +456,9 @@ class BlenderCloudBrowser(bpy.types.Operator):
"""Draws the GUI with OpenGL.""" """Draws the GUI with OpenGL."""
drawers = { drawers = {
'CHECKING_CREDENTIALS': self._draw_checking_credentials,
'BROWSING': self._draw_browser, 'BROWSING': self._draw_browser,
'DOWNLOADING_TEXTURE': self._draw_downloading, 'DOWNLOADING_TEXTURE': self._draw_downloading,
'EXCEPTION': self._draw_exception, 'EXCEPTION': self._draw_exception,
'PLEASE_SUBSCRIBE': self._draw_subscribe,
} }
if self._state in drawers: if self._state in drawers:
@@ -601,7 +470,7 @@ class BlenderCloudBrowser(bpy.types.Operator):
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)
blf.position(font_id, 5, 5, 0) blf.position(font_id, 5, 5, 0)
blf.draw(font_id, '%s %s' % (self._state, self.project_name)) blf.draw(font_id, self._state)
bgl.glDisable(bgl.GL_BLEND) bgl.glDisable(bgl.GL_BLEND)
@staticmethod @staticmethod
@@ -661,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)
@@ -707,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)
@@ -732,11 +583,6 @@ class BlenderCloudBrowser(bpy.types.Operator):
blf.draw(font_id, line) blf.draw(font_id, line)
bgl.glDisable(bgl.GL_BLEND) bgl.glDisable(bgl.GL_BLEND)
def _draw_subscribe(self, context):
self._draw_text_on_colour(context,
'Click to subscribe to the Blender Cloud',
(0.0, 0.0, 0.2, 0.6))
def get_clicked(self) -> MenuItem: def get_clicked(self) -> MenuItem:
for item in self.current_display_content: for item in self.current_display_content:
@@ -748,13 +594,11 @@ class BlenderCloudBrowser(bpy.types.Operator):
def handle_item_selection(self, context, item: MenuItem): def handle_item_selection(self, context, item: MenuItem):
"""Called when the user clicks on a menu item that doesn't represent a folder.""" """Called when the user clicks on a menu item that doesn't represent a folder."""
from pillarsdk.utils import sanitize_filename
self.clear_images() self.clear_images()
self._state = 'DOWNLOADING_TEXTURE' self._state = 'DOWNLOADING_TEXTURE'
node_path_components = (node['name'] for node in self.path_stack if node is not None) node_path_components = [node['name'] for node in self.path_stack if node is not None]
local_path_components = [sanitize_filename(comp) for comp in node_path_components] local_path_components = [self.project_uuid] + node_path_components + [self.node['name']]
top_texture_directory = bpy.path.abspath(context.scene.local_texture_dir) top_texture_directory = bpy.path.abspath(context.scene.local_texture_dir)
local_path = os.path.join(top_texture_directory, *local_path_components) local_path = os.path.join(top_texture_directory, *local_path_components)
@@ -787,13 +631,6 @@ class BlenderCloudBrowser(bpy.types.Operator):
future=signalling_future)) future=signalling_future))
self.async_task.add_done_callback(texture_download_completed) self.async_task.add_done_callback(texture_download_completed)
def open_browser_subscribe(self):
import webbrowser
webbrowser.open_new_tab('https://cloud.blender.org/join')
self.report({'INFO'}, 'We just started a browser for you.')
# store keymaps here to access after registration # store keymaps here to access after registration
addon_keymaps = [] addon_keymaps = []
@@ -807,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,17 +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 self.__class__.__name__
class CredentialsNotSyncedError(UserNotLoggedInError):
"""Raised when the user may be logged in on Blender ID, but has no Blender Cloud token."""
class NotSubscribedToCloudError(UserNotLoggedInError):
"""Raised when the user may be logged in on Blender ID, but has no Blender Cloud token."""
class PillarError(RuntimeError): class PillarError(RuntimeError):
"""Raised when there is some issue with the communication with Pillar. """Raised when there is some issue with the communication with Pillar.
@@ -63,8 +50,6 @@ class CloudPath(pathlib.PurePosixPath):
@property @property
def project_uuid(self) -> str: def project_uuid(self) -> str:
assert self.parts[0] == '/' assert self.parts[0] == '/'
if len(self.parts) <= 1:
return None
return self.parts[1] return self.parts[1]
@property @property
@@ -74,10 +59,11 @@ class CloudPath(pathlib.PurePosixPath):
@property @property
def node_uuid(self) -> str: def node_uuid(self) -> str:
if len(self.parts) <= 2: node_uuids = self.node_uuids
return None
return self.parts[-1] if not node_uuids:
return None
return node_uuids[-1]
@contextmanager @contextmanager
@@ -98,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:
@@ -125,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:
@@ -138,95 +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()
pillar_user_id = subclient['subclient_user_id']
if not pillar_user_id:
raise CredentialsNotSyncedError()
try:
db_user = await pillar_call(pillarsdk.User.find, pillar_user_id)
except (pillarsdk.UnauthorizedAccess, pillarsdk.ResourceNotFound):
raise CredentialsNotSyncedError()
roles = db_user.roles
log.debug('User has roles %r', roles)
if not roles or not {'subscriber', 'demo'}.intersection(set(roles)):
# Delete the subclient info. This forces a re-check later, which can
# then pick up on the user's new status.
del profile.subclients[SUBCLIENT_ID]
profile.save_json()
raise NotSubscribedToCloudError()
async def refresh_pillar_credentials():
"""Refreshes the authentication token on Pillar.
:raises blender_id.BlenderIdCommError: when Blender ID refuses to send a token to Pillar.
:raises Exception: when the Pillar credential check fails.
"""
global _pillar_api
import blender_id
from . import blender
pillar_endpoint = blender.preferences().pillar_server.rstrip('/')
# Create a subclient token and send it to Pillar.
# May raise a blender_id.BlenderIdCommError
try:
blender_id.create_subclient_token(SUBCLIENT_ID, pillar_endpoint)
except blender_id.communication.BlenderIdCommError as ex:
log.warning("Unable to create authentication token: %s", ex)
raise CredentialsNotSyncedError()
# Test the new URL
_pillar_api = None
await check_pillar_credentials()
async def get_project_uuid(project_url: str) -> str: async def get_project_uuid(project_url: str) -> str:
"""Returns the UUID for the project, given its '/p/<project_url>' string.""" """Returns the UUID for the project, given its '/p/<project_url>' string."""
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
@@ -236,7 +153,7 @@ async def get_project_uuid(project_url: str) -> str:
async def get_nodes(project_uuid: str = None, parent_node_uuid: str = None, async def get_nodes(project_uuid: str = None, parent_node_uuid: str = None,
node_type = None) -> list: node_type: str = None) -> list:
"""Gets nodes for either a project or given a parent node. """Gets nodes for either a project or given a parent node.
@param project_uuid: the UUID of the project, or None if only querying by parent_node_uuid. @param project_uuid: the UUID of the project, or None if only querying by parent_node_uuid.
@@ -261,31 +178,19 @@ async def get_nodes(project_uuid: str = None, parent_node_uuid: str = None,
where['project'] = project_uuid where['project'] = project_uuid
if node_type: if node_type:
if isinstance(node_type, str):
where['node_type'] = node_type where['node_type'] = node_type
else:
where['node_type'] = {'$in': 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,
'embed': ['parent']}) 'sort': 'properties.order',
'embed': ['parent']}, api=pillar_api())
return children['_items'] loop = asyncio.get_event_loop()
children = await loop.run_in_executor(None, node_all)
async def get_texture_projects() -> list:
"""Returns project dicts that contain textures."""
try:
children = await pillar_call(pillarsdk.Project.all_from_endpoint,
'/bcloud/texture-libraries')
except pillarsdk.ResourceNotFound as ex:
log.warning('Unable to find texture projects: %s', ex)
raise PillarError('Unable to find texture projects: %s' % ex)
return children['_items'] return children['_items']
@@ -401,7 +306,11 @@ async def fetch_thumbnail_info(file: pillarsdk.File, directory: str, desired_siz
finished. finished.
""" """
thumb_link = await pillar_call(file.thumbnail, 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 {}"
@@ -443,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)
@@ -471,28 +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()
# Find out which file to use for the thumbnail picture. file_find = functools.partial(pillarsdk.File.find, params={
pic_uuid = texture_node.picture
if not pic_uuid:
# Fall back to the first texture file, if it exists.
log.debug('Node %r does not have a picture, falling back to first file.',
texture_node['_id'])
files = texture_node.properties and texture_node.properties.files
if not files:
log.info('Node %r does not have a picture nor files, skipping.', texture_node['_id'])
return
pic_uuid = files[0].file
if not pic_uuid:
log.info('Node %r does not have a picture nor files, skipping.', texture_node['_id'])
return
# Load the File that belongs to this texture node's 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}, 'projection': {'filename': 1, 'variations': 1, 'width': 1, 'height': 1},
}) }, api=api)
# Find the File that belongs to this texture node
pic_uuid = texture_node['picture']
loop.call_soon_threadsafe(thumbnail_loading, texture_node, texture_node)
file_desc = await loop.run_in_executor(None, file_find, pic_uuid)
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)
@@ -535,21 +442,18 @@ 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)
save_as_json(file_desc, metadata_file) save_as_json(file_desc, metadata_file)
root, ext = os.path.splitext(file_desc['filename']) file_path = os.path.join(target_directory,
if root.endswith(map_type): sanitize_filename('%s-%s' % (map_type, file_desc['filename'])))
target_filename = '%s%s' % (root, ext)
else:
target_filename = '%s-%s%s' % (root, map_type, ext)
file_path = os.path.join(target_directory, sanitize_filename(target_filename))
file_url = file_desc['link'] file_url = file_desc['link']
# log.debug('Texture %r:\n%s', file_uuid, pprint.pformat(file_desc.to_dict())) # log.debug('Texture %r:\n%s', file_uuid, pprint.pformat(file_desc.to_dict()))
loop.call_soon_threadsafe(file_loading, file_path, file_desc) loop.call_soon_threadsafe(file_loading, file_path, file_desc)
@@ -583,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

@@ -18,9 +18,8 @@ def load_wheel(module_name, fname_prefix):
try: try:
module = __import__(module_name) module = __import__(module_name)
except ImportError as ex: except ImportError:
log.debug('Unable to import %s directly, will try wheel: %s', pass
module_name, ex)
else: else:
log.debug('Was able to load %s from %s, no need to load wheel %s', log.debug('Was able to load %s from %s, no need to load wheel %s',
module_name, module.__file__, fname_prefix) module_name, module.__file__, fname_prefix)
@@ -39,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.2.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."""
@@ -173,12 +164,11 @@ setup(
'wheels': BuildWheels}, 'wheels': BuildWheels},
name='blender_cloud', name='blender_cloud',
description='The Blender Cloud addon allows browsing the Blender Cloud from Blender.', description='The Blender Cloud addon allows browsing the Blender Cloud from Blender.',
version='1.1.0', version='1.0.0',
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+)',