├── .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 |
.*?)
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 |
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 |
14 | {% endblock body %}
15 |
--------------------------------------------------------------------------------
/examples/flaskr/templates/show_entries.html:
--------------------------------------------------------------------------------
1 | {% extends "layout.html" %}
2 | {% block body %}
3 | {% if session.get('logged_in') %}
4 |
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 |
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 |
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 |
8 | /
This page.
9 | /ip
Returns Origin IP.
10 | /user-agent
Returns user-agent.
11 | /headers
Returns header dict.
12 | /get
Returns GET data.
13 | /post
Returns POST data.
14 | /patch
Returns PATCH data.
15 | /put
Returns PUT data.
16 | /delete
Returns DELETE data
17 | /encoding/utf8
Returns page containing UTF-8 data.
18 | /gzip
Returns gzip-encoded data.
19 | /deflate
Returns deflate-encoded data.
20 | /status/:code
Returns given HTTP Status code.
21 | /response-headers?key=val
Returns given response headers.
22 | /redirect/:n
302 Redirects n times.
23 | /redirect-to?url=foo
302 Redirects to the foo URL.
24 | /relative-redirect/:n
302 Relative redirects n times.
25 | /absolute-redirect/:n
302 Absolute redirects n times.
26 | /cookies
Returns cookie data.
27 | /cookies/set?name=value
Sets one or more simple cookies.
28 | /cookies/delete?name
Deletes one or more simple cookies.
29 | /basic-auth/:user/:passwd
Challenges HTTPBasic Auth.
30 | /hidden-basic-auth/:user/:passwd
404'd BasicAuth.
31 |
32 | /stream/:n
Streams min(n, 100) lines.
33 | /delay/:n
Delays responding for min(n, 10) seconds.
34 | /drip?numbytes=n&duration=s&delay=s&code=code
Drips data over a duration after an optional initial delay, then (optionally) returns with the given status code.
35 | /range/1024?duration=s&chunk_size=code
Streams n bytes, and allows specifying a Range header to select a subset of the data. Accepts a chunk_size and request duration parameter.
36 | /html
Renders an HTML Page.
37 | /robots.txt
Returns some robots.txt rules.
38 | /deny
Denied by robots.txt file.
39 | /cache
Returns 200 unless an If-Modified-Since or If-None-Match header is provided, when it returns a 304.
40 | {# /cache/:n
Sets a Cache-Control header for n seconds. #}
41 | {# /bytes/:n
Generates n random bytes of binary data, accepts optional seed integer parameter. #}
42 | {# /stream-bytes/:n
Streams n random bytes of binary data, accepts optional seed and chunk_size integer parameters. #}
43 | {# /links/:n
Returns page containing n HTML links. #}
44 | /image
Returns page containing an image based on sent Accept header.
45 | /image/png
Returns page containing a PNG image.
46 | /image/jpeg
Returns page containing a JPEG image.
47 | /image/webp
Returns page containing a WEBP image.
48 | /image/svg
Returns page containing a SVG image.
49 | /forms/post
HTML form that submits to /post
50 | /xml
Returns some XML
51 |
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 |
147 | Originally created by Kenneth Reitz.
148 |
149 | SEE ALSO
150 |
151 | Hurl.it - Make HTTP requests.
152 | RequestBin - Inspect HTTP requests.
153 |
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 |
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 |
--------------------------------------------------------------------------------