diff options
Diffstat (limited to 'mediagoblin/tools')
-rw-r--r-- | mediagoblin/tools/common.py | 1 | ||||
-rw-r--r-- | mediagoblin/tools/crypto.py | 113 | ||||
-rw-r--r-- | mediagoblin/tools/exif.py | 48 | ||||
-rw-r--r-- | mediagoblin/tools/files.py | 2 | ||||
-rw-r--r-- | mediagoblin/tools/licenses.py | 84 | ||||
-rw-r--r-- | mediagoblin/tools/mail.py | 13 | ||||
-rw-r--r-- | mediagoblin/tools/pagination.py | 15 | ||||
-rw-r--r-- | mediagoblin/tools/pluginapi.py | 167 | ||||
-rw-r--r-- | mediagoblin/tools/processing.py | 2 | ||||
-rw-r--r-- | mediagoblin/tools/request.py | 17 | ||||
-rw-r--r-- | mediagoblin/tools/response.py | 91 | ||||
-rw-r--r-- | mediagoblin/tools/routing.py | 67 | ||||
-rw-r--r-- | mediagoblin/tools/session.py | 68 | ||||
-rw-r--r-- | mediagoblin/tools/template.py | 87 | ||||
-rw-r--r-- | mediagoblin/tools/text.py | 26 | ||||
-rw-r--r-- | mediagoblin/tools/timesince.py | 95 | ||||
-rw-r--r-- | mediagoblin/tools/translate.py | 102 | ||||
-rw-r--r-- | mediagoblin/tools/url.py | 17 | ||||
-rw-r--r-- | mediagoblin/tools/workbench.py | 164 |
19 files changed, 1002 insertions, 177 deletions
diff --git a/mediagoblin/tools/common.py b/mediagoblin/tools/common.py index c9f9d032..34586611 100644 --- a/mediagoblin/tools/common.py +++ b/mediagoblin/tools/common.py @@ -16,7 +16,6 @@ import sys -DISPLAY_IMAGE_FETCHING_ORDER = [u'medium', u'original', u'thumb'] global TESTS_ENABLED TESTS_ENABLED = False diff --git a/mediagoblin/tools/crypto.py b/mediagoblin/tools/crypto.py new file mode 100644 index 00000000..1379d21b --- /dev/null +++ b/mediagoblin/tools/crypto.py @@ -0,0 +1,113 @@ +# GNU MediaGoblin -- federated, autonomous media hosting +# Copyright (C) 2013 MediaGoblin contributors. See AUTHORS. +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as published by +# the Free Software Foundation, either version 3 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 Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see <http://www.gnu.org/licenses/>. + +import errno +import itsdangerous +import logging +import os.path +import random +import tempfile +from mediagoblin import mg_globals + +_log = logging.getLogger(__name__) + + +# Use the system (hardware-based) random number generator if it exists. +# -- this optimization is lifted from Django +try: + getrandbits = random.SystemRandom().getrandbits +except AttributeError: + getrandbits = random.getrandbits + + +__itsda_secret = None + + +def load_key(filename): + global __itsda_secret + key_file = open(filename) + try: + __itsda_secret = key_file.read() + finally: + key_file.close() + + +def create_key(key_dir, key_filepath): + global __itsda_secret + old_umask = os.umask(077) + key_file = None + try: + if not os.path.isdir(key_dir): + os.makedirs(key_dir) + _log.info("Created %s", key_dir) + key = str(getrandbits(192)) + key_file = tempfile.NamedTemporaryFile(dir=key_dir, suffix='.bin', + delete=False) + key_file.write(key) + key_file.flush() + os.rename(key_file.name, key_filepath) + key_file.close() + finally: + os.umask(old_umask) + if (key_file is not None) and (not key_file.closed): + key_file.close() + os.unlink(key_file.name) + __itsda_secret = key + _log.info("Saved new key for It's Dangerous") + + +def setup_crypto(): + global __itsda_secret + key_dir = mg_globals.app_config["crypto_path"] + key_filepath = os.path.join(key_dir, 'itsdangeroussecret.bin') + try: + load_key(key_filepath) + except IOError, error: + if error.errno != errno.ENOENT: + raise + create_key(key_dir, key_filepath) + + +def get_timed_signer_url(namespace): + """ + This gives a basic signing/verifying object. + + The namespace makes sure signed tokens can't be used in + a different area. Like using a forgot-password-token as + a session cookie. + + Basic usage: + + .. code-block:: python + + _signer = None + TOKEN_VALID_DAYS = 10 + def setup(): + global _signer + _signer = get_timed_signer_url("session cookie") + def create_token(obj): + return _signer.dumps(obj) + def parse_token(token): + # This might raise an exception in case + # of an invalid token, or an expired token. + return _signer.loads(token, max_age=TOKEN_VALID_DAYS*24*3600) + + For more details see + http://pythonhosted.org/itsdangerous/#itsdangerous.URLSafeTimedSerializer + """ + assert __itsda_secret is not None + return itsdangerous.URLSafeTimedSerializer(__itsda_secret, + salt=namespace) diff --git a/mediagoblin/tools/exif.py b/mediagoblin/tools/exif.py index 543484c9..d0f9d0a6 100644 --- a/mediagoblin/tools/exif.py +++ b/mediagoblin/tools/exif.py @@ -14,7 +14,11 @@ # You should have received a copy of the GNU Affero General Public License # along with this program. If not, see <http://www.gnu.org/licenses/>. -from mediagoblin.tools.extlib.EXIF import process_file, Ratio +try: + from EXIF import process_file, Ratio +except ImportError: + from mediagoblin.tools.extlib.EXIF import process_file, Ratio + from mediagoblin.processing import BadMediaFail from mediagoblin.tools.translate import pass_to_ugettext as _ @@ -46,7 +50,10 @@ def exif_fix_image_orientation(im, exif_tags): Translate any EXIF orientation to raw orientation Cons: - - REDUCES IMAGE QUALITY by recompressig it + - Well, it changes the image, which means we'll recompress + it... not a problem if scaling it down already anyway. We might + lose some quality in recompressing if it's at the same-size + though Pros: - Prevents neck pain @@ -58,7 +65,7 @@ def exif_fix_image_orientation(im, exif_tags): 6: 270, 8: 90} orientation = exif_tags['Image Orientation'].values[0] - if orientation in rotation_map.keys(): + if orientation in rotation_map: im = im.rotate( rotation_map[orientation]) @@ -69,16 +76,12 @@ def extract_exif(filename): """ Returns EXIF tags found in file at ``filename`` """ - exif_tags = {} - try: - image = open(filename) - exif_tags = process_file(image) + with file(filename) as image: + return process_file(image, details=False) except IOError: raise BadMediaFail(_('Could not read the image file.')) - return exif_tags - def clean_exif(exif): ''' @@ -92,13 +95,8 @@ def clean_exif(exif): 'JPEGThumbnail', 'Thumbnail JPEGInterchangeFormat'] - clean_exif = {} - - for key, value in exif.items(): - if not key in disabled_tags: - clean_exif[key] = _ifd_tag_to_dict(value) - - return clean_exif + return dict((key, _ifd_tag_to_dict(value)) for (key, value) + in exif.iteritems() if key not in disabled_tags) def _ifd_tag_to_dict(tag): @@ -119,13 +117,8 @@ def _ifd_tag_to_dict(tag): data['printable'] = tag.printable.decode('utf8', 'replace') if type(tag.values) == list: - data['values'] = [] - for val in tag.values: - if isinstance(val, Ratio): - data['values'].append( - _ratio_to_list(val)) - else: - data['values'].append(val) + data['values'] = [_ratio_to_list(val) if isinstance(val, Ratio) else val + for val in tag.values] else: if isinstance(tag.values, str): # Force UTF-8, so that it fits into the DB @@ -141,12 +134,7 @@ def _ratio_to_list(ratio): def get_useful(tags): - useful = {} - for key, tag in tags.items(): - if key in USEFUL_TAGS: - useful[key] = tag - - return useful + return dict((key, tag) for (key, tag) in tags.iteritems() if key in USEFUL_TAGS) def get_gps_data(tags): @@ -163,7 +151,7 @@ def get_gps_data(tags): 'latitude': tags['GPS GPSLatitude'], 'longitude': tags['GPS GPSLongitude']} - for key, dat in dms_data.items(): + for key, dat in dms_data.iteritems(): gps_data[key] = ( lambda v: float(v[0].num) / float(v[0].den) \ diff --git a/mediagoblin/tools/files.py b/mediagoblin/tools/files.py index fd38f05e..848c86f2 100644 --- a/mediagoblin/tools/files.py +++ b/mediagoblin/tools/files.py @@ -37,7 +37,7 @@ def delete_media_files(media): mg_globals.public_store.delete_file( attachment['filepath']) except OSError: - no_such_files.append("/".join(attachment)) + no_such_files.append("/".join(attachment['filepath'])) if no_such_files: raise OSError(", ".join(no_such_files)) diff --git a/mediagoblin/tools/licenses.py b/mediagoblin/tools/licenses.py index 48cb442b..a964980e 100644 --- a/mediagoblin/tools/licenses.py +++ b/mediagoblin/tools/licenses.py @@ -14,42 +14,56 @@ # You should have received a copy of the GNU Affero General Public License # along with this program. If not, see <http://www.gnu.org/licenses/>. -SORTED_SUPPORTED_LICENSES = [ - ("", - {"name": "No license specified", - "abbreviation": "All rights reserved"}), - ("http://creativecommons.org/licenses/by/3.0/", - {"name": "Creative Commons Attribution Unported 3.0", - "abbreviation": "CC BY 3.0"}), - ("http://creativecommons.org/licenses/by-sa/3.0/", - {"name": "Creative Commons Attribution-ShareAlike Unported 3.0", - "abbreviation": "CC BY-SA 3.0"}), - ("http://creativecommons.org/licenses/by-nd/3.0/", - {"name": "Creative Commons Attribution-NoDerivs 3.0 Unported", - "abbreviation": "CC BY-ND 3.0"}), - ("http://creativecommons.org/licenses/by-nc/3.0/", - {"name": "Creative Commons Attribution-NonCommercial Unported 3.0", - "abbreviation": "CC BY-NC 3.0"}), - ("http://creativecommons.org/licenses/by-nc-sa/3.0/", - {"name": "Creative Commons Attribution-NonCommercial-ShareAlike 3.0 Unported", - "abbreviation": "CC BY-NC-SA 3.0"}), - ("http://creativecommons.org/licenses/by-nc-nd/3.0/", - {"name": "Creative Commons Attribution-NonCommercial-NoDerivs 3.0 Unported", - "abbreviation": "CC BY-NC-ND 3.0"}), - ("http://creativecommons.org/publicdomain/zero/1.0/", - {"name": "Creative Commons CC0 1.0 Universal", - "abbreviation": "CC0 1.0"}), - ("http://creativecommons.org/publicdomain/mark/1.0/", - {"name": "Public Domain", - "abbreviation": "Public Domain"})] - -SUPPORTED_LICENSES = dict(SORTED_SUPPORTED_LICENSES) +from collections import namedtuple +# Give a License attribute names: uri, name, abbreviation +License = namedtuple("License", ["abbreviation", "name", "uri"]) +SORTED_LICENSES = [ + License("All rights reserved", "No license specified", ""), + License("CC BY 3.0", "Creative Commons Attribution Unported 3.0", + "http://creativecommons.org/licenses/by/3.0/"), + License("CC BY-SA 3.0", + "Creative Commons Attribution-ShareAlike Unported 3.0", + "http://creativecommons.org/licenses/by-sa/3.0/"), + License("CC BY-ND 3.0", + "Creative Commons Attribution-NoDerivs 3.0 Unported", + "http://creativecommons.org/licenses/by-nd/3.0/"), + License("CC BY-NC 3.0", + "Creative Commons Attribution-NonCommercial Unported 3.0", + "http://creativecommons.org/licenses/by-nc/3.0/"), + License("CC BY-NC-SA 3.0", + "Creative Commons Attribution-NonCommercial-ShareAlike 3.0 Unported", + "http://creativecommons.org/licenses/by-nc-sa/3.0/"), + License("CC BY-NC-ND 3.0", + "Creative Commons Attribution-NonCommercial-NoDerivs 3.0 Unported", + "http://creativecommons.org/licenses/by-nc-nd/3.0/"), + License("CC0 1.0", + "Creative Commons CC0 1.0 Universal", + "http://creativecommons.org/publicdomain/zero/1.0/"), + License("Public Domain","Public Domain", + "http://creativecommons.org/publicdomain/mark/1.0/"), + ] -def licenses_as_choices(): - license_list = [] +# dict {uri: License,...} to enable fast license lookup by uri. Ideally, +# we'd want to use an OrderedDict (python 2.7+) here to avoid having the +# same data in two structures +SUPPORTED_LICENSES = dict(((l.uri, l) for l in SORTED_LICENSES)) + + +def get_license_by_url(url): + """Look up a license by its url and return the License object""" + try: + return SUPPORTED_LICENSES[url] + except KeyError: + # in case of an unknown License, just display the url given + # rather than exploding in the user's face. + return License(url, url, url) - for uri, data in SORTED_SUPPORTED_LICENSES: - license_list.append((uri, data["abbreviation"])) - return license_list +def licenses_as_choices(): + """List of (uri, abbreviation) tuples for HTML choice field population + + The data seems to be consumed/deleted during usage, so hand over a + throwaway list, rather than just a generator. + """ + return [(lic.uri, lic.abbreviation) for lic in SORTED_LICENSES] diff --git a/mediagoblin/tools/mail.py b/mediagoblin/tools/mail.py index 8639ba0c..4fa02ce5 100644 --- a/mediagoblin/tools/mail.py +++ b/mediagoblin/tools/mail.py @@ -122,3 +122,16 @@ def send_email(from_addr, to_addrs, subject, message_body): print message.get_payload(decode=True) return mhost.sendmail(from_addr, to_addrs, message.as_string()) + + +def normalize_email(email): + """return case sensitive part, lower case domain name + + :returns: None in case of broken email addresses""" + try: + em_user, em_dom = email.split('@', 1) + except ValueError: + # email contained no '@' + return None + email = "@".join((em_user, em_dom.lower())) + return email diff --git a/mediagoblin/tools/pagination.py b/mediagoblin/tools/pagination.py index 50e59070..d0f08c94 100644 --- a/mediagoblin/tools/pagination.py +++ b/mediagoblin/tools/pagination.py @@ -25,7 +25,7 @@ PAGINATION_DEFAULT_PER_PAGE = 30 class Pagination(object): """ - Pagination class for mongodb queries. + Pagination class for database queries. Initialization through __init__(self, cursor, page=1, per_page=2), get actual data slice through __call__(). @@ -40,8 +40,8 @@ class Pagination(object): - page: requested page - per_page: number of objects per page - cursor: db cursor - - jump_to_id: ObjectId, sets the page to the page containing the - object with _id == jump_to_id. + - jump_to_id: object id, sets the page to the page containing the + object with id == jump_to_id. """ self.page = page self.per_page = per_page @@ -53,7 +53,7 @@ class Pagination(object): cursor = copy.copy(self.cursor) for (doc, increment) in izip(cursor, count(0)): - if doc._id == jump_to_id: + if doc.id == jump_to_id: self.page = 1 + int(floor(increment / self.per_page)) self.active_id = jump_to_id @@ -63,8 +63,11 @@ class Pagination(object): """ Returns slice of objects for the requested page """ - return self.cursor.skip( - (self.page - 1) * self.per_page).limit(self.per_page) + # TODO, return None for out of index so templates can + # distinguish between empty galleries and out-of-bound pages??? + return self.cursor.slice( + (self.page - 1) * self.per_page, + self.page * self.per_page) @property def pages(self): diff --git a/mediagoblin/tools/pluginapi.py b/mediagoblin/tools/pluginapi.py index 1752dfc8..3f98aa8a 100644 --- a/mediagoblin/tools/pluginapi.py +++ b/mediagoblin/tools/pluginapi.py @@ -83,6 +83,9 @@ class PluginManager(object): # list of registered template paths "template_paths": set(), + # list of template hooks + "template_hooks": {}, + # list of registered routes "routes": [], } @@ -131,6 +134,18 @@ class PluginManager(object): def get_routes(self): return tuple(self.routes) + def register_template_hooks(self, template_hooks): + for hook, templates in template_hooks.items(): + if isinstance(templates, (list, tuple)): + self.template_hooks.setdefault(hook, []).extend(list(templates)) + else: + # In this case, it's actually a single callable---not a + # list of callables. + self.template_hooks.setdefault(hook, []).append(templates) + + def get_template_hooks(self, hook_name): + return self.template_hooks.get(hook_name, []) + def register_routes(routes): """Registers one or more routes @@ -145,16 +160,14 @@ def register_routes(routes): Example passing in a single route: - >>> from routes import Route - >>> register_routes(Route('about-view', '/about', - ... controller=about_view_handler)) + >>> register_routes(('about-view', '/about', + ... 'mediagoblin.views:about_view_handler')) Example passing in a list of routes: - >>> from routes import Route >>> register_routes([ - ... Route('contact-view', '/contact', controller=contact_handler), - ... Route('about-view', '/about', controller=about_handler) + ... ('contact-view', '/contact', 'mediagoblin.views:contact_handler'), + ... ('about-view', '/about', 'mediagoblin.views:about_handler') ... ]) @@ -210,3 +223,145 @@ def get_config(key): return plugin_section.get(key, {}) +def register_template_hooks(template_hooks): + """ + Register a dict of template hooks. + + Takes template_hooks as an argument, which is a dictionary of + template hook names/keys to the templates they should provide. + (The value can either be a single template path or an iterable + of paths.) + + Example: + + .. code-block:: python + + {"media_sidebar": "/plugin/sidemess/mess_up_the_side.html", + "media_descriptionbox": ["/plugin/sidemess/even_more_mess.html", + "/plugin/sidemess/so_much_mess.html"]} + """ + PluginManager().register_template_hooks(template_hooks) + + +def get_hook_templates(hook_name): + """ + Get a list of hook templates for this hook_name. + + Note: for the most part, you access this via a template tag, not + this method directly, like so: + + .. code-block:: html+jinja + + {% template_hook "media_sidebar" %} + + ... which will include all templates for you, partly using this + method. + + However, this method is exposed to templates, and if you wish, you + can iterate over templates in a template hook manually like so: + + .. code-block:: html+jinja + + {% for template_path in get_hook_templates("media_sidebar") %} + <div class="extra_structure"> + {% include template_path %} + </div> + {% endfor %} + + Returns: + A list of strings representing template paths. + """ + return PluginManager().get_template_hooks(hook_name) + + +############################# +## Hooks: The Next Generation +############################# + + +def hook_handle(hook_name, *args, **kwargs): + """ + Run through hooks attempting to find one that handle this hook. + + All callables called with the same arguments until one handles + things and returns a non-None value. + + (If you are writing a handler and you don't have a particularly + useful value to return even though you've handled this, returning + True is a good solution.) + + Note that there is a special keyword argument: + if "default_handler" is passed in as a keyword argument, this will + be used if no handler is found. + + Some examples of using this: + - You need an interface implemented, but only one fit for it + - You need to *do* something, but only one thing needs to do it. + """ + default_handler = kwargs.pop('default_handler', None) + + callables = PluginManager().get_hook_callables(hook_name) + + result = None + + for callable in callables: + result = callable(*args, **kwargs) + + if result is not None: + break + + if result is None and default_handler is not None: + result = default_handler(*args, **kwargs) + + return result + + +def hook_runall(hook_name, *args, **kwargs): + """ + Run through all callable hooks and pass in arguments. + + All non-None results are accrued in a list and returned from this. + (Other "false-like" values like False and friends are still + accrued, however.) + + Some examples of using this: + - You have an interface call where actually multiple things can + and should implement it + - You need to get a list of things from various plugins that + handle them and do something with them + - You need to *do* something, and actually multiple plugins need + to do it separately + """ + callables = PluginManager().get_hook_callables(hook_name) + + results = [] + + for callable in callables: + result = callable(*args, **kwargs) + + if result is not None: + results.append(result) + + return results + + +def hook_transform(hook_name, arg): + """ + Run through a bunch of hook callables and transform some input. + + Note that unlike the other hook tools, this one only takes ONE + argument. This argument is passed to each function, which in turn + returns something that becomes the input of the next callable. + + Some examples of using this: + - You have an object, say a form, but you want plugins to each be + able to modify it. + """ + result = arg + + callables = PluginManager().get_hook_callables(hook_name) + + for callable in callables: + result = callable(result) + + return result diff --git a/mediagoblin/tools/processing.py b/mediagoblin/tools/processing.py index cff4cb9d..2abe6452 100644 --- a/mediagoblin/tools/processing.py +++ b/mediagoblin/tools/processing.py @@ -21,8 +21,6 @@ import traceback from urllib2 import urlopen, Request, HTTPError from urllib import urlencode -from mediagoblin.tools.common import TESTS_ENABLED - _log = logging.getLogger(__name__) TESTS_CALLBACKS = {} diff --git a/mediagoblin/tools/request.py b/mediagoblin/tools/request.py index ae372c92..ee342eae 100644 --- a/mediagoblin/tools/request.py +++ b/mediagoblin/tools/request.py @@ -15,7 +15,7 @@ # along with this program. If not, see <http://www.gnu.org/licenses/>. import logging -from mediagoblin.db.util import ObjectId, InvalidId +from mediagoblin.db.models import User _log = logging.getLogger(__name__) @@ -25,21 +25,14 @@ def setup_user_in_request(request): Examine a request and tack on a request.user parameter if that's appropriate. """ - if not request.session.has_key('user_id'): + if 'user_id' not in request.session: request.user = None return - try: - oid = ObjectId(request.session['user_id']) - except InvalidId: - user = None - else: - user = request.db.User.find_one({'_id': oid}) + request.user = User.query.get(request.session['user_id']) - if not user: + if not request.user: # Something's wrong... this user doesn't exist? Invalidate # this session. _log.warn("Killing session for user id %r", request.session['user_id']) - request.session.invalidate() - - request.user = user + request.session.delete() diff --git a/mediagoblin/tools/response.py b/mediagoblin/tools/response.py index a54c32fb..aaf31d0b 100644 --- a/mediagoblin/tools/response.py +++ b/mediagoblin/tools/response.py @@ -14,8 +14,15 @@ # You should have received a copy of the GNU Affero General Public License # along with this program. If not, see <http://www.gnu.org/licenses/>. -from webob import Response, exc +import werkzeug.utils +from werkzeug.wrappers import Response as wz_Response from mediagoblin.tools.template import render_template +from mediagoblin.tools.translate import (lazy_pass_to_ugettext as _, + pass_to_ugettext) + +class Response(wz_Response): + """Set default response mimetype to HTML, otherwise we get text/plain""" + default_mimetype = u'text/html' def render_to_response(request, template, context, status=200): @@ -25,23 +32,77 @@ def render_to_response(request, template, context, status=200): status=status) -def render_404(request): - """ - Render a 404. +def render_error(request, status=500, title=_('Oops!'), + err_msg=_('An error occured')): + """Render any error page with a given error code, title and text body + + Title and description are passed through as-is to allow html. Make + sure no user input is contained therein for security reasons. The + description will be wrapped in <p></p> tags. """ - return render_to_response( - request, 'mediagoblin/404.html', {}, status=404) + return Response(render_template(request, 'mediagoblin/error.html', + {'err_code': status, 'title': title, 'err_msg': err_msg}), + status=status) + + +def render_403(request): + """Render a standard 403 page""" + _ = pass_to_ugettext + title = _('Operation not allowed') + err_msg = _("Sorry Dave, I can't let you do that!</p><p>You have tried " + " to perform a function that you are not allowed to. Have you " + "been trying to delete all user accounts again?") + return render_error(request, 403, title, err_msg) + +def render_404(request): + """Render a standard 404 page.""" + _ = pass_to_ugettext + err_msg = _("There doesn't seem to be a page at this address. Sorry!</p>" + "<p>If you're sure the address is correct, maybe the page " + "you're looking for has been moved or deleted.") + return render_error(request, 404, err_msg=err_msg) + + +def render_http_exception(request, exc, description): + """Return Response() given a werkzeug.HTTPException + + :param exc: werkzeug.HTTPException or subclass thereof + :description: message describing the error.""" + # If we were passed the HTTPException stock description on + # exceptions where we have localized ones, use those: + stock_desc = (description == exc.__class__.description) + + if stock_desc and exc.code == 403: + return render_403(request) + elif stock_desc and exc.code == 404: + return render_404(request) + + return render_error(request, title=exc.args[0], + err_msg=description, + status=exc.code) def redirect(request, *args, **kwargs): - """Returns a HTTPFound(), takes a request and then urlgen params""" + """Redirects to an URL, using urlgen params or location string + + :param querystring: querystring to be appended to the URL + :param location: If the location keyword is given, redirect to the URL + """ + querystring = kwargs.pop('querystring', None) + + # Redirect to URL if given by "location=..." + if 'location' in kwargs: + location = kwargs.pop('location') + else: + location = request.urlgen(*args, **kwargs) + + if querystring: + location += querystring + return werkzeug.utils.redirect(location) + - querystring = None - if kwargs.get('querystring'): - querystring = kwargs.get('querystring') - del kwargs['querystring'] +def redirect_obj(request, obj): + """Redirect to the page for the given object. - return exc.HTTPFound( - location=''.join([ - request.urlgen(*args, **kwargs), - querystring if querystring else ''])) + Requires obj to have a .url_for_self method.""" + return redirect(request, location=obj.url_for_self(request.urlgen)) diff --git a/mediagoblin/tools/routing.py b/mediagoblin/tools/routing.py new file mode 100644 index 00000000..a15795fe --- /dev/null +++ b/mediagoblin/tools/routing.py @@ -0,0 +1,67 @@ +# GNU MediaGoblin -- federated, autonomous media hosting +# Copyright (C) 2011, 2012 MediaGoblin contributors. See AUTHORS. +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as published by +# the Free Software Foundation, either version 3 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 Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see <http://www.gnu.org/licenses/>. + +import logging + +import six +from werkzeug.routing import Map, Rule +from mediagoblin.tools.common import import_component + + +_log = logging.getLogger(__name__) + +url_map = Map() + + +class MGRoute(Rule): + def __init__(self, endpoint, url, controller): + Rule.__init__(self, url, endpoint=endpoint) + self.gmg_controller = controller + + def empty(self): + new_rule = Rule.empty(self) + new_rule.gmg_controller = self.gmg_controller + return new_rule + + +def endpoint_to_controller(rule): + endpoint = rule.endpoint + view_func = rule.gmg_controller + + _log.debug('endpoint: {0} view_func: {1}'.format(endpoint, view_func)) + + # import the endpoint, or if it's already a callable, call that + if isinstance(view_func, six.string_types): + view_func = import_component(view_func) + rule.gmg_controller = view_func + + return view_func + + +def add_route(endpoint, url, controller): + """ + Add a route to the url mapping + """ + url_map.add(MGRoute(endpoint, url, controller)) + + +def mount(mountpoint, routes): + """ + Mount a bunch of routes to this mountpoint + """ + for endpoint, url, controller in routes: + url = "%s/%s" % (mountpoint.rstrip('/'), url.lstrip('/')) + add_route(endpoint, url, controller) diff --git a/mediagoblin/tools/session.py b/mediagoblin/tools/session.py new file mode 100644 index 00000000..fdc32523 --- /dev/null +++ b/mediagoblin/tools/session.py @@ -0,0 +1,68 @@ +# GNU MediaGoblin -- federated, autonomous media hosting +# Copyright (C) 2013 MediaGoblin contributors. See AUTHORS. +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as published by +# the Free Software Foundation, either version 3 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 Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see <http://www.gnu.org/licenses/>. + +import itsdangerous +import logging + +import crypto + +_log = logging.getLogger(__name__) + +class Session(dict): + def __init__(self, *args, **kwargs): + self.send_new_cookie = False + dict.__init__(self, *args, **kwargs) + + def save(self): + self.send_new_cookie = True + + def is_updated(self): + return self.send_new_cookie + + def delete(self): + self.clear() + self.save() + + +class SessionManager(object): + def __init__(self, cookie_name='MGSession', namespace=None): + if namespace is None: + namespace = cookie_name + self.signer = crypto.get_timed_signer_url(namespace) + self.cookie_name = cookie_name + + def load_session_from_cookie(self, request): + cookie = request.cookies.get(self.cookie_name) + if not cookie: + return Session() + ### FIXME: Future cookie-blacklisting code + # m = BadCookie.query.filter_by(cookie = cookie) + # if m: + # _log.warn("Bad cookie received: %s", m.reason) + # raise BadRequest() + try: + return Session(self.signer.loads(cookie)) + except itsdangerous.BadData: + return Session() + + def save_session_to_cookie(self, session, request, response): + if not session.is_updated(): + return + elif not session: + response.delete_cookie(self.cookie_name) + else: + response.set_cookie(self.cookie_name, self.signer.dumps(session), + httponly=True) diff --git a/mediagoblin/tools/template.py b/mediagoblin/tools/template.py index 158d5321..3d651a6e 100644 --- a/mediagoblin/tools/template.py +++ b/mediagoblin/tools/template.py @@ -14,16 +14,25 @@ # You should have received a copy of the GNU Affero General Public License # along with this program. If not, see <http://www.gnu.org/licenses/>. -from math import ceil + import jinja2 +from jinja2.ext import Extension +from jinja2.nodes import Include, Const + from babel.localedata import exists +from werkzeug.urls import url_quote_plus + from mediagoblin import mg_globals from mediagoblin import messages +from mediagoblin import _version from mediagoblin.tools import common -from mediagoblin.tools.translate import setup_gettext +from mediagoblin.tools.translate import set_thread_locale +from mediagoblin.tools.pluginapi import get_hook_templates, hook_transform +from mediagoblin.tools.timesince import timesince from mediagoblin.meddleware.csrf import render_csrf_form_token + SETUP_JINJA_ENVS = {} @@ -34,11 +43,11 @@ def get_jinja_env(template_loader, locale): (In the future we may have another system for providing theming; for now this is good enough.) """ - setup_gettext(locale) + set_thread_locale(locale) # If we have a jinja environment set up with this locale, just # return that one. - if SETUP_JINJA_ENVS.has_key(locale): + if locale in SETUP_JINJA_ENVS: return SETUP_JINJA_ENVS[locale] # jinja2.StrictUndefined will give exceptions on references @@ -46,7 +55,9 @@ def get_jinja_env(template_loader, locale): template_env = jinja2.Environment( loader=template_loader, autoescape=True, undefined=jinja2.StrictUndefined, - extensions=['jinja2.ext.i18n', 'jinja2.ext.autoescape']) + extensions=[ + 'jinja2.ext.i18n', 'jinja2.ext.autoescape', + TemplateHookExtension]) template_env.install_gettext_callables( mg_globals.thread_scope.translations.ugettext, @@ -57,10 +68,20 @@ def get_jinja_env(template_loader, locale): # ... construct a grid of thumbnails or other media # ... have access to the global and app config template_env.globals['fetch_messages'] = messages.fetch_messages - template_env.globals['gridify_list'] = gridify_list - template_env.globals['gridify_cursor'] = gridify_cursor template_env.globals['app_config'] = mg_globals.app_config template_env.globals['global_config'] = mg_globals.global_config + template_env.globals['version'] = _version.__version__ + + template_env.filters['urlencode'] = url_quote_plus + + # add human readable fuzzy date time + template_env.globals['timesince'] = timesince + + # allow for hooking up plugin templates + template_env.globals['get_hook_templates'] = get_hook_templates + + template_env.globals = hook_transform( + 'template_global_context', template_env.globals) if exists(locale): SETUP_JINJA_ENVS[locale] = template_env @@ -85,6 +106,20 @@ def render_template(request, template_path, context): rendered_csrf_token = render_csrf_form_token(request) if rendered_csrf_token is not None: context['csrf_token'] = render_csrf_form_token(request) + + # allow plugins to do things to the context + if request.controller_name: + context = hook_transform( + (request.controller_name, template_path), + context) + + # More evil: allow plugins to possibly do something to the context + # in every request ever with access to the request and other + # variables. Note: this is slower than using + # template_global_context + context = hook_transform( + 'template_context_prerender', context) + rendered = template.render(context) if common.TESTS_ENABLED: @@ -98,30 +133,28 @@ def clear_test_template_context(): TEMPLATE_TEST_CONTEXT = {} -def gridify_list(this_list, num_cols=5): - """ - Generates a list of lists where each sub-list's length depends on - the number of columns in the list +class TemplateHookExtension(Extension): """ - grid = [] + Easily loop through a bunch of templates from a template hook. - # Figure out how many rows we should have - num_rows = int(ceil(float(len(this_list)) / num_cols)) + Use: + {% template_hook("comment_extras") %} - for row_num in range(num_rows): - slice_min = row_num * num_cols - slice_max = (row_num + 1) * num_cols - - row = this_list[slice_min:slice_max] + ... will include all templates hooked into the comment_extras section. + """ - grid.append(row) + tags = set(["template_hook"]) - return grid + def parse(self, parser): + includes = [] + expr = parser.parse_expression() + lineno = expr.lineno + hook_name = expr.args[0].value + for template_name in get_hook_templates(hook_name): + includes.append( + parser.parse_import_context( + Include(Const(template_name), True, False, lineno=lineno), + True)) -def gridify_cursor(this_cursor, num_cols=5): - """ - Generates a list of lists where each sub-list's length depends on - the number of columns in the list - """ - return gridify_list(list(this_cursor), num_cols) + return includes diff --git a/mediagoblin/tools/text.py b/mediagoblin/tools/text.py index ea231244..96df49d2 100644 --- a/mediagoblin/tools/text.py +++ b/mediagoblin/tools/text.py @@ -38,13 +38,12 @@ HTML_CLEANER = Cleaner( allow_tags=[ 'div', 'b', 'i', 'em', 'strong', 'p', 'ul', 'ol', 'li', 'a', 'br', 'pre', 'code'], - remove_unknown_tags=False, # can't be used with allow_tags + remove_unknown_tags=False, # can't be used with allow_tags safe_attrs_only=True, - add_nofollow=True, # for now + add_nofollow=True, # for now host_whitelist=(), whitelist_tags=set([])) -TAGS_DELIMITER=','; def clean_html(html): # clean_html barfs on an empty string @@ -68,14 +67,12 @@ def convert_to_tag_list_of_dicts(tag_string): stripped_tag_string = u' '.join(tag_string.strip().split()) # Split the tag string into a list of tags - for tag in stripped_tag_string.split( - TAGS_DELIMITER): - + for tag in stripped_tag_string.split(','): + tag = tag.strip() # Ignore empty or duplicate tags - if tag.strip() and tag.strip() not in [t['name'] for t in taglist]: - - taglist.append({'name': tag.strip(), - 'slug': url.slugify(tag.strip())}) + if tag and tag not in [t['name'] for t in taglist]: + taglist.append({'name': tag, + 'slug': url.slugify(tag)}) return taglist @@ -85,11 +82,10 @@ def media_tags_as_string(media_entry_tags): This is the opposite of convert_to_tag_list_of_dicts """ - media_tag_string = '' + tags_string = '' if media_entry_tags: - media_tag_string = (TAGS_DELIMITER+u' ').join( - [tag['name'] for tag in media_entry_tags]) - return media_tag_string + tags_string = u', '.join([tag['name'] for tag in media_entry_tags]) + return tags_string TOO_LONG_TAG_WARNING = \ @@ -107,7 +103,7 @@ def tag_length_validator(form, field): if too_long_tags: raise wtforms.ValidationError( - TOO_LONG_TAG_WARNING % (mg_globals.app_config['tags_max_length'], \ + TOO_LONG_TAG_WARNING % (mg_globals.app_config['tags_max_length'], ', '.join(too_long_tags))) diff --git a/mediagoblin/tools/timesince.py b/mediagoblin/tools/timesince.py new file mode 100644 index 00000000..b761c1be --- /dev/null +++ b/mediagoblin/tools/timesince.py @@ -0,0 +1,95 @@ +# Copyright (c) Django Software Foundation and individual contributors. +# All rights reserved. +# +# Redistribution and use in source and binary forms, with or without modification, +# are permitted provided that the following conditions are met: +# +# 1. Redistributions of source code must retain the above copyright notice, +# this list of conditions and the following disclaimer. +# +# 2. Redistributions in binary form must reproduce the above copyright +# notice, this list of conditions and the following disclaimer in the +# documentation and/or other materials provided with the distribution. +# +# 3. Neither the name of Django nor the names of its contributors may be used +# to endorse or promote products derived from this software without +# specific prior written permission. +# +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND +# ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +# WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +# DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR +# ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES +# (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; +# LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON +# ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +# (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS +# SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +from __future__ import unicode_literals + +import datetime +import pytz + +from mediagoblin.tools.translate import pass_to_ugettext, lazy_pass_to_ungettext as _ + +"""UTC time zone as a tzinfo instance.""" +utc = pytz.utc if pytz else UTC() + +def is_aware(value): + """ + Determines if a given datetime.datetime is aware. + + The logic is described in Python's docs: + http://docs.python.org/library/datetime.html#datetime.tzinfo + """ + return value.tzinfo is not None and value.tzinfo.utcoffset(value) is not None + +def timesince(d, now=None, reversed=False): + """ + Takes two datetime objects and returns the time between d and now + as a nicely formatted string, e.g. "10 minutes". If d occurs after now, + then "0 minutes" is returned. + + Units used are years, months, weeks, days, hours, and minutes. + Seconds and microseconds are ignored. Up to two adjacent units will be + displayed. For example, "2 weeks, 3 days" and "1 year, 3 months" are + possible outputs, but "2 weeks, 3 hours" and "1 year, 5 days" are not. + + Adapted from http://blog.natbat.co.uk/archive/2003/Jun/14/time_since + """ + chunks = ( + (60 * 60 * 24 * 365, lambda n: _('year', 'years', n)), + (60 * 60 * 24 * 30, lambda n: _('month', 'months', n)), + (60 * 60 * 24 * 7, lambda n : _('week', 'weeks', n)), + (60 * 60 * 24, lambda n : _('day', 'days', n)), + (60 * 60, lambda n: _('hour', 'hours', n)), + (60, lambda n: _('minute', 'minutes', n)) + ) + # Convert datetime.date to datetime.datetime for comparison. + if not isinstance(d, datetime.datetime): + d = datetime.datetime(d.year, d.month, d.day) + if now and not isinstance(now, datetime.datetime): + now = datetime.datetime(now.year, now.month, now.day) + + if not now: + now = datetime.datetime.now(utc if is_aware(d) else None) + + delta = (d - now) if reversed else (now - d) + # ignore microseconds + since = delta.days * 24 * 60 * 60 + delta.seconds + if since <= 0: + # d is in the future compared to now, stop processing. + return '0 ' + pass_to_ugettext('minutes') + for i, (seconds, name) in enumerate(chunks): + count = since // seconds + if count != 0: + break + s = pass_to_ugettext('%(number)d %(type)s') % {'number': count, 'type': name(count)} + if i + 1 < len(chunks): + # Now get the second item + seconds2, name2 = chunks[i + 1] + count2 = (since - (seconds * count)) // seconds2 + if count2 != 0: + s += pass_to_ugettext(', %(number)d %(type)s') % {'number': count2, 'type': name2(count2)} + return s diff --git a/mediagoblin/tools/translate.py b/mediagoblin/tools/translate.py index 01cabe6a..b20e57d1 100644 --- a/mediagoblin/tools/translate.py +++ b/mediagoblin/tools/translate.py @@ -16,7 +16,9 @@ import gettext import pkg_resources -from babel.localedata import exists + + +from babel import localedata from babel.support import LazyProxy from mediagoblin import mg_globals @@ -25,14 +27,40 @@ from mediagoblin import mg_globals # Translation tools ################### - +AVAILABLE_LOCALES = None TRANSLATIONS_PATH = pkg_resources.resource_filename( 'mediagoblin', 'i18n') +def set_available_locales(): + """Set available locales for which we have translations""" + global AVAILABLE_LOCALES + locales=['en', 'en_US'] # these are available without translations + for locale in localedata.list(): + if gettext.find('mediagoblin', TRANSLATIONS_PATH, [locale]): + locales.append(locale) + AVAILABLE_LOCALES = locales + + +class ReallyLazyProxy(LazyProxy): + """ + Like LazyProxy, except that it doesn't cache the value ;) + """ + @property + def value(self): + return self._func(*self._args, **self._kwargs) + + def __repr__(self): + return "<%s for %s(%r, %r)>" % ( + self.__class__.__name__, + self._func, + self._args, + self._kwargs) + + def locale_to_lower_upper(locale): """ - Take a locale, regardless of style, and format it like "en-US" + Take a locale, regardless of style, and format it like "en_US" """ if '-' in locale: lang, country = locale.split('-', 1) @@ -57,28 +85,32 @@ def locale_to_lower_lower(locale): def get_locale_from_request(request): """ - Figure out what target language is most appropriate based on the - request + Return most appropriate language based on prefs/request request """ - request_form = request.GET or request.form + request_args = (request.args, request.form)[request.method=='POST'] - if request_form.has_key('lang'): - return locale_to_lower_upper(request_form['lang']) + if 'lang' in request_args: + # User explicitely demanded a language, normalize lower_uppercase + target_lang = locale_to_lower_upper(request_args['lang']) - if 'target_lang' in request.session: + elif 'target_lang' in request.session: + # TODO: Uh, ohh, this is never ever set anywhere? target_lang = request.session['target_lang'] - # Pull the first acceptable language or English else: - # TODO: Internationalization broken - target_lang = 'en' + # Pull the most acceptable language based on browser preferences + # This returns one of AVAILABLE_LOCALES which is aready case-normalized. + # Note: in our tests request.accept_languages is None, so we need + # to explicitely fallback to en here. + target_lang = request.accept_languages.best_match(AVAILABLE_LOCALES) \ + or "en_US" - return locale_to_lower_upper(target_lang) + return target_lang SETUP_GETTEXTS = {} -def setup_gettext(locale): +def get_gettext_translation(locale): """ - Setup the gettext instance based on this locale + Return the gettext instance based on this locale """ # Later on when we have plugins we may want to enable the # multi-translations system they have so we can handle plugin @@ -91,15 +123,14 @@ def setup_gettext(locale): else: this_gettext = gettext.translation( 'mediagoblin', TRANSLATIONS_PATH, [locale], fallback=True) - if exists(locale): + if localedata.exists(locale): SETUP_GETTEXTS[locale] = this_gettext - - mg_globals.thread_scope.translations = this_gettext + return this_gettext -# Force en to be setup before anything else so that -# mg_globals.translations is never None -setup_gettext('en') +def set_thread_locale(locale): + """Set the current translation for this thread""" + mg_globals.thread_scope.translations = get_gettext_translation(locale) def pass_to_ugettext(*args, **kwargs): @@ -112,6 +143,16 @@ def pass_to_ugettext(*args, **kwargs): return mg_globals.thread_scope.translations.ugettext( *args, **kwargs) +def pass_to_ungettext(*args, **kwargs): + """ + Pass a translation on to the appropriate ungettext method. + + The reason we can't have a global ugettext method is because + mg_globals gets swapped out by the application per-request. + """ + return mg_globals.thread_scope.translations.ungettext( + *args, **kwargs) + def lazy_pass_to_ugettext(*args, **kwargs): """ @@ -119,9 +160,12 @@ def lazy_pass_to_ugettext(*args, **kwargs): This is useful if you have to define a translation on a module level but you need it to not translate until the time that it's - used as a string. + used as a string. For example, in: + def func(self, message=_('Hello boys and girls')) + + you would want to use the lazy version for _. """ - return LazyProxy(pass_to_ugettext, *args, **kwargs) + return ReallyLazyProxy(pass_to_ugettext, *args, **kwargs) def pass_to_ngettext(*args, **kwargs): @@ -143,7 +187,17 @@ def lazy_pass_to_ngettext(*args, **kwargs): level but you need it to not translate until the time that it's used as a string. """ - return LazyProxy(pass_to_ngettext, *args, **kwargs) + return ReallyLazyProxy(pass_to_ngettext, *args, **kwargs) + +def lazy_pass_to_ungettext(*args, **kwargs): + """ + Lazily pass to ungettext. + + This is useful if you have to define a translation on a module + level but you need it to not translate until the time that it's + used as a string. + """ + return ReallyLazyProxy(pass_to_ungettext, *args, **kwargs) def fake_ugettext_passthrough(string): diff --git a/mediagoblin/tools/url.py b/mediagoblin/tools/url.py index 7477173a..d9179f9e 100644 --- a/mediagoblin/tools/url.py +++ b/mediagoblin/tools/url.py @@ -15,10 +15,17 @@ # along with this program. If not, see <http://www.gnu.org/licenses/>. import re -import translitcodec +# This import *is* used; see word.encode('tranlit/long') below. +from unicodedata import normalize +try: + import translitcodec + USING_TRANSLITCODEC = True +except ImportError: + USING_TRANSLITCODEC = False -_punct_re = re.compile(r'[\t !"#$%&\'()*\-/<=>?@\[\\\]^_`{|},.]+') + +_punct_re = re.compile(r'[\t !"#:$%&\'()*\-/<=>?@\[\\\]^_`{|},.]+') def slugify(text, delim=u'-'): @@ -27,7 +34,11 @@ def slugify(text, delim=u'-'): """ result = [] for word in _punct_re.split(text.lower()): - word = word.encode('translit/long') + if USING_TRANSLITCODEC: + word = word.encode('translit/long') + else: + word = normalize('NFKD', word).encode('ascii', 'ignore') + if word: result.append(word) return unicode(delim.join(result)) diff --git a/mediagoblin/tools/workbench.py b/mediagoblin/tools/workbench.py new file mode 100644 index 00000000..0bd4096b --- /dev/null +++ b/mediagoblin/tools/workbench.py @@ -0,0 +1,164 @@ +# GNU MediaGoblin -- federated, autonomous media hosting +# Copyright (C) 2011, 2012 MediaGoblin contributors. See AUTHORS. +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as published by +# the Free Software Foundation, either version 3 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 Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see <http://www.gnu.org/licenses/>. + +import os +import shutil +import tempfile + + +# Actual workbench stuff +# ---------------------- + +class Workbench(object): + """ + Represent the directory for the workbench + + WARNING: DO NOT create Workbench objects on your own, + let the WorkbenchManager do that for you! + """ + def __init__(self, dir): + """ + WARNING: DO NOT create Workbench objects on your own, + let the WorkbenchManager do that for you! + """ + self.dir = dir + + def __unicode__(self): + return unicode(self.dir) + + def __str__(self): + return str(self.dir) + + def __repr__(self): + try: + return str(self) + except AttributeError: + return 'None' + + def joinpath(self, *args): + return os.path.join(self.dir, *args) + + def localized_file(self, storage, filepath, + filename_if_copying=None, + keep_extension_if_copying=True): + """ + Possibly localize the file from this storage system (for read-only + purposes, modifications should be written to a new file.). + + If the file is already local, just return the absolute filename of that + local file. Otherwise, copy the file locally to the workbench, and + return the absolute path of the new file. + + If it is copying locally, we might want to require a filename like + "source.jpg" to ensure that we won't conflict with other filenames in + our workbench... if that's the case, make sure filename_if_copying is + set to something like 'source.jpg'. Relatedly, if you set + keep_extension_if_copying, you don't have to set an extension on + filename_if_copying yourself, it'll be set for you (assuming such an + extension can be extacted from the filename in the filepath). + + Returns: + localized_filename + + Examples: + >>> wb_manager.localized_file( + ... '/our/workbench/subdir', local_storage, + ... ['path', 'to', 'foobar.jpg']) + u'/local/storage/path/to/foobar.jpg' + + >>> wb_manager.localized_file( + ... '/our/workbench/subdir', remote_storage, + ... ['path', 'to', 'foobar.jpg']) + '/our/workbench/subdir/foobar.jpg' + + >>> wb_manager.localized_file( + ... '/our/workbench/subdir', remote_storage, + ... ['path', 'to', 'foobar.jpg'], 'source.jpeg', False) + '/our/workbench/subdir/foobar.jpeg' + + >>> wb_manager.localized_file( + ... '/our/workbench/subdir', remote_storage, + ... ['path', 'to', 'foobar.jpg'], 'source', True) + '/our/workbench/subdir/foobar.jpg' + """ + if storage.local_storage: + return storage.get_local_path(filepath) + else: + if filename_if_copying is None: + dest_filename = filepath[-1] + else: + orig_filename, orig_ext = os.path.splitext(filepath[-1]) + if keep_extension_if_copying and orig_ext: + dest_filename = filename_if_copying + orig_ext + else: + dest_filename = filename_if_copying + + full_dest_filename = os.path.join( + self.dir, dest_filename) + + # copy it over + storage.copy_locally( + filepath, full_dest_filename) + + return full_dest_filename + + def destroy(self): + """ + Destroy this workbench! Deletes the directory and all its contents! + + WARNING: Does no checks for a sane value in self.dir! + """ + # just in case + workbench = os.path.abspath(self.dir) + shutil.rmtree(workbench) + del self.dir + + def __enter__(self): + """Make Workbench a context manager so we can use `with Workbench() as bench:`""" + return self + + def __exit__(self, *args): + """Clean up context manager, aka ourselves, deleting the workbench""" + self.destroy() + + +class WorkbenchManager(object): + """ + A system for generating and destroying workbenches. + + Workbenches are actually just subdirectories of a (local) temporary + storage space for during the processing stage. The preferred way to + create them is to use: + + with workbenchmger.create() as workbench: + do stuff... + + This will automatically clean up all temporary directories even in + case of an exceptions. Also check the + @mediagoblin.decorators.get_workbench decorator for a convenient + wrapper. + """ + + def __init__(self, base_workbench_dir): + self.base_workbench_dir = os.path.abspath(base_workbench_dir) + if not os.path.exists(self.base_workbench_dir): + os.makedirs(self.base_workbench_dir) + + def create(self): + """ + Create and return the path to a new workbench (directory). + """ + return Workbench(tempfile.mkdtemp(dir=self.base_workbench_dir)) |