aboutsummaryrefslogtreecommitdiffstats
path: root/mediagoblin/tools
diff options
context:
space:
mode:
Diffstat (limited to 'mediagoblin/tools')
-rw-r--r--mediagoblin/tools/common.py1
-rw-r--r--mediagoblin/tools/crypto.py113
-rw-r--r--mediagoblin/tools/exif.py48
-rw-r--r--mediagoblin/tools/files.py2
-rw-r--r--mediagoblin/tools/licenses.py84
-rw-r--r--mediagoblin/tools/mail.py13
-rw-r--r--mediagoblin/tools/pagination.py15
-rw-r--r--mediagoblin/tools/pluginapi.py167
-rw-r--r--mediagoblin/tools/processing.py2
-rw-r--r--mediagoblin/tools/request.py17
-rw-r--r--mediagoblin/tools/response.py91
-rw-r--r--mediagoblin/tools/routing.py67
-rw-r--r--mediagoblin/tools/session.py68
-rw-r--r--mediagoblin/tools/template.py87
-rw-r--r--mediagoblin/tools/text.py26
-rw-r--r--mediagoblin/tools/timesince.py95
-rw-r--r--mediagoblin/tools/translate.py102
-rw-r--r--mediagoblin/tools/url.py17
-rw-r--r--mediagoblin/tools/workbench.py164
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))