├── test ├── __init__.py ├── data │ ├── bar │ ├── foo.txt │ └── foo.c ├── __main__.py ├── data.py ├── test_multi_dict.py ├── test_cube.py ├── test_regex_route.py ├── test_response.py ├── test_request.py ├── test_wildcard.py ├── test_router.py ├── test_ice.py ├── test_wildcard_route.py └── test_examples.py ├── MANIFEST.in ├── .gitignore ├── docs ├── ice.rst ├── index.rst ├── conf.py ├── Makefile ├── make.bat └── tutorial.rst ├── .travis.yml ├── CHANGES.rst ├── Makefile ├── LICENSE.rst ├── setup.py ├── README.rst └── ice.py /test/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /test/data/bar: -------------------------------------------------------------------------------- 1 |

bar

2 | -------------------------------------------------------------------------------- /test/data/foo.txt: -------------------------------------------------------------------------------- 1 | foo 2 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include *.rst 2 | -------------------------------------------------------------------------------- /test/data/foo.c: -------------------------------------------------------------------------------- 1 | #include 2 | 3 | int main() 4 | { 5 | printf("hello, world\n"); 6 | return 0; 7 | } 8 | -------------------------------------------------------------------------------- /test/__main__.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | suite = unittest.defaultTestLoader.discover('.') 3 | unittest.TextTestRunner(verbosity=2).run(suite) 4 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.sw? 2 | /.coverage 3 | /install.txt 4 | __pycache__/ 5 | /MANIFEST 6 | /dist/ 7 | /build/ 8 | /htmlcov/ 9 | /docs/_build/ 10 | -------------------------------------------------------------------------------- /docs/ice.rst: -------------------------------------------------------------------------------- 1 | .. _api: 2 | 3 | API Documentation 4 | ================= 5 | 6 | ice module 7 | ---------- 8 | .. automodule:: ice 9 | :members: 10 | :undoc-members: 11 | :show-inheritance: 12 | -------------------------------------------------------------------------------- /docs/index.rst: -------------------------------------------------------------------------------- 1 | .. include:: ../README.rst 2 | 3 | 4 | Tutorial & API 5 | -------------- 6 | .. toctree:: 7 | :maxdepth: 2 8 | 9 | tutorial 10 | ice 11 | 12 | 13 | Indices 14 | ------- 15 | * :ref:`genindex` 16 | * :ref:`modindex` 17 | * :ref:`search` 18 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: python 2 | python: 3 | - "3.3" 4 | - "3.4" 5 | - "3.5" 6 | - "3.6" 7 | install: 8 | - pip install coveralls 9 | script: 10 | - python -m unittest -v 11 | - coverage run --branch -m test 12 | - coverage report 13 | - python setup.py install 14 | - python setup.py sdist 15 | after_success: 16 | - coveralls 17 | -------------------------------------------------------------------------------- /CHANGES.rst: -------------------------------------------------------------------------------- 1 | ChangeLog 2 | ========= 3 | 4 | 0.0.2 (2017-09-06) 5 | ------------------ 6 | - NEW: Rudimentary error message when no error handler is defined. 7 | - NEW: Return integer status code from a route's callable. 8 | - NEW: Wildcard pattern of path type, e.g. ``<:path>``, to match paths. 9 | - NEW: Return static files using the ``static()`` method. 10 | - NEW: Send attachment to client using the ``download()`` method. 11 | - NEW: Cookies dictionary in request object. 12 | - NEW: Set cookie in response header using the ``set_cookie()`` method. 13 | - NEW: Send redirects by returning tuple of status code and URL. 14 | 15 | 0.0.1 (2014-06-05) 16 | ------------------ 17 | - NEW: Literal, wildcard and regular expression routes. 18 | - NEW: Anonymous, named and throwaway wildcards. 19 | - NEW: Query and form dictionaries in request object. 20 | - NEW: Custom error pages. 21 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | ifeq ($(OS), Windows_NT) 2 | PYTHON = python 3 | else 4 | PYTHON = python3 5 | endif 6 | 7 | build: 8 | $(PYTHON) setup.py build 9 | 10 | install: 11 | $(PYTHON) setup.py install --record install.txt 12 | 13 | test: .FORCE 14 | $(PYTHON) -m unittest -vf 15 | 16 | coverage: 17 | coverage run --branch -m test 18 | coverage report -m 19 | coverage html 20 | 21 | test-release: 22 | $(PYTHON) setup.py register -r https://testpypi.python.org/pypi 23 | $(PYTHON) setup.py sdist upload -r https://testpypi.python.org/pypi 24 | 25 | release: 26 | $(PYTHON) setup.py register 27 | $(PYTHON) setup.py sdist upload 28 | 29 | build-rtd: 30 | # See http://docs.readthedocs.io/en/latest/webhooks.html#others 31 | curl -XPOST http://readthedocs.org/build/icepy 32 | 33 | clean: 34 | rm -rf build dist MANIFEST install.txt 35 | rm -rf .coverage htmlcov 36 | rm -rf docs/_build 37 | find . -name "__pycache__" -exec rm -r {} + 38 | find . -name "*.pyc" -exec rm {} + 39 | 40 | .FORCE: 41 | 42 | # vim: noet 43 | -------------------------------------------------------------------------------- /docs/conf.py: -------------------------------------------------------------------------------- 1 | import sys 2 | import os 3 | import time 4 | 5 | sys.path.insert(0, os.path.dirname(os.path.abspath('.'))) 6 | 7 | import ice 8 | 9 | extensions = ['sphinx.ext.autodoc', 'sphinx.ext.napoleon'] 10 | templates_path = ['_templates'] 11 | source_suffix = '.rst' 12 | master_doc = 'index' 13 | 14 | project = 'Ice' 15 | copyright = '2014-{}, {}'.format(time.strftime('%Y'), ice.__author__) 16 | author = ice.__author__ 17 | version = '.'.join(ice.__version__.split('.')[:2]) 18 | release = ice.__version__ 19 | 20 | language = None 21 | exclude_patterns = ['_build'] 22 | pygments_style = 'sphinx' 23 | todo_include_todos = False 24 | html_static_path = ['_static'] 25 | 26 | 27 | htmlhelp_basename = 'Icedoc' 28 | latex_elements = { 29 | } 30 | 31 | latex_documents = [ 32 | (master_doc, 'Ice.tex', 'Ice Documentation', 33 | 'Susam Pal', 'manual'), 34 | ] 35 | 36 | man_pages = [ 37 | (master_doc, 'ice', 'Ice Documentation', 38 | [author], 1) 39 | ] 40 | 41 | texinfo_documents = [ 42 | (master_doc, 'Ice', 'Ice Documentation', 43 | author, 'Ice', 'One line description of project.', 44 | 'Miscellaneous'), 45 | ] 46 | -------------------------------------------------------------------------------- /LICENSE.rst: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | ===================== 3 | 4 | Copyright (c) 2014-2017 Susam Pal 5 | 6 | Permission is hereby granted, free of charge, to any person obtaining 7 | a copy of this software and associated documentation files (the 8 | "Software"), to deal in the Software without restriction, including 9 | without limitation the rights to use, copy, modify, merge, publish, 10 | distribute, sublicense, and/or sell copies of the Software, and to 11 | permit persons to whom the Software is furnished to do so, subject to 12 | the following conditions: 13 | 14 | The above copyright notice and this permission notice shall be 15 | included in all copies or substantial portions of the Software. 16 | 17 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 18 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 19 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. 20 | IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY 21 | CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, 22 | TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE 23 | SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 24 | -------------------------------------------------------------------------------- /test/data.py: -------------------------------------------------------------------------------- 1 | # The MIT License (MIT) 2 | # 3 | # Copyright (c) 2014-2017 Susam Pal 4 | # 5 | # Permission is hereby granted, free of charge, to any person obtaining 6 | # a copy of this software and associated documentation files (the 7 | # "Software"), to deal in the Software without restriction, including 8 | # without limitation the rights to use, copy, modify, merge, publish, 9 | # distribute, sublicense, and/or sell copies of the Software, and to 10 | # permit persons to whom the Software is furnished to do so, subject to 11 | # the following conditions: 12 | # 13 | # The above copyright notice and this permission notice shall be 14 | # included in all copies or substantial portions of the Software. 15 | # 16 | # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 17 | # EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 18 | # MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. 19 | # IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY 20 | # CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, 21 | # TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE 22 | # SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 23 | 24 | 25 | """Test data related data and utility functions. 26 | 27 | This module provides variables and functions to conveniently determine 28 | paths of test data files. This module may be used by unit tests that 29 | need to load test data from filesystem to perform tests. 30 | 31 | Attributes: 32 | dirpath -- Absolute path to the base of the test data directory 33 | """ 34 | 35 | 36 | import os 37 | 38 | 39 | dirpath = os.path.abspath(os.path.join(os.path.dirname(__file__), 'data')) 40 | 41 | 42 | def filepath(*paths): 43 | """Join test data directory path and specified path components. 44 | 45 | Arguments: 46 | paths -- One or more path components specified as separate arguments 47 | of type str (type: list) 48 | 49 | Return: Concatenation of test directory path and path components 50 | (type: str) 51 | """ 52 | return os.path.join(dirpath, *paths) 53 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | # The MIT License (MIT) 2 | # 3 | # Copyright (c) 2014-2017 Susam Pal 4 | # 5 | # Permission is hereby granted, free of charge, to any person obtaining 6 | # a copy of this software and associated documentation files (the 7 | # "Software"), to deal in the Software without restriction, including 8 | # without limitation the rights to use, copy, modify, merge, publish, 9 | # distribute, sublicense, and/or sell copies of the Software, and to 10 | # permit persons to whom the Software is furnished to do so, subject to 11 | # the following conditions: 12 | # 13 | # The above copyright notice and this permission notice shall be 14 | # included in all copies or substantial portions of the Software. 15 | # 16 | # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 17 | # EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 18 | # MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. 19 | # IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY 20 | # CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, 21 | # TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE 22 | # SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 23 | 24 | 25 | """Ice setup script.""" 26 | 27 | 28 | from distutils.core import setup 29 | import ice 30 | 31 | 32 | _description = ice.__doc__.strip().split('\n', 1)[0] 33 | _long_description = open('README.rst').read() 34 | 35 | 36 | setup(name='ice', 37 | version=ice.__version__, 38 | description=_description, 39 | long_description=_long_description, 40 | author='Susam Pal', 41 | author_email='susam@susam.in', 42 | url='https://github.com/susam/ice', 43 | download_url='https://pypi.python.org/pypi/ice', 44 | py_modules=['ice'], 45 | classifiers=[ 46 | 'Development Status :: 2 - Pre-Alpha', 47 | 'Environment :: Console', 48 | 'Environment :: Web Environment', 49 | 'Intended Audience :: Developers', 50 | 'Intended Audience :: End Users/Desktop', 51 | 'License :: OSI Approved :: MIT License', 52 | 'Operating System :: OS Independent', 53 | 'Programming Language :: Python :: 3.4', 54 | 'Topic :: Internet :: WWW/HTTP :: WSGI :: Application', 55 | 'Topic :: Software Development :: Libraries :: Python Modules' 56 | ], 57 | license='MIT License', 58 | keywords=['wsgi', 'web', 'www', 'framework'], 59 | platforms=['Any']) 60 | -------------------------------------------------------------------------------- /test/test_multi_dict.py: -------------------------------------------------------------------------------- 1 | # The MIT License (MIT) 2 | # 3 | # Copyright (c) 2014-2017 Susam Pal 4 | # 5 | # Permission is hereby granted, free of charge, to any person obtaining 6 | # a copy of this software and associated documentation files (the 7 | # "Software"), to deal in the Software without restriction, including 8 | # without limitation the rights to use, copy, modify, merge, publish, 9 | # distribute, sublicense, and/or sell copies of the Software, and to 10 | # permit persons to whom the Software is furnished to do so, subject to 11 | # the following conditions: 12 | # 13 | # The above copyright notice and this permission notice shall be 14 | # included in all copies or substantial portions of the Software. 15 | # 16 | # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 17 | # EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 18 | # MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. 19 | # IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY 20 | # CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, 21 | # TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE 22 | # SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 23 | 24 | 25 | """Tests for class MultiDict.""" 26 | 27 | 28 | import unittest 29 | import ice 30 | 31 | 32 | class MultiDictTest(unittest.TestCase): 33 | 34 | def test_setitem(self): 35 | d = ice.MultiDict() 36 | d['a'] = 'foo' 37 | d['b'] = 'bar' 38 | d['b'] = 'baz' 39 | self.assertEqual(d.data, {'a': ['foo'], 'b': ['bar', 'baz']}) 40 | 41 | def test_getitem(self): 42 | d = ice.MultiDict() 43 | d['a'] = 'foo' 44 | self.assertEqual(d['a'], 'foo') 45 | 46 | def test_get(self): 47 | d = ice.MultiDict() 48 | d['a'] = 'foo' 49 | self.assertEqual(d.get('a'), 'foo') 50 | self.assertEqual(d.get('b', 'bar'), 'bar') 51 | 52 | def test_getitem_for_multiple_values(self): 53 | d = ice.MultiDict() 54 | d['a'] = 'foo' 55 | d['a'] = 'bar' 56 | self.assertEqual(d['a'], 'bar') 57 | 58 | def test_len(self): 59 | d = ice.MultiDict() 60 | d['a'] = 'foo' 61 | d['b'] = 'bar' 62 | d['b'] = 'baz' 63 | self.assertEqual(len(d), 2) 64 | 65 | def test_missing_key(self): 66 | d = ice.MultiDict() 67 | with self.assertRaises(KeyError) as cm: 68 | d['foo'] 69 | self.assertEqual(str(cm.exception), "'foo'") 70 | 71 | def test_getall_for_single_value(self): 72 | d = ice.MultiDict() 73 | d['a'] = 'foo' 74 | self.assertEqual(d.getall('a'), ['foo']) 75 | 76 | def test_getall_for_multiple_value(self): 77 | d = ice.MultiDict() 78 | d['a'] = 'foo' 79 | d['a'] = 'bar' 80 | self.assertEqual(d.getall('a'), ['foo', 'bar']) 81 | 82 | def test_getall_for_missing_key(self): 83 | d = ice.MultiDict() 84 | self.assertEqual(d.getall('a'), []) 85 | 86 | def test_getall_default_value_for_missing_key(self): 87 | d = ice.MultiDict() 88 | self.assertEqual(d.getall('a', 'foo'), 'foo') 89 | -------------------------------------------------------------------------------- /test/test_cube.py: -------------------------------------------------------------------------------- 1 | # The MIT License (MIT) 2 | # 3 | # Copyright (c) 2014-2017 Susam Pal 4 | # 5 | # Permission is hereby granted, free of charge, to any person obtaining 6 | # a copy of this software and associated documentation files (the 7 | # "Software"), to deal in the Software without restriction, including 8 | # without limitation the rights to use, copy, modify, merge, publish, 9 | # distribute, sublicense, and/or sell copies of the Software, and to 10 | # permit persons to whom the Software is furnished to do so, subject to 11 | # the following conditions: 12 | # 13 | # The above copyright notice and this permission notice shall be 14 | # included in all copies or substantial portions of the Software. 15 | # 16 | # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 17 | # EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 18 | # MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. 19 | # IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY 20 | # CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, 21 | # TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE 22 | # SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 23 | 24 | 25 | """Tests for class ice cube.""" 26 | 27 | 28 | import unittest 29 | import ice 30 | import threading 31 | import urllib.request 32 | import textwrap 33 | import time 34 | 35 | class IceTest(unittest.TestCase): 36 | def setUp(self): 37 | self.app = ice.cube() 38 | 39 | def tearDown(self): 40 | self.app.exit() 41 | 42 | def run_app(self): 43 | threading.Thread(target=self.app.run).start() 44 | while not self.app.running(): 45 | time.sleep(0.1) 46 | 47 | def test_default_page(self): 48 | self.run_app() 49 | response = urllib.request.urlopen('http://127.0.0.1:8080/') 50 | self.assertEqual(response.read(), 51 | b'\n' 52 | b'\n' 53 | b'It works!\n' 54 | b'\n' 55 | b'

It works!

\n' 56 | b'

This is the default ice web page.

\n' 57 | b'\n' 58 | b'\n') 59 | 60 | def test_error_page(self): 61 | self.run_app() 62 | with self.assertRaises(urllib.error.HTTPError) as cm: 63 | urllib.request.urlopen('http://127.0.0.1:8080/foo') 64 | self.assertEqual(cm.exception.code, 404) 65 | self.assertEqual(cm.exception.read(), 66 | b'\n' 67 | b'\n' 68 | b'404 Not Found\n' 69 | b'\n' 70 | b'

404 Not Found

\n' 71 | b'

Nothing matches the given URI

