Compare commits

..

13 Commits

Author SHA1 Message Date
8065ab88a4 Bumped version to 1.8.0 2018-01-03 14:09:01 +01:00
6baf43e53b Fix KeyError when browsing HDRIs 2018-01-02 17:24:03 +01:00
f1fa273370 Updated changelog 2018-01-02 16:48:47 +01:00
bf96638c88 Updated changelog 2018-01-02 16:44:33 +01:00
bc8a985228 Added button to open a Cloud project in webbrowser. 2018-01-02 16:44:29 +01:00
ba14c33b6d Store project-specific settings in the preferences.
This stores project-specific settings, such as filesystem paths, for each
project, and restores those settings when the project is selected again.
Does not touch settings that haven't been set for the newly selected
project.
2018-01-02 16:42:37 +01:00
0a7e7195a2 Changed default Flamenco path to tempfile.gettempdir()
The previous defaults were very Blender Institute specific.
2018-01-02 15:37:38 +01:00
ecab0f6163 Upgraded cryptography package version + its dependencies
This was required to get the package to build on Kubuntu 17.10.
2018-01-02 15:36:19 +01:00
3c91ccced6 Texture Browser: use DPI from user preferences 2018-01-02 15:10:27 +01:00
c9ed6c7d23 Texture browser: show which map types are available in GUI 2018-01-02 14:53:30 +01:00
5fa01daf9e Revisit previous path when re-opening texture browser. 2018-01-02 14:11:30 +01:00
77664fb6d7 Distinguish between 'renew' and 'join' in messages & URL to open. 2018-01-02 14:02:17 +01:00
45cffc5365 Bumped version to 1.7.99 (surrogate for 1.8-dev) 2018-01-02 14:02:17 +01:00
11 changed files with 264 additions and 88 deletions

View File

@@ -1,5 +1,21 @@
# Blender Cloud changelog
## Version 1.8 (in development)
- Distinguish between 'please subscribe' (to get a new subscription) and 'please renew' (to renew an
existing subscription).
- When re-opening the Texture Browser it now opens in the same folder as where it was when closed.
- In the texture browser, draw the components of the texture (i.e. which map types are available),
such as 'bump, normal, specular'.
- Use Interface Scale setting from user preferences to draw the Texture Browser text.
- Store project-specific settings in the preferences, such as filesystem paths, for each project,
and restore those settings when the project is selected again. Does not touch settings that
haven't been set for the newly selected project. These settings are only saved when a setting
is updated, so to save your current settings need to update a single setting; this saves all
settings for the project.
- Added button in the User Preferences to open a Cloud project in your webbrowser.
## Version 1.7.5 (2017-10-06)
- Sorting the project list alphabetically.

View File

@@ -21,7 +21,7 @@
bl_info = {
'name': 'Blender Cloud',
"author": "Sybren A. Stüvel, Francesco Siddi, Inês Almeida, Antony Riakiotakis",
'version': (1, 7, 5),
'version': (1, 8, 0),
'blender': (2, 77, 0),
'location': 'Addon Preferences panel, and Ctrl+Shift+Alt+A anywhere for texture browser',
'description': 'Texture library browser and Blender Sync. Requires the Blender ID addon '
@@ -79,6 +79,7 @@ def register():
reload_mod('blendfile')
reload_mod('home_project')
reload_mod('utils')
reload_mod('pillar')
async_loop = reload_mod('async_loop')
flamenco = reload_mod('flamenco')
@@ -87,9 +88,10 @@ def register():
settings_sync = reload_mod('settings_sync')
image_sharing = reload_mod('image_sharing')
blender = reload_mod('blender')
project_specific = reload_mod('project_specific')
else:
from . import (blender, texture_browser, async_loop, settings_sync, blendfile, home_project,
image_sharing, attract, flamenco)
image_sharing, attract, flamenco, project_specific)
async_loop.setup_asyncio_executor()
async_loop.register()
@@ -101,7 +103,7 @@ def register():
image_sharing.register()
blender.register()
blender.handle_project_update()
project_specific.handle_project_update()
def _monkey_patch_requests():

View File

