├── .bumpversion.cfg ├── .coveragerc ├── .gitignore ├── .gitmodules ├── .pre-commit-config.yaml ├── .travis.yml ├── CHANGELOG.rst ├── LICENSE ├── Makefile ├── README.rst ├── TODO.md ├── bustard ├── __init__.py ├── app.py ├── constants.py ├── exceptions.py ├── http.py ├── orm.py ├── router.py ├── servers.py ├── sessions.py ├── template.py ├── testing.py ├── utils.py ├── views.py └── wsgi_server.py ├── examples ├── flaskr │ ├── README.md │ ├── flaskr.py │ ├── static │ │ └── style.css │ ├── templates │ │ ├── layout.html │ │ ├── login.html │ │ └── show_entries.html │ └── test_flaskr.py ├── hello.py ├── session.py ├── static_files.py ├── upload.py └── views.py ├── pytest.ini ├── requirements.txt ├── requirements_dev.txt ├── setup.py └── tests ├── __init__.py ├── conftest.py ├── httpbin ├── AUTHORS ├── LICENSE ├── README.rst ├── __init__.py ├── core.py ├── filters.py ├── helpers.py ├── requirements.txt ├── structures.py ├── templates │ ├── UTF-8-demo.txt │ ├── forms-post.html │ ├── httpbin.1.html │ ├── images │ │ ├── jackal.jpg │ │ ├── pig_icon.png │ │ ├── svg_logo.svg │ │ └── wolf_1.webp │ ├── index.html │ ├── moby.html │ ├── sample.xml │ └── trackingscripts.html └── utils.py ├── templates ├── child.html ├── hello.html ├── index.html ├── list.html └── parent.html ├── test.png ├── test_app.py ├── test_http.py ├── test_httpbin.py ├── test_multipart.py ├── test_orm.py ├── test_router.py ├── test_session.py ├── test_static_file.py ├── test_template.py ├── test_utils.py ├── test_views.py └── utils.py /.bumpversion.cfg: -------------------------------------------------------------------------------- 1 | [bumpversion] 2 | commit = True 3 | tag = True 4 | current_version = 0.1.6 5 | 6 | [bumpversion:file:bustard/__init__.py] 7 | 8 | -------------------------------------------------------------------------------- /.coveragerc: -------------------------------------------------------------------------------- 1 | [run] 2 | omit = 3 | bustard/wsgi_server.py 4 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | 5 | # C extensions 6 | *.so 7 | 8 | # Distribution / packaging 9 | .Python 10 | env/ 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | *.egg-info/ 23 | .installed.cfg 24 | *.egg 25 | 26 | # PyInstaller 27 | # Usually these files are written by a python script from a template 28 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 29 | *.manifest 30 | *.spec 31 | 32 | # Installer logs 33 | pip-log.txt 34 | pip-delete-this-directory.txt 35 | 36 | # Unit test / coverage reports 37 | htmlcov/ 38 | .tox/ 39 | .coverage 40 | .coverage.* 41 | .cache 42 | nosetests.xml 43 | coverage.xml 44 | *,cover 45 | 46 | # Translations 47 | *.mo 48 | *.pot 49 | 50 | # Django stuff: 51 | *.log 52 | 53 | # Sphinx documentation 54 | docs/_build/ 55 | 56 | # PyBuilder 57 | target/ 58 | *~ 59 | venv/ 60 | .python-version 61 | tests/_orm.py 62 | -------------------------------------------------------------------------------- /.gitmodules: -------------------------------------------------------------------------------- 1 | [submodule "examples/bustard-httpbin"] 2 | path = examples/bustard-httpbin 3 | url = https://github.com/mozillazg/bustard-httpbin.git 4 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | - repo: git://github.com/pre-commit/pre-commit-hooks 2 | sha: daa1e9a837eceab5c0869ea7fa22a016311391ef 3 | hooks: 4 | - id: check-merge-conflict 5 | - id: debug-statements 6 | - id: double-quote-string-fixer 7 | - id: end-of-file-fixer 8 | - id: requirements-txt-fixer 9 | - id: trailing-whitespace 10 | - id: flake8 11 | - id: fix-encoding-pragma 12 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: python 2 | python: 3 | - 3.5 4 | 5 | addons: 6 | postgresql: '9.4' 7 | 8 | services: 9 | - postgresql 10 | 11 | sudo: false 12 | 13 | install: 14 | - travis_retry pip install coveralls 15 | - travis_retry pip install -r requirements_dev.txt 16 | 17 | before_script: 18 | - psql -c 'create database exampledb;' -U postgres 19 | 20 | script: 21 | - export BUSTARD_TEST_PG_URI='postgresql://postgres@localhost/exampledb' 22 | - pre-commit run --all-files 23 | - make test 24 | 25 | after_script: 26 | - coveralls 27 | -------------------------------------------------------------------------------- /CHANGELOG.rst: -------------------------------------------------------------------------------- 1 | ChangeLog 2 | ---------- 3 | 4 | 0.1.6 (2016-03-19) 5 | ==================== 6 | 7 | template engine 8 | ~~~~~~~~~~~~~~~~~ 9 | 10 | * [improve] refactoring 11 | * [change] variable and tag can't be empty 12 | * [change] ``template.code`` change to ``template.code_builder`` 13 | * [change] ``Template.__init__(self, text, context=None, ...)`` 14 | change to ``Template.__init__(self, text, default_context=None, ...)`` 15 | * [change] ``Template.TOKEN_EXPR_START`` change to ``Template.TOKEN_VARIABLE_START`` 16 | * [change] ``Template.TOKEN_EXPR_END`` change to ``Template.TOKEN_VARIABLE_END`` 17 | 18 | 19 | 0.1.5 (2016-03-12) 20 | ==================== 21 | 22 | * [new] template engine: support ``{{ block.super }}`` 23 | 24 | 25 | 0.1.4 (2016-03-03) 26 | ==================== 27 | 28 | * [new] template engine support extends 29 | * [improve] refactoring template engine 30 | * [bugfix] fix WerkzeugfServer.run 31 | * [improve] don't connect to db when init orm.Session instance 32 | 33 | 34 | 0.1.3 (2016-02-27) 35 | ==================== 36 | 37 | * [improve] refactoring template engine 38 | * [bugfix] fix orm.queryset.count: don't include limit and offset in sql 39 | * [improve] improve orm.session.connect: auto reconnect 40 | * [bugfix] fix testing about post form 41 | * [new] testing.open support ``follow_redirects=False`` argument 42 | 43 | 44 | 0.1.2 (2016-02-26) 45 | ==================== 46 | 47 | * [new] add views.View and app.add_url_rule 48 | * [new] remove psycopg2 from setup.py 49 | * [new] add views.StaticFilesView 50 | * [new] add orm.session.transaction 51 | * [change] rename ServerInterface to ServerAdapter 52 | * [change] rename WSGIrefServer to WSGIRefServer 53 | 54 | 55 | 0.1.1 (2016-02-22) 56 | ==================== 57 | 58 | * [bugfix] fix a session key issue 59 | * [change] refactoring ServerInterface 60 | 61 | 62 | 0.1.0 (2016-02-19) 63 | ==================== 64 | 65 | * Initial Release 66 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2015 mozillazg 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | 23 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | .PHONY: help 2 | help: 3 | @echo "test" 4 | @echo "publish" 5 | 6 | .PHONY: test 7 | test: 8 | @py.test --cov bustard tests --cov-report=term-missing 9 | 10 | .PHONY: publish 11 | publish: 12 | @python setup.py publish 13 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | bustard 2 | ----------- 3 | 4 | .. image:: https://badges.gitter.im/mozillazg/bustard.svg 5 | :alt: Join the chat at https://gitter.im/mozillazg/bustard 6 | :target: https://gitter.im/mozillazg/bustard?utm_source=badge&utm_medium=badge&utm_campaign=pr-badge&utm_content=badge 7 | 8 | |Build| |Coverage| |Pypi version| 9 | 10 | A tiny WSGI web framework. 11 | 12 | 13 | features 14 | =============== 15 | 16 | * router 17 | * orm 18 | * request and response 19 | * cookies and session 20 | * template engine 21 | * wsgi server 22 | 23 | install 24 | ============= 25 | 26 | :: 27 | 28 | pip install bustard 29 | pip install psycopg2 # if you need orm feature 30 | 31 | 32 | Getting Started 33 | =================== 34 | 35 | :: 36 | 37 | from bustard.app import Bustard 38 | 39 | app = Bustard() 40 | 41 | 42 | @app.route('/') 43 | def helloword(request): 44 | return 'hello world' 45 | 46 | if __name__ == '__main__': 47 | app.run() 48 | 49 | Just save it as hello.py and run it :: 50 | 51 | $ python hello.py 52 | WSGIServer: Serving HTTP on ('127.0.0.1', 5000) ... 53 | 54 | Now visit http://localhost:5000, and you should see ``hello world``. 55 | 56 | 57 | .. |Build| image:: https://img.shields.io/travis/mozillazg/bustard/master.svg 58 | :target: https://travis-ci.org/mozillazg/bustard 59 | .. |Coverage| image:: https://img.shields.io/coveralls/mozillazg/bustard/master.svg 60 | :target: https://coveralls.io/r/mozillazg/bustard 61 | .. |PyPI version| image:: https://img.shields.io/pypi/v/bustard.svg 62 | :target: https://pypi.python.org/pypi/bustard 63 | -------------------------------------------------------------------------------- /TODO.md: -------------------------------------------------------------------------------- 1 | ## TODO 2 | 3 | * [ ] template engine 4 | * [x] support `{{ block.super }}` 5 | * [ ] inject `request` object 6 | * [ ] support `{% with a=b c=d %} ... {% endwith %}` 7 | * [ ] ORM 8 | * [ ] support join 9 | * [ ] test for foreignkey 10 | * [ ] builtin WSGI server 11 | * [ ] refactoring 12 | * [ ] support handle multi request at once 13 | -------------------------------------------------------------------------------- /bustard/__init__.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | '''A tiny WSGI web framework''' 4 | 5 | __version__ = '0.1.6' 6 | __title__ = 'bustard' 7 | __author__ = 'mozillazg' 8 | __license__ = 'MIT' 9 | __copyright__ = 'Copyright (c) 2016 mozillazg' 10 | -------------------------------------------------------------------------------- /bustard/app.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | import collections 3 | import inspect 4 | import os 5 | 6 | from .constants import CONFIGURE 7 | from .exceptions import HTTPException, NotFound 8 | from .http import Request, Response 9 | from .router import Router 10 | from .template import Template 11 | from .testing import Client 12 | from .utils import to_bytes 13 | from .servers import WSGIRefServer 14 | from . import sessions 15 | 16 | 17 | class Bustard: 18 | session_class = sessions.MemorySession 19 | before_request_hooks = (sessions.before_request_hook,) 20 | after_request_hooks = (sessions.after_request_hook,) 21 | 22 | def __init__(self, name='', template_dir='', 23 | template_default_context=None): 24 | self.name = name 25 | self._router = Router() 26 | self.template_dir = template_dir 27 | if template_default_context is not None: 28 | self.template_default_context = template_default_context 29 | else: 30 | self.template_default_context = {} 31 | self.template_default_context.setdefault('url_for', self.url_for) 32 | 33 | self._before_request_hooks = [] 34 | self._before_request_hooks.extend(self.before_request_hooks) 35 | self._after_request_hooks = [] 36 | self._after_request_hooks.extend(self.after_request_hooks) 37 | 38 | self._config = {} 39 | self._config.update(CONFIGURE) 40 | 41 | @property 42 | def config(self): 43 | return self._config 44 | 45 | def render_template(self, template_name, **kwargs): 46 | return render_template( 47 | template_name, template_dir=self.template_dir, 48 | default_context=self.template_default_context, 49 | context=kwargs 50 | ).encode('utf-8') 51 | 52 | def url_for(self, func_name, _request=None, _external=False, **kwargs): 53 | url = self._router.url_for(func_name, **kwargs) 54 | if _external: 55 | request = _request 56 | url = '{}://{}{}'.format(request.scheme, request.host, url) 57 | return url 58 | 59 | def url_resolve(self, path): 60 | """url -> view 61 | 62 | :return: (func, methods, func_kwargs) 63 | """ 64 | return self._router.get_func(path) 65 | 66 | def __call__(self, environ, start_response): 67 | """for wsgi server""" 68 | self.start_response = start_response 69 | path = environ['PATH_INFO'] 70 | method = environ['REQUEST_METHOD'] 71 | func, methods, func_kwargs = self.url_resolve(path) 72 | 73 | try: 74 | if func is None: 75 | self.notfound() 76 | if method not in methods: 77 | self.abort(405) 78 | request = Request(environ) 79 | result = self.handle_before_request_hooks(request, view_func=func) 80 | if isinstance(result, Response): 81 | response = result 82 | else: 83 | response = self.handle_view(request, func, func_kwargs) 84 | self.handle_after_request_hooks(request, response, view_func=func) 85 | except HTTPException as ex: 86 | response = ex.response 87 | 88 | return self._start_response(response) 89 | 90 | def handle_view(self, request, view_func, func_kwargs): 91 | result = view_func(request, **func_kwargs) 92 | if isinstance(result, (list, tuple)): 93 | response = Response(content=result[0], 94 | status_code=result[1], 95 | headers=result[2]) 96 | elif isinstance(result, Response): 97 | response = result 98 | else: 99 | response = Response(result) 100 | return response 101 | 102 | def _start_response(self, response): 103 | body = response.body 104 | status_code = response.status 105 | headers_list = response.headers_list 106 | self.start_response(status_code, headers_list) 107 | 108 | if isinstance(body, collections.Iterator): 109 | return (to_bytes(x) for x in body) 110 | else: 111 | return [to_bytes(body)] 112 | 113 | def route(self, path, methods=None): 114 | 115 | def wrapper(view_func): 116 | self._router.register(path, view_func, methods) 117 | return view_func 118 | 119 | return wrapper 120 | 121 | def add_url_rule(self, path, view_func): 122 | methods = view_func.methods 123 | self.route(path, methods=methods)(view_func) 124 | 125 | def before_request(self, func): 126 | self._before_request_hooks.append(func) 127 | return func 128 | 129 | def handle_before_request_hooks(self, request, view_func): 130 | hooks = self._before_request_hooks 131 | for hook in hooks: 132 | if len(inspect.signature(hook).parameters) > 1: 133 | result = hook(request, view_func, self) 134 | else: 135 | result = hook(request) 136 | if isinstance(result, Response): 137 | return result 138 | 139 | def after_request(self, func): 140 | self._after_request_hooks.append(func) 141 | return func 142 | 143 | def handle_after_request_hooks(self, request, response, view_func): 144 | hooks = self._after_request_hooks 145 | for hook in hooks: 146 | if len(inspect.signature(hook).parameters) > 2: 147 | hook(request, response, view_func, self) 148 | else: 149 | hook(request, response) 150 | 151 | def notfound(self): 152 | raise NotFound() 153 | 154 | def abort(self, code): 155 | raise HTTPException(Response(status_code=code)) 156 | 157 | def make_response(self, content=b'', **kwargs): 158 | if isinstance(content, Response): 159 | return content 160 | return Response(content, **kwargs) 161 | 162 | def test_client(self): 163 | return Client(self) 164 | 165 | def run(self, host='127.0.0.1', port=5000): 166 | address = (host, port) 167 | httpd = WSGIRefServer(host, port) 168 | print('WSGIServer: Serving HTTP on %s ...\n' % str(address)) 169 | httpd.run(self) 170 | 171 | 172 | def render_template(template_name, template_dir='', default_context=None, 173 | context=None, **kwargs): 174 | with open(os.path.join(template_dir, template_name), 175 | encoding='utf-8') as f: 176 | return Template(f.read(), default_context=default_context, 177 | template_dir=template_dir, **kwargs 178 | ).render(**context) 179 | -------------------------------------------------------------------------------- /bustard/constants.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | # 模板引擎默认支持的内置函数 4 | TEMPLATE_BUILTIN_FUNC_WHITELIST = ( 5 | 'all', 6 | 'unicode', 7 | 'isinstance', 8 | 'dict', 9 | 'format', 10 | 'repr', 11 | 'sorted', 12 | 'list', 13 | 'iter', 14 | 'round', 15 | 'cmp', 16 | 'set', 17 | 'bytes', 18 | 'reduce', 19 | 'slice', 20 | 'sum', 21 | 'getattr', 22 | 'abs', 23 | 'hash', 24 | 'len', 25 | 'ord', 26 | 'filter', 27 | 'range', 28 | 'pow', 29 | 'float', 30 | 'divmod', 31 | 'enumerate', 32 | 'basestring', 33 | 'zip', 34 | 'hex', 35 | 'long', 36 | 'next', 37 | 'chr', 38 | 'xrange', 39 | 'type', 40 | 'tuple', 41 | 'reversed', 42 | 'hasattr', 43 | 'delattr', 44 | 'setattr', 45 | 'str', 46 | 'int', 47 | 'unichr', 48 | 'min', 49 | 'any', 50 | 'complex', 51 | 'bool', 52 | 'map', 53 | 'max', 54 | 'object', 55 | 'callable', 56 | ) 57 | 58 | HTTP_STATUS_CODES = { 59 | 100: 'Continue', 60 | 101: 'Switching Protocols', 61 | 102: 'Processing', 62 | 200: 'OK', 63 | 201: 'Created', 64 | 202: 'Accepted', 65 | 203: 'Non Authoritative Information', 66 | 204: 'No Content', 67 | 205: 'Reset Content', 68 | 206: 'Partial Content', 69 | 207: 'Multi Status', 70 | 226: 'IM Used', # see RFC 3229 71 | 300: 'Multiple Choices', 72 | 301: 'Moved Permanently', 73 | 302: 'Found', 74 | 303: 'See Other', 75 | 304: 'Not Modified', 76 | 305: 'Use Proxy', 77 | 307: 'Temporary Redirect', 78 | 400: 'Bad Request', 79 | 401: 'Unauthorized', 80 | 402: 'Payment Required', # unused 81 | 403: 'Forbidden', 82 | 404: 'Not Found', 83 | 405: 'Method Not Allowed', 84 | 406: 'Not Acceptable', 85 | 407: 'Proxy Authentication Required', 86 | 408: 'Request Timeout', 87 | 409: 'Conflict', 88 | 410: 'Gone', 89 | 411: 'Length Required', 90 | 412: 'Precondition Failed', 91 | 413: 'Request Entity Too Large', 92 | 414: 'Request URI Too Long', 93 | 415: 'Unsupported Media Type', 94 | 416: 'Requested Range Not Satisfiable', 95 | 417: 'Expectation Failed', 96 | 418: 'I\'m a teapot', # see RFC 2324 97 | 422: 'Unprocessable Entity', 98 | 423: 'Locked', 99 | 424: 'Failed Dependency', 100 | 426: 'Upgrade Required', 101 | 428: 'Precondition Required', # see RFC 6585 102 | 429: 'Too Many Requests', 103 | 431: 'Request Header Fields Too Large', 104 | 449: 'Retry With', # proprietary MS extension 105 | 451: 'Unavailable For Legal Reasons', 106 | 500: 'Internal Server Error', 107 | 501: 'Not Implemented', 108 | 502: 'Bad Gateway', 109 | 503: 'Service Unavailable', 110 | 504: 'Gateway Timeout', 111 | 505: 'HTTP Version Not Supported', 112 | 507: 'Insufficient Storage', 113 | 510: 'Not Extended' 114 | } 115 | 116 | 117 | # 默认配置 118 | CONFIGURE = { 119 | 'DEBUG': False, 120 | 'SESSION_COOKIE_NAME': 'sid', 121 | 'SESSION_COOKIE_MAX_AGE': None, 122 | 'SESSION_COOKIE_DOMAIN': None, 123 | 'SESSION_COOKIE_PATH': '/', 124 | 'SESSION_COOKIE_SECURE': False, 125 | 'SESSION_COOKIE_HTTPONLY': True, 126 | } 127 | 128 | NOTFOUND_HTML = b""" 129 | 130 |

404 Not Found