\n' 72 | b'
\n' 73 | b'
Ice/' + ice.__version__.encode() + 74 | b'
\n' 75 | b'\n' 76 | b'\n') 77 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | Ice - WSGI on the rocks 2 | ======================= 3 | Ice is a Python module with a WSGI microframework meant for developing 4 | small web applications in Python. It is a single file Python module 5 | inspired by `Bottle`_. 6 | 7 | .. image:: https://travis-ci.org/susam/ice.svg?branch=master 8 | :target: https://travis-ci.org/susam/ice 9 | 10 | .. image:: https://coveralls.io/repos/susam/ice/badge.svg?branch=master 11 | :target: https://coveralls.io/r/susam/ice?branch=master 12 | 13 | .. image:: https://img.shields.io/badge/license-MIT-blue.svg 14 | :target: https://github.com/susam/ice/blob/master/LICENSE.rst 15 | 16 | Why Ice? 17 | -------- 18 | This microframework was born as a result of experimenting with WSGI 19 | framework. Since what started as a small experiment turned out to be 20 | several hundred lines of code, it made sense to share the source code on 21 | the web, just in case anyone else benefits from it. 22 | 23 | This microframework has a very limited set of features currently. It may 24 | be used to develop small web applications. For large web applications, 25 | it may make more sense to use a more wholesome framework such as 26 | `Flask`_ or `Django`_. 27 | 28 | It is possible that you may find that this framework is missing a useful 29 | API that another major framework provides. In such a case, you have 30 | direct access to the WSGI internals to do what you want via the 31 | documented `API`_. 32 | 33 | If you believe that a missing feature or a bug fix would be useful to 34 | others, you may `report an issue`_, or even better, fork this `project 35 | on GitHub`_, develop the missing feature or the bug fix, and send a 36 | patch or a pull request. In fact, you are very welcome to do so, and 37 | turn this experimental project into a matured one by contributing your 38 | code and expertise. 39 | 40 | .. _Bottle: https://bottlepy.org/ 41 | .. _Flask: http://flask.pocoo.org/ 42 | .. _Django: https://www.djangoproject.com/ 43 | .. _API: http://icepy.readthedocs.io/en/latest/ice.html 44 | .. _report an issue: https://github.com/susam/ice/issues 45 | .. _project on GitHub: https://github.com/susam/ice 46 | 47 | 48 | Requirements 49 | ------------ 50 | This module should be used with Python 3.3 or any later version of 51 | Python interpreter. 52 | 53 | This module depends only on the Python standard library. It does not 54 | depend on any third party libraries. 55 | 56 | 57 | Installation 58 | ------------ 59 | You can install this module using pip3 using the following command. :: 60 | 61 | pip3 install ice 62 | 63 | You can install this module from source distribution. To do so, 64 | download the latest .tar.gz file from https://pypi.python.org/pypi/ice, 65 | extract it, then open command prompt or shell, and change your current 66 | directory to the directory where you extracted the source distribution, 67 | and then execute the following command. :: 68 | 69 | python3 setup.py install 70 | 71 | Note that on a Windows system, you may have to replace ``python3`` with 72 | the path to your Python 3 interpreter. 73 | 74 | 75 | Resources 76 | --------- 77 | Here is a list of useful links about this project. 78 | 79 | - `Documentation on Read The Docs `_ 80 | - `Latest release on PyPI `_ 81 | - `Source code on GitHub `_ 82 | - `Issue tracker on GitHub `_ 83 | - `Changelog on GitHub 84 | `_ 85 | 86 | 87 | Support 88 | ------- 89 | To report bugs, suggest improvements, or ask questions, please create a 90 | new issue at http://github.com/susam/ice/issues. 91 | 92 | 93 | License 94 | ------- 95 | This is free software. You are permitted to use, copy, modify, merge, 96 | publish, distribute, sublicense, and/or sell copies of it, under the 97 | terms of the MIT License. See `LICENSE.rst`_ for the complete license. 98 | 99 | This software is provided WITHOUT ANY WARRANTY; without even the implied 100 | warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See 101 | `LICENSE.rst`_ for the complete disclaimer. 102 | 103 | .. _LICENSE.rst: https://github.com/susam/ice/blob/master/LICENSE.rst 104 | -------------------------------------------------------------------------------- /test/test_regex_route.py: -------------------------------------------------------------------------------- 1 | # The MIT License (MIT) 2 | # 3 | # Copyright (c) 2014-2017 Susam Pal 4 | # 5 | # Permission is hereby granted, free of charge, to any person obtaining 6 | # a copy of this software and associated documentation files (the 7 | # "Software"), to deal in the Software without restriction, including 8 | # without limitation the rights to use, copy, modify, merge, publish, 9 | # distribute, sublicense, and/or sell copies of the Software, and to 10 | # permit persons to whom the Software is furnished to do so, subject to 11 | # the following conditions: 12 | # 13 | # The above copyright notice and this permission notice shall be 14 | # included in all copies or substantial portions of the Software. 15 | # 16 | # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 17 | # EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 18 | # MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. 19 | # IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY 20 | # CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, 21 | # TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE 22 | # SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 23 | 24 | 25 | """Tests for class ice.WildcardRoute.""" 26 | 27 | 28 | import unittest 29 | from unittest import mock 30 | import ice 31 | 32 | 33 | class RegexRouteTest(unittest.TestCase): 34 | def test_regex_without_capturing_groups(self): 35 | m = mock.Mock() 36 | 37 | r = ice.RegexRoute('/.*', m) 38 | self.assertEqual(r.match('/.*'), (m, [], {})) 39 | self.assertEqual(r.match('/foo'), (m, [], {})) 40 | self.assertEqual(r.match('/foo/bar'), (m, [], {})) 41 | self.assertEqual(r.match('foo/bar'), (m, [], {})) 42 | self.assertIsNone(r.match('.*')) 43 | self.assertIsNone(r.match('foo')) 44 | 45 | r = ice.RegexRoute('^/.*$', m) 46 | self.assertEqual(r.match('/.*'), (m, [], {})) 47 | self.assertEqual(r.match('/foo'), (m, [], {})) 48 | self.assertEqual(r.match('/foo/bar'), (m, [], {})) 49 | self.assertIsNone(r.match('.*')) 50 | self.assertIsNone(r.match('foo')) 51 | self.assertIsNone(r.match('foo/bar')) 52 | 53 | r = ice.RegexRoute(r'/\w{3}', m) 54 | self.assertEqual(r.match('/foo'), (m, [], {})) 55 | self.assertEqual(r.match('/bar'), (m, [], {})) 56 | self.assertEqual(r.match('/foo/bar'), (m, [], {})) 57 | self.assertIsNone(r.match('/.*')) 58 | 59 | r = ice.RegexRoute(r'^/\w{3}$', m) 60 | self.assertEqual(r.match('/foo'), (m, [], {})) 61 | self.assertEqual(r.match('/bar'), (m, [], {})) 62 | self.assertIsNone(r.match('/.*')) 63 | self.assertIsNone(r.match('/foo/bar')) 64 | 65 | def test_regex_with_capturing_groups(self): 66 | m = mock.Mock() 67 | 68 | r = ice.RegexRoute('/(.*)', m) 69 | self.assertEqual(r.match('/.*'), (m, ['.*'], {})) 70 | self.assertEqual(r.match('/foo'), (m, ['foo'], {})) 71 | self.assertEqual(r.match('/foo/bar'), (m, ['foo/bar'], {})) 72 | self.assertEqual(r.match('foo/bar'), (m, ['bar'], {})) 73 | self.assertIsNone(r.match('.*')) 74 | self.assertIsNone(r.match('foo')) 75 | 76 | r = ice.RegexRoute('^/(.*)$', m) 77 | self.assertEqual(r.match('/.*'), (m, ['.*'], {})) 78 | self.assertEqual(r.match('/foo'), (m, ['foo'], {})) 79 | self.assertEqual(r.match('/foo/bar'), (m, ['foo/bar'], {})) 80 | self.assertIsNone(r.match('.*')) 81 | self.assertIsNone(r.match('foo')) 82 | self.assertIsNone(r.match('foo/bar')) 83 | 84 | r = ice.RegexRoute('/<>//(.*)', m) 85 | self.assertEqual(r.match('/<>//foo'), (m, ['foo'], {})) 86 | self.assertEqual(r.match('~/<>//foo'), (m, ['foo'], {})) 87 | self.assertIsNone(r.match('/foo/bar/(.*)')) 88 | self.assertIsNone(r.match('/foo/bar/baz')) 89 | self.assertIsNone(r.match('/foo/bar/(baz)')) 90 | 91 | r = ice.RegexRoute('^/<>//(.*)$', m) 92 | self.assertEqual(r.match('/<>//foo'), (m, ['foo'], {})) 93 | self.assertIsNone(r.match('/foo/bar/(.*)')) 94 | self.assertIsNone(r.match('/foo/bar/baz')) 95 | self.assertIsNone(r.match('/foo/bar/(baz)')) 96 | self.assertIsNone(r.match('~/<>//foo')) 97 | 98 | def test_regex_with_symbolic_groups(self): 99 | m = mock.Mock() 100 | r = ice.RegexRoute('^/(?P(?:foo|bar))$', m) 101 | self.assertEqual(r.match('/foo'), (m, [], {'a': 'foo'})) 102 | self.assertEqual(r.match('/bar'), (m, [], {'a': 'bar'})) 103 | self.assertIsNone(r.match('/foo/bar')) 104 | 105 | def test_regex_with_symbolic_and_non_symbolic_groups(self): 106 | m = mock.Mock() 107 | r = ice.RegexRoute('^/([a-z]+)(?:\d+)' 108 | '/(?P[a-z/]+)(?P\d+)$', m) 109 | self.assertEqual(r.match('/foo123/bar456'), 110 | (m, ['foo'], {'a': 'bar', 'b': '456'})) 111 | self.assertIsNone(r.match('/foo123/bar456/')) 112 | 113 | def test_like(self): 114 | self.assertTrue(ice.RegexRoute.like('^/(?P(?:foo|bar))$')) 115 | self.assertFalse(ice.RegexRoute.like('^/.*')) 116 | self.assertFalse(ice.RegexRoute.like('^/<>')) 117 | -------------------------------------------------------------------------------- /test/test_response.py: -------------------------------------------------------------------------------- 1 | # The MIT License (MIT) 2 | # 3 | # Copyright (c) 2014-2017 Susam Pal 4 | # 5 | # Permission is hereby granted, free of charge, to any person obtaining 6 | # a copy of this software and associated documentation files (the 7 | # "Software"), to deal in the Software without restriction, including 8 | # without limitation the rights to use, copy, modify, merge, publish, 9 | # distribute, sublicense, and/or sell copies of the Software, and to 10 | # permit persons to whom the Software is furnished to do so, subject to 11 | # the following conditions: 12 | # 13 | # The above copyright notice and this permission notice shall be 14 | # included in all copies or substantial portions of the Software. 15 | # 16 | # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 17 | # EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 18 | # MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. 19 | # IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY 20 | # CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, 21 | # TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE 22 | # SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 23 | 24 | 25 | """Tests for class Response.""" 26 | 27 | 28 | import unittest 29 | from unittest import mock 30 | import ice 31 | 32 | 33 | class ResponseTest(unittest.TestCase): 34 | 35 | def test_start_with_no_body(self): 36 | m = mock.Mock() 37 | ice.Response(m).response() 38 | m.assert_called_with('200 OK', [ 39 | ('Content-Type', 'text/html; charset=UTF-8'), 40 | ('Content-Length', '0') 41 | ]) 42 | 43 | def test_start_with_body(self): 44 | m = mock.Mock() 45 | r = ice.Response(m) 46 | r.body = 'foo' 47 | r.response() 48 | m.assert_called_with('200 OK', [ 49 | ('Content-Type', 'text/html; charset=UTF-8'), 50 | ('Content-Length', '3') 51 | ]) 52 | 53 | def test_response_return_value_with_no_body(self): 54 | self.assertEqual(ice.Response(mock.Mock()).response(), [b'']) 55 | 56 | def test_response_return_value_with_str_body(self): 57 | r = ice.Response(mock.Mock()) 58 | r.body = 'foo' 59 | self.assertEqual(r.response(), [b'foo']) 60 | 61 | def test_response_return_value_with_bytes_body(self): 62 | r = ice.Response(mock.Mock()) 63 | r.body = b'foo' 64 | self.assertEqual(r.response(), [b'foo']) 65 | 66 | def test_status_line(self): 67 | r = ice.Response(mock.Mock()) 68 | r.status = 400 69 | self.assertEqual(r.status_line, '400 Bad Request') 70 | 71 | def test_status_phrase(self): 72 | r = ice.Response(mock.Mock()) 73 | r.status = 400 74 | self.assertEqual(r.status_detail, 75 | 'Bad request syntax or unsupported method') 76 | 77 | def test_none_media_type(self): 78 | m = mock.Mock() 79 | r = ice.Response(m) 80 | r.media_type = None 81 | r.response() 82 | m.assert_called_with('200 OK', [ 83 | ('Content-Length', '0'), 84 | ]) 85 | 86 | def test_text_media_type(self): 87 | m = mock.Mock() 88 | r = ice.Response(m) 89 | r.media_type = 'text/css' 90 | r.response() 91 | m.assert_called_with('200 OK', [ 92 | ('Content-Type', 'text/css; charset=UTF-8'), 93 | ('Content-Length', '0'), 94 | ]) 95 | 96 | def test_non_text_media_type(self): 97 | m = mock.Mock() 98 | r = ice.Response(m) 99 | r.media_type = 'image/png' 100 | r.response() 101 | m.assert_called_with('200 OK', [ 102 | ('Content-Type', 'image/png'), 103 | ('Content-Length', '0'), 104 | ]) 105 | 106 | def test_cookies(self): 107 | m = mock.Mock() 108 | r = ice.Response(m) 109 | r.set_cookie('a', 'foo') 110 | r.set_cookie('b', 'bar', {'path': '/blog'}) 111 | r.set_cookie('c', 'baz', {'secure': True, 'httponly': True}) 112 | r.set_cookie('d', 'qux', {'PaTh': '/blog', 'SeCuRe': True}) 113 | r.response() 114 | # The mock is called with the following arguments. 115 | # 116 | # ('200 OK', [ 117 | # ('Set-Cookie', 'a=foo'), 118 | # ('Set-Cookie', 'b=bar; Path=/blog'), 119 | # ('Set-Cookie', 'c=baz; HttpOnly; Secure'), 120 | # ('Set-Cookie', 'd=qux; Path=/blog; Secure') 121 | # ('Content-Type', 'text/html; charset=UTF-8'), 122 | # ('Content-Length', '0') 123 | # ]) 124 | # 125 | # However, "HttpOnly" and "Secure" may occur in lowercase prior 126 | # to version 3.4.2. See the following URLs for more details. 127 | # 128 | # - https://docs.python.org/3.4/whatsnew/changelog.html 129 | # - http://bugs.python.org/issue23250 130 | 131 | # First item in call_args contains positional arguments and the 132 | # second contains keyword arguments. We pick up the positional 133 | # arguments and then within that we pick up the list of headers. 134 | headers = m.call_args[0][1] 135 | 136 | # Convert all cookies to lower case for case-insensitive 137 | # matching. 138 | cookies = [] 139 | for field, value in headers: 140 | if field == 'Set-Cookie': 141 | cookies.append(value.lower()) 142 | 143 | # Verify that expected cookies are present in the response. 144 | self.assertIn('a=foo', cookies) 145 | self.assertIn('b=bar; path=/blog', cookies) 146 | self.assertIn('c=baz; httponly; secure', cookies) 147 | self.assertIn('d=qux; path=/blog; secure', cookies) 148 | 149 | -------------------------------------------------------------------------------- /test/test_request.py: -------------------------------------------------------------------------------- 1 | # The MIT License (MIT) 2 | # 3 | # Copyright (c) 2014-2017 Susam Pal 4 | # 5 | # Permission is hereby granted, free of charge, to any person obtaining 6 | # a copy of this software and associated documentation files (the 7 | # "Software"), to deal in the Software without restriction, including 8 | # without limitation the rights to use, copy, modify, merge, publish, 9 | # distribute, sublicense, and/or sell copies of the Software, and to 10 | # permit persons to whom the Software is furnished to do so, subject to 11 | # the following conditions: 12 | # 13 | # The above copyright notice and this permission notice shall be 14 | # included in all copies or substantial portions of the Software. 15 | # 16 | # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 17 | # EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 18 | # MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. 19 | # IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY 20 | # CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, 21 | # TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE 22 | # SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 23 | 24 | 25 | """Tests for class Request.""" 26 | 27 | 28 | import unittest 29 | import io 30 | import ice 31 | 32 | 33 | class RequestTest(unittest.TestCase): 34 | 35 | def test_environ(self): 36 | r = ice.Request({'foo': 'bar'}) 37 | self.assertEqual(r.environ, {'foo': 'bar'}) 38 | 39 | def test_method(self): 40 | r = ice.Request({'REQUEST_METHOD': 'HEAD'}) 41 | self.assertEqual(r.method, 'HEAD') 42 | 43 | def test_missing_method(self): 44 | r = ice.Request({}) 45 | self.assertEqual(r.method, 'GET') 46 | 47 | def test_path(self): 48 | r = ice.Request({'PATH_INFO': '/foo'}) 49 | self.assertEqual(r.path, '/foo') 50 | 51 | def test_missing_path(self): 52 | r = ice.Request({}) 53 | self.assertEqual(r.path, '/') 54 | 55 | def test_empty_path(self): 56 | r = ice.Request({'PATH_INFO': ''}) 57 | self.assertEqual(r.path, '/') 58 | 59 | def test_query_with_two_names(self): 60 | r = ice.Request({'QUERY_STRING': 'a=foo&b=bar'}) 61 | self.assertEqual(r.query.data, {'a': ['foo'], 'b': ['bar']}) 62 | 63 | def test_query_with_duplicate_names(self): 64 | r = ice.Request({'QUERY_STRING': 'a=foo&b=bar&a=baz'}) 65 | self.assertEqual(r.query.data, {'a': ['foo', 'baz'], 66 | 'b': ['bar']}) 67 | 68 | def test_query_with_missing_value(self): 69 | r = ice.Request({'QUERY_STRING': 'a=foo&b='}) 70 | self.assertEqual(r.query.data, {'a': ['foo']}) 71 | 72 | def test_query_with_no_data(self): 73 | r = ice.Request({'QUERY_STRING': ''}) 74 | self.assertEqual(r.query.data, {}) 75 | 76 | def test_query_with_no_query_string(self): 77 | r = ice.Request({}) 78 | self.assertEqual(r.query.data, {}) 79 | 80 | def test_query_with_plus_sign(self): 81 | r = ice.Request({'QUERY_STRING': 'a=foo+bar&b=baz'}) 82 | self.assertEqual(r.query.data, {'a': ['foo bar'], 'b': ['baz']}) 83 | 84 | def test_query_with_percent_encoding(self): 85 | r = ice.Request({'QUERY_STRING': 'a=f%6f%6f&b=bar'}) 86 | self.assertEqual(r.query.data, {'a': ['foo'], 'b': ['bar']}) 87 | 88 | # environ['REQUEST_METHOD'] = 'POST' is required by cgi.FieldStorage 89 | # to read bytes from environ['wsgi.input']. Hence, it is defined in 90 | # every form test. 91 | 92 | def test_form_with_two_names(self): 93 | environ = { 94 | 'wsgi.input': io.BytesIO(b'a=foo&b=bar'), 95 | 'REQUEST_METHOD': 'POST', 96 | 'CONTENT_LENGTH': '11', 97 | } 98 | r = ice.Request(environ) 99 | self.assertEqual(r.form.data, {'a': ['foo'], 'b': ['bar']}) 100 | 101 | def test_form_with_duplicate_names(self): 102 | environ = { 103 | 'wsgi.input': io.BytesIO(b'a=foo&b=bar&a=baz'), 104 | 'REQUEST_METHOD': 'POST', 105 | 'CONTENT_LENGTH': '17', 106 | } 107 | r = ice.Request(environ) 108 | self.assertEqual(r.form.data, {'a': ['foo', 'baz'], 109 | 'b': ['bar']}) 110 | 111 | def test_form_with_missing_value(self): 112 | environ = { 113 | 'wsgi.input': io.BytesIO(b'a=foo&b='), 114 | 'REQUEST_METHOD': 'POST', 115 | 'CONTENT_LENGTH': '8', 116 | } 117 | r = ice.Request(environ) 118 | self.assertEqual(r.form.data, {'a': ['foo']}) 119 | 120 | def test_form_with_no_data(self): 121 | environ = { 122 | 'wsgi.input': None, 123 | 'REQUEST_METHOD': 'POST', 124 | 'CONTENT_LENGTH': '0', 125 | } 126 | r = ice.Request(environ) 127 | self.assertEqual(r.form.data, {}) 128 | 129 | def test_form_with_no_wsgi_input(self): 130 | r = ice.Request({'REQUEST_METHOD': 'POST'}) 131 | self.assertEqual(r.form.data, {}) 132 | 133 | def test_form_with_truncated_data(self): 134 | environ = { 135 | 'wsgi.input': io.BytesIO(b'a=foo'), 136 | 'REQUEST_METHOD': 'POST', 137 | 'CONTENT_LENGTH': '11', 138 | } 139 | r = ice.Request(environ) 140 | self.assertEqual(r.form.data, {'a': ['foo']}) 141 | 142 | def test_form_with_excess_data(self): 143 | environ = { 144 | 'wsgi.input': io.BytesIO(b'a=foo&b=bar'), 145 | 'REQUEST_METHOD': 'POST', 146 | 'CONTENT_LENGTH': '4', 147 | } 148 | r = ice.Request(environ) 149 | self.assertEqual(r.form.data, {'a': ['fo']}) 150 | 151 | def test_form_with_plus_sign(self): 152 | environ = { 153 | 'wsgi.input': io.BytesIO(b'a=foo+bar&b=baz'), 154 | 'REQUEST_METHOD': 'POST', 155 | 'CONTENT_LENGTH': '15', 156 | } 157 | r = ice.Request(environ) 158 | self.assertEqual(r.form.data, {'a': ['foo bar'], 'b': ['baz']}) 159 | 160 | def test_form_with_percent_encoding(self): 161 | environ = { 162 | 'wsgi.input': io.BytesIO(b'a=f%6f%6f&b=bar'), 163 | 'REQUEST_METHOD': 'POST', 164 | 'CONTENT_LENGTH': '15', 165 | } 166 | r = ice.Request(environ) 167 | self.assertEqual(r.form.data, {'a': ['foo'], 'b': ['bar']}) 168 | 169 | def test_cookies(self): 170 | environ = { 171 | 'HTTP_COOKIE': 'a=foo; b="bar"; c="baz qux"' 172 | } 173 | r = ice.Request(environ) 174 | self.assertEqual(r.cookies, {'a': 'foo', 'b': 'bar', 'c': 'baz qux'}) 175 | -------------------------------------------------------------------------------- /docs/Makefile: -------------------------------------------------------------------------------- 1 | # Makefile for Sphinx documentation 2 | # 3 | 4 | # You can set these variables from the command line. 5 | SPHINXOPTS = 6 | SPHINXBUILD = sphinx-build 7 | PAPER = 8 | BUILDDIR = _build 9 | 10 | # User-friendly check for sphinx-build 11 | ifeq ($(shell which $(SPHINXBUILD) >/dev/null 2>&1; echo $$?), 1) 12 | $(error The '$(SPHINXBUILD)' command was not found. Make sure you have Sphinx installed, then set the SPHINXBUILD environment variable to point to the full path of the '$(SPHINXBUILD)' executable. Alternatively you can add the directory with the executable to your PATH. If you don't have Sphinx installed, grab it from http://sphinx-doc.org/) 13 | endif 14 | 15 | # Internal variables. 16 | PAPEROPT_a4 = -D latex_paper_size=a4 17 | PAPEROPT_letter = -D latex_paper_size=letter 18 | ALLSPHINXOPTS = -d $(BUILDDIR)/doctrees $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) . 19 | # the i18n builder cannot share the environment and doctrees with the others 20 | I18NSPHINXOPTS = $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) . 21 | 22 | .PHONY: help clean html dirhtml singlehtml pickle json htmlhelp qthelp devhelp epub latex latexpdf text man changes linkcheck doctest coverage gettext 23 | 24 | help: 25 | @echo "Please use \`make ' where is one of" 26 | @echo " html to make standalone HTML files" 27 | @echo " dirhtml to make HTML files named index.html in directories" 28 | @echo " singlehtml to make a single large HTML file" 29 | @echo " pickle to make pickle files" 30 | @echo " json to make JSON files" 31 | @echo " htmlhelp to make HTML files and a HTML help project" 32 | @echo " qthelp to make HTML files and a qthelp project" 33 | @echo " applehelp to make an Apple Help Book" 34 | @echo " devhelp to make HTML files and a Devhelp project" 35 | @echo " epub to make an epub" 36 | @echo " latex to make LaTeX files, you can set PAPER=a4 or PAPER=letter" 37 | @echo " latexpdf to make LaTeX files and run them through pdflatex" 38 | @echo " latexpdfja to make LaTeX files and run them through platex/dvipdfmx" 39 | @echo " text to make text files" 40 | @echo " man to make manual pages" 41 | @echo " texinfo to make Texinfo files" 42 | @echo " info to make Texinfo files and run them through makeinfo" 43 | @echo " gettext to make PO message catalogs" 44 | @echo " changes to make an overview of all changed/added/deprecated items" 45 | @echo " xml to make Docutils-native XML files" 46 | @echo " pseudoxml to make pseudoxml-XML files for display purposes" 47 | @echo " linkcheck to check all external links for integrity" 48 | @echo " doctest to run all doctests embedded in the documentation (if enabled)" 49 | @echo " coverage to run coverage check of the documentation (if enabled)" 50 | 51 | clean: 52 | rm -rf $(BUILDDIR)/* 53 | 54 | html: 55 | $(SPHINXBUILD) -b html $(ALLSPHINXOPTS) $(BUILDDIR)/html 56 | @echo 57 | @echo "Build finished. The HTML pages are in $(BUILDDIR)/html." 58 | 59 | dirhtml: 60 | $(SPHINXBUILD) -b dirhtml $(ALLSPHINXOPTS) $(BUILDDIR)/dirhtml 61 | @echo 62 | @echo "Build finished. The HTML pages are in $(BUILDDIR)/dirhtml." 63 | 64 | singlehtml: 65 | $(SPHINXBUILD) -b singlehtml $(ALLSPHINXOPTS) $(BUILDDIR)/singlehtml 66 | @echo 67 | @echo "Build finished. The HTML page is in $(BUILDDIR)/singlehtml." 68 | 69 | pickle: 70 | $(SPHINXBUILD) -b pickle $(ALLSPHINXOPTS) $(BUILDDIR)/pickle 71 | @echo 72 | @echo "Build finished; now you can process the pickle files." 73 | 74 | json: 75 | $(SPHINXBUILD) -b json $(ALLSPHINXOPTS) $(BUILDDIR)/json 76 | @echo 77 | @echo "Build finished; now you can process the JSON files." 78 | 79 | htmlhelp: 80 | $(SPHINXBUILD) -b htmlhelp $(ALLSPHINXOPTS) $(BUILDDIR)/htmlhelp 81 | @echo 82 | @echo "Build finished; now you can run HTML Help Workshop with the" \ 83 | ".hhp project file in $(BUILDDIR)/htmlhelp." 84 | 85 | qthelp: 86 | $(SPHINXBUILD) -b qthelp $(ALLSPHINXOPTS) $(BUILDDIR)/qthelp 87 | @echo 88 | @echo "Build finished; now you can run "qcollectiongenerator" with the" \ 89 | ".qhcp project file in $(BUILDDIR)/qthelp, like this:" 90 | @echo "# qcollectiongenerator $(BUILDDIR)/qthelp/Ice.qhcp" 91 | @echo "To view the help file:" 92 | @echo "# assistant -collectionFile $(BUILDDIR)/qthelp/Ice.qhc" 93 | 94 | applehelp: 95 | $(SPHINXBUILD) -b applehelp $(ALLSPHINXOPTS) $(BUILDDIR)/applehelp 96 | @echo 97 | @echo "Build finished. The help book is in $(BUILDDIR)/applehelp." 98 | @echo "N.B. You won't be able to view it unless you put it in" \ 99 | "~/Library/Documentation/Help or install it in your application" \ 100 | "bundle." 101 | 102 | devhelp: 103 | $(SPHINXBUILD) -b devhelp $(ALLSPHINXOPTS) $(BUILDDIR)/devhelp 104 | @echo 105 | @echo "Build finished." 106 | @echo "To view the help file:" 107 | @echo "# mkdir -p $$HOME/.local/share/devhelp/Ice" 108 | @echo "# ln -s $(BUILDDIR)/devhelp $$HOME/.local/share/devhelp/Ice" 109 | @echo "# devhelp" 110 | 111 | epub: 112 | $(SPHINXBUILD) -b epub $(ALLSPHINXOPTS) $(BUILDDIR)/epub 113 | @echo 114 | @echo "Build finished. The epub file is in $(BUILDDIR)/epub." 115 | 116 | latex: 117 | $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex 118 | @echo 119 | @echo "Build finished; the LaTeX files are in $(BUILDDIR)/latex." 120 | @echo "Run \`make' in that directory to run these through (pdf)latex" \ 121 | "(use \`make latexpdf' here to do that automatically)." 122 | 123 | latexpdf: 124 | $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex 125 | @echo "Running LaTeX files through pdflatex..." 126 | $(MAKE) -C $(BUILDDIR)/latex all-pdf 127 | @echo "pdflatex finished; the PDF files are in $(BUILDDIR)/latex." 128 | 129 | latexpdfja: 130 | $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex 131 | @echo "Running LaTeX files through platex and dvipdfmx..." 132 | $(MAKE) -C $(BUILDDIR)/latex all-pdf-ja 133 | @echo "pdflatex finished; the PDF files are in $(BUILDDIR)/latex." 134 | 135 | text: 136 | $(SPHINXBUILD) -b text $(ALLSPHINXOPTS) $(BUILDDIR)/text 137 | @echo 138 | @echo "Build finished. The text files are in $(BUILDDIR)/text." 139 | 140 | man: 141 | $(SPHINXBUILD) -b man $(ALLSPHINXOPTS) $(BUILDDIR)/man 142 | @echo 143 | @echo "Build finished. The manual pages are in $(BUILDDIR)/man." 144 | 145 | texinfo: 146 | $(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo 147 | @echo 148 | @echo "Build finished. The Texinfo files are in $(BUILDDIR)/texinfo." 149 | @echo "Run \`make' in that directory to run these through makeinfo" \ 150 | "(use \`make info' here to do that automatically)." 151 | 152 | info: 153 | $(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo 154 | @echo "Running Texinfo files through makeinfo..." 155 | make -C $(BUILDDIR)/texinfo info 156 | @echo "makeinfo finished; the Info files are in $(BUILDDIR)/texinfo." 157 | 158 | gettext: 159 | $(SPHINXBUILD) -b gettext $(I18NSPHINXOPTS) $(BUILDDIR)/locale 160 | @echo 161 | @echo "Build finished. The message catalogs are in $(BUILDDIR)/locale." 162 | 163 | changes: 164 | $(SPHINXBUILD) -b changes $(ALLSPHINXOPTS) $(BUILDDIR)/changes 165 | @echo 166 | @echo "The overview file is in $(BUILDDIR)/changes." 167 | 168 | linkcheck: 169 | $(SPHINXBUILD) -b linkcheck $(ALLSPHINXOPTS) $(BUILDDIR)/linkcheck 170 | @echo 171 | @echo "Link check complete; look for any errors in the above output " \ 172 | "or in $(BUILDDIR)/linkcheck/output.txt." 173 | 174 | doctest: 175 | $(SPHINXBUILD) -b doctest $(ALLSPHINXOPTS) $(BUILDDIR)/doctest 176 | @echo "Testing of doctests in the sources finished, look at the " \ 177 | "results in $(BUILDDIR)/doctest/output.txt." 178 | 179 | coverage: 180 | $(SPHINXBUILD) -b coverage $(ALLSPHINXOPTS) $(BUILDDIR)/coverage 181 | @echo "Testing of coverage in the sources finished, look at the " \ 182 | "results in $(BUILDDIR)/coverage/python.txt." 183 | 184 | xml: 185 | $(SPHINXBUILD) -b xml $(ALLSPHINXOPTS) $(BUILDDIR)/xml 186 | @echo 187 | @echo "Build finished. The XML files are in $(BUILDDIR)/xml." 188 | 189 | pseudoxml: 190 | $(SPHINXBUILD) -b pseudoxml $(ALLSPHINXOPTS) $(BUILDDIR)/pseudoxml 191 | @echo 192 | @echo "Build finished. The pseudo-XML files are in $(BUILDDIR)/pseudoxml." 193 | -------------------------------------------------------------------------------- /docs/make.bat: -------------------------------------------------------------------------------- 1 | @ECHO OFF 2 | 3 | REM Command file for Sphinx documentation 4 | 5 | if "%SPHINXBUILD%" == "" ( 6 | set SPHINXBUILD=sphinx-build 7 | ) 8 | set BUILDDIR=_build 9 | set ALLSPHINXOPTS=-d %BUILDDIR%/doctrees %SPHINXOPTS% . 10 | set I18NSPHINXOPTS=%SPHINXOPTS% . 11 | if NOT "%PAPER%" == "" ( 12 | set ALLSPHINXOPTS=-D latex_paper_size=%PAPER% %ALLSPHINXOPTS% 13 | set I18NSPHINXOPTS=-D latex_paper_size=%PAPER% %I18NSPHINXOPTS% 14 | ) 15 | 16 | if "%1" == "" goto help 17 | 18 | if "%1" == "help" ( 19 | :help 20 | echo.Please use `make ^` where ^ is one of 21 | echo. html to make standalone HTML files 22 | echo. dirhtml to make HTML files named index.html in directories 23 | echo. singlehtml to make a single large HTML file 24 | echo. pickle to make pickle files 25 | echo. json to make JSON files 26 | echo. htmlhelp to make HTML files and a HTML help project 27 | echo. qthelp to make HTML files and a qthelp project 28 | echo. devhelp to make HTML files and a Devhelp project 29 | echo. epub to make an epub 30 | echo. latex to make LaTeX files, you can set PAPER=a4 or PAPER=letter 31 | echo. text to make text files 32 | echo. man to make manual pages 33 | echo. texinfo to make Texinfo files 34 | echo. gettext to make PO message catalogs 35 | echo. changes to make an overview over all changed/added/deprecated items 36 | echo. xml to make Docutils-native XML files 37 | echo. pseudoxml to make pseudoxml-XML files for display purposes 38 | echo. linkcheck to check all external links for integrity 39 | echo. doctest to run all doctests embedded in the documentation if enabled 40 | echo. coverage to run coverage check of the documentation if enabled 41 | goto end 42 | ) 43 | 44 | if "%1" == "clean" ( 45 | for /d %%i in (%BUILDDIR%\*) do rmdir /q /s %%i 46 | del /q /s %BUILDDIR%\* 47 | goto end 48 | ) 49 | 50 | 51 | REM Check if sphinx-build is available and fallback to Python version if any 52 | %SPHINXBUILD% 1>NUL 2>NUL 53 | if errorlevel 9009 goto sphinx_python 54 | goto sphinx_ok 55 | 56 | :sphinx_python 57 | 58 | set SPHINXBUILD=python -m sphinx.__init__ 59 | %SPHINXBUILD% 2> nul 60 | if errorlevel 9009 ( 61 | echo. 62 | echo.The 'sphinx-build' command was not found. Make sure you have Sphinx 63 | echo.installed, then set the SPHINXBUILD environment variable to point 64 | echo.to the full path of the 'sphinx-build' executable. Alternatively you 65 | echo.may add the Sphinx directory to PATH. 66 | echo. 67 | echo.If you don't have Sphinx installed, grab it from 68 | echo.http://sphinx-doc.org/ 69 | exit /b 1 70 | ) 71 | 72 | :sphinx_ok 73 | 74 | 75 | if "%1" == "html" ( 76 | %SPHINXBUILD% -b html %ALLSPHINXOPTS% %BUILDDIR%/html 77 | if errorlevel 1 exit /b 1 78 | echo. 79 | echo.Build finished. The HTML pages are in %BUILDDIR%/html. 80 | goto end 81 | ) 82 | 83 | if "%1" == "dirhtml" ( 84 | %SPHINXBUILD% -b dirhtml %ALLSPHINXOPTS% %BUILDDIR%/dirhtml 85 | if errorlevel 1 exit /b 1 86 | echo. 87 | echo.Build finished. The HTML pages are in %BUILDDIR%/dirhtml. 88 | goto end 89 | ) 90 | 91 | if "%1" == "singlehtml" ( 92 | %SPHINXBUILD% -b singlehtml %ALLSPHINXOPTS% %BUILDDIR%/singlehtml 93 | if errorlevel 1 exit /b 1 94 | echo. 95 | echo.Build finished. The HTML pages are in %BUILDDIR%/singlehtml. 96 | goto end 97 | ) 98 | 99 | if "%1" == "pickle" ( 100 | %SPHINXBUILD% -b pickle %ALLSPHINXOPTS% %BUILDDIR%/pickle 101 | if errorlevel 1 exit /b 1 102 | echo. 103 | echo.Build finished; now you can process the pickle files. 104 | goto end 105 | ) 106 | 107 | if "%1" == "json" ( 108 | %SPHINXBUILD% -b json %ALLSPHINXOPTS% %BUILDDIR%/json 109 | if errorlevel 1 exit /b 1 110 | echo. 111 | echo.Build finished; now you can process the JSON files. 112 | goto end 113 | ) 114 | 115 | if "%1" == "htmlhelp" ( 116 | %SPHINXBUILD% -b htmlhelp %ALLSPHINXOPTS% %BUILDDIR%/htmlhelp 117 | if errorlevel 1 exit /b 1 118 | echo. 119 | echo.Build finished; now you can run HTML Help Workshop with the ^ 120 | .hhp project file in %BUILDDIR%/htmlhelp. 121 | goto end 122 | ) 123 | 124 | if "%1" == "qthelp" ( 125 | %SPHINXBUILD% -b qthelp %ALLSPHINXOPTS% %BUILDDIR%/qthelp 126 | if errorlevel 1 exit /b 1 127 | echo. 128 | echo.Build finished; now you can run "qcollectiongenerator" with the ^ 129 | .qhcp project file in %BUILDDIR%/qthelp, like this: 130 | echo.^> qcollectiongenerator %BUILDDIR%\qthelp\Ice.qhcp 131 | echo.To view the help file: 132 | echo.^> assistant -collectionFile %BUILDDIR%\qthelp\Ice.ghc 133 | goto end 134 | ) 135 | 136 | if "%1" == "devhelp" ( 137 | %SPHINXBUILD% -b devhelp %ALLSPHINXOPTS% %BUILDDIR%/devhelp 138 | if errorlevel 1 exit /b 1 139 | echo. 140 | echo.Build finished. 141 | goto end 142 | ) 143 | 144 | if "%1" == "epub" ( 145 | %SPHINXBUILD% -b epub %ALLSPHINXOPTS% %BUILDDIR%/epub 146 | if errorlevel 1 exit /b 1 147 | echo. 148 | echo.Build finished. The epub file is in %BUILDDIR%/epub. 149 | goto end 150 | ) 151 | 152 | if "%1" == "latex" ( 153 | %SPHINXBUILD% -b latex %ALLSPHINXOPTS% %BUILDDIR%/latex 154 | if errorlevel 1 exit /b 1 155 | echo. 156 | echo.Build finished; the LaTeX files are in %BUILDDIR%/latex. 157 | goto end 158 | ) 159 | 160 | if "%1" == "latexpdf" ( 161 | %SPHINXBUILD% -b latex %ALLSPHINXOPTS% %BUILDDIR%/latex 162 | cd %BUILDDIR%/latex 163 | make all-pdf 164 | cd %~dp0 165 | echo. 166 | echo.Build finished; the PDF files are in %BUILDDIR%/latex. 167 | goto end 168 | ) 169 | 170 | if "%1" == "latexpdfja" ( 171 | %SPHINXBUILD% -b latex %ALLSPHINXOPTS% %BUILDDIR%/latex 172 | cd %BUILDDIR%/latex 173 | make all-pdf-ja 174 | cd %~dp0 175 | echo. 176 | echo.Build finished; the PDF files are in %BUILDDIR%/latex. 177 | goto end 178 | ) 179 | 180 | if "%1" == "text" ( 181 | %SPHINXBUILD% -b text %ALLSPHINXOPTS% %BUILDDIR%/text 182 | if errorlevel 1 exit /b 1 183 | echo. 184 | echo.Build finished. The text files are in %BUILDDIR%/text. 185 | goto end 186 | ) 187 | 188 | if "%1" == "man" ( 189 | %SPHINXBUILD% -b man %ALLSPHINXOPTS% %BUILDDIR%/man 190 | if errorlevel 1 exit /b 1 191 | echo. 192 | echo.Build finished. The manual pages are in %BUILDDIR%/man. 193 | goto end 194 | ) 195 | 196 | if "%1" == "texinfo" ( 197 | %SPHINXBUILD% -b texinfo %ALLSPHINXOPTS% %BUILDDIR%/texinfo 198 | if errorlevel 1 exit /b 1 199 | echo. 200 | echo.Build finished. The Texinfo files are in %BUILDDIR%/texinfo. 201 | goto end 202 | ) 203 | 204 | if "%1" == "gettext" ( 205 | %SPHINXBUILD% -b gettext %I18NSPHINXOPTS% %BUILDDIR%/locale 206 | if errorlevel 1 exit /b 1 207 | echo. 208 | echo.Build finished. The message catalogs are in %BUILDDIR%/locale. 209 | goto end 210 | ) 211 | 212 | if "%1" == "changes" ( 213 | %SPHINXBUILD% -b changes %ALLSPHINXOPTS% %BUILDDIR%/changes 214 | if errorlevel 1 exit /b 1 215 | echo. 216 | echo.The overview file is in %BUILDDIR%/changes. 217 | goto end 218 | ) 219 | 220 | if "%1" == "linkcheck" ( 221 | %SPHINXBUILD% -b linkcheck %ALLSPHINXOPTS% %BUILDDIR%/linkcheck 222 | if errorlevel 1 exit /b 1 223 | echo. 224 | echo.Link check complete; look for any errors in the above output ^ 225 | or in %BUILDDIR%/linkcheck/output.txt. 226 | goto end 227 | ) 228 | 229 | if "%1" == "doctest" ( 230 | %SPHINXBUILD% -b doctest %ALLSPHINXOPTS% %BUILDDIR%/doctest 231 | if errorlevel 1 exit /b 1 232 | echo. 233 | echo.Testing of doctests in the sources finished, look at the ^ 234 | results in %BUILDDIR%/doctest/output.txt. 235 | goto end 236 | ) 237 | 238 | if "%1" == "coverage" ( 239 | %SPHINXBUILD% -b coverage %ALLSPHINXOPTS% %BUILDDIR%/coverage 240 | if errorlevel 1 exit /b 1 241 | echo. 242 | echo.Testing of coverage in the sources finished, look at the ^ 243 | results in %BUILDDIR%/coverage/python.txt. 244 | goto end 245 | ) 246 | 247 | if "%1" == "xml" ( 248 | %SPHINXBUILD% -b xml %ALLSPHINXOPTS% %BUILDDIR%/xml 249 | if errorlevel 1 exit /b 1 250 | echo. 251 | echo.Build finished. The XML files are in %BUILDDIR%/xml. 252 | goto end 253 | ) 254 | 255 | if "%1" == "pseudoxml" ( 256 | %SPHINXBUILD% -b pseudoxml %ALLSPHINXOPTS% %BUILDDIR%/pseudoxml 257 | if errorlevel 1 exit /b 1 258 | echo. 259 | echo.Build finished. The pseudo-XML files are in %BUILDDIR%/pseudoxml. 260 | goto end 261 | ) 262 | 263 | :end 264 | -------------------------------------------------------------------------------- /test/test_wildcard.py: -------------------------------------------------------------------------------- 1 | # The MIT License (MIT) 2 | # 3 | # Copyright (c) 2014-2017 Susam Pal 4 | # 5 | # Permission is hereby granted, free of charge, to any person obtaining 6 | # a copy of this software and associated documentation files (the 7 | # "Software"), to deal in the Software without restriction, including 8 | # without limitation the rights to use, copy, modify, merge, publish, 9 | # distribute, sublicense, and/or sell copies of the Software, and to 10 | # permit persons to whom the Software is furnished to do so, subject to 11 | # the following conditions: 12 | # 13 | # The above copyright notice and this permission notice shall be 14 | # included in all copies or substantial portions of the Software. 15 | # 16 | # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 17 | # EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 18 | # MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. 19 | # IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY 20 | # CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, 21 | # TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE 22 | # SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 23 | 24 | 25 | """Tests for class Wildcard.""" 26 | 27 | 28 | import unittest 29 | import ice 30 | 31 | 32 | class WildcardTest(unittest.TestCase): 33 | 34 | def test_str_wildcards(self): 35 | wildcard = ice.Wildcard('<>') 36 | self.assertEqual(wildcard.value(''), '') 37 | self.assertEqual(wildcard.value('foo'), 'foo') 38 | self.assertEqual(wildcard.value('-15'), '-15') 39 | self.assertEqual(wildcard.value('0'), '0') 40 | self.assertEqual(wildcard.value('10'), '10') 41 | 42 | wildcard = ice.Wildcard('') 43 | self.assertEqual(wildcard.value(''), '') 44 | self.assertEqual(wildcard.value('foo'), 'foo') 45 | self.assertEqual(wildcard.value('10.2'), '10.2') 46 | self.assertEqual(wildcard.value('-15'), '-15') 47 | self.assertEqual(wildcard.value('0'), '0') 48 | self.assertEqual(wildcard.value('10'), '10') 49 | 50 | wildcard = ice.Wildcard('') 51 | self.assertEqual(wildcard.value(''), '') 52 | self.assertEqual(wildcard.value('foo'), 'foo') 53 | self.assertEqual(wildcard.value('10.2'), '10.2') 54 | self.assertEqual(wildcard.value('-15'), '-15') 55 | self.assertEqual(wildcard.value('0'), '0') 56 | self.assertEqual(wildcard.value('10'), '10') 57 | 58 | wildcard = ice.Wildcard('<:str>') 59 | self.assertEqual(wildcard.value(''), '') 60 | self.assertEqual(wildcard.value('foo'), 'foo') 61 | self.assertEqual(wildcard.value('10.2'), '10.2') 62 | self.assertEqual(wildcard.value('-15'), '-15') 63 | self.assertEqual(wildcard.value('0'), '0') 64 | self.assertEqual(wildcard.value('10'), '10') 65 | 66 | wildcard = ice.Wildcard('') 67 | self.assertEqual(wildcard.value(''), '') 68 | self.assertEqual(wildcard.value('foo'), 'foo') 69 | self.assertEqual(wildcard.value('10.2'), '10.2') 70 | self.assertEqual(wildcard.value('-15'), '-15') 71 | self.assertEqual(wildcard.value('0'), '0') 72 | self.assertEqual(wildcard.value('10'), '10') 73 | 74 | def test_path_wildcards(self): 75 | wildcard = ice.Wildcard('<:path>') 76 | self.assertEqual(wildcard.value('/foo'), '/foo') 77 | 78 | wildcard = ice.Wildcard('') 79 | self.assertEqual(wildcard.value('/foo'), '/foo') 80 | 81 | def test_int_wildcards(self): 82 | wildcard = ice.Wildcard('<:int>') 83 | self.assertEqual(wildcard.value('0'), 0) 84 | self.assertEqual(wildcard.value('10'), 10) 85 | 86 | wildcard = ice.Wildcard('') 87 | self.assertEqual(wildcard.value('0'), 0) 88 | self.assertEqual(wildcard.value('10'), 10) 89 | 90 | def test_positive_int_wildcards(self): 91 | wildcard = ice.Wildcard('<:+int.') 92 | self.assertEqual(wildcard.value('10'), 10) 93 | 94 | wildcard = ice.Wildcard('') 95 | self.assertEqual(wildcard.value('10'), 10) 96 | 97 | def test_negative_int_wildcards(self): 98 | wildcard = ice.Wildcard('<:-int>') 99 | self.assertEqual(wildcard.value('-15'), -15) 100 | self.assertEqual(wildcard.value('0'), 0) 101 | self.assertEqual(wildcard.value('10'), 10) 102 | 103 | wildcard = ice.Wildcard('') 104 | self.assertEqual(wildcard.value('-15'), -15) 105 | self.assertEqual(wildcard.value('0'), 0) 106 | self.assertEqual(wildcard.value('10'), 10) 107 | 108 | def test_name_validation(self): 109 | # No errors on positive test cases 110 | ice.Wildcard('<>') 111 | ice.Wildcard('<:>') 112 | ice.Wildcard('<_>') 113 | ice.Wildcard('<_:>') 114 | ice.Wildcard('') 115 | ice.Wildcard('') 116 | ice.Wildcard('<_foo>') 117 | ice.Wildcard('<_foo:>') 118 | ice.Wildcard('<__foo_bar>') 119 | ice.Wildcard('<__foo_bar:>') 120 | ice.Wildcard('') 121 | ice.Wildcard('') 122 | 123 | # Errors on negative test cases 124 | with self.assertRaises(ice.RouteError) as cm: 125 | ice.Wildcard('<@foo>') 126 | self.assertEqual(str(cm.exception), 127 | "Invalid wildcard name '@foo' in '<@foo>'") 128 | 129 | with self.assertRaises(ice.RouteError) as cm: 130 | ice.Wildcard('') 131 | self.assertEqual(str(cm.exception), 132 | "Invalid wildcard name 'foo!' in ''") 133 | 134 | with self.assertRaises(ice.RouteError) as cm: 135 | ice.Wildcard('< >') 136 | self.assertEqual(str(cm.exception), 137 | "Invalid wildcard name ' ' in '< >'") 138 | 139 | with self.assertRaises(ice.RouteError) as cm: 140 | ice.Wildcard('') 141 | self.assertEqual(str(cm.exception), 142 | "Invalid wildcard name '! ' in ''") 143 | 144 | with self.assertRaises(ice.RouteError) as cm: 145 | ice.Wildcard('') 146 | self.assertEqual(str(cm.exception), 147 | "Invalid wildcard name '!!' in ''") 148 | 149 | with self.assertRaises(ice.RouteError) as cm: 150 | ice.Wildcard('') 151 | self.assertEqual(str(cm.exception), 152 | "Invalid wildcard name '!_' in ''") 153 | 154 | with self.assertRaises(ice.RouteError) as cm: 155 | ice.Wildcard('') 156 | self.assertEqual(str(cm.exception), 157 | "Invalid wildcard name '!foo' in ''") 158 | 159 | with self.assertRaises(ice.RouteError) as cm: 160 | ice.Wildcard('') 161 | self.assertEqual(str(cm.exception), 162 | "Invalid wildcard name '!_foo' in ''") 163 | 164 | with self.assertRaises(ice.RouteError) as cm: 165 | ice.Wildcard('') 166 | self.assertEqual(str(cm.exception), 167 | "Invalid wildcard name '!__foo_bar' in " 168 | "''") 169 | 170 | with self.assertRaises(ice.RouteError) as cm: 171 | ice.Wildcard('<%>') 172 | self.assertEqual(str(cm.exception), 173 | "Invalid wildcard name '%' in '<%>'") 174 | 175 | 176 | def test_type_validation(self): 177 | # No errors on positive test cases 178 | ice.Wildcard('<>') 179 | ice.Wildcard('<:>') 180 | ice.Wildcard('<:str>') 181 | ice.Wildcard('<:path>') 182 | ice.Wildcard('<:int>') 183 | ice.Wildcard('<:+int>') 184 | ice.Wildcard('<:-int>') 185 | 186 | # Errors on negative test cases 187 | with self.assertRaises(ice.RouteError) as cm: 188 | ice.Wildcard('<:int*>') 189 | self.assertEqual(str(cm.exception), 190 | "Invalid wildcard type 'int*' in '<:int*>'") 191 | 192 | with self.assertRaises(ice.RouteError) as cm: 193 | ice.Wildcard('<:str+>') 194 | self.assertEqual(str(cm.exception), 195 | "Invalid wildcard type 'str+' in '<:str+>'") 196 | 197 | with self.assertRaises(ice.RouteError) as cm: 198 | ice.Wildcard('<:str+>') 199 | self.assertEqual(str(cm.exception), 200 | "Invalid wildcard type 'str+' in '<:str+>'") 201 | 202 | with self.assertRaises(ice.RouteError) as cm: 203 | ice.Wildcard('<:!>') 204 | self.assertEqual(str(cm.exception), 205 | "Invalid wildcard type '!' in '<:!>'") 206 | -------------------------------------------------------------------------------- /test/test_router.py: -------------------------------------------------------------------------------- 1 | # The MIT License (MIT) 2 | # 3 | # Copyright (c) 2014-2017 Susam Pal 4 | # 5 | # Permission is hereby granted, free of charge, to any person obtaining 6 | # a copy of this software and associated documentation files (the 7 | # "Software"), to deal in the Software without restriction, including 8 | # without limitation the rights to use, copy, modify, merge, publish, 9 | # distribute, sublicense, and/or sell copies of the Software, and to 10 | # permit persons to whom the Software is furnished to do so, subject to 11 | # the following conditions: 12 | # 13 | # The above copyright notice and this permission notice shall be 14 | # included in all copies or substantial portions of the Software. 15 | # 16 | # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 17 | # EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 18 | # MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. 19 | # IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY 20 | # CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, 21 | # TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE 22 | # SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 23 | 24 | 25 | """Tests for class Route.""" 26 | 27 | 28 | import unittest 29 | from unittest import mock 30 | import ice 31 | 32 | 33 | class RouterTest(unittest.TestCase): 34 | 35 | # Implicit route tests 36 | 37 | def test_literal_route_absent(self): 38 | r = ice.Router() 39 | m = mock.Mock() 40 | r.add('GET', '/foo', m.f) 41 | self.assertIsNone(r.resolve('GET', '/')) 42 | self.assertIsNone(r.resolve('GET', '/bar')) 43 | 44 | def test_literal_route(self): 45 | r = ice.Router() 46 | m = mock.Mock() 47 | r.add('GET', '/', m.f) 48 | r.add('GET', '/foo/bar', m.g) 49 | r.add('GET', '/foo/bar/baz', m.h) 50 | 51 | self.assertEqual(r.resolve('GET', '/'), (m.f, [], {})) 52 | self.assertEqual(r.resolve('GET', '/foo/bar'), (m.g, [], {})) 53 | self.assertEqual(r.resolve('GET', '/foo/bar/baz'), (m.h, [], {})) 54 | 55 | def test_explicit_literal_route(self): 56 | r = ice.Router() 57 | m = mock.Mock() 58 | r.add('GET', 'literal:/', m.f) 59 | r.add('GET', 'literal:/foo/bar', m.g) 60 | self.assertEqual(r.resolve('GET', '/'), (m.f, [], {})) 61 | self.assertEqual(r.resolve('GET', '/foo/bar'), (m.g, [], {})) 62 | 63 | def test_wildcard_like_explicit_literal_route(self): 64 | r = ice.Router() 65 | m = mock.Mock() 66 | r.add('GET', 'literal:/foo/', m.f) 67 | r.add('GET', 'literal:/foo/', m.g) 68 | r.add('GET', 'literal:/foo/bar/<:int>', m.h) 69 | self.assertIsNone(r.resolve('GET', '/foo/bar')) 70 | self.assertIsNone(r.resolve('GET', '/foo/1')) 71 | self.assertIsNone(r.resolve('GET', '/foo/bar/1')) 72 | self.assertEqual(r.resolve('GET', '/foo/'), (m.f, [], {})) 73 | self.assertEqual(r.resolve('GET', '/foo/'), (m.g, [], {})) 74 | self.assertEqual(r.resolve('GET', '/foo/bar/<:int>'), (m.h, [], {})) 75 | 76 | def test_regex_like_explicit_literal_route(self): 77 | r = ice.Router() 78 | m = mock.Mock() 79 | r.add('GET', 'literal:/(.*)', m.f) 80 | self.assertIsNone(r.resolve('GET', '/foo')) 81 | self.assertEqual(r.resolve('GET', '/(.*)'), (m.f, [], {})) 82 | 83 | def test_implicit_literal_route_overrides_implict(self): 84 | r = ice.Router() 85 | m = mock.Mock() 86 | r.add('GET', '/foo', m.f) 87 | r.add('GET', '/foo', m.g) 88 | self.assertEqual(r.resolve('GET', '/foo'), (m.g, [], {})) 89 | 90 | def test_explicit_literal_route_overrides_implicit(self): 91 | r = ice.Router() 92 | m = mock.Mock() 93 | r.add('GET', '/foo', m.f) 94 | r.add('GET', 'literal:/foo', m.g) 95 | self.assertEqual(r.resolve('GET', '/foo'), (m.g, [], {})) 96 | 97 | def test_implicit_literal_route_overrides_explicit(self): 98 | r = ice.Router() 99 | m = mock.Mock() 100 | r.add('GET', 'literal:/foo', m.h) 101 | r.add('GET', '/foo', m.g) 102 | self.assertEqual(r.resolve('GET', '/foo'), (m.g, [], {})) 103 | 104 | def test_explicit_literal_route_overrides_explicit(self): 105 | r = ice.Router() 106 | m = mock.Mock() 107 | r.add('GET', 'literal:/foo', m.h) 108 | r.add('GET', 'literal:/foo', m.g) 109 | self.assertEqual(r.resolve('GET', '/foo'), (m.g, [], {})) 110 | 111 | def test_regex_ignored_in_literal_route(self): 112 | r = ice.Router() 113 | m = mock.Mock() 114 | r.add('GET', '/foo/.*', m.f) 115 | self.assertIsNone(r.resolve('GET', '/foo/bar')) 116 | self.assertEqual(r.resolve('GET', '/foo/.*'), (m.f, [], {})) 117 | 118 | # Wildcard route tests 119 | 120 | def test_wildcard_route_absent(self): 121 | r = ice.Router() 122 | m = mock.Mock() 123 | r.add('GET', '/foo/', m.f) 124 | self.assertIsNone(r.resolve('GET', '/')) 125 | self.assertIsNone(r.resolve('GET', '/foo')) 126 | self.assertIsNone(r.resolve('GET', '/foo/')) 127 | self.assertIsNone(r.resolve('GET', '/foo/bar/')) 128 | self.assertIsNone(r.resolve('GET', '/foo/bar/baz')) 129 | 130 | def test_explicit_wildcard_route(self): 131 | r = ice.Router() 132 | m = mock.Mock() 133 | r.add('GET', 'wildcard:/<>', m.f) 134 | self.assertEqual(r.resolve('GET', '/foo'), (m.f, ['foo'], {})) 135 | 136 | def test_literal_like_explicit_wildcard_route(self): 137 | r = ice.Router() 138 | m = mock.Mock() 139 | r.add('GET', 'wildcard:/foo', m.f) 140 | self.assertEqual(r.resolve('GET', '/foo'), (m.f, [], {})) 141 | 142 | def test_regex_like_explicit_wildcard_route(self): 143 | r = ice.Router() 144 | m = mock.Mock() 145 | r.add('GET', 'wildcard:/<>/(.*)', m.f) 146 | self.assertIsNone(r.resolve('GET', '/<>/foo')) 147 | self.assertEqual(r.resolve('GET', '/foo/(.*)'), (m.f, ['foo'], {})) 148 | 149 | def test_implicit_wildcard_route_overrides_implict(self): 150 | r = ice.Router() 151 | m = mock.Mock() 152 | r.add('GET', '/<>', m.f) 153 | r.add('GET', '/<>', m.g) 154 | self.assertEqual(r.resolve('GET', '/foo'), (m.g, ['foo'], {})) 155 | 156 | def test_explicit_wildcard_route_overrides_implicit(self): 157 | r = ice.Router() 158 | m = mock.Mock() 159 | r.add('GET', '/<>', m.f) 160 | r.add('GET', 'wildcard:/<>', m.g) 161 | self.assertEqual(r.resolve('GET', '/foo'), (m.g, ['foo'], {})) 162 | 163 | def test_implicit_wildcard_route_overrides_explicit(self): 164 | r = ice.Router() 165 | m = mock.Mock() 166 | r.add('GET', 'wildcard:/<>', m.f) 167 | r.add('GET', '/<>', m.g) 168 | self.assertEqual(r.resolve('GET', '/foo'), (m.g, ['foo'], {})) 169 | 170 | def test_explicit_wildcard_route_overrides_explicit(self): 171 | r = ice.Router() 172 | m = mock.Mock() 173 | r.add('GET', 'wildcard:/<>', m.f) 174 | r.add('GET', 'wildcard:/<>', m.g) 175 | self.assertEqual(r.resolve('GET', '/foo'), (m.g, ['foo'], {})) 176 | 177 | # Regex route test 178 | def test_regex_route_absent(self): 179 | r = ice.Router() 180 | m = mock.Mock() 181 | r.add('GET', '^/([a-z]+)(\d+)$', m.f) 182 | self.assertIsNone(r.resolve('GET', '/foo')) 183 | 184 | def test_explicit_regex_route(self): 185 | r = ice.Router() 186 | m = mock.Mock() 187 | r.add('GET', 'regex:^/foo/.*', m.f) 188 | self.assertEqual(r.resolve('GET', '/foo/bar'), (m.f, [], {})) 189 | 190 | def test_literal_like_explicit_regex_route(self): 191 | r = ice.Router() 192 | m = mock.Mock() 193 | r.add('GET', 'regex:/bar', m.f) 194 | self.assertEqual(r.resolve('GET', '/foo/bar/baz'), (m.f, [], {})) 195 | 196 | def test_wildcard_like_explicit_regex_route(self): 197 | r = ice.Router() 198 | m = mock.Mock() 199 | r.add('GET', 'regex:/foo/<:int>', m.f) 200 | self.assertEqual(r.resolve('GET', '/foo/<:int>'), (m.f, [], {})) 201 | 202 | def test_implicit_regex_route_overrides_implict(self): 203 | r = ice.Router() 204 | m = mock.Mock() 205 | r.add('GET', '/foo/(.*)/(.*)', m.f) 206 | r.add('GET', '/foo/(.*)', m.g) 207 | self.assertEqual(r.resolve('GET', '/foo/bar/baz'), 208 | (m.g, ['bar/baz'], {})) 209 | 210 | def test_explicit_regex_route_overrides_implicit(self): 211 | r = ice.Router() 212 | m = mock.Mock() 213 | r.add('GET', '/foo/(.*)/(.*)', m.f) 214 | r.add('GET', 'regex:/foo/(.*)', m.g) 215 | self.assertEqual(r.resolve('GET', '/foo/bar/baz'), 216 | (m.g, ['bar/baz'], {})) 217 | 218 | def test_implicit_regex_route_overrides_explicit(self): 219 | r = ice.Router() 220 | m = mock.Mock() 221 | r.add('GET', 'regex:/foo/(.*)/(.*)', m.f) 222 | r.add('GET', '/foo/(.*)', m.g) 223 | self.assertEqual(r.resolve('GET', '/foo/bar/baz'), 224 | (m.g, ['bar/baz'], {})) 225 | 226 | def test_explicit_regex_route_overrides_explicit(self): 227 | r = ice.Router() 228 | m = mock.Mock() 229 | r.add('GET', 'regex:/foo/(.*)/(.*)', m.f) 230 | r.add('GET', 'regex:/foo/(.*)', m.g) 231 | self.assertEqual(r.resolve('GET', '/foo/bar/baz'), 232 | (m.g, ['bar/baz'], {})) 233 | -------------------------------------------------------------------------------- /test/test_ice.py: -------------------------------------------------------------------------------- 1 | # The MIT License (MIT) 2 | # 3 | # Copyright (c) 2014-2017 Susam Pal 4 | # 5 | # Permission is hereby granted, free of charge, to any person obtaining 6 | # a copy of this software and associated documentation files (the 7 | # "Software"), to deal in the Software without restriction, including 8 | # without limitation the rights to use, copy, modify, merge, publish, 9 | # distribute, sublicense, and/or sell copies of the Software, and to 10 | # permit persons to whom the Software is furnished to do so, subject to 11 | # the following conditions: 12 | # 13 | # The above copyright notice and this permission notice shall be 14 | # included in all copies or substantial portions of the Software. 15 | # 16 | # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 17 | # EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 18 | # MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. 19 | # IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY 20 | # CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, 21 | # TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE 22 | # SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 23 | 24 | 25 | """Tests for class Ice.""" 26 | 27 | 28 | import unittest 29 | import unittest.mock 30 | import ice 31 | import threading 32 | import urllib.request 33 | import textwrap 34 | import time 35 | 36 | from test import data 37 | 38 | 39 | class IceTest(unittest.TestCase): 40 | def setUp(self): 41 | self.app = ice.cube() 42 | 43 | def tearDown(self): 44 | self.app.exit() 45 | 46 | def test_not_found(self): 47 | # Add a route for GET method to ensure HTTP GET is implemented. 48 | app = ice.Ice() 49 | app.get('/')(unittest.mock.Mock()) 50 | m = unittest.mock.Mock() 51 | # Now try to invoke a route that does not exist. 52 | r = app({'REQUEST_METHOD': 'GET', 'PATH_INFO': '/foo'}, m) 53 | expected = '404 Not Found' 54 | m.assert_called_with(expected, [ 55 | ('Content-Type', 'text/plain; charset=UTF-8'), 56 | ('Content-Length', str(len(expected))) 57 | ]) 58 | self.assertEqual(r, [expected.encode()]) 59 | 60 | def test_get_route(self): 61 | expected = '

