├── .coveragerc ├── .gitignore ├── .gitlab-ci.yml ├── .travis.yml ├── AUTHORS.rst ├── CHANGELOG.rst ├── LICENSE ├── Makefile ├── README.rst ├── docs ├── Makefile ├── _themes │ └── sphinx_rtd_theme │ │ ├── __init__.py │ │ ├── breadcrumbs.html │ │ ├── footer.html │ │ ├── layout.html │ │ ├── layout_old.html │ │ ├── search.html │ │ ├── searchbox.html │ │ ├── static │ │ ├── css │ │ │ ├── badge_only.css │ │ │ └── theme.css │ │ ├── font │ │ │ ├── fontawesome_webfont.eot │ │ │ ├── fontawesome_webfont.svg │ │ │ ├── fontawesome_webfont.ttf │ │ │ └── fontawesome_webfont.woff │ │ └── js │ │ │ └── theme.js │ │ ├── theme.conf │ │ └── versions.html ├── api │ ├── caching.rst │ ├── consumer.rst │ ├── decorators.rst │ ├── environment.rst │ ├── health_checks.rst │ ├── index.rst │ ├── provider.rst │ ├── queryparams.rst │ ├── request_handler.rst │ ├── service.rst │ └── statistics.rst ├── conf.py ├── getting_started.rst ├── index.rst ├── make.bat └── topics │ ├── consumer.rst │ ├── html.rst │ ├── index.rst │ ├── logging.rst │ └── provider.rst ├── example ├── gettingstarted.py └── main.html ├── pytest.ini ├── requirements-test.txt ├── requirements.txt ├── setup.py ├── supercell ├── __init__.py ├── _compat.py ├── acceptparsing.py ├── api.py ├── cache.py ├── consumer.py ├── decorators.py ├── environment.py ├── health.py ├── logging.py ├── mediatypes.py ├── middleware.py ├── provider.py ├── queryparam.py ├── requesthandler.py ├── service.py ├── testing.py ├── utils.py └── version.py ├── test ├── config.cfg ├── html_test_template │ └── test.html ├── logging.conf ├── test_acceptparsing.py ├── test_cache.py ├── test_consumer.py ├── test_decorators.py ├── test_environment.py ├── test_healthchecks.py ├── test_logging.py ├── test_middleware.py ├── test_provider.py ├── test_queryparam.py ├── test_requesthandler.py ├── test_returning_errors.py ├── test_returning_non_model.py ├── test_service.py └── test_utils.py └── travistest.py /.coveragerc: -------------------------------------------------------------------------------- 1 | # .coveragerc to control coverage.py 2 | [run] 3 | branch = True 4 | 5 | [report] 6 | # Regexes for lines to exclude from consideration 7 | exclude_lines = 8 | # Have to re-enable the standard pragma 9 | pragma: no cover 10 | 11 | ignore_errors = True 12 | 13 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.py[cod] 2 | 3 | # C extensions 4 | *.so 5 | 6 | # Packages 7 | *.egg 8 | *.egg-info 9 | dist 10 | build 11 | env* 12 | eggs 13 | parts 14 | bin 15 | var 16 | sdist 17 | develop-eggs 18 | .installed.cfg 19 | lib 20 | lib64 21 | 22 | # Installer logs 23 | pip-log.txt 24 | 25 | # Unit test / coverage reports 26 | .coverage 27 | .tox 28 | nosetests.xml 29 | coverage.xml 30 | pylint-report.txt 31 | 32 | # Translations 33 | *.mo 34 | 35 | # Mr Developer 36 | .mr.developer.cfg 37 | .project 38 | .pydevproject 39 | 40 | ###### 41 | # VIM 42 | ###### 43 | *.swp 44 | *.swo 45 | .ropeproject 46 | 47 | .idea 48 | .cache 49 | 50 | ###### 51 | # docs 52 | ###### 53 | docs/doctrees 54 | docs/html 55 | 56 | ###### 57 | # logs 58 | ###### 59 | root*.log* 60 | -------------------------------------------------------------------------------- /.gitlab-ci.yml: -------------------------------------------------------------------------------- 1 | variables: 2 | PIP_CACHE_DIR: "${CI_PROJECT_DIR}/.cache/pip" 3 | 4 | .test_template: 5 | stage: test 6 | cache: 7 | key: ${CI_JOB_NAME} 8 | paths: 9 | - .cache/pip 10 | artifacts: 11 | expire_in: 1 day 12 | paths: 13 | - coverage.xml 14 | - pylint-report.txt 15 | script: 16 | - make install 17 | - make test 18 | - set +e 19 | - 'env/bin/pylint supercell --jobs=1 --reports=no --msg-template="{path}:{line}: [{msg_id}({symbol}), {obj}] {msg}" > pylint-report.txt' 20 | - cat pylint-report.txt 21 | coverage: '/^TOTAL.*\s+(\d+\%)$/' 22 | 23 | .sonar_template: 24 | stage: sonar 25 | image: ciricihq/gitlab-sonar-scanner 26 | before_script: 27 | - export SONAR_ANALYSIS_MODE=publish 28 | - projectVersion=$(awk -F\" '/^__version__ = / {print $2}' supercell/version.py) 29 | - echo sonar.projectVersion=${projectVersion} >> sonar-project.properties 30 | - echo "sonar.projectKey=RTR:${projectKey}" >> sonar-project.properties 31 | - 'echo "sonar.projectName=Python :: ${projectKey}" >> sonar-project.properties' 32 | - echo "sonar.sources=supercell" >> sonar-project.properties 33 | - echo "sonar.python.coverage.reportPath=coverage.xml" >> sonar-project.properties 34 | - echo "sonar.python.pylint.reportPath=pylint-report.txt" >> sonar-project.properties 35 | script: 36 | - tail -2 sonar-project.properties 37 | - ls -l coverage.xml 38 | - sonar-scanner-run.sh 39 | only: 40 | - develop 41 | 42 | stages: 43 | - test 44 | - sonar 45 | 46 | test:36: 47 | extends: .test_template 48 | image: python:3.6 49 | 50 | test:37: 51 | extends: .test_template 52 | image: python:3.7 53 | 54 | test:38: 55 | extends: .test_template 56 | image: python:3.8 57 | 58 | test:39: 59 | extends: .test_template 60 | image: python:3.9 61 | 62 | test:310: 63 | extends: .test_template 64 | image: python:3.10 65 | 66 | test:311: 67 | extends: .test_template 68 | image: python:3.11 69 | 70 | test:312: 71 | extends: .test_template 72 | image: python:3.12 73 | 74 | test:313: 75 | extends: .test_template 76 | image: python:3.13 77 | 78 | sonar:36: 79 | extends: .sonar_template 80 | dependencies: 81 | - test:36 82 | variables: 83 | projectKey: supercell-36 84 | 85 | sonar:37: 86 | extends: .sonar_template 87 | dependencies: 88 | - test:37 89 | variables: 90 | projectKey: supercell-37 91 | 92 | sonar:38: 93 | extends: .sonar_template 94 | dependencies: 95 | - test:38 96 | variables: 97 | projectKey: supercell-38 98 | 99 | sonar:39: 100 | extends: .sonar_template 101 | dependencies: 102 | - test:39 103 | variables: 104 | projectKey: supercell-39 105 | 106 | sonar:310: 107 | extends: .sonar_template 108 | dependencies: 109 | - test:310 110 | variables: 111 | projectKey: supercell-310 112 | 113 | sonar:311: 114 | extends: .sonar_template 115 | dependencies: 116 | - test:311 117 | variables: 118 | projectKey: supercell-311 119 | 120 | sonar:312: 121 | extends: .sonar_template 122 | dependencies: 123 | - test:312 124 | variables: 125 | projectKey: supercell-312 126 | 127 | sonar:313: 128 | extends: .sonar_template 129 | dependencies: 130 | - test:313 131 | variables: 132 | projectKey: supercell-313 133 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: python 2 | python: 3 | - 3.6 4 | - 3.7 5 | - 3.8 6 | - 3.9 7 | - 3.10 8 | - 3.11 9 | - 3.12 10 | - 3.13 11 | # command to install dependencies 12 | install: 13 | - pip install -r requirements.txt 14 | - pip install -r requirements-test.txt 15 | - pip install coveralls 16 | - pip install -e . 17 | script: 18 | - python travistest.py 19 | after_success: 20 | - coveralls 21 | -------------------------------------------------------------------------------- /AUTHORS.rst: -------------------------------------------------------------------------------- 1 | Authors 2 | ======= 3 | 4 | In the list of contributing to the codebase: 5 | 6 | - Daniel Truemper < truemped at gmail > 7 | 8 | - Marko Drotschmann < drotschmann at retresco de > 9 | 10 | - Tobias Guenther < tobias.guenther at retresco de > 11 | 12 | - Matthias Rebel < matthias.rebel at retresco de > 13 | 14 | - Fabian Neumann < mail at fabianneumann de > 15 | 16 | - Michael Jurke < michael.jurke at retresco de > 17 | 18 | - Steffen Becker < steffen.becker at retresco de > 19 | 20 | - Andreas Peldszus < andreas.peldszus at retresco de > 21 | 22 | - Jannis Uhlendorf < jannis.uhlendorf at restresco.de > 23 | 24 | - Wolfgang Seeker < wolfgang.seeker at retresco.de > 25 | 26 | - Stephan Becker < stephan.becker at retresco.de > 27 | 28 | - Andrii Kucherenko < andrii.kucherenko at retresco.de > 29 | -------------------------------------------------------------------------------- /CHANGELOG.rst: -------------------------------------------------------------------------------- 1 | Next version 2 | ------------------------- 3 | 4 | New Features 5 | ~~~~~~~~~~~~ 6 | 7 | Development Changes 8 | ~~~~~~~~~~~~~~~~~~~ 9 | 10 | 11 | 0.14.0 (October 18, 2024) 12 | ------------------------- 13 | 14 | New Features 15 | ~~~~~~~~~~~~ 16 | 17 | * support of Python 3.13 18 | 19 | 20 | 0.13.0 - (July 25, 2024) 21 | ------------------------- 22 | 23 | New Features 24 | ~~~~~~~~~~~~ 25 | 26 | * support of Python 3.10 to 3.12 27 | 28 | 29 | Development Changes 30 | ~~~~~~~~~~~~~~~~~~~ 31 | 32 | * removal of left-over traces of support of Python version < 3.6. 33 | 34 | 35 | 0.12.0 - (September 25, 2023) 36 | ------------------------- 37 | 38 | New Features 39 | ~~~~~~~~~~~~ 40 | 41 | * support of Python 3.9 42 | * support of tornado < 6.3 43 | 44 | Development Changes 45 | ~~~~~~~~~~~~~~~~~~~ 46 | 47 | * removal of Python 2 support 48 | 49 | 0.11.0 - (August 06, 2020) 50 | ------------------------- 51 | 52 | New Features 53 | ~~~~~~~~~~~~ 54 | 55 | * support of Python 3.8 56 | 57 | Development Changes 58 | ~~~~~~~~~~~~~~~~~~~ 59 | 60 | * use `html.escape` instead of `cgi.escape` if available 61 | 62 | 0.10.0 - (March 29, 2019) 63 | ------------------------- 64 | 65 | Development Changes 66 | ~~~~~~~~~~~~~~~~~~~ 67 | 68 | * removed statistics collection logic and default statistics 69 | handler (`/_system/stats`) 70 | * set the Server header to `Supercell` (was `TornadoServer` and tornado version 71 | before) 72 | 73 | 0.9.0 - (February 13, 2019) 74 | ------------------------- 75 | 76 | New Features 77 | ~~~~~~~~~~~~ 78 | 79 | * added a flag `validate` to the consumer decorator 80 | `supercell.decorators.consumes` that is True by default and controls whether 81 | the input model is validated 82 | * introduced new command line argument `--logformat` to customize the logging 83 | format, the default logging format was changed to: 84 | `%(asctime)s [%(levelname)s] %(hostname)s %(name)s: %(message)s` 85 | 86 | Migration 87 | ~~~~~~~~~ 88 | 89 | * set the `validate` flag of the `supercell.decorators.consumes` decorator to 90 | False in case input model validation should not be done before passing the 91 | model to the handler 92 | 93 | 0.8.4 - (January 30, 2019) 94 | ------------------------- 95 | 96 | New Features 97 | ~~~~~~~~~~~~ 98 | 99 | * official Python 3.7 support 100 | 101 | Bugfixes / Improvements 102 | ~~~~~~~~~~~~~~~~~~~~~~~ 103 | 104 | * require pytest-cov < 2.6.0 to ensure that travis CI pipeline works 105 | 106 | 0.8.3 - (January 21, 2019) 107 | ------------------------- 108 | 109 | New Features 110 | ~~~~~~~~~~~~ 111 | 112 | * introduced Python 3.7 compatibility 113 | 114 | Bugfixes / Improvements 115 | ~~~~~~~~~~~~~~~~~~~~~~~ 116 | 117 | * return default content-type for wild-card accept type (`*.*`) instead 118 | of raising a 406 HTTP response status 119 | 120 | 121 | Development Changes 122 | ~~~~~~~~~~~~~~~~~~~ 123 | 124 | * renamed the `async` decorator form supercell.api to `coroutine` because 125 | async will be a reserved keyword in Python 3.7 126 | 127 | 128 | Migration 129 | ~~~~~~~~~ 130 | 131 | * rename all occurrences of `supercell.api.async` to `supercell.api.coroutine` 132 | 133 | 134 | 0.8.2 - (January 8, 2019) 135 | ------------------------- 136 | 137 | New Features 138 | ~~~~~~~~~~~~ 139 | 140 | * Add configuration via environment variables. The load precedence of service 141 | configurations is: 142 | 143 | environment variables > command line arguments > config file 144 | 145 | 146 | Bugfixes / Improvements 147 | ~~~~~~~~~~~~~~~~~~~~~~~ 148 | 149 | * Requirements update: 150 | * tornado: >=4.2.1,<=5.1.1 151 | * schematics: >= 1.1.1 152 | 153 | * Due to a security risk, query values in responding error messages encode 154 | html (<,>,&) now 155 | 156 | * HTTP response status 406 if no matching provider is found. If the request is 157 | not parsable (400) and no matching provider (406) the responded http status is 158 | 406. 159 | 160 | Development Changes 161 | ~~~~~~~~~~~~~~~~~~~ 162 | 163 | * Add gitlab-ci configuration to the project to run automatic testing 164 | The configuration is not part of the released package 165 | 166 | * Add Makefile to build and test the project in python 2.7, 3.6 and a local version 167 | To build and test the project run: 168 | 169 | .. code-block:: bash 170 | 171 | make install test 172 | 173 | The Makefile is not part of the released package 174 | 175 | Migration 176 | ~~~~~~~~~ 177 | 178 | 179 | 180 | 0.8.1 - (May 2, 2018) 181 | --------------------- 182 | 183 | - added option to suppress (successful) health check logs in an application 184 | 185 | 0.8.0 - (March 8, 2018) 186 | ----------------------- 187 | 188 | - new load model from arguments helper for request handlers 189 | - provides decorator with new partial option for partial validation 190 | - added support for partial validation in case of JsonProvider 191 | - NOTE: with schematics < 2.0.1, ModelType isn't properly partially validated 192 | - added python3.6 travis integration 193 | - removed python2.6 support 194 | 195 | 0.7.4 - (March 8, 2018) 196 | ----------------------- 197 | 198 | - add patch to http verbs that consume models 199 | - add Content-Type and Consumer for json patches 200 | 201 | 0.7.3 - (April 21, 2017) 202 | ------------------------ 203 | 204 | - extend RequestHandler for async-await syntax compatibility 205 | 206 | 0.7.2 - (March 17, 2017) 207 | ------------------------ 208 | 209 | - allow to log forwarded requests differently if X-Forwarded-For is set 210 | - improved error mechanism to be consistent in error writing 211 | - updated requirements to newer versions 212 | 213 | 0.7.1 - (February 3, 2017) 214 | -------------------------- 215 | 216 | - schematics BaseError handling 217 | - changes necessary for moving truemped->retresco 218 | 219 | 0.7.0 - (August 24, 2015) 220 | ------------------------- 221 | 222 | - Updated requires.io badge 223 | - Removed buildout 224 | - Tornado 4.2.1 225 | - Python 3.4 compatibility 226 | 227 | 228 | 0.6.3 - (January 12, 2015) 229 | -------------------------- 230 | 231 | - Add pytest to mocked sys.argv 232 | 233 | 0.6.2 - (December 28, 2014) 234 | --------------------------- 235 | 236 | - Simplify integration testing of services 237 | 238 | 0.6.1 - (December 23, 2014) 239 | --------------------------- 240 | 241 | - Optionally install signal handlers 242 | - Fix: the exception is called NotImplementedError. 243 | - Fix minor typo in @provides docstring 244 | 245 | 0.6.0 - (April 24, 2014) 246 | ------------------------ 247 | 248 | - add graceful shutdown 249 | - allow logging to `stdout` 250 | - Enable log file name with pid 251 | - General base class for middleware decorators 252 | - Typed query params deal with validation of query params 253 | 254 | 0.5.0 - 255 | --------------------------- 256 | 257 | - add a NoContent (204) http response 258 | - upgrade schematics to 0.9-4 (#7, #8) 259 | - add a text/html provider for rendering html using tornado.template 260 | 261 | 0.4.0 - (December 09, 2013) 262 | --------------------------- 263 | 264 | - Raise HTTPError when not returning a model 265 | - A ValueError thrown by Model initialization returns a 400 Error 266 | - fix for broken IE6 accept header 267 | - allow latin1 encoded urls 268 | - show-config, show-config-name and show-config-file-order 269 | - enable tornado debug mode in the config 270 | - Only add future callbacks if it is a future in the 271 | request handler 272 | - Unittests using py.test 273 | - HTTP Expires header support 274 | - Caching configurable when adding the handlers 275 | - Stats collecting using scales 276 | - Fixed logging configuration 277 | 278 | 0.3.0 - (July, 16, 2013) 279 | ------------------------ 280 | 281 | - Introduce health checks into supercell 282 | - Add a test for mapping ctypes with encodings 283 | 284 | 0.2.5 - (July 16, 2013) 285 | ----------------------- 286 | 287 | - Only call finish() if the handler did not 288 | - Minor fix for accessing the app in environments 289 | 290 | 0.2.4 - (July 10, 2013) 291 | ----------------------- 292 | 293 | - Add the `@s.cache` decorator 294 | 295 | 296 | 0.2.3 - (July 4, 2013) 297 | ---------------------- 298 | 299 | - Allow binding to a socket via command line param 300 | - Use MediaType.ApplicationJson instead of the plain string 301 | - Add managed objects and their access in handlers 302 | 303 | 304 | 0.1.0 - (July 3, 2013) 305 | ---------------------- 306 | 307 | - Use the async decorator instead of gen.coroutine 308 | - Application integration tests 309 | - Initial base service with testing 310 | - Add the initial default environment 311 | - No Python 3.3 because schematics is not compatible 312 | - Request handling code, working provider/consumer 313 | - Base consumer and consumer mapping 314 | - Cleaned up code for provider logic 315 | - Working provider logic and accept negotiation 316 | - Fixing FloatType on Python 3.3 317 | - Initial provider logic 318 | - PyPy testing, dependencies and py2.6 unittest2 319 | - Decorators simplified and working correctly 320 | - Unused import 321 | - Fixing iteritems on dicts in Py 3.3 322 | - Fixing sort comparator issue on Py 3.3 323 | - fix string format in Python 2.6 324 | - Fixing test requirements 325 | - nosetests 326 | - travis-ci 327 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | 2 | Apache License 3 | Version 2.0, January 2004 4 | http://www.apache.org/licenses/ 5 | 6 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 7 | 8 | 1. Definitions. 9 | 10 | "License" shall mean the terms and conditions for use, reproduction, 11 | and distribution as defined by Sections 1 through 9 of this document. 12 | 13 | "Licensor" shall mean the copyright owner or entity authorized by 14 | the copyright owner that is granting the License. 15 | 16 | "Legal Entity" shall mean the union of the acting entity and all 17 | other entities that control, are controlled by, or are under common 18 | control with that entity. For the purposes of this definition, 19 | "control" means (i) the power, direct or indirect, to cause the 20 | direction or management of such entity, whether by contract or 21 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 22 | outstanding shares, or (iii) beneficial ownership of such entity. 23 | 24 | "You" (or "Your") shall mean an individual or Legal Entity 25 | exercising permissions granted by this License. 26 | 27 | "Source" form shall mean the preferred form for making modifications, 28 | including but not limited to software source code, documentation 29 | source, and configuration files. 30 | 31 | "Object" form shall mean any form resulting from mechanical 32 | transformation or translation of a Source form, including but 33 | not limited to compiled object code, generated documentation, 34 | and conversions to other media types. 35 | 36 | "Work" shall mean the work of authorship, whether in Source or 37 | Object form, made available under the License, as indicated by a 38 | copyright notice that is included in or attached to the work 39 | (an example is provided in the Appendix below). 40 | 41 | "Derivative Works" shall mean any work, whether in Source or Object 42 | form, that is based on (or derived from) the Work and for which the 43 | editorial revisions, annotations, elaborations, or other modifications 44 | represent, as a whole, an original work of authorship. For the purposes 45 | of this License, Derivative Works shall not include works that remain 46 | separable from, or merely link (or bind by name) to the interfaces of, 47 | the Work and Derivative Works thereof. 48 | 49 | "Contribution" shall mean any work of authorship, including 50 | the original version of the Work and any modifications or additions 51 | to that Work or Derivative Works thereof, that is intentionally 52 | submitted to Licensor for inclusion in the Work by the copyright owner 53 | or by an individual or Legal Entity authorized to submit on behalf of 54 | the copyright owner. For the purposes of this definition, "submitted" 55 | means any form of electronic, verbal, or written communication sent 56 | to the Licensor or its representatives, including but not limited to 57 | communication on electronic mailing lists, source code control systems, 58 | and issue tracking systems that are managed by, or on behalf of, the 59 | Licensor for the purpose of discussing and improving the Work, but 60 | excluding communication that is conspicuously marked or otherwise 61 | designated in writing by the copyright owner as "Not a Contribution." 62 | 63 | "Contributor" shall mean Licensor and any individual or Legal Entity 64 | on behalf of whom a Contribution has been received by Licensor and 65 | subsequently incorporated within the Work. 66 | 67 | 2. Grant of Copyright License. Subject to the terms and conditions of 68 | this License, each Contributor hereby grants to You a perpetual, 69 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 70 | copyright license to reproduce, prepare Derivative Works of, 71 | publicly display, publicly perform, sublicense, and distribute the 72 | Work and such Derivative Works in Source or Object form. 73 | 74 | 3. Grant of Patent License. Subject to the terms and conditions of 75 | this License, each Contributor hereby grants to You a perpetual, 76 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 77 | (except as stated in this section) patent license to make, have made, 78 | use, offer to sell, sell, import, and otherwise transfer the Work, 79 | where such license applies only to those patent claims licensable 80 | by such Contributor that are necessarily infringed by their 81 | Contribution(s) alone or by combination of their Contribution(s) 82 | with the Work to which such Contribution(s) was submitted. If You 83 | institute patent litigation against any entity (including a 84 | cross-claim or counterclaim in a lawsuit) alleging that the Work 85 | or a Contribution incorporated within the Work constitutes direct 86 | or contributory patent infringement, then any patent licenses 87 | granted to You under this License for that Work shall terminate 88 | as of the date such litigation is filed. 89 | 90 | 4. Redistribution. You may reproduce and distribute copies of the 91 | Work or Derivative Works thereof in any medium, with or without 92 | modifications, and in Source or Object form, provided that You 93 | meet the following conditions: 94 | 95 | (a) You must give any other recipients of the Work or 96 | Derivative Works a copy of this License; and 97 | 98 | (b) You must cause any modified files to carry prominent notices 99 | stating that You changed the files; and 100 | 101 | (c) You must retain, in the Source form of any Derivative Works 102 | that You distribute, all copyright, patent, trademark, and 103 | attribution notices from the Source form of the Work, 104 | excluding those notices that do not pertain to any part of 105 | the Derivative Works; and 106 | 107 | (d) If the Work includes a "NOTICE" text file as part of its 108 | distribution, then any Derivative Works that You distribute must 109 | include a readable copy of the attribution notices contained 110 | within such NOTICE file, excluding those notices that do not 111 | pertain to any part of the Derivative Works, in at least one 112 | of the following places: within a NOTICE text file distributed 113 | as part of the Derivative Works; within the Source form or 114 | documentation, if provided along with the Derivative Works; or, 115 | within a display generated by the Derivative Works, if and 116 | wherever such third-party notices normally appear. The contents 117 | of the NOTICE file are for informational purposes only and 118 | do not modify the License. You may add Your own attribution 119 | notices within Derivative Works that You distribute, alongside 120 | or as an addendum to the NOTICE text from the Work, provided 121 | that such additional attribution notices cannot be construed 122 | as modifying the License. 123 | 124 | You may add Your own copyright statement to Your modifications and 125 | may provide additional or different license terms and conditions 126 | for use, reproduction, or distribution of Your modifications, or 127 | for any such Derivative Works as a whole, provided Your use, 128 | reproduction, and distribution of the Work otherwise complies with 129 | the conditions stated in this License. 130 | 131 | 5. Submission of Contributions. Unless You explicitly state otherwise, 132 | any Contribution intentionally submitted for inclusion in the Work 133 | by You to the Licensor shall be under the terms and conditions of 134 | this License, without any additional terms or conditions. 135 | Notwithstanding the above, nothing herein shall supersede or modify 136 | the terms of any separate license agreement you may have executed 137 | with Licensor regarding such Contributions. 138 | 139 | 6. Trademarks. This License does not grant permission to use the trade 140 | names, trademarks, service marks, or product names of the Licensor, 141 | except as required for reasonable and customary use in describing the 142 | origin of the Work and reproducing the content of the NOTICE file. 143 | 144 | 7. Disclaimer of Warranty. Unless required by applicable law or 145 | agreed to in writing, Licensor provides the Work (and each 146 | Contributor provides its Contributions) on an "AS IS" BASIS, 147 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 148 | implied, including, without limitation, any warranties or conditions 149 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 150 | PARTICULAR PURPOSE. You are solely responsible for determining the 151 | appropriateness of using or redistributing the Work and assume any 152 | risks associated with Your exercise of permissions under this License. 153 | 154 | 8. Limitation of Liability. In no event and under no legal theory, 155 | whether in tort (including negligence), contract, or otherwise, 156 | unless required by applicable law (such as deliberate and grossly 157 | negligent acts) or agreed to in writing, shall any Contributor be 158 | liable to You for damages, including any direct, indirect, special, 159 | incidental, or consequential damages of any character arising as a 160 | result of this License or out of the use or inability to use the 161 | Work (including but not limited to damages for loss of goodwill, 162 | work stoppage, computer failure or malfunction, or any and all 163 | other commercial damages or losses), even if such Contributor 164 | has been advised of the possibility of such damages. 165 | 166 | 9. Accepting Warranty or Additional Liability. While redistributing 167 | the Work or Derivative Works thereof, You may choose to offer, 168 | and charge a fee for, acceptance of support, warranty, indemnity, 169 | or other liability obligations and/or rights consistent with this 170 | License. However, in accepting such obligations, You may act only 171 | on Your own behalf and on Your sole responsibility, not on behalf 172 | of any other Contributor, and only if You agree to indemnify, 173 | defend, and hold each Contributor harmless for any liability 174 | incurred by, or claims asserted against, such Contributor by reason 175 | of your accepting any such warranty or additional liability. 176 | 177 | END OF TERMS AND CONDITIONS 178 | 179 | APPENDIX: How to apply the Apache License to your work. 180 | 181 | To apply the Apache License to your work, attach the following 182 | boilerplate notice, with the fields enclosed by brackets "[]" 183 | replaced with your own identifying information. (Don't include 184 | the brackets!) The text should be enclosed in the appropriate 185 | comment syntax for the file format. We also recommend that a 186 | file or class name and description of purpose be included on the 187 | same "printed page" as the copyright notice for easier 188 | identification within third-party archives. 189 | 190 | Copyright [yyyy] [name of copyright owner] 191 | 192 | Licensed under the Apache License, Version 2.0 (the "License"); 193 | you may not use this file except in compliance with the License. 194 | You may obtain a copy of the License at 195 | 196 | http://www.apache.org/licenses/LICENSE-2.0 197 | 198 | Unless required by applicable law or agreed to in writing, software 199 | distributed under the License is distributed on an "AS IS" BASIS, 200 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 201 | See the License for the specific language governing permissions and 202 | limitations under the License. 203 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | SRCDIR=supercell 2 | TEST?=test 3 | VIRTUALENV_DIR=${PWD}/env 4 | PIP=${VIRTUALENV_DIR}/bin/pip 5 | PYTHON=${VIRTUALENV_DIR}/bin/python 6 | 7 | .PHONY: all 8 | all: virtualenv install 9 | 10 | .PHONY: virtualenv 11 | virtualenv: 12 | if [ ! -e ${PIP} ]; then python3 -m venv ${VIRTUALENV_DIR}; fi 13 | ${PIP} install --upgrade pip 14 | 15 | .PHONY: install 16 | install: virtualenv 17 | ${PIP} install -r requirements.txt 18 | ${PIP} install -e . 19 | ${PIP} install -r requirements-test.txt 20 | 21 | .PHONY: test 22 | test: 23 | ${VIRTUALENV_DIR}/bin/py.test -vvrw ${TEST} --cov ${SRCDIR} --cov-report=term:skip-covered --cov-report=xml:coverage.xml 24 | 25 | .PHONY: clean 26 | clean: 27 | -rm -f .DS_Store .coverage 28 | find . -name '*.pyc' -exec rm -f {} \; 29 | find . -name '*.pyo' -exec rm -f {} \; 30 | find . -depth -name '__pycache__' -exec rm -rf {} \; 31 | 32 | .PHONY: dist-clean 33 | dist-clean: clean 34 | rm -rf ${VIRTUALENV_DIR}; 35 | find . -depth -name '*.egg-info' -exec rm -rf {} \; 36 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | supercell 2 | ========= 3 | 4 | Supercell is a framework for creating RESTful APIs that loosely follow the idea 5 | of *domain driven design*. Writing APIs in supercell begins with creating the 6 | domain classes using *schematics* and creating code for storing, manipulating 7 | and querying the data. Only then *Tornado* request handlers are created and 8 | *scales* will give you insights into the running API instance. At this point 9 | supercell will take care of transforming the incoming and outgoing 10 | serializations of the domain models. 11 | 12 | |TravisImage|_ |CoverAlls|_ |RequiresIo|_ 13 | 14 | .. |TravisImage| image:: https://travis-ci.org/retresco/supercell.png?branch=master 15 | .. _TravisImage: https://travis-ci.org/retresco/supercell 16 | 17 | .. |CoverAlls| image:: https://coveralls.io/repos/retresco/supercell/badge.png?branch=master 18 | .. _CoverAlls: https://coveralls.io/r/retresco/supercell 19 | 20 | .. |RequiresIo| image:: https://requires.io/github/retresco/supercell/requirements.svg?branch=master 21 | .. _RequiresIo: https://requires.io/github/retresco/supercell/requirements/?branch=master 22 | 23 | 24 | Quick Links 25 | =========== 26 | 27 | * `Documentation `_ 28 | * `Source (Github) `_ 29 | * `Issues (Github) `_ 30 | 31 | 32 | Authors 33 | ======= 34 | 35 | In order of contribution: 36 | 37 | * Daniel Truemper `@truemped `_ 38 | * Marko Drotschmann `@themakodi `_ 39 | * Tobias Guenther `@tobigue_ `_ 40 | * Matthias Rebel `@_rebeling `_ 41 | * Fabian Neumann `@hellp `_ 42 | * Michael Jurke `@mjrk `_ 43 | * Steffen Becker `@exorbit `_ 44 | * Andreas Peldszus `@peldszus `_ 45 | 46 | 47 | License 48 | ======= 49 | 50 | Licensed under Apache 2.0 -- see LICENSE 51 | -------------------------------------------------------------------------------- /docs/Makefile: -------------------------------------------------------------------------------- 1 | # Makefile for Sphinx documentation 2 | # 3 | 4 | # You can set these variables from the command line. 5 | SPHINXOPTS = 6 | SPHINXBUILD = /Users/daniel/Source/python/supercell/bin/sphinx-build 7 | PAPER = 8 | BUILDDIR = /Users/daniel/Source/python/supercell/docs 9 | 10 | # Internal variables. 11 | PAPEROPT_a4 = -D latex_paper_size=a4 12 | PAPEROPT_letter = -D latex_paper_size=letter 13 | ALLSPHINXOPTS = -d $(BUILDDIR)/doctrees $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) /Users/daniel/Source/python/supercell/docs 14 | # the i18n builder cannot share the environment and doctrees with the others 15 | I18NSPHINXOPTS = $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) /Users/daniel/Source/python/supercell/docs 16 | 17 | .PHONY: help clean html dirhtml singlehtml pickle json htmlhelp qthelp devhelp epub latex latexpdf text man changes linkcheck doctest gettext 18 | 19 | help: 20 | @echo "Please use \`make ' where is one of" 21 | @echo " html to make standalone HTML files" 22 | @echo " warnings-html to make standalone HTML files (warnings become errors)" 23 | @echo " dirhtml to make HTML files named index.html in directories" 24 | @echo " singlehtml to make a single large HTML file" 25 | @echo " pickle to make pickle files" 26 | @echo " json to make JSON files" 27 | @echo " htmlhelp to make HTML files and a HTML help project" 28 | @echo " qthelp to make HTML files and a qthelp project" 29 | @echo " devhelp to make HTML files and a Devhelp project" 30 | @echo " epub to make an epub" 31 | @echo " latex to make LaTeX files, you can set PAPER=a4 or PAPER=letter" 32 | @echo " latexpdf to make LaTeX files and run them through pdflatex" 33 | @echo " text to make text files" 34 | @echo " man to make manual pages" 35 | @echo " texinfo to make Texinfo files" 36 | @echo " info to make Texinfo files and run them through makeinfo" 37 | @echo " gettext to make PO message catalogs" 38 | @echo " changes to make an overview of all changed/added/deprecated items" 39 | @echo " linkcheck to check all external links for integrity" 40 | @echo " doctest to run all doctests embedded in the documentation (if enabled)" 41 | 42 | clean: 43 | -rm -rf $(BUILDDIR)/* 44 | 45 | html: 46 | $(SPHINXBUILD) -b html $(ALLSPHINXOPTS) $(BUILDDIR)/html 47 | @echo 48 | @echo "Build finished. The HTML pages are in $(BUILDDIR)/html." 49 | 50 | warnings-html: 51 | $(SPHINXBUILD) -W -b html $(ALLSPHINXOPTS) $(BUILDDIR)/html 52 | @echo 53 | @echo "Build finished. The HTML pages are in $(BUILDDIR)/html." 54 | 55 | dirhtml: 56 | $(SPHINXBUILD) -b dirhtml $(ALLSPHINXOPTS) $(BUILDDIR)/dirhtml 57 | @echo 58 | @echo "Build finished. The HTML pages are in $(BUILDDIR)/dirhtml." 59 | 60 | singlehtml: 61 | $(SPHINXBUILD) -b singlehtml $(ALLSPHINXOPTS) $(BUILDDIR)/singlehtml 62 | @echo 63 | @echo "Build finished. The HTML page is in $(BUILDDIR)/singlehtml." 64 | 65 | pickle: 66 | $(SPHINXBUILD) -b pickle $(ALLSPHINXOPTS) $(BUILDDIR)/pickle 67 | @echo 68 | @echo "Build finished; now you can process the pickle files." 69 | 70 | json: 71 | $(SPHINXBUILD) -b json $(ALLSPHINXOPTS) $(BUILDDIR)/json 72 | @echo 73 | @echo "Build finished; now you can process the JSON files." 74 | 75 | htmlhelp: 76 | $(SPHINXBUILD) -b htmlhelp $(ALLSPHINXOPTS) $(BUILDDIR)/htmlhelp 77 | @echo 78 | @echo "Build finished; now you can run HTML Help Workshop with the" \ 79 | ".hhp project file in $(BUILDDIR)/htmlhelp." 80 | 81 | qthelp: 82 | $(SPHINXBUILD) -b qthelp $(ALLSPHINXOPTS) $(BUILDDIR)/qthelp 83 | @echo 84 | @echo "Build finished; now you can run "qcollectiongenerator" with the" \ 85 | ".qhcp project file in $(BUILDDIR)/qthelp, like this:" 86 | @echo "# qcollectiongenerator $(BUILDDIR)/qthelp/sphinxbuilder.qhcp" 87 | @echo "To view the help file:" 88 | @echo "# assistant -collectionFile $(BUILDDIR)/qthelp/sphinxbuilder.qhc" 89 | 90 | devhelp: 91 | $(SPHINXBUILD) -b devhelp $(ALLSPHINXOPTS) $(BUILDDIR)/devhelp 92 | @echo 93 | @echo "Build finished." 94 | @echo "To view the help file:" 95 | @echo "# mkdir -p $$HOME/.local/share/devhelp/sphinxbuilder" 96 | @echo "# ln -s $(BUILDDIR)/devhelp $$HOME/.local/share/devhelp/sphinxbuilder" 97 | @echo "# devhelp" 98 | 99 | epub: 100 | $(SPHINXBUILD) -b epub $(ALLSPHINXOPTS) $(BUILDDIR)/epub 101 | @echo 102 | @echo "Build finished. The epub file is in $(BUILDDIR)/epub." 103 | 104 | latex: 105 | $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex 106 | @echo 107 | @echo "Build finished; the LaTeX files are in $(BUILDDIR)/latex." 108 | @echo "Run \`make' in that directory to run these through (pdf)latex" \ 109 | "(use \`make latexpdf' here to do that automatically)." 110 | 111 | latexpdf: 112 | $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex 113 | @echo "Running LaTeX files through pdflatex..." 114 | $(MAKE) -C $(BUILDDIR)/latex all-pdf 115 | @echo "pdflatex finished; the PDF files are in $(BUILDDIR)/latex." 116 | 117 | text: 118 | $(SPHINXBUILD) -b text $(ALLSPHINXOPTS) $(BUILDDIR)/text 119 | @echo 120 | @echo "Build finished. The text files are in $(BUILDDIR)/text." 121 | 122 | man: 123 | $(SPHINXBUILD) -b man $(ALLSPHINXOPTS) $(BUILDDIR)/man 124 | @echo 125 | @echo "Build finished. The manual pages are in $(BUILDDIR)/man." 126 | 127 | texinfo: 128 | $(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo 129 | @echo 130 | @echo "Build finished. The Texinfo files are in $(BUILDDIR)/texinfo." 131 | @echo "Run \`make' in that directory to run these through makeinfo" \ 132 | "(use \`make info' here to do that automatically)." 133 | 134 | info: 135 | $(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo 136 | @echo "Running Texinfo files through makeinfo..." 137 | make -C $(BUILDDIR)/texinfo info 138 | @echo "makeinfo finished; the Info files are in $(BUILDDIR)/texinfo." 139 | 140 | gettext: 141 | $(SPHINXBUILD) -b gettext $(I18NSPHINXOPTS) $(BUILDDIR)/locale 142 | @echo 143 | @echo "Build finished. The message catalogs are in $(BUILDDIR)/locale." 144 | 145 | changes: 146 | $(SPHINXBUILD) -b changes $(ALLSPHINXOPTS) $(BUILDDIR)/changes 147 | @echo 148 | @echo "The overview file is in $(BUILDDIR)/changes." 149 | 150 | linkcheck: 151 | $(SPHINXBUILD) -b linkcheck $(ALLSPHINXOPTS) $(BUILDDIR)/linkcheck 152 | @echo 153 | @echo "Link check complete; look for any errors in the above output " \ 154 | "or in $(BUILDDIR)/linkcheck/output.txt." 155 | 156 | doctest: 157 | $(SPHINXBUILD) -b doctest $(ALLSPHINXOPTS) $(BUILDDIR)/doctest 158 | @echo "Testing of doctests in the sources finished, look at the " \ 159 | "results in $(BUILDDIR)/doctest/output.txt." 160 | -------------------------------------------------------------------------------- /docs/_themes/sphinx_rtd_theme/__init__.py: -------------------------------------------------------------------------------- 1 | """Sphinx ReadTheDocs theme. 2 | 3 | From https://github.com/ryan-roemer/sphinx-bootstrap-theme. 4 | 5 | """ 6 | import os 7 | 8 | VERSION = (0, 1, 4) 9 | 10 | __version__ = ".".join(str(v) for v in VERSION) 11 | __version_full__ = __version__ 12 | 13 | 14 | def get_html_theme_path(): 15 | """Return list of HTML theme paths.""" 16 | cur_dir = os.path.abspath(os.path.dirname(os.path.dirname(__file__))) 17 | return cur_dir 18 | -------------------------------------------------------------------------------- /docs/_themes/sphinx_rtd_theme/breadcrumbs.html: -------------------------------------------------------------------------------- 1 | 10 |
11 | 12 | -------------------------------------------------------------------------------- /docs/_themes/sphinx_rtd_theme/footer.html: -------------------------------------------------------------------------------- 1 |
2 | {% if next or prev %} 3 | 11 | {% endif %} 12 | 13 |
14 | 15 |