@@ -23,13 +23,14 @@ Separated from __init__.py so that we can import & run from non-Blender environm
import functools
import logging
import os.path
import tempfile
import bpy
from bpy.types import AddonPreferences, Operator, WindowManager, Scene, PropertyGroup
from bpy.props import StringProperty, EnumProperty, PointerProperty, BoolProperty, IntProperty
import rna_prop_ui
from . import pillar, async_loop, flamenco
from . import pillar, async_loop, flamenco, project_specific
from .utils import pyside_cache, redraw
PILLAR_WEB_SERVER_URL = os.environ.get('BCLOUD_SERVER', 'https://cloud.blender.org/')
@@ -37,7 +38,6 @@ PILLAR_SERVER_URL = '%sapi/' % PILLAR_WEB_SERVER_URL
ADDON_NAME = 'blender_cloud'
log = logging.getLogger(__name__)
icons = None
@@ -139,30 +139,6 @@ def project_extensions(project_id) -> set:
return set(proj.get('enabled_for', ()))
def handle_project_update(_=None, _2=None):
"""Handles changing projects, which may cause extensions to be disabled/enabled.
Ignores arguments so that it can be used as property update callback.
"""
project_id = preferences().project.project
log.info('Updating internal state to reflect extensions enabled on current project %s.',
project_id)
project_extensions.cache_clear()
from blender_cloud import attract, flamenco
attract.deactivate()
flamenco.deactivate()
enabled_for = project_extensions(project_id)
log.info('Project extensions: %s', enabled_for)
if 'attract' in enabled_for:
attract.activate()
if 'flamenco' in enabled_for:
flamenco.activate()
class BlenderCloudProjectGroup(PropertyGroup):
status = EnumProperty(
items=[
@@ -177,7 +153,7 @@ class BlenderCloudProjectGroup(PropertyGroup):
items=bcloud_available_projects,
name='Cloud project',
description='Which Blender Cloud project to work with',
update=handle_project_update
update=project_specific.handle_project_update
)
# List of projects is stored in 'available_projects' ID property,
@@ -189,13 +165,13 @@ class BlenderCloudProjectGroup(PropertyGroup):
@available_projects.setter
def available_projects(self, new_projects):
self['available_projects'] = new_projects
handle_project_update()
project_specific.handle_project_update()
class BlenderCloudPreferences(AddonPreferences):
bl_idname = ADDON_NAME
# The following two properties are read-only to limit the scope of the
# The following property is read-only to limit the scope of the
# addon and allow for proper testing within this scope.
pillar_server = StringProperty(
name='Blender Cloud Server',
@@ -224,29 +200,32 @@ class BlenderCloudPreferences(AddonPreferences):
description='Local path of your Attract project, used to search for blend files; '
'usually best to set to an absolute path',
subtype='DIR_PATH',
default='//../')
default='//../',
update=project_specific.store,
)
flamenco_manager = PointerProperty(type=flamenco.FlamencoManagerGroup)
flamenco_exclude_filter = StringProperty(
name='File Exclude Filter',
description='Filter like "*.abc;*.mkv" to prevent certain files to be packed '
'into the output directory',
default='')
# TODO: before making Flamenco public, change the defaults to something less Institute-specific.
# NOTE: The assumption is that the workers can also find the files in the same path.
# This assumption is true for the Blender Institute.
default='',
update=project_specific.store,
)
flamenco_job_file_path = StringProperty(
name='Job Storage Path',
description='Path where to store job files, should be accesible for Workers too',
subtype='DIR_PATH',
default='/render/_flamenco/storage')
# TODO: before making Flamenco public, change the defaults to something less Institute-specific.
default=tempfile.gettempdir(),
update=project_specific.store,
)
flamenco_job_output_path = StringProperty(
name='Job Output Path',
description='Path where to store output files, should be accessible for Workers',
subtype='DIR_PATH',
default='/render/_flamenco/output')
default=tempfile.gettempdir(),
update=project_specific.store,
)
flamenco_job_output_strip_components = IntProperty(
name='Job Output Path Strip Components',
description='The final output path comprises of the job output path, and the blend file '
@@ -255,11 +234,12 @@ class BlenderCloudPreferences(AddonPreferences):
min=0,
default=0,
soft_max=4,
update=project_specific.store,
)
flamenco_open_browser_after_submit = BoolProperty(
name='Open Browser after Submitting Job',
description='When enabled, Blender will open a webbrowser',
default=True
default=True,
)
def draw(self, context):
@@ -406,6 +386,10 @@ class BlenderCloudPreferences(AddonPreferences):
row_buttons.operator('pillar.projects',
text='',
icon='FILE_REFRESH')
props = row_buttons.operator('pillar.project_open_in_browser',
text='',
icon='WORLD')
props.project_id = project
else:
row_buttons.label('Fetching available projects.')
@@ -545,6 +529,36 @@ class PILLAR_OT_subscribe(Operator):
return {'FINISHED'}
class PILLAR_OT_project_open_in_browser(Operator):
bl_idname = 'pillar.project_open_in_browser'
bl_label = 'Open in Browser'
bl_description = 'Opens a webbrowser to show the project'
project_id = StringProperty(name='Project ID')
def execute(self, context):
if not self.project_id:
return {'CANCELLED'}
import webbrowser
import urllib.parse
import pillarsdk
from .pillar import sync_call
project = sync_call(pillarsdk.Project.find, self.project_id, {'projection': {'url': True}})
if log.isEnabledFor(logging.DEBUG):
import pprint
log.debug('found project: %s', pprint.pformat(project.to_dict()))
url = urllib.parse.urljoin(PILLAR_WEB_SERVER_URL, 'p/' + project.url)
webbrowser.open_new_tab(url)
self.report({'INFO'}, 'Opened a browser at %s' % url)
return {'FINISHED'}
class PILLAR_OT_projects(async_loop.AsyncModalOperatorMixin,
pillar.AuthenticatedPillarOperatorMixin,
Operator):
@@ -672,6 +686,7 @@ def register():
bpy.utils.register_class(SyncStatusProperties)
bpy.utils.register_class(PILLAR_OT_subscribe)
bpy.utils.register_class(PILLAR_OT_projects)
bpy.utils.register_class(PILLAR_OT_project_open_in_browser)
bpy.utils.register_class(PILLAR_PT_image_custom_properties)
addon_prefs = preferences()
@@ -706,6 +721,7 @@ def unregister():
bpy.utils.unregister_class(SyncStatusProperties)
bpy.utils.unregister_class(PILLAR_OT_subscribe)
bpy.utils.unregister_class(PILLAR_OT_projects)
bpy.utils.unregister_class(PILLAR_OT_project_open_in_browser)
bpy.utils.unregister_class(PILLAR_PT_image_custom_properties)
del WindowManager.last_blender_cloud_location