Foo

' 62 | app = ice.Ice() 63 | @app.get('/') 64 | def foo(): 65 | return expected 66 | 67 | m = unittest.mock.Mock() 68 | r = app({'REQUEST_METHOD': 'GET', 'PATH_INFO': '/'}, m) 69 | m.assert_called_with('200 OK', [ 70 | ('Content-Type', 'text/html; charset=UTF-8'), 71 | ('Content-Length', str(len(expected))) 72 | ]) 73 | self.assertEqual(r, [expected.encode()]) 74 | 75 | expected = '501 Not Implemented' 76 | r = app({'REQUEST_METHOD': 'POST', 'PATH_INFO': '/'}, m) 77 | m.assert_called_with(expected, [ 78 | ('Content-Type', 'text/plain; charset=UTF-8'), 79 | ('Content-Length', str(len(expected))) 80 | ]) 81 | self.assertEqual(r, [expected.encode()]) 82 | 83 | def test_post_route(self): 84 | expected = '

Foo

' 85 | app = ice.Ice() 86 | @app.post('/') 87 | def foo(): 88 | return expected 89 | 90 | m = unittest.mock.Mock() 91 | r = app({'REQUEST_METHOD': 'POST', 'PATH_INFO': '/'}, m) 92 | m.assert_called_with('200 OK', [ 93 | ('Content-Type', 'text/html; charset=UTF-8'), 94 | ('Content-Length', str(len(expected))) 95 | ]) 96 | self.assertEqual(r, [expected.encode()]) 97 | 98 | expected = '501 Not Implemented' 99 | r = app({'REQUEST_METHOD': 'GET', 'PATH_INFO': '/'}, m) 100 | m.assert_called_with(expected, [ 101 | ('Content-Type', 'text/plain; charset=UTF-8'), 102 | ('Content-Length', str(len(expected))) 103 | ]) 104 | self.assertEqual(r, [expected.encode()]) 105 | 106 | def test_error_in_callback(self): 107 | app = ice.Ice() 108 | @app.get('/') 109 | def foo(): 110 | raise NotImplementedError() 111 | 112 | with self.assertRaises(NotImplementedError) as cm: 113 | app({'REQUEST_METHOD': 'GET', 'PATH_INFO': '/'}, 114 | unittest.mock.Mock()) 115 | 116 | def test_error_callback(self): 117 | expected = '

