├── .gitignore ├── .tmuxp.yml ├── .travis.yml ├── CONTRIBUTING.md ├── LICENSE.md ├── MANIFEST.in ├── Makefile ├── README.md ├── docs ├── changes.rst ├── conf.py ├── guide.rst ├── index.rst ├── introduction.rst ├── questions.rst ├── reference.rst └── tutorial.rst ├── examples ├── advanced.py ├── endpoints.py ├── middlewares.py └── service.py ├── interest ├── VERSION ├── __init__.py ├── adapter.py ├── backend.py ├── endpoint.py ├── handler │ ├── __init__.py │ ├── handler.py │ └── record.py ├── helpers │ ├── __init__.py │ ├── chain.py │ ├── config.py │ ├── loop.py │ ├── match.py │ ├── name.py │ ├── order.py │ ├── plugin.py │ ├── port.py │ ├── python.py │ └── sticker.py ├── logger │ ├── __init__.py │ └── logger.py ├── middleware.py ├── plugins │ └── __init__.py ├── provider.py ├── router │ ├── __init__.py │ ├── parser.py │ ├── pattern.py │ └── router.py ├── service.py └── tester.py ├── pylintrc ├── setup.py ├── tests ├── __init__.py ├── component │ ├── __init__.py │ ├── handler │ │ ├── __init__.py │ │ ├── test_handler.py │ │ └── test_record.py │ ├── helpers │ │ ├── __init__.py │ │ └── test_plugin.py │ ├── logger │ │ ├── __init__.py │ │ └── test_logger.py │ ├── plugins │ │ └── __init__.py │ ├── router │ │ ├── __init__.py │ │ ├── test_parser.py │ │ └── test_pattern.py │ ├── test_middleware.py │ └── test_service.py ├── integration │ └── __init__.py └── system │ ├── __init__.py │ └── test_advanced.py └── tox.ini /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | 5 | # C extensions 6 | *.so 7 | 8 | # Distribution / packaging 9 | .Python 10 | env/ 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | *.egg-info/ 23 | .installed.cfg 24 | *.egg 25 | 26 | # PyInstaller 27 | # Usually these files are written by a python script from a template 28 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 29 | *.manifest 30 | *.spec 31 | 32 | # Installer logs 33 | pip-log.txt 34 | pip-delete-this-directory.txt 35 | 36 | # Unit test / coverage reports 37 | htmlcov/ 38 | .tox/ 39 | .coverage 40 | .coverage.* 41 | .cache 42 | nosetests.xml 43 | coverage.xml 44 | *,cover 45 | 46 | # Translations 47 | *.mo 48 | *.pot 49 | 50 | # Django stuff: 51 | *.log 52 | 53 | # Sphinx documentation 54 | docs/_build/ 55 | 56 | # PyBuilder 57 | target/ 58 | -------------------------------------------------------------------------------- /.tmuxp.yml: -------------------------------------------------------------------------------- 1 | # General 2 | session_name: interest 3 | 4 | # Windows 5 | windows: 6 | - window_name: bash 7 | panes: 8 | - shell_command: 9 | - 10 | - window_name: vim 11 | panes: 12 | - shell_command: 13 | - vim 14 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | sudo: 2 | false 3 | 4 | language: 5 | python 6 | 7 | python: 8 | - 3.4 9 | 10 | env: 11 | global: 12 | - TOXENV="py${PYTHON_VERSION//./}" 13 | 14 | install: 15 | - make develop 16 | - pip install coveralls 17 | 18 | script: 19 | # FIXME: configure linter 20 | # - make lint 21 | - make test 22 | 23 | after_success: 24 | - coveralls 25 | 26 | deploy: 27 | provider: pypi 28 | user: roll 29 | password: 30 | secure: EtPOQV6AoJUbSlFX4C84zP9KK46BUzjy8IcXwfviNiieyfUsO3S4zs4N4PSTKBjQCJudCXU6+kRQlQV0WBbT80cr4IQVKGV+KqysK5NabytzXHEXUHci/vGhPwzhWv7bGNDMUdwp3FoWqbYT8AYpNXkHFOuZRIr3r4O3I++YjkwSednqt3U7822eFuNfujpsO6A6CRIdhzzQ3bfXtyEtQd1M17lUv1WyRrV0DgXtYvnkzm5M0I6h6h861iXLkPgW1DMLn4vIeouj/txzzhs0HlYyZFsDRdvAaNPTI0o63VGXJ81LExHUa4fVYasW1IGyzABT9dBEENc0LiF7035QkwSHgEfVzieQ0OcuEmVsBMB0uVGupCprus9dgX1zZCBx/SE+4WxXKZ+/R6OGOcIVlKYVsCtZnFv7aYLWN9Z0hzE9tlESXwM6/4VbEV9lVtOzsVjT7ePZ0+nmR63hcrmfPUdL3ec1crGoNvWmY2JmmjDWvke11qyMw/XmHohPVByc9RwwX/i/EuF0+Jj4OY7p3mOZMrnk8a/H01ifuXVj/HfIQuY5i1bILS8lny6E6QGugG9CkcRPRQSLzLg0stR6LVlmdy1wUMqwSpcMq6YzWaUj+pdOT6HkPA7GkmpfNjTI9Pkn6MNo7TwOjZFiglQHAMwriz3rfyR4f5iDFAb/J38= 31 | on: 32 | tags: true 33 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing 2 | 3 | The project follows the [PEP 8 - Style Guide for Python Code](https://www.python.org/dev/peps/pep-0008/). 4 | 5 | ## Getting Started 6 | 7 | Recommended way to get started is to create and activate a project virtual environment. 8 | To install package and development dependencies into active environment: 9 | 10 | ``` 11 | $ make develop 12 | ``` 13 | 14 | ## Linting 15 | 16 | To lint the project codebase: 17 | 18 | ``` 19 | $ make lint 20 | ``` 21 | 22 | Under the hood `pylint` configured in `.pylintrc` is used. On this stage it's already 23 | installed into your environment and could be used separately with more fine-grained control 24 | as described in documentation - https://www.pylint.org/. 25 | 26 | For example to check only errors: 27 | 28 | ``` 29 | $ pylint -E 30 | ``` 31 | 32 | ## Testing 33 | 34 | To run tests with coverage: 35 | 36 | ``` 37 | $ make test 38 | ``` 39 | Under the hood `tox` powered by `py.test` and `coverage` configured in `tox.ini` is used. 40 | It's already installed into your environment and could be used separately with more fine-grained control 41 | as described in documentation - https://testrun.org/tox/latest/. 42 | 43 | For example to check subset of tests against Python 2 environment with increased verbosity. 44 | All positional arguments and options after `--` will be passed to `py.test`: 45 | 46 | ``` 47 | tox -e py27 -- -v tests/ 48 | ``` 49 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2016 Inventive Ninja 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | 23 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | global-include VERSION 2 | include LICENSE.md 3 | include Makefile 4 | include pylintrc 5 | include README.md 6 | include tox.ini 7 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | .PHONY: all develop list lint release test version 2 | 3 | 4 | PACKAGE := $(shell grep '^PACKAGE =' setup.py | cut -d "'" -f2) 5 | VERSION := $(shell head -n 1 $(PACKAGE)/VERSION) 6 | 7 | 8 | all: list 9 | 10 | develop: 11 | pip install --upgrade -e .[develop] 12 | 13 | list: 14 | @grep '^\.PHONY' Makefile | cut -d' ' -f2- | tr ' ' '\n' 15 | 16 | lint: 17 | pylint $(PACKAGE) 18 | 19 | release: 20 | bash -c '[[ -z `git status -s` ]]' 21 | git tag -a -m release $(VERSION) 22 | git push --tags 23 | 24 | test: 25 | tox 26 | 27 | version: 28 | @echo $(VERSION) 29 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Interest 2 | 3 | [![Travis](https://img.shields.io/travis/inventive-ninja/interest.svg)](https://travis-ci.org/inventive-ninja/interest) 4 | [![Coveralls](https://img.shields.io/coveralls/inventive-ninja/interest.svg?branch=master)](https://coveralls.io/r/inventive-ninja/interest?branch=master) 5 | [![PyPI](https://img.shields.io/pypi/v/interest.svg)](https://pypi.org/project/interest) 6 | 7 | Event-driven web framework on top of aiohttp/asyncio. 8 | 9 | ## Features 10 | 11 | - event-driven on top of aiohttp/asyncio 12 | - consistent, modular and flexible flow model, class-based 13 | - configurable and pluggable 14 | 15 | ## Example 16 | 17 | Install interest package: 18 | 19 | ``` 20 | $ pip install interest 21 | ``` 22 | 23 | Save the following code as `server.py`: 24 | 25 | 26 | ```python 27 | # server.py 28 | from interest import Service, http 29 | 30 | class Service(Service): 31 | 32 | # Public 33 | 34 | @http.get('/') 35 | def hello(self, request): 36 | return http.Response(text='Hello World!') 37 | 38 | 39 | # Listen forever 40 | service = Service() 41 | service.listen(host='127.0.0.1', port=9000, override=True, forever=True) 42 | ``` 43 | 44 | Run the server in the terminal and use another to interact: 45 | 46 | ``` 47 | $ python server.py 48 | ... 49 | $ curl -X GET http://127.0.0.1:9000/; echo 50 | Hello World! 51 | ... 52 | ``` 53 | 54 | ## Read more 55 | 56 | Please visit Interest's developer hub to get docs, news and support: 57 | 58 | [Developer Hub](https://interest.readme.io/) 59 | 60 | ## Contributing 61 | 62 | Please read the contribution guideline: 63 | 64 | [How to Contribute](CONTRIBUTING.md) 65 | 66 | Thanks! 67 | -------------------------------------------------------------------------------- /docs/changes.rst: -------------------------------------------------------------------------------- 1 | Changes 2 | ======= 3 | 4 | Package hasn't a public changelog. 5 | -------------------------------------------------------------------------------- /docs/conf.py: -------------------------------------------------------------------------------- 1 | import os 2 | import sphinx 3 | import sphinx_rtd_theme 4 | from sphinx_settings import Settings 5 | copyset = '2015, Inventive Ninja' # REPLACE: copyset = '{{ copyright }}' 6 | project = 'interest' # REPLACE: project = '{{ name }}' 7 | version = '0.4.1' # REPLACE: version = '{{ version }}' 8 | 9 | 10 | class Settings(Settings): 11 | 12 | # Documentation: 13 | # http://sphinx-doc.org/config.html 14 | 15 | # General 16 | 17 | extensions = ['sphinx.ext.autodoc'] 18 | master_doc = 'index' 19 | pygments_style = 'sphinx' 20 | 21 | # Project 22 | 23 | copyright = copyset 24 | project = project 25 | version = version 26 | 27 | # HTML 28 | 29 | html_theme = 'sphinx_rtd_theme' 30 | html_theme_path = [sphinx_rtd_theme.get_html_theme_path()] 31 | 32 | # Autodoc 33 | 34 | autodoc_member_order = 'bysource' 35 | autodoc_default_flags = ['members', 'special-members', 'private-members'] 36 | autodoc_skip_members = ['__weakref__'] 37 | 38 | 39 | locals().update(Settings()) 40 | -------------------------------------------------------------------------------- /docs/guide.rst: -------------------------------------------------------------------------------- 1 | Extended Guide 2 | ============== 3 | 4 | Welcome to the interest's extended guide. We will try to cover all 5 | framework aspects. If you're interested in concrete topic use left 6 | menu to pick it. 7 | 8 | *under development* 9 | 10 | Terminology 11 | ----------- 12 | 13 | Flow model 14 | ---------- 15 | 16 | Routing 17 | ------- 18 | 19 | Logging 20 | ------- 21 | 22 | Testing 23 | ------- 24 | 25 | Debugging 26 | --------- 27 | 28 | Serving static 29 | -------------- 30 | 31 | Template engines 32 | ---------------- 33 | 34 | Database integration 35 | -------------------- 36 | 37 | Putting all together 38 | -------------------- 39 | 40 | We'll put all features together to the example: 41 | 42 | .. literalinclude:: ../examples/advanced.py 43 | 44 | Run the server in the terminal and use another to interact: 45 | 46 | .. code-block:: bash 47 | 48 | $ python3 server.py 49 | INFO:interest:Start listening host="127.0.0.1" port="9000" 50 | ... ... 51 | $ curl -X GET http://127.0.0.1:9000/api/v1/comment/key=1; echo 52 | {"key": 1} 53 | $ curl -X PUT http://127.0.0.1:9000/api/v1/comment; echo 54 | {"message": "Created"} 55 | $ curl -X POST http://127.0.0.1:9000/api/v1/comment; echo 56 | {"message": "Unauthorized"} 57 | -------------------------------------------------------------------------------- /docs/index.rst: -------------------------------------------------------------------------------- 1 | Interest 2 | ======== 3 | 4 | Welcome to the interest documentation! 5 | 6 | This resourse is simple to use. If you're new in interest 7 | just start read from top to bottom of left menu. It provides you through all 8 | the interest documentation starting from very basic things like 9 | installation and ending with full API reference and advanced tips and tricks. 10 | 11 | Contents 12 | -------- 13 | 14 | .. toctree:: 15 | 16 | Introduction 17 | tutorial 18 | guide 19 | reference 20 | questions 21 | changes 22 | 23 | Indices 24 | ------- 25 | 26 | - :ref:`Main Index` 27 | -------------------------------------------------------------------------------- /docs/introduction.rst: -------------------------------------------------------------------------------- 1 | .. include:: ../README.rst 2 | -------------------------------------------------------------------------------- /docs/questions.rst: -------------------------------------------------------------------------------- 1 | Questions 2 | ========= 3 | 4 | How to start a server manually? 5 | ------------------------------- 6 | 7 | Just use :meth:`.Service.listen` method without forever flag. 8 | You can start as many servers on different ports as you want: 9 | 10 | .. code-block:: python 11 | 12 | import asyncio 13 | from interest import Service 14 | 15 | service = Service() 16 | server1 = service.listen(host='127.0.0.1', port=9001) 17 | server2 = service.listen(host='127.0.0.1', port=9002) 18 | try: 19 | service.loop.run_forever() 20 | except KeyboardInterrupt: 21 | pass 22 | -------------------------------------------------------------------------------- /docs/reference.rst: -------------------------------------------------------------------------------- 1 | API Reference 2 | ============= 3 | 4 | Left menu reflects interest's pulic API. There are high-level abstraction 5 | elements on top and low-level abstraction elements on bottom grouped by 6 | responsibility. 7 | 8 | Service 9 | ------- 10 | 11 | .. autoclass:: interest.Service 12 | 13 | Middleware 14 | ---------- 15 | 16 | .. autoclass:: interest.Middleware 17 | 18 | Endpoint 19 | -------- 20 | 21 | .. autoclass:: interest.Endpoint 22 | 23 | Router 24 | ------ 25 | 26 | .. autoclass:: interest.Router 27 | 28 | Parser 29 | ------ 30 | 31 | .. autoclass:: interest.Parser 32 | 33 | Logger 34 | ------ 35 | 36 | .. autoclass:: interest.Logger 37 | 38 | Handler 39 | ------- 40 | 41 | .. autoclass:: interest.Handler 42 | 43 | Record 44 | ------ 45 | 46 | .. autoclass:: interest.Record 47 | 48 | Provider 49 | -------- 50 | 51 | .. autoclass:: interest.Provider 52 | 53 | Chain 54 | ----- 55 | 56 | .. autoclass:: interest.Chain 57 | 58 | Config 59 | ------ 60 | 61 | .. autoclass:: interest.Config 62 | 63 | Match 64 | ----- 65 | 66 | .. autoclass:: interest.Match 67 | 68 | Adapter 69 | ------- 70 | 71 | .. autoclass:: interest.Adapter 72 | 73 | Tester 74 | ------ 75 | 76 | .. autoclass:: interest.Tester 77 | 78 | http 79 | ---- 80 | 81 | .. autoclass:: interest.http 82 | 83 | version 84 | ------- 85 | 86 | .. data:: interest.version 87 | 88 | Current version. 89 | -------------------------------------------------------------------------------- /docs/tutorial.rst: -------------------------------------------------------------------------------- 1 | Getting Started 2 | =============== 3 | 4 | .. note:: 5 | 6 | It's a full-featured tutorial. 7 | For a very starter examples see :doc:`Introduction `. 8 | 9 | Let's say we want a REST service. It sounds pretty reasonable for the 10 | interest tutorial. We want standard functionality some CRUD, few formats 11 | like json and xml/yaml to communicate it with. We want to deploy it 12 | on server using load balancer like nginx and process manager like supervisor. 13 | Last but not least we want a solid log output to understand 14 | what's happening with our service. 15 | 16 | OK. Let's do it! 17 | 18 | *under development* 19 | 20 | Step 1 21 | ------ 22 | 23 | Step 2 24 | ------ 25 | 26 | Step 3 27 | ------ 28 | -------------------------------------------------------------------------------- /examples/advanced.py: -------------------------------------------------------------------------------- 1 | # server.py 2 | import json 3 | import asyncio 4 | import logging 5 | from interest import Service, Middleware, http 6 | from interest import Logger, Handler, Router, Parser, Provider, Endpoint 7 | 8 | 9 | class Restful(Middleware): 10 | 11 | # Public 12 | 13 | @asyncio.coroutine 14 | def process(self, request): 15 | try: 16 | response = http.Response() 17 | payload = yield from self.next(request) 18 | except http.Exception as exception: 19 | response = exception 20 | payload = {'message': str(response)} 21 | response.text = json.dumps(payload) 22 | response.content_type = 'application/json' 23 | return response 24 | 25 | 26 | class Session(Middleware): 27 | 28 | # Public 29 | 30 | @asyncio.coroutine 31 | def process(self, request): 32 | assert self.main == self.service.over 33 | assert self.over == self.service 34 | assert self.prev == self.service['restful'] 35 | assert self.next == self.service['comment']['read'].over 36 | request.user = False 37 | response = yield from self.next(request) 38 | return response 39 | 40 | 41 | class MyEndpoint(Endpoint): 42 | 43 | # Public 44 | 45 | @asyncio.coroutine 46 | def __call__(self, request): 47 | for header in self.extra.get('headers', []): 48 | if header not in request.headers: 49 | return (yield from self.next(request)) 50 | return (yield from super().__call__(request)) 51 | 52 | 53 | class Auth(Middleware): 54 | 55 | # Public 56 | 57 | METHODS = ['POST'] 58 | 59 | @asyncio.coroutine 60 | def process(self, request): 61 | assert self.service.match(request, root='/api/v1') 62 | assert self.service.match(request, path=request.path) 63 | assert self.service.match(request, methods=['POST']) 64 | if not request.user: 65 | raise http.Unauthorized() 66 | response = yield from self.next(request) 67 | return response 68 | 69 | 70 | class Comment(Middleware): 71 | 72 | # Public 73 | 74 | PREFIX = '/comment' 75 | ENDPOINT = MyEndpoint 76 | MIDDLEWARES = [Auth] 77 | 78 | @http.get('/key=') 79 | def read(self, request, key): 80 | url = '/api/v1/comment/key=' + str(key) 81 | assert url == self.service.url('comment.read', key=key) 82 | assert url == self.service.url('read', base=self, key=key) 83 | return {'key': key} 84 | 85 | @http.put 86 | @http.post # Auth 87 | def upsert(self, request): 88 | self.service.log('info', 'Adding custom header!') 89 | raise http.Created(headers={'endpoint': 'upsert'}) 90 | 91 | @http.delete(headers=['ACCEPT']) 92 | def delete(self, request): 93 | assert self.service.db == '' 94 | raise http.Forbidden() 95 | 96 | 97 | class Database(Provider): 98 | 99 | # Public 100 | 101 | @asyncio.coroutine 102 | def provide(self, service): 103 | self.service.db = '' 104 | 105 | 106 | # Create restful service 107 | restful = Service( 108 | prefix='/api/v1', 109 | middlewares=[Restful, Session, Comment], 110 | providers=[Database], 111 | router=Router.config( 112 | parsers={'myint': Parser.config( 113 | pattern=r'[1-9]+', convert=int)})) 114 | 115 | # Create main service 116 | service = Service( 117 | logger=Logger.config( 118 | template='%(request)s | %(status)s | %()s'), 119 | handler=Handler.config( 120 | connection_timeout=25, request_timeout=5)) 121 | 122 | # Add restful to main 123 | service.push(restful) 124 | 125 | # Listen forever with logging 126 | logging.basicConfig(level=logging.DEBUG) 127 | service.listen(host='127.0.0.1', port=9000, override=True, forever=True) 128 | -------------------------------------------------------------------------------- /examples/endpoints.py: -------------------------------------------------------------------------------- 1 | # server.py 2 | import asyncio 3 | from interest import Service, Middleware, http 4 | 5 | 6 | class Math(Middleware): 7 | 8 | # Public 9 | 10 | PREFIX = '/math' 11 | 12 | @http.get('/power') 13 | @http.get('/power/') 14 | def power(self, request, value=1): 15 | return http.Response(text=str(value ** 2)) 16 | 17 | 18 | class Upper(Middleware): 19 | 20 | # Public 21 | 22 | PREFIX = '/upper' 23 | METHODS = ['GET'] 24 | 25 | @asyncio.coroutine 26 | def process(self, request): 27 | try: 28 | # Process request here 29 | response = (yield from self.next(request)) 30 | # Process response here 31 | response.text = response.text.upper() 32 | except http.Exception as exception: 33 | # Process exception here 34 | response = exception 35 | print(self.service) 36 | return response 37 | 38 | 39 | class Service(Service): 40 | 41 | # Public 42 | 43 | @http.get('/') 44 | def hello(self, request, key): 45 | return http.Response(text='Hello World!') 46 | 47 | 48 | # Listen forever 49 | service = Service(middlewares=[Math, Upper]) 50 | service.listen(host='127.0.0.1', port=9000, override=True, forever=True) 51 | -------------------------------------------------------------------------------- /examples/middlewares.py: -------------------------------------------------------------------------------- 1 | # server.py 2 | import asyncio 3 | from interest import Service, Middleware, http 4 | 5 | 6 | class Upper(Middleware): 7 | 8 | # Public 9 | 10 | PREFIX = '/upper' 11 | METHODS = ['GET'] 12 | 13 | @asyncio.coroutine 14 | def process(self, request): 15 | try: 16 | # Process request here 17 | response = (yield from self.next(request)) 18 | # Process response here 19 | response.text = response.text.upper() 20 | except http.Exception as exception: 21 | # Process exception here 22 | response = exception 23 | print(self.service) 24 | return response 25 | 26 | 27 | class Service(Service): 28 | 29 | # Public 30 | 31 | @http.get('/') 32 | def hello(self, request, key): 33 | return http.Response(text='Hello World!') 34 | 35 | 36 | # Listen forever 37 | service = Service(middlewares=[Upper]) 38 | service.listen(host='127.0.0.1', port=9000, override=True, forever=True) 39 | -------------------------------------------------------------------------------- /examples/service.py: -------------------------------------------------------------------------------- 1 | # server.py 2 | from interest import Service, http 3 | 4 | 5 | class Service(Service): 6 | 7 | # Public 8 | 9 | @http.get('/') 10 | def hello(self, request): 11 | return http.Response(text='Hello World!') 12 | 13 | 14 | # Listen forever 15 | service = Service() 16 | service.listen(host='127.0.0.1', port=9000, override=True, forever=True) 17 | -------------------------------------------------------------------------------- /interest/VERSION: -------------------------------------------------------------------------------- 1 | 0.4.1 2 | -------------------------------------------------------------------------------- /interest/__init__.py: -------------------------------------------------------------------------------- 1 | from .adapter import Adapter 2 | from .backend import http 3 | from .endpoint import Endpoint 4 | from .handler import Handler, Record 5 | from .helpers import Chain, Config, Match 6 | from .logger import Logger 7 | from .middleware import Middleware 8 | from .provider import Provider 9 | from .router import Router, Parser 10 | from .service import Service 11 | from .tester import Tester 12 | version = '0.4.1' # REPLACE: version = '{{ version }}' 13 | -------------------------------------------------------------------------------- /interest/adapter.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | from .middleware import Middleware 3 | 4 | 5 | class Adapter(Middleware): 6 | """Adapter is a middleware to use aiohttp.web's middleware factories. 7 | 8 | .. seealso:: Implements: 9 | :class:`.Middleware` 10 | :class:`.Chain`, 11 | :class:`.Config` 12 | 13 | Parameters 14 | ---------- 15 | factory: coroutine 16 | aiohttp.web's middleware factoriy. 17 | """ 18 | 19 | # Public 20 | 21 | def __init__(self, *args, factory, **kwargs): 22 | self.__factory = factory 23 | super.__init__(*args, **kwargs) 24 | 25 | @asyncio.coroutine 26 | def process(self, request): 27 | handler = yield from self.factory(None, self.next) 28 | return (yield from handler(request)) 29 | -------------------------------------------------------------------------------- /interest/backend.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | from aiohttp import web 3 | from functools import partial 4 | from .helpers import STICKER 5 | 6 | 7 | class http: 8 | """HTTP library/proxy backed by aiohttp.web. 9 | 10 | Part of the documetation is loaded from aiohttp package by Sphinx. 11 | 12 | .. note:: © Copyright 2013, 2014, 2015, KeepSafe. 13 | .. seealso:: 14 | - `Documentation of aiohttp `_ 15 | - `HTTP 1.1 specification `_ 16 | - `HTTP 1.1 update `_ 17 | """ 18 | 19 | # Public 20 | 21 | Request = web.Request 22 | """Request. 23 | """ 24 | StreamResponse = web.StreamResponse 25 | """Stream response. 26 | """ 27 | Response = web.Response 28 | """Response. 29 | """ 30 | WebSocketResponse = web.WebSocketResponse 31 | """WebSocket response. 32 | """ 33 | Exception = web.HTTPException 34 | """Exception. 35 | """ 36 | Successful = web.HTTPSuccessful 37 | """2xx Successful. 38 | """ 39 | Ok = web.HTTPOk 40 | """200 OK. 41 | """ 42 | Created = web.HTTPCreated 43 | """201 Created. 44 | """ 45 | Accepted = web.HTTPAccepted 46 | """202 Accepted. 47 | """ 48 | NonAuthoritativeInformation = web.HTTPNonAuthoritativeInformation 49 | """203 Non-Authoritative Information. 50 | """ 51 | NoContent = web.HTTPNoContent 52 | """204 No Content. 53 | """ 54 | ResetContent = web.HTTPResetContent 55 | """205 Reset Content. 56 | """ 57 | PartialContent = web.HTTPPartialContent 58 | """206 Partial Content. 59 | """ 60 | Redirection = web.HTTPRedirection 61 | """3xx Redirection. 62 | """ 63 | MultipleChoices = web.HTTPMultipleChoices 64 | """300 Multiple Choices. 65 | """ 66 | MovedPermanently = web.HTTPMovedPermanently 67 | """301 Moved Permanently. 68 | """ 69 | Found = web.HTTPFound 70 | """302 Found. 71 | """ 72 | SeeOther = web.HTTPSeeOther 73 | """303 See Other. 74 | """ 75 | NotModified = web.HTTPNotModified 76 | """304 Not Modified. 77 | """ 78 | UseProxy = web.HTTPUseProxy 79 | """305 Use Proxy. 80 | """ 81 | TemporaryRedirect = web.HTTPTemporaryRedirect 82 | """307 Temporary Redirect. 83 | """ 84 | Error = web.HTTPError 85 | """4/5xx Client or Server Error. 86 | """ 87 | ClientError = web.HTTPClientError 88 | """4xx Client Error. 89 | """ 90 | BadRequest = web.HTTPBadRequest 91 | """400 Bad Request. 92 | """ 93 | Unauthorized = web.HTTPUnauthorized 94 | """401 Unauthorized. 95 | """ 96 | PaymentRequired = web.HTTPPaymentRequired 97 | """402 Payment Required. 98 | """ 99 | Forbidden = web.HTTPForbidden 100 | """403 Forbidden. 101 | """ 102 | NotFound = web.HTTPNotFound 103 | """404 Not Found. 104 | """ 105 | MethodNotAllowed = web.HTTPMethodNotAllowed 106 | """405 Method Not Allowed. 107 | """ 108 | NotAcceptable = web.HTTPNotAcceptable 109 | """406 Not Acceptable. 110 | """ 111 | ProxyAuthenticationRequired = web.HTTPProxyAuthenticationRequired 112 | """407 Proxy Authentication Required. 113 | """ 114 | RequestTimeout = web.HTTPRequestTimeout 115 | """408 Request Timeout. 116 | """ 117 | Conflict = web.HTTPConflict 118 | """409 Conflict. 119 | """ 120 | Gone = web.HTTPGone 121 | """410 Gone. 122 | """ 123 | LengthRequired = web.HTTPLengthRequired 124 | """411 Length Required. 125 | """ 126 | PreconditionFailed = web.HTTPPreconditionFailed 127 | """412 Precondition Failed. 128 | """ 129 | RequestEntityTooLarge = web.HTTPRequestEntityTooLarge 130 | """413 Request Entity Too Large. 131 | """ 132 | RequestURITooLong = web.HTTPRequestURITooLong 133 | """414 Request-URI Too Long. 134 | """ 135 | UnsupportedMediaType = web.HTTPUnsupportedMediaType 136 | """415 Unsupported Media Type. 137 | """ 138 | RequestRangeNotSatisfiable = web.HTTPRequestRangeNotSatisfiable 139 | """416 Requested Range Not Satisfiable. 140 | """ 141 | ExpectationFailed = web.HTTPExpectationFailed 142 | """417 Expectation Failed. 143 | """ 144 | ServerError = web.HTTPServerError 145 | """5xx Server Error. 146 | """ 147 | InternalServerError = web.HTTPInternalServerError 148 | """500 Internal Server Error. 149 | """ 150 | NotImplemented = web.HTTPNotImplemented 151 | """501 Not Implemented. 152 | """ 153 | BadGateway = web.HTTPBadGateway 154 | """502 Bad Gateway. 155 | """ 156 | ServiceUnavailable = web.HTTPServiceUnavailable 157 | """503 Service Unavailable. 158 | """ 159 | GatewayTimeout = web.HTTPGatewayTimeout 160 | """504 Gateway Timeout. 161 | """ 162 | VersionNotSupported = web.HTTPVersionNotSupported 163 | """505 HTTP Version Not Supported. 164 | """ 165 | 166 | @classmethod 167 | def bind(cls, param=None, **kwargs): 168 | """Bind middleware's method as endpoint. 169 | """ 170 | def stick(function, **binding): 171 | if not asyncio.iscoroutine(function): 172 | function = asyncio.coroutine(function) 173 | bindings = getattr(function, STICKER, []) 174 | bindings.append(binding) 175 | setattr(function, STICKER, bindings) 176 | return function 177 | if isinstance(param, str): 178 | return partial(stick, prefix=param, **kwargs) 179 | return stick(param, **kwargs) 180 | 181 | @classmethod 182 | def options(cls, param=None, **kwargs): 183 | """Bind middleware's method as OPTIONS endpoint. 184 | """ 185 | return cls.bind(param, methods=['OPTIONS'], **kwargs) 186 | 187 | @classmethod 188 | def get(cls, param=None, **kwargs): 189 | """Bind middleware's method as GET endpoint. 190 | """ 191 | return cls.bind(param, methods=['GET'], **kwargs) 192 | 193 | @classmethod 194 | def head(cls, param=None, **kwargs): 195 | """Bind middleware's method as HEAD endpoint. 196 | """ 197 | return cls.bind(param, methods=['HEAD'], **kwargs) 198 | 199 | @classmethod 200 | def post(cls, param=None, **kwargs): 201 | """Bind middleware's method as POST endpoint. 202 | """ 203 | return cls.bind(param, methods=['POST'], **kwargs) 204 | 205 | @classmethod 206 | def put(cls, param=None, **kwargs): 207 | """Bind middleware's method as PUT endpoint. 208 | """ 209 | return cls.bind(param, methods=['PUT'], **kwargs) 210 | 211 | @classmethod 212 | def delete(cls, param=None, **kwargs): 213 | """Bind middleware's method as DELETE endpoint. 214 | """ 215 | return cls.bind(param, methods=['DELETE'], **kwargs) 216 | 217 | @classmethod 218 | def patch(cls, param=None, **kwargs): 219 | """Bind middleware's method as PATCH endpoint. 220 | """ 221 | return cls.bind(param, methods=['PATCH'], **kwargs) 222 | -------------------------------------------------------------------------------- /interest/endpoint.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | from .backend import http 3 | from .middleware import Middleware 4 | 5 | 6 | class Endpoint(Middleware): 7 | """Endpoint is a middleware capable to respont to a request. 8 | 9 | Enpoint is used by :meth:`.http.bind` methods family to convert 10 | middleware's methods to the middleware's endpoints. 11 | Usually user application never creates enpoints by itself. 12 | 13 | .. seealso:: Implements: 14 | :class:`.Middleware`, 15 | :class:`.Chain`, 16 | :class:`.Config` 17 | 18 | Parameters 19 | ---------- 20 | respond: coroutine 21 | Coroutine to respond to a request. 22 | extra: dict 23 | Extra arguments. 24 | 25 | Examples 26 | -------- 27 | Let see how to get access to an endpoint:: 28 | 29 | class Middleware(Middleware): 30 | 31 | # Public 32 | 33 | @http.get('/') 34 | def echo(self, request, text): 35 | return http.Response(text=text) 36 | 37 | endpoint = Middleware('')['echo'] 38 | response = yield from endpoint('') 39 | """ 40 | 41 | # Public 42 | 43 | RESPOND = None 44 | """Default respond parameter. 45 | """ 46 | 47 | def __init__(self, service, *, 48 | name=None, prefix=None, methods=None, endpoint=None, 49 | respond=None, **extra): 50 | if respond is None: 51 | respond = self.RESPOND 52 | super().__init__(service, 53 | name=name, prefix=prefix, 54 | methods=methods, endpoint=endpoint) 55 | # Override attributes 56 | if respond is not None: 57 | self.respond = respond 58 | self.__extra = extra 59 | 60 | @asyncio.coroutine 61 | def __call__(self, request): 62 | match = self.service.match(request, path=self.path) 63 | if match: 64 | lmatch = self.service.match(request, methods=self.methods) 65 | if not lmatch: 66 | raise http.MethodNotAllowed(request.method, self.methods) 67 | if self.respond is not None: 68 | return (yield from self.respond(request, **match)) 69 | return (yield from self.process(request)) 70 | return (yield from self.next(request)) 71 | 72 | def __repr__(self): 73 | template = ( 74 | '') 76 | compiled = template.format(self=self) 77 | return compiled 78 | 79 | @property 80 | def extra(self): 81 | """Dict if extra arguments passed to the endpoint. 82 | """ 83 | return self.__extra 84 | 85 | @asyncio.coroutine 86 | def respond(self, request, **kwargs): 87 | """Respond to a request (coroutine). 88 | 89 | Parameters 90 | ---------- 91 | request: :class:`.http.Request` 92 | Request instance. 93 | kwargs: dict 94 | Keyword arguments. 95 | 96 | Returns 97 | ------- 98 | object 99 | Reply value. 100 | """ 101 | raise http.NotFound() 102 | -------------------------------------------------------------------------------- /interest/handler/__init__.py: -------------------------------------------------------------------------------- 1 | from .handler import Handler 2 | from .record import Record 3 | -------------------------------------------------------------------------------- /interest/handler/handler.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import traceback 3 | from aiohttp.server import ServerHttpProtocol 4 | from ..backend import http 5 | from ..helpers import Config 6 | from .record import Record 7 | 8 | 9 | class Handler(Config, ServerHttpProtocol): 10 | """Handler is a component responsible for the request handling. 11 | 12 | Handler handles requests on low-level. Handler is 13 | :class:`asyncio.Protocol` implementation derived from 14 | the aiohttp's :class:`aiohttp.server.ServerHttpProtocol`. 15 | 16 | .. seealso:: Implements: 17 | :class:`.Config` 18 | 19 | Parameters 20 | ---------- 21 | service: :class:`.Service` 22 | Service instance. 23 | connection_timeout: int 24 | Time to keep connection opened in seconds. 25 | request_timeout: int 26 | Slow request timeout in seconds. 27 | 28 | Example 29 | ------- 30 | Let's create a handler and then an asyncio server:: 31 | 32 | # Create handler 33 | handler = Handler( 34 | '', connection_timeout=25, request_timeout=5) 35 | 36 | # Create server 37 | loop = asyncio.get_event_loop() 38 | server = loop.create_server(handler.fork) 39 | server = self.loop.run_until_complete(server) 40 | """ 41 | 42 | # Public 43 | 44 | CONNECTION_TIMEOUT = 75 45 | """Time to keep connection opened in seconds (default). 46 | """ 47 | REQUEST_TIMEOUT = 15 48 | """Slow request timeout in seconds (default). 49 | """ 50 | 51 | def __new__(cls, *args, **kwargs): 52 | self = object.__new__(cls) 53 | self.__args = args 54 | self.__kwargs = kwargs 55 | return self 56 | 57 | def __init__(self, service, *, 58 | connection_timeout=None, request_timeout=None): 59 | if connection_timeout is None: 60 | connection_timeout = self.CONNECTION_TIMEOUT 61 | if request_timeout is None: 62 | request_timeout = self.REQUEST_TIMEOUT 63 | super().__init__( 64 | loop=service.loop, 65 | keep_alive=connection_timeout, 66 | timeout=request_timeout) 67 | self.__service = service 68 | 69 | @property 70 | def service(self): 71 | """:class:`.Service` instance (read-only). 72 | """ 73 | return self.__service 74 | 75 | def fork(self): 76 | """Handler factory for asyncio's loop.create_server. 77 | """ 78 | return type(self)(*self.__args, **self.__kwargs) 79 | 80 | # Internal (aiohttp.server.ServerHttpProtocol's hooks) 81 | 82 | @asyncio.coroutine 83 | def handle_request(self, message, payload): 84 | start_time = self.service.loop.time() 85 | request = http.Request( 86 | None, message, payload, 87 | self.transport, self.reader, self.writer) 88 | try: 89 | response = yield from self.service(request) 90 | except http.Exception as exception: 91 | response = exception 92 | if not isinstance(response, http.StreamResponse): 93 | raise RuntimeError('Service returned not a StreamResponse') 94 | resp_msg = response.start(request) 95 | yield from response.write_eof() 96 | self.keep_alive(resp_msg.keep_alive()) 97 | stop_time = self.service.loop.time() 98 | self.log_access(message, None, resp_msg, stop_time - start_time) 99 | 100 | def log_access(self, message, environ, response, time): 101 | try: 102 | record = Record( 103 | request=message, response=response, 104 | transport=self.transport, duration=time) 105 | self.service.log('access', record) 106 | except: 107 | self.service.log('error', traceback.format_exc()) 108 | 109 | def log_debug(self, message, *args, **kwargs): 110 | self.service.log('debug', message, *args, **kwargs) 111 | 112 | def log_exception(self, message, *args, **kwargs): 113 | self.service.log('exception', message, *args, **kwargs) 114 | -------------------------------------------------------------------------------- /interest/handler/record.py: -------------------------------------------------------------------------------- 1 | from aiohttp.helpers import atoms 2 | 3 | 4 | class Record(dict): 5 | """Record is a safe dictionary with data about request handling. 6 | 7 | Record object represents interaction between :class:`.Handler` 8 | and client as dict ready to use with text templates. Dict is safe. 9 | If key is missing client gets '-' symbol. All values are strings. 10 | See available items below. 11 | 12 | .. seealso:: Implements: 13 | :class:`dict` 14 | 15 | Parameters 16 | ---------- 17 | .. warning:: Parameters are not a part of the public API. 18 | 19 | Items 20 | ----- 21 | agent: str 22 | Client's agent representation. 23 | duration: str 24 | Handling duration in mileseconds. 25 | host: str 26 | Client remote adress. 27 | lenght: str 28 | Response length in bytes. 29 | process: str 30 | Process identifier. 31 | referer: str 32 | Client's referer representation. 33 | request: str 34 | Client's request representation. 35 | status: str 36 | Response status. 37 | time: str 38 | Time when handling have been done (GMT). 39 | : str 40 | Request's header by key. 41 | : str 42 | Response's header by key. 43 | 44 | Example 45 | ------- 46 | Usually we have Intercation instance in :meth:`.Logger.access` call. 47 | Imagine our interactive console works in context of this method:: 48 | 49 | >>> record['host'] 50 | '127.0.0.1', 51 | >>> record['length'] 52 | '193' 53 | >>> record[''] 54 | 'application/json; charset=utf-8' 55 | 56 | Notes 57 | ----- 58 | Safe dict idea with random access to request/respones headers 59 | is borrowed from Gunicorn/aiohttp libraries. 60 | """ 61 | 62 | # Public 63 | 64 | def __init__(self, *, request, response, transport, duration): 65 | self.__reqheads = getattr(request, 'headers', None) 66 | self.__resheads = getattr(response, 'headers', None) 67 | self.__request = request 68 | self.__response = response 69 | self.__transport = transport 70 | self.__duration = duration 71 | self.__add_values() 72 | 73 | def __missing__(self, key): 74 | default = '-' 75 | if key.startswith('<'): 76 | headers = None 77 | if key.endswith(':req>'): 78 | headers = self.__reqheads 79 | elif key.endswith(':res>'): 80 | headers = self.__resheads 81 | if headers is not None: 82 | return headers.get(key[1:-5], default) 83 | return default 84 | 85 | # Private 86 | 87 | def __add_values(self): 88 | data = atoms( 89 | self.__request, None, self.__response, 90 | self.__transport, self.__duration) 91 | self['agent'] = data.get('a', '-') 92 | self['duration'] = data.get('D', '-') 93 | self['host'] = data.get('h', '-') 94 | self['lenght'] = data.get('b', '-') 95 | self['process'] = data.get('p', '-') 96 | self['referer'] = data.get('f', '-') 97 | self['request'] = data.get('r', '-') 98 | self['status'] = data.get('s', '-') 99 | self['time'] = data.get('t', '-') 100 | -------------------------------------------------------------------------------- /interest/helpers/__init__.py: -------------------------------------------------------------------------------- 1 | from .chain import Chain 2 | from .config import Config 3 | from .loop import loop 4 | from .match import Match 5 | from .name import name 6 | from .order import OrderedMetaclass 7 | from .plugin import PluginImporter 8 | from .port import port 9 | from .python import python 10 | from .sticker import STICKER 11 | -------------------------------------------------------------------------------- /interest/helpers/chain.py: -------------------------------------------------------------------------------- 1 | from collections.abc import Iterable, Sized 2 | 3 | 4 | class Chain(Iterable, Sized): 5 | """Chain is a enhanced sequence. 6 | """ 7 | 8 | # Public 9 | 10 | def __init__(self, *args, **kwargs): 11 | self.__list = [] 12 | self.__dict = {} 13 | super().__init__(*args, **kwargs) 14 | 15 | def __getitem__(self, param): 16 | if isinstance(param, int): 17 | return self.__list[param] 18 | return self.__dict[param] 19 | 20 | def __setitem__(self, param, item): 21 | if isinstance(param, int): 22 | self.__list.pop(param) 23 | index = param 24 | else: 25 | prev = self.__dict.pop(param) 26 | index = self.__list.index(prev) 27 | self.push(item, index=index) 28 | 29 | def __iter__(self): 30 | return self.__list.__iter__() 31 | 32 | def __bool__(self): 33 | return bool(self.__list) 34 | 35 | def __len__(self): 36 | return len(self.__list) 37 | 38 | def push(self, item, *, index=None): 39 | """Push item to the chain. 40 | """ 41 | if index is None: 42 | self.__list.append(item) 43 | else: 44 | self.__list.insert(index, item) 45 | name = getattr(item, 'name', None) 46 | if name is not None: 47 | self.__dict[name] = item 48 | 49 | def pull(self, *, index=None): 50 | """Pull item from the chain. 51 | """ 52 | item = self.__list.pop(index) 53 | name = getattr(item, 'name', None) 54 | if name is not None: 55 | del self.__dict[name] 56 | return item 57 | -------------------------------------------------------------------------------- /interest/helpers/config.py: -------------------------------------------------------------------------------- 1 | class Config: 2 | """Config is a interface to fork classes. 3 | """ 4 | 5 | # Public 6 | 7 | @classmethod 8 | def config(cls, **defaults): 9 | """Return config with updated defaults. 10 | 11 | Parameters 12 | ---------- 13 | defaults: dict 14 | Defaults values. 15 | 16 | Returns 17 | ------- 18 | :class:`.Config` 19 | Config with updated defaults. 20 | """ 21 | return ConfigEdition(cls, **defaults) 22 | 23 | 24 | class ConfigEdition(Config): 25 | """Config edition representation. 26 | """ 27 | 28 | # Public 29 | 30 | def __init__(self, factory, **defaults): 31 | self.__factory = factory 32 | self.__add_defaults(defaults) 33 | 34 | def __call__(self, *args, **kwargs): 35 | kwargs = self.__merge_dicts(self.__defaults, kwargs) 36 | return self.__factory(*args, **kwargs) 37 | 38 | def __repr__(self): 39 | template = ( 40 | '<{factory.__name__} configuration with ' 41 | 'defaults="{defaults}">') 42 | compiled = template.format( 43 | factory=self.__factory, 44 | defaults=self.__defaults) 45 | return compiled 46 | 47 | def config(self, **defaults): 48 | defaults = self.__merge_dicts(self.__defaults, defaults) 49 | return Config(self.__factory, **defaults) 50 | 51 | # Private 52 | 53 | def __add_defaults(self, defaults): 54 | self.__defaults = defaults 55 | for key, value in defaults.items(): 56 | setattr(self, key.upper(), value) 57 | 58 | def __merge_dicts(self, dict1, dict2): 59 | merged = dict1.copy() 60 | merged.update(dict2) 61 | return merged 62 | -------------------------------------------------------------------------------- /interest/helpers/loop.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | 3 | 4 | @property 5 | def loop(self): 6 | return asyncio.get_event_loop() 7 | -------------------------------------------------------------------------------- /interest/helpers/match.py: -------------------------------------------------------------------------------- 1 | class Match(dict): 2 | """Match is a dictionary which always resolves to True in boolean context. 3 | 4 | .. seealso:: Implements: 5 | :class:`dict` 6 | """ 7 | 8 | # Public 9 | 10 | def __bool__(self): 11 | return True 12 | 13 | def __repr__(self): 14 | template = '' 15 | compiled = template.format(dict=super().__repr__()) 16 | return compiled 17 | -------------------------------------------------------------------------------- /interest/helpers/name.py: -------------------------------------------------------------------------------- 1 | @property 2 | def name(self): 3 | return type(self).__name__.lower() 4 | -------------------------------------------------------------------------------- /interest/helpers/order.py: -------------------------------------------------------------------------------- 1 | from abc import ABCMeta 2 | from collections import OrderedDict 3 | 4 | 5 | class OrderedMetaclass(ABCMeta): 6 | 7 | # Public 8 | 9 | @classmethod 10 | def __prepare__(cls, name, bases): 11 | return OrderedDict() 12 | 13 | def __new__(cls, name, bases, attrs): 14 | attrs['__order__'] = tuple(attrs) 15 | return super().__new__(cls, name, bases, attrs) 16 | -------------------------------------------------------------------------------- /interest/helpers/plugin.py: -------------------------------------------------------------------------------- 1 | import sys 2 | from importlib import import_module 3 | 4 | 5 | class PluginImporter: 6 | """Plugin importer. 7 | 8 | Example 9 | ------- 10 | Add to myapp.plugins something like that:: 11 | 12 | importer = PluginImporter(virtual='myapp.plugins.', actual='myapp_') 13 | importer.register() 14 | del PluginImporter 15 | del importer 16 | """ 17 | 18 | # Public 19 | 20 | def __init__(self, *, virtual, actual): 21 | self.__virtual = virtual 22 | self.__actual = actual 23 | 24 | def __eq__(self, other): 25 | if not isinstance(other, type(self)): 26 | return False 27 | return (self.virtual == other.virtual and 28 | self.actual == other.actual) 29 | 30 | @property 31 | def virtual(self): 32 | return self.__virtual 33 | 34 | @property 35 | def actual(self): 36 | return self.__actual 37 | 38 | def register(self): 39 | if self not in sys.meta_path: 40 | sys.meta_path.append(self) 41 | 42 | def find_module(self, fullname, path=None): 43 | if fullname.startswith(self.virtual): 44 | return self 45 | return None 46 | 47 | def load_module(self, fullname): 48 | if fullname in sys.modules: 49 | return sys.modules[fullname] 50 | if not fullname.startswith(self.virtual): 51 | raise ImportError(fullname) 52 | realname = fullname.replace(self.virtual, self.actual) 53 | module = import_module(realname) 54 | sys.modules[realname] = module 55 | sys.modules[fullname] = module 56 | return module 57 | -------------------------------------------------------------------------------- /interest/helpers/port.py: -------------------------------------------------------------------------------- 1 | import socket 2 | 3 | 4 | @property 5 | def port(self): 6 | sock = socket.socket() 7 | sock.bind(('', 0)) 8 | port = sock.getsockname()[1] 9 | sock.close() 10 | return port -------------------------------------------------------------------------------- /interest/helpers/python.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | 4 | @property 5 | def python(self): 6 | try: 7 | venv = os.environ['VIRTUAL_ENV'] 8 | return os.path.join(venv, 'bin', 'python3') 9 | except KeyError: 10 | return 'python3' 11 | -------------------------------------------------------------------------------- /interest/helpers/sticker.py: -------------------------------------------------------------------------------- 1 | STICKER = '_{name}.sticker'.format(name=__name__) 2 | -------------------------------------------------------------------------------- /interest/logger/__init__.py: -------------------------------------------------------------------------------- 1 | from .logger import Logger 2 | -------------------------------------------------------------------------------- /interest/logger/logger.py: -------------------------------------------------------------------------------- 1 | import logging 2 | from ..helpers import Config 3 | 4 | 5 | class Logger(Config): 6 | """Logger is a component responsible for the logging. 7 | 8 | Logger provides standard log methods named after level and access 9 | method to log access' :class:`Record` instances. 10 | 11 | .. seealso:: Implements: 12 | :class:`.Config` 13 | 14 | Parameters 15 | ---------- 16 | service: :class:`.Service` 17 | Service instance. 18 | system: object 19 | System logger instance. 20 | template: str 21 | Template for access formatting. 22 | 23 | Examples 24 | -------- 25 | For production use let's print the access log to the stdout 26 | and skip the debug log at all:: 27 | 28 | class ProductionLogger(Logger): 29 | 30 | # Public 31 | 32 | SYSTEM = logging.getLogger('myapp') 33 | TEMPLATE = '%(host)s %(time)s and so on' 34 | 35 | def access(self, record): 36 | print(self.template % record) 37 | 38 | def debug(self, message, *args, **kwargs): 39 | pass 40 | 41 | logger = ProductionLogger() 42 | """ 43 | 44 | # Public 45 | 46 | SYSTEM = logging.getLogger('interest') 47 | """Default system parameter. 48 | """ 49 | TEMPLATE = ('%(host)s %(time)s "%(request)s" %(status)s ' 50 | '%(length)s "%(referer)s" "%(agent)s"') 51 | """Default template parameter. 52 | """ 53 | 54 | def __init__(self, service, *, system=None, template=None): 55 | if system is None: 56 | system = self.SYSTEM 57 | if template is None: 58 | template = self.TEMPLATE 59 | self.__service = service 60 | self.__system = system 61 | self.__template = template 62 | 63 | @property 64 | def service(self): 65 | """:class:`.Service` instance (read-only). 66 | """ 67 | return self.__service 68 | 69 | @property 70 | def system(self): 71 | """System logger (read-only). 72 | """ 73 | return self.__system 74 | 75 | @property 76 | def template(self): 77 | """Template for access formatting (read-only). 78 | """ 79 | return self.__template 80 | 81 | def access(self, record): 82 | """Log access event. 83 | 84 | Parameters 85 | ---------- 86 | record: :class:`.Record` 87 | Record dict to use with template. 88 | """ 89 | self.info(self.template % record) 90 | 91 | def debug(self, message, *args, **kwargs): 92 | """Log debug event. 93 | 94 | Compatible with logging.debug signature. 95 | """ 96 | self.system.debug(message, *args, **kwargs) 97 | 98 | def info(self, message, *args, **kwargs): 99 | """Log info event. 100 | 101 | Compatible with logging.info signature. 102 | """ 103 | self.system.info(message, *args, **kwargs) 104 | 105 | def warning(self, message, *args, **kwargs): 106 | """Log warning event. 107 | 108 | Compatible with logging.warning signature. 109 | """ 110 | self.system.warning(message, *args, **kwargs) 111 | 112 | def error(self, message, *args, **kwargs): 113 | """Log error event. 114 | 115 | Compatible with logging.error signature. 116 | """ 117 | self.system.error(message, *args, **kwargs) 118 | 119 | def exception(self, message, *args, **kwargs): 120 | """Log exception event. 121 | 122 | Compatible with logging.exception signature. 123 | """ 124 | self.system.exception(message, *args, **kwargs) 125 | 126 | def critical(self, message, *args, **kwargs): 127 | """Log critical event. 128 | 129 | Compatible with logging.critical signature. 130 | """ 131 | self.system.critical(message, *args, **kwargs) 132 | -------------------------------------------------------------------------------- /interest/middleware.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import inspect 3 | from .backend import http 4 | from .helpers import Chain, Config, OrderedMetaclass, STICKER, name 5 | 6 | 7 | class Middleware(Chain, Config, metaclass=OrderedMetaclass): 8 | """Middleware is a extended coroutine to process requests. 9 | 10 | Middleware is a key concept of the interest. For example 11 | :class:`.Service` and :class:`.Endpoint` are the Middlewares. 12 | The interest framework uses middlewares only as coroutines 13 | calling :meth:`.Middleware.__call__` method. 14 | But user application may use all provided API because of 15 | knowledge of the application topology. 16 | 17 | .. seealso:: Implements: 18 | :class:`.Chain`, 19 | :class:`.Config` 20 | 21 | Parameters 22 | ---------- 23 | service: :class:`.Service` 24 | Service instance. 25 | name: str 26 | Middleware's name. 27 | prefix: str 28 | HTTP path prefix constraint. 29 | methods: list 30 | HTTP methods allowed constraint. 31 | middlewares: list 32 | List of submiddlewares. 33 | endpoint: :class:`.Endpoint` subclass. 34 | Default endpoint class for bindings. 35 | 36 | Examples 37 | -------- 38 | Processing middleware:: 39 | 40 | class Middleware(Middleware): 41 | 42 | # Public 43 | 44 | @asyncio.coroutine 45 | def process(self, request): 46 | try: 47 | # Process request here 48 | response = yield from self.next(request) 49 | # Process response here 50 | except http.Exception as exception: 51 | # Process exception here 52 | response = exception 53 | return response 54 | 55 | middleware = Middleware('') 56 | response = yield from middleware('') 57 | """ 58 | 59 | # Public 60 | 61 | NAME = name 62 | """Default name parameter. 63 | """ 64 | PREFIX = '' 65 | """Default prefix parameter. 66 | """ 67 | METHODS = [] 68 | """Default methods parameter. 69 | """ 70 | MIDDLEWARES = [] 71 | """Default middlewares parameter. 72 | """ 73 | ENDPOINT = None 74 | """Default endpoint parameter. 75 | """ 76 | 77 | def __init__(self, service, *, 78 | name=None, prefix=None, methods=None, 79 | middlewares=None, endpoint=None): 80 | if name is None: 81 | name = self.NAME 82 | if prefix is None: 83 | prefix = self.PREFIX 84 | if methods is None: 85 | methods = self.METHODS.copy() 86 | if middlewares is None: 87 | middlewares = self.MIDDLEWARES.copy() 88 | if endpoint is None: 89 | endpoint = self.ENDPOINT 90 | if endpoint is None: 91 | from .endpoint import Endpoint 92 | endpoint = Endpoint 93 | self.main = self 94 | self.over = self 95 | super().__init__() 96 | self.__service = service 97 | self.__name = name 98 | self.__prefix = prefix 99 | self.__methods = methods 100 | self.__endpoint = endpoint 101 | self.__add_middlewares(middlewares) 102 | self.__add_endpoints() 103 | 104 | @asyncio.coroutine 105 | def __call__(self, request): 106 | """Process a request (coroutine). 107 | 108 | Parameters 109 | ---------- 110 | request: :class:`.http.Request` 111 | Request instance. 112 | 113 | Returns 114 | ------- 115 | object 116 | Reply value. 117 | """ 118 | match = self.service.match( 119 | request, root=self.path, methods=self.methods) 120 | if match: 121 | return (yield from self.process(request)) 122 | return (yield from self.next(request)) 123 | 124 | def __repr__(self): 125 | template = ( 126 | '') 129 | compiled = template.format( 130 | self=self, middlewares=list(self)) 131 | return compiled 132 | 133 | @property 134 | def service(self): 135 | """:class:`.Service` instance (read-only). 136 | """ 137 | return self.__service 138 | 139 | @property 140 | def name(self): 141 | """Middleware's name (read-only). 142 | """ 143 | return self.__name 144 | 145 | @property 146 | def path(self): 147 | """HTTP full path constraint. (read-only). 148 | """ 149 | path = self.__prefix 150 | if self is not self.over: 151 | path = self.over.path + path 152 | return path 153 | 154 | @property 155 | def methods(self): 156 | """HTTP methods allowed constraint (read-only). 157 | """ 158 | return self.__methods 159 | 160 | @asyncio.coroutine 161 | def process(self, request): 162 | """Process a request (coroutine). 163 | 164 | .. note:: This coroutine will be reached only if request 165 | matches :attr:`.path` and :attr:`.methods` constraints. 166 | 167 | By default this method sends request to submiddleware chain and 168 | returns reply. It's standard point to override Middleware behavior 169 | by user application. 170 | 171 | Parameters 172 | ---------- 173 | request: :class:`.http.Request` 174 | Request instance. 175 | 176 | Returns 177 | ------- 178 | object 179 | Reply value. 180 | """ 181 | if self: 182 | return (yield from self[0](request)) 183 | raise http.NotFound() 184 | 185 | @asyncio.coroutine 186 | def main(self, request): 187 | """Link to the main middleware (coroutine). 188 | """ 189 | raise http.NotFound() 190 | 191 | @asyncio.coroutine 192 | def over(self, request): 193 | """Link to the over middleware (coroutine). 194 | """ 195 | raise http.NotFound() 196 | 197 | @asyncio.coroutine 198 | def prev(self, request): 199 | """Link to the previous middleware (coroutine). 200 | """ 201 | raise http.NotFound() 202 | 203 | @asyncio.coroutine 204 | def next(self, request): 205 | """Link to the next middleware (coroutine). 206 | """ 207 | raise http.NotFound() 208 | 209 | # Internal (Chain's hooks) 210 | 211 | def push(self, item, *, index=None): 212 | super().push(item, index=index) 213 | self.__update_topology() 214 | 215 | def pull(self, *, index=None): 216 | super().pull(index=index) 217 | self.__update_topology() 218 | 219 | # Private 220 | 221 | def __add_middlewares(self, middlewares): 222 | for middleware in middlewares: 223 | if not asyncio.iscoroutine(middleware): 224 | middleware = middleware(self.service) 225 | self.push(middleware) 226 | 227 | def __add_endpoints(self): 228 | for name in self.__order__: 229 | if name.startswith('_'): 230 | continue 231 | func = getattr(type(self), name) 232 | if inspect.isdatadescriptor(func): 233 | continue 234 | bindings = getattr(func, STICKER, []) 235 | for binding in reversed(bindings): 236 | factory = binding.pop( 237 | 'endpoint', self.__endpoint) 238 | respond = getattr(self, name) 239 | endpoint = factory(self.service, 240 | name=name, respond=respond, **binding) 241 | self.push(endpoint) 242 | 243 | def __update_topology(self): 244 | for index, middleware in enumerate(self): 245 | if isinstance(middleware, Middleware): 246 | # Override attributes 247 | middleware.main = self.main 248 | middleware.over = self 249 | if index - 1 > -1: 250 | middleware.prev = self[index - 1] 251 | if index + 1 < len(self): 252 | middleware.next = self[index + 1] 253 | middleware.__update_topology() 254 | -------------------------------------------------------------------------------- /interest/plugins/__init__.py: -------------------------------------------------------------------------------- 1 | from ..helpers import PluginImporter 2 | importer = PluginImporter(virtual='interest.plugins.', actual='interest_') 3 | importer.register() 4 | del PluginImporter 5 | del importer 6 | -------------------------------------------------------------------------------- /interest/provider.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | from .helpers import Config 3 | 4 | 5 | class Provider(Config): 6 | """Provider is a extended coroutine to update the service. 7 | 8 | Parameters 9 | ---------- 10 | service: :class:`.Service` 11 | Service instance. 12 | provide: coroutine 13 | Coroutine for actual work. 14 | """ 15 | 16 | # Public 17 | 18 | PROVIDE = None 19 | """Default provide parameter. 20 | """ 21 | 22 | def __init__(self, service, *, provide=None): 23 | if provide is None: 24 | provide = self.PROVIDE 25 | self.__service = service 26 | # Override attributes 27 | if provide is not None: 28 | self.provide = provide 29 | 30 | @asyncio.coroutine 31 | def __call__(self, service): 32 | """Update the service. 33 | 34 | Parameters 35 | ---------- 36 | service: :class:`.Service` 37 | Service instance. 38 | """ 39 | return (yield from self.provide(service)) 40 | 41 | def __repr__(self): 42 | template = '' 43 | compiled = template.format(self=self) 44 | return compiled 45 | 46 | @property 47 | def service(self): 48 | """:class:`.Service` instance (read-only). 49 | """ 50 | return self.__service 51 | 52 | @asyncio.coroutine 53 | def provide(self, service): 54 | """Update the service. 55 | 56 | Parameters 57 | ---------- 58 | service: :class:`.Service` 59 | Service instance. 60 | """ 61 | raise NotImplementedError() 62 | -------------------------------------------------------------------------------- /interest/router/__init__.py: -------------------------------------------------------------------------------- 1 | from .router import Router 2 | from .parser import Parser 3 | -------------------------------------------------------------------------------- /interest/router/parser.py: -------------------------------------------------------------------------------- 1 | from ..helpers import Config 2 | 3 | 4 | class Parser(Config): 5 | """Parser is a component responsible for the parsing. 6 | 7 | Router uses a parsers dictionary to handle user placeholders in paths. 8 | Placeholder is a path insertion in following form "". 9 | 10 | .. seealso:: Implements: 11 | :class:`.Config` 12 | 13 | Parameters 14 | ---------- 15 | service: :class:`Service` 16 | Service instance. 17 | pattern: str 18 | Regex pattern to match. 19 | convert: callable 20 | Callable to convert a string to a value. 21 | restore: callable 22 | Callable to restore a string from a value. 23 | 24 | Examples 25 | -------- 26 | Let's create a binary parser:: 27 | 28 | class BinaryParser(Parser): 29 | 30 | # Public 31 | 32 | PATTERN = r'[01]+' 33 | CONVERT = int 34 | 35 | router = Router(parsers={'bin': BinaryParser}) 36 | router.match('', '/some/path/') 37 | """ 38 | 39 | # Public 40 | 41 | PATTERN = None 42 | """Default pattern parameter. 43 | """ 44 | CONVERT = str 45 | """Default convert parameter. 46 | """ 47 | RESTORE = str 48 | """Default restore parameter. 49 | """ 50 | 51 | def __init__(self, service, *, 52 | pattern=None, convert=None, restore=None): 53 | if pattern is None: 54 | pattern = self.PATTERN 55 | if convert is None: 56 | convert = self.CONVERT 57 | if restore is None: 58 | restore = self.RESTORE 59 | self.__service = service 60 | self.__pattern = pattern 61 | assert isinstance(self.pattern, str) 62 | # Override attributes 63 | if convert is not None: 64 | self.convert = convert 65 | if restore is not None: 66 | self.restore = restore 67 | 68 | def __repr__(self): 69 | template = ( 70 | '') 73 | compiled = template.format(self=self) 74 | return compiled 75 | 76 | @property 77 | def service(self): 78 | """:class:`.Service` instance (read-only). 79 | """ 80 | return self.__service 81 | 82 | @property 83 | def pattern(self): 84 | """Parser's pattern (read-only). 85 | """ 86 | return self.__pattern 87 | 88 | def convert(self, string): 89 | """Convert a given string to a value. 90 | 91 | Parameters 92 | ---------- 93 | string: str 94 | String to convert. 95 | 96 | Returns 97 | ------- 98 | object 99 | Converted string. 100 | """ 101 | raise NotImplementedError() 102 | 103 | def restore(self, value): 104 | """Restore a given value to a string. 105 | 106 | Parameters 107 | ---------- 108 | value: object 109 | Value to restore. 110 | 111 | Returns 112 | ------- 113 | str 114 | Restored string. 115 | """ 116 | raise NotImplementedError() 117 | 118 | 119 | class StringParser(Parser): 120 | 121 | # Public 122 | 123 | PATTERN = '[^/]+' 124 | 125 | 126 | class PathParser(Parser): 127 | 128 | # Public 129 | 130 | PATTERN = r'.*' 131 | 132 | 133 | class IntegerParser(Parser): 134 | 135 | # Public 136 | 137 | PATTERN = r'[0-9]+' 138 | CONVERT = int 139 | 140 | 141 | class FloatParser(Parser): 142 | 143 | # Public 144 | 145 | PATTERN = r'[0-9.]+' 146 | CONVERT = float 147 | -------------------------------------------------------------------------------- /interest/router/pattern.py: -------------------------------------------------------------------------------- 1 | import re 2 | from abc import ABCMeta, abstractmethod 3 | from ..helpers import Match 4 | 5 | 6 | class Pattern(metaclass=ABCMeta): 7 | """Pattern representation (abstract). 8 | """ 9 | 10 | # Public 11 | 12 | @abstractmethod 13 | def match(self, string, left=False): 14 | pass # pragma: no cover 15 | 16 | @abstractmethod 17 | def format(self, **match): 18 | pass # pragma: no cover 19 | 20 | @classmethod 21 | def create(cls, pattern, parsers): 22 | matches = list(cls.__PARSER_PATTERN.finditer(pattern)) 23 | if not matches: 24 | return StringPattern(pattern) 25 | lastend = 0 26 | cpattern = '' 27 | cparsers = {} 28 | template = '' 29 | for match in matches: 30 | name = match.group('name') 31 | meta = match.group('meta') 32 | if meta is None: 33 | meta = 'str' 34 | if meta not in parsers: 35 | raise ValueError( 36 | 'Unsupported parser {meta}'.format(meta=meta)) 37 | parser = parsers[meta] 38 | before = pattern[lastend:match.start()] 39 | cpattern += cls.__pattern_escape(before) 40 | cpattern += cls.__PARSER_TEMPLATE.format( 41 | name=name, parser=parser) 42 | cparsers[name] = parser 43 | template += cls.__template_escape(before) 44 | template += '{' + name + '}' 45 | lastend = match.end() 46 | after = pattern[lastend:] 47 | cpattern += re.escape(after) 48 | template += after 49 | return RegexPattern(cpattern, cparsers, template) 50 | 51 | # Private 52 | 53 | __PARSER_PATTERN = re.compile(r'\<(?P\w+)(?::(?P\w+))?\>') 54 | __PARSER_TEMPLATE = '(?P<{name}>{parser.pattern})' 55 | 56 | @classmethod 57 | def __pattern_escape(cls, pattern): 58 | pattern = re.escape(pattern) 59 | return pattern 60 | 61 | @classmethod 62 | def __template_escape(cls, template): 63 | template = template.replace('{', '{{') 64 | template = template.replace('}', '}}') 65 | return template 66 | 67 | 68 | class StringPattern(Pattern): 69 | 70 | # Public 71 | 72 | def __init__(self, pattern): 73 | self.__pattern = pattern 74 | 75 | def __repr__(self): 76 | template = '' 77 | compiled = template.format(pattern=self.__pattern) 78 | return compiled 79 | 80 | def match(self, string, left=False): 81 | match = Match() 82 | if left: 83 | if not string.startswith(self.__pattern): 84 | return None 85 | return match 86 | if string != self.__pattern: 87 | return None 88 | return match 89 | 90 | def format(self, **match): 91 | return self.__pattern 92 | 93 | 94 | class RegexPattern(Pattern): 95 | 96 | # Public 97 | 98 | def __init__(self, pattern, parsers, template): 99 | self.__pattern = pattern 100 | self.__parsers = parsers 101 | self.__template = template 102 | try: 103 | self.__left = re.compile('^' + pattern) 104 | self.__full = re.compile('^' + pattern + '$') 105 | except re.error: 106 | raise ValueError( 107 | 'Invalid pattern "{pattern}"'. 108 | format(pattern=pattern)) 109 | 110 | def __repr__(self): 111 | template = '' 112 | compiled = template.format(pattern=self.__pattern) 113 | return compiled 114 | 115 | def match(self, string, left=False): 116 | match = Match() 117 | pattern = self.__full 118 | if left: 119 | pattern = self.__left 120 | result = pattern.match(string) 121 | if not result: 122 | return None 123 | for name, string in result.groupdict().items(): 124 | try: 125 | value = self.__parsers[name].convert(string) 126 | except Exception: 127 | return None 128 | match[name] = value 129 | return match 130 | 131 | def format(self, **match): 132 | for name, value in match.items(): 133 | match[name] = self.__parsers[name].restore(value) 134 | return self.__template.format_map(match) 135 | -------------------------------------------------------------------------------- /interest/router/router.py: -------------------------------------------------------------------------------- 1 | from urllib.parse import urlencode 2 | from ..helpers import Config, Match 3 | from .parser import StringParser, PathParser, IntegerParser, FloatParser 4 | from .pattern import Pattern 5 | 6 | 7 | class Router(Config): 8 | """Router is a component responsible for the routing. 9 | 10 | Router's only two fings to do are to check if request/constraints 11 | pair have :class:`.Match` or not and to costruct url back from 12 | the middleware name and the given parameters. Router uses 13 | :class:`.Parser` dict to handle placeholders in paths. 14 | Builtin parsers are liste below. 15 | 16 | .. seealso:: Implements: 17 | :class:`.Config` 18 | 19 | Parameters 20 | ---------- 21 | service: :class:`.Service` 22 | Service instance. 23 | parsers: dict 24 | Dictionary of the :class:`.Parser` sublasses. 25 | 26 | Builtin parsers 27 | --------------- 28 | - str 29 | - path 30 | - int 31 | - float 32 | 33 | Examples 34 | -------- 35 | Let's see how match and url work:: 36 | 37 | router = Router() 38 | match = router.match('', '/some/path/') 39 | url = router.url('name', **match) 40 | """ 41 | 42 | # Public 43 | 44 | PARSERS = {} 45 | """Default parsers parameter. 46 | """ 47 | 48 | 49 | def __init__(self, service, *, parsers=None): 50 | if parsers is None: 51 | parsers = self.PARSERS.copy() 52 | self.__service = service 53 | self.__add_parsers(parsers) 54 | self.__patterns = {} 55 | 56 | @property 57 | def service(self): 58 | """:class:`.Service` instance (read-only). 59 | """ 60 | return self.__service 61 | 62 | def match(self, request, *, root=None, path=None, methods=None): 63 | """Return match or None for the request/constraints pair. 64 | 65 | Parameters 66 | ---------- 67 | request: :class:`.http.Request` 68 | Request instance. 69 | root: str 70 | HTTP path root. 71 | path: str 72 | HTTP path. 73 | methods: list 74 | HTTP methods. 75 | 76 | Returns 77 | ------- 78 | :class:`.Match`/None 79 | Match instance (True) or None (False). 80 | """ 81 | match = Match() 82 | if path is not None: 83 | pattern = self.__get_pattern(path) 84 | match = pattern.match(request.path) 85 | elif root is not None: 86 | pattern = self.__get_pattern(root) 87 | match = pattern.match(request.path, left=True) 88 | if not match: 89 | return None 90 | if methods: 91 | methods = map(str.upper, methods) 92 | if request.method not in methods: 93 | return None 94 | return match 95 | 96 | def url(self, name, *, base=None, query=None, **match): 97 | """Construct an url for the given parameters. 98 | 99 | Parameters 100 | ---------- 101 | name: str 102 | Nested middleware's name separated by dots. 103 | base: :class:`.Middleware` 104 | Base middleware to start searching from. 105 | query: dict 106 | Query string data. 107 | match: dict 108 | Path parameters. 109 | 110 | Returns 111 | ------- 112 | str 113 | Constructed url. 114 | """ 115 | if base is None: 116 | base = self.service 117 | for name in name.split('.'): 118 | base = base[name] 119 | pattern = self.__get_pattern(base.path) 120 | url = pattern.format(**match) 121 | if query is not None: 122 | url += '?' + urlencode(query) 123 | return url 124 | 125 | # Private 126 | 127 | __PARSERS = { 128 | 'str': StringParser, 129 | 'path': PathParser, 130 | 'int': IntegerParser, 131 | 'float': FloatParser} 132 | 133 | def __add_parsers(self, parsers): 134 | self.__parsers = {} 135 | eparsers = self.__PARSERS.copy() 136 | eparsers.update(parsers) 137 | for key, cls in eparsers.items(): 138 | self.__parsers[key] = cls(self) 139 | 140 | def __get_pattern(self, path): 141 | if path not in self.__patterns: 142 | self.__patterns[path] = Pattern.create( 143 | path, self.__parsers) 144 | return self.__patterns[path] 145 | -------------------------------------------------------------------------------- /interest/service.py: -------------------------------------------------------------------------------- 1 | import sys 2 | import asyncio 3 | from .logger import Logger 4 | from .handler import Handler 5 | from .helpers import loop 6 | from .router import Router 7 | from .middleware import Middleware 8 | 9 | 10 | class Service(Middleware): 11 | """Service is a middleware capable to listen on TCP/IP socket. 12 | 13 | Service also provides methods :meth:`.Service.match`, :meth:`.Service.url` 14 | and :meth:`.Service.log` to use in request processing. This list can be 15 | updated via :class:`.Provider` system. Concrete service functionality 16 | is based on :class:`.Router`, :class:`.Logger` 17 | and :class:`.Handler` classes. 18 | 19 | .. seealso:: Implements: 20 | :class:`.Middleware`, 21 | :class:`.Chain`, 22 | :class:`.Config` 23 | 24 | Parameters 25 | ---------- 26 | loop: object 27 | Custom asyncio's loop. 28 | router: type 29 | :class:`.Router` subclass. 30 | logger: type 31 | :class:`.Logger` subclass. 32 | handler: type 33 | :class:`.Handler` subclass. 34 | providers: list 35 | List of :class:`.Provider` subclasses. 36 | 37 | Examples 38 | -------- 39 | Minimal service can be initiated without subclassing and parameters 40 | passed. But for example we will add some custom components:: 41 | 42 | # Create server 43 | service = Service( 44 | router='', 45 | logger='', 46 | handler='', 47 | providers=[''], 48 | middlewares=['']) 49 | 50 | # Listen forever 51 | service.listen(host='127.0.0.1', port=9000, forever=True) 52 | """ 53 | 54 | # Public 55 | 56 | LOOP = loop 57 | """Default loop parameter. 58 | """ 59 | ROUTER = Router 60 | """Default router parameter. 61 | """ 62 | LOGGER = Logger 63 | """Default logger parameter. 64 | """ 65 | HANDLER = Handler 66 | """Default handler parameter. 67 | """ 68 | PROVIDERS = [] 69 | """Default providers parameter. 70 | """ 71 | 72 | def __init__(self, service=None, *, 73 | name=None, prefix=None, methods=None, 74 | middlewares=None, endpoint=None, 75 | loop=None, router=None, logger=None, handler=None, 76 | providers=None): 77 | if loop is None: 78 | loop = self.LOOP 79 | if router is None: 80 | router = self.ROUTER 81 | if logger is None: 82 | logger = self.LOGGER 83 | if handler is None: 84 | handler = self.HANDLER 85 | if providers is None: 86 | providers = self.PROVIDERS.copy() 87 | service = self 88 | super().__init__(service, 89 | name=name, prefix=prefix, methods=methods, 90 | middlewares=middlewares, endpoint=endpoint) 91 | self.__loop = loop 92 | self.__router = router(self) 93 | self.__logger = logger(self) 94 | self.__handler = handler(self) 95 | self.__apply_providers(providers) 96 | 97 | def __repr__(self): 98 | template = ( 99 | '') 102 | compiled = template.format( 103 | self=self, middlewares=list(self)) 104 | return compiled 105 | 106 | @property 107 | def loop(self): 108 | """asyncio's loop (read-only). 109 | """ 110 | return self.__loop 111 | 112 | def listen(self, *, host, port, override=False, forever=False, **kwargs): 113 | """Listen on TCP/IP socket. 114 | 115 | Parameters 116 | ---------- 117 | host: str 118 | Host like '127.0.0.1' 119 | port: 120 | Port like 80. 121 | """ 122 | if override: 123 | argv = dict(enumerate(sys.argv)) 124 | host = argv.get(1, host) 125 | port = int(argv.get(2, port)) 126 | server = self.loop.create_server( 127 | self.__handler.fork, host, port, **kwargs) 128 | server = self.loop.run_until_complete(server) 129 | self.log('info', 130 | 'Start listening host="{host}" port="{port}"'. 131 | format(host=host, port=port)) 132 | if forever: 133 | try: 134 | self.loop.run_forever() 135 | except KeyboardInterrupt: 136 | pass 137 | return server 138 | 139 | def match(self, request, *, root=None, path=None, methods=None): 140 | """Return match or None for the request/constraints pair. 141 | 142 | .. seealso:: Proxy: 143 | :meth:`.Router.match` 144 | """ 145 | return self.__router.match( 146 | request, root=root, path=path, methods=methods) 147 | 148 | def url(self, name, *, base=None, query=None, **match): 149 | """Construct an url for the given parameters. 150 | 151 | .. seealso:: Proxy: 152 | :meth:`.Router.url` 153 | """ 154 | return self.__router.url(name, base=base, query=query, **match) 155 | 156 | def log(self, level, *args, **kwargs): 157 | """Log something. 158 | 159 | .. seealso:: Proxy: 160 | :class:`.Logger`.level 161 | """ 162 | target = getattr(self.__logger, level) 163 | target(*args, **kwargs) 164 | 165 | # Private 166 | 167 | def __apply_providers(self, providers): 168 | for provider in providers: 169 | if not asyncio.iscoroutine(provider): 170 | provider = provider(self) 171 | self.loop.run_until_complete( 172 | provider(self)) 173 | -------------------------------------------------------------------------------- /interest/tester.py: -------------------------------------------------------------------------------- 1 | import os 2 | import time 3 | import asyncio 4 | import aiohttp 5 | import subprocess 6 | from .helpers import loop, port, python 7 | from .service import Service 8 | 9 | 10 | class Tester: 11 | """Tester is a teststand to test interest servers. 12 | 13 | Parameters 14 | ---------- 15 | loop: object 16 | Custom asyncio's loop. 17 | python: type 18 | Python interpreter for subprocess. 19 | environ: dic 20 | Environ update for subprocess. 21 | scheme: str 22 | Scheme for requests. 23 | host: str 24 | Host to listen on. 25 | port: int 26 | Port to listen on. 27 | """ 28 | 29 | # Public 30 | 31 | LOOP = loop 32 | """Default loop parameter. 33 | """ 34 | PYTHON = python 35 | """Default python parameter. 36 | """ 37 | ENVIRON = {} 38 | """Default environ parameter. 39 | """ 40 | SCHEME = 'http' 41 | """Default scheme parameter. 42 | """ 43 | HOST = '127.0.0.1' 44 | """Default host parameter. 45 | """ 46 | PORT = port 47 | """Default port parameter. 48 | """ 49 | 50 | def __init__(self, factory, *, 51 | loop=None, python=None, environ=None, 52 | scheme=None, host=None, port=None): 53 | if loop is None: 54 | loop = self.LOOP 55 | if python is None: 56 | python = self.PYTHON 57 | if environ is None: 58 | environ = self.ENVIRON.copy() 59 | if scheme is None: 60 | scheme = self.SCHEME 61 | if host is None: 62 | host = self.HOST 63 | if port is None: 64 | port = self.PORT 65 | self.__factory = factory 66 | self.__loop = loop 67 | self.__python = python 68 | self.__environ = environ 69 | self.__scheme = scheme 70 | self.__host = host 71 | self.__port = port 72 | self.__server = None 73 | 74 | def start(self): 75 | if isinstance(self.__factory, Service): 76 | self.__server = self.__loop.create_server( 77 | self.__factory.handler.fork, 78 | self.__host, self.__port) 79 | else: # Asyncio/Subrocess 80 | environ = os.environ.copy() 81 | environ.update(self.__environ) 82 | self.__server = subprocess.Popen( 83 | [self.__python, self.__factory, 84 | self.__host, str(self.__port)], 85 | env=environ) 86 | time.sleep(1) 87 | 88 | def stop(self): 89 | if isinstance(self.__factory, Service): 90 | self.__server.close() 91 | else: # Asyncio/Subrocess 92 | self.__server.terminate() 93 | 94 | def request(self, method, path, **kwargs): 95 | @asyncio.coroutine 96 | def coroutine(): 97 | response = yield from aiohttp.request( 98 | method, self.__make_url(path), **kwargs) 99 | try: 100 | response.read = yield from response.read() 101 | response.text = yield from response.text() 102 | response.json = yield from response.json() 103 | except Exception: 104 | pass 105 | return response 106 | response = self.__loop.run_until_complete(coroutine()) 107 | return response 108 | 109 | # Private 110 | 111 | def __make_url(self, path=''): 112 | template = '{scheme}://{host}:{port}{path}' 113 | compiled = template.format( 114 | scheme=self.__scheme, 115 | host=self.__host, 116 | port=self.__port, 117 | path=path) 118 | return compiled 119 | -------------------------------------------------------------------------------- /pylintrc: -------------------------------------------------------------------------------- 1 | [BASIC] 2 | 3 | # List of builtins function names that should not be used, separated by a comma. 4 | bad-functions=map,filter,input,open 5 | 6 | [FORMAT] 7 | 8 | # Maximum number of characters on a single line. 9 | max-line-length=79 10 | 11 | [MESSAGES CONTROL] 12 | 13 | # Allow modules to be without docstrings. 14 | disable=C0111 15 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from __future__ import division 3 | from __future__ import print_function 4 | from __future__ import absolute_import 5 | 6 | import os 7 | import io 8 | from setuptools import setup, find_packages 9 | 10 | 11 | # Helpers 12 | def read(*paths): 13 | """Read a text file.""" 14 | basedir = os.path.dirname(__file__) 15 | fullpath = os.path.join(basedir, *paths) 16 | contents = io.open(fullpath, encoding='utf-8').read().strip() 17 | return contents 18 | 19 | 20 | # Prepare 21 | PACKAGE = 'interest' 22 | INSTALL_REQUIRES = [ 23 | 'aiohttp==0.14', 24 | ] 25 | LINT_REQUIRES = [ 26 | 'pylint', 27 | ] 28 | TESTS_REQUIRE = [ 29 | 'tox', 30 | ] 31 | README = read('README.md') 32 | VERSION = read(PACKAGE, 'VERSION') 33 | PACKAGES = find_packages(exclude=['examples', 'tests']) 34 | 35 | 36 | # Run 37 | setup( 38 | name=PACKAGE, 39 | version=VERSION, 40 | packages=PACKAGES, 41 | include_package_data=True, 42 | install_requires=INSTALL_REQUIRES, 43 | tests_require=TESTS_REQUIRE, 44 | extras_require={'develop': LINT_REQUIRES + TESTS_REQUIRE}, 45 | zip_safe=False, 46 | long_description=README, 47 | description='Interest is a event-driven web framework on top of aiohttp/asyncio.', 48 | author='roll', 49 | author_email='roll@post.agency', 50 | url='https://github.com/inventive-ninja/interest', 51 | license='MIT', 52 | keywords=[ 53 | 'web framework', 54 | ], 55 | classifiers=[ 56 | 'Development Status :: 4 - Beta', 57 | 'Environment :: Web Environment', 58 | 'Intended Audience :: Developers', 59 | 'License :: OSI Approved :: MIT License', 60 | 'Operating System :: OS Independent', 61 | 'Programming Language :: Python :: 2', 62 | 'Programming Language :: Python :: 2.7', 63 | 'Programming Language :: Python :: 3', 64 | 'Programming Language :: Python :: 3.3', 65 | 'Programming Language :: Python :: 3.4', 66 | 'Programming Language :: Python :: 3.5', 67 | 'Topic :: Internet :: WWW/HTTP :: Dynamic Content', 68 | 'Topic :: Software Development :: Libraries :: Python Modules' 69 | ], 70 | ) 71 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/roll/interest-py/e6e1def4f2999222aac2fb1d290ae94250673b89/tests/__init__.py -------------------------------------------------------------------------------- /tests/component/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/roll/interest-py/e6e1def4f2999222aac2fb1d290ae94250673b89/tests/component/__init__.py -------------------------------------------------------------------------------- /tests/component/handler/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/roll/interest-py/e6e1def4f2999222aac2fb1d290ae94250673b89/tests/component/handler/__init__.py -------------------------------------------------------------------------------- /tests/component/handler/test_handler.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import unittest 3 | from importlib import import_module 4 | from unittest.mock import Mock, patch 5 | component = import_module('interest.handler.handler') 6 | 7 | 8 | class HandlerTest(unittest.TestCase): 9 | 10 | # Actions 11 | 12 | def setUp(self): 13 | self.args = ('arg1',) 14 | self.kwargs = {'kwarg1': 'kwarg1'} 15 | self.service = Mock() 16 | self.handler = component.Handler(self.service) 17 | 18 | # Tests 19 | 20 | def test_service(self): 21 | self.assertEqual(self.handler.service, self.service) 22 | 23 | def test_fork(self): 24 | fork = self.handler.fork() 25 | self.assertEqual(type(self.handler), type(fork)) 26 | self.assertEqual(self.service, fork.service) 27 | 28 | @unittest.skip 29 | @patch.object(component.http, 'Request') 30 | def test_handle_request(self, Request): 31 | c = asyncio.coroutine 32 | match = Mock() 33 | response = Mock() 34 | response.write_eof = c(lambda: None) 35 | match.route.handler = c(lambda req: req) 36 | self.handler.log_access = Mock() 37 | self.service.loop.time.return_value = 10 38 | self.service.process = c(lambda request: response) 39 | loop = asyncio.get_event_loop() 40 | loop.run_until_complete( 41 | self.handler.handle_request('message', 'payload')) 42 | # Check Request call 43 | Request.assert_called_with( 44 | None, 'message', 'payload', 45 | self.handler.transport, self.handler.reader, self.handler.writer) 46 | # Check log_access call 47 | self.handler.log_access.assert_called_with( 48 | 'message', None, response.start.return_value, 0) 49 | 50 | @patch.object(component, 'Record') 51 | def test_log_access(self, Record): 52 | self.handler.log_access('message', 'environ', 'response', 'time') 53 | # Check Record call 54 | Record.assert_called_with( 55 | request='message', response='response', 56 | transport=self.handler.transport, duration='time') 57 | # Check service.logger call 58 | self.service.log.assert_called_with( 59 | 'access', Record.return_value) 60 | 61 | @patch.object(component, 'traceback') 62 | @patch.object(component, 'Record') 63 | def test_log_access_with_error(self, Record, traceback): 64 | Record.side_effect = RuntimeError() 65 | self.handler.log_access('message', 'environ', 'response', 'time') 66 | # Check service.logger call 67 | self.service.log.assert_called_with( 68 | 'error', traceback.format_exc.return_value) 69 | 70 | def test_log_debug(self): 71 | self.handler.log_debug('message', *self.args, **self.kwargs) 72 | # Check service.logger call 73 | self.service.log.assert_called_with( 74 | 'debug', 'message', *self.args, **self.kwargs) 75 | 76 | def test_log_exception(self): 77 | self.handler.log_exception('message', *self.args, **self.kwargs) 78 | # Check service.logger call 79 | self.service.log.assert_called_with( 80 | 'exception', 'message', *self.args, **self.kwargs) 81 | -------------------------------------------------------------------------------- /tests/component/handler/test_record.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | from unittest.mock import Mock, patch 3 | from importlib import import_module 4 | component = import_module('interest.handler.record') 5 | 6 | 7 | class RecordTest(unittest.TestCase): 8 | 9 | # Actions 10 | 11 | def setUp(self): 12 | self.addCleanup(patch.stopall) 13 | self.request = Mock(headers={'key': 'value'}) 14 | self.response = Mock(headers={'key': 'value'}) 15 | self.atoms = patch.object(component, 'atoms').start() 16 | self.atoms.return_value = {'h': 'host'} 17 | self.record = component.Record( 18 | request=self.request, response=self.response, 19 | transport='transport', duration='duration') 20 | 21 | # Tests 22 | 23 | def test(self): 24 | # Check atoms call 25 | self.atoms.assert_called_with( 26 | self.request, None, self.response, 'transport', 'duration') 27 | 28 | def test_key_existent(self): 29 | self.assertEqual(self.record['host'], 'host') 30 | 31 | def test_key_extended(self): 32 | self.assertEqual(self.record[''], 'value') 33 | self.assertEqual(self.record[''], 'value') 34 | 35 | def test_key_non_existent(self): 36 | self.assertEqual(self.record['non_existent'], '-') 37 | -------------------------------------------------------------------------------- /tests/component/helpers/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/roll/interest-py/e6e1def4f2999222aac2fb1d290ae94250673b89/tests/component/helpers/__init__.py -------------------------------------------------------------------------------- /tests/component/helpers/test_plugin.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | from importlib import import_module 3 | from unittest.mock import patch 4 | component = import_module('interest.helpers.plugin') 5 | 6 | 7 | class PluginImporterTest(unittest.TestCase): 8 | 9 | # Actions 10 | 11 | def setUp(self): 12 | self.addCleanup(patch.stopall) 13 | self.sys = patch.object(component, 'sys').start() 14 | self.import_module = patch.object(component, 'import_module').start() 15 | self.importer = component.PluginImporter( 16 | virtual='virtual', actual='actual') 17 | 18 | # Tests 19 | 20 | def test___eq__(self): 21 | importer1 = component.PluginImporter(virtual='s1', actual='t1') 22 | importer2 = component.PluginImporter(virtual='s1', actual='t1') 23 | importer3 = component.PluginImporter(virtual='s3', actual='t3') 24 | self.assertEqual(importer1, importer2) 25 | self.assertNotEqual(importer1, importer3) 26 | 27 | def test_virtual(self): 28 | self.assertEqual(self.importer.virtual, 'virtual') 29 | 30 | def test_actual(self): 31 | self.assertEqual(self.importer.actual, 'actual') 32 | 33 | def test_register(self): 34 | self.sys.meta_path = [] 35 | self.importer.register() 36 | self.importer.register() 37 | # Check just 1 importer added 38 | self.assertEqual(self.sys.meta_path, [self.importer]) 39 | 40 | def test_find_module(self): 41 | self.assertEqual( 42 | self.importer.find_module('virtual_name'), 43 | self.importer) 44 | 45 | def test_find_module_not_match(self): 46 | self.assertIsNone(self.importer.find_module('not_virtual_name')) 47 | 48 | def test_load_module(self): 49 | self.sys.modules = {} 50 | self.assertEqual( 51 | self.importer.load_module('virtual_name'), 52 | self.import_module.return_value) 53 | # Check sys.modules 54 | self.assertEqual( 55 | self.sys.modules, 56 | {'virtual_name': self.import_module.return_value, 57 | 'actual_name': self.import_module.return_value}) 58 | 59 | def test_load_module_already_in_sys_modules(self): 60 | self.sys.modules = {'virtual_name': 'module'} 61 | self.assertEqual(self.importer.load_module('virtual_name'), 'module') 62 | 63 | def test_load_module_not_match(self): 64 | self.sys.modules = {'virtual_name': 'module'} 65 | self.assertRaises(ImportError, 66 | self.importer.load_module, 'not_virtual_name') 67 | -------------------------------------------------------------------------------- /tests/component/logger/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/roll/interest-py/e6e1def4f2999222aac2fb1d290ae94250673b89/tests/component/logger/__init__.py -------------------------------------------------------------------------------- /tests/component/logger/test_logger.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | from unittest.mock import Mock 3 | from importlib import import_module 4 | component = import_module('interest.logger.logger') 5 | 6 | 7 | class LoggerTest(unittest.TestCase): 8 | 9 | # Actions 10 | 11 | def setUp(self): 12 | self.args = ('arg1',) 13 | self.kwargs = {'kwarg1': 'kwarg1'} 14 | self.system = Mock() 15 | self.logger = component.Logger('service', system=self.system) 16 | 17 | # Helpers 18 | 19 | def make_mock_record_class(self): 20 | class MockRecord(dict): 21 | # Public 22 | def __getitem__(self, key): 23 | return key 24 | return MockRecord 25 | 26 | # Tests 27 | 28 | def test_access(self): 29 | record = self.make_mock_record_class()() 30 | self.assertIsNone(self.logger.access(record)) 31 | # Check system.info call 32 | self.system.info.assert_called_with( 33 | 'host time "request" status length "referer" "agent"') 34 | 35 | def test_debug(self): 36 | self.logger.debug('message', *self.args, **self.kwargs) 37 | # Check system.debug call 38 | self.system.debug.assert_called_with( 39 | 'message', *self.args, **self.kwargs) 40 | 41 | def test_info(self): 42 | self.logger.info('message', *self.args, **self.kwargs) 43 | # Check system.info call 44 | self.logger.system.info.assert_called_with( 45 | 'message', *self.args, **self.kwargs) 46 | 47 | def test_warning(self): 48 | self.logger.warning('message', *self.args, **self.kwargs) 49 | # Check system.warning call 50 | self.system.warning.assert_called_with( 51 | 'message', *self.args, **self.kwargs) 52 | 53 | def test_error(self): 54 | self.logger.error('message', *self.args, **self.kwargs) 55 | # Check system.error call 56 | self.system.error.assert_called_with( 57 | 'message', *self.args, **self.kwargs) 58 | 59 | def test_exception(self): 60 | self.logger.exception('message', *self.args, **self.kwargs) 61 | # Check system.exception call 62 | self.system.exception.assert_called_with( 63 | 'message', *self.args, **self.kwargs) 64 | 65 | def test_critical(self): 66 | self.logger.critical('message', *self.args, **self.kwargs) 67 | # Check system.critical call 68 | self.system.critical.assert_called_with( 69 | 'message', *self.args, **self.kwargs) 70 | -------------------------------------------------------------------------------- /tests/component/plugins/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/roll/interest-py/e6e1def4f2999222aac2fb1d290ae94250673b89/tests/component/plugins/__init__.py -------------------------------------------------------------------------------- /tests/component/router/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/roll/interest-py/e6e1def4f2999222aac2fb1d290ae94250673b89/tests/component/router/__init__.py -------------------------------------------------------------------------------- /tests/component/router/test_parser.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | from unittest.mock import Mock 3 | from importlib import import_module 4 | component = import_module('interest.router.parser') 5 | 6 | 7 | class ParserTest(unittest.TestCase): 8 | 9 | # Actions 10 | 11 | def setUp(self): 12 | self.convert = Mock() 13 | self.parser = component.Parser( 14 | 'service', pattern='pattern', convert=self.convert) 15 | 16 | # Tests 17 | 18 | def test_service(self): 19 | self.assertEqual(self.parser.service, 'service') 20 | 21 | def test_pattern(self): 22 | self.assertEqual(self.parser.pattern, 'pattern') 23 | 24 | def test_convert(self): 25 | self.assertEqual( 26 | self.parser.convert('string'), 27 | self.convert.return_value) 28 | # Check convert call 29 | self.convert.assert_called_with('string') 30 | -------------------------------------------------------------------------------- /tests/component/router/test_pattern.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | from unittest.mock import Mock 3 | from importlib import import_module 4 | component = import_module('interest.router.pattern') 5 | 6 | 7 | class PatternTest(unittest.TestCase): 8 | 9 | # Helpers 10 | 11 | S = component.StringPattern 12 | R = component.RegexPattern 13 | E = Exception 14 | N = None 15 | 16 | parsers = { 17 | # Meta, instance 18 | 'str': Mock(pattern=r'[^<>/]+', convert=str), 19 | 'int': Mock(pattern=r'[1-9/]+', convert=int), 20 | 'float': Mock(pattern=r'[1-9./]+', convert=float), 21 | 'path': Mock(pattern=r'[^<>]+', convert=str), 22 | } 23 | 24 | fixtures = [ 25 | # Pattern, string, left, match, type/exception 26 | ['/test', '/test2', False, N, S], 27 | ['/test', '/test', False, {}, S], 28 | ['/', '/value', False, {'key': 'value'}, R], 29 | ['/', '/5', False, {'key': '5'}, R], 30 | ['/', '/5', False, {'key': 5}, R], 31 | ['/', '/5.5', False, {'key': 5.5}, R], 32 | ['/', '/my/path', False, {'key': 'my/path'}, R], 33 | ] 34 | 35 | # Tests 36 | 37 | def test(self): 38 | for fixture in self.fixtures: 39 | (pattern, string, left, match, tex) = fixture 40 | if issubclass(tex, Exception): 41 | self.assertRaises(tex, 42 | component.Pattern.create, pattern, self.parsers) 43 | continue 44 | pattern = component.Pattern.create(pattern, self.parsers) 45 | self.assertIsInstance(pattern, tex, (pattern, fixture)) 46 | self.assertEqual( 47 | pattern.match(string, left), 48 | match, (pattern, fixture)) 49 | -------------------------------------------------------------------------------- /tests/component/test_middleware.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | from unittest.mock import Mock 3 | from importlib import import_module 4 | component = import_module('interest.middleware') 5 | 6 | 7 | class MiddlewareTest(unittest.TestCase): 8 | 9 | # Actions 10 | 11 | def setUp(self): 12 | self.service = Mock(path='/path') 13 | self.Middleware = self.make_mock_middleware_class() 14 | self.middleware = self.Middleware(self.service) 15 | 16 | # Helpers 17 | 18 | def make_mock_middleware_class(self): 19 | class MockMiddleware(component.Middleware): 20 | # Public 21 | pass 22 | return MockMiddleware 23 | 24 | # Tests 25 | 26 | def test_service(self): 27 | self.assertEqual(self.middleware.service, self.service) 28 | -------------------------------------------------------------------------------- /tests/component/test_service.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import unittest 3 | from unittest.mock import Mock 4 | from importlib import import_module 5 | component = import_module('interest.service') 6 | 7 | 8 | class ServiceTest(unittest.TestCase): 9 | 10 | # Actions 11 | 12 | def setUp(self): 13 | self.loop = Mock() 14 | self.logger = Mock() 15 | self.handler = Mock() 16 | self.Logger = Mock(return_value=self.logger) 17 | self.Handler = Mock(return_value=self.handler) 18 | self.service = component.Service( 19 | loop=self.loop, 20 | logger=self.Logger, 21 | handler=self.Handler) 22 | 23 | # Tests 24 | 25 | def test(self): 26 | # Check class calls 27 | self.Logger.assert_called_with(self.service) 28 | self.Handler.assert_called_with(self.service) 29 | 30 | def test_listen(self): 31 | self.service.listen(host='host', port='port', forever=True) 32 | # Check loop calls 33 | self.loop.create_server.assert_called_with( 34 | self.handler.fork, 'host', 'port') 35 | self.loop.run_until_complete.assert_called_with( 36 | self.loop.create_server.return_value) 37 | self.loop.run_forever.assert_called_with() 38 | 39 | def test_listen_keyboard_interrupt(self): 40 | self.loop.run_forever.side_effect = KeyboardInterrupt() 41 | self.service.listen(host='host', port='port', forever=True) 42 | 43 | def test_loop(self): 44 | self.assertEqual(self.service.loop, self.loop) 45 | 46 | def test_loop_default(self): 47 | self.service = component.Service() 48 | self.assertEqual(self.service.loop, asyncio.get_event_loop()) 49 | -------------------------------------------------------------------------------- /tests/integration/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/roll/interest-py/e6e1def4f2999222aac2fb1d290ae94250673b89/tests/integration/__init__.py -------------------------------------------------------------------------------- /tests/system/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/roll/interest-py/e6e1def4f2999222aac2fb1d290ae94250673b89/tests/system/__init__.py -------------------------------------------------------------------------------- /tests/system/test_advanced.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | from pathlib import Path 3 | from interest import Tester 4 | 5 | 6 | class ExampleTest(unittest.TestCase): 7 | 8 | # Actions 9 | 10 | @classmethod 11 | def setUpClass(cls): 12 | current = Path(__file__).parent 13 | basedir = current / '..' / '..' 14 | factory = basedir / 'examples' / 'advanced.py' 15 | cls.tester = Tester(str(factory), 16 | environ={'PYTHONPATH': str(basedir)}) 17 | cls.tester.start() 18 | 19 | @classmethod 20 | def tearDownClass(cls): 21 | cls.tester.stop() 22 | 23 | # Tests 24 | 25 | def test_read(self): 26 | response = self.tester.request('GET', '/api/v1/comment/key=1') 27 | self.assertEqual(response.status, 200) 28 | self.assertEqual( 29 | response.headers['CONTENT-TYPE'], 30 | 'application/json; charset=utf-8') 31 | self.assertEqual(response.json, {'key': 1}) 32 | 33 | def test_upsert_put(self): 34 | response = self.tester.request('PUT', '/api/v1/comment') 35 | self.assertEqual(response.status, 201) 36 | self.assertEqual(response.json, {'message': 'Created'}) 37 | 38 | def test_upsert_post(self): 39 | response = self.tester.request('POST', '/api/v1/comment') 40 | self.assertEqual(response.status, 401) 41 | self.assertEqual(response.json, {'message': 'Unauthorized'}) 42 | 43 | def test_not_found(self): 44 | response = self.tester.request('PUT', '/api/v1/not-found') 45 | self.assertEqual(response.status, 404) 46 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [tox] 2 | package=interest 3 | skip_missing_interpreters=true 4 | envlist= 5 | py34 6 | 7 | [testenv] 8 | deps= 9 | mock 10 | pytest 11 | pytest-cov 12 | coverage 13 | passenv= 14 | CI 15 | TRAVIS 16 | TRAVIS_JOB_ID 17 | TRAVIS_BRANCH 18 | commands= 19 | py.test \ 20 | --cov {[tox]package} \ 21 | --cov-config tox.ini \ 22 | --cov-report term-missing \ 23 | {posargs} 24 | 25 | [pytest] 26 | # pytest.ini configuration here 27 | testpaths = tests 28 | 29 | [report] 30 | # .coveragerc configuration here 31 | --------------------------------------------------------------------------------