commit 099d4ec9e9db8771407b8fdaa7ff6539439416ed
parent 4fe4546a6ddb419f6cfb42a46dfc6e5428e2edfb
Author: a3nm <a3nm@a3nm.net>
Date: Sat, 17 Aug 2019 16:34:57 +0000
Merge branch 'master' into 'master'
Refactoring + package
See merge request a3nm/plint!4
Diffstat:
29 files changed, 4307 insertions(+), 4157 deletions(-)
diff --git a/.gitignore b/.gitignore
@@ -36,5 +36,8 @@ final_diaeresis2.ctx
final_syneresis2.ctx
coverage
.coverage
+build/
+dist/
+plint.egg-info/
ouliplint/stanford-postagger-full-2013-11-12/
test_temp.txt
diff --git a/lexique_comparison/count_syllables_plint.py b/lexique_comparison/count_syllables_plint.py
@@ -5,24 +5,25 @@ import sys
# modules are in the parent folder
import plint.pattern
+from plint.rhyme import Rhyme
+from plint.template import Template
+from plint.verse import Verse
sys.path.insert(1, os.path.join(sys.path[0], '..'))
-from plint import template, verse, rhyme
-
-templateobj = template.Template()
-patternobj = plint.pattern.Pattern("12")
+template = Template()
+pattern = plint.pattern.Pattern("12")
for l in sys.stdin.readlines():
w = (l.strip().split("\t"))[0]
- v = verse.Verse(w, templateobj, patternobj)
- rhymeobj = rhyme.Rhyme(v.normalized,
- patternobj.constraint, templateobj.mergers, templateobj.options)
- v.phon = rhymeobj.phon
- v.annotate()
+ verse = Verse(w, template, pattern)
+ rhyme = Rhyme(verse.normalized,
+ pattern.constraint, template.mergers, template.options)
+ verse.phon = rhyme.phon
+ verse.annotate()
mx = 0
mn = 0
- for c in v.chunks:
+ for c in verse.chunks:
if 'weights' in c.keys():
mn += min(c['weights'])
mx += max(c['weights'])
diff --git a/plint.py b/plint.py
@@ -1,68 +0,0 @@
-#!/usr/bin/python3 -u
-
-from plint import localization, error, template, diaeresis
-import sys
-
-
-def run():
- ok = True
- f2 = None
- nsyl = None
- offset = 0
- if len(sys.argv) >= 4:
- f2 = open(sys.argv[3], 'w')
- if len(sys.argv) >= 5:
- nsyl = int(sys.argv[4])
- if len(sys.argv) == 6:
- offset = int(sys.argv[5])
- should_end = False
- while True:
- line = sys.stdin.readline()
- if not line:
- should_end = True
- line = ""
- errors = template.check(line, f2, last=should_end, n_syllables=nsyl, offset=offset)
- if errors:
- print(errors.report(), file=sys.stderr)
- ok = False
- if should_end:
- break
- return ok
-
-
-if __name__ == '__main__':
- localization.init_locale()
- if len(sys.argv) < 2 or len(sys.argv) > 6:
- print(_("Usage: %s TEMPLATE [DFILE [OCONTEXT [NSYL [OFFSET]]]]") % sys.argv[0],
- file=sys.stderr)
- print(_("Check stdin according to TEMPLATE, report errors on stdout"),
- file=sys.stderr)
- print(_("For internal use:"),
- file=sys.stderr)
- print(_("DFILE is the diaeresis file, OCONTEXT is the context output file"),
- file=sys.stderr)
- print(_("NSYL is the assigned weight to the last chunk (diaeresis training)"),
- file=sys.stderr)
- print(_("OFFSET is to add after the last chunk (diaeresis training)"),
- file=sys.stderr)
- sys.exit(2)
-
- template_name = sys.argv[1]
- if len(sys.argv) > 2:
- diaeresis_name = sys.argv[2]
- else:
- diaeresis_name = "../data/diaeresis.json"
- diaeresis.set_diaeresis(diaeresis_name)
-
- f = open(template_name)
- x = f.read()
- f.close()
-
- try:
- template = template.Template(x)
- except error.TemplateLoadError as e:
- print("Could not load template %s: %s" % (template_name, e.msg), file=sys.stderr)
- sys.exit(2)
-
- ok = run()
- sys.exit(0 if ok else 1)
diff --git a/plint/__init__.py b/plint/__init__.py
@@ -0,0 +1,13 @@
+from . import chunk
+from . import chunks
+from . import common
+from . import diaeresis
+from . import hyphen_splitter
+from . import localization
+from . import nature
+from . import options
+from . import pattern
+from . import rhyme
+from . import template
+from . import verse
+from . import vowels
diff --git a/plint/__main__.py b/plint/__main__.py
@@ -0,0 +1,69 @@
+#!/usr/bin/python3 -u
+
+from plint import localization, error, template, diaeresis
+import sys
+
+
+def run():
+ is_ok = True
+ f2 = None
+ n_syllables = None
+ offset = 0
+ if len(sys.argv) >= 4:
+ f2 = open(sys.argv[3], 'w')
+ if len(sys.argv) >= 5:
+ n_syllables = int(sys.argv[4])
+ if len(sys.argv) == 6:
+ offset = int(sys.argv[5])
+ should_end = False
+ while True:
+ line = sys.stdin.readline()
+ if not line:
+ should_end = True
+ line = ""
+ errors = template.check(line, f2, last=should_end, n_syllables=n_syllables, offset=offset)
+ if errors:
+ print(errors.report(), file=sys.stderr)
+ is_ok = False
+ if should_end:
+ break
+ return is_ok
+
+
+def main():
+ global template
+ localization.init_locale()
+ if len(sys.argv) < 2 or len(sys.argv) > 6:
+ print("Usage: %s TEMPLATE [DFILE [OCONTEXT [NSYL [OFFSET]]]]" % sys.argv[0],
+ file=sys.stderr)
+ print("Check stdin according to TEMPLATE, report errors on stdout",
+ file=sys.stderr)
+ print("For internal use:",
+ file=sys.stderr)
+ print("DFILE is the diaeresis file, OCONTEXT is the context output file",
+ file=sys.stderr)
+ print("NSYL is the assigned weight to the last chunk (diaeresis training)",
+ file=sys.stderr)
+ print("OFFSET is to add after the last chunk (diaeresis training)",
+ file=sys.stderr)
+ sys.exit(2)
+ template_name = sys.argv[1]
+ if len(sys.argv) > 2:
+ diaeresis_name = sys.argv[2]
+ else:
+ diaeresis_name = "../data/diaeresis.json"
+ diaeresis.set_diaeresis(diaeresis_name)
+ f = open(template_name)
+ x = f.read()
+ f.close()
+ try:
+ template = template.Template(x)
+ except error.TemplateLoadError as e:
+ print("Could not load template %s: %s" % (template_name, e.msg), file=sys.stderr)
+ sys.exit(2)
+ ok = run()
+ sys.exit(0 if ok else 1)
+
+
+if __name__ == '__main__':
+ main()
diff --git a/plint/bottle.py b/plint/bottle.py
@@ -1,3081 +0,0 @@
-#!/usr/bin/env python
-# -*- coding: utf-8 -*-
-"""
-Bottle is a fast and simple micro-framework for small web applications. It
-offers request dispatching (Routes) with url parameter support, templates,
-a built-in HTTP Server and adapters for many third party WSGI/HTTP-server and
-template engines - all in a single file and with no dependencies other than the
-Python Standard Library.
-
-Homepage and documentation: http://bottlepy.org/
-
-Copyright (c) 2011, Marcel Hellkamp.
-License: MIT (see LICENSE for details)
-"""
-
-from __future__ import with_statement
-
-__author__ = 'Marcel Hellkamp'
-__version__ = '0.11.dev'
-__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 base64, cgi, email.utils, functools, hmac, imp, itertools, mimetypes,\
- os, re, subprocess, sys, tempfile, threading, time, urllib, warnings
-
-from datetime import date as datedate, datetime, timedelta
-from tempfile import TemporaryFile
-from traceback import format_exc, print_exc
-
-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, loads as json_lds
- except ImportError:
- try: from django.utils.simplejson import dumps as json_dumps, loads as json_lds
- except ImportError:
- def json_dumps(data):
- raise ImportError("JSON support requires Python 2.6 or simplejson.")
- json_lds = json_dumps
-
-
-
-# We now try to fix 2.5/2.6/3.1/3.2 incompatibilities.
-# It ain't pretty but it works... Sorry for the mess.
-
-py = sys.version_info
-py3k = py >= (3,0,0)
-py25 = py < (2,6,0)
-
-# Workaround for the missing "as" keyword in py3k.
-def _e(): return sys.exc_info()[1]
-
-# Workaround for the "print is a keyword/function" dilemma.
-_stdout, _stderr = sys.stdout.write, sys.stderr.write
-
-# Lots of stdlib and builtin differences.
-if py3k:
- import http.client as httplib
- import _thread as thread
- from urllib.parse import urljoin, parse_qsl, SplitResult as UrlSplitResult
- from urllib.parse import urlencode, quote as urlquote, unquote as urlunquote
- from http.cookies import SimpleCookie
- from collections import MutableMapping as DictMixin
- import pickle
- from io import BytesIO
- basestring = str
- unicode = str
- json_loads = lambda s: json_lds(touni(s))
- callable = lambda x: hasattr(x, '__call__')
- imap = map
-else: # 2.x
- import httplib
- import thread
- from urlparse import urljoin, SplitResult as UrlSplitResult
- from urllib import urlencode, quote as urlquote, unquote as urlunquote
- from Cookie import SimpleCookie
- from itertools import imap
- import cPickle as pickle
- from StringIO import StringIO as BytesIO
- if py25:
- msg = "Python 2.5 support may be dropped in future versions of Bottle."
- warnings.warn(msg, DeprecationWarning)
- from cgi import parse_qsl
- from UserDict import DictMixin
- def next(it): return it.next()
- bytes = str
- else: # 2.6, 2.7
- from urlparse import parse_qsl
- from collections import MutableMapping as DictMixin
- json_loads = json_lds
-
-# Some helpers for string/byte handling
-def tob(s, enc='utf8'):
- return s.encode(enc) if isinstance(s, unicode) else bytes(s)
-def touni(s, enc='utf8', err='strict'):
- return s.decode(enc, err) if isinstance(s, bytes) else unicode(s)
-tonat = touni if py3k else tob
-
-# 3.2 fixes cgi.FieldStorage to accept bytes (which makes a lot of sense).
-# 3.1 needs a workaround.
-NCTextIOWrapper = None
-if (3,0,0) < py < (3,2,0):
- from io import TextIOWrapper
- class NCTextIOWrapper(TextIOWrapper):
- def close(self): pass # Keep wrapped buffer open.
-
-# A bug in functools causes it to break if the wrapper is an instance method
-def update_wrapper(wrapper, wrapped, *a, **ka):
- try: functools.update_wrapper(wrapper, wrapped, *a, **ka)
- except AttributeError: pass
-
-
-
-# These helpers are used at module level and need to be defined first.
-# And yes, I know PEP-8, but sometimes a lower-case classname makes more sense.
-
-def depr(message):
- warnings.warn(message, DeprecationWarning, stacklevel=3)
-
-def makelist(data): # This is just to handy
- if isinstance(data, (tuple, list, set, dict)): return list(data)
- elif data: return [data]
- else: return []
-
-
-class DictProperty(object):
- ''' Property that maps to a key in a local dict-like attribute. '''
- def __init__(self, attr, key=None, read_only=False):
- self.attr, self.key, self.read_only = attr, key, read_only
-
- def __call__(self, func):
- functools.update_wrapper(self, func, updated=[])
- self.getter, self.key = func, self.key or func.__name__
- return self
-
- def __get__(self, obj, cls):
- 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]
-
- def __set__(self, obj, value):
- if self.read_only: raise AttributeError("Read-Only property.")
- getattr(obj, self.attr)[self.key] = value
-
- def __delete__(self, obj):
- if self.read_only: raise AttributeError("Read-Only property.")
- del getattr(obj, self.attr)[self.key]
-
-
-class cached_property(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
-
-
-class lazy_attribute(object):
- ''' A property that caches itself to the class object. '''
- def __init__(self, func):
- functools.update_wrapper(self, func, updated=[])
- self.getter = func
-
- def __get__(self, obj, cls):
- value = self.getter(cls)
- setattr(cls, self.__name__, value)
- return value
-
-
-
-
-
-
-###############################################################################
-# Exceptions and Events ########################################################
-###############################################################################
-
-
-class BottleException(Exception):
- """ A base class for exceptions used by bottle. """
- pass
-
-
-#TODO: This should subclass BaseRequest
-class HTTPResponse(BottleException):
- """ Used to break execution and immediately finish the response """
- def __init__(self, output='', status=200, header=None):
- super(BottleException, self).__init__("HTTP Response %d" % status)
- self.status = int(status)
- self.output = output
- self.headers = HeaderDict(header) if header else None
-
- def apply(self, response):
- if self.headers:
- for key, value in self.headers.allitems():
- response.headers[key] = value
- response.status = self.status
-
-
-class HTTPError(HTTPResponse):
- """ Used to generate an error page """
- def __init__(self, code=500, output='Unknown Error', exception=None,
- traceback=None, header=None):
- super(HTTPError, self).__init__(output, code, header)
- self.exception = exception
- self.traceback = traceback
-
- def __repr__(self):
- return tonat(template(ERROR_PAGE_TEMPLATE, e=self))
-
-
-
-
-
-
-###############################################################################
-# Routing ######################################################################
-###############################################################################
-
-
-class RouteError(BottleException):
- """ This is a base class for all routing related exceptions """
-
-
-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
- the first target that satisfies the request. The target may be anything,
- usually a string, ID or callable object. A route consists of a path-rule
- 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>`). The wildcard syntax
- and details on the matching order are described in docs:`routing`.
- '''
-
- 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.float_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.]+', 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 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)
-
- try:
- re_match = re.compile('^(%s)$' % pattern).match
- except re.error:
- 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
-
- try:
- 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): # 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:
- raise RouteBuildError('Missing URL argument: %r' % _e().args[0])
-
- def match(self, 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()
- if method in targets:
- return targets[method], urlargs
- if method == 'HEAD' and 'GET' in targets:
- return targets['GET'], urlargs
- if 'ANY' in targets:
- return targets['ANY'], urlargs
- allowed = [verb for verb in targets if verb != 'ANY']
- if 'GET' in allowed and 'HEAD' not in allowed:
- allowed.append('HEAD')
- raise HTTPError(405, "Method not allowed.",
- header=[('Allow',",".join(allowed))])
-
-
-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:
- 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:
- update_wrapper(callback, self.callback)
- return callback
-
- def __repr__(self):
- return '<%s %r %r>' % (self.method, self.rule, self.callback)
-
-
-
-
-
-
-###############################################################################
-# Application Object ###########################################################
-###############################################################################
-
-
-class Bottle(object):
- """ Each Bottle object represents a single, distinct web application and
- consists of routes, callbacks, plugins and configuration. Instances are
- callable WSGI applications. """
-
- def __init__(self, catchall=True, autojson=True, config=None):
- self.routes = [] # List of installed :class:`Route` instances.
- self.router = Router() # Maps requests to :class:`Route` instances.
- self.plugins = [] # List of installed plugins.
-
- self.error_handler = {}
- self.config = ConfigDict(config or {})
- #: If true, most exceptions are catched and returned as :exc:`HTTPError`
- self.catchall = catchall
- #: An instance of :class:`HooksPlugin`. Empty by default.
- self.hooks = HooksPlugin()
- self.install(self.hooks)
- if autojson:
- self.install(JSONPlugin())
- self.install(TemplatePlugin())
-
- def mount(self, prefix, app, **options):
- ''' Mount an application (:class:`Bottle` or plain WSGI) to a specific
- URL prefix. Example::
-
- root_app.mount('/admin/', admin_app)
-
- :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 isinstance(app, basestring):
- prefix, app = app, prefix
- depr('Parameter order of Bottle.mount() changed.') # 0.10
-
- parts = [p for p in prefix.split('/') if p]
- if not parts: raise ValueError('Empty path prefix.')
- path_depth = len(parts)
- options.setdefault('skip', True)
- options.setdefault('method', 'ANY')
-
- @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_code, rs.headers)
- finally:
- request.path_shift(-path_depth)
-
- if not prefix.endswith('/'):
- self.route('/' + '/'.join(parts), callback=mountpoint, **options)
-
- def merge(self, routes):
- ''' Merge the routes of another :cls:`Bottle` application or a list of
- :class:`Route` objects into this application. The routes keep their
- 'owner', meaning that the :data:`Route.app` attribute is not
- changed. '''
- if isinstance(routes, Bottle):
- routes = routes.routes
- for route in routes:
- self.add_route(route)
-
- def install(self, plugin):
- ''' 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.
- '''
- if hasattr(plugin, 'setup'): plugin.setup(self)
- if not callable(plugin) and not hasattr(plugin, 'apply'):
- raise TypeError("Plugins must be callable or implement .apply()")
- self.plugins.append(plugin)
- self.reset()
- return plugin
-
- def uninstall(self, plugin):
- ''' 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) \
- or getattr(plugin, 'name', True) == remove:
- removed.append(plugin)
- del self.plugins[i]
- if hasattr(plugin, 'close'): plugin.close()
- if removed: self.reset()
- return removed
-
- def run(self, **kwargs):
- ''' Calls :func:`run` with the same parameters. '''
- run(self, **kwargs)
-
- def reset(self, route=None):
- ''' Reset all routes (force plugins to be re-applied) and clear all
- 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 routes: route.prepare()
- self.hooks.trigger('app_reset')
-
- def close(self):
- ''' Close the application and all installed plugins. '''
- for plugin in self.plugins:
- if hasattr(plugin, 'close'): plugin.close()
- self.stopped = True
-
- def match(self, environ):
- """ 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 """
- scriptname = request.environ.get('SCRIPT_NAME', '').strip('/') + '/'
- location = self.router.build(routename, **kargs).lstrip('/')
- return urljoin(urljoin('/', scriptname), location)
-
- def add_route(self, route):
- ''' Add a route object, but do not change the :data:`Route.app`
- attribute.'''
- self.routes.append(route)
- self.router.add(route.rule, route.method, route, name=route.name)
- if DEBUG: route.prepare()
-
- def route(self, path=None, method='GET', callback=None, name=None,
- apply=None, skip=None, **config):
- """ A decorator to bind a function to a request URL. Example::
-
- @app.route('/hello/:name')
- def hello(name):
- return 'Hello %s' % name
-
- The ``:name`` part is a wildcard. See :class:`Router` for syntax
- details.
-
- :param path: Request path or a list of paths to listen to. If no
- path is specified, it is automatically generated from the
- signature of the function.
- :param method: HTTP method (`GET`, `POST`, `PUT`, ...) or a list of
- methods to listen to. (default: `GET`)
- :param callback: An optional shortcut to avoid the decorator
- syntax. ``route(..., callback=func)`` equals ``route(...)(func)``
- :param name: The name for this route. (default: None)
- :param apply: A decorator or plugin or a list of plugins. These are
- applied to the route callback in addition to installed plugins.
- :param skip: A list of plugins, plugin classes or names. Matching
- plugins are not installed to this route. ``True`` skips all.
-
- Any additional keyword arguments are stored as route-specific
- configuration and passed to plugins (see :meth:`Plugin.apply`).
- """
- if callable(path): path, callback = None, path
- plugins = makelist(apply)
- skiplist = makelist(skip)
- def decorator(callback):
- # TODO: Documentation and test_data
- if isinstance(callback, basestring): callback = load(callback)
- for rule in makelist(path) or yieldroutes(callback):
- for verb in makelist(method):
- verb = verb.upper()
- route = Route(self, rule, verb, callback, name=name,
- plugins=plugins, skiplist=skiplist, **config)
- self.add_route(route)
- return callback
- return decorator(callback) if callback else decorator
-
- def get(self, path=None, method='GET', **options):
- """ Equals :meth:`route`. """
- return self.route(path, method, **options)
-
- def post(self, path=None, method='POST', **options):
- """ Equals :meth:`route` with a ``POST`` method parameter. """
- return self.route(path, method, **options)
-
- def put(self, path=None, method='PUT', **options):
- """ Equals :meth:`route` with a ``PUT`` method parameter. """
- return self.route(path, method, **options)
-
- def delete(self, path=None, method='DELETE', **options):
- """ Equals :meth:`route` with a ``DELETE`` method parameter. """
- return self.route(path, method, **options)
-
- def error(self, code=500):
- """ Decorator: Register an output handler for a HTTP error code"""
- def wrapper(handler):
- self.error_handler[int(code)] = handler
- return handler
- return wrapper
-
- def hook(self, name):
- """ Return a decorator that attaches a callback to a hook. Three hooks
- are currently implemented:
-
- - before_request: Executed once before each request
- - after_request: Executed once after each request
- - app_reset: Called whenever :meth:`reset` is called.
- """
- def wrapper(func):
- self.hooks.add(name, func)
- return func
- return wrapper
-
- def handle(self, path, method='GET'):
- """ (deprecated) Execute the first matching route callback and return
- the result. :exc:`HTTPResponse` exceptions are catched and returned.
- If :attr:`Bottle.catchall` is true, other exceptions are catched as
- well and returned as :exc:`HTTPError` instances (500).
- """
- depr("This method will change semantics in 0.10. Try to avoid it.")
- if isinstance(path, dict):
- return self._handle(path)
- return self._handle({'PATH_INFO': path, 'REQUEST_METHOD': method.upper()})
-
- def _handle(self, environ):
- try:
- environ['bottle.app'] = self
- request.bind(environ)
- response.bind()
- route, args = self.router.match(environ)
- environ['route.handle'] = environ['bottle.route'] = route
- environ['route.url_args'] = args
- return route.call(**args)
- except HTTPResponse:
- return _e()
- except RouteReset:
- route.reset()
- return self._handle(environ)
- except (KeyboardInterrupt, SystemExit, MemoryError):
- raise
- except Exception:
- if not self.catchall: raise
- stacktrace = format_exc(10)
- environ['wsgi.errors'].write(stacktrace)
- return HTTPError(500, "Internal Server Error", _e(), stacktrace)
-
- def _cast(self, out, peek=None):
- """ Try to convert the parameter into something WSGI compatible and set
- correct HTTP headers when possible.
- Support: False, str, unicode, dict, HTTPResponse, HTTPError, file-like,
- iterable of strings and iterable of unicodes
- """
-
- # Empty output is done here
- if not out:
- response['Content-Length'] = 0
- return []
- # Join lists of byte or unicode strings. Mixed lists are NOT supported
- if isinstance(out, (tuple, list))\
- and isinstance(out[0], (bytes, unicode)):
- out = out[0][0:0].join(out) # b'abc'[0:0] -> b''
- # Encode unicode strings
- if isinstance(out, unicode):
- out = out.encode(response.charset)
- # Byte Strings are just returned
- if isinstance(out, bytes):
- 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)
- if isinstance(out, HTTPResponse):
- depr('Error handlers must not return :exc:`HTTPResponse`.') #0.9
- return self._cast(out)
- if isinstance(out, HTTPResponse):
- out.apply(response)
- return self._cast(out.output)
-
- # File-like objects.
- if hasattr(out, 'read'):
- if 'wsgi.file_wrapper' in request.environ:
- return request.environ['wsgi.file_wrapper'](out)
- elif hasattr(out, 'close') or not hasattr(out, '__iter__'):
- return WSGIFileWrapper(out)
-
- # Handle Iterables. We peek into them to detect their inner type.
- try:
- out = iter(out)
- first = next(out)
- while not first:
- first = next(out)
- except StopIteration:
- return self._cast('')
- except HTTPResponse:
- first = _e()
- except (KeyboardInterrupt, SystemExit, MemoryError):
- raise
- except Exception:
- if not self.catchall: raise
- first = HTTPError(500, 'Unhandled exception', _e(), format_exc(10))
-
- # These are the inner types allowed in iterator or generator objects.
- if isinstance(first, HTTPResponse):
- return self._cast(first)
- if isinstance(first, bytes):
- return itertools.chain([first], out)
- if isinstance(first, unicode):
- return imap(lambda x: x.encode(response.charset),
- itertools.chain([first], out))
- return self._cast(HTTPError(500, 'Unsupported response type: %s'\
- % type(first)))
-
- def wsgi(self, environ, start_response):
- """ The bottle WSGI-interface. """
- try:
- out = self._cast(self._handle(environ))
- # rfc2616 section 4.3
- if response._status_code in (100, 101, 204, 304)\
- or request.method == 'HEAD':
- if hasattr(out, 'close'): out.close()
- out = []
- if isinstance(response._status_line, unicode):
- response._status_line = str(response._status_line)
- start_response(response._status_line, list(response.iter_headers()))
- return out
- except (KeyboardInterrupt, SystemExit, MemoryError):
- raise
- except Exception:
- if not self.catchall: raise
- err = '<h1>Critical error while processing request: %s</h1>' \
- % html_escape(environ.get('PATH_INFO', '/'))
- if DEBUG:
- err += '<h2>Error:</h2>\n<pre>\n%s\n</pre>\n' \
- '<h2>Traceback:</h2>\n<pre>\n%s\n</pre>\n' \
- % (html_escape(repr(_e())), html_escape(format_exc(10)))
- environ['wsgi.errors'].write(err)
- headers = [('Content-Type', 'text/html; charset=UTF-8')]
- start_response('500 INTERNAL SERVER ERROR', headers)
- return [tob(err)]
-
- def __call__(self, environ, start_response):
- ''' Each instance of :class:'Bottle' is a WSGI application. '''
- return self.wsgi(environ, start_response)
-
-
-
-
-
-
-###############################################################################
-# HTTP and WSGI Tools ##########################################################
-###############################################################################
-
-
-class BaseRequest(object):
- """ A wrapper for WSGI environment dictionaries that adds a lot of
- convenient access methods and properties. Most of them are read-only."""
-
- #: Maximum size of memory buffer for :attr:`body` in bytes.
- MEMFILE_MAX = 102400
- #: Maximum number pr GET or POST parameters per request
- MAX_PARAMS = 100
-
- 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
- environ['bottle.request'] = self
-
- @DictProperty('environ', 'bottle.app', read_only=True)
- def app(self):
- ''' Bottle application handling this request. '''
- raise AttributeError('This request is not connected to an application.')
-
- @property
- 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 method(self):
- ''' The ``REQUEST_METHOD`` value as an uppercase string. '''
- return self.environ.get('REQUEST_METHOD', 'GET').upper()
-
- @DictProperty('environ', 'bottle.request.headers', read_only=True)
- def headers(self):
- ''' A :class:`WSGIHeaderDict` that provides case-insensitive access to
- HTTP request headers. '''
- return WSGIHeaderDict(self.environ)
-
- 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',''))
- cookies = list(cookies.values())[:self.MAX_PARAMS]
- return FormsDict((c.key, c.value) for c in cookies)
-
- 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`. '''
- pairs = parse_qsl(self.query_string, keep_blank_values=True)
- get = self.environ['bottle.get'] = FormsDict()
- for key, value in pairs[:self.MAX_PARAMS]:
- get[key] = value
- return get
-
- @DictProperty('environ', 'bottle.request.forms', read_only=True)
- def forms(self):
- """ 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.allitems():
- if not hasattr(item, 'filename'):
- forms[name] = item
- return forms
-
- @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.allitems():
- params[key] = value
- for key, value in self.forms.allitems():
- params[key] = value
- return params
-
- @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 = FormsDict()
- for name, item in self.POST.allitems():
- if hasattr(item, 'filename'):
- files[name] = item
- return files
-
- @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.request.body', read_only=True)
- def _body(self):
- maxread = max(0, self.content_length)
- stream = self.environ['wsgi.input']
- body = BytesIO() if maxread < self.MEMFILE_MAX else TemporaryFile(mode='w+b')
- while maxread > 0:
- part = stream.read(min(maxread, self.MEMFILE_MAX))
- if not part: break
- body.write(part)
- maxread -= len(part)
- self.environ['wsgi.input'] = body
- body.seek(0)
- return body
-
- @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
-
- #: An alias for :attr:`query`.
- GET = query
-
- @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).
- """
- 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 [])[:self.MAX_PARAMS]:
- post[item.name] = item if item.filename else item.value
- return post
-
- @property
- def COOKIES(self):
- ''' Alias for :attr:`cookies` (deprecated). '''
- depr('BaseRequest.COOKIES was renamed to BaseRequest.cookies (lowercase).')
- return self.cookies
-
- @property
- 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'), '')
-
- @property
- def fullpath(self):
- """ Request path including :attr:`script_name` (if present). """
- return urljoin(self.script_name, self.path.lstrip('/'))
-
- @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', '')
-
- @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 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 get(self, value, default=None): return self.environ.get(value, default)
- 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._cookies = None
- self._headers = {'Content-Type': [self.default_content_type]}
- self.body = body
- 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 = 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):
- return self._status_line
-
- 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 updated accordingly. The return value is
- always a status string. ''')
- 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.items()
- 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):
- depr('The wsgiheader method is deprecated. See headerlist.') #0.10
- return self.headerlist
-
- @property
- def headerlist(self):
- ''' WSGI conform list of (header, value) tuples. '''
- return list(self.iter_headers())
-
- 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. 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 name: the name of the cookie.
- :param value: the value of the cookie.
- :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. (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: 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` 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
- cookies are limited to 4kb in most browsers.
-
- Warning: Signed cookies are not encrypted (the client can still see
- the content) and not copy-protected (the client can restore an old
- 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((name, value), secret))
- elif not isinstance(value, basestring):
- 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.items():
- 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`
- settings as used to create the cookie. '''
- kwargs['max_age'] = -1
- kwargs['expires'] = 0
- self.set_cookie(key, '', **kwargs)
-
- def __repr__(self):
- out = ''
- for name, value in self.headerlist:
- out += '%s: %s\n' % (name.title(), value.strip())
- return out
-
-#: Thread-local storage for :class:`LocalRequest` and :class:`LocalResponse`
-#: attributes.
-_lctx = threading.local()
-
-def local_property(name, doc=None):
-
- return property(
- lambda self: getattr(_lctx, name),
- lambda self, value: setattr(_lctx, name, value),
- lambda self: delattr(_lctx, name),
- doc or ('Thread-local property stored in :data:`_lctx.%s` ' % name)
- )
-
-class LocalRequest(BaseRequest):
- ''' A thread-local subclass of :class:`BaseRequest` with a different
- set of attribues for each thread. There is usually only one global
- instance of this class (:data:`request`). If accessed during a
- request/response cycle, this instance always refers to the *current*
- request (even on a multithreaded server). '''
- def __init__(self): pass
- bind = BaseRequest.__init__
- environ = local_property('request_environ')
-
-
-class LocalResponse(BaseResponse):
- ''' A thread-local subclass of :class:`BaseResponse` with a different
- set of attribues for each thread. There is usually only one global
- instance of this class (:data:`response`). Its attributes are used
- to build the HTTP response at the end of the request/response cycle.
- '''
- def __init__(self): pass
- bind = BaseResponse.__init__
- _status_line = local_property('response_status_line')
- _status_code = local_property('response_status_code')
- _cookies = local_property('response_cookies')
- _headers = local_property('response_headers')
- body = local_property('response_body')
-
-Response = LocalResponse # BC 0.9
-Request = LocalRequest # BC 0.9
-
-
-
-
-
-
-###############################################################################
-# Plugins ######################################################################
-###############################################################################
-
-class PluginError(BottleException): pass
-
-class JSONPlugin(object):
- name = 'json'
- api = 2
-
- def __init__(self, json_dumps=json_dumps):
- self.json_dumps = json_dumps
-
- def apply(self, callback, context):
- dumps = self.json_dumps
- if not dumps: return callback
- 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 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 = dict((name, []) for name in self._names)
- self.app = None
-
- def _empty(self):
- return not (self.hooks['before_request'] or self.hooks['after_request'])
-
- def setup(self, app):
- self.app = app
-
- def add(self, name, func):
- ''' Attach a callback to a hook. '''
- was_empty = self._empty()
- 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. '''
- was_empty = self._empty()
- 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 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):
- if self._empty(): return callback
- def wrapper(*a, **ka):
- self.trigger('before_request')
- rv = callback(*a, **ka)
- self.trigger('after_request', reversed=True)
- return rv
- return wrapper
-
-
-class TemplatePlugin(object):
- ''' This plugin applies the :func:`view` decorator to all routes with a
- `template` config parameter. If the parameter is a tuple, the second
- 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, 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 route.config:
- depr('The `template_opts` parameter is deprecated.') #0.9
- return view(conf, **route.config['template_opts'])(callback)
- elif isinstance(conf, str):
- return view(conf)(callback)
- else:
- return callback
-
-
-#: Not a plugin, but part of the plugin API. TODO: Find a better place.
-class _ImportRedirect(object):
- def __init__(self, name, impmask):
- ''' Create a virtual package that redirects imports (see PEP 302). '''
- self.name = name
- self.impmask = impmask
- self.module = sys.modules.setdefault(name, imp.new_module(name))
- self.module.__dict__.update({'__file__': __file__, '__path__': [],
- '__all__': [], '__loader__': self})
- sys.meta_path.append(self)
-
- def find_module(self, fullname, path=None):
- if '.' not in fullname: return
- packname, modname = fullname.rsplit('.', 1)
- if packname != self.name: return
- return self
-
- def load_module(self, fullname):
- if fullname in sys.modules: return sys.modules[fullname]
- packname, modname = fullname.rsplit('.', 1)
- realname = self.impmask % modname
- __import__(realname)
- module = sys.modules[fullname] = sys.modules[realname]
- setattr(self.module, modname, module)
- module.__loader__ = self
- return module
-
-
-
-
-
-
-###############################################################################
-# Common Utilities #############################################################
-###############################################################################
-
-
-class MultiDict(DictMixin):
- """ 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).items())
-
- 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 __getitem__(self, key): return self.dict[key][-1]
- def __setitem__(self, key, value): self.append(key, value)
- def keys(self): return self.dict.keys()
-
- if py3k:
- def values(self): return (v[-1] for v in self.dict.values())
- def items(self): return ((k, v[-1]) for k, v in self.dict.items())
- def allitems(self):
- return ((k, v) for k, vl in self.dict.items() for v in vl)
- iterkeys = keys
- itervalues = values
- iteritems = items
- iterallitems = allitems
-
- else:
- def values(self): return [v[-1] for v in self.dict.values()]
- def items(self): return [(k, v[-1]) for k, v in self.dict.items()]
- 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):
- return ((k, v) for k, vl in self.dict.iteritems() for v in vl)
- def allitems(self):
- return [(k, v) for k, vl in self.dict.iteritems() for v in vl]
-
- 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:
- pass
- return default
-
- def append(self, key, value):
- ''' Add a new value to the list of values for this key. '''
- self.dict.setdefault(key, []).append(value)
-
- def replace(self, key, value):
- ''' 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. Attributes are automatically 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'
- #: If true (default), unicode strings are first encoded with `latin1`
- #: and then decoded to match :attr:`input_encoding`.
- recode_unicode = True
-
- def _fix(self, s, encoding=None):
- if isinstance(s, unicode) and self.recode_unicode: # Python 3 WSGI
- s = s.encode('latin1')
- if isinstance(s, bytes): # Python 2 WSGI
- return s.decode(encoding or self.input_encoding)
- return s
-
- def decode(self, encoding=None):
- ''' Returns a copy with all keys and values de- or recoded to match
- :attr:`input_encoding`. Some libraries (e.g. WTForms) want a
- unicode dictionary. '''
- copy = FormsDict()
- enc = copy.input_encoding = encoding or self.input_encoding
- copy.recode_unicode = False
- for key, value in self.allitems():
- copy.append(self._fix(key, enc), self._fix(value, enc))
- return copy
-
- def getunicode(self, name, default=None, encoding=None):
- try:
- return self._fix(self[name], encoding)
- except (UnicodeError, KeyError):
- return default
-
- def __getattr__(self, name, default=unicode()):
- return self.getunicode(name, default=default)
-
-
-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 [_hkey(n) for n in names]:
- if name in self.dict:
- del self.dict[name]
-
-
-class WSGIHeaderDict(DictMixin):
- ''' This dict-like class wraps a WSGI environ dict and provides convenient
- access to HTTP_* fields. Keys and values are native strings
- (2.x bytes or 3.x unicode) and keys are case-insensitive. If the WSGI
- environment contains non-native string values, these are de- or encoded
- using a lossless 'latin1' character set.
-
- The API will remain stable even on changes to the relevant PEPs.
- Currently PEP 333, 444 and 3333 are supported. (PEP 444 is the only one
- that uses non-native strings.)
- '''
- #: List of keys that do not have a 'HTTP_' prefix.
- cgikeys = ('CONTENT_TYPE', 'CONTENT_LENGTH')
-
- def __init__(self, environ):
- self.environ = environ
-
- def _ekey(self, key):
- ''' Translate header field name to CGI/WSGI environ key. '''
- key = key.replace('-','_').upper()
- if key in self.cgikeys:
- return key
- return 'HTTP_' + key
-
- def raw(self, key, default=None):
- ''' Return the header value as is (may be bytes or unicode). '''
- return self.environ.get(self._ekey(key), default)
-
- def __getitem__(self, key):
- return tonat(self.environ[self._ekey(key)], 'latin1')
-
- def __setitem__(self, key, value):
- raise TypeError("%s is read-only." % self.__class__)
-
- def __delitem__(self, key):
- raise TypeError("%s is read-only." % self.__class__)
-
- def __iter__(self):
- for key in self.environ:
- if key[:5] == 'HTTP_':
- yield key[5:].replace('_', '-').title()
- elif key in self.cgikeys:
- yield key.replace('_', '-').title()
-
- 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).items(): setattr(self, key, value)
- return self
-
-
-class AppStack(list):
- """ A stack-like list. Calling it returns the head of the stack. """
-
- def __call__(self):
- """ Return the current default application. """
- return self[-1]
-
- def push(self, value=None):
- """ Add a new :class:`Bottle` instance to the stack """
- if not isinstance(value, Bottle):
- value = Bottle()
- self.append(value)
- return value
-
-
-class WSGIFileWrapper(object):
-
- def __init__(self, fp, buffer_size=1024*64):
- self.fp, self.buffer_size = fp, buffer_size
- for attr in ('fileno', 'close', 'read', 'readlines', 'tell', 'seek'):
- if hasattr(fp, attr): setattr(self, attr, getattr(fp, attr))
-
- def __iter__(self):
- buff, read = self.buffer_size, self.read
- while True:
- part = read(buff)
- if not part: return
- yield part
-
-
-
-
-
-
-###############################################################################
-# Application Helper ###########################################################
-###############################################################################
-
-
-def abort(code=500, text='Unknown Error: Application stopped.'):
- """ Aborts execution and causes a HTTP error. """
- raise HTTPError(code, text)
-
-
-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 _file_iter_range(fp, offset, bytes, maxread=1024*1024):
- ''' Yield chunks from a range in a file. No chunk is bigger than maxread.'''
- fp.seek(offset)
- while bytes > 0:
- part = fp.read(min(bytes, maxread))
- if not part: break
- bytes -= len(part)
- yield part
-
-
-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
- and HEAD requests.
- """
- root = os.path.abspath(root) + os.sep
- filename = os.path.abspath(os.path.join(root, filename.strip('/\\')))
- header = dict()
-
- if not filename.startswith(root):
- return HTTPError(403, "Access denied.")
- if not os.path.exists(filename) or not os.path.isfile(filename):
- return HTTPError(404, "File does not exist.")
- if not os.access(filename, os.R_OK):
- return HTTPError(403, "You do not have permission to access this file.")
-
- if mimetype == 'auto':
- mimetype, encoding = mimetypes.guess_type(filename)
- if mimetype: header['Content-Type'] = mimetype
- if encoding: header['Content-Encoding'] = encoding
- elif mimetype:
- header['Content-Type'] = mimetype
-
- if download:
- download = os.path.basename(filename if download == True else download)
- header['Content-Disposition'] = 'attachment; filename="%s"' % download
-
- stats = os.stat(filename)
- header['Content-Length'] = clen = stats.st_size
- lm = time.strftime("%a, %d %b %Y %H:%M:%S GMT", time.gmtime(stats.st_mtime))
- header['Last-Modified'] = lm
-
- ims = request.environ.get('HTTP_IF_MODIFIED_SINCE')
- if ims:
- ims = parse_date(ims.split(";")[0].strip())
- if ims is not None and ims >= int(stats.st_mtime):
- header['Date'] = time.strftime("%a, %d %b %Y %H:%M:%S GMT", time.gmtime())
- return HTTPResponse(status=304, header=header)
-
- body = '' if request.method == 'HEAD' else open(filename, 'rb')
-
- header["Accept-Ranges"] = "bytes"
- ranges = request.environ.get('HTTP_RANGE')
- if 'HTTP_RANGE' in request.environ:
- ranges = list(parse_range_header(request.environ['HTTP_RANGE'], clen))
- if not ranges:
- return HTTPError(416, "Requested Range Not Satisfiable")
- offset, end = ranges[0]
- header["Content-Range"] = "bytes %d-%d/%d" % (offset, end-1, clen)
- header["Content-Length"] = str(end-offset)
- if body: body = _file_iter_range(body, offset, end-offset)
- return HTTPResponse(body, header=header, status=206)
- return HTTPResponse(body, header=header)
-
-
-
-
-
-
-###############################################################################
-# HTTP Utilities and MISC (TODO) ###############################################
-###############################################################################
-
-
-def debug(mode=True):
- """ Change the debug level.
- There is only one debug level supported at the moment."""
- global DEBUG
- DEBUG = bool(mode)
-
-
-def parse_date(ims):
- """ Parse rfc1123, rfc850 and asctime timestamps and return UTC epoch. """
- try:
- ts = email.utils.parsedate_tz(ims)
- return time.mktime(ts[:8] + (0,)) - (ts[9] or 0) - time.timezone
- except (TypeError, ValueError, IndexError, OverflowError):
- return None
-
-
-def parse_auth(header):
- """ Parse rfc2617 HTTP authentication header string (basic) and return (user,pass) tuple or None"""
- try:
- method, data = header.split(None, 1)
- if method.lower() == 'basic':
- user, pwd = touni(base64.b64decode(tob(data))).split(':',1)
- return user, pwd
- except (KeyError, ValueError):
- return None
-
-def parse_range_header(header, maxlen=0):
- ''' Yield (start, end) ranges parsed from a HTTP Range header. Skip
- unsatisfiable ranges. The end index is non-inclusive.'''
- if not header or header[:6] != 'bytes=': return
- ranges = [r.split('-', 1) for r in header[6:].split(',') if '-' in r]
- for start, end in ranges:
- try:
- if not start: # bytes=-100 -> last 100 bytes
- start, end = max(0, maxlen-int(end)), maxlen
- elif not end: # bytes=100- -> all but the first 99 bytes
- start, end = int(start), maxlen
- else: # bytes=100-200 -> bytes 100-200 (inclusive)
- start, end = int(start), min(int(end)+1, maxlen)
- if 0 <= start < end <= maxlen:
- yield start, end
- except ValueError:
- pass
-
-def _lscmp(a, b):
- ''' Compares two strings in a cryptographically save way:
- Runtime is not affected by length of common prefix. '''
- return not sum(0 if x==y else 1 for x, y in zip(a, b)) and len(a) == len(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(tob(key), msg).digest())
- return tob('!') + sig + tob('?') + msg
-
-
-def cookie_decode(data, key):
- ''' Verify and decode an encoded string. Return an object or None.'''
- data = tob(data)
- if cookie_is_encoded(data):
- sig, msg = data.split(tob('?'), 1)
- if _lscmp(sig[1:], base64.b64encode(hmac.new(tob(key), msg).digest())):
- return pickle.loads(base64.b64decode(msg))
- return None
-
-
-def cookie_is_encoded(data):
- ''' Return True if the argument looks like a encoded cookie.'''
- 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
- takes optional keyword arguments. The output is best described by example::
-
- a() -> '/a'
- b(x, y) -> '/b/:x/:y'
- c(x, y=5) -> '/c/:x' and '/c/:x/:y'
- d(x=5, y=6) -> '/d' and '/d/:x' and '/d/:x/:y'
- """
- import inspect # Expensive module. Only import if necessary.
- path = '/' + func.__name__.replace('__','/').lstrip('/')
- spec = inspect.getargspec(func)
- argc = len(spec[0]) - len(spec[3] or [])
- path += ('/:%s' * argc) % tuple(spec[0][:argc])
- yield path
- for arg in spec[0][argc:]:
- path += '/:%s' % arg
- yield path
-
-
-def path_shift(script_name, path_info, shift=1):
- ''' Shift path fragments from PATH_INFO to SCRIPT_NAME and vice versa.
-
- :return: The modified paths.
- :param script_name: The SCRIPT_NAME path.
- :param script_name: The PATH_INFO path.
- :param shift: The number of path fragments to shift. May be negative to
- change the shift direction. (default: 1)
- '''
- if shift == 0: return script_name, path_info
- pathlist = path_info.strip('/').split('/')
- scriptlist = script_name.strip('/').split('/')
- if pathlist and pathlist[0] == '': pathlist = []
- if scriptlist and scriptlist[0] == '': scriptlist = []
- if shift > 0 and shift <= len(pathlist):
- moved = pathlist[:shift]
- scriptlist = scriptlist + moved
- pathlist = pathlist[shift:]
- elif shift < 0 and shift >= -len(scriptlist):
- moved = scriptlist[shift:]
- pathlist = moved + pathlist
- scriptlist = scriptlist[:shift]
- else:
- empty = 'SCRIPT_NAME' if shift < 0 else 'PATH_INFO'
- raise AssertionError("Cannot shift. Nothing left from %s" % empty)
- new_script_name = '/' + '/'.join(scriptlist)
- new_path_info = '/' + '/'.join(pathlist)
- if path_info.endswith('/') and pathlist: new_path_info += '/'
- return new_script_name, new_path_info
-
-
-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):
- @functools.wraps(func)
- def wrapper(*args, **kargs):
- for key, value in vkargs.items():
- if key not in kargs:
- abort(403, 'Missing parameter: %s' % key)
- try:
- kargs[key] = value(kargs[key])
- except ValueError:
- abort(403, 'Wrong parameter format for: %s' % key)
- return func(*args, **kargs)
- return wrapper
- return decorator
-
-
-def auth_basic(check, realm="private", text="Access denied"):
- ''' Callback decorator to require HTTP auth (basic).
- TODO: Add route(check_auth=...) parameter. '''
- def decorator(func):
- def wrapper(*a, **ka):
- user, password = request.auth or (None, None)
- if user is None or not check(user, password):
- response.headers['WWW-Authenticate'] = 'Basic realm="%s"' % realm
- return HTTPError(401, text)
- return func(*a, **ka)
- return wrapper
- return decorator
-
-
-# Shortcuts for common Bottle methods.
-# They all refer to the current default application.
-
-def make_default_app_wrapper(name):
- ''' Return a callable that relays calls to the current default app. '''
- @functools.wraps(getattr(Bottle, name))
- def wrapper(*a, **ka):
- return getattr(app(), name)(*a, **ka)
- return wrapper
-
-route = make_default_app_wrapper('route')
-get = make_default_app_wrapper('get')
-post = make_default_app_wrapper('post')
-put = make_default_app_wrapper('put')
-delete = make_default_app_wrapper('delete')
-error = make_default_app_wrapper('error')
-mount = make_default_app_wrapper('mount')
-hook = make_default_app_wrapper('hook')
-install = make_default_app_wrapper('install')
-uninstall = make_default_app_wrapper('uninstall')
-url = make_default_app_wrapper('get_url')
-
-
-
-
-
-
-
-###############################################################################
-# Server Adapter ###############################################################
-###############################################################################
-
-
-class ServerAdapter(object):
- quiet = False
- def __init__(self, host='127.0.0.1', port=8080, **config):
- self.options = config
- self.host = host
- self.port = int(port)
-
- def run(self, handler): # pragma: no cover
- pass
-
- def __repr__(self):
- args = ', '.join(['%s=%s'%(k,repr(v)) for k, v in self.options.items()])
- return "%s(%s)" % (self.__class__.__name__, args)
-
-
-class CGIServer(ServerAdapter):
- quiet = True
- def run(self, handler): # pragma: no cover
- from wsgiref.handlers import CGIHandler
- 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
- self.options.setdefault('bindAddress', (self.host, self.port))
- flup.server.fcgi.WSGIServer(handler, **self.options).run()
-
-
-class WSGIRefServer(ServerAdapter):
- def run(self, handler): # pragma: no cover
- from wsgiref.simple_server import make_server, WSGIRequestHandler
- if self.quiet:
- class QuietHandler(WSGIRequestHandler):
- def log_request(*args, **kw): pass
- self.options['handler_class'] = QuietHandler
- srv = make_server(self.host, self.port, handler, **self.options)
- srv.serve_forever()
-
-
-class CherryPyServer(ServerAdapter):
- def run(self, handler): # pragma: no cover
- from cherrypy import wsgiserver
- server = wsgiserver.CherryPyWSGIServer((self.host, self.port), handler)
- try:
- server.start()
- finally:
- server.stop()
-
-
-class WaitressServer(ServerAdapter):
- def run(self, handler):
- from waitress import serve
- serve(handler, host=self.host, port=self.port)
-
-
-class PasteServer(ServerAdapter):
- def run(self, handler): # pragma: no cover
- from paste import httpserver
- if not self.quiet:
- from paste.translogger import TransLogger
- handler = TransLogger(handler)
- httpserver.serve(handler, host=self.host, port=str(self.port),
- **self.options)
-
-
-class MeinheldServer(ServerAdapter):
- def run(self, handler):
- from meinheld import server
- server.listen((self.host, self.port))
- server.run(handler)
-
-
-class FapwsServer(ServerAdapter):
- """ Extremely fast webserver using libev. See http://www.fapws.org/ """
- def run(self, handler): # pragma: no cover
- import fapws._evwsgi as evwsgi
- from fapws import base, config
- port = self.port
- if float(config.SERVER_IDENT[-2:]) > 0.4:
- # fapws3 silently changed its API in 0.5
- port = str(port)
- evwsgi.start(self.host, port)
- # fapws3 never releases the GIL. Complain upstream. I tried. No luck.
- if 'BOTTLE_CHILD' in os.environ and not self.quiet:
- _stderr("WARNING: Auto-reloading does not work with Fapws3.\n")
- _stderr(" (Fapws3 breaks python thread support)\n")
- evwsgi.set_base_module(base)
- def app(environ, start_response):
- environ['wsgi.multiprocess'] = False
- return handler(environ, start_response)
- evwsgi.wsgi_cb(('', app))
- evwsgi.run()
-
-
-class TornadoServer(ServerAdapter):
- """ The super hyped asynchronous server by facebook. Untested. """
- def run(self, handler): # pragma: no cover
- import tornado.wsgi, tornado.httpserver, tornado.ioloop
- container = tornado.wsgi.WSGIContainer(handler)
- server = tornado.httpserver.HTTPServer(container)
- server.listen(port=self.port)
- tornado.ioloop.IOLoop.instance().start()
-
-
-class AppEngineServer(ServerAdapter):
- """ Adapter for Google App Engine. """
- quiet = True
- def run(self, handler):
- from google.appengine.ext.webapp import util
- # A main() function in the handler script enables 'App Caching'.
- # Lets makes sure it is there. This _really_ improves performance.
- module = sys.modules.get('__main__')
- if module and not hasattr(module, 'main'):
- module.main = lambda: util.run_wsgi_app(handler)
- util.run_wsgi_app(handler)
-
-
-class TwistedServer(ServerAdapter):
- """ Untested. """
- def run(self, handler):
- from twisted.web import server, wsgi
- from twisted.python.threadpool import ThreadPool
- from twisted.internet import reactor
- thread_pool = ThreadPool()
- thread_pool.start()
- reactor.addSystemEventTrigger('after', 'shutdown', thread_pool.stop)
- factory = server.Site(wsgi.WSGIResource(reactor, thread_pool, handler))
- reactor.listenTCP(self.port, factory, interface=self.host)
- reactor.run()
-
-
-class DieselServer(ServerAdapter):
- """ Untested. """
- def run(self, handler):
- from diesel.protocols.wsgi import WSGIApplication
- app = WSGIApplication(handler, port=self.port)
- app.run()
-
-
-class GeventServer(ServerAdapter):
- """ Untested. Options:
-
- * `monkey` (default: True) fixes the stdlib to use greenthreads.
- * `fast` (default: False) uses libevent's http server, but has some
- issues: No streaming, no pipelining, no SSL.
- """
- def run(self, handler):
- from gevent import wsgi as wsgi_fast, pywsgi, monkey, local
- if self.options.get('monkey', True):
- 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. See http://gunicorn.org/configure.html for options. """
- def run(self, handler):
- 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):
- """ Untested """
- def run(self, handler):
- from eventlet import wsgi, listen
- try:
- wsgi.server(listen((self.host, self.port)), handler,
- log_output=(not self.quiet))
- except TypeError:
- # Fallback, if we have old version of eventlet
- wsgi.server(listen((self.host, self.port)), handler)
-
-
-class RocketServer(ServerAdapter):
- """ Untested. """
- def run(self, handler):
- from rocket import Rocket
- server = Rocket((self.host, self.port), 'wsgi', { 'wsgi_app' : handler })
- server.start()
-
-
-class BjoernServer(ServerAdapter):
- """ Fast server written in C: https://github.com/jonashaag/bjoern """
- def run(self, handler):
- from bjoern import run
- run(handler, self.host, self.port)
-
-
-class AutoServer(ServerAdapter):
- """ Untested. """
- adapters = [WaitressServer, PasteServer, TwistedServer, CherryPyServer, WSGIRefServer]
- def run(self, handler):
- for sa in self.adapters:
- try:
- return sa(self.host, self.port, **self.options).run(handler)
- except ImportError:
- pass
-
-server_names = {
- 'cgi': CGIServer,
- 'flup': FlupFCGIServer,
- 'wsgiref': WSGIRefServer,
- 'waitress': WaitressServer,
- 'cherrypy': CherryPyServer,
- 'paste': PasteServer,
- 'fapws3': FapwsServer,
- 'tornado': TornadoServer,
- 'gae': AppEngineServer,
- 'twisted': TwistedServer,
- 'diesel': DieselServer,
- 'meinheld': MeinheldServer,
- 'gunicorn': GunicornServer,
- 'eventlet': EventletServer,
- 'gevent': GeventServer,
- 'rocket': RocketServer,
- 'bjoern' : BjoernServer,
- 'auto': AutoServer,
-}
-
-
-
-
-
-
-###############################################################################
-# Application Control ##########################################################
-###############################################################################
-
-
-def load(target, **namespace):
- """ Import a module or fetch an object from a module.
-
- * ``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)
- package_name = module.split('.')[0]
- namespace[package_name] = sys.modules[package_name]
- return eval('%s.%s' % (module, target), namespace)
-
-
-def load_app(target):
- """ 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
-
-_debug = debug
-def run(app=None, server='wsgiref', host='127.0.0.1', port=8080,
- interval=1, reloader=False, quiet=False, plugins=None,
- debug=False, **kargs):
- """ Start a server instance. This method blocks until the server terminates.
-
- :param app: WSGI application or target string supported by
- :func:`load_app`. (default: :func:`default_app`)
- :param server: Server adapter to use. See :data:`server_names` keys
- for valid names or pass a :class:`ServerAdapter` subclass.
- (default: `wsgiref`)
- :param host: Server address to bind to. Pass ``0.0.0.0`` to listens on
- all interfaces including the external one. (default: 127.0.0.1)
- :param port: Server port to bind to. Values below 1024 require root
- privileges. (default: 8080)
- :param reloader: Start auto-reloading server? (default: False)
- :param interval: Auto-reloader interval in seconds (default: 1)
- :param quiet: Suppress output to stdout and stderr? (default: False)
- :param options: Options passed to the server adapter.
- """
- if NORUN: return
- if reloader and not os.environ.get('BOTTLE_CHILD'):
- try:
- lockfile = None
- 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
-
- try:
- _debug(debug)
- 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 v%s server starting up (using %s)...\n" % (__version__, repr(server)))
- _stderr("Listening on http://%s:%d/\n" % (server.host, server.port))
- _stderr("Hit Ctrl-C to quit.\n\n")
-
- if reloader:
- 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
- except (SystemExit, MemoryError):
- raise
- except:
- if not reloader: raise
- if not getattr(server, 'quiet', quiet):
- print_exc()
- time.sleep(interval)
- sys.exit(3)
-
-
-
-class FileCheckerThread(threading.Thread):
- ''' 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
- #: 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 list(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 list(files.items()):
- if not exists(path) or mtime(path) > lmtime:
- 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 exc_type is not None and issubclass(exc_type, KeyboardInterrupt)
-
-
-
-
-
-###############################################################################
-# Template Adapters ############################################################
-###############################################################################
-
-
-class TemplateError(HTTPError):
- def __init__(self, message):
- HTTPError.__init__(self, 500, message)
-
-
-class BaseTemplate(object):
- """ Base class and minimal API for template adapters """
- extensions = ['tpl','html','thtml','stpl']
- settings = {} #used in prepare()
- defaults = {} #used in render()
-
- def __init__(self, source=None, name=None, lookup=[], encoding='utf8', **settings):
- """ Create a new template.
- If the source parameter (str or buffer) is missing, the name argument
- is used to guess a template filename. Subclasses can assume that
- self.source and/or self.filename are set. Both are strings.
- The lookup, encoding and settings parameters are stored as instance
- variables.
- The lookup parameter stores a list containing directory paths.
- The encoding parameter should be used to decode byte strings or files.
- The settings parameter contains a dict for engine-specific settings.
- """
- self.name = name
- self.source = source.read() if hasattr(source, 'read') else source
- self.filename = source.filename if hasattr(source, 'filename') else None
- self.lookup = [os.path.abspath(x) for x in lookup]
- self.encoding = encoding
- self.settings = self.settings.copy() # Copy from class variable
- self.settings.update(settings) # Apply
- if not self.source and self.name:
- self.filename = self.search(self.name, self.lookup)
- if not self.filename:
- raise TemplateError('Template %s not found.' % repr(name))
- if not self.source and not self.filename:
- raise TemplateError('No template specified.')
- self.prepare(**self.settings)
-
- @classmethod
- def search(cls, name, lookup=[]):
- """ Search name in all directories specified in lookup.
- First without, then with common extensions. Return first hit. """
- if os.path.isfile(name): return name
- for spath in lookup:
- fname = os.path.join(spath, name)
- if os.path.isfile(fname):
- return fname
- for ext in cls.extensions:
- if os.path.isfile('%s.%s' % (fname, ext)):
- return '%s.%s' % (fname, ext)
-
- @classmethod
- 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]
-
- def prepare(self, **options):
- """ Run preparations (parsing, caching, ...).
- It should be possible to call this again to refresh a template or to
- update settings.
- """
- raise NotImplementedError
-
- def render(self, *args, **kwargs):
- """ Render the template with the specified local variables and return
- a single byte or unicode string. If it is a byte string, the encoding
- must match self.encoding. This method must be thread-safe!
- Local variables may be provided in dictionaries (*args)
- or directly, as keywords (**kwargs).
- """
- raise NotImplementedError
-
-
-class MakoTemplate(BaseTemplate):
- def prepare(self, **options):
- from mako.template import Template
- from mako.lookup import TemplateLookup
- options.update({'input_encoding':self.encoding})
- options.setdefault('format_exceptions', bool(DEBUG))
- lookup = TemplateLookup(directories=self.lookup, **options)
- if self.source:
- self.tpl = Template(self.source, lookup=lookup, **options)
- else:
- self.tpl = Template(uri=self.name, filename=self.filename, lookup=lookup, **options)
-
- def render(self, *args, **kwargs):
- for dictarg in args: kwargs.update(dictarg)
- _defaults = self.defaults.copy()
- _defaults.update(kwargs)
- return self.tpl.render(**_defaults)
-
-
-class CheetahTemplate(BaseTemplate):
- def prepare(self, **options):
- from Cheetah.Template import Template
- self.context = threading.local()
- self.context.vars = {}
- options['searchList'] = [self.context.vars]
- if self.source:
- self.tpl = Template(source=self.source, **options)
- else:
- self.tpl = Template(file=self.filename, **options)
-
- def render(self, *args, **kwargs):
- for dictarg in args: kwargs.update(dictarg)
- self.context.vars.update(self.defaults)
- self.context.vars.update(kwargs)
- out = str(self.tpl)
- self.context.vars.clear()
- return out
-
-
-class Jinja2Template(BaseTemplate):
- def prepare(self, filters=None, tests=None, **kwargs):
- from jinja2 import Environment, FunctionLoader
- if 'prefix' in kwargs: # TODO: to be removed after a while
- raise RuntimeError('The keyword argument `prefix` has been removed. '
- 'Use the full jinja2 environment name line_statement_prefix instead.')
- self.env = Environment(loader=FunctionLoader(self.loader), **kwargs)
- if filters: self.env.filters.update(filters)
- if tests: self.env.tests.update(tests)
- if self.source:
- self.tpl = self.env.from_string(self.source)
- else:
- self.tpl = self.env.get_template(self.filename)
-
- def render(self, *args, **kwargs):
- for dictarg in args: kwargs.update(dictarg)
- _defaults = self.defaults.copy()
- _defaults.update(kwargs)
- return self.tpl.render(**_defaults)
-
- def loader(self, name):
- fname = self.search(name, self.lookup)
- if not fname: return
- with open(fname, "rb") as f:
- return f.read().decode(self.encoding)
-
-
-class SimpleTALTemplate(BaseTemplate):
- ''' Deprecated, do not use. '''
- def prepare(self, **options):
- depr('The SimpleTAL template handler is deprecated'\
- ' and will be removed in 0.12')
- from simpletal import simpleTAL
- if self.source:
- self.tpl = simpleTAL.compileHTMLTemplate(self.source)
- else:
- with open(self.filename, 'rb') as fp:
- self.tpl = simpleTAL.compileHTMLTemplate(tonat(fp.read()))
-
- def render(self, *args, **kwargs):
- from simpletal import simpleTALES
- for dictarg in args: kwargs.update(dictarg)
- context = simpleTALES.Context()
- for k,v in self.defaults.items():
- context.addGlobal(k, v)
- for k,v in kwargs.items():
- context.addGlobal(k, v)
- output = StringIO()
- self.tpl.expand(context, output)
- return output.getvalue()
-
-
-class SimpleTemplate(BaseTemplate):
- blocks = ('if', 'elif', 'else', 'try', 'except', 'finally', 'for', 'while',
- 'with', 'def', 'class')
- dedent_blocks = ('elif', 'else', 'except', 'finally')
-
- @lazy_attribute
- def re_pytokens(cls):
- ''' This matches comments and all kinds of quoted strings but does
- NOT match comments (#...) within quoted strings. (trust me) '''
- return re.compile(r'''
- (''(?!')|""(?!")|'{6}|"{6} # Empty strings (all 4 types)
- |'(?:[^\\']|\\.)+?' # Single quotes (')
- |"(?:[^\\"]|\\.)+?" # Double quotes (")
- |'{3}(?:[^\\]|\\.|\n)+?'{3} # Triple-quoted strings (')
- |"{3}(?:[^\\]|\\.|\n)+?"{3} # Triple-quoted strings (")
- |\#.* # Comments
- )''', re.VERBOSE)
-
- def prepare(self, escape_func=html_escape, noescape=False, **kwargs):
- self.cache = {}
- enc = self.encoding
- self._str = lambda x: touni(x, enc)
- self._escape = lambda x: escape_func(touni(x, enc))
- if noescape:
- self._str, self._escape = self._escape, self._str
-
- @classmethod
- def split_comment(cls, code):
- """ Removes comments (#...) from python code. """
- if '#' not in code: return code
- #: Remove comments only (leave quoted strings as they are)
- subf = lambda m: '' if m.group(0)[0]=='#' else m.group(0)
- return re.sub(cls.re_pytokens, subf, code)
-
- @cached_property
- def co(self):
- return compile(self.code, self.filename or '<string>', 'exec')
-
- @cached_property
- def code(self):
- stack = [] # Current Code indentation
- lineno = 0 # Current line of code
- ptrbuffer = [] # Buffer for printable strings and token tuple instances
- codebuffer = [] # Buffer for generated python code
- multiline = dedent = oneline = False
- template = self.source or open(self.filename, 'rb').read()
-
- def yield_tokens(line):
- for i, part in enumerate(re.split(r'\{\{(.*?)\}\}', line)):
- if i % 2:
- if part.startswith('!'): yield 'RAW', part[1:]
- else: yield 'CMD', part
- else: yield 'TXT', part
-
- def flush(): # Flush the ptrbuffer
- if not ptrbuffer: return
- cline = ''
- for line in ptrbuffer:
- for token, value in line:
- if token == 'TXT': cline += repr(value)
- elif token == 'RAW': cline += '_str(%s)' % value
- elif token == 'CMD': cline += '_escape(%s)' % value
- cline += ', '
- cline = cline[:-2] + '\\\n'
- cline = cline[:-2]
- if cline[:-1].endswith('\\\\\\\\\\n'):
- cline = cline[:-7] + cline[-1] # 'nobr\\\\\n' --> 'nobr'
- cline = '_printlist([' + cline + '])'
- del ptrbuffer[:] # Do this before calling code() again
- code(cline)
-
- def code(stmt):
- for line in stmt.splitlines():
- codebuffer.append(' ' * len(stack) + line.strip())
-
- for line in template.splitlines(True):
- lineno += 1
- line = touni(line, self.encoding)
- sline = line.lstrip()
- if lineno <= 2:
- m = re.match(r"%\s*#.*coding[:=]\s*([-\w.]+)", sline)
- if m: self.encoding = m.group(1)
- if m: line = line.replace('coding','coding (removed)')
- if sline and sline[0] == '%' and sline[:2] != '%%':
- 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() # 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:"
- if dedent and not oneline and not multiline:
- cmd = stack.pop()
- code(line)
- oneline = not cline.endswith(':') # "if 1: pass"
- multiline = cmd if cline.endswith('\\') else False
- if not oneline and not multiline:
- stack.append(cmd)
- elif cmd == 'end' and stack:
- code('#end(%s) %s' % (stack.pop(), line.strip()[3:]))
- elif cmd == 'include':
- p = cline.split(None, 2)[1:]
- if len(p) == 2:
- code("_=_include(%s, _stdout, %s)" % (repr(p[0]), p[1]))
- elif p:
- code("_=_include(%s, _stdout)" % repr(p[0]))
- else: # Empty %include -> reverse of %rebase
- code("_printlist(_base)")
- elif cmd == 'rebase':
- p = cline.split(None, 2)[1:]
- if len(p) == 2:
- code("globals()['_rebase']=(%s, dict(%s))" % (repr(p[0]), p[1]))
- elif p:
- code("globals()['_rebase']=(%s, {})" % repr(p[0]))
- else:
- code(line)
- else: # Line starting with text (not '%') or '%%' (escaped)
- if line.strip().startswith('%%'):
- line = line.replace('%%', '%', 1)
- ptrbuffer.append(yield_tokens(line))
- flush()
- return '\n'.join(codebuffer) + '\n'
-
- def subtemplate(self, _name, _stdout, *args, **kwargs):
- for dictarg in args: kwargs.update(dictarg)
- if _name not in self.cache:
- self.cache[_name] = self.__class__(name=_name, lookup=self.lookup)
- return self.cache[_name].execute(_stdout, kwargs)
-
- def execute(self, _stdout, *args, **kwargs):
- for dictarg in args: kwargs.update(dictarg)
- env = self.defaults.copy()
- env.update({'_stdout': _stdout, '_printlist': _stdout.extend,
- '_include': self.subtemplate, '_str': self._str,
- '_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']
- rargs['_base'] = _stdout[:] #copy stdout
- del _stdout[:] # clear stdout
- return self.subtemplate(subtpl,_stdout,rargs)
- return env
-
- def render(self, *args, **kwargs):
- """ Render the template using keyword arguments as local variables. """
- for dictarg in args: kwargs.update(dictarg)
- stdout = []
- self.execute(stdout, kwargs)
- return ''.join(stdout)
-
-
-def template(*args, **kwargs):
- '''
- Get a rendered template as a string iterator.
- You can use a name, a filename or a template string as first parameter.
- Template rendering arguments can be passed as dictionaries
- or directly (as keyword arguments).
- '''
- tpl = args[0] if args else None
- template_adapter = kwargs.pop('template_adapter', SimpleTemplate)
- if tpl not in TEMPLATES or DEBUG:
- settings = kwargs.pop('template_settings', {})
- lookup = kwargs.pop('template_lookup', TEMPLATE_PATH)
- if isinstance(tpl, template_adapter):
- TEMPLATES[tpl] = tpl
- if settings: TEMPLATES[tpl].prepare(**settings)
- elif "\n" in tpl or "{" in tpl or "%" in tpl or '$' in tpl:
- TEMPLATES[tpl] = template_adapter(source=tpl, lookup=lookup, **settings)
- else:
- TEMPLATES[tpl] = template_adapter(name=tpl, lookup=lookup, **settings)
- if not TEMPLATES[tpl]:
- abort(500, 'Template (%s) not found' % tpl)
- for dictarg in args[1:]: kwargs.update(dictarg)
- return TEMPLATES[tpl].render(kwargs)
-
-mako_template = functools.partial(template, template_adapter=MakoTemplate)
-cheetah_template = functools.partial(template, template_adapter=CheetahTemplate)
-jinja2_template = functools.partial(template, template_adapter=Jinja2Template)
-simpletal_template = functools.partial(template, template_adapter=SimpleTALTemplate)
-
-
-def view(tpl_name, **defaults):
- ''' Decorator: renders a template for a handler.
- The handler can control its behavior like that:
-
- - return a dict of template vars to fill out the template
- - return something other than a dict and the view decorator will not
- process the template, but return the handler result as is.
- This includes returning a HTTPResponse(dict) to get,
- for instance, JSON with autojson or other castfilters.
- '''
- def decorator(func):
- @functools.wraps(func)
- def wrapper(*args, **kwargs):
- result = func(*args, **kwargs)
- if isinstance(result, (dict, DictMixin)):
- tplvars = defaults.copy()
- tplvars.update(result)
- return template(tpl_name, **tplvars)
- return result
- return wrapper
- return decorator
-
-mako_view = functools.partial(view, template_adapter=MakoTemplate)
-cheetah_view = functools.partial(view, template_adapter=CheetahTemplate)
-jinja2_view = functools.partial(view, template_adapter=Jinja2Template)
-simpletal_view = functools.partial(view, template_adapter=SimpleTALTemplate)
-
-
-
-
-
-
-###############################################################################
-# Constants and Globals ########################################################
-###############################################################################
-
-
-TEMPLATE_PATH = ['./', './views/']
-TEMPLATES = {}
-DEBUG = False
-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.items())
-
-#: The default template used for error pages. Override with @error()
-ERROR_PAGE_TEMPLATE = """
-%try:
- %from bottle import DEBUG, HTTP_CODES, request, touni
- %status_name = HTTP_CODES.get(e.status, 'Unknown').title()
- <!DOCTYPE HTML PUBLIC "-//IETF//DTD HTML 2.0//EN">
- <html>
- <head>
- <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;}
- 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>
- <pre>{{e.output}}</pre>
- %if DEBUG and e.exception:
- <h2>Exception:</h2>
- <pre>{{repr(e.exception)}}</pre>
- %end
- %if DEBUG and e.traceback:
- <h2>Traceback:</h2>
- <pre>{{e.traceback}}</pre>
- %end
- </body>
- </html>
-%except ImportError:
- <b>ImportError:</b> Could not generate the error page. Please add bottle to
- the import path.
-%end
-"""
-
-#: A thread-safe instance of :class:`LocalRequest`. If accessed from within a
-#: request callback, this instance always refers to the *current* request
-#: (even on a multithreaded server).
-request = LocalRequest()
-
-#: A thread-safe instance of :class:`LocalResponse`. It is used to change the
-#: HTTP response for the *current* request.
-response = LocalResponse()
-
-#: A thread-safe namespace. Not used by Bottle.
-local = threading.local()
-
-# Initialize app stack (create first empty Bottle app)
-# BC: 0.6.4 and needed for run()
-app = default_app = AppStack()
-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:
- _stdout('Bottle %s\n'%__version__)
- sys.exit(0)
- if not args:
- parser.print_help()
- _stderr('\nError: No application specified.\n')
- sys.exit(1)
-
- sys.path.insert(0, '.')
- sys.modules.setdefault('bottle', sys.modules['__main__'])
-
- host, port = (opt.bind or 'localhost'), 8080
- if ':' in host:
- host, port = host.rsplit(':', 1)
-
- run(args[0], host=host, port=port, server=opt.server,
- reloader=opt.reload, plugins=opt.plugin, debug=opt.debug)
-
-
-
-
-# THE END
diff --git a/plint/chunk.py b/plint/chunk.py
@@ -1,9 +1,11 @@
import re
+import sys
from haspirater import haspirater
from plint import common, diaeresis, error
from plint.common import normalize, strip_accents_one, is_consonants, APOSTROPHES, is_vowels, get_consonants_regex, \
strip_accents, SURE_END_FEM
+from plint.error import ErrorCollection
from plint.vowels import contains_trema, intersperse
@@ -63,8 +65,12 @@ class Chunk:
new_chunk.weight = self.weight
return new_chunk
- def set_hemistiche(self, hemis):
- self.hemistiche = hemis
+ def set_hemistiche(self, hemistiche):
+ # The hemistiche can take the following values
+ # ok: correct
+ # cut: falls at the middle of a word
+ # fem: preceding word ends by a mute e
+ self.hemistiche = hemistiche
def check_forbidden_characters(self):
es = ""
@@ -523,6 +529,42 @@ class Chunk:
errors_chunk.add(error.ErrorBadCharacters)
return errors_chunk
+ def is_masculine(self):
+ return (self.had_hyphen or False) or (self.word_end or False)
+
+ def render(self, key):
+ if key == 'error' and self.error == 'illegal':
+ return self.illegal_str
+ if key == 'original':
+ return str(self.original)
+ elif key == 'weights':
+ return '-'.join([str(a) for a in self.weights or []])
+ elif key == 'error':
+ return ErrorCollection.keys.get(self.error, '') * len(self.original)
+ elif key == 'hemis':
+ return str(self.hemistiche or "")
+ else:
+ print(key, file=sys.stderr)
+ assert False
+
+ def get_normalized_rendering(self, key, keys):
+ return ('{:^' + str(self.get_max_render_size(keys)) + '}').format(self.render(key))
+
+ def get_min_weight(self):
+ return min(self.weights or [0])
+
+ def get_max_weight(self):
+ return max(self.weights or [0])
+
+ def get_max_render_size(self, keys):
+ return max(len(self.render(key)) for key in keys)
+
+ def print_query(self, chunks_after, chunks_before, output_file):
+ if (self.weights is not None and len(self.weights) > 1
+ and self.weight is not None and self.weight > 0):
+ print(str(self.weight) + ' ' +
+ ' '.join(self.make_query(chunks_before, chunks_after)), file=output_file)
+
LETTERS = {
'f': 'effe',
diff --git a/plint/chunks.py b/plint/chunks.py
@@ -2,8 +2,9 @@ import re
import sys
from pprint import pprint
+from plint import common
from plint.chunk import Chunk
-from plint.common import normalize, get_consonants_regex
+from plint.common import normalize, get_consonants_regex, SURE_END_FEM, strip_accents
from plint.hyphen_splitter import HyphenSplitter
@@ -219,6 +220,113 @@ class Chunks:
errors = errors.union(errors_chunk)
return errors
+ def get_feminine(self, template, threshold, align=None):
+ text = self.annotate(template, threshold)
+ for a in SURE_END_FEM:
+ if text.endswith(a):
+ # if vowel before, it must be fem
+ try:
+ if strip_accents(text[-len(a) - 1]) in common.VOWELS:
+ return ['F']
+ except IndexError:
+ # too short
+ if text == "es":
+ return ['M']
+ else:
+ return ['F']
+ # check that this isn't a one-syllabe word that ends with "es"
+ # => must be masculine as '-es' cannot be mute then
+ # => except if there is another vowel before ("fées")
+ if text.endswith("es") and (len(text) == 2 or strip_accents(text[-3]) not in common.VOWELS):
+ for i in range(4):
+ try:
+ if self.chunks[-i - 1].is_masculine():
+ return ['M']
+ except IndexError:
+ return ['M']
+ return ['F']
+ if not text.endswith('ent'):
+ return ['M']
+ # verse ends with 'ent'
+ if align:
+ if align and align[-2].weight == 0:
+ return ['F'] # mute -ent
+ if align and align[-2].weight > 0 and align[-2].text == 'e':
+ return ['M'] # non-mute "-ent" by the choice of metric
+ possible = []
+ # now, we must check pronunciation?
+ # "tient" vs. "lient" for instance, "excellent"...
+ for possible_phon in self.verse.phon:
+ if possible_phon.endswith(')') or possible_phon.endswith('#'):
+ possible.append('M')
+ else:
+ possible.append('F')
+ if possible_phon.endswith('E') and text.endswith('aient'):
+ # imparfait and conditionnel are masculine...
+ possible.append('M')
+ return possible
+
+ def fit(self, hemistiches, pos=0, count=0):
+ if count > self.verse.pattern.length:
+ return [] # no possibilites
+ if len(hemistiches) > 0 and hemistiches[0] < count:
+ return [] # missed a hemistiche
+ if pos == len(self.chunks):
+ if count == self.verse.pattern.length:
+ return [[]] # empty list is the only possibility
+ else:
+ return []
+ chunk = self.chunks[pos]
+ result = []
+ for weight in (chunk.weights or [0]):
+ next_hemistiches = hemistiches
+ if (len(hemistiches) > 0 and count + weight == hemistiches[0] and
+ chunk.is_vowels()):
+ # need to try to hemistiche
+ if chunk.hemistiche == "ok" or (chunk.hemistiche == "elid" and weight == 0):
+ # we hemistiche here
+ next_hemistiches = next_hemistiches[1:]
+ current = chunk.copy()
+ if current.weights is not None:
+ current.weight = weight
+ for x in self.fit(next_hemistiches, pos + 1, count + weight):
+ result.append([current] + x)
+ return result
+
+ def get_last_count(self):
+ tot = 0
+ for chunk in self.chunks[::-1]:
+ if chunk.original.endswith(' ') or chunk.original.endswith('-'):
+ if tot > 0:
+ break
+ if chunk.weights is not None:
+ tot += min(chunk.weights)
+ if ' ' in chunk.original.rstrip() or '-' in chunk.original.rstrip():
+ if tot > 0:
+ break
+ return tot
+
+ def align_from_keys(self, keys):
+ lines = {}
+ for key in keys:
+ lines[key] = ""
+ for chunk in self.chunks:
+ for key in keys:
+ lines[key] += chunk.get_normalized_rendering(key, keys)
+ if 'weights' in keys:
+ bounds = self.get_weights_bounds()
+ bounds = [str(x) for x in bounds]
+ lines['weights'] += " (total: " + ('-'.join(bounds)
+ if bounds[1] > bounds[0] else bounds[0]) + ")"
+ return ["> " + lines[key] for key in keys if len(lines[key].strip()) > 0]
+
+ def get_weights_bounds(self):
+ bounds = [0, 0]
+ for chunk in self.chunks:
+ bounds[0] += chunk.get_min_weight()
+ bounds[1] += chunk.get_max_weight()
+ return bounds
+
def remove_trivial(words, predicate):
new_chunks = []
diff --git a/plint/common.py b/plint/common.py
@@ -10,7 +10,7 @@ APOSTROPHES = "'’`"
LEGAL = VOWELS + CONSONANTS + ' -'
# a variant of x-sampa such that all french phonemes are one-character
-SUBSTS = [
+SUBSTITUTIONS = [
('#', 'A~'),
('$', 'O~'),
(')', 'E~'),
@@ -24,9 +24,7 @@ SURE_END_FEM = ['es', 'e', 'ë']
# http://stackoverflow.com/questions/517923/what-is-the-best-way-to-remove-accents-in-a-python-unicode-string
def strip_accents_one(s, with_except=False):
- """Strip accent from a string
-
- with_except keeps specifically 'é' and 'è'"""
+ """Strip accent from a string with_except keeps specifically 'é' and 'è'"""
r = []
for x in s:
if with_except and x in ['è', 'é']:
@@ -44,53 +42,47 @@ def strip_accents(s, with_except=False):
def normalize_spaces(text):
"""Remove multiple consecutive whitespace"""
- return re.sub("\s+-*\s*", ' ', text)
+ return re.sub(r"\s+-*\s*", ' ', text)
def remove_punctuation(text, rm_all=False, rm_apostrophe=False, rm_apostrophe_end=True):
"""Remove punctuation from text"""
text = re.sub("[" + APOSTROPHES + "]", "'", text) # no weird apostrophes
if rm_apostrophe:
- text = re.sub("'", "", text)
+ text = re.sub(r"'", "", text)
if rm_apostrophe_end:
- text = re.sub("'*$", "", text) # apostrophes at end of line
- text = re.sub("[‒–—―⁓⸺⸻]", " ", text) # no weird dashes
- text = re.sub("^--*\s", " ", text) # no isolated dashes
- text = re.sub("--*\s", " ", text) # no trailing dashes
- text = re.sub("^\s*-\s*$", " ", text) # no lone dash
- text = re.sub("^--*$", "", text) # no only dashes
+ text = re.sub(r"'*$", "", text) # apostrophes at end of line
+ text = re.sub(r"[‒–—―⁓⸺⸻]", " ", text) # no weird dashes
+ text = re.sub(r"^--*\s", " ", text) # no isolated dashes
+ text = re.sub(r"--*\s", " ", text) # no trailing dashes
+ text = re.sub(r"^\s*-\s*$", " ", text) # no lone dash
+ text = re.sub(r"^--*$", "", text) # no only dashes
# TODO rather: keep only good chars
if not rm_all:
- pattern = re.compile("[^'\w -]", re.UNICODE)
+ pattern = re.compile(r"[^'\w -]", re.UNICODE)
text2 = pattern.sub(' ', text)
else:
- pattern = re.compile("[^\w]", re.UNICODE)
+ pattern = re.compile(r"[^\w]", re.UNICODE)
text2 = pattern.sub('', text)
- text2 = re.sub("\s'*$", " ", text2) # no lonely apostrophes
- text2 = re.sub("^'*$", "", text2) # not only apostrophes
+ text2 = re.sub(r"\s'*$", " ", text2) # no lonely apostrophes
+ text2 = re.sub(r"^'*$", "", text2) # not only apostrophes
return text2
-def is_vowels(chunk, with_h=False, with_y=True, with_crap=False):
- """Test if a chunk is vowels
-
- with_h counts 'h' as vowel, with_y allows 'y'"""
-
- if not with_y and chunk == 'y':
+def is_vowels(chunk_text, with_h=False, with_y=True, with_crap=False):
+ """Test if a chunk is vowels with_h counts 'h' as vowel, with_y allows 'y'"""
+ if not with_y and chunk_text == 'y':
return False
- for char in strip_accents(chunk):
- if char not in VOWELS:
- if (char != 'h' or not with_h) and (char not in ['*', '?'] or not
- with_crap):
- return False
+ for char in strip_accents(chunk_text):
+ if char not in VOWELS and (char != 'h' or not with_h) and (char not in ['*', '?'] or not with_crap):
+ return False
return True
-def is_consonants(chunk):
+def is_consonants(chunk_text):
"""Test if a chunk is consonants"""
-
- for char in strip_accents(chunk):
+ for char in strip_accents(chunk_text):
if char not in CONSONANTS:
return False
return True
@@ -116,15 +108,15 @@ def subst(string, subs):
def to_xsampa(s):
"""convert our modified format to x-sampa"""
- return subst(s, SUBSTS)
+ return subst(s, SUBSTITUTIONS)
def from_xsampa(s):
"""convert x-sampa to our modified format"""
- return subst(s, [(x[1], x[0]) for x in SUBSTS])
+ return subst(s, [(x[1], x[0]) for x in SUBSTITUTIONS])
def get_consonants_regex():
all_consonants = CONSONANTS + CONSONANTS.upper()
- consonants_regexp = re.compile('([^' + all_consonants + '*-]+)', re.UNICODE)
- return consonants_regexp-
\ No newline at end of file
+ consonants_regexp = re.compile(r'([^' + all_consonants + '*-]+)', re.UNICODE)
+ return consonants_regexp
diff --git a/plint/diaeresis.py b/plint/diaeresis.py
@@ -15,7 +15,7 @@ class DiaeresisFinder(object):
try:
self._load_diaeresis()
except json.JSONDecodeError:
- pass # cannot read the file, we assume that another file will be loaded later
+ pass # cannot read the file, we assume that another file will be loaded later
def _load_diaeresis(self):
with open(os.path.join(os.path.dirname(os.path.realpath(__file__)), self._diaeresis_file)) as f:
diff --git a/plint/error.py b/plint/error.py
@@ -1,173 +1,182 @@
-import sys
from plint import common
-class ErrorCollection:
- keys = {'hiatus': 'H', 'ambiguous': 'A', 'illegal': 'I'}
-
- @property
- def prefix(self):
- return "stdin:%d: " % self.line_no
-
- def __init__(self, line_no, line, pattern, verse, errors=[]):
- self.line_no = line_no
- self.line = line
- self.errors = errors
- self.pattern = pattern
- self.verse = verse
-
- def say(self, l, short):
- return l if short else self.prefix + l
-
- def align(self):
- chunks = self.verse.chunks
- keys = ['original', 'error']
- if len(self.verse.possible) == 0:
- keys.append('weights')
- if len(self.pattern.hemistiches) > 0:
- keys.append('hemis')
- formatters = {'weights': lambda x, y: '-'.join([str(a) for a in x]),
- 'error': lambda x, y: ErrorCollection.keys.get(x, '') *
- len(chunk.original)}
- def render(chunk, key):
- if key == 'error' and chunk.error == 'illegal':
- return chunk.illegal_str
- if key == 'original':
- return (formatters.get(key, lambda x, y: str(x)))(chunk.original, chunk)
- elif key == 'weights':
- return (formatters.get(key, lambda x, y: str(x)))(chunk.weights or [], chunk)
- elif key == 'error':
- return (formatters.get(key, lambda x, y: str(x)))(chunk.error, chunk)
- elif key == 'hemis':
- return (formatters.get(key, lambda x, y: str(x)))(chunk.hemistiche or "", chunk)
- else:
- print(key, file=sys.stderr)
- assert(False)
- lines = {}
- for key in keys:
- lines[key] = ""
- for chunk in chunks.chunks:
- l = max(len(render(chunk, key)) for key in keys)
- for key in keys:
- lines[key] += ('{:^'+str(l)+'}').format(render(chunk, key))
- if 'weights' in keys:
- bounds = [0, 0]
- for chunk in self.verse.chunks.chunks:
- weights = chunk.weights or [0]
- bounds[0] += min(weights)
- bounds[1] += max(weights)
- bounds = [str(x) for x in bounds]
- lines['weights'] += " (total: " + ('-'.join(bounds)
- if bounds[1] > bounds[0] else bounds[0]) + ")"
- return ["> " + lines[key] for key in keys if len(lines[key].strip()) > 0]
-
- def lines(self, short=False):
- l = []
- if self.verse.possible != None:
- l.append([self.say(x, short) for x in self.align()])
- for e in self.errors:
- l.append([self.say(e.report(self.pattern), short)])
- return l
-
- def report(self, short=False):
- return '\n'.join(sum(self.lines(short), []))
-
-class ErrorBadElement:
- def report(self, pattern):
- return (self.message
- + _(" (see '%s' above)") % ErrorCollection.keys[self.key])
+class ReportableError:
+
+ def report(self, pattern):
+ raise NotImplementedError
+
+
+class ErrorCollection(ReportableError):
+ keys = {'hiatus': 'H', 'ambiguous': 'A', 'illegal': 'I'}
+
+ @property
+ def prefix(self):
+ return "stdin:%d: " % self.line_no
+
+ def __init__(self, line_no, line, pattern, verse, errors=None):
+ self.line_no = line_no
+ self.line = line
+ self.errors = errors or []
+ self.pattern = pattern
+ self.verse = verse
+
+ def say(self, l, short):
+ return l if short else self.prefix + l
+
+ def align(self):
+ return self.verse.align()
+
+ def lines(self, short=False):
+ result = []
+ if self.verse.possible is not None:
+ result.append([self.say(x, short) for x in self.align()])
+ for e in self.errors:
+ result.append([self.say(e.report(self.pattern), short)])
+ return result
+
+ def report(self, short=False):
+ return '\n'.join(sum(self.lines(short), []))
+
+
+class ErrorBadElement(ReportableError):
+
+ def __init__(self):
+ self.message = None
+ self.key = None
+
+ def report(self, pattern):
+ return (self.message
+ + " (see '%s' above)") % ErrorCollection.keys[self.key]
+
class ErrorBadCharacters(ErrorBadElement):
- @property
- def message(self):
- return _("Illegal characters")
- key = "illegal"
+
+ def __init__(self):
+ super().__init__()
+ self.message = "Illegal Characters"
+ self.key = "illegal"
+
class ErrorForbiddenPattern(ErrorBadElement):
- @property
- def message(self):
- return _("Illegal ambiguous pattern")
- key = "ambiguous"
+
+ def __init__(self):
+ super().__init__()
+ self.message = "Illegal ambiguous pattern"
+ self.key = "ambiguous"
+
class ErrorHiatus(ErrorBadElement):
- @property
- def message(self):
- return _("Illegal hiatus")
- key = "hiatus"
-
-class ErrorBadRhyme:
- def __init__(self, expected, inferred, old_phon=None):
- self.expected = expected
- self.inferred = inferred
- self.old_phon = old_phon
-
- def report(self, pattern):
- return (_("%s for type %s (expected %s, inferred %s)")
- % (self.kind, self.get_id(pattern), self.fmt(self.expected),
- self.fmt(self.inferred)))
+
+ def __init__(self):
+ super().__init__()
+ self.message = "Illegal hiatus"
+ self.key = "hiatus"
+
+
+class ErrorBadRhyme(ReportableError):
+
+ def __init__(self, expected, inferred, old_phon=None):
+ self.expected = expected
+ self.inferred = inferred
+ self.old_phon = old_phon
+ self.kind = None
+
+ def get_id(self, pattern):
+ raise NotImplementedError
+
+ def fmt(self, l):
+ raise NotImplementedError
+
+ def report(self, pattern):
+ return ("%s for type %s (expected %s, inferred %s)"
+ % (self.kind, self.get_id(pattern), self.fmt(self.expected),
+ self.fmt(self.inferred)))
+
class ErrorBadRhymeGenre(ErrorBadRhyme):
- @property
- def kind(self):
- return _("Bad rhyme genre")
- def fmt(self, l):
- result = _(' or ').join(sorted(list(l)))
- if result == '':
- result = "?"
- return "\"" + result + "\""
+ def __init__(self, expected, inferred, old_phon=None):
+ super().__init__(expected, inferred, old_phon)
+ self.kind = "Bad rhyme genre"
+
+ def fmt(self, l):
+ result = ' or '.join(sorted(list(l)))
+ if result == '':
+ result = "?"
+ return "\"" + result + "\""
+
+ def get_id(self, pattern):
+ return pattern.feminine_id
- def get_id(self, pattern):
- return pattern.feminine_id
class ErrorBadRhymeObject(ErrorBadRhyme):
- def get_id(self, pattern):
- return pattern.my_id
+
+ def fmt(self, l):
+ raise NotImplementedError
+
+ def get_id(self, pattern):
+ return pattern.my_id
+
class ErrorBadRhymeSound(ErrorBadRhymeObject):
- @property
- def kind(self):
- return _("Bad rhyme sound")
- def fmt(self, l):
- return '/'.join("\"" + common.to_xsampa(x) + "\"" for x in
- sorted(list(l.sufficient_phon())))
+ def __init__(self, expected, inferred, old_phon=None):
+ super().__init__(expected, inferred, old_phon)
+ self.kind = "Bad rhyme sound"
+
+ def fmt(self, l):
+ return '/'.join("\"" + common.to_xsampa(x) + "\"" for x in
+ sorted(list(l.sufficient_phon())))
+
class ErrorBadRhymeEye(ErrorBadRhymeObject):
- @property
- def kind(self):
- return _("Bad rhyme ending")
-
- def fmt(self, l):
- return "\"-" + l.sufficient_eye(self.old_phon) + "\""
-
-class ErrorBadMetric:
- def report(self, pattern):
- return (_("Illegal metric: expected %d syllable%s%s") %
- (pattern.length, '' if pattern.length == 1 else 's',
- '' if len(pattern.hemistiches) == 0
- else (_(" with hemistiche%s at ") %
- '' if len(pattern.hemistiches) == 1 else 's')
- + ','.join(str(a) for a in pattern.hemistiches)))
-
-class ErrorMultipleWordOccurrence:
- def __init__(self, word, occurrences):
- self.word = word
- self.occurrences = occurrences
-
- def report(self, pattern):
- return (_("Too many occurrences of word \"%s\" for rhyme %s")
- % (self.word, pattern.my_id))
-
-class ErrorIncompleteTemplate:
- def report(self, pattern):
- return _("Poem is not complete")
-
-class ErrorOverflowedTemplate:
- def report(self, pattern):
- return _("Verse is beyond end of poem")
+
+ def __init__(self, expected, inferred, old_phon=None):
+ super().__init__(expected, inferred, old_phon)
+ self.kind = "Bad rhyme ending"
+
+ def fmt(self, l):
+ return "\"-" + l.sufficient_eye(self.old_phon) + "\""
+
+
+class ErrorBadMetric(ReportableError):
+
+ def report(self, pattern):
+ plural_hemistiche = '' if len(pattern.hemistiches) == 1 else 's'
+ plural_syllable = '' if pattern.length == 1 else 's'
+ if len(pattern.hemistiches) == 0:
+ hemistiche_string = ""
+ else:
+ hemistiche_positions = ','.join(str(a) for a in pattern.hemistiches)
+ hemistiche_string = (" with hemistiche%s at " % plural_hemistiche) + hemistiche_positions
+ return ("Illegal metric: expected %d syllable%s%s" %
+ (pattern.length, plural_syllable, hemistiche_string))
+
+
+class ErrorMultipleWordOccurrence(ReportableError):
+
+ def __init__(self, word, occurrences):
+ self.word = word
+ self.occurrences = occurrences
+
+ def report(self, pattern):
+ return "Too many occurrences of word \"%s\" for rhyme %s" % (self.word, pattern.my_id)
+
+
+class ErrorIncompleteTemplate(ReportableError):
+
+ def report(self, pattern):
+ return "Poem is not complete"
+
+
+class ErrorOverflowedTemplate(ReportableError):
+
+ def report(self, pattern):
+ return "Verse is beyond end of poem"
+
class TemplateLoadError(BaseException):
- def __init__(self, msg):
- self.msg = msg
+ def __init__(self, msg):
+ self.msg = msg
diff --git a/plint/filter_irc.py b/plint/filter_irc.py
@@ -1,11 +0,0 @@
-#!/usr/bin/python3 -u
-
-import sys
-
-sys.stdin = sys.stdin.detach()
-while True:
- l = sys.stdin.readline()
- if not l:
- break
- l = l.decode('utf8').strip()
- print(''.join((l.split(">"))[1:]))
diff --git a/plint/hyphen_splitter.py b/plint/hyphen_splitter.py
@@ -28,34 +28,34 @@ class HyphenSplitter:
return self.get_tokens_with_last_word_indication()
def complete_tokens(self, word):
- word_split_by_hyphen = re.split(HYPHEN_REGEX, word)
- for i, sub_word in enumerate(word_split_by_hyphen):
- self.add_subword_to_tokens(sub_word)
+ word_split_by_hyphen = re.split(HYPHEN_REGEX, word)
+ for i, sub_word in enumerate(word_split_by_hyphen):
+ self.add_subword_to_tokens(sub_word)
def add_subword_to_tokens(self, sub_word):
- if self.just_append:
- self.append_to_last_token(sub_word)
- elif self.is_separator(sub_word):
- self.process_separator(sub_word)
- elif is_consonants(normalize(sub_word)):
- self.append_with_miss(sub_word)
- else:
- self.append_with_miss(sub_word)
- self.just_append = True
+ if self.just_append:
+ self.append_to_last_token(sub_word)
+ elif self.is_separator(sub_word):
+ self.process_separator(sub_word)
+ elif is_consonants(normalize(sub_word)):
+ self.append_with_miss(sub_word)
+ else:
+ self.append_with_miss(sub_word)
+ self.just_append = True
def get_tokens_with_last_word_indication(self):
- return list(zip([False] * (len(self.tokens) - 1) + [True], self.tokens))
+ return list(zip([False] * (len(self.tokens) - 1) + [True], self.tokens))
def append_with_miss(self, sub_word):
self.tokens.append(self._missed + sub_word)
self._missed = ""
def process_remaining_missed(self):
- if self._missed:
- if self.tokens:
- self.append_to_last_token(self._missed)
- else:
- self.tokens = [self._missed]
+ if self._missed:
+ if self.tokens:
+ self.append_to_last_token(self._missed)
+ else:
+ self.tokens = [self._missed]
@staticmethod
def get_token(before_word_end, word):
@@ -72,4 +72,4 @@ class HyphenSplitter:
@staticmethod
def is_separator(word):
- return re.match("^-*$", word) or re.match("^ *$", word)-
\ No newline at end of file
+ return re.match(r"^-*$", word) or re.match(r"^ *$", word)
diff --git a/plint/nature.py b/plint/nature.py
@@ -1,23 +1,24 @@
import os
+
def nature_count(x):
- # for uppercased words, only one occurrence should be allowed
- if x.lower() != x:
- return 1
- try:
- return count[x]
- except KeyError:
- # return a reasonable overapproximation
- return 9
+ # for uppercased words, only one occurrence should be allowed
+ if x.lower() != x:
+ return 1
+ try:
+ return count[x]
+ except KeyError:
+ # return a reasonable overapproximation
+ return 9
+
count = {}
f = open(os.path.join(os.path.dirname(os.path.realpath(__file__)), '../data/occurrences'))
while True:
- l = f.readline()
- if not l:
- break
- l = l.rstrip().split(' ')
- count[l[0]] = int(l[1])
-
+ line = f.readline()
+ if not line:
+ break
+ line = line.rstrip().split(' ')
+ count[line[0]] = int(line[1])
diff --git a/plint/pattern.py b/plint/pattern.py
@@ -28,4 +28,4 @@ class Pattern:
for v in verse:
self.length += v
self.hemistiches.append(self.length)
- self.length = self.hemistiches.pop()-
\ No newline at end of file
+ self.length = self.hemistiches.pop()
diff --git a/plint/plint_irc.py b/plint/plint_irc.py
@@ -1,138 +0,0 @@
-#!/usr/bin/python3 -uO
-
-from plint import localization, rhyme, diaeresis
-import re
-import sys
-from plint.template import Template
-from pprint import pprint
-from plint.common import normalize
-
-buf = ""
-lbuf = []
-
-def write(l, descriptor=None):
- if descriptor:
- f = descriptor
- else:
- f = open(sys.argv[2], 'a')
- print(' '.join(l), file=f)
- if not descriptor:
- f.close()
-
-def output(l, descriptor):
- print(' '.join(l), file=descriptor)
- write(l, descriptor if descriptor != sys.stdout else None)
-
-def leading_cap(text):
- for c in text:
- if c.upper() == c.lower():
- continue # symbol
- if c != c.lower():
- return True
- if c != c.upper():
- return False
- return False
-
-def manage(line, descriptor=sys.stdout):
- """manage one line, indicate if an error occurred"""
- global buf
- global lbuf
- usebuf = False
- l = line.rstrip().split(' ')
- text = ' '.join(l[1:])
- if normalize(text.strip()) == '':
- return True # no text
- first = [a for a in l[1:] if a != ''][0]
- if first == '/me' and len(l) >= 3 and l[2] == 'plint':
- # always accept actions
- if len(lbuf) > 0:
- lbuf.append(l)
- else:
- write(l, descriptor)
- return True
- if first[0] == '/':
- return False # ignore other commands
- if first.lstrip().startswith("...") or first.lstrip().startswith("…"):
- text = buf+text
- usebuf = True
- if not usebuf:
- if first[-1] == ':':
- return False
- if not leading_cap(text):
- return False
- if (not (text.rstrip().endswith("...") or text.rstrip().endswith("…")
- or text.lstrip().endswith("...") or text.lstrip().endswith("…")) and
- len(text) < 13):
- return False # too short
- if len(text) > 130:
- return False # too long
- if (text.rstrip().endswith("...") or
- text.rstrip().endswith("…")):
- # it might be a call
- buf = text
- if usebuf:
- lbuf.append(l)
- else:
- lbuf = [l]
- return True
- errors = template.check(text)
- quiet = False
- if errors:
- print(errors.report())
- if not errors:
- buf = ""
- if usebuf:
- for bl in lbuf:
- output(bl, descriptor)
- output(l, descriptor)
- lbuf = []
- return not errors
-
-if len(sys.argv) not in [3, 4]:
- print("Usage: %s TEMPLATE POEM [OFFSET]" % sys.argv[0], file=sys.stderr)
- print("Check POEM according to TEMPLATE, add valid verse from stdin to POEM",
- file=sys.stderr)
- print("Ignore OFFSET lines from POEM",
- file=sys.stderr)
- sys.exit(1)
-
-localization.init_locale()
-
-f = open(sys.argv[1])
-x = f.read()
-f.close()
-template = Template(x)
-
-template.reject_errors = True
-
-offset = 0
-if len(sys.argv) == 4:
- offset = int(sys.argv[3])
-
-pos = 0
-
-localization.init_locale()
-f = open(sys.argv[2], 'r')
-for line in f.readlines():
- pos += 1
- if pos <= offset:
- continue # ignore first lines
- print("%s (read)" % line.rstrip(), file=sys.stderr)
- if not manage(line, sys.stderr):
- print("Existing poem is wrong!", file=sys.stderr)
- sys.exit(2)
-f.close()
-
-print("ready", file=sys.stderr)
-
-def run():
- global lbuf
- while True:
- line = sys.stdin.readline()
- if not line:
- break
- print("Seen: %s" % line, file=sys.stderr)
- manage(' '.join(line.split(' ')[1:]))
-
-run()
-
diff --git a/plint/plint_irc/filter_irc.py b/plint/plint_irc/filter_irc.py
@@ -0,0 +1,11 @@
+#!/usr/bin/python3 -u
+
+import sys
+
+sys.stdin = sys.stdin.detach()
+while True:
+ l = sys.stdin.readline()
+ if not l:
+ break
+ l = l.decode('utf8').strip()
+ print(''.join((l.split(">"))[1:]))
diff --git a/plint/plint_irc/plint_irc.py b/plint/plint_irc/plint_irc.py
@@ -0,0 +1,138 @@
+#!/usr/bin/python3 -uO
+
+from plint import localization, rhyme, diaeresis
+import re
+import sys
+from plint.template import Template
+from pprint import pprint
+from plint.common import normalize
+
+buf = ""
+lbuf = []
+
+def write(l, descriptor=None):
+ if descriptor:
+ f = descriptor
+ else:
+ f = open(sys.argv[2], 'a')
+ print(' '.join(l), file=f)
+ if not descriptor:
+ f.close()
+
+def output(l, descriptor):
+ print(' '.join(l), file=descriptor)
+ write(l, descriptor if descriptor != sys.stdout else None)
+
+def leading_cap(text):
+ for c in text:
+ if c.upper() == c.lower():
+ continue # symbol
+ if c != c.lower():
+ return True
+ if c != c.upper():
+ return False
+ return False
+
+def manage(line, descriptor=sys.stdout):
+ """manage one line, indicate if an error occurred"""
+ global buf
+ global lbuf
+ usebuf = False
+ l = line.rstrip().split(' ')
+ text = ' '.join(l[1:])
+ if normalize(text.strip()) == '':
+ return True # no text
+ first = [a for a in l[1:] if a != ''][0]
+ if first == '/me' and len(l) >= 3 and l[2] == 'plint':
+ # always accept actions
+ if len(lbuf) > 0:
+ lbuf.append(l)
+ else:
+ write(l, descriptor)
+ return True
+ if first[0] == '/':
+ return False # ignore other commands
+ if first.lstrip().startswith("...") or first.lstrip().startswith("…"):
+ text = buf+text
+ usebuf = True
+ if not usebuf:
+ if first[-1] == ':':
+ return False
+ if not leading_cap(text):
+ return False
+ if (not (text.rstrip().endswith("...") or text.rstrip().endswith("…")
+ or text.lstrip().endswith("...") or text.lstrip().endswith("…")) and
+ len(text) < 13):
+ return False # too short
+ if len(text) > 130:
+ return False # too long
+ if (text.rstrip().endswith("...") or
+ text.rstrip().endswith("…")):
+ # it might be a call
+ buf = text
+ if usebuf:
+ lbuf.append(l)
+ else:
+ lbuf = [l]
+ return True
+ errors = template.check(text)
+ quiet = False
+ if errors:
+ print(errors.report())
+ if not errors:
+ buf = ""
+ if usebuf:
+ for bl in lbuf:
+ output(bl, descriptor)
+ output(l, descriptor)
+ lbuf = []
+ return not errors
+
+if len(sys.argv) not in [3, 4]:
+ print("Usage: %s TEMPLATE POEM [OFFSET]" % sys.argv[0], file=sys.stderr)
+ print("Check POEM according to TEMPLATE, add valid verse from stdin to POEM",
+ file=sys.stderr)
+ print("Ignore OFFSET lines from POEM",
+ file=sys.stderr)
+ sys.exit(1)
+
+localization.init_locale()
+
+f = open(sys.argv[1])
+x = f.read()
+f.close()
+template = Template(x)
+
+template.reject_errors = True
+
+offset = 0
+if len(sys.argv) == 4:
+ offset = int(sys.argv[3])
+
+pos = 0
+
+localization.init_locale()
+f = open(sys.argv[2], 'r')
+for line in f.readlines():
+ pos += 1
+ if pos <= offset:
+ continue # ignore first lines
+ print("%s (read)" % line.rstrip(), file=sys.stderr)
+ if not manage(line, sys.stderr):
+ print("Existing poem is wrong!", file=sys.stderr)
+ sys.exit(2)
+f.close()
+
+print("ready", file=sys.stderr)
+
+def run():
+ global lbuf
+ while True:
+ line = sys.stdin.readline()
+ if not line:
+ break
+ print("Seen: %s" % line, file=sys.stderr)
+ manage(' '.join(line.split(' ')[1:]))
+
+run()
+
diff --git a/plint/plint_web.py b/plint/plint_web.py
@@ -1,252 +0,0 @@
-#!/usr/bin/python3 -Ou
-# encoding: utf8
-
-from plint import localization, error, template, diaeresis
-import re
-from plint.bottle import run, Bottle, request, static_file, redirect, response
-from jinja2 import Environment, PackageLoader
-from json import dumps
-import time
-
-env = Environment(loader=PackageLoader('plint_web', 'views'))
-
-
-# force HTTPS usage
-# http://bottlepy.org/docs/dev/faq.html#problems-with-reverse-proxies
-# because bottle makes absolute redirects
-# https://github.com/bottlepy/bottle/blob/9fe68c89e465004a5e6babed0955bc1eeba88002/bottle.py#L2637
-# even though relative Location: is now allowed
-# http://stackoverflow.com/a/25643550
-def fix_https(app):
- def fixed_app(environ, start_response):
- environ['wsgi.url_scheme'] = 'https'
- return app(environ, start_response)
-
- return fixed_app
-
-
-app = Bottle()
-app.wsgi = fix_https(app.wsgi)
-
-THROTTLE_DELAY = 2
-throttle = set()
-
-
-def best_match(matches, header):
- # inspired by http://www.xml.com/pub/a/2005/06/08/restful.html
-
- def parse_one(t):
- parts = t.split(";")
- d = {}
- for param in parts[1:]:
- spl = param.split("=")
- if (len(spl) != 2):
- # this should be formatted as key=value
- # so ignore it
- continue
- k, v = spl
- d[k.strip().lower()] = v.strip()
- if 'q' not in d.keys():
- d['q'] = "1"
- return (parts[0], d)
-
- parts = []
- for p in header.split(","):
- parsed = parse_one(p)
- try:
- value = float(parsed[1]['q'])
- except ValueError:
- # q value should be a float; set it to 0
- value = 0
- parts.append((value, parsed[0].split("-")))
- for lang in [x[1] for x in sorted(parts, reverse=True)]:
- for match in matches:
- if match in lang:
- return match
- return matches[0]
-
-
-def get_locale():
- header = request.headers.get('Accept-Language')
- print(header)
- try:
- return best_match(['fr', 'en'], header)
- except AttributeError:
- return 'en'
-
-
-def get_title(lang):
- if lang == 'fr':
- return "plint -- vérification formelle de poèmes"
- else:
- return "plint -- French poetry checker"
-
-
-@app.route('/static/tpl/<filename>')
-def server_static(filename):
- return static_file(filename, root="./static/tpl", mimetype="text/plain")
-
-
-@app.route('/<lang>/static/img/<filename>')
-def server_static(filename, lang=None):
- return static_file(filename, root="./static/img")
-
-
-@app.route('/<lang>/static/tpl/<filename>')
-def server_static(filename, lang=None):
- return static_file(filename, root="./static/tpl", mimetype="text/plain")
-
-
-@app.route('/static/<filename>')
-def server_static(filename):
- return static_file(filename, root="./static")
-
-
-@app.route('/<lang>/static/<filename>')
-def server_static(filename, lang=None):
- return static_file(filename, root="./static")
-
-
-@app.route('/')
-def root():
- redirect('/' + get_locale() + '/')
-
-
-@app.route('/<page>')
-def paged(page):
- redirect('/' + get_locale() + '/' + page)
-
-
-@app.route('/<lang>/')
-def root(lang):
- if lang not in ['fr', 'en']:
- return paged(lang)
- return env.get_template('index.html').render(title=get_title(lang),
- lang=lang, path="")
-
-
-@app.route('/<lang>/about')
-def about(lang):
- return env.get_template('about.html').render(title=get_title(lang),
- lang=lang, path="about")
-
-
-MAX_POEM_LEN = 8192
-MAX_LINE_LEN = 512
-
-
-class TooBigException(Exception):
- pass
-
-
-class TooLongLinesException(Exception):
- pass
-
-
-def check(poem):
- if len(poem) > MAX_POEM_LEN:
- raise TooBigException
- s = poem.split("\n")
- for x in range(len(s)):
- if len(s[x]) > MAX_LINE_LEN:
- raise TooLongLinesException
- s[x] = s[x].strip()
- return s
-
-
-@app.route('/<lang>/checkjs', method='POST')
-def q(lang):
- global throttle
- # necessary when serving with lighttpd proxy-core
- ip = request.environ.get('HTTP_X_FORWARDED_FOR')
- if not ip:
- # fallback; this is 127.0.0.1 with proxy-core
- ip = request.environ.get('REMOTE_ADDR')
- t = time.time()
- print("== %s %s ==" % (ip, t))
- response.content_type = 'application/json'
- localization.init_locale(lang)
- throttle = set(x for x in throttle if t - x[1] < THROTTLE_DELAY)
- if ip in (x[0] for x in throttle):
- if lang == 'fr':
- msg = (("Trop de requêtes pour vérifier le poème,"
- + " veuillez réessayer dans %d secondes") %
- THROTTLE_DELAY)
- else:
- msg = (("Too many requests to check poem,"
- + " please try again in %d seconds") %
- THROTTLE_DELAY)
- return dumps({'error': msg})
- throttle.add((ip, t))
- poem = re.sub(r'<>&', '', request.forms.get('poem'))
- print(poem)
-
- # default message
- if lang == 'fr':
- msg = "Le poème est vide"
- else:
- msg = "Poem is empty"
-
- try:
- poem = check(poem)
- except TooBigException:
- poem = None
- if lang == 'fr':
- msg = "Le poème est trop long (maximum %d caractères)" % MAX_POEM_LEN
- else:
- msg = "Poem is too long (maximum %d characters)" % MAX_POEM_LEN
- except TooLongLinesException:
- poem = None
- if lang == 'fr':
- msg = "Certaines lignes du poème sont trop longues (maximum %d caractères)" % MAX_LINE_LEN
- else:
- msg = "Some lines of the poem are too long (maximum %d characters)" % MAX_LINE_LEN
- if not poem or len(poem) == 0 or (len(poem) == 1 and len(poem[0]) == 0):
- return dumps({'error': msg})
- templateName = re.sub(r'[^a-z_]', '', request.forms.get('template'))
- print(templateName)
- if templateName == 'custom':
- x = request.forms.get('custom_template')
- else:
- try:
- f = open("static/tpl/" + templateName + ".tpl")
- x = f.read()
- f.close()
- except IOError:
- if lang == 'fr':
- msg = "Modèle inexistant"
- else:
- msg = "No such template"
- return dumps({'error': msg})
- print(x)
- try:
- templ = template.Template(x)
- except error.TemplateLoadError as e:
- if lang == 'fr':
- msg = "Erreur à la lecture du modèle : " + e.msg
- else:
- msg = "Error when reading template: " + e.msg
- return dumps({'error': msg})
- poem.append(None)
- r = []
- i = 0
- d = {}
- for line in poem:
- i += 1
- last = False
- if line == None:
- line = ""
- last = True
- errors = templ.check(line, last=last)
- if errors:
- r.append({
- 'line': line,
- 'num': i,
- 'errors': sum(errors.lines(short=True), [])
- })
- d['result'] = r
- return dumps(d)
-
-
-if __name__ == '__main__':
- run(app, port='5000', server="cherrypy", host="::")
diff --git a/plint/rhyme.py b/plint/rhyme.py
@@ -1,5 +1,5 @@
#!/usr/bin/python3 -u
-#encoding: utf8
+# encoding: utf8
import copy
import re
@@ -27,7 +27,7 @@ liaison = {
't': 't',
'x': 'z',
'z': 'z',
- }
+}
tolerance = {
'ï': 'hi',
@@ -36,273 +36,281 @@ tolerance = {
'à': 'a',
'û': 'u',
'ù': 'u'
- }
+}
-class Constraint:
- def __init__(self, classical=True, phon=1):
- self.phon = phon # minimal number of common suffix phones
- self.classical = classical # should we impose classical rhyme rules
-
- def mmax(self, a, b):
- """max, with -1 representing infty"""
+def mmax(a, b):
+ """max, with -1 representing infinity"""
if a == -1 or b == -1:
- return -1
+ return -1
else:
- return max(a, b)
+ return max(a, b)
+
+
+class Constraint:
+
+ def __init__(self, classical=True, phon=1):
+ self.phon = phon # minimal number of common suffix phones
+ self.classical = classical # should we impose classical rhyme rules
+
+ def restrict(self, c):
+ """take the max between us and constraint object c"""
+ if not c:
+ return
+ self.phon = mmax(self.phon, c.phon)
+ self.classical = self.classical or c.classical
- def restrict(self, c):
- """take the max between us and constraint object c"""
- if not c:
- return
- self.phon = self.mmax(self.phon, c.phon)
- self.classical = self.classical or c.classical
class Rhyme:
- def apply_mergers(self, phon):
- return ''.join([(self.mergers[x] if x in self.mergers.keys()
- else x) for x in phon])
-
- def supposed_liaison(self, x):
- if x[-1] in liaison.keys() and self.options['eye_supposed_ok']:
- return x[:-1] + liaison[x[-1]]
- return x
-
- def __init__(self, line, constraint=None, mergers=None, options=None, phon=None):
- if constraint:
- self.constraint = constraint
- else:
- self.constraint = Constraint()
- self.mergers = {}
- # length of smallest end-of-verse word in syllables
- # will be provided later
- self.last_count = 42
- if options:
- self.options = options
- else:
- self.options = default_options
- if mergers:
- for phon_set in mergers:
- for pho in phon_set[1:]:
- self.mergers[pho] = phon_set[0]
- if not phon:
- phon = self.lookup(line)
- self.phon = set([self.apply_mergers(x) for x in phon])
- self.eye = self.supposed_liaison(self.consonant_suffix(line))
- self.raw_eye = line
- self.old_phon = None
- self.old_eye = None
- self.old_raw_eye = None
- self.old_last_count = None
- self.new_rhyme = None
-
- # store if rhyme is a succession of two vowels
- self.double_vocalic = False
- l2 = normalize(line)
- if len(l2) >= 2:
- if is_vowels(l2[-2], with_y=False, with_h=False):
- self.double_vocalic = True
- if l2[-2] == 'h':
- if len(l2) >= 3 and is_vowels(l2[-3], with_y=False, with_h=False):
- self.double_vocalic = True
- self.old_double_vocalic = False
-
- def rollback(self):
- self.phon = self.old_phon
- self.eye = self.old_eye
- self.raw_eye = self.old_raw_eye
- self.last_count = self.old_last_count
- self.double_vocalic = self.old_double_vocalic
-
- def sufficient_phon(self):
- # return the shortest accepted rhymes among old_phon
- ok = set()
- for p in self.phon:
- slen = len(p)
- for i in range(len(p)):
- if p[-(i+1)] in vowel:
- slen = i+1
- break
- slen = max(slen, self.constraint.phon)
- ok.add(p[-slen:])
- return ok
-
- def sufficient_eye_length(self, old_phon=None):
- if not self.constraint.classical:
- return self.eye, 0 # not classical, nothing required
- if ((old_phon >= 2 if old_phon else self.satisfied_phon(2))
- or not self.options['poor_eye_required']):
- return self.eye, 1
- if self.last_count == 1:
- return self.eye, 1
- if self.options['poor_eye_vocalic_ok'] and self.double_vocalic:
- return self.eye, 1
- if self.options['poor_eye_supposed_ok']:
- return self.eye, 2
- else:
- return self.raw_eye, 2
- def sufficient_eye(self, old_phon=None):
- d, val = self.sufficient_eye_length(old_phon)
- if val <= len(d):
- return d[-val:]
- else:
- return d
-
- def match(self, phon, eye, raw_eye):
- """limit our phon and eye to those which match phon and eye and which
- respect constraints"""
- new_phon = set()
- for x in self.phon:
- for y in phon:
- val = phon_rhyme(x, y)
- if val >= self.constraint.phon and self.constraint.phon >= 0:
- new_phon.add(x[-val:])
- self.phon = new_phon
- if self.eye:
- val = eye_rhyme(self.eye, eye)
- if val == 0:
- self.eye = ""
- else:
- self.eye = self.eye[-val:]
- if self.raw_eye:
- val = eye_rhyme(self.raw_eye, raw_eye)
- if val == 0:
- self.raw_eye = ""
- else:
- self.raw_eye = self.raw_eye[-val:]
-
- def adjustLastCount(self, v):
- self.last_count = min(self.last_count, v)
-
- def restrict(self, r):
- """take the intersection between us and rhyme object r"""
- if self.satisfied():
- self.old_phon = self.phon
- self.old_eye = self.eye
- self.old_last_count = self.last_count
- self.old_double_vocalic = self.double_vocalic
- self.old_raw_eye = self.raw_eye
- # lastCount will be applied later
- self.constraint.restrict(r.constraint)
- self.new_rhyme = r
- if not r.double_vocalic:
- self.double_vocalic = False # rhyme is ok if all rhymes are double vocalic
- self.match(set([self.apply_mergers(x) for x in r.phon]),
- self.supposed_liaison(self.consonant_suffix(r.eye)), r.raw_eye)
-
- def consonant_suffix(self, s):
- if not self.options['eye_tolerance_ok']:
- return s
- for k in tolerance.keys():
- if s.endswith(k):
- return s[:-(len(k))] + tolerance[k]
- return s
+ def __init__(self, line, constraint=None, mergers=None, options=None, phon=None):
+ if constraint:
+ self.constraint = constraint
+ else:
+ self.constraint = Constraint()
+ self.mergers = {}
+ # length of smallest end-of-verse word in syllables
+ # will be provided later
+ self.last_count = 42
+ if options:
+ self.options = options
+ else:
+ self.options = default_options
+ if mergers:
+ for phon_set in mergers:
+ for pho in phon_set[1:]:
+ self.mergers[pho] = phon_set[0]
+ if not phon:
+ phon = self.lookup(line)
+ self.phon = set([self.apply_mergers(x) for x in phon])
+ self.eye = self.supposed_liaison(self.consonant_suffix(line))
+ self.raw_eye = line
+ self.old_phon = None
+ self.old_eye = None
+ self.old_raw_eye = None
+ self.old_last_count = None
+ self.new_rhyme = None
+
+ # store if rhyme is a succession of two vowels
+ self.double_vocalic = False
+ l2 = normalize(line)
+ if len(l2) >= 2:
+ if is_vowels(l2[-2], with_y=False, with_h=False):
+ self.double_vocalic = True
+ if l2[-2] == 'h':
+ if len(l2) >= 3 and is_vowels(l2[-3], with_y=False, with_h=False):
+ self.double_vocalic = True
+ self.old_double_vocalic = False
+
+ def apply_mergers(self, phon):
+ return ''.join([(self.mergers[x] if x in self.mergers.keys()
+ else x) for x in phon])
+
+ def supposed_liaison(self, x):
+ if x[-1] in liaison.keys() and self.options['eye_supposed_ok']:
+ return x[:-1] + liaison[x[-1]]
+ return x
+
+ def rollback(self):
+ self.phon = self.old_phon
+ self.eye = self.old_eye
+ self.raw_eye = self.old_raw_eye
+ self.last_count = self.old_last_count
+ self.double_vocalic = self.old_double_vocalic
+
+ def sufficient_phon(self):
+ # return the shortest accepted rhymes among old_phon
+ ok = set()
+ for p in self.phon:
+ slen = len(p)
+ for i in range(len(p)):
+ if p[-(i + 1)] in vowel:
+ slen = i + 1
+ break
+ slen = max(slen, self.constraint.phon)
+ ok.add(p[-slen:])
+ return ok
+
+ def sufficient_eye_length(self, old_phon=None):
+ if not self.constraint.classical:
+ return self.eye, 0 # not classical, nothing required
+ if ((old_phon >= 2 if old_phon else self.satisfied_phon(2))
+ or not self.options['poor_eye_required']):
+ return self.eye, 1
+ if self.last_count == 1:
+ return self.eye, 1
+ if self.options['poor_eye_vocalic_ok'] and self.double_vocalic:
+ return self.eye, 1
+ if self.options['poor_eye_supposed_ok']:
+ return self.eye, 2
+ else:
+ return self.raw_eye, 2
+
+ def sufficient_eye(self, old_phon=None):
+ d, val = self.sufficient_eye_length(old_phon)
+ if val <= len(d):
+ return d[-val:]
+ else:
+ return d
+
+ def match(self, phon, eye, raw_eye):
+ """limit our phon and eye to those which match phon and eye and which respect constraints"""
+ new_phon = set()
+ for x in self.phon:
+ for y in phon:
+ val = phon_rhyme(x, y)
+ if 0 <= self.constraint.phon <= val:
+ new_phon.add(x[-val:])
+ self.phon = new_phon
+ if self.eye:
+ val = eye_rhyme(self.eye, eye)
+ if val == 0:
+ self.eye = ""
+ else:
+ self.eye = self.eye[-val:]
+ if self.raw_eye:
+ val = eye_rhyme(self.raw_eye, raw_eye)
+ if val == 0:
+ self.raw_eye = ""
+ else:
+ self.raw_eye = self.raw_eye[-val:]
+
+ def adjust_last_count(self, v):
+ self.last_count = min(self.last_count, v)
+
+ def restrict(self, r):
+ """take the intersection between us and rhyme object r"""
+ if self.satisfied():
+ self.old_phon = self.phon
+ self.old_eye = self.eye
+ self.old_last_count = self.last_count
+ self.old_double_vocalic = self.double_vocalic
+ self.old_raw_eye = self.raw_eye
+ # lastCount will be applied later
+ self.constraint.restrict(r.constraint)
+ self.new_rhyme = r
+ if not r.double_vocalic:
+ self.double_vocalic = False # rhyme is ok if all rhymes are double vocalic
+ self.match(set([self.apply_mergers(x) for x in r.phon]),
+ self.supposed_liaison(self.consonant_suffix(r.eye)), r.raw_eye)
+
+ def consonant_suffix(self, s):
+ if not self.options['eye_tolerance_ok']:
+ return s
+ for k in tolerance.keys():
+ if s.endswith(k):
+ return s[:-(len(k))] + tolerance[k]
+ return s
+
+ def feed(self, line, constraint=None):
+ """extend us with a line and a constraint"""
+ # lastCount is not applied yet
+ return self.restrict(Rhyme(line, constraint, self.mergers, self.options))
+
+ def satisfied_phon(self, val=None):
+ if not val:
+ val = self.constraint.phon
+ for x in self.phon:
+ if len(x) >= val:
+ return True
+ return False
+
+ def satisfied_eye(self):
+ d, val = self.sufficient_eye_length()
+ return len(d) >= val
+
+ def satisfied(self):
+ return self.satisfied_phon() and self.satisfied_eye()
+
+ def pprint(self):
+ pprint(self.phon)
+
+ def adjust(self, result, s):
+ """add liason kludges"""
+ result2 = copy.deepcopy(result)
+ # adjust for tolerance with classical rhymes
+ # e.g. "vautours"/"ours", "estomac"/"Sidrac"
+ if self.options['phon_supposed_ok']:
+ # the case 'ent' would lead to trouble for gender
+ if s[-1] in liaison.keys() and not s.endswith('ent'):
+ for r in result2:
+ result.add(r + liaison[s[-1]])
+ if s[-1] == 's':
+ result.add(r + 's')
+ return result
+
+ def lookup(self, s):
+ """lookup the pronunciation of s, adding rime normande kludges"""
+ result = raw_lookup(s)
+ if self.options['normande_ok'] and (s.endswith('er') or s.endswith('ers')):
+ result.add("ER")
+ return self.adjust(result, s)
- def feed(self, line, constraint=None):
- """extend us with a line and a constraint"""
- # lastCount is not applied yet
- return self.restrict(Rhyme(line, constraint, self.mergers, self.options))
-
- def satisfied_phon(self, val=None):
- if not val:
- val = self.constraint.phon
- for x in self.phon:
- if len(x) >= val:
- return True
- return False
-
- def satisfied_eye(self):
- d, l = self.sufficient_eye_length()
- return len(d) >= l
-
- def satisfied(self):
- return self.satisfied_phon() and self.satisfied_eye()
-
- def pprint(self):
- pprint(self.phon)
-
- def adjust(self, result, s):
- """add liason kludges"""
- result2 = copy.deepcopy(result)
- # adjust for tolerance with classical rhymes
- # e.g. "vautours"/"ours", "estomac"/"Sidrac"
- if self.options['phon_supposed_ok']:
- # the case 'ent' would lead to trouble for gender
- if s[-1] in liaison.keys() and not s.endswith('ent'):
- for r in result2:
- result.add(r + liaison[s[-1]])
- if (s[-1] == 's'):
- result.add(r + 's')
- return result
-
- def lookup(self, s):
- """lookup the pronunciation of s, adding rime normande kludges"""
- result = raw_lookup(s)
- if self.options['normande_ok'] and (s.endswith('er') or s.endswith('ers')):
- result.add("ER")
- return self.adjust(result, s)
def suffix(x, y):
- """length of the longest common suffix of x and y"""
- bound = min(len(x), len(y))
- for i in range(bound):
- a = x[-(1+i)]
- b = y[-(1+i)]
- if a != b:
- return i
- return bound
+ """length of the longest common suffix of x and y"""
+ bound = min(len(x), len(y))
+ for i in range(bound):
+ a = x[-(1 + i)]
+ b = y[-(1 + i)]
+ if a != b:
+ return i
+ return bound
+
def phon_rhyme(x, y):
- """are x and y acceptable phonetic rhymes?"""
- assert(isinstance(x, str))
- assert(isinstance(y, str))
- nphon = suffix(x, y)
- for c in x[-nphon:]:
- if c in vowel:
- return nphon
- return 0
+ """are x and y acceptable phonetic rhymes?"""
+ assert (isinstance(x, str))
+ assert (isinstance(y, str))
+ nphon = suffix(x, y)
+ for c in x[-nphon:]:
+ if c in vowel:
+ return nphon
+ return 0
+
def eye_rhyme(x, y):
- """value of x and y as an eye rhyme"""
- return suffix(x, y)
+ """value of x and y as an eye rhyme"""
+ return suffix(x, y)
+
def concat_couples(a, b):
- """the set of x+y for x in a, y in b"""
- s = set()
- for x in a:
- for y in b:
- s.add(x + y)
- return s
+ """the set of x+y for x in a, y in b"""
+ s = set()
+ for x in a:
+ for y in b:
+ s.add(x + y)
+ return s
+
def raw_lookup(s):
- # kludge: take the last three words and concatenate them to take short words
- # into account
- s = s.split(' ')[-3:]
- sets = list(map((lambda a: set([x[1] for x in
- frhyme.lookup(escape(a), NBEST)])), s))
- return functools.reduce(concat_couples, sets, set(['']))
-
-#workaround for lexique
+ # kludge: take the last three words and concatenate them to take short words
+ # into account
+ s = s.split(' ')[-3:]
+ sets = list(map((lambda a: set([x[1] for x in
+ frhyme.lookup(escape(a), NBEST)])), s))
+ return functools.reduce(concat_couples, sets, {''})
+
+
+# workaround for lexique
def escape(t):
- return re.sub('œ', 'oe', re.sub('æ', 'ae', t))
+ return re.sub('œ', 'oe', re.sub('æ', 'ae', t))
-if __name__ == '__main__':
- while True:
- line = sys.stdin.readline()
- if not line:
- break
- line = line.lower().strip().split(' ')
- if len(line) < 1:
- continue
- constraint = Constraint()
- rhyme = Rhyme(line[0], constraint, self.mergers, self.options)
- for x in line[1:]:
- rhyme.feed(x, 42)
- rhyme.pprint()
- if not rhyme.satisfied():
- print("No.")
- break
- if rhyme.satisfied():
- print ("Yes.")
+if __name__ == '__main__':
+ while True:
+ input_line = sys.stdin.readline()
+ if not input_line:
+ break
+ input_line = input_line.lower().strip().split(' ')
+ if len(input_line) < 1:
+ continue
+ rhyme = Rhyme(input_line[0], Constraint())
+ for character in input_line[1:]:
+ rhyme.feed(character, 42)
+ rhyme.pprint()
+ if not rhyme.satisfied():
+ print("No.")
+ break
+ if rhyme.satisfied():
+ print("Yes.")
diff --git a/plint/template.py b/plint/template.py
@@ -26,6 +26,10 @@ OPTION_ALIASES = {
}
+def reset_conditional(d):
+ return dict((k, v) for k, v in d.items() if len(k) > 0 and k[0] == '!')
+
+
class Template:
def __init__(self, template_string=None):
@@ -33,6 +37,10 @@ class Template:
self.pattern_line_no = 0
self.options = dict(default_options)
self.mergers = []
+ self.old_position = None
+ self.old_env = None
+ self.old_femenv = None
+ self.old_occenv = None
self.overflowed = False
if template_string is not None:
self.load(template_string)
@@ -157,8 +165,9 @@ class Template:
self.occurrence_environment[pattern.my_id][last_word] = 0
self.occurrence_environment[pattern.my_id][last_word] += 1
if self.occurrence_environment[pattern.my_id][last_word] > nature_count(last_word):
- errors.insert(0, error.ErrorMultipleWordOccurrence(last_word,
- self.occurrence_environment[pattern.my_id][last_word]))
+ errors.insert(0,
+ error.ErrorMultipleWordOccurrence(last_word,
+ self.occurrence_environment[pattern.my_id][last_word]))
verse.phon = self.env[pattern.my_id].phon
verse.parse()
@@ -166,7 +175,7 @@ class Template:
# now that we have parsed, adjust rhyme to reflect last word length
# and check eye
if not rhyme_failed:
- self.env[pattern.my_id].adjustLastCount(verse.last_count())
+ self.env[pattern.my_id].adjust_last_count(verse.get_last_count())
if not self.env[pattern.my_id].satisfied_eye():
old_phon = len(self.env[pattern.my_id].phon)
self.env[pattern.my_id].rollback()
@@ -175,16 +184,7 @@ class Template:
errors = verse.problems() + errors
- if output_file:
- possible = verse.possible
- if len(possible) == 1:
- for i, chunk in enumerate(possible[0]):
- if (chunk.weights is not None and len(chunk.weights) > 1
- and chunk.weight is not None and chunk.weight > 0):
- chunks_before = possible[0][:i]
- chunks_after = possible[0][i + 1:]
- print(str(chunk.weight) + ' '
- + ' '.join(chunk.make_query(chunks_before, chunks_after)), file=output_file)
+ verse.print_possible(output_file)
# rhyme genres
# inequality constraint
@@ -209,14 +209,11 @@ class Template:
return errors, pattern, verse
- def reset_conditional(self, d):
- return dict((k, v) for k, v in d.items() if len(k) > 0 and k[0] == '!')
-
- def reset_state(self, with_femenv=False):
+ def reset_state(self):
"""Reset our state, except ids starting with '!'"""
self.position = 0
- self.env = self.reset_conditional(self.env)
- self.feminine_environment = self.reset_conditional(self.feminine_environment)
+ self.env = reset_conditional(self.env)
+ self.feminine_environment = reset_conditional(self.feminine_environment)
self.occurrence_environment = {} # always reset
@property
@@ -265,4 +262,4 @@ def str2bool(x):
return True
if x.lower() in ["no", "non", "n", "false", "faux", "f"]:
return False
- raise error.TemplateLoadError(("Bad value in global option"))
+ raise error.TemplateLoadError("Bad value in global option")
diff --git a/plint/tests/test_rhyme.py b/plint/tests/test_rhyme.py
@@ -0,0 +1,24 @@
+import unittest
+
+from plint.template import Template
+
+
+class TestRhyme(unittest.TestCase):
+
+ def _test_rimes(self):
+ template = Template("""6/6 A !X
+6/6 A !X""")
+ text = """Je te fais plus parfait mille fois que tu n’es :
+ Ton feu ne peut aller au point où je le mets ;"""
+ should_end = False
+ for line in text.split("\n"):
+ errors = template.check(line, None, last=should_end, n_syllables=None, offset=0)
+ self.assertIsNone(errors, errors.report() if errors is not None else "OK")
+ should_end = True
+ line = ""
+ errors = template.check(line, None, last=should_end, n_syllables=None, offset=0)
+ self.assertIsNone(errors)
+
+
+if __name__ == '__main__':
+ unittest.main()
diff --git a/plint/verse.py b/plint/verse.py
@@ -1,13 +1,14 @@
#!/usr/bin/python3
-from plint import error, common
+
+from plint import error
from plint.chunks import Chunks
-from plint.common import SURE_END_FEM, strip_accents
# the writing is designed to make frhyme succeed
# end vowels will be elided
# missing letters have a default case
+
class Verse:
@property
@@ -26,109 +27,17 @@ class Verse:
self.possible = None
self.input_line = input_line
self.chunks = Chunks(self)
- self.text = None
def annotate(self):
- self.text = self.chunks.annotate(self.template, self.threshold)
+ self.chunks.annotate(self.template, self.threshold)
def parse(self):
self.annotate()
- self.possible = self.fit(0, 0, self.pattern.hemistiches)
-
- def feminine(self, align=None):
- for a in SURE_END_FEM:
- if self.text.endswith(a):
- # if vowel before, it must be fem
- try:
- if strip_accents(self.text[-len(a) - 1]) in common.VOWELS:
- return ['F']
- except IndexError:
- # too short
- if self.text == "es":
- return ['M']
- else:
- return ['F']
- # check that this isn't a one-syllabe word that ends with "es"
- # => must be masculine as '-es' cannot be mute then
- # => except if there is another vowel before ("fées")
- if (self.text.endswith("es") and (len(self.text) == 2 or
- strip_accents(self.text[-3]) not in common.VOWELS)):
- for i in range(4):
- try:
- if ((self.chunks.chunks[-i - 1].had_hyphen or False) or
- (self.chunks.chunks[-i - 1].word_end or False)):
- return ['M']
- except IndexError:
- return ['M']
- return ['F']
- if not self.text.endswith('ent'):
- return ['M']
- # verse ends with 'ent'
- if align:
- if align and align[-2].weight == 0:
- return ['F'] # mute -ent
- if align and align[-2].weight > 0 and align[-2].text == 'e':
- return ['M'] # non-mute "-ent" by the choice of metric
- possible = []
- # now, we must check pronunciation?
- # "tient" vs. "lient" for instance, "excellent"...
- for possible_phon in self.phon:
- if possible_phon.endswith(')') or possible_phon.endswith('#'):
- possible.append('M')
- else:
- possible.append('F')
- if possible_phon.endswith('E') and self.text.endswith('aient'):
- # imparfait and conditionnel are masculine...
- possible.append('M')
- return possible
-
- def fit(self, pos, count, hemistiches):
- if count > self.pattern.length:
- return [] # no possibilites
- if len(hemistiches) > 0 and hemistiches[0] < count:
- return [] # missed a hemistiche
- if pos == len(self.chunks.chunks):
- if count == self.pattern.length:
- return [[]] # empty list is the only possibility
- else:
- return []
- chunk = self.chunks.chunks[pos]
- result = []
- for weight in (chunk.weights or [0]):
- next_hemistiches = hemistiches
- if (len(hemistiches) > 0 and count + weight == hemistiches[0] and
- chunk.is_vowels()):
- # need to try to hemistiche
- if chunk.hemistiche == "ok" or (chunk.hemistiche == "elid" and weight == 0):
- # we hemistiche here
- next_hemistiches = next_hemistiches[1:]
- current = chunk.copy()
- # TODO There was written "weight" here, without the s. Are we sure of the condition?
- if current.weights is not None:
- current.weight = weight
- for x in self.fit(pos + 1, count + weight, next_hemistiches):
- result.append([current] + x)
- return result
+ self.possible = self.chunks.fit(self.pattern.hemistiches)
- hemis_types = {
- 'ok': '/', # correct
- 'cut': '?', # falls at the middle of a word
- 'fem': '\\', # preceding word ends by a mute e
- }
-
- def last_count(self):
+ def get_last_count(self):
"""return min number of syllables for last word"""
- tot = 0
- for chunk in self.chunks.chunks[::-1]:
- if chunk.original.endswith(' ') or chunk.original.endswith('-'):
- if tot > 0:
- break
- if chunk.weights is not None:
- tot += min(chunk.weights)
- if ' ' in chunk.original.rstrip() or '-' in chunk.original.rstrip():
- if tot > 0:
- break
- return tot
+ return self.chunks.get_last_count()
def problems(self):
errors = self.chunks.get_errors_set(self.template.options['forbidden_ok'], self.template.options['hiatus_ok'])
@@ -145,10 +54,10 @@ class Verse:
def genders(self):
result = set()
for p in self.possible:
- result.update(set(self.feminine(p)))
+ result.update(set(self.chunks.get_feminine(self.template, self.threshold, p)))
if len(self.possible) == 0:
# try to infer gender even when metric is wrong
- result.update(set(self.feminine(None)))
+ result.update(set(self.chunks.get_feminine(self.template, self.threshold, None)))
return result
def print_n_syllables(self, n_syllables, offset, output_file):
@@ -156,3 +65,21 @@ class Verse:
# only generate a context with the prescribed final weight
# where "final" is the offset-th chunk with a weight from the end
self.chunks.print_n_syllables(n_syllables, offset, output_file)
+
+ def align(self):
+ keys = ['original', 'error']
+ if len(self.possible) == 0:
+ keys.append('weights')
+ if len(self.pattern.hemistiches) > 0:
+ keys.append('hemis')
+ return self.chunks.align_from_keys(keys)
+
+ def print_possible(self, output_file):
+ if not output_file:
+ return
+ possible = self.possible
+ if len(possible) == 1:
+ for i, chunk in enumerate(possible[0]):
+ chunks_before = possible[0][:i]
+ chunks_after = possible[0][i + 1:]
+ chunk.print_query(chunks_after, chunks_before, output_file)
diff --git a/plint/website/bottle.py b/plint/website/bottle.py
@@ -0,0 +1,3081 @@
+#!/usr/bin/env python
+# -*- coding: utf-8 -*-
+"""
+Bottle is a fast and simple micro-framework for small web applications. It
+offers request dispatching (Routes) with url parameter support, templates,
+a built-in HTTP Server and adapters for many third party WSGI/HTTP-server and
+template engines - all in a single file and with no dependencies other than the
+Python Standard Library.
+
+Homepage and documentation: http://bottlepy.org/
+
+Copyright (c) 2011, Marcel Hellkamp.
+License: MIT (see LICENSE for details)
+"""
+
+from __future__ import with_statement
+
+__author__ = 'Marcel Hellkamp'
+__version__ = '0.11.dev'
+__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 base64, cgi, email.utils, functools, hmac, imp, itertools, mimetypes,\
+ os, re, subprocess, sys, tempfile, threading, time, urllib, warnings
+
+from datetime import date as datedate, datetime, timedelta
+from tempfile import TemporaryFile
+from traceback import format_exc, print_exc
+
+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, loads as json_lds
+ except ImportError:
+ try: from django.utils.simplejson import dumps as json_dumps, loads as json_lds
+ except ImportError:
+ def json_dumps(data):
+ raise ImportError("JSON support requires Python 2.6 or simplejson.")
+ json_lds = json_dumps
+
+
+
+# We now try to fix 2.5/2.6/3.1/3.2 incompatibilities.
+# It ain't pretty but it works... Sorry for the mess.
+
+py = sys.version_info
+py3k = py >= (3,0,0)
+py25 = py < (2,6,0)
+
+# Workaround for the missing "as" keyword in py3k.
+def _e(): return sys.exc_info()[1]
+
+# Workaround for the "print is a keyword/function" dilemma.
+_stdout, _stderr = sys.stdout.write, sys.stderr.write
+
+# Lots of stdlib and builtin differences.
+if py3k:
+ import http.client as httplib
+ import _thread as thread
+ from urllib.parse import urljoin, parse_qsl, SplitResult as UrlSplitResult
+ from urllib.parse import urlencode, quote as urlquote, unquote as urlunquote
+ from http.cookies import SimpleCookie
+ from collections import MutableMapping as DictMixin
+ import pickle
+ from io import BytesIO
+ basestring = str
+ unicode = str
+ json_loads = lambda s: json_lds(touni(s))
+ callable = lambda x: hasattr(x, '__call__')
+ imap = map
+else: # 2.x
+ import httplib
+ import thread
+ from urlparse import urljoin, SplitResult as UrlSplitResult
+ from urllib import urlencode, quote as urlquote, unquote as urlunquote
+ from Cookie import SimpleCookie
+ from itertools import imap
+ import cPickle as pickle
+ from StringIO import StringIO as BytesIO
+ if py25:
+ msg = "Python 2.5 support may be dropped in future versions of Bottle."
+ warnings.warn(msg, DeprecationWarning)
+ from cgi import parse_qsl
+ from UserDict import DictMixin
+ def next(it): return it.next()
+ bytes = str
+ else: # 2.6, 2.7
+ from urlparse import parse_qsl
+ from collections import MutableMapping as DictMixin
+ json_loads = json_lds
+
+# Some helpers for string/byte handling
+def tob(s, enc='utf8'):
+ return s.encode(enc) if isinstance(s, unicode) else bytes(s)
+def touni(s, enc='utf8', err='strict'):
+ return s.decode(enc, err) if isinstance(s, bytes) else unicode(s)
+tonat = touni if py3k else tob
+
+# 3.2 fixes cgi.FieldStorage to accept bytes (which makes a lot of sense).
+# 3.1 needs a workaround.
+NCTextIOWrapper = None
+if (3,0,0) < py < (3,2,0):
+ from io import TextIOWrapper
+ class NCTextIOWrapper(TextIOWrapper):
+ def close(self): pass # Keep wrapped buffer open.
+
+# A bug in functools causes it to break if the wrapper is an instance method
+def update_wrapper(wrapper, wrapped, *a, **ka):
+ try: functools.update_wrapper(wrapper, wrapped, *a, **ka)
+ except AttributeError: pass
+
+
+
+# These helpers are used at module level and need to be defined first.
+# And yes, I know PEP-8, but sometimes a lower-case classname makes more sense.
+
+def depr(message):
+ warnings.warn(message, DeprecationWarning, stacklevel=3)
+
+def makelist(data): # This is just to handy
+ if isinstance(data, (tuple, list, set, dict)): return list(data)
+ elif data: return [data]
+ else: return []
+
+
+class DictProperty(object):
+ ''' Property that maps to a key in a local dict-like attribute. '''
+ def __init__(self, attr, key=None, read_only=False):
+ self.attr, self.key, self.read_only = attr, key, read_only
+
+ def __call__(self, func):
+ functools.update_wrapper(self, func, updated=[])
+ self.getter, self.key = func, self.key or func.__name__
+ return self
+
+ def __get__(self, obj, cls):
+ 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]
+
+ def __set__(self, obj, value):
+ if self.read_only: raise AttributeError("Read-Only property.")
+ getattr(obj, self.attr)[self.key] = value
+
+ def __delete__(self, obj):
+ if self.read_only: raise AttributeError("Read-Only property.")
+ del getattr(obj, self.attr)[self.key]
+
+
+class cached_property(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
+
+
+class lazy_attribute(object):
+ ''' A property that caches itself to the class object. '''
+ def __init__(self, func):
+ functools.update_wrapper(self, func, updated=[])
+ self.getter = func
+
+ def __get__(self, obj, cls):
+ value = self.getter(cls)
+ setattr(cls, self.__name__, value)
+ return value
+
+
+
+
+
+
+###############################################################################
+# Exceptions and Events ########################################################
+###############################################################################
+
+
+class BottleException(Exception):
+ """ A base class for exceptions used by bottle. """
+ pass
+
+
+#TODO: This should subclass BaseRequest
+class HTTPResponse(BottleException):
+ """ Used to break execution and immediately finish the response """
+ def __init__(self, output='', status=200, header=None):
+ super(BottleException, self).__init__("HTTP Response %d" % status)
+ self.status = int(status)
+ self.output = output
+ self.headers = HeaderDict(header) if header else None
+
+ def apply(self, response):
+ if self.headers:
+ for key, value in self.headers.allitems():
+ response.headers[key] = value
+ response.status = self.status
+
+
+class HTTPError(HTTPResponse):
+ """ Used to generate an error page """
+ def __init__(self, code=500, output='Unknown Error', exception=None,
+ traceback=None, header=None):
+ super(HTTPError, self).__init__(output, code, header)
+ self.exception = exception
+ self.traceback = traceback
+
+ def __repr__(self):
+ return tonat(template(ERROR_PAGE_TEMPLATE, e=self))
+
+
+
+
+
+
+###############################################################################
+# Routing ######################################################################
+###############################################################################
+
+
+class RouteError(BottleException):
+ """ This is a base class for all routing related exceptions """
+
+
+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
+ the first target that satisfies the request. The target may be anything,
+ usually a string, ID or callable object. A route consists of a path-rule
+ 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>`). The wildcard syntax
+ and details on the matching order are described in docs:`routing`.
+ '''
+
+ 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.float_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.]+', 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 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)
+
+ try:
+ re_match = re.compile('^(%s)$' % pattern).match
+ except re.error:
+ 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
+
+ try:
+ 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): # 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:
+ raise RouteBuildError('Missing URL argument: %r' % _e().args[0])
+
+ def match(self, 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()
+ if method in targets:
+ return targets[method], urlargs
+ if method == 'HEAD' and 'GET' in targets:
+ return targets['GET'], urlargs
+ if 'ANY' in targets:
+ return targets['ANY'], urlargs
+ allowed = [verb for verb in targets if verb != 'ANY']
+ if 'GET' in allowed and 'HEAD' not in allowed:
+ allowed.append('HEAD')
+ raise HTTPError(405, "Method not allowed.",
+ header=[('Allow',",".join(allowed))])
+
+
+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:
+ 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:
+ update_wrapper(callback, self.callback)
+ return callback
+
+ def __repr__(self):
+ return '<%s %r %r>' % (self.method, self.rule, self.callback)
+
+
+
+
+
+
+###############################################################################
+# Application Object ###########################################################
+###############################################################################
+
+
+class Bottle(object):
+ """ Each Bottle object represents a single, distinct web application and
+ consists of routes, callbacks, plugins and configuration. Instances are
+ callable WSGI applications. """
+
+ def __init__(self, catchall=True, autojson=True, config=None):
+ self.routes = [] # List of installed :class:`Route` instances.
+ self.router = Router() # Maps requests to :class:`Route` instances.
+ self.plugins = [] # List of installed plugins.
+
+ self.error_handler = {}
+ self.config = ConfigDict(config or {})
+ #: If true, most exceptions are catched and returned as :exc:`HTTPError`
+ self.catchall = catchall
+ #: An instance of :class:`HooksPlugin`. Empty by default.
+ self.hooks = HooksPlugin()
+ self.install(self.hooks)
+ if autojson:
+ self.install(JSONPlugin())
+ self.install(TemplatePlugin())
+
+ def mount(self, prefix, app, **options):
+ ''' Mount an application (:class:`Bottle` or plain WSGI) to a specific
+ URL prefix. Example::
+
+ root_app.mount('/admin/', admin_app)
+
+ :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 isinstance(app, basestring):
+ prefix, app = app, prefix
+ depr('Parameter order of Bottle.mount() changed.') # 0.10
+
+ parts = [p for p in prefix.split('/') if p]
+ if not parts: raise ValueError('Empty path prefix.')
+ path_depth = len(parts)
+ options.setdefault('skip', True)
+ options.setdefault('method', 'ANY')
+
+ @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_code, rs.headers)
+ finally:
+ request.path_shift(-path_depth)
+
+ if not prefix.endswith('/'):
+ self.route('/' + '/'.join(parts), callback=mountpoint, **options)
+
+ def merge(self, routes):
+ ''' Merge the routes of another :cls:`Bottle` application or a list of
+ :class:`Route` objects into this application. The routes keep their
+ 'owner', meaning that the :data:`Route.app` attribute is not
+ changed. '''
+ if isinstance(routes, Bottle):
+ routes = routes.routes
+ for route in routes:
+ self.add_route(route)
+
+ def install(self, plugin):
+ ''' 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.
+ '''
+ if hasattr(plugin, 'setup'): plugin.setup(self)
+ if not callable(plugin) and not hasattr(plugin, 'apply'):
+ raise TypeError("Plugins must be callable or implement .apply()")
+ self.plugins.append(plugin)
+ self.reset()
+ return plugin
+
+ def uninstall(self, plugin):
+ ''' 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) \
+ or getattr(plugin, 'name', True) == remove:
+ removed.append(plugin)
+ del self.plugins[i]
+ if hasattr(plugin, 'close'): plugin.close()
+ if removed: self.reset()
+ return removed
+
+ def run(self, **kwargs):
+ ''' Calls :func:`run` with the same parameters. '''
+ run(self, **kwargs)
+
+ def reset(self, route=None):
+ ''' Reset all routes (force plugins to be re-applied) and clear all
+ 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 routes: route.prepare()
+ self.hooks.trigger('app_reset')
+
+ def close(self):
+ ''' Close the application and all installed plugins. '''
+ for plugin in self.plugins:
+ if hasattr(plugin, 'close'): plugin.close()
+ self.stopped = True
+
+ def match(self, environ):
+ """ 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 """
+ scriptname = request.environ.get('SCRIPT_NAME', '').strip('/') + '/'
+ location = self.router.build(routename, **kargs).lstrip('/')
+ return urljoin(urljoin('/', scriptname), location)
+
+ def add_route(self, route):
+ ''' Add a route object, but do not change the :data:`Route.app`
+ attribute.'''
+ self.routes.append(route)
+ self.router.add(route.rule, route.method, route, name=route.name)
+ if DEBUG: route.prepare()
+
+ def route(self, path=None, method='GET', callback=None, name=None,
+ apply=None, skip=None, **config):
+ """ A decorator to bind a function to a request URL. Example::
+
+ @app.route('/hello/:name')
+ def hello(name):
+ return 'Hello %s' % name
+
+ The ``:name`` part is a wildcard. See :class:`Router` for syntax
+ details.
+
+ :param path: Request path or a list of paths to listen to. If no
+ path is specified, it is automatically generated from the
+ signature of the function.
+ :param method: HTTP method (`GET`, `POST`, `PUT`, ...) or a list of
+ methods to listen to. (default: `GET`)
+ :param callback: An optional shortcut to avoid the decorator
+ syntax. ``route(..., callback=func)`` equals ``route(...)(func)``
+ :param name: The name for this route. (default: None)
+ :param apply: A decorator or plugin or a list of plugins. These are
+ applied to the route callback in addition to installed plugins.
+ :param skip: A list of plugins, plugin classes or names. Matching
+ plugins are not installed to this route. ``True`` skips all.
+
+ Any additional keyword arguments are stored as route-specific
+ configuration and passed to plugins (see :meth:`Plugin.apply`).
+ """
+ if callable(path): path, callback = None, path
+ plugins = makelist(apply)
+ skiplist = makelist(skip)
+ def decorator(callback):
+ # TODO: Documentation and test_data
+ if isinstance(callback, basestring): callback = load(callback)
+ for rule in makelist(path) or yieldroutes(callback):
+ for verb in makelist(method):
+ verb = verb.upper()
+ route = Route(self, rule, verb, callback, name=name,
+ plugins=plugins, skiplist=skiplist, **config)
+ self.add_route(route)
+ return callback
+ return decorator(callback) if callback else decorator
+
+ def get(self, path=None, method='GET', **options):
+ """ Equals :meth:`route`. """
+ return self.route(path, method, **options)
+
+ def post(self, path=None, method='POST', **options):
+ """ Equals :meth:`route` with a ``POST`` method parameter. """
+ return self.route(path, method, **options)
+
+ def put(self, path=None, method='PUT', **options):
+ """ Equals :meth:`route` with a ``PUT`` method parameter. """
+ return self.route(path, method, **options)
+
+ def delete(self, path=None, method='DELETE', **options):
+ """ Equals :meth:`route` with a ``DELETE`` method parameter. """
+ return self.route(path, method, **options)
+
+ def error(self, code=500):
+ """ Decorator: Register an output handler for a HTTP error code"""
+ def wrapper(handler):
+ self.error_handler[int(code)] = handler
+ return handler
+ return wrapper
+
+ def hook(self, name):
+ """ Return a decorator that attaches a callback to a hook. Three hooks
+ are currently implemented:
+
+ - before_request: Executed once before each request
+ - after_request: Executed once after each request
+ - app_reset: Called whenever :meth:`reset` is called.
+ """
+ def wrapper(func):
+ self.hooks.add(name, func)
+ return func
+ return wrapper
+
+ def handle(self, path, method='GET'):
+ """ (deprecated) Execute the first matching route callback and return
+ the result. :exc:`HTTPResponse` exceptions are catched and returned.
+ If :attr:`Bottle.catchall` is true, other exceptions are catched as
+ well and returned as :exc:`HTTPError` instances (500).
+ """
+ depr("This method will change semantics in 0.10. Try to avoid it.")
+ if isinstance(path, dict):
+ return self._handle(path)
+ return self._handle({'PATH_INFO': path, 'REQUEST_METHOD': method.upper()})
+
+ def _handle(self, environ):
+ try:
+ environ['bottle.app'] = self
+ request.bind(environ)
+ response.bind()
+ route, args = self.router.match(environ)
+ environ['route.handle'] = environ['bottle.route'] = route
+ environ['route.url_args'] = args
+ return route.call(**args)
+ except HTTPResponse:
+ return _e()
+ except RouteReset:
+ route.reset()
+ return self._handle(environ)
+ except (KeyboardInterrupt, SystemExit, MemoryError):
+ raise
+ except Exception:
+ if not self.catchall: raise
+ stacktrace = format_exc(10)
+ environ['wsgi.errors'].write(stacktrace)
+ return HTTPError(500, "Internal Server Error", _e(), stacktrace)
+
+ def _cast(self, out, peek=None):
+ """ Try to convert the parameter into something WSGI compatible and set
+ correct HTTP headers when possible.
+ Support: False, str, unicode, dict, HTTPResponse, HTTPError, file-like,
+ iterable of strings and iterable of unicodes
+ """
+
+ # Empty output is done here
+ if not out:
+ response['Content-Length'] = 0
+ return []
+ # Join lists of byte or unicode strings. Mixed lists are NOT supported
+ if isinstance(out, (tuple, list))\
+ and isinstance(out[0], (bytes, unicode)):
+ out = out[0][0:0].join(out) # b'abc'[0:0] -> b''
+ # Encode unicode strings
+ if isinstance(out, unicode):
+ out = out.encode(response.charset)
+ # Byte Strings are just returned
+ if isinstance(out, bytes):
+ 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)
+ if isinstance(out, HTTPResponse):
+ depr('Error handlers must not return :exc:`HTTPResponse`.') #0.9
+ return self._cast(out)
+ if isinstance(out, HTTPResponse):
+ out.apply(response)
+ return self._cast(out.output)
+
+ # File-like objects.
+ if hasattr(out, 'read'):
+ if 'wsgi.file_wrapper' in request.environ:
+ return request.environ['wsgi.file_wrapper'](out)
+ elif hasattr(out, 'close') or not hasattr(out, '__iter__'):
+ return WSGIFileWrapper(out)
+
+ # Handle Iterables. We peek into them to detect their inner type.
+ try:
+ out = iter(out)
+ first = next(out)
+ while not first:
+ first = next(out)
+ except StopIteration:
+ return self._cast('')
+ except HTTPResponse:
+ first = _e()
+ except (KeyboardInterrupt, SystemExit, MemoryError):
+ raise
+ except Exception:
+ if not self.catchall: raise
+ first = HTTPError(500, 'Unhandled exception', _e(), format_exc(10))
+
+ # These are the inner types allowed in iterator or generator objects.
+ if isinstance(first, HTTPResponse):
+ return self._cast(first)
+ if isinstance(first, bytes):
+ return itertools.chain([first], out)
+ if isinstance(first, unicode):
+ return imap(lambda x: x.encode(response.charset),
+ itertools.chain([first], out))
+ return self._cast(HTTPError(500, 'Unsupported response type: %s'\
+ % type(first)))
+
+ def wsgi(self, environ, start_response):
+ """ The bottle WSGI-interface. """
+ try:
+ out = self._cast(self._handle(environ))
+ # rfc2616 section 4.3
+ if response._status_code in (100, 101, 204, 304)\
+ or request.method == 'HEAD':
+ if hasattr(out, 'close'): out.close()
+ out = []
+ if isinstance(response._status_line, unicode):
+ response._status_line = str(response._status_line)
+ start_response(response._status_line, list(response.iter_headers()))
+ return out
+ except (KeyboardInterrupt, SystemExit, MemoryError):
+ raise
+ except Exception:
+ if not self.catchall: raise
+ err = '<h1>Critical error while processing request: %s</h1>' \
+ % html_escape(environ.get('PATH_INFO', '/'))
+ if DEBUG:
+ err += '<h2>Error:</h2>\n<pre>\n%s\n</pre>\n' \
+ '<h2>Traceback:</h2>\n<pre>\n%s\n</pre>\n' \
+ % (html_escape(repr(_e())), html_escape(format_exc(10)))
+ environ['wsgi.errors'].write(err)
+ headers = [('Content-Type', 'text/html; charset=UTF-8')]
+ start_response('500 INTERNAL SERVER ERROR', headers)
+ return [tob(err)]
+
+ def __call__(self, environ, start_response):
+ ''' Each instance of :class:'Bottle' is a WSGI application. '''
+ return self.wsgi(environ, start_response)
+
+
+
+
+
+
+###############################################################################
+# HTTP and WSGI Tools ##########################################################
+###############################################################################
+
+
+class BaseRequest(object):
+ """ A wrapper for WSGI environment dictionaries that adds a lot of
+ convenient access methods and properties. Most of them are read-only."""
+
+ #: Maximum size of memory buffer for :attr:`body` in bytes.
+ MEMFILE_MAX = 102400
+ #: Maximum number pr GET or POST parameters per request
+ MAX_PARAMS = 100
+
+ 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
+ environ['bottle.request'] = self
+
+ @DictProperty('environ', 'bottle.app', read_only=True)
+ def app(self):
+ ''' Bottle application handling this request. '''
+ raise AttributeError('This request is not connected to an application.')
+
+ @property
+ 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 method(self):
+ ''' The ``REQUEST_METHOD`` value as an uppercase string. '''
+ return self.environ.get('REQUEST_METHOD', 'GET').upper()
+
+ @DictProperty('environ', 'bottle.request.headers', read_only=True)
+ def headers(self):
+ ''' A :class:`WSGIHeaderDict` that provides case-insensitive access to
+ HTTP request headers. '''
+ return WSGIHeaderDict(self.environ)
+
+ 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',''))
+ cookies = list(cookies.values())[:self.MAX_PARAMS]
+ return FormsDict((c.key, c.value) for c in cookies)
+
+ 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`. '''
+ pairs = parse_qsl(self.query_string, keep_blank_values=True)
+ get = self.environ['bottle.get'] = FormsDict()
+ for key, value in pairs[:self.MAX_PARAMS]:
+ get[key] = value
+ return get
+
+ @DictProperty('environ', 'bottle.request.forms', read_only=True)
+ def forms(self):
+ """ 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.allitems():
+ if not hasattr(item, 'filename'):
+ forms[name] = item
+ return forms
+
+ @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.allitems():
+ params[key] = value
+ for key, value in self.forms.allitems():
+ params[key] = value
+ return params
+
+ @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 = FormsDict()
+ for name, item in self.POST.allitems():
+ if hasattr(item, 'filename'):
+ files[name] = item
+ return files
+
+ @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.request.body', read_only=True)
+ def _body(self):
+ maxread = max(0, self.content_length)
+ stream = self.environ['wsgi.input']
+ body = BytesIO() if maxread < self.MEMFILE_MAX else TemporaryFile(mode='w+b')
+ while maxread > 0:
+ part = stream.read(min(maxread, self.MEMFILE_MAX))
+ if not part: break
+ body.write(part)
+ maxread -= len(part)
+ self.environ['wsgi.input'] = body
+ body.seek(0)
+ return body
+
+ @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
+
+ #: An alias for :attr:`query`.
+ GET = query
+
+ @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).
+ """
+ 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 [])[:self.MAX_PARAMS]:
+ post[item.name] = item if item.filename else item.value
+ return post
+
+ @property
+ def COOKIES(self):
+ ''' Alias for :attr:`cookies` (deprecated). '''
+ depr('BaseRequest.COOKIES was renamed to BaseRequest.cookies (lowercase).')
+ return self.cookies
+
+ @property
+ 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'), '')
+
+ @property
+ def fullpath(self):
+ """ Request path including :attr:`script_name` (if present). """
+ return urljoin(self.script_name, self.path.lstrip('/'))
+
+ @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', '')
+
+ @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 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 get(self, value, default=None): return self.environ.get(value, default)
+ 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._cookies = None
+ self._headers = {'Content-Type': [self.default_content_type]}
+ self.body = body
+ 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 = 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):
+ return self._status_line
+
+ 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 updated accordingly. The return value is
+ always a status string. ''')
+ 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.items()
+ 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):
+ depr('The wsgiheader method is deprecated. See headerlist.') #0.10
+ return self.headerlist
+
+ @property
+ def headerlist(self):
+ ''' WSGI conform list of (header, value) tuples. '''
+ return list(self.iter_headers())
+
+ 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. 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 name: the name of the cookie.
+ :param value: the value of the cookie.
+ :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. (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: 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` 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
+ cookies are limited to 4kb in most browsers.
+
+ Warning: Signed cookies are not encrypted (the client can still see
+ the content) and not copy-protected (the client can restore an old
+ 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((name, value), secret))
+ elif not isinstance(value, basestring):
+ 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.items():
+ 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`
+ settings as used to create the cookie. '''
+ kwargs['max_age'] = -1
+ kwargs['expires'] = 0
+ self.set_cookie(key, '', **kwargs)
+
+ def __repr__(self):
+ out = ''
+ for name, value in self.headerlist:
+ out += '%s: %s\n' % (name.title(), value.strip())
+ return out
+
+#: Thread-local storage for :class:`LocalRequest` and :class:`LocalResponse`
+#: attributes.
+_lctx = threading.local()
+
+def local_property(name, doc=None):
+
+ return property(
+ lambda self: getattr(_lctx, name),
+ lambda self, value: setattr(_lctx, name, value),
+ lambda self: delattr(_lctx, name),
+ doc or ('Thread-local property stored in :data:`_lctx.%s` ' % name)
+ )
+
+class LocalRequest(BaseRequest):
+ ''' A thread-local subclass of :class:`BaseRequest` with a different
+ set of attribues for each thread. There is usually only one global
+ instance of this class (:data:`request`). If accessed during a
+ request/response cycle, this instance always refers to the *current*
+ request (even on a multithreaded server). '''
+ def __init__(self): pass
+ bind = BaseRequest.__init__
+ environ = local_property('request_environ')
+
+
+class LocalResponse(BaseResponse):
+ ''' A thread-local subclass of :class:`BaseResponse` with a different
+ set of attribues for each thread. There is usually only one global
+ instance of this class (:data:`response`). Its attributes are used
+ to build the HTTP response at the end of the request/response cycle.
+ '''
+ def __init__(self): pass
+ bind = BaseResponse.__init__
+ _status_line = local_property('response_status_line')
+ _status_code = local_property('response_status_code')
+ _cookies = local_property('response_cookies')
+ _headers = local_property('response_headers')
+ body = local_property('response_body')
+
+Response = LocalResponse # BC 0.9
+Request = LocalRequest # BC 0.9
+
+
+
+
+
+
+###############################################################################
+# Plugins ######################################################################
+###############################################################################
+
+class PluginError(BottleException): pass
+
+class JSONPlugin(object):
+ name = 'json'
+ api = 2
+
+ def __init__(self, json_dumps=json_dumps):
+ self.json_dumps = json_dumps
+
+ def apply(self, callback, context):
+ dumps = self.json_dumps
+ if not dumps: return callback
+ 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 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 = dict((name, []) for name in self._names)
+ self.app = None
+
+ def _empty(self):
+ return not (self.hooks['before_request'] or self.hooks['after_request'])
+
+ def setup(self, app):
+ self.app = app
+
+ def add(self, name, func):
+ ''' Attach a callback to a hook. '''
+ was_empty = self._empty()
+ 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. '''
+ was_empty = self._empty()
+ 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 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):
+ if self._empty(): return callback
+ def wrapper(*a, **ka):
+ self.trigger('before_request')
+ rv = callback(*a, **ka)
+ self.trigger('after_request', reversed=True)
+ return rv
+ return wrapper
+
+
+class TemplatePlugin(object):
+ ''' This plugin applies the :func:`view` decorator to all routes with a
+ `template` config parameter. If the parameter is a tuple, the second
+ 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, 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 route.config:
+ depr('The `template_opts` parameter is deprecated.') #0.9
+ return view(conf, **route.config['template_opts'])(callback)
+ elif isinstance(conf, str):
+ return view(conf)(callback)
+ else:
+ return callback
+
+
+#: Not a plugin, but part of the plugin API. TODO: Find a better place.
+class _ImportRedirect(object):
+ def __init__(self, name, impmask):
+ ''' Create a virtual package that redirects imports (see PEP 302). '''
+ self.name = name
+ self.impmask = impmask
+ self.module = sys.modules.setdefault(name, imp.new_module(name))
+ self.module.__dict__.update({'__file__': __file__, '__path__': [],
+ '__all__': [], '__loader__': self})
+ sys.meta_path.append(self)
+
+ def find_module(self, fullname, path=None):
+ if '.' not in fullname: return
+ packname, modname = fullname.rsplit('.', 1)
+ if packname != self.name: return
+ return self
+
+ def load_module(self, fullname):
+ if fullname in sys.modules: return sys.modules[fullname]
+ packname, modname = fullname.rsplit('.', 1)
+ realname = self.impmask % modname
+ __import__(realname)
+ module = sys.modules[fullname] = sys.modules[realname]
+ setattr(self.module, modname, module)
+ module.__loader__ = self
+ return module
+
+
+
+
+
+
+###############################################################################
+# Common Utilities #############################################################
+###############################################################################
+
+
+class MultiDict(DictMixin):
+ """ 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).items())
+
+ 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 __getitem__(self, key): return self.dict[key][-1]
+ def __setitem__(self, key, value): self.append(key, value)
+ def keys(self): return self.dict.keys()
+
+ if py3k:
+ def values(self): return (v[-1] for v in self.dict.values())
+ def items(self): return ((k, v[-1]) for k, v in self.dict.items())
+ def allitems(self):
+ return ((k, v) for k, vl in self.dict.items() for v in vl)
+ iterkeys = keys
+ itervalues = values
+ iteritems = items
+ iterallitems = allitems
+
+ else:
+ def values(self): return [v[-1] for v in self.dict.values()]
+ def items(self): return [(k, v[-1]) for k, v in self.dict.items()]
+ 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):
+ return ((k, v) for k, vl in self.dict.iteritems() for v in vl)
+ def allitems(self):
+ return [(k, v) for k, vl in self.dict.iteritems() for v in vl]
+
+ 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:
+ pass
+ return default
+
+ def append(self, key, value):
+ ''' Add a new value to the list of values for this key. '''
+ self.dict.setdefault(key, []).append(value)
+
+ def replace(self, key, value):
+ ''' 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. Attributes are automatically 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'
+ #: If true (default), unicode strings are first encoded with `latin1`
+ #: and then decoded to match :attr:`input_encoding`.
+ recode_unicode = True
+
+ def _fix(self, s, encoding=None):
+ if isinstance(s, unicode) and self.recode_unicode: # Python 3 WSGI
+ s = s.encode('latin1')
+ if isinstance(s, bytes): # Python 2 WSGI
+ return s.decode(encoding or self.input_encoding)
+ return s
+
+ def decode(self, encoding=None):
+ ''' Returns a copy with all keys and values de- or recoded to match
+ :attr:`input_encoding`. Some libraries (e.g. WTForms) want a
+ unicode dictionary. '''
+ copy = FormsDict()
+ enc = copy.input_encoding = encoding or self.input_encoding
+ copy.recode_unicode = False
+ for key, value in self.allitems():
+ copy.append(self._fix(key, enc), self._fix(value, enc))
+ return copy
+
+ def getunicode(self, name, default=None, encoding=None):
+ try:
+ return self._fix(self[name], encoding)
+ except (UnicodeError, KeyError):
+ return default
+
+ def __getattr__(self, name, default=unicode()):
+ return self.getunicode(name, default=default)
+
+
+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 [_hkey(n) for n in names]:
+ if name in self.dict:
+ del self.dict[name]
+
+
+class WSGIHeaderDict(DictMixin):
+ ''' This dict-like class wraps a WSGI environ dict and provides convenient
+ access to HTTP_* fields. Keys and values are native strings
+ (2.x bytes or 3.x unicode) and keys are case-insensitive. If the WSGI
+ environment contains non-native string values, these are de- or encoded
+ using a lossless 'latin1' character set.
+
+ The API will remain stable even on changes to the relevant PEPs.
+ Currently PEP 333, 444 and 3333 are supported. (PEP 444 is the only one
+ that uses non-native strings.)
+ '''
+ #: List of keys that do not have a 'HTTP_' prefix.
+ cgikeys = ('CONTENT_TYPE', 'CONTENT_LENGTH')
+
+ def __init__(self, environ):
+ self.environ = environ
+
+ def _ekey(self, key):
+ ''' Translate header field name to CGI/WSGI environ key. '''
+ key = key.replace('-','_').upper()
+ if key in self.cgikeys:
+ return key
+ return 'HTTP_' + key
+
+ def raw(self, key, default=None):
+ ''' Return the header value as is (may be bytes or unicode). '''
+ return self.environ.get(self._ekey(key), default)
+
+ def __getitem__(self, key):
+ return tonat(self.environ[self._ekey(key)], 'latin1')
+
+ def __setitem__(self, key, value):
+ raise TypeError("%s is read-only." % self.__class__)
+
+ def __delitem__(self, key):
+ raise TypeError("%s is read-only." % self.__class__)
+
+ def __iter__(self):
+ for key in self.environ:
+ if key[:5] == 'HTTP_':
+ yield key[5:].replace('_', '-').title()
+ elif key in self.cgikeys:
+ yield key.replace('_', '-').title()
+
+ 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).items(): setattr(self, key, value)
+ return self
+
+
+class AppStack(list):
+ """ A stack-like list. Calling it returns the head of the stack. """
+
+ def __call__(self):
+ """ Return the current default application. """
+ return self[-1]
+
+ def push(self, value=None):
+ """ Add a new :class:`Bottle` instance to the stack """
+ if not isinstance(value, Bottle):
+ value = Bottle()
+ self.append(value)
+ return value
+
+
+class WSGIFileWrapper(object):
+
+ def __init__(self, fp, buffer_size=1024*64):
+ self.fp, self.buffer_size = fp, buffer_size
+ for attr in ('fileno', 'close', 'read', 'readlines', 'tell', 'seek'):
+ if hasattr(fp, attr): setattr(self, attr, getattr(fp, attr))
+
+ def __iter__(self):
+ buff, read = self.buffer_size, self.read
+ while True:
+ part = read(buff)
+ if not part: return
+ yield part
+
+
+
+
+
+
+###############################################################################
+# Application Helper ###########################################################
+###############################################################################
+
+
+def abort(code=500, text='Unknown Error: Application stopped.'):
+ """ Aborts execution and causes a HTTP error. """
+ raise HTTPError(code, text)
+
+
+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 _file_iter_range(fp, offset, bytes, maxread=1024*1024):
+ ''' Yield chunks from a range in a file. No chunk is bigger than maxread.'''
+ fp.seek(offset)
+ while bytes > 0:
+ part = fp.read(min(bytes, maxread))
+ if not part: break
+ bytes -= len(part)
+ yield part
+
+
+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
+ and HEAD requests.
+ """
+ root = os.path.abspath(root) + os.sep
+ filename = os.path.abspath(os.path.join(root, filename.strip('/\\')))
+ header = dict()
+
+ if not filename.startswith(root):
+ return HTTPError(403, "Access denied.")
+ if not os.path.exists(filename) or not os.path.isfile(filename):
+ return HTTPError(404, "File does not exist.")
+ if not os.access(filename, os.R_OK):
+ return HTTPError(403, "You do not have permission to access this file.")
+
+ if mimetype == 'auto':
+ mimetype, encoding = mimetypes.guess_type(filename)
+ if mimetype: header['Content-Type'] = mimetype
+ if encoding: header['Content-Encoding'] = encoding
+ elif mimetype:
+ header['Content-Type'] = mimetype
+
+ if download:
+ download = os.path.basename(filename if download == True else download)
+ header['Content-Disposition'] = 'attachment; filename="%s"' % download
+
+ stats = os.stat(filename)
+ header['Content-Length'] = clen = stats.st_size
+ lm = time.strftime("%a, %d %b %Y %H:%M:%S GMT", time.gmtime(stats.st_mtime))
+ header['Last-Modified'] = lm
+
+ ims = request.environ.get('HTTP_IF_MODIFIED_SINCE')
+ if ims:
+ ims = parse_date(ims.split(";")[0].strip())
+ if ims is not None and ims >= int(stats.st_mtime):
+ header['Date'] = time.strftime("%a, %d %b %Y %H:%M:%S GMT", time.gmtime())
+ return HTTPResponse(status=304, header=header)
+
+ body = '' if request.method == 'HEAD' else open(filename, 'rb')
+
+ header["Accept-Ranges"] = "bytes"
+ ranges = request.environ.get('HTTP_RANGE')
+ if 'HTTP_RANGE' in request.environ:
+ ranges = list(parse_range_header(request.environ['HTTP_RANGE'], clen))
+ if not ranges:
+ return HTTPError(416, "Requested Range Not Satisfiable")
+ offset, end = ranges[0]
+ header["Content-Range"] = "bytes %d-%d/%d" % (offset, end-1, clen)
+ header["Content-Length"] = str(end-offset)
+ if body: body = _file_iter_range(body, offset, end-offset)
+ return HTTPResponse(body, header=header, status=206)
+ return HTTPResponse(body, header=header)
+
+
+
+
+
+
+###############################################################################
+# HTTP Utilities and MISC (TODO) ###############################################
+###############################################################################
+
+
+def debug(mode=True):
+ """ Change the debug level.
+ There is only one debug level supported at the moment."""
+ global DEBUG
+ DEBUG = bool(mode)
+
+
+def parse_date(ims):
+ """ Parse rfc1123, rfc850 and asctime timestamps and return UTC epoch. """
+ try:
+ ts = email.utils.parsedate_tz(ims)
+ return time.mktime(ts[:8] + (0,)) - (ts[9] or 0) - time.timezone
+ except (TypeError, ValueError, IndexError, OverflowError):
+ return None
+
+
+def parse_auth(header):
+ """ Parse rfc2617 HTTP authentication header string (basic) and return (user,pass) tuple or None"""
+ try:
+ method, data = header.split(None, 1)
+ if method.lower() == 'basic':
+ user, pwd = touni(base64.b64decode(tob(data))).split(':',1)
+ return user, pwd
+ except (KeyError, ValueError):
+ return None
+
+def parse_range_header(header, maxlen=0):
+ ''' Yield (start, end) ranges parsed from a HTTP Range header. Skip
+ unsatisfiable ranges. The end index is non-inclusive.'''
+ if not header or header[:6] != 'bytes=': return
+ ranges = [r.split('-', 1) for r in header[6:].split(',') if '-' in r]
+ for start, end in ranges:
+ try:
+ if not start: # bytes=-100 -> last 100 bytes
+ start, end = max(0, maxlen-int(end)), maxlen
+ elif not end: # bytes=100- -> all but the first 99 bytes
+ start, end = int(start), maxlen
+ else: # bytes=100-200 -> bytes 100-200 (inclusive)
+ start, end = int(start), min(int(end)+1, maxlen)
+ if 0 <= start < end <= maxlen:
+ yield start, end
+ except ValueError:
+ pass
+
+def _lscmp(a, b):
+ ''' Compares two strings in a cryptographically save way:
+ Runtime is not affected by length of common prefix. '''
+ return not sum(0 if x==y else 1 for x, y in zip(a, b)) and len(a) == len(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(tob(key), msg).digest())
+ return tob('!') + sig + tob('?') + msg
+
+
+def cookie_decode(data, key):
+ ''' Verify and decode an encoded string. Return an object or None.'''
+ data = tob(data)
+ if cookie_is_encoded(data):
+ sig, msg = data.split(tob('?'), 1)
+ if _lscmp(sig[1:], base64.b64encode(hmac.new(tob(key), msg).digest())):
+ return pickle.loads(base64.b64decode(msg))
+ return None
+
+
+def cookie_is_encoded(data):
+ ''' Return True if the argument looks like a encoded cookie.'''
+ 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
+ takes optional keyword arguments. The output is best described by example::
+
+ a() -> '/a'
+ b(x, y) -> '/b/:x/:y'
+ c(x, y=5) -> '/c/:x' and '/c/:x/:y'
+ d(x=5, y=6) -> '/d' and '/d/:x' and '/d/:x/:y'
+ """
+ import inspect # Expensive module. Only import if necessary.
+ path = '/' + func.__name__.replace('__','/').lstrip('/')
+ spec = inspect.getargspec(func)
+ argc = len(spec[0]) - len(spec[3] or [])
+ path += ('/:%s' * argc) % tuple(spec[0][:argc])
+ yield path
+ for arg in spec[0][argc:]:
+ path += '/:%s' % arg
+ yield path
+
+
+def path_shift(script_name, path_info, shift=1):
+ ''' Shift path fragments from PATH_INFO to SCRIPT_NAME and vice versa.
+
+ :return: The modified paths.
+ :param script_name: The SCRIPT_NAME path.
+ :param script_name: The PATH_INFO path.
+ :param shift: The number of path fragments to shift. May be negative to
+ change the shift direction. (default: 1)
+ '''
+ if shift == 0: return script_name, path_info
+ pathlist = path_info.strip('/').split('/')
+ scriptlist = script_name.strip('/').split('/')
+ if pathlist and pathlist[0] == '': pathlist = []
+ if scriptlist and scriptlist[0] == '': scriptlist = []
+ if shift > 0 and shift <= len(pathlist):
+ moved = pathlist[:shift]
+ scriptlist = scriptlist + moved
+ pathlist = pathlist[shift:]
+ elif shift < 0 and shift >= -len(scriptlist):
+ moved = scriptlist[shift:]
+ pathlist = moved + pathlist
+ scriptlist = scriptlist[:shift]
+ else:
+ empty = 'SCRIPT_NAME' if shift < 0 else 'PATH_INFO'
+ raise AssertionError("Cannot shift. Nothing left from %s" % empty)
+ new_script_name = '/' + '/'.join(scriptlist)
+ new_path_info = '/' + '/'.join(pathlist)
+ if path_info.endswith('/') and pathlist: new_path_info += '/'
+ return new_script_name, new_path_info
+
+
+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):
+ @functools.wraps(func)
+ def wrapper(*args, **kargs):
+ for key, value in vkargs.items():
+ if key not in kargs:
+ abort(403, 'Missing parameter: %s' % key)
+ try:
+ kargs[key] = value(kargs[key])
+ except ValueError:
+ abort(403, 'Wrong parameter format for: %s' % key)
+ return func(*args, **kargs)
+ return wrapper
+ return decorator
+
+
+def auth_basic(check, realm="private", text="Access denied"):
+ ''' Callback decorator to require HTTP auth (basic).
+ TODO: Add route(check_auth=...) parameter. '''
+ def decorator(func):
+ def wrapper(*a, **ka):
+ user, password = request.auth or (None, None)
+ if user is None or not check(user, password):
+ response.headers['WWW-Authenticate'] = 'Basic realm="%s"' % realm
+ return HTTPError(401, text)
+ return func(*a, **ka)
+ return wrapper
+ return decorator
+
+
+# Shortcuts for common Bottle methods.
+# They all refer to the current default application.
+
+def make_default_app_wrapper(name):
+ ''' Return a callable that relays calls to the current default app. '''
+ @functools.wraps(getattr(Bottle, name))
+ def wrapper(*a, **ka):
+ return getattr(app(), name)(*a, **ka)
+ return wrapper
+
+route = make_default_app_wrapper('route')
+get = make_default_app_wrapper('get')
+post = make_default_app_wrapper('post')
+put = make_default_app_wrapper('put')
+delete = make_default_app_wrapper('delete')
+error = make_default_app_wrapper('error')
+mount = make_default_app_wrapper('mount')
+hook = make_default_app_wrapper('hook')
+install = make_default_app_wrapper('install')
+uninstall = make_default_app_wrapper('uninstall')
+url = make_default_app_wrapper('get_url')
+
+
+
+
+
+
+
+###############################################################################
+# Server Adapter ###############################################################
+###############################################################################
+
+
+class ServerAdapter(object):
+ quiet = False
+ def __init__(self, host='127.0.0.1', port=8080, **config):
+ self.options = config
+ self.host = host
+ self.port = int(port)
+
+ def run(self, handler): # pragma: no cover
+ pass
+
+ def __repr__(self):
+ args = ', '.join(['%s=%s'%(k,repr(v)) for k, v in self.options.items()])
+ return "%s(%s)" % (self.__class__.__name__, args)
+
+
+class CGIServer(ServerAdapter):
+ quiet = True
+ def run(self, handler): # pragma: no cover
+ from wsgiref.handlers import CGIHandler
+ 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
+ self.options.setdefault('bindAddress', (self.host, self.port))
+ flup.server.fcgi.WSGIServer(handler, **self.options).run()
+
+
+class WSGIRefServer(ServerAdapter):
+ def run(self, handler): # pragma: no cover
+ from wsgiref.simple_server import make_server, WSGIRequestHandler
+ if self.quiet:
+ class QuietHandler(WSGIRequestHandler):
+ def log_request(*args, **kw): pass
+ self.options['handler_class'] = QuietHandler
+ srv = make_server(self.host, self.port, handler, **self.options)
+ srv.serve_forever()
+
+
+class CherryPyServer(ServerAdapter):
+ def run(self, handler): # pragma: no cover
+ from cherrypy import wsgiserver
+ server = wsgiserver.CherryPyWSGIServer((self.host, self.port), handler)
+ try:
+ server.start()
+ finally:
+ server.stop()
+
+
+class WaitressServer(ServerAdapter):
+ def run(self, handler):
+ from waitress import serve
+ serve(handler, host=self.host, port=self.port)
+
+
+class PasteServer(ServerAdapter):
+ def run(self, handler): # pragma: no cover
+ from paste import httpserver
+ if not self.quiet:
+ from paste.translogger import TransLogger
+ handler = TransLogger(handler)
+ httpserver.serve(handler, host=self.host, port=str(self.port),
+ **self.options)
+
+
+class MeinheldServer(ServerAdapter):
+ def run(self, handler):
+ from meinheld import server
+ server.listen((self.host, self.port))
+ server.run(handler)
+
+
+class FapwsServer(ServerAdapter):
+ """ Extremely fast webserver using libev. See http://www.fapws.org/ """
+ def run(self, handler): # pragma: no cover
+ import fapws._evwsgi as evwsgi
+ from fapws import base, config
+ port = self.port
+ if float(config.SERVER_IDENT[-2:]) > 0.4:
+ # fapws3 silently changed its API in 0.5
+ port = str(port)
+ evwsgi.start(self.host, port)
+ # fapws3 never releases the GIL. Complain upstream. I tried. No luck.
+ if 'BOTTLE_CHILD' in os.environ and not self.quiet:
+ _stderr("WARNING: Auto-reloading does not work with Fapws3.\n")
+ _stderr(" (Fapws3 breaks python thread support)\n")
+ evwsgi.set_base_module(base)
+ def app(environ, start_response):
+ environ['wsgi.multiprocess'] = False
+ return handler(environ, start_response)
+ evwsgi.wsgi_cb(('', app))
+ evwsgi.run()
+
+
+class TornadoServer(ServerAdapter):
+ """ The super hyped asynchronous server by facebook. Untested. """
+ def run(self, handler): # pragma: no cover
+ import tornado.wsgi, tornado.httpserver, tornado.ioloop
+ container = tornado.wsgi.WSGIContainer(handler)
+ server = tornado.httpserver.HTTPServer(container)
+ server.listen(port=self.port)
+ tornado.ioloop.IOLoop.instance().start()
+
+
+class AppEngineServer(ServerAdapter):
+ """ Adapter for Google App Engine. """
+ quiet = True
+ def run(self, handler):
+ from google.appengine.ext.webapp import util
+ # A main() function in the handler script enables 'App Caching'.
+ # Lets makes sure it is there. This _really_ improves performance.
+ module = sys.modules.get('__main__')
+ if module and not hasattr(module, 'main'):
+ module.main = lambda: util.run_wsgi_app(handler)
+ util.run_wsgi_app(handler)
+
+
+class TwistedServer(ServerAdapter):
+ """ Untested. """
+ def run(self, handler):
+ from twisted.web import server, wsgi
+ from twisted.python.threadpool import ThreadPool
+ from twisted.internet import reactor
+ thread_pool = ThreadPool()
+ thread_pool.start()
+ reactor.addSystemEventTrigger('after', 'shutdown', thread_pool.stop)
+ factory = server.Site(wsgi.WSGIResource(reactor, thread_pool, handler))
+ reactor.listenTCP(self.port, factory, interface=self.host)
+ reactor.run()
+
+
+class DieselServer(ServerAdapter):
+ """ Untested. """
+ def run(self, handler):
+ from diesel.protocols.wsgi import WSGIApplication
+ app = WSGIApplication(handler, port=self.port)
+ app.run()
+
+
+class GeventServer(ServerAdapter):
+ """ Untested. Options:
+
+ * `monkey` (default: True) fixes the stdlib to use greenthreads.
+ * `fast` (default: False) uses libevent's http server, but has some
+ issues: No streaming, no pipelining, no SSL.
+ """
+ def run(self, handler):
+ from gevent import wsgi as wsgi_fast, pywsgi, monkey, local
+ if self.options.get('monkey', True):
+ 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. See http://gunicorn.org/configure.html for options. """
+ def run(self, handler):
+ 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):
+ """ Untested """
+ def run(self, handler):
+ from eventlet import wsgi, listen
+ try:
+ wsgi.server(listen((self.host, self.port)), handler,
+ log_output=(not self.quiet))
+ except TypeError:
+ # Fallback, if we have old version of eventlet
+ wsgi.server(listen((self.host, self.port)), handler)
+
+
+class RocketServer(ServerAdapter):
+ """ Untested. """
+ def run(self, handler):
+ from rocket import Rocket
+ server = Rocket((self.host, self.port), 'wsgi', { 'wsgi_app' : handler })
+ server.start()
+
+
+class BjoernServer(ServerAdapter):
+ """ Fast server written in C: https://github.com/jonashaag/bjoern """
+ def run(self, handler):
+ from bjoern import run
+ run(handler, self.host, self.port)
+
+
+class AutoServer(ServerAdapter):
+ """ Untested. """
+ adapters = [WaitressServer, PasteServer, TwistedServer, CherryPyServer, WSGIRefServer]
+ def run(self, handler):
+ for sa in self.adapters:
+ try:
+ return sa(self.host, self.port, **self.options).run(handler)
+ except ImportError:
+ pass
+
+server_names = {
+ 'cgi': CGIServer,
+ 'flup': FlupFCGIServer,
+ 'wsgiref': WSGIRefServer,
+ 'waitress': WaitressServer,
+ 'cherrypy': CherryPyServer,
+ 'paste': PasteServer,
+ 'fapws3': FapwsServer,
+ 'tornado': TornadoServer,
+ 'gae': AppEngineServer,
+ 'twisted': TwistedServer,
+ 'diesel': DieselServer,
+ 'meinheld': MeinheldServer,
+ 'gunicorn': GunicornServer,
+ 'eventlet': EventletServer,
+ 'gevent': GeventServer,
+ 'rocket': RocketServer,
+ 'bjoern' : BjoernServer,
+ 'auto': AutoServer,
+}
+
+
+
+
+
+
+###############################################################################
+# Application Control ##########################################################
+###############################################################################
+
+
+def load(target, **namespace):
+ """ Import a module or fetch an object from a module.
+
+ * ``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)
+ package_name = module.split('.')[0]
+ namespace[package_name] = sys.modules[package_name]
+ return eval('%s.%s' % (module, target), namespace)
+
+
+def load_app(target):
+ """ 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
+
+_debug = debug
+def run(app=None, server='wsgiref', host='127.0.0.1', port=8080,
+ interval=1, reloader=False, quiet=False, plugins=None,
+ debug=False, **kargs):
+ """ Start a server instance. This method blocks until the server terminates.
+
+ :param app: WSGI application or target string supported by
+ :func:`load_app`. (default: :func:`default_app`)
+ :param server: Server adapter to use. See :data:`server_names` keys
+ for valid names or pass a :class:`ServerAdapter` subclass.
+ (default: `wsgiref`)
+ :param host: Server address to bind to. Pass ``0.0.0.0`` to listens on
+ all interfaces including the external one. (default: 127.0.0.1)
+ :param port: Server port to bind to. Values below 1024 require root
+ privileges. (default: 8080)
+ :param reloader: Start auto-reloading server? (default: False)
+ :param interval: Auto-reloader interval in seconds (default: 1)
+ :param quiet: Suppress output to stdout and stderr? (default: False)
+ :param options: Options passed to the server adapter.
+ """
+ if NORUN: return
+ if reloader and not os.environ.get('BOTTLE_CHILD'):
+ try:
+ lockfile = None
+ 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
+
+ try:
+ _debug(debug)
+ 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 v%s server starting up (using %s)...\n" % (__version__, repr(server)))
+ _stderr("Listening on http://%s:%d/\n" % (server.host, server.port))
+ _stderr("Hit Ctrl-C to quit.\n\n")
+
+ if reloader:
+ 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
+ except (SystemExit, MemoryError):
+ raise
+ except:
+ if not reloader: raise
+ if not getattr(server, 'quiet', quiet):
+ print_exc()
+ time.sleep(interval)
+ sys.exit(3)
+
+
+
+class FileCheckerThread(threading.Thread):
+ ''' 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
+ #: 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 list(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 list(files.items()):
+ if not exists(path) or mtime(path) > lmtime:
+ 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 exc_type is not None and issubclass(exc_type, KeyboardInterrupt)
+
+
+
+
+
+###############################################################################
+# Template Adapters ############################################################
+###############################################################################
+
+
+class TemplateError(HTTPError):
+ def __init__(self, message):
+ HTTPError.__init__(self, 500, message)
+
+
+class BaseTemplate(object):
+ """ Base class and minimal API for template adapters """
+ extensions = ['tpl','html','thtml','stpl']
+ settings = {} #used in prepare()
+ defaults = {} #used in render()
+
+ def __init__(self, source=None, name=None, lookup=[], encoding='utf8', **settings):
+ """ Create a new template.
+ If the source parameter (str or buffer) is missing, the name argument
+ is used to guess a template filename. Subclasses can assume that
+ self.source and/or self.filename are set. Both are strings.
+ The lookup, encoding and settings parameters are stored as instance
+ variables.
+ The lookup parameter stores a list containing directory paths.
+ The encoding parameter should be used to decode byte strings or files.
+ The settings parameter contains a dict for engine-specific settings.
+ """
+ self.name = name
+ self.source = source.read() if hasattr(source, 'read') else source
+ self.filename = source.filename if hasattr(source, 'filename') else None
+ self.lookup = [os.path.abspath(x) for x in lookup]
+ self.encoding = encoding
+ self.settings = self.settings.copy() # Copy from class variable
+ self.settings.update(settings) # Apply
+ if not self.source and self.name:
+ self.filename = self.search(self.name, self.lookup)
+ if not self.filename:
+ raise TemplateError('Template %s not found.' % repr(name))
+ if not self.source and not self.filename:
+ raise TemplateError('No template specified.')
+ self.prepare(**self.settings)
+
+ @classmethod
+ def search(cls, name, lookup=[]):
+ """ Search name in all directories specified in lookup.
+ First without, then with common extensions. Return first hit. """
+ if os.path.isfile(name): return name
+ for spath in lookup:
+ fname = os.path.join(spath, name)
+ if os.path.isfile(fname):
+ return fname
+ for ext in cls.extensions:
+ if os.path.isfile('%s.%s' % (fname, ext)):
+ return '%s.%s' % (fname, ext)
+
+ @classmethod
+ 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]
+
+ def prepare(self, **options):
+ """ Run preparations (parsing, caching, ...).
+ It should be possible to call this again to refresh a template or to
+ update settings.
+ """
+ raise NotImplementedError
+
+ def render(self, *args, **kwargs):
+ """ Render the template with the specified local variables and return
+ a single byte or unicode string. If it is a byte string, the encoding
+ must match self.encoding. This method must be thread-safe!
+ Local variables may be provided in dictionaries (*args)
+ or directly, as keywords (**kwargs).
+ """
+ raise NotImplementedError
+
+
+class MakoTemplate(BaseTemplate):
+ def prepare(self, **options):
+ from mako.template import Template
+ from mako.lookup import TemplateLookup
+ options.update({'input_encoding':self.encoding})
+ options.setdefault('format_exceptions', bool(DEBUG))
+ lookup = TemplateLookup(directories=self.lookup, **options)
+ if self.source:
+ self.tpl = Template(self.source, lookup=lookup, **options)
+ else:
+ self.tpl = Template(uri=self.name, filename=self.filename, lookup=lookup, **options)
+
+ def render(self, *args, **kwargs):
+ for dictarg in args: kwargs.update(dictarg)
+ _defaults = self.defaults.copy()
+ _defaults.update(kwargs)
+ return self.tpl.render(**_defaults)
+
+
+class CheetahTemplate(BaseTemplate):
+ def prepare(self, **options):
+ from Cheetah.Template import Template
+ self.context = threading.local()
+ self.context.vars = {}
+ options['searchList'] = [self.context.vars]
+ if self.source:
+ self.tpl = Template(source=self.source, **options)
+ else:
+ self.tpl = Template(file=self.filename, **options)
+
+ def render(self, *args, **kwargs):
+ for dictarg in args: kwargs.update(dictarg)
+ self.context.vars.update(self.defaults)
+ self.context.vars.update(kwargs)
+ out = str(self.tpl)
+ self.context.vars.clear()
+ return out
+
+
+class Jinja2Template(BaseTemplate):
+ def prepare(self, filters=None, tests=None, **kwargs):
+ from jinja2 import Environment, FunctionLoader
+ if 'prefix' in kwargs: # TODO: to be removed after a while
+ raise RuntimeError('The keyword argument `prefix` has been removed. '
+ 'Use the full jinja2 environment name line_statement_prefix instead.')
+ self.env = Environment(loader=FunctionLoader(self.loader), **kwargs)
+ if filters: self.env.filters.update(filters)
+ if tests: self.env.tests.update(tests)
+ if self.source:
+ self.tpl = self.env.from_string(self.source)
+ else:
+ self.tpl = self.env.get_template(self.filename)
+
+ def render(self, *args, **kwargs):
+ for dictarg in args: kwargs.update(dictarg)
+ _defaults = self.defaults.copy()
+ _defaults.update(kwargs)
+ return self.tpl.render(**_defaults)
+
+ def loader(self, name):
+ fname = self.search(name, self.lookup)
+ if not fname: return
+ with open(fname, "rb") as f:
+ return f.read().decode(self.encoding)
+
+
+class SimpleTALTemplate(BaseTemplate):
+ ''' Deprecated, do not use. '''
+ def prepare(self, **options):
+ depr('The SimpleTAL template handler is deprecated'\
+ ' and will be removed in 0.12')
+ from simpletal import simpleTAL
+ if self.source:
+ self.tpl = simpleTAL.compileHTMLTemplate(self.source)
+ else:
+ with open(self.filename, 'rb') as fp:
+ self.tpl = simpleTAL.compileHTMLTemplate(tonat(fp.read()))
+
+ def render(self, *args, **kwargs):
+ from simpletal import simpleTALES
+ for dictarg in args: kwargs.update(dictarg)
+ context = simpleTALES.Context()
+ for k,v in self.defaults.items():
+ context.addGlobal(k, v)
+ for k,v in kwargs.items():
+ context.addGlobal(k, v)
+ output = StringIO()
+ self.tpl.expand(context, output)
+ return output.getvalue()
+
+
+class SimpleTemplate(BaseTemplate):
+ blocks = ('if', 'elif', 'else', 'try', 'except', 'finally', 'for', 'while',
+ 'with', 'def', 'class')
+ dedent_blocks = ('elif', 'else', 'except', 'finally')
+
+ @lazy_attribute
+ def re_pytokens(cls):
+ ''' This matches comments and all kinds of quoted strings but does
+ NOT match comments (#...) within quoted strings. (trust me) '''
+ return re.compile(r'''
+ (''(?!')|""(?!")|'{6}|"{6} # Empty strings (all 4 types)
+ |'(?:[^\\']|\\.)+?' # Single quotes (')
+ |"(?:[^\\"]|\\.)+?" # Double quotes (")
+ |'{3}(?:[^\\]|\\.|\n)+?'{3} # Triple-quoted strings (')
+ |"{3}(?:[^\\]|\\.|\n)+?"{3} # Triple-quoted strings (")
+ |\#.* # Comments
+ )''', re.VERBOSE)
+
+ def prepare(self, escape_func=html_escape, noescape=False, **kwargs):
+ self.cache = {}
+ enc = self.encoding
+ self._str = lambda x: touni(x, enc)
+ self._escape = lambda x: escape_func(touni(x, enc))
+ if noescape:
+ self._str, self._escape = self._escape, self._str
+
+ @classmethod
+ def split_comment(cls, code):
+ """ Removes comments (#...) from python code. """
+ if '#' not in code: return code
+ #: Remove comments only (leave quoted strings as they are)
+ subf = lambda m: '' if m.group(0)[0]=='#' else m.group(0)
+ return re.sub(cls.re_pytokens, subf, code)
+
+ @cached_property
+ def co(self):
+ return compile(self.code, self.filename or '<string>', 'exec')
+
+ @cached_property
+ def code(self):
+ stack = [] # Current Code indentation
+ lineno = 0 # Current line of code
+ ptrbuffer = [] # Buffer for printable strings and token tuple instances
+ codebuffer = [] # Buffer for generated python code
+ multiline = dedent = oneline = False
+ template = self.source or open(self.filename, 'rb').read()
+
+ def yield_tokens(line):
+ for i, part in enumerate(re.split(r'\{\{(.*?)\}\}', line)):
+ if i % 2:
+ if part.startswith('!'): yield 'RAW', part[1:]
+ else: yield 'CMD', part
+ else: yield 'TXT', part
+
+ def flush(): # Flush the ptrbuffer
+ if not ptrbuffer: return
+ cline = ''
+ for line in ptrbuffer:
+ for token, value in line:
+ if token == 'TXT': cline += repr(value)
+ elif token == 'RAW': cline += '_str(%s)' % value
+ elif token == 'CMD': cline += '_escape(%s)' % value
+ cline += ', '
+ cline = cline[:-2] + '\\\n'
+ cline = cline[:-2]
+ if cline[:-1].endswith('\\\\\\\\\\n'):
+ cline = cline[:-7] + cline[-1] # 'nobr\\\\\n' --> 'nobr'
+ cline = '_printlist([' + cline + '])'
+ del ptrbuffer[:] # Do this before calling code() again
+ code(cline)
+
+ def code(stmt):
+ for line in stmt.splitlines():
+ codebuffer.append(' ' * len(stack) + line.strip())
+
+ for line in template.splitlines(True):
+ lineno += 1
+ line = touni(line, self.encoding)
+ sline = line.lstrip()
+ if lineno <= 2:
+ m = re.match(r"%\s*#.*coding[:=]\s*([-\w.]+)", sline)
+ if m: self.encoding = m.group(1)
+ if m: line = line.replace('coding','coding (removed)')
+ if sline and sline[0] == '%' and sline[:2] != '%%':
+ 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() # 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:"
+ if dedent and not oneline and not multiline:
+ cmd = stack.pop()
+ code(line)
+ oneline = not cline.endswith(':') # "if 1: pass"
+ multiline = cmd if cline.endswith('\\') else False
+ if not oneline and not multiline:
+ stack.append(cmd)
+ elif cmd == 'end' and stack:
+ code('#end(%s) %s' % (stack.pop(), line.strip()[3:]))
+ elif cmd == 'include':
+ p = cline.split(None, 2)[1:]
+ if len(p) == 2:
+ code("_=_include(%s, _stdout, %s)" % (repr(p[0]), p[1]))
+ elif p:
+ code("_=_include(%s, _stdout)" % repr(p[0]))
+ else: # Empty %include -> reverse of %rebase
+ code("_printlist(_base)")
+ elif cmd == 'rebase':
+ p = cline.split(None, 2)[1:]
+ if len(p) == 2:
+ code("globals()['_rebase']=(%s, dict(%s))" % (repr(p[0]), p[1]))
+ elif p:
+ code("globals()['_rebase']=(%s, {})" % repr(p[0]))
+ else:
+ code(line)
+ else: # Line starting with text (not '%') or '%%' (escaped)
+ if line.strip().startswith('%%'):
+ line = line.replace('%%', '%', 1)
+ ptrbuffer.append(yield_tokens(line))
+ flush()
+ return '\n'.join(codebuffer) + '\n'
+
+ def subtemplate(self, _name, _stdout, *args, **kwargs):
+ for dictarg in args: kwargs.update(dictarg)
+ if _name not in self.cache:
+ self.cache[_name] = self.__class__(name=_name, lookup=self.lookup)
+ return self.cache[_name].execute(_stdout, kwargs)
+
+ def execute(self, _stdout, *args, **kwargs):
+ for dictarg in args: kwargs.update(dictarg)
+ env = self.defaults.copy()
+ env.update({'_stdout': _stdout, '_printlist': _stdout.extend,
+ '_include': self.subtemplate, '_str': self._str,
+ '_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']
+ rargs['_base'] = _stdout[:] #copy stdout
+ del _stdout[:] # clear stdout
+ return self.subtemplate(subtpl,_stdout,rargs)
+ return env
+
+ def render(self, *args, **kwargs):
+ """ Render the template using keyword arguments as local variables. """
+ for dictarg in args: kwargs.update(dictarg)
+ stdout = []
+ self.execute(stdout, kwargs)
+ return ''.join(stdout)
+
+
+def template(*args, **kwargs):
+ '''
+ Get a rendered template as a string iterator.
+ You can use a name, a filename or a template string as first parameter.
+ Template rendering arguments can be passed as dictionaries
+ or directly (as keyword arguments).
+ '''
+ tpl = args[0] if args else None
+ template_adapter = kwargs.pop('template_adapter', SimpleTemplate)
+ if tpl not in TEMPLATES or DEBUG:
+ settings = kwargs.pop('template_settings', {})
+ lookup = kwargs.pop('template_lookup', TEMPLATE_PATH)
+ if isinstance(tpl, template_adapter):
+ TEMPLATES[tpl] = tpl
+ if settings: TEMPLATES[tpl].prepare(**settings)
+ elif "\n" in tpl or "{" in tpl or "%" in tpl or '$' in tpl:
+ TEMPLATES[tpl] = template_adapter(source=tpl, lookup=lookup, **settings)
+ else:
+ TEMPLATES[tpl] = template_adapter(name=tpl, lookup=lookup, **settings)
+ if not TEMPLATES[tpl]:
+ abort(500, 'Template (%s) not found' % tpl)
+ for dictarg in args[1:]: kwargs.update(dictarg)
+ return TEMPLATES[tpl].render(kwargs)
+
+mako_template = functools.partial(template, template_adapter=MakoTemplate)
+cheetah_template = functools.partial(template, template_adapter=CheetahTemplate)
+jinja2_template = functools.partial(template, template_adapter=Jinja2Template)
+simpletal_template = functools.partial(template, template_adapter=SimpleTALTemplate)
+
+
+def view(tpl_name, **defaults):
+ ''' Decorator: renders a template for a handler.
+ The handler can control its behavior like that:
+
+ - return a dict of template vars to fill out the template
+ - return something other than a dict and the view decorator will not
+ process the template, but return the handler result as is.
+ This includes returning a HTTPResponse(dict) to get,
+ for instance, JSON with autojson or other castfilters.
+ '''
+ def decorator(func):
+ @functools.wraps(func)
+ def wrapper(*args, **kwargs):
+ result = func(*args, **kwargs)
+ if isinstance(result, (dict, DictMixin)):
+ tplvars = defaults.copy()
+ tplvars.update(result)
+ return template(tpl_name, **tplvars)
+ return result
+ return wrapper
+ return decorator
+
+mako_view = functools.partial(view, template_adapter=MakoTemplate)
+cheetah_view = functools.partial(view, template_adapter=CheetahTemplate)
+jinja2_view = functools.partial(view, template_adapter=Jinja2Template)
+simpletal_view = functools.partial(view, template_adapter=SimpleTALTemplate)
+
+
+
+
+
+
+###############################################################################
+# Constants and Globals ########################################################
+###############################################################################
+
+
+TEMPLATE_PATH = ['./', './views/']
+TEMPLATES = {}
+DEBUG = False
+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.items())
+
+#: The default template used for error pages. Override with @error()
+ERROR_PAGE_TEMPLATE = """
+%try:
+ %from bottle import DEBUG, HTTP_CODES, request, touni
+ %status_name = HTTP_CODES.get(e.status, 'Unknown').title()
+ <!DOCTYPE HTML PUBLIC "-//IETF//DTD HTML 2.0//EN">
+ <html>
+ <head>
+ <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;}
+ 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>
+ <pre>{{e.output}}</pre>
+ %if DEBUG and e.exception:
+ <h2>Exception:</h2>
+ <pre>{{repr(e.exception)}}</pre>
+ %end
+ %if DEBUG and e.traceback:
+ <h2>Traceback:</h2>
+ <pre>{{e.traceback}}</pre>
+ %end
+ </body>
+ </html>
+%except ImportError:
+ <b>ImportError:</b> Could not generate the error page. Please add bottle to
+ the import path.
+%end
+"""
+
+#: A thread-safe instance of :class:`LocalRequest`. If accessed from within a
+#: request callback, this instance always refers to the *current* request
+#: (even on a multithreaded server).
+request = LocalRequest()
+
+#: A thread-safe instance of :class:`LocalResponse`. It is used to change the
+#: HTTP response for the *current* request.
+response = LocalResponse()
+
+#: A thread-safe namespace. Not used by Bottle.
+local = threading.local()
+
+# Initialize app stack (create first empty Bottle app)
+# BC: 0.6.4 and needed for run()
+app = default_app = AppStack()
+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:
+ _stdout('Bottle %s\n'%__version__)
+ sys.exit(0)
+ if not args:
+ parser.print_help()
+ _stderr('\nError: No application specified.\n')
+ sys.exit(1)
+
+ sys.path.insert(0, '.')
+ sys.modules.setdefault('bottle', sys.modules['__main__'])
+
+ host, port = (opt.bind or 'localhost'), 8080
+ if ':' in host:
+ host, port = host.rsplit(':', 1)
+
+ run(args[0], host=host, port=port, server=opt.server,
+ reloader=opt.reload, plugins=opt.plugin, debug=opt.debug)
+
+
+
+
+# THE END
diff --git a/plint/website/plint_web.py b/plint/website/plint_web.py
@@ -0,0 +1,252 @@
+#!/usr/bin/python3 -Ou
+# encoding: utf8
+
+from plint import localization, error, template, diaeresis
+import re
+from plint.bottle import run, Bottle, request, static_file, redirect, response
+from jinja2 import Environment, PackageLoader
+from json import dumps
+import time
+
+env = Environment(loader=PackageLoader('plint_web', 'views'))
+
+
+# force HTTPS usage
+# http://bottlepy.org/docs/dev/faq.html#problems-with-reverse-proxies
+# because bottle makes absolute redirects
+# https://github.com/bottlepy/bottle/blob/9fe68c89e465004a5e6babed0955bc1eeba88002/bottle.py#L2637
+# even though relative Location: is now allowed
+# http://stackoverflow.com/a/25643550
+def fix_https(app):
+ def fixed_app(environ, start_response):
+ environ['wsgi.url_scheme'] = 'https'
+ return app(environ, start_response)
+
+ return fixed_app
+
+
+app = Bottle()
+app.wsgi = fix_https(app.wsgi)
+
+THROTTLE_DELAY = 2
+throttle = set()
+
+
+def best_match(matches, header):
+ # inspired by http://www.xml.com/pub/a/2005/06/08/restful.html
+
+ def parse_one(t):
+ parts = t.split(";")
+ d = {}
+ for param in parts[1:]:
+ spl = param.split("=")
+ if (len(spl) != 2):
+ # this should be formatted as key=value
+ # so ignore it
+ continue
+ k, v = spl
+ d[k.strip().lower()] = v.strip()
+ if 'q' not in d.keys():
+ d['q'] = "1"
+ return (parts[0], d)
+
+ parts = []
+ for p in header.split(","):
+ parsed = parse_one(p)
+ try:
+ value = float(parsed[1]['q'])
+ except ValueError:
+ # q value should be a float; set it to 0
+ value = 0
+ parts.append((value, parsed[0].split("-")))
+ for lang in [x[1] for x in sorted(parts, reverse=True)]:
+ for match in matches:
+ if match in lang:
+ return match
+ return matches[0]
+
+
+def get_locale():
+ header = request.headers.get('Accept-Language')
+ print(header)
+ try:
+ return best_match(['fr', 'en'], header)
+ except AttributeError:
+ return 'en'
+
+
+def get_title(lang):
+ if lang == 'fr':
+ return "plint -- vérification formelle de poèmes"
+ else:
+ return "plint -- French poetry checker"
+
+
+@app.route('/static/tpl/<filename>')
+def server_static(filename):
+ return static_file(filename, root="./static/tpl", mimetype="text/plain")
+
+
+@app.route('/<lang>/static/img/<filename>')
+def server_static(filename, lang=None):
+ return static_file(filename, root="./static/img")
+
+
+@app.route('/<lang>/static/tpl/<filename>')
+def server_static(filename, lang=None):
+ return static_file(filename, root="./static/tpl", mimetype="text/plain")
+
+
+@app.route('/static/<filename>')
+def server_static(filename):
+ return static_file(filename, root="./static")
+
+
+@app.route('/<lang>/static/<filename>')
+def server_static(filename, lang=None):
+ return static_file(filename, root="./static")
+
+
+@app.route('/')
+def root():
+ redirect('/' + get_locale() + '/')
+
+
+@app.route('/<page>')
+def paged(page):
+ redirect('/' + get_locale() + '/' + page)
+
+
+@app.route('/<lang>/')
+def root(lang):
+ if lang not in ['fr', 'en']:
+ return paged(lang)
+ return env.get_template('index.html').render(title=get_title(lang),
+ lang=lang, path="")
+
+
+@app.route('/<lang>/about')
+def about(lang):
+ return env.get_template('about.html').render(title=get_title(lang),
+ lang=lang, path="about")
+
+
+MAX_POEM_LEN = 8192
+MAX_LINE_LEN = 512
+
+
+class TooBigException(Exception):
+ pass
+
+
+class TooLongLinesException(Exception):
+ pass
+
+
+def check(poem):
+ if len(poem) > MAX_POEM_LEN:
+ raise TooBigException
+ s = poem.split("\n")
+ for x in range(len(s)):
+ if len(s[x]) > MAX_LINE_LEN:
+ raise TooLongLinesException
+ s[x] = s[x].strip()
+ return s
+
+
+@app.route('/<lang>/checkjs', method='POST')
+def q(lang):
+ global throttle
+ # necessary when serving with lighttpd proxy-core
+ ip = request.environ.get('HTTP_X_FORWARDED_FOR')
+ if not ip:
+ # fallback; this is 127.0.0.1 with proxy-core
+ ip = request.environ.get('REMOTE_ADDR')
+ t = time.time()
+ print("== %s %s ==" % (ip, t))
+ response.content_type = 'application/json'
+ localization.init_locale(lang)
+ throttle = set(x for x in throttle if t - x[1] < THROTTLE_DELAY)
+ if ip in (x[0] for x in throttle):
+ if lang == 'fr':
+ msg = (("Trop de requêtes pour vérifier le poème,"
+ + " veuillez réessayer dans %d secondes") %
+ THROTTLE_DELAY)
+ else:
+ msg = (("Too many requests to check poem,"
+ + " please try again in %d seconds") %
+ THROTTLE_DELAY)
+ return dumps({'error': msg})
+ throttle.add((ip, t))
+ poem = re.sub(r'<>&', '', request.forms.get('poem'))
+ print(poem)
+
+ # default message
+ if lang == 'fr':
+ msg = "Le poème est vide"
+ else:
+ msg = "Poem is empty"
+
+ try:
+ poem = check(poem)
+ except TooBigException:
+ poem = None
+ if lang == 'fr':
+ msg = "Le poème est trop long (maximum %d caractères)" % MAX_POEM_LEN
+ else:
+ msg = "Poem is too long (maximum %d characters)" % MAX_POEM_LEN
+ except TooLongLinesException:
+ poem = None
+ if lang == 'fr':
+ msg = "Certaines lignes du poème sont trop longues (maximum %d caractères)" % MAX_LINE_LEN
+ else:
+ msg = "Some lines of the poem are too long (maximum %d characters)" % MAX_LINE_LEN
+ if not poem or len(poem) == 0 or (len(poem) == 1 and len(poem[0]) == 0):
+ return dumps({'error': msg})
+ templateName = re.sub(r'[^a-z_]', '', request.forms.get('template'))
+ print(templateName)
+ if templateName == 'custom':
+ x = request.forms.get('custom_template')
+ else:
+ try:
+ f = open("static/tpl/" + templateName + ".tpl")
+ x = f.read()
+ f.close()
+ except IOError:
+ if lang == 'fr':
+ msg = "Modèle inexistant"
+ else:
+ msg = "No such template"
+ return dumps({'error': msg})
+ print(x)
+ try:
+ templ = template.Template(x)
+ except error.TemplateLoadError as e:
+ if lang == 'fr':
+ msg = "Erreur à la lecture du modèle : " + e.msg
+ else:
+ msg = "Error when reading template: " + e.msg
+ return dumps({'error': msg})
+ poem.append(None)
+ r = []
+ i = 0
+ d = {}
+ for line in poem:
+ i += 1
+ last = False
+ if line == None:
+ line = ""
+ last = True
+ errors = templ.check(line, last=last)
+ if errors:
+ r.append({
+ 'line': line,
+ 'num': i,
+ 'errors': sum(errors.lines(short=True), [])
+ })
+ d['result'] = r
+ return dumps(d)
+
+
+if __name__ == '__main__':
+ run(app, port='5000', server="cherrypy", host="::")
diff --git a/requirements.txt b/requirements.txt
@@ -0,0 +1,2 @@
+frhyme
+haspirater
diff --git a/setup.py b/setup.py
@@ -0,0 +1,25 @@
+import setuptools
+
+with open("README", "r") as fh:
+ long_description = fh.read()
+
+setuptools.setup(
+ name='plint',
+ version='0.1',
+ author="Antoine Amarilli",
+ author_email="a3nm@a3nm.net",
+ description="French poetry validator",
+ package_data={'plint' :['../data/*']},
+ long_description=long_description,
+ long_description_content_type="text/markdown",
+ url="https://gitlab.com/a3nm/plint",
+ packages=setuptools.find_packages(),
+ classifiers=[
+ "Programming Language :: Python :: 3",
+ ],
+ entry_points={
+ 'console_scripts': [
+ 'poemlint=plint.__main__:main',
+ ],
+ },
+)
diff --git a/test.sh b/test.sh
@@ -15,7 +15,7 @@ for a in plint/test_data/*.tpl; do
echo "$a" >> test_temp.txt
if [[ $a == *cyrano_full* ]]
then
- ./plint.py $(pwd)/$a ../data/diaeresis_cyrano.json < $(pwd)/${a%.tpl} &>> test_temp.txt
+ python3 -m plint $(pwd)/$a ../data/diaeresis_cyrano.json < $(pwd)/${a%.tpl} &>> test_temp.txt
else
./test_one.sh $(basename "${a%.tpl}") &>> test_temp.txt
fi
diff --git a/test_one.sh b/test_one.sh
@@ -2,4 +2,4 @@
TEXT="$1"
-./plint.py "plint/test_data/$TEXT.tpl" < "plint/test_data/$TEXT"
+python3 -m plint "plint/test_data/$TEXT.tpl" < "plint/test_data/$TEXT"