HTTP method not implemented

' 118 | app = ice.Ice() 119 | @app.error(501) 120 | def error(): 121 | return expected 122 | m = unittest.mock.Mock() 123 | r = app({'REQUEST_METHOD': 'GET', 'PATH_INFO': '/'}, m) 124 | m.assert_called_with('501 Not Implemented', [ 125 | ('Content-Type', 'text/html; charset=UTF-8'), 126 | ('Content-Length', str(len(expected))) 127 | ]) 128 | self.assertEqual(r, [expected.encode()]) 129 | 130 | def test_return_code_from_callback(self): 131 | app = ice.Ice() 132 | @app.get('/') 133 | def foo(): 134 | return 200 135 | 136 | @app.get('/bar') 137 | def bar(): 138 | return 404 139 | 140 | expected2 = '

Baz

' 141 | @app.get('/baz') 142 | def baz(): 143 | app.response.body = expected2 144 | return 200 145 | 146 | expected = '200 OK' 147 | m = unittest.mock.Mock() 148 | r = app({'REQUEST_METHOD': 'GET', 'PATH_INFO': '/'}, m) 149 | m.assert_called_with(expected, [ 150 | ('Content-Type', 'text/plain; charset=UTF-8'), 151 | ('Content-Length', str(len(expected))) 152 | ]) 153 | self.assertEqual(r, [expected.encode()]) 154 | 155 | expected = '404 Not Found' 156 | m = unittest.mock.Mock() 157 | r = app({'REQUEST_METHOD': 'GET', 'PATH_INFO': '/bar'}, m) 158 | m.assert_called_with(expected, [ 159 | ('Content-Type', 'text/plain; charset=UTF-8'), 160 | ('Content-Length', str(len(expected))) 161 | ]) 162 | self.assertEqual(r, [expected.encode()]) 163 | 164 | m = unittest.mock.Mock() 165 | r = app({'REQUEST_METHOD': 'GET', 'PATH_INFO': '/baz'}, m) 166 | m.assert_called_with('200 OK', [ 167 | ('Content-Type', 'text/html; charset=UTF-8'), 168 | ('Content-Length', str(len(expected2))) 169 | ]) 170 | self.assertEqual(r, [expected2.encode()]) 171 | 172 | def test_invalid_return_type_from_callback(self): 173 | app = ice.Ice() 174 | @app.get('/') 175 | def foo(): 176 | return [] 177 | 178 | with self.assertRaises(ice.Error) as cm: 179 | app({'REQUEST_METHOD': 'GET', 'PATH_INFO': '/'}, 180 | unittest.mock.Mock()) 181 | self.assertEqual(str(cm.exception), 'Route callback for GET / ' 182 | 'returned invalid value: list: []') 183 | 184 | def test_invalid_return_code_from_callback(self): 185 | app = ice.Ice() 186 | @app.get('/') 187 | def foo(): 188 | return 1000 189 | 190 | with self.assertRaises(ice.Error) as cm: 191 | app({'REQUEST_METHOD': 'GET', 'PATH_INFO': '/'}, 192 | unittest.mock.Mock()) 193 | self.assertEqual(str(cm.exception), 'Route callback for GET / ' 194 | 'returned invalid value: int: 1000') 195 | 196 | def test_redirect(self): 197 | app = ice.Ice() 198 | 199 | expected = '303 See Other' 200 | 201 | @app.get('/') 202 | def foo(): 203 | return 303, '/foo' 204 | 205 | expected2 = '

Bar

' 206 | @app.get('/bar') 207 | def bar(): 208 | app.response.body = expected2 209 | return 303, '/baz' 210 | 211 | m = unittest.mock.Mock() 212 | r = app({'REQUEST_METHOD': 'GET', 'PATH_INFO': '/'}, m) 213 | m.assert_called_with(expected, [ 214 | ('Location', '/foo'), 215 | ('Content-Type', 'text/plain; charset=UTF-8'), 216 | ('Content-Length', str(len(expected))) 217 | ]) 218 | self.assertEqual(r, [expected.encode()]) 219 | 220 | r = app({'REQUEST_METHOD': 'GET', 'PATH_INFO': '/bar'}, m) 221 | m.assert_called_with(expected, [ 222 | ('Location', '/baz'), 223 | ('Content-Type', 'text/html; charset=UTF-8'), 224 | ('Content-Length', str(len(expected2))) 225 | ]) 226 | self.assertEqual(r, [expected2.encode()]) 227 | 228 | def test_run_and_exit(self): 229 | app = ice.Ice() 230 | threading.Thread(target=app.run).start() 231 | while not app.running(): 232 | time.sleep(0.1) 233 | app.exit() 234 | # Calling another unnecessary exit should cause no problem. 235 | app.exit() 236 | 237 | def test_run_exit_without_run(self): 238 | app = ice.Ice() 239 | app.exit() 240 | # Calling another unnecessary exit should cause no problem. 241 | app.exit() 242 | 243 | def test_run_serve_and_exit(self): 244 | app = self.app = ice.Ice() 245 | expected = '

Foo

' 246 | 247 | @app.get('/') 248 | def foo(): 249 | return expected 250 | 251 | @app.get('/bar') 252 | def bar(): 253 | raise NotImplementedError() 254 | 255 | threading.Thread(target=app.run).start() 256 | while not app.running(): 257 | time.sleep(0.1) 258 | 259 | # 200 OK 260 | r = urllib.request.urlopen('http://127.0.0.1:8080/') 261 | self.assertEqual(r.status, 200) 262 | self.assertEqual(r.reason, 'OK') 263 | self.assertEqual(r.getheader('Content-Type'), 264 | 'text/html; charset=UTF-8') 265 | self.assertEqual(r.getheader('Content-Length'), 266 | str(len(expected))) 267 | self.assertEqual(r.read(), expected.encode()) 268 | 269 | # 404 Not Found 270 | expected = '404 Not Found' 271 | with self.assertRaises(urllib.error.HTTPError) as cm: 272 | urllib.request.urlopen('http://127.0.0.1:8080/foo') 273 | self.assertEqual(cm.exception.code, 404) 274 | self.assertEqual(cm.exception.reason, 'Not Found') 275 | h = dict(cm.exception.headers) 276 | self.assertEqual(h['Content-Type'], 'text/plain; charset=UTF-8') 277 | self.assertEqual(h['Content-Length'], str(len(expected))) 278 | self.assertEqual(cm.exception.read(), expected.encode()) 279 | 280 | # 501 Not Implemented 281 | expected = '501 Not Implemented' 282 | with self.assertRaises(urllib.error.HTTPError) as cm: 283 | urllib.request.urlopen('http://127.0.0.1:8080/', b'') 284 | self.assertEqual(cm.exception.code, 501) 285 | self.assertEqual(cm.exception.reason, 'Not Implemented') 286 | h = dict(cm.exception.headers) 287 | self.assertEqual(h['Content-Type'], 'text/plain; charset=UTF-8') 288 | self.assertEqual(h['Content-Length'], str(len(expected))) 289 | self.assertEqual(cm.exception.read(), expected.encode()) 290 | 291 | # Exception while processing request 292 | with self.assertRaises(urllib.error.HTTPError) as cm: 293 | urllib.request.urlopen('http://127.0.0.1:8080/bar') 294 | self.assertEqual(cm.exception.code, 500) 295 | self.assertEqual(cm.exception.reason, 'Internal Server Error') 296 | 297 | def test_static(self): 298 | app = ice.Ice() 299 | 300 | @app.get('/foo') 301 | def foo(): 302 | return app.static(data.dirpath, 'foo.txt') 303 | 304 | @app.get('/bar') 305 | def bar(): 306 | return app.static(data.dirpath, 'bar', 'text/html') 307 | 308 | m = unittest.mock.Mock() 309 | 310 | expected = 'foo\n' 311 | r = app({'REQUEST_METHOD': 'GET', 'PATH_INFO': '/foo'}, m) 312 | m.assert_called_with('200 OK', [ 313 | ('Content-Type', 'text/plain; charset=UTF-8'), 314 | ('Content-Length', str(len(expected))) 315 | ]) 316 | self.assertEqual(r, [expected.encode()]) 317 | 318 | expected = '

bar