View File

@@ -42,7 +42,7 @@ import bpy
from bpy.types import AddonPreferences, Operator, WindowManager, Scene, PropertyGroup
from bpy.props import StringProperty, EnumProperty, PointerProperty, BoolProperty, IntProperty
from .. import async_loop, pillar
from .. import async_loop, pillar, project_specific
from ..utils import pyside_cache, redraw
log = logging.getLogger(__name__)
@@ -67,7 +67,9 @@ class FlamencoManagerGroup(PropertyGroup):
manager = EnumProperty(
items=available_managers,
name='Flamenco Manager',
description='Which Flamenco Manager to use for jobs')
description='Which Flamenco Manager to use for jobs',
update=project_specific.store,
)
status = EnumProperty(
items=[

View File

@@ -124,9 +124,8 @@ class PILLAR_OT_image_share(pillar.PillarOperatorMixin,
db_user = await self.check_credentials(context, REQUIRES_ROLES_FOR_IMAGE_SHARING)
self.user_id = db_user['_id']
self.log.debug('Found user ID: %s', self.user_id)
except pillar.NotSubscribedToCloudError:
self.log.exception('User not subscribed to cloud.')
self.report({'ERROR'}, 'Please subscribe to the Blender Cloud.')
except pillar.NotSubscribedToCloudError as ex:
self._log_subscription_needed(can_renew=ex.can_renew)
self._state = 'QUIT'
return
except pillar.UserNotLoggedInError:

View File

@@ -62,7 +62,16 @@ class CredentialsNotSyncedError(UserNotLoggedInError):
class NotSubscribedToCloudError(UserNotLoggedInError):
"""Raised when the user may be logged in on Blender ID, but has no Blender Cloud token."""
"""Raised when the user does not have an active Cloud subscription.
:ivar can_renew: True when the user has an inactive subscription that can be renewed,
or False when the user has no subscription at all.
"""
def __init__(self, can_renew: bool):
super().__init__()
self.can_renew = can_renew
log.warning('Not subscribed to cloud, can_renew=%s', can_renew)
class PillarError(RuntimeError):
@@ -273,14 +282,15 @@ async def check_pillar_credentials(required_roles: set):
except (pillarsdk.UnauthorizedAccess, pillarsdk.ResourceNotFound, pillarsdk.ForbiddenAccess):
raise CredentialsNotSyncedError()
roles = db_user.roles or set()
log.debug('User has roles %r', roles)
if required_roles and not required_roles.intersection(set(roles)):
roles = set(db_user.roles or set())
log.getChild('check_pillar_credentials').debug('user has roles %r', roles)
if required_roles and not required_roles.intersection(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()
raise NotSubscribedToCloudError(can_renew='has_subscription' in roles)
return db_user
@@ -834,7 +844,6 @@ class PillarOperatorMixin:
try:
db_user = await check_pillar_credentials(required_roles)
except NotSubscribedToCloudError:
self._log_subscription_needed()
raise
except CredentialsNotSyncedError:
self.log.info('Credentials not synced, re-syncing automatically.')
@@ -845,7 +854,6 @@ class PillarOperatorMixin:
try:
db_user = await refresh_pillar_credentials(required_roles)
except NotSubscribedToCloudError:
self._log_subscription_needed()
raise
except CredentialsNotSyncedError:
self.log.info('Credentials not synced after refreshing, handling as not logged in.')
@@ -857,11 +865,13 @@ class PillarOperatorMixin:
self.log.info('Credentials refreshed and ok.')
return db_user
def _log_subscription_needed(self):
self.log.warning(
'Please subscribe to the blender cloud at https://cloud.blender.org/join')
self.report({'INFO'},
'Please subscribe to the blender cloud at https://cloud.blender.org/join')
def _log_subscription_needed(self, *, can_renew: bool, level='ERROR'):
if can_renew:
msg = 'Please renew your Blender Cloud subscription at https://cloud.blender.org/renew'
else:
msg = 'Please subscribe to the blender cloud at https://cloud.blender.org/join'
self.log.warning(msg)
self.report({level}, msg)
class AuthenticatedPillarOperatorMixin(PillarOperatorMixin):

View File

@@ -0,0 +1,101 @@
"""Handle saving and loading project-specific settings."""
import logging
# Names of BlenderCloudPreferences properties that are both project-specific
# and simple enough to store directly in a dict.
PROJECT_SPECIFIC_SIMPLE_PROPS = (
'cloud_project_local_path',
'flamenco_exclude_filter',
'flamenco_job_file_path',
'flamenco_job_output_path',
'flamenco_job_output_strip_components'
)
log = logging.getLogger(__name__)
project_settings_loading = False
def handle_project_update(_=None, _2=None):
"""Handles changing projects, which may cause extensions to be disabled/enabled.
Ignores arguments so that it can be used as property update callback.
"""
from .blender import preferences, project_extensions
global project_settings_loading
project_settings_loading = True
try:
prefs = preferences()
project_id = prefs.project.project
log.info('Updating internal state to reflect extensions enabled on current project %s.',
project_id)
project_extensions.cache_clear()
from blender_cloud import attract, flamenco
attract.deactivate()
flamenco.deactivate()
enabled_for = project_extensions(project_id)
log.info('Project extensions: %s', enabled_for)
if 'attract' in enabled_for:
attract.activate()
if 'flamenco' in enabled_for:
flamenco.activate()
# Load project-specific settings from the last time we visited this project.
ps = prefs.get('project_settings', {}).get(project_id, {})
if not ps:
log.debug('no project-specific settings are available, not touching options')
return
if log.isEnabledFor(logging.DEBUG):
from pprint import pformat
log.debug('loading project-specific settings:\n%s', pformat(ps.to_dict()))
for name in PROJECT_SPECIFIC_SIMPLE_PROPS:
if name in ps and hasattr(prefs, name):
setattr(prefs, name, ps[name])
if ps.get('flamenco_manager'):
prefs.flamenco_manager.manager = ps['flamenco_manager']
log.debug('setting flamenco manager to %s', ps['flamenco_manager'])
finally:
project_settings_loading = False
def store(_=None, _2=None):
"""Remember project-specific settings as soon as one of them changes.
Ignores arguments so that it can be used as property update callback.
No-op when project_settings_loading=True, to prevent saving project-
specific settings while they are actually being loaded.
"""
from .blender import preferences
global project_settings_loading
if project_settings_loading:
return
prefs = preferences()
project_id = prefs.project.project
all_settings = prefs.get('project_settings', {})
ps = all_settings.get(project_id, {})
for name in PROJECT_SPECIFIC_SIMPLE_PROPS:
ps[name] = getattr(prefs, name)
ps['flamenco_manager'] = prefs.flamenco_manager.manager
if log.isEnabledFor(logging.DEBUG):
from pprint import pformat
if hasattr(ps, 'to_dict'):
ps_to_log = ps.to_dict()
else:
ps_to_log = ps
log.debug('saving project-specific settings:\n%s', pformat(ps_to_log))
all_settings[project_id] = ps
prefs['project_settings'] = all_settings

View File

@@ -284,9 +284,8 @@ class PILLAR_OT_sync(pillar.PillarOperatorMixin,
db_user = await self.check_credentials(context, REQUIRES_ROLES_FOR_SYNC)
self.user_id = db_user['_id']
log.debug('Found user ID: %s', self.user_id)
except pillar.NotSubscribedToCloudError:
self.log.exception('User not subscribed to cloud.')
self.bss_report({'SUBSCRIBE'}, 'Please subscribe to the Blender Cloud.')
except pillar.NotSubscribedToCloudError as ex:
self._log_subscription_needed(can_renew=ex.can_renew)
self._state = 'QUIT'
return
except pillar.UserNotLoggedInError:

View File

@@ -76,8 +76,8 @@ class MenuItem:
icon_margin_y = 4
text_margin_x = 6
text_height = 16
text_width = 72
text_size = 12
text_size_small = 10
DEFAULT_ICONS = {
'FOLDER': os.path.join(library_icons_path, 'folder.png'),
@@ -214,12 +214,32 @@ class MenuItem:
# draw some text
font_id = 0
blf.position(font_id,
self.x + self.icon_margin_x + ICON_WIDTH + self.text_margin_x,
self.y + ICON_HEIGHT * 0.5 - 0.25 * self.text_height, 0)
blf.size(font_id, self.text_height, self.text_width)
text_dpi = bpy.context.user_preferences.system.dpi
text_x = self.x + self.icon_margin_x + ICON_WIDTH + self.text_margin_x
text_y = self.y + ICON_HEIGHT * 0.5 - 0.25 * self.text_size
blf.position(font_id, text_x, text_y, 0)
blf.size(font_id, self.text_size, text_dpi)
blf.draw(font_id, self.label_text)
# Draw the components of the texture (i.e. which map types are available)
try:
node_files = self.node.properties.files
except AttributeError:
# Happens for nodes that don't have .properties.files.
node_files = None
if not node_files:
return
map_types = {f.map_type for f in node_files if f.map_type}
map_types.discard('color') # all textures have colour
if not map_types:
return
bgl.glColor4f(1.0, 1.0, 1.0, 0.5)
blf.size(font_id, self.text_size_small, text_dpi)
blf.position(font_id, text_x, self.y + 0.5 * self.text_size_small, 0)
blf.draw(font_id, ', '.join(sorted(map_types)))
def hits(self, mouse_x: int, mouse_y: int) -> bool:
return self.x < mouse_x < self.x + self.width and self.y < mouse_y < self.y + self.height
@@ -311,8 +331,8 @@ class BlenderCloudBrowser(pillar.PillarOperatorMixin,
self.mouse_y = event.mouse_y
left_mouse_release = event.type == 'LEFTMOUSE' and event.value == 'RELEASE'
if self._state == 'PLEASE_SUBSCRIBE' and left_mouse_release:
self.open_browser_subscribe()
if left_mouse_release and self._state in {'PLEASE_SUBSCRIBE', 'PLEASE_RENEW'}:
self.open_browser_subscribe(renew=self._state == 'PLEASE_RENEW')
self._finish(context)
return {'FINISHED'}
@@ -365,9 +385,9 @@ class BlenderCloudBrowser(pillar.PillarOperatorMixin,
try:
db_user = await self.check_credentials(context, REQUIRED_ROLES_FOR_TEXTURE_BROWSER)
except pillar.NotSubscribedToCloudError:
self.log.info('User not subscribed to Blender Cloud.')
self._show_subscribe_screen()
except pillar.NotSubscribedToCloudError as ex:
self._log_subscription_needed(can_renew=ex.can_renew, level='INFO')
self._show_subscribe_screen(can_renew=ex.can_renew)
return None
if db_user is None:
@@ -375,10 +395,14 @@ class BlenderCloudBrowser(pillar.PillarOperatorMixin,
await self.async_download_previews()
def _show_subscribe_screen(self):
def _show_subscribe_screen(self, *, can_renew: bool):
"""Shows the "You need to subscribe" screen."""
self._state = 'PLEASE_SUBSCRIBE'
if can_renew:
self._state = 'PLEASE_RENEW'
else:
self._state = 'PLEASE_SUBSCRIBE'
bpy.context.window.cursor_set('HAND')
def descend_node(self, menu_item: MenuItem):
@@ -548,6 +572,7 @@ class BlenderCloudBrowser(pillar.PillarOperatorMixin,
def browse_assets(self):
self.log.debug('Browsing assets at %r', self.current_path)
bpy.context.window_manager.last_blender_cloud_location = str(self.current_path)
self._new_async_task(self.async_download_previews())
def draw_menu(self, context):
@@ -560,6 +585,7 @@ class BlenderCloudBrowser(pillar.PillarOperatorMixin,
'DOWNLOADING_TEXTURE': self._draw_downloading,
'EXCEPTION': self._draw_exception,
'PLEASE_SUBSCRIBE': self._draw_subscribe,
'PLEASE_RENEW': self._draw_renew,
}
if self._state in drawers:
@@ -727,6 +753,11 @@ class BlenderCloudBrowser(pillar.PillarOperatorMixin,
'Click to subscribe to the Blender Cloud',
(0.0, 0.0, 0.2, 0.6))
def _draw_renew(self, context):
self._draw_text_on_colour(context,
'Click to renew your Blender Cloud subscription',
(0.0, 0.0, 0.2, 0.6))
def get_clicked(self) -> MenuItem:
for item in self.current_display_content:
@@ -807,11 +838,11 @@ class BlenderCloudBrowser(pillar.PillarOperatorMixin,
future=signalling_future))
self.async_task.add_done_callback(texture_download_completed)
def open_browser_subscribe(self):
def open_browser_subscribe(self, *, renew: bool):
import webbrowser
webbrowser.open_new_tab('https://cloud.blender.org/join')
url = 'renew' if renew else 'join'
webbrowser.open_new_tab('https://cloud.blender.org/%s' % url)
self.report({'INFO'}, 'We just started a browser for you.')
def _scroll_smooth(self):
@@ -866,9 +897,8 @@ class PILLAR_OT_switch_hdri(pillar.PillarOperatorMixin,
try:
db_user = await self.check_credentials(context, REQUIRED_ROLES_FOR_TEXTURE_BROWSER)
user_id = db_user['_id']
except pillar.NotSubscribedToCloudError:
self.log.exception('User not subscribed to cloud.')
self.report({'ERROR'}, 'Please subscribe to the Blender Cloud.')
except pillar.NotSubscribedToCloudError as ex:
self._log_subscription_needed(can_renew=ex.can_renew)
self._state = 'QUIT'
return
except pillar.UserNotLoggedInError:

View File

@@ -6,11 +6,12 @@ wheel==0.29.0
blender-bam==1.1.7
# Secondary requirements:
cffi==1.6.0
cryptography==1.3.1
idna==2.1
asn1crypto==0.24.0
cffi==1.11.2
cryptography==2.1.4
idna==2.6
pyasn1==0.1.9
pycparser==2.14
pyOpenSSL==16.0.0
pycparser==2.18
pyOpenSSL==17.5.0
requests==2.10.0
six==1.10.0
six==1.11.0

View File

@@ -232,7 +232,7 @@ setup(
'wheels': BuildWheels},
name='blender_cloud',
description='The Blender Cloud addon allows browsing the Blender Cloud from Blender.',
version='1.7.5',
version='1.8.0',
author='Sybren A. Stüvel',
author_email='sybren@stuvel.eu',
packages=find_packages('.'),