131 | 132 | """ 133 | -------------------------------------------------------------------------------- /bustard/exceptions.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from .constants import NOTFOUND_HTML 3 | from .http import Response 4 | 5 | 6 | class HTTPException(Exception): 7 | def __init__(self, response): 8 | self.response = response 9 | 10 | 11 | class NotFound(HTTPException): 12 | def __init__(self): 13 | self.response = Response(NOTFOUND_HTML, status_code=404) 14 | -------------------------------------------------------------------------------- /bustard/http.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | import cgi 3 | from http.cookies import SimpleCookie 4 | import io 5 | import json 6 | 7 | from .constants import HTTP_STATUS_CODES 8 | from .utils import ( 9 | json_dumps_default, MultiDict, parse_query_string, 10 | to_header_key, to_text, to_bytes, parse_basic_auth_header 11 | ) 12 | 13 | 14 | class Request: 15 | 16 | def __init__(self, environ): 17 | self.environ = environ 18 | 19 | @property 20 | def method(self): 21 | """``GET``, ``POST`` etc.""" 22 | return self.environ['REQUEST_METHOD'] 23 | 24 | @property 25 | def path(self): 26 | return self.environ['PATH_INFO'] 27 | 28 | @property 29 | def host(self): 30 | return self.environ['HTTP_HOST'] 31 | 32 | @property 33 | def scheme(self): 34 | return self.environ['wsgi.url_scheme'] 35 | 36 | @property 37 | def url(self): 38 | return '{scheme}://{host}{path}'.format( 39 | scheme=self.scheme, host=self.host, path=self.path 40 | ) 41 | 42 | @property 43 | def remote_addr(self): 44 | return self.environ.get('REMOTE_ADDR', '') 45 | 46 | @property 47 | def form(self): 48 | if self.method not in ['POST', 'PUT', 'PATCH', 'DELETE']: 49 | return {} 50 | content_type = self.content_type 51 | if ( 52 | content_type.startswith('multipart/form-data; boundary=') or 53 | content_type.startswith('application/x-www-form-urlencoded') 54 | ): 55 | self.parse_form_data() 56 | return MultiDict(self._form) 57 | else: 58 | return {} 59 | 60 | def parse_form_data(self): 61 | if hasattr(self, '_form'): 62 | return 63 | 64 | fields = cgi.FieldStorage(fp=self.stream, environ=self.environ) 65 | _form = {} 66 | _files = {} 67 | if fields.length > 0 and fields.list: 68 | for key in fields: 69 | values = fields[key] 70 | if isinstance(values, list): 71 | _form[key] = [x.value for x in values] 72 | elif 'Content-Type' in values.headers: 73 | _files[key] = File(values.value, values.filename, 74 | values.type) 75 | else: 76 | _form[key] = [values.value] 77 | self._form = _form 78 | self._files = _files 79 | 80 | @property 81 | def args(self): 82 | query_string = self.environ['QUERY_STRING'] 83 | return MultiDict(parse_query_string(query_string)) 84 | 85 | @property 86 | def values(self): 87 | raise NotImplementedError 88 | 89 | @property 90 | def cookies(self): 91 | """Request cookies 92 | 93 | :rtype: dict 94 | """ 95 | http_cookie = self.environ.get('HTTP_COOKIE', '') 96 | _cookies = { 97 | k: v.value 98 | for (k, v) in SimpleCookie(http_cookie).items() 99 | } 100 | return _cookies 101 | 102 | @property 103 | def headers(self): 104 | _headers = { 105 | to_header_key(key.replace('HTTP_', '', 1).replace('_', '-')): value 106 | for key, value in self.environ.items() 107 | if key.startswith('HTTP_') 108 | } 109 | _headers.setdefault('Content-Type', self.content_type) 110 | _headers.setdefault('Content-Length', self.content_length) 111 | return Headers(_headers) 112 | 113 | @property 114 | def data(self, as_text=False, encoding='utf-8'): 115 | if hasattr(self, '_content'): 116 | return self._content 117 | 118 | if self.content_type in [ 119 | 'application/x-www-form-urlencoded', 120 | 'multipart/form-data', 121 | ]: 122 | content = b'' 123 | else: 124 | content = self.stream.read(int(self.content_length or 0)) 125 | self._content = content 126 | if as_text: 127 | content = content.decode(encoding) 128 | return content 129 | 130 | @property 131 | def files(self): 132 | if self.method not in ['POST', 'PUT']: 133 | return {} 134 | self.parse_form_data() 135 | return self._files 136 | 137 | @property 138 | def stream(self): 139 | return self.environ['wsgi.input'] 140 | 141 | def get_json(self, encoding='utf-8'): 142 | content = self.data.decode(encoding) 143 | try: 144 | return json.loads(content) 145 | except ValueError: 146 | return 147 | 148 | @property 149 | def content_type(self): 150 | return self.environ.get('CONTENT_TYPE', '') 151 | 152 | @property 153 | def content_length(self): 154 | return self.environ.get('CONTENT_LENGTH', '') 155 | 156 | @property 157 | def is_json(self): 158 | content_type = self.content_type 159 | if content_type.startswith('application/json') or ( 160 | content_type.startswith('application/') and 161 | content_type.endswith('+json') 162 | ): 163 | return True 164 | return False 165 | 166 | @property 167 | def is_ajax(self): 168 | """The ``X-Requested-With`` header equal to ``HttpRequest`` """ 169 | requested_with = self.headers.get('HTTP_X_REQUESTED_WITH', '').lower() 170 | return requested_with == 'xmlhttprequest' 171 | 172 | @property 173 | def authorization(self): 174 | headers = self.headers 175 | auth_header_value = headers.get('Authorization', '') 176 | if len(auth_header_value.split()) > 1: 177 | return parse_basic_auth_header(auth_header_value) 178 | 179 | 180 | class Response: 181 | 182 | def __init__(self, content=b'', status_code=200, 183 | content_type='text/html; charset=utf-8', 184 | headers=None): 185 | self._content = content 186 | self._status_code = status_code 187 | _headers = headers or {} 188 | _headers.setdefault('Content-Type', content_type) 189 | if isinstance(_headers, Headers): 190 | self._headers = _headers 191 | else: 192 | self._headers = Headers(_headers) 193 | self._cookies = SimpleCookie() 194 | self._load_cookies_from_headers() 195 | 196 | def _load_cookies_from_headers(self): 197 | cookies = self._headers.to_dict().pop('Set-Cookie', []) 198 | for cookie in cookies: 199 | self._cookies.load(cookie) 200 | 201 | @property 202 | def content(self): 203 | return self._content 204 | 205 | @content.setter 206 | def content(self, value): 207 | if isinstance(value, str): 208 | value = value.encode('utf-8') 209 | self._content = value 210 | body = data = content 211 | 212 | def get_data(self): 213 | return self._content 214 | 215 | @property 216 | def content_type(self, value): 217 | return self.headers.get('Content-Type', '') 218 | 219 | @content_type.setter 220 | def content_type(self, value): 221 | self.headers['Content-Type'] = value 222 | 223 | @property 224 | def content_length(self): 225 | return int(self.headers.get('Content-Length', '0')) 226 | 227 | @property 228 | def status_code(self): 229 | return self._status_code 230 | 231 | @status_code.setter 232 | def status_code(self, value): 233 | self._status_code = value 234 | 235 | @property 236 | def status(self): 237 | code = self._status_code 238 | return response_status_string(code) 239 | 240 | @property 241 | def headers(self): 242 | return self._headers 243 | 244 | @headers.setter 245 | def headers(self, value): 246 | self._headers = Headers(value) 247 | 248 | @property 249 | def content_type(self): 250 | return self._headers.get('Content-Type', '') 251 | 252 | @content_type.setter 253 | def content_type(self, value): 254 | self._headers['Content-Type'] = value 255 | 256 | @property 257 | def cookies(self): 258 | return self._cookies 259 | 260 | def set_cookie(self, key, value='', max_age=None, expires=None, path='/', 261 | domain=None, secure=False, httponly=False): 262 | cookie = cookie_dump( 263 | key, value=value, max_age=max_age, expires=expires, path=path, 264 | domain=domain, secure=secure, httponly=httponly 265 | ) 266 | self._cookies.load(cookie) 267 | 268 | def delete_cookie(self, key, max_age=0, 269 | expires='Thu, 01-Jan-1970 00:00:00 GMT'): 270 | self.set_cookie(key, value='', max_age=max_age, expires=expires) 271 | 272 | @property 273 | def headers_list(self): 274 | # normal headers 275 | headers_list = list(self.headers.to_list()) 276 | 277 | # set-cookies 278 | headers_list.extend( 279 | ('Set-Cookie', value.OutputString()) 280 | for value in self.cookies.values() 281 | ) 282 | return headers_list 283 | 284 | def json(self): 285 | return json.loads(to_text(self.data)) 286 | 287 | def __repr__(self): 288 | return '<{} [{}]>'.format(self.__class__.__name__, self.status_code) 289 | 290 | 291 | def cookie_dump(key, value='', max_age=None, expires=None, path='/', 292 | domain=None, secure=False, httponly=False): 293 | """ 294 | :rtype: ``Cookie.SimpleCookie`` 295 | """ 296 | cookie = SimpleCookie() 297 | cookie[key] = value 298 | for attr in ('max_age', 'expires', 'path', 'domain', 299 | 'secure', 'httponly'): 300 | attr_key = attr.replace('_', '-') 301 | attr_value = locals()[attr] 302 | if attr_value: 303 | cookie[key][attr_key] = attr_value 304 | return cookie 305 | 306 | 307 | def response_status_string(code): 308 | """e.g. ``200 OK`` """ 309 | mean = HTTP_STATUS_CODES.get(code, 'unknown').upper() 310 | return '{code} {mean}'.format(code=code, mean=mean) 311 | 312 | 313 | def jsonify(*args, **kwargs): 314 | data = json.dumps(dict(*args, **kwargs), indent=2, sort_keys=True, 315 | separators=(', ', ': '), default=json_dumps_default) 316 | data = data.encode('utf-8') 317 | response = Response(data + b'\n', content_type='application/json') 318 | response.headers['Content-Length'] = str(len(response.data)) 319 | return response 320 | 321 | 322 | def redirect(url, code=302): 323 | response = Response(status_code=code) 324 | response.headers['Location'] = url 325 | return response 326 | 327 | 328 | class Headers(MultiDict): 329 | 330 | def add(self, key, value): 331 | key = to_text(to_header_key(key)) 332 | if isinstance(value, (tuple, list)): 333 | self.data.setdefault(key, []).extend(map(to_text, value)) 334 | else: 335 | self.data.setdefault(key, []).append(to_text(value)) 336 | 337 | def set(self, key, value): 338 | self.__setitem__(key, value) 339 | 340 | def get_all(self, key): 341 | key = to_header_key(key) 342 | return self.data[key] 343 | 344 | @classmethod 345 | def from_list(cls, headers_list): 346 | headers = cls() 347 | for (k, v) in headers_list: 348 | headers.add(k, v) 349 | return headers 350 | 351 | def to_list(self): 352 | return [ 353 | (k, v) 354 | for k, values in self.to_dict().items() 355 | for v in values 356 | ] 357 | 358 | def __getitem__(self, key): 359 | key = to_header_key(key) 360 | return super(Headers, self).__getitem__(key) 361 | 362 | def __setitem__(self, key, value): 363 | key = to_text(to_header_key(key)) 364 | if isinstance(value, (list, tuple)): 365 | value = list(map(to_text, value)) 366 | else: 367 | value = to_text(value) 368 | super(Headers, self).__setitem__(key, value) 369 | 370 | 371 | class File: 372 | 373 | def __init__(self, data, filename, 374 | content_type='application/octet-stream'): 375 | self.file = io.BytesIO(to_bytes(data)) 376 | self.file.name = filename 377 | self.file.content_type = content_type 378 | 379 | def __getattr__(self, attr): 380 | return getattr(self.file, attr) 381 | -------------------------------------------------------------------------------- /bustard/router.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | import collections 3 | import re 4 | import urllib 5 | 6 | 7 | class Router: 8 | def __init__(self): 9 | self._urls_regex_map = {} 10 | self._urls_builer_map = {} 11 | 12 | def register(self, path, func, methods=None): 13 | url_builder = URLBuilder(path) 14 | url_match_re = re.compile(url_builder.url_regex) 15 | 16 | methods = set([x.upper() for x in methods or ['GET']]) 17 | if 'GET' in methods and 'HEAD' not in methods: 18 | methods.add('HEAD') 19 | 20 | FuncPair = collections.namedtuple('FuncPair', ('func', 'methods')) 21 | self._urls_regex_map[url_match_re] = FuncPair(func, methods) 22 | self._urls_builer_map[url_builder] = FuncPair(func, methods) 23 | 24 | def get_func(self, path): 25 | """ 26 | :return: (func, methods) 27 | """ 28 | for url_match, func_pair in self._urls_regex_map.items(): 29 | m = url_match.match(path) 30 | if m is not None: 31 | return func_pair.func, func_pair.methods, m.groupdict() 32 | return None, None, None 33 | 34 | def url_for(self, func_name, **kwargs): 35 | for url_builder, func_pair in self._urls_builer_map.items(): 36 | func = func_pair.func 37 | if func.__name__ == func_name: 38 | return url_builder.build_url(**kwargs) 39 | return '' 40 | 41 | 42 | class URLBuilder: 43 | # / 44 | RE_PATH_TYPE = re.compile(r'''< 45 | (?:(?Pint|float|path):)? 46 | (?P\w+) 47 | >''', re.X) 48 | TYPE_REGEX_MAP = { 49 | 'int': r'\d+', 50 | 'float': r'\d+(?:\.\d+)?', 51 | 'path': r'.+', 52 | } 53 | # /(?P\d+) 54 | RE_PATH_REGEX = re.compile(r''' 55 | \(\?P< 56 | (?P\w+) 57 | >[^\)]+ 58 | \)''', re.X) 59 | 60 | def __init__(self, url_exp): 61 | self.url_format, self.url_kwarg_names = self.exp_to_format(url_exp) 62 | self.url_regex = self.exp_to_regex(url_exp) 63 | 64 | @classmethod 65 | def exp_to_format(cls, exp): 66 | names = set() 67 | if cls.RE_PATH_TYPE.search(exp) or cls.RE_PATH_REGEX.search(exp): 68 | 69 | def replace(m): 70 | name = m.group('name') 71 | names.add(name) 72 | return '{' + name + '}' 73 | 74 | exp = cls.RE_PATH_REGEX.sub(replace, exp) 75 | exp = cls.RE_PATH_TYPE.sub(replace, exp) 76 | return exp, names 77 | 78 | @classmethod 79 | def exp_to_regex(cls, exp): 80 | if not exp.startswith('^'): 81 | if exp.startswith('/'): 82 | exp = '^' + exp 83 | else: 84 | exp = '^/' + exp 85 | if not exp.endswith('$'): 86 | exp = exp + '$' 87 | 88 | # / 89 | if '(?P<' not in exp and cls.RE_PATH_TYPE.search(exp): 90 | exp = cls.RE_PATH_TYPE.sub(cls._replace_type_to_regex, exp) 91 | return exp 92 | 93 | @classmethod 94 | def _replace_type_to_regex(cls, match): 95 | """ / -> r'(?P\d+)' """ 96 | groupdict = match.groupdict() 97 | _type = groupdict.get('type') 98 | type_regex = cls.TYPE_REGEX_MAP.get(_type, '[^/]+') 99 | name = groupdict.get('name') 100 | return r'(?P<{name}>{type_regex})'.format( 101 | name=name, type_regex=type_regex 102 | ) 103 | 104 | def build_url(self, **kwargs): 105 | unknown_names = set(kwargs.keys()) - self.url_kwarg_names 106 | url = self.url_format.format(**kwargs) 107 | if unknown_names: 108 | url += '?' + urllib.parse.urlencode( 109 | {k: kwargs[k] for k in unknown_names} 110 | ) 111 | return url 112 | 113 | def __repr__(self): 114 | return r''.format(self.url_format) 115 | 116 | def __eq__(self, other): 117 | return hash(self.url_regex) == hash(other.url_regex) 118 | 119 | def __hash__(self): 120 | return hash(self.url_regex) 121 | -------------------------------------------------------------------------------- /bustard/servers.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | import abc 3 | import wsgiref.simple_server 4 | 5 | from . import wsgi_server 6 | 7 | 8 | class ServerAdapter(metaclass=abc.ABCMeta): 9 | def __init__(self, host='127.0.0.1', port=5000, **options): 10 | self.host = host 11 | self.port = port 12 | self.options = options 13 | 14 | @abc.abstractmethod 15 | def run(self, app): 16 | pass 17 | 18 | 19 | class BustardServer(ServerAdapter): 20 | 21 | def run(self, app): 22 | httpd = wsgi_server.make_server( 23 | (self.host, self.port), app, **self.options 24 | ) 25 | httpd.serve_forever() 26 | 27 | 28 | class WSGIRefServer(ServerAdapter): 29 | 30 | def run(self, app): 31 | httpd = wsgiref.simple_server.make_server( 32 | self.host, self.port, app, **self.options 33 | ) 34 | httpd.serve_forever() 35 | 36 | 37 | class WerkzeugfServer(ServerAdapter): 38 | 39 | def run(self, app): 40 | from werkzeug.serving import run_simple 41 | run_simple(self.host, self.port, app, **self.options) 42 | -------------------------------------------------------------------------------- /bustard/sessions.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | import abc 3 | import collections 4 | import uuid 5 | 6 | 7 | class SessionBase(collections.UserDict): 8 | 9 | def __init__(self, session_key=None): 10 | super(SessionBase, self).__init__() 11 | self.session_key = session_key 12 | self.modified = False 13 | 14 | def is_empty(self): 15 | return not (self.session_key and self.data) 16 | 17 | def __setitem__(self, key, value): 18 | self.data[key] = value 19 | self.modified = True 20 | 21 | def __delitem__(self, key): 22 | del self.data[key] 23 | self.modified = True 24 | 25 | def pop(self, key, default=None): 26 | self.modified = self.modified or key in self.data 27 | return self.data.pop(key, default) 28 | 29 | def setdefault(self, key, value): 30 | if key in self.data: 31 | return self.data[key] 32 | else: 33 | self.modified = True 34 | self.data[key] = value 35 | return value 36 | 37 | def update(self, dict_): 38 | self.data.update(dict_) 39 | self.modified = True 40 | 41 | def clear(self): 42 | self.data = {} 43 | self.accessed = True 44 | self.modified = True 45 | 46 | def _get_or_create_session_key(self): 47 | if not self.session_key or not self.exists(self.session_key): 48 | self.session_key = self._new_session_key() 49 | return self.session_key 50 | 51 | def _new_session_key(self): 52 | return uuid.uuid4().hex 53 | 54 | @abc.abstractmethod 55 | def create(self): 56 | pass 57 | 58 | @abc.abstractmethod 59 | def exists(self, session_key=None): 60 | pass 61 | 62 | @abc.abstractmethod 63 | def save(self): 64 | pass 65 | 66 | @abc.abstractmethod 67 | def delete(self, session_key=None): 68 | pass 69 | 70 | 71 | def before_request_hook(request, view_func, app): 72 | session_key = request.cookies.get(app.config['SESSION_COOKIE_NAME']) 73 | request.session = app.session_class(session_key=session_key) 74 | 75 | 76 | def after_request_hook(request, response, view_func, app): 77 | session = request.session 78 | if not session.modified: 79 | return 80 | 81 | config = app.config 82 | cookie_name = config['SESSION_COOKIE_NAME'] 83 | if session.is_empty(): 84 | if cookie_name in request.cookies: 85 | session.delete() 86 | response.delete_cookie(cookie_name) 87 | else: 88 | session.save() 89 | response.set_cookie( 90 | cookie_name, value=session.session_key, 91 | max_age=config['SESSION_COOKIE_MAX_AGE'], 92 | expires=None, path=config['SESSION_COOKIE_PATH'], 93 | domain=config['SESSION_COOKIE_DOMAIN'], 94 | secure=config['SESSION_COOKIE_SECURE'], 95 | httponly=config['SESSION_COOKIE_HTTPONLY'] 96 | ) 97 | return response 98 | 99 | 100 | class MemorySession(SessionBase): 101 | _sessions = {} 102 | 103 | def __init__(self, session_key=None): 104 | super(MemorySession, self).__init__(session_key=session_key) 105 | self.load(session_key) 106 | 107 | def load(self, session_key): 108 | _sessions = self.__class__._sessions 109 | if session_key not in _sessions: 110 | self.create() 111 | else: 112 | self.data = _sessions[self.session_key] 113 | 114 | def exists(self, key): 115 | _sessions = self.__class__._sessions 116 | return key in _sessions 117 | 118 | def create(self): 119 | self.modified = True 120 | self.save(must_create=True) 121 | 122 | def save(self, must_create=False): 123 | session_key = self._get_or_create_session_key() 124 | _sessions = self.__class__._sessions 125 | if self.data or must_create: 126 | if session_key in _sessions and not self.modified: 127 | self.data = _sessions[self.session_key] 128 | else: 129 | _sessions[self.session_key] = self.data 130 | else: 131 | self.delete() 132 | 133 | def delete(self, session_key=None): 134 | _sessions = self.__class__._sessions 135 | session_key = session_key or self.session_key 136 | _sessions.pop(session_key, None) 137 | self.modified = True 138 | -------------------------------------------------------------------------------- /bustard/template.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | """ 3 | simple template engine 4 | 5 | support: 6 | 7 | * {{ foobar }} 8 | * {{ ' foobar '.strip() }} 9 | * {{ foo.bar }} {{ foo.hello() }} 10 | * {{ foo['bar'] }} 11 | * {{ sum(filter(lambda x: x > 2, numbers)) }} 12 | * {# ... #} 13 | * {% if xx %} {% elif yy %} {% else %} {% endif %} 14 | * {% for x in lst %} {% endfor %} 15 | * {% include "path/to/b.tpl" %} 16 | * {% extends "path/to/b.tpl" %} 17 | * {% block body %} {% endblock body %} 18 | 19 | """ 20 | import builtins 21 | import os 22 | import re 23 | 24 | from .constants import TEMPLATE_BUILTIN_FUNC_WHITELIST 25 | from .utils import to_text 26 | 27 | 28 | class CodeBuilder: 29 | INDENT_STEP = 4 30 | 31 | def __init__(self, indent=0): 32 | self.source_code = [] 33 | self.indent_level = indent 34 | 35 | def add(self, code): 36 | self.source_code.append(code) 37 | 38 | def add_line(self, line, *args, **kwargs): 39 | line = line.format(*args, **kwargs) 40 | line = ' ' * self.indent_level + line + '\n' 41 | self.source_code.append(line) 42 | 43 | def forward_indent(self): 44 | self.indent_level += self.INDENT_STEP 45 | 46 | def backward_indent(self): 47 | self.indent_level -= self.INDENT_STEP 48 | 49 | def _compile(self): 50 | assert self.indent_level == 0 51 | self._code = compile(str(self), '', 'exec') 52 | return self._code 53 | 54 | def _exec(self, globals_dict=None): 55 | """exec compiled code""" 56 | globals_dict = globals_dict or {} 57 | globals_dict.setdefault('__builtins__', {}) 58 | exec(self._code, globals_dict) 59 | return globals_dict 60 | 61 | def __str__(self): 62 | return ''.join(map(str, self.source_code)) 63 | 64 | def __repr__(self): 65 | return self.__str__() 66 | 67 | 68 | class Template: 69 | TOKEN_VARIABLE_START = '{{' 70 | TOKEN_VARIABLE_END = '}}' 71 | TOKEN_TAG_START = '{%' 72 | TOKEN_TAG_END = '%}' 73 | TOKEN_COMMENT_START = '{#' 74 | TOKEN_COMMENT_END = '#}' 75 | FUNC_WHITELIST = TEMPLATE_BUILTIN_FUNC_WHITELIST 76 | 77 | def __init__(self, text, default_context=None, 78 | pre_compile=True, 79 | indent=0, template_dir='', 80 | func_name='__render_function', 81 | result_var='__result', 82 | auto_escape=True 83 | ): 84 | self.re_tokens = re.compile(r'''(?x)( 85 | (?:{token_variable_start} .+? {token_variable_end}) 86 | |(?:{token_tag_start} .+? {token_tag_end}) 87 | |(?:{token_comment_start}.*?{token_comment_end}) 88 | )'''.format(token_variable_start=re.escape(self.TOKEN_VARIABLE_START), 89 | token_variable_end=re.escape(self.TOKEN_VARIABLE_END), 90 | token_tag_start=re.escape(self.TOKEN_TAG_START), 91 | token_tag_end=re.escape(self.TOKEN_TAG_END), 92 | token_comment_start=re.escape(self.TOKEN_COMMENT_START), 93 | token_comment_end=re.escape(self.TOKEN_COMMENT_END), 94 | ) 95 | ) 96 | # {{ variable }} 97 | self.re_variable = re.compile(r''' 98 | {token_variable_start} .+? {token_variable_end} 99 | '''.format( 100 | token_variable_start=re.escape(self.TOKEN_VARIABLE_START), 101 | token_variable_end=re.escape(self.TOKEN_VARIABLE_END) 102 | ), re.VERBOSE) 103 | # {# comment #} 104 | self.re_comment = re.compile(r''' 105 | {token_comment_start}.*?{token_comment_end} 106 | '''.format( 107 | token_comment_start=re.escape(self.TOKEN_COMMENT_START), 108 | token_comment_end=re.escape(self.TOKEN_COMMENT_END) 109 | ), re.VERBOSE) 110 | # {% tag %} 111 | self.re_tag = re.compile(r''' 112 | {token_tag_start}.*?{token_tag_end} 113 | '''.format( 114 | token_tag_start=re.escape(self.TOKEN_TAG_START), 115 | token_tag_end=re.escape(self.TOKEN_TAG_END) 116 | ), re.VERBOSE) 117 | # {% extends "base.html" %} 118 | self.re_extends = re.compile(r''' 119 | ^{token_tag_start}\s+extends\s+[\'"](?P[^\'"]+)[\'"]\s+ 120 | {token_tag_end} 121 | '''.format( 122 | token_tag_start=re.escape(self.TOKEN_TAG_START), 123 | token_tag_end=re.escape(self.TOKEN_TAG_END), 124 | ), re.VERBOSE) 125 | # {% block header %}...{% endblock header %} 126 | self.re_block = re.compile(r''' 127 | {token_tag_start}\s+block\s+(?P\w+)\s+{token_tag_end} 128 | (?P.*?) 129 | {token_tag_start}\s+endblock(?:\s+\1)?\s+{token_tag_end} 130 | '''.format( 131 | token_tag_start=re.escape(self.TOKEN_TAG_START), 132 | token_tag_end=re.escape(self.TOKEN_TAG_END), 133 | ), re.DOTALL | re.VERBOSE) 134 | # {{ block.super }} 135 | self.re_block_super = re.compile(r''' 136 | {token_variable_start}\s+block\.super\s+ 137 | {token_variable_end} 138 | '''.format( 139 | token_variable_start=re.escape(self.TOKEN_VARIABLE_START), 140 | token_variable_end=re.escape(self.TOKEN_VARIABLE_END), 141 | ), re.VERBOSE) 142 | 143 | self.default_context = { 144 | k: v 145 | for k, v in builtins.__dict__.items() 146 | if k in self.FUNC_WHITELIST 147 | } 148 | self.default_context.update({ 149 | 'escape': escape, 150 | 'noescape': noescape, 151 | 'to_text': noescape, 152 | }) 153 | if default_context is not None: 154 | self.default_context.update(default_context) 155 | self.base_dir = template_dir 156 | self.func_name = func_name 157 | self.result_var = result_var 158 | self.auto_escape = auto_escape 159 | 160 | self.buffered = [] # store common string 161 | self.code_builder = code_builder = CodeBuilder(indent=indent) 162 | # def func_name(): 163 | # result = [] 164 | code_builder.add_line('def {}():', func_name) 165 | code_builder.forward_indent() 166 | code_builder.add_line('{} = []', self.result_var) 167 | 168 | self.tpl_text = text 169 | self.parse_text(text) 170 | 171 | def parse_text(self, text): 172 | # if has extends, replace parent template with current blocks 173 | extends_text = self.handle_extends(text) 174 | if extends_text is not None: 175 | return self.parse_text(extends_text) 176 | 177 | tokens = self.re_tokens.split(text) 178 | handlers = ( 179 | (self.re_variable.match, self._handle_variable), # {{ variable }} 180 | (self.re_tag.match, self._handle_tag), # {% tag %} 181 | (self.re_comment.match, self._handle_comment), # {# comment #} 182 | ) 183 | default_handler = self._handle_string # common string 184 | 185 | for token in tokens: 186 | for match, handler in handlers: 187 | if match(token): 188 | handler(token) 189 | break 190 | else: 191 | default_handler(token) 192 | 193 | self.flush_buffer() 194 | self.code_builder.add_line('return "".join({})', self.result_var) 195 | self.code_builder.backward_indent() 196 | 197 | def _handle_variable(self, token): 198 | variable = self.strip_token(token, self.TOKEN_VARIABLE_START, 199 | self.TOKEN_VARIABLE_END).strip() 200 | if self.auto_escape: 201 | self.buffered.append('escape({})'.format(variable)) 202 | else: 203 | self.buffered.append('to_text({})'.format(variable)) 204 | 205 | def _handle_comment(self, token): 206 | pass 207 | 208 | def _handle_string(self, token): 209 | self.buffered.append('{}'.format(repr(token))) 210 | 211 | def _handle_tag(self, token): 212 | self.flush_buffer() 213 | tag = self.strip_token(token, self.TOKEN_TAG_START, 214 | self.TOKEN_TAG_END).strip() 215 | tag_name = tag.split()[0] 216 | if tag_name == 'include': 217 | self._handle_include(tag) 218 | else: 219 | self._handle_statement(tag, tag_name) 220 | 221 | def _handle_statement(self, tag, tag_name): 222 | if tag_name in ('if', 'elif', 'else', 'for'): 223 | if tag_name in ('elif', 'else'): 224 | self.code_builder.backward_indent() 225 | self.code_builder.add_line('{}:'.format(tag)) 226 | self.code_builder.forward_indent() 227 | elif tag_name in ('break',): 228 | self.code_builder.add_line(tag) 229 | elif tag_name in ('endif', 'endfor'): 230 | self.code_builder.backward_indent() 231 | 232 | def _handle_include(self, tag): 233 | # parse included template file 234 | # def func_name(): # current 235 | # result = [] 236 | # ... 237 | # def func_name_inclued(): # included 238 | # result_included = [] 239 | # ... 240 | # return ''.join(result_included) 241 | # result.append(func_name_inclued()) 242 | # return ''.join(result) 243 | path = ''.join(tag.split()[1:]).strip().strip('\'"') 244 | _template = self._parse_another_template_file(path) 245 | self.code_builder.add(_template.code_builder) 246 | self.code_builder.add_line( 247 | '{0}.append({1}())', 248 | self.result_var, _template.func_name 249 | ) 250 | 251 | def _parse_another_template_file(self, path): 252 | path = os.path.join(self.base_dir, path) 253 | _hash = str(hash(path)).replace('-', '_').replace('.', '_') 254 | func_name = self.func_name + _hash 255 | result_var = self.result_var + _hash 256 | 257 | with open(path, encoding='utf-8') as f: 258 | _template = self.__class__( 259 | f.read(), default_context=self.default_context, 260 | pre_compile=False, indent=self.code_builder.indent_level, 261 | template_dir=self.base_dir, 262 | auto_escape=self.auto_escape, 263 | func_name=func_name, result_var=result_var 264 | ) 265 | return _template 266 | 267 | def handle_extends(self, text): 268 | """replace all blocks in extends with current blocks""" 269 | match = self.re_extends.match(text) 270 | if match: 271 | extra_text = self.re_extends.sub('', text, count=1) 272 | blocks = self.get_blocks(extra_text) 273 | path = os.path.join(self.base_dir, match.group('path')) 274 | with open(path, encoding='utf-8') as fp: 275 | return self.replace_blocks_in_extends(fp.read(), blocks) 276 | else: 277 | return None 278 | 279 | def get_blocks(self, text): 280 | return { 281 | name: code 282 | for (name, code) in self.re_block.findall(text) 283 | } 284 | 285 | def replace_blocks_in_extends(self, extends_text, blocks): 286 | def replace(match): 287 | name = match.group('name') 288 | old_code = match.group('code') 289 | code = blocks.get(name) or old_code 290 | # {{ block.super }} 291 | return self.re_block_super.sub(old_code, code) 292 | return self.re_block.sub(replace, extends_text) 293 | 294 | def render(self, **context): 295 | self.code_builder._compile() 296 | globals_dict = { 297 | '__builtins__': self.default_context, 298 | } 299 | globals_dict.update(context) 300 | namespace = self.code_builder._exec(globals_dict) 301 | self.render_function = namespace[self.func_name] 302 | 303 | html = self.render_function() 304 | return self.cleanup_extra_whitespaces(html) 305 | 306 | def flush_buffer(self): 307 | """flush all buffered string into code""" 308 | self.code_builder.add_line('{0}.extend([{1}])', 309 | self.result_var, ','.join(self.buffered)) 310 | self.buffered = [] 311 | 312 | def strip_token(self, text, start, end): 313 | """{{ a }} -> a""" 314 | text = text.replace(start, '', 1) 315 | text = text.replace(end, '', 1) 316 | return text 317 | 318 | def cleanup_extra_whitespaces(self, text): 319 | """cleanup extra whitespaces let numbers of whitespaces <=1""" 320 | return re.sub(r'(\s)\s+', r'\1', text) 321 | 322 | 323 | class NoEscapedText: 324 | 325 | def __init__(self, raw_text): 326 | self.raw_text = raw_text 327 | 328 | 329 | html_escape_table = { 330 | '&': '&', 331 | '"': '"', 332 | '\'': ''', 333 | '>': '>', 334 | '<': '<', 335 | } 336 | 337 | 338 | def html_escape(text): 339 | return ''.join(html_escape_table.get(c, c) for c in text) 340 | 341 | 342 | def escape(text): 343 | if isinstance(text, NoEscapedText): 344 | return to_text(text.raw_text) 345 | else: 346 | text = to_text(text) 347 | return html_escape(text) 348 | 349 | 350 | def noescape(text): 351 | return NoEscapedText(text) 352 | -------------------------------------------------------------------------------- /bustard/testing.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | import collections 3 | import mimetypes 4 | import itertools 5 | import io 6 | import random 7 | import sys 8 | import time 9 | import urllib 10 | 11 | from .http import Headers, Response 12 | from .utils import to_text, to_bytes 13 | 14 | 15 | def run_wsgi_app(app, environ): 16 | 17 | response = [] 18 | buffer = [] 19 | 20 | def start_response(status, headers, exc_info=None): 21 | if exc_info is not None: 22 | raise (exc_info[0], exc_info[1], exc_info[2]) 23 | response[:] = [status, headers] 24 | return buffer.append 25 | 26 | app_rv = app(environ, start_response) 27 | app_iter = itertools.chain(buffer, app_rv) 28 | 29 | return app_iter, response[0], Headers.from_list(response[1]) 30 | 31 | 32 | def build_multipart_body(data, files): 33 | body = io.BytesIO() 34 | values = collections.OrderedDict() 35 | values.update(data or {}) 36 | values.update(files or {}) 37 | 38 | def write(content): 39 | body.write(to_bytes(content)) 40 | boundary = '{0}{1}'.format(time.time(), random.random()) 41 | 42 | for name, value in values.items(): 43 | write('--{}\r\n'.format(boundary)) 44 | write('Content-Disposition: form-data; name="{}"'.format(name)) 45 | 46 | if isinstance(value, dict): # file field 47 | filename = value.get('name', '') 48 | if filename: 49 | write('; filename="{}"'.format(filename)) 50 | write('\r\n') 51 | content_type = ( 52 | value.get('content_type') or 53 | mimetypes.guess_type(filename)[0] or 54 | 'application/octet-stream') 55 | write('Content-Type: {}\r\n'.format(content_type)) 56 | if not content_type.startswith('text'): 57 | write('Content-Transfer-Encoding: binary') 58 | value = value['file'] 59 | 60 | write('\r\n\r\n') 61 | write(value) 62 | write('\r\n') 63 | write('--{}--\r\n'.format(boundary)) 64 | length = body.tell() 65 | body.seek(0) 66 | return body, length, boundary 67 | 68 | 69 | class Client: 70 | 71 | def __init__(self, app, host='localhost', port='80', cookies=None): 72 | self.app = app 73 | self.host = host 74 | self.environ_builder = EnvironBuilder() 75 | self.cookies = cookies or {} 76 | 77 | def open(self, path, method, params=None, data=None, 78 | files=None, headers=None, cookies=None, 79 | content_type='', charset='utf-8', follow_redirects=False): 80 | if isinstance(headers, dict): 81 | headers = Headers(headers) 82 | content_type = content_type or (headers or {}).get('Content-Type', '') 83 | cookies = cookies or {} 84 | cookies.update(self.cookies) 85 | body = None 86 | if files: 87 | body_reader, _, boundary = build_multipart_body(data, files) 88 | body = body_reader.read() 89 | body_reader.close() 90 | data = None 91 | content_type = 'multipart/form-data; boundary={}'.format(boundary) 92 | 93 | environ = self.environ_builder.build_environ( 94 | path=path, method=method, params=params, 95 | data=data, body=body, headers=headers, 96 | cookies=cookies, 97 | content_type=content_type, charset=charset 98 | ) 99 | app_iter, status, headers = run_wsgi_app(self.app, environ) 100 | status_code = int(status[:3]) 101 | response = Response(b''.join(app_iter), status_code=status_code, 102 | headers=headers, 103 | content_type=content_type) 104 | self.cookies.update({ 105 | k: v.value 106 | for (k, v) in response.cookies.items() 107 | }) 108 | if status_code in (301, 302, 303, 307) and follow_redirects: 109 | new_path = headers['Location'] 110 | return self.open(new_path, 'GET') 111 | return response 112 | 113 | # get = functools.partialmethod(open, method='GET', data=None) 114 | def get(self, path, params=None, **kwargs): 115 | return self.open(path, method='GET', params=params, **kwargs) 116 | 117 | def options(self, path, **kwargs): 118 | return self.open(path, method='OPTIONS', **kwargs) 119 | 120 | def head(self, path, **kwargs): 121 | return self.open(path, method='HEAD', **kwargs) 122 | 123 | def trace(self, path, **kwargs): 124 | return self.open(path, method='TRACE', **kwargs) 125 | 126 | def post(self, path, **kwargs): 127 | return self.open(path, method='POST', **kwargs) 128 | 129 | def put(self, path, **kwargs): 130 | return self.open(path, method='PUT', **kwargs) 131 | 132 | def patch(self, path, **kwargs): 133 | return self.open(path, method='PATCH', **kwargs) 134 | 135 | def delete(self, path, **kwargs): 136 | return self.open(path, method='DELETE', **kwargs) 137 | 138 | 139 | class EnvironBuilder: 140 | 141 | def __init__(self, host='localhost', port='80', 142 | multithread=False, multiprocess=False, 143 | run_once=False, environ_base=None, 144 | url_scheme='http'): 145 | self.host = host 146 | self.port = str(port) 147 | if self.port == '80': 148 | self.http_host = self.host 149 | else: 150 | self.http_host = '{}:{}'.format(self.host, self.port) 151 | self.default_environ = { 152 | 'SERVER_NAME': self.host, 153 | 'GATEWAY_INTERFACE': 'CGI/1.1', 154 | 'SERVER_PORT': self.port, 155 | 'REMOTE_HOST': '', 156 | 'CONTENT_LENGTH': '', 157 | 'SCRIPT_NAME': '', 158 | 'SERVER_PROTOCOL': 'HTTP/1.1', 159 | 'REMOTE_ADDR': None, 160 | 'REMOTE_PORT': None, 161 | 162 | 'wsgi.version': (1, 0), 163 | 'wsgi.url_scheme': url_scheme, 164 | 'wsgi.errors': sys.stderr, 165 | 'wsgi.multithread': multithread, 166 | 'wsgi.multiprocess': multithread, 167 | 'wsgi.run_once': run_once, 168 | } 169 | if environ_base: 170 | self.default_environ.update(environ_base) 171 | 172 | def build_environ(self, path='/', method='GET', params=None, 173 | data=None, body=None, headers=None, cookies=None, 174 | content_type='', charset='utf-8'): 175 | query_string = '' 176 | path = to_text(path) 177 | if '?' in path: 178 | path, query_string = path.split('?', 1) 179 | if isinstance(params, (dict, list, tuple)): 180 | query_string = query_string + '&' + urllib.parse.urlencode(params) 181 | 182 | if isinstance(data, dict): 183 | body = to_bytes(urllib.parse.urlencode(data), encoding=charset) 184 | content_type = 'application/x-www-form-urlencoded' 185 | elif data: 186 | body = to_bytes(data, encoding=charset) 187 | 188 | environ = self.default_environ.copy() 189 | environ.update({ 190 | 'REQUEST_METHOD': method, 191 | 'PATH_INFO': path, 192 | 'QUERY_STRING': query_string, 193 | 'CONTENT_TYPE': content_type, 194 | 'CONTENT_LENGTH': str(len(body or b'')), 195 | 'wsgi.input': io.BytesIO(body), 196 | }) 197 | # headers 198 | if headers is not None: 199 | for k, v in headers.to_dict().items(): 200 | key = 'HTTP_' + k.replace('-', '_').upper() 201 | value = ', '.join(v) 202 | environ.update({key: value}) 203 | environ.setdefault('HTTP_HOST', self.http_host) 204 | # cookies 205 | if cookies is not None: 206 | http_cookie = '; '.join('='.join([k, v]) 207 | for (k, v) in cookies.items()) 208 | environ.update({'HTTP_COOKIE': http_cookie}) 209 | 210 | return environ 211 | -------------------------------------------------------------------------------- /bustard/utils.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | import binascii 3 | import base64 4 | import collections 5 | import urllib 6 | 7 | 8 | class MultiDict(collections.UserDict): 9 | 10 | def getlist(self, key): 11 | return self.data[key] 12 | 13 | def to_dict(self): 14 | return self.data 15 | 16 | def __getitem__(self, key): 17 | return self.data[key][-1] 18 | 19 | def __setitem__(self, key, value): 20 | if isinstance(value, (list, tuple)): 21 | self.data[key] = list(value) 22 | else: 23 | self.data[key] = [value] 24 | 25 | def __repr__(self): 26 | return '{}({})'.format(self.__class__.__name__, self.data) 27 | 28 | 29 | def json_dumps_default(obj): 30 | if isinstance(obj, collections.UserDict): 31 | return obj.to_dict() 32 | return obj 33 | 34 | 35 | def parse_query_string(query_string, encoding='utf-8'): 36 | query_dict = collections.defaultdict(list) 37 | for query_item in query_string.split('&'): 38 | if '=' not in query_item: 39 | continue 40 | keyword, value = query_item.split('=', 1) 41 | value = urllib.parse.unquote_plus(value) 42 | query_dict[keyword].append(to_text(value, encoding=encoding)) 43 | return query_dict 44 | 45 | 46 | def to_header_key(key): 47 | return '-'.join(x.capitalize() for x in key.split('-')) 48 | 49 | 50 | def to_text(st, encoding='utf-8'): 51 | if isinstance(st, str): 52 | return st 53 | elif isinstance(st, collections.ByteString): 54 | return st.decode(encoding) 55 | else: 56 | return str(st) 57 | 58 | 59 | def to_bytes(bt, encoding='utf-8'): 60 | if isinstance(bt, collections.ByteString): 61 | return bt 62 | elif isinstance(bt, str): 63 | return bt.encode(encoding) 64 | else: 65 | return bytes(bt) 66 | 67 | 68 | class Authorization: 69 | 70 | def __init__(self, _type, username, password): 71 | self.type = _type 72 | self.username = username 73 | self.password = password 74 | 75 | def __eq__(self, other): 76 | return ( 77 | self.type == other.type and 78 | self.username == other.username and 79 | self.password == other.password 80 | ) 81 | 82 | __hash__ = object.__hash__ 83 | 84 | def __repr__(self): 85 | return '{}(type:{}, username:{})'.format( 86 | self.__class__.__name__, self.type, self.username 87 | ) 88 | 89 | 90 | def parse_basic_auth_header(value): 91 | try: 92 | auth_type, auth_info = to_bytes(value).split(None, 1) 93 | except ValueError: 94 | return 95 | auth_type = auth_type.lower() 96 | 97 | if auth_type == b'basic': 98 | try: 99 | username, password = base64.b64decode(auth_info).split(b':', 1) 100 | except (binascii.Error, ValueError): 101 | return 102 | 103 | return Authorization( 104 | to_text(auth_type), 105 | username=to_text(username), 106 | password=to_text(password) 107 | ) 108 | 109 | 110 | def cookie_date(): 111 | pass 112 | -------------------------------------------------------------------------------- /bustard/views.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | import mimetypes 3 | import os 4 | 5 | from .exceptions import NotFound 6 | from .http import Response 7 | 8 | http_methods = ('get', 'post', 'head', 'options', 9 | 'delete', 'put', 'trace', 'patch') 10 | 11 | 12 | class View: 13 | decorators = () 14 | 15 | def dispatch_request(self, request, *args, **kwargs): 16 | method = request.method.lower() 17 | if method == 'head' and not hasattr(self, 'head'): 18 | method = 'get' 19 | view_func = getattr(self, method) 20 | return view_func(request, *args, **kwargs) 21 | 22 | @classmethod 23 | def as_view(cls, name=None, *class_args, **class_kwargs): 24 | 25 | def view(request, *args, **kwargs): 26 | instance = view.view_class(*class_args, **class_kwargs) 27 | return instance.dispatch_request(request, *args, **kwargs) 28 | 29 | for decorator in cls.decorators: 30 | view = decorator(view) 31 | 32 | view.view_class = cls 33 | view.__name__ = name or cls.__name__ 34 | methods = [] 35 | for method in http_methods: 36 | if hasattr(cls, method): 37 | methods.append(method.upper()) 38 | methods = frozenset(methods) 39 | cls.methods = methods 40 | view.methods = methods 41 | return view 42 | 43 | 44 | class StaticFilesView(View): 45 | 46 | def __init__(self, static_dir): 47 | self.static_dir = os.path.abspath(static_dir) 48 | 49 | def get(self, request, path): 50 | file_path = os.path.abspath(os.path.join(self.static_dir, path)) 51 | if not file_path.startswith(self.static_dir): 52 | raise NotFound() 53 | if not os.path.isfile(file_path): 54 | raise NotFound() 55 | 56 | with open(file_path, 'rb') as fp: 57 | content = fp.read() 58 | content_type = mimetypes.guess_type(file_path)[0] 59 | content_type = content_type or 'application/octet-stream' 60 | return Response(content, content_type=content_type) 61 | -------------------------------------------------------------------------------- /bustard/wsgi_server.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | import datetime 4 | import io 5 | import socket 6 | import sys 7 | import time 8 | import urllib 9 | 10 | from .utils import to_text, to_bytes 11 | 12 | 13 | class WSGIServer: 14 | address_family = socket.AF_INET 15 | socket_type = socket.SOCK_STREAM 16 | request_queue_size = 5 17 | allow_reuse_address = True 18 | default_request_version = 'HTTP/1.1' 19 | server_version = 'WSGIServer/0.1' 20 | weekdayname = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'] 21 | monthname = [None, 22 | 'Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 23 | 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'] 24 | 25 | def __init__(self, server_address): 26 | # 创建 socket 27 | self.socket = socket.socket(self.address_family, self.socket_type) 28 | # 绑定 29 | self.server_bind(server_address) 30 | # 监听 31 | self.server_activate() 32 | # 基本的 environ 33 | self.setup_environ() 34 | self.headers_set = [] 35 | 36 | def server_bind(self, server_address): 37 | if self.allow_reuse_address: 38 | self.socket.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) 39 | 40 | self.socket.bind(server_address) 41 | self.server_address = self.socket.getsockname() 42 | 43 | host, port = self.socket.getsockname()[:2] 44 | self.server_name = socket.getfqdn(host) 45 | self.server_port = port 46 | 47 | def server_activate(self): 48 | self.socket.listen(self.request_queue_size) 49 | 50 | def setup_environ(self): 51 | """https://www.python.org/dev/peps/pep-0333/#environ-variables""" 52 | # Set up base environment 53 | env = self.base_environ = {} 54 | env['SERVER_NAME'] = self.server_name 55 | env['GATEWAY_INTERFACE'] = 'CGI/1.1' 56 | env['SERVER_PORT'] = str(self.server_port) 57 | env['REMOTE_HOST'] = '' 58 | env['CONTENT_LENGTH'] = '' 59 | env['SCRIPT_NAME'] = '' 60 | 61 | def get_app(self): 62 | return self.application 63 | 64 | def set_app(self, application): 65 | self.application = application 66 | 67 | def serve_forever(self): 68 | while 1: 69 | self.handle_one_request() 70 | self.client_connection.close() 71 | 72 | def handle_one_request(self): 73 | self.client_connection, self.client_address = self.socket.accept() 74 | # self.raw_request = raw_request = self.client_connection.recv(65537) 75 | init_read = 65536 76 | self.raw_request = raw_request = self.client_connection.recv(init_read) 77 | 78 | self.parse_request(raw_request) 79 | self.parse_headers(raw_request) 80 | length = int(self.headers.get('Content-Length', '0')) 81 | while len(self.raw_request) == length: 82 | self.raw_request += self.client_connection.recv(init_read) 83 | 84 | env = self.get_environ() 85 | 86 | print('[%s] "%s %s %s"' % ( 87 | datetime.datetime.now(), env['REQUEST_METHOD'], 88 | env['PATH_INFO'], env['SERVER_PROTOCOL'], 89 | )) 90 | 91 | result = self.application(env, self.start_response) 92 | self.finish_response(result) 93 | 94 | def parse_request(self, raw_request): 95 | # GET /foo?a=1&b=2 HTTP/1.1 96 | first_line = to_text(raw_request.split(b'\r\n', 1)[0].strip()) 97 | (self.request_method, # GET 98 | self.path, # /foo?a=1&b=2 99 | self.request_version # HTTP/1.1 100 | ) = first_line.split() 101 | 102 | def parse_headers(self, raw_request): 103 | header_string = to_text(raw_request.split(b'\r\n\r\n', 1)[0]) 104 | self.headers = headers = {} 105 | for header in header_string.splitlines()[1:]: 106 | k, v = header.split(':', 1) 107 | if headers.get(k): 108 | headers[k] += ', ' + v.strip() # 多个同名 header 109 | else: 110 | headers[k] = v.strip() 111 | 112 | def get_environ(self): 113 | """https://www.python.org/dev/peps/pep-0333/#environ-variables""" 114 | env = self.base_environ.copy() 115 | env['REQUEST_METHOD'] = self.request_method 116 | 117 | if '?' in self.path: 118 | path, query = self.path.split('?', 1) 119 | else: 120 | path, query = self.path, '' 121 | env['PATH_INFO'] = urllib.parse.unquote(path) 122 | env['QUERY_STRING'] = query 123 | 124 | env['CONTENT_TYPE'] = self.headers.get('Content-Type', '') 125 | env['CONTENT_LENGTH'] = self.headers.get('Content-Length', '0') 126 | 127 | env['SERVER_PROTOCOL'] = self.request_version 128 | env['REMOTE_ADDR'] = self.client_address[0] 129 | env['REMOTE_PORT'] = self.client_address[1] 130 | 131 | env['wsgi.version'] = (1, 0) 132 | env['wsgi.url_scheme'] = 'http' 133 | env['wsgi.input'] = io.BytesIO(self.raw_request) 134 | env['wsgi.errors'] = sys.stderr 135 | env['wsgi.multithread'] = False 136 | env['wsgi.multiprocess'] = True 137 | env['wsgi.run_once'] = False 138 | 139 | for k, v in self.headers.items(): 140 | k = k.replace('-', '_').upper() 141 | if k in env: 142 | continue 143 | env['HTTP_' + k] = v 144 | return env 145 | 146 | def start_response(self, status, headers, exc_info=None): 147 | server_headers = [ 148 | ('Date', self.date_time_string()), 149 | ('Server', self.version_string()), 150 | ] 151 | headers = list(headers) + server_headers 152 | 153 | if exc_info: 154 | try: 155 | if self.headers_set: 156 | # Re-raise original exception if headers sent 157 | raise (exc_info[0], exc_info[1], exc_info[2]) 158 | finally: 159 | exc_info = None # avoid dangling circular ref 160 | 161 | self.headers_set[:] = [status, headers] 162 | 163 | def finish_response(self, body): 164 | try: 165 | status, headers = self.headers_set 166 | # status line 167 | response = ( 168 | to_bytes(self.default_request_version) + 169 | b' ' + 170 | to_bytes(status) + 171 | b'\r\n' 172 | ) 173 | # headers 174 | response += b'\r\n'.join([to_bytes(': '.join(x)) for x in headers]) 175 | response += b'\r\n\r\n' 176 | # body 177 | for d in body: 178 | response += d 179 | self.client_connection.sendall(response) 180 | finally: 181 | self.client_connection.close() 182 | 183 | def version_string(self): 184 | return self.server_version 185 | 186 | def date_time_string(self, timestamp=None): 187 | if timestamp is None: 188 | timestamp = time.time() 189 | year, month, day, hh, mm, ss, wd, y, z = time.gmtime(timestamp) 190 | s = '%s, %02d %3s %4d %02d:%02d:%02d GMT' % ( 191 | self.weekdayname[wd], 192 | day, self.monthname[month], year, 193 | hh, mm, ss 194 | ) 195 | return s 196 | 197 | 198 | def make_server(server_address, application): 199 | server = WSGIServer(server_address) 200 | server.set_app(application) 201 | return server 202 | 203 | 204 | if __name__ == '__main__': 205 | if len(sys.argv) < 2: 206 | sys.exit('Provide a WSGI application object as module:callable') 207 | app_path = sys.argv[1] 208 | module, application = app_path.split(':') 209 | module = __import__(module) 210 | application = getattr(module, application) 211 | 212 | SERVER_ADDRESS = (HOST, PORT) = '', 8888 213 | httpd = make_server(SERVER_ADDRESS, application) 214 | print('WSGIServer: Serving HTTP on port {port} ...\n'.format(port=PORT)) 215 | 216 | httpd.serve_forever() 217 | -------------------------------------------------------------------------------- /examples/flaskr/README.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | 1. create database 4 | 2. creat tables: `flaskr.init_db()` 5 | 3. `python flaskr.py` 6 | 7 | 8 | 9 | Based on [flaskr](https://github.com/mitsuhiko/flask/tree/master/examples/flaskr) 10 | 11 | :copyright: (c) 2015 by Armin Ronacher. 12 | :license: BSD, see LICENSE for more details. 13 | -------------------------------------------------------------------------------- /examples/flaskr/flaskr.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | import os 3 | 4 | from bustard.app import Bustard 5 | from bustard.http import redirect 6 | from bustard.orm import ( 7 | Engine, Model, MetaData, Session, 8 | AutoField, TextField 9 | ) 10 | from bustard.views import StaticFilesView 11 | 12 | 13 | class Entry(Model): 14 | __tablename__ = 'entries' 15 | 16 | id = AutoField(primary_key=True) 17 | title = TextField() 18 | content = TextField(default='', server_default="''") 19 | 20 | 21 | CURRENT_DIR = os.path.dirname(os.path.abspath(__file__)) 22 | TEMPLATE_DIR = os.path.join(CURRENT_DIR, 'templates') 23 | STATIC_DIR = os.path.join(CURRENT_DIR, 'static') 24 | USERNAME = 'admin' 25 | PASSWORD = 'passwd' 26 | PG_URI = 'postgresql://dbuser:password@localhost/example_bustardr' 27 | engine = Engine(PG_URI) 28 | db_session = Session(engine) 29 | app = Bustard(__name__, template_dir=TEMPLATE_DIR) 30 | app.add_url_rule('/static/', 31 | StaticFilesView.as_view(static_dir=STATIC_DIR)) 32 | 33 | 34 | def init_db(): 35 | MetaData.create_all(engine) 36 | 37 | 38 | def drop_db(): 39 | MetaData.drop_all(engine) 40 | 41 | 42 | @app.route('/') 43 | def show_entries(request): 44 | entries = db_session.query(Entry).filter().order_by(Entry.id.desc) 45 | return app.render_template('show_entries.html', entries=entries, 46 | session=request.session) 47 | 48 | 49 | @app.route('/add', methods=['POST']) 50 | def add_entry(request): 51 | if not request.session.get('logged_in'): 52 | app.abort(401) 53 | entry = Entry(title=request.form['title'], 54 | content=request.form['content']) 55 | db_session.insert(entry) 56 | db_session.commit() 57 | return redirect(app.url_for('show_entries')) 58 | 59 | 60 | @app.route('/login', methods=['GET', 'POST']) 61 | def login(request): 62 | error = None 63 | if request.method == 'POST': 64 | if request.form['username'] != USERNAME: 65 | error = 'Invalid username' 66 | elif request.form['password'] != PASSWORD: 67 | error = 'Invalid password' 68 | else: 69 | request.session['logged_in'] = True 70 | return redirect(app.url_for('show_entries')) 71 | return app.render_template('login.html', error=error, 72 | session=request.session) 73 | 74 | 75 | @app.route('/logout') 76 | def logout(request): 77 | request.session.pop('logged_in', None) 78 | return redirect(app.url_for('show_entries')) 79 | 80 | if __name__ == '__main__': 81 | from bustard.servers import WerkzeugfServer 82 | server = WerkzeugfServer(use_reloader=True, use_debugger=True) 83 | server.run(app) 84 | -------------------------------------------------------------------------------- /examples/flaskr/static/style.css: -------------------------------------------------------------------------------- 1 | body { font-family: sans-serif; background: #eee; } 2 | a, h1, h2 { color: #377BA8; } 3 | h1, h2 { font-family: 'Georgia', serif; margin: 0; } 4 | h1 { border-bottom: 2px solid #eee; } 5 | h2 { font-size: 1.2em; } 6 | 7 | .page { margin: 2em auto; width: 35em; border: 5px solid #ccc; 8 | padding: 0.8em; background: white; } 9 | .entries { list-style: none; margin: 0; padding: 0; } 10 | .entries li { margin: 0.8em 1.2em; } 11 | .entries li h2 { margin-left: -1em; } 12 | .add-entry { font-size: 0.9em; border-bottom: 1px solid #ccc; } 13 | .add-entry dl { font-weight: bold; } 14 | .metanav { text-align: right; font-size: 0.8em; padding: 0.3em; 15 | margin-bottom: 1em; background: #fafafa; } 16 | .flash { background: #CEE5F5; padding: 0.5em; 17 | border: 1px solid #AACBE2; } 18 | .error { background: #F0D6D6; padding: 0.5em; } 19 | -------------------------------------------------------------------------------- /examples/flaskr/templates/layout.html: -------------------------------------------------------------------------------- 1 | 2 | Flaskr 3 | 4 |
5 |

Flaskr

6 |
7 | {% if not session.get('logged_in') %} 8 | log in 9 | {% else %} 10 | log out 11 | {% endif %} 12 |
13 | {% block body %}{% endblock %} 14 |
15 | -------------------------------------------------------------------------------- /examples/flaskr/templates/login.html: -------------------------------------------------------------------------------- 1 | {% extends "layout.html" %} 2 | {% block body %} 3 |

Login

4 | {% if error %}

Error: {{ error }}{% endif %} 5 |

6 |
7 |
Username: 8 |
9 |
Password: 10 |
11 |
12 |
13 |
14 | {% endblock body %} 15 | -------------------------------------------------------------------------------- /examples/flaskr/templates/show_entries.html: -------------------------------------------------------------------------------- 1 | {% extends "layout.html" %} 2 | {% block body %} 3 | {% if session.get('logged_in') %} 4 |
5 |
6 |
Title: 7 |
8 |
Text: 9 |
10 |
11 |
12 |
13 | {% endif %} 14 |
    15 | {% if entries %} 16 | {% for entry in entries %} 17 |
  • {{ entry.title }}

    {{ noescape(entry.content) }}
  • 18 | {% endfor %} 19 | {% else %} 20 |
  • Unbelievable. No entries here so far
  • 21 | {% endif %} 22 |
23 | {% endblock body %} 24 | -------------------------------------------------------------------------------- /examples/flaskr/test_flaskr.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | import pytest 3 | 4 | import flaskr 5 | PG_URI = 'postgresql://dbuser:password@localhost/example_bustardr_test' 6 | engine = flaskr.Engine(PG_URI) 7 | flaskr.db_session.bind = engine 8 | flaskr.db_session.connect() 9 | 10 | 11 | @pytest.yield_fixture 12 | def client(request): 13 | flaskr.db_session.close() 14 | client = flaskr.app.test_client() 15 | flaskr.MetaData.create_all(engine) 16 | yield client 17 | flaskr.db_session.close() 18 | flaskr.MetaData.drop_all(engine) 19 | 20 | 21 | def login(client, username, password): 22 | return client.post('/login', data=dict( 23 | username=username, 24 | password=password 25 | ), follow_redirects=True) 26 | 27 | 28 | def logout(client): 29 | return client.get('/logout', follow_redirects=True) 30 | 31 | 32 | def test_empty_db(client): 33 | """Start with a blank database.""" 34 | rv = client.get('/') 35 | assert b'No entries here so far' in rv.data 36 | 37 | 38 | def test_login_logout(client): 39 | """Make sure login and logout works""" 40 | username = flaskr.USERNAME 41 | password = flaskr.PASSWORD 42 | rv = login(client, username, password) 43 | assert b'log out' in rv.data 44 | rv = logout(client) 45 | assert b'log in' in rv.data 46 | rv = login(client, username + 'x', password) 47 | assert b'Invalid username' in rv.data 48 | rv = login(client, username, password + 'x') 49 | assert b'Invalid password' in rv.data 50 | 51 | 52 | def test_messages(client): 53 | """Test that messages work""" 54 | login(client, flaskr.USERNAME, flaskr.PASSWORD) 55 | rv = client.post('/add', data=dict( 56 | title='', 57 | content='HTML allowed here' 58 | ), follow_redirects=True) 59 | assert b'No entries here so far' not in rv.data 60 | assert b'<Hello>' in rv.data 61 | assert b'HTML allowed here' in rv.data 62 | -------------------------------------------------------------------------------- /examples/hello.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from bustard.app import Bustard 3 | 4 | app = Bustard() 5 | 6 | 7 | @app.route('/') 8 | def helloword(request): 9 | return 'hello world' 10 | 11 | if __name__ == '__main__': 12 | app.run() 13 | -------------------------------------------------------------------------------- /examples/session.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from bustard.app import Bustard 3 | 4 | app = Bustard() 5 | 6 | 7 | @app.route('/set/') 8 | def set_session(request, value): 9 | request.session['name'] = value 10 | return 'hello {}'.format(value) 11 | 12 | 13 | @app.route('/') 14 | def get_session(request): 15 | value = request.session.get('name', '') 16 | return 'hello {}'.format(value) 17 | 18 | if __name__ == '__main__': 19 | app.run(host='0.0.0.0') 20 | -------------------------------------------------------------------------------- /examples/static_files.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | import os 3 | 4 | from bustard.app import Bustard 5 | from bustard.views import StaticFilesView 6 | 7 | app = Bustard(__name__) 8 | current_dir = os.path.dirname(os.path.abspath(__file__)) 9 | app.add_url_rule('/static/', 10 | StaticFilesView.as_view(static_dir=current_dir)) 11 | 12 | if __name__ == '__main__': 13 | app.run() 14 | -------------------------------------------------------------------------------- /examples/upload.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from bustard.app import Bustard 3 | from bustard.http import Response 4 | 5 | app = Bustard() 6 | 7 | 8 | @app.route('/') 9 | def index(request): 10 | return ''' 11 |
12 | Username: 13 | Password: 14 | File: 15 | 16 |
17 | ''' 18 | 19 | 20 | @app.route('/upload', methods=['POST']) 21 | def upload(request): 22 | _file = request.files['file'] 23 | response = Response(_file.read(), content_type=_file.content_type) 24 | return response 25 | 26 | if __name__ == '__main__': 27 | app.run('0.0.0.0') 28 | -------------------------------------------------------------------------------- /examples/views.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | from bustard.app import Bustard 4 | from bustard.views import View 5 | 6 | app = Bustard(__name__) 7 | 8 | 9 | class IndexView(View): 10 | 11 | def get(self, request, name): 12 | return name 13 | 14 | def post(self, request, name): 15 | return '{} post'.format(name) 16 | 17 | 18 | app.add_url_rule('/hello/(?P\w+)', IndexView.as_view()) 19 | 20 | if __name__ == '__main__': 21 | app.run(host='0.0.0.0') 22 | -------------------------------------------------------------------------------- /pytest.ini: -------------------------------------------------------------------------------- 1 | [pytest] 2 | addopts = -slv --tb=short --pdb 3 | norecursedirs = .git __pycache__ venv 4 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | psycopg2 2 | -------------------------------------------------------------------------------- /requirements_dev.txt: -------------------------------------------------------------------------------- 1 | -r requirements.txt 2 | decorator 3 | pre-commit 4 | pytest 5 | pytest-cov 6 | six 7 | Werkzeug 8 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | from __future__ import absolute_import, print_function, unicode_literals 4 | 5 | from codecs import open 6 | import sys 7 | import os 8 | 9 | try: 10 | from setuptools import setup 11 | except ImportError: 12 | from distutils.core import setup 13 | 14 | import bustard 15 | 16 | if sys.argv[-1] == 'publish': 17 | os.system('python setup.py register') 18 | os.system('python setup.py sdist upload') 19 | os.system('python setup.py bdist_wheel upload') 20 | sys.exit() 21 | 22 | packages = [ 23 | 'bustard', 24 | ] 25 | 26 | requirements = [ 27 | # 'psycopg2', 28 | ] 29 | 30 | 31 | def long_description(): 32 | readme = open('README.rst', encoding='utf8').read() 33 | return readme 34 | 35 | setup( 36 | name=bustard.__title__, 37 | version=bustard.__version__, 38 | description=bustard.__doc__, 39 | long_description=long_description(), 40 | url='https://github.com/mozillazg/bustard', 41 | author=bustard.__author__, 42 | author_email='mozillazg101@gmail.com', 43 | license=bustard.__license__, 44 | packages=packages, 45 | package_data={'': ['LICENSE']}, 46 | package_dir={'bustard': 'bustard'}, 47 | include_package_data=True, 48 | install_requires=requirements, 49 | zip_safe=False, 50 | platforms='any', 51 | classifiers=[ 52 | 'Development Status :: 3 - Alpha', 53 | 'Environment :: Web Environment', 54 | 'Intended Audience :: Developers', 55 | 'License :: OSI Approved :: MIT License', 56 | 'Operating System :: OS Independent', 57 | 'Programming Language :: Python', 58 | 'Programming Language :: Python :: 3', 59 | 'Programming Language :: Python :: 3.5', 60 | 'Topic :: Internet :: WWW/HTTP :: Dynamic Content', 61 | 'Topic :: Software Development :: Libraries :: Python Modules' 62 | ], 63 | ) 64 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mozillazg/bustard/bd7b47f3ba5440cf6ea026c8b633060fedeb80b7/tests/__init__.py -------------------------------------------------------------------------------- /tests/conftest.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mozillazg/bustard/bd7b47f3ba5440cf6ea026c8b633060fedeb80b7/tests/conftest.py -------------------------------------------------------------------------------- /tests/httpbin/AUTHORS: -------------------------------------------------------------------------------- 1 | HttpBin is written and maintained by Kenneth Reitz and 2 | various contributors: 3 | 4 | Development Lead 5 | ```````````````` 6 | 7 | - Kenneth Reitz <_@kennethreitz.com> 8 | 9 | 10 | Patches and Suggestions 11 | ``````````````````````` 12 | 13 | - Zbigniew Siciarz 14 | - Andrey Petrov 15 | - Lispython 16 | - Kyle Conroy 17 | - Flavio Percoco 18 | - Radomir Stevanovic (http://github.com/randomir) 19 | - Steven Honson 20 | - Bob Carroll @rcarz 21 | - Cory Benfield (Lukasa) 22 | - Matt Robenolt (https://github.com/mattrobenolt) 23 | - Dave Challis (https://github.com/davechallis) 24 | - Florian Bruhin (https://github.com/The-Compiler) 25 | -------------------------------------------------------------------------------- /tests/httpbin/LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2011 Kenneth Reitz. 2 | 3 | Permission to use, copy, modify, and/or distribute this software for any 4 | purpose with or without fee is hereby granted, provided that the above 5 | copyright notice and this permission notice appear in all copies. 6 | 7 | THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES 8 | WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF 9 | MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR 10 | ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES 11 | WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN 12 | ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF 13 | OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. -------------------------------------------------------------------------------- /tests/httpbin/README.rst: -------------------------------------------------------------------------------- 1 | httpbin(1): HTTP Request & Response Service 2 | =========================================== 3 | 4 | Freely hosted in `HTTP `__, 5 | `HTTPS `__ & `EU `__ 6 | flavors by `Runscope `__ 7 | 8 | |Deploy|_ 9 | 10 | .. |Deploy| image:: https://www.herokucdn.com/deploy/button.svg 11 | .. _Deploy: https://heroku.com/deploy?template=https://github.com/runscope/httpbin 12 | 13 | |Build Status| 14 | 15 | ENDPOINTS 16 | --------- 17 | 18 | ====================================== ================================================================================================================== 19 | Endpoint Description 20 | -------------------------------------- ------------------------------------------------------------------------------------------------------------------ 21 | `/`_ This page. 22 | `/ip`_ Returns Origin IP. 23 | `/user-agent`_ Returns user-agent. 24 | `/headers`_ Returns header dict. 25 | `/get`_ Returns GET data. 26 | `/post` Returns POST data. 27 | `/patch` Returns PATCH data. 28 | `/put` Returns PUT data. 29 | `/delete` Returns DELETE data 30 | `/gzip`_ Returns gzip-encoded data. 31 | `/deflate`_ Returns deflate-encoded data. 32 | `/status/:code`_ Returns given HTTP Status code. 33 | `/response-headers`_ Returns given response headers. 34 | `/redirect/:n`_ 302 Redirects *n* times. 35 | `/redirect-to?url=foo`_ 302 Redirects to the *foo* URL. 36 | `/relative-redirect/:n`_ 302 Relative redirects *n* times. 37 | `/cookies`_ Returns cookie data. 38 | `/cookies/set?name=value`_ Sets one or more simple cookies. 39 | `/cookies/delete?name`_ Deletes one or more simple cookies. 40 | `/basic-auth/:user/:passwd`_ Challenges HTTPBasic Auth. 41 | `/hidden-basic-auth/:user/:passwd`_ 404'd BasicAuth. 42 | `/digest-auth/:qop/:user/:passwd`_ Challenges HTTP Digest Auth. 43 | `/stream/:n`_ Streams *n* - 100 lines. 44 | `/delay/:n`_ Delays responding for *n* - 10 seconds. 45 | `/drip`_ Drips data over a duration after an optional initial delay, then (optionally) returns with the given status code. 46 | `/range/:n`_ Streams *n* bytes, and allows specifying a *Range* header to select a subset of the data. Accepts a *chunk\_size* and request *duration* parameter. 47 | `/html`_ Renders an HTML Page. 48 | `/robots.txt`_ Returns some robots.txt rules. 49 | `/deny`_ Denied by robots.txt file. 50 | `/cache`_ Returns 200 unless an If-Modified-Since or If-None-Match header is provided, when it returns a 304. 51 | `/cache/:n`_ Sets a Cache-Control header for *n* seconds. 52 | `/bytes/:n`_ Generates *n* random bytes of binary data, accepts optional *seed* integer parameter. 53 | `/stream-bytes/:n`_ Streams *n* random bytes of binary data, accepts optional *seed* and *chunk\_size* integer parameters. 54 | `/links/:n`_ Returns page containing *n* HTML links. 55 | `/forms/post`_ HTML form that submits to */post* 56 | `/xml`_ Returns some XML 57 | `/encoding/utf8`_ Returns page containing UTF-8 data. 58 | ====================================== ================================================================================================================== 59 | 60 | .. _/user-agent: http://httpbin.org/user-agent 61 | .. _/headers: http://httpbin.org/headers 62 | .. _/get: http://httpbin.org/get 63 | .. _/: http://httpbin.org/ 64 | .. _/ip: http://httpbin.org/ip 65 | .. _/gzip: http://httpbin.org/gzip 66 | .. _/deflate: http://httpbin.org/deflate 67 | .. _/status/:code: http://httpbin.org/status/418 68 | .. _/response-headers: http://httpbin.org/response-headers?Content-Type=text/plain;%20charset=UTF-8&Server=httpbin 69 | .. _/redirect/:n: http://httpbin.org/redirect/6 70 | .. _/redirect-to?url=foo: http://httpbin.org/redirect-to?url=http://example.com/ 71 | .. _/relative-redirect/:n: http://httpbin.org/relative-redirect/6 72 | .. _/cookies: http://httpbin.org/cookies 73 | .. _/cookies/set?name=value: http://httpbin.org/cookies/set?k1=v1&k2=v2 74 | .. _/cookies/delete?name: http://httpbin.org/cookies/delete?k1&k2 75 | .. _/basic-auth/:user/:passwd: http://httpbin.org/basic-auth/user/passwd 76 | .. _/hidden-basic-auth/:user/:passwd: http://httpbin.org/hidden-basic-auth/user/passwd 77 | .. _/digest-auth/:qop/:user/:passwd: http://httpbin.org/digest-auth/auth/user/passwd 78 | .. _/stream/:n: http://httpbin.org/stream/20 79 | .. _/delay/:n: http://httpbin.org/delay/3 80 | .. _/drip: http://httpbin.org/drip?numbytes=5&duration=5&code=200 81 | .. _/range/:n: http://httpbin.org/range/1024 82 | .. _/html: http://httpbin.org/html 83 | .. _/robots.txt: http://httpbin.org/robots.txt 84 | .. _/deny: http://httpbin.org/deny 85 | .. _/cache: http://httpbin.org/cache 86 | .. _/cache/:n: http://httpbin.org/cache/60 87 | .. _/bytes/:n: http://httpbin.org/bytes/1024 88 | .. _/stream-bytes/:n: http://httpbin.org/stream-bytes/1024 89 | .. _/links/:n: http://httpbin.org/links/10 90 | .. _/forms/post: http://httpbin.org/forms/post 91 | .. _/xml: http://httpbin.org/xml 92 | .. _/encoding/utf8: http://httpbin.org/encoding/utf8 93 | 94 | 95 | DESCRIPTION 96 | ----------- 97 | 98 | Testing an HTTP Library can become difficult sometimes. 99 | `RequestBin `__ is fantastic for testing POST 100 | requests, but doesn't let you control the response. This exists to cover 101 | all kinds of HTTP scenarios. Additional endpoints are being considered. 102 | 103 | All endpoint responses are JSON-encoded. 104 | 105 | EXAMPLES 106 | -------- 107 | 108 | $ curl http://httpbin.org/ip 109 | ~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 110 | 111 | :: 112 | 113 | {"origin": "24.127.96.129"} 114 | 115 | $ curl http://httpbin.org/user-agent 116 | ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 117 | 118 | :: 119 | 120 | {"user-agent": "curl/7.19.7 (universal-apple-darwin10.0) libcurl/7.19.7 OpenSSL/0.9.8l zlib/1.2.3"} 121 | 122 | $ curl http://httpbin.org/get 123 | ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 124 | 125 | :: 126 | 127 | { 128 | "args": {}, 129 | "headers": { 130 | "Accept": "*/*", 131 | "Connection": "close", 132 | "Content-Length": "", 133 | "Content-Type": "", 134 | "Host": "httpbin.org", 135 | "User-Agent": "curl/7.19.7 (universal-apple-darwin10.0) libcurl/7.19.7 OpenSSL/0.9.8l zlib/1.2.3" 136 | }, 137 | "origin": "24.127.96.129", 138 | "url": "http://httpbin.org/get" 139 | } 140 | 141 | $ curl -I http://httpbin.org/status/418 142 | ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 143 | 144 | :: 145 | 146 | HTTP/1.1 418 I'M A TEAPOT 147 | Server: nginx/0.7.67 148 | Date: Mon, 13 Jun 2011 04:25:38 GMT 149 | Connection: close 150 | x-more-info: http://tools.ietf.org/html/rfc2324 151 | Content-Length: 135 152 | 153 | $ curl https://httpbin.org/get?show\_env=1 154 | ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 155 | 156 | :: 157 | 158 | { 159 | "headers": { 160 | "Content-Length": "", 161 | "Accept-Language": "en-US,en;q=0.8", 162 | "Accept-Encoding": "gzip,deflate,sdch", 163 | "X-Forwarded-Port": "443", 164 | "X-Forwarded-For": "109.60.101.240", 165 | "Host": "httpbin.org", 166 | "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", 167 | "User-Agent": "Mozilla/5.0 (X11; Linux i686) AppleWebKit/535.11 (KHTML, like Gecko) Chrome/17.0.963.83 Safari/535.11", 168 | "X-Request-Start": "1350053933441", 169 | "Accept-Charset": "ISO-8859-1,utf-8;q=0.7,*;q=0.3", 170 | "Connection": "keep-alive", 171 | "X-Forwarded-Proto": "https", 172 | "Cookie": "_gauges_unique_day=1; _gauges_unique_month=1; _gauges_unique_year=1; _gauges_unique=1; _gauges_unique_hour=1", 173 | "Content-Type": "" 174 | }, 175 | "args": { 176 | "show_env": "1" 177 | }, 178 | "origin": "109.60.101.240", 179 | "url": "http://httpbin.org/get?show_env=1" 180 | } 181 | 182 | Installing and running from PyPI 183 | -------------------------------- 184 | 185 | You can install httpbin as a library from PyPI and run it as a WSGI app. 186 | For example, using Gunicorn: 187 | 188 | .. code:: bash 189 | 190 | $ pip install httpbin 191 | $ gunicorn httpbin:app 192 | 193 | Or run it directly: 194 | 195 | .. code:: bash 196 | 197 | $ python -m httpbin.core 198 | 199 | Changelog 200 | --------- 201 | - 0.4.1: Added floating-point support for /delay endpoint 202 | - 0.4.0: New /image/svg endpoint, add deploy to heroku button, add 406 response to /image, and don't always emit the transfer-encoding header for stream endpoint. 203 | - 0.3.0: A number of new features, including a /range endpoint, lots of 204 | bugfixes, and a /encoding/utf8 endpoint 205 | - 0.2.0: Added an XML endpoint. Also fixes several bugs with unicode, 206 | CORS headers, digest auth, and more. 207 | - 0.1.2: Fix a couple Python3 bugs with the random byte endpoints, fix 208 | a bug when uploading files without a Content-Type header set. 209 | - 0.1.1: Added templates as data in setup.py 210 | - 0.1.0: Added python3 support and (re)publish on PyPI 211 | 212 | AUTHOR 213 | ------ 214 | 215 | A `Runscope Community Project `__. 216 | Originally created by `Kenneth Reitz `__. 217 | 218 | SEE ALSO 219 | -------- 220 | 221 | - https://www.hurl.it 222 | - http://requestb.in 223 | - http://python-requests.org 224 | 225 | .. |Build Status| image:: https://travis-ci.org/mozillazg/bustard-httpbin.svg?branch=bustard 226 | :target: https://travis-ci.org/mozillazg/bustard-httpbin 227 | -------------------------------------------------------------------------------- /tests/httpbin/__init__.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | from .core import * # noqa 4 | -------------------------------------------------------------------------------- /tests/httpbin/filters.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | """ 4 | httpbin.filters 5 | ~~~~~~~~~~~~~~~ 6 | 7 | This module provides response filter decorators. 8 | """ 9 | 10 | import gzip as gzip2 11 | import zlib 12 | 13 | from six import BytesIO 14 | from decimal import Decimal 15 | from time import time as now 16 | 17 | from bustard.http import Response 18 | from decorator import decorator 19 | 20 | 21 | @decorator 22 | def x_runtime(f, *args, **kwargs): 23 | """X-Runtime Flask Response Decorator.""" 24 | 25 | _t0 = now() 26 | r = f(*args, **kwargs) 27 | _t1 = now() 28 | r.headers['X-Runtime'] = '{0}s'.format(Decimal(str(_t1 - _t0))) 29 | 30 | return r 31 | 32 | 33 | @decorator 34 | def gzip(f, *args, **kwargs): 35 | """GZip Flask Response Decorator.""" 36 | 37 | data = f(*args, **kwargs) 38 | 39 | if isinstance(data, Response): 40 | content = data.data 41 | else: 42 | content = data 43 | 44 | gzip_buffer = BytesIO() 45 | gzip_file = gzip2.GzipFile( 46 | mode='wb', 47 | compresslevel=4, 48 | fileobj=gzip_buffer 49 | ) 50 | gzip_file.write(content) 51 | gzip_file.close() 52 | 53 | gzip_data = gzip_buffer.getvalue() 54 | 55 | if isinstance(data, Response): 56 | data.data = gzip_data 57 | data.headers['Content-Encoding'] = 'gzip' 58 | data.headers['Content-Length'] = str(len(data.data)) 59 | 60 | return data 61 | 62 | return gzip_data 63 | 64 | 65 | @decorator 66 | def deflate(f, *args, **kwargs): 67 | """Deflate Flask Response Decorator.""" 68 | 69 | data = f(*args, **kwargs) 70 | 71 | if isinstance(data, Response): 72 | content = data.data 73 | else: 74 | content = data 75 | 76 | deflater = zlib.compressobj() 77 | deflated_data = deflater.compress(content) 78 | deflated_data += deflater.flush() 79 | 80 | if isinstance(data, Response): 81 | data.data = deflated_data 82 | data.headers['Content-Encoding'] = 'deflate' 83 | data.headers['Content-Length'] = str(len(data.data)) 84 | 85 | return data 86 | 87 | return deflated_data 88 | -------------------------------------------------------------------------------- /tests/httpbin/helpers.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | """ 4 | httpbin.helpers 5 | ~~~~~~~~~~~~~~~ 6 | 7 | This module provides helper functions for httpbin. 8 | """ 9 | 10 | import json 11 | import base64 12 | from hashlib import md5 13 | from werkzeug.http import parse_authorization_header 14 | 15 | from bustard.http import Response 16 | from six.moves.urllib.parse import urlparse, urlunparse 17 | 18 | 19 | from .structures import CaseInsensitiveDict 20 | 21 | 22 | ASCII_ART = """ 23 | -=[ teapot ]=- 24 | 25 | _...._ 26 | .' _ _ `. 27 | | ."` ^ `". _, 28 | \_;`"---"`|// 29 | | ;/ 30 | \_ _/ 31 | `\"\"\"` 32 | """ 33 | 34 | REDIRECT_LOCATION = '/redirect/1' 35 | 36 | ENV_HEADERS = ( 37 | 'X-Varnish', 38 | 'X-Request-Start', 39 | 'X-Heroku-Queue-Depth', 40 | 'X-Real-Ip', 41 | 'X-Forwarded-Proto', 42 | 'X-Forwarded-Protocol', 43 | 'X-Forwarded-Ssl', 44 | 'X-Heroku-Queue-Wait-Time', 45 | 'X-Forwarded-For', 46 | 'X-Heroku-Dynos-In-Use', 47 | 'X-Forwarded-For', 48 | 'X-Forwarded-Protocol', 49 | 'X-Forwarded-Port', 50 | 'Runscope-Service' 51 | ) 52 | 53 | ROBOT_TXT = """User-agent: * 54 | Disallow: /deny 55 | """ 56 | 57 | ACCEPTED_MEDIA_TYPES = [ 58 | 'image/webp', 59 | 'image/svg+xml', 60 | 'image/jpeg', 61 | 'image/png', 62 | 'image/*' 63 | ] 64 | 65 | ANGRY_ASCII = """ 66 | .-''''''-. 67 | .' _ _ '. 68 | / O O \\ 69 | : : 70 | | | 71 | : __ : 72 | \ .-"` `"-. / 73 | '. .' 74 | '-......-' 75 | YOU SHOULDN'T BE HERE 76 | """ 77 | 78 | 79 | def json_safe(string, content_type='application/octet-stream'): 80 | """Returns JSON-safe version of `string`. 81 | 82 | If `string` is a Unicode string or a valid UTF-8, 83 | it is returned unmodified, 84 | as it can safely be encoded to JSON string. 85 | 86 | If `string` contains raw/binary data, it is Base64-encoded, formatted and 87 | returned according to "data" URL scheme (RFC2397). Since JSON is not 88 | suitable for binary data, some additional encoding was necessary; "data" 89 | URL scheme was chosen for its simplicity. 90 | """ 91 | try: 92 | string = string.decode('utf-8') 93 | json.dumps(string) 94 | return string 95 | except (ValueError, TypeError): 96 | return b''.join([ 97 | b'data:', 98 | content_type.encode('utf-8'), 99 | b';base64,', 100 | base64.b64encode(string) 101 | ]).decode('utf-8') 102 | 103 | 104 | def get_files(request): 105 | """Returns files dict from request context.""" 106 | 107 | files = dict() 108 | 109 | for k, v in request.files.items(): 110 | content_type = ( 111 | request.files[k].content_type or 'application/octet-stream' 112 | ) 113 | val = json_safe(v.read(), content_type) 114 | if files.get(k): 115 | if not isinstance(files[k], list): 116 | files[k] = [files[k]] 117 | files[k].append(val) 118 | else: 119 | files[k] = val 120 | 121 | return files 122 | 123 | 124 | def get_headers(request, hide_env=True): 125 | """Returns headers dict from request context.""" 126 | 127 | headers = dict(request.headers.items()) 128 | 129 | if hide_env and ('show_env' not in request.args): 130 | for key in ENV_HEADERS: 131 | try: 132 | del headers[key] 133 | except KeyError: 134 | pass 135 | 136 | return CaseInsensitiveDict(headers.items()) 137 | 138 | 139 | def semiflatten(multi): 140 | """Convert a MutiDict into a regular dict. If there are more than one value 141 | for a key, the result will have a list of values for the key. Otherwise it 142 | will have the plain value.""" 143 | if multi: 144 | result = multi.to_dict() 145 | for k, v in result.items(): 146 | if len(v) == 1: 147 | result[k] = v[0] 148 | return result 149 | else: 150 | return multi 151 | 152 | 153 | def get_url(request): 154 | """ 155 | Since we might be hosted behind a proxy, we need to check the 156 | X-Forwarded-Proto, X-Forwarded-Protocol, or X-Forwarded-SSL headers 157 | to find out what protocol was used to access us. 158 | """ 159 | protocol = ( 160 | request.headers.get('X-Forwarded-Proto') or 161 | request.headers.get('X-Forwarded-Protocol') 162 | ) 163 | if protocol is None and request.headers.get('X-Forwarded-Ssl') == 'on': 164 | protocol = 'https' 165 | if protocol is None: 166 | return request.url 167 | url = list(urlparse(request.url)) 168 | url[0] = protocol 169 | return urlunparse(url) 170 | 171 | 172 | def get_dict(request, *keys, **extras): 173 | """Returns request dict of given keys.""" 174 | 175 | _keys = ('url', 'args', 'form', 'data', 'origin', 'headers', 176 | 'files', 'json') 177 | 178 | assert all(map(_keys.__contains__, keys)) 179 | data = request.data 180 | form = request.form 181 | form = semiflatten(request.form) 182 | 183 | try: 184 | _json = json.loads(data.decode('utf-8')) 185 | except (ValueError, TypeError): 186 | _json = None 187 | 188 | d = dict( 189 | url=get_url(request), 190 | args=semiflatten(request.args), 191 | form=form, 192 | data=json_safe(data), 193 | origin=request.headers.get('X-Forwarded-For', request.remote_addr), 194 | headers=get_headers(request), 195 | files=get_files(request), 196 | json=_json 197 | ) 198 | 199 | out_d = dict() 200 | 201 | for key in keys: 202 | out_d[key] = d.get(key) 203 | 204 | out_d.update(extras) 205 | 206 | return out_d 207 | 208 | 209 | def status_code(code): 210 | """Returns response object of given status code.""" 211 | 212 | redirect = dict(headers=dict(location=REDIRECT_LOCATION)) 213 | 214 | code_map = { 215 | 301: redirect, 216 | 302: redirect, 217 | 303: redirect, 218 | 304: dict(data=''), 219 | 305: redirect, 220 | 307: redirect, 221 | 401: dict(headers={'WWW-Authenticate': 'Basic realm="Fake Realm"'}), 222 | 402: dict( 223 | data='Fuck you, pay me!', 224 | headers={ 225 | 'x-more-info': 'http://vimeo.com/22053820' 226 | } 227 | ), 228 | 406: dict(data=json.dumps({ 229 | 'message': 'Client did not request a supported media type.', 230 | 'accept': ACCEPTED_MEDIA_TYPES 231 | }), 232 | headers={ 233 | 'Content-Type': 'application/json' 234 | }), 235 | 407: dict(headers={'Proxy-Authenticate': 'Basic realm="Fake Realm"'}), 236 | 418: dict( # I'm a teapot! 237 | data=ASCII_ART, 238 | headers={ 239 | 'x-more-info': 'http://tools.ietf.org/html/rfc2324' 240 | } 241 | ), 242 | 243 | } 244 | 245 | r = Response() 246 | r.status_code = code 247 | 248 | if code in code_map: 249 | 250 | m = code_map[code] 251 | 252 | if 'data' in m: 253 | r.data = m['data'] 254 | if 'headers' in m: 255 | r.headers = m['headers'] 256 | 257 | return r 258 | 259 | 260 | def check_basic_auth(request, user, passwd): 261 | """Checks user authentication using HTTP Basic Auth.""" 262 | 263 | auth = request.authorization 264 | return auth and auth.username == user and auth.password == passwd 265 | 266 | # Digest auth helpers 267 | # qop is a quality of protection 268 | 269 | 270 | def H(data): 271 | return md5(data).hexdigest() 272 | 273 | 274 | def HA1(realm, username, password): 275 | """Create HA1 hash by realm, username, password 276 | 277 | HA1 = md5(A1) = MD5(username:realm:password) 278 | """ 279 | if not realm: 280 | realm = u'' 281 | return H(b':'.join([username.encode('utf-8'), 282 | realm.encode('utf-8'), 283 | password.encode('utf-8')])) 284 | 285 | 286 | def HA2(credentails, request): 287 | """Create HA2 md5 hash 288 | 289 | If the qop directive's value is "auth" or is unspecified, then HA2: 290 | HA2 = md5(A2) = MD5(method:digestURI) 291 | If the qop directive's value is "auth-int" , then HA2 is 292 | HA2 = md5(A2) = MD5(method:digestURI:MD5(entityBody)) 293 | """ 294 | if credentails.get('qop') == 'auth' or credentails.get('qop') is None: 295 | return H(b':'.join([request['method'].encode('utf-8'), 296 | request['uri'].encode('utf-8')])) 297 | elif credentails.get('qop') == 'auth-int': 298 | for k in 'method', 'uri', 'body': 299 | if k not in request: 300 | raise ValueError('%s required' % k) 301 | return H('%s:%s:%s' % (request['method'], 302 | request['uri'], 303 | H(request['body']))) 304 | raise ValueError 305 | 306 | 307 | def response(credentails, password, request): 308 | """Compile digest auth response 309 | 310 | If the qop directive's value is "auth" or "auth-int" , 311 | then compute the response as follows: 312 | RESPONSE = MD5(HA1:nonce:nonceCount:clienNonce:qop:HA2) 313 | Else if the qop directive is unspecified, 314 | then compute the response as follows: 315 | RESPONSE = MD5(HA1:nonce:HA2) 316 | 317 | Arguments: 318 | - `credentails`: credentails dict 319 | - `password`: request user password 320 | - `request`: request dict 321 | """ 322 | response = None 323 | HA1_value = HA1( 324 | credentails.get('realm'), 325 | credentails.get('username'), 326 | password 327 | ) 328 | HA2_value = HA2(credentails, request) 329 | if credentails.get('qop') is None: 330 | response = H(b':'.join([ 331 | HA1_value.encode('utf-8'), 332 | credentails.get('nonce', '').encode('utf-8'), 333 | HA2_value.encode('utf-8') 334 | ])) 335 | elif (credentails.get('qop') == 'auth' or 336 | credentails.get('qop') == 'auth-int'): 337 | for k in 'nonce', 'nc', 'cnonce', 'qop': 338 | if k not in credentails: 339 | raise ValueError('%s required for response H' % k) 340 | response = H(b':'.join([HA1_value.encode('utf-8'), 341 | credentails.get('nonce').encode('utf-8'), 342 | credentails.get('nc').encode('utf-8'), 343 | credentails.get('cnonce').encode('utf-8'), 344 | credentails.get('qop').encode('utf-8'), 345 | HA2_value.encode('utf-8')])) 346 | else: 347 | raise ValueError('qop value are wrong') 348 | 349 | return response 350 | 351 | 352 | def check_digest_auth(request, user, passwd): 353 | """Check user authentication using HTTP Digest auth""" 354 | 355 | if request.headers.get('Authorization'): 356 | credentails = parse_authorization_header( 357 | request.headers.get('Authorization') 358 | ) 359 | if not credentails: 360 | return 361 | response_hash = response(credentails, passwd, 362 | dict(uri=request.script_root + request.path, 363 | body=request.data, 364 | method=request.method)) 365 | if credentails.get('response') == response_hash: 366 | return True 367 | return False 368 | 369 | 370 | def secure_cookie(request): 371 | """Return true if cookie should have secure attribute""" 372 | return request.environ['wsgi.url_scheme'] == 'https' 373 | 374 | 375 | def __parse_request_range(range_header_text): 376 | """ Return a tuple describing the byte range requested in a GET request 377 | If the range is open ended on the left or right side, then a value of None 378 | will be set. 379 | RFC7233: 380 | http://svn.tools.ietf.org/svn/wg/httpbis/specs/rfc7233.html#header.range 381 | Examples: 382 | Range : bytes=1024- 383 | Range : bytes=10-20 384 | Range : bytes=-999 385 | """ 386 | 387 | left = None 388 | right = None 389 | 390 | if not range_header_text: 391 | return left, right 392 | 393 | range_header_text = range_header_text.strip() 394 | if not range_header_text.startswith('bytes'): 395 | return left, right 396 | 397 | components = range_header_text.split('=') 398 | if len(components) != 2: 399 | return left, right 400 | 401 | components = components[1].split('-') 402 | 403 | try: 404 | right = int(components[1]) 405 | except: 406 | pass 407 | 408 | try: 409 | left = int(components[0]) 410 | except: 411 | pass 412 | 413 | return left, right 414 | 415 | 416 | def get_request_range(request_headers, upper_bound): 417 | first_byte_pos, last_byte_pos = __parse_request_range( 418 | request_headers['range'] 419 | ) 420 | 421 | if first_byte_pos is None and last_byte_pos is None: 422 | # Request full range 423 | first_byte_pos = 0 424 | last_byte_pos = upper_bound - 1 425 | elif first_byte_pos is None: 426 | # Request the last X bytes 427 | first_byte_pos = max(0, upper_bound - last_byte_pos) 428 | last_byte_pos = upper_bound - 1 429 | elif last_byte_pos is None: 430 | # Request the last X bytes 431 | last_byte_pos = upper_bound - 1 432 | 433 | return first_byte_pos, last_byte_pos 434 | -------------------------------------------------------------------------------- /tests/httpbin/requirements.txt: -------------------------------------------------------------------------------- 1 | bustard 2 | decorator==3.4.0 3 | # gevent==1.0.2 4 | # gunicorn==19.2 5 | six==1.6.1 6 | Werkzeug==0.9.4 7 | -------------------------------------------------------------------------------- /tests/httpbin/structures.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | """ 4 | httpbin.structures 5 | ~~~~~~~~~~~~~~~~~~~ 6 | 7 | Data structures that power httpbin. 8 | """ 9 | 10 | 11 | class CaseInsensitiveDict(dict): 12 | """Case-insensitive Dictionary for headers. 13 | 14 | For example, ``headers['content-encoding']`` will return the 15 | value of a ``'Content-Encoding'`` response header. 16 | """ 17 | 18 | def _lower_keys(self): 19 | return [str.lower(k) for k in self.keys()] 20 | 21 | def __contains__(self, key): 22 | return key.lower() in self._lower_keys() 23 | 24 | def __getitem__(self, key): 25 | # We allow fall-through here, so values default to None 26 | if key in self: 27 | return list(self.items())[self._lower_keys().index(key.lower())][1] 28 | -------------------------------------------------------------------------------- /tests/httpbin/templates/UTF-8-demo.txt: -------------------------------------------------------------------------------- 1 |

Unicode Demo

2 | 3 |

Taken from http://www.cl.cam.ac.uk/~mgk25/ucs/examples/UTF-8-demo.txt

5 | 6 |
  7 | 
  8 | UTF-8 encoded sample plain-text file
  9 | ‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾
 10 | 
 11 | Markus Kuhn [ˈmaʳkʊs kuːn]  — 2002-07-25
 12 | 
 13 | 
 14 | The ASCII compatible UTF-8 encoding used in this plain-text file
 15 | is defined in Unicode, ISO 10646-1, and RFC 2279.
 16 | 
 17 | 
 18 | Using Unicode/UTF-8, you can write in emails and source code things such as
 19 | 
 20 | Mathematics and sciences:
 21 | 
 22 |   ∮ E⋅da = Q,  n → ∞, ∑ f(i) = ∏ g(i),      ⎧⎡⎛┌─────┐⎞⎤⎫
 23 |                                             ⎪⎢⎜│a²+b³ ⎟⎥⎪
 24 |   ∀x∈ℝ: ⌈x⌉ = −⌊−x⌋, α ∧ ¬β = ¬(¬α ∨ β),    ⎪⎢⎜│───── ⎟⎥⎪
 25 |                                             ⎪⎢⎜⎷ c₈   ⎟⎥⎪
 26 |   ℕ ⊆ ℕ₀ ⊂ ℤ ⊂ ℚ ⊂ ℝ ⊂ ℂ,                   ⎨⎢⎜       ⎟⎥⎬
 27 |                                             ⎪⎢⎜ ∞     ⎟⎥⎪
 28 |   ⊥ < a ≠ b ≡ c ≤ d ≪ ⊤ ⇒ (⟦A⟧ ⇔ ⟪B⟫),      ⎪⎢⎜ ⎲     ⎟⎥⎪
 29 |                                             ⎪⎢⎜ ⎳aⁱ-bⁱ⎟⎥⎪
 30 |   2H₂ + O₂ ⇌ 2H₂O, R = 4.7 kΩ, ⌀ 200 mm     ⎩⎣⎝i=1    ⎠⎦⎭
 31 | 
 32 | Linguistics and dictionaries:
 33 | 
 34 |   ði ıntəˈnæʃənəl fəˈnɛtık əsoʊsiˈeıʃn
 35 |   Y [ˈʏpsilɔn], Yen [jɛn], Yoga [ˈjoːgɑ]
 36 | 
 37 | APL:
 38 | 
 39 |   ((V⍳V)=⍳⍴V)/V←,V    ⌷←⍳→⍴∆∇⊃‾⍎⍕⌈
 40 | 
 41 | Nicer typography in plain text files:
 42 | 
 43 |   ╔══════════════════════════════════════════╗
 44 |   ║                                          ║
 45 |   ║   • ‘single’ and “double” quotes         ║
 46 |   ║                                          ║
 47 |   ║   • Curly apostrophes: “We’ve been here” ║
 48 |   ║                                          ║
 49 |   ║   • Latin-1 apostrophe and accents: '´`  ║
 50 |   ║                                          ║
 51 |   ║   • ‚deutsche‘ „Anführungszeichen“       ║
 52 |   ║                                          ║
 53 |   ║   • †, ‡, ‰, •, 3–4, —, −5/+5, ™, …      ║
 54 |   ║                                          ║
 55 |   ║   • ASCII safety test: 1lI|, 0OD, 8B     ║
 56 |   ║                      ╭─────────╮         ║
 57 |   ║   • the euro symbol: │ 14.95 € │         ║
 58 |   ║                      ╰─────────╯         ║
 59 |   ╚══════════════════════════════════════════╝
 60 | 
 61 | Combining characters:
 62 | 
 63 |   STARGΛ̊TE SG-1, a = v̇ = r̈, a⃑ ⊥ b⃑
 64 | 
 65 | Greek (in Polytonic):
 66 | 
 67 |   The Greek anthem:
 68 | 
 69 |   Σὲ γνωρίζω ἀπὸ τὴν κόψη
 70 |   τοῦ σπαθιοῦ τὴν τρομερή,
 71 |   σὲ γνωρίζω ἀπὸ τὴν ὄψη
 72 |   ποὺ μὲ βία μετράει τὴ γῆ.
 73 | 
 74 |   ᾿Απ᾿ τὰ κόκκαλα βγαλμένη
 75 |   τῶν ῾Ελλήνων τὰ ἱερά
 76 |   καὶ σὰν πρῶτα ἀνδρειωμένη
 77 |   χαῖρε, ὦ χαῖρε, ᾿Ελευθεριά!
 78 | 
 79 |   From a speech of Demosthenes in the 4th century BC:
 80 | 
 81 |   Οὐχὶ ταὐτὰ παρίσταταί μοι γιγνώσκειν, ὦ ἄνδρες ᾿Αθηναῖοι,
 82 |   ὅταν τ᾿ εἰς τὰ πράγματα ἀποβλέψω καὶ ὅταν πρὸς τοὺς
 83 |   λόγους οὓς ἀκούω· τοὺς μὲν γὰρ λόγους περὶ τοῦ
 84 |   τιμωρήσασθαι Φίλιππον ὁρῶ γιγνομένους, τὰ δὲ πράγματ᾿
 85 |   εἰς τοῦτο προήκοντα,  ὥσθ᾿ ὅπως μὴ πεισόμεθ᾿ αὐτοὶ
 86 |   πρότερον κακῶς σκέψασθαι δέον. οὐδέν οὖν ἄλλο μοι δοκοῦσιν
 87 |   οἱ τὰ τοιαῦτα λέγοντες ἢ τὴν ὑπόθεσιν, περὶ ἧς βουλεύεσθαι,
 88 |   οὐχὶ τὴν οὖσαν παριστάντες ὑμῖν ἁμαρτάνειν. ἐγὼ δέ, ὅτι μέν
 89 |   ποτ᾿ ἐξῆν τῇ πόλει καὶ τὰ αὑτῆς ἔχειν ἀσφαλῶς καὶ Φίλιππον
 90 |   τιμωρήσασθαι, καὶ μάλ᾿ ἀκριβῶς οἶδα· ἐπ᾿ ἐμοῦ γάρ, οὐ πάλαι
 91 |   γέγονεν ταῦτ᾿ ἀμφότερα· νῦν μέντοι πέπεισμαι τοῦθ᾿ ἱκανὸν
 92 |   προλαβεῖν ἡμῖν εἶναι τὴν πρώτην, ὅπως τοὺς συμμάχους
 93 |   σώσομεν. ἐὰν γὰρ τοῦτο βεβαίως ὑπάρξῃ, τότε καὶ περὶ τοῦ
 94 |   τίνα τιμωρήσεταί τις καὶ ὃν τρόπον ἐξέσται σκοπεῖν· πρὶν δὲ
 95 |   τὴν ἀρχὴν ὀρθῶς ὑποθέσθαι, μάταιον ἡγοῦμαι περὶ τῆς
 96 |   τελευτῆς ὁντινοῦν ποιεῖσθαι λόγον.
 97 | 
 98 |   Δημοσθένους, Γ´ ᾿Ολυνθιακὸς
 99 | 
100 | Georgian:
101 | 
102 |   From a Unicode conference invitation:
103 | 
104 |   გთხოვთ ახლავე გაიაროთ რეგისტრაცია Unicode-ის მეათე საერთაშორისო
105 |   კონფერენციაზე დასასწრებად, რომელიც გაიმართება 10-12 მარტს,
106 |   ქ. მაინცში, გერმანიაში. კონფერენცია შეჰკრებს ერთად მსოფლიოს
107 |   ექსპერტებს ისეთ დარგებში როგორიცაა ინტერნეტი და Unicode-ი,
108 |   ინტერნაციონალიზაცია და ლოკალიზაცია, Unicode-ის გამოყენება
109 |   ოპერაციულ სისტემებსა, და გამოყენებით პროგრამებში, შრიფტებში,
110 |   ტექსტების დამუშავებასა და მრავალენოვან კომპიუტერულ სისტემებში.
111 | 
112 | Russian:
113 | 
114 |   From a Unicode conference invitation:
115 | 
116 |   Зарегистрируйтесь сейчас на Десятую Международную Конференцию по
117 |   Unicode, которая состоится 10-12 марта 1997 года в Майнце в Германии.
118 |   Конференция соберет широкий круг экспертов по  вопросам глобального
119 |   Интернета и Unicode, локализации и интернационализации, воплощению и
120 |   применению Unicode в различных операционных системах и программных
121 |   приложениях, шрифтах, верстке и многоязычных компьютерных системах.
122 | 
123 | Thai (UCS Level 2):
124 | 
125 |   Excerpt from a poetry on The Romance of The Three Kingdoms (a Chinese
126 |   classic 'San Gua'):
127 | 
128 |   [----------------------------|------------------------]
129 |     ๏ แผ่นดินฮั่นเสื่อมโทรมแสนสังเวช  พระปกเกศกองบู๊กู้ขึ้นใหม่
130 |   สิบสองกษัตริย์ก่อนหน้าแลถัดไป       สององค์ไซร้โง่เขลาเบาปัญญา
131 |     ทรงนับถือขันทีเป็นที่พึ่ง           บ้านเมืองจึงวิปริตเป็นนักหนา
132 |   โฮจิ๋นเรียกทัพทั่วหัวเมืองมา         หมายจะฆ่ามดชั่วตัวสำคัญ
133 |     เหมือนขับไสไล่เสือจากเคหา      รับหมาป่าเข้ามาเลยอาสัญ
134 |   ฝ่ายอ้องอุ้นยุแยกให้แตกกัน          ใช้สาวนั้นเป็นชนวนชื่นชวนใจ
135 |     พลันลิฉุยกุยกีกลับก่อเหตุ          ช่างอาเพศจริงหนาฟ้าร้องไห้
136 |   ต้องรบราฆ่าฟันจนบรรลัย           ฤๅหาใครค้ำชูกู้บรรลังก์ ฯ
137 | 
138 |   (The above is a two-column text. If combining characters are handled
139 |   correctly, the lines of the second column should be aligned with the
140 |   | character above.)
141 | 
142 | Ethiopian:
143 | 
144 |   Proverbs in the Amharic language:
145 | 
146 |   ሰማይ አይታረስ ንጉሥ አይከሰስ።
147 |   ብላ ካለኝ እንደአባቴ በቆመጠኝ።
148 |   ጌጥ ያለቤቱ ቁምጥና ነው።
149 |   ደሀ በሕልሙ ቅቤ ባይጠጣ ንጣት በገደለው።
150 |   የአፍ ወለምታ በቅቤ አይታሽም።
151 |   አይጥ በበላ ዳዋ ተመታ።
152 |   ሲተረጉሙ ይደረግሙ።
153 |   ቀስ በቀስ፥ ዕንቁላል በእግሩ ይሄዳል።
154 |   ድር ቢያብር አንበሳ ያስር።
155 |   ሰው እንደቤቱ እንጅ እንደ ጉረቤቱ አይተዳደርም።
156 |   እግዜር የከፈተውን ጉሮሮ ሳይዘጋው አይድርም።
157 |   የጎረቤት ሌባ፥ ቢያዩት ይስቅ ባያዩት ያጠልቅ።
158 |   ሥራ ከመፍታት ልጄን ላፋታት።
159 |   ዓባይ ማደሪያ የለው፥ ግንድ ይዞ ይዞራል።
160 |   የእስላም አገሩ መካ የአሞራ አገሩ ዋርካ።
161 |   ተንጋሎ ቢተፉ ተመልሶ ባፉ።
162 |   ወዳጅህ ማር ቢሆን ጨርስህ አትላሰው።
163 |   እግርህን በፍራሽህ ልክ ዘርጋ።
164 | 
165 | Runes:
166 | 
167 |   ᚻᛖ ᚳᚹᚫᚦ ᚦᚫᛏ ᚻᛖ ᛒᚢᛞᛖ ᚩᚾ ᚦᚫᛗ ᛚᚪᚾᛞᛖ ᚾᚩᚱᚦᚹᛖᚪᚱᛞᚢᛗ ᚹᛁᚦ ᚦᚪ ᚹᛖᛥᚫ
168 | 
169 |   (Old English, which transcribed into Latin reads 'He cwaeth that he
170 |   bude thaem lande northweardum with tha Westsae.' and means 'He said
171 |   that he lived in the northern land near the Western Sea.')
172 | 
173 | Braille:
174 | 
175 |   ⡌⠁⠧⠑ ⠼⠁⠒  ⡍⠜⠇⠑⠹⠰⠎ ⡣⠕⠌
176 | 
177 |   ⡍⠜⠇⠑⠹ ⠺⠁⠎ ⠙⠑⠁⠙⠒ ⠞⠕ ⠃⠑⠛⠔ ⠺⠊⠹⠲ ⡹⠻⠑ ⠊⠎ ⠝⠕ ⠙⠳⠃⠞
178 |   ⠱⠁⠞⠑⠧⠻ ⠁⠃⠳⠞ ⠹⠁⠞⠲ ⡹⠑ ⠗⠑⠛⠊⠌⠻ ⠕⠋ ⠙⠊⠎ ⠃⠥⠗⠊⠁⠇ ⠺⠁⠎
179 |   ⠎⠊⠛⠝⠫ ⠃⠹ ⠹⠑ ⠊⠇⠻⠛⠹⠍⠁⠝⠂ ⠹⠑ ⠊⠇⠻⠅⠂ ⠹⠑ ⠥⠝⠙⠻⠞⠁⠅⠻⠂
180 |   ⠁⠝⠙ ⠹⠑ ⠡⠊⠑⠋ ⠍⠳⠗⠝⠻⠲ ⡎⠊⠗⠕⠕⠛⠑ ⠎⠊⠛⠝⠫ ⠊⠞⠲ ⡁⠝⠙
181 |   ⡎⠊⠗⠕⠕⠛⠑⠰⠎ ⠝⠁⠍⠑ ⠺⠁⠎ ⠛⠕⠕⠙ ⠥⠏⠕⠝ ⠰⡡⠁⠝⠛⠑⠂ ⠋⠕⠗ ⠁⠝⠹⠹⠔⠛ ⠙⠑
182 |   ⠡⠕⠎⠑ ⠞⠕ ⠏⠥⠞ ⠙⠊⠎ ⠙⠁⠝⠙ ⠞⠕⠲
183 | 
184 |   ⡕⠇⠙ ⡍⠜⠇⠑⠹ ⠺⠁⠎ ⠁⠎ ⠙⠑⠁⠙ ⠁⠎ ⠁ ⠙⠕⠕⠗⠤⠝⠁⠊⠇⠲
185 | 
186 |   ⡍⠔⠙⠖ ⡊ ⠙⠕⠝⠰⠞ ⠍⠑⠁⠝ ⠞⠕ ⠎⠁⠹ ⠹⠁⠞ ⡊ ⠅⠝⠪⠂ ⠕⠋ ⠍⠹
187 |   ⠪⠝ ⠅⠝⠪⠇⠫⠛⠑⠂ ⠱⠁⠞ ⠹⠻⠑ ⠊⠎ ⠏⠜⠞⠊⠊⠥⠇⠜⠇⠹ ⠙⠑⠁⠙ ⠁⠃⠳⠞
188 |   ⠁ ⠙⠕⠕⠗⠤⠝⠁⠊⠇⠲ ⡊ ⠍⠊⠣⠞ ⠙⠁⠧⠑ ⠃⠑⠲ ⠔⠊⠇⠔⠫⠂ ⠍⠹⠎⠑⠇⠋⠂ ⠞⠕
189 |   ⠗⠑⠛⠜⠙ ⠁ ⠊⠕⠋⠋⠔⠤⠝⠁⠊⠇ ⠁⠎ ⠹⠑ ⠙⠑⠁⠙⠑⠌ ⠏⠊⠑⠊⠑ ⠕⠋ ⠊⠗⠕⠝⠍⠕⠝⠛⠻⠹
190 |   ⠔ ⠹⠑ ⠞⠗⠁⠙⠑⠲ ⡃⠥⠞ ⠹⠑ ⠺⠊⠎⠙⠕⠍ ⠕⠋ ⠳⠗ ⠁⠝⠊⠑⠌⠕⠗⠎
191 |   ⠊⠎ ⠔ ⠹⠑ ⠎⠊⠍⠊⠇⠑⠆ ⠁⠝⠙ ⠍⠹ ⠥⠝⠙⠁⠇⠇⠪⠫ ⠙⠁⠝⠙⠎
192 |   ⠩⠁⠇⠇ ⠝⠕⠞ ⠙⠊⠌⠥⠗⠃ ⠊⠞⠂ ⠕⠗ ⠹⠑ ⡊⠳⠝⠞⠗⠹⠰⠎ ⠙⠕⠝⠑ ⠋⠕⠗⠲ ⡹⠳
193 |   ⠺⠊⠇⠇ ⠹⠻⠑⠋⠕⠗⠑ ⠏⠻⠍⠊⠞ ⠍⠑ ⠞⠕ ⠗⠑⠏⠑⠁⠞⠂ ⠑⠍⠏⠙⠁⠞⠊⠊⠁⠇⠇⠹⠂ ⠹⠁⠞
194 |   ⡍⠜⠇⠑⠹ ⠺⠁⠎ ⠁⠎ ⠙⠑⠁⠙ ⠁⠎ ⠁ ⠙⠕⠕⠗⠤⠝⠁⠊⠇⠲
195 | 
196 |   (The first couple of paragraphs of "A Christmas Carol" by Dickens)
197 | 
198 | Compact font selection example text:
199 | 
200 |   ABCDEFGHIJKLMNOPQRSTUVWXYZ /0123456789
201 |   abcdefghijklmnopqrstuvwxyz £©µÀÆÖÞßéöÿ
202 |   –—‘“”„†•…‰™œŠŸž€ ΑΒΓΔΩαβγδω АБВГДабвгд
203 |   ∀∂∈ℝ∧∪≡∞ ↑↗↨↻⇣ ┐┼╔╘░►☺♀ fi�⑀₂ἠḂӥẄɐː⍎אԱა
204 | 
205 | Greetings in various languages:
206 | 
207 |   Hello world, Καλημέρα κόσμε, コンニチハ
208 | 
209 | Box drawing alignment tests:                                          █
210 |                                                                       ▉
211 |   ╔══╦══╗  ┌──┬──┐  ╭──┬──╮  ╭──┬──╮  ┏━━┳━━┓  ┎┒┏┑   ╷  ╻ ┏┯┓ ┌┰┐    ▊ ╱╲╱╲╳╳╳
212 |   ║┌─╨─┐║  │╔═╧═╗│  │╒═╪═╕│  │╓─╁─╖│  ┃┌─╂─┐┃  ┗╃╄┙  ╶┼╴╺╋╸┠┼┨ ┝╋┥    ▋ ╲╱╲╱╳╳╳
213 |   ║│╲ ╱│║  │║   ║│  ││ │ ││  │║ ┃ ║│  ┃│ ╿ │┃  ┍╅╆┓   ╵  ╹ ┗┷┛ └┸┘    ▌ ╱╲╱╲╳╳╳
214 |   ╠╡ ╳ ╞╣  ├╢   ╟┤  ├┼─┼─┼┤  ├╫─╂─╫┤  ┣┿╾┼╼┿┫  ┕┛┖┚     ┌┄┄┐ ╎ ┏┅┅┓ ┋ ▍ ╲╱╲╱╳╳╳
215 |   ║│╱ ╲│║  │║   ║│  ││ │ ││  │║ ┃ ║│  ┃│ ╽ │┃  ░░▒▒▓▓██ ┊  ┆ ╎ ╏  ┇ ┋ ▎
216 |   ║└─╥─┘║  │╚═╤═╝│  │╘═╪═╛│  │╙─╀─╜│  ┃└─╂─┘┃  ░░▒▒▓▓██ ┊  ┆ ╎ ╏  ┇ ┋ ▏
217 |   ╚══╩══╝  └──┴──┘  ╰──┴──╯  ╰──┴──╯  ┗━━┻━━┛  ▗▄▖▛▀▜   └╌╌┘ ╎ ┗╍╍┛ ┋  ▁▂▃▄▅▆▇█
218 |                                                ▝▀▘▙▄▟
219 | 
220 | 
221 | -------------------------------------------------------------------------------- /tests/httpbin/templates/forms-post.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 |
8 |

9 |

10 |

11 |
12 | Pizza Size 13 |

14 |

15 |

16 |
17 |
18 | Pizza Toppings 19 |

20 |

21 |

22 |

23 |
24 |

25 |

26 |

27 |
28 | 29 | 30 | -------------------------------------------------------------------------------- /tests/httpbin/templates/httpbin.1.html: -------------------------------------------------------------------------------- 1 |
2 |

httpbin(1): HTTP Request & Response Service

3 |

Freely hosted in HTTP, HTTPS & EU flavors by Runscope

4 | 5 |

ENDPOINTS

6 | 7 | 52 | 53 | 54 |

DESCRIPTION

55 | 56 |

Testing an HTTP Library can become difficult sometimes. RequestBin is fantastic for testing POST requests, but doesn't let you control the response. This exists to cover all kinds of HTTP scenarios. Additional endpoints are being considered.

57 | 58 |

All endpoint responses are JSON-encoded.

59 | 60 |

EXAMPLES

61 | 62 |

$ curl http://httpbin.org/ip

63 | 64 |
{"origin": "24.127.96.129"}
 65 | 
66 | 67 |

$ curl http://httpbin.org/user-agent

68 | 69 |
{"user-agent": "curl/7.19.7 (universal-apple-darwin10.0) libcurl/7.19.7 OpenSSL/0.9.8l zlib/1.2.3"}
 70 | 
71 | 72 |

$ curl http://httpbin.org/get

73 | 74 |
{
 75 |    "args": {},
 76 |    "headers": {
 77 |       "Accept": "*/*",
 78 |       "Connection": "close",
 79 |       "Content-Length": "",
 80 |       "Content-Type": "",
 81 |       "Host": "httpbin.org",
 82 |       "User-Agent": "curl/7.19.7 (universal-apple-darwin10.0) libcurl/7.19.7 OpenSSL/0.9.8l zlib/1.2.3"
 83 |    },
 84 |    "origin": "24.127.96.129",
 85 |    "url": "http://httpbin.org/get"
 86 | }
 87 | 
88 | 89 |

$ curl -I http://httpbin.org/status/418

90 | 91 |
HTTP/1.1 418 I'M A TEAPOT
 92 | Server: nginx/0.7.67
 93 | Date: Mon, 13 Jun 2011 04:25:38 GMT
 94 | Connection: close
 95 | x-more-info: http://tools.ietf.org/html/rfc2324
 96 | Content-Length: 135
 97 | 
98 | 99 |

$ curl https://httpbin.org/get?show_env=1

100 | 101 |
{
102 |   "headers": {
103 |     "Content-Length": "",
104 |     "Accept-Language": "en-US,en;q=0.8",
105 |     "Accept-Encoding": "gzip,deflate,sdch",
106 |     "X-Forwarded-Port": "443",
107 |     "X-Forwarded-For": "109.60.101.240",
108 |     "Host": "httpbin.org",
109 |     "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
110 |     "User-Agent": "Mozilla/5.0 (X11; Linux i686) AppleWebKit/535.11 (KHTML, like Gecko) Chrome/17.0.963.83 Safari/535.11",
111 |     "X-Request-Start": "1350053933441",
112 |     "Accept-Charset": "ISO-8859-1,utf-8;q=0.7,*;q=0.3",
113 |     "Connection": "keep-alive",
114 |     "X-Forwarded-Proto": "https",
115 |     "Cookie": "_gauges_unique_day=1; _gauges_unique_month=1; _gauges_unique_year=1; _gauges_unique=1; _gauges_unique_hour=1",
116 |     "Content-Type": ""
117 |   },
118 |   "args": {
119 |     "show_env": "1"
120 |   },
121 |   "origin": "109.60.101.240",
122 |   "url": "http://httpbin.org/get?show_env=1"
123 | }
124 | 
125 | 126 |

Installing and running from PyPI

127 | 128 |

You can install httpbin as a library from PyPI and run it as a WSGI app. For example, using Gunicorn:

129 | 130 |
$ pip install httpbin
131 | $ gunicorn httpbin:app
132 | 
133 | 134 |

Changelog

135 | 136 |
    137 |
  • 0.2.0: Added an XML endpoint. Also fixes several bugs with unicode, CORS headers, digest auth, and more.
  • 138 |
  • 0.1.2: Fix a couple Python3 bugs with the random byte endpoints, fix a bug when uploading files without a Content-Type header set.
  • 139 |
  • 0.1.1: Added templates as data in setup.py
  • 140 |
  • 0.1.0: Added python3 support and (re)publish on PyPI
  • 141 |
142 | 143 | 144 |

AUTHOR

145 | 146 |

A Runscope Community Project.

147 |

Originally created by Kenneth Reitz.

148 | 149 |

SEE ALSO

150 | 151 |

Hurl.it - Make HTTP requests.

152 |

RequestBin - Inspect HTTP requests.

153 |

http://python-requests.org

154 | 155 |
156 | -------------------------------------------------------------------------------- /tests/httpbin/templates/images/jackal.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mozillazg/bustard/bd7b47f3ba5440cf6ea026c8b633060fedeb80b7/tests/httpbin/templates/images/jackal.jpg -------------------------------------------------------------------------------- /tests/httpbin/templates/images/pig_icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mozillazg/bustard/bd7b47f3ba5440cf6ea026c8b633060fedeb80b7/tests/httpbin/templates/images/pig_icon.png -------------------------------------------------------------------------------- /tests/httpbin/templates/images/svg_logo.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | SVG Logo 4 | 5 | 7 | 8 | 15 | 16 | 23 | 24 | 33 | 34 | 35 | 40 | 41 | 46 | 47 | 52 | 53 | 58 | 59 | 64 | 65 | 70 | 71 | 76 | 77 | 82 | 83 | 84 | 85 | 104 | 105 | 157 | 158 | 159 | 160 | 169 | 170 | 181 | 182 | 183 | SVG 184 | 215 | 227 | 228 | 256 | 257 | 258 | 259 | 260 | -------------------------------------------------------------------------------- /tests/httpbin/templates/images/wolf_1.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mozillazg/bustard/bd7b47f3ba5440cf6ea026c8b633060fedeb80b7/tests/httpbin/templates/images/wolf_1.webp -------------------------------------------------------------------------------- /tests/httpbin/templates/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | httpbin(1): HTTP Client Testing Service 7 | 44 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | {% include 'httpbin.1.html' %} 58 | 59 | {% if tracking_enabled %} 60 | {% include 'trackingscripts.html' %} 61 | {% endif %} 62 | 63 | 64 | 65 | -------------------------------------------------------------------------------- /tests/httpbin/templates/moby.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 |

Herman Melville - Moby-Dick

7 | 8 |
9 |

10 | Availing himself of the mild, summer-cool weather that now reigned in these latitudes, and in preparation for the peculiarly active pursuits shortly to be anticipated, Perth, the begrimed, blistered old blacksmith, had not removed his portable forge to the hold again, after concluding his contributory work for Ahab's leg, but still retained it on deck, fast lashed to ringbolts by the foremast; being now almost incessantly invoked by the headsmen, and harpooneers, and bowsmen to do some little job for them; altering, or repairing, or new shaping their various weapons and boat furniture. Often he would be surrounded by an eager circle, all waiting to be served; holding boat-spades, pike-heads, harpoons, and lances, and jealously watching his every sooty movement, as he toiled. Nevertheless, this old man's was a patient hammer wielded by a patient arm. No murmur, no impatience, no petulance did come from him. Silent, slow, and solemn; bowing over still further his chronically broken back, he toiled away, as if toil were life itself, and the heavy beating of his hammer the heavy beating of his heart. And so it was.—Most miserable! A peculiar walk in this old man, a certain slight but painful appearing yawing in his gait, had at an early period of the voyage excited the curiosity of the mariners. And to the importunity of their persisted questionings he had finally given in; and so it came to pass that every one now knew the shameful story of his wretched fate. Belated, and not innocently, one bitter winter's midnight, on the road running between two country towns, the blacksmith half-stupidly felt the deadly numbness stealing over him, and sought refuge in a leaning, dilapidated barn. The issue was, the loss of the extremities of both feet. Out of this revelation, part by part, at last came out the four acts of the gladness, and the one long, and as yet uncatastrophied fifth act of the grief of his life's drama. He was an old man, who, at the age of nearly sixty, had postponedly encountered that thing in sorrow's technicals called ruin. He had been an artisan of famed excellence, and with plenty to do; owned a house and garden; embraced a youthful, daughter-like, loving wife, and three blithe, ruddy children; every Sunday went to a cheerful-looking church, planted in a grove. But one night, under cover of darkness, and further concealed in a most cunning disguisement, a desperate burglar slid into his happy home, and robbed them all of everything. And darker yet to tell, the blacksmith himself did ignorantly conduct this burglar into his family's heart. It was the Bottle Conjuror! Upon the opening of that fatal cork, forth flew the fiend, and shrivelled up his home. Now, for prudent, most wise, and economic reasons, the blacksmith's shop was in the basement of his dwelling, but with a separate entrance to it; so that always had the young and loving healthy wife listened with no unhappy nervousness, but with vigorous pleasure, to the stout ringing of her young-armed old husband's hammer; whose reverberations, muffled by passing through the floors and walls, came up to her, not unsweetly, in her nursery; and so, to stout Labor's iron lullaby, the blacksmith's infants were rocked to slumber. Oh, woe on woe! Oh, Death, why canst thou not sometimes be timely? Hadst thou taken this old blacksmith to thyself ere his full ruin came upon him, then had the young widow had a delicious grief, and her orphans a truly venerable, legendary sire to dream of in their after years; and all of them a care-killing competency. 11 |

12 |
13 | 14 | 15 | -------------------------------------------------------------------------------- /tests/httpbin/templates/sample.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 10 | 11 | 12 | 13 | Wake up to WonderWidgets! 14 | 15 | 16 | 17 | 18 | Overview 19 | Why WonderWidgets are great 20 | 21 | Who buys WonderWidgets 22 | 23 | 24 | 25 | -------------------------------------------------------------------------------- /tests/httpbin/templates/trackingscripts.html: -------------------------------------------------------------------------------- 1 | {# 2 | place tracking scripts (like Google Analytics) here 3 | #} 4 | 13 | 14 | 24 | -------------------------------------------------------------------------------- /tests/httpbin/utils.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | """ 4 | httpbin.utils 5 | ~~~~~~~~~~~~~~~ 6 | 7 | Utility functions. 8 | """ 9 | 10 | import random 11 | import bisect 12 | 13 | 14 | def weighted_choice(choices): 15 | """Returns a value from choices chosen by weighted random selection 16 | 17 | choices should be a list of (value, weight) tuples. 18 | 19 | eg. weighted_choice([('val1', 5), ('val2', 0.3), ('val3', 1)]) 20 | 21 | """ 22 | values, weights = zip(*choices) 23 | total = 0 24 | cum_weights = [] 25 | for w in weights: 26 | total += w 27 | cum_weights.append(total) 28 | x = random.uniform(0, total) 29 | i = bisect.bisect(cum_weights, x) 30 | return values[i] 31 | -------------------------------------------------------------------------------- /tests/templates/child.html: -------------------------------------------------------------------------------- 1 | {% extends "parent.html" %} 2 | 3 | {% block header %} 4 | child_header {{ block.super }} 5 | {% endblock header %} 6 | 7 | {% block footer %} 8 | child_footer 9 | {% include "index.html" %} 10 | {% endblock footer %} 11 | -------------------------------------------------------------------------------- /tests/templates/hello.html: -------------------------------------------------------------------------------- 1 | hello {{ name }} {{ url_for("hello", name=name) }} 2 | -------------------------------------------------------------------------------- /tests/templates/index.html: -------------------------------------------------------------------------------- 1 |
    {% include "list.html" %}
2 | -------------------------------------------------------------------------------- /tests/templates/list.html: -------------------------------------------------------------------------------- 1 | {% for item in items %}
  • {{ item }}
  • {% endfor %} 2 | -------------------------------------------------------------------------------- /tests/templates/parent.html: -------------------------------------------------------------------------------- 1 | 2 |

    hello

    3 | {% block header %} 4 | parent_header 5 | {% endblock header %} 6 |

    world

    7 | {% block footer %} 8 | parent_footer 9 | {% endblock footer %} 10 | 11 | {% block lalala %}{% endblock %} 12 | 13 | {% block yes %} 14 | yes 15 | {% endblock %} 16 |

    !

    17 | 18 | -------------------------------------------------------------------------------- /tests/test.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mozillazg/bustard/bd7b47f3ba5440cf6ea026c8b633060fedeb80b7/tests/test.png -------------------------------------------------------------------------------- /tests/test_app.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | import os 3 | 4 | import pytest 5 | 6 | from bustard.app import Bustard 7 | from .utils import CURRENT_DIR 8 | 9 | app = Bustard(template_dir=os.path.join(CURRENT_DIR, 'templates')) 10 | 11 | 12 | @pytest.yield_fixture 13 | def client(): 14 | yield app.test_client() 15 | 16 | 17 | @app.route('/hello/') 18 | def hello(request, name): 19 | return app.render_template('hello.html', name=name) 20 | 21 | 22 | def test_hello(client): 23 | url = app.url_for('hello', name='Tom') 24 | response = client.get(url) 25 | assert response.data.strip() == b'hello Tom /hello/Tom' 26 | -------------------------------------------------------------------------------- /tests/test_http.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | import pytest 3 | 4 | from bustard.http import jsonify, Headers, redirect, response_status_string 5 | 6 | 7 | class TestHeaders: 8 | 9 | def test_normal(self): 10 | headers = Headers({'User-Agent': 'firefox/34'}) 11 | assert headers['User-Agent'] == 'firefox/34' 12 | assert headers['user-agent'] == headers['User-Agent'] 13 | 14 | headers.add('name', 'value') 15 | headers.add('name', 'value2') 16 | assert headers['name'] == headers['Name'] == 'value2' 17 | assert headers.get_all('Name') == ['value', 'value2'] 18 | 19 | headers.set('Name', 'v') 20 | assert headers.get_all('Name') == ['v'] 21 | 22 | headers['a'] = 'b' 23 | assert headers['a'] == 'b' 24 | assert headers.get_all('a') == ['b'] 25 | 26 | def test_value_list(self): 27 | headers = Headers() 28 | headers.add('name', ['value', 'v2']) 29 | assert headers.get_all('name') == ['value', 'v2'] 30 | assert set(headers.to_list()) == {('Name', 'value'), ('Name', 'v2')} 31 | 32 | h2 = Headers.from_list( 33 | [('name', 'v1'), ('Name', 'v2'), ('key', 'value')] 34 | ) 35 | assert set(h2.to_list()) == { 36 | ('Name', 'v1'), ('Name', 'v2'), ('Key', 'value') 37 | } 38 | 39 | headers['foo'] = ['v1', 'v2'] 40 | assert headers['foo'] == 'v2' 41 | assert headers.get_all('Foo') == ['v1', 'v2'] 42 | 43 | 44 | @pytest.mark.parametrize('url, code', [ 45 | ('http://a.com', None), 46 | ('/a/b/c', 301), 47 | ]) 48 | def test_redirect(url, code): 49 | kwargs = {'url': url} 50 | if code: 51 | kwargs['code'] = code 52 | response = redirect(**kwargs) 53 | assert response.status_code == (code or 302) 54 | assert response.headers['location'] == url 55 | 56 | 57 | @pytest.mark.parametrize('obj', [ 58 | {'a': 1, 'b': 2}, 59 | {'a': 'b', 'headers': Headers({'a': 'b'})}, 60 | {}, 61 | ]) 62 | def test_jsonify(obj): 63 | response = jsonify(obj) 64 | assert response.json() is not None 65 | 66 | 67 | @pytest.mark.parametrize('code, result', [ 68 | (200, '200 OK'), 69 | (1234, '1234 UNKNOWN'), 70 | ]) 71 | def test_response_status_string(code, result): 72 | assert response_status_string(code) == result 73 | -------------------------------------------------------------------------------- /tests/test_httpbin.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | import os 4 | import base64 5 | import unittest 6 | import contextlib 7 | import six 8 | import json 9 | 10 | from . import httpbin 11 | 12 | 13 | @contextlib.contextmanager 14 | def _setenv(key, value): 15 | """Context manager to set an environment variable temporarily.""" 16 | old_value = os.environ.get(key, None) 17 | if value is None: 18 | os.environ.pop(key, None) 19 | else: 20 | os.environ[key] = value 21 | 22 | yield 23 | 24 | if old_value is None: 25 | os.environ.pop(key, None) 26 | else: 27 | os.environ[key] = value 28 | 29 | 30 | def _string_to_base64(string): 31 | """Encodes string to utf-8 and then base64""" 32 | utf8_encoded = string.encode('utf-8') 33 | return base64.urlsafe_b64encode(utf8_encoded) 34 | 35 | 36 | class HttpbinTestCase(unittest.TestCase): 37 | """Httpbin tests""" 38 | 39 | def setUp(self): 40 | httpbin.app.debug = True 41 | self.app = httpbin.app.test_client() 42 | 43 | def test_response_headers_simple(self): 44 | response = self.app.get('/response-headers?animal=dog') 45 | self.assertEqual(response.status_code, 200) 46 | self.assertEqual(response.headers.get_all('Animal'), ['dog']) 47 | assert json.loads(response.data.decode('utf-8'))['Animal'] == 'dog' 48 | 49 | def test_response_headers_multi(self): 50 | response = self.app.get('/response-headers?animal=dog&animal=cat') 51 | self.assertEqual(response.status_code, 200) 52 | self.assertEqual(response.headers.get_all('Animal'), ['dog', 'cat']) 53 | assert ( 54 | json.loads(response.data.decode('utf-8'))['Animal'] == 55 | ['dog', 'cat'] 56 | ) 57 | 58 | def test_get(self): 59 | response = self.app.get('/get', headers={'User-Agent': 'test'}) 60 | self.assertEqual(response.status_code, 200) 61 | data = json.loads(response.data.decode('utf-8')) 62 | self.assertEqual(data['args'], {}) 63 | self.assertEqual(data['headers']['Host'], 'localhost') 64 | self.assertEqual(data['headers']['Content-Type'], '') 65 | self.assertEqual(data['headers']['Content-Length'], '0') 66 | self.assertEqual(data['headers']['User-Agent'], 'test') 67 | self.assertEqual(data['origin'], None) 68 | self.assertEqual(data['url'], 'http://localhost/get') 69 | self.assertTrue(response.data.endswith(b'\n')) 70 | 71 | def test_base64(self): 72 | greeting = u'Здравствуй, мир!' 73 | b64_encoded = _string_to_base64(greeting) 74 | response = self.app.get(b'/base64/' + b64_encoded) 75 | content = response.data.decode('utf-8') 76 | self.assertEqual(greeting, content) 77 | 78 | def test_post_binary(self): 79 | response = self.app.post('/post', 80 | data=b'\x01\x02\x03\x81\x82\x83', 81 | content_type='application/octet-stream') 82 | self.assertEqual(response.status_code, 200) 83 | 84 | def test_post_body_text(self): 85 | response = self.app.post('/post', data={'file': 'hello'}) 86 | self.assertEqual(response.status_code, 200) 87 | 88 | def test_post_body_binary(self): 89 | response = self.app.post( 90 | '/post', 91 | data={'file': b'\x01\x02\x03\x81\x82\x83'}) 92 | self.assertEqual(response.status_code, 200) 93 | 94 | def test_post_body_unicode(self): 95 | response = self.app.post('/post', data=u'оживлённым'.encode('utf-8')) 96 | self.assertEqual(json.loads(response.data.decode('utf-8'))['data'], 97 | u'оживлённым') 98 | 99 | def test_post_file_with_missing_content_type_header(self): 100 | # I built up the form data manually here because I couldn't find a way 101 | # to convince the werkzeug test client to send files without the 102 | # content-type of the file set. 103 | data = '--bound\r\nContent-Disposition: form-data; name="media"; ' 104 | data += 'filename="test.bin"\r\n\r\n\xa5\xc6\n--bound--\r\n' 105 | response = self.app.post( 106 | '/post', 107 | content_type='multipart/form-data; boundary=bound', 108 | data=data, 109 | ) 110 | self.assertEqual(response.status_code, 200) 111 | 112 | def test_set_cors_headers_after_request(self): 113 | response = self.app.get('/get') 114 | self.assertEqual( 115 | response.headers.get('Access-Control-Allow-Origin'), '*' 116 | ) 117 | 118 | def test_set_cors_credentials_headers_after_auth_request(self): 119 | response = self.app.get('/basic-auth/foo/bar') 120 | self.assertEqual( 121 | response.headers.get('Access-Control-Allow-Credentials'), 'true' 122 | ) 123 | 124 | def test_set_cors_headers_after_request_with_request_origin(self): 125 | response = self.app.get('/get', headers={'Origin': 'origin'}) 126 | self.assertEqual( 127 | response.headers.get('Access-Control-Allow-Origin'), 'origin' 128 | ) 129 | 130 | def test_set_cors_headers_with_options_verb(self): 131 | response = self.app.open('/get', method='OPTIONS') 132 | self.assertEqual( 133 | response.headers.get('Access-Control-Allow-Origin'), '*' 134 | ) 135 | self.assertEqual( 136 | response.headers.get('Access-Control-Allow-Credentials'), 'true' 137 | ) 138 | self.assertEqual( 139 | response.headers.get('Access-Control-Allow-Methods'), 140 | 'GET, POST, PUT, DELETE, PATCH, OPTIONS' 141 | ) 142 | self.assertEqual( 143 | response.headers.get('Access-Control-Max-Age'), '3600' 144 | ) 145 | # FIXME should we add any extra headers? 146 | self.assertNotIn( 147 | 'Access-Control-Allow-Headers', response.headers 148 | ) 149 | 150 | def test_set_cors_allow_headers(self): 151 | headers = {'Access-Control-Request-Headers': 'X-Test-Header'} 152 | response = self.app.open('/get', method='OPTIONS', headers=headers) 153 | self.assertEqual( 154 | response.headers.get('Access-Control-Allow-Headers'), 155 | 'X-Test-Header' 156 | ) 157 | 158 | def test_user_agent(self): 159 | response = self.app.get( 160 | '/user-agent', headers={'User-Agent': 'test'} 161 | ) 162 | self.assertIn('test', response.data.decode('utf-8')) 163 | self.assertEqual(response.status_code, 200) 164 | 165 | def test_gzip(self): 166 | response = self.app.get('/gzip') 167 | self.assertEqual(response.status_code, 200) 168 | 169 | def _test_digest_auth_with_wrong_password(self): 170 | auth_header = 'Digest username="user",realm="wrong",nonce="wrong",uri="/digest-auth/user/passwd",response="wrong",opaque="wrong"' # noqa 171 | response = self.app.get( 172 | '/digest-auth/auth/user/passwd', 173 | environ_base={ 174 | # httpbin's digest auth implementation uses the remote addr to 175 | # build the nonce 176 | 'REMOTE_ADDR': '127.0.0.1', 177 | }, 178 | headers={ 179 | 'Authorization': auth_header, 180 | } 181 | ) 182 | assert 'Digest' in response.headers.get('WWW-Authenticate') 183 | 184 | def _test_digest_auth(self): 185 | # make first request 186 | unauthorized_response = self.app.get( 187 | '/digest-auth/auth/user/passwd', 188 | environ_base={ 189 | # digest auth uses the remote addr to build the nonce 190 | 'REMOTE_ADDR': '127.0.0.1', 191 | } 192 | ) 193 | # make sure it returns a 401 194 | self.assertEqual(unauthorized_response.status_code, 401) 195 | header = unauthorized_response.headers.get('WWW-Authenticate') 196 | auth_type, auth_info = header.split(None, 1) 197 | 198 | # Begin crappy digest-auth implementation 199 | # d = parse_dict_header(auth_info) 200 | # a1 = b'user:' + d['realm'].encode('utf-8') + b':passwd' 201 | # ha1 = md5(a1).hexdigest().encode('utf-8') 202 | # a2 = b'GET:/digest-auth/auth/user/passwd' 203 | # ha2 = md5(a2).hexdigest().encode('utf-8') 204 | # a3 = ha1 + b':' + d['nonce'].encode('utf-8') + b':' + ha2 205 | # auth_response = md5(a3).hexdigest() 206 | # auth_header = 'Digest username="user",realm="' + \ 207 | # d['realm'] + \ 208 | # '",nonce="' + \ 209 | # d['nonce'] + \ 210 | # '",uri="/digest-auth/auth/user/passwd",response="' + \ 211 | # auth_response + \ 212 | # '",opaque="' + \ 213 | # d['opaque'] + '"' 214 | # 215 | # # make second request 216 | # authorized_response = self.app.get( 217 | # '/digest-auth/auth/user/passwd', 218 | # environ_base={ 219 | # # httpbin's digest auth implementation uses the remote addr to # noqa 220 | # # build the nonce 221 | # 'REMOTE_ADDR': '127.0.0.1', 222 | # }, 223 | # headers={ 224 | # 'Authorization': auth_header, 225 | # } 226 | # ) 227 | # 228 | # # done! 229 | # self.assertEqual(authorized_response.status_code, 200) 230 | 231 | def test_drip(self): 232 | response = self.app.get('/drip?numbytes=400&duration=2&delay=1') 233 | self.assertEqual(response.content_length, 400) 234 | self.assertEqual(len(response.get_data()), 400) 235 | self.assertEqual(response.status_code, 200) 236 | 237 | def test_drip_with_custom_code(self): 238 | response = self.app.get('/drip?numbytes=400&duration=2&code=500') 239 | self.assertEqual(response.content_length, 400) 240 | self.assertEqual(len(response.get_data()), 400) 241 | self.assertEqual(response.status_code, 500) 242 | 243 | def test_get_bytes(self): 244 | response = self.app.get('/bytes/1024') 245 | self.assertEqual(len(response.get_data()), 1024) 246 | self.assertEqual(response.status_code, 200) 247 | 248 | def test_bytes_with_seed(self): 249 | response = self.app.get('/bytes/10?seed=0') 250 | # The RNG changed in python3, so even though we are 251 | # setting the seed, we can't expect the value to be the 252 | # same across both interpreters. 253 | if six.PY3: 254 | self.assertEqual( 255 | response.data, b'\xc5\xd7\x14\x84\xf8\xcf\x9b\xf4\xb7o' 256 | ) 257 | else: 258 | self.assertEqual( 259 | response.data, b'\xd8\xc2kB\x82g\xc8Mz\x95' 260 | ) 261 | 262 | def test_stream_bytes(self): 263 | response = self.app.get('/stream-bytes/1024') 264 | self.assertEqual(len(response.get_data()), 1024) 265 | self.assertEqual(response.status_code, 200) 266 | 267 | def test_stream_bytes_with_seed(self): 268 | response = self.app.get('/stream-bytes/10?seed=0') 269 | # The RNG changed in python3, so even though we are 270 | # setting the seed, we can't expect the value to be the 271 | # same across both interpreters. 272 | if six.PY3: 273 | self.assertEqual( 274 | response.data, b'\xc5\xd7\x14\x84\xf8\xcf\x9b\xf4\xb7o' 275 | ) 276 | else: 277 | self.assertEqual( 278 | response.data, b'\xd8\xc2kB\x82g\xc8Mz\x95' 279 | ) 280 | 281 | def test_delete_endpoint_returns_body(self): 282 | response = self.app.delete( 283 | '/delete', 284 | data={'name': 'kevin'}, 285 | content_type='application/x-www-form-urlencoded' 286 | ) 287 | form_data = json.loads(response.data.decode('utf-8'))['form'] 288 | self.assertEqual(form_data, {'name': 'kevin'}) 289 | 290 | def test_methods__to_status_endpoint(self): 291 | methods = [ 292 | 'GET', 293 | 'HEAD', 294 | 'POST', 295 | 'PUT', 296 | 'DELETE', 297 | 'PATCH', 298 | 'TRACE', 299 | ] 300 | for m in methods: 301 | response = self.app.open(path='/status/418', method=m) 302 | self.assertEqual(response.status_code, 418) 303 | 304 | def test_xml_endpoint(self): 305 | response = self.app.get(path='/xml') 306 | self.assertEqual( 307 | response.headers.get('Content-Type'), 'application/xml' 308 | ) 309 | 310 | def test_x_forwarded_proto(self): 311 | response = self.app.get(path='/get', headers={ 312 | 'X-Forwarded-Proto': 'https' 313 | }) 314 | assert json.loads(response.data.decode('utf-8') 315 | )['url'].startswith('https://') 316 | 317 | def test_redirect_n_higher_than_1(self): 318 | response = self.app.get('/redirect/5') 319 | self.assertEqual( 320 | response.headers.get('Location'), '/relative-redirect/4' 321 | ) 322 | 323 | def test_redirect_absolute_param_n_higher_than_1(self): 324 | response = self.app.get('/redirect/5?absolute=true') 325 | self.assertEqual( 326 | response.headers.get('Location'), 327 | 'http://localhost/absolute-redirect/4' 328 | ) 329 | 330 | def test_redirect_n_equals_to_1(self): 331 | response = self.app.get('/redirect/1') 332 | self.assertEqual(response.status_code, 302) 333 | self.assertEqual( 334 | response.headers.get('Location'), '/get' 335 | ) 336 | 337 | def test_relative_redirect_n_equals_to_1(self): 338 | response = self.app.get('/relative-redirect/1') 339 | self.assertEqual( 340 | response.headers.get('Location'), '/get' 341 | ) 342 | 343 | def test_relative_redirect_n_higher_than_1(self): 344 | response = self.app.get('/relative-redirect/7') 345 | self.assertEqual(response.status_code, 302) 346 | self.assertEqual( 347 | response.headers.get('Location'), '/relative-redirect/6' 348 | ) 349 | 350 | def test_absolute_redirect_n_higher_than_1(self): 351 | response = self.app.get('/absolute-redirect/5') 352 | self.assertEqual( 353 | response.headers.get('Location'), 354 | 'http://localhost/absolute-redirect/4' 355 | ) 356 | 357 | def test_absolute_redirect_n_equals_to_1(self): 358 | response = self.app.get('/absolute-redirect/1') 359 | self.assertEqual(response.status_code, 302) 360 | self.assertEqual( 361 | response.headers.get('Location'), 'http://localhost/get' 362 | ) 363 | 364 | def test_request_range(self): 365 | response1 = self.app.get('/range/1234') 366 | self.assertEqual(response1.status_code, 200) 367 | self.assertEqual(response1.headers.get('ETag'), 'range1234') 368 | self.assertEqual(response1.headers.get('Content-range'), 369 | 'bytes 0-1233/1234') 370 | self.assertEqual(response1.headers.get('Accept-ranges'), 'bytes') 371 | self.assertEqual(len(response1.get_data()), 1234) 372 | 373 | response2 = self.app.get('/range/1234') 374 | self.assertEqual(response2.status_code, 200) 375 | self.assertEqual(response2.headers.get('ETag'), 'range1234') 376 | self.assertEqual(response1.get_data(), response2.get_data()) 377 | 378 | def test_request_range_with_parameters(self): 379 | response = self.app.get( 380 | '/range/100?duration=1.5&chunk_size=5', 381 | headers={'Range': 'bytes=10-24'} 382 | ) 383 | 384 | self.assertEqual(response.status_code, 206) 385 | self.assertEqual(response.headers.get('ETag'), 'range100') 386 | self.assertEqual(response.headers.get('Content-range'), 387 | 'bytes 10-24/100') 388 | self.assertEqual(response.headers.get('Accept-ranges'), 'bytes') 389 | self.assertEqual(response.get_data(), 'klmnopqrstuvwxy'.encode('utf8')) 390 | 391 | def test_request_range_first_15_bytes(self): 392 | response = self.app.get( 393 | '/range/1000', 394 | headers={'Range': 'bytes=0-15'} 395 | ) 396 | 397 | self.assertEqual(response.status_code, 206) 398 | self.assertEqual(response.headers.get('ETag'), 'range1000') 399 | self.assertEqual(response.get_data(), 400 | 'abcdefghijklmnop'.encode('utf8')) 401 | self.assertEqual(response.headers.get('Content-range'), 402 | 'bytes 0-15/1000') 403 | 404 | def test_request_range_open_ended_last_6_bytes(self): 405 | response = self.app.get( 406 | '/range/26', 407 | headers={'Range': 'bytes=20-'} 408 | ) 409 | 410 | self.assertEqual(response.status_code, 206) 411 | self.assertEqual(response.headers.get('ETag'), 'range26') 412 | self.assertEqual(response.get_data(), 'uvwxyz'.encode('utf8')) 413 | self.assertEqual(response.headers.get('Content-range'), 414 | 'bytes 20-25/26') 415 | 416 | def test_request_range_suffix(self): 417 | response = self.app.get( 418 | '/range/26', 419 | headers={'Range': 'bytes=-5'} 420 | ) 421 | 422 | self.assertEqual(response.status_code, 206) 423 | self.assertEqual(response.headers.get('ETag'), 'range26') 424 | self.assertEqual(response.get_data(), 'vwxyz'.encode('utf8')) 425 | self.assertEqual(response.headers.get('Content-range'), 426 | 'bytes 21-25/26') 427 | 428 | def test_request_out_of_bounds(self): 429 | response = self.app.get( 430 | '/range/26', 431 | headers={'Range': 'bytes=10-5'} 432 | ) 433 | 434 | self.assertEqual(response.status_code, 416) 435 | self.assertEqual(response.headers.get('ETag'), 'range26') 436 | self.assertEqual(len(response.get_data()), 0) 437 | self.assertEqual(response.headers.get('Content-range'), 'bytes */26') 438 | 439 | response = self.app.get( 440 | '/range/26', 441 | headers={'Range': 'bytes=32-40'} 442 | ) 443 | 444 | self.assertEqual(response.status_code, 416) 445 | response = self.app.get( 446 | '/range/26', 447 | headers={'Range': 'bytes=0-40'} 448 | ) 449 | self.assertEqual(response.status_code, 416) 450 | 451 | def test_tracking_disabled(self): 452 | with _setenv('HTTPBIN_TRACKING', None): 453 | response = self.app.get('/') 454 | data = response.data.decode('utf-8') 455 | self.assertNotIn('google-analytics', data) 456 | self.assertNotIn('perfectaudience', data) 457 | 458 | def test_tracking_enabled(self): 459 | with _setenv('HTTPBIN_TRACKING', '1'): 460 | response = self.app.get('/') 461 | data = response.data.decode('utf-8') 462 | self.assertIn('google-analytics', data) 463 | self.assertIn('perfectaudience', data) 464 | 465 | 466 | if __name__ == '__main__': 467 | unittest.main() 468 | -------------------------------------------------------------------------------- /tests/test_multipart.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | import json 3 | import os 4 | 5 | import pytest 6 | 7 | from bustard.app import Bustard 8 | from bustard.utils import to_bytes, to_text 9 | 10 | app = Bustard() 11 | current_dir = os.path.dirname(os.path.abspath(__file__)) 12 | 13 | 14 | @pytest.yield_fixture 15 | def client(): 16 | yield app.test_client() 17 | 18 | 19 | @app.route('/echo', methods=['POST']) 20 | def echo(request): 21 | files = request.files 22 | data = { 23 | 'hello': to_text(files['hello'].read()), 24 | } 25 | data.update(request.form) 26 | return json.dumps(data) 27 | 28 | 29 | @app.route('/bin', methods=['POST']) 30 | def echo_bin(request): 31 | files = request.files 32 | return files['file'].read() 33 | 34 | 35 | def test_upload(client): 36 | content = to_bytes('你好吗') 37 | files = { 38 | 'hello': { 39 | 'file': to_text(content), 40 | 'filename': 'hello.txt', 41 | } 42 | } 43 | data = { 44 | 'abc': 'a', 45 | 'a': 'b', 46 | } 47 | expect_data = {} 48 | expect_data.update(data) 49 | expect_data.update({k: f['file'] for k, f in files.items()}) 50 | response = client.post('/echo', data=data, files=files) 51 | assert response.json() == expect_data 52 | 53 | 54 | def test_upload_bin(client): 55 | content = b'' 56 | with open(os.path.join(current_dir, 'test.png'), 'rb') as f: 57 | content = f.read() 58 | f.seek(0) 59 | files = { 60 | 'file': { 61 | 'file': f.read(), 62 | 'name': f.name, 63 | } 64 | } 65 | response = client.post('/bin', files=files) 66 | assert response.content == content 67 | -------------------------------------------------------------------------------- /tests/test_orm.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | import os 3 | 4 | import pytest 5 | 6 | from bustard import orm 7 | 8 | pg_uri = os.environ.get( 9 | 'BUSTARD_TEST_PG_URI', 10 | 'postgresql://dbuser:password@localhost/exampledb' 11 | ) 12 | 13 | 14 | @pytest.yield_fixture 15 | def model(): 16 | yield orm.Model 17 | orm.MetaData.tables = {} 18 | orm.MetaData.indexes = [] 19 | 20 | 21 | @pytest.mark.parametrize('fieldclass, data_type', [ 22 | ('CharField', 'varchar'), 23 | ('IntegerField', 'integer'), 24 | ('DateField', 'date'), 25 | ('DateTimeField', 'timestamp'), 26 | ('TextField', 'text'), 27 | ('BooleanField', 'boolean'), 28 | ('UUIDField', 'uuid'), 29 | ('JSONField', 'json'), 30 | ('AutoField', 'serial'), 31 | ]) 32 | def test_field(model, fieldclass, data_type): 33 | field = getattr(orm, fieldclass)(name='name') 34 | assert field.to_sql() == 'name {}'.format(data_type) 35 | 36 | 37 | @pytest.mark.parametrize('kwargs, to_sql', [ 38 | ({'max_length': 10}, 'field_name varchar(10)'), 39 | ({'server_default': '\'\''}, 'field_name varchar DEFAULT \'\''), 40 | ({'unique': True}, 'field_name varchar UNIQUE'), 41 | ({'nullable': False}, 'field_name varchar NOT NULL'), 42 | ({'primary_key': True}, 'field_name varchar PRIMARY KEY'), 43 | ({'foreign_key': orm.ForeignKey('users.id')}, 44 | 'field_name varchar REFERENCES users (id)'), 45 | ]) 46 | def test_field_option(model, kwargs, to_sql): 47 | field = orm.CharField(name='field_name', **kwargs) 48 | assert field.to_sql() == to_sql 49 | 50 | 51 | def test_define_model(model): 52 | 53 | class User(orm.Model): 54 | __tablename__ = 'users' 55 | id = orm.AutoField(primary_key=True) 56 | username = orm.CharField(max_length=80, default='', 57 | server_default="''", index=True) 58 | password = orm.CharField(max_length=200, default='', 59 | server_default="''") 60 | is_actived = orm.BooleanField(default=False, server_default=False) 61 | description = orm.TextField(default='', server_default="''") 62 | 63 | assert User.table_sql() == ''' 64 | CREATE TABLE users ( 65 | id serial PRIMARY KEY, 66 | username varchar(80) DEFAULT '', 67 | password varchar(200) DEFAULT '', 68 | is_actived boolean DEFAULT FALSE, 69 | description text DEFAULT '' 70 | ); 71 | ''' 72 | 73 | assert ( 74 | orm.MetaData.index_sqls() == 75 | 'CREATE INDEX index_users_username ON users (username);' 76 | ) 77 | 78 | 79 | @pytest.yield_fixture 80 | def model_context(): 81 | class User(orm.Model): 82 | __tablename__ = 'users' 83 | 84 | id = orm.AutoField(primary_key=True) 85 | username = orm.CharField(max_length=80, default='', 86 | server_default="''", index=True) 87 | password = orm.CharField(max_length=200, default='', 88 | server_default="''") 89 | is_actived = orm.BooleanField(default=False, server_default=False) 90 | description = orm.TextField(default='', server_default="''") 91 | 92 | def __repr__(self): 93 | return ''.format(self.id) 94 | 95 | engine = orm.Engine(pg_uri) 96 | session = orm.Session(engine) 97 | models = { 98 | 'User': User, 99 | 'engine': engine, 100 | 'session': session, 101 | } 102 | orm.MetaData.create_all(engine) 103 | yield models 104 | session.rollback() 105 | session.close() 106 | orm.MetaData.drop_all(engine) 107 | orm.MetaData.tables = {} 108 | orm.MetaData.indexes = [] 109 | 110 | 111 | def create_user(User, session): 112 | user = User(username='test', password='passwd', is_actived=True) 113 | session.insert(user) 114 | return user 115 | 116 | 117 | class TestSession: 118 | 119 | def test_insert(self, model_context): 120 | User = model_context['User'] 121 | session = model_context['session'] 122 | user = create_user(User, session) 123 | session.commit() 124 | assert user.id == 1 125 | 126 | def test_update(self, model_context): 127 | User = model_context['User'] 128 | session = model_context['session'] 129 | user = create_user(User, session) 130 | session.commit() 131 | # old_username = user.username 132 | user.username = 'new name' 133 | session.update(user) 134 | session.commit() 135 | 136 | def test_delete(self, model_context): 137 | User = model_context['User'] 138 | session = model_context['session'] 139 | user = create_user(User, session) 140 | session.commit() 141 | assert user.id == 1 142 | session.delete(user) 143 | session.commit() 144 | assert user.id is None 145 | 146 | def test_transaction(self, model_context): 147 | User = model_context['User'] 148 | session = model_context['session'] 149 | with session.transaction(): 150 | create_user(User, session) 151 | session.rollback() 152 | assert session.query(User).filter(id=1).count() == 1 153 | 154 | def test_transaction_exception(self, model_context): 155 | User = model_context['User'] 156 | session = model_context['session'] 157 | with pytest.raises(AssertionError): 158 | with session.transaction(): 159 | create_user(User, session) 160 | assert 0 > 1 161 | assert session.query(User).filter(id=1).count() == 0 162 | 163 | 164 | class TestQuerySet: 165 | 166 | def users(self, User, session): 167 | return [ 168 | create_user(User, session), 169 | create_user(User, session), 170 | create_user(User, session), 171 | create_user(User, session), 172 | ] 173 | 174 | def test_select(self, model_context): 175 | User = model_context['User'] 176 | session = model_context['session'] 177 | self.users(User, session) 178 | session.commit() 179 | 180 | queryset = session.query(User).filter() 181 | assert len(queryset) == 4 182 | assert queryset[1].id == 2 183 | 184 | def test_select_lt(self, model_context): 185 | User = model_context['User'] 186 | session = model_context['session'] 187 | self.users(User, session) 188 | session.commit() 189 | 190 | queryset = session.query(User).filter(User.id < 2) 191 | assert queryset.count() == 1 192 | assert [us.id for us in queryset] == [1] 193 | 194 | def test_select_le(self, model_context): 195 | User = model_context['User'] 196 | session = model_context['session'] 197 | self.users(User, session) 198 | session.commit() 199 | 200 | queryset = session.query(User).filter(User.id <= 2) 201 | assert queryset.count() == 2 202 | assert [us.id for us in queryset] == [1, 2] 203 | 204 | def test_select_eq(self, model_context): 205 | User = model_context['User'] 206 | session = model_context['session'] 207 | self.users(User, session) 208 | session.commit() 209 | 210 | queryset = session.query(User).filter(User.id == 2) 211 | assert queryset.count() == 1 212 | assert [us.id for us in queryset] == [2] 213 | 214 | queryset = session.query(User).filter(id=2) 215 | assert queryset.count() == 1 216 | assert [us.id for us in queryset] == [2] 217 | 218 | def test_select_gt(self, model_context): 219 | User = model_context['User'] 220 | session = model_context['session'] 221 | self.users(User, session) 222 | session.commit() 223 | 224 | queryset = session.query(User).filter(User.id > 2) 225 | assert queryset.count() == 2 226 | assert [us.id for us in queryset] == [3, 4] 227 | 228 | def test_select_ge(self, model_context): 229 | User = model_context['User'] 230 | session = model_context['session'] 231 | self.users(User, session) 232 | session.commit() 233 | 234 | queryset = session.query(User).filter(User.id >= 2) 235 | assert queryset.count() == 3 236 | assert [us.id for us in queryset] == [2, 3, 4] 237 | 238 | def test_select_is(self, model_context): 239 | User = model_context['User'] 240 | session = model_context['session'] 241 | self.users(User, session) 242 | session.commit() 243 | 244 | queryset = session.query(User).filter(User.is_actived.is_(True)) 245 | assert queryset.count() == 4 246 | 247 | def test_select_isnot(self, model_context): 248 | User = model_context['User'] 249 | session = model_context['session'] 250 | self.users(User, session) 251 | session.commit() 252 | 253 | queryset = session.query(User).filter(User.is_actived.is_not(False)) 254 | assert queryset.count() == 4 255 | 256 | def test_select_in(self, model_context): 257 | User = model_context['User'] 258 | session = model_context['session'] 259 | self.users(User, session) 260 | session.commit() 261 | 262 | queryset = session.query(User).filter(User.id.in_((1, 2))) 263 | assert queryset.count() == 2 264 | assert [us.id for us in queryset] == [1, 2] 265 | 266 | def test_select_notin(self, model_context): 267 | User = model_context['User'] 268 | session = model_context['session'] 269 | self.users(User, session) 270 | session.commit() 271 | 272 | queryset = session.query(User).filter(User.id.not_in((1, 2))) 273 | assert queryset.count() == 2 274 | assert [us.id for us in queryset] == [3, 4] 275 | 276 | def test_select_limit(self, model_context): 277 | User = model_context['User'] 278 | session = model_context['session'] 279 | self.users(User, session) 280 | session.commit() 281 | 282 | queryset = session.query(User).filter().limit(1) 283 | assert len(queryset) == 1 284 | assert [us.id for us in queryset] == [1] 285 | 286 | def test_select_offset(self, model_context): 287 | User = model_context['User'] 288 | session = model_context['session'] 289 | self.users(User, session) 290 | session.commit() 291 | 292 | queryset = session.query(User).filter().limit(1).offset(2) 293 | assert len(queryset) == 1 294 | assert [us.id for us in queryset] == [3] 295 | 296 | def test_update(self, model_context): 297 | User = model_context['User'] 298 | session = model_context['session'] 299 | self.users(User, session) 300 | session.commit() 301 | 302 | assert session.query(User).filter(is_actived=False).count() == 0 303 | session.query(User).filter(User.id > 2).update(is_actived=False) 304 | session.commit() 305 | assert session.query(User).filter(is_actived=False).count() == 2 306 | 307 | def test_delete(self, model_context): 308 | User = model_context['User'] 309 | session = model_context['session'] 310 | self.users(User, session) 311 | session.commit() 312 | 313 | queryset = session.query(User).filter(User.id > 2) 314 | assert len(queryset) == 2 315 | queryset.delete() 316 | session.commit() 317 | assert len(queryset) == 0 318 | -------------------------------------------------------------------------------- /tests/test_router.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | import pytest 3 | 4 | from bustard.router import Router 5 | from .utils import copy_func 6 | router = Router() 7 | 8 | 9 | def func_a(): pass 10 | func_b = copy_func(func_a, 'func_b') 11 | func_c = copy_func(func_a, 'func_c') 12 | func_d = copy_func(func_a, 'func_d') 13 | func_e = copy_func(func_a, 'func_e') 14 | func_f = copy_func(func_a, 'func_f') 15 | func_g = copy_func(func_a, 'func_g') 16 | func_h = copy_func(func_a, 'func_h') 17 | func_i = copy_func(func_a, 'func_i') 18 | func_j = copy_func(func_a, 'func_j') 19 | router.register('/a', func_a, methods=['GET', 'POST']) 20 | router.register('/b/c/', func_b, methods=['DELETE', 'POST']) 21 | router.register('/c/d/f', func_c, methods=['PATCH', 'PUT']) 22 | # regex 23 | router.register('/d/(?P\d+)', func_d, methods=['GET']) 24 | router.register('/e/(?P\d+)', func_e, methods=['POST']) 25 | router.register('/f/(?P\d+)/(?P\w+)', func_f, methods=['GET']) 26 | # / 27 | router.register('/g/', func_g, methods=['GET', 'POST']) 28 | router.register('/h/', func_h, methods=['GET', 'PUT']) 29 | router.register('/i/', func_i, methods=['GET', 'POST']) 30 | router.register('/j/', func_j, methods=['PUT', 'POST']) 31 | 32 | 33 | @pytest.mark.parametrize('path, func_name, methods, kwargs', [ 34 | # /path 35 | ('/a', 'func_a', {'GET', 'POST', 'HEAD'}, {}), 36 | ('/a/b', None, None, None), 37 | ('/b/c/', 'func_b', {'DELETE', 'POST'}, {}), 38 | ('/b/c/d', None, None, None), 39 | ('/c/d/f', 'func_c', {'PATCH', 'PUT'}, {}), 40 | ('/c/d/g', None, None, None), 41 | # regex 42 | ('/d/1', 'func_d', {'GET', 'HEAD'}, {'id': '1'}), 43 | ('/d/a', None, None, None), 44 | ('/e/2', 'func_e', {'POST'}, {'id': '2'}), 45 | ('/e/e', None, None, None), 46 | ('/f/3/c', 'func_f', {'GET', 'HEAD'}, {'id': '3', 'code': 'c'}), 47 | ('/f/3/c/d', None, None, None), 48 | # /, /, / 49 | ('/g/e', 'func_g', {'GET', 'POST', 'HEAD'}, {'id': 'e'}), 50 | ('/h/8', 'func_h', {'GET', 'PUT', 'HEAD'}, {'id': '8'}), 51 | ('/h/a', None, None, None), 52 | ('/i/2.3', 'func_i', {'GET', 'POST', 'HEAD'}, {'id': '2.3'}), 53 | ('/i/a', None, None, None), 54 | ('/j/a/b/c/', 'func_j', {'PUT', 'POST'}, {'path': 'a/b/c/'}), 55 | ('/j/', None, None, None), 56 | ]) 57 | def test_get_func(path, func_name, methods, kwargs): 58 | assert router.get_func(path) == ( 59 | (globals()[func_name] if func_name is not None else None), 60 | methods, 61 | kwargs 62 | ) 63 | 64 | 65 | @pytest.mark.parametrize('func_name, kwargs, path', [ 66 | # /path 67 | ('func_a', {}, '/a'), 68 | ('func_b', {}, '/b/c/'), 69 | ('func_c', {}, '/c/d/f'), 70 | # regex 71 | ('func_d', {'id': 1}, '/d/1'), 72 | ('func_e', {'id': 2}, '/e/2'), 73 | ('func_f', {'id': 3, 'code': 'c'}, '/f/3/c'), 74 | ('func_f', {'id': 3, 'code': 'c', 'k': 'v'}, '/f/3/c?k=v'), 75 | # /, /, / 76 | ('func_g', {'id': 'e'}, '/g/e'), 77 | ('func_h', {'id': 8}, '/h/8'), 78 | ('func_i', {'id': 2.3}, '/i/2.3'), 79 | ('func_j', {'path': 'a/b/c/'}, '/j/a/b/c/'), 80 | ('func_abc', {}, ''), 81 | ]) 82 | def test_url_for(func_name, kwargs, path): 83 | assert router.url_for(func_name, **kwargs) == path 84 | 85 | 86 | def test_url_for2(): 87 | result = router.url_for('func_a', a='b', c=1) 88 | try: 89 | assert result == '/a?a=b&c=1' 90 | except: 91 | assert result == '/a?c=1&a=b' 92 | -------------------------------------------------------------------------------- /tests/test_session.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | import pytest 3 | 4 | from bustard.app import Bustard 5 | from bustard.utils import to_bytes 6 | 7 | app = Bustard() 8 | 9 | 10 | @pytest.yield_fixture 11 | def client(): 12 | yield app.test_client() 13 | 14 | 15 | @app.route('/') 16 | def get_session(request): 17 | value = request.session.get('name', '') 18 | return 'hello {}'.format(value) 19 | 20 | 21 | @app.route('/set/') 22 | def set_session(request, value): 23 | request.session['name'] = value 24 | return '' 25 | 26 | 27 | @app.route('/clear') 28 | def clear_session(request): 29 | request.session.clear() 30 | return '' 31 | 32 | 33 | def _get_session(client, value): 34 | response = client.get('/') 35 | assert response.content == to_bytes('hello {}'.format(value)) 36 | 37 | 38 | def test_set_session(client): 39 | client.get('/set/session') 40 | _get_session(client, 'session') 41 | 42 | 43 | def test_clear_session(client): 44 | client.get('/set/hello') 45 | _get_session(client, 'hello') 46 | client.get('/clear') 47 | _get_session(client, '') 48 | -------------------------------------------------------------------------------- /tests/test_static_file.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | import os 3 | 4 | import pytest 5 | 6 | from bustard.app import Bustard 7 | from bustard.views import StaticFilesView 8 | 9 | app = Bustard() 10 | current_dir = os.path.dirname(os.path.abspath(__file__)) 11 | 12 | 13 | @pytest.yield_fixture 14 | def client(): 15 | yield app.test_client() 16 | 17 | 18 | app.add_url_rule('/', 19 | StaticFilesView.as_view(static_dir=current_dir)) 20 | 21 | 22 | @pytest.mark.parametrize('filename', [ 23 | 'test_static_file.py', 24 | 'test.png', 25 | ]) 26 | def test_ok(client, filename): 27 | response = client.get('/{}'.format(filename)) 28 | with open(os.path.join(current_dir, filename), 'rb') as fp: 29 | assert response.content == fp.read() 30 | 31 | 32 | @pytest.mark.parametrize('filename', [ 33 | '../test_static_file.py', 34 | '../../test.png', 35 | 'bustard-httpbin', 36 | 'bustard-httpbin/abc', 37 | ]) 38 | def test_404(client, filename): 39 | response = client.get('/{}'.format(filename)) 40 | assert response.status_code == 404 41 | -------------------------------------------------------------------------------- /tests/test_template.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | from __future__ import absolute_import, print_function, unicode_literals 4 | import collections 5 | import os 6 | 7 | import pytest 8 | 9 | from bustard.template import Template 10 | 11 | current_dir = os.path.dirname(os.path.abspath(__file__)) 12 | template_dir = os.path.join(current_dir, 'templates') 13 | 14 | 15 | def echo(*args, **kwargs): 16 | return args, sorted(kwargs.items()) 17 | 18 | 19 | test_data = ( 20 | # var 21 | ('{{ abc }}', {'abc': 'foobar'}, 'foobar'), 22 | ('b{{ abc }}c', {'abc': 'foobar'}, 'bfoobarc'), 23 | ('{{ abc }', {'abc': 'foobar'}, '{{ abc }'), 24 | # comment 25 | ('{# abc #}', {'abc': 'foobar'}, ''), 26 | # index 27 | ('{{ abc[1] }}', {'abc': [1, 2]}, '2'), 28 | # key 29 | ('{{ abc["key"] }}', {'abc': {'key': 'eg'}}, 'eg'), 30 | # dot 31 | ('{{ abc.key }}', {'abc': collections.namedtuple('abc', 'key')('你好')}, 32 | '你好'), 33 | # func 34 | ('{{ echo(1, 2, 3, a=1, b=a) }}', {'echo': echo, 'a': 4}, 35 | '((1, 2, 3), [('a', 1), ('b', 4)])'), 36 | 37 | # if 38 | ('{% if abc %}true{% endif %}', {'abc': True}, 'true'), 39 | ('{% if "a" in abc %}true{% endif %}', {'abc': 'aa'}, 'true'), 40 | ('{% if a in abc %}true{% endif %}', {'a': 'a', 'abc': 'aa'}, 'true'), 41 | # if + func 42 | ('{% if len(abc) %}true{% endif %}', {'abc': 'abc'}, 'true'), 43 | ('{% if len(abc) > 1 %}true{% endif %}', {'abc': 'aa'}, 'true'), 44 | # if ... else ... 45 | ('{% if abc %}true{% else %}false{% endif %}', {'abc': ''}, 'false'), 46 | 47 | # if ... elif ... else 48 | ('{% if abc == "abc" %}true' + 49 | '{% elif abc == "efg" %}{{ abc }}' + 50 | '{% else %}false{% endif %}', 51 | {'abc': 'efg'}, 'efg'), 52 | 53 | # for x in y 54 | ('{% for item in items %}{{ item }}{% endfor %}', 55 | {'items': [1, 2, 3]}, '123'), 56 | 57 | ('{% for n, item in enumerate(items) %}' + 58 | '{{ n }}{{ item }},' + 59 | '{% endfor %}', 60 | {'items': ['a', 'b', 'c']}, '0a,1b,2c,'), 61 | 62 | # for + if 63 | ('{% for item in items %}' + 64 | '{% if item > 2 %}{{ item }}{% endif %}' + 65 | '{% endfor %}' + 66 | '{{ items[1] }}', 67 | {'items': [1, 2, 3, 4]}, '342'), 68 | 69 | # escape 70 | ('{{ title }}', {'title': ''}, '<a>'), 71 | # noescape 72 | ('{{ noescape(title) }}', {'title': ''}, ''), 73 | 74 | ('{{ list(map(lambda x: x * 2, [1, 2, 3])) }}', {}, '[2, 4, 6]'), 75 | ('{{ sum(filter(lambda x: x > 2, numbers)) }}', 76 | {'numbers': [1, 2, 3, 2, 4]}, '7'), 77 | 78 | ('{{ noescape(str) }}', {}, ""), 79 | ('{{ noescape(abs) }}', {}, ''), 80 | ) 81 | 82 | 83 | @pytest.mark.parametrize( 84 | ('tpl', 'context', 'result'), 85 | test_data 86 | ) 87 | def test_base(tpl, context, result): 88 | assert Template(tpl).render(**context) == result 89 | 90 | 91 | @pytest.mark.parametrize(('tpl', 'context'), [ 92 | ('{{ hello }}', {}), 93 | ('{{ SystemExit }}', {}), 94 | ('{{ __name__ }}', {}), 95 | ('{{ __import__ }}', {}), 96 | ]) 97 | def test_name_error(tpl, context): 98 | with pytest.raises(NameError): 99 | assert Template(tpl).render(**context) 100 | 101 | 102 | def test_include(): 103 | with open(os.path.join(template_dir, 'index.html')) as fp: 104 | template = Template(fp.read(), template_dir=template_dir) 105 | assert template.render(items=[1, 2, 3]) == ( 106 | '
      ' 107 | '
    • 1
    • ' 108 | '
    • 2
    • ' 109 | '
    • 3
    • \n' 110 | '
    \n' 111 | ) 112 | 113 | 114 | def test_extends(): 115 | with open(os.path.join(template_dir, 'child.html')) as fp: 116 | template = Template(fp.read(), template_dir=template_dir) 117 | expect = ''' 118 |

    hello

    119 | child_header parent_header 120 |

    world

    121 | child_footer 122 |
    • 1
    • 2
    • 3
    • 123 |
    124 | yes 125 |

    !

    126 | 127 | ''' 128 | result = template.render(items=[1, 2, 3]) 129 | assert result == expect 130 | -------------------------------------------------------------------------------- /tests/test_utils.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | import base64 3 | import json 4 | 5 | import pytest 6 | 7 | from bustard.utils import ( 8 | MultiDict, parse_query_string, parse_basic_auth_header, 9 | to_header_key, to_text, to_bytes, json_dumps_default, 10 | Authorization 11 | ) 12 | 13 | 14 | def test_multidict(): 15 | d = MultiDict({'a': 1, 'b': 'a'}) 16 | assert d['a'] == 1 17 | assert d['b'] == 'a' 18 | 19 | d['a'] = 2 20 | assert d['a'] == 2 21 | assert d.getlist('a') == [2] 22 | d['a'] = [1, 2] 23 | assert d['a'] == 2 24 | assert d.getlist('a') == [1, 2] 25 | 26 | assert d.to_dict() == {'a': [1, 2], 'b': ['a']} 27 | 28 | 29 | def test_json_dumps_default(): 30 | d = MultiDict({'a': 1}) 31 | assert json.dumps(d, default=json_dumps_default) == json.dumps({'a': [1]}) 32 | 33 | d['b'] = [1, 2, 3] 34 | assert ( 35 | json.dumps(d, default=json_dumps_default, sort_keys=True) == 36 | json.dumps(d.to_dict(), sort_keys=True) 37 | ) 38 | 39 | 40 | @pytest.mark.parametrize('qs, expect', [ 41 | ('a=b', {'a': ['b']}), 42 | ('a=b&a=c', {'a': ['b', 'c']}), 43 | ('a=b&d&a=c', {'a': ['b', 'c']}), 44 | ('a=b&d=abc&a=c', {'a': ['b', 'c'], 'd': ['abc']}), 45 | ]) 46 | def test_parse_query_string(qs, expect): 47 | assert parse_query_string(qs) == expect 48 | 49 | 50 | @pytest.mark.parametrize('key, expect', [ 51 | ('abc', 'Abc'), 52 | ('abc_name', 'Abc_name'), 53 | ('UserAgent', 'Useragent'), 54 | ('user-Agent', 'User-Agent'), 55 | ('x-rage', 'X-Rage'), 56 | ]) 57 | def test_to_header_key(key, expect): 58 | assert to_header_key(key) == expect 59 | 60 | 61 | @pytest.mark.parametrize('st, expect', [ 62 | (b'abc', 'abc'), 63 | ('你好'.encode('utf8'), '你好'), 64 | ]) 65 | def test_to_text(st, expect): 66 | assert to_text(st) == expect 67 | 68 | 69 | @pytest.mark.parametrize('bt, expect', [ 70 | ('abc', b'abc'), 71 | ('你好', '你好'.encode('utf8')), 72 | ]) 73 | def test_to_bytes(bt, expect): 74 | assert to_bytes(bt) == expect 75 | 76 | 77 | @pytest.mark.parametrize('value, expect', [ 78 | ('', None), 79 | ('basic user:passwd', None), 80 | ('Basic user:passwd', None), 81 | ('basic user:{}'.format(base64.b64encode(b'passwd').decode()), None), 82 | ('basic {}'.format(base64.b64encode(b'user:passwd').decode()), 83 | Authorization('basic', 'user', 'passwd') 84 | ), 85 | ('Basic {}'.format(base64.b64encode(b'user:passwd').decode()), 86 | Authorization('basic', 'user', 'passwd') 87 | ), 88 | ]) 89 | def test_parse_basic_auth_header(value, expect): 90 | assert parse_basic_auth_header(value) == expect 91 | -------------------------------------------------------------------------------- /tests/test_views.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | import pytest 3 | 4 | from bustard.app import Bustard 5 | from bustard.views import View 6 | from bustard.utils import to_bytes 7 | 8 | app = Bustard() 9 | 10 | 11 | @pytest.yield_fixture 12 | def client(): 13 | yield app.test_client() 14 | 15 | 16 | def login_required(view): 17 | 18 | def wrapper(request, *args, **kwargs): 19 | if request.method == 'DELETE': 20 | app.abort(403) 21 | return view(request, *args, **kwargs) 22 | return wrapper 23 | 24 | 25 | class IndexView(View): 26 | decorators = (login_required,) 27 | 28 | def get(self, request, name): 29 | return name 30 | 31 | def post(self, request, name): 32 | return '{} post'.format(name) 33 | 34 | def delete(self, request, name): 35 | return 'delete' 36 | 37 | 38 | app.add_url_rule('/hello/(?P\w+)', IndexView.as_view()) 39 | 40 | 41 | class TestView: 42 | 43 | def test_get(self, client): 44 | name = 'Tom' 45 | response = client.get(app.url_for('IndexView', name=name)) 46 | assert response.data == to_bytes(name) 47 | 48 | def test_post(self, client): 49 | name = 'Tom' 50 | response = client.post(app.url_for('IndexView', name=name)) 51 | assert response.data == to_bytes(name + ' post') 52 | 53 | def test_404(self, client): 54 | response = client.get('/hello/--') 55 | assert response.status_code == 404 56 | 57 | def test_405(self, client): 58 | response = client.put('/hello/aaa') 59 | assert response.status_code == 405 60 | 61 | def test_403(self, client): 62 | response = client.delete('/hello/aaa') 63 | assert response.status_code == 403 64 | -------------------------------------------------------------------------------- /tests/utils.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | import types 3 | import os 4 | 5 | CURRENT_DIR = os.path.dirname(os.path.abspath(__file__)) 6 | 7 | 8 | def copy_func(func, name=None): 9 | new_func = types.FunctionType( 10 | func.__code__, func.__globals__, 11 | name or func.__name__, 12 | func.__defaults__, func.__closure__ 13 | ) 14 | new_func.__dict__.update(func.__dict__) 15 | return new_func 16 | --------------------------------------------------------------------------------