\n' 319 | r = app({'REQUEST_METHOD': 'GET', 'PATH_INFO': '/bar'}, m) 320 | m.assert_called_with('200 OK', [ 321 | ('Content-Type', 'text/html; charset=UTF-8'), 322 | ('Content-Length', str(len(expected))) 323 | ]) 324 | self.assertEqual(r, [expected.encode()]) 325 | 326 | def test_static_403_error(self): 327 | app = ice.Ice() 328 | 329 | @app.get('/') 330 | def foo(): 331 | return app.static(data.filepath('subdir'), '../foo.txt') 332 | 333 | m = unittest.mock.Mock() 334 | 335 | expected = '403 Forbidden' 336 | r = app({'REQUEST_METHOD': 'GET', 'PATH_INFO': '/'}, m) 337 | m.assert_called_with(expected, [ 338 | ('Content-Type', 'text/plain; charset=UTF-8'), 339 | ('Content-Length', str(len(expected))) 340 | ]) 341 | self.assertEqual(r, [expected.encode()]) 342 | 343 | def test_static_avoid_403_error(self): 344 | app = ice.Ice() 345 | 346 | @app.get('/') 347 | def foo(): 348 | return app.static('/', data.filepath('foo.txt')) 349 | 350 | m = unittest.mock.Mock() 351 | 352 | expected = 'foo\n' 353 | r = app({'REQUEST_METHOD': 'GET', 'PATH_INFO': '/'}, m) 354 | m.assert_called_with('200 OK', [ 355 | ('Content-Type', 'text/plain; charset=UTF-8'), 356 | ('Content-Length', str(len(expected))) 357 | ]) 358 | self.assertEqual(r, [expected.encode()]) 359 | 360 | def test_static_404_error(self): 361 | app = ice.Ice() 362 | 363 | @app.get('/') 364 | def foo(): 365 | return app.static(data.dirpath, 'nonexistent.txt') 366 | 367 | m = unittest.mock.Mock() 368 | 369 | expected = '404 Not Found' 370 | r = app({'REQUEST_METHOD': 'GET', 'PATH_INFO': '/'}, m) 371 | m.assert_called_with(expected, [ 372 | ('Content-Type', 'text/plain; charset=UTF-8'), 373 | ('Content-Length', str(len(expected))) 374 | ]) 375 | self.assertEqual(r, [expected.encode()]) 376 | 377 | def test_download_with_filename_argument(self): 378 | app = ice.Ice() 379 | 380 | expected1 = 'foo' 381 | expected2 = 'echo "hi"' 382 | 383 | @app.get('/') 384 | def foo(): 385 | return app.download(expected1, 'foo.txt') 386 | 387 | @app.get('/bar') 388 | def bar(): 389 | return app.download(expected2, 'foo.sh', 'text/plain') 390 | 391 | m = unittest.mock.Mock() 392 | 393 | r = app({'REQUEST_METHOD': 'GET', 'PATH_INFO': '/'}, m) 394 | m.assert_called_with('200 OK', [ 395 | ('Content-Disposition', 'attachment; filename="foo.txt"'), 396 | ('Content-Type', 'text/plain; charset=UTF-8'), 397 | ('Content-Length', str(len(expected1))) 398 | ]) 399 | self.assertEqual(r, [expected1.encode()]) 400 | 401 | r = app({'REQUEST_METHOD': 'GET', 'PATH_INFO': '/bar'}, m) 402 | m.assert_called_with('200 OK', [ 403 | ('Content-Disposition', 'attachment; filename="foo.sh"'), 404 | ('Content-Type', 'text/plain; charset=UTF-8'), 405 | ('Content-Length', str(len(expected2))) 406 | ]) 407 | self.assertEqual(r, [expected2.encode()]) 408 | 409 | def test_download_without_filename_argument(self): 410 | app = ice.Ice() 411 | 412 | @app.get('/') 413 | def foo(): 414 | return app.download('foo') 415 | 416 | m = unittest.mock.Mock() 417 | expected = 'foo' 418 | 419 | r = app({'REQUEST_METHOD': 'GET', 'PATH_INFO': '/foo.txt'}, m) 420 | m.assert_called_with('200 OK', [ 421 | ('Content-Disposition', 'attachment; filename="foo.txt"'), 422 | ('Content-Type', 'text/plain; charset=UTF-8'), 423 | ('Content-Length', str(len(expected))) 424 | ]) 425 | self.assertEqual(r, [expected.encode()]) 426 | 427 | r = app({'REQUEST_METHOD': 'GET', 'PATH_INFO': '/foo.css'}, m) 428 | m.assert_called_with('200 OK', [ 429 | ('Content-Disposition', 'attachment; filename="foo.css"'), 430 | ('Content-Type', 'text/css; charset=UTF-8'), 431 | ('Content-Length', str(len(expected))) 432 | ]) 433 | self.assertEqual(r, [expected.encode()]) 434 | 435 | def test_download_without_filename(self): 436 | app = ice.Ice() 437 | 438 | @app.get('/') 439 | def foo(): 440 | return app.download('foo') 441 | 442 | m = unittest.mock.Mock() 443 | expected = 'foo' 444 | with self.assertRaises(ice.LogicError) as cm: 445 | app({'REQUEST_METHOD': 'GET', 'PATH_INFO': '/'}, m) 446 | self.assertEqual(str(cm.exception), 447 | 'Cannot determine filename for download') 448 | 449 | def test_download_static(self): 450 | app = ice.Ice() 451 | 452 | @app.get('/') 453 | def foo(): 454 | return app.download(app.static(data.dirpath, 'foo.txt')) 455 | 456 | m = unittest.mock.Mock() 457 | expected = 'foo\n' 458 | r = app({'REQUEST_METHOD': 'GET', 'PATH_INFO': '/'}, m) 459 | m.assert_called_with('200 OK', [ 460 | ('Content-Disposition', 'attachment; filename="foo.txt"'), 461 | ('Content-Type', 'text/plain; charset=UTF-8'), 462 | ('Content-Length', str(len(expected))) 463 | ]) 464 | self.assertEqual(r, [expected.encode()]) 465 | 466 | def test_download_with_status_code(self): 467 | app = ice.Ice() 468 | 469 | # Return an error from app.static. 470 | @app.get('/') 471 | def foo(): 472 | return app.download(app.static(data.dirpath, 'nonexistent.txt')) 473 | 474 | # Return an error status code. 475 | @app.get('/bar') 476 | def bar(): 477 | return app.download(410) 478 | 479 | # Set body and return status code 200. 480 | @app.get('/baz') 481 | def baz(): 482 | app.response.body = 'baz' 483 | return app.download(200, 'baz.txt') 484 | 485 | m = unittest.mock.Mock() 486 | 487 | expected = '404 Not Found' 488 | r = app({'REQUEST_METHOD': 'GET', 'PATH_INFO': '/'}, m) 489 | m.assert_called_with(expected, [ 490 | ('Content-Type', 'text/plain; charset=UTF-8'), 491 | ('Content-Length', str(len(expected))) 492 | ]) 493 | self.assertEqual(r, [expected.encode()]) 494 | 495 | expected = '410 Gone' 496 | r = app({'REQUEST_METHOD': 'GET', 'PATH_INFO': '/bar'}, m) 497 | m.assert_called_with(expected, [ 498 | ('Content-Type', 'text/plain; charset=UTF-8'), 499 | ('Content-Length', str(len(expected))) 500 | ]) 501 | self.assertEqual(r, [expected.encode()]) 502 | 503 | expected = 'baz' 504 | r = app({'REQUEST_METHOD': 'GET', 'PATH_INFO': '/baz'}, m) 505 | m.assert_called_with('200 OK', [ 506 | ('Content-Disposition', 'attachment; filename="baz.txt"'), 507 | ('Content-Type', 'text/plain; charset=UTF-8'), 508 | ('Content-Length', str(len(expected))) 509 | ]) 510 | self.assertEqual(r, [expected.encode()]) 511 | -------------------------------------------------------------------------------- /test/test_wildcard_route.py: -------------------------------------------------------------------------------- 1 | # The MIT License (MIT) 2 | # 3 | # Copyright (c) 2014-2017 Susam Pal 4 | # 5 | # Permission is hereby granted, free of charge, to any person obtaining 6 | # a copy of this software and associated documentation files (the 7 | # "Software"), to deal in the Software without restriction, including 8 | # without limitation the rights to use, copy, modify, merge, publish, 9 | # distribute, sublicense, and/or sell copies of the Software, and to 10 | # permit persons to whom the Software is furnished to do so, subject to 11 | # the following conditions: 12 | # 13 | # The above copyright notice and this permission notice shall be 14 | # included in all copies or substantial portions of the Software. 15 | # 16 | # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 17 | # EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 18 | # MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. 19 | # IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY 20 | # CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, 21 | # TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE 22 | # SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 23 | 24 | 25 | """Tests for class ice.WildcardRoute.""" 26 | 27 | 28 | import unittest 29 | from unittest import mock 30 | import ice 31 | 32 | class WildcardRouteTest(unittest.TestCase): 33 | def test_tokens(self): 34 | self.assertEqual(ice.WildcardRoute.tokens(''), []) 35 | self.assertEqual(ice.WildcardRoute.tokens('/'), ['/']) 36 | self.assertEqual(ice.WildcardRoute.tokens('//'), ['/', '/']) 37 | self.assertEqual(ice.WildcardRoute.tokens('/foo//bar'), 38 | ['/', 'foo', '/', '/', 'bar']) 39 | self.assertEqual(ice.WildcardRoute.tokens('foo/'), ['foo', '/']) 40 | self.assertEqual(ice.WildcardRoute.tokens('/foo'), ['/', 'foo']) 41 | self.assertEqual(ice.WildcardRoute.tokens('/foo/bar'), 42 | ['/', 'foo', '/', 'bar']) 43 | self.assertEqual(ice.WildcardRoute.tokens('/foo/bar/'), 44 | ['/', 'foo', '/', 'bar', '/']) 45 | self.assertEqual(ice.WildcardRoute.tokens('/foo/<>'), 46 | ['/', 'foo', '/', '<>']) 47 | self.assertEqual(ice.WildcardRoute.tokens('/foo/<>/'), 48 | ['/', 'foo', '/', '<>', '/']) 49 | self.assertEqual(ice.WildcardRoute.tokens('/foo/<>-<>'), 50 | ['/', 'foo', '/', '<>', '-', '<>']) 51 | self.assertEqual(ice.WildcardRoute.tokens('/foo/-'), 52 | ['/', 'foo', '/', '', '-', '']) 53 | self.assertEqual(ice.WildcardRoute.tokens('/foo/-/'), 54 | ['/', 'foo', '/', '', '-', '', '/']) 55 | self.assertEqual(ice.WildcardRoute.tokens('/-/'), 56 | ['/', '', '-', '', '/']) 57 | 58 | def test_tokens_with_delimiters_in_path(self): 59 | self.assertEqual(ice.WildcardRoute.tokens('/foo/<<>>/'), 60 | ['/', 'foo', '/', '<', '<>', '>', '/']) 61 | self.assertEqual(ice.WildcardRoute.tokens('/foo/'), 62 | ['/', 'foo', '/', '<', 'bar', '/', 'baz', '>']) 63 | 64 | def test_no_wildcard(self): 65 | m = mock.Mock() 66 | r = ice.WildcardRoute('/foo', m) 67 | self.assertEqual(r.match('/foo'), (m, [], {})) 68 | self.assertIsNone(r.match('/foo/')) 69 | 70 | def test_anonymous_wildcard(self): 71 | m = mock.Mock() 72 | 73 | r = ice.WildcardRoute('/<>', m) 74 | self.assertEqual(r.match('/foo'), (m, ['foo'], {})) 75 | self.assertEqual(r.match('/bar'), (m, ['bar'], {})) 76 | self.assertEqual(r.match('/'), (m, [''], {})) 77 | self.assertIsNone(r.match('/')) 78 | self.assertIsNone(r.match('/foo/')) 79 | self.assertIsNone(r.match('/foo/bar')) 80 | 81 | r = ice.WildcardRoute('/<>/<>', m) 82 | self.assertEqual(r.match('/foo/bar'), (m, ['foo', 'bar'], {})) 83 | self.assertEqual(r.match('//bar'), (m, ['', 'bar'], {})) 84 | self.assertIsNone(r.match('/foo')) 85 | self.assertIsNone(r.match('/foo/')) 86 | self.assertIsNone(r.match('/foo/bar/')) 87 | self.assertIsNone(r.match('//bar/')) 88 | 89 | r = ice.WildcardRoute('/<>/bar', m) 90 | self.assertEqual(r.match('/foo/bar'), (m, ['foo'], {})) 91 | self.assertIsNone(r.match('/foo')) 92 | self.assertIsNone(r.match('/bar')) 93 | self.assertIsNone(r.match('/foo/bar/')) 94 | self.assertIsNone(r.match('/foo/bar/baz')) 95 | 96 | def test_named_wildcard(self): 97 | m = mock.Mock() 98 | 99 | r = ice.WildcardRoute('/
', m) 100 | self.assertEqual(r.match('/foo'), (m, [], {'a': 'foo'})) 101 | self.assertEqual(r.match('/bar'), (m, [], {'a': 'bar'})) 102 | self.assertEqual(r.match('/'), (m, [], {'a': ''})) 103 | self.assertIsNone(r.match('/foo/')) 104 | self.assertIsNone(r.match('/foo/bar')) 105 | 106 | r = ice.WildcardRoute('//', m) 107 | self.assertEqual(r.match('/foo/bar'), 108 | (m, [], {'a': 'foo', 'b': 'bar'})) 109 | self.assertIsNone(r.match('/foo'), (m, [], {})) 110 | self.assertIsNone(r.match('/foo/'), (m, [], {})) 111 | self.assertIsNone(r.match('/foo/'), (m, [], {})) 112 | 113 | r = ice.WildcardRoute('//bar', m) 114 | self.assertEqual(r.match('/foo/bar'), (m, [], {'a': 'foo'})) 115 | self.assertIsNone(r.match('/foo')) 116 | self.assertIsNone(r.match('/bar')) 117 | self.assertIsNone(r.match('/foo/bar/')) 118 | self.assertIsNone(r.match('/foo/bar/baz')) 119 | 120 | def test_throwaway_wildcard(self): 121 | m = mock.Mock() 122 | 123 | r = ice.WildcardRoute('/', m) 124 | self.assertEqual(r.match('/foo'), (m, [], {})) 125 | self.assertEqual(r.match('/bar'), (m, [], {})) 126 | self.assertIsNone(r.match('/foo/')) 127 | self.assertIsNone(r.match('/foo/bar')) 128 | 129 | r = ice.WildcardRoute('//', m) 130 | self.assertEqual(r.match('/foo/bar'), (m, [], {})) 131 | self.assertIsNone(r.match('/foo'), (m, [], {})) 132 | self.assertIsNone(r.match('/foo/'), (m, [], {})) 133 | self.assertIsNone(r.match('/foo/'), (m, [], {})) 134 | 135 | r = ice.WildcardRoute('//bar', m) 136 | self.assertEqual(r.match('/foo/bar'), (m, [], {})) 137 | self.assertIsNone(r.match('/foo')) 138 | self.assertIsNone(r.match('/bar')) 139 | self.assertIsNone(r.match('/foo/bar/')) 140 | self.assertIsNone(r.match('/foo/bar/baz')) 141 | 142 | def test_throwaway_wildcard_with_colon(self): 143 | m = mock.Mock() 144 | 145 | r = ice.WildcardRoute('/', m) 146 | self.assertEqual(r.match('/foo'), (m, [], {})) 147 | self.assertEqual(r.match('/bar'), (m, [], {})) 148 | self.assertIsNone(r.match('/foo/')) 149 | self.assertIsNone(r.match('/foo/bar')) 150 | 151 | r = ice.WildcardRoute('//', m) 152 | self.assertEqual(r.match('/foo/bar'), (m, [], {})) 153 | self.assertIsNone(r.match('/foo'), (m, [], {})) 154 | self.assertIsNone(r.match('/foo/'), (m, [], {})) 155 | self.assertIsNone(r.match('/foo/'), (m, [], {})) 156 | 157 | r = ice.WildcardRoute('//bar', m) 158 | self.assertEqual(r.match('/foo/bar'), (m, [], {})) 159 | self.assertIsNone(r.match('/foo')) 160 | self.assertIsNone(r.match('/bar')) 161 | self.assertIsNone(r.match('/foo/bar/')) 162 | self.assertIsNone(r.match('/foo/bar/baz')) 163 | 164 | def test_throwaway_wildcard_with_type(self): 165 | m = mock.Mock() 166 | 167 | r = ice.WildcardRoute('/', m) 168 | self.assertEqual(r.match('/foo'), (m, [], {})) 169 | self.assertEqual(r.match('/bar'), (m, [], {})) 170 | self.assertIsNone(r.match('/foo/')) 171 | self.assertIsNone(r.match('/foo/bar')) 172 | 173 | r = ice.WildcardRoute('//', m) 174 | self.assertEqual(r.match('/10/-20'), (m, [], {})) 175 | self.assertIsNone(r.match('/-10/-20')) 176 | self.assertIsNone(r.match('/-10')) 177 | self.assertIsNone(r.match('/foo/bar')) 178 | self.assertIsNone(r.match('/')) 179 | 180 | def test_anonymous_str_wildcard(self): 181 | m = mock.Mock() 182 | 183 | r = ice.WildcardRoute('/<:str>', m) 184 | self.assertEqual(r.match('/foo'), (m, ['foo'], {})) 185 | self.assertEqual(r.match('/bar'), (m, ['bar'], {})) 186 | self.assertEqual(r.match('/'), (m, [''], {})) 187 | self.assertIsNone(r.match('/foo/')) 188 | self.assertIsNone(r.match('/foo/bar')) 189 | self.assertIsNone(r.match('/')) 190 | 191 | r = ice.WildcardRoute('/<:str>/<:str>', m) 192 | self.assertEqual(r.match('/foo/bar'), (m, ['foo', 'bar'], {})) 193 | self.assertIsNone(r.match('/foo')) 194 | self.assertIsNone(r.match('/foo/')) 195 | self.assertIsNone(r.match('/foo/bar/')) 196 | self.assertIsNone(r.match('//bar/')) 197 | 198 | r = ice.WildcardRoute('/<:str>/bar', m) 199 | self.assertEqual(r.match('/foo/bar'), (m, ['foo'], {})) 200 | self.assertEqual(r.match('//bar'), (m, [''], {})) 201 | self.assertIsNone(r.match('/foo')) 202 | self.assertIsNone(r.match('/bar')) 203 | self.assertIsNone(r.match('/foo/bar/')) 204 | self.assertIsNone(r.match('/foo/bar/baz')) 205 | 206 | def test_named_str_wildcard(self): 207 | m = mock.Mock() 208 | 209 | r = ice.WildcardRoute('/', m) 210 | self.assertEqual(r.match('/foo'), (m, [], {'a': 'foo'})) 211 | self.assertEqual(r.match('/bar'), (m, [], {'a': 'bar'})) 212 | self.assertIsNone(r.match('/foo/')) 213 | self.assertIsNone(r.match('/foo/bar')) 214 | 215 | r = ice.WildcardRoute('//', m) 216 | self.assertEqual(r.match('/foo/bar'), 217 | (m, [], {'a': 'foo', 'b': 'bar'})) 218 | self.assertIsNone(r.match('/foo'), (m, [], {})) 219 | self.assertIsNone(r.match('/foo/'), (m, [], {})) 220 | self.assertIsNone(r.match('/foo/'), (m, [], {})) 221 | self.assertIsNone(r.match('//foo'), (m, [], {})) 222 | 223 | r = ice.WildcardRoute('//bar', m) 224 | self.assertEqual(r.match('/foo/bar'), (m, [], {'a': 'foo'})) 225 | self.assertIsNone(r.match('/foo')) 226 | self.assertIsNone(r.match('/bar')) 227 | self.assertIsNone(r.match('/foo/bar/')) 228 | self.assertIsNone(r.match('/foo/bar/baz')) 229 | 230 | def test_anonymous_path_wildcard(self): 231 | m = mock.Mock() 232 | 233 | r = ice.WildcardRoute('<:path>', m) 234 | self.assertEqual(r.match('/foo'), (m, ['/foo'], {})) 235 | self.assertEqual(r.match('/bar'), (m, ['/bar'], {})) 236 | self.assertEqual(r.match('/'), (m, ['/'], {})) 237 | self.assertEqual(r.match('/foo/'), (m, ['/foo/'], {})) 238 | self.assertEqual(r.match('/foo/bar'), (m, ['/foo/bar'], {})) 239 | self.assertEqual(r.match('foo'), (m, ['foo'], {})) 240 | self.assertEqual(r.match('foo/'), (m, ['foo/'], {})) 241 | self.assertEqual(r.match('foo/bar'), (m, ['foo/bar'], {})) 242 | 243 | r = ice.WildcardRoute('<:path><:path>', m) 244 | self.assertEqual(r.match('/foo/bar'), (m, ['/foo/ba', 'r'], {})) 245 | self.assertEqual(r.match('/foo/bar/baz'), (m, ['/foo/bar/ba', 'z'], {})) 246 | self.assertEqual(r.match('/foo'), (m, ['/fo', 'o'], {})) 247 | 248 | r = ice.WildcardRoute('/<:path>/bar', m) 249 | self.assertEqual(r.match('/foo/bar'), (m, ['foo'], {})) 250 | self.assertEqual(r.match('//bar'), (m, [''], {})) 251 | self.assertEqual(r.match('/foo/bar/bar'), (m, ['foo/bar'], {})) 252 | self.assertIsNone(r.match('/foo')) 253 | self.assertIsNone(r.match('/bar')) 254 | self.assertIsNone(r.match('/foo/bar/')) 255 | self.assertIsNone(r.match('/foo/bar/baz')) 256 | 257 | r = ice.WildcardRoute('/<:path>', m) 258 | self.assertEqual(r.match('/foo/bar'), (m, ['foo/bar'], {})) 259 | self.assertIsNone(r.match('/')) 260 | 261 | def test_named_path_wildcard(self): 262 | m = mock.Mock() 263 | 264 | r = ice.WildcardRoute('', m) 265 | self.assertEqual(r.match('/foo'), (m, [], {'a': '/foo'})) 266 | self.assertEqual(r.match('/bar'), (m, [], {'a': '/bar'})) 267 | self.assertEqual(r.match('/'), (m, [], {'a': '/'})) 268 | self.assertEqual(r.match('/foo/'), (m, [], {'a': '/foo/'})) 269 | self.assertEqual(r.match('/foo/bar'), (m, [], {'a': '/foo/bar'})) 270 | self.assertEqual(r.match('foo'), (m, [], {'a': 'foo'})) 271 | self.assertEqual(r.match('foo/'), (m, [], {'a': 'foo/'})) 272 | self.assertEqual(r.match('foo/bar'), (m, [], {'a': 'foo/bar'})) 273 | 274 | r = ice.WildcardRoute('', m) 275 | self.assertEqual(r.match('/foo/bar'), 276 | (m, [], {'a': '/foo/ba', 'b': 'r'})) 277 | self.assertEqual(r.match('/foo/bar/baz'), 278 | (m, [], {'a': '/foo/bar/ba', 'b': 'z'})) 279 | self.assertEqual(r.match('/foo'), 280 | (m, [], {'a': '/fo', 'b': 'o'})) 281 | 282 | r = ice.WildcardRoute('//bar', m) 283 | self.assertEqual(r.match('/foo/bar'), (m, [], {'a': 'foo'})) 284 | self.assertIsNone(r.match('foo')) 285 | self.assertIsNone(r.match('/bar')) 286 | self.assertIsNone(r.match('/foo/bar/')) 287 | self.assertIsNone(r.match('/foo/bar/baz')) 288 | 289 | r = ice.WildcardRoute('/', m) 290 | self.assertEqual(r.match('/foo/bar'), (m, [], {'a': 'foo/bar'})) 291 | self.assertIsNone(r.match('/')) 292 | 293 | def test_anonymous_int_wildcard(self): 294 | m = mock.Mock() 295 | r = ice.WildcardRoute('/<:int>', m) 296 | self.assertEqual(r.match('/0'), (m, [0], {})) 297 | self.assertEqual(r.match('/1'), (m, [1], {})) 298 | self.assertEqual(r.match('/12'), (m, [12], {})) 299 | self.assertEqual(r.match('/123'), (m, [123], {})) 300 | self.assertIsNone(r.match('/-0')) 301 | self.assertIsNone(r.match('/+0')) 302 | self.assertIsNone(r.match('/-1')) 303 | self.assertIsNone(r.match('/+1')) 304 | self.assertIsNone(r.match('/-12')) 305 | self.assertIsNone(r.match('/+12')) 306 | self.assertIsNone(r.match('/-123')) 307 | self.assertIsNone(r.match('/+123')) 308 | self.assertIsNone(r.match('/-01')) 309 | self.assertIsNone(r.match('/01')) 310 | self.assertIsNone(r.match('/+01')) 311 | self.assertIsNone(r.match('/-012')) 312 | self.assertIsNone(r.match('/012')) 313 | self.assertIsNone(r.match('/+012')) 314 | self.assertIsNone(r.match('/1foo')) 315 | self.assertIsNone(r.match('/foo')) 316 | self.assertIsNone(r.match('/1/2')) 317 | 318 | def test_anonymous_positive_int_wildcard(self): 319 | m = mock.Mock() 320 | r = ice.WildcardRoute('/<:+int>', m) 321 | self.assertEqual(r.match('/1'), (m, [1], {})) 322 | self.assertEqual(r.match('/12'), (m, [12], {})) 323 | self.assertEqual(r.match('/123'), (m, [123], {})) 324 | self.assertIsNone(r.match('/-0')) 325 | self.assertIsNone(r.match('/0')) 326 | self.assertIsNone(r.match('/+0')) 327 | self.assertIsNone(r.match('/-1')) 328 | self.assertIsNone(r.match('/+1')) 329 | self.assertIsNone(r.match('/-12')) 330 | self.assertIsNone(r.match('/+12')) 331 | self.assertIsNone(r.match('/-123')) 332 | self.assertIsNone(r.match('/+123')) 333 | self.assertIsNone(r.match('/-01')) 334 | self.assertIsNone(r.match('/01')) 335 | self.assertIsNone(r.match('/+01')) 336 | self.assertIsNone(r.match('/-012')) 337 | self.assertIsNone(r.match('/012')) 338 | self.assertIsNone(r.match('/+012')) 339 | self.assertIsNone(r.match('/1foo')) 340 | self.assertIsNone(r.match('/foo')) 341 | self.assertIsNone(r.match('/1/2')) 342 | 343 | def test_anonymous_negative_int_wildcard(self): 344 | m = mock.Mock() 345 | r = ice.WildcardRoute('/<:-int>', m) 346 | self.assertEqual(r.match('/0'), (m, [0], {})) 347 | self.assertEqual(r.match('/-1'), (m, [-1], {})) 348 | self.assertEqual(r.match('/1'), (m, [1], {})) 349 | self.assertEqual(r.match('/-12'), (m, [-12], {})) 350 | self.assertEqual(r.match('/12'), (m, [12], {})) 351 | self.assertEqual(r.match('/-123'), (m, [-123], {})) 352 | self.assertEqual(r.match('/123'), (m, [123], {})) 353 | self.assertIsNone(r.match('/-0')) 354 | self.assertIsNone(r.match('/+0')) 355 | self.assertIsNone(r.match('/+1')) 356 | self.assertIsNone(r.match('/+12')) 357 | self.assertIsNone(r.match('/+123')) 358 | self.assertIsNone(r.match('/-01')) 359 | self.assertIsNone(r.match('/01')) 360 | self.assertIsNone(r.match('/+01')) 361 | self.assertIsNone(r.match('/-012')) 362 | self.assertIsNone(r.match('/012')) 363 | self.assertIsNone(r.match('/+012')) 364 | self.assertIsNone(r.match('/1foo')) 365 | self.assertIsNone(r.match('/foo')) 366 | self.assertIsNone(r.match('/1/2')) 367 | 368 | def test_consecutive_int_wildcard(self): 369 | m = mock.Mock() 370 | r = ice.WildcardRoute('/<:int><:-int>', m) 371 | self.assertEqual(r.match('/123'), (m, [12, 3], {})) 372 | self.assertEqual(r.match('/00'), (m, [0, 0], {})) 373 | self.assertEqual(r.match('/12-3'), (m, [12, -3], {})) 374 | self.assertIsNone(r.match('/12+3')) 375 | self.assertIsNone(r.match('/-12-3')) 376 | self.assertIsNone(r.match('/12-3-4')) 377 | self.assertIsNone(r.match('/000')) 378 | self.assertIsNone(r.match('/0000')) 379 | 380 | def test_regex_ineffective_in_wildcard(self): 381 | # Any regular expression should get escaped by ice.WildcardRoute 382 | # so that they match literal strings 383 | m = mock.Mock() 384 | 385 | r = ice.WildcardRoute('/.*', m) 386 | self.assertEqual(r.match('/.*'), (m, [], {})) 387 | self.assertIsNone(r.match('/foo')) 388 | 389 | r = ice.WildcardRoute('/(.*)', m) 390 | self.assertEqual(r.match('/(.*)'), (m, [], {})) 391 | self.assertIsNone(r.match('/foo')) 392 | self.assertIsNone(r.match('/(foo)')) 393 | 394 | r = ice.WildcardRoute(r'/\w{3}', m) 395 | self.assertEqual(r.match('/\w{3}'), (m, [], {})) 396 | self.assertIsNone(r.match('/foo')) 397 | 398 | r = ice.WildcardRoute('/<>//(.*)', m) 399 | self.assertEqual(r.match('/foo/bar/(.*)'), 400 | (m, ['foo'], {'a': 'bar'})) 401 | self.assertIsNone(r.match('/foo/bar/baz')) 402 | self.assertIsNone(r.match('/foo/bar/(baz)')) 403 | 404 | def test_like(self): 405 | self.assertTrue(ice.WildcardRoute.like('/<>')) 406 | self.assertTrue(ice.WildcardRoute.like('/<<>>')) 407 | self.assertTrue(ice.WildcardRoute.like('/')) 408 | self.assertFalse(ice.WildcardRoute.like('/')) 409 | self.assertFalse(ice.WildcardRoute.like('/')) 410 | self.assertFalse(ice.WildcardRoute.like('/.*')) 411 | self.assertFalse(ice.WildcardRoute.like('/(.*)')) 412 | self.assertFalse(ice.WildcardRoute.like('/foo')) 413 | -------------------------------------------------------------------------------- /test/test_examples.py: -------------------------------------------------------------------------------- 1 | # The MIT License (MIT) 2 | # 3 | # Copyright (c) 2014-2017 Susam Pal 4 | # 5 | # Permission is hereby granted, free of charge, to any person obtaining 6 | # a copy of this software and associated documentation files (the 7 | # "Software"), to deal in the Software without restriction, including 8 | # without limitation the rights to use, copy, modify, merge, publish, 9 | # distribute, sublicense, and/or sell copies of the Software, and to 10 | # permit persons to whom the Software is furnished to do so, subject to 11 | # the following conditions: 12 | # 13 | # The above copyright notice and this permission notice shall be 14 | # included in all copies or substantial portions of the Software. 15 | # 16 | # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 17 | # EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 18 | # MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. 19 | # IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY 20 | # CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, 21 | # TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE 22 | # SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 23 | 24 | 25 | """Tests to verify examples in README.rst.""" 26 | 27 | 28 | import unittest 29 | import urllib.request 30 | import urllib.error 31 | import threading 32 | import time 33 | 34 | import ice 35 | from test import data 36 | 37 | class ExamplesTest(unittest.TestCase): 38 | 39 | def setUp(self): 40 | self.app = ice.cube() 41 | 42 | def tearDown(self): 43 | self.app.exit() 44 | 45 | def run_app(self): 46 | threading.Thread(target=self.app.run).start() 47 | while not self.app.running(): 48 | time.sleep(0.1) 49 | 50 | def assert200(self, path, *snippets): 51 | r = urllib.request.urlopen('http://localhost:8080' + path) 52 | response = r.read() 53 | for snippet in snippets: 54 | self.assertIn(snippet.encode(), response) 55 | 56 | def assert404(self, path): 57 | with self.assertRaises(urllib.error.HTTPError) as cm: 58 | r = urllib.request.urlopen('http://localhost:8080' + path) 59 | self.assertEqual(cm.exception.code, 404) 60 | self.assertIn(b'

404 Not Found

', cm.exception.read()) 61 | 62 | def test_getting_started_example(self): 63 | self.run_app() 64 | self.assert200('/', '

It works!

') 65 | self.assert404('/foo') 66 | 67 | def test_literal_route_example(self): 68 | app = self.app 69 | 70 | # Example 71 | @app.get('/') 72 | def home(): 73 | return ('' 74 | 'Home' 75 | '

Home

') 76 | 77 | @app.get('/foo') 78 | def foo(): 79 | return ('' 80 | 'Foo' 81 | '

Foo

') 82 | 83 | # Test 84 | self.run_app() 85 | self.assert200('/', '

Home

') 86 | self.assert200('/foo', '

Foo

') 87 | self.assert404('/foo/') 88 | self.assert404('/bar') 89 | 90 | def test_anonymous_wildcard_example(self): 91 | app = self.app 92 | 93 | # Example 94 | @app.get('/<>') 95 | def foo(a): 96 | return ('' 97 | '' + a + '' 98 | '

' + a + '

') 99 | 100 | # Test 101 | self.run_app() 102 | self.assert200('/foo', '

foo

') 103 | self.assert200('/bar', '

bar

') 104 | self.assert404('/foo/') 105 | self.assert404('/foo/bar') 106 | 107 | def test_named_wildcard_example1(self): 108 | app = self.app 109 | 110 | # Example 111 | @app.get('/
') 112 | def foo(a): 113 | return ('' 114 | '' + a + '' 115 | '

' + a + '

') 116 | 117 | # Test 118 | self.run_app() 119 | self.assert200('/foo', '

foo

') 120 | self.assert200('/bar', '

bar

') 121 | self.assert404('/foo/') 122 | self.assert404('/foo/bar') 123 | 124 | def test_named_wildcard_example2(self): 125 | app = self.app 126 | 127 | # Example 128 | @app.get('/foo/<>-<>/
-/<>-') 129 | def foo(*args, **kwargs): 130 | return (' ' 131 | 'Example ' 132 | '

args: {}
kwargs: {}

' 133 | '').format(args, kwargs) 134 | 135 | # Test 136 | self.run_app() 137 | self.assert200('/foo/hello-world/ice-cube/wsgi-rocks', 138 | "args: ('hello', 'world', 'wsgi')", 139 | "'a': 'ice'", "'b': 'cube'", "'c': 'rocks'") 140 | 141 | def test_named_wildcard_example3(self): 142 | app = self.app 143 | 144 | # Example 145 | @app.get('///<>') 146 | def page(page_id, user, category): 147 | return ('' 148 | 'Example ' 149 | '

page_id: {}
user: {}
category: {}

' 150 | '').format(page_id, user, category) 151 | 152 | # Test 153 | self.run_app() 154 | self.assert200('/snowman/articles/python', 155 | '

page_id: python
user: snowman
' 156 | 'category: articles

') 157 | 158 | def test_throwaway_wildcard_example1(self): 159 | app = self.app 160 | 161 | # Example 162 | @app.get('/') 163 | def foo(*args, **kwargs): 164 | return ('' 165 | 'Example' 166 | '

args: {}
kwargs: {}

' 167 | '').format(args, kwargs) 168 | 169 | # Test 170 | self.run_app() 171 | self.assert200('/foo', '

args: ()
kwargs: {}

') 172 | 173 | def test_throwaway_wildcard_example2(self): 174 | app = self.app 175 | 176 | # Example 177 | @app.get('///<>') 178 | def page(page_id): 179 | return ('' 180 | 'Example' 181 | '

page_id: ' + page_id + '

' 182 | '') 183 | 184 | # Test 185 | self.run_app() 186 | self.assert200('/snowman/articles/python', 187 | '

page_id: python

') 188 | 189 | def test_wildcard_specification_example(self): 190 | app = self.app 191 | 192 | # Example 193 | @app.get('/notes/<:path>/<:int>') 194 | def note(note_path, note_id): 195 | return ('' 196 | 'Example' 197 | '

note_path: {}
note_id: {}

' 198 | '').format(note_path, note_id) 199 | 200 | # Test 201 | self.run_app() 202 | self.assert200('/notes/tech/python/12', 203 | '

note_path: tech/python
note_id: 12

') 204 | self.assert200('/notes/tech/python/0', 205 | '

note_path: tech/python
note_id: 0

') 206 | self.assert404('/notes/tech/python/+12') 207 | self.assert404('/notes/tech/python/+0') 208 | self.assert404('/notes/tech/python/012') 209 | 210 | def test_regex_route_example1(self): 211 | app = self.app 212 | 213 | # Example 214 | @app.get('/(.*)') 215 | def foo(a): 216 | return ('' 217 | '' + a + '' 218 | '

' + a + '

') 219 | 220 | # Test 221 | self.run_app() 222 | self.assert200('/foo', '

foo

') 223 | self.assert200('/foo/bar/', '

foo/bar/

') 224 | 225 | def test_regex_route_example2(self): 226 | app = self.app 227 | 228 | # Example 229 | @app.get('/(?P[^/]*)/(?P[^/]*)/([^/]*)') 230 | def page(page_id, user, category): 231 | return ('' 232 | 'Example' 233 | '

page_id: {}
user: {}
category: {}

' 234 | '').format(page_id, user, category) 235 | 236 | # Test 237 | self.run_app() 238 | self.assert200('/snowman/articles/python', 239 | '

page_id: python
user: snowman
' 240 | 'category: articles

') 241 | 242 | def test_explicit_literal_route_example(self): 243 | app = self.app 244 | 245 | # Example 246 | @app.get('literal:/') 247 | def foo(): 248 | return ('' 249 | 'Foo' 250 | '

Foo

') 251 | 252 | # Test 253 | self.run_app() 254 | self.assert200('/', '

Foo

') 255 | self.assert404('/foo') 256 | 257 | def test_explicit_wildcard_route_example(self): 258 | # Example 259 | app = self.app 260 | @app.get('wildcard:/(foo)/<>') 261 | def foo(a): 262 | return ('' 263 | 'Foo' 264 | '

a: ' + a + '

') 265 | 266 | # Test 267 | self.run_app() 268 | self.assert200('/(foo)/bar', '

a: bar

') 269 | self.assert404('/foo/<>') 270 | 271 | def test_explicit_regex_route_example(self): 272 | app = self.app 273 | 274 | # Example 275 | @app.get('regex:/foo\d*$') 276 | def foo(): 277 | return ('' 278 | 'Foo' 279 | '

Foo

') 280 | 281 | # Test 282 | self.run_app() 283 | self.assert200('/foo123', '

Foo

') 284 | self.assert200('/foo', '

Foo

') 285 | self.assert404('/foo\d*$') 286 | 287 | def test_query_string_example1(self): 288 | app = self.app 289 | 290 | # Example 291 | @app.get('/') 292 | def home(): 293 | return ('' 294 | 'Foo' 295 | '

name: {}

' 296 | '').format(app.request.query['name']) 297 | 298 | # Test 299 | self.run_app() 300 | self.assert200('/?name=Humpty+Dumpty', 301 | '

name: Humpty Dumpty

') 302 | 303 | def test_query_string_example2(self): 304 | app = self.app 305 | 306 | # Example 307 | @app.get('/') 308 | def home(): 309 | return ('' 310 | 'Foo' 311 | '

name: {}

' 312 | '').format(app.request.query.getall('name')) 313 | 314 | # Test 315 | self.run_app() 316 | self.assert200('/?name=Humpty&name=Santa', 317 | "

name: ['Humpty', 'Santa']

") 318 | 319 | def test_form_example1(self): 320 | app = self.app 321 | 322 | # Example 323 | @app.get('/') 324 | def show_form(): 325 | return ('' 326 | 'Foo' 327 | '
' 328 | 'First name:
' 329 | 'Last name:
' 330 | '' 331 | '
') 332 | 333 | @app.post('/result') 334 | def show_post(): 335 | return ('' 336 | 'Foo' 337 | '

First name: {}
Last name: {}

' 338 | '').format(app.request.form['firstName'], 339 | app.request.form['lastName']) 340 | 341 | # Test 342 | self.run_app() 343 | self.assert200('/', 'First name') 344 | form = {'firstName': 'Humpty', 'lastName': 'Dumpty'} 345 | data = urllib.parse.urlencode(form).encode() 346 | response = urllib.request.urlopen( 347 | 'http://localhost:8080/result', data) 348 | self.assertIn(b'

