diff options
author | zoidberg10 <zoidberg@mujmail.cz> | 2011-12-08 02:08:35 +0100 |
---|---|---|
committer | zoidberg10 <zoidberg@mujmail.cz> | 2011-12-08 02:08:35 +0100 |
commit | 30ba647fe479d86c3d7bac71908ee56ec80eb928 (patch) | |
tree | 765c5e0295d81f712e070da774d591561848f34b /module | |
parent | httprequest: encode('utf_8') for unicode post (diff) | |
parent | updated bottle.py (diff) | |
download | pyload-30ba647fe479d86c3d7bac71908ee56ec80eb928.tar.xz |
Merge
Diffstat (limited to 'module')
-rw-r--r-- | module/Api.py | 3 | ||||
-rw-r--r-- | module/common/pylgettext.py | 62 | ||||
-rw-r--r-- | module/lib/bottle.py | 2026 | ||||
-rw-r--r-- | module/remote/thriftbackend/pyload.thrift | 57 | ||||
-rwxr-xr-x | module/remote/thriftbackend/thriftgen/pyload/Pyload-remote | 58 | ||||
-rw-r--r-- | module/remote/thriftbackend/thriftgen/pyload/Pyload.py | 646 | ||||
-rw-r--r-- | module/remote/thriftbackend/thriftgen/pyload/constants.py | 2 | ||||
-rw-r--r-- | module/remote/thriftbackend/thriftgen/pyload/ttypes.py | 108 | ||||
-rw-r--r-- | module/setup.py | 165 | ||||
-rw-r--r-- | module/web/pyload_app.py | 2 | ||||
-rw-r--r-- | module/web/webinterface.py | 6 |
11 files changed, 1879 insertions, 1256 deletions
diff --git a/module/Api.py b/module/Api.py index fc36c9fea..f0bf5e264 100644 --- a/module/Api.py +++ b/module/Api.py @@ -65,6 +65,7 @@ class PERMS: DOWNLOAD = 64 # can download from webinterface SETTINGS = 128 # can access settings ACCOUNTS = 256 # can access accounts + LOGS = 512 # can see server logs class ROLE: ADMIN = 0 #admin has all permissions implicit @@ -249,7 +250,7 @@ class Api(Iface): """Restart pyload core""" self.core.do_restart = True - @permission(PERMS.STATUS) + @permission(PERMS.LOGS) def getLog(self, offset=0): """Returns most recent log entries. diff --git a/module/common/pylgettext.py b/module/common/pylgettext.py new file mode 100644 index 000000000..ae6d39325 --- /dev/null +++ b/module/common/pylgettext.py @@ -0,0 +1,62 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- + +from gettext import * + +_searchdirs = None + +origfind = find + +def setpaths(pathlist): + global _searchdirs + if isinstance(pathlist, list): + _searchdirs = pathlist + else: + _searchdirs = list(pathlist) + + +def addpath(path): + global _searchdirs + if _searchdirs is None: + _searchdirs = list(path) + else: + if path not in _searchdirs: + _searchdirs.append(path) + + +def delpath(path): + global _searchdirs + if _searchdirs is not None: + if path in _searchdirs: + _searchdirs.remove(path) + + +def clearpath(): + global _searchdirs + if _searchdirs is not None: + _searchdirs = None + + +def find(domain, localedir=None, languages=None, all=False): + if _searchdirs is None: + return origfind(domain, localedir, languages, all) + searches = [localedir] + _searchdirs + results = list() + for dir in searches: + res = origfind(domain, dir, languages, all) + if all is False: + results.append(res) + else: + results.extend(res) + if all is False: + results = filter(lambda x: x is not None, results) + if len(results) == 0: + return None + else: + return results[0] + else: + return results + +#Is there a smarter/cleaner pythonic way for this? +translation.__globals__['find'] = find + diff --git a/module/lib/bottle.py b/module/lib/bottle.py index f449e182c..f8624bf13 100644 --- a/module/lib/bottle.py +++ b/module/lib/bottle.py @@ -1,3 +1,4 @@ +#!/usr/bin/env python # -*- coding: utf-8 -*- """ Bottle is a fast and simple micro-framework for small web applications. It @@ -15,9 +16,26 @@ License: MIT (see LICENSE.txt for details) from __future__ import with_statement __author__ = 'Marcel Hellkamp' -__version__ = '0.9.1' +__version__ = '0.10.2' __license__ = 'MIT' +# The gevent server adapter needs to patch some modules before they are imported +# This is why we parse the commandline parameters here but handle them later +if __name__ == '__main__': + from optparse import OptionParser + _cmd_parser = OptionParser(usage="usage: %prog [options] package.module:app") + _opt = _cmd_parser.add_option + _opt("--version", action="store_true", help="show version number.") + _opt("-b", "--bind", metavar="ADDRESS", help="bind socket to ADDRESS.") + _opt("-s", "--server", default='wsgiref', help="use SERVER as backend.") + _opt("-p", "--plugin", action="append", help="install additional plugin/s.") + _opt("--debug", action="store_true", help="start server in debug mode.") + _opt("--reload", action="store_true", help="auto-reload on file changes.") + _cmd_options, _cmd_args = _cmd_parser.parse_args() + if _cmd_options.server and _cmd_options.server.startswith('gevent'): + import gevent.monkey; gevent.monkey.patch_all() + +import sys import base64 import cgi import email.utils @@ -30,7 +48,6 @@ import mimetypes import os import re import subprocess -import sys import tempfile import thread import threading @@ -38,10 +55,16 @@ import time import warnings from Cookie import SimpleCookie +from datetime import date as datedate, datetime, timedelta from tempfile import TemporaryFile -from traceback import format_exc -from urllib import urlencode, quote as urlquote, unquote as urlunquote -from urlparse import urlunsplit, urljoin, SplitResult as UrlSplitResult +from traceback import format_exc, print_exc +from urlparse import urljoin, SplitResult as UrlSplitResult + +# Workaround for a bug in some versions of lib2to3 (fixed on CPython 2.7 and 3.2) +import urllib +urlencode = urllib.urlencode +urlquote = urllib.quote +urlunquote = urllib.unquote try: from collections import MutableMapping as DictMixin except ImportError: # pragma: no cover @@ -55,16 +78,25 @@ try: import cPickle as pickle except ImportError: # pragma: no cover import pickle -try: from json import dumps as json_dumps +try: from json import dumps as json_dumps, loads as json_lds except ImportError: # pragma: no cover - try: from simplejson import dumps as json_dumps + try: from simplejson import dumps as json_dumps, loads as json_lds except ImportError: # pragma: no cover - try: from django.utils.simplejson import dumps as json_dumps + try: from django.utils.simplejson import dumps as json_dumps, loads as json_lds except ImportError: # pragma: no cover - json_dumps = None + def json_dumps(data): + raise ImportError("JSON support requires Python 2.6 or simplejson.") + json_lds = json_dumps +py3k = sys.version_info >= (3,0,0) NCTextIOWrapper = None -if sys.version_info >= (3,0,0): # pragma: no cover + +if sys.version_info < (2,6,0): + msg = "Python 2.5 support may be dropped in future versions of Bottle." + warnings.warn(msg, DeprecationWarning) + +if py3k: # pragma: no cover + json_loads = lambda s: json_lds(touni(s)) # See Request.POST from io import BytesIO def touni(x, enc='utf8', err='strict'): @@ -77,6 +109,7 @@ if sys.version_info >= (3,0,0): # pragma: no cover the wrapped buffer. This subclass keeps it open. ''' def close(self): pass else: + json_loads = json_lds from StringIO import StringIO as BytesIO bytes = str def touni(x, enc='utf8', err='strict'): @@ -87,17 +120,16 @@ def tob(data, enc='utf8'): """ Convert anything to bytes """ return data.encode(enc) if isinstance(data, unicode) else bytes(data) -# Convert strings and unicode to native strings -if sys.version_info >= (3,0,0): - tonat = touni -else: - tonat = tob +tonat = touni if py3k else tob tonat.__doc__ = """ Convert anything to native strings """ +def try_update_wrapper(wrapper, wrapped, *a, **ka): + try: # Bug: functools breaks if wrapper is an instane method + functools.update_wrapper(wrapper, wrapped, *a, **ka) + except AttributeError: pass # Backward compatibility -def depr(message, critical=False): - if critical: raise DeprecationWarning(message) +def depr(message): warnings.warn(message, DeprecationWarning, stacklevel=3) @@ -119,7 +151,7 @@ class DictProperty(object): return self def __get__(self, obj, cls): - if not obj: return self + if obj is None: return self key, storage = self.key, getattr(obj, self.attr) if key not in storage: storage[key] = self.getter(obj) return storage[key] @@ -132,10 +164,22 @@ class DictProperty(object): if self.read_only: raise AttributeError("Read-Only property.") del getattr(obj, self.attr)[self.key] -def cached_property(func): - ''' A property that, if accessed, replaces itself with the computed - value. Subsequent accesses won't call the getter again. ''' - return DictProperty('__dict__')(func) + +class CachedProperty(object): + ''' A property that is only computed once per instance and then replaces + itself with an ordinary attribute. Deleting the attribute resets the + property. ''' + + def __init__(self, func): + self.func = func + + def __get__(self, obj, cls): + if obj is None: return self + value = obj.__dict__[self.func.__name__] = self.func(obj) + return value + +cached_property = CachedProperty + class lazy_attribute(object): # Does not need configuration -> lower-case name ''' A property that caches itself to the class object. ''' @@ -163,6 +207,8 @@ class BottleException(Exception): pass +#TODO: These should subclass BaseRequest + class HTTPResponse(BottleException): """ Used to break execution and immediately finish the response """ def __init__(self, output='', status=200, header=None): @@ -207,15 +253,14 @@ class RouteReset(BottleException): """ If raised by a plugin or request handler, the route is reset and all plugins are re-applied. """ +class RouterUnknownModeError(RouteError): pass class RouteSyntaxError(RouteError): """ The route parser found something not supported by this router """ - class RouteBuildError(RouteError): """ The route could not been built """ - class Router(object): ''' A Router is an ordered collection of route->target pairs. It is used to efficiently match WSGI requests against a number of routes and return @@ -224,83 +269,153 @@ class Router(object): and a HTTP method. The path-rule is either a static path (e.g. `/contact`) or a dynamic - path that contains wildcards (e.g. `/wiki/:page`). By default, wildcards - consume characters up to the next slash (`/`). To change that, you may - add a regular expression pattern (e.g. `/wiki/:page#[a-z]+#`). - - For performance reasons, static routes (rules without wildcards) are - checked first. Dynamic routes are searched in order. Try to avoid - ambiguous or overlapping rules. - - The HTTP method string matches only on equality, with two exceptions: - * ´GET´ routes also match ´HEAD´ requests if there is no appropriate - ´HEAD´ route installed. - * ´ANY´ routes do match if there is no other suitable route installed. - - An optional ``name`` parameter is used by :meth:`build` to identify - routes. + path that contains wildcards (e.g. `/wiki/<page>`). The wildcard syntax + and details on the matching order are described in docs:`routing`. ''' - default = '[^/]+' - - @lazy_attribute - def syntax(cls): - return re.compile(r'(?<!\\):([a-zA-Z_][a-zA-Z_0-9]*)?(?:#(.*?)#)?') + default_pattern = '[^/]+' + default_filter = 're' + #: Sorry for the mess. It works. Trust me. + rule_syntax = re.compile('(\\\\*)'\ + '(?:(?::([a-zA-Z_][a-zA-Z_0-9]*)?()(?:#(.*?)#)?)'\ + '|(?:<([a-zA-Z_][a-zA-Z_0-9]*)?(?::([a-zA-Z_]*)'\ + '(?::((?:\\\\.|[^\\\\>]+)+)?)?)?>))') + + def __init__(self, strict=False): + self.rules = {} # A {rule: Rule} mapping + self.builder = {} # A rule/name->build_info mapping + self.static = {} # Cache for static routes: {path: {method: target}} + self.dynamic = [] # Cache for dynamic routes. See _compile() + #: If true, static routes are no longer checked first. + self.strict_order = strict + self.filters = {'re': self.re_filter, 'int': self.int_filter, + 'float': self.re_filter, 'path': self.path_filter} + + def re_filter(self, conf): + return conf or self.default_pattern, None, None + + def int_filter(self, conf): + return r'-?\d+', int, lambda x: str(int(x)) + + def float_filter(self, conf): + return r'-?\d*\.\d+', float, lambda x: str(float(x)) + + def path_filter(self, conf): + return r'.*?', None, None + + def add_filter(self, name, func): + ''' Add a filter. The provided function is called with the configuration + string as parameter and must return a (regexp, to_python, to_url) tuple. + The first element is a string, the last two are callables or None. ''' + self.filters[name] = func + + def parse_rule(self, rule): + ''' Parses a rule into a (name, filter, conf) token stream. If mode is + None, name contains a static rule part. ''' + offset, prefix = 0, '' + for match in self.rule_syntax.finditer(rule): + prefix += rule[offset:match.start()] + g = match.groups() + if len(g[0])%2: # Escaped wildcard + prefix += match.group(0)[len(g[0]):] + offset = match.end() + continue + if prefix: yield prefix, None, None + name, filtr, conf = g[1:4] if not g[2] is None else g[4:7] + if not filtr: filtr = self.default_filter + yield name, filtr, conf or None + offset, prefix = match.end(), '' + if offset <= len(rule) or prefix: + yield prefix+rule[offset:], None, None + + def add(self, rule, method, target, name=None): + ''' Add a new route or replace the target for an existing route. ''' + if rule in self.rules: + self.rules[rule][method] = target + if name: self.builder[name] = self.builder[rule] + return + + target = self.rules[rule] = {method: target} + + # Build pattern and other structures for dynamic routes + anons = 0 # Number of anonymous wildcards + pattern = '' # Regular expression pattern + filters = [] # Lists of wildcard input filters + builder = [] # Data structure for the URL builder + is_static = True + for key, mode, conf in self.parse_rule(rule): + if mode: + is_static = False + mask, in_filter, out_filter = self.filters[mode](conf) + if key: + pattern += '(?P<%s>%s)' % (key, mask) + else: + pattern += '(?:%s)' % mask + key = 'anon%d' % anons; anons += 1 + if in_filter: filters.append((key, in_filter)) + builder.append((key, out_filter or str)) + elif key: + pattern += re.escape(key) + builder.append((None, key)) + self.builder[rule] = builder + if name: self.builder[name] = builder + + if is_static and not self.strict_order: + self.static[self.build(rule)] = target + return - def __init__(self): - self.routes = {} # A {rule: {method: target}} mapping - self.rules = [] # An ordered list of rules - self.named = {} # A name->(rule, build_info) mapping - self.static = {} # Cache for static routes: {path: {method: target}} - self.dynamic = [] # Cache for dynamic routes. See _compile() + def fpat_sub(m): + return m.group(0) if len(m.group(1)) % 2 else m.group(1) + '(?:' + flat_pattern = re.sub(r'(\\*)(\(\?P<[^>]*>|\((?!\?))', fpat_sub, pattern) - def add(self, rule, method, target, name=None, static=False): - ''' Add a new route or replace the target for an existing route. ''' - if static: - depr("Use a backslash to escape ':' in routes.") # 0.9 - rule = rule.replace(':','\\:') + try: + re_match = re.compile('^(%s)$' % pattern).match + except re.error, e: + raise RouteSyntaxError("Could not add Route: %s (%s)" % (rule, e)) + + def match(path): + """ Return an url-argument dictionary. """ + url_args = re_match(path).groupdict() + for name, wildcard_filter in filters: + try: + url_args[name] = wildcard_filter(url_args[name]) + except ValueError: + raise HTTPError(400, 'Path has wrong format.') + return url_args - if rule in self.routes: - self.routes[rule][method.upper()] = target - else: - self.routes[rule] = {method.upper(): target} - self.rules.append(rule) - if self.static or self.dynamic: # Clear precompiler cache. - self.static, self.dynamic = {}, {} - if name: - self.named[name] = (rule, None) - - def build(self, _name, *anon, **args): - ''' Return a string that matches a named route. Use keyword arguments - to fill out named wildcards. Remaining arguments are appended as a - query string. Raises RouteBuildError or KeyError.''' - if _name not in self.named: - raise RouteBuildError("No route with that name.", _name) - rule, pairs = self.named[_name] - if not pairs: - token = self.syntax.split(rule) - parts = [p.replace('\\:',':') for p in token[::3]] - names = token[1::3] - if len(parts) > len(names): names.append(None) - pairs = zip(parts, names) - self.named[_name] = (rule, pairs) try: - anon = list(anon) - url = [s if k is None - else s+str(args.pop(k)) if k else s+str(anon.pop()) - for s, k in pairs] - except IndexError: - msg = "Not enough arguments to fill out anonymous wildcards." - raise RouteBuildError(msg) + combined = '%s|(^%s$)' % (self.dynamic[-1][0].pattern, flat_pattern) + self.dynamic[-1] = (re.compile(combined), self.dynamic[-1][1]) + self.dynamic[-1][1].append((match, target)) + except (AssertionError, IndexError), e: # AssertionError: Too many groups + self.dynamic.append((re.compile('(^%s$)' % flat_pattern), + [(match, target)])) + return match + + def build(self, _name, *anons, **query): + ''' Build an URL by filling the wildcards in a rule. ''' + builder = self.builder.get(_name) + if not builder: raise RouteBuildError("No route with that name.", _name) + try: + for i, value in enumerate(anons): query['anon%d'%i] = value + url = ''.join([f(query.pop(n)) if n else f for (n,f) in builder]) + return url if not query else url+'?'+urlencode(query) except KeyError, e: - raise RouteBuildError(*e.args) - - if args: url += ['?', urlencode(args)] - return ''.join(url) + raise RouteBuildError('Missing URL argument: %r' % e.args[0]) def match(self, environ): - ''' Return a (target, url_agrs) tuple or raise HTTPError(404/405). ''' - targets, urlargs = self._match_path(environ) + ''' Return a (target, url_agrs) tuple or raise HTTPError(400/404/405). ''' + path, targets, urlargs = environ['PATH_INFO'] or '/', None, {} + if path in self.static: + targets = self.static[path] + else: + for combined, rules in self.dynamic: + match = combined.match(path) + if not match: continue + getargs, targets = rules[match.lastindex - 1] + urlargs = getargs(path) if getargs else {} + break + if not targets: raise HTTPError(404, "Not found: " + repr(environ['PATH_INFO'])) method = environ['REQUEST_METHOD'].upper() @@ -316,66 +431,90 @@ class Router(object): raise HTTPError(405, "Method not allowed.", header=[('Allow',",".join(allowed))]) - def _match_path(self, environ): - ''' Optimized PATH_INFO matcher. ''' - path = environ['PATH_INFO'] or '/' - # Assume we are in a warm state. Search compiled rules first. - match = self.static.get(path) - if match: return match, {} - for combined, rules in self.dynamic: - match = combined.match(path) - if not match: continue - gpat, match = rules[match.lastindex - 1] - return match, gpat.match(path).groupdict() if gpat else {} - # Lazy-check if we are really in a warm state. If yes, stop here. - if self.static or self.dynamic or not self.routes: return None, {} - # Cold state: We have not compiled any rules yet. Do so and try again. - if not environ.get('wsgi.run_once'): - self._compile() - return self._match_path(environ) - # For run_once (CGI) environments, don't compile. Just check one by one. - epath = path.replace(':','\\:') # Turn path into its own static rule. - match = self.routes.get(epath) # This returns static rule only. - if match: return match, {} - for rule in self.rules: - #: Skip static routes to reduce re.compile() calls. - if rule.count(':') < rule.count('\\:'): continue - match = self._compile_pattern(rule).match(path) - if match: return self.routes[rule], match.groupdict() - return None, {} - - def _compile(self): - ''' Prepare static and dynamic search structures. ''' - self.static = {} - self.dynamic = [] - def fpat_sub(m): - return m.group(0) if len(m.group(1)) % 2 else m.group(1) + '(?:' - for rule in self.rules: - target = self.routes[rule] - if not self.syntax.search(rule): - self.static[rule.replace('\\:',':')] = target - continue - gpat = self._compile_pattern(rule) - fpat = re.sub(r'(\\*)(\(\?P<[^>]*>|\((?!\?))', fpat_sub, gpat.pattern) - gpat = gpat if gpat.groupindex else None + + +class Route(object): + ''' This class wraps a route callback along with route specific metadata and + configuration and applies Plugins on demand. It is also responsible for + turing an URL path rule into a regular expression usable by the Router. + ''' + + + def __init__(self, app, rule, method, callback, name=None, + plugins=None, skiplist=None, **config): + #: The application this route is installed to. + self.app = app + #: The path-rule string (e.g. ``/wiki/:page``). + self.rule = rule + #: The HTTP method as a string (e.g. ``GET``). + self.method = method + #: The original callback with no plugins applied. Useful for introspection. + self.callback = callback + #: The name of the route (if specified) or ``None``. + self.name = name or None + #: A list of route-specific plugins (see :meth:`Bottle.route`). + self.plugins = plugins or [] + #: A list of plugins to not apply to this route (see :meth:`Bottle.route`). + self.skiplist = skiplist or [] + #: Additional keyword arguments passed to the :meth:`Bottle.route` + #: decorator are stored in this dictionary. Used for route-specific + #: plugin configuration and meta-data. + self.config = ConfigDict(config) + + def __call__(self, *a, **ka): + depr("Some APIs changed to return Route() instances instead of"\ + " callables. Make sure to use the Route.call method and not to"\ + " call Route instances directly.") + return self.call(*a, **ka) + + @cached_property + def call(self): + ''' The route callback with all plugins applied. This property is + created on demand and then cached to speed up subsequent requests.''' + return self._make_callback() + + def reset(self): + ''' Forget any cached values. The next time :attr:`call` is accessed, + all plugins are re-applied. ''' + self.__dict__.pop('call', None) + + def prepare(self): + ''' Do all on-demand work immediately (useful for debugging).''' + self.call + + @property + def _context(self): + depr('Switch to Plugin API v2 and access the Route object directly.') + return dict(rule=self.rule, method=self.method, callback=self.callback, + name=self.name, app=self.app, config=self.config, + apply=self.plugins, skip=self.skiplist) + + def all_plugins(self): + ''' Yield all Plugins affecting this route. ''' + unique = set() + for p in reversed(self.app.plugins + self.plugins): + if True in self.skiplist: break + name = getattr(p, 'name', False) + if name and (name in self.skiplist or name in unique): continue + if p in self.skiplist or type(p) in self.skiplist: continue + if name: unique.add(name) + yield p + + def _make_callback(self): + callback = self.callback + for plugin in self.all_plugins(): try: - combined = '%s|(%s)' % (self.dynamic[-1][0].pattern, fpat) - self.dynamic[-1] = (re.compile(combined), self.dynamic[-1][1]) - self.dynamic[-1][1].append((gpat, target)) - except (AssertionError, IndexError), e: # AssertionError: Too many groups - self.dynamic.append((re.compile('(^%s$)'%fpat), - [(gpat, target)])) - except re.error, e: - raise RouteSyntaxError("Could not add Route: %s (%s)" % (rule, e)) - - def _compile_pattern(self, rule): - ''' Return a regular expression with named groups for each wildcard. ''' - out = '' - for i, part in enumerate(self.syntax.split(rule)): - if i%3 == 0: out += re.escape(part.replace('\\:',':')) - elif i%3 == 1: out += '(?P<%s>' % part if part else '(?:' - else: out += '%s)' % (part or '[^/]+') - return re.compile('^%s$'%out) + if hasattr(plugin, 'apply'): + api = getattr(plugin, 'api', 1) + context = self if api > 1 else self._context + callback = plugin.apply(callback, context) + else: + callback = plugin(callback) + except RouteReset: # Try again with changed configuration. + return self._make_callback() + if not callback is self.callback: + try_update_wrapper(callback, self.callback) + return callback @@ -394,61 +533,62 @@ class Bottle(object): """ Create a new bottle instance. You usually don't do that. Use `bottle.app.push()` instead. """ - self.routes = [] # List of installed routes including metadata. - self.router = Router() # Maps requests to self.route indices. - self.ccache = {} # Cache for callbacks with plugins applied. - + self.routes = [] # List of installed :class:`Route` instances. + self.router = Router() # Maps requests to :class:`Route` instances. self.plugins = [] # List of installed plugins. - self.mounts = {} self.error_handler = {} #: If true, most exceptions are catched and returned as :exc:`HTTPError` + self.config = ConfigDict(config or {}) self.catchall = catchall - self.config = config or {} - self.serve = True - # Default plugins - self.hooks = self.install(HooksPlugin()) - self.typefilter = self.install(TypeFilterPlugin()) + #: An instance of :class:`HooksPlugin`. Empty by default. + self.hooks = HooksPlugin() + self.install(self.hooks) if autojson: self.install(JSONPlugin()) self.install(TemplatePlugin()) - def optimize(self, *a, **ka): - depr("Bottle.optimize() is obsolete.") + def mount(self, prefix, app, **options): + ''' Mount an application (:class:`Bottle` or plain WSGI) to a specific + URL prefix. Example:: - def mount(self, app, prefix, **options): - ''' Mount an application to a specific URL prefix. The prefix is added - to SCIPT_PATH and removed from PATH_INFO before the sub-application - is called. + root_app.mount('/admin/', admin_app) - :param app: an instance of :class:`Bottle`. - :param prefix: path prefix used as a mount-point. + :param prefix: path prefix or `mount-point`. If it ends in a slash, + that slash is mandatory. + :param app: an instance of :class:`Bottle` or a WSGI application. All other parameters are passed to the underlying :meth:`route` call. ''' - if not isinstance(app, Bottle): - raise TypeError('Only Bottle instances are supported for now.') - prefix = '/'.join(filter(None, prefix.split('/'))) - if not prefix: - raise TypeError('Empty prefix. Perhaps you want a merge()?') - for other in self.mounts: - if other.startswith(prefix): - raise TypeError('Conflict with existing mount: %s' % other) - path_depth = prefix.count('/') + 1 - options.setdefault('method', 'ANY') + if isinstance(app, basestring): + prefix, app = app, prefix + depr('Parameter order of Bottle.mount() changed.') # 0.10 + + parts = filter(None, prefix.split('/')) + if not parts: raise ValueError('Empty path prefix.') + path_depth = len(parts) options.setdefault('skip', True) - self.mounts[prefix] = app - @self.route('/%s/:#.*#' % prefix, **options) - def mountpoint(): - request.path_shift(path_depth) - return app.handle(request.environ) + options.setdefault('method', 'ANY') - def add_filter(self, ftype, func): - depr("Filters are deprecated and can be replaced with plugins.") #0.9 - self.typefilter.add(ftype, func) + @self.route('/%s/:#.*#' % '/'.join(parts), **options) + def mountpoint(): + try: + request.path_shift(path_depth) + rs = BaseResponse([], 200) + def start_response(status, header): + rs.status = status + for name, value in header: rs.add_header(name, value) + return rs.body.append + rs.body = itertools.chain(rs.body, app(request.environ, start_response)) + return HTTPResponse(rs.body, rs.status, rs.headers) + finally: + request.path_shift(-path_depth) + + if not prefix.endswith('/'): + self.route('/' + '/'.join(parts), callback=mountpoint, **options) def install(self, plugin): - ''' Add a plugin to the list of plugins and prepare it for beeing + ''' Add a plugin to the list of plugins and prepare it for being applied to all routes of this application. A plugin may be a simple decorator or an object that implements the :class:`Plugin` API. ''' @@ -460,11 +600,10 @@ class Bottle(object): return plugin def uninstall(self, plugin): - ''' Uninstall plugins. Pass an instance to remove a specific plugin. - Pass a type object to remove all plugins that match that type. - Subclasses are not removed. Pass a string to remove all plugins with - a matching ``name`` attribute. Pass ``True`` to remove all plugins. - The list of affected plugins is returned. ''' + ''' Uninstall plugins. Pass an instance to remove a specific plugin, a type + object to remove all plugins that match that type, a string to remove + all plugins with a matching ``name`` attribute or ``True`` to remove all + plugins. Return the list of removed plugins. ''' removed, remove = [], plugin for i, plugin in list(enumerate(self.plugins))[::-1]: if remove is True or remove is plugin or remove is type(plugin) \ @@ -475,15 +614,17 @@ class Bottle(object): if removed: self.reset() return removed - def reset(self, id=None): + def reset(self, route=None): ''' Reset all routes (force plugins to be re-applied) and clear all - caches. If an ID is given, only that specific route is affected. ''' - if id is None: self.ccache.clear() - else: self.ccache.pop(id, None) + caches. If an ID or route object is given, only that specific route + is affected. ''' + if route is None: routes = self.routes + elif isinstance(route, Route): routes = [route] + else: routes = [self.routes[route]] + for route in routes: route.reset() if DEBUG: - for route in self.routes: - if route['id'] not in self.ccache: - self.ccache[route['id']] = self._build_callback(route) + for route in routes: route.prepare() + self.hooks.trigger('app_reset') def close(self): ''' Close the application and all installed plugins. ''' @@ -492,45 +633,10 @@ class Bottle(object): self.stopped = True def match(self, environ): - """ (deprecated) Search for a matching route and return a - (callback, urlargs) tuple. - The first element is the associated route callback with plugins - applied. The second value is a dictionary with parameters extracted - from the URL. The :class:`Router` raises :exc:`HTTPError` (404/405) - on a non-match.""" - depr("This method will change semantics in 0.10.") - return self._match(environ) - - def _match(self, environ): - handle, args = self.router.match(environ) - environ['route.handle'] = handle # TODO move to router? - environ['route.url_args'] = args - try: - return self.ccache[handle], args - except KeyError: - config = self.routes[handle] - callback = self.ccache[handle] = self._build_callback(config) - return callback, args - - def _build_callback(self, config): - ''' Apply plugins to a route and return a new callable. ''' - wrapped = config['callback'] - plugins = self.plugins + config['apply'] - skip = config['skip'] - try: - for plugin in reversed(plugins): - if True in skip: break - if plugin in skip or type(plugin) in skip: continue - if getattr(plugin, 'name', True) in skip: continue - if hasattr(plugin, 'apply'): - wrapped = plugin.apply(wrapped, config) - else: - wrapped = plugin(wrapped) - if not wrapped: break - functools.update_wrapper(wrapped, config['callback']) - return wrapped - except RouteReset: # A plugin may have changed the config dict inplace. - return self._build_callback(config) # Apply all plugins again. + """ Search for a matching route and return a (:class:`Route` , urlargs) + tuple. The second value is a dictionary with parameters extracted + from the URL. Raise :exc:`HTTPError` (404/405) on a non-match.""" + return self.router.match(environ) def get_url(self, routename, **kargs): """ Return a string that matches a named route """ @@ -566,31 +672,20 @@ class Bottle(object): configuration and passed to plugins (see :meth:`Plugin.apply`). """ if callable(path): path, callback = None, path - plugins = makelist(apply) skiplist = makelist(skip) - if 'decorate' in config: - depr("The 'decorate' parameter was renamed to 'apply'") # 0.9 - plugins += makelist(config.pop('decorate')) - if config.pop('no_hooks', False): - depr("The no_hooks parameter is no longer used. Add 'hooks' to the"\ - " list of skipped plugins instead.") # 0.9 - skiplist.append('hooks') - static = config.get('static', False) # depr 0.9 - def decorator(callback): + # TODO: Documentation and tests + if isinstance(callback, basestring): callback = load(callback) for rule in makelist(path) or yieldroutes(callback): for verb in makelist(method): verb = verb.upper() - cfg = dict(rule=rule, method=verb, callback=callback, - name=name, app=self, config=config, - apply=plugins, skip=skiplist) - self.routes.append(cfg) - cfg['id'] = self.routes.index(cfg) - self.router.add(rule, verb, cfg['id'], name=name, static=static) - if DEBUG: self.ccache[cfg['id']] = self._build_callback(cfg) + route = Route(self, rule, verb, callback, name=name, + plugins=plugins, skiplist=skiplist, **config) + self.routes.append(route) + self.router.add(rule, verb, route, name=name) + if DEBUG: route.prepare() return callback - return decorator(callback) if callback else decorator def get(self, path=None, method='GET', **options): @@ -623,14 +718,6 @@ class Bottle(object): return func return wrapper - def add_hook(self, name, func): - depr("Call Bottle.hooks.add() instead.") #0.9 - self.hooks.add(name, func) - - def remove_hook(self, name, func): - depr("Call Bottle.hooks.remove() instead.") #0.9 - self.hooks.remove(name, func) - def handle(self, path, method='GET'): """ (deprecated) Execute the first matching route callback and return the result. :exc:`HTTPResponse` exceptions are catched and returned. @@ -641,24 +728,25 @@ class Bottle(object): if isinstance(path, dict): return self._handle(path) return self._handle({'PATH_INFO': path, 'REQUEST_METHOD': method.upper()}) - + def _handle(self, environ): - if not self.serve: - depr("Bottle.serve will be removed in 0.10.") - return HTTPError(503, "Server stopped") try: - callback, args = self._match(environ) - return callback(**args) + route, args = self.router.match(environ) + environ['route.handle'] = environ['bottle.route'] = route + environ['route.url_args'] = args + return route.call(**args) except HTTPResponse, r: return r - except RouteReset: # Route reset requested by the callback or a plugin. - del self.ccache[handle] - return self.handle(environ) # Try again. + except RouteReset: + route.reset() + return self._handle(environ) except (KeyboardInterrupt, SystemExit, MemoryError): raise except Exception, e: if not self.catchall: raise - return HTTPError(500, "Internal Server Error", e, format_exc(10)) + stacktrace = format_exc(10) + environ['wsgi.errors'].write(stacktrace) + return HTTPError(500, "Internal Server Error", e, stacktrace) def _cast(self, out, request, response, peek=None): """ Try to convert the parameter into something WSGI compatible and set @@ -669,7 +757,7 @@ class Bottle(object): # Empty output is done here if not out: - response.headers['Content-Length'] = 0 + response['Content-Length'] = 0 return [] # Join lists of byte or unicode strings. Mixed lists are NOT supported if isinstance(out, (tuple, list))\ @@ -680,9 +768,10 @@ class Bottle(object): out = out.encode(response.charset) # Byte Strings are just returned if isinstance(out, bytes): - response.headers['Content-Length'] = str(len(out)) + response['Content-Length'] = len(out) return [out] # HTTPError or HTTPException (recursive, because they may wrap anything) + # TODO: Handle these explicitly in handle() or make them iterable. if isinstance(out, HTTPError): out.apply(response) out = self.error_handler.get(out.status, repr)(out) @@ -732,14 +821,13 @@ class Bottle(object): environ['bottle.app'] = self request.bind(environ) response.bind() - out = self._handle(environ) - out = self._cast(out, request, response) + out = self._cast(self._handle(environ), request, response) # rfc2616 section 4.3 - if response.status in (100, 101, 204, 304) or request.method == 'HEAD': + if response._status_code in (100, 101, 204, 304)\ + or request.method == 'HEAD': if hasattr(out, 'close'): out.close() out = [] - status = '%d %s' % (response.status, HTTP_CODES[response.status]) - start_response(status, response.headerlist) + start_response(response._status_line, list(response.iter_headers())) return out except (KeyboardInterrupt, SystemExit, MemoryError): raise @@ -755,6 +843,7 @@ class Bottle(object): return [tob(err)] def __call__(self, environ, start_response): + ''' Each instance of :class:'Bottle' is a WSGI application. ''' return self.wsgi(environ, start_response) @@ -767,196 +856,136 @@ class Bottle(object): ############################################################################### -class Request(threading.local, DictMixin): - """ Represents a single HTTP request using thread-local attributes. - The Request object wraps a WSGI environment and can be used as such. - """ - def __init__(self, environ=None): - """ Create a new Request instance. - - You usually don't do this but use the global `bottle.request` - instance instead. - """ - self.bind(environ or {},) +class BaseRequest(DictMixin): + """ A wrapper for WSGI environment dictionaries that adds a lot of + convenient access methods and properties. Most of them are read-only.""" - def bind(self, environ): - """ Bind a new WSGI environment. + #: Maximum size of memory buffer for :attr:`body` in bytes. + MEMFILE_MAX = 102400 - This is done automatically for the global `bottle.request` - instance on every request. - """ + def __init__(self, environ): + """ Wrap a WSGI environ dictionary. """ + #: The wrapped WSGI environ dictionary. This is the only real attribute. + #: All other attributes actually are read-only properties. self.environ = environ - # These attributes are used anyway, so it is ok to compute them here - self.path = '/' + environ.get('PATH_INFO', '/').lstrip('/') - self.method = environ.get('REQUEST_METHOD', 'GET').upper() - - @property - def _environ(self): - depr("Request._environ renamed to Request.environ") - return self.environ - - def copy(self): - ''' Returns a copy of self ''' - return Request(self.environ.copy()) - - def path_shift(self, shift=1): - ''' Shift path fragments from PATH_INFO to SCRIPT_NAME and vice versa. - - :param shift: The number of path fragments to shift. May be negative - to change the shift direction. (default: 1) - ''' - script_name = self.environ.get('SCRIPT_NAME','/') - self['SCRIPT_NAME'], self.path = path_shift(script_name, self.path, shift) - self['PATH_INFO'] = self.path - - def __getitem__(self, key): return self.environ[key] - def __delitem__(self, key): self[key] = ""; del(self.environ[key]) - def __iter__(self): return iter(self.environ) - def __len__(self): return len(self.environ) - def keys(self): return self.environ.keys() - def __setitem__(self, key, value): - """ Shortcut for Request.environ.__setitem__ """ - self.environ[key] = value - todelete = [] - if key in ('PATH_INFO','REQUEST_METHOD'): - self.bind(self.environ) - elif key == 'wsgi.input': todelete = ('body','forms','files','params') - elif key == 'QUERY_STRING': todelete = ('get','params') - elif key.startswith('HTTP_'): todelete = ('headers', 'cookies') - for key in todelete: - if 'bottle.' + key in self.environ: - del self.environ['bottle.' + key] - - @DictProperty('environ', 'bottle.urlparts', read_only=True) - def urlparts(self): - ''' Return a :class:`urlparse.SplitResult` tuple that can be used - to reconstruct the full URL as requested by the client. - The tuple contains: (scheme, host, path, query_string, fragment). - The fragment is always empty because it is not visible to the server. - ''' - env = self.environ - host = env.get('HTTP_X_FORWARDED_HOST') or env.get('HTTP_HOST', '') - http = env.get('wsgi.url_scheme', 'http') - port = env.get('SERVER_PORT') - if ':' in host: # Overrule SERVER_POST (proxy support) - host, port = host.rsplit(':', 1) - if not host or host == '127.0.0.1': - host = env.get('SERVER_NAME', host) - if port and http+port not in ('http80', 'https443'): - host += ':' + port - spath = self.environ.get('SCRIPT_NAME','').rstrip('/') + '/' - rpath = self.path.lstrip('/') - path = urlquote(urljoin(spath, rpath)) - return UrlSplitResult(http, host, path, env.get('QUERY_STRING'), '') - - @property - def url(self): - """ Full URL as requested by the client. """ - return self.urlparts.geturl() - - @property - def fullpath(self): - """ Request path including SCRIPT_NAME (if present). """ - return urlunquote(self.urlparts[2]) - - @property - def query_string(self): - """ The part of the URL following the '?'. """ - return self.environ.get('QUERY_STRING', '') + environ['bottle.request'] = self @property - def content_length(self): - """ Content-Length header as an integer, -1 if not specified """ - return int(self.environ.get('CONTENT_LENGTH', '') or -1) + def path(self): + ''' The value of ``PATH_INFO`` with exactly one prefixed slash (to fix + broken clients and avoid the "empty path" edge case). ''' + return '/' + self.environ.get('PATH_INFO','').lstrip('/') @property - def header(self): - depr("The Request.header property was renamed to Request.headers") - return self.headers + def method(self): + ''' The ``REQUEST_METHOD`` value as an uppercase string. ''' + return self.environ.get('REQUEST_METHOD', 'GET').upper() - @DictProperty('environ', 'bottle.headers', read_only=True) + @DictProperty('environ', 'bottle.request.headers', read_only=True) def headers(self): - ''' Request HTTP Headers stored in a :class:`HeaderDict`. ''' + ''' A :class:`WSGIHeaderDict` that provides case-insensitive access to + HTTP request headers. ''' return WSGIHeaderDict(self.environ) - @DictProperty('environ', 'bottle.get', read_only=True) - def GET(self): - """ The QUERY_STRING parsed into an instance of :class:`MultiDict`. """ + def get_header(self, name, default=None): + ''' Return the value of a request header, or a given default value. ''' + return self.headers.get(name, default) + + @DictProperty('environ', 'bottle.request.cookies', read_only=True) + def cookies(self): + """ Cookies parsed into a :class:`FormsDict`. Signed cookies are NOT + decoded. Use :meth:`get_cookie` if you expect signed cookies. """ + cookies = SimpleCookie(self.environ.get('HTTP_COOKIE','')) + return FormsDict((c.key, c.value) for c in cookies.itervalues()) + + def get_cookie(self, key, default=None, secret=None): + """ Return the content of a cookie. To read a `Signed Cookie`, the + `secret` must match the one used to create the cookie (see + :meth:`BaseResponse.set_cookie`). If anything goes wrong (missing + cookie or wrong signature), return a default value. """ + value = self.cookies.get(key) + if secret and value: + dec = cookie_decode(value, secret) # (key, value) tuple or None + return dec[1] if dec and dec[0] == key else default + return value or default + + @DictProperty('environ', 'bottle.request.query', read_only=True) + def query(self): + ''' The :attr:`query_string` parsed into a :class:`FormsDict`. These + values are sometimes called "URL arguments" or "GET parameters", but + not to be confused with "URL wildcards" as they are provided by the + :class:`Router`. ''' data = parse_qs(self.query_string, keep_blank_values=True) - get = self.environ['bottle.get'] = MultiDict() + get = self.environ['bottle.get'] = FormsDict() for key, values in data.iteritems(): for value in values: get[key] = value return get - @DictProperty('environ', 'bottle.post', read_only=True) - def POST(self): - """ The combined values from :attr:`forms` and :attr:`files`. Values are - either strings (form values) or instances of - :class:`cgi.FieldStorage` (file uploads). - """ - post = MultiDict() - safe_env = {'QUERY_STRING':''} # Build a safe environment for cgi - for key in ('REQUEST_METHOD', 'CONTENT_TYPE', 'CONTENT_LENGTH'): - if key in self.environ: safe_env[key] = self.environ[key] - if NCTextIOWrapper: - fb = NCTextIOWrapper(self.body, encoding='ISO-8859-1', newline='\n') - else: - fb = self.body - data = cgi.FieldStorage(fp=fb, environ=safe_env, keep_blank_values=True) - for item in data.list or []: - post[item.name] = item if item.filename else item.value - return post - - @DictProperty('environ', 'bottle.forms', read_only=True) + @DictProperty('environ', 'bottle.request.forms', read_only=True) def forms(self): - """ POST form values parsed into an instance of :class:`MultiDict`. - - This property contains form values parsed from an `url-encoded` - or `multipart/form-data` encoded POST request bidy. The values are - native strings. - """ - forms = MultiDict() + """ Form values parsed from an `url-encoded` or `multipart/form-data` + encoded POST or PUT request body. The result is retuned as a + :class:`FormsDict`. All keys and values are strings. File uploads + are stored separately in :attr:`files`. """ + forms = FormsDict() for name, item in self.POST.iterallitems(): if not hasattr(item, 'filename'): forms[name] = item return forms - @DictProperty('environ', 'bottle.files', read_only=True) - def files(self): - """ File uploads parsed into an instance of :class:`MultiDict`. + @DictProperty('environ', 'bottle.request.params', read_only=True) + def params(self): + """ A :class:`FormsDict` with the combined values of :attr:`query` and + :attr:`forms`. File uploads are stored in :attr:`files`. """ + params = FormsDict() + for key, value in self.query.iterallitems(): + params[key] = value + for key, value in self.forms.iterallitems(): + params[key] = value + return params - This property contains file uploads parsed from an - `multipart/form-data` encoded POST request body. The values are - instances of :class:`cgi.FieldStorage`. + @DictProperty('environ', 'bottle.request.files', read_only=True) + def files(self): + """ File uploads parsed from an `url-encoded` or `multipart/form-data` + encoded POST or PUT request body. The values are instances of + :class:`cgi.FieldStorage`. The most important attributes are: + + filename + The filename, if specified; otherwise None; this is the client + side filename, *not* the file name on which it is stored (that's + a temporary file you don't deal with) + file + The file(-like) object from which you can read the data. + value + The value as a *string*; for file uploads, this transparently + reads the file every time you request the value. Do not do this + on big files. """ - files = MultiDict() + files = FormsDict() for name, item in self.POST.iterallitems(): if hasattr(item, 'filename'): files[name] = item return files - @DictProperty('environ', 'bottle.params', read_only=True) - def params(self): - """ A combined :class:`MultiDict` with values from :attr:`forms` and - :attr:`GET`. File-uploads are not included. """ - params = MultiDict(self.GET) - for key, value in self.forms.iterallitems(): - params[key] = value - return params + @DictProperty('environ', 'bottle.request.json', read_only=True) + def json(self): + ''' If the ``Content-Type`` header is ``application/json``, this + property holds the parsed content of the request body. Only requests + smaller than :attr:`MEMFILE_MAX` are processed to avoid memory + exhaustion. ''' + if 'application/json' in self.environ.get('CONTENT_TYPE', '') \ + and 0 < self.content_length < self.MEMFILE_MAX: + return json_loads(self.body.read(self.MEMFILE_MAX)) + return None - @DictProperty('environ', 'bottle.body', read_only=True) + @DictProperty('environ', 'bottle.request.body', read_only=True) def _body(self): - """ The HTTP request body as a seekable file-like object. - - This property returns a copy of the `wsgi.input` stream and should - be used instead of `environ['wsgi.input']`. - """ maxread = max(0, self.content_length) stream = self.environ['wsgi.input'] - body = BytesIO() if maxread < MEMFILE_MAX else TemporaryFile(mode='w+b') + body = BytesIO() if maxread < self.MEMFILE_MAX else TemporaryFile(mode='w+b') while maxread > 0: - part = stream.read(min(maxread, MEMFILE_MAX)) + part = stream.read(min(maxread, self.MEMFILE_MAX)) if not part: break body.write(part) maxread -= len(part) @@ -966,124 +995,378 @@ class Request(threading.local, DictMixin): @property def body(self): + """ The HTTP request body as a seek-able file-like object. Depending on + :attr:`MEMFILE_MAX`, this is either a temporary file or a + :class:`io.BytesIO` instance. Accessing this property for the first + time reads and replaces the ``wsgi.input`` environ variable. + Subsequent accesses just do a `seek(0)` on the file object. """ self._body.seek(0) return self._body - @property - def auth(self): #TODO: Tests and docs. Add support for digest. namedtuple? - """ HTTP authorization data as a (user, passwd) tuple. (experimental) + #: An alias for :attr:`query`. + GET = query - This implementation currently only supports basic auth and returns - None on errors. + @DictProperty('environ', 'bottle.request.post', read_only=True) + def POST(self): + """ The values of :attr:`forms` and :attr:`files` combined into a single + :class:`FormsDict`. Values are either strings (form values) or + instances of :class:`cgi.FieldStorage` (file uploads). """ - return parse_auth(self.headers.get('Authorization','')) + post = FormsDict() + safe_env = {'QUERY_STRING':''} # Build a safe environment for cgi + for key in ('REQUEST_METHOD', 'CONTENT_TYPE', 'CONTENT_LENGTH'): + if key in self.environ: safe_env[key] = self.environ[key] + if NCTextIOWrapper: + fb = NCTextIOWrapper(self.body, encoding='ISO-8859-1', newline='\n') + else: + fb = self.body + data = cgi.FieldStorage(fp=fb, environ=safe_env, keep_blank_values=True) + for item in data.list or []: + post[item.name] = item if item.filename else item.value + return post - @DictProperty('environ', 'bottle.cookies', read_only=True) + @property def COOKIES(self): - """ Cookies parsed into a dictionary. Signed cookies are NOT decoded - automatically. See :meth:`get_cookie` for details. - """ - raw_dict = SimpleCookie(self.headers.get('Cookie','')) - cookies = {} - for cookie in raw_dict.itervalues(): - cookies[cookie.key] = cookie.value - return cookies - - def get_cookie(self, key, secret=None): - """ Return the content of a cookie. To read a `Signed Cookies`, use the - same `secret` as used to create the cookie (see - :meth:`Response.set_cookie`). If anything goes wrong, None is - returned. - """ - value = self.COOKIES.get(key) - if secret and value: - dec = cookie_decode(value, secret) # (key, value) tuple or None - return dec[1] if dec and dec[0] == key else None - return value or None + ''' Alias for :attr:`cookies` (deprecated). ''' + depr('BaseRequest.COOKIES was renamed to BaseRequest.cookies (lowercase).') + return self.cookies @property - def is_ajax(self): - ''' True if the request was generated using XMLHttpRequest ''' - #TODO: write tests - return self.header.get('X-Requested-With') == 'XMLHttpRequest' + def url(self): + """ The full request URI including hostname and scheme. If your app + lives behind a reverse proxy or load balancer and you get confusing + results, make sure that the ``X-Forwarded-Host`` header is set + correctly. """ + return self.urlparts.geturl() + @DictProperty('environ', 'bottle.request.urlparts', read_only=True) + def urlparts(self): + ''' The :attr:`url` string as an :class:`urlparse.SplitResult` tuple. + The tuple contains (scheme, host, path, query_string and fragment), + but the fragment is always empty because it is not visible to the + server. ''' + env = self.environ + http = env.get('wsgi.url_scheme', 'http') + host = env.get('HTTP_X_FORWARDED_HOST') or env.get('HTTP_HOST') + if not host: + # HTTP 1.1 requires a Host-header. This is for HTTP/1.0 clients. + host = env.get('SERVER_NAME', '127.0.0.1') + port = env.get('SERVER_PORT') + if port and port != ('80' if http == 'http' else '443'): + host += ':' + port + path = urlquote(self.fullpath) + return UrlSplitResult(http, host, path, env.get('QUERY_STRING'), '') -class Response(threading.local): - """ Represents a single HTTP response using thread-local attributes. - """ + @property + def fullpath(self): + """ Request path including :attr:`script_name` (if present). """ + return urljoin(self.script_name, self.path.lstrip('/')) - def __init__(self): - self.bind() + @property + def query_string(self): + """ The raw :attr:`query` part of the URL (everything in between ``?`` + and ``#``) as a string. """ + return self.environ.get('QUERY_STRING', '') - def bind(self): - """ Resets the Response object to its factory defaults. """ - self._COOKIES = None - self.status = 200 - self.headers = HeaderDict() - self.content_type = 'text/html; charset=UTF-8' + @property + def script_name(self): + ''' The initial portion of the URL's `path` that was removed by a higher + level (server or routing middleware) before the application was + called. This script path is returned with leading and tailing + slashes. ''' + script_name = self.environ.get('SCRIPT_NAME', '').strip('/') + return '/' + script_name + '/' if script_name else '/' + + def path_shift(self, shift=1): + ''' Shift path segments from :attr:`path` to :attr:`script_name` and + vice versa. + + :param shift: The number of path segments to shift. May be negative + to change the shift direction. (default: 1) + ''' + script = self.environ.get('SCRIPT_NAME','/') + self['SCRIPT_NAME'], self['PATH_INFO'] = path_shift(script, self.path, shift) @property - def header(self): - depr("Response.header renamed to Response.headers") - return self.headers + def content_length(self): + ''' The request body length as an integer. The client is responsible to + set this header. Otherwise, the real length of the body is unknown + and -1 is returned. In this case, :attr:`body` will be empty. ''' + return int(self.environ.get('CONTENT_LENGTH') or -1) + + @property + def is_xhr(self): + ''' True if the request was triggered by a XMLHttpRequest. This only + works with JavaScript libraries that support the `X-Requested-With` + header (most of the popular libraries do). ''' + requested_with = self.environ.get('HTTP_X_REQUESTED_WITH','') + return requested_with.lower() == 'xmlhttprequest' + + @property + def is_ajax(self): + ''' Alias for :attr:`is_xhr`. "Ajax" is not the right term. ''' + return self.is_xhr + + @property + def auth(self): + """ HTTP authentication data as a (user, password) tuple. This + implementation currently supports basic (not digest) authentication + only. If the authentication happened at a higher level (e.g. in the + front web-server or a middleware), the password field is None, but + the user field is looked up from the ``REMOTE_USER`` environ + variable. On any errors, None is returned. """ + basic = parse_auth(self.environ.get('HTTP_AUTHORIZATION','')) + if basic: return basic + ruser = self.environ.get('REMOTE_USER') + if ruser: return (ruser, None) + return None + + @property + def remote_route(self): + """ A list of all IPs that were involved in this request, starting with + the client IP and followed by zero or more proxies. This does only + work if all proxies support the ```X-Forwarded-For`` header. Note + that this information can be forged by malicious clients. """ + proxy = self.environ.get('HTTP_X_FORWARDED_FOR') + if proxy: return [ip.strip() for ip in proxy.split(',')] + remote = self.environ.get('REMOTE_ADDR') + return [remote] if remote else [] + + @property + def remote_addr(self): + """ The client IP as a string. Note that this information can be forged + by malicious clients. """ + route = self.remote_route + return route[0] if route else None + + def copy(self): + """ Return a new :class:`Request` with a shallow :attr:`environ` copy. """ + return Request(self.environ.copy()) + + def __getitem__(self, key): return self.environ[key] + def __delitem__(self, key): self[key] = ""; del(self.environ[key]) + def __iter__(self): return iter(self.environ) + def __len__(self): return len(self.environ) + def keys(self): return self.environ.keys() + def __setitem__(self, key, value): + """ Change an environ value and clear all caches that depend on it. """ + + if self.environ.get('bottle.request.readonly'): + raise KeyError('The environ dictionary is read-only.') + + self.environ[key] = value + todelete = () + + if key == 'wsgi.input': + todelete = ('body', 'forms', 'files', 'params', 'post', 'json') + elif key == 'QUERY_STRING': + todelete = ('query', 'params') + elif key.startswith('HTTP_'): + todelete = ('headers', 'cookies') + + for key in todelete: + self.environ.pop('bottle.request.'+key, None) + + def __repr__(self): + return '<%s: %s %s>' % (self.__class__.__name__, self.method, self.url) + +def _hkey(s): + return s.title().replace('_','-') + + +class HeaderProperty(object): + def __init__(self, name, reader=None, writer=str, default=''): + self.name, self.reader, self.writer, self.default = name, reader, writer, default + self.__doc__ = 'Current value of the %r header.' % name.title() + + def __get__(self, obj, cls): + if obj is None: return self + value = obj.headers.get(self.name) + return self.reader(value) if (value and self.reader) else (value or self.default) + + def __set__(self, obj, value): + if self.writer: value = self.writer(value) + obj.headers[self.name] = value + + def __delete__(self, obj): + if self.name in obj.headers: + del obj.headers[self.name] + + +class BaseResponse(object): + """ Storage class for a response body as well as headers and cookies. + + This class does support dict-like case-insensitive item-access to + headers, but is NOT a dict. Most notably, iterating over a response + yields parts of the body and not the headers. + """ + + default_status = 200 + default_content_type = 'text/html; charset=UTF-8' + + # Header blacklist for specific response codes + # (rfc2616 section 10.2.3 and 10.3.5) + bad_headers = { + 204: set(('Content-Type',)), + 304: set(('Allow', 'Content-Encoding', 'Content-Language', + 'Content-Length', 'Content-Range', 'Content-Type', + 'Content-Md5', 'Last-Modified'))} + + def __init__(self, body='', status=None, **headers): + self._status_line = None + self._status_code = None + self.body = body + self._cookies = None + self._headers = {'Content-Type': [self.default_content_type]} + self.status = status or self.default_status + if headers: + for name, value in headers.items(): + self[name] = value def copy(self): ''' Returns a copy of self. ''' copy = Response() copy.status = self.status - copy.headers = self.headers.copy() - copy.content_type = self.content_type + copy._headers = dict((k, v[:]) for (k, v) in self._headers.items()) return copy + def __iter__(self): + return iter(self.body) + + def close(self): + if hasattr(self.body, 'close'): + self.body.close() + + @property + def status_line(self): + ''' The HTTP status line as a string (e.g. ``404 Not Found``).''' + return self._status_line + + @property + def status_code(self): + ''' The HTTP status code as an integer (e.g. 404).''' + return self._status_code + + def _set_status(self, status): + if isinstance(status, int): + code, status = status, _HTTP_STATUS_LINES.get(status) + elif ' ' in status: + status = status.strip() + code = int(status.split()[0]) + else: + raise ValueError('String status line without a reason phrase.') + if not 100 <= code <= 999: raise ValueError('Status code out of range.') + self._status_code = code + self._status_line = status or ('%d Unknown' % code) + + def _get_status(self): + depr('BaseReuqest.status will change to return a string in 0.11. Use'\ + ' status_line and status_code to make sure.') #0.10 + return self._status_code + + status = property(_get_status, _set_status, None, + ''' A writeable property to change the HTTP response status. It accepts + either a numeric code (100-999) or a string with a custom reason + phrase (e.g. "404 Brain not found"). Both :data:`status_line` and + :data:`status_code` are updates accordingly. The return value is + always a numeric code. ''') + del _get_status, _set_status + + @property + def headers(self): + ''' An instance of :class:`HeaderDict`, a case-insensitive dict-like + view on the response headers. ''' + self.__dict__['headers'] = hdict = HeaderDict() + hdict.dict = self._headers + return hdict + + def __contains__(self, name): return _hkey(name) in self._headers + def __delitem__(self, name): del self._headers[_hkey(name)] + def __getitem__(self, name): return self._headers[_hkey(name)][-1] + def __setitem__(self, name, value): self._headers[_hkey(name)] = [str(value)] + + def get_header(self, name, default=None): + ''' Return the value of a previously defined header. If there is no + header with that name, return a default value. ''' + return self._headers.get(_hkey(name), [default])[-1] + + def set_header(self, name, value, append=False): + ''' Create a new response header, replacing any previously defined + headers with the same name. ''' + if append: + self.add_header(name, value) + else: + self._headers[_hkey(name)] = [str(value)] + + def add_header(self, name, value): + ''' Add an additional response header, not removing duplicates. ''' + self._headers.setdefault(_hkey(name), []).append(str(value)) + + def iter_headers(self): + ''' Yield (header, value) tuples, skipping headers that are not + allowed with the current response status code. ''' + headers = self._headers.iteritems() + bad_headers = self.bad_headers.get(self.status_code) + if bad_headers: + headers = [h for h in headers if h[0] not in bad_headers] + for name, values in headers: + for value in values: + yield name, value + if self._cookies: + for c in self._cookies.values(): + yield 'Set-Cookie', c.OutputString() + def wsgiheader(self): - ''' Returns a wsgi conform list of header/value pairs. ''' - for c in self.COOKIES.values(): - if c.OutputString() not in self.headers.getall('Set-Cookie'): - self.headers.append('Set-Cookie', c.OutputString()) - # rfc2616 section 10.2.3, 10.3.5 - if self.status in (204, 304) and 'content-type' in self.headers: - del self.headers['content-type'] - if self.status == 304: - for h in ('allow', 'content-encoding', 'content-language', - 'content-length', 'content-md5', 'content-range', - 'content-type', 'last-modified'): # + c-location, expires? - if h in self.headers: - del self.headers[h] - return list(self.headers.iterallitems()) - headerlist = property(wsgiheader) + depr('The wsgiheader method is deprecated. See headerlist.') #0.10 + return self.headerlist @property - def charset(self): - """ Return the charset specified in the content-type header. + def headerlist(self): + ''' WSGI conform list of (header, value) tuples. ''' + return list(self.iter_headers()) - This defaults to `UTF-8`. - """ + content_type = HeaderProperty('Content-Type') + content_length = HeaderProperty('Content-Length', reader=int) + + @property + def charset(self): + """ Return the charset specified in the content-type header (default: utf8). """ if 'charset=' in self.content_type: return self.content_type.split('charset=')[-1].split(';')[0].strip() return 'UTF-8' @property def COOKIES(self): - """ A dict-like SimpleCookie instance. Use :meth:`set_cookie` instead. """ - if not self._COOKIES: - self._COOKIES = SimpleCookie() - return self._COOKIES - - def set_cookie(self, key, value, secret=None, **kargs): - ''' Add a cookie or overwrite an old one. If the `secret` parameter is + """ A dict-like SimpleCookie instance. This should not be used directly. + See :meth:`set_cookie`. """ + depr('The COOKIES dict is deprecated. Use `set_cookie()` instead.') # 0.10 + if not self._cookies: + self._cookies = SimpleCookie() + return self._cookies + + def set_cookie(self, name, value, secret=None, **options): + ''' Create a new cookie or replace an old one. If the `secret` parameter is set, create a `Signed Cookie` (described below). - :param key: the name of the cookie. + :param name: the name of the cookie. :param value: the value of the cookie. - :param secret: required for signed cookies. (default: None) + :param secret: a signature key required for signed cookies. + + Additionally, this method accepts all RFC 2109 attributes that are + supported by :class:`cookie.Morsel`, including: + :param max_age: maximum age in seconds. (default: None) - :param expires: a datetime object or UNIX timestamp. (defaut: None) + :param expires: a datetime object or UNIX timestamp. (default: None) :param domain: the domain that is allowed to read the cookie. (default: current domain) - :param path: limits the cookie to a given path (default: /) + :param path: limits the cookie to a given path (default: current path) + :param secure: limit the cookie to HTTPS connections (default: off). + :param httponly: prevents client-side javascript to read this cookie + (default: off, requires Python 2.6 or newer). - If neither `expires` nor `max_age` are set (default), the cookie - lasts only as long as the browser is not closed. + If neither `expires` nor `max_age` is set (default), the cookie will + expire at the end of the browser session (as soon as the browser + window is closed). Signed cookies may store any pickle-able object and are cryptographically signed to prevent manipulation. Keep in mind that @@ -1094,31 +1377,55 @@ class Response(threading.local): cookie). The main intention is to make pickling and unpickling save, not to store secret information at client side. ''' + if not self._cookies: + self._cookies = SimpleCookie() + if secret: - value = touni(cookie_encode((key, value), secret)) + value = touni(cookie_encode((name, value), secret)) elif not isinstance(value, basestring): - raise TypeError('Secret missing for non-string Cookie.') - - self.COOKIES[key] = value - for k, v in kargs.iteritems(): - self.COOKIES[key][k.replace('_', '-')] = v + raise TypeError('Secret key missing for non-string Cookie.') + + if len(value) > 4096: raise ValueError('Cookie value to long.') + self._cookies[name] = value + + for key, value in options.iteritems(): + if key == 'max_age': + if isinstance(value, timedelta): + value = value.seconds + value.days * 24 * 3600 + if key == 'expires': + if isinstance(value, (datedate, datetime)): + value = value.timetuple() + elif isinstance(value, (int, float)): + value = time.gmtime(value) + value = time.strftime("%a, %d %b %Y %H:%M:%S GMT", value) + self._cookies[name][key.replace('_', '-')] = value def delete_cookie(self, key, **kwargs): ''' Delete a cookie. Be sure to use the same `domain` and `path` - parameters as used to create the cookie. ''' + settings as used to create the cookie. ''' kwargs['max_age'] = -1 kwargs['expires'] = 0 self.set_cookie(key, '', **kwargs) - def get_content_type(self): - """ Current 'Content-Type' header. """ - return self.headers['Content-Type'] + def __repr__(self): + out = '' + for name, value in self.headerlist: + out += '%s: %s\n' % (name.title(), value.strip()) + return out + - def set_content_type(self, value): - self.headers['Content-Type'] = value +class LocalRequest(BaseRequest, threading.local): + ''' A thread-local subclass of :class:`BaseRequest`. ''' + def __init__(self): pass + bind = BaseRequest.__init__ - content_type = property(get_content_type, set_content_type, None, - get_content_type.__doc__) + +class LocalResponse(BaseResponse, threading.local): + ''' A thread-local subclass of :class:`BaseResponse`. ''' + bind = BaseResponse.__init__ + +Response = LocalResponse # BC 0.9 +Request = LocalRequest # BC 0.9 @@ -1129,10 +1436,11 @@ class Response(threading.local): # Plugins ###################################################################### ############################################################################### - +class PluginError(BottleException): pass class JSONPlugin(object): name = 'json' + api = 2 def __init__(self, json_dumps=json_dumps): self.json_dumps = json_dumps @@ -1143,18 +1451,23 @@ class JSONPlugin(object): def wrapper(*a, **ka): rv = callback(*a, **ka) if isinstance(rv, dict): + #Attempt to serialize, raises exception on failure + json_response = dumps(rv) + #Set content type only if serialization succesful response.content_type = 'application/json' - return dumps(rv) + return json_response return rv return wrapper - class HooksPlugin(object): name = 'hooks' + api = 2 + + _names = 'before_request', 'after_request', 'app_reset' def __init__(self): - self.hooks = {'before_request': [], 'after_request': []} + self.hooks = dict((name, []) for name in self._names) self.app = None def _empty(self): @@ -1165,56 +1478,29 @@ class HooksPlugin(object): def add(self, name, func): ''' Attach a callback to a hook. ''' - if name not in self.hooks: - raise ValueError("Unknown hook name %s" % name) was_empty = self._empty() - self.hooks[name].append(func) + self.hooks.setdefault(name, []).append(func) if self.app and was_empty and not self._empty(): self.app.reset() def remove(self, name, func): ''' Remove a callback from a hook. ''' - if name not in self.hooks: - raise ValueError("Unknown hook name %s" % name) was_empty = self._empty() - self.hooks[name].remove(func) + if name in self.hooks and func in self.hooks[name]: + self.hooks[name].remove(func) if self.app and not was_empty and self._empty(): self.app.reset() - def apply(self, callback, context): - if self._empty(): return callback - before_request = self.hooks['before_request'] - after_request = self.hooks['after_request'] - def wrapper(*a, **ka): - for hook in before_request: hook() - rv = callback(*a, **ka) - for hook in after_request[::-1]: hook() - return rv - return wrapper - - - -class TypeFilterPlugin(object): - def __init__(self): - self.filter = [] - self.app = None - - def setup(self, app): - self.app = app - - def add(self, ftype, func): - if not isinstance(ftype, type): - raise TypeError("Expected type object, got %s" % type(ftype)) - self.filter = [(t, f) for (t, f) in self.filter if t != ftype] - self.filter.append((ftype, func)) - if len(self.filter) == 1 and self.app: self.app.reset() + def trigger(self, name, *a, **ka): + ''' Trigger a hook and return a list of results. ''' + hooks = self.hooks[name] + if ka.pop('reversed', False): hooks = hooks[::-1] + return [hook(*a, **ka) for hook in hooks] def apply(self, callback, context): - filter = self.filter - if not filter: return callback + if self._empty(): return callback def wrapper(*a, **ka): + self.trigger('before_request') rv = callback(*a, **ka) - for testtype, filterfunc in filter: - if isinstance(rv, testtype): - rv = filterfunc(rv) + self.trigger('after_request', reversed=True) return rv return wrapper @@ -1225,14 +1511,15 @@ class TemplatePlugin(object): element must be a dict with additional options (e.g. `template_engine`) or default variables for the template. ''' name = 'template' + api = 2 - def apply(self, callback, context): - conf = context['config'].get('template') + def apply(self, callback, route): + conf = route.config.get('template') if isinstance(conf, (tuple, list)) and len(conf) == 2: return view(conf[0], **conf[1])(callback) - elif isinstance(conf, str) and 'template_opts' in context['config']: + elif isinstance(conf, str) and 'template_opts' in route.config: depr('The `template_opts` parameter is deprecated.') #0.9 - return view(conf, **context['config']['template_opts'])(callback) + return view(conf, **route.config['template_opts'])(callback) elif isinstance(conf, str): return view(conf)(callback) else: @@ -1246,7 +1533,7 @@ class _ImportRedirect(object): self.name = name self.impmask = impmask self.module = sys.modules.setdefault(name, imp.new_module(name)) - self.module.__dict__.update({'__file__': '<virtual>', '__path__': [], + self.module.__dict__.update({'__file__': __file__, '__path__': [], '__all__': [], '__loader__': self}) sys.meta_path.append(self) @@ -1277,53 +1564,115 @@ class _ImportRedirect(object): class MultiDict(DictMixin): - """ A dict that remembers old values for each key """ - # collections.MutableMapping would be better for Python >= 2.6 - def __init__(self, *a, **k): - self.dict = dict() - for k, v in dict(*a, **k).iteritems(): - self[k] = v + """ This dict stores multiple values per key, but behaves exactly like a + normal dict in that it returns only the newest value for any given key. + There are special methods available to access the full list of values. + """ + def __init__(self, *a, **k): + self.dict = dict((k, [v]) for k, v in dict(*a, **k).iteritems()) def __len__(self): return len(self.dict) def __iter__(self): return iter(self.dict) def __contains__(self, key): return key in self.dict def __delitem__(self, key): del self.dict[key] - def keys(self): return self.dict.keys() - def __getitem__(self, key): return self.get(key, KeyError, -1) + def __getitem__(self, key): return self.dict[key][-1] def __setitem__(self, key, value): self.append(key, value) - - def append(self, key, value): self.dict.setdefault(key, []).append(value) - def replace(self, key, value): self.dict[key] = [value] - def getall(self, key): return self.dict.get(key) or [] - - def get(self, key, default=None, index=-1): - if key not in self.dict and default != KeyError: - return [default][index] - return self.dict[key][index] - + def iterkeys(self): return self.dict.iterkeys() + def itervalues(self): return (v[-1] for v in self.dict.itervalues()) + def iteritems(self): return ((k, v[-1]) for (k, v) in self.dict.iteritems()) def iterallitems(self): for key, values in self.dict.iteritems(): for value in values: yield key, value + # 2to3 is not able to fix these automatically. + keys = iterkeys if py3k else lambda self: list(self.iterkeys()) + values = itervalues if py3k else lambda self: list(self.itervalues()) + items = iteritems if py3k else lambda self: list(self.iteritems()) + allitems = iterallitems if py3k else lambda self: list(self.iterallitems()) + + def get(self, key, default=None, index=-1, type=None): + ''' Return the most recent value for a key. + + :param default: The default value to be returned if the key is not + present or the type conversion fails. + :param index: An index for the list of available values. + :param type: If defined, this callable is used to cast the value + into a specific type. Exception are suppressed and result in + the default value to be returned. + ''' + try: + val = self.dict[key][index] + return type(val) if type else val + except Exception, e: + pass + return default -class HeaderDict(MultiDict): - """ Same as :class:`MultiDict`, but title()s the keys and overwrites. """ - def __contains__(self, key): - return MultiDict.__contains__(self, self.httpkey(key)) - def __getitem__(self, key): - return MultiDict.__getitem__(self, self.httpkey(key)) - def __delitem__(self, key): - return MultiDict.__delitem__(self, self.httpkey(key)) - def __setitem__(self, key, value): self.replace(key, value) - def get(self, key, default=None, index=-1): - return MultiDict.get(self, self.httpkey(key), default, index) def append(self, key, value): - return MultiDict.append(self, self.httpkey(key), str(value)) + ''' Add a new value to the list of values for this key. ''' + self.dict.setdefault(key, []).append(value) + def replace(self, key, value): - return MultiDict.replace(self, self.httpkey(key), str(value)) - def getall(self, key): return MultiDict.getall(self, self.httpkey(key)) - def httpkey(self, key): return str(key).replace('_','-').title() + ''' Replace the list of values with a single value. ''' + self.dict[key] = [value] + + def getall(self, key): + ''' Return a (possibly empty) list of values for a key. ''' + return self.dict.get(key) or [] + + #: Aliases for WTForms to mimic other multi-dict APIs (Django) + getone = get + getlist = getall + + + +class FormsDict(MultiDict): + ''' This :class:`MultiDict` subclass is used to store request form data. + Additionally to the normal dict-like item access methods (which return + unmodified data as native strings), this container also supports + attribute-like access to its values. Attribues are automatiically de- or + recoded to match :attr:`input_encoding` (default: 'utf8'). Missing + attributes default to an empty string. ''' + + #: Encoding used for attribute values. + input_encoding = 'utf8' + + def getunicode(self, name, default=None, encoding=None): + value, enc = self.get(name, default), encoding or self.input_encoding + try: + if isinstance(value, bytes): # Python 2 WSGI + return value.decode(enc) + elif isinstance(value, unicode): # Python 3 WSGI + return value.encode('latin1').decode(enc) + return value + except UnicodeError, e: + return default + + def __getattr__(self, name): return self.getunicode(name, default=u'') + + +class HeaderDict(MultiDict): + """ A case-insensitive version of :class:`MultiDict` that defaults to + replace the old value instead of appending it. """ + + def __init__(self, *a, **ka): + self.dict = {} + if a or ka: self.update(*a, **ka) + + def __contains__(self, key): return _hkey(key) in self.dict + def __delitem__(self, key): del self.dict[_hkey(key)] + def __getitem__(self, key): return self.dict[_hkey(key)][-1] + def __setitem__(self, key, value): self.dict[_hkey(key)] = [str(value)] + def append(self, key, value): + self.dict.setdefault(_hkey(key), []).append(str(value)) + def replace(self, key, value): self.dict[_hkey(key)] = [str(value)] + def getall(self, key): return self.dict.get(_hkey(key)) or [] + def get(self, key, default=None, index=-1): + return MultiDict.get(self, _hkey(key), default, index) + def filter(self, names): + for name in map(_hkey, names): + if name in self.dict: + del self.dict[name] class WSGIHeaderDict(DictMixin): @@ -1370,11 +1719,44 @@ class WSGIHeaderDict(DictMixin): elif key in self.cgikeys: yield key.replace('_', '-').title() - def keys(self): return list(self) - def __len__(self): return len(list(self)) + def keys(self): return [x for x in self] + def __len__(self): return len(self.keys()) def __contains__(self, key): return self._ekey(key) in self.environ +class ConfigDict(dict): + ''' A dict-subclass with some extras: You can access keys like attributes. + Uppercase attributes create new ConfigDicts and act as name-spaces. + Other missing attributes return None. Calling a ConfigDict updates its + values and returns itself. + + >>> cfg = ConfigDict() + >>> cfg.Namespace.value = 5 + >>> cfg.OtherNamespace(a=1, b=2) + >>> cfg + {'Namespace': {'value': 5}, 'OtherNamespace': {'a': 1, 'b': 2}} + ''' + + def __getattr__(self, key): + if key not in self and key[0].isupper(): + self[key] = ConfigDict() + return self.get(key) + + def __setattr__(self, key, value): + if hasattr(dict, key): + raise AttributeError('Read-only attribute.') + if key in self and self[key] and isinstance(self[key], ConfigDict): + raise AttributeError('Non-empty namespace attribute.') + self[key] = value + + def __delattr__(self, key): + if key in self: del self[key] + + def __call__(self, *a, **ka): + for key, value in dict(*a, **ka).iteritems(): setattr(self, key, value) + return self + + class AppStack(list): """ A stack-like list. Calling it returns the head of the stack. """ @@ -1414,30 +1796,21 @@ class WSGIFileWrapper(object): ############################################################################### -def dict2json(d): - depr('JSONPlugin is the preferred way to return JSON.') #0.9 - response.content_type = 'application/json' - return json_dumps(d) - - def abort(code=500, text='Unknown Error: Application stopped.'): """ Aborts execution and causes a HTTP error. """ raise HTTPError(code, text) -def redirect(url, code=303): - """ Aborts execution and causes a 303 redirect. """ +def redirect(url, code=None): + """ Aborts execution and causes a 303 or 302 redirect, depending on + the HTTP protocol version. """ + if code is None: + code = 303 if request.get('SERVER_PROTOCOL') == "HTTP/1.1" else 302 location = urljoin(request.url, url) raise HTTPResponse("", status=code, header=dict(Location=location)) -def send_file(*a, **k): #BC 0.6.4 - """ Raises the output of static_file(). (deprecated) """ - depr("Use 'raise static_file()' instead of 'send_file()'.") - raise static_file(*a, **k) - - -def static_file(filename, root, mimetype='auto', guessmime=True, download=False): +def static_file(filename, root, mimetype='auto', download=False): """ Open a file in a safe way and return :exc:`HTTPResponse` with status code 200, 305, 401 or 404. Set Content-Type, Content-Encoding, Content-Length and Last-Modified header. Obey If-Modified-Since header @@ -1454,9 +1827,6 @@ def static_file(filename, root, mimetype='auto', guessmime=True, download=False) if not os.access(filename, os.R_OK): return HTTPError(403, "You do not have permission to access this file.") - if not guessmime: #0.9 - if mimetype == 'auto': mimetype = 'text/plain' - depr("To disable mime-type guessing, specify a type explicitly.") if mimetype == 'auto': mimetype, encoding = mimetypes.guess_type(filename) if mimetype: header['Content-Type'] = mimetype @@ -1514,9 +1884,10 @@ def parse_auth(header): try: method, data = header.split(None, 1) if method.lower() == 'basic': - name, pwd = base64.b64decode(data).split(':', 1) - return name, pwd - except (KeyError, ValueError, TypeError): + #TODO: Add 2to3 save base64[encode/decode] functions. + user, pwd = touni(base64.b64decode(tob(data))).split(':',1) + return user, pwd + except (KeyError, ValueError): return None @@ -1529,7 +1900,7 @@ def _lscmp(a, b): def cookie_encode(data, key): ''' Encode and sign a pickle-able object. Return a (byte) string ''' msg = base64.b64encode(pickle.dumps(data, -1)) - sig = base64.b64encode(hmac.new(key, msg).digest()) + sig = base64.b64encode(hmac.new(tob(key), msg).digest()) return tob('!') + sig + tob('?') + msg @@ -1538,7 +1909,7 @@ def cookie_decode(data, key): data = tob(data) if cookie_is_encoded(data): sig, msg = data.split(tob('?'), 1) - if _lscmp(sig[1:], base64.b64encode(hmac.new(key, msg).digest())): + if _lscmp(sig[1:], base64.b64encode(hmac.new(tob(key), msg).digest())): return pickle.loads(base64.b64decode(msg)) return None @@ -1548,6 +1919,18 @@ def cookie_is_encoded(data): return bool(data.startswith(tob('!')) and tob('?') in data) +def html_escape(string): + ''' Escape HTML special characters ``&<>`` and quotes ``'"``. ''' + return string.replace('&','&').replace('<','<').replace('>','>')\ + .replace('"','"').replace("'",''') + + +def html_quote(string): + ''' Escape and quote a string to be used as an HTTP attribute.''' + return '"%s"' % html_escape(string).replace('\n','%#10;')\ + .replace('\r',' ').replace('\t','	') + + def yieldroutes(func): """ Return a generator for routes that match the signature (name, args) of the func parameter. This may yield more than one route if the function @@ -1600,17 +1983,15 @@ def path_shift(script_name, path_info, shift=1): return new_script_name, new_path_info - -# Decorators -#TODO: Replace default_app() with app() - def validate(**vkargs): """ Validates and manipulates keyword arguments by user defined callables. Handles ValueError and missing arguments by raising HTTPError(403). """ + depr('Use route wildcard filters instead.') def decorator(func): - def wrapper(**kargs): + @functools.wraps(func) + def wrapper(*args, **kargs): for key, value in vkargs.iteritems(): if key not in kargs: abort(403, 'Missing parameter: %s' % key) @@ -1618,7 +1999,7 @@ def validate(**vkargs): kargs[key] = value(kargs[key]) except ValueError: abort(403, 'Wrong parameter format for: %s' % key) - return func(**kargs) + return func(*args, **kargs) return wrapper return decorator @@ -1652,11 +2033,6 @@ url = make_default_app_wrapper('get_url') del name -def default(): - depr("The default() decorator is deprecated. Use @error(404) instead.") - return error(404) - - @@ -1685,15 +2061,17 @@ class CGIServer(ServerAdapter): quiet = True def run(self, handler): # pragma: no cover from wsgiref.handlers import CGIHandler - CGIHandler().run(handler) # Just ignore host and port here + def fixed_environ(environ, start_response): + environ.setdefault('PATH_INFO', '') + return handler(environ, start_response) + CGIHandler().run(fixed_environ) class FlupFCGIServer(ServerAdapter): def run(self, handler): # pragma: no cover import flup.server.fcgi - kwargs = {'bindAddress':(self.host, self.port)} - kwargs.update(self.options) # allow to override bindAddress and others - flup.server.fcgi.WSGIServer(handler, **kwargs).run() + self.options.setdefault('bindAddress', (self.host, self.port)) + flup.server.fcgi.WSGIServer(handler, **self.options).run() class WSGIRefServer(ServerAdapter): @@ -1711,7 +2089,10 @@ class CherryPyServer(ServerAdapter): def run(self, handler): # pragma: no cover from cherrypy import wsgiserver server = wsgiserver.CherryPyWSGIServer((self.host, self.port), handler) - server.start() + try: + server.start() + finally: + server.stop() class PasteServer(ServerAdapter): @@ -1723,6 +2104,7 @@ class PasteServer(ServerAdapter): httpserver.serve(handler, host=self.host, port=str(self.port), **self.options) + class MeinheldServer(ServerAdapter): def run(self, handler): from meinheld import server @@ -1755,9 +2137,7 @@ class FapwsServer(ServerAdapter): class TornadoServer(ServerAdapter): """ The super hyped asynchronous server by facebook. Untested. """ def run(self, handler): # pragma: no cover - import tornado.wsgi - import tornado.httpserver - import tornado.ioloop + import tornado.wsgi, tornado.httpserver, tornado.ioloop container = tornado.wsgi.WSGIContainer(handler) server = tornado.httpserver.HTTPServer(container) server.listen(port=self.port) @@ -1807,22 +2187,29 @@ class GeventServer(ServerAdapter): issues: No streaming, no pipelining, no SSL. """ def run(self, handler): - from gevent import wsgi as wsgi_fast, pywsgi as wsgi, monkey + from gevent import wsgi as wsgi_fast, pywsgi, monkey, local if self.options.get('monkey', True): - monkey.patch_all() - if self.options.get('fast', False): - wsgi = wsgi_fast + if not threading.local is local.local: monkey.patch_all() + wsgi = wsgi_fast if self.options.get('fast') else pywsgi wsgi.WSGIServer((self.host, self.port), handler).serve_forever() class GunicornServer(ServerAdapter): - """ Untested. """ + """ Untested. See http://gunicorn.org/configure.html for options. """ def run(self, handler): - from gunicorn.arbiter import Arbiter - from gunicorn.config import Config - handler.cfg = Config({'bind': "%s:%d" % (self.host, self.port), 'workers': 4}) - arbiter = Arbiter(handler) - arbiter.run() + from gunicorn.app.base import Application + + config = {'bind': "%s:%d" % (self.host, int(self.port))} + config.update(self.options) + + class GunicornApplication(Application): + def init(self, parser, opts, args): + return config + + def load(self): + return handler + + GunicornApplication().run() class EventletServer(ServerAdapter): @@ -1833,8 +2220,7 @@ class EventletServer(ServerAdapter): class RocketServer(ServerAdapter): - """ Untested. As requested in issue 63 - https://github.com/defnull/bottle/issues/#issue/63 """ + """ Untested. """ def run(self, handler): from rocket import Rocket server = Rocket((self.host, self.port), 'wsgi', { 'wsgi_app' : handler }) @@ -1842,7 +2228,7 @@ class RocketServer(ServerAdapter): class BjoernServer(ServerAdapter): - """ Screamingly fast server written in C: https://github.com/jonashaag/bjoern """ + """ Fast server written in C: https://github.com/jonashaag/bjoern """ def run(self, handler): from bjoern import run run(handler, self.host, self.port) @@ -1858,7 +2244,6 @@ class AutoServer(ServerAdapter): except ImportError: pass - server_names = { 'cgi': CGIServer, 'flup': FlupFCGIServer, @@ -1889,57 +2274,41 @@ server_names = { ############################################################################### -def _load(target, **vars): - """ Fetch something from a module. The exact behaviour depends on the the - target string: - - If the target is a valid python import path (e.g. `package.module`), - the rightmost part is returned as a module object. - If the target contains a colon (e.g. `package.module:var`) the module - variable specified after the colon is returned. - If the part after the colon contains any non-alphanumeric characters - (e.g. `package.module:func(var)`) the result of the expression - is returned. The expression has access to keyword arguments supplied - to this function. +def load(target, **namespace): + """ Import a module or fetch an object from a module. - Example:: - >>> _load('bottle') - <module 'bottle' from 'bottle.py'> - >>> _load('bottle:Bottle') - <class 'bottle.Bottle'> - >>> _load('bottle:cookie_encode(v, secret)', v='foo', secret='bar') - '!F+hN4dQxaDJ4QxxaZ+Z3jw==?gAJVA2Zvb3EBLg==' + * ``package.module`` returns `module` as a module object. + * ``pack.mod:name`` returns the module variable `name` from `pack.mod`. + * ``pack.mod:func()`` calls `pack.mod.func()` and returns the result. + The last form accepts not only function calls, but any type of + expression. Keyword arguments passed to this function are available as + local variables. Example: ``import_string('re:compile(x)', x='[a-z]')`` """ module, target = target.split(":", 1) if ':' in target else (target, None) - if module not in sys.modules: - __import__(module) - if not target: - return sys.modules[module] - if target.isalnum(): - return getattr(sys.modules[module], target) + if module not in sys.modules: __import__(module) + if not target: return sys.modules[module] + if target.isalnum(): return getattr(sys.modules[module], target) package_name = module.split('.')[0] - vars[package_name] = sys.modules[package_name] - return eval('%s.%s' % (module, target), vars) + namespace[package_name] = sys.modules[package_name] + return eval('%s.%s' % (module, target), namespace) def load_app(target): - """ Load a bottle application based on a target string and return the - application object. - - If the target is an import path (e.g. package.module), the application - stack is used to isolate the routes defined in that module. - If the target contains a colon (e.g. package.module:myapp) the - module variable specified after the colon is returned instead. - """ - tmp = app.push() # Create a new "default application" - rv = _load(target) # Import the target module - app.remove(tmp) # Remove the temporary added default application - return rv if isinstance(rv, Bottle) else tmp - + """ Load a bottle application from a module and make sure that the import + does not affect the current default application, but returns a separate + application object. See :func:`load` for the target parameter. """ + global NORUN; NORUN, nr_old = True, NORUN + try: + tmp = default_app.push() # Create a new "default application" + rv = load(target) # Import the target module + return rv if callable(rv) else tmp + finally: + default_app.remove(tmp) # Remove the temporary added default application + NORUN = nr_old def run(app=None, server='wsgiref', host='127.0.0.1', port=8080, - interval=1, reloader=False, quiet=False, **kargs): + interval=1, reloader=False, quiet=False, plugins=None, **kargs): """ Start a server instance. This method blocks until the server terminates. :param app: WSGI application or target string supported by @@ -1956,114 +2325,115 @@ def run(app=None, server='wsgiref', host='127.0.0.1', port=8080, :param quiet: Suppress output to stdout and stderr? (default: False) :param options: Options passed to the server adapter. """ - app = app or default_app() - if isinstance(app, basestring): - app = load_app(app) - if isinstance(server, basestring): - server = server_names.get(server) - if isinstance(server, type): - server = server(host=host, port=port, **kargs) - if not isinstance(server, ServerAdapter): - raise RuntimeError("Server must be a subclass of ServerAdapter") - server.quiet = server.quiet or quiet - if not server.quiet and not os.environ.get('BOTTLE_CHILD'): - print "Bottle server starting up (using %s)..." % repr(server) - print "Listening on http://%s:%d/" % (server.host, server.port) - print "Use Ctrl-C to quit." - print + if NORUN: return + if reloader and not os.environ.get('BOTTLE_CHILD'): + try: + fd, lockfile = tempfile.mkstemp(prefix='bottle.', suffix='.lock') + os.close(fd) # We only need this file to exist. We never write to it + while os.path.exists(lockfile): + args = [sys.executable] + sys.argv + environ = os.environ.copy() + environ['BOTTLE_CHILD'] = 'true' + environ['BOTTLE_LOCKFILE'] = lockfile + p = subprocess.Popen(args, env=environ) + while p.poll() is None: # Busy wait... + os.utime(lockfile, None) # I am alive! + time.sleep(interval) + if p.poll() != 3: + if os.path.exists(lockfile): os.unlink(lockfile) + sys.exit(p.poll()) + except KeyboardInterrupt: + pass + finally: + if os.path.exists(lockfile): + os.unlink(lockfile) + return + + stderr = sys.stderr.write + try: + app = app or default_app() + if isinstance(app, basestring): + app = load_app(app) + if not callable(app): + raise ValueError("Application is not callable: %r" % app) + + for plugin in plugins or []: + app.install(plugin) + + if server in server_names: + server = server_names.get(server) + if isinstance(server, basestring): + server = load(server) + if isinstance(server, type): + server = server(host=host, port=port, **kargs) + if not isinstance(server, ServerAdapter): + raise ValueError("Unknown or unsupported server: %r" % server) + + server.quiet = server.quiet or quiet + if not server.quiet: + stderr("Bottle server starting up (using %s)...\n" % repr(server)) + stderr("Listening on http://%s:%d/\n" % (server.host, server.port)) + stderr("Hit Ctrl-C to quit.\n\n") + if reloader: - interval = min(interval, 1) - if os.environ.get('BOTTLE_CHILD'): - _reloader_child(server, app, interval) - else: - _reloader_observer(server, app, interval) + lockfile = os.environ.get('BOTTLE_LOCKFILE') + bgcheck = FileCheckerThread(lockfile, interval) + with bgcheck: + server.run(app) + if bgcheck.status == 'reload': + sys.exit(3) else: server.run(app) except KeyboardInterrupt: pass - if not server.quiet and not os.environ.get('BOTTLE_CHILD'): - print "Shutting down..." + except (SyntaxError, ImportError): + if not reloader: raise + if not getattr(server, 'quiet', False): print_exc() + sys.exit(3) + finally: + if not getattr(server, 'quiet', False): stderr('Shutdown...\n') class FileCheckerThread(threading.Thread): - ''' Thread that periodically checks for changed module files. ''' + ''' Interrupt main-thread as soon as a changed module file is detected, + the lockfile gets deleted or gets to old. ''' def __init__(self, lockfile, interval): threading.Thread.__init__(self) self.lockfile, self.interval = lockfile, interval - #1: lockfile to old; 2: lockfile missing - #3: module file changed; 5: external exit - self.status = 0 + #: Is one of 'reload', 'error' or 'exit' + self.status = None def run(self): exists = os.path.exists mtime = lambda path: os.stat(path).st_mtime files = dict() + for module in sys.modules.values(): path = getattr(module, '__file__', '') if path[-4:] in ('.pyo', '.pyc'): path = path[:-1] if path and exists(path): files[path] = mtime(path) + while not self.status: + if not exists(self.lockfile)\ + or mtime(self.lockfile) < time.time() - self.interval - 5: + self.status = 'error' + thread.interrupt_main() for path, lmtime in files.iteritems(): if not exists(path) or mtime(path) > lmtime: - self.status = 3 - if not exists(self.lockfile): - self.status = 2 - elif mtime(self.lockfile) < time.time() - self.interval - 5: - self.status = 1 - if not self.status: - time.sleep(self.interval) - if self.status != 5: - thread.interrupt_main() - - -def _reloader_child(server, app, interval): - ''' Start the server and check for modified files in a background thread. - As soon as an update is detected, KeyboardInterrupt is thrown in - the main thread to exit the server loop. The process exists with status - code 3 to request a reload by the observer process. If the lockfile - is not modified in 2*interval second or missing, we assume that the - observer process died and exit with status code 1 or 2. - ''' - lockfile = os.environ.get('BOTTLE_LOCKFILE') - bgcheck = FileCheckerThread(lockfile, interval) - try: - bgcheck.start() - server.run(app) - except KeyboardInterrupt: - pass - bgcheck.status, status = 5, bgcheck.status - bgcheck.join() # bgcheck.status == 5 --> silent exit - if status: sys.exit(status) - - -def _reloader_observer(server, app, interval): - ''' Start a child process with identical commandline arguments and restart - it as long as it exists with status code 3. Also create a lockfile and - touch it (update mtime) every interval seconds. - ''' - fd, lockfile = tempfile.mkstemp(prefix='bottle-reloader.', suffix='.lock') - os.close(fd) # We only need this file to exist. We never write to it - try: - while os.path.exists(lockfile): - args = [sys.executable] + sys.argv - environ = os.environ.copy() - environ['BOTTLE_CHILD'] = 'true' - environ['BOTTLE_LOCKFILE'] = lockfile - p = subprocess.Popen(args, env=environ) - while p.poll() is None: # Busy wait... - os.utime(lockfile, None) # I am alive! - time.sleep(interval) - if p.poll() != 3: - if os.path.exists(lockfile): os.unlink(lockfile) - sys.exit(p.poll()) - elif not server.quiet: - print "Reloading server..." - except KeyboardInterrupt: - pass - if os.path.exists(lockfile): os.unlink(lockfile) - + self.status = 'reload' + thread.interrupt_main() + break + time.sleep(self.interval) + + def __enter__(self): + self.start() + + def __exit__(self, exc_type, exc_val, exc_tb): + if not self.status: self.status = 'exit' # silent exit + self.join() + return issubclass(exc_type, KeyboardInterrupt) @@ -2081,7 +2451,7 @@ class TemplateError(HTTPError): class BaseTemplate(object): """ Base class and minimal API for template adapters """ - extentions = ['tpl','html','thtml','stpl'] + extensions = ['tpl','html','thtml','stpl'] settings = {} #used in prepare() defaults = {} #used in render() @@ -2120,7 +2490,7 @@ class BaseTemplate(object): fname = os.path.join(spath, name) if os.path.isfile(fname): return fname - for ext in cls.extentions: + for ext in cls.extensions: if os.path.isfile('%s.%s' % (fname, ext)): return '%s.%s' % (fname, ext) @@ -2128,6 +2498,7 @@ class BaseTemplate(object): def global_config(cls, key, *args): ''' This reads or sets the global settings stored in class.settings. ''' if args: + cls.settings = cls.settings.copy() # Make settings local to class cls.settings[key] = args[0] else: return cls.settings[key] @@ -2185,7 +2556,7 @@ class CheetahTemplate(BaseTemplate): self.context.vars.update(kwargs) out = str(self.tpl) self.context.vars.clear() - return [out] + return out class Jinja2Template(BaseTemplate): @@ -2206,7 +2577,7 @@ class Jinja2Template(BaseTemplate): for dictarg in args: kwargs.update(dictarg) _defaults = self.defaults.copy() _defaults.update(kwargs) - return self.tpl.render(**_defaults).encode("utf-8") + return self.tpl.render(**_defaults) def loader(self, name): fname = self.search(name, self.lookup) @@ -2228,7 +2599,6 @@ class SimpleTALTemplate(BaseTemplate): def render(self, *args, **kwargs): from simpletal import simpleTALES - from StringIO import StringIO for dictarg in args: kwargs.update(dictarg) # TODO: maybe reuse a context instead of always creating one context = simpleTALES.Context() @@ -2242,7 +2612,8 @@ class SimpleTALTemplate(BaseTemplate): class SimpleTemplate(BaseTemplate): - blocks = ('if','elif','else','try','except','finally','for','while','with','def','class') + blocks = ('if', 'elif', 'else', 'try', 'except', 'finally', 'for', 'while', + 'with', 'def', 'class') dedent_blocks = ('elif', 'else', 'except', 'finally') @lazy_attribute @@ -2258,7 +2629,7 @@ class SimpleTemplate(BaseTemplate): |\#.* # Comments )''', re.VERBOSE) - def prepare(self, escape_func=cgi.escape, noescape=False): + def prepare(self, escape_func=html_escape, noescape=False, **kwargs): self.cache = {} enc = self.encoding self._str = lambda x: touni(x, enc) @@ -2285,7 +2656,7 @@ class SimpleTemplate(BaseTemplate): ptrbuffer = [] # Buffer for printable strings and token tuple instances codebuffer = [] # Buffer for generated python code multiline = dedent = oneline = False - template = self.source if self.source else open(self.filename).read() + template = self.source or open(self.filename, 'rb').read() def yield_tokens(line): for i, part in enumerate(re.split(r'\{\{(.*?)\}\}', line)): @@ -2327,7 +2698,7 @@ class SimpleTemplate(BaseTemplate): line = line.split('%',1)[1].lstrip() # Full line following the % cline = self.split_comment(line).strip() cmd = re.split(r'[^a-zA-Z0-9_]', cline)[0] - flush() ##encodig (TODO: why?) + flush() # You are actually reading this? Good luck, it's a mess :) if cmd in self.blocks or multiline: cmd = multiline or cmd dedent = cmd in self.dedent_blocks # "else:" @@ -2374,15 +2745,15 @@ class SimpleTemplate(BaseTemplate): env = self.defaults.copy() env.update({'_stdout': _stdout, '_printlist': _stdout.extend, '_include': self.subtemplate, '_str': self._str, - '_escape': self._escape}) + '_escape': self._escape, 'get': env.get, + 'setdefault': env.setdefault, 'defined': env.__contains__}) env.update(kwargs) eval(self.co, env) if '_rebase' in env: subtpl, rargs = env['_rebase'] - subtpl = self.__class__(name=subtpl, lookup=self.lookup) rargs['_base'] = _stdout[:] #copy stdout del _stdout[:] # clear stdout - return subtpl.execute(_stdout, rargs) + return self.subtemplate(subtpl,_stdout,rargs) return env def render(self, *args, **kwargs): @@ -2463,11 +2834,16 @@ simpletal_view = functools.partial(view, template_adapter=SimpleTALTemplate) TEMPLATE_PATH = ['./', './views/'] TEMPLATES = {} DEBUG = False -MEMFILE_MAX = 1024*100 +NORUN = False # If set, run() does nothing. Used by load_app() #: A dict to map HTTP status codes (e.g. 404) to phrases (e.g. 'Not Found') HTTP_CODES = httplib.responses HTTP_CODES[418] = "I'm a teapot" # RFC 2324 +HTTP_CODES[428] = "Precondition Required" +HTTP_CODES[429] = "Too Many Requests" +HTTP_CODES[431] = "Request Header Fields Too Large" +HTTP_CODES[511] = "Network Authentication Required" +_HTTP_STATUS_LINES = dict((k, '%d %s'%(k,v)) for (k,v) in HTTP_CODES.iteritems()) #: The default template used for error pages. Override with @error() ERROR_PAGE_TEMPLATE = """ @@ -2480,13 +2856,15 @@ ERROR_PAGE_TEMPLATE = """ <title>Error {{e.status}}: {{status_name}}</title> <style type="text/css"> html {background-color: #eee; font-family: sans;} - body {background-color: #fff; border: 1px solid #ddd; padding: 15px; margin: 15px;} + body {background-color: #fff; border: 1px solid #ddd; + padding: 15px; margin: 15px;} pre {background-color: #eee; border: 1px solid #ddd; padding: 5px;} </style> </head> <body> <h1>Error {{e.status}}: {{status_name}}</h1> - <p>Sorry, the requested URL <tt>{{repr(request.url)}}</tt> caused an error:</p> + <p>Sorry, the requested URL <tt>{{repr(request.url)}}</tt> + caused an error:</p> <pre>{{e.output}}</pre> %if DEBUG and e.exception: <h2>Exception:</h2> @@ -2499,17 +2877,18 @@ ERROR_PAGE_TEMPLATE = """ </body> </html> %except ImportError: - <b>ImportError:</b> Could not generate the error page. Please add bottle to sys.path + <b>ImportError:</b> Could not generate the error page. Please add bottle to + the import path. %end """ -#: A thread-save instance of :class:`Request` representing the `current` request. +#: A thread-safe instance of :class:`Request` representing the `current` request. request = Request() -#: A thread-save instance of :class:`Response` used to build the HTTP response. +#: A thread-safe instance of :class:`Response` used to build the HTTP response. response = Response() -#: A thread-save namepsace. Not used by Bottle. +#: A thread-safe namespace. Not used by Bottle. local = threading.local() # Initialize app stack (create first empty Bottle app) @@ -2520,3 +2899,28 @@ app.push() #: A virtual package that redirects import statements. #: Example: ``import bottle.ext.sqlite`` actually imports `bottle_sqlite`. ext = _ImportRedirect(__name__+'.ext', 'bottle_%s').module + +if __name__ == '__main__': + opt, args, parser = _cmd_options, _cmd_args, _cmd_parser + if opt.version: + print 'Bottle', __version__; sys.exit(0) + if not args: + parser.print_help() + print '\nError: No application specified.\n' + sys.exit(1) + + try: + sys.path.insert(0, '.') + sys.modules.setdefault('bottle', sys.modules['__main__']) + except (AttributeError, ImportError), e: + parser.error(e.args[0]) + + if opt.bind and ':' in opt.bind: + host, port = opt.bind.rsplit(':', 1) + else: + host, port = (opt.bind or 'localhost'), 8080 + + debug(opt.debug) + run(args[0], host=host, port=port, server=opt.server, reloader=opt.reload, plugins=opt.plugin) + +# THE END diff --git a/module/remote/thriftbackend/pyload.thrift b/module/remote/thriftbackend/pyload.thrift index 5d828854c..1542e651a 100644 --- a/module/remote/thriftbackend/pyload.thrift +++ b/module/remote/thriftbackend/pyload.thrift @@ -4,6 +4,7 @@ typedef i32 FileID typedef i32 PackageID typedef i32 TaskID typedef i32 ResultID +typedef i32 InteractionID typedef list<string> LinkList typedef string PluginName typedef byte Progress @@ -38,6 +39,31 @@ enum ElementType { File } +// types for user interaction +// some may only be place holder currently not supported +// also all input - output combination are not reasonable, see InteractionManager for further info +enum Input { + NONE, + TEXT, + TEXTBOX, + PASSWORD, + BOOL, // confirm like, yes or no dialog + CLICK, // for positional captchas + CHOICE, // choice from list + MULTIPLE, // multiple choice from list of elements + LIST, // arbitary list of elements + TABLE // table like data structure +} +// more can be implemented by need + +// this describes the type of the outgoing interaction +// ensure they can be logcial or'ed +enum Output { + CAPTCHA = 1, + QUESTION = 2, + NOTIFICATION = 4, +} + struct DownloadInfo { 1: FileID fid, 2: string name, @@ -111,6 +137,18 @@ struct PackageData { 13: optional list<FileID> fids } +struct InteractionTask { + 1: InteractionID iid, + 2: Input input, + 3: list<string> structure, + 4: list<string> preset, + 5: Output output, + 6: list<string> data, + 7: string title, + 8: string description, + 9: string plugin, +} + struct CaptchaTask { 1: i16 tid, 2: binary data, @@ -257,13 +295,6 @@ service Pyload { list<PackageID> deleteFinished(), void restartFailed(), - - //captcha - bool isCaptchaWaiting(), - CaptchaTask getCaptchaTask(1: bool exclusive), - string getCaptchaTaskStatus(1: TaskID tid), - void setCaptchaResult(1: TaskID tid, 2: string result), - //events list<EventInfo> getEvents(1: string uuid) @@ -289,8 +320,18 @@ service Pyload { //info // {plugin: {name: value}} map<PluginName, map<string,string>> getAllInfo(), - map<string, string> getInfoByPlugin(1: PluginName plugin) + map<string, string> getInfoByPlugin(1: PluginName plugin), //scheduler + // TODO + + + // User interaction + + //captcha + bool isCaptchaWaiting(), + CaptchaTask getCaptchaTask(1: bool exclusive), + string getCaptchaTaskStatus(1: TaskID tid), + void setCaptchaResult(1: TaskID tid, 2: string result), } diff --git a/module/remote/thriftbackend/thriftgen/pyload/Pyload-remote b/module/remote/thriftbackend/thriftgen/pyload/Pyload-remote index 854b1589e..bfaf5b078 100755 --- a/module/remote/thriftbackend/thriftgen/pyload/Pyload-remote +++ b/module/remote/thriftbackend/thriftgen/pyload/Pyload-remote @@ -1,6 +1,6 @@ #!/usr/bin/env python # -# Autogenerated by Thrift Compiler (0.8.0-dev) +# Autogenerated by Thrift Compiler (0.9.0-dev) # # DO NOT EDIT UNLESS YOU ARE SURE THAT YOU KNOW WHAT YOU ARE DOING # @@ -76,10 +76,6 @@ if len(sys.argv) <= 1 or sys.argv[1] == '--help': print ' void setPackageData(PackageID pid, data)' print ' deleteFinished()' print ' void restartFailed()' - print ' bool isCaptchaWaiting()' - print ' CaptchaTask getCaptchaTask(bool exclusive)' - print ' string getCaptchaTaskStatus(TaskID tid)' - print ' void setCaptchaResult(TaskID tid, string result)' print ' getEvents(string uuid)' print ' getAccounts(bool refresh)' print ' getAccountTypes()' @@ -93,6 +89,10 @@ if len(sys.argv) <= 1 or sys.argv[1] == '--help': print ' string call(ServiceCall info)' print ' getAllInfo()' print ' getInfoByPlugin(PluginName plugin)' + print ' bool isCaptchaWaiting()' + print ' CaptchaTask getCaptchaTask(bool exclusive)' + print ' string getCaptchaTaskStatus(TaskID tid)' + print ' void setCaptchaResult(TaskID tid, string result)' print '' sys.exit(0) @@ -462,30 +462,6 @@ elif cmd == 'restartFailed': sys.exit(1) pp.pprint(client.restartFailed()) -elif cmd == 'isCaptchaWaiting': - if len(args) != 0: - print 'isCaptchaWaiting requires 0 args' - sys.exit(1) - pp.pprint(client.isCaptchaWaiting()) - -elif cmd == 'getCaptchaTask': - if len(args) != 1: - print 'getCaptchaTask requires 1 args' - sys.exit(1) - pp.pprint(client.getCaptchaTask(eval(args[0]),)) - -elif cmd == 'getCaptchaTaskStatus': - if len(args) != 1: - print 'getCaptchaTaskStatus requires 1 args' - sys.exit(1) - pp.pprint(client.getCaptchaTaskStatus(eval(args[0]),)) - -elif cmd == 'setCaptchaResult': - if len(args) != 2: - print 'setCaptchaResult requires 2 args' - sys.exit(1) - pp.pprint(client.setCaptchaResult(eval(args[0]),args[1],)) - elif cmd == 'getEvents': if len(args) != 1: print 'getEvents requires 1 args' @@ -564,6 +540,30 @@ elif cmd == 'getInfoByPlugin': sys.exit(1) pp.pprint(client.getInfoByPlugin(eval(args[0]),)) +elif cmd == 'isCaptchaWaiting': + if len(args) != 0: + print 'isCaptchaWaiting requires 0 args' + sys.exit(1) + pp.pprint(client.isCaptchaWaiting()) + +elif cmd == 'getCaptchaTask': + if len(args) != 1: + print 'getCaptchaTask requires 1 args' + sys.exit(1) + pp.pprint(client.getCaptchaTask(eval(args[0]),)) + +elif cmd == 'getCaptchaTaskStatus': + if len(args) != 1: + print 'getCaptchaTaskStatus requires 1 args' + sys.exit(1) + pp.pprint(client.getCaptchaTaskStatus(eval(args[0]),)) + +elif cmd == 'setCaptchaResult': + if len(args) != 2: + print 'setCaptchaResult requires 2 args' + sys.exit(1) + pp.pprint(client.setCaptchaResult(eval(args[0]),args[1],)) + else: print 'Unrecognized method %s' % cmd sys.exit(1) diff --git a/module/remote/thriftbackend/thriftgen/pyload/Pyload.py b/module/remote/thriftbackend/thriftgen/pyload/Pyload.py index a1bc63f75..78a42f16a 100644 --- a/module/remote/thriftbackend/thriftgen/pyload/Pyload.py +++ b/module/remote/thriftbackend/thriftgen/pyload/Pyload.py @@ -1,5 +1,5 @@ # -# Autogenerated by Thrift Compiler (0.8.0-dev) +# Autogenerated by Thrift Compiler (0.9.0-dev) # # DO NOT EDIT UNLESS YOU ARE SURE THAT YOU KNOW WHAT YOU ARE DOING # @@ -9,7 +9,7 @@ from thrift.Thrift import TType, TMessageType, TException from ttypes import * from thrift.Thrift import TProcessor -from thrift.protocol.TBase import TBase, TExceptionBase, TApplicationException +from thrift.protocol.TBase import TBase, TExceptionBase class Iface(object): @@ -319,31 +319,6 @@ class Iface(object): def restartFailed(self, ): pass - def isCaptchaWaiting(self, ): - pass - - def getCaptchaTask(self, exclusive): - """ - Parameters: - - exclusive - """ - pass - - def getCaptchaTaskStatus(self, tid): - """ - Parameters: - - tid - """ - pass - - def setCaptchaResult(self, tid, result): - """ - Parameters: - - tid - - result - """ - pass - def getEvents(self, uuid): """ Parameters: @@ -426,6 +401,31 @@ class Iface(object): """ pass + def isCaptchaWaiting(self, ): + pass + + def getCaptchaTask(self, exclusive): + """ + Parameters: + - exclusive + """ + pass + + def getCaptchaTaskStatus(self, tid): + """ + Parameters: + - tid + """ + pass + + def setCaptchaResult(self, tid, result): + """ + Parameters: + - tid + - result + """ + pass + class Client(Iface): def __init__(self, iprot, oprot=None): @@ -1919,121 +1919,6 @@ class Client(Iface): self._iprot.readMessageEnd() return - def isCaptchaWaiting(self, ): - self.send_isCaptchaWaiting() - return self.recv_isCaptchaWaiting() - - def send_isCaptchaWaiting(self, ): - self._oprot.writeMessageBegin('isCaptchaWaiting', TMessageType.CALL, self._seqid) - args = isCaptchaWaiting_args() - args.write(self._oprot) - self._oprot.writeMessageEnd() - self._oprot.trans.flush() - - def recv_isCaptchaWaiting(self, ): - (fname, mtype, rseqid) = self._iprot.readMessageBegin() - if mtype == TMessageType.EXCEPTION: - x = TApplicationException() - x.read(self._iprot) - self._iprot.readMessageEnd() - raise x - result = isCaptchaWaiting_result() - result.read(self._iprot) - self._iprot.readMessageEnd() - if result.success is not None: - return result.success - raise TApplicationException(TApplicationException.MISSING_RESULT, "isCaptchaWaiting failed: unknown result"); - - def getCaptchaTask(self, exclusive): - """ - Parameters: - - exclusive - """ - self.send_getCaptchaTask(exclusive) - return self.recv_getCaptchaTask() - - def send_getCaptchaTask(self, exclusive): - self._oprot.writeMessageBegin('getCaptchaTask', TMessageType.CALL, self._seqid) - args = getCaptchaTask_args() - args.exclusive = exclusive - args.write(self._oprot) - self._oprot.writeMessageEnd() - self._oprot.trans.flush() - - def recv_getCaptchaTask(self, ): - (fname, mtype, rseqid) = self._iprot.readMessageBegin() - if mtype == TMessageType.EXCEPTION: - x = TApplicationException() - x.read(self._iprot) - self._iprot.readMessageEnd() - raise x - result = getCaptchaTask_result() - result.read(self._iprot) - self._iprot.readMessageEnd() - if result.success is not None: - return result.success - raise TApplicationException(TApplicationException.MISSING_RESULT, "getCaptchaTask failed: unknown result"); - - def getCaptchaTaskStatus(self, tid): - """ - Parameters: - - tid - """ - self.send_getCaptchaTaskStatus(tid) - return self.recv_getCaptchaTaskStatus() - - def send_getCaptchaTaskStatus(self, tid): - self._oprot.writeMessageBegin('getCaptchaTaskStatus', TMessageType.CALL, self._seqid) - args = getCaptchaTaskStatus_args() - args.tid = tid - args.write(self._oprot) - self._oprot.writeMessageEnd() - self._oprot.trans.flush() - - def recv_getCaptchaTaskStatus(self, ): - (fname, mtype, rseqid) = self._iprot.readMessageBegin() - if mtype == TMessageType.EXCEPTION: - x = TApplicationException() - x.read(self._iprot) - self._iprot.readMessageEnd() - raise x - result = getCaptchaTaskStatus_result() - result.read(self._iprot) - self._iprot.readMessageEnd() - if result.success is not None: - return result.success - raise TApplicationException(TApplicationException.MISSING_RESULT, "getCaptchaTaskStatus failed: unknown result"); - - def setCaptchaResult(self, tid, result): - """ - Parameters: - - tid - - result - """ - self.send_setCaptchaResult(tid, result) - self.recv_setCaptchaResult() - - def send_setCaptchaResult(self, tid, result): - self._oprot.writeMessageBegin('setCaptchaResult', TMessageType.CALL, self._seqid) - args = setCaptchaResult_args() - args.tid = tid - args.result = result - args.write(self._oprot) - self._oprot.writeMessageEnd() - self._oprot.trans.flush() - - def recv_setCaptchaResult(self, ): - (fname, mtype, rseqid) = self._iprot.readMessageBegin() - if mtype == TMessageType.EXCEPTION: - x = TApplicationException() - x.read(self._iprot) - self._iprot.readMessageEnd() - raise x - result = setCaptchaResult_result() - result.read(self._iprot) - self._iprot.readMessageEnd() - return - def getEvents(self, uuid): """ Parameters: @@ -2418,6 +2303,121 @@ class Client(Iface): return result.success raise TApplicationException(TApplicationException.MISSING_RESULT, "getInfoByPlugin failed: unknown result"); + def isCaptchaWaiting(self, ): + self.send_isCaptchaWaiting() + return self.recv_isCaptchaWaiting() + + def send_isCaptchaWaiting(self, ): + self._oprot.writeMessageBegin('isCaptchaWaiting', TMessageType.CALL, self._seqid) + args = isCaptchaWaiting_args() + args.write(self._oprot) + self._oprot.writeMessageEnd() + self._oprot.trans.flush() + + def recv_isCaptchaWaiting(self, ): + (fname, mtype, rseqid) = self._iprot.readMessageBegin() + if mtype == TMessageType.EXCEPTION: + x = TApplicationException() + x.read(self._iprot) + self._iprot.readMessageEnd() + raise x + result = isCaptchaWaiting_result() + result.read(self._iprot) + self._iprot.readMessageEnd() + if result.success is not None: + return result.success + raise TApplicationException(TApplicationException.MISSING_RESULT, "isCaptchaWaiting failed: unknown result"); + + def getCaptchaTask(self, exclusive): + """ + Parameters: + - exclusive + """ + self.send_getCaptchaTask(exclusive) + return self.recv_getCaptchaTask() + + def send_getCaptchaTask(self, exclusive): + self._oprot.writeMessageBegin('getCaptchaTask', TMessageType.CALL, self._seqid) + args = getCaptchaTask_args() + args.exclusive = exclusive + args.write(self._oprot) + self._oprot.writeMessageEnd() + self._oprot.trans.flush() + + def recv_getCaptchaTask(self, ): + (fname, mtype, rseqid) = self._iprot.readMessageBegin() + if mtype == TMessageType.EXCEPTION: + x = TApplicationException() + x.read(self._iprot) + self._iprot.readMessageEnd() + raise x + result = getCaptchaTask_result() + result.read(self._iprot) + self._iprot.readMessageEnd() + if result.success is not None: + return result.success + raise TApplicationException(TApplicationException.MISSING_RESULT, "getCaptchaTask failed: unknown result"); + + def getCaptchaTaskStatus(self, tid): + """ + Parameters: + - tid + """ + self.send_getCaptchaTaskStatus(tid) + return self.recv_getCaptchaTaskStatus() + + def send_getCaptchaTaskStatus(self, tid): + self._oprot.writeMessageBegin('getCaptchaTaskStatus', TMessageType.CALL, self._seqid) + args = getCaptchaTaskStatus_args() + args.tid = tid + args.write(self._oprot) + self._oprot.writeMessageEnd() + self._oprot.trans.flush() + + def recv_getCaptchaTaskStatus(self, ): + (fname, mtype, rseqid) = self._iprot.readMessageBegin() + if mtype == TMessageType.EXCEPTION: + x = TApplicationException() + x.read(self._iprot) + self._iprot.readMessageEnd() + raise x + result = getCaptchaTaskStatus_result() + result.read(self._iprot) + self._iprot.readMessageEnd() + if result.success is not None: + return result.success + raise TApplicationException(TApplicationException.MISSING_RESULT, "getCaptchaTaskStatus failed: unknown result"); + + def setCaptchaResult(self, tid, result): + """ + Parameters: + - tid + - result + """ + self.send_setCaptchaResult(tid, result) + self.recv_setCaptchaResult() + + def send_setCaptchaResult(self, tid, result): + self._oprot.writeMessageBegin('setCaptchaResult', TMessageType.CALL, self._seqid) + args = setCaptchaResult_args() + args.tid = tid + args.result = result + args.write(self._oprot) + self._oprot.writeMessageEnd() + self._oprot.trans.flush() + + def recv_setCaptchaResult(self, ): + (fname, mtype, rseqid) = self._iprot.readMessageBegin() + if mtype == TMessageType.EXCEPTION: + x = TApplicationException() + x.read(self._iprot) + self._iprot.readMessageEnd() + raise x + result = setCaptchaResult_result() + result.read(self._iprot) + self._iprot.readMessageEnd() + return + class Processor(Iface, TProcessor): def __init__(self, handler): @@ -2476,10 +2476,6 @@ class Processor(Iface, TProcessor): self._processMap["setPackageData"] = Processor.process_setPackageData self._processMap["deleteFinished"] = Processor.process_deleteFinished self._processMap["restartFailed"] = Processor.process_restartFailed - self._processMap["isCaptchaWaiting"] = Processor.process_isCaptchaWaiting - self._processMap["getCaptchaTask"] = Processor.process_getCaptchaTask - self._processMap["getCaptchaTaskStatus"] = Processor.process_getCaptchaTaskStatus - self._processMap["setCaptchaResult"] = Processor.process_setCaptchaResult self._processMap["getEvents"] = Processor.process_getEvents self._processMap["getAccounts"] = Processor.process_getAccounts self._processMap["getAccountTypes"] = Processor.process_getAccountTypes @@ -2493,6 +2489,10 @@ class Processor(Iface, TProcessor): self._processMap["call"] = Processor.process_call self._processMap["getAllInfo"] = Processor.process_getAllInfo self._processMap["getInfoByPlugin"] = Processor.process_getInfoByPlugin + self._processMap["isCaptchaWaiting"] = Processor.process_isCaptchaWaiting + self._processMap["getCaptchaTask"] = Processor.process_getCaptchaTask + self._processMap["getCaptchaTaskStatus"] = Processor.process_getCaptchaTaskStatus + self._processMap["setCaptchaResult"] = Processor.process_setCaptchaResult def process(self, iprot, oprot): (name, type, seqid) = iprot.readMessageBegin() @@ -3104,50 +3104,6 @@ class Processor(Iface, TProcessor): oprot.writeMessageEnd() oprot.trans.flush() - def process_isCaptchaWaiting(self, seqid, iprot, oprot): - args = isCaptchaWaiting_args() - args.read(iprot) - iprot.readMessageEnd() - result = isCaptchaWaiting_result() - result.success = self._handler.isCaptchaWaiting() - oprot.writeMessageBegin("isCaptchaWaiting", TMessageType.REPLY, seqid) - result.write(oprot) - oprot.writeMessageEnd() - oprot.trans.flush() - - def process_getCaptchaTask(self, seqid, iprot, oprot): - args = getCaptchaTask_args() - args.read(iprot) - iprot.readMessageEnd() - result = getCaptchaTask_result() - result.success = self._handler.getCaptchaTask(args.exclusive) - oprot.writeMessageBegin("getCaptchaTask", TMessageType.REPLY, seqid) - result.write(oprot) - oprot.writeMessageEnd() - oprot.trans.flush() - - def process_getCaptchaTaskStatus(self, seqid, iprot, oprot): - args = getCaptchaTaskStatus_args() - args.read(iprot) - iprot.readMessageEnd() - result = getCaptchaTaskStatus_result() - result.success = self._handler.getCaptchaTaskStatus(args.tid) - oprot.writeMessageBegin("getCaptchaTaskStatus", TMessageType.REPLY, seqid) - result.write(oprot) - oprot.writeMessageEnd() - oprot.trans.flush() - - def process_setCaptchaResult(self, seqid, iprot, oprot): - args = setCaptchaResult_args() - args.read(iprot) - iprot.readMessageEnd() - result = setCaptchaResult_result() - self._handler.setCaptchaResult(args.tid, args.result) - oprot.writeMessageBegin("setCaptchaResult", TMessageType.REPLY, seqid) - result.write(oprot) - oprot.writeMessageEnd() - oprot.trans.flush() - def process_getEvents(self, seqid, iprot, oprot): args = getEvents_args() args.read(iprot) @@ -3296,6 +3252,50 @@ class Processor(Iface, TProcessor): oprot.writeMessageEnd() oprot.trans.flush() + def process_isCaptchaWaiting(self, seqid, iprot, oprot): + args = isCaptchaWaiting_args() + args.read(iprot) + iprot.readMessageEnd() + result = isCaptchaWaiting_result() + result.success = self._handler.isCaptchaWaiting() + oprot.writeMessageBegin("isCaptchaWaiting", TMessageType.REPLY, seqid) + result.write(oprot) + oprot.writeMessageEnd() + oprot.trans.flush() + + def process_getCaptchaTask(self, seqid, iprot, oprot): + args = getCaptchaTask_args() + args.read(iprot) + iprot.readMessageEnd() + result = getCaptchaTask_result() + result.success = self._handler.getCaptchaTask(args.exclusive) + oprot.writeMessageBegin("getCaptchaTask", TMessageType.REPLY, seqid) + result.write(oprot) + oprot.writeMessageEnd() + oprot.trans.flush() + + def process_getCaptchaTaskStatus(self, seqid, iprot, oprot): + args = getCaptchaTaskStatus_args() + args.read(iprot) + iprot.readMessageEnd() + result = getCaptchaTaskStatus_result() + result.success = self._handler.getCaptchaTaskStatus(args.tid) + oprot.writeMessageBegin("getCaptchaTaskStatus", TMessageType.REPLY, seqid) + result.write(oprot) + oprot.writeMessageEnd() + oprot.trans.flush() + + def process_setCaptchaResult(self, seqid, iprot, oprot): + args = setCaptchaResult_args() + args.read(iprot) + iprot.readMessageEnd() + result = setCaptchaResult_result() + self._handler.setCaptchaResult(args.tid, args.result) + oprot.writeMessageBegin("setCaptchaResult", TMessageType.REPLY, seqid) + result.write(oprot) + oprot.writeMessageEnd() + oprot.trans.flush() + # HELPER FUNCTIONS AND STRUCTURES @@ -4941,139 +4941,6 @@ class restartFailed_result(TBase): ) -class isCaptchaWaiting_args(TBase): - - __slots__ = [ - ] - - thrift_spec = ( - ) - - -class isCaptchaWaiting_result(TBase): - """ - Attributes: - - success - """ - - __slots__ = [ - 'success', - ] - - thrift_spec = ( - (0, TType.BOOL, 'success', None, None, ), # 0 - ) - - def __init__(self, success=None,): - self.success = success - - -class getCaptchaTask_args(TBase): - """ - Attributes: - - exclusive - """ - - __slots__ = [ - 'exclusive', - ] - - thrift_spec = ( - None, # 0 - (1, TType.BOOL, 'exclusive', None, None, ), # 1 - ) - - def __init__(self, exclusive=None,): - self.exclusive = exclusive - - -class getCaptchaTask_result(TBase): - """ - Attributes: - - success - """ - - __slots__ = [ - 'success', - ] - - thrift_spec = ( - (0, TType.STRUCT, 'success', (CaptchaTask, CaptchaTask.thrift_spec), None, ), # 0 - ) - - def __init__(self, success=None,): - self.success = success - - -class getCaptchaTaskStatus_args(TBase): - """ - Attributes: - - tid - """ - - __slots__ = [ - 'tid', - ] - - thrift_spec = ( - None, # 0 - (1, TType.I32, 'tid', None, None, ), # 1 - ) - - def __init__(self, tid=None,): - self.tid = tid - - -class getCaptchaTaskStatus_result(TBase): - """ - Attributes: - - success - """ - - __slots__ = [ - 'success', - ] - - thrift_spec = ( - (0, TType.STRING, 'success', None, None, ), # 0 - ) - - def __init__(self, success=None,): - self.success = success - - -class setCaptchaResult_args(TBase): - """ - Attributes: - - tid - - result - """ - - __slots__ = [ - 'tid', - 'result', - ] - - thrift_spec = ( - None, # 0 - (1, TType.I32, 'tid', None, None, ), # 1 - (2, TType.STRING, 'result', None, None, ), # 2 - ) - - def __init__(self, tid=None, result=None,): - self.tid = tid - self.result = result - - -class setCaptchaResult_result(TBase): - - __slots__ = [ - ] - - thrift_spec = ( - ) - - class getEvents_args(TBase): """ Attributes: @@ -5532,3 +5399,136 @@ class getInfoByPlugin_result(TBase): def __init__(self, success=None,): self.success = success + +class isCaptchaWaiting_args(TBase): + + __slots__ = [ + ] + + thrift_spec = ( + ) + + +class isCaptchaWaiting_result(TBase): + """ + Attributes: + - success + """ + + __slots__ = [ + 'success', + ] + + thrift_spec = ( + (0, TType.BOOL, 'success', None, None, ), # 0 + ) + + def __init__(self, success=None,): + self.success = success + + +class getCaptchaTask_args(TBase): + """ + Attributes: + - exclusive + """ + + __slots__ = [ + 'exclusive', + ] + + thrift_spec = ( + None, # 0 + (1, TType.BOOL, 'exclusive', None, None, ), # 1 + ) + + def __init__(self, exclusive=None,): + self.exclusive = exclusive + + +class getCaptchaTask_result(TBase): + """ + Attributes: + - success + """ + + __slots__ = [ + 'success', + ] + + thrift_spec = ( + (0, TType.STRUCT, 'success', (CaptchaTask, CaptchaTask.thrift_spec), None, ), # 0 + ) + + def __init__(self, success=None,): + self.success = success + + +class getCaptchaTaskStatus_args(TBase): + """ + Attributes: + - tid + """ + + __slots__ = [ + 'tid', + ] + + thrift_spec = ( + None, # 0 + (1, TType.I32, 'tid', None, None, ), # 1 + ) + + def __init__(self, tid=None,): + self.tid = tid + + +class getCaptchaTaskStatus_result(TBase): + """ + Attributes: + - success + """ + + __slots__ = [ + 'success', + ] + + thrift_spec = ( + (0, TType.STRING, 'success', None, None, ), # 0 + ) + + def __init__(self, success=None,): + self.success = success + + +class setCaptchaResult_args(TBase): + """ + Attributes: + - tid + - result + """ + + __slots__ = [ + 'tid', + 'result', + ] + + thrift_spec = ( + None, # 0 + (1, TType.I32, 'tid', None, None, ), # 1 + (2, TType.STRING, 'result', None, None, ), # 2 + ) + + def __init__(self, tid=None, result=None,): + self.tid = tid + self.result = result + + +class setCaptchaResult_result(TBase): + + __slots__ = [ + ] + + thrift_spec = ( + ) + diff --git a/module/remote/thriftbackend/thriftgen/pyload/constants.py b/module/remote/thriftbackend/thriftgen/pyload/constants.py index f5ef663f1..f8960dc63 100644 --- a/module/remote/thriftbackend/thriftgen/pyload/constants.py +++ b/module/remote/thriftbackend/thriftgen/pyload/constants.py @@ -1,5 +1,5 @@ # -# Autogenerated by Thrift Compiler (0.8.0-dev) +# Autogenerated by Thrift Compiler (0.9.0-dev) # # DO NOT EDIT UNLESS YOU ARE SURE THAT YOU KNOW WHAT YOU ARE DOING # diff --git a/module/remote/thriftbackend/thriftgen/pyload/ttypes.py b/module/remote/thriftbackend/thriftgen/pyload/ttypes.py index 626bd1c29..1299b515d 100644 --- a/module/remote/thriftbackend/thriftgen/pyload/ttypes.py +++ b/module/remote/thriftbackend/thriftgen/pyload/ttypes.py @@ -1,5 +1,5 @@ # -# Autogenerated by Thrift Compiler (0.8.0-dev) +# Autogenerated by Thrift Compiler (0.9.0-dev) # # DO NOT EDIT UNLESS YOU ARE SURE THAT YOU KNOW WHAT YOU ARE DOING # @@ -92,6 +92,61 @@ class ElementType(TBase): "File": 1, } +class Input(TBase): + NONE = 0 + TEXT = 1 + TEXTBOX = 2 + PASSWORD = 3 + BOOL = 4 + CLICK = 5 + CHOICE = 6 + MULTIPLE = 7 + LIST = 8 + TABLE = 9 + + _VALUES_TO_NAMES = { + 0: "NONE", + 1: "TEXT", + 2: "TEXTBOX", + 3: "PASSWORD", + 4: "BOOL", + 5: "CLICK", + 6: "CHOICE", + 7: "MULTIPLE", + 8: "LIST", + 9: "TABLE", + } + + _NAMES_TO_VALUES = { + "NONE": 0, + "TEXT": 1, + "TEXTBOX": 2, + "PASSWORD": 3, + "BOOL": 4, + "CLICK": 5, + "CHOICE": 6, + "MULTIPLE": 7, + "LIST": 8, + "TABLE": 9, + } + +class Output(TBase): + CAPTCHA = 1 + QUESTION = 2 + NOTIFICATION = 4 + + _VALUES_TO_NAMES = { + 1: "CAPTCHA", + 2: "QUESTION", + 4: "NOTIFICATION", + } + + _NAMES_TO_VALUES = { + "CAPTCHA": 1, + "QUESTION": 2, + "NOTIFICATION": 4, + } + class DownloadInfo(TBase): """ @@ -403,6 +458,57 @@ class PackageData(TBase): self.fids = fids +class InteractionTask(TBase): + """ + Attributes: + - iid + - input + - structure + - preset + - output + - data + - title + - description + - plugin + """ + + __slots__ = [ + 'iid', + 'input', + 'structure', + 'preset', + 'output', + 'data', + 'title', + 'description', + 'plugin', + ] + + thrift_spec = ( + None, # 0 + (1, TType.I32, 'iid', None, None, ), # 1 + (2, TType.I32, 'input', None, None, ), # 2 + (3, TType.LIST, 'structure', (TType.STRING,None), None, ), # 3 + (4, TType.LIST, 'preset', (TType.STRING,None), None, ), # 4 + (5, TType.I32, 'output', None, None, ), # 5 + (6, TType.LIST, 'data', (TType.STRING,None), None, ), # 6 + (7, TType.STRING, 'title', None, None, ), # 7 + (8, TType.STRING, 'description', None, None, ), # 8 + (9, TType.STRING, 'plugin', None, None, ), # 9 + ) + + def __init__(self, iid=None, input=None, structure=None, preset=None, output=None, data=None, title=None, description=None, plugin=None,): + self.iid = iid + self.input = input + self.structure = structure + self.preset = preset + self.output = output + self.data = data + self.title = title + self.description = description + self.plugin = plugin + + class CaptchaTask(TBase): """ Attributes: diff --git a/module/setup.py b/module/setup.py index 4a1c59da6..85b33b1ee 100644 --- a/module/setup.py +++ b/module/setup.py @@ -17,7 +17,7 @@ @author: RaNaN """ from getpass import getpass -import gettext +import module.common.pylgettext as gettext import os from os import makedirs from os.path import abspath @@ -34,33 +34,37 @@ class Setup(): """ pyLoads initial setup configuration assistent """ - def __init__(self, path, config): + def __init__(self, path, config): self.path = path self.config = config - def start(self): - langs = self.config.getMetaData("general", "language")["type"].split(";") lang = self.ask(u"Choose your Language / Wähle deine Sprache", "en", langs) - translation = gettext.translation("setup", join(self.path, "locale"), languages=["en", lang]) + gettext.setpaths([join(os.sep, "usr", "share", "pyload", "locale"), None]) + translation = gettext.translation("setup", join(self.path, "locale"), languages=[lang, "en"], fallback=True) translation.install(True) -# print "" -# print _("Would you like to configure pyLoad via Webinterface?") -# print _("You need a Browser and a connection to this PC for it.") -# viaweb = self.ask(_("Start initial webinterface for configuration?"), "y", bool=True) -# if viaweb: -# try: -# from module.web import ServerThread -# ServerThread.setup = self -# from module.web import webinterface -# webinterface.run_simple() -# return False -# except Exception, e: -# print "Setup failed with this error: ", e -# print "Falling back to commandline setup." + #Input shorthand for yes + self.yes = _("y") + #Input shorthand for no + self.no = _("n") + + # print "" + # print _("Would you like to configure pyLoad via Webinterface?") + # print _("You need a Browser and a connection to this PC for it.") + # viaweb = self.ask(_("Start initial webinterface for configuration?"), "y", bool=True) + # if viaweb: + # try: + # from module.web import ServerThread + # ServerThread.setup = self + # from module.web import webinterface + # webinterface.run_simple() + # return False + # except Exception, e: + # print "Setup failed with this error: ", e + # print "Falling back to commandline setup." print "" @@ -69,13 +73,14 @@ class Setup(): print "" print _("The value in brackets [] always is the default value,") print _("in case you don't want to change it or you are unsure what to choose, just hit enter.") - print _("Don't forget: You can always rerun this assistent with --setup or -s parameter, when you start pyLoadCore.") + print _( + "Don't forget: You can always rerun this assistent with --setup or -s parameter, when you start pyLoadCore.") print _("If you have any problems with this assistent hit STRG-C,") print _("to abort and don't let him start with pyLoadCore automatically anymore.") print "" print _("When you are ready for system check, hit enter.") raw_input() - + basic, ssl, captcha, gui, web, js = self.system_check() print "" @@ -90,8 +95,7 @@ class Setup(): print "" print _("## Status ##") print "" - - + avail = [] if self.check_module("Crypto"): avail.append(_("container decrypting")) if ssl: avail.append(_("ssl connection")) @@ -99,64 +103,64 @@ class Setup(): if gui: avail.append(_("GUI")) if web: avail.append(_("Webinterface")) if js: avail.append(_("extended Click'N'Load")) - + string = "" - + for av in avail: - string += ", "+av + string += ", " + av print _("Features available:") + string[1:] print "" - + if len(avail) < 5: print _("Featues missing: ") print - + if not self.check_module("Crypto"): print _("no py-crypto available") print _("You need this if you want to decrypt container files.") print "" - + if not ssl: print _("no SSL available") print _("This is needed if you want to establish a secure connection to core or webinterface.") print _("If you only want to access locally to pyLoad ssl is not usefull.") print "" - + if not captcha: print _("no Captcha Recognition available") print _("Only needed for some hosters and as freeuser.") print "" - + if not gui: print _("Gui not available") print _("The Graphical User Interface.") print "" - + if not js: print _("no JavaScript engine found") print _("You will need this for some Click'N'Load links. Install Spidermonkey, ossp-js, pyv8 or rhino") - + print _("You can abort the setup now and fix some dependicies if you want.") - con = self.ask(_("Continue with setup?"), "y", bool=True) + con = self.ask(_("Continue with setup?"), self.yes, bool=True) if not con: return False print "" - print _("Do you want to change the config path? Current is %s" % abspath("")) - print _("If you use pyLoad on a server or the home partition lives on an iternal flash it may be a good idea to change it.") - path = self.ask(_("Change config path?"), "n", bool=True) + print _("Do you want to change the config path? Current is %s") % abspath("") + print _( + "If you use pyLoad on a server or the home partition lives on an iternal flash it may be a good idea to change it.") + path = self.ask(_("Change config path?"), self.no, bool=True) if path: self.conf_path() #calls exit when changed - - + print "" print _("Do you want to configure login data and basic settings?") print _("This is recommend for first run.") - con = self.ask(_("Make basic setup?"), "y", bool=True) + con = self.ask(_("Make basic setup?"), self.yes, bool=True) if con: self.conf_basic() @@ -164,14 +168,14 @@ class Setup(): if ssl: print "" print _("Do you want to configure ssl?") - ssl = self.ask(_("Configure ssl?"), "n", bool=True) + ssl = self.ask(_("Configure ssl?"), self.no, bool=True) if ssl: self.conf_ssl() if web: print "" print _("Do you want to configure webinterface?") - web = self.ask(_("Configure webinterface?"), "y", bool=True) + web = self.ask(_("Configure webinterface?"), self.yes, bool=True) if web: self.conf_web() @@ -197,7 +201,6 @@ class Setup(): print _("Python Version: OK") python = True - curl = self.check_module("pycurl") self.print_dep("pycurl", curl) @@ -207,10 +210,10 @@ class Setup(): basic = python and curl and sqlite print "" - + crypto = self.check_module("Crypto") self.print_dep("pycrypto", crypto) - + ssl = self.check_module("OpenSSL") self.print_dep("py-OpenSSL", ssl) @@ -218,12 +221,12 @@ class Setup(): pil = self.check_module("Image") self.print_dep("py-imaging", pil) - + if os.name == "nt": tesser = self.check_prog([join(pypath, "tesseract", "tesseract.exe"), "-v"]) else: tesser = self.check_prog(["tesseract", "-v"]) - + self.print_dep("tesseract", tesser) captcha = pil and tesser @@ -238,6 +241,7 @@ class Setup(): try: import jinja2 + v = jinja2.__version__ if v and "unknown" not in v: if not v.startswith("2.5") and not v.startswith("2.6"): @@ -246,15 +250,13 @@ class Setup(): print _("please upgrade or deinstall it, pyLoad includes a sufficient jinja2 libary.") print jinja = False - except : + except: pass - self.print_dep("jinja2", jinja) beaker = self.check_module("beaker") self.print_dep("beaker", beaker) - web = sqlite and beaker from module.common import JsEngine @@ -270,11 +272,12 @@ class Setup(): print "" print _("The following logindata is valid for CLI, GUI and webinterface.") - + from module.database import DatabaseBackend + db = DatabaseBackend(None) db.setup() - username = self.ask(_("Username"), "User") + username = self.ask(_("Username"), "User") password = self.ask("", "", password=True) db.addUser(username, password) db.shutdown() @@ -282,20 +285,18 @@ class Setup(): print "" print _("External clients (GUI, CLI or other) need remote access to work over the network.") print _("However, if you only want to use the webinterface you may disable it to save ram.") - self.config["remote"]["activated"] = self.ask(_("Enable remote access"), "y", bool=True) - + self.config["remote"]["activated"] = self.ask(_("Enable remote access"), self.yes, bool=True) print "" langs = self.config.getMetaData("general", "language") self.config["general"]["language"] = self.ask(_("Language"), "en", langs["type"].split(";")) - self.config["general"]["download_folder"] = self.ask(_("Downloadfolder"), "Downloads") self.config["download"]["max_downloads"] = self.ask(_("Max parallel downloads"), "3") #print _("You should disable checksum proofing, if you have low hardware requirements.") #self.config["general"]["checksum"] = self.ask(_("Proof checksum?"), "y", bool=True) - reconnect = self.ask(_("Use Reconnect?"), "n", bool=True) + reconnect = self.ask(_("Use Reconnect?"), self.no, bool=True) self.config["reconnect"]["activated"] = reconnect if reconnect: self.config["reconnect"]["method"] = self.ask(_("Reconnect script location"), "./reconnect.sh") @@ -304,9 +305,9 @@ class Setup(): def conf_web(self): print "" print _("## Webinterface Setup ##") - + print "" - self.config["webinterface"]["activated"] = self.ask(_("Activate webinterface?"), "y", bool=True) + self.config["webinterface"]["activated"] = self.ask(_("Activate webinterface?"), self.yes, bool=True) print "" print _("Listen address, if you use 127.0.0.1 or localhost, the webinterface will only accessible locally.") self.config["webinterface"]["host"] = self.ask(_("Address"), "0.0.0.0") @@ -315,17 +316,19 @@ class Setup(): print _("pyLoad offers several server backends, now following a short explanation.") print "builtin:", _("Default server, best choice if you dont know which one to choose.") print "threaded:", _("This server offers SSL and is a good alternative to builtin.") - print "fastcgi:", _("Can be used by apache, lighttpd, requires you to configure them, which is not too easy job.") + print "fastcgi:", _( + "Can be used by apache, lighttpd, requires you to configure them, which is not too easy job.") print "lightweight:", _("Very fast alternative written in C, requires libev and linux knowlegde.") print "\t", _("Get it from here: https://github.com/jonashaag/bjoern, compile it") print "\t", _("and copy bjoern.so to module/lib") print - print _("Attention: In some rare cases the builtin server is not working, if you notice problems with the webinterface") + print _( + "Attention: In some rare cases the builtin server is not working, if you notice problems with the webinterface") print _("come back here and change the builtin server to the threaded one here.") - - self.config["webinterface"]["server"] = self.ask(_("Server"), "builtin", ["builtin", "threaded", "fastcgi", "lightweight"]) + self.config["webinterface"]["server"] = self.ask(_("Server"), "builtin", + ["builtin", "threaded", "fastcgi", "lightweight"]) def conf_ssl(self): print "" @@ -339,17 +342,19 @@ class Setup(): print "" print _("If you're done and everything went fine, you can activate ssl now.") - self.config["ssl"]["activated"] = self.ask(_("Activate SSL?"), "y", bool=True) + self.config["ssl"]["activated"] = self.ask(_("Activate SSL?"), self.yes, bool=True) def set_user(self): - - translation = gettext.translation("setup", join(self.path, "locale"), languages=["en", self.config["general"]["language"]]) + gettext.setpaths([join(os.sep, "usr", "share", "pyload", "locale"), None]) + translation = gettext.translation("setup", join(self.path, "locale"), + languages=[self.config["general"]["language"], "en"], fallback=True) translation.install(True) - + from module.database import DatabaseBackend + db = DatabaseBackend(None) db.setup() - + noaction = True try: while True: @@ -363,7 +368,7 @@ class Setup(): continue elif action == "1": print "" - username = self.ask(_("Username"), "User") + username = self.ask(_("Username"), "User") password = self.ask("", "", password=True) db.addUser(username, password) noaction = False @@ -394,16 +399,18 @@ class Setup(): def conf_path(self, trans=False): if trans: - translation = gettext.translation("setup", join(self.path, "locale"), languages=[self.config["general"]["language"]]) + gettext.setpaths([join(os.sep, "usr", "share", "pyload", "locale"), None]) + translation = gettext.translation("setup", join(self.path, "locale"), + languages=[self.config["general"]["language"], "en"], fallback=True) translation.install(True) - + print _("Setting new configpath, current configuration will not be transfered!") path = self.ask(_("Configpath"), abspath("")) try: path = join(pypath, path) if not exists(path): makedirs(path) - f = open(join(pypath, "module","config", "configdir"), "wb") + f = open(join(pypath, "module", "config", "configdir"), "wb") f.write(path) f.close() print _("Configpath changed, setup will now close, please restart to go on.") @@ -412,7 +419,7 @@ class Setup(): exit() except Exception, e: print _("Setting config path failed: %s") % str(e) - + def print_dep(self, name, value): """Print Status of dependency""" if value: @@ -446,10 +453,10 @@ class Setup(): info += ")" elif bool: - if default == "y": - info = "([y]/n)" + if default == self.yes: + info = _("[y]/n") else: - info = "(y/[n])" + info = _("y/[n]") else: info = "[%s]" % default @@ -457,7 +464,6 @@ class Setup(): p1 = True p2 = False while p1 != p2: - if os.name == "nt": qst = str("Password: ") #no unicode on windows else: @@ -489,15 +495,16 @@ class Setup(): input = default if bool: - if re.match(r"(y|yes|j|ja|true)", input.lower().strip()): + # yes, true,t are inputs for booleans with value true + if input.lower().strip() in [self.yes, _("yes"), _("true"), _("t")]: return True - elif re.match(r"(n|no|nein|false)", input.lower().strip()): + # no, false,f are inputs for booleans with value false + elif input.lower().strip() in [self.no, _("no"), _("false"), _("f")]: return False else: print _("Invalid Input") continue - if not answers: return input diff --git a/module/web/pyload_app.py b/module/web/pyload_app.py index e6c9ab436..9612e495e 100644 --- a/module/web/pyload_app.py +++ b/module/web/pyload_app.py @@ -381,7 +381,7 @@ def path(file="", path=""): @route("/logs", method="POST") @route("/logs/:item") @route("/logs/:item", method="POST") -@login_required('STATUS') +@login_required('LOGS') def logs(item=-1): s = request.environ.get('beaker.session') diff --git a/module/web/webinterface.py b/module/web/webinterface.py index 68724e3f6..ec8b2e56c 100644 --- a/module/web/webinterface.py +++ b/module/web/webinterface.py @@ -18,8 +18,9 @@ """ import sys -import gettext +import module.common.pylgettext as gettext +import os from os.path import join, abspath, dirname, exists from os import makedirs @@ -98,8 +99,9 @@ if PREFIX: else: env.filters["url"] = lambda x: PREFIX + x if x.startswith("/") else x +gettext.setpaths([join(os.sep, "usr", "share", "pyload", "locale"), None]) translation = gettext.translation("django", join(PYLOAD_DIR, "locale"), - languages=["en", config.get("general", "language")]) + languages=[config.get("general", "language"), "en"],fallback=True) translation.install(True) env.install_gettext_translations(translation) |