diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..2cc2704 --- /dev/null +++ b/.gitignore @@ -0,0 +1,54 @@ +*.py[cod] + +test.db +test.txt +sina.html + +# C extensions +*.so + +# Packages +*.egg +*.egg-info +dist +build +eggs +parts +bin +var +sdist +develop-eggs +.installed.cfg +lib +lib64 +__pycache__ + +# Installer logs +pip-log.txt + +# Unit test / coverage reports +.coverage +.tox +nosetests.xml + +###################### +# OS generated files # +###################### +.DS_Store +.DS_Store? +._* +.Spotlight-V100 +.Trashes +Icon? +ehthumbs.db +Thumbs.db +dist +MANIFEST + +# Translations +*.mo + +# Mr Developer +.mr.developer.cfg +.project +.pydevproject diff --git a/README.md b/README.md new file mode 100644 index 0000000..07bf4f6 --- /dev/null +++ b/README.md @@ -0,0 +1,4 @@ +awesome-python3-webapp +====================== + +A python webapp tutorial. diff --git a/www/apis.py b/www/apis.py new file mode 100644 index 0000000..3a6b2c6 --- /dev/null +++ b/www/apis.py @@ -0,0 +1,41 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- + +__author__ = 'Michael Liao' + +''' +JSON API definition. +''' + +import json, logging, inspect, functools + +class APIError(Exception): + ''' + the base APIError which contains error(required), data(optional) and message(optional). + ''' + def __init__(self, error, data='', message=''): + super(APIError, self).__init__(message) + self.error = error + self.data = data + self.message = message + +class APIValueError(APIError): + ''' + Indicate the input value has error or invalid. The data specifies the error field of input form. + ''' + def __init__(self, field, message=''): + super(APIValueError, self).__init__('value:invalid', field, message) + +class APIResourceNotFoundError(APIError): + ''' + Indicate the resource was not found. The data specifies the resource name. + ''' + def __init__(self, field, message=''): + super(APIResourceNotFoundError, self).__init__('value:notfound', field, message) + +class APIPermissionError(APIError): + ''' + Indicate the api has no permission. + ''' + def __init__(self, message=''): + super(APIPermissionError, self).__init__('permission:forbidden', 'permission', message) diff --git a/www/app.py b/www/app.py new file mode 100755 index 0000000..50e94cd --- /dev/null +++ b/www/app.py @@ -0,0 +1,126 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- + +__author__ = 'Michael Liao' + +''' +async web application. +''' + +import logging; logging.basicConfig(level=logging.INFO) + +import asyncio, os, json, time +from datetime import datetime + +from aiohttp import web +from jinja2 import Environment, FileSystemLoader + +import orm +from coroweb import add_routes, add_static + +def init_jinja2(app, **kw): + logging.info('init jinja2...') + options = dict( + autoescape = kw.get('autoescape', True), + block_start_string = kw.get('block_start_string', '{%'), + block_end_string = kw.get('block_end_string', '%}'), + variable_start_string = kw.get('variable_start_string', '{{'), + variable_end_string = kw.get('variable_end_string', '}}'), + auto_reload = kw.get('auto_reload', True) + ) + path = kw.get('path', None) + if path is None: + path = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'templates') + logging.info('set jinja2 template path: %s' % path) + env = Environment(loader=FileSystemLoader(path), **options) + filters = kw.get('filters', None) + if filters is not None: + for name, f in filters.items(): + env.filters[name] = f + app['__templating__'] = env + +async def logger_factory(app, handler): + async def logger(request): + logging.info('Request: %s %s' % (request.method, request.path)) + # await asyncio.sleep(0.3) + return (await handler(request)) + return logger + +async def data_factory(app, handler): + async def parse_data(request): + if request.method == 'POST': + if request.content_type.startswith('application/json'): + request.__data__ = await request.json() + logging.info('request json: %s' % str(request.__data__)) + elif request.content_type.startswith('application/x-www-form-urlencoded'): + request.__data__ = await request.post() + logging.info('request form: %s' % str(request.__data__)) + return (await handler(request)) + return parse_data + +async def response_factory(app, handler): + async def response(request): + logging.info('Response handler...') + r = await handler(request) + if isinstance(r, web.StreamResponse): + return r + if isinstance(r, bytes): + resp = web.Response(body=r) + resp.content_type = 'application/octet-stream' + return resp + if isinstance(r, str): + if r.startswith('redirect:'): + return web.HTTPFound(r[9:]) + resp = web.Response(body=r.encode('utf-8')) + resp.content_type = 'text/html;charset=utf-8' + return resp + if isinstance(r, dict): + template = r.get('__template__') + if template is None: + resp = web.Response(body=json.dumps(r, ensure_ascii=False, default=lambda o: o.__dict__).encode('utf-8')) + resp.content_type = 'application/json;charset=utf-8' + return resp + else: + resp = web.Response(body=app['__templating__'].get_template(template).render(**r).encode('utf-8')) + resp.content_type = 'text/html;charset=utf-8' + return resp + if isinstance(r, int) and r >= 100 and r < 600: + return web.Response(r) + if isinstance(r, tuple) and len(r) == 2: + t, m = r + if isinstance(t, int) and t >= 100 and t < 600: + return web.Response(t, str(m)) + # default: + resp = web.Response(body=str(r).encode('utf-8')) + resp.content_type = 'text/plain;charset=utf-8' + return resp + return response + +def datetime_filter(t): + delta = int(time.time() - t) + if delta < 60: + return u'1分钟前' + if delta < 3600: + return u'%s分钟前' % (delta // 60) + if delta < 86400: + return u'%s小时前' % (delta // 3600) + if delta < 604800: + return u'%s天前' % (delta // 86400) + dt = datetime.fromtimestamp(t) + return u'%s年%s月%s日' % (dt.year, dt.month, dt.day) + +async def init(loop): + await orm.create_pool(loop=loop, host='127.0.0.1', port=3306, user='www', password='www', db='awesome') + app = web.Application(loop=loop, middlewares=[ + logger_factory, response_factory + ]) + init_jinja2(app, filters=dict(datetime=datetime_filter)) + add_routes(app, 'handlers') + add_static(app) + srv = await loop.create_server(app.make_handler(), '127.0.0.1', 9000) + logging.info('server started at http://127.0.0.1:9000...') + return srv + +loop = asyncio.get_event_loop() +loop.run_until_complete(init(loop)) +loop.run_forever() diff --git a/www/coroweb.py b/www/coroweb.py new file mode 100644 index 0000000..40c52ea --- /dev/null +++ b/www/coroweb.py @@ -0,0 +1,173 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- + +__author__ = 'Michael Liao' + +import asyncio, os, inspect, logging, functools + +from urllib import parse + +from aiohttp import web + +from apis import APIError + +def get(path): + ''' + Define decorator @get('/path') + ''' + def decorator(func): + @functools.wraps(func) + def wrapper(*args, **kw): + return func(*args, **kw) + wrapper.__method__ = 'GET' + wrapper.__route__ = path + return wrapper + return decorator + +def post(path): + ''' + Define decorator @post('/path') + ''' + def decorator(func): + @functools.wraps(func) + def wrapper(*args, **kw): + return func(*args, **kw) + wrapper.__method__ = 'POST' + wrapper.__route__ = path + return wrapper + return decorator + +def get_required_kw_args(fn): + args = [] + params = inspect.signature(fn).parameters + for name, param in params.items(): + if param.kind == inspect.Parameter.KEYWORD_ONLY and param.default == inspect.Parameter.empty: + args.append(name) + return tuple(args) + +def get_named_kw_args(fn): + args = [] + params = inspect.signature(fn).parameters + for name, param in params.items(): + if param.kind == inspect.Parameter.KEYWORD_ONLY: + args.append(name) + return tuple(args) + +def has_named_kw_args(fn): + params = inspect.signature(fn).parameters + for name, param in params.items(): + if param.kind == inspect.Parameter.KEYWORD_ONLY: + return True + +def has_var_kw_arg(fn): + params = inspect.signature(fn).parameters + for name, param in params.items(): + if param.kind == inspect.Parameter.VAR_KEYWORD: + return True + +def has_request_arg(fn): + sig = inspect.signature(fn) + params = sig.parameters + found = False + for name, param in params.items(): + if name == 'request': + found = True + continue + if found and (param.kind != inspect.Parameter.VAR_POSITIONAL and param.kind != inspect.Parameter.KEYWORD_ONLY and param.kind != inspect.Parameter.VAR_KEYWORD): + raise ValueError('request parameter must be the last named parameter in function: %s%s' % (fn.__name__, str(sig))) + return found + +class RequestHandler(object): + + def __init__(self, app, fn): + self._app = app + self._func = fn + self._has_request_arg = has_request_arg(fn) + self._has_var_kw_arg = has_var_kw_arg(fn) + self._has_named_kw_args = has_named_kw_args(fn) + self._named_kw_args = get_named_kw_args(fn) + self._required_kw_args = get_required_kw_args(fn) + + async def __call__(self, request): + kw = None + if self._has_var_kw_arg or self._has_named_kw_args or self._required_kw_args: + if request.method == 'POST': + if not request.content_type: + return web.HTTPBadRequest('Missing Content-Type.') + ct = request.content_type.lower() + if ct.startswith('application/json'): + params = await request.json() + if not isinstance(params, dict): + return web.HTTPBadRequest('JSON body must be object.') + kw = params + elif ct.startswith('application/x-www-form-urlencoded') or ct.startswith('multipart/form-data'): + params = await request.post() + kw = dict(**params) + else: + return web.HTTPBadRequest('Unsupported Content-Type: %s' % request.content_type) + if request.method == 'GET': + qs = request.query_string + if qs: + kw = dict() + for k, v in parse.parse_qs(qs, True).items(): + kw[k] = v[0] + if kw is None: + kw = dict(**request.match_info) + else: + if not self._has_var_kw_arg and self._named_kw_args: + # remove all unamed kw: + copy = dict() + for name in self._named_kw_args: + if name in kw: + copy[name] = kw[name] + kw = copy + # check named arg: + for k, v in request.match_info.items(): + if k in kw: + logging.warning('Duplicate arg name in named arg and kw args: %s' % k) + kw[k] = v + if self._has_request_arg: + kw['request'] = request + # check required kw: + if self._required_kw_args: + for name in self._required_kw_args: + if not name in kw: + return web.HTTPBadRequest('Missing argument: %s' % name) + logging.info('call with args: %s' % str(kw)) + try: + r = await self._func(**kw) + return r + except APIError as e: + return dict(error=e.error, data=e.data, message=e.message) + +def add_static(app): + path = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'static') + app.router.add_static('/static/', path) + logging.info('add static %s => %s' % ('/static/', path)) + +def add_route(app, fn): + method = getattr(fn, '__method__', None) + path = getattr(fn, '__route__', None) + if path is None or method is None: + raise ValueError('@get or @post not defined in %s.' % str(fn)) + if not asyncio.iscoroutinefunction(fn) and not inspect.isgeneratorfunction(fn): + fn = asyncio.coroutine(fn) + logging.info('add route %s %s => %s(%s)' % (method, path, fn.__name__, ', '.join(inspect.signature(fn).parameters.keys()))) + app.router.add_route(method, path, RequestHandler(app, fn)) + +def add_routes(app, module_name): + n = module_name.rfind('.') + if n == (-1): + mod = __import__(module_name, globals(), locals()) + else: + name = module_name[n+1:] + mod = getattr(__import__(module_name[:n], globals(), locals(), [name]), name) + for attr in dir(mod): + if attr.startswith('_'): + continue + fn = getattr(mod, attr) + if callable(fn): + method = getattr(fn, '__method__', None) + path = getattr(fn, '__route__', None) + if method and path: + add_route(app, fn) diff --git a/www/favicon.ico b/www/favicon.ico new file mode 100644 index 0000000..5f9ba4a Binary files /dev/null and b/www/favicon.ico differ diff --git a/www/handlers.py b/www/handlers.py new file mode 100644 index 0000000..e668764 --- /dev/null +++ b/www/handlers.py @@ -0,0 +1,6 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- + +__author__ = 'Michael Liao' + +' url handlers ' diff --git a/www/markdown2.py b/www/markdown2.py new file mode 100755 index 0000000..31c0ae9 --- /dev/null +++ b/www/markdown2.py @@ -0,0 +1,2440 @@ +#!/usr/bin/env python +# Copyright (c) 2012 Trent Mick. +# Copyright (c) 2007-2008 ActiveState Corp. +# License: MIT (http://www.opensource.org/licenses/mit-license.php) + +from __future__ import generators + +r"""A fast and complete Python implementation of Markdown. + +[from http://daringfireball.net/projects/markdown/] +> Markdown is a text-to-HTML filter; it translates an easy-to-read / +> easy-to-write structured text format into HTML. Markdown's text +> format is most similar to that of plain text email, and supports +> features such as headers, *emphasis*, code blocks, blockquotes, and +> links. +> +> Markdown's syntax is designed not as a generic markup language, but +> specifically to serve as a front-end to (X)HTML. You can use span-level +> HTML tags anywhere in a Markdown document, and you can use block level +> HTML tags (like
tags.
+ """
+ yield 0, ""
+ for tup in inner:
+ yield tup
+ yield 0, ""
+
+ def wrap(self, source, outfile):
+ """Return the source with a code, pre, and div."""
+ return self._wrap_div(self._wrap_pre(self._wrap_code(source)))
+
+ formatter_opts.setdefault("cssclass", "codehilite")
+ formatter = HtmlCodeFormatter(**formatter_opts)
+ return pygments.highlight(codeblock, lexer, formatter)
+
+ def _code_block_sub(self, match, is_fenced_code_block=False):
+ lexer_name = None
+ if is_fenced_code_block:
+ lexer_name = match.group(1)
+ if lexer_name:
+ formatter_opts = self.extras['fenced-code-blocks'] or {}
+ codeblock = match.group(2)
+ codeblock = codeblock[:-1] # drop one trailing newline
+ else:
+ codeblock = match.group(1)
+ codeblock = self._outdent(codeblock)
+ codeblock = self._detab(codeblock)
+ codeblock = codeblock.lstrip('\n') # trim leading newlines
+ codeblock = codeblock.rstrip() # trim trailing whitespace
+
+ # Note: "code-color" extra is DEPRECATED.
+ if "code-color" in self.extras and codeblock.startswith(":::"):
+ lexer_name, rest = codeblock.split('\n', 1)
+ lexer_name = lexer_name[3:].strip()
+ codeblock = rest.lstrip("\n") # Remove lexer declaration line.
+ formatter_opts = self.extras['code-color'] or {}
+
+ if lexer_name:
+ def unhash_code( codeblock ):
+ for key, sanitized in list(self.html_spans.items()):
+ codeblock = codeblock.replace(key, sanitized)
+ replacements = [
+ ("&", "&"),
+ ("<", "<"),
+ (">", ">")
+ ]
+ for old, new in replacements:
+ codeblock = codeblock.replace(old, new)
+ return codeblock
+ lexer = self._get_pygments_lexer(lexer_name)
+ if lexer:
+ codeblock = unhash_code( codeblock )
+ colored = self._color_with_pygments(codeblock, lexer,
+ **formatter_opts)
+ return "\n\n%s\n\n" % colored
+
+ codeblock = self._encode_code(codeblock)
+ pre_class_str = self._html_class_str_from_tag("pre")
+ code_class_str = self._html_class_str_from_tag("code")
+ return "\n\n%s\n
\n\n" % (
+ pre_class_str, code_class_str, codeblock)
+
+ def _html_class_str_from_tag(self, tag):
+ """Get the appropriate ' class="..."' string (note the leading
+ space), if any, for the given tag.
+ """
+ if "html-classes" not in self.extras:
+ return ""
+ try:
+ html_classes_from_tag = self.extras["html-classes"]
+ except TypeError:
+ return ""
+ else:
+ if tag in html_classes_from_tag:
+ return ' class="%s"' % html_classes_from_tag[tag]
+ return ""
+
+ def _do_code_blocks(self, text):
+ """Process Markdown `` blocks."""
+ code_block_re = re.compile(r'''
+ (?:\n\n|\A\n?)
+ ( # $1 = the code block -- one or more lines, starting with a space/tab
+ (?:
+ (?:[ ]{%d} | \t) # Lines must start with a tab or a tab-width of spaces
+ .*\n+
+ )+
+ )
+ ((?=^[ ]{0,%d}\S)|\Z) # Lookahead for non-space at line-start, or end of doc
+ # Lookahead to make sure this block isn't already in a code block.
+ # Needed when syntax highlighting is being used.
+ (?![^<]*\)
+ ''' % (self.tab_width, self.tab_width),
+ re.M | re.X)
+ return code_block_re.sub(self._code_block_sub, text)
+
+ _fenced_code_block_re = re.compile(r'''
+ (?:\n\n|\A\n?)
+ ^```([\w+-]+)?[ \t]*\n # opening fence, $1 = optional lang
+ (.*?) # $2 = code block content
+ ^```[ \t]*\n # closing fence
+ ''', re.M | re.X | re.S)
+
+ def _fenced_code_block_sub(self, match):
+ return self._code_block_sub(match, is_fenced_code_block=True);
+
+ def _do_fenced_code_blocks(self, text):
+ """Process ```-fenced unindented code blocks ('fenced-code-blocks' extra)."""
+ return self._fenced_code_block_re.sub(self._fenced_code_block_sub, text)
+
+ # Rules for a code span:
+ # - backslash escapes are not interpreted in a code span
+ # - to include one or or a run of more backticks the delimiters must
+ # be a longer run of backticks
+ # - cannot start or end a code span with a backtick; pad with a
+ # space and that space will be removed in the emitted HTML
+ # See `test/tm-cases/escapes.text` for a number of edge-case
+ # examples.
+ _code_span_re = re.compile(r'''
+ (?%s
" % c
+
+ def _do_code_spans(self, text):
+ # * Backtick quotes are used for spans.
+ #
+ # * You can use multiple backticks as the delimiters if you want to
+ # include literal backticks in the code span. So, this input:
+ #
+ # Just type ``foo `bar` baz`` at the prompt.
+ #
+ # Will translate to:
+ #
+ # Just type foo `bar` baz at the prompt.
`bar` ...
+ return self._code_span_re.sub(self._code_span_sub, text)
+
+ def _encode_code(self, text):
+ """Encode/escape certain characters inside Markdown code runs.
+ The point is that in code, these characters are literals,
+ and lose their special Markdown meanings.
+ """
+ replacements = [
+ # Encode all ampersands; HTML entities are not
+ # entities within a Markdown code span.
+ ('&', '&'),
+ # Do the angle bracket song and dance:
+ ('<', '<'),
+ ('>', '>'),
+ ]
+ for before, after in replacements:
+ text = text.replace(before, after)
+ hashed = _hash_text(text)
+ self._escape_table[text] = hashed
+ return hashed
+
+ _strong_re = re.compile(r"(\*\*|__)(?=\S)(.+?[*_]*)(?<=\S)\1", re.S)
+ _em_re = re.compile(r"(\*|_)(?=\S)(.+?)(?<=\S)\1", re.S)
+ _code_friendly_strong_re = re.compile(r"\*\*(?=\S)(.+?[*_]*)(?<=\S)\*\*", re.S)
+ _code_friendly_em_re = re.compile(r"\*(?=\S)(.+?)(?<=\S)\*", re.S)
+ def _do_italics_and_bold(self, text):
+ # must go first:
+ if "code-friendly" in self.extras:
+ text = self._code_friendly_strong_re.sub(r"\1", text)
+ text = self._code_friendly_em_re.sub(r"\1", text)
+ else:
+ text = self._strong_re.sub(r"\2", text)
+ text = self._em_re.sub(r"\2", text)
+ return text
+
+ # "smarty-pants" extra: Very liberal in interpreting a single prime as an
+ # apostrophe; e.g. ignores the fact that "round", "bout", "twer", and
+ # "twixt" can be written without an initial apostrophe. This is fine because
+ # using scare quotes (single quotation marks) is rare.
+ _apostrophe_year_re = re.compile(r"'(\d\d)(?=(\s|,|;|\.|\?|!|$))")
+ _contractions = ["tis", "twas", "twer", "neath", "o", "n",
+ "round", "bout", "twixt", "nuff", "fraid", "sup"]
+ def _do_smart_contractions(self, text):
+ text = self._apostrophe_year_re.sub(r"’\1", text)
+ for c in self._contractions:
+ text = text.replace("'%s" % c, "’%s" % c)
+ text = text.replace("'%s" % c.capitalize(),
+ "’%s" % c.capitalize())
+ return text
+
+ # Substitute double-quotes before single-quotes.
+ _opening_single_quote_re = re.compile(r"(?
+ See "test/tm-cases/smarty_pants.text" for a full discussion of the
+ support here and
+ .+?)', re.S) + def _dedent_two_spaces_sub(self, match): + return re.sub(r'(?m)^ ', '', match.group(1)) + + def _block_quote_sub(self, match): + bq = match.group(1) + bq = self._bq_one_level_re.sub('', bq) # trim one level of quoting + bq = self._ws_only_line_re.sub('', bq) # trim whitespace-only lines + bq = self._run_block_gamut(bq) # recurse + + bq = re.sub('(?m)^', ' ', bq) + # These leading spaces screw with
content, so we need to fix that: + bq = self._html_pre_block_re.sub(self._dedent_two_spaces_sub, bq) + + return "\n%s\n\n\n" % bq + + def _do_block_quotes(self, text): + if '>' not in text: + return text + return self._block_quote_re.sub(self._block_quote_sub, text) + + def _form_paragraphs(self, text): + # Strip leading and trailing lines: + text = text.strip('\n') + + # Wraptags. + grafs = [] + for i, graf in enumerate(re.split(r"\n{2,}", text)): + if graf in self.html_blocks: + # Unhashify HTML blocks + grafs.append(self.html_blocks[graf]) + else: + cuddled_list = None + if "cuddled-lists" in self.extras: + # Need to put back trailing '\n' for `_list_item_re` + # match at the end of the paragraph. + li = self._list_item_re.search(graf + '\n') + # Two of the same list marker in this paragraph: a likely + # candidate for a list cuddled to preceding paragraph + # text (issue 33). Note the `[-1]` is a quick way to + # consider numeric bullets (e.g. "1." and "2.") to be + # equal. + if (li and len(li.group(2)) <= 3 and li.group("next_marker") + and li.group("marker")[-1] == li.group("next_marker")[-1]): + start = li.start() + cuddled_list = self._do_lists(graf[start:]).rstrip("\n") + assert cuddled_list.startswith("
tags. + graf = self._run_span_gamut(graf) + grafs.append("
" + graf.lstrip(" \t") + "
") + + if cuddled_list: + grafs.append(cuddled_list) + + return "\n\n".join(grafs) + + def _add_footnotes(self, text): + if self.footnotes: + footer = [ + '%s
" % backlink) + footer.append('