First name: Humpty
Last name: Dumpty

', 349 | response.read() ) 350 | 351 | def test_form_example2(self): 352 | app = self.app 353 | 354 | # Example 355 | @app.get('/') 356 | def show_form(): 357 | return ('' 358 | 'Foo' 359 | '
' 360 | 'name1:
' 361 | 'name2:
' 362 | '' 363 | '
') 364 | 365 | @app.post('/result') 366 | def show_post(): 367 | return ('' 368 | 'Foo' 369 | '

name (single): {}
name (multi): {}

' 370 | '').format(app.request.form['name'], 371 | app.request.form.getall('name')) 372 | 373 | # Test 374 | self.run_app() 375 | self.assert200('/', 'name1') 376 | form = (('name', 'Humpty'), ('name', 'Santa')) 377 | data = urllib.parse.urlencode(form).encode() 378 | response = urllib.request.urlopen( 379 | 'http://localhost:8080/result', data) 380 | self.assertIn(b'

name (single): Santa
' 381 | b"name (multi): ['Humpty', 'Santa']

", 382 | response.read() ) 383 | 384 | def test_cookie_example(self): 385 | app = self.app 386 | 387 | @app.get('/') 388 | def show_count(): 389 | count = int(app.request.cookies.get('count', 0)) + 1 390 | app.response.set_cookie('count', str(count)) 391 | return ('' 392 | 'Foo' 393 | '

Count: {}

'.format(count)) 394 | 395 | # Test 396 | self.run_app() 397 | response = urllib.request.urlopen('http://localhost:8080/') 398 | self.assertEqual(response.getheader('Set-Cookie'), 'count=1') 399 | self.assertIn(b'

Count: 1

', response.read()) 400 | 401 | response = urllib.request.urlopen( 402 | urllib.request.Request('http://localhost:8080/', 403 | headers={'Cookie': 'count=1'})) 404 | self.assertEqual(response.getheader('Set-Cookie'), 'count=2') 405 | self.assertIn(b'

Count: 2

', response.read()) 406 | 407 | 408 | def test_error_example(self): 409 | app = self.app 410 | 411 | # Example 412 | @app.error(404) 413 | def error(): 414 | return ('' 415 | 'Page not found' 416 | '

Page not found

') 417 | 418 | # Test 419 | self.run_app() 420 | with self.assertRaises(urllib.error.HTTPError) as cm: 421 | urllib.request.urlopen('http://localhost:8080/foo') 422 | self.assertEqual(cm.exception.code, 404) 423 | self.assertIn(b'

Page not found

', cm.exception.read()) 424 | 425 | # Set status code and return body 426 | def test_status_codes_example1(self): 427 | app = self.app 428 | 429 | # Example 430 | @app.get('/foo') 431 | def foo(): 432 | app.response.status = 403 433 | return ('' 434 | 'Access is forbidden' 435 | '

Access is forbidden

') 436 | 437 | # Test 438 | self.run_app() 439 | with self.assertRaises(urllib.error.HTTPError) as cm: 440 | urllib.request.urlopen('http://localhost:8080/foo') 441 | self.assertEqual(cm.exception.code, 403) 442 | self.assertIn(b'

Access is forbidden

', cm.exception.read()) 443 | 444 | # Set body and return status code (not recommended) 445 | def test_status_code_example2(self): 446 | app = self.app 447 | 448 | # Example 449 | @app.get('/foo') 450 | def foo(): 451 | app.response.body = ('' 452 | 'Access is forbidden' 453 | '

Access is forbidden

') 454 | return 403 455 | 456 | # Test 457 | self.run_app() 458 | with self.assertRaises(urllib.error.HTTPError) as cm: 459 | urllib.request.urlopen('http://localhost:8080/foo') 460 | self.assertEqual(cm.exception.code, 403) 461 | self.assertIn(b'

Access is forbidden

', cm.exception.read()) 462 | 463 | # Set status code and error handler (recommended) 464 | def test_status_code_example3(self): 465 | app = self.app 466 | 467 | # Example 468 | @app.get('/foo') 469 | def foo(): 470 | return 403 471 | 472 | @app.error(403) 473 | def error403(): 474 | return ('' 475 | 'Access is forbidden' 476 | '

Access is forbidden

') 477 | 478 | # Test 479 | self.run_app() 480 | with self.assertRaises(urllib.error.HTTPError) as cm: 481 | urllib.request.urlopen('http://localhost:8080/foo') 482 | self.assertEqual(cm.exception.code, 403) 483 | self.assertIn(b'

Access is forbidden

', cm.exception.read()) 484 | 485 | # Set return code only (generic error handler is invoked) 486 | def test_status_code_example4(self): 487 | app = self.app 488 | 489 | # Example 490 | @app.get('/foo') 491 | def foo(): 492 | return 403 493 | 494 | # Test 495 | self.run_app() 496 | with self.assertRaises(urllib.error.HTTPError) as cm: 497 | urllib.request.urlopen('http://localhost:8080/foo') 498 | self.assertEqual(cm.exception.code, 403) 499 | self.assertIn(b'

403 Forbidden

\n

Request forbidden ' 500 | b'-- authorization will not help

\n', 501 | cm.exception.read()) 502 | 503 | def test_redirect_example1(self): 504 | app = self.app 505 | 506 | @app.get('/foo') 507 | def foo(): 508 | return 303, '/bar' 509 | 510 | @app.get('/bar') 511 | def bar(): 512 | return ('' 513 | 'Bar' 514 | '

Bar

') 515 | 516 | self.run_app() 517 | response = urllib.request.urlopen('http://localhost:8080/foo') 518 | self.assertIn(b'

Bar

', response.read()) 519 | 520 | def test_redirect_example2(self): 521 | app = self.app 522 | 523 | @app.get('/foo') 524 | def foo(): 525 | app.response.add_header('Location', '/bar') 526 | return 303 527 | 528 | @app.get('/bar') 529 | def bar(): 530 | return ('' 531 | 'Bar' 532 | '

Bar

') 533 | 534 | self.run_app() 535 | response = urllib.request.urlopen('http://localhost:8080/foo') 536 | self.assertIn(b'

Bar

', response.read()) 537 | 538 | # Static file with media type guessing 539 | def test_static_file_example1(self): 540 | app = self.app 541 | 542 | # Example 543 | @app.get('/code/<:path>') 544 | def send_code(path): 545 | return app.static(data.dirpath, path) 546 | 547 | # Test regular request 548 | self.run_app() 549 | response = urllib.request.urlopen('http://localhost:8080/code/foo.txt') 550 | self.assertEqual(response.read(), b'foo\n') 551 | self.assertEqual(response.getheader('Content-Type'), 552 | 'text/plain; charset=UTF-8') 553 | 554 | # Test directory traversal attack 555 | with self.assertRaises(urllib.error.HTTPError) as cm: 556 | urllib.request.urlopen('http://localhost:8080/code/%2e%2e/foo.txt') 557 | self.assertEqual(cm.exception.code, 403) 558 | 559 | # Static file with explicit media type 560 | def test_static_file_example2(self): 561 | app = self.app 562 | 563 | # Example 564 | @app.get('/code/<:path>') 565 | def send_code(path): 566 | return app.static(data.dirpath, path, 567 | media_type='text/plain', charset='ISO-8859-1') 568 | 569 | # Test regular request 570 | self.run_app() 571 | response = urllib.request.urlopen('http://localhost:8080/code/foo.c') 572 | self.assertEqual(b'#include \n\n' 573 | b'int main()\n{\n' 574 | b' printf("hello, world\\n");\n' 575 | b' return 0;\n' 576 | b'}\n', response.read()) 577 | self.assertEqual(response.getheader('Content-Type'), 578 | 'text/plain; charset=ISO-8859-1') 579 | 580 | # Test directory traversal attack 581 | with self.assertRaises(urllib.error.HTTPError) as cm: 582 | urllib.request.urlopen('http://localhost:8080/code/%2e%2e/foo.txt') 583 | self.assertEqual(cm.exception.code, 403) 584 | 585 | def test_download_example1(self): 586 | app = self.app 587 | 588 | # Example 589 | @app.get('/foo') 590 | def foo(): 591 | return app.download('hello, world', 'foo.txt') 592 | 593 | @app.get('/bar') 594 | def bar(): 595 | return app.download('hello, world', 'bar', 596 | media_type='text/plain', charset='ISO-8859-1') 597 | 598 | # Test 599 | self.run_app() 600 | response = urllib.request.urlopen('http://localhost:8080/foo') 601 | self.assertEqual(response.getheader('Content-Disposition'), 602 | 'attachment; filename="foo.txt"') 603 | self.assertEqual(response.getheader('Content-Type'), 604 | 'text/plain; charset=UTF-8') 605 | self.assertEqual(b'hello, world', response.read()) 606 | 607 | response = urllib.request.urlopen('http://localhost:8080/bar') 608 | self.assertEqual(response.getheader('Content-Disposition'), 609 | 'attachment; filename="bar"') 610 | self.assertEqual(response.getheader('Content-Type'), 611 | 'text/plain; charset=ISO-8859-1') 612 | self.assertEqual(b'hello, world', response.read()) 613 | 614 | def test_download_example2(self): 615 | app = self.app 616 | 617 | # Example 618 | @app.get('/code/<:path>') 619 | def send_download(path): 620 | return app.download(app.static(data.dirpath, path)) 621 | 622 | # Test 623 | self.run_app() 624 | response = urllib.request.urlopen('http://localhost:8080/code/foo.txt') 625 | self.assertEqual(response.getheader('Content-Disposition'), 626 | 'attachment; filename="foo.txt"') 627 | self.assertEqual(response.getheader('Content-Type'), 628 | 'text/plain; charset=UTF-8') 629 | self.assertEqual(b'foo\n', response.read()) 630 | 631 | def test_download_example3(self): 632 | app = self.app 633 | 634 | # Example 635 | @app.get('/') 636 | def send_download(): 637 | return app.download('hello, world') 638 | 639 | # Test 640 | self.run_app() 641 | response = urllib.request.urlopen('http://localhost:8080/foo.txt') 642 | self.assertEqual(response.getheader('Content-Disposition'), 643 | 'attachment; filename="foo.txt"') 644 | self.assertEqual(response.getheader('Content-Type'), 645 | 'text/plain; charset=UTF-8') 646 | self.assertEqual(b'hello, world', response.read()) 647 | 648 | with self.assertRaises(urllib.error.HTTPError) as cm: 649 | r = urllib.request.urlopen('http://localhost:8080/foo/') 650 | self.assertEqual(cm.exception.code, 500) 651 | 652 | def test_environ(self): 653 | app = self.app 654 | 655 | # Example 656 | @app.get('/') 657 | def foo(): 658 | user_agent = app.request.environ.get('HTTP_USER_AGENT', None) 659 | return ('' 660 | 'User Agent' 661 | '

{}

'.format(user_agent)) 662 | 663 | # Test 664 | self.run_app() 665 | self.assert200('/', 'Python-urllib') 666 | -------------------------------------------------------------------------------- /ice.py: -------------------------------------------------------------------------------- 1 | # The MIT License (MIT) 2 | # 3 | # Copyright (c) 2014-2017 Susam Pal 4 | # 5 | # Permission is hereby granted, free of charge, to any person obtaining 6 | # a copy of this software and associated documentation files (the 7 | # "Software"), to deal in the Software without restriction, including 8 | # without limitation the rights to use, copy, modify, merge, publish, 9 | # distribute, sublicense, and/or sell copies of the Software, and to 10 | # permit persons to whom the Software is furnished to do so, subject to 11 | # the following conditions: 12 | # 13 | # The above copyright notice and this permission notice shall be 14 | # included in all copies or substantial portions of the Software. 15 | # 16 | # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 17 | # EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 18 | # MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. 19 | # IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY 20 | # CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, 21 | # TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE 22 | # SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 23 | 24 | 25 | """Ice - WSGI on the rocks. 26 | 27 | Ice is a simple and tiny WSGI microframework meant for developing small 28 | Python web applications. 29 | """ 30 | 31 | 32 | __version__ = '0.0.2' 33 | __date__ = '25 March 2014' 34 | __author__ = 'Susam Pal ' 35 | __credits__ = ('Marcel Hellkamp, for writing bottle, the inspiration ' 36 | 'behind ice.') 37 | 38 | 39 | import collections 40 | import itertools 41 | import re 42 | import cgi 43 | import urllib.parse 44 | import http.server 45 | import http.cookies 46 | import os 47 | import mimetypes 48 | 49 | 50 | def cube(): 51 | """Return an Ice application with a default home page. 52 | 53 | Create :class:`Ice` object, add a route to return the default page 54 | when a client requests the server root, i.e. /, using HTTP GET 55 | method, add an error handler to return HTTP error pages when an 56 | error occurs and return this object. The returned object can be used 57 | as a WSGI application. 58 | 59 | Returns: 60 | Ice: WSGI application. 61 | """ 62 | app = Ice() 63 | 64 | @app.get('/') 65 | def default_home_page(): 66 | """Return a default home page.""" 67 | return simple_html('It works!', 68 | '

It works!

\n' 69 | '

This is the default ice web page.

') 70 | 71 | @app.error() 72 | def generic_error_page(): 73 | """Return a simple and generic error page.""" 74 | return simple_html(app.response.status_line, 75 | '

{title}

\n' 76 | '

{description}