16 | {%- if show_copyright %} 17 | {%- if hasdoc('copyright') %} 18 | {% trans path=pathto('copyright'), copyright=copyright|e %}© Copyright {{ copyright }}.{% endtrans %} 19 | {%- else %} 20 | {% trans copyright=copyright|e %}© Copyright {{ copyright }}.{% endtrans %} 21 | {%- endif %} 22 | {%- endif %} 23 | 24 | {%- if last_updated %} 25 | {% trans last_updated=last_updated|e %}Last updated on {{ last_updated }}.{% endtrans %} 26 | {%- endif %} 27 |

28 | 29 | {% trans %}Sphinx theme provided by Read the Docs{% endtrans %} 30 |
31 | -------------------------------------------------------------------------------- /docs/_themes/sphinx_rtd_theme/layout.html: -------------------------------------------------------------------------------- 1 | {# TEMPLATE VAR SETTINGS #} 2 | {%- set url_root = pathto('', 1) %} 3 | {%- if url_root == '#' %}{% set url_root = '' %}{% endif %} 4 | {%- if not embedded and docstitle %} 5 | {%- set titlesuffix = " — "|safe + docstitle|e %} 6 | {%- else %} 7 | {%- set titlesuffix = "" %} 8 | {%- endif %} 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | {% block htmltitle %} 17 | {{ title|striptags|e }}{{ titlesuffix }} 18 | {% endblock %} 19 | 20 | {# FAVICON #} 21 | {% if favicon %} 22 | 23 | {% endif %} 24 | 25 | {# CSS #} 26 | 27 | 28 | {# JS #} 29 | {% if not embedded %} 30 | 31 | 40 | {%- for scriptfile in script_files %} 41 | 42 | {%- endfor %} 43 | 44 | {% if use_opensearch %} 45 | 46 | {% endif %} 47 | 48 | {% endif %} 49 | 50 | {# RTD hosts these file themselves, so just load on non RTD builds #} 51 | {% if not READTHEDOCS %} 52 | 53 | 54 | {% endif %} 55 | 56 | {% for cssfile in css_files %} 57 | 58 | {% endfor %} 59 | 60 | {%- block linktags %} 61 | {%- if hasdoc('about') %} 62 | 64 | {%- endif %} 65 | {%- if hasdoc('genindex') %} 66 | 68 | {%- endif %} 69 | {%- if hasdoc('search') %} 70 | 71 | {%- endif %} 72 | {%- if hasdoc('copyright') %} 73 | 74 | {%- endif %} 75 | 76 | {%- if parents %} 77 | 78 | {%- endif %} 79 | {%- if next %} 80 | 81 | {%- endif %} 82 | {%- if prev %} 83 | 84 | {%- endif %} 85 | {%- endblock %} 86 | {%- block extrahead %} {% endblock %} 87 | 88 | 89 | 90 | 91 | 92 | 93 | 94 |
95 | 96 | {# SIDE NAV, TOGGLES ON MOBILE #} 97 | 113 | 114 |
115 | 116 | {# MOBILE NAV, TRIGGLES SIDE NAV ON TOGGLE #} 117 | 121 | 122 | 123 | {# PAGE CONTENT #} 124 |
125 |
126 | {% include "breadcrumbs.html" %} 127 | {% block body %}{% endblock %} 128 | {% include "footer.html" %} 129 |
130 |
131 | 132 |
133 | 134 |
135 | {% include "versions.html" %} 136 | 137 | 138 | -------------------------------------------------------------------------------- /docs/_themes/sphinx_rtd_theme/layout_old.html: -------------------------------------------------------------------------------- 1 | {# 2 | basic/layout.html 3 | ~~~~~~~~~~~~~~~~~ 4 | 5 | Master layout template for Sphinx themes. 6 | 7 | :copyright: Copyright 2007-2013 by the Sphinx team, see AUTHORS. 8 | :license: BSD, see LICENSE for details. 9 | #} 10 | {%- block doctype -%} 11 | 13 | {%- endblock %} 14 | {%- set reldelim1 = reldelim1 is not defined and ' »' or reldelim1 %} 15 | {%- set reldelim2 = reldelim2 is not defined and ' |' or reldelim2 %} 16 | {%- set render_sidebar = (not embedded) and (not theme_nosidebar|tobool) and 17 | (sidebars != []) %} 18 | {%- set url_root = pathto('', 1) %} 19 | {# XXX necessary? #} 20 | {%- if url_root == '#' %}{% set url_root = '' %}{% endif %} 21 | {%- if not embedded and docstitle %} 22 | {%- set titlesuffix = " — "|safe + docstitle|e %} 23 | {%- else %} 24 | {%- set titlesuffix = "" %} 25 | {%- endif %} 26 | 27 | {%- macro relbar() %} 28 | 46 | {%- endmacro %} 47 | 48 | {%- macro sidebar() %} 49 | {%- if render_sidebar %} 50 |
51 |
52 | {%- block sidebarlogo %} 53 | {%- if logo %} 54 | 57 | {%- endif %} 58 | {%- endblock %} 59 | {%- if sidebars != None %} 60 | {#- new style sidebar: explicitly include/exclude templates #} 61 | {%- for sidebartemplate in sidebars %} 62 | {%- include sidebartemplate %} 63 | {%- endfor %} 64 | {%- else %} 65 | {#- old style sidebars: using blocks -- should be deprecated #} 66 | {%- block sidebartoc %} 67 | {%- include "localtoc.html" %} 68 | {%- endblock %} 69 | {%- block sidebarrel %} 70 | {%- include "relations.html" %} 71 | {%- endblock %} 72 | {%- block sidebarsourcelink %} 73 | {%- include "sourcelink.html" %} 74 | {%- endblock %} 75 | {%- if customsidebar %} 76 | {%- include customsidebar %} 77 | {%- endif %} 78 | {%- block sidebarsearch %} 79 | {%- include "searchbox.html" %} 80 | {%- endblock %} 81 | {%- endif %} 82 |
83 |
84 | {%- endif %} 85 | {%- endmacro %} 86 | 87 | {%- macro script() %} 88 | 97 | {%- for scriptfile in script_files %} 98 | 99 | {%- endfor %} 100 | {%- endmacro %} 101 | 102 | {%- macro css() %} 103 | 104 | 105 | {%- for cssfile in css_files %} 106 | 107 | {%- endfor %} 108 | {%- endmacro %} 109 | 110 | 111 | 112 | 113 | {{ metatags }} 114 | {%- block htmltitle %} 115 | {{ title|striptags|e }}{{ titlesuffix }} 116 | {%- endblock %} 117 | {{ css() }} 118 | {%- if not embedded %} 119 | {{ script() }} 120 | {%- if use_opensearch %} 121 | 124 | {%- endif %} 125 | {%- if favicon %} 126 | 127 | {%- endif %} 128 | {%- endif %} 129 | {%- block linktags %} 130 | {%- if hasdoc('about') %} 131 | 132 | {%- endif %} 133 | {%- if hasdoc('genindex') %} 134 | 135 | {%- endif %} 136 | {%- if hasdoc('search') %} 137 | 138 | {%- endif %} 139 | {%- if hasdoc('copyright') %} 140 | 141 | {%- endif %} 142 | 143 | {%- if parents %} 144 | 145 | {%- endif %} 146 | {%- if next %} 147 | 148 | {%- endif %} 149 | {%- if prev %} 150 | 151 | {%- endif %} 152 | {%- endblock %} 153 | {%- block extrahead %} {% endblock %} 154 | 155 | 156 | {%- block header %}{% endblock %} 157 | 158 | {%- block relbar1 %}{{ relbar() }}{% endblock %} 159 | 160 | {%- block content %} 161 | {%- block sidebar1 %} {# possible location for sidebar #} {% endblock %} 162 | 163 |
164 | {%- block document %} 165 |
166 | {%- if render_sidebar %} 167 |
168 | {%- endif %} 169 |
170 | {% block body %} {% endblock %} 171 |
172 | {%- if render_sidebar %} 173 |
174 | {%- endif %} 175 |
176 | {%- endblock %} 177 | 178 | {%- block sidebar2 %}{{ sidebar() }}{% endblock %} 179 |
180 |
181 | {%- endblock %} 182 | 183 | {%- block relbar2 %}{{ relbar() }}{% endblock %} 184 | 185 | {%- block footer %} 186 | 201 |

asdf asdf asdf asdf 22

202 | {%- endblock %} 203 | 204 | 205 | 206 | -------------------------------------------------------------------------------- /docs/_themes/sphinx_rtd_theme/search.html: -------------------------------------------------------------------------------- 1 | {# 2 | basic/search.html 3 | ~~~~~~~~~~~~~~~~~ 4 | 5 | Template for the search page. 6 | 7 | :copyright: Copyright 2007-2013 by the Sphinx team, see AUTHORS. 8 | :license: BSD, see LICENSE for details. 9 | #} 10 | {%- extends "layout.html" %} 11 | {% set title = _('Search') %} 12 | {% set script_files = script_files + ['_static/searchtools.js'] %} 13 | {% block extrahead %} 14 | 17 | {# this is used when loading the search index using $.ajax fails, 18 | such as on Chrome for documents on localhost #} 19 | 20 | {{ super() }} 21 | {% endblock %} 22 | {% block body %} 23 | 31 | 32 | {% if search_performed %} 33 |

{{ _('Search Results') }}

34 | {% if not search_results %} 35 |

{{ _('Your search did not match any documents. Please make sure that all words are spelled correctly and that you\'ve selected enough categories.') }}

36 | {% endif %} 37 | {% endif %} 38 |
39 | {% if search_results %} 40 |
    41 | {% for href, caption, context in search_results %} 42 |
  • 43 | {{ caption }} 44 |

    {{ context|e }}

    45 |
  • 46 | {% endfor %} 47 |
48 | {% endif %} 49 |
50 | {% endblock %} 51 | -------------------------------------------------------------------------------- /docs/_themes/sphinx_rtd_theme/searchbox.html: -------------------------------------------------------------------------------- 1 |
2 | 3 | 4 | 5 |
6 | -------------------------------------------------------------------------------- /docs/_themes/sphinx_rtd_theme/static/css/badge_only.css: -------------------------------------------------------------------------------- 1 | .font-smooth,.icon:before{-webkit-font-smoothing:antialiased}.clearfix{*zoom:1}.clearfix:before,.clearfix:after{display:table;content:""}.clearfix:after{clear:both}@font-face{font-family:fontawesome-webfont;font-weight:normal;font-style:normal;src:url("../font/fontawesome_webfont.eot");src:url("../font/fontawesome_webfont.eot?#iefix") format("embedded-opentype"),url("../font/fontawesome_webfont.woff") format("woff"),url("../font/fontawesome_webfont.ttf") format("truetype"),url("../font/fontawesome_webfont.svg#fontawesome-webfont") format("svg")}.icon:before{display:inline-block;font-family:fontawesome-webfont;font-style:normal;font-weight:normal;line-height:1;text-decoration:inherit}a .icon{display:inline-block;text-decoration:inherit}li .icon{display:inline-block}li .icon-large:before,li .icon-large:before{width:1.875em}ul.icons{list-style-type:none;margin-left:2em;text-indent:-0.8em}ul.icons li .icon{width:0.8em}ul.icons li .icon-large:before,ul.icons li .icon-large:before{vertical-align:baseline}.icon-book:before{content:"\f02d"}.icon-caret-down:before{content:"\f0d7"}.icon-caret-up:before{content:"\f0d8"}.icon-caret-left:before{content:"\f0d9"}.icon-caret-right:before{content:"\f0da"}.rst-versions{position:fixed;bottom:0;left:0;width:300px;color:#fcfcfc;background:#1f1d1d;border-top:solid 10px #343131;font-family:"Lato","proxima-nova","Helvetica Neue",Arial,sans-serif;z-index:400}.rst-versions a{color:#2980b9;text-decoration:none}.rst-versions .rst-badge-small{display:none}.rst-versions .rst-current-version{padding:12px;background-color:#272525;display:block;text-align:right;font-size:90%;cursor:pointer;color:#27ae60;*zoom:1}.rst-versions .rst-current-version:before,.rst-versions .rst-current-version:after{display:table;content:""}.rst-versions .rst-current-version:after{clear:both}.rst-versions .rst-current-version .icon{color:#fcfcfc}.rst-versions .rst-current-version .icon-book{float:left}.rst-versions .rst-current-version.rst-out-of-date{background-color:#e74c3c;color:#fff}.rst-versions.shift-up .rst-other-versions{display:block}.rst-versions .rst-other-versions{font-size:90%;padding:12px;color:gray;display:none}.rst-versions .rst-other-versions hr{display:block;height:1px;border:0;margin:20px 0;padding:0;border-top:solid 1px #413d3d}.rst-versions .rst-other-versions dd{display:inline-block;margin:0}.rst-versions .rst-other-versions dd a{display:inline-block;padding:6px;color:#fcfcfc}.rst-versions.rst-badge{width:auto;bottom:20px;right:20px;left:auto;border:none;max-width:300px}.rst-versions.rst-badge .icon-book{float:none}.rst-versions.rst-badge.shift-up .rst-current-version{text-align:right}.rst-versions.rst-badge.shift-up .rst-current-version .icon-book{float:left}.rst-versions.rst-badge .rst-current-version{width:auto;height:30px;line-height:30px;padding:0 6px;display:block;text-align:center}@media screen and (max-width: 768px){.rst-versions{width:85%;display:none}.rst-versions.shift{display:block}img{width:100%;height:auto}} 2 | -------------------------------------------------------------------------------- /docs/_themes/sphinx_rtd_theme/static/font/fontawesome_webfont.eot: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/retresco/supercell/ad92646038cabb7463de42e864ab0cf499f7dc3a/docs/_themes/sphinx_rtd_theme/static/font/fontawesome_webfont.eot -------------------------------------------------------------------------------- /docs/_themes/sphinx_rtd_theme/static/font/fontawesome_webfont.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/retresco/supercell/ad92646038cabb7463de42e864ab0cf499f7dc3a/docs/_themes/sphinx_rtd_theme/static/font/fontawesome_webfont.ttf -------------------------------------------------------------------------------- /docs/_themes/sphinx_rtd_theme/static/font/fontawesome_webfont.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/retresco/supercell/ad92646038cabb7463de42e864ab0cf499f7dc3a/docs/_themes/sphinx_rtd_theme/static/font/fontawesome_webfont.woff -------------------------------------------------------------------------------- /docs/_themes/sphinx_rtd_theme/static/js/theme.js: -------------------------------------------------------------------------------- 1 | $( document ).ready(function() { 2 | // Shift nav in mobile when clicking the menu. 3 | $("[data-toggle='wy-nav-top']").click(function() { 4 | $("[data-toggle='wy-nav-shift']").toggleClass("shift"); 5 | $("[data-toggle='rst-versions']").toggleClass("shift"); 6 | }); 7 | // Close menu when you click a link. 8 | $(".wy-menu-vertical .current ul li a").click(function() { 9 | $("[data-toggle='wy-nav-shift']").removeClass("shift"); 10 | $("[data-toggle='rst-versions']").toggleClass("shift"); 11 | }); 12 | $("[data-toggle='rst-current-version']").click(function() { 13 | $("[data-toggle='rst-versions']").toggleClass("shift-up"); 14 | }); 15 | $("table.docutils:not(.field-list").wrap("
"); 16 | }); 17 | -------------------------------------------------------------------------------- /docs/_themes/sphinx_rtd_theme/theme.conf: -------------------------------------------------------------------------------- 1 | [theme] 2 | inherit = basic 3 | stylesheet = css/theme.css 4 | 5 | [options] 6 | typekit_id = hiw1hhg 7 | analytics_id = 8 | -------------------------------------------------------------------------------- /docs/_themes/sphinx_rtd_theme/versions.html: -------------------------------------------------------------------------------- 1 | {% if READTHEDOCS %} 2 | {# Add rst-badge after rst-versions for small badge style. #} 3 |
4 | 5 | Read the Docs 6 | v: {{ current_version }} 7 | 8 | 9 |
10 |
11 |
Versions
12 | {% for slug, url in versions %} 13 |
{{ slug }}
14 | {% endfor %} 15 |
16 |
17 |
Downloads
18 | {% for type, url in downloads %} 19 |
{{ type }}
20 | {% endfor %} 21 |
22 |
23 |
On Read the Docs
24 |
25 | Project Home 26 |
27 |
28 | Builds 29 |
30 |
31 |
32 | Free document hosting provided by Read the Docs. 33 | 34 |
35 |
36 | {% endif %} 37 | 38 | -------------------------------------------------------------------------------- /docs/api/caching.rst: -------------------------------------------------------------------------------- 1 | .. vim: set fileencoding=UTF-8 : 2 | .. vim: set tw=80 : 3 | 4 | 5 | Caching 6 | ------- 7 | 8 | .. automodule:: supercell.cache 9 | :members: 10 | -------------------------------------------------------------------------------- /docs/api/consumer.rst: -------------------------------------------------------------------------------- 1 | .. vim: set fileencoding=UTF-8 : 2 | .. vim: set tw=80 : 3 | 4 | 5 | Consumer 6 | -------- 7 | 8 | .. automodule:: supercell.consumer 9 | :members: 10 | 11 | 12 | -------------------------------------------------------------------------------- /docs/api/decorators.rst: -------------------------------------------------------------------------------- 1 | .. vim: set fileencoding=UTF-8 : 2 | .. vim: set tw=80 : 3 | 4 | 5 | Decorators 6 | ---------- 7 | 8 | .. automodule:: supercell.decorators 9 | :members: 10 | -------------------------------------------------------------------------------- /docs/api/environment.rst: -------------------------------------------------------------------------------- 1 | .. vim: set fileencoding=UTF-8 : 2 | .. vim: set tw=80 : 3 | 4 | 5 | Environment 6 | ----------- 7 | 8 | .. automodule:: supercell.environment 9 | :members: 10 | -------------------------------------------------------------------------------- /docs/api/health_checks.rst: -------------------------------------------------------------------------------- 1 | .. vim: set fileencoding=UTF-8 : 2 | .. vim: set tw=80 : 3 | 4 | 5 | Health Checks 6 | ------------- 7 | 8 | .. automodule:: supercell.health 9 | :members: 10 | -------------------------------------------------------------------------------- /docs/api/index.rst: -------------------------------------------------------------------------------- 1 | .. vim: set fileencoding=UTF-8 : 2 | .. vim: set tw=80 : 3 | 4 | API 5 | === 6 | .. _api: 7 | 8 | This is the main `supercell` API reference. 9 | 10 | .. toctree:: 11 | 12 | environment 13 | service 14 | request_handler 15 | consumer 16 | provider 17 | queryparams 18 | decorators 19 | health_checks 20 | statistics 21 | caching 22 | -------------------------------------------------------------------------------- /docs/api/provider.rst: -------------------------------------------------------------------------------- 1 | .. vim: set fileencoding=UTF-8 : 2 | .. vim: set tw=80 : 3 | 4 | 5 | Provider 6 | -------- 7 | 8 | .. automodule:: supercell.provider 9 | :members: 10 | -------------------------------------------------------------------------------- /docs/api/queryparams.rst: -------------------------------------------------------------------------------- 1 | .. vim: set fileencoding=UTF-8 : 2 | .. vim: set tw=80 : 3 | 4 | 5 | Query Parameter 6 | --------------- 7 | 8 | .. automodule:: supercell.queryparam 9 | :members: 10 | -------------------------------------------------------------------------------- /docs/api/request_handler.rst: -------------------------------------------------------------------------------- 1 | .. vim: set fileencoding=UTF-8 : 2 | .. vim: set tw=80 : 3 | 4 | 5 | Request handler 6 | --------------- 7 | 8 | .. automodule:: supercell.requesthandler 9 | :members: 10 | -------------------------------------------------------------------------------- /docs/api/service.rst: -------------------------------------------------------------------------------- 1 | .. vim: set fileencoding=UTF-8 : 2 | .. vim: set tw=80 : 3 | 4 | 5 | Service 6 | ------- 7 | 8 | .. automodule:: supercell.service 9 | :members: 10 | -------------------------------------------------------------------------------- /docs/api/statistics.rst: -------------------------------------------------------------------------------- 1 | .. vim: set fileencoding=UTF-8 : 2 | .. vim: set tw=80 : 3 | 4 | 5 | Statistics 6 | ---------- 7 | 8 | .. automodule:: supercell.stats 9 | :members: 10 | -------------------------------------------------------------------------------- /docs/conf.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # 3 | # supercell documentation build configuration file, created by 4 | # sphinx-quickstart on Tue Jun 11 10:51:43 2013. 5 | # 6 | # This file is execfile()d with the current directory set to its containing dir. 7 | # 8 | # Note that not all possible configuration values are present in this 9 | # autogenerated file. 10 | # 11 | # All configuration values have a default; values that are commented out 12 | # serve to show the default. 13 | 14 | import os 15 | 16 | # If extensions (or modules to document with autodoc) are in another directory, 17 | # add these directories to sys.path here. If the directory is relative to the 18 | # documentation root, use os.path.abspath to make it absolute, like shown here. 19 | #sys.path.insert(0, os.path.abspath('.')) 20 | 21 | # -- General configuration ----------------------------------------------------- 22 | 23 | # If your documentation needs a minimal Sphinx version, state it here. 24 | #needs_sphinx = '1.0' 25 | 26 | # Add any Sphinx extension module names here, as strings. They can be extensions 27 | # coming with Sphinx (named 'sphinx.ext.*') or your custom ones. 28 | extensions = ['sphinx.ext.autodoc', 'sphinx.ext.viewcode'] 29 | 30 | # Add any paths that contain templates here, relative to this directory. 31 | templates_path = ['_templates'] 32 | 33 | # The suffix of source filenames. 34 | source_suffix = '.rst' 35 | 36 | # The encoding of source files. 37 | #source_encoding = 'utf-8-sig' 38 | 39 | # The master toctree document. 40 | master_doc = 'index' 41 | 42 | # General information about the project. 43 | project = u'supercell' 44 | copyright = u'2014, Daniel Truemper' 45 | 46 | # The version info for the project you're documenting, acts as replacement for 47 | # |version| and |release|, also used in various other places throughout the 48 | # built documents. 49 | # 50 | # The short X.Y version. 51 | from imp import load_source 52 | init = load_source('init', os.path.join('..', 'supercell', '__init__.py')) 53 | 54 | version = '.'.join([str(v) for v in init.__version__.split(".")][:2]) + '.0' 55 | # The full version, including alpha/beta/rc tags. 56 | release = init.__version__ 57 | 58 | # The language for content autogenerated by Sphinx. Refer to documentation 59 | # for a list of supported languages. 60 | #language = None 61 | 62 | # There are two options for replacing |today|: either, you set today to some 63 | # non-false value, then it is used: 64 | #today = '' 65 | # Else, today_fmt is used as the format for a strftime call. 66 | #today_fmt = '%B %d, %Y' 67 | 68 | # List of patterns, relative to source directory, that match files and 69 | # directories to ignore when looking for source files. 70 | exclude_patterns = ['_build'] 71 | 72 | # The reST default role (used for this markup: `text`) to use for all documents. 73 | #default_role = None 74 | 75 | # If true, '()' will be appended to :func: etc. cross-reference text. 76 | #add_function_parentheses = True 77 | 78 | # If true, the current module name will be prepended to all description 79 | # unit titles (such as .. function::). 80 | #add_module_names = True 81 | 82 | # If true, sectionauthor and moduleauthor directives will be shown in the 83 | # output. They are ignored by default. 84 | #show_authors = False 85 | 86 | # The name of the Pygments (syntax highlighting) style to use. 87 | pygments_style = 'sphinx' 88 | 89 | # A list of ignored prefixes for module index sorting. 90 | #modindex_common_prefix = [] 91 | 92 | 93 | # -- Options for HTML output --------------------------------------------------- 94 | 95 | # The theme to use for HTML and HTML Help pages. See the documentation for 96 | # a list of builtin themes. 97 | html_theme = 'sphinx_rtd_theme' 98 | 99 | # Theme options are theme-specific and customize the look and feel of a theme 100 | # further. For a list of options available for each theme, see the 101 | # documentation. 102 | #html_theme_options = {} 103 | 104 | # Add any paths that contain custom themes here, relative to this directory. 105 | html_theme_path = ['_themes'] 106 | 107 | # The name for this set of Sphinx documents. If None, it defaults to 108 | # " v documentation". 109 | #html_title = None 110 | 111 | # A shorter title for the navigation bar. Default is the same as html_title. 112 | #html_short_title = None 113 | 114 | # The name of an image file (relative to this directory) to place at the top 115 | # of the sidebar. 116 | #html_logo = None 117 | 118 | # The name of an image file (within the static path) to use as favicon of the 119 | # docs. This file should be a Windows icon file (.ico) being 16x16 or 32x32 120 | # pixels large. 121 | #html_favicon = None 122 | 123 | # Add any paths that contain custom static files (such as style sheets) here, 124 | # relative to this directory. They are copied after the builtin static files, 125 | # so a file named "default.css" will overwrite the builtin "default.css". 126 | html_static_path = ['_static'] 127 | 128 | # If not '', a 'Last updated on:' timestamp is inserted at every page bottom, 129 | # using the given strftime format. 130 | #html_last_updated_fmt = '%b %d, %Y' 131 | 132 | # If true, SmartyPants will be used to convert quotes and dashes to 133 | # typographically correct entities. 134 | #html_use_smartypants = True 135 | 136 | # Custom sidebar templates, maps document names to template names. 137 | #html_sidebars = {} 138 | 139 | # Additional templates that should be rendered to pages, maps page names to 140 | # template names. 141 | #html_additional_pages = {} 142 | 143 | # If false, no module index is generated. 144 | #html_domain_indices = True 145 | 146 | # If false, no index is generated. 147 | #html_use_index = True 148 | 149 | # If true, the index is split into individual pages for each letter. 150 | #html_split_index = False 151 | 152 | # If true, links to the reST sources are added to the pages. 153 | #html_show_sourcelink = True 154 | 155 | # If true, "Created using Sphinx" is shown in the HTML footer. Default is True. 156 | #html_show_sphinx = True 157 | 158 | # If true, "(C) Copyright ..." is shown in the HTML footer. Default is True. 159 | #html_show_copyright = True 160 | 161 | # If true, an OpenSearch description file will be output, and all pages will 162 | # contain a tag referring to it. The value of this option must be the 163 | # base URL from which the finished HTML is served. 164 | #html_use_opensearch = '' 165 | 166 | # This is the file name suffix for HTML files (e.g. ".xhtml"). 167 | #html_file_suffix = None 168 | 169 | # Output file base name for HTML help builder. 170 | htmlhelp_basename = 'supercelldoc' 171 | 172 | 173 | # -- Options for LaTeX output -------------------------------------------------- 174 | 175 | latex_elements = { 176 | # The paper size ('letterpaper' or 'a4paper'). 177 | #'papersize': 'letterpaper', 178 | 179 | # The font size ('10pt', '11pt' or '12pt'). 180 | #'pointsize': '10pt', 181 | 182 | # Additional stuff for the LaTeX preamble. 183 | #'preamble': '', 184 | } 185 | 186 | # Grouping the document tree into LaTeX files. List of tuples 187 | # (source start file, target name, title, author, documentclass [howto/manual]). 188 | latex_documents = [ 189 | ('index', 'supercell.tex', u'supercell Documentation', 190 | u'Daniel Truemper', 'manual'), 191 | ] 192 | 193 | # The name of an image file (relative to this directory) to place at the top of 194 | # the title page. 195 | #latex_logo = None 196 | 197 | # For "manual" documents, if this is true, then toplevel headings are parts, 198 | # not chapters. 199 | #latex_use_parts = False 200 | 201 | # If true, show page references after internal links. 202 | #latex_show_pagerefs = False 203 | 204 | # If true, show URL addresses after external links. 205 | #latex_show_urls = False 206 | 207 | # Documents to append as an appendix to all manuals. 208 | #latex_appendices = [] 209 | 210 | # If false, no module index is generated. 211 | #latex_domain_indices = True 212 | 213 | 214 | # -- Options for manual page output -------------------------------------------- 215 | 216 | # One entry per manual page. List of tuples 217 | # (source start file, name, description, authors, manual section). 218 | man_pages = [ 219 | ('index', 'supercell', u'supercell Documentation', 220 | [u'Daniel Truemper'], 1) 221 | ] 222 | 223 | # If true, show URL addresses after external links. 224 | #man_show_urls = False 225 | 226 | 227 | # -- Options for Texinfo output ------------------------------------------------ 228 | 229 | # Grouping the document tree into Texinfo files. List of tuples 230 | # (source start file, target name, title, author, 231 | # dir menu entry, description, category) 232 | texinfo_documents = [ 233 | ('index', 'supercell', u'supercell Documentation', 234 | u'Daniel Truemper', 'supercell', 'One line description of project.', 235 | 'Miscellaneous'), 236 | ] 237 | 238 | # Documents to append as an appendix to all manuals. 239 | #texinfo_appendices = [] 240 | 241 | # If false, no module index is generated. 242 | #texinfo_domain_indices = True 243 | 244 | # How to display URL addresses: 'footnote', 'no', or 'inline'. 245 | #texinfo_show_urls = 'footnote' 246 | -------------------------------------------------------------------------------- /docs/getting_started.rst: -------------------------------------------------------------------------------- 1 | .. vim: set fileencoding=UTF-8 : 2 | .. vim: set tw=80 : 3 | 4 | Getting Started 5 | =============== 6 | .. _getting_started: 7 | 8 | This guide will help you get started with a simple *Hello World* Supercell 9 | project. 10 | 11 | 12 | Overview 13 | -------- 14 | 15 | Supercell applications use `Tornado `_ as a 16 | HTTP server, `Schematics `_ for dealing 17 | with representations, and `Scales `_ for metrics. 18 | 19 | 20 | Project structure 21 | ----------------- 22 | 23 | A typical supercell application is structured in submodules: 24 | 25 | - app 26 | 27 | - api - representations 28 | - core - domain implementation, i.e. crud operatios on representations 29 | - health - health checks 30 | - handler - request handler 31 | - provider - custom providers if any 32 | - consumer - custom consumers if any 33 | - service.py - the service class 34 | - config.py - the configuration 35 | 36 | 37 | Configuration 38 | ------------- 39 | 40 | Start with creating a `config.py` file:: 41 | 42 | from tornado.options import define 43 | 44 | define('default_name', 'Hello %s', help='Default name') 45 | define('template', 'main.html', help='Tornado template file') 46 | 47 | Here we are only defining the configuration names and their default 48 | configuration values. Shortly we will se the different ways to really set the 49 | configuraion. 50 | 51 | 52 | Create a service class 53 | ---------------------- 54 | 55 | The service class is the part of the application defining it's handlers and 56 | startup behaviour. For this purpose we start with a very simple class:: 57 | 58 | import supercell.api as s 59 | 60 | class MyService(s.Service): 61 | 62 | def bootstrap(self): 63 | self.environment.config_file_paths.append('./etc') 64 | self.environment.config_file_paths.append('/etc/hello-world/') 65 | 66 | def run(self): 67 | # nothing done yet 68 | pass 69 | 70 | def main(): 71 | MyService().main() 72 | 73 | This class is not doing too much for now. Basically it only handles the order in 74 | which configuration files are being parsed. Right now supercell will parse the 75 | following files in that order: 76 | 77 | #. $PWD/etc/config.cfg 78 | #. $PWD/etc/$USER_$HOSTNAME.cfg 79 | #. /etc/hello-world/config.cfg 80 | #. /etc/hello-world/$USER_$HOSTNAME.cfg 81 | 82 | After all these files were parsed, one may still overwrite the values using the 83 | command line parameters. 84 | 85 | Assume we have this entry point in the `setup.py`:: 86 | 87 | hello-world = helloworld.service:main 88 | 89 | we can start the application with something like `hello-world`. In order to 90 | debug configuration settings you have the following command line parameters at 91 | hand:: 92 | 93 | # see the config file name you have to generate for this machine 94 | $ hello-world --show-config-name 95 | 96 | # see the order in which the files would be parsed 97 | $ hello-world --show-config-file-order 98 | 99 | # see the effective configuration 100 | $ hello-world --show-config 101 | 102 | 103 | Representation 104 | -------------- 105 | 106 | Now we create the model for the application:: 107 | 108 | from schematics.models import Model 109 | from schematics.types import StringType, IntType 110 | 111 | class Saying(Model): 112 | 113 | id = IntType() 114 | content = StringType() 115 | 116 | There is nothing special to it assuming you have some knowledge on schematics. 117 | We simply have a `Saying` model that contains an `id` as integer and some 118 | `content` as a string. 119 | 120 | 121 | Request Handler 122 | --------------- 123 | 124 | The request handler is very similar to a `Tornado` handler, except it also takes 125 | care of de-/serializing in- and output:: 126 | 127 | @s.provides(s.MediaType.ApplicationJson) 128 | class HelloWorld(s.RequestHandler): 129 | 130 | @property 131 | def counter(self): 132 | if not hasattr(self.__class__, '_counter'): 133 | self.__class__._counter = 0 134 | return self.__class__._counter or 0 135 | 136 | @counter.setter 137 | def counter(self, value): 138 | self.__class__._counter = value 139 | 140 | @s.async 141 | def get(self): 142 | self.counter += 1 143 | name = self.get_argument('name', self.config.default_name) 144 | content = self.render_string(self.config.template, name) 145 | raise s.Return(Saying(id=self.counter, content=content)) 146 | 147 | Ok, let's get through this example step by step. The `s.provides` decorator 148 | tells supercell the content type, that this handler should return. In this case 149 | a predefined one (`s.MediaType.ApplicationJson`) that will transform the 150 | returned model as `application/json`. 151 | 152 | The `counter` property is a simple wrapper around a class level variable that 153 | stores the overall counter. Keep in mind that for each request a new instance of 154 | the handler class is created, so a simple instance variable would always be `0`. 155 | 156 | The `s.async` decorator is a simple wrapper for the two `Tornado` decorators 157 | `web.asynchronous` and `gen.coroutine`. With the new `coroutine` decorator 158 | `Tornado` can now make use of the `concurrent.Futures` of Python 3.3 and the 159 | backported library for Python < 3. 160 | 161 | Now we only have to add the request handler to the service implementation:: 162 | 163 | class MyService(s.Service): 164 | 165 | def run(self): 166 | self.environment.add_handler('/hello-world', HelloWorld) 167 | 168 | 169 | Start the application and point your browser to 170 | `http://localhost:8080/hello-world `_ to see 171 | the response. The `id` is growing on every request and to change the output you 172 | may add the `name` parameter: `http://localhost:8080/hello-world?name=you 173 | `_ 174 | 175 | See 176 | `example/gettingstarted.py 177 | `_ 178 | for the full example code. 179 | -------------------------------------------------------------------------------- /docs/index.rst: -------------------------------------------------------------------------------- 1 | .. vim: set fileencoding=UTF-8 : 2 | .. vim: set tw=80 : 3 | 4 | Welcome to supercell's documentation! 5 | ===================================== 6 | 7 | Supercell is a simple set of classes for creating domain driven RESTful APIs 8 | in Python. We use `schematics` for domain modeling, `scales` for statistics and 9 | `Tornado` as web server. 10 | 11 | A very simple example for a supercell request handler looks like this:: 12 | 13 | from schematics.models import Model 14 | from schematics.types import StringType, IntType 15 | 16 | class Saying(Model): 17 | 18 | id = IntType() 19 | content = StringType() 20 | 21 | @s.provides(s.MediaType.ApplicationJson) 22 | class HelloWorld(s.RequestHandler): 23 | 24 | @property 25 | def counter(self): 26 | if not hasattr(self.__class__, '_counter'): 27 | self.__class__._counter = 0 28 | return self.__class__._counter or 0 29 | 30 | @counter.setter 31 | def counter(self, value): 32 | self.__class__._counter = value 33 | 34 | @s.async 35 | def get(self): 36 | self.counter += 1 37 | name = self.get_argument('name', self.config.default_name) 38 | content = self.render_string(self.config.template, name) 39 | raise s.Return(Saying(id=self.counter, content=content)) 40 | 41 | 42 | The **Getting Started** guide should help you becoming familiar with the 43 | ideas behind and the **Topics** contain a growingly part of in depth 44 | documentation on certain aspects. The **API** contains the full API 45 | documentation. 46 | 47 | Contents: 48 | --------- 49 | 50 | .. toctree:: 51 | :maxdepth: 2 52 | 53 | getting_started 54 | topics/index 55 | api/index 56 | 57 | 58 | Indices and tables 59 | ================== 60 | 61 | * :ref:`genindex` 62 | * :ref:`modindex` 63 | * :ref:`search` 64 | -------------------------------------------------------------------------------- /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=/Users/daniel/Source/python/supercell/docs 9 | set ALLSPHINXOPTS=-d %BUILDDIR%/doctrees %SPHINXOPTS% /Users/daniel/Source/python/supercell/docs 10 | set I18NSPHINXOPTS=%SPHINXOPTS% /Users/daniel/Source/python/supercell/docs 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. warnings-html to make standalone HTML files (turn warnings into errors) 23 | echo. dirhtml to make HTML files named index.html in directories 24 | echo. singlehtml to make a single large HTML file 25 | echo. pickle to make pickle files 26 | echo. json to make JSON files 27 | echo. htmlhelp to make HTML files and a HTML help project 28 | echo. qthelp to make HTML files and a qthelp project 29 | echo. devhelp to make HTML files and a Devhelp project 30 | echo. epub to make an epub 31 | echo. latex to make LaTeX files, you can set PAPER=a4 or PAPER=letter 32 | echo. text to make text files 33 | echo. man to make manual pages 34 | echo. texinfo to make Texinfo files 35 | echo. gettext to make PO message catalogs 36 | echo. changes to make an overview over all changed/added/deprecated items 37 | echo. linkcheck to check all external links for integrity 38 | echo. doctest to run all doctests embedded in the documentation if enabled 39 | goto end 40 | ) 41 | 42 | if "%1" == "clean" ( 43 | for /d %%i in (%BUILDDIR%\*) do rmdir /q /s %%i 44 | del /q /s %BUILDDIR%\* 45 | goto end 46 | ) 47 | 48 | if "%1" == "html" ( 49 | %SPHINXBUILD% -b html %ALLSPHINXOPTS% %BUILDDIR%/html 50 | if errorlevel 1 exit /b 1 51 | echo. 52 | echo.Build finished. The HTML pages are in %BUILDDIR%/html. 53 | goto end 54 | ) 55 | 56 | if "%1" == "warnings-html" ( 57 | %SPHINXBUILD% -W -b html %ALLSPHINXOPTS% %BUILDDIR%/html 58 | if errorlevel 1 exit /b 1 59 | echo. 60 | echo.Build finished. The HTML pages are in %BUILDDIR%/html. 61 | goto end 62 | ) 63 | 64 | if "%1" == "dirhtml" ( 65 | %SPHINXBUILD% -b dirhtml %ALLSPHINXOPTS% %BUILDDIR%/dirhtml 66 | if errorlevel 1 exit /b 1 67 | echo. 68 | echo.Build finished. The HTML pages are in %BUILDDIR%/dirhtml. 69 | goto end 70 | ) 71 | 72 | if "%1" == "singlehtml" ( 73 | %SPHINXBUILD% -b singlehtml %ALLSPHINXOPTS% %BUILDDIR%/singlehtml 74 | if errorlevel 1 exit /b 1 75 | echo. 76 | echo.Build finished. The HTML pages are in %BUILDDIR%/singlehtml. 77 | goto end 78 | ) 79 | 80 | if "%1" == "pickle" ( 81 | %SPHINXBUILD% -b pickle %ALLSPHINXOPTS% %BUILDDIR%/pickle 82 | if errorlevel 1 exit /b 1 83 | echo. 84 | echo.Build finished; now you can process the pickle files. 85 | goto end 86 | ) 87 | 88 | if "%1" == "json" ( 89 | %SPHINXBUILD% -b json %ALLSPHINXOPTS% %BUILDDIR%/json 90 | if errorlevel 1 exit /b 1 91 | echo. 92 | echo.Build finished; now you can process the JSON files. 93 | goto end 94 | ) 95 | 96 | if "%1" == "htmlhelp" ( 97 | %SPHINXBUILD% -b htmlhelp %ALLSPHINXOPTS% %BUILDDIR%/htmlhelp 98 | if errorlevel 1 exit /b 1 99 | echo. 100 | echo.Build finished; now you can run HTML Help Workshop with the ^ 101 | .hhp project file in %BUILDDIR%/htmlhelp. 102 | goto end 103 | ) 104 | 105 | if "%1" == "qthelp" ( 106 | %SPHINXBUILD% -b qthelp %ALLSPHINXOPTS% %BUILDDIR%/qthelp 107 | if errorlevel 1 exit /b 1 108 | echo. 109 | echo.Build finished; now you can run "qcollectiongenerator" with the ^ 110 | .qhcp project file in %BUILDDIR%/qthelp, like this: 111 | echo.^> qcollectiongenerator %BUILDDIR%\qthelp\sphinxbuilder.qhcp 112 | echo.To view the help file: 113 | echo.^> assistant -collectionFile %BUILDDIR%\qthelp\sphinxbuilder.ghc 114 | goto end 115 | ) 116 | 117 | if "%1" == "devhelp" ( 118 | %SPHINXBUILD% -b devhelp %ALLSPHINXOPTS% %BUILDDIR%/devhelp 119 | if errorlevel 1 exit /b 1 120 | echo. 121 | echo.Build finished. 122 | goto end 123 | ) 124 | 125 | if "%1" == "epub" ( 126 | %SPHINXBUILD% -b epub %ALLSPHINXOPTS% %BUILDDIR%/epub 127 | if errorlevel 1 exit /b 1 128 | echo. 129 | echo.Build finished. The epub file is in %BUILDDIR%/epub. 130 | goto end 131 | ) 132 | 133 | if "%1" == "latex" ( 134 | %SPHINXBUILD% -b latex %ALLSPHINXOPTS% %BUILDDIR%/latex 135 | if errorlevel 1 exit /b 1 136 | echo. 137 | echo.Build finished; the LaTeX files are in %BUILDDIR%/latex. 138 | goto end 139 | ) 140 | 141 | if "%1" == "text" ( 142 | %SPHINXBUILD% -b text %ALLSPHINXOPTS% %BUILDDIR%/text 143 | if errorlevel 1 exit /b 1 144 | echo. 145 | echo.Build finished. The text files are in %BUILDDIR%/text. 146 | goto end 147 | ) 148 | 149 | if "%1" == "man" ( 150 | %SPHINXBUILD% -b man %ALLSPHINXOPTS% %BUILDDIR%/man 151 | if errorlevel 1 exit /b 1 152 | echo. 153 | echo.Build finished. The manual pages are in %BUILDDIR%/man. 154 | goto end 155 | ) 156 | 157 | if "%1" == "texinfo" ( 158 | %SPHINXBUILD% -b texinfo %ALLSPHINXOPTS% %BUILDDIR%/texinfo 159 | if errorlevel 1 exit /b 1 160 | echo. 161 | echo.Build finished. The Texinfo files are in %BUILDDIR%/texinfo. 162 | goto end 163 | ) 164 | 165 | if "%1" == "gettext" ( 166 | %SPHINXBUILD% -b gettext %I18NSPHINXOPTS% %BUILDDIR%/locale 167 | if errorlevel 1 exit /b 1 168 | echo. 169 | echo.Build finished. The message catalogs are in %BUILDDIR%/locale. 170 | goto end 171 | ) 172 | 173 | if "%1" == "changes" ( 174 | %SPHINXBUILD% -b changes %ALLSPHINXOPTS% %BUILDDIR%/changes 175 | if errorlevel 1 exit /b 1 176 | echo. 177 | echo.The overview file is in %BUILDDIR%/changes. 178 | goto end 179 | ) 180 | 181 | if "%1" == "linkcheck" ( 182 | %SPHINXBUILD% -b linkcheck %ALLSPHINXOPTS% %BUILDDIR%/linkcheck 183 | if errorlevel 1 exit /b 1 184 | echo. 185 | echo.Link check complete; look for any errors in the above output ^ 186 | or in %BUILDDIR%/linkcheck/output.txt. 187 | goto end 188 | ) 189 | 190 | if "%1" == "doctest" ( 191 | %SPHINXBUILD% -b doctest %ALLSPHINXOPTS% %BUILDDIR%/doctest 192 | if errorlevel 1 exit /b 1 193 | echo. 194 | echo.Testing of doctests in the sources finished, look at the ^ 195 | results in %BUILDDIR%/doctest/output.txt. 196 | goto end 197 | ) 198 | 199 | :end 200 | -------------------------------------------------------------------------------- /docs/topics/consumer.rst: -------------------------------------------------------------------------------- 1 | .. vim: set fileencoding=UTF-8 : 2 | .. vim: set tw=80 : 3 | 4 | 5 | Consumer 6 | -------- 7 | 8 | Consumers convert the client's input into an internal format defined as a 9 | `schematics` model. The mapping of incoming data to one of the available 10 | consumers is based on the client's `Content-Type` header. If this is missing and 11 | no default consumer is defined, the client will receive a HTTP 406 error 12 | (**Content not acceptable**). 13 | 14 | Defining consumers for a request handler is done using the `consumes` decorator 15 | on the class definition:: 16 | 17 | @s.consumes(s.MediaType.ApplicationJson, model=Model) 18 | class MyHandler(s.RequestHandler): 19 | pass 20 | 21 | 22 | Create custom consumer 23 | ^^^^^^^^^^^^^^^^^^^^^^ 24 | 25 | Creating a custom consumer is as easy as subclassing the `ConsumerBase` class. 26 | See the `JsonConsumer` for example:: 27 | 28 | class JsonConsumer(ConsumerBase): 29 | 30 | CONTENT_TYPE = ContentType('application/json') 31 | 32 | def consume(self, handler, model): 33 | return model(**json.loads(handler.request.body)) 34 | 35 | 36 | Content Types 37 | ^^^^^^^^^^^^^ 38 | 39 | 40 | The `CONTENT_TYPE` class level variable maps a certain `Content-Type` header to 41 | this consumer. The `consume(handler, model)` method converts the request body to 42 | an instance of the `model` class. 43 | 44 | In situations where you need to accept the same content type, but the model has 45 | different versions, you can use the `vendor` and `version` parameters to the 46 | content type definition. This allows for multiple consumers for the same 47 | serialization format like `json` but different versions of the data. See for 48 | example the following two definitions and their respective content type value:: 49 | 50 | ContentType('application/json') == 'application/json' 51 | 52 | ContentType('application/json', version='1.1', vendor='corp') == \ 53 | 'application/vnd.corp-v1.1+json' 54 | 55 | 56 | If you create two consumers for both content types, the client can decide which 57 | version is sent. 58 | -------------------------------------------------------------------------------- /docs/topics/html.rst: -------------------------------------------------------------------------------- 1 | .. vim: set fileencoding=UTF-8 : 2 | .. vim: set tw=80 : 3 | 4 | 5 | Rendering HTML 6 | -------------- 7 | 8 | HTML is just another output format supported by `supercell`. The default 9 | implementation is using tornado's built in template mechanism for rendering 10 | HTML. A `RequestHandler` supporting HTML only has to implement the 11 | `get_template()` method, that will return the template file name based on the 12 | final model. By setting the `template_path` configuration variable one may 13 | define the directory containing all templates. 14 | 15 | A minimum example would look like this:: 16 | 17 | @s.provides(s.MediaType.TextHtml, default=True) 18 | class SimpleHtml(s.RequestHandler): 19 | 20 | def get_template(self, model): 21 | return 'simple.html' 22 | 23 | def get(self, *args, **kwargs): 24 | raise s.Return(Saying({'id': self.counter, 'content': content})) 25 | 26 | class MyService(s.Service): 27 | 28 | def bootstrap(self): 29 | self.environment.tornado_settings['template_path'] = 'templates/' 30 | 31 | def run(self): 32 | self.environment.add_handler('/', SimpleHtml) 33 | -------------------------------------------------------------------------------- /docs/topics/index.rst: -------------------------------------------------------------------------------- 1 | .. vim: set fileencoding=UTF-8 : 2 | .. vim: set tw=80 : 3 | 4 | Topics 5 | ====== 6 | 7 | .. toctree:: 8 | :maxdepth: 1 9 | 10 | logging 11 | html 12 | consumer 13 | provider 14 | -------------------------------------------------------------------------------- /docs/topics/logging.rst: -------------------------------------------------------------------------------- 1 | .. vim: set fileencoding=UTF-8 : 2 | .. vim: set tw=80 : 3 | 4 | 5 | Logging 6 | ------- 7 | 8 | Logging with `supercell` is configured with two simple configuration options: 9 | the *logfile* defines the file to which the logs are written. By default it is 10 | named *root.log* and does a daily log rotation for 10 days. 11 | 12 | The second configuration sets the *loglevel*. This can be on of *DEBUG*, *INFO*, 13 | *WARN*, *ERROR*, i.e. any valid default Python *logging.level* value. 14 | 15 | The default logging implementation is simply adding a 16 | *supercell.SupercellLoggingHandler* and sets the *loglevel* on the root logger. 17 | We also disable the *tornado.log* module, so that it does not add its own 18 | handler. The *SupercellLoggingHandler* is simply a *TimedRotatingFileHandler* 19 | with some default values like number of backups and rotation interval and it 20 | sets the logging format. 21 | 22 | Custom logging 23 | ++++++++++++++ 24 | 25 | If the default logging does not fit your need, you may simply overwrite the 26 | *initialize_logging* method of your *Service* implementation. 27 | -------------------------------------------------------------------------------- /docs/topics/provider.rst: -------------------------------------------------------------------------------- 1 | .. vim: set fileencoding=UTF-8 : 2 | .. vim: set tw=80 : 3 | 4 | 5 | Provider 6 | -------- 7 | 8 | A `Provider` is the equivalent to a `Consumer` only that it transforms the 9 | request handler's resulting model into a serialization requested from the 10 | client. The client is then able to request a certain serialization using the 11 | `Accept` header in her request. 12 | 13 | Defining providers for a request handler is done using the `provides` decorator 14 | on the class definition:: 15 | 16 | @s.provides(s.MediaType.ApplicationJson) 17 | class MyHandler(s.RequestHandler): 18 | pass 19 | 20 | 21 | Create custom provider 22 | ^^^^^^^^^^^^^^^^^^^^^^ 23 | 24 | A provider can be created equivalently to a consumer:: 25 | 26 | class JsonProvider(ConsumerBase): 27 | 28 | CONTENT_TYPE = ContentType(MediaType.ApplicationJson) 29 | 30 | def provide(self, model, handler): 31 | try: 32 | model.validate() 33 | handler.write(model.to_primitive()) 34 | except ModelValidationError as e: 35 | # for schematics 1.1.1, use e.messages instead 36 | raise HTTPError(500, reason=json.dumps(e.to_primitive())) 37 | 38 | def error(self, status_code, message, handler): 39 | handler.finish(message) 40 | 41 | 42 | For `Producers` the same remarks about the content type hold as for the 43 | `Consumers`. 44 | -------------------------------------------------------------------------------- /example/gettingstarted.py: -------------------------------------------------------------------------------- 1 | # vim: set fileencoding=utf-8 : 2 | # 3 | # Copyright (c) 2013 Daniel Truemper 4 | # 5 | # Licensed under the Apache License, Version 2.0 (the "License"); 6 | # you may not use this file except in compliance with the License. 7 | # You may obtain a copy of the License at 8 | # 9 | # http://www.apache.org/licenses/LICENSE-2.0 10 | # 11 | # Unless required by applicable law or agreed to in writing, software 12 | # distributed under the License is distributed on an "AS IS" BASIS, 13 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 14 | # See the License for the specific language governing permissions and 15 | # limitations under the License. 16 | # 17 | # 18 | from schematics.models import Model 19 | from schematics.types import StringType, IntType 20 | 21 | from tornado.options import define 22 | 23 | import supercell.api as s 24 | 25 | 26 | define('default_name', 'World', help='Default name') 27 | define('template', 'main.html', help='Tornado template file') 28 | 29 | 30 | class Saying(Model): 31 | 32 | id = IntType() 33 | content = StringType() 34 | 35 | 36 | @s.provides(s.MediaType.ApplicationJson, default=True) 37 | class HelloWorld(s.RequestHandler): 38 | 39 | @property 40 | def counter(self): 41 | if not hasattr(self.__class__, '_counter'): 42 | self.__class__._counter = 0 43 | return self.__class__._counter or 0 44 | 45 | @counter.setter 46 | def counter(self, value): 47 | self.__class__._counter = value 48 | 49 | @s.coroutine 50 | def get(self): 51 | self.counter += 1 52 | name = self.get_argument('name', self.config.default_name) 53 | content = self.render_string(self.config.template, name=name) 54 | raise s.Return(Saying({'id': self.counter, 'content': content})) 55 | 56 | 57 | class MyService(s.Service): 58 | 59 | def bootstrap(self): 60 | self.environment.config_file_paths.append('./etc') 61 | self.environment.config_file_paths.append('/etc/hello-world/') 62 | 63 | def run(self): 64 | self.environment.add_handler('/hello-world', HelloWorld) 65 | 66 | 67 | def main(): 68 | MyService().main() 69 | 70 | 71 | if __name__ == '__main__': 72 | main() 73 | -------------------------------------------------------------------------------- /example/main.html: -------------------------------------------------------------------------------- 1 | Hello {{name}}! 2 | -------------------------------------------------------------------------------- /pytest.ini: -------------------------------------------------------------------------------- 1 | [pytest] 2 | addopts = --cov supercell --cov-report term-missing 3 | -------------------------------------------------------------------------------- /requirements-test.txt: -------------------------------------------------------------------------------- 1 | mock 2 | pytest < 8.2 3 | pytest-cov 4 | pylint 5 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | tornado >=4.2.1,<6.3 2 | schematics >= 1.1.1 3 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | # 2 | # Copyright (c) 2012 Daniel Truemper 3 | # 4 | # Licensed under the Apache License, Version 2.0 (the "License"); 5 | # you may not use this file except in compliance with the License. 6 | # You may obtain a copy of the License at 7 | # 8 | # http://www.apache.org/licenses/LICENSE-2.0 9 | # 10 | # Unless required by applicable law or agreed to in writing, software 11 | # distributed under the License is distributed on an "AS IS" BASIS, 12 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | # See the License for the specific language governing permissions and 14 | # limitations under the License. 15 | # 16 | # 17 | 18 | from setuptools import setup 19 | 20 | from supercell.version import __version__ 21 | 22 | 23 | tests_require = [ 24 | 'mock', 25 | 'pytest', 26 | 'pytest-cov' 27 | ] 28 | 29 | 30 | extras_require = {} 31 | extras_require['test'] = tests_require 32 | extras_require['futures'] = '' 33 | 34 | 35 | def readme(): 36 | with open('README.rst') as f: 37 | return f.read() 38 | 39 | 40 | setup( 41 | name='supercell', 42 | version=__version__, 43 | 44 | author='Daniel Truemper', 45 | author_email='truemped@gmail.com', 46 | url='http://supercell.readthedocs.org/', 47 | license="http://www.apache.org/licenses/LICENSE-2.0", 48 | 49 | description='Supercell is a framework for creating RESTful APIs that ' + 50 | 'loosely follow the idea of domain driven design.', 51 | long_description=readme(), 52 | packages=['supercell'], 53 | 54 | install_requires=[ 55 | 'tornado >=4.2.1, <6.3', 56 | 'schematics >= 1.1.1' 57 | ], 58 | 59 | tests_require=tests_require, 60 | extras_require=extras_require, 61 | classifiers=[ 62 | 'Development Status :: 4 - Beta', 63 | 'Intended Audience :: Developers', 64 | 'License :: OSI Approved :: Apache Software License', 65 | 'Programming Language :: Python :: 3.13', 66 | 'Programming Language :: Python :: 3.12', 67 | 'Programming Language :: Python :: 3.11', 68 | 'Programming Language :: Python :: 3.10', 69 | 'Programming Language :: Python :: 3.9', 70 | 'Programming Language :: Python :: 3.8', 71 | 'Programming Language :: Python :: 3.7', 72 | 'Programming Language :: Python :: 3.6', 73 | ] 74 | ) 75 | -------------------------------------------------------------------------------- /supercell/__init__.py: -------------------------------------------------------------------------------- 1 | # 2 | # Copyright (c) 2013 Daniel Truemper 3 | # 4 | # Licensed under the Apache License, Version 2.0 (the "License"); 5 | # you may not use this file except in compliance with the License. 6 | # You may obtain a copy of the License at 7 | # 8 | # http://www.apache.org/licenses/LICENSE-2.0 9 | # 10 | # Unless required by applicable law or agreed to in writing, software 11 | # distributed under the License is distributed on an "AS IS" BASIS, 12 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | # See the License for the specific language governing permissions and 14 | # limitations under the License. 15 | # 16 | # 17 | 18 | from supercell.version import __version__ 19 | 20 | __all__ = ['__version__'] 21 | -------------------------------------------------------------------------------- /supercell/_compat.py: -------------------------------------------------------------------------------- 1 | # 2 | # Copyright (c) 2013 Daniel Truemper 3 | # 4 | # Licensed under the Apache License, Version 2.0 (the "License"); 5 | # you may not use this file except in compliance with the License. 6 | # You may obtain a copy of the License at 7 | # 8 | # http://www.apache.org/licenses/LICENSE-2.0 9 | # 10 | # Unless required by applicable law or agreed to in writing, software 11 | # distributed under the License is distributed on an "AS IS" BASIS, 12 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | # See the License for the specific language governing permissions and 14 | # limitations under the License. 15 | # 16 | # 17 | 18 | """ 19 | Compatibility module. 20 | 21 | Heavily inspired by jinja2 and 22 | http://lucumr.pocoo.org/2013/5/21/porting-to-python-3-redux/ 23 | 24 | Also provides a schematics 1.1.1 compatibility helper. 25 | """ 26 | 27 | 28 | def with_metaclass(meta, *bases): 29 | # This requires a bit of explanation: the basic idea is to make a 30 | # dummy metaclass for one level of class instanciation that replaces 31 | # itself with the actual metaclass. Because of internal type checks 32 | # we also need to make sure that we downgrade the custom metaclass 33 | # for one level to something closer to type (that's why __call__ and 34 | # __init__ comes back from type etc.). 35 | # 36 | # This has the advantage over six.with_metaclass in that it does not 37 | # introduce dummy classes into the final MRO. 38 | class metaclass(meta): 39 | __call__ = type.__call__ 40 | __init__ = type.__init__ 41 | 42 | def __new__(cls, name, this_bases, d): 43 | if this_bases is None: 44 | return type.__new__(cls, name, (), d) 45 | return meta(name, bases, d) 46 | 47 | return metaclass('temporary_class', None, {}) 48 | 49 | 50 | def error_messages(schematics_error): 51 | if hasattr(schematics_error, 'to_primitive'): 52 | return schematics_error.to_primitive() 53 | else: 54 | return schematics_error.messages 55 | -------------------------------------------------------------------------------- /supercell/acceptparsing.py: -------------------------------------------------------------------------------- 1 | # 2 | # Taken from: https://gist.github.com/samuraisam/2714195 3 | # 4 | # The author disclaims copyright to this source code. In place of a legal 5 | # notice, here is a blessing: 6 | # 7 | # May you do good and not evil. 8 | # May you find forgiveness for yourself and forgive others. 9 | # May you share freely, never taking more than you give. 10 | # 11 | # It is based on a snipped found in this project: 12 | # https://github.com/martinblech/mimerender 13 | 14 | 15 | def parse_accept_header(accept): 16 | """ 17 | Parse the Accept header *accept*, returning a list with 3-tuples of 18 | [(str(media_type), dict(params), float(q_value)),] ordered by q values. 19 | 20 | If the accept header includes vendor-specific types like:: 21 | 22 | application/vnd.yourcompany.yourproduct-v1.1+json 23 | 24 | It will actually convert the vendor and version into parameters and 25 | convert the content type into `application/json` so appropriate content 26 | negotiation decisions can be made. 27 | 28 | Default `q` for values that are not specified is 1.0 29 | """ 30 | result = [] 31 | 32 | if accept == 'text/*,image/*;application/*;*/*;': 33 | # strange IE6 Accept header that we ignore 34 | return [('*/*', {}, 1.0)] 35 | 36 | for media_range in accept.split(","): 37 | 38 | parts = media_range.split(";") 39 | media_type = parts.pop(0).strip() 40 | media_params = [] 41 | # convert vendor-specific content types into something useful (see 42 | # docstring) 43 | if media_type.find('/') == -1: 44 | result.append(('*/*', {}, 1.0)) 45 | continue 46 | typ, subtyp = media_type.split('/') 47 | # check for a + in the sub-type 48 | if '+' in subtyp: 49 | # if it exists, determine if the subtype is a vendor-specific type 50 | vnd, sep, extra = subtyp.partition('+') 51 | if vnd.startswith('vnd'): 52 | # and then... if it ends in something like "-v1.1" parse the 53 | # version out 54 | if '-v' in vnd: 55 | vnd, sep, rest = vnd.rpartition('-v') 56 | if len(rest): 57 | # add the version as a media param 58 | try: 59 | version = media_params.append(('version', 60 | float(rest))) 61 | except ValueError: 62 | version = 1.0 # could not be parsed 63 | # add the vendor code as a media param 64 | media_params.append(('vendor', vnd.replace('vnd.', ''))) 65 | # and re-write media_type to something like application/json so 66 | # it can be used usefully when looking up emitters 67 | media_type = '/'.join([typ, extra]) 68 | q = 1.0 69 | for part in parts: 70 | (key, value) = part.lstrip().split("=", 1) 71 | key = key.strip() 72 | value = value.strip() 73 | if key == "q": 74 | q = float(value) 75 | else: 76 | media_params.append((key, value)) 77 | result.append((media_type, dict(media_params), q)) 78 | result.sort(key=lambda r: -r[2]) 79 | return result 80 | -------------------------------------------------------------------------------- /supercell/api.py: -------------------------------------------------------------------------------- 1 | # 2 | # Copyright (c) 2013 Daniel Truemper 3 | # 4 | # Licensed under the Apache License, Version 2.0 (the "License"); 5 | # you may not use this file except in compliance with the License. 6 | # You may obtain a copy of the License at 7 | # 8 | # http://www.apache.org/licenses/LICENSE-2.0 9 | # 10 | # Unless required by applicable law or agreed to in writing, software 11 | # distributed under the License is distributed on an "AS IS" BASIS, 12 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | # See the License for the specific language governing permissions and 14 | # limitations under the License. 15 | # 16 | # 17 | 18 | from tornado.gen import coroutine 19 | 20 | from supercell.cache import CacheConfig 21 | from supercell.mediatypes import (ContentType, MediaType, Return, Ok, Error, 22 | OkCreated, NoContent) 23 | from supercell.decorators import provides, consumes 24 | from supercell.health import (HealthCheckOk, HealthCheckWarning, 25 | HealthCheckError) 26 | from supercell.environment import Environment 27 | from supercell.consumer import ConsumerBase, JsonConsumer 28 | from supercell.provider import ProviderBase, JsonProvider 29 | from supercell.requesthandler import RequestHandler 30 | from supercell.service import Service 31 | from supercell.middleware import Middleware 32 | 33 | 34 | __all__ = [ 35 | 'coroutine', 36 | 'consumes', 37 | 'provides', 38 | 'CacheConfig', 39 | 'ContentType', 40 | 'ConsumerBase', 41 | 'Environment', 42 | 'Error', 43 | 'HealthCheckOk', 44 | 'HealthCheckError', 45 | 'HealthCheckWarning', 46 | 'MediaType', 47 | 'NoContent', 48 | 'Ok', 49 | 'OkCreated', 50 | 'ProviderBase', 51 | 'JsonConsumer', 52 | 'JsonProvider', 53 | 'RequestHandler', 54 | 'Return', 55 | 'Service', 56 | 'Middleware' 57 | ] 58 | -------------------------------------------------------------------------------- /supercell/cache.py: -------------------------------------------------------------------------------- 1 | # 2 | # Copyright (c) 2013 Daniel Truemper 3 | # 4 | # Licensed under the Apache License, Version 2.0 (the "License"); 5 | # you may not use this file except in compliance with the License. 6 | # You may obtain a copy of the License at 7 | # 8 | # http://www.apache.org/licenses/LICENSE-2.0 9 | # 10 | # Unless required by applicable law or agreed to in writing, software 11 | # distributed under the License is distributed on an "AS IS" BASIS, 12 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | # See the License for the specific language governing permissions and 14 | # limitations under the License. 15 | # 16 | # 17 | 18 | """Helpers for dealing with HTTP level caching. 19 | 20 | The `Cache-Control` and `Expires` header can be defined while adding a handler 21 | to the environment:: 22 | 23 | class MyService(Service): 24 | 25 | def run(self): 26 | self.environment.add_handler(..., 27 | cache=CacheConfig( 28 | timedelta(minutes=10), 29 | expires=timedelta(minutes=10)) 30 | 31 | The details of setting the `CacheControl` header are documented in the 32 | :func:`CacheConfig`. The `expires` argument simply takes a 33 | :func:`datetime.timedelta` as input and will then generate the `Expires` header 34 | based on the current time and the :func:`datetime.timedelta`. 35 | """ 36 | 37 | from collections import namedtuple 38 | 39 | 40 | __all__ = ['CacheConfig'] 41 | 42 | 43 | CacheConfigT = namedtuple('CacheConfigT', ['max_age', 's_max_age', 'public', 44 | 'private', 'no_cache', 'no_store', 45 | 'must_revalidate', 46 | 'proxy_revalidate']) 47 | 48 | 49 | def CacheConfig(max_age, s_max_age=None, public=False, private=False, 50 | no_cache=False, no_store=False, must_revalidate=True, 51 | proxy_revalidate=False): 52 | """Create a :class:`CacheConfigT` with default values. 53 | :param max_age: Number of seconds the response can be cached 54 | :type max_age: datetime.timedelta 55 | 56 | :param s_max_age: Like `max_age` but only applies to shared caches 57 | :type s_max_age: datetime.timedelta 58 | 59 | :param public: Marks responses as cachable even if they contain 60 | authentication information 61 | :type public: bool 62 | 63 | :param private: Allows the browser to cache the result but not shared 64 | caches 65 | :type private: bool 66 | 67 | :param no_cache: If *True* caches will revalidate the request before 68 | delivering the cached copy 69 | :type no_cache: bool 70 | 71 | :param no_store: Caches should not store any cached copy. 72 | :type no_store: bool 73 | 74 | :param must_revalidate: Tells the cache to not serve stale copies of the 75 | response 76 | :type must_revalidate: bool 77 | 78 | :param proxy_revalidate: Like `must_revalidate` except it only applies to 79 | public caches 80 | :type proxy_revalidate: bool 81 | """ 82 | return CacheConfigT(max_age, s_max_age=s_max_age, public=public, 83 | private=private, no_cache=no_cache, no_store=no_store, 84 | must_revalidate=must_revalidate, 85 | proxy_revalidate=proxy_revalidate) 86 | 87 | 88 | def compute_cache_header(cache_config): 89 | """Compute the cache header for a given :class:`CacheConfigT`. 90 | 91 | :param cache_config: The :class:`CacheConfigT` defining the cache params 92 | :type cache_config: :class:`supercell.api.cache.CacheConfigT` 93 | :return: The computed `Cache-Control` header 94 | :rtype: str 95 | """ 96 | params = [] 97 | params.append('max-age=%s' % cache_config.max_age.seconds) 98 | if cache_config.s_max_age: 99 | params.append('s-max-age=%s' % cache_config.s_max_age.seconds) 100 | if cache_config.public: 101 | params.append('public') 102 | if cache_config.private: 103 | params.append('private') 104 | if cache_config.no_cache: 105 | params.append('no-cache') 106 | if cache_config.no_store: 107 | params.append('no-store') 108 | if cache_config.must_revalidate: 109 | params.append('must-revalidate') 110 | if cache_config.proxy_revalidate: 111 | params.append('proxy-revalidate') 112 | 113 | return ', '.join(params) 114 | -------------------------------------------------------------------------------- /supercell/consumer.py: -------------------------------------------------------------------------------- 1 | # 2 | # Copyright (c) 2013 Daniel Truemper 3 | # 4 | # Licensed under the Apache License, Version 2.0 (the "License"); 5 | # you may not use this file except in compliance with the License. 6 | # You may obtain a copy of the License at 7 | # 8 | # http://www.apache.org/licenses/LICENSE-2.0 9 | # 10 | # Unless required by applicable law or agreed to in writing, software 11 | # distributed under the License is distributed on an "AS IS" BASIS, 12 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | # See the License for the specific language governing permissions and 14 | # limitations under the License. 15 | # 16 | # 17 | 18 | from collections import defaultdict 19 | import json 20 | 21 | from supercell._compat import with_metaclass 22 | from supercell.mediatypes import ContentType, MediaType 23 | from supercell.acceptparsing import parse_accept_header 24 | 25 | 26 | __all__ = ['NoConsumerFound', 'ConsumerBase', 'JsonConsumer'] 27 | 28 | 29 | class NoConsumerFound(Exception): 30 | """Raised if no matching consumer for the client's `Content-Type` header 31 | was found.""" 32 | pass 33 | 34 | 35 | class ConsumerMeta(type): 36 | """Meta class for all consumers. 37 | 38 | This will simply register a consumer with the respective content type 39 | information and make them available in a list of content types and their 40 | consumer. 41 | """ 42 | 43 | KNOWN_CONTENT_TYPES = defaultdict(list) 44 | 45 | def __new__(cls, name, bases, dct): 46 | consumer_class = type.__new__(cls, name, bases, dct) 47 | 48 | if name != 'ConsumerBase' and hasattr(consumer_class, 'CONTENT_TYPE'): 49 | ct = consumer_class.CONTENT_TYPE 50 | ConsumerMeta.KNOWN_CONTENT_TYPES[ct.content_type].append( 51 | (ct, consumer_class)) 52 | 53 | return consumer_class 54 | 55 | 56 | class ConsumerBase(with_metaclass(ConsumerMeta, object)): 57 | """Base class for content type consumers. 58 | 59 | In order to create a new consumer, you must create a new class that 60 | inherits from :py:class:`ConsumerBase` and sets the 61 | :data:`ConsumerBase.CONTENT_TYPE` variable:: 62 | 63 | class MyConsumer(s.ConsumerBase): 64 | 65 | CONTENT_TYPE = s.ContentType('application/xml') 66 | 67 | def consume(self, handler, model): 68 | return model(lxml.from_string(handler.request.body)) 69 | 70 | .. seealso:: :py:mod:`supercell.api.consumer.JsonConsumer.consume` 71 | """ 72 | 73 | CONTENT_TYPE = None 74 | """The target content type for the consumer. 75 | 76 | :type: `supercell.api.ContentType` 77 | """ 78 | 79 | @staticmethod 80 | def map_consumer(content_type, handler): 81 | """Map a given content type to the correct provider implementation. 82 | 83 | If no provider matches, raise a `NoProviderFound` exception. 84 | 85 | :param accept_header: HTTP Accept header value 86 | :type accept_header: str 87 | :param handler: supercell request handler 88 | 89 | :raises: :exc:`NoConsumerFound` 90 | """ 91 | accept = parse_accept_header(content_type) 92 | if len(accept) == 0: 93 | raise NoConsumerFound() 94 | 95 | (ctype, params, q) = accept[0] 96 | 97 | if ctype not in handler._CONS_CONTENT_TYPES: 98 | raise NoConsumerFound() 99 | 100 | c = ContentType(ctype, vendor=params.get('vendor', None), 101 | version=params.get('version', None)) 102 | if c not in handler._CONS_CONTENT_TYPES[ctype]: 103 | raise NoConsumerFound() 104 | 105 | known_types = [t for t in ConsumerMeta.KNOWN_CONTENT_TYPES[ctype] 106 | if t[0] == c] 107 | 108 | if len(known_types) == 1: 109 | return (handler._CONS_MODEL[c], known_types[0][1]) 110 | 111 | raise NoConsumerFound() 112 | 113 | def consume(self, handler, model): 114 | """This method should return the correct representation as a parsed 115 | model. 116 | 117 | :param model: the model to convert to a certain content type 118 | :type model: :class:`schematics.models.Model` 119 | """ 120 | raise NotImplementedError 121 | 122 | 123 | class JsonConsumer(ConsumerBase): 124 | """Default **application/json** consumer.""" 125 | 126 | CONTENT_TYPE = ContentType(MediaType.ApplicationJson) 127 | """The **application/json** :class:`ContentType`.""" 128 | 129 | def consume(self, handler, model): 130 | """Parse the body json via :func:`json.loads` and initialize the 131 | `model`. 132 | 133 | .. seealso:: :py:mod:`supercell.api.provider.ProviderBase.provide` 134 | """ 135 | # TODO error if no request body is set 136 | return model(json.loads(handler.request.body.decode('utf8'))) 137 | 138 | 139 | class JsonPatchConsumer(JsonConsumer): 140 | """Default **application/json-patch+json** consumer.""" 141 | 142 | CONTENT_TYPE = ContentType(MediaType.ApplicationJsonPatch) 143 | """The **application/json-patch+json** :class:`ContentType`.""" 144 | -------------------------------------------------------------------------------- /supercell/decorators.py: -------------------------------------------------------------------------------- 1 | # 2 | # Copyright (c) 2013 Daniel Truemper 3 | # 4 | # Licensed under the Apache License, Version 2.0 (the "License"); 5 | # you may not use this file except in compliance with the License. 6 | # You may obtain a copy of the License at 7 | # 8 | # http://www.apache.org/licenses/LICENSE-2.0 9 | # 10 | # Unless required by applicable law or agreed to in writing, software 11 | # distributed under the License is distributed on an "AS IS" BASIS, 12 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | # See the License for the specific language governing permissions and 14 | # limitations under the License. 15 | # 16 | # 17 | """Several decorators for using with :class:`supercell.api.RequestHandler` 18 | implementations. 19 | """ 20 | 21 | from collections import defaultdict 22 | 23 | from supercell.mediatypes import ContentType 24 | 25 | 26 | def provides(content_type, vendor=None, version=None, default=False, 27 | partial=False): 28 | """Class decorator for mapping HTTP GET responses to content types and 29 | their representation. 30 | 31 | In order to allow the **application/json** content type, create the handler 32 | class like this:: 33 | 34 | @s.provides(s.MediaType.ApplicationJson) 35 | class MyHandler(s.RequestHandler): 36 | pass 37 | 38 | It is also possible to support more than one content type. The content type 39 | selection is then based on the client **Accept** header. If this is not 40 | present, ordering of the :func:`provides` decorators matter, i.e. the first 41 | content type is used:: 42 | 43 | @s.provides(s.MediaType.ApplicationJson) 44 | class MyHandler(s.RequestHandler): 45 | ... 46 | 47 | :param str content_type: The base content type such as **application/json** 48 | :param str vendor: Any vendor information for the base content type 49 | :param float version: The vendor version 50 | :param bool default: If **True** and no **Accept** header is present, this 51 | content type is provided 52 | :param bool partial: If **True**, the provider can return partial 53 | representations, i.e. the underlying model validates 54 | even though required fields are missing. 55 | """ 56 | 57 | def wrapper(cls): 58 | """The real class decorator.""" 59 | assert isinstance(cls, type), 'This decorator may only be used as ' + \ 60 | 'class decorator' 61 | 62 | if not hasattr(cls, '_PROD_CONTENT_TYPES'): 63 | cls._PROD_CONTENT_TYPES = defaultdict(list) 64 | if not hasattr(cls, '_PROD_CONFIGURATION'): 65 | cls._PROD_CONFIGURATION = defaultdict(dict) 66 | 67 | ctype = ContentType(content_type, 68 | vendor, 69 | version) 70 | cls._PROD_CONTENT_TYPES[content_type].append(ctype) 71 | cls._PROD_CONFIGURATION[content_type]['partial'] = partial 72 | if default: 73 | assert 'default' not in cls._PROD_CONTENT_TYPES, 'TODO: nice msg' 74 | cls._PROD_CONTENT_TYPES['*/*'] = [ctype] 75 | cls._PROD_CONFIGURATION['*/*']['partial'] = partial 76 | 77 | return cls 78 | 79 | return wrapper 80 | 81 | 82 | def consumes(content_type, model, vendor=None, version=None, validate=True): 83 | """Class decorator for mapping HTTP POST and PUT bodies to 84 | 85 | Example:: 86 | 87 | @s.consumes(s.MediaType.ApplicationJson, model=Model) 88 | class MyHandler(s.RequestHandler): 89 | 90 | def post(self, *args, **kwargs): 91 | # ... 92 | raise s.OkCreated() 93 | 94 | :param str content_type: The base content type such as **application/json** 95 | :param model: The model that should be consumed. 96 | :type model: :class:`schematics.models.Model` 97 | :param str vendor: Any vendor information for the base content type 98 | :param float version: The vendor version 99 | :param bool validate: Whether to validate the consumed model 100 | """ 101 | 102 | def wrapper(cls): 103 | """The real decorator.""" 104 | assert isinstance(cls, type), 'This decorator may only be used as ' + \ 105 | 'class decorator' 106 | assert model, 'In order to consume content a schematics model ' + \ 107 | 'class has to be given via the model parameter' 108 | 109 | if not hasattr(cls, '_CONS_CONTENT_TYPES'): 110 | cls._CONS_CONTENT_TYPES = defaultdict(list) 111 | if not hasattr(cls, '_CONS_MODEL'): 112 | cls._CONS_MODEL = dict() 113 | 114 | ct = ContentType(content_type, vendor, version) 115 | cls._CONS_CONTENT_TYPES[content_type].append(ct) 116 | cls._CONS_MODEL[ct] = (model, validate) 117 | return cls 118 | 119 | return wrapper 120 | -------------------------------------------------------------------------------- /supercell/environment.py: -------------------------------------------------------------------------------- 1 | # 2 | # Copyright (c) 2013 Daniel Truemper 3 | # 4 | # Licensed under the Apache License, Version 2.0 (the "License"); 5 | # you may not use this file except in compliance with the License. 6 | # You may obtain a copy of the License at 7 | # 8 | # http://www.apache.org/licenses/LICENSE-2.0 9 | # 10 | # Unless required by applicable law or agreed to in writing, software 11 | # distributed under the License is distributed on an "AS IS" BASIS, 12 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | # See the License for the specific language governing permissions and 14 | # limitations under the License. 15 | # 16 | # 17 | 18 | """The :class:`Environment` is a container for request handlers, managed 19 | objects and other runtime settings as well as the 20 | :class:`tornado.web.Application` settings. 21 | 22 | There are two cases where you will need to work with the it: during the 23 | bootstrapping phase you may change paths to look for configuration files and 24 | you will add the request handlers to the environment. In addition to that you 25 | can also use it from within a request handler in and access managed objects, 26 | such as HTTP clients that can be used accross a number of client libraries for 27 | connection pooling, e.g. 28 | """ 29 | 30 | from collections import namedtuple 31 | from datetime import timedelta 32 | 33 | from tornado.web import Application as _TAPP 34 | 35 | from supercell.cache import CacheConfigT 36 | from supercell.health import SystemHealthCheck 37 | 38 | __all__ = ['Environment'] 39 | 40 | 41 | Handler = namedtuple('Handler', ['host_pattern', 'path', 'handler_class', 42 | 'init_dict', 'name', 'cache', 'expires']) 43 | 44 | 45 | class Application(_TAPP): 46 | """Overwrite :class:`tornado.web.Application` in order to give access to 47 | environment and configuration instances.""" 48 | 49 | def __init__(self, environment, config, *args, **kwargs): 50 | """Initialize the tornado Application and add the config and 51 | environment. 52 | """ 53 | self.environment = environment 54 | self.config = config 55 | super().__init__(*args, **kwargs) 56 | 57 | def log_request(self, handler): 58 | """ 59 | suppress non-error logs for system health check handlers in case 60 | the command-line parameter --suppress_health_check_log has been set. 61 | """ 62 | if self.config is not None and \ 63 | self.config.suppress_health_check_log and \ 64 | isinstance(handler, SystemHealthCheck) and \ 65 | handler.get_status() < 400: 66 | return 67 | super().log_request(handler) 68 | 69 | 70 | class Environment: 71 | """Environment for **supercell** processes. 72 | """ 73 | 74 | def __init__(self): 75 | """Initialize the handlers and health checks variables.""" 76 | self._handlers = [] 77 | self._cache_infos = {} 78 | self._expires_infos = {} 79 | self._managed_objects = {} 80 | self._health_checks = {} 81 | self._finalized = False 82 | 83 | def add_handler(self, path, handler_class, init_dict=None, name=None, 84 | host_pattern='.*$', cache=None, expires=None): 85 | """Add a handler to the :class:`tornado.web.Application`. 86 | 87 | The environment will manage the available request handlers and managed 88 | objects. So in the :py:func:`Service.run()` method you will add the 89 | handlers:: 90 | 91 | class MyService(s.Service): 92 | 93 | def run(): 94 | self.environment.add_handler('/', Handler) 95 | 96 | :param path: The regular expression for the URL path the handler should 97 | be bound to. 98 | :type path: str or re.pattern 99 | 100 | :param handler_class: The request handler class 101 | :type handler_class: supercell.api.RequestHandler 102 | 103 | :param init_dict: The initialization dict that is passed to the 104 | `RequestHandler.initialize()` method. 105 | :type init_dict: dict 106 | 107 | :param name: If set the handler and its URL will be available in the 108 | `RequestHandler.reverse_url()` method. 109 | :type name: str 110 | 111 | :param host_pattern: A regular expression for matching the hostname the 112 | handler will be bound to. By default this will 113 | match all hosts ('.*$') 114 | :type host_pattern: str 115 | 116 | :param cache: Cache info for GET and HEAD requests to this handler 117 | defined by :class:`supercell.api.cache.CacheConfig`. 118 | :type cache: supercell.api.cache.CacheConfig 119 | 120 | :param expires: Set the `Expires` header according to the provided 121 | timedelta 122 | :type expires: datetime.timedelta 123 | """ 124 | assert not self._finalized, 'Do not change the environment at runtime' 125 | handler = Handler(host_pattern=host_pattern, path=path, 126 | handler_class=handler_class, init_dict=init_dict, 127 | name=name, cache=cache, expires=expires) 128 | self._handlers.append(handler) 129 | if cache: 130 | assert isinstance(cache, CacheConfigT), 'cache not a CacheConfig' 131 | self._cache_infos[handler_class] = cache 132 | if expires: 133 | assert isinstance(expires, timedelta), 'expires not a timedelta' 134 | self._expires_infos[handler_class] = expires 135 | 136 | def add_managed_object(self, name, instance): 137 | """Add a managed instance to the environment. 138 | 139 | A managed object is identified by a name and you can then access it 140 | from the environment as an attribute, so in your request handler you 141 | may:: 142 | 143 | class MyService(s.Service): 144 | 145 | def run(self): 146 | managed = HeavyObjectFactory.get_heavy_object() 147 | self.environment.add_managed_object('managed', managed) 148 | 149 | class MyHandler(s.RequestHandler): 150 | 151 | def get(self): 152 | self.environment.managed 153 | 154 | :param name: The managed object identifier 155 | :type name: str 156 | 157 | :param instance: Some arbitrary instance 158 | :type instance: object 159 | """ 160 | assert not self._finalized 161 | assert name not in self._managed_objects 162 | self._managed_objects[name] = instance 163 | 164 | def _finalize(self): 165 | """When called it is not possible to add more managed objects. 166 | 167 | When the `Service.main()` method starts, it will call `_finalize()` 168 | in order to not be able to change the environment with respect to 169 | managed objects and request handlers.""" 170 | self._finalized = True 171 | 172 | def __getattr__(self, name): 173 | """Retrieve a managed object from `self._managed_objects`.""" 174 | if name not in self._managed_objects: 175 | raise AttributeError('%s not a managed object' % name) 176 | return self._managed_objects[name] 177 | 178 | def add_health_check(self, name, check): 179 | """Add a health check to the API. 180 | 181 | :param name: The name for the health check to add 182 | :type name: str 183 | 184 | :param check: The request handler performing the health check 185 | :type check: A :class:`supercell.api.RequestHandler` 186 | """ 187 | assert not self._finalized 188 | assert name not in self._health_checks 189 | self._health_checks[name] = check 190 | 191 | @property 192 | def health_checks(self): 193 | """Simple property access for health checks.""" 194 | return self._health_checks 195 | 196 | @property 197 | def config_file_paths(self): 198 | """The list containing all paths to look for config files. 199 | 200 | In order to manipulate the paths looked for configuration files just 201 | manipulate this list:: 202 | 203 | class MyService(s.Service): 204 | 205 | def bootstrap(self): 206 | self.environment.config_file_paths.append( 207 | '/etc/myservice/') 208 | self.environment.config_file_paths.append('./etc/') 209 | """ 210 | if not hasattr(self, '_config_file_paths'): 211 | self._config_file_paths = [] 212 | return self._config_file_paths 213 | 214 | @property 215 | def tornado_settings(self): 216 | """The dictionary passed to the :class:`tornado.web.Application` 217 | containing all relevant tornado server settings.""" 218 | if not hasattr(self, '_tornado_settings'): 219 | self._tornado_settings = {} 220 | return self._tornado_settings 221 | 222 | def get_application(self, config=None): 223 | """Create the tornado application. 224 | 225 | :param config: The configuration that will be added to the app 226 | """ 227 | if not hasattr(self, '_app'): 228 | self._app = Application(self, config, 229 | **self.tornado_settings) 230 | 231 | # add the default health check 232 | self._app.add_handlers('.*', [('/_system/check', 233 | SystemHealthCheck)]) 234 | 235 | # add the custom health checks 236 | for check_name in self.health_checks: 237 | check = self.health_checks[check_name] 238 | self._app.add_handlers('.*', [ 239 | ('/_system/check/%s' % check_name, check)]) 240 | 241 | for handler in self._handlers: 242 | if handler.init_dict: 243 | spec = (handler.path, handler.handler_class, 244 | handler.init_dict) 245 | else: 246 | spec = (handler.path, handler.handler_class) 247 | 248 | self._app.add_handlers(handler.host_pattern, [spec]) 249 | 250 | return self._app 251 | 252 | def get_cache_info(self, handler): 253 | """Return the :class:`supercell.api.cache.CacheConfig` for a certain 254 | handler.""" 255 | return self._cache_infos.get(handler, None) 256 | 257 | def get_expires_info(self, handler): 258 | """Return the `timedelta` for a specific handler that should define the 259 | `Expires` header for GET and HEAD requests.""" 260 | return self._expires_infos.get(handler, None) 261 | 262 | @property 263 | def config_name(self): 264 | """Determine the configuration file name for the machine this 265 | application is running on. 266 | 267 | The filenames are generated using a combination of username and 268 | machine name. If you deploy the application as user **webapp** on host 269 | **fe01.local.dev** the configuration name would be 270 | **webapp_fe01.local.dev.cfg**. 271 | """ 272 | if not hasattr(self, '_config_name'): 273 | import getpass 274 | import socket 275 | self._config_name = '{}_{}.cfg'.format(getpass.getuser(), 276 | socket.gethostname()) 277 | return self._config_name 278 | -------------------------------------------------------------------------------- /supercell/health.py: -------------------------------------------------------------------------------- 1 | # 2 | # Copyright (c) 2013 Daniel Truemper 3 | # 4 | # Licensed under the Apache License, Version 2.0 (the "License"); 5 | # you may not use this file except in compliance with the License. 6 | # You may obtain a copy of the License at 7 | # 8 | # http://www.apache.org/licenses/LICENSE-2.0 9 | # 10 | # Unless required by applicable law or agreed to in writing, software 11 | # distributed under the License is distributed on an "AS IS" BASIS, 12 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | # See the License for the specific language governing permissions and 14 | # limitations under the License. 15 | # 16 | # 17 | 18 | """Health checks provide a way for the hoster to check if the application is 19 | still running and working as expected. 20 | 21 | The most basic health check is enabled by default on the */_system/check* 22 | route. This is a very simple check if the process is running and provides no 23 | details to your application checks. 24 | 25 | To demonstrate the idea we will describe a simple health check perfoming a 26 | request to a HTTP resource and return the appropriate result:: 27 | 28 | @s.provides(s.MediaType.ApplicationJson, default=True) 29 | class SimpleHttpResourceCheck(s.RequestHandler): 30 | 31 | @s.async 32 | def get(self): 33 | result = self.environment.http_resource.ping() 34 | if result.code == 200: 35 | raise s.HealthCheckOk() 36 | if result.code == 599: 37 | raise s.HealthCheckError() 38 | 39 | To enable this health check simply add this to the environment in your services 40 | run method:: 41 | 42 | class MyService(s.Service): 43 | 44 | def run(self): 45 | self.environment.add_health_check('http_resource', 46 | SimpleHttpResourceCheck) 47 | 48 | When the service is then started you can access the check as 49 | */_system/check/http_resource*:: 50 | 51 | $ curl 'http://127.0.0.1/_system/check/http_resource' 52 | {"code": "OK", "ok": true} 53 | 54 | The HTTP response code will be **200** when everything is ok. Any error, 55 | **WARNING** or **ERROR** will return the HTTP code **500**. A warning will 56 | return the response:: 57 | 58 | $ curl 'http://127.0.0.1/_system/check/http_resource_with_warning' 59 | {"code": "WARNING", "error": true} 60 | 61 | and an error a similar one:: 62 | 63 | $ curl 'http://127.0.0.1/_system/check/http_resource_with_warning' 64 | {"code": "ERROR", "error": true} 65 | """ 66 | 67 | from tornado.gen import coroutine 68 | 69 | from supercell.decorators import provides 70 | from supercell.mediatypes import Ok, Error, MediaType 71 | from supercell.requesthandler import RequestHandler 72 | 73 | 74 | class HealthCheckOk(Ok): 75 | """Exception for health checks return values indicating a **OK** 76 | health check.""" 77 | 78 | def __init__(self, additional=None): 79 | """Initialize the health checks response.""" 80 | additional = additional or {} 81 | additional['code'] = 'OK' 82 | super().__init__(code=200, additional=additional) 83 | 84 | 85 | class HealthCheckWarning(Error): 86 | """Exception for health checks return values indicating a 87 | **WARNING** health check. 88 | 89 | The **WARNING** state indicates a problem that is not critical to the 90 | application. This could involve things like long response and similar 91 | problems.""" 92 | 93 | def __init__(self, additional=None): 94 | """Initialize the health checks response.""" 95 | additional = additional or {} 96 | additional['code'] = 'WARNING' 97 | super().__init__(code=500, additional=additional) 98 | 99 | 100 | class HealthCheckError(Error): 101 | """Exception for health checks return values indicating a 102 | **ERROR** health check. 103 | 104 | The **ERROR** state indicates a major problem like a failed connection to a 105 | database.""" 106 | 107 | def __init__(self, additional=None): 108 | """Initialize the health checks response.""" 109 | additional = additional or {} 110 | additional['code'] = 'ERROR' 111 | super().__init__(code=500, additional=additional) 112 | 113 | 114 | @provides(MediaType.ApplicationJson, default=True) 115 | class SystemHealthCheck(RequestHandler): 116 | """The default system health check. 117 | 118 | This check is returning this JSON:: 119 | 120 | {"message": "API running", "code": "OK", "ok": true} 121 | 122 | and its primiary use is to check if the process is still running and 123 | working as expected. If this request takes too long to respond, and all 124 | other systems are working correctly, you probably need to create more 125 | instances of the service since the current number of processes cannot 126 | deal with the number of requests coming from the outside. 127 | """ 128 | 129 | @coroutine 130 | def get(self): 131 | """Run the default **/_system** healthcheck and return it's result.""" 132 | raise HealthCheckOk(additional={'message': 'API running'}) 133 | -------------------------------------------------------------------------------- /supercell/logging.py: -------------------------------------------------------------------------------- 1 | # 2 | # Copyright (c) 2013 Daniel Truemper 3 | # 4 | # Licensed under the Apache License, Version 2.0 (the "License"); 5 | # you may not use this file except in compliance with the License. 6 | # You may obtain a copy of the License at 7 | # 8 | # http://www.apache.org/licenses/LICENSE-2.0 9 | # 10 | # Unless required by applicable law or agreed to in writing, software 11 | # distributed under the License is distributed on an "AS IS" BASIS, 12 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | # See the License for the specific language governing permissions and 14 | # limitations under the License. 15 | # 16 | # 17 | 18 | import logging 19 | from logging.handlers import TimedRotatingFileHandler 20 | import os 21 | import socket 22 | 23 | 24 | class SupercellLoggingHandler(TimedRotatingFileHandler): 25 | """Logging handler for :mod:`supercell` applications. 26 | """ 27 | 28 | def __init__(self, logfile): 29 | """Initialize the :class:`TimedRotatingFileHandler`.""" 30 | logfile = logfile % {'pid': os.getpid()} 31 | TimedRotatingFileHandler.__init__(self, logfile, when='d', 32 | interval=1, backupCount=10) 33 | 34 | 35 | class HostnameFormatter(logging.Formatter): 36 | """ 37 | Formatter that adds a hostname field to the LogRecord 38 | """ 39 | def format(self, record): 40 | record.hostname = socket.gethostname() 41 | record = super().format(record) 42 | return record 43 | -------------------------------------------------------------------------------- /supercell/mediatypes.py: -------------------------------------------------------------------------------- 1 | # 2 | # Copyright (c) 2013 Daniel Truemper 3 | # 4 | # Licensed under the Apache License, Version 2.0 (the "License"); 5 | # you may not use this file except in compliance with the License. 6 | # You may obtain a copy of the License at 7 | # 8 | # http://www.apache.org/licenses/LICENSE-2.0 9 | # 10 | # Unless required by applicable law or agreed to in writing, software 11 | # distributed under the License is distributed on an "AS IS" BASIS, 12 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | # See the License for the specific language governing permissions and 14 | # limitations under the License. 15 | # 16 | # 17 | 18 | from collections import namedtuple 19 | 20 | from tornado import gen 21 | 22 | 23 | ContentTypeT = namedtuple('ContentType', ['content_type', 'vendor', 24 | 'version']) 25 | 26 | 27 | def ContentType(content_type, vendor=None, version=None): 28 | if version: 29 | assert isinstance(version, float), 'Version must be a float' 30 | return ContentTypeT(content_type, vendor, version) 31 | 32 | 33 | class MediaType: 34 | """Collection of content types.""" 35 | 36 | ApplicationJson = 'application/json' 37 | """Content type for `application/json`""" 38 | 39 | ApplicationJsonPatch = 'application/json-patch+json' 40 | """Content type for `application/json-patch+json`""" 41 | 42 | TextHtml = 'text/html' 43 | """Content type for `text/html`""" 44 | 45 | 46 | ReturnInformationT = namedtuple('ReturnInformation', ['code', 'message']) 47 | 48 | 49 | def ReturnInformation(code, message=None): 50 | return ReturnInformationT(code, message=message) 51 | 52 | 53 | class Return(gen.Return): 54 | pass 55 | 56 | 57 | class Ok(Return): 58 | 59 | def __init__(self, code=200, additional=None): 60 | v = {'ok': True} 61 | if additional: 62 | assert isinstance(additional, dict), 'Additional messages must ' +\ 63 | 'be of type dict' 64 | v.update(additional) 65 | 66 | super().__init__(ReturnInformation(code, message=v)) 67 | 68 | 69 | class OkCreated(Ok): 70 | 71 | def __init__(self, additional=None): 72 | super().__init__(201, additional=additional) 73 | 74 | 75 | class NoContent(Return): 76 | 77 | def __init__(self): 78 | super().__init__(ReturnInformation(204)) 79 | 80 | 81 | class Error(Return): 82 | 83 | def __init__(self, code=400, additional=None): 84 | v = {'error': True} 85 | if additional: 86 | assert isinstance(additional, dict), 'Additional messages must ' +\ 87 | 'be of type dict' 88 | v.update(additional) 89 | 90 | super().__init__(ReturnInformation(code, message=v)) 91 | -------------------------------------------------------------------------------- /supercell/middleware.py: -------------------------------------------------------------------------------- 1 | # 2 | # Copyright (c) 2014 Daniel Truemper 3 | # 4 | # Licensed under the Apache License, Version 2.0 (the "License"); 5 | # you may not use this file except in compliance with the License. 6 | # You may obtain a copy of the License at 7 | # 8 | # http://www.apache.org/licenses/LICENSE-2.0 9 | # 10 | # Unless required by applicable law or agreed to in writing, software 11 | # distributed under the License is distributed on an "AS IS" BASIS, 12 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | # See the License for the specific language governing permissions and 14 | # limitations under the License. 15 | # 16 | # 17 | 18 | from abc import ABCMeta, abstractmethod 19 | from functools import wraps 20 | 21 | from schematics.models import Model 22 | from tornado.gen import coroutine, Return 23 | 24 | from supercell._compat import with_metaclass 25 | from supercell.mediatypes import ReturnInformationT 26 | 27 | 28 | class Middleware(with_metaclass(ABCMeta, object)): 29 | """Base class for middleware implementations. 30 | 31 | Each request handler is assigned a list of `Middleware` implementations. 32 | Before a handler is called, each middleware is executed using the 33 | `Middleware.before` method. When the underlying handler is finished, the 34 | `Middleware.after` method may manipulate the result. 35 | """ 36 | 37 | def __init__(self, *args, **kwargs): 38 | """Initialize the decorator and register the `after()` callback.""" 39 | pass 40 | 41 | def __call__(self, fn): 42 | """Call the `before()` method and then the decorated method. If this 43 | returns a `Future`, add the `after()` method as a `done` callback. 44 | Otherwise execute it immediately. 45 | """ 46 | 47 | @coroutine 48 | @wraps(fn) 49 | def before(other, *args, **kwargs): 50 | before_result = yield self.before(other, args, kwargs) 51 | 52 | if isinstance(before_result, (ReturnInformationT, Model)): 53 | raise Return(before_result) 54 | 55 | result = yield fn(other, *args, **kwargs) 56 | 57 | after_result = yield self.after(other, args, kwargs, result) 58 | 59 | if isinstance(after_result, (ReturnInformationT, Model)): 60 | raise Return(after_result) 61 | 62 | raise Return(result) 63 | 64 | return before 65 | 66 | @abstractmethod 67 | def before(self, handler, args, kwargs): 68 | """Method executed before the underlying request handler is called.""" 69 | 70 | @abstractmethod 71 | def after(self, handler, args, kwargs, result): 72 | """Method executed after the unterlying request handler ist called.""" 73 | -------------------------------------------------------------------------------- /supercell/provider.py: -------------------------------------------------------------------------------- 1 | # 2 | # Copyright (c) 2013 Daniel Truemper 3 | # 4 | # Licensed under the Apache License, Version 2.0 (the "License"); 5 | # you may not use this file except in compliance with the License. 6 | # You may obtain a copy of the License at 7 | # 8 | # http://www.apache.org/licenses/LICENSE-2.0 9 | # 10 | # Unless required by applicable law or agreed to in writing, software 11 | # distributed under the License is distributed on an "AS IS" BASIS, 12 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | # See the License for the specific language governing permissions and 14 | # limitations under the License. 15 | # 16 | # 17 | 18 | import json 19 | from collections import defaultdict 20 | from schematics.exceptions import ModelValidationError 21 | from tornado.web import HTTPError 22 | from tornado import escape 23 | 24 | from supercell._compat import with_metaclass, error_messages 25 | from supercell.mediatypes import ContentType, MediaType 26 | from supercell.acceptparsing import parse_accept_header 27 | from supercell.utils import escape_contents 28 | 29 | __all__ = ['NoProviderFound', 'ProviderBase', 'JsonProvider'] 30 | 31 | 32 | class NoProviderFound(Exception): 33 | """Raised if no matching provider for the client's `Accept` header was 34 | found.""" 35 | pass 36 | 37 | 38 | class ProviderMeta(type): 39 | """Meta class for all content type providers. 40 | 41 | This will simply register a provider with the respective content type 42 | information and make them available in a list of content types and their 43 | mappers. 44 | """ 45 | 46 | KNOWN_CONTENT_TYPES = defaultdict(list) 47 | 48 | def __new__(cls, name, bases, dct): 49 | provider_class = type.__new__(cls, name, bases, dct) 50 | 51 | if name != 'ProviderBase' and hasattr(provider_class, 'CONTENT_TYPE'): 52 | ct = provider_class.CONTENT_TYPE 53 | ProviderMeta.KNOWN_CONTENT_TYPES[ct.content_type].append( 54 | (ct, provider_class)) 55 | 56 | return provider_class 57 | 58 | 59 | class ProviderBase(with_metaclass(ProviderMeta, object)): 60 | """Base class for content type providers. 61 | 62 | Creating a new provider is just as simple as creating new consumers:: 63 | 64 | class MyProvider(s.ProviderBase): 65 | 66 | CONTENT_TYPE = s.ContentType('application/xml') 67 | 68 | def provide(self, model, handler): 69 | self.set_header('Content-Type', 'application/xml') 70 | handler.write(model.to_xml()) 71 | """ 72 | 73 | CONTENT_TYPE = None 74 | """The target content type for the provider. 75 | 76 | :type: `supercell.api.ContentType` 77 | """ 78 | 79 | @staticmethod 80 | def has_provider(handler): 81 | """ 82 | Find out if any provider decorator is used in a given handler. 83 | 84 | :param handler: supercell request handler 85 | :return: true if any provider is used, else false 86 | """ 87 | return hasattr(handler, '_PROD_CONTENT_TYPES') 88 | 89 | @staticmethod 90 | def map_provider(accept_header, handler, allow_default=False): 91 | """Map a given content type to the correct provider implementation. 92 | 93 | If no provider matches, raise a `NoProviderFound` exception. 94 | 95 | :param accept_header: HTTP Accept header value 96 | :type accept_header: str 97 | :param handler: supercell request handler 98 | :param allow_default: allow usage of default provider if no accept 99 | header is set, default is False 100 | :type allow_default: bool 101 | :raises: :exc:`NoProviderFound` 102 | 103 | :return: A tuple of the matching provider implementation class and 104 | the provide()-kwargs 105 | :rtype: (supercell.api.provider.ProviderBase, dict) 106 | """ 107 | if not hasattr(handler, '_PROD_CONTENT_TYPES'): 108 | raise NoProviderFound() 109 | 110 | accept = parse_accept_header(accept_header) 111 | 112 | for (ctype, params, q) in accept: 113 | if ctype not in handler._PROD_CONTENT_TYPES: 114 | continue 115 | 116 | if ctype == '*/*': 117 | if not allow_default: 118 | continue 119 | c = handler._PROD_CONTENT_TYPES[ctype][0] 120 | else: 121 | c = ContentType(ctype, vendor=params.get('vendor', None), 122 | version=params.get('version', None)) 123 | 124 | if c not in handler._PROD_CONTENT_TYPES[c.content_type]: 125 | continue 126 | 127 | known_types = [t for t in 128 | ProviderMeta.KNOWN_CONTENT_TYPES[c.content_type] 129 | if t[0] == c] 130 | 131 | configuration = handler._PROD_CONFIGURATION[ctype] 132 | if len(known_types) == 1: 133 | return (known_types[0][1], configuration) 134 | 135 | raise NoProviderFound() 136 | 137 | def provide(self, model, handler, **kwargs): 138 | """This method should return the correct representation as a simple 139 | string (i.e. byte buffer) that will be used as return value. 140 | 141 | :param model: the model to convert to a certain content type 142 | :type model: supercell.schematics.Model 143 | :param handler: the handler to write the return 144 | :type handler: supercell.requesthandler.RequestHandler 145 | """ 146 | raise NotImplementedError 147 | 148 | def error(self, status_code, message, handler): 149 | """This method should return the correct representation of errors 150 | that will be used as return value. 151 | 152 | :param status_code: the HTTP status code to return 153 | :type status_code: int 154 | :param message: the error message to return 155 | :type message: str 156 | :param handler: the handler to write the return 157 | :type handler: supercell.requesthandler.RequestHandler 158 | """ 159 | raise NotImplementedError 160 | 161 | 162 | class JsonProvider(ProviderBase): 163 | """Default `application/json` provider.""" 164 | 165 | CONTENT_TYPE = ContentType(MediaType.ApplicationJson) 166 | 167 | def provide(self, model, handler, **kwargs): 168 | """Simply return the json via `json.dumps`. 169 | 170 | Keyword arguments: 171 | :param partial: if **True** the model will be validate as a partial. 172 | :type partial: bool 173 | 174 | .. seealso:: :py:mod:`supercell.api.provider.ProviderBase.provide` 175 | """ 176 | try: 177 | partial = kwargs.get("partial", False) 178 | model.validate(partial=partial) 179 | handler.write(model.to_primitive()) 180 | except ModelValidationError as e: 181 | raise HTTPError(500, reason=json.dumps({ 182 | "result_model": escape_contents(error_messages(e)) 183 | })) 184 | 185 | def error(self, status_code, message, handler): 186 | """Simply return errors in json. 187 | 188 | .. seealso:: :py:mod:`supercell.api.provider.ProviderBase.error` 189 | """ 190 | try: 191 | message = json.loads(message) 192 | except ValueError: 193 | pass 194 | 195 | res = {"message": message, 196 | "error": True} 197 | handler.set_header('Content-Type', MediaType.ApplicationJson) 198 | handler.finish(escape.json_encode(res)) 199 | 200 | 201 | class TornadoTemplateProvider(ProviderBase): 202 | """Default provider for `text/html`.""" 203 | 204 | CONTENT_TYPE = ContentType(MediaType.TextHtml) 205 | 206 | def provide(self, model, handler, **kwargs): 207 | """Render a template with the given model into HTML. 208 | 209 | By default we will use the tornado built in template language.""" 210 | try: 211 | model.validate() 212 | handler.render(handler.get_template(model), **model.to_primitive()) 213 | except ModelValidationError as e: 214 | raise HTTPError(500, reason=json.dumps({ 215 | "result_model": escape_contents(error_messages(e)) 216 | })) 217 | 218 | def error(self, status_code, message, handler): 219 | """Simply return errors in html 220 | 221 | .. seealso:: :py:mod:`supercell.api.provider.ProviderBase.error` 222 | """ 223 | handler.finish("%(code)d: %(message)s" 224 | "%(code)d: %(message)s" % { 225 | "code": status_code, 226 | "message": message, 227 | }) 228 | -------------------------------------------------------------------------------- /supercell/queryparam.py: -------------------------------------------------------------------------------- 1 | # 2 | # Copyright (c) 2014 Daniel Truemper 3 | # 4 | # Licensed under the Apache License, Version 2.0 (the "License"); 5 | # you may not use this file except in compliance with the License. 6 | # You may obtain a copy of the License at 7 | # 8 | # http://www.apache.org/licenses/LICENSE-2.0 9 | # 10 | # Unless required by applicable law or agreed to in writing, software 11 | # distributed under the License is distributed on an "AS IS" BASIS, 12 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | # See the License for the specific language governing permissions and 14 | # limitations under the License. 15 | # 16 | # 17 | """Simple decorator for dealing with typed query parameters.""" 18 | 19 | from schematics.exceptions import ConversionError, ValidationError 20 | 21 | import supercell.api as s 22 | from supercell._compat import error_messages 23 | 24 | 25 | class QueryParams(s.Middleware): 26 | """Simple middleware for ensuring types in query parameters. 27 | 28 | A simple example:: 29 | 30 | @QueryParams(( 31 | ('limit', IntType()), 32 | ('q', StringType()) 33 | ) 34 | ) 35 | @s.async 36 | def get(self, *args, **kwargs): 37 | limit = kwargs.get('limit', 0) 38 | q = kwargs.get('q', None) 39 | ... 40 | 41 | If a param is required, simply set the `required` property for the 42 | schematics type definition:: 43 | 44 | @QueryParams(( 45 | ('limit', IntType(required=True)), 46 | ('q', StringType()) 47 | ) 48 | ) 49 | ... 50 | 51 | If the parameter is missing, a HTTP 400 error is raised. 52 | 53 | By default the dictionary containing the typed query parameters is added 54 | to the `kwargs` of the method with the key *query*. In order to change 55 | that, simply change the key in the definition:: 56 | 57 | @QueryParams(( 58 | ... 59 | ), 60 | kwargs_name='myquery' 61 | ) 62 | ... 63 | """ 64 | 65 | def __init__(self, params, kwargs_name='query'): 66 | super().__init__() 67 | self.params = params 68 | self.kwargs_name = kwargs_name 69 | 70 | @s.coroutine 71 | def before(self, handler, args, kwargs): 72 | kwargs[self.kwargs_name] = q = {} 73 | for (name, typedef) in self.params: 74 | if handler.get_argument(name, None): 75 | try: 76 | parsed = typedef(handler.get_argument(name)) 77 | q[name] = parsed 78 | except (ConversionError, ValidationError) as e: 79 | validation_errors = {name: error_messages(e)} 80 | raise s.Error(additional=validation_errors) 81 | elif typedef.required and not handler.get_argument(name, None): 82 | raise s.Error(additional={'msg': 83 | 'Missing required argument "%s"' % 84 | name}) 85 | 86 | @s.coroutine 87 | def after(self, handler, args, kwargs, result): 88 | pass 89 | -------------------------------------------------------------------------------- /supercell/testing.py: -------------------------------------------------------------------------------- 1 | # 2 | # Copyright (c) 2014 Daniel Truemper 3 | # 4 | # Licensed under the Apache License, Version 2.0 (the "License"); 5 | # you may not use this file except in compliance with the License. 6 | # You may obtain a copy of the License at 7 | # 8 | # http://www.apache.org/licenses/LICENSE-2.0 9 | # 10 | # Unless required by applicable law or agreed to in writing, software 11 | # distributed under the License is distributed on an "AS IS" BASIS, 12 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | # See the License for the specific language governing permissions and 14 | # limitations under the License. 15 | # 16 | # 17 | 18 | import sys 19 | 20 | from tornado.ioloop import IOLoop 21 | from tornado.testing import AsyncHTTPTestCase as TornadoAsyncHTTPTestCase 22 | 23 | import pytest 24 | 25 | 26 | class AsyncHTTPTestCase(TornadoAsyncHTTPTestCase): 27 | 28 | ARGV = [] 29 | SERVICE = None 30 | 31 | @pytest.fixture(autouse=True) 32 | def set_commandline(self, monkeypatch): 33 | monkeypatch.setattr(sys, 'argv', ['pytest'] + self.ARGV) 34 | 35 | def get_new_ioloop(self): 36 | return IOLoop.current() 37 | 38 | def get_app(self): 39 | service = self.SERVICE() 40 | service.initialize_logging() 41 | return service.get_app() 42 | -------------------------------------------------------------------------------- /supercell/utils.py: -------------------------------------------------------------------------------- 1 | # 2 | # Copyright (c) 2014 Daniel Truemper 3 | # 4 | # Licensed under the Apache License, Version 2.0 (the "License"); 5 | # you may not use this file except in compliance with the License. 6 | # You may obtain a copy of the License at 7 | # 8 | # http://www.apache.org/licenses/LICENSE-2.0 9 | # 10 | # Unless required by applicable law or agreed to in writing, software 11 | # distributed under the License is distributed on an "AS IS" BASIS, 12 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | # See the License for the specific language governing permissions and 14 | # limitations under the License. 15 | # 16 | # 17 | 18 | from html import escape 19 | 20 | 21 | __all__ = ['escape_contents'] 22 | 23 | 24 | def escape_contents(o): 25 | """ 26 | Encodes chars <, > and & as HTML entities. 27 | """ 28 | _e = escape_contents 29 | if isinstance(o, str): 30 | return escape(o, quote=False) 31 | elif isinstance(o, dict): 32 | o = {_e(k): _e(o[k]) for k in o} 33 | elif isinstance(o, list): 34 | o = [_e(v) for v in o] 35 | elif isinstance(o, tuple): 36 | o = tuple(_e(v) for v in o) 37 | elif isinstance(o, set): 38 | o = {_e(v) for v in o} 39 | return o 40 | -------------------------------------------------------------------------------- /supercell/version.py: -------------------------------------------------------------------------------- 1 | # 2 | # Copyright (c) 2013 Daniel Truemper 3 | # 4 | # Licensed under the Apache License, Version 2.0 (the "License"); 5 | # you may not use this file except in compliance with the License. 6 | # You may obtain a copy of the License at 7 | # 8 | # http://www.apache.org/licenses/LICENSE-2.0 9 | # 10 | # Unless required by applicable law or agreed to in writing, software 11 | # distributed under the License is distributed on an "AS IS" BASIS, 12 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | # See the License for the specific language governing permissions and 14 | # limitations under the License. 15 | # 16 | # 17 | 18 | __version__ = "0.14.0" 19 | -------------------------------------------------------------------------------- /test/config.cfg: -------------------------------------------------------------------------------- 1 | test = "filevalue" 2 | logconf = 'test/logging.conf' 3 | -------------------------------------------------------------------------------- /test/html_test_template/test.html: -------------------------------------------------------------------------------- 1 | 2 | Test 3 | 4 | doc_id: {{ doc_id }} 5 | message: {{ message }} 6 | 7 | 8 | -------------------------------------------------------------------------------- /test/logging.conf: -------------------------------------------------------------------------------- 1 | [loggers] 2 | keys=root 3 | 4 | [logger_root] 5 | level=ERROR 6 | handlers=root 7 | 8 | [handlers] 9 | keys=root 10 | 11 | [handler_root] 12 | class=StreamHandler 13 | level=DEBUG 14 | formatter=default 15 | args=(sys.stdout,) 16 | 17 | [formatters] 18 | keys=default 19 | 20 | [formatter_default] 21 | format = [%(asctime)s] - %(name)s - %(levelname)s - %(message)s 22 | class = logging.Formatter 23 | -------------------------------------------------------------------------------- /test/test_acceptparsing.py: -------------------------------------------------------------------------------- 1 | # vim: set fileencoding=utf-8 : 2 | # 3 | # Taken from: https://gist.github.com/samuraisam/2714195 4 | # 5 | from unittest import TestCase 6 | 7 | from supercell.acceptparsing import parse_accept_header 8 | 9 | 10 | class TestParseAcceptHeader(TestCase): 11 | def test_parse_accept_header_browser(self): 12 | accept = ("text/html,application/xhtml+xml,application/xml;" + 13 | "q=0.9,*/*;q=0.8,application/json") 14 | should = [('text/html', {}, 1.0), 15 | ('application/xhtml+xml', {}, 1.0), 16 | ('application/json', {}, 1.0), 17 | ('application/xml', {}, 0.9), 18 | ('*/*', {}, 0.8)] 19 | self.assertEqual(parse_accept_header(accept), should) 20 | 21 | def test_parse_accept_header_smart_client(self): 22 | accept = "application/vnd.ficture.lightt-v1.1+json" 23 | should = [('application/json', {'version': 1.1, 24 | 'vendor': 'ficture.lightt'}, 1.0)] 25 | self.assertEqual(parse_accept_header(accept), should) 26 | 27 | def test_parse_accept_header_smart_client_without_version(self): 28 | accept = "application/vnd.ficture.lightt+json" 29 | should = [('application/json', {'vendor': 'ficture.lightt'}, 1.0)] 30 | self.assertEqual(parse_accept_header(accept), should) 31 | 32 | def test_parse_accept_header_dumbish_client(self): 33 | accept = "application/vnd.ficture.lightt-v1.0" 34 | should = [('application/vnd.ficture.lightt-v1.0', {}, 1.0)] 35 | self.assertEqual(parse_accept_header(accept), should) 36 | 37 | def test_parse_accept_header_also_dumb_client(self): 38 | accept = "application/vnd.ficture.lightt" 39 | should = [('application/vnd.ficture.lightt', {}, 1.0)] 40 | self.assertEqual(parse_accept_header(accept), should) 41 | 42 | def test_parse_accept_header_dumb_client(self): 43 | accept = "application/json" 44 | should = [('application/json', {}, 1.0)] 45 | self.assertEqual(parse_accept_header(accept), should) 46 | 47 | def test_parse_accept_header_really_dumb_client(self): 48 | accept = "" 49 | should = [('*/*', {}, 1.0)] 50 | self.assertEqual(parse_accept_header(accept), should) 51 | 52 | def test_iesix_bad_accept_header(self): 53 | accept = 'text/*,image/*;application/*;*/*;' 54 | should = [('*/*', {}, 1.0)] 55 | self.assertEqual(parse_accept_header(accept), should) 56 | 57 | def test_parse_accept_header_wildcard(self): 58 | accept = '*/*' 59 | should = [('*/*', {}, 1.0)] 60 | self.assertEqual(parse_accept_header(accept), should) 61 | 62 | -------------------------------------------------------------------------------- /test/test_cache.py: -------------------------------------------------------------------------------- 1 | # vim: set fileencoding=utf-8 : 2 | # 3 | # Copyright (c) 2013 Daniel Truemper 4 | # 5 | # Licensed under the Apache License, Version 2.0 (the "License"); 6 | # you may not use this file except in compliance with the License. 7 | # You may obtain a copy of the License at 8 | # 9 | # http://www.apache.org/licenses/LICENSE-2.0 10 | # 11 | # Unless required by applicable law or agreed to in writing, software 12 | # distributed under the License is distributed on an "AS IS" BASIS, 13 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 14 | # See the License for the specific language governing permissions and 15 | # limitations under the License. 16 | # 17 | # 18 | from datetime import datetime, timedelta 19 | import json 20 | 21 | from schematics.models import Model 22 | from schematics.types import StringType 23 | 24 | from tornado import gen 25 | from tornado.testing import AsyncHTTPTestCase 26 | 27 | import supercell.api as s 28 | from supercell.api import (RequestHandler, provides, CacheConfig) 29 | from supercell.environment import Environment 30 | 31 | 32 | class SimpleMessage(Model): 33 | doc_id = StringType() 34 | message = StringType() 35 | 36 | 37 | @provides(s.MediaType.ApplicationJson, default=True) 38 | class MyHandler(RequestHandler): 39 | 40 | @s.coroutine 41 | def get(self, *args, **kwargs): 42 | raise s.Return(SimpleMessage({"doc_id": 'test123', 43 | "message": 'A test'})) 44 | 45 | 46 | @provides(s.MediaType.ApplicationJson, default=True) 47 | class MyExtremeCachingHandler(RequestHandler): 48 | 49 | @s.coroutine 50 | def get(self, *args, **kwargs): 51 | raise s.Return(SimpleMessage({"doc_id": 'test123', 52 | "message": 'A test'})) 53 | 54 | 55 | @provides(s.MediaType.ApplicationJson, default=True) 56 | class MyPrivateCaching(RequestHandler): 57 | 58 | @s.coroutine 59 | def get(self, *args, **kwargs): 60 | raise s.Return(SimpleMessage({"doc_id": 'test123', 61 | "message": 'A test'})) 62 | 63 | 64 | @provides(s.MediaType.ApplicationJson, default=True) 65 | class CachingWithYielding(RequestHandler): 66 | 67 | @s.coroutine 68 | def get(self, *args, **kwargs): 69 | result = yield self.a_coroutine() 70 | assert result, 'yes' 71 | raise s.Return(SimpleMessage({"doc_id": 'test123', 72 | "message": 'A test'})) 73 | 74 | @gen.coroutine 75 | def a_coroutine(self): 76 | raise s.Return('yes') 77 | 78 | 79 | @provides(s.MediaType.ApplicationJson, default=True) 80 | class CachingWithoutDecorator(RequestHandler): 81 | 82 | @s.coroutine 83 | def get(self, *args, **kwargs): 84 | raise s.Return(SimpleMessage({"doc_id": 'test123', 85 | "message": 'A test'})) 86 | 87 | 88 | class TestCacheDecorator(AsyncHTTPTestCase): 89 | 90 | def get_app(self): 91 | env = Environment() 92 | env.add_handler(r'/', MyHandler, 93 | cache=CacheConfig(timedelta(minutes=10))) 94 | env.add_handler(r'/cache', MyExtremeCachingHandler, 95 | cache=CacheConfig(timedelta(minutes=10), 96 | s_max_age=timedelta(minutes=10), 97 | public=True, must_revalidate=True, 98 | proxy_revalidate=True), 99 | expires=timedelta(minutes=15)) 100 | env.add_handler(r'/private', MyPrivateCaching, 101 | cache=CacheConfig(timedelta(seconds=10), 102 | s_max_age=timedelta(seconds=0), 103 | private=True, no_store=True)) 104 | env.add_handler(r'/nested_async', CachingWithYielding, 105 | cache=CacheConfig(timedelta(seconds=10))) 106 | return env.get_application() 107 | 108 | def test_simple_timedelta(self): 109 | response = self.fetch('/') 110 | self.assertEqual(response.code, 200) 111 | self.assertTrue('Cache-Control' in response.headers) 112 | self.assertEqual('max-age=600, must-revalidate', 113 | response.headers['Cache-Control']) 114 | 115 | def test_extreme_cache(self): 116 | response = self.fetch('/cache') 117 | self.assertEqual(response.code, 200) 118 | self.assertTrue('Cache-Control' in response.headers) 119 | self.assertEqual('max-age=600, s-max-age=600, public, ' + 120 | 'must-revalidate, proxy-revalidate', 121 | response.headers['Cache-Control']) 122 | 123 | self.assertTrue('Expires' in response.headers) 124 | ts = datetime.strptime(response.headers['Expires'], 125 | '%a, %d %b %Y %H:%M:%S %Z') 126 | self.assertTrue(ts > datetime.now()) 127 | 128 | def test_private_cache(self): 129 | response = self.fetch('/private') 130 | self.assertEqual(response.code, 200) 131 | self.assertTrue('Cache-Control' in response.headers) 132 | self.assertEqual('max-age=10, private, no-store, must-revalidate', 133 | response.headers['Cache-Control']) 134 | 135 | def test_caching_with_yielding(self): 136 | response = self.fetch('/nested_async') 137 | self.assertEqual(response.code, 200) 138 | self.assertTrue('Cache-Control' in response.headers) 139 | self.assertEqual('max-age=10, must-revalidate', 140 | response.headers['Cache-Control']) 141 | self.assertEqual('{"doc_id": "test123", "message": "A test"}', 142 | json.dumps(json.loads(response.body.decode('utf8')), 143 | sort_keys=True)) 144 | -------------------------------------------------------------------------------- /test/test_consumer.py: -------------------------------------------------------------------------------- 1 | # vim: set fileencoding=utf-8 : 2 | # 3 | # Copyright (c) 2013 Daniel Truemper 4 | # 5 | # Licensed under the Apache License, Version 2.0 (the "License"); 6 | # you may not use this file except in compliance with the License. 7 | # You may obtain a copy of the License at 8 | # 9 | # http://www.apache.org/licenses/LICENSE-2.0 10 | # 11 | # Unless required by applicable law or agreed to in writing, software 12 | # distributed under the License is distributed on an "AS IS" BASIS, 13 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 14 | # See the License for the specific language governing permissions and 15 | # limitations under the License. 16 | # 17 | # 18 | from __future__ import (absolute_import, division, print_function, 19 | with_statement) 20 | 21 | import sys 22 | from unittest import TestCase 23 | 24 | from supercell.api import consumes, RequestHandler 25 | from supercell.mediatypes import ContentType, MediaType 26 | from supercell.consumer import (ConsumerBase, JsonConsumer, 27 | NoConsumerFound) 28 | 29 | 30 | class MoreDetailedJsonConsumer(JsonConsumer): 31 | 32 | CONTENT_TYPE = ContentType(MediaType.ApplicationJson, vendor='supercell') 33 | 34 | 35 | class JsonConsumerWithVendorAndVersion(JsonConsumer): 36 | 37 | CONTENT_TYPE = ContentType(MediaType.ApplicationJson, vendor='supercell', 38 | version=1.0) 39 | 40 | 41 | class TestBasicConsumer(TestCase): 42 | 43 | def test_default_json_consumer(self): 44 | 45 | @consumes(MediaType.ApplicationJson, object) 46 | class MyHandler(RequestHandler): 47 | pass 48 | 49 | (_, consumer) = ConsumerBase.map_consumer(MediaType.ApplicationJson, 50 | handler=MyHandler) 51 | 52 | self.assertIs(consumer, JsonConsumer) 53 | 54 | with self.assertRaises(NoConsumerFound): 55 | ConsumerBase.map_consumer('application/vnd.supercell-v1.1+json', 56 | handler=MyHandler) 57 | 58 | def test_default_json_consumer_with_encoding_in_ctype(self): 59 | 60 | @consumes(MediaType.ApplicationJson, object) 61 | class MyHandler(RequestHandler): 62 | pass 63 | 64 | (_, consumer) = ConsumerBase.map_consumer( 65 | 'application/json; encoding=UTF-8', handler=MyHandler) 66 | 67 | self.assertIs(consumer, JsonConsumer) 68 | 69 | with self.assertRaises(NoConsumerFound): 70 | ConsumerBase.map_consumer('application/vnd.supercell-v1.1+json', 71 | handler=MyHandler) 72 | 73 | def test_specific_json_consumer(self): 74 | 75 | @consumes(MediaType.ApplicationJson, object, vendor='supercell') 76 | class MyHandler(RequestHandler): 77 | pass 78 | 79 | (_, consumer) = ConsumerBase.map_consumer( 80 | 'application/vnd.supercell+json', handler=MyHandler) 81 | self.assertIs(consumer, MoreDetailedJsonConsumer) 82 | 83 | with self.assertRaises(NoConsumerFound): 84 | ConsumerBase.map_consumer(MediaType.ApplicationJson, 85 | handler=MyHandler) 86 | 87 | def test_json_consumer_with_version(self): 88 | 89 | @consumes(MediaType.ApplicationJson, object, vendor='supercell', 90 | version=1.0) 91 | class MyHandler(RequestHandler): 92 | pass 93 | 94 | (_, consumer) = ConsumerBase.map_consumer( 95 | 'application/vnd.supercell-v1.0+json', handler=MyHandler) 96 | self.assertIs(consumer, JsonConsumerWithVendorAndVersion) 97 | 98 | with self.assertRaises(NoConsumerFound): 99 | ConsumerBase.map_consumer(MediaType.ApplicationJson, 100 | handler=MyHandler) 101 | -------------------------------------------------------------------------------- /test/test_decorators.py: -------------------------------------------------------------------------------- 1 | # vim: set fileencoding=utf-8 : 2 | # 3 | # Copyright (c) 2013 Daniel Truemper 4 | # 5 | # Licensed under the Apache License, Version 2.0 (the "License"); 6 | # you may not use this file except in compliance with the License. 7 | # You may obtain a copy of the License at 8 | # 9 | # http://www.apache.org/licenses/LICENSE-2.0 10 | # 11 | # Unless required by applicable law or agreed to in writing, software 12 | # distributed under the License is distributed on an "AS IS" BASIS, 13 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 14 | # See the License for the specific language governing permissions and 15 | # limitations under the License. 16 | # 17 | # 18 | from __future__ import (absolute_import, division, print_function, 19 | with_statement) 20 | 21 | from collections import defaultdict 22 | 23 | import sys 24 | from unittest import TestCase 25 | 26 | from pytest import raises 27 | 28 | from supercell.api import (RequestHandler, provides, consumes, MediaType) 29 | 30 | 31 | class TestConsumesDecorator(TestCase): 32 | '''Test the consumes decorator.''' 33 | 34 | def test_on_non_class(self): 35 | 36 | with raises(AssertionError): 37 | @consumes(MediaType.ApplicationJson, object) 38 | def get(): 39 | pass 40 | 41 | def test_simple_consumes_decorator_with_post(self): 42 | 43 | @consumes(MediaType.ApplicationJson, object) 44 | class MyHandler(RequestHandler): 45 | 46 | _CONS_CONTENT_TYPES = defaultdict(list) 47 | 48 | def post(self): 49 | pass 50 | 51 | self.assertTrue(hasattr(MyHandler, '_CONS_CONTENT_TYPES')) 52 | self.assertEqual(len(MyHandler._CONS_CONTENT_TYPES), 1) 53 | self.assertTrue(MediaType.ApplicationJson in 54 | MyHandler._CONS_CONTENT_TYPES) 55 | content_type = MyHandler._CONS_CONTENT_TYPES[ 56 | MediaType.ApplicationJson][0] 57 | self.assertEqual(content_type.content_type, 58 | MediaType.ApplicationJson) 59 | self.assertIsNone(content_type.vendor) 60 | self.assertIsNone(content_type.version) 61 | self.assertEqual(MyHandler._CONS_MODEL[content_type], (object, True)) 62 | 63 | def test_consumes_decorator_with_vendor_info(self): 64 | 65 | @consumes(MediaType.ApplicationJson, object, vendor='ficture.light', 66 | version=1.0) 67 | class MyHandler(RequestHandler): 68 | 69 | def post(self): 70 | pass 71 | 72 | self.assertTrue(hasattr(MyHandler, '_CONS_CONTENT_TYPES')) 73 | self.assertEqual(len(MyHandler._CONS_CONTENT_TYPES), 1) 74 | self.assertTrue(MediaType.ApplicationJson in 75 | MyHandler._CONS_CONTENT_TYPES) 76 | content_type = MyHandler._CONS_CONTENT_TYPES[ 77 | MediaType.ApplicationJson][0] 78 | self.assertEqual(content_type.content_type, 79 | MediaType.ApplicationJson) 80 | self.assertEqual(content_type.vendor, 'ficture.light') 81 | self.assertEqual(content_type.version, 1.0) 82 | self.assertEqual(MyHandler._CONS_MODEL[content_type], (object, True)) 83 | 84 | def test_consumes_decorator_with_model(self): 85 | 86 | @consumes(MediaType.ApplicationJson, object, validate=False) 87 | class MyHandler(RequestHandler): 88 | 89 | def post(self): 90 | pass 91 | 92 | self.assertTrue(hasattr(MyHandler, '_CONS_CONTENT_TYPES')) 93 | self.assertEqual(len(MyHandler._CONS_CONTENT_TYPES), 1) 94 | self.assertTrue(MediaType.ApplicationJson in 95 | MyHandler._CONS_CONTENT_TYPES) 96 | content_type = MyHandler._CONS_CONTENT_TYPES[ 97 | MediaType.ApplicationJson][0] 98 | self.assertEqual(content_type.content_type, 99 | MediaType.ApplicationJson) 100 | self.assertIsNone(content_type.vendor) 101 | self.assertIsNone(content_type.version) 102 | self.assertEqual(MyHandler._CONS_MODEL[content_type], (object, False)) 103 | 104 | 105 | class TestProvidesDecorator(TestCase): 106 | '''Test the consumes decorator.''' 107 | 108 | def test_on_non_class(self): 109 | 110 | with raises(AssertionError): 111 | @provides(MediaType.ApplicationJson) 112 | def get(): 113 | pass 114 | 115 | def test_simple_provides_decorator_with_post(self): 116 | 117 | @provides(MediaType.ApplicationJson) 118 | class MyHandler(RequestHandler): 119 | _PROD_CONTENT_TYPES = defaultdict(list) 120 | 121 | pass 122 | 123 | self.assertTrue(hasattr(MyHandler, '_PROD_CONTENT_TYPES')) 124 | self.assertEqual(len(MyHandler._PROD_CONTENT_TYPES), 1) 125 | self.assertTrue(MediaType.ApplicationJson in 126 | MyHandler._PROD_CONTENT_TYPES) 127 | content_type = MyHandler._PROD_CONTENT_TYPES[ 128 | MediaType.ApplicationJson][0] 129 | self.assertEqual(content_type.content_type, 130 | MediaType.ApplicationJson) 131 | self.assertIsNone(content_type.vendor) 132 | self.assertIsNone(content_type.version) 133 | 134 | def test_provides_decorator_with_vendor_info(self): 135 | 136 | @provides(MediaType.ApplicationJson, 'ficture.light', version=1.0) 137 | class MyHandler(RequestHandler): 138 | 139 | def update_stuff(self): 140 | pass 141 | 142 | self.assertTrue(hasattr(MyHandler, '_PROD_CONTENT_TYPES')) 143 | self.assertEqual(len(MyHandler._PROD_CONTENT_TYPES), 1) 144 | self.assertTrue(MediaType.ApplicationJson in 145 | MyHandler._PROD_CONTENT_TYPES) 146 | content_type = MyHandler._PROD_CONTENT_TYPES[ 147 | MediaType.ApplicationJson][0] 148 | self.assertEqual(content_type.content_type, 149 | MediaType.ApplicationJson) 150 | self.assertEqual(content_type.vendor, 'ficture.light') 151 | self.assertEqual(content_type.version, 1.0) 152 | 153 | def test_provides_decorator_with_model(self): 154 | 155 | @provides(MediaType.ApplicationJson) 156 | class MyHandler(RequestHandler): 157 | 158 | def update_stuff(self): 159 | pass 160 | 161 | self.assertTrue(hasattr(MyHandler, '_PROD_CONTENT_TYPES')) 162 | self.assertEqual(len(MyHandler._PROD_CONTENT_TYPES), 1) 163 | self.assertTrue(MediaType.ApplicationJson in 164 | MyHandler._PROD_CONTENT_TYPES) 165 | content_type = MyHandler._PROD_CONTENT_TYPES[ 166 | MediaType.ApplicationJson][0] 167 | self.assertEqual(content_type.content_type, 168 | MediaType.ApplicationJson) 169 | self.assertIsNone(content_type.vendor) 170 | self.assertIsNone(content_type.version) 171 | 172 | def test_provides_decorator_with_partial(self): 173 | 174 | @provides(MediaType.ApplicationJson, partial=True) 175 | class MyHandler(RequestHandler): 176 | 177 | def update_stuff(self): 178 | pass 179 | 180 | self.assertTrue(hasattr(MyHandler, '_PROD_CONFIGURATION')) 181 | self.assertEqual(len(MyHandler._PROD_CONFIGURATION), 1) 182 | self.assertTrue(MediaType.ApplicationJson in 183 | MyHandler._PROD_CONFIGURATION) 184 | configuration = MyHandler._PROD_CONFIGURATION[ 185 | MediaType.ApplicationJson] 186 | self.assertIs(configuration["partial"], True) 187 | -------------------------------------------------------------------------------- /test/test_environment.py: -------------------------------------------------------------------------------- 1 | # vim: set fileencoding=utf-8 : 2 | # 3 | # Copyright (c) 2013 Daniel Truemper 4 | # 5 | # Licensed under the Apache License, Version 2.0 (the "License"); 6 | # you may not use this file except in compliance with the License. 7 | # You may obtain a copy of the License at 8 | # 9 | # http://www.apache.org/licenses/LICENSE-2.0 10 | # 11 | # Unless required by applicable law or agreed to in writing, software 12 | # distributed under the License is distributed on an "AS IS" BASIS, 13 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 14 | # See the License for the specific language governing permissions and 15 | # limitations under the License. 16 | # 17 | # 18 | from __future__ import (absolute_import, division, print_function, 19 | with_statement) 20 | 21 | import sys 22 | from unittest import TestCase 23 | from unittest import skipIf 24 | 25 | import tornado 26 | from tornado import httputil 27 | from tornado.web import Application, RequestHandler 28 | 29 | from supercell.environment import Environment 30 | 31 | 32 | class EnvironmentTest(TestCase): 33 | 34 | def test_simple_app_creation(self): 35 | env = Environment() 36 | app = env.get_application() 37 | self.assertIsInstance(app, Application) 38 | if tornado.version < '4.5': 39 | self.assertEqual(len(app.handlers), 2) 40 | else: 41 | self.assertEqual(len(app.default_router.rules), 2) 42 | 43 | def test_config_file_paths(self): 44 | env = Environment() 45 | self.assertEqual(len(env.config_file_paths), 0) 46 | 47 | @skipIf(tornado.version < '4.5', 'test requires tornado.routing') 48 | def test_add_handler(self): 49 | env = Environment() 50 | self.assertEqual(len(env._handlers), 0) 51 | 52 | class MyHandler(RequestHandler): 53 | def get(self): 54 | self.write({'ok': True}) 55 | 56 | env.add_handler('/test', MyHandler, {}) 57 | 58 | self.assertEqual(len(env._handlers), 1) 59 | 60 | app = env.get_application() 61 | 62 | request = httputil.HTTPServerRequest(uri='/test') 63 | handler_delegate = app.default_router.find_handler(request) 64 | self.assertIsNotNone(handler_delegate) 65 | self.assertEqual(handler_delegate.handler_class, MyHandler) 66 | 67 | def test_managed_object_access(self): 68 | env = Environment() 69 | 70 | managed = object() 71 | env.add_managed_object('i_am_managed', managed) 72 | self.assertEqual(managed, env.i_am_managed) 73 | 74 | def test_no_overwriting_of_managed_objects(self): 75 | env = Environment() 76 | managed = object() 77 | env.add_managed_object('i_am_managed', managed) 78 | 79 | self.assertEqual(managed, env.i_am_managed) 80 | with self.assertRaises(AssertionError): 81 | env.add_managed_object('i_am_managed', object()) 82 | 83 | def test_finalizing(self): 84 | env = Environment() 85 | managed = object() 86 | env.add_managed_object('i_am_managed', managed) 87 | env._finalize() 88 | 89 | with self.assertRaises(AssertionError): 90 | env.add_managed_object('another_managed', object()) 91 | -------------------------------------------------------------------------------- /test/test_healthchecks.py: -------------------------------------------------------------------------------- 1 | # vim: set fileencoding=utf-8 : 2 | # 3 | # Copyright (c) 2013 Daniel Truemper 4 | # 5 | # Licensed under the Apache License, Version 2.0 (the "License"); 6 | # you may not use this file except in compliance with the License. 7 | # You may obtain a copy of the License at 8 | # 9 | # http://www.apache.org/licenses/LICENSE-2.0 10 | # 11 | # Unless required by applicable law or agreed to in writing, software 12 | # distributed under the License is distributed on an "AS IS" BASIS, 13 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 14 | # See the License for the specific language governing permissions and 15 | # limitations under the License. 16 | # 17 | # 18 | from __future__ import (absolute_import, division, print_function, 19 | with_statement) 20 | 21 | import json 22 | 23 | from tornado.testing import AsyncHTTPTestCase 24 | 25 | from supercell.health import SystemHealthCheck 26 | import supercell.api as s 27 | from supercell.environment import Environment 28 | 29 | 30 | class TestBasicHealthChecks(AsyncHTTPTestCase): 31 | 32 | def get_app(self): 33 | env = Environment() 34 | env.add_handler('/_system/check', SystemHealthCheck) 35 | return env.get_application() 36 | 37 | def test_simple_check(self): 38 | response = self.fetch('/_system/check') 39 | self.assertEqual(response.code, 200) 40 | self.assertEqual( 41 | '{"code": "OK", "message": "API running", "ok": true}', 42 | json.dumps(json.loads(response.body.decode('utf8')), 43 | sort_keys=True)) 44 | 45 | 46 | class SimpleHealthCheckExample(s.RequestHandler): 47 | 48 | @s.coroutine 49 | def get(self): 50 | raise s.HealthCheckWarning() 51 | 52 | 53 | class SimpleErrorCheckExample(s.RequestHandler): 54 | 55 | @s.coroutine 56 | def get(self): 57 | raise s.HealthCheckError() 58 | 59 | 60 | class TestCustomHealthCheck(AsyncHTTPTestCase): 61 | 62 | def get_app(self): 63 | env = Environment() 64 | env.add_health_check('test', SimpleHealthCheckExample) 65 | env.add_health_check('error', SimpleErrorCheckExample) 66 | return env.get_application() 67 | 68 | def test_simple_warning(self): 69 | response = self.fetch('/_system/check/test') 70 | self.assertEqual(response.code, 500) 71 | self.assertEqual('{"code": "WARNING", "error": true}', 72 | json.dumps(json.loads(response.body.decode('utf8')), 73 | sort_keys=True)) 74 | 75 | def test_simple_error(self): 76 | response = self.fetch('/_system/check/error') 77 | self.assertEqual(response.code, 500) 78 | self.assertEqual('{"code": "ERROR", "error": true}', 79 | json.dumps(json.loads(response.body.decode('utf8')), 80 | sort_keys=True)) 81 | -------------------------------------------------------------------------------- /test/test_logging.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Copyright (c) 2018 Retresco GmbH 3 | 4 | from logging import makeLogRecord 5 | import mock 6 | from unittest import TestCase 7 | 8 | from supercell.logging import HostnameFormatter 9 | 10 | 11 | class TestHostnameFormatter(TestCase): 12 | 13 | def test_hostname_in_format_string(self): 14 | with mock.patch('socket.gethostname', return_value='horst'): 15 | formatter = HostnameFormatter('%(hostname)s - %(message)s') 16 | record = makeLogRecord({'msg': 'test123'}) 17 | log = formatter.format(record) 18 | self.assertEqual(log, 'horst - test123') 19 | -------------------------------------------------------------------------------- /test/test_middleware.py: -------------------------------------------------------------------------------- 1 | # vim: set fileencoding=utf-8 : 2 | # 3 | # Copyright (c) 2014 Daniel Truemper 4 | # 5 | # Licensed under the Apache License, Version 2.0 (the "License"); 6 | # you may not use this file except in compliance with the License. 7 | # You may obtain a copy of the License at 8 | # 9 | # http://www.apache.org/licenses/LICENSE-2.0 10 | # 11 | # Unless required by applicable law or agreed to in writing, software 12 | # distributed under the License is distributed on an "AS IS" BASIS, 13 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 14 | # See the License for the specific language governing permissions and 15 | # limitations under the License. 16 | # 17 | # 18 | from __future__ import (absolute_import, division, print_function, 19 | with_statement) 20 | 21 | import json 22 | 23 | from tornado.testing import AsyncHTTPTestCase 24 | 25 | from schematics.models import Model 26 | from schematics.types import StringType 27 | from schematics.types import IntType 28 | 29 | import supercell.api as s 30 | from supercell.middleware import Middleware 31 | 32 | 33 | class SimpleMessage(Model): 34 | doc_id = StringType() 35 | message = StringType() 36 | number = IntType() 37 | 38 | class Options: 39 | serialize_when_none = False 40 | 41 | 42 | class ReturnOtherResult(Middleware): 43 | 44 | @s.coroutine 45 | def before(self, handler, args, kwargs): 46 | pass 47 | 48 | @s.coroutine 49 | def after(self, handler, args, kwargs, result): 50 | raise s.Return(SimpleMessage({"doc_id": "no way", 51 | "message": "forget about it!"})) 52 | 53 | 54 | class AddDummyHeader(Middleware): 55 | 56 | @s.coroutine 57 | def before(self, handler, args, kwargs): 58 | yield self.add_another_header(handler) 59 | 60 | @s.coroutine 61 | def add_another_header(self, handler): 62 | handler.add_header('X-Dummy2', 'nice') 63 | raise s.Return(None) 64 | 65 | @s.coroutine 66 | def after(self, handler, args, kwargs, result): 67 | yield self.add_header(handler) 68 | 69 | @s.coroutine 70 | def add_header(self, handler): 71 | handler.add_header('X-Dummy', 'kewl') 72 | 73 | 74 | class RewriteQueryParam(Middleware): 75 | 76 | @s.coroutine 77 | def before(self, handler, args, kwargs): 78 | arg = handler.get_argument('id', None) 79 | if arg: 80 | new_arg = arg.replace('test', 'TEST') 81 | handler.request.arguments['id'] = [new_arg] 82 | 83 | @s.coroutine 84 | def after(self, handler, args, kwargs, result): 85 | yield self.wait_a_little() 86 | 87 | @s.coroutine 88 | def wait_a_little(self): 89 | return 90 | 91 | 92 | @s.provides(s.MediaType.ApplicationJson, default=True) 93 | class MyHandler(s.RequestHandler): 94 | 95 | @AddDummyHeader() 96 | @RewriteQueryParam() 97 | @s.coroutine 98 | def get(self, *args, **kwargs): 99 | doc_id = self.get_argument('id', 'test123') 100 | model = yield self.get_message(doc_id) 101 | raise s.Return(model) 102 | 103 | @s.coroutine 104 | def get_message(self, doc_id): 105 | raise s.Return(SimpleMessage({"doc_id": doc_id, 106 | "message": 'A test'})) 107 | 108 | 109 | @s.provides(s.MediaType.ApplicationJson, default=True) 110 | class MyHandlerReturningOtherStuff(s.RequestHandler): 111 | 112 | @RewriteQueryParam() 113 | @ReturnOtherResult() 114 | @s.coroutine 115 | def get(self, *args, **kwargs): 116 | raise s.Return(SimpleMessage({"doc_id": "yo", 117 | "message": 'A test'})) 118 | 119 | 120 | class TestDummyHeaderMiddleware(AsyncHTTPTestCase): 121 | 122 | def get_app(self): 123 | env = s.Environment() 124 | env.add_handler('/test', MyHandler) 125 | env.add_handler('/otherresult', MyHandlerReturningOtherStuff) 126 | return env.get_application() 127 | 128 | def test_that_header_exists(self): 129 | response = self.fetch('/test') 130 | 131 | assert response.code == 200, 'Something went wrong' 132 | assert 'X-Dummy' in response.headers, 'Header missing' 133 | assert response.headers.get('X-Dummy') == 'kewl', 'wrong header value?' 134 | assert '{"doc_id": "test123", "message": "A test"}' == json.dumps( 135 | json.loads(response.body.decode('utf8')), sort_keys=True) 136 | 137 | def test_manipulating_query_params(self): 138 | response = self.fetch('/test?id=test432') 139 | 140 | assert response.code == 200, 'Something went wrong' 141 | assert 'X-Dummy' in response.headers, 'Header missing' 142 | assert response.headers.get('X-Dummy') == 'kewl', 'wrong header value?' 143 | assert '{"doc_id": "TEST432", "message": "A test"}' == json.dumps( 144 | json.loads(response.body.decode('utf8')), sort_keys=True) 145 | 146 | def test_returning_other_data_in_after(self): 147 | response = self.fetch('/otherresult') 148 | 149 | assert response.code == 200, 'Something went wrong' 150 | assert '{"doc_id": "no way", "message": "forget about it!"}' == \ 151 | json.dumps(json.loads(response.body.decode('utf8')), 152 | sort_keys=True) 153 | -------------------------------------------------------------------------------- /test/test_provider.py: -------------------------------------------------------------------------------- 1 | # vim: set fileencoding=utf-8 : 2 | # 3 | # Copyright (c) 2013 Daniel Truemper 4 | # 5 | # Licensed under the Apache License, Version 2.0 (the "License"); 6 | # you may not use this file except in compliance with the License. 7 | # You may obtain a copy of the License at 8 | # 9 | # http://www.apache.org/licenses/LICENSE-2.0 10 | # 11 | # Unless required by applicable law or agreed to in writing, software 12 | # distributed under the License is distributed on an "AS IS" BASIS, 13 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 14 | # See the License for the specific language governing permissions and 15 | # limitations under the License. 16 | # 17 | # 18 | from __future__ import (absolute_import, division, print_function, 19 | with_statement) 20 | 21 | import sys 22 | from unittest import TestCase 23 | 24 | from supercell.api import provides, RequestHandler 25 | from supercell.mediatypes import ContentType, MediaType 26 | from supercell.provider import (ProviderBase, JsonProvider, 27 | NoProviderFound) 28 | 29 | 30 | class MoreDetailedJsonProvider(JsonProvider): 31 | 32 | CONTENT_TYPE = ContentType(MediaType.ApplicationJson, vendor='supercell') 33 | 34 | 35 | class JsonProviderWithVendorAndVersion(JsonProvider): 36 | 37 | CONTENT_TYPE = ContentType(MediaType.ApplicationJson, vendor='supercell', 38 | version=1.0) 39 | 40 | 41 | class TestBasicProvider(TestCase): 42 | 43 | def test_default_json_provider(self): 44 | 45 | @provides(MediaType.ApplicationJson) 46 | class MyHandler(RequestHandler): 47 | pass 48 | 49 | provider, _ = ProviderBase.map_provider(MediaType.ApplicationJson, 50 | handler=MyHandler) 51 | self.assertIs(provider, JsonProvider) 52 | 53 | with self.assertRaises(NoProviderFound): 54 | ProviderBase.map_provider('application/vnd.supercell-v1.1+json', 55 | handler=MyHandler) 56 | 57 | def test_specific_json_provider(self): 58 | 59 | @provides(MediaType.ApplicationJson, vendor='supercell') 60 | class MyHandler(RequestHandler): 61 | pass 62 | 63 | provider, _ = ProviderBase.map_provider('application/vnd.supercell+json', 64 | handler=MyHandler) 65 | self.assertIs(provider, MoreDetailedJsonProvider) 66 | 67 | def test_json_provider_with_version(self): 68 | 69 | @provides(MediaType.ApplicationJson, vendor='supercell', version=1.0) 70 | class MyHandler(RequestHandler): 71 | 72 | def __init__(self, *args, **kwargs): 73 | # do not call super here in order to test mapping on instances 74 | # of this class 75 | pass 76 | 77 | provider, _ = ProviderBase.map_provider( 78 | 'application/vnd.supercell-v1.0+json', handler=MyHandler) 79 | self.assertIs(provider, JsonProviderWithVendorAndVersion) 80 | 81 | handler = MyHandler() 82 | provider, _ = ProviderBase.map_provider( 83 | 'application/vnd.supercell-v1.0+json', handler=handler) 84 | self.assertIs(provider, JsonProviderWithVendorAndVersion) 85 | 86 | def test_json_provider_with_configuration(self): 87 | 88 | @provides(MediaType.ApplicationJson, partial=True) 89 | class MyHandler(RequestHandler): 90 | pass 91 | 92 | provider, configuration = ProviderBase.map_provider( 93 | MediaType.ApplicationJson, handler=MyHandler) 94 | self.assertIs(provider, JsonProvider) 95 | self.assertIs(configuration["partial"], True) 96 | -------------------------------------------------------------------------------- /test/test_queryparam.py: -------------------------------------------------------------------------------- 1 | # vim: set fileencoding=utf-8 : 2 | # 3 | # Copyright (c) 2014 Daniel Truemper 4 | # 5 | # Licensed under the Apache License, Version 2.0 (the "License"); 6 | # you may not use this file except in compliance with the License. 7 | # You may obtain a copy of the License at 8 | # 9 | # http://www.apache.org/licenses/LICENSE-2.0 10 | # 11 | # Unless required by applicable law or agreed to in writing, software 12 | # distributed under the License is distributed on an "AS IS" BASIS, 13 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 14 | # See the License for the specific language governing permissions and 15 | # limitations under the License. 16 | # 17 | # 18 | from __future__ import (absolute_import, division, print_function, 19 | with_statement) 20 | 21 | import json 22 | 23 | from schematics.models import Model 24 | from schematics.types import StringType 25 | from schematics.types import IntType 26 | 27 | from tornado.testing import AsyncHTTPTestCase 28 | 29 | import supercell.api as s 30 | from supercell.api import RequestHandler, provides 31 | from supercell.environment import Environment 32 | from supercell.queryparam import QueryParams 33 | 34 | 35 | class SimpleMessage(Model): 36 | doc_id = IntType() 37 | message = StringType() 38 | number = IntType() 39 | 40 | class Options: 41 | serialize_when_none = False 42 | 43 | 44 | @provides(s.MediaType.ApplicationJson, default=True) 45 | class MyQueryparamHandler(RequestHandler): 46 | 47 | @QueryParams(( 48 | ('docid', IntType(min_value=1, max_value=2, required=False)), 49 | ('message', StringType(required=True)) 50 | )) 51 | @s.coroutine 52 | def get(self, *args, **kwargs): 53 | query = kwargs.get('query') 54 | raise s.Return(SimpleMessage({"doc_id": query.get('docid', 5), 55 | "message": query.get('message')})) 56 | 57 | 58 | @provides(s.MediaType.ApplicationJson, default=True) 59 | class MyQueryparamHandlerWithCustomKwargsName(RequestHandler): 60 | 61 | @QueryParams(( 62 | ('docid', IntType(min_value=1, max_value=2, required=False)), 63 | ('message', StringType(required=True)) 64 | ), kwargs_name='really_my_name') 65 | @s.coroutine 66 | def get(self, *args, **kwargs): 67 | query = kwargs.get('really_my_name') 68 | raise s.Return(SimpleMessage({"doc_id": query.get('docid', 5), 69 | "message": query.get('message')})) 70 | 71 | 72 | class TestSimpleQueryParam(AsyncHTTPTestCase): 73 | 74 | def get_app(self): 75 | env = Environment() 76 | env.add_handler('/test', MyQueryparamHandler) 77 | env.tornado_settings['debug'] = True 78 | return env.get_application() 79 | 80 | def test_simple_params(self): 81 | response = self.fetch('/test?docid=1&message=A%20test') 82 | 83 | self.assertEqual(200, response.code) 84 | self.assertEqual('{"doc_id": 1, "message": "A test"}', json.dumps( 85 | json.loads(response.body.decode('utf8')), sort_keys=True)) 86 | 87 | def test_missing_required_param(self): 88 | response = self.fetch('/test?docid=1') 89 | 90 | self.assertEqual(400, response.code) 91 | self.assertEqual('{"error": true, "msg": "Missing required argument ' + 92 | '\\"message\\""}', json.dumps( 93 | json.loads(response.body.decode('utf8')), 94 | sort_keys=True)) 95 | 96 | def test_bad_param_validation(self): 97 | response = self.fetch('/test?docid=noway&message=1') 98 | 99 | self.assertEqual(400, response.code) 100 | self.assertEqual('{"docid": ["Value \'noway\' is not int."], ' + 101 | '"error": true}', 102 | json.dumps(json.loads(response.body.decode('utf8')), 103 | sort_keys=True)) 104 | 105 | def test_missing_optional_param(self): 106 | response = self.fetch('/test?message=test') 107 | 108 | self.assertEqual(200, response.code) 109 | self.assertEqual('{"doc_id": 5, "message": "test"}', json.dumps( 110 | json.loads(response.body.decode('utf8')), sort_keys=True)) 111 | 112 | 113 | class TestSimpleQueryParamWithCustomKwargsName(TestSimpleQueryParam): 114 | 115 | def get_app(self): 116 | env = Environment() 117 | env.add_handler('/test', MyQueryparamHandlerWithCustomKwargsName) 118 | env.tornado_settings['debug'] = True 119 | return env.get_application() 120 | -------------------------------------------------------------------------------- /test/test_returning_errors.py: -------------------------------------------------------------------------------- 1 | # vim: set fileencoding=utf-8 : 2 | # 3 | # Copyright (c) 2013 Daniel Truemper 4 | # 5 | # Licensed under the Apache License, Version 2.0 (the "License"); 6 | # you may not use this file except in compliance with the License. 7 | # You may obtain a copy of the License at 8 | # 9 | # http://www.apache.org/licenses/LICENSE-2.0 10 | # 11 | # Unless required by applicable law or agreed to in writing, software 12 | # distributed under the License is distributed on an "AS IS" BASIS, 13 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 14 | # See the License for the specific language governing permissions and 15 | # limitations under the License. 16 | # 17 | # 18 | from __future__ import (absolute_import, division, print_function, 19 | with_statement) 20 | 21 | import sys 22 | 23 | import pytest 24 | import json 25 | 26 | from tornado.testing import AsyncHTTPTestCase 27 | from tornado.web import HTTPError 28 | 29 | from schematics.models import Model 30 | from schematics.types import BooleanType, StringType 31 | 32 | from supercell.api import coroutine 33 | from supercell.api import provides 34 | from supercell.api import consumes 35 | from supercell.api import MediaType 36 | from supercell.api import RequestHandler 37 | from supercell.api import Return 38 | from supercell.api import Service 39 | from supercell.api import Error 40 | 41 | 42 | @provides('text/html') 43 | @provides('application/json', default=True) 44 | @consumes(MediaType.ApplicationJson, object) 45 | class MySimpleHandler(RequestHandler): 46 | @coroutine 47 | def get(self): 48 | raise Return({"this": "is not returned"}) 49 | 50 | @coroutine 51 | def post(self): 52 | raise Return({"this": "is not returned"}) 53 | 54 | 55 | class SimpleModel(Model): 56 | doc_id = StringType(required=True) 57 | doc_bool = BooleanType(required=True) 58 | 59 | 60 | @provides(MediaType.TextHtml) 61 | @provides(MediaType.ApplicationJson, default=True) 62 | @consumes(MediaType.ApplicationJson, SimpleModel) 63 | class MyModelHandler(RequestHandler): 64 | @coroutine 65 | def get(self, model, **kwargs): 66 | invalid_model = SimpleModel({'doc_id': 'id'}) 67 | raise Return(invalid_model) 68 | 69 | @coroutine 70 | def post(self, model, **kwargs): 71 | invalid_model = SimpleModel({'doc_id': 'id'}) 72 | raise Return(invalid_model) 73 | 74 | 75 | @provides(MediaType.TextHtml) 76 | @provides(MediaType.ApplicationJson, default=True) 77 | @consumes(MediaType.ApplicationJson, SimpleModel) 78 | class MyErrorHandler(RequestHandler): 79 | @coroutine 80 | def get(self, *args, **kwargs): 81 | raise Error(406, additional={'message': 'My own error', 'code': 406}) 82 | 83 | @coroutine 84 | def post(self, model, **kwargs): 85 | raise HTTPError(406, reason='My own error') 86 | 87 | 88 | @provides(MediaType.TextHtml) 89 | @provides(MediaType.ApplicationJson, default=True) 90 | @consumes(MediaType.ApplicationJson, SimpleModel) 91 | class MyWorkingModelHandler(RequestHandler): 92 | 93 | @coroutine 94 | def post(self, model, **kwargs): 95 | response_model = SimpleModel({'doc_id': 'id', 'doc_bool': True}) 96 | raise Return(response_model) 97 | 98 | 99 | @provides(MediaType.TextHtml) 100 | @provides(MediaType.ApplicationJson, default=True) 101 | @consumes(MediaType.ApplicationJson, SimpleModel) 102 | class MyExceptionHandler(RequestHandler): 103 | @coroutine 104 | def get(self, *args, **kwargs): 105 | raise Exception('My exception') 106 | 107 | 108 | class MyService(Service): 109 | def run(self): 110 | env = self.environment 111 | env.add_handler('/test', MySimpleHandler, {}) 112 | env.add_handler('/test-model', MyModelHandler, {}) 113 | env.add_handler('/test-error', MyErrorHandler, {}) 114 | env.add_handler('/test-exception', MyExceptionHandler, {}) 115 | env.add_handler('/test-model-no-error', MyWorkingModelHandler, {}) 116 | 117 | 118 | class ApplicationIntegrationTest(AsyncHTTPTestCase): 119 | @pytest.fixture(autouse=True) 120 | def empty_commandline(self, monkeypatch): 121 | monkeypatch.setattr(sys, 'argv', []) 122 | 123 | def get_app(self): 124 | service = MyService() 125 | service.initialize_logging() 126 | return service.get_app() 127 | 128 | def eval_json(self, response, code): 129 | body = json.loads(response.body.decode('utf8')) 130 | self.assertIn('message', body) 131 | self.assertIn('error', body) 132 | self.assertEqual(code, response.code) 133 | 134 | def eval_html(self, response, code): 135 | self.assertEqual(code, response.code) 136 | self.assertTrue(response.body.decode('utf8').startswith('')) 137 | self.assertEqual(code, response.code) 138 | 139 | def test_that_returning_non_model_is_an_error(self): 140 | response = self.fetch('/test', headers={'Accept': 'application/json'}) 141 | self.eval_json(response, 500) 142 | 143 | response = self.fetch('/test', headers={'Accept': 'text/html'}) 144 | self.eval_html(response, 500) 145 | 146 | def test_that_requesting_unknown_content_type_is_an_error(self): 147 | response = self.fetch('/test', method='POST', 148 | body='...', 149 | headers={'Content-Type': 'unknown'}) 150 | self.eval_json(response, 400) 151 | 152 | response = self.fetch('/test', method='POST', 153 | body='...', 154 | headers={'Accept': 'text/html', 155 | 'Content-Type': 'application/json'}) 156 | self.eval_html(response, 400) 157 | 158 | def test_that_rogue_field_in_model_is_an_error(self): 159 | response = self.fetch('/test-model', 160 | method='POST', 161 | body='{"unknown":"true"}', 162 | headers={'Content-Type': 'application/json'}) 163 | self.eval_json(response, 400) 164 | 165 | response = self.fetch('/test-model', 166 | method='POST', 167 | body='{"unknown":"true"}', 168 | headers={'Accept': 'text/html', 169 | 'Content-Type': 'application/json'}) 170 | self.eval_html(response, 400) 171 | 172 | def test_that_wrong_type_in_model_is_an_error(self): 173 | response = self.fetch('/test-model', method='POST', 174 | body='{"doc_id":"id", "doc_bool":"unknown"}', 175 | headers={'Content-Type': 'application/json'}) 176 | self.eval_json(response, 400) 177 | 178 | response = self.fetch('/test-model', method='POST', 179 | body='{"doc_id":"id", "doc_bool":"unknown"}', 180 | headers={'Accept': 'text/html', 181 | 'Content-Type': 'application/json'}) 182 | self.eval_html(response, 400) 183 | 184 | def test_that_wrong_accept_type_is_an_error(self): 185 | response = self.fetch('/test-model', method='POST', 186 | body='{"doc_id":"id", "doc_bool":"unknown"}', 187 | headers={'Accept': 'application/unexpected', 188 | 'Content-Type': 'application/json'}) 189 | self.eval_html(response, 406) 190 | 191 | response = self.fetch('/test-model', method='POST', 192 | body='{"doc_id":"id", "doc_bool":"unknown"}', 193 | headers={'Accept': 'text/html', 194 | 'Content-Type': 'application/unexpected'} 195 | ) 196 | self.eval_html(response, 400) 197 | 198 | def test_that_empty_accept_type_works(self): 199 | response = self.fetch('/test-model-no-error', method='POST', 200 | body='{"doc_id":"id", "doc_bool":"true"}', 201 | headers={'Accept': '', 202 | 'Content-Type': 'application/json'}) 203 | self.assertEqual(200, response.code) 204 | 205 | response = self.fetch('/test-model-no-error', method='POST', 206 | body='{"doc_id":"id", "doc_bool":"true"}', 207 | headers={'Accept': '*/*', 208 | 'Content-Type': 'application/json'}) 209 | self.assertEqual(200, response.code) 210 | 211 | def test_that_invalid_return_model_is_an_error(self): 212 | response = self.fetch('/test-model', method='POST', 213 | body='{"doc_id":"id", "doc_bool":"True"}', 214 | headers={'Content-Type': 'application/json'}) 215 | self.eval_json(response, 500) 216 | 217 | response = self.fetch('/test-model', method='POST', 218 | body='{"doc_id":"id", "doc_bool":"True"}', 219 | headers={'Accept': 'text/html', 220 | 'Content-Type': 'application/json'}) 221 | self.eval_html(response, 500) 222 | 223 | def test_that_individual_error_is_an_error(self): 224 | # supercell.mediatypes.Error 225 | response = self.fetch('/test-error') 226 | self.eval_json(response, 406) 227 | 228 | # TODO: supercell.mediatypes.Error produces NO html, fixing this would be a break! 229 | # response = self.fetch('/test-error', 230 | # headers={'Accept': 'text/html'}) 231 | # self.eval_html(response, 406) 232 | 233 | # tornado.web.HTTPError 234 | response = self.fetch('/test-error', method='POST', 235 | body='{"doc_id":"id", "doc_bool":"True"}', 236 | headers={'Content-Type': 'application/json'}) 237 | self.eval_json(response, 406) 238 | 239 | response = self.fetch('/test-error', method='POST', 240 | body='{"doc_id":"id", "doc_bool":"True"}', 241 | headers={'Accept': 'text/html', 242 | 'Content-Type': 'application/json'}) 243 | self.eval_html(response, 406) 244 | 245 | def test_that_exception_is_an_error(self): 246 | response = self.fetch('/test-exception') 247 | self.eval_json(response, 500) 248 | 249 | response = self.fetch('/test-exception', 250 | headers={'Accept': 'text/html'}) 251 | self.eval_html(response, 500) 252 | -------------------------------------------------------------------------------- /test/test_returning_non_model.py: -------------------------------------------------------------------------------- 1 | # vim: set fileencoding=utf-8 : 2 | # 3 | # Copyright (c) 2013 Daniel Truemper 4 | # 5 | # Licensed under the Apache License, Version 2.0 (the "License"); 6 | # you may not use this file except in compliance with the License. 7 | # You may obtain a copy of the License at 8 | # 9 | # http://www.apache.org/licenses/LICENSE-2.0 10 | # 11 | # Unless required by applicable law or agreed to in writing, software 12 | # distributed under the License is distributed on an "AS IS" BASIS, 13 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 14 | # See the License for the specific language governing permissions and 15 | # limitations under the License. 16 | # 17 | # 18 | from __future__ import (absolute_import, division, print_function, 19 | with_statement) 20 | 21 | import sys 22 | 23 | import pytest 24 | 25 | from tornado.testing import AsyncHTTPTestCase 26 | 27 | from supercell.api import coroutine 28 | from supercell.api import provides 29 | from supercell.api import RequestHandler 30 | from supercell.api import Return 31 | from supercell.api import Service 32 | 33 | 34 | @provides('application/json', default=True) 35 | class MyHandler(RequestHandler): 36 | 37 | @coroutine 38 | def get(self): 39 | raise Return({"this": "is not returned"}) 40 | 41 | 42 | class MyService(Service): 43 | def run(self): 44 | env = self.environment 45 | env.add_handler('/test', MyHandler, {}) 46 | 47 | 48 | class ApplicationIntegrationTest(AsyncHTTPTestCase): 49 | 50 | @pytest.fixture(autouse=True) 51 | def empty_commandline(self, monkeypatch): 52 | monkeypatch.setattr(sys, 'argv', []) 53 | 54 | def get_app(self): 55 | service = MyService() 56 | service.initialize_logging() 57 | return service.get_app() 58 | 59 | def test_that_returning_non_model_is_an_error(self): 60 | response = self.fetch('/test') 61 | self.assertEqual(500, response.code) 62 | -------------------------------------------------------------------------------- /test/test_service.py: -------------------------------------------------------------------------------- 1 | # vim: set fileencoding=utf-8 : 2 | # 3 | # Copyright (c) 2013 Daniel Truemper 4 | # 5 | # Licensed under the Apache License, Version 2.0 (the "License"); 6 | # you may not use this file except in compliance with the License. 7 | # You may obtain a copy of the License at 8 | # 9 | # http://www.apache.org/licenses/LICENSE-2.0 10 | # 11 | # Unless required by applicable law or agreed to in writing, software 12 | # distributed under the License is distributed on an "AS IS" BASIS, 13 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 14 | # See the License for the specific language governing permissions and 15 | # limitations under the License. 16 | # 17 | # 18 | from __future__ import (absolute_import, division, print_function, 19 | with_statement) 20 | 21 | import sys 22 | from unittest import TestCase 23 | 24 | import socket 25 | 26 | import mock 27 | import pytest 28 | 29 | from schematics.models import Model 30 | from schematics.types import StringType 31 | import tornado.options 32 | from tornado.testing import AsyncHTTPTestCase 33 | 34 | import supercell.api as s 35 | from supercell.environment import Environment 36 | 37 | 38 | class SimpleModel(Model): 39 | msg = StringType() 40 | 41 | 42 | @s.provides(s.MediaType.ApplicationJson) 43 | @s.consumes(s.MediaType.ApplicationJson, SimpleModel) 44 | class MyHandler(s.RequestHandler): 45 | 46 | @s.coroutine 47 | def get(self): 48 | self.logger.info('Holy moly') 49 | self.logger.info('That worked') 50 | raise s.Return(SimpleModel({"msg": 'Holy moly'})) 51 | 52 | @s.coroutine 53 | def post(self, doc_id, model=None): 54 | self.logger.info('Holy moly') 55 | raise s.Return(SimpleModel({"msg": 'Holy moly'})) 56 | assert isinstance(self.environment, Environment) 57 | assert isinstance(self.config, tornado.options.options) 58 | raise s.OkCreated({'docid': 123}) 59 | 60 | 61 | @s.provides(s.MediaType.ApplicationJson, default=True) 62 | class MyHandlerThrowingExceptions(s.RequestHandler): 63 | 64 | @s.coroutine 65 | def get(self): 66 | self.logger.info('Starting request with unhandled exception') 67 | raise Exception() 68 | 69 | 70 | class MyService(s.Service): 71 | 72 | def bootstrap(self): 73 | self.environment.config_file_paths.append('test/') 74 | 75 | def run(self): 76 | self.environment.add_handler('/test', MyHandler, {}) 77 | self.environment.add_handler('/test/(\d+)', MyHandler, {}) 78 | self.environment.add_handler('/exception', MyHandlerThrowingExceptions, 79 | {}) 80 | 81 | 82 | class ServiceTest(TestCase): 83 | 84 | @pytest.fixture(autouse=True) 85 | def empty_commandline(self, monkeypatch): 86 | monkeypatch.setattr(sys, 'argv', []) 87 | 88 | def test_environment_creation(self): 89 | service = s.Service() 90 | env = service.environment 91 | self.assertIsInstance(env, Environment) 92 | 93 | def test_logging_initialization(self): 94 | service = s.Service() 95 | env = service.environment 96 | env.config_file_paths.append('test/') 97 | 98 | service.initialize_logging() 99 | 100 | @mock.patch('tornado.ioloop.IOLoop.current') 101 | def test_main_method(self, ioloop_instance_mock): 102 | service = MyService() 103 | service.main() 104 | 105 | ioloop_instance_mock.assert_called() 106 | ioloop_instance_mock().start.assert_called() 107 | ioloop_instance_mock().add_handler.assert_called() 108 | service.shutdown() 109 | 110 | 111 | @mock.patch('tornado.ioloop.IOLoop.current') 112 | @mock.patch('socket.fromfd') 113 | def test_startup_with_socket_fd(self, socket_fromfd_mock, 114 | ioloop_instance_mock): 115 | 116 | service = MyService() 117 | service.config.socketfd = '123' 118 | 119 | service.main() 120 | 121 | expected = [mock.call(), mock.call().add_handler(mock.ANY, mock.ANY, 122 | mock.ANY), 123 | mock.call(), mock.call().start()] 124 | assert expected == ioloop_instance_mock.mock_calls 125 | 126 | assert (mock.call(123, socket.AF_INET, socket.SOCK_STREAM) 127 | in socket_fromfd_mock.mock_calls) 128 | service.shutdown() 129 | 130 | 131 | @mock.patch('tornado.ioloop.IOLoop.current') 132 | def test_graceful_shutdown_pending_callbacks(self, ioloop_instance_mock): 133 | service = MyService() 134 | service.main() 135 | 136 | expected = [mock.call(), mock.call().add_handler(mock.ANY, mock.ANY, 137 | mock.ANY), 138 | mock.call(), mock.call().start()] 139 | assert expected == ioloop_instance_mock.mock_calls 140 | 141 | service.shutdown() 142 | 143 | if sys.version_info < (3,): 144 | expected.extend([mock.call(), mock.call().remove_handler(mock.ANY), 145 | mock.call()._callbacks.__nonzero__(), 146 | mock.call()._callbacks.__nonzero__(), 147 | mock.call().add_timeout(mock.ANY, mock.ANY)]) 148 | else: 149 | expected.extend([mock.call(), mock.call().remove_handler(mock.ANY), 150 | mock.call()._callbacks.__bool__(), 151 | mock.call()._callbacks.__bool__(), 152 | mock.call().add_timeout(mock.ANY, mock.ANY)]) 153 | 154 | assert expected == ioloop_instance_mock.mock_calls 155 | 156 | @mock.patch('tornado.ioloop.IOLoop.current') 157 | def test_graceful_final_shutdown(self, ioloop_instance_mock): 158 | service = MyService() 159 | service.main() 160 | service.config.max_grace_seconds = -10 161 | 162 | expected = [mock.call(), mock.call().add_handler(mock.ANY, mock.ANY, 163 | mock.ANY), 164 | mock.call(), mock.call().start()] 165 | assert expected == ioloop_instance_mock.mock_calls 166 | 167 | service.shutdown() 168 | 169 | expected.extend([mock.call(), mock.call().remove_handler(mock.ANY), 170 | mock.call().stop()]) 171 | assert expected == ioloop_instance_mock.mock_calls 172 | 173 | service.config.max_grace_seconds = 3 174 | service.shutdown() 175 | 176 | 177 | class ApplicationIntegrationTest(AsyncHTTPTestCase): 178 | 179 | @pytest.fixture(autouse=True) 180 | def set_commandline(self, monkeypatch): 181 | monkeypatch.setattr(sys, 'argv', ['pytest']) 182 | 183 | def get_app(self): 184 | service = MyService() 185 | service.initialize_logging() 186 | return service.get_app() 187 | 188 | def test_simple_get(self): 189 | response = self.fetch('/test', headers={'Accept': 190 | s.MediaType.ApplicationJson}) 191 | self.assertEqual(200, response.code) 192 | self.assertEqual('{"msg": "Holy moly"}', response.body.decode('utf8')) 193 | 194 | def test_get_with_exception(self): 195 | response = self.fetch('/exception') 196 | self.assertEqual(500, response.code) 197 | 198 | 199 | @pytest.mark.parametrize('option_name', [ 200 | '--show_config', 201 | '--show_config_name', 202 | '--show_config_file_order' 203 | ]) 204 | def test_system_exit_after_showing_config(option_name): 205 | with mock.patch('sys.argv', ['', option_name]): 206 | service = s.Service() 207 | with pytest.raises(SystemExit): 208 | service.get_app() 209 | 210 | 211 | @pytest.fixture 212 | def test_env_var(monkeypatch): 213 | monkeypatch.setenv("TEST", "envvalue") 214 | 215 | 216 | def test_config_parsing(test_env_var): 217 | service = s.Service() 218 | env = service.environment 219 | env.config_file_paths.append('test/') 220 | tornado.options.define('test', default='default') 221 | service.parse_config_files() 222 | 223 | from tornado.options import options 224 | assert 'filevalue' == options.test 225 | 226 | service.parse_environment_variables(options) 227 | assert 'envvalue' == options.test 228 | -------------------------------------------------------------------------------- /test/test_utils.py: -------------------------------------------------------------------------------- 1 | # vim: set fileencoding=utf-8 : 2 | # 3 | # Copyright (c) 2014 Daniel Truemper 4 | # 5 | # Licensed under the Apache License, Version 2.0 (the "License"); 6 | # you may not use this file except in compliance with the License. 7 | # You may obtain a copy of the License at 8 | # 9 | # http://www.apache.org/licenses/LICENSE-2.0 10 | # 11 | # Unless required by applicable law or agreed to in writing, software 12 | # distributed under the License is distributed on an "AS IS" BASIS, 13 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 14 | # See the License for the specific language governing permissions and 15 | # limitations under the License. 16 | # 17 | # 18 | import pytest 19 | 20 | from supercell.utils import escape_contents 21 | 22 | 23 | PLAIN_STRING = 'string' 24 | TAG_STRING = 'string' 25 | ESCAPED_TAG_STRING = '<b>string</b>' 26 | 27 | 28 | @pytest.mark.parametrize('source,expected', [ 29 | [PLAIN_STRING, PLAIN_STRING], 30 | [TAG_STRING, ESCAPED_TAG_STRING], 31 | [[PLAIN_STRING], [PLAIN_STRING]], 32 | [[TAG_STRING], [ESCAPED_TAG_STRING]], 33 | [ 34 | {PLAIN_STRING: TAG_STRING}, 35 | {PLAIN_STRING: ESCAPED_TAG_STRING} 36 | ], 37 | [ 38 | {TAG_STRING: PLAIN_STRING}, 39 | {ESCAPED_TAG_STRING: PLAIN_STRING} 40 | ], 41 | [{PLAIN_STRING}, {PLAIN_STRING}], 42 | [{TAG_STRING}, {ESCAPED_TAG_STRING}], 43 | [ 44 | (TAG_STRING, PLAIN_STRING), 45 | (ESCAPED_TAG_STRING, PLAIN_STRING) 46 | ], 47 | [ 48 | { 49 | TAG_STRING: [(TAG_STRING,)] 50 | }, 51 | { 52 | ESCAPED_TAG_STRING: [(ESCAPED_TAG_STRING,)] 53 | }, 54 | ], 55 | ]) 56 | def test_escaping(source, expected): 57 | """ 58 | Tests that escaped input matches expected output 59 | """ 60 | assert escape_contents(source) == expected 61 | -------------------------------------------------------------------------------- /travistest.py: -------------------------------------------------------------------------------- 1 | # vim: set fileencoding=utf-8 : 2 | # 3 | # Copyright (c) 2013 Daniel Truemper 4 | # 5 | # Licensed under the Apache License, Version 2.0 (the "License"); 6 | # you may not use this file except in compliance with the License. 7 | # You may obtain a copy of the License at 8 | # 9 | # http://www.apache.org/licenses/LICENSE-2.0 10 | # 11 | # Unless required by applicable law or agreed to in writing, software 12 | # distributed under the License is distributed on an "AS IS" BASIS, 13 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 14 | # See the License for the specific language governing permissions and 15 | # limitations under the License. 16 | # 17 | # 18 | import sys 19 | 20 | import pytest 21 | 22 | if __name__ == '__main__': 23 | sys.exit(pytest.main()) 24 | --------------------------------------------------------------------------------