\n' 77 | '
\n' 78 | '
Ice/{version}
'.format( 79 | title=app.response.status_line, 80 | description=app.response.status_detail, 81 | version=__version__)) 82 | 83 | def simple_html(title, body): 84 | """Return a simple HTML page.""" 85 | return ( 86 | '\n' 87 | '\n{title}\n' 88 | '\n{body}\n\n\n' 89 | ).format(title=title, body=body) 90 | 91 | return app 92 | 93 | 94 | class Ice: 95 | 96 | """A single WSGI application. 97 | 98 | Each instance of this class is a single, distinct callable object 99 | that functions as WSGI application. 100 | """ 101 | 102 | def __init__(self): 103 | """Initialize the application.""" 104 | self._router = Router() 105 | self._server = None 106 | self._error_handlers = {} 107 | 108 | def run(self, host='127.0.0.1', port=8080): 109 | """Run the application using a simple WSGI server. 110 | 111 | Arguments: 112 | host (str, optional): Host on which to listen. 113 | port (int, optional): Port number on which to listen. 114 | """ 115 | from wsgiref import simple_server 116 | self._server = simple_server.make_server(host, port, self) 117 | self._server.serve_forever() 118 | 119 | def exit(self): 120 | """Stop the simple WSGI server running the appliation.""" 121 | if self._server is not None: 122 | self._server.shutdown() 123 | self._server.server_close() 124 | self._server = None 125 | 126 | def running(self): 127 | """Return ``True`` iff simple WSGI server is running. 128 | 129 | Returns: 130 | bool: ``True`` if simple WSGI server associated with this 131 | application is running, ``False`` otherwise. 132 | """ 133 | return self._server is not None 134 | 135 | def get(self, pattern): 136 | """Decorator to add route for an HTTP GET request. 137 | 138 | Arguments: 139 | pattern (str): Routing pattern the path must match. 140 | 141 | Returns: 142 | function: Decorator to add route for HTTP GET request. 143 | """ 144 | return self.route('GET', pattern) 145 | 146 | def post(self, pattern): 147 | """Decorator to add route for an HTTP POST request. 148 | 149 | Arguments: 150 | pattern (str): Routing pattern the path must match. 151 | 152 | Returns: 153 | function: Decorator to add route for HTTP POST request. 154 | """ 155 | return self.route('POST', pattern) 156 | 157 | def route(self, method, pattern): 158 | """Decorator to add route for a request with any HTTP method. 159 | 160 | Arguments: 161 | method (str): HTTP method name, e.g. GET, POST, etc. 162 | pattern (str): Routing pattern the path must match. 163 | 164 | Returns: 165 | function: Decorator function to add route. 166 | """ 167 | def decorator(callback): 168 | self._router.add(method, pattern, callback) 169 | return callback 170 | return decorator 171 | 172 | def error(self, status=None): 173 | """Decorator to add a callback that generates error page. 174 | 175 | The *status* parameter specifies the HTTP response status code 176 | for which the decorated callback should be invoked. If the 177 | *status* argument is not specified, then the decorated callable 178 | is considered to be a fallback callback. 179 | 180 | A fallback callback, when defined, is invoked to generate the 181 | error page for any HTTP response representing an error when 182 | there is no error handler defined explicitly for the response 183 | code of the HTTP response. 184 | 185 | Arguments: 186 | status(int, optional): HTTP response status code. 187 | 188 | Returns: 189 | function: Decorator function to add error handler. 190 | """ 191 | def decorator(callback): 192 | self._error_handlers[status] = callback 193 | return callback 194 | return decorator 195 | 196 | def static(self, root, path, media_type=None, charset='UTF-8'): 197 | """Send content of a static file as response. 198 | 199 | The path to the document root directory should be specified as 200 | the root argument. This is very important to prevent directory 201 | traversal attack. This method guarantees that only files within 202 | the document root directory are served and no files outside this 203 | directory can be accessed by a client. 204 | 205 | The path to the actual file to be returned should be specified 206 | as the path argument. This path must be relative to the document 207 | directory. 208 | 209 | The *media_type* and *charset* arguments are used to set the 210 | Content-Type header of the HTTP response. If *media_type* 211 | is not specified or specified as ``None`` (the default), then it 212 | is guessed from the filename of the file to be returned. 213 | 214 | Arguments: 215 | root (str): Path to document root directory. 216 | path (str): Path to file relative to document root directory. 217 | media_type (str, optional): Media type of file. 218 | charset (str, optional): Character set of file. 219 | 220 | Returns: 221 | bytes: Content of file to be returned in the HTTP response. 222 | """ 223 | root = os.path.abspath(os.path.join(root, '')) 224 | path = os.path.abspath(os.path.join(root, path.lstrip('/\\'))) 225 | 226 | # Save the filename from the path in the response state, so that 227 | # a following download() call can default to this filename for 228 | # downloadable file when filename is not explicitly specified. 229 | self.response.state['filename'] = os.path.basename(path) 230 | 231 | if not path.startswith(root): 232 | return 403 233 | elif not os.path.isfile(path): 234 | return 404 235 | 236 | if media_type is not None: 237 | self.response.media_type = media_type 238 | else: 239 | self.response.media_type = mimetypes.guess_type(path)[0] 240 | self.response.charset = charset 241 | 242 | with open(path, 'rb') as f: 243 | return f.read() 244 | 245 | def download(self, content, filename=None, 246 | media_type=None, charset='UTF-8'): 247 | """Send content as attachment (downloadable file). 248 | 249 | The *content* is sent after setting Content-Disposition header 250 | such that the client prompts the user to save the content 251 | locally as a file. An HTTP response status code may be specified 252 | as *content*. If the status code is not ``200``, then this 253 | method does nothing and returns the status code. 254 | 255 | The filename used for the download is determined according to 256 | the following rules. The rules are followed in the specified 257 | order. 258 | 259 | 1. If *filename* is specified, then the base name from this 260 | argument, i.e. ``os.path.basename(filename)``, is used as the 261 | filename for the download. 262 | 2. If *filename* is not specified or specified as ``None`` 263 | (the default), then the base name from the file path 264 | specified to a previous :meth:`static` call made while 265 | handling the current request is used. 266 | 3. If *filename* is not specified and there was no 267 | :meth:`static` call made previously for the current 268 | request, then the base name from the current HTTP request 269 | path is used. 270 | 4. As a result of the above steps, if the resultant *filename* 271 | turns out to be empty, then :exc:`ice.LogicError` is raised. 272 | 273 | The *media_type* and *charset* arguments are used in the same 274 | manner as they are used in :meth:`static`. 275 | 276 | Arguments: 277 | content (str, bytes or int): Content to be sent as download or 278 | HTTP status code of the response to be returned. 279 | filename (str): Filename to use for saving the content 280 | media_type (str, optional): Media type of file. 281 | charset (str, optional): Character set of file. 282 | 283 | Returns: 284 | content, i.e. the first argument passed to this method. 285 | 286 | Raises: 287 | LogicError: When filename cannot be determined. 288 | """ 289 | if isinstance(content, int) and content != 200: 290 | return content 291 | if filename is not None: 292 | filename = os.path.basename(filename) 293 | elif 'filename' in self.response.state: 294 | filename = self.response.state['filename'] 295 | else: 296 | filename = os.path.basename(self.request.path) 297 | 298 | if filename == '': 299 | raise LogicError('Cannot determine filename for download') 300 | 301 | if media_type is not None: 302 | self.response.media_type = media_type 303 | else: 304 | self.response.media_type = mimetypes.guess_type(filename)[0] 305 | self.response.charset = charset 306 | self.response.add_header('Content-Disposition', 'attachment; ' 307 | 'filename="{}"'.format(filename)) 308 | return content 309 | 310 | def __call__(self, environ, start_response): 311 | """Respond to an HTTP request. 312 | 313 | Arguments: 314 | environ (dict): Dictionary of environment variables 315 | start_response (callable): Callable to start HTTP response 316 | 317 | Returns: 318 | list: List containing a single sequence of bytes. 319 | """ 320 | self.request = Request(environ) 321 | self.response = Response(start_response) 322 | 323 | route = self._router.resolve(self.request.method, 324 | self.request.path) 325 | if route is not None: 326 | callback, args, kwargs = route 327 | value = callback(*args, **kwargs) 328 | elif self._router.contains_method(self.request.method): 329 | value = 404 # Not found 330 | else: 331 | value = 501 # Not Implemented 332 | 333 | if isinstance(value, str) or isinstance(value, bytes): 334 | self.response.body = value 335 | 336 | elif isinstance(value, int) and value in Response._responses: 337 | self.response.status = value 338 | if self.response.body is None: 339 | self.response.body = self._get_error_page_callback()() 340 | 341 | elif (isinstance(value, tuple) and 342 | isinstance(value[0], int) and 343 | isinstance(value[1], str) and 344 | value[0] in Response._responses and 345 | 300 <= value[0] <= 308): 346 | 347 | self.response.add_header('Location', value[1]) 348 | self.response.status = value[0] 349 | if self.response.body is None: 350 | self.response.body = self._get_error_page_callback()() 351 | 352 | else: 353 | raise Error('Route callback for {} {} returned invalid ' 354 | 'value: {}: {!r}'.format(self.request.method, 355 | self.request.path, type(value).__name__, value)) 356 | 357 | return self.response.response() 358 | 359 | def _get_error_page_callback(self): 360 | """Return an error page for the current response status.""" 361 | if self.response.status in self._error_handlers: 362 | return self._error_handlers[self.response.status] 363 | elif None in self._error_handlers: 364 | return self._error_handlers[None] 365 | else: 366 | # Rudimentary error handler if no error handler was found 367 | self.response.media_type = 'text/plain' 368 | return lambda: self.response.status_line 369 | 370 | 371 | class Router: 372 | 373 | """Route management and resolution.""" 374 | 375 | def __init__(self): 376 | """Initialize router.""" 377 | self._literal = collections.defaultdict(dict) 378 | self._wildcard = collections.defaultdict(list) 379 | self._regex = collections.defaultdict(list) 380 | 381 | def add(self, method, pattern, callback): 382 | """Add a route. 383 | 384 | Arguments: 385 | method (str): HTTP method, e.g. GET, POST, etc. 386 | pattern (str): Pattern that request paths must match. 387 | callback (str): Route handler that is invoked when a request 388 | path matches the *pattern*. 389 | """ 390 | pat_type, pat = self._normalize_pattern(pattern) 391 | if pat_type == 'literal': 392 | self._literal[method][pat] = callback 393 | elif pat_type == 'wildcard': 394 | self._wildcard[method].append(WildcardRoute(pat, callback)) 395 | else: 396 | self._regex[method].append(RegexRoute(pat, callback)) 397 | 398 | def contains_method(self, method): 399 | """Check if there is at least one handler for *method*. 400 | 401 | Arguments: 402 | method (str): HTTP method name, e.g. GET, POST, etc. 403 | 404 | Returns: 405 | ``True`` if there is at least one route defined for *method*, 406 | ``False`` otherwise 407 | """ 408 | return method in itertools.chain(self._literal, self._wildcard, 409 | self._regex) 410 | 411 | def resolve(self, method, path): 412 | """Resolve a request to a route handler. 413 | 414 | Arguments: 415 | method (str): HTTP method, e.g. GET, POST, etc. (type: str) 416 | path (str): Request path 417 | 418 | Returns: 419 | tuple or None: A tuple of three items: 420 | 421 | 1. Route handler (callable) 422 | 2. Positional arguments (list) 423 | 3. Keyword arguments (dict) 424 | 425 | ``None`` if no route matches the request. 426 | """ 427 | if method in self._literal and path in self._literal[method]: 428 | return self._literal[method][path], [], {} 429 | else: 430 | return self._resolve_non_literal_route(method, path) 431 | 432 | 433 | def _resolve_non_literal_route(self, method, path): 434 | """Resolve a request to a wildcard or regex route handler. 435 | 436 | Arguments: 437 | method (str): HTTP method name, e.g. GET, POST, etc. 438 | path (str): Request path 439 | 440 | Returns: 441 | tuple or None: A tuple of three items: 442 | 443 | 1. Route handler (callable) 444 | 2. Positional arguments (list) 445 | 3. Keyword arguments (dict) 446 | 447 | ``None`` if no route matches the request. 448 | """ 449 | for route_dict in (self._wildcard, self._regex): 450 | if method in route_dict: 451 | for route in reversed(route_dict[method]): 452 | callback_data = route.match(path) 453 | if callback_data is not None: 454 | return callback_data 455 | return None 456 | 457 | @staticmethod 458 | def _normalize_pattern(pattern): 459 | """Return a normalized form of the pattern. 460 | 461 | Normalize the pattern by removing pattern type prefix if it 462 | exists in the pattern. Then return the pattern type and the 463 | pattern as a tuple of two strings. 464 | 465 | Arguments: 466 | pattern (str): Route pattern to match request paths 467 | 468 | Returns: 469 | tuple: Ruple of pattern type (str) and pattern (str) 470 | """ 471 | if pattern.startswith('regex:'): 472 | pattern_type = 'regex' 473 | pattern = pattern[len('regex:'):] 474 | elif pattern.startswith('wildcard:'): 475 | pattern_type = 'wildcard' 476 | pattern = pattern[len('wildcard:'):] 477 | elif pattern.startswith('literal:'): 478 | pattern_type = 'literal' 479 | pattern = pattern[len('literal:'):] 480 | elif RegexRoute.like(pattern): 481 | pattern_type = 'regex' 482 | elif WildcardRoute.like(pattern): 483 | pattern_type = 'wildcard' 484 | else: 485 | pattern_type = 'literal' 486 | return pattern_type, pattern 487 | 488 | 489 | class WildcardRoute: 490 | 491 | """Route containing wildcards to match request path.""" 492 | 493 | _wildcard_re = re.compile(r'<[^<>/]*>') 494 | _tokenize_re = re.compile(r'<[^<>/]*>|[^<>/]+|/|<|>') 495 | 496 | def __init__(self, pattern, callback): 497 | """Initialize wildcard route. 498 | 499 | Arguments: 500 | pattern (str): Pattern associated with the route. 501 | callback (callable): Route handler. 502 | """ 503 | self._re = [] 504 | self._wildcards = [] 505 | for token in WildcardRoute.tokens(pattern): 506 | if token and token.startswith('<') and token.endswith('>'): 507 | w = Wildcard(token) 508 | self._wildcards.append(w) 509 | self._re.append(w.regex()) 510 | else: 511 | self._re.append(re.escape(token)) 512 | self._re = re.compile('^' + ''.join(self._re) + '$') 513 | self._callback = callback 514 | 515 | def match(self, path): 516 | """Return route handler with arguments if path matches this route. 517 | 518 | Arguments: 519 | path (str): Request path 520 | 521 | Returns: 522 | tuple or None: A tuple of three items: 523 | 524 | 1. Route handler (callable) 525 | 2. Positional arguments (list) 526 | 3. Keyword arguments (dict) 527 | 528 | ``None`` if the route does not match the path. 529 | """ 530 | match = self._re.search(path) 531 | if match is None: 532 | return None 533 | args = [] 534 | kwargs = {} 535 | for i, wildcard in enumerate(self._wildcards): 536 | if wildcard.name == '!': 537 | continue 538 | value = wildcard.value(match.groups()[i]) 539 | if not wildcard.name: 540 | args.append(value) 541 | else: 542 | kwargs[wildcard.name] = value 543 | return self._callback, args, kwargs 544 | 545 | @staticmethod 546 | def like(pattern): 547 | """Determine if a pattern looks like a wildcard pattern. 548 | 549 | Arguments: 550 | pattern (str): Any route pattern. 551 | 552 | Returns: 553 | ``True`` if the specified pattern looks like a wildcard pattern, 554 | ``False`` otherwise. 555 | """ 556 | return WildcardRoute._wildcard_re.search(pattern) is not None 557 | 558 | @staticmethod 559 | def tokens(pattern): 560 | """Return tokens in a pattern.""" 561 | return WildcardRoute._tokenize_re.findall(pattern) 562 | 563 | 564 | class Wildcard: 565 | 566 | """A single wildcard definition in a wildcard route pattern.""" 567 | 568 | _types_re = { 569 | 'str': r'([^/]+)', 570 | 'path': r'(.+)', 571 | 'int': r'(0|[1-9][0-9]*)', 572 | '+int': r'([1-9][0-9]*)', 573 | '-int': r'(0|-?[1-9][0-9]*)', 574 | } 575 | _name_re = re.compile(r'^(?:[^\d\W]\w*|!|)$') # Identifiers, '!', '' 576 | 577 | def __init__(self, spec): 578 | """Initialize wildcard definition. 579 | 580 | Arguments: 581 | spec (str): An angle-bracket delimited wildcard specification. 582 | """ 583 | # Split '' into ['foo', 'int'] 584 | tokens = spec[1:-1].split(':', 1) 585 | if len(tokens) == 1: # Split '' into ['foo', ''] 586 | tokens.append('') 587 | self.name, self._type = tokens 588 | if not self._type: 589 | self._type = 'str' 590 | if Wildcard._name_re.search(self.name) is None: 591 | raise RouteError('Invalid wildcard name {!r} in {!r}' 592 | .format(self.name, spec)) 593 | if self._type not in Wildcard._types_re.keys(): 594 | raise RouteError('Invalid wildcard type {!r} in {!r}' 595 | .format(self._type, spec)) 596 | 597 | def regex(self): 598 | """Convert the wildcard to a regular expression. 599 | 600 | Returns: 601 | str: A regular expression that matches strings that the 602 | wildcard is meant to match. 603 | """ 604 | return Wildcard._types_re[self._type] 605 | 606 | def value(self, value): 607 | """Convert specified value to a value of wildcard type. 608 | 609 | This method does not check if the value matches the wildcard 610 | type. The caller of this method must ensure that the value 611 | passed to this method was obtained from a match by regular 612 | expression returned by the regex method of this class. Ensuring 613 | this guarantees that the value passed to the method matches the 614 | wildcard type. 615 | 616 | Arguments: 617 | value (str): Value to convert. 618 | 619 | Returns: 620 | str or int: Converted value. 621 | """ 622 | return value if self._type in ['str', 'path'] else int(value) 623 | 624 | 625 | class RegexRoute: 626 | 627 | """A regular expression pattern.""" 628 | 629 | _group_re = re.compile(r'\(.*\)') 630 | 631 | def __init__(self, pattern, callback): 632 | """Initialize regular expression route. 633 | 634 | Arguments: 635 | pattern (str): Pattern associated with the route. 636 | callback (callable): Route handler. 637 | """ 638 | self._re = re.compile(pattern) 639 | self._callback = callback 640 | 641 | def match(self, path): 642 | """Return route handler with arguments if path matches this route. 643 | 644 | Arguments: 645 | path (str): Request path 646 | 647 | Returns: 648 | tuple or None: A tuple of three items: 649 | 650 | 1. Route handler (callable) 651 | 2. Positional arguments (list) 652 | 3. Keyword arguments (dict) 653 | 654 | ``None`` if the route does not match the path. 655 | """ 656 | match = self._re.search(path) 657 | if match is None: 658 | return None 659 | kwargs_indexes = match.re.groupindex.values() 660 | args_indexes = [i for i in range(1, match.re.groups + 1) 661 | if i not in kwargs_indexes] 662 | args = [match.group(i) for i in args_indexes] 663 | kwargs = {} 664 | for name, index in match.re.groupindex.items(): 665 | kwargs[name] = match.group(index) 666 | return self._callback, args, kwargs 667 | 668 | @staticmethod 669 | def like(pattern): 670 | """Determine if a pattern looks like a regular expression. 671 | 672 | Arguments: 673 | pattern (str): Any route pattern. 674 | 675 | Returns: 676 | ``True`` if the specified pattern looks like a regex, 677 | ``False`` otherwise. 678 | """ 679 | return RegexRoute._group_re.search(pattern) is not None 680 | 681 | 682 | class Request: 683 | 684 | """Current request. 685 | 686 | Attributes: 687 | environ (dict): Dictionary of request environment variables. 688 | method (str): Request method. 689 | path (str): Request path. 690 | query (MultiDict): Key-value pairs from query string. 691 | form (MultiDict): Key-value pairs from form data in POST request. 692 | cookies (MultiDict): Key-value pairs from cookie string. 693 | """ 694 | 695 | def __init__(self, environ): 696 | """Initialize the current request object. 697 | 698 | Arguments: 699 | environ (dict): Dictionary of environment variables. 700 | """ 701 | self.environ = environ 702 | self.method = environ.get('REQUEST_METHOD', 'GET') 703 | self.path = environ.get('PATH_INFO', '/') 704 | if not self.path: 705 | self.path = '/' 706 | self.query = MultiDict() 707 | self.form = MultiDict() 708 | self.cookies = MultiDict() 709 | 710 | if 'QUERY_STRING' in environ: 711 | for k, v in urllib.parse.parse_qsl(environ['QUERY_STRING']): 712 | self.query[k] = v 713 | 714 | if 'wsgi.input' in environ: 715 | fs = cgi.FieldStorage(fp=environ['wsgi.input'], 716 | environ=environ) 717 | for k in fs: 718 | for v in fs.getlist(k): 719 | self.form[k] = v 720 | 721 | if 'HTTP_COOKIE' in environ: 722 | cookies = http.cookies.SimpleCookie(environ['HTTP_COOKIE']) 723 | for c in cookies.values(): 724 | self.cookies[c.key] = c.value 725 | 726 | class Response: 727 | 728 | """Current response. 729 | 730 | Attributes: 731 | start (callable): Callable that starts response. 732 | status (int): HTTP response status code, defaults to 200. 733 | media_type (str): Media type of HTTP response, defaults to 734 | 'text/html'. This together with :attr:`charset` determines the 735 | Content-Type response header. 736 | charset (str): Character set of HTTP response, defaults to 737 | 'UTF-8'. This together with :attr:`media_type` determines the 738 | Content-Type response header. 739 | body (str or bytes): HTTP response body. 740 | """ 741 | 742 | # Convert HTTP response status codes, phrases and detail in 743 | # http.server module into a dictionary of objects 744 | _Status = collections.namedtuple('_Status', ('phrase', 'detail')) 745 | 746 | _responses = {} 747 | 748 | for k, v in http.server.BaseHTTPRequestHandler.responses.items(): 749 | _responses[k] = _Status(*v) 750 | del k, v 751 | 752 | def __init__(self, start_response_callable): 753 | """Initialize the current response object. 754 | 755 | Arguments: 756 | start_response_callable (callable): Callable that starts response. 757 | """ 758 | self.start = start_response_callable 759 | self.status = 200 760 | self.media_type = 'text/html' 761 | self.charset = 'UTF-8' 762 | self._headers = [] 763 | self.body = None 764 | self.state = {} 765 | 766 | def response(self): 767 | """Return the HTTP response body. 768 | 769 | Returns: 770 | bytes: HTTP response body as a sequence of bytes 771 | """ 772 | if isinstance(self.body, bytes): 773 | out = self.body 774 | elif isinstance(self.body, str): 775 | out = self.body.encode(self.charset) 776 | else: 777 | out = b'' 778 | self.add_header('Content-Type', self.content_type) 779 | self.add_header('Content-Length', str(len(out))) 780 | 781 | self.start(self.status_line, self._headers) 782 | return [out] 783 | 784 | def add_header(self, name, value): 785 | """Add an HTTP header to response object. 786 | 787 | Arguments: 788 | name (str): HTTP header field name 789 | value (str): HTTP header field value 790 | """ 791 | if value is not None: 792 | self._headers.append((name, value)) 793 | 794 | def set_cookie(self, name, value, attrs={}): 795 | """Add a Set-Cookie header to response object. 796 | 797 | For a description about cookie attribute values, see 798 | https://docs.python.org/3/library/http.cookies.html#http.cookies.Morsel. 799 | 800 | Arguments: 801 | name (str): Name of the cookie 802 | value (str): Value of the cookie 803 | attrs (dict): Dicitionary with cookie attribute keys and 804 | values. 805 | """ 806 | cookie = http.cookies.SimpleCookie() 807 | cookie[name] = value 808 | for key, value in attrs.items(): 809 | cookie[name][key] = value 810 | self.add_header('Set-Cookie', cookie[name].OutputString()) 811 | 812 | @property 813 | def status_line(self): 814 | """Return the HTTP response status line. 815 | 816 | The status line is determined from :attr:`status` code. For 817 | example, if the status code is 200, then '200 OK' is returned. 818 | 819 | Returns: 820 | str: Status line 821 | """ 822 | return (str(self.status) + ' ' + 823 | Response._responses[self.status].phrase) 824 | 825 | @property 826 | def status_detail(self): 827 | """Return a description of the current HTTP response status. 828 | 829 | Returns: 830 | str: Response status description 831 | """ 832 | return Response._responses[self.status].detail 833 | 834 | @property 835 | def content_type(self): 836 | """Return the value of Content-Type header field. 837 | 838 | The value for the Content-Type header field is determined from 839 | the :attr:`media_type` and :attr:`charset` data attributes. 840 | 841 | Returns: 842 | str: Value of Content-Type header field 843 | """ 844 | if (self.media_type is not None and 845 | self.media_type.startswith('text/') and 846 | self.charset is not None): 847 | return self.media_type + '; charset=' + self.charset 848 | else: 849 | return self.media_type 850 | 851 | 852 | class MultiDict(collections.UserDict): 853 | 854 | """Dictionary with multiple values for a key. 855 | 856 | Setting an existing key to a new value merely adds the value to the 857 | list of values for the key. Getting the value of an existing key 858 | returns the newest value set for the key. 859 | """ 860 | 861 | def __setitem__(self, key, value): 862 | """Adds value to the list of values for the specified key. 863 | 864 | Arguments: 865 | key (object): Key 866 | value (object): Value 867 | """ 868 | if key not in self.data: 869 | self.data[key] = [value] 870 | else: 871 | self.data[key].append(value) 872 | 873 | def __getitem__(self, key): 874 | """Return the newest value for the specified key. 875 | 876 | Arguments: 877 | key (object): Key 878 | 879 | Returns: 880 | object: Newest value for the specified key 881 | """ 882 | return self.data[key][-1] 883 | 884 | def getall(self, key, default=[]): 885 | """Return the list of all values for the specified key. 886 | 887 | Arguments: 888 | key (object): Key 889 | default (list): Default value to return if the key does not 890 | exist, defaults to ``[]``, i.e. an empty list. 891 | 892 | Returns: 893 | list: List of all values for the specified key if the key 894 | exists, ``default`` otherwise. 895 | """ 896 | return self.data[key] if key in self.data else default 897 | 898 | 899 | class Error(Exception): 900 | """Base class for exceptions.""" 901 | 902 | 903 | class RouteError(Error): 904 | """Route related exception.""" 905 | 906 | 907 | class LogicError(Error): 908 | """Logical error that can be avoided by careful coding.""" 909 | -------------------------------------------------------------------------------- /docs/tutorial.rst: -------------------------------------------------------------------------------- 1 | Tutorial 2 | ======== 3 | Ice is a Python module with a WSGI microframework meant for developing 4 | small web applications in Python. It is a single file Python module 5 | inspired by `Bottle`_. 6 | 7 | .. _Bottle: https://bottlepy.org/ 8 | 9 | You can install this module using pip3 using the following command. :: 10 | 11 | pip3 install ice 12 | 13 | This module should be used with Python 3.3 or a later version of Python 14 | interpreter. 15 | 16 | The source code of this module is available at 17 | https://github.com/susam/ice. 18 | 19 | 20 | Getting Started 21 | --------------- 22 | The simplest way to get started with an ice application is to write a 23 | minimal application that serves a default web page. 24 | 25 | .. code:: python 26 | 27 | import ice 28 | app = ice.cube() 29 | if __name__ == '__main__': 30 | app.run() 31 | 32 | Save the above code in a file and execute it with your Python 33 | interpreter. Then open your browser, visit http://localhost:8080/, and 34 | you should be able to see a web page that says, 'It works!'. 35 | 36 | .. reST convention 37 | --------------- 38 | - URLs are written in plain text. 39 | - Request paths are written in plain text. 40 | - Request path patterns are enclosed in `` and ``. 41 | - Code samples are written in literal blocks constructed with the 42 | code directive. 43 | - Strings, even when they are part of a request path, are enclosed 44 | in ``' and '``. 45 | 46 | 47 | Routes 48 | ------ 49 | Once you are able to run a minimal ice application as mentioned in the 50 | previous section, you'll note that while visiting http://localhost:8080/ 51 | displays the default 'It works!' page, visiting any other URL, such as 52 | http://localhost:8080/foo displays the '404 Not Found' page. This 53 | happens because the application object returned by the ``ice.cube`` 54 | function has a default route defined to invoke a function that returns 55 | the default page when the client requests / using the HTTP GET method. 56 | There is no such route defined by default for /foo or any request path 57 | other than /. 58 | 59 | In this document, a request path is defined as the part of the URL after 60 | the domain name and before the query string. For example, in a request 61 | for http://localhost:8080/foo/bar?x=10, the request path is /foo/bar. 62 | 63 | A route is used to map an HTTP request to a Python callable. This 64 | callable is also known as the route handler. A route consists of three 65 | objects: 66 | 67 | 1. HTTP request method, e.g. ``'GET'``, ``'POST'``. 68 | 2. Request path pattern, e.g. ``'/foo'``, ``'/post/'``, ``'/(.*)'``. 69 | 3. Route handler, a Python callable object, e.g. Python function 70 | 71 | A route is said to match a request path when the request pattern of the 72 | route matches the request path. When a client makes a request to an ice 73 | application, if a route matches the request path, then the route's 74 | handler is invoked and the value returned by the route's handler is used 75 | to send a response to the client. 76 | 77 | The request path pattern of a route can be specified in one of three 78 | ways: 79 | 80 | 1. Literal path, e.g. ``'/'``, ``'/contact/'``, ``'/about/'``. 81 | 2. Pattern with wildcards, e.g. ``'/blog/'``, ``'/order/<:int>'``. 82 | 3. Regular expression, e.g. ``'/blog/\w+'``, ``'/order/\d+'``. 83 | 84 | These three types of routes are described in the subsections below. 85 | 86 | Literal Routes 87 | ~~~~~~~~~~~~~~ 88 | The following application overrides the default 'It works!' page for / 89 | with a custom page. Additionally, it sets up a route for /foo. 90 | 91 | .. code:: python 92 | 93 | import ice 94 | app = ice.cube() 95 | 96 | @app.get('/') 97 | def home(): 98 | return ('' 99 | 'Home' 100 | '

Home

') 101 | 102 | @app.get('/foo') 103 | def foo(): 104 | return ('' 105 | 'Foo' 106 | '

Foo

') 107 | 108 | if __name__ == '__main__': 109 | app.run() 110 | 111 | The routes defined in the above example are called literal routes 112 | because they match the request path exactly as specified in the argument 113 | to ``app.get`` decorator. Routes defined with the ``app.get`` decorator 114 | matches HTTP GET requests. Now, visiting http://localhost:8080/ displays 115 | a page with the following text. 116 | 117 | | Home 118 | 119 | Visiting http://localhost:8080/foo displays a page with the following 120 | text. 121 | 122 | | Foo 123 | 124 | However, visiting http://localhost:8080/foo/ or 125 | http://localhost:8080/foo/bar displays the '404 Not Found' page because 126 | the literal pattern ``'/foo'`` does not match the request path 127 | ``'/foo/'`` or ``'/foo/bar'``. 128 | 129 | Wildcard Routes 130 | ~~~~~~~~~~~~~~~ 131 | Anonymous Wildcards 132 | ''''''''''''''''''' 133 | The following code example is the simplest application demonstrating a 134 | wildcard route that matches request path of the form ``/`` followed by 135 | any string devoid of ``/``, ``<`` and ``>``. The characters ``<>`` is an 136 | anonymous wildcard because there is no name associated with this 137 | wildcard. The part of the request path matched by an anonymous wildcard 138 | is passed as a positional argument to the route's handler. 139 | 140 | .. code:: python 141 | 142 | import ice 143 | app = ice.cube() 144 | 145 | @app.get('/<>') 146 | def foo(a): 147 | return ('' 148 | '' + a + '' 149 | '

' + a + '

') 150 | 151 | if __name__ == '__main__': 152 | app.run() 153 | 154 | Save the above code in a file and execute it with Python interpreter. 155 | Then open your browser, visit http://localhost:8080/foo, and you should 156 | be able to see a page with the followning text. 157 | 158 | | foo 159 | 160 | If you visit http://localhost:8080/bar instead, you should see a page 161 | with the following text. 162 | 163 | | bar 164 | 165 | However, visiting http://localhost:8080/foo/ or 166 | http://localhost:8080/foo/bar displays the '404 Not Found' page because 167 | the wildcard based pattern ``/<>`` does not match ``/foo/`` or 168 | ``/foo/bar``. 169 | 170 | Named Wildcards 171 | ''''''''''''''' 172 | A wildcard with a valid Python identifier as its name is called a named 173 | wildcard. The part of the request path matched by a named wildcard is 174 | passed as a keyword argument, with the same name as that of the 175 | wildcard, to the route's handler. 176 | 177 | .. code:: python 178 | 179 | import ice 180 | app = ice.cube() 181 | 182 | @app.get('/
') 183 | def foo(a): 184 | return ('' 185 | '' + a + '' 186 | '

' + a + '

') 187 | 188 | if __name__ == '__main__': 189 | app.run() 190 | 191 | The ``a``, in ``
``, is the name of the wildcard. The ice application 192 | in this example with a named wildcard behaves similar to the earlier one 193 | with an anonymous wildcard. The following example code clearly 194 | demonstrates how matches due to anonymous wildcards are passed 195 | differently from the matches due to named wildcards. 196 | 197 | .. code:: python 198 | 199 | import ice 200 | app = ice.cube() 201 | 202 | @app.get('/foo/<>-<>/-/<>-') 203 | def foo(*args, **kwargs): 204 | return (' ' 205 | 'Example ' 206 | '

args: {}
kwargs: {}

' 207 | '').format(args, kwargs) 208 | 209 | if __name__ == '__main__': 210 | app.run() 211 | 212 | After running this application, visiting 213 | http://localhost:8080/foo/hello-world/ice-cube/wsgi-rocks displays a 214 | page with the following text. 215 | 216 | | args: ('hello', 'world', 'wsgi') 217 | | kwargs: {'a': 'ice', 'b': 'cube', 'c': 'rocks'} 218 | 219 | Here is a more typical example that demonstrates how anonymous wildcard 220 | and named wildcard may be used together. 221 | 222 | .. code:: python 223 | 224 | import ice 225 | app = ice.cube() 226 | 227 | @app.get('///<>') 228 | def page(page_id, user, category): 229 | return ('' 230 | 'Example ' 231 | '

page_id: {}
user: {}
category: {}

' 232 | '').format(page_id, user, category) 233 | 234 | if __name__ == '__main__': 235 | app.run() 236 | 237 | After running this application, visiting 238 | http://localhost:8080/snowman/articles/python displays a page with the 239 | following text. 240 | 241 | | page_id: python 242 | | user: snowman 243 | | category: articles 244 | 245 | Note: Since parts of the request path matched by anonymous wildcards are 246 | passed as positional arguments and parts of the request path matched by 247 | named wildcards are passed as keyword arguments to the route's handler, 248 | it is required by the Python language that all positional arguments 249 | must come before all keyword arguments in the function definition. 250 | However, the wildcards may appear in any order in the route's pattern. 251 | 252 | Throwaway Wildcard 253 | '''''''''''''''''' 254 | A wildcard with exclamation mark, ``!``, as its name is a throwaway 255 | wildcard. The part of the request path matched by a throwaway wildcard 256 | is not passed to the route's handler. *They are thrown away!* 257 | 258 | .. code:: python 259 | 260 | import ice 261 | app = ice.cube() 262 | 263 | @app.get('/') 264 | def foo(*args, **kwargs): 265 | return ('' 266 | 'Example' 267 | '

args: {}
kwargs: {}

' 268 | '').format(args, kwargs) 269 | 270 | if __name__ == '__main__': 271 | app.run() 272 | 273 | After running this application, visiting http://localhost:8080/foo 274 | displays a page with the following text. 275 | 276 | | args: () 277 | | kwargs: {} 278 | 279 | The output confirms that no argument is passed to the ``foo`` function. 280 | Here is a more typical example that demonstrates how a throwaway 281 | wildcard may be used with other wildcards. 282 | 283 | .. code:: python 284 | 285 | import ice 286 | app = ice.cube() 287 | 288 | @app.get('///<>') 289 | def page(page_id): 290 | return ('' 291 | 'Example' 292 | '

page_id: ' + page_id + '

' 293 | '') 294 | 295 | if __name__ == '__main__': 296 | app.run() 297 | 298 | After running this application, visiting 299 | http://localhost:8080/snowman/articles/python displays a page with 300 | the following text. 301 | 302 | | page_id: python 303 | 304 | There are three wildcards in the route's request path pattern but there 305 | is only one argument in the route's handler because two out of the 306 | three wildcards are throwaway wildcards. 307 | 308 | Wildcard Specification 309 | '''''''''''''''''''''' 310 | The complete syntax of a wildcard specification is: <*name*:*type*>. 311 | 312 | The following rules describe how a wildcard is interpreted. 313 | 314 | 1. The delimiters ``<`` (less-than sign) and ``>`` (greater-than sign), 315 | are mandatory. 316 | 2. However, *name*, ``:`` (colon) and *type* are optional. 317 | 3. Either a valid Python identifier or the exclamation mark, ``!``, 318 | must be specified as *name*. 319 | 4. If *name* is missing, the part of the request path matched by the 320 | wildcard is passed as a positional argument to the route's handler. 321 | 5. If *name* is present and it is a valid Python identifier, the part 322 | of the request path matched by the wildcard is passed as a keyword 323 | argument to the route's handler. 324 | 6. If *name* is present and it is ``!``, the part of the request path 325 | matched by the wildcard is not passed to the route's handler. 326 | 7. If *name* is present but it is neither ``!`` nor a valid Python 327 | identifier, ice.RouteError is raised. 328 | 8. If *type* is present, it must be preceded by ``:`` (colon). 329 | 9. If *type* is present but it is not ``str``, ``path``, ``int``, 330 | ``+int`` and ``-int``, ice.RouteError is raised. 331 | 10. If *type* is missing, it is assumed to be ``str``. 332 | 11. If *type* is ``str``, it matches a string of one or more characters 333 | such that none of the characters is ``/``. The path of the request 334 | path matched by the wildcard is passed as an ``str`` object to the 335 | route's handler. 336 | 12. If *type* is ``path``, it matches a string of one or more characters 337 | that may contain ``/``. The path of the request path matched by the 338 | wildcard is passed as an ``str`` object to the route's handler. 339 | 13. If *type* is ``int``, ``+int`` or ``-int``, the path of the request 340 | path matched by the wildcard is passed as an ``int`` object to the 341 | route's handler. 342 | 14. If *type* is ``+int``, the wildcard matches a positive integer 343 | beginning with a non-zero digit. 344 | 15. If *type* is ``int``, the wildcard matches ``0`` as well as 345 | everything that a wildcard of type ``+int`` matches. 346 | 16. If *type* is ``-int``, the wildcard matches a negative integer that 347 | begins with the ``-`` sign followed by a non-zero digit as well as 348 | everything that a wildcard of type ``int`` matches. 349 | 350 | Here is an example that demonstrates a typical route with ``path`` and 351 | ``int`` wildcards. 352 | 353 | .. code:: python 354 | 355 | import ice 356 | app = ice.cube() 357 | 358 | @app.get('/notes/<:path>/<:int>') 359 | def note(note_path, note_id): 360 | return ('' 361 | 'Example' 362 | '

note_path: {}
note_id: {}

' 363 | '').format(note_path, note_id) 364 | 365 | if __name__ == '__main__': 366 | app.run() 367 | 368 | After running this application, visiting 369 | http://localhost:8080/notes/tech/python/12 displays a page with the 370 | following text. 371 | 372 | | note_path: tech/python 373 | | note_id: 12 374 | 375 | Visiting http://localhost:8080/notes/tech/python/0 displays a page with 376 | the following text. 377 | 378 | | note_path: tech/python 379 | | note_id: 0 380 | 381 | However, visiting http://localhost:8080/notes/tech/python/+12 382 | http://localhost:8080/notes/tech/python/+0 or 383 | http://localhost:8080/notes/tech/python/012, displays the 384 | '404 Not Found' page because ``<:int>`` does not match an integer with a 385 | leading ``+`` sign or with a leading ``0``. It matches ``0`` and a 386 | positive integer beginning with a non-zero digit only. 387 | 388 | Regular Expression Routes 389 | ~~~~~~~~~~~~~~~~~~~~~~~~~ 390 | The following code demonstrates a simple regular expression based route. 391 | The part of the request path matched by a non-symbolic capturing group 392 | is passed as a positional argument to the route's handler. 393 | 394 | .. code:: python 395 | 396 | import ice 397 | app = ice.cube() 398 | 399 | @app.get('/(.*)') 400 | def foo(a): 401 | return ('' 402 | '' + a + '' 403 | '

' + a + '

') 404 | 405 | if __name__ == '__main__': 406 | app.run() 407 | 408 | After running this application, visiting http://localhost:8080/foo 409 | displays a page with the following text. 410 | 411 | | foo 412 | 413 | Visiting http://localhost:8080/foo/bar/ displays a page with the 414 | following text. 415 | 416 | | foo/bar/ 417 | 418 | The part of the request path matched by a symbolic capturing group in 419 | the regular expression is passed as a keyword argument with the same 420 | name as that of the symbolic group. 421 | 422 | .. code:: python 423 | 424 | import ice 425 | app = ice.cube() 426 | 427 | @app.get('/(?P[^/]*)/(?P[^/]*)/([^/]*)') 428 | def page(page_id, user, category): 429 | return ('' 430 | 'Example' 431 | '

page_id: {}
user: {}
category: {}

' 432 | '').format(page_id, user, category) 433 | 434 | if __name__ == '__main__': 435 | app.run() 436 | 437 | After running this application, visiting 438 | http://localhost:8080/snowman/articles/python displays a page with the 439 | following text. 440 | 441 | | page_id: python 442 | | user: snowman 443 | | category: articles 444 | 445 | Note: Since parts of the request path matched by non-symbolic capturing 446 | groups are passed as positional arguments and parts of the request path 447 | matched by symbolic capturing groups are passed as keyword arguments to 448 | the route's handler, it is required by the Python language that all 449 | positional arguments must come before all keyword arguments in the 450 | function definition. However, the capturing groups may appear in any 451 | order in the route's pattern. 452 | 453 | Interpretation of Request Path Pattern 454 | ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 455 | The request path pattern is interpreted according to the following 456 | rules. The rules are processed in the order specified and as soon as one 457 | of the rules succeeds in determining how the request path pattern should 458 | be interpreted, further rules are not processed. 459 | 460 | 1. If a route's request path pattern begins with ``regex:`` prefix, 461 | then it is interpreted as a regular expression route. 462 | 2. If a route's request path pattern begins with ``wildcard:`` prefix, 463 | then it is interpreted as a wildcard route. 464 | 3. If a route's request path pattern begins with ``literal:`` prefix, 465 | then it is interpreted as a literal route. 466 | 4. If a route's request path pattern contains what looks like a 467 | regular expression capturing group, i.e. it contains ``(`` before 468 | ``)`` somewhere in the pattern, then it is automatically interpreted 469 | as a regular expression route. 470 | 5. If a route's request path pattern contains what looks like a 471 | wildcard, i.e. it contains ``<`` before ``>`` somewhere in the 472 | pattern with no ``/``, ``<`` and ``>`` in between them, then it is 473 | automatically interpreted as a wildcard route. 474 | 6. If none of the above rules succeed in determining how to interpret 475 | the request path, then it is interpreted as a literal route. 476 | 477 | The next three sections clarify the above rules with some contrived 478 | examples. 479 | 480 | Explicit Literal Routes 481 | ''''''''''''''''''''''' 482 | To define a literal route with the request path pattern as ``/``, 483 | ``literal:`` prefix must be used. Without it, the ```` in the 484 | pattern is interpreted as a wildcard and the route is defined as a 485 | wildcard route. With the ``literal:`` prefix, the pattern is explicitly 486 | defined as a literal pattern. 487 | 488 | .. code:: python 489 | 490 | import ice 491 | app = ice.cube() 492 | 493 | @app.get('literal:/') 494 | def foo(): 495 | return ('' 496 | 'Foo' 497 | '

Foo

') 498 | 499 | if __name__ == '__main__': 500 | app.run() 501 | 502 | After running this application, visiting 503 | http://localhost:8080/%3Cfoo%3E displays a page containing the 504 | following text. 505 | 506 | | Foo 507 | 508 | A request path pattern that seems to contain a wildcard or a capturing 509 | group but needs to be treated as a literal pattern must be prefixed with 510 | the string ``literal:``. 511 | 512 | Explicit Wildcard Routes 513 | '''''''''''''''''''''''' 514 | To define a wildcard route with the request path pattern as 515 | ``/(foo)/<>``, the ``wildcard:`` prefix must be used. Without it, the 516 | pattern is interpreted as a regular expression pattern because the 517 | ``(foo)`` in the pattern looks like a regular expression capturing 518 | group. 519 | 520 | .. code:: python 521 | 522 | import ice 523 | app = ice.cube() 524 | 525 | @app.get('wildcard:/(foo)/<>') 526 | def foo(a): 527 | return ('' 528 | 'Foo' 529 | '

a: ' + a + '

') 530 | 531 | if __name__ == '__main__': 532 | app.run() 533 | 534 | After running this application, visiting http://localhost:8080/(foo)/bar 535 | displays a page with the following text. 536 | 537 | | a: bar 538 | 539 | A request path pattern that seems to contain a regular expression 540 | capturing group but needs to be treated as a wildcard pattern must be 541 | prefixed with the string ``wildcard:``. 542 | 543 | Explicit Regular Expression Routes 544 | '''''''''''''''''''''''''''''''''' 545 | To define a regular expression route with the request path pattern as 546 | ``^/foo\d*$``, the ``regex:`` prefix must be used. Without it, the 547 | pattern is interpreted as a literal pattern because there is no 548 | capturing group in the pattern. 549 | 550 | .. code:: python 551 | 552 | import ice 553 | app = ice.cube() 554 | 555 | @app.get('regex:/foo\d*$') 556 | def foo(): 557 | return ('' 558 | 'Foo' 559 | '

Foo

') 560 | 561 | if __name__ == '__main__': 562 | app.run() 563 | 564 | After running this application, visiting http://localhost:8080/foo or 565 | http://localhost:8080/foo123 displays a page containing the following 566 | text. 567 | 568 | | Foo 569 | 570 | A request path pattern that does not contain a regular expression 571 | capturing group but needs to be treated as a regular expression pattern 572 | must be prefixed with the string ``regex:``. 573 | 574 | 575 | Query Strings 576 | ------------- 577 | The following example shows an application that can process a query 578 | string in a GET request. 579 | 580 | .. code:: python 581 | 582 | import ice 583 | app = ice.cube() 584 | 585 | @app.get('/') 586 | def home(): 587 | return ('' 588 | 'Foo' 589 | '

name: {}

' 590 | '').format(app.request.query['name']) 591 | 592 | if __name__ == '__main__': 593 | app.run() 594 | 595 | After running this application, visiting 596 | http://localhost:8080/?name=Humpty+Dumpty displays a page with the 597 | following text. 598 | 599 | | name: Humpty Dumpty 600 | 601 | Note that the ``+`` sign in the query string has been properly URL 602 | decoded into a space. 603 | 604 | The ``app.request.query`` object in the code is an ``ice.MultiDict`` 605 | object that can store multiple values for every key. However, when used 606 | like a dictionary, it returns the most recently added value for a key. 607 | Therefore, visiting http://localhost:8080/?name=Humpty&name=Santa 608 | displays a page with the following text. 609 | 610 | | name: Santa 611 | 612 | Note that in this URL, there are two values passed for the ``name`` 613 | field in the query string, but accessing ``app.request.query['name']`` 614 | provides us only the value that is most recently added. To get all the 615 | values for a key in ``app.request.query``, we can use the 616 | ``ice.MultiDict.getall`` method as shown below. 617 | 618 | .. code:: python 619 | 620 | import ice 621 | app = ice.cube() 622 | 623 | @app.get('/') 624 | def home(): 625 | return ('' 626 | 'Foo' 627 | '

name: {}

' 628 | '').format(app.request.query.getall('name')) 629 | 630 | if __name__ == '__main__': 631 | app.run() 632 | 633 | Now, visiting http://localhost:8080/?name=Humpty&name=Santa 634 | displays a page with the following text. 635 | 636 | | name: ['Humpty', 'Santa'] 637 | 638 | Note that the ``ice.MultiDict.getall`` method returns all the values 639 | belonging to the key as a ``list`` object. 640 | 641 | 642 | Forms 643 | ----- 644 | The following example shows an application that can process forms 645 | submitted by a POST request. 646 | 647 | .. code:: python 648 | 649 | import ice 650 | app = ice.cube() 651 | 652 | @app.get('/') 653 | def show_form(): 654 | return ('' 655 | 'Foo' 656 | '
' 657 | 'First name:
' 658 | 'Last name:
' 659 | '' 660 | '
') 661 | 662 | @app.post('/result') 663 | def show_post(): 664 | return ('' 665 | 'Foo' 666 | '

First name: {}
Last name: {}

' 667 | '').format(app.request.form['firstName'], 668 | app.request.form['lastName']) 669 | 670 | if __name__ == '__main__': 671 | app.run() 672 | 673 | After running this application, visiting http://localhost:8080/, filling 674 | up the form and submitting it displays the form data. 675 | 676 | The ``app.request.form`` object in this code, like the 677 | ``app.request.query`` object in the previous section, is a MultiDict 678 | object. 679 | 680 | .. code:: python 681 | 682 | import ice 683 | app = ice.cube() 684 | 685 | @app.get('/') 686 | def show_form(): 687 | return ('' 688 | 'Foo' 689 | '
' 690 | 'name1:
' 691 | 'name2:
' 692 | '' 693 | '
') 694 | 695 | @app.post('/result') 696 | def show_post(): 697 | return ('' 698 | 'Foo' 699 | '

name (single): {}
name (multi): {}

' 700 | '').format(app.request.form['name'], 701 | app.request.form.getall('name')) 702 | 703 | if __name__ == '__main__': 704 | app.run() 705 | 706 | After running this application, visiting http://localhost:8080/, filling 707 | up the form and submitting it displays the form data. While 708 | ``app.request.form['name']`` returns the string entered in the second 709 | input field, ``app.request.form.getall('name')`` returns strings entered 710 | in both input fields as a list object. 711 | 712 | 713 | Cookies 714 | ------- 715 | The following example shows an application that can read and set 716 | cookies. 717 | 718 | .. code:: python 719 | 720 | import ice 721 | app = ice.cube() 722 | 723 | @app.get('/') 724 | def show_count(): 725 | count = int(app.request.cookies.get('count', 0)) + 1 726 | app.response.set_cookie('count', str(count)) 727 | return ('' 728 | 'Foo' 729 | '

Count: {}

'.format(count)) 730 | 731 | app.run() 732 | 733 | The ``app.request.cookies`` object in this code, like the 734 | ``app.request.query`` object in a previous section, is a MultiDict 735 | object. Every cookie name and value sent by the client to the 736 | application found in the HTTP Cookie header is available in this object 737 | as key value pairs. 738 | 739 | The ``app.response.set_cookie`` method is used to set cookies to be sent 740 | from the application to the client. 741 | 742 | 743 | Error Pages 744 | ----------- 745 | The application object returned by the ``ice.cube`` function contains a 746 | generic fallback error handler that returns a simple error page with the 747 | HTTP status line, a short description of the status and the version of 748 | the ice module. 749 | 750 | This error handler may be overridden using the ``error`` decorator. This 751 | decorator accepts one optional integer argument that may be used to 752 | explicitly specify the HTTP status code of responses for which the 753 | handler should be invoked to generate an error page. If no argument is 754 | provided, the error handler is defined as a fallback error handler. A 755 | fallback error handler is invoked to generate an error page for any HTTP 756 | response representing an error when there is no error handler defined 757 | explicitly for the response status code of the HTTP response. 758 | 759 | Here is an example. 760 | 761 | .. code:: python 762 | 763 | import ice 764 | app = ice.cube() 765 | 766 | @app.error(404) 767 | def error(): 768 | return ('' 769 | 'Page not found' 770 | '

Page not found

') 771 | 772 | if __name__ == '__main__': 773 | app.run() 774 | 775 | After running this application, visiting http://localhost:8080/foo 776 | displays a page with the following text. 777 | 778 | | Page not found 779 | 780 | 781 | .. _status-codes: 782 | 783 | Status Codes 784 | ------------ 785 | In all the examples above, the response message body is returned as a 786 | string from a route's handler. It is also possible to return the 787 | response status code as an integer. In other words, a route's handler 788 | must either return a string or an integer. When a string is returned, it 789 | is sent as response message body to the client. When an integer is 790 | returned and it is a valid HTTP status code, an HTTP response with this 791 | status code is sent to the client. If the value returned by a route's 792 | handler is neither a string nor an integer representing a valid HTTP 793 | status code, then an error is raised. 794 | 795 | Therefore there are two ways to return an HTTP response from a route's 796 | handler. 797 | 798 | 1. Return message body and optionally set status code. This is the 799 | preferred way of returning content for normal HTTP responses (200 800 | OK). If the status code is not set explicitly in a route's handler, 801 | then it has a default value of 200. 802 | 2. Return status code and optionally set message body. This is the 803 | preferred way of returning content for HTTP errors. If the message 804 | body is not set explicitly in a route's handler, then the error 805 | handler for the returned status code is invoked to return a message 806 | body. 807 | 808 | Here is an example where status code is set to 403 and a custom 809 | error page is returned. 810 | 811 | .. code:: python 812 | 813 | import ice 814 | 815 | app = ice.cube() 816 | 817 | @app.get('/foo') 818 | def foo(): 819 | app.response.status = 403 820 | return ('' 821 | 'Access is forbidden' 822 | '

Access is forbidden

') 823 | 824 | if __name__ == '__main__': 825 | app.run() 826 | 827 | After running this application, visiting http://localhost:8080/foo 828 | displays a page with the following text. 829 | 830 | | Access is forbidden 831 | 832 | Here is another way of writing the above application. In this case, the 833 | message body is set and the status code is returned. 834 | 835 | .. code:: python 836 | 837 | import ice 838 | 839 | app = ice.cube() 840 | 841 | @app.get('/foo') 842 | def foo(): 843 | app.response.body = ('' 844 | 'Access is forbidden' 845 | '

Access is forbidden

') 846 | return 403 847 | 848 | if __name__ == '__main__': 849 | app.run() 850 | 851 | Although the above way of setting message body works, using an error 852 | handler is the preferred way of defining the message body for an HTTP 853 | error. Here is an example that demonstrates this. 854 | 855 | .. code:: python 856 | 857 | import ice 858 | 859 | app = ice.cube() 860 | 861 | @app.get('/foo') 862 | def foo(): 863 | return 403 864 | 865 | @app.error(403) 866 | def error403(): 867 | return ('' 868 | 'Access is forbidden' 869 | '

Access is forbidden

') 870 | 871 | if __name__ == '__main__': 872 | app.run() 873 | 874 | For simple web applications, just returning the status code is 875 | sufficient. When neither a message body is defined nor an error handler 876 | is defined, a generic fallback error handler set in the application 877 | object returned by the ``ice.cube`` is used to return a simple error 878 | page with the HTTP status line, a short description of the status and 879 | the version of the ice module. 880 | 881 | .. code:: python 882 | 883 | import ice 884 | 885 | app = ice.cube() 886 | 887 | @app.get('/foo') 888 | def foo(): 889 | return 403 890 | 891 | if __name__ == '__main__': 892 | app.run() 893 | 894 | After running this application, visiting http://localhost:8080/foo 895 | displays a page with the following text. 896 | 897 | | 403 Forbidden 898 | | Request forbidden -- authorization will not help 899 | 900 | 901 | Redirects 902 | --------- 903 | Here is an example that demonstrates how to redirect a client to a 904 | different URL. 905 | 906 | .. code:: python 907 | 908 | import ice 909 | app = ice.cube() 910 | 911 | @app.get('/foo') 912 | def foo(): 913 | return 303, '/bar' 914 | 915 | @app.get('/bar') 916 | def bar(): 917 | return ('' 918 | 'Bar' 919 | '

Bar

') 920 | 921 | app.run() 922 | 923 | After running this application, visiting http://localhost:8080/foo 924 | with a browser redirects the browser to http://localhost:8080/bar and 925 | displays a page with the following text. 926 | 927 | | Bar 928 | 929 | To send a redirect, the route handler needs to return a tuple such that 930 | the first item in the tuple is an HTTP status code for redirection and 931 | the second item is the URL to which the client should be redirected to. 932 | 933 | The behaviour of the above code is equivalent to the following code. 934 | 935 | .. code:: python 936 | 937 | import ice 938 | app = ice.cube() 939 | 940 | @app.get('/foo') 941 | def foo(): 942 | app.response.add_header('Location', '/bar') 943 | return 303 944 | 945 | @app.get('/bar') 946 | def bar(): 947 | return ('' 948 | 'Bar' 949 | '

Bar

') 950 | 951 | app.run() 952 | 953 | Much of the discussion in the :ref:`status-codes` section applies to 954 | this section too, i.e. it is possible to set the status code in 955 | ``app.response.status``, add a Location header and return a message 956 | body, or add a Location header, set the message body in 957 | ``app.response.body`` and return a status code. However, returning a 958 | tuple of redirection status code and URL, as shown in the first example 959 | in this section, is the simplest and preferred way to send a redirect. 960 | 961 | 962 | .. _static-files: 963 | 964 | Static Files 965 | ------------ 966 | In a typical production environment, a web server may be configured to 967 | receive HTTP requests and forward it to a Python application via WSGI. 968 | In such a setup, it might make more sense to configure the web server to 969 | serve static files because web servers implement several standard file 970 | handling capabilities and response headers, e.g. 'Last-Modified', 971 | 'If-Modified-Since', etc. However, it is possible to serve static files 972 | from an ice application using :meth:`ice.Ice.static` that provides a 973 | very rudimentary means of serving static files. This could be useful in 974 | a development environment where one would want to test pages with static 975 | content such as style sheets, images, etc. served by an ice application 976 | without using a web server. 977 | 978 | .. automethod:: ice.Ice.static 979 | :noindex: 980 | 981 | Here is an example. 982 | 983 | .. code:: python 984 | 985 | import ice 986 | app = ice.cube() 987 | 988 | @app.get('/code/<:path>') 989 | def send_code(path): 990 | return app.static('/var/www/project/code', path) 991 | 992 | if __name__ == '__main__': 993 | app.run() 994 | 995 | If there is a file called /var/www/project/code/data/foo.txt, then 996 | visiting http://localhost:8080/code/data/foo.txt would return the 997 | content of this file as response. 998 | 999 | However, visiting http://localhost:8080/code/%2e%2e/foo.txt would 1000 | display a '403 Forbidden' page because this request attempts to access 1001 | foo.txt in the parent directory of the document root directory 1002 | (``%2e%2d`` is the URL encoding of ``..``). This is not allowed in order 1003 | to prevent `directory traversal attack`_. 1004 | 1005 | .. _directory traversal attack: https://en.wikipedia.org/wiki/Directory_traversal_attack 1006 | 1007 | In the above example, the 'Content-Type' header of the response is 1008 | automatically set to 'text/plain; charset=UTF-8'. With only two 1009 | arguments specified to this method, it uses the extension name of the 1010 | file being returned to automatically guess the media type to be used in 1011 | the 'Content-Type' header. For example, the media type of a .txt file is 1012 | typically *guessed* to be 'text/plain'. But this may be different 1013 | because system configuration files may be referred in order to guess the 1014 | media type and such configuration files may map a .txt file to a 1015 | different media type. 1016 | 1017 | For example, on a Debian 8.0 system, /etc/mime.types maps a .c file to 1018 | 'text/x-csrc'. This is one of the files that is referred to guess the 1019 | media type. Therefore, the 'Content-Type' header for a request to 1020 | http://localhost:8080/code/data/foo.c would be set to 1021 | 'text/x-csrc; charset=UTF-8' on such a system. 1022 | 1023 | To see the list of files that may be referred to guess media type, 1024 | execute this command. :: 1025 | 1026 | python3 -c "import mimetypes; print(mimetypes.knownfiles)" 1027 | 1028 | The media type of static file being returned in a response can be set 1029 | explicitly to a desired value using the ``media_type`` keyword argument. 1030 | 1031 | The charset defaults to 'UTF-8' for any media type of type 'text' 1032 | regardless of the subtype. This may be changed with the ``charset`` 1033 | keyword argument. 1034 | 1035 | .. code:: python 1036 | 1037 | import ice 1038 | app = ice.cube() 1039 | 1040 | @app.get('/code/<:path>') 1041 | def send_code(path): 1042 | return app.static('/var/www/project/code', path, 1043 | media_type='text/plain', charset='ISO-8859-1') 1044 | 1045 | if __name__ == '__main__': 1046 | app.run() 1047 | 1048 | The above code guarantees that the 'Content-Type' header of a request to 1049 | http://localhost:8080/code/data/foo.c is set to 1050 | 'text/plain; charset=ISO-8859-1' regardless of how the media type of a 1051 | .c file is defined in the system configuration files. 1052 | 1053 | 1054 | Downloads 1055 | --------- 1056 | The :meth:`ice.Ice.download` method may be used to force a client, e.g. 1057 | a browser, to prompt the user to save the returned content locally as a 1058 | file. 1059 | 1060 | .. automethod:: ice.Ice.download 1061 | :noindex: 1062 | 1063 | Here is an example. 1064 | 1065 | .. code:: python 1066 | 1067 | import ice 1068 | app = ice.cube() 1069 | 1070 | @app.get('/foo') 1071 | def foo(): 1072 | return app.download('hello, world', 'foo.txt') 1073 | 1074 | @app.get('/bar') 1075 | def bar(): 1076 | return app.download('hello, world', 'bar', 1077 | media_type='text/plain', charset='ISO-8859-1') 1078 | 1079 | if __name__ == '__main__': 1080 | app.run() 1081 | 1082 | The first argument to this method is the content to return, specified as 1083 | a string or sequence of bytes. The second argument is the filename that 1084 | the client should use to save the returned content. 1085 | 1086 | The discussion about media type and character set described in the 1087 | :ref:`static-files` applies to this section too. 1088 | 1089 | Visiting http://localhost:8080/foo with a standard browser displays a 1090 | prompt to download and save a file called foo.txt. Visiting 1091 | http://localhost:8080/bar displays a prompt to download and save a file 1092 | called bar. 1093 | 1094 | Since the first argument may be a sequence of bytes, it is quite simple 1095 | to return a static file for download. The :meth:`ice.Ice.static` method 1096 | usually returns a sequence of bytes which can be passed directly to the 1097 | :meth:`ice.Ice.download` method. The ``static()`` method may return an 1098 | HTTP status code, e.g. 403 or 404, which is handled gracefully by the 1099 | ``download()`` method in order to return an error page as response. 1100 | 1101 | .. code:: python 1102 | 1103 | import ice 1104 | app = ice.cube() 1105 | 1106 | @app.get('/code/<:path>') 1107 | def send_download(path): 1108 | return app.download(app.static('/var/www/project/code', path)) 1109 | 1110 | if __name__ == '__main__': 1111 | app.run() 1112 | 1113 | Note that in the above example, no filename argument is specified for 1114 | the ``download()`` method. The path argument that was specified in the 1115 | ``static()`` call is automatically used to obtain the filename for the 1116 | ``download()`` call. 1117 | 1118 | If there is a file called /var/www/project/code/data/foo.txt, then 1119 | visiting http://localhost:8080/code/data/foo.txt with a standard browser 1120 | displays a prompt to download and save a file called foo.txt. 1121 | 1122 | Here are the complete set of rules that determine the filename that is 1123 | used for the download. The rules are followed in the specified order. 1124 | 1125 | 1. If the *filename* argument is specified, the base name from this 1126 | argument, i.e. ``os.path.basename(filename)``, is used as the 1127 | filename for the download. 1128 | 2. If the *filename* argument is not specified, the base name from the 1129 | file path specified to a previous *static()* method call made while 1130 | handling the current request is used. 1131 | 3. If the *filename* argument is not specified and there was no 1132 | ``static()`` call made previously for the current request, then the 1133 | base name from the current HTTP request path is used. 1134 | 4. As a result of the above three steps, if the resultant *filename* 1135 | turns out to be empty, then ice.LogicError is raised. 1136 | 1137 | The first two points have been demonstrated in the previous two examples 1138 | above. The last two points are demonstrated in the following example. 1139 | 1140 | .. code:: python 1141 | 1142 | import ice 1143 | app = ice.cube() 1144 | 1145 | @app.get('/') 1146 | def send_download(): 1147 | return app.download('hello, world') 1148 | 1149 | if __name__ == '__main__': 1150 | app.run() 1151 | 1152 | Visiting http://localhost:8080/foo.txt with a standard browser would 1153 | download a file foo.txt. However, visiting http://localhost:8080/foo/ 1154 | would display an error due to the unhandled ice.LogicError that is 1155 | raised because no filename can be determined from the request path /foo/ 1156 | which refers to a directory, not a file. 1157 | 1158 | 1159 | Request Environ 1160 | --------------- 1161 | The following example shows how to access the ``environ`` dictionary 1162 | defined in the `WSGI specification`_. 1163 | 1164 | .. _WSGI specification: https://www.python.org/dev/peps/pep-3333/#environ-variables 1165 | 1166 | .. code:: python 1167 | 1168 | import ice 1169 | app = ice.cube() 1170 | 1171 | @app.get('/') 1172 | def foo(): 1173 | user_agent = app.request.environ.get('HTTP_USER_AGENT', None) 1174 | return ('' 1175 | 'User Agent' 1176 | '

{}

'.format(user_agent)) 1177 | 1178 | app.run() 1179 | 1180 | The ``environ`` dictionary specified in the WSGI specification is made 1181 | available in ``app.request.environ``. The above example retrieves the 1182 | HTTP User-Agent header from this dictionary and displays it to the 1183 | client. 1184 | 1185 | 1186 | More 1187 | ---- 1188 | Since this is a microframework with a very limited set of features, 1189 | it is possible that you may find from time to time that this framework 1190 | is missing a useful API that another major framework provides. In such a 1191 | case, you have direct access to the WSGI internals to do what you want 1192 | via the documented API (see :ref:`api`). 1193 | 1194 | If you believe that the missing feature would be useful to all users of 1195 | this framework, please feel free to send a patch or a pull request. 1196 | --------------------------------------------------------------------------------