├── .gitignore ├── .travis.yml ├── LICENSE ├── README.md ├── bench ├── ab.sh ├── ab_complex.sh ├── bench_echo.py ├── bench_simple_100_conns_100k_req_httpx.sh ├── bench_simple_100conns_100k_req.sh ├── post_complex.txt ├── post_loc.txt ├── run_fapi_uvicorn.sh └── run_tino_multi.py ├── examples └── database.py ├── setup.py ├── tests ├── conftest.py ├── test_auth.py ├── test_basic_api.py └── test_state.py └── tino ├── __init__.py ├── config.py ├── protocol.py ├── serializer.py ├── server.py ├── special_args.py └── tino.py /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | pip-wheel-metadata/ 24 | share/python-wheels/ 25 | *.egg-info/ 26 | .installed.cfg 27 | *.egg 28 | MANIFEST 29 | 30 | # PyInstaller 31 | # Usually these files are written by a python script from a template 32 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 33 | *.manifest 34 | *.spec 35 | 36 | # Installer logs 37 | pip-log.txt 38 | pip-delete-this-directory.txt 39 | 40 | # Unit test / coverage reports 41 | htmlcov/ 42 | .tox/ 43 | .nox/ 44 | .coverage 45 | .coverage.* 46 | .cache 47 | nosetests.xml 48 | coverage.xml 49 | *.cover 50 | *.py,cover 51 | .hypothesis/ 52 | .pytest_cache/ 53 | 54 | # Translations 55 | *.mo 56 | *.pot 57 | 58 | # Django stuff: 59 | *.log 60 | local_settings.py 61 | db.sqlite3 62 | db.sqlite3-journal 63 | 64 | # Flask stuff: 65 | instance/ 66 | .webassets-cache 67 | 68 | # Scrapy stuff: 69 | .scrapy 70 | 71 | # Sphinx documentation 72 | docs/_build/ 73 | 74 | # PyBuilder 75 | target/ 76 | 77 | # Jupyter Notebook 78 | .ipynb_checkpoints 79 | 80 | # IPython 81 | profile_default/ 82 | ipython_config.py 83 | 84 | # pyenv 85 | .python-version 86 | 87 | # pipenv 88 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 89 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 90 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 91 | # install all needed dependencies. 92 | #Pipfile.lock 93 | 94 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow 95 | __pypackages__/ 96 | 97 | # Celery stuff 98 | celerybeat-schedule 99 | celerybeat.pid 100 | 101 | # SageMath parsed files 102 | *.sage.py 103 | 104 | # Environments 105 | .env 106 | .venv 107 | env/ 108 | venv/ 109 | ENV/ 110 | env.bak/ 111 | venv.bak/ 112 | 113 | # Spyder project settings 114 | .spyderproject 115 | .spyproject 116 | 117 | # Rope project settings 118 | .ropeproject 119 | 120 | # mkdocs documentation 121 | /site 122 | 123 | # mypy 124 | .mypy_cache/ 125 | .dmypy.json 126 | dmypy.json 127 | 128 | # Pyre type checker 129 | .pyre/ 130 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: python 2 | python: 3 | - '3.7' 4 | - '3.8' 5 | - 3.8-dev 6 | install: 7 | - pip install . 8 | - pip install pytest 9 | script: 10 | - pytest 11 | deploy: 12 | provider: pypi 13 | username: hansonkd 14 | skip_existing: true 15 | on: 16 | branch: master 17 | password: 18 | secure: cne/3OFCyrZLJO8sHhxgVgKMlg/1uFU9G5vdPemseor2TTj/nzzEFz7lONMR5uFYS16Skf2YcVBQm/Er7AIzW4U+F7sJAiBHn7XedJqOAsqXvgvc/lEORFWRM6o4t4gR4s4CqR7LjLkS+m9kRue9qbsnWwJdgoF/Lz4Rjoy66MO/sH48QGhp7M2MVCChxqggYciohoMXO3BpbbMBS8l5C1re38gezi/9fT/qvpRVi8F5/WTfL4UaARjPXgxC13CHin1GmWKKg5kzjkMpJG6u4n1W15TX1NafK5AbFhKkkBCujdQ5jFMS4Nd0EAg2T3hiZTJqYZCdUiVBzYTmiw3NKOeNNp4rE0cP3COXAoo3/CM7U6lv8cSZKz1NSyodosYhRUG5n+i4CkId9q8pv3a2Pe+oOyhfC+6mGgmuuwTDMvkNG5erOF65yDaMbtlBrt7glnZd+U82OMerwZH+0xP55S/L4s6awQDLW4gnNBEqzLJ4ahQTYczl+mItOfZyZRsL5sdhz30ZsJZAhtHJFqqGy1DHckuoub8mRfFDVcbVtKmfe+3I72D4t5TOBx6CeQpiKeZY9amsF/ZhZwu7ih8FG064sxEaycm8Z4mJgLi112I1RDYsMlt71pY5Xm7ooDNJkYLIPObnEcqFxMBVidG11V6yrGDG/cvYt9ymKvkxoFs= 19 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 Kyle Hanson 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 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Tino 2 | The one-of-a-kind, stupid fast, Python API using Redis Protocol and MsgPack 3 | 4 | ![tino](https://user-images.githubusercontent.com/496914/84339977-79f62b00-ab54-11ea-97b2-a8d1b74b07af.png) 5 | 6 | ### What is Tino? 7 | 8 | Tino is a framework to remotely call functions. It builds both clients and servers. 9 | 10 | Traditional APIs are JSON over HTTP. Tino is Msgpack over the Redis Serialization Protocol. This makes it go faster, use less bandwidth, and its binary format easier to understand. 11 | 12 | ### Highlights 13 | 14 | * Redis Protocol 15 | * MessagePack Serialization 16 | * Pydantic for automatically parsing rich datastructures 17 | * Fast. Up to 10x faster than the fastest HTTP Python framework + client combination. 18 | * Small. Under 500 lines of code. 19 | * Able to run multiple workers with uvicorn (more below) 20 | 21 | ### Does Tino use Redis? 22 | 23 | No. Tino uses RESP, the Redis Serialization Protocol, to encode messages over TCP. Tino only needs Redis if the service you build adds it. 24 | 25 | 26 | ### Generalizing the Redis Protocol 27 | 28 | Tino was born after a long search to find an alternative to HTTP. The protocol is great for the web, but for backend server-to-server services, it is a bit overkill. HTTP2 tried to fix some of the problems, but ended up as is a mess of statemachines, multiplexing, and header compression algorithms that proper clients took years to come out and some languages still haven't implemented it fully. 29 | 30 | 31 | [RESP](https://redis.io/topics/protocol), the Redis Serialization Protocol, is a Request-Response model with a very simple human readable syntax. It is also very fast to parse which makes it a great candidate for use in an API. After a [weekend of hacking](https://medium.com/swlh/hacking-the-redis-protocol-as-an-http-api-alternative-using-asyncio-in-python-7e57ba65dce3?source=friends_link&sk=029399a8cd40d6ef63895fd777459cad), a proof of concept was born and Tino quickly followed. 32 | 33 | 34 | ### MessagePack for Serialization 35 | 36 | It is fast, can enable zero-copy string and bytes decoding, and the most important, it is [only an afternoon of hacking](https://medium.com/@hansonkd/building-beautiful-binary-parsers-in-elixir-1bd7f865bf17?source=friends_link&sk=6f7b440eb04ee81679c3ddfede9bab07) to get a serializer and parser going. 37 | 38 | MessagePack paired with RESP means that you can implement the entire stack, protocol and serialization, by yourself from scratch if you needed to without too much trouble. And it will be fast. 39 | 40 | 41 | ### Uvicorn 42 | 43 | Tino is built on the popular ASGI server Uvicorn. Its still a bit of a work in progress as Tino is NOT an ASGI framework so we get some warnings, but things are working. See [run_tino_multi.py](https://github.com/hansonkd/Tino/blob/master/bench/run_tino_multi.py) for an example of passing Uvicorn arguments. SSL and `workers` are working but I wouldn't expect too many other config options to work. 44 | 45 | ### Why not ProtocolBuffers, CapnProto, etc. 46 | 47 | Most other protocols that you have to generate are much much much more complex and thus are not widely implemented. The support of individual languages is iffy too. By choosing a simple protocol and simple serialization, we have ensured that we have a client in nearly language as nearly every language has a Redis client and MsgPack library. 48 | 49 | 50 | ### The Basics 51 | 52 | Tino follows closely the design of [FastAPI](https://fastapi.tiangolo.com/). Type annotations are required for both arguments and return values so that values can automatically be parsed and serialized. In Redis all commands are arrays. The position of your argument in the signature of the function matches the position of the string array of the redis command. Tino commands can not contain keyword arguments. Tino will automatically fill in and parse Pydantic models. 53 | 54 | ```python 55 | # server.py 56 | from tino import Tino 57 | from pydantic import BaseModel 58 | 59 | app = Tino() 60 | 61 | class NumberInput(BaseModel): 62 | value: int 63 | 64 | @app.command 65 | def add(a: int, b: NumberInput) -> int: 66 | return a + b.value 67 | 68 | if __name__ == "__main__": 69 | app.run() 70 | ``` 71 | 72 | Now you can run commands against the server using any Redis api in any language as long as the client supports custom Redis commands (most do). 73 | 74 | Or you can use Tino's builtin high-performance client: 75 | 76 | ```python 77 | # client.py 78 | import asyncio 79 | from server import app, NumberInput # import the app from above 80 | 81 | async def go(): 82 | client = app.client() 83 | await client.connect() 84 | 85 | three = await client.add(1, NumberInput(value=2)) 86 | 87 | client.close() 88 | await client.wait_closed() 89 | 90 | if __name__ == "__main__": 91 | asyncio.run(go()) 92 | ``` 93 | 94 | 95 | ### Authorization 96 | 97 | Tino has authorization by adding `AuthRequired` to the type signature of the methods you want to protect and supplying the `Tino` object with an `auth_func`. The `auth_func` takes a `bytes` object and returns `None` if the connection failed authorization or any other value symbolizing the authorization state if they succeeded. 98 | 99 | ```python 100 | from tino import Tino 101 | 102 | KEYS = { 103 | b'tinaspassword': 'tina' 104 | } 105 | def auth_func(password: bytes): 106 | return KEYS.get(password, None) 107 | 108 | app = Tino(auth_func=auth_func) 109 | 110 | @app.command 111 | def add(a: int, b: int, auth: AuthRequired) -> int: 112 | print(auth.value) 113 | return a + b 114 | ``` 115 | 116 | And pass the password to the `client.connect`. 117 | 118 | ```python 119 | async def do(): 120 | client = app.client() 121 | await client.connect(password="tinaspassword") 122 | ``` 123 | 124 | ### Other Magic Arguments 125 | 126 | Besides `AuthRequired` you can also add `Auth` (where `auth.value` can be None) and `ConnState` to get the state if you also supply a `state_factory`. This state is mutatable and is private to the connection. 127 | 128 | ```python 129 | from tino import Tino 130 | 131 | async def state_factory(): 132 | return 0 133 | 134 | app = Tino(state_factory=state_factory) 135 | 136 | @app.command 137 | def add(a: int, b: int, auth: Auth, conn: ConnState) -> int: 138 | # Count the number of unauthorized calls on this connection. 139 | if auth.value is None: 140 | conn.value += 1 141 | return a + b 142 | 143 | ``` 144 | 145 | 146 | ### Is Tino Secure? 147 | Probably the biggest vulnerability is a DDOS attack. More testing needs to be done to see how Tino behaves under large message sizes. Currently placing views behind `AuthRequired` does not protect against this because the entire message is parsed. So for the time being, Tino should only be considered for private connections. This can be improved however, by parsing the command first, doing the permission check then reading and parsing the body. 148 | 149 | ### What about Databases? 150 | For SQL I recommend using the [databases](https://pypi.org/project/databases/) project with SQLAlchemy to get true asyncio support. This example is borrowed from [fastapi](https://fastapi.tiangolo.com/advanced/async-sql-databases/) 151 | 152 | ```python 153 | from tino import Tino 154 | import databases 155 | import sqlalchemy 156 | from typing import List 157 | 158 | # SQLAlchemy specific code, as with any other app 159 | DATABASE_URL = "sqlite:///./test.db" 160 | # DATABASE_URL = "postgresql://user:password@postgresserver/db" 161 | 162 | database = databases.Database(DATABASE_URL) 163 | 164 | metadata = sqlalchemy.MetaData() 165 | 166 | notes = sqlalchemy.Table( 167 | "notes", 168 | metadata, 169 | sqlalchemy.Column("id", sqlalchemy.Integer, primary_key=True), 170 | sqlalchemy.Column("text", sqlalchemy.String), 171 | sqlalchemy.Column("completed", sqlalchemy.Boolean), 172 | ) 173 | 174 | 175 | engine = sqlalchemy.create_engine( 176 | DATABASE_URL, connect_args={"check_same_thread": False} 177 | ) 178 | metadata.create_all(engine) 179 | 180 | 181 | class NoteIn(BaseModel): 182 | text: str 183 | completed: bool 184 | 185 | 186 | class Note(BaseModel): 187 | id: int 188 | text: str 189 | completed: bool 190 | 191 | 192 | app = Tino() 193 | 194 | @app.on_startup 195 | async def startup(): 196 | await database.connect() 197 | 198 | 199 | @app.on_shutdown 200 | async def shutdown(): 201 | await database.disconnect() 202 | 203 | @app.command 204 | async def read_notes() -> List[Note]: 205 | query = notes.select() 206 | rows = await database.fetch_all(query) 207 | return [Note(**n) for n in rows] 208 | 209 | 210 | @app.command 211 | async def create_note(note: NoteIn) -> Note: 212 | query = notes.insert().values(text=note.text, completed=note.completed) 213 | last_record_id = await database.execute(query) 214 | return Note(id=last_record_id, **note.dict()) 215 | 216 | 217 | 218 | if __name__ == "__main__": 219 | app.run() 220 | ``` 221 | 222 | ### Should I use Tino in Production? 223 | 224 | Its not ready for public consumption at the moment, but if you want my help to run it, just drop me a line and we will make it happen. me at khanson dot io 225 | 226 | 227 | 228 | ### TLS Support 229 | 230 | Its probably easiest to deploy Tino behind a TCP loadbalancer that already supports TLS. You can pass in the `SSLContext` to the `client.connect` function as kwargs to the Redis connection pool. 231 | 232 | ### Benchmarks 233 | 234 | This is run with uvicorn as a single worker. `httpx` seemed to be a major point of performance problems so I also benchmarked against `ab` (apache benchmark). However, `httpx` results are typical of what you would see if you were using python-to-python communication. 235 | 236 | `httpx` is abysmal at concurrent requests. Anything over a few thousand and it slows to a crawl. To test multiple connections, I instead chained together the single connection bench with the unix operator `&` to execute 100 scripts in parrellel. 237 | 238 | `tino` client did not suffer the same fate and scaled to handle hundreds of thousands of tasks with ease. However, there were some slight performance gains from chaining together 10 processses of 10 connections. 239 | 240 | This is a micro benchmark of echoing a 1234 character unicode string of emojis. Each test, except the last, are ran with 6 workers on the server. 241 | 242 | Screen Shot 2020-06-10 at 12 54 54 AM 243 | 244 | More comprehensive benchmarks of multiple workers, different content sizes, requiring authorization would also be good to have. However, these are contrived and strictly meant to show the overhead of the protocol and serializers. 245 | ### Coming Soon 246 | 247 | * Iterators 248 | -------------------------------------------------------------------------------- /bench/ab.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | ab -p ./bench/post_loc.txt -T application/json -c 10 -n 10000 http://localhost:9999/fapi/echo/simple -------------------------------------------------------------------------------- /bench/ab_complex.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | ab -p ./bench/post_complex.txt -T application/json -c 100 -n 100000 http://localhost:9999/fapi/echo/complex -------------------------------------------------------------------------------- /bench/bench_echo.py: -------------------------------------------------------------------------------- 1 | from tino import Tino, make_client_class 2 | from fastapi import FastAPI, Body 3 | from pydantic import BaseModel 4 | from typing import Dict, List 5 | import time 6 | import sys 7 | import asyncio 8 | import httpx 9 | import multiprocessing 10 | 11 | 12 | class ComplexModel(BaseModel): 13 | a: int 14 | b: bool 15 | c: List["ComplexModel"] 16 | d: Dict[str, "ComplexModel"] 17 | e: str 18 | f: str 19 | g: str 20 | h: str 21 | i: str 22 | 23 | 24 | ComplexModel.update_forward_refs() 25 | 26 | 27 | complex_obj = ComplexModel( 28 | a=1, 29 | b=True, 30 | c=[ 31 | ComplexModel( 32 | a=1, 33 | b=True, 34 | c=[ 35 | ComplexModel( 36 | a=1, 37 | b=True, 38 | c=[], 39 | d={}, 40 | e="some 😊 😊 string", 41 | f="some 😊 😊 string", 42 | g="some 😊 😊 string", 43 | h="some 😊 😊 string", 44 | i="some 😊 😊 string", 45 | ), 46 | ComplexModel( 47 | a=1, 48 | b=True, 49 | c=[], 50 | d={}, 51 | e="some 😊 😊 string", 52 | f="some 😊 😊 string", 53 | g="some 😊 😊 string", 54 | h="some 😊 😊 string", 55 | i="some 😊 😊 string", 56 | ), 57 | ], 58 | d={ 59 | "f": ComplexModel( 60 | a=1, 61 | b=True, 62 | c=[ 63 | ComplexModel( 64 | a=1, 65 | b=True, 66 | c=[], 67 | d={}, 68 | e="some 😊 😊 string", 69 | f="some 😊 😊 string", 70 | g="some 😊 😊 string", 71 | h="some 😊 😊 string", 72 | i="some 😊 😊 string", 73 | ) 74 | ], 75 | d={}, 76 | e="some 😊 😊 string", 77 | f="some 😊 😊 string", 78 | g="some 😊 😊 string", 79 | h="some 😊 😊 string", 80 | i="some 😊 😊 string", 81 | ) 82 | }, 83 | e="some 😊 😊 string", 84 | f="some 😊 😊 string", 85 | g="some 😊 😊 string", 86 | h="some 😊 😊 string", 87 | i="some 😊 😊 string", 88 | ) 89 | ], 90 | d={ 91 | "g": ComplexModel( 92 | a=1, 93 | b=True, 94 | c=[], 95 | d={}, 96 | e="some 😊 😊 string", 97 | f="some 😊 😊 string", 98 | g="some 😊 😊 string", 99 | h="some 😊 😊 string", 100 | i="some 😊 😊 string", 101 | ) 102 | }, 103 | e="some 😊 😊 string", 104 | f="some 😊 😊 string", 105 | g="some 😊 😊 string", 106 | h="some 😊 😊 string", 107 | i="some 😊 😊 string", 108 | ) 109 | 110 | simple_string = "😊 😊 😊" * 100 111 | 112 | api = Tino() 113 | 114 | 115 | @api.command 116 | async def tino_echo_complex(a: ComplexModel) -> ComplexModel: 117 | return a 118 | 119 | 120 | @api.command 121 | async def tino_echo_simple(a: str) -> str: 122 | return a 123 | 124 | 125 | client_class = make_client_class(api) 126 | 127 | fapi = FastAPI() 128 | 129 | 130 | @fapi.post("/fapi/echo/complex") 131 | async def fapi_echo_complex(a: ComplexModel) -> ComplexModel: 132 | return a 133 | 134 | 135 | class Echo(BaseModel): 136 | a: str 137 | 138 | 139 | @fapi.post("/fapi/echo/simple") 140 | async def fapi_echo_simple(a: str = Body(...)) -> str: 141 | return a 142 | 143 | 144 | async def simple_echo_client_tino_simple(ntimes): 145 | client = client_class() 146 | await client.connect(minsize=1, maxsize=1) 147 | t1 = time.time() 148 | for _ in range(ntimes): 149 | await client.tino_echo_simple(simple_string) 150 | return time.time() - t1 151 | client.close() 152 | await client.wait_closed() 153 | 154 | 155 | async def simple_echo_client_tino_simple_concurrent(ntimes): 156 | client = client_class() 157 | await client.connect(minsize=100, maxsize=100) 158 | t1 = time.time() 159 | 160 | futures = [] 161 | for _ in range(ntimes): 162 | futures.append(client.tino_echo_simple(simple_string)) 163 | 164 | await asyncio.gather(*futures) 165 | return time.time() - t1 166 | client.close() 167 | await client.wait_closed() 168 | 169 | 170 | async def simple_echo_client_tino_complex_concurrent(ntimes): 171 | client = client_class() 172 | await client.connect(minsize=10, maxsize=10) 173 | t1 = time.time() 174 | 175 | futures = [] 176 | for _ in range(ntimes): 177 | futures.append(client.tino_echo_complex(complex_obj)) 178 | 179 | await asyncio.gather(*futures) 180 | return time.time() - t1 181 | client.close() 182 | await client.wait_closed() 183 | 184 | 185 | async def simple_echo_client_tino_complex(ntimes): 186 | client = client_class() 187 | await client.connect() 188 | t1 = time.time() 189 | for _ in range(ntimes): 190 | await client.tino_echo_complex(complex_obj) 191 | return time.time() - t1 192 | client.close() 193 | await client.wait_closed() 194 | 195 | 196 | async def simple_echo_client_fapi_simple(ntimes): 197 | async with httpx.AsyncClient() as client: 198 | t1 = time.time() 199 | for _ in range(ntimes): 200 | res = await client.post( 201 | "http://localhost:9999/fapi/echo/simple", json=simple_string 202 | ) 203 | res.json() 204 | return time.time() - t1 205 | 206 | 207 | async def simple_echo_client_fapi_simple_concurrent(ntimes): 208 | max_conns = 100 209 | limits = httpx.PoolLimits(hard_limit=max_conns) 210 | async with httpx.AsyncClient(timeout=600, pool_limits=limits) as client: 211 | 212 | futures = [] 213 | for _ in range(max_conns): 214 | futures.append( 215 | client.post( 216 | "http://localhost:9999/fapi/echo/simple", json=simple_string 217 | ) 218 | ) 219 | 220 | await asyncio.gather(*futures) 221 | print("warmed up") 222 | 223 | t1 = time.time() 224 | futures = [] 225 | for _ in range(ntimes): 226 | futures.append( 227 | client.post( 228 | "http://localhost:9999/fapi/echo/simple", json=simple_string 229 | ) 230 | ) 231 | 232 | await asyncio.gather(*futures) 233 | 234 | return time.time() - t1 235 | 236 | 237 | async def simple_echo_client_fapi_concurrent_concurrent(ntimes): 238 | max_conns = 100 239 | limits = httpx.PoolLimits(hard_limit=max_conns) 240 | async with httpx.AsyncClient(timeout=600, pool_limits=limits) as client: 241 | 242 | futures = [] 243 | for _ in range(max_conns): 244 | futures.append( 245 | client.post( 246 | "http://localhost:9999/fapi/echo/simple", json=simple_string 247 | ) 248 | ) 249 | 250 | await asyncio.gather(*futures) 251 | print("warmed up") 252 | 253 | t1 = time.time() 254 | futures = [] 255 | for _ in range(ntimes): 256 | futures.append( 257 | client.post( 258 | "http://localhost:9999/fapi/echo/complex", json=complex_obj.dict() 259 | ) 260 | ) 261 | 262 | await asyncio.gather(*futures) 263 | 264 | return time.time() - t1 265 | 266 | 267 | async def simple_echo_client_fapi_complex(ntimes): 268 | async with httpx.AsyncClient() as client: 269 | t1 = time.time() 270 | for _ in range(ntimes): 271 | res = await client.post( 272 | "http://localhost:9999/fapi/echo/complex", json=complex_obj.dict() 273 | ) 274 | ComplexModel(**res.json()) 275 | return time.time() - t1 276 | 277 | 278 | NUM_TIMES = 100 * 1000 279 | NUM_CONCURRENT = multiprocessing.cpu_count() 280 | if __name__ == "__main__": 281 | # import uvloop 282 | # uvloop.install() 283 | if sys.argv[1] == "tino_client_simple": 284 | print(asyncio.run(simple_echo_client_tino_simple(NUM_TIMES))) 285 | elif sys.argv[1] == "tino_client_simple_concurrent": 286 | print(asyncio.run(simple_echo_client_tino_simple_concurrent(NUM_TIMES))) 287 | elif sys.argv[1] == "tino_client_complex_concurrent": 288 | print(asyncio.run(simple_echo_client_tino_complex_concurrent(NUM_TIMES))) 289 | elif sys.argv[1] == "tino_client_complex": 290 | print(asyncio.run(simple_echo_client_tino_complex(NUM_TIMES))) 291 | elif sys.argv[1] == "tino_server": 292 | api.run(workers=2, host="localhost", port=7777) 293 | elif sys.argv[1] == "tino_server_uvloop": 294 | import uvloop 295 | 296 | uvloop.install() 297 | api.run(workers=2, host="localhost", port=7777) 298 | elif sys.argv[1] == "fapi_client_simple": 299 | print(asyncio.run(simple_echo_client_fapi_simple(NUM_TIMES))) 300 | elif sys.argv[1] == "fapi_client_simple_concurrent": 301 | print(asyncio.run(simple_echo_client_fapi_simple_concurrent(NUM_TIMES))) 302 | elif sys.argv[1] == "fapi_client_complex_concurrent": 303 | print(asyncio.run(simple_echo_client_fapi_concurrent_concurrent(NUM_TIMES))) 304 | elif sys.argv[1] == "fapi_client_complex": 305 | print(asyncio.run(simple_echo_client_fapi_complex(NUM_TIMES))) 306 | elif sys.argv[1] == "fapi_server": 307 | import uvicorn 308 | 309 | uvicorn.run(fapi, host="0.0.0.0", port=9999, loop="asyncio", log_level="error") 310 | elif sys.argv[1] == "fapi_server_uvloop": 311 | import uvicorn 312 | 313 | uvicorn.run(fapi, host="0.0.0.0", port=9999, loop="uvloop", log_level="error") 314 | -------------------------------------------------------------------------------- /bench/bench_simple_100_conns_100k_req_httpx.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | python3 bench/bench_echo.py fapi_client_simple & python3 bench/bench_echo.py fapi_client_simple & python3 bench/bench_echo.py fapi_client_simple & python3 bench/bench_echo.py fapi_client_simple & python3 bench/bench_echo.py fapi_client_simple & python3 bench/bench_echo.py fapi_client_simple & python3 bench/bench_echo.py fapi_client_simple & python3 bench/bench_echo.py fapi_client_simple & python3 bench/bench_echo.py fapi_client_simple & python3 bench/bench_echo.py fapi_client_simple & python3 bench/bench_echo.py fapi_client_simple & python3 bench/bench_echo.py fapi_client_simple & python3 bench/bench_echo.py fapi_client_simple & python3 bench/bench_echo.py fapi_client_simple & python3 bench/bench_echo.py fapi_client_simple & python3 bench/bench_echo.py fapi_client_simple & python3 bench/bench_echo.py fapi_client_simple & python3 bench/bench_echo.py fapi_client_simple & python3 bench/bench_echo.py fapi_client_simple & python3 bench/bench_echo.py fapi_client_simple & python3 bench/bench_echo.py fapi_client_simple & python3 bench/bench_echo.py fapi_client_simple & python3 bench/bench_echo.py fapi_client_simple & python3 bench/bench_echo.py fapi_client_simple & python3 bench/bench_echo.py fapi_client_simple & python3 bench/bench_echo.py fapi_client_simple & python3 bench/bench_echo.py fapi_client_simple & python3 bench/bench_echo.py fapi_client_simple & python3 bench/bench_echo.py fapi_client_simple & python3 bench/bench_echo.py fapi_client_simple & python3 bench/bench_echo.py fapi_client_simple & python3 bench/bench_echo.py fapi_client_simple & python3 bench/bench_echo.py fapi_client_simple & python3 bench/bench_echo.py fapi_client_simple & python3 bench/bench_echo.py fapi_client_simple & python3 bench/bench_echo.py fapi_client_simple & python3 bench/bench_echo.py fapi_client_simple & python3 bench/bench_echo.py fapi_client_simple & python3 bench/bench_echo.py fapi_client_simple & python3 bench/bench_echo.py fapi_client_simple & python3 bench/bench_echo.py fapi_client_simple & python3 bench/bench_echo.py fapi_client_simple & python3 bench/bench_echo.py fapi_client_simple & python3 bench/bench_echo.py fapi_client_simple & python3 bench/bench_echo.py fapi_client_simple & python3 bench/bench_echo.py fapi_client_simple & python3 bench/bench_echo.py fapi_client_simple & python3 bench/bench_echo.py fapi_client_simple & python3 bench/bench_echo.py fapi_client_simple & python3 bench/bench_echo.py fapi_client_simple & python3 bench/bench_echo.py fapi_client_simple & python3 bench/bench_echo.py fapi_client_simple & python3 bench/bench_echo.py fapi_client_simple & python3 bench/bench_echo.py fapi_client_simple & python3 bench/bench_echo.py fapi_client_simple & python3 bench/bench_echo.py fapi_client_simple & python3 bench/bench_echo.py fapi_client_simple & python3 bench/bench_echo.py fapi_client_simple & python3 bench/bench_echo.py fapi_client_simple & python3 bench/bench_echo.py fapi_client_simple & python3 bench/bench_echo.py fapi_client_simple & python3 bench/bench_echo.py fapi_client_simple & python3 bench/bench_echo.py fapi_client_simple & python3 bench/bench_echo.py fapi_client_simple & python3 bench/bench_echo.py fapi_client_simple & python3 bench/bench_echo.py fapi_client_simple & python3 bench/bench_echo.py fapi_client_simple & python3 bench/bench_echo.py fapi_client_simple & python3 bench/bench_echo.py fapi_client_simple & python3 bench/bench_echo.py fapi_client_simple & python3 bench/bench_echo.py fapi_client_simple & python3 bench/bench_echo.py fapi_client_simple & python3 bench/bench_echo.py fapi_client_simple & python3 bench/bench_echo.py fapi_client_simple & python3 bench/bench_echo.py fapi_client_simple & python3 bench/bench_echo.py fapi_client_simple & python3 bench/bench_echo.py fapi_client_simple & python3 bench/bench_echo.py fapi_client_simple & python3 bench/bench_echo.py fapi_client_simple & python3 bench/bench_echo.py fapi_client_simple & python3 bench/bench_echo.py fapi_client_simple & python3 bench/bench_echo.py fapi_client_simple & python3 bench/bench_echo.py fapi_client_simple & python3 bench/bench_echo.py fapi_client_simple & python3 bench/bench_echo.py fapi_client_simple & python3 bench/bench_echo.py fapi_client_simple & python3 bench/bench_echo.py fapi_client_simple & python3 bench/bench_echo.py fapi_client_simple & python3 bench/bench_echo.py fapi_client_simple & python3 bench/bench_echo.py fapi_client_simple & python3 bench/bench_echo.py fapi_client_simple & python3 bench/bench_echo.py fapi_client_simple & python3 bench/bench_echo.py fapi_client_simple & python3 bench/bench_echo.py fapi_client_simple & python3 bench/bench_echo.py fapi_client_simple & python3 bench/bench_echo.py fapi_client_simple & python3 bench/bench_echo.py fapi_client_simple & python3 bench/bench_echo.py fapi_client_simple & python3 bench/bench_echo.py fapi_client_simple & python3 bench/bench_echo.py fapi_client_simple 4 | -------------------------------------------------------------------------------- /bench/bench_simple_100conns_100k_req.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | python3 bench/bench_echo.py tino_client_simple & python3 bench/bench_echo.py tino_client_simple & python3 bench/bench_echo.py tino_client_simple & python3 bench/bench_echo.py tino_client_simple & python3 bench/bench_echo.py tino_client_simple & python3 bench/bench_echo.py tino_client_simple & python3 bench/bench_echo.py tino_client_simple & python3 bench/bench_echo.py tino_client_simple & python3 bench/bench_echo.py tino_client_simple & python3 bench/bench_echo.py tino_client_simple & python3 bench/bench_echo.py tino_client_simple & python3 bench/bench_echo.py tino_client_simple & python3 bench/bench_echo.py tino_client_simple & python3 bench/bench_echo.py tino_client_simple & python3 bench/bench_echo.py tino_client_simple & python3 bench/bench_echo.py tino_client_simple & python3 bench/bench_echo.py tino_client_simple & python3 bench/bench_echo.py tino_client_simple & python3 bench/bench_echo.py tino_client_simple & python3 bench/bench_echo.py tino_client_simple & python3 bench/bench_echo.py tino_client_simple & python3 bench/bench_echo.py tino_client_simple & python3 bench/bench_echo.py tino_client_simple & python3 bench/bench_echo.py tino_client_simple & python3 bench/bench_echo.py tino_client_simple & python3 bench/bench_echo.py tino_client_simple & python3 bench/bench_echo.py tino_client_simple & python3 bench/bench_echo.py tino_client_simple & python3 bench/bench_echo.py tino_client_simple & python3 bench/bench_echo.py tino_client_simple & python3 bench/bench_echo.py tino_client_simple & python3 bench/bench_echo.py tino_client_simple & python3 bench/bench_echo.py tino_client_simple & python3 bench/bench_echo.py tino_client_simple & python3 bench/bench_echo.py tino_client_simple & python3 bench/bench_echo.py tino_client_simple & python3 bench/bench_echo.py tino_client_simple & python3 bench/bench_echo.py tino_client_simple & python3 bench/bench_echo.py tino_client_simple & python3 bench/bench_echo.py tino_client_simple & python3 bench/bench_echo.py tino_client_simple & python3 bench/bench_echo.py tino_client_simple & python3 bench/bench_echo.py tino_client_simple & python3 bench/bench_echo.py tino_client_simple & python3 bench/bench_echo.py tino_client_simple & python3 bench/bench_echo.py tino_client_simple & python3 bench/bench_echo.py tino_client_simple & python3 bench/bench_echo.py tino_client_simple & python3 bench/bench_echo.py tino_client_simple & python3 bench/bench_echo.py tino_client_simple & python3 bench/bench_echo.py tino_client_simple & python3 bench/bench_echo.py tino_client_simple & python3 bench/bench_echo.py tino_client_simple & python3 bench/bench_echo.py tino_client_simple & python3 bench/bench_echo.py tino_client_simple & python3 bench/bench_echo.py tino_client_simple & python3 bench/bench_echo.py tino_client_simple & python3 bench/bench_echo.py tino_client_simple & python3 bench/bench_echo.py tino_client_simple & python3 bench/bench_echo.py tino_client_simple & python3 bench/bench_echo.py tino_client_simple & python3 bench/bench_echo.py tino_client_simple & python3 bench/bench_echo.py tino_client_simple & python3 bench/bench_echo.py tino_client_simple & python3 bench/bench_echo.py tino_client_simple & python3 bench/bench_echo.py tino_client_simple & python3 bench/bench_echo.py tino_client_simple & python3 bench/bench_echo.py tino_client_simple & python3 bench/bench_echo.py tino_client_simple & python3 bench/bench_echo.py tino_client_simple & python3 bench/bench_echo.py tino_client_simple & python3 bench/bench_echo.py tino_client_simple & python3 bench/bench_echo.py tino_client_simple & python3 bench/bench_echo.py tino_client_simple & python3 bench/bench_echo.py tino_client_simple & python3 bench/bench_echo.py tino_client_simple & python3 bench/bench_echo.py tino_client_simple & python3 bench/bench_echo.py tino_client_simple & python3 bench/bench_echo.py tino_client_simple & python3 bench/bench_echo.py tino_client_simple & python3 bench/bench_echo.py tino_client_simple & python3 bench/bench_echo.py tino_client_simple & python3 bench/bench_echo.py tino_client_simple & python3 bench/bench_echo.py tino_client_simple & python3 bench/bench_echo.py tino_client_simple & python3 bench/bench_echo.py tino_client_simple & python3 bench/bench_echo.py tino_client_simple & python3 bench/bench_echo.py tino_client_simple & python3 bench/bench_echo.py tino_client_simple & python3 bench/bench_echo.py tino_client_simple & python3 bench/bench_echo.py tino_client_simple & python3 bench/bench_echo.py tino_client_simple & python3 bench/bench_echo.py tino_client_simple & python3 bench/bench_echo.py tino_client_simple & python3 bench/bench_echo.py tino_client_simple & python3 bench/bench_echo.py tino_client_simple & python3 bench/bench_echo.py tino_client_simple & python3 bench/bench_echo.py tino_client_simple & python3 bench/bench_echo.py tino_client_simple & python3 bench/bench_echo.py tino_client_simple 4 | -------------------------------------------------------------------------------- /bench/post_complex.txt: -------------------------------------------------------------------------------- 1 | {"a": 1, "b": true, "c": [{"a": 1, "b": true, "c": [{"a": 1, "b": true, "c": [], "d": {}, "e": "some \ud83d\ude0a \ud83d\ude0a string", "f": "some \ud83d\ude0a \ud83d\ude0a string", "g": "some \ud83d\ude0a \ud83d\ude0a string", "h": "some \ud83d\ude0a \ud83d\ude0a string", "i": "some \ud83d\ude0a \ud83d\ude0a string"}, {"a": 1, "b": true, "c": [], "d": {}, "e": "some \ud83d\ude0a \ud83d\ude0a string", "f": "some \ud83d\ude0a \ud83d\ude0a string", "g": "some \ud83d\ude0a \ud83d\ude0a string", "h": "some \ud83d\ude0a \ud83d\ude0a string", "i": "some \ud83d\ude0a \ud83d\ude0a string"}], "d": {"f": {"a": 1, "b": true, "c": [{"a": 1, "b": true, "c": [], "d": {}, "e": "some \ud83d\ude0a \ud83d\ude0a string", "f": "some \ud83d\ude0a \ud83d\ude0a string", "g": "some \ud83d\ude0a \ud83d\ude0a string", "h": "some \ud83d\ude0a \ud83d\ude0a string", "i": "some \ud83d\ude0a \ud83d\ude0a string"}], "d": {}, "e": "some \ud83d\ude0a \ud83d\ude0a string", "f": "some \ud83d\ude0a \ud83d\ude0a string", "g": "some \ud83d\ude0a \ud83d\ude0a string", "h": "some \ud83d\ude0a \ud83d\ude0a string", "i": "some \ud83d\ude0a \ud83d\ude0a string"}}, "e": "some \ud83d\ude0a \ud83d\ude0a string", "f": "some \ud83d\ude0a \ud83d\ude0a string", "g": "some \ud83d\ude0a \ud83d\ude0a string", "h": "some \ud83d\ude0a \ud83d\ude0a string", "i": "some \ud83d\ude0a \ud83d\ude0a string"}], "d": {"g": {"a": 1, "b": true, "c": [], "d": {}, "e": "some \ud83d\ude0a \ud83d\ude0a string", "f": "some \ud83d\ude0a \ud83d\ude0a string", "g": "some \ud83d\ude0a \ud83d\ude0a string", "h": "some \ud83d\ude0a \ud83d\ude0a string", "i": "some \ud83d\ude0a \ud83d\ude0a string"}}, "e": "some \ud83d\ude0a \ud83d\ude0a string", "f": "some \ud83d\ude0a \ud83d\ude0a string", "g": "some \ud83d\ude0a \ud83d\ude0a string", "h": "some \ud83d\ude0a \ud83d\ude0a string", "i": "some \ud83d\ude0a \ud83d\ude0a string"} -------------------------------------------------------------------------------- /bench/post_loc.txt: -------------------------------------------------------------------------------- 1 | "\ud83d\ude0a \ud83d\ude0a \ud83d\ude0a\ud83d\ude0a \ud83d\ude0a \ud83d\ude0a\ud83d\ude0a \ud83d\ude0a \ud83d\ude0a\ud83d\ude0a \ud83d\ude0a \ud83d\ude0a\ud83d\ude0a \ud83d\ude0a \ud83d\ude0a\ud83d\ude0a \ud83d\ude0a \ud83d\ude0a\ud83d\ude0a \ud83d\ude0a \ud83d\ude0a\ud83d\ude0a \ud83d\ude0a \ud83d\ude0a\ud83d\ude0a \ud83d\ude0a \ud83d\ude0a\ud83d\ude0a \ud83d\ude0a \ud83d\ude0a\ud83d\ude0a \ud83d\ude0a \ud83d\ude0a\ud83d\ude0a \ud83d\ude0a \ud83d\ude0a\ud83d\ude0a \ud83d\ude0a \ud83d\ude0a\ud83d\ude0a \ud83d\ude0a \ud83d\ude0a\ud83d\ude0a \ud83d\ude0a \ud83d\ude0a\ud83d\ude0a \ud83d\ude0a \ud83d\ude0a\ud83d\ude0a \ud83d\ude0a \ud83d\ude0a\ud83d\ude0a \ud83d\ude0a \ud83d\ude0a\ud83d\ude0a \ud83d\ude0a \ud83d\ude0a\ud83d\ude0a \ud83d\ude0a \ud83d\ude0a\ud83d\ude0a \ud83d\ude0a \ud83d\ude0a\ud83d\ude0a \ud83d\ude0a \ud83d\ude0a\ud83d\ude0a \ud83d\ude0a \ud83d\ude0a\ud83d\ude0a \ud83d\ude0a \ud83d\ude0a\ud83d\ude0a \ud83d\ude0a \ud83d\ude0a\ud83d\ude0a \ud83d\ude0a \ud83d\ude0a\ud83d\ude0a \ud83d\ude0a \ud83d\ude0a\ud83d\ude0a \ud83d\ude0a \ud83d\ude0a\ud83d\ude0a \ud83d\ude0a \ud83d\ude0a\ud83d\ude0a \ud83d\ude0a \ud83d\ude0a\ud83d\ude0a \ud83d\ude0a \ud83d\ude0a\ud83d\ude0a \ud83d\ude0a \ud83d\ude0a\ud83d\ude0a \ud83d\ude0a \ud83d\ude0a\ud83d\ude0a \ud83d\ude0a \ud83d\ude0a\ud83d\ude0a \ud83d\ude0a \ud83d\ude0a\ud83d\ude0a \ud83d\ude0a \ud83d\ude0a\ud83d\ude0a \ud83d\ude0a \ud83d\ude0a\ud83d\ude0a \ud83d\ude0a \ud83d\ude0a\ud83d\ude0a \ud83d\ude0a \ud83d\ude0a\ud83d\ude0a \ud83d\ude0a \ud83d\ude0a\ud83d\ude0a \ud83d\ude0a \ud83d\ude0a\ud83d\ude0a \ud83d\ude0a \ud83d\ude0a\ud83d\ude0a \ud83d\ude0a \ud83d\ude0a\ud83d\ude0a \ud83d\ude0a \ud83d\ude0a\ud83d\ude0a \ud83d\ude0a \ud83d\ude0a\ud83d\ude0a \ud83d\ude0a \ud83d\ude0a\ud83d\ude0a \ud83d\ude0a \ud83d\ude0a\ud83d\ude0a \ud83d\ude0a \ud83d\ude0a\ud83d\ude0a \ud83d\ude0a \ud83d\ude0a\ud83d\ude0a \ud83d\ude0a \ud83d\ude0a\ud83d\ude0a \ud83d\ude0a \ud83d\ude0a\ud83d\ude0a \ud83d\ude0a \ud83d\ude0a\ud83d\ude0a \ud83d\ude0a \ud83d\ude0a\ud83d\ude0a \ud83d\ude0a \ud83d\ude0a\ud83d\ude0a \ud83d\ude0a \ud83d\ude0a\ud83d\ude0a \ud83d\ude0a \ud83d\ude0a\ud83d\ude0a \ud83d\ude0a \ud83d\ude0a\ud83d\ude0a \ud83d\ude0a \ud83d\ude0a\ud83d\ude0a \ud83d\ude0a \ud83d\ude0a\ud83d\ude0a \ud83d\ude0a \ud83d\ude0a\ud83d\ude0a \ud83d\ude0a \ud83d\ude0a\ud83d\ude0a \ud83d\ude0a \ud83d\ude0a\ud83d\ude0a \ud83d\ude0a \ud83d\ude0a\ud83d\ude0a \ud83d\ude0a \ud83d\ude0a\ud83d\ude0a \ud83d\ude0a \ud83d\ude0a\ud83d\ude0a \ud83d\ude0a \ud83d\ude0a\ud83d\ude0a \ud83d\ude0a \ud83d\ude0a\ud83d\ude0a \ud83d\ude0a \ud83d\ude0a\ud83d\ude0a \ud83d\ude0a \ud83d\ude0a\ud83d\ude0a \ud83d\ude0a \ud83d\ude0a\ud83d\ude0a \ud83d\ude0a \ud83d\ude0a\ud83d\ude0a \ud83d\ude0a \ud83d\ude0a\ud83d\ude0a \ud83d\ude0a \ud83d\ude0a\ud83d\ude0a \ud83d\ude0a \ud83d\ude0a\ud83d\ude0a \ud83d\ude0a \ud83d\ude0a\ud83d\ude0a \ud83d\ude0a \ud83d\ude0a\ud83d\ude0a \ud83d\ude0a \ud83d\ude0a\ud83d\ude0a \ud83d\ude0a \ud83d\ude0a\ud83d\ude0a \ud83d\ude0a \ud83d\ude0a\ud83d\ude0a \ud83d\ude0a \ud83d\ude0a\ud83d\ude0a \ud83d\ude0a \ud83d\ude0a\ud83d\ude0a \ud83d\ude0a \ud83d\ude0a\ud83d\ude0a \ud83d\ude0a \ud83d\ude0a\ud83d\ude0a \ud83d\ude0a \ud83d\ude0a\ud83d\ude0a \ud83d\ude0a \ud83d\ude0a\ud83d\ude0a \ud83d\ude0a \ud83d\ude0a\ud83d\ude0a \ud83d\ude0a \ud83d\ude0a\ud83d\ude0a \ud83d\ude0a \ud83d\ude0a" -------------------------------------------------------------------------------- /bench/run_fapi_uvicorn.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | uvicorn bench_echo:fapi --host "localhost" --port "9999" --log-level error -------------------------------------------------------------------------------- /bench/run_tino_multi.py: -------------------------------------------------------------------------------- 1 | from tino import run 2 | 3 | if __name__ == "__main__": 4 | run("bench_echo:api", workers=6, host="localhost", port=7777) 5 | -------------------------------------------------------------------------------- /examples/database.py: -------------------------------------------------------------------------------- 1 | from tino import Tino 2 | import databases 3 | import sqlalchemy 4 | from typing import List 5 | from pydantic import BaseModel 6 | 7 | # SQLAlchemy specific code, as with any other app 8 | DATABASE_URL = "sqlite:///./test.db" 9 | # DATABASE_URL = "postgresql://user:password@postgresserver/db" 10 | 11 | database = databases.Database(DATABASE_URL) 12 | 13 | metadata = sqlalchemy.MetaData() 14 | 15 | notes = sqlalchemy.Table( 16 | "notes", 17 | metadata, 18 | sqlalchemy.Column("id", sqlalchemy.Integer, primary_key=True), 19 | sqlalchemy.Column("text", sqlalchemy.String), 20 | sqlalchemy.Column("completed", sqlalchemy.Boolean), 21 | ) 22 | 23 | 24 | engine = sqlalchemy.create_engine( 25 | DATABASE_URL, connect_args={"check_same_thread": False} 26 | ) 27 | metadata.create_all(engine) 28 | 29 | 30 | class NoteIn(BaseModel): 31 | text: str 32 | completed: bool 33 | 34 | 35 | class Note(BaseModel): 36 | id: int 37 | text: str 38 | completed: bool 39 | 40 | 41 | app = Tino() 42 | 43 | 44 | @app.on_startup 45 | async def startup(): 46 | await database.connect() 47 | 48 | 49 | @app.on_shutdown 50 | async def shutdown(): 51 | await database.disconnect() 52 | 53 | 54 | @app.command 55 | async def read_notes() -> List[Note]: 56 | query = notes.select() 57 | rows = await database.fetch_all(query) 58 | return [Note(**n) for n in rows] 59 | 60 | 61 | @app.command 62 | async def create_note(note: NoteIn) -> Note: 63 | query = notes.insert().values(text=note.text, completed=note.completed) 64 | last_record_id = await database.execute(query) 65 | return Note(id=last_record_id, **note.dict()) 66 | 67 | 68 | if __name__ == "__main__": 69 | app.run() 70 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | 4 | import os 5 | import re 6 | 7 | from setuptools import setup 8 | 9 | 10 | def get_version(package): 11 | """ 12 | Return package version as listed in `__version__` in `init.py`. 13 | """ 14 | with open(os.path.join(package, "__init__.py")) as f: 15 | return re.search("__version__ = ['\"]([^'\"]+)['\"]", f.read()).group(1) 16 | 17 | 18 | def get_long_description(): 19 | """ 20 | Return the README. 21 | """ 22 | with open("README.md", encoding="utf8") as f: 23 | return f.read() 24 | 25 | 26 | def get_packages(package): 27 | """ 28 | Return root package and all sub-packages. 29 | """ 30 | return [ 31 | dirpath 32 | for dirpath, dirnames, filenames in os.walk(package) 33 | if os.path.exists(os.path.join(dirpath, "__init__.py")) 34 | ] 35 | 36 | 37 | setup( 38 | name="tino", 39 | version=get_version("tino"), 40 | python_requires=">=3.6", 41 | url="https://github.com/hansonkd/tino", 42 | license="MIT", 43 | description="API Framework built on MsgPack and Redis Protocol", 44 | long_description=get_long_description(), 45 | long_description_content_type="text/markdown", 46 | author="Kyle Hanson", 47 | author_email="me@khanson.io", 48 | packages=get_packages("tino"), 49 | package_data={"tino": ["py.typed"]}, 50 | data_files=[("", ["LICENSE"])], 51 | install_requires=[ 52 | "pydantic>=1.5.1", 53 | "msgpack>=1.0.0", 54 | "aioredis>=1.3.1", 55 | "uvicorn>=0.11.5", 56 | ], 57 | classifiers=[ 58 | "Development Status :: 3 - Alpha", 59 | "Intended Audience :: Developers", 60 | "Operating System :: OS Independent", 61 | "Programming Language :: Python :: 3.6", 62 | "Programming Language :: Python :: 3.7", 63 | "Programming Language :: Python :: 3.8", 64 | ], 65 | zip_safe=False, 66 | ) 67 | -------------------------------------------------------------------------------- /tests/conftest.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hansonkd/Tino/a1d5ed6ff7c3ff95e4f5b8decaa704fb4f7ece1c/tests/conftest.py -------------------------------------------------------------------------------- /tests/test_auth.py: -------------------------------------------------------------------------------- 1 | from tino import Tino, Auth, AuthRequired 2 | from pydantic import BaseModel 3 | import datetime 4 | from typing import List, Dict, Optional 5 | import pytest 6 | from aioredis import ReplyError 7 | 8 | 9 | async def authorize(password): 10 | if password == b"test123": 11 | return password 12 | 13 | 14 | api = Tino(auth_func=authorize) 15 | 16 | 17 | @api.command 18 | async def echo(a: int, auth: AuthRequired) -> int: 19 | return a 20 | 21 | 22 | @api.command 23 | async def echo_auth(auth: Auth) -> Optional[bytes]: 24 | return auth.value 25 | 26 | 27 | @pytest.mark.asyncio 28 | async def test_api_success(): 29 | async with api.test_server_with_client(password="test123") as client: 30 | result = await client.echo(1) 31 | assert result == 1 32 | 33 | 34 | @pytest.mark.asyncio 35 | async def test_api_permission_denied(): 36 | async with api.test_server_with_client() as client: 37 | with pytest.raises(ReplyError) as e: 38 | await client.echo(1) 39 | assert str(e.value) == "PERMISSION_DENIED" 40 | 41 | 42 | @pytest.mark.asyncio 43 | async def test_api_wrong_password(): 44 | with pytest.raises(ReplyError) as e: 45 | async with api.test_server_with_client(password="notthepassword"): 46 | pass 47 | assert str(e.value) == "PERMISSION_DENIED" 48 | 49 | 50 | @pytest.mark.asyncio 51 | async def test_api_echo_auth(): 52 | async with api.test_server_with_client(password="test123") as client: 53 | auth_key = await client.echo_auth() 54 | assert auth_key == b"test123" 55 | 56 | 57 | @pytest.mark.asyncio 58 | async def test_api_echo_auth_none(): 59 | async with api.test_server_with_client() as client: 60 | auth_key = await client.echo_auth() 61 | assert auth_key == None 62 | -------------------------------------------------------------------------------- /tests/test_basic_api.py: -------------------------------------------------------------------------------- 1 | from tino import Tino 2 | from pydantic import BaseModel 3 | import datetime 4 | from typing import List, Dict, Optional 5 | import pytest 6 | from aioredis import ReplyError 7 | 8 | 9 | api = Tino() 10 | 11 | 12 | class MyModel(BaseModel): 13 | dt = datetime.datetime 14 | 15 | 16 | @api.command 17 | async def awesome(a: int, b: bool, c: List[str], d: Optional[MyModel]) -> Dict: 18 | return {"a": a, "b": b, "c": c, "d": d} 19 | 20 | 21 | @pytest.mark.asyncio 22 | async def test_api_success(): 23 | async with api.test_server_with_client() as client: 24 | result = await client.awesome(1, True, [], MyModel(dt=datetime.datetime.now())) 25 | assert result == { 26 | "a": 1, 27 | "b": True, 28 | "c": [], 29 | "d": MyModel(dt=datetime.datetime.now()), 30 | } 31 | 32 | 33 | @pytest.mark.asyncio 34 | async def test_api_one_list(): 35 | async with api.test_server_with_client() as client: 36 | result = await client.awesome( 37 | 1, True, ["boom"], MyModel(dt=datetime.datetime.now()) 38 | ) 39 | assert result == { 40 | "a": 1, 41 | "b": True, 42 | "c": ["boom"], 43 | "d": MyModel(dt=datetime.datetime.now()), 44 | } 45 | 46 | 47 | @pytest.mark.asyncio 48 | async def test_api_two_list(): 49 | async with api.test_server_with_client() as client: 50 | result = await client.awesome( 51 | pow(2, 31), False, ["boom", "boom2"], MyModel(dt=datetime.datetime.now()) 52 | ) 53 | assert result == { 54 | "a": pow(2, 31), 55 | "b": False, 56 | "c": ["boom", "boom2"], 57 | "d": MyModel(dt=datetime.datetime.now()), 58 | } 59 | 60 | 61 | @pytest.mark.asyncio 62 | async def test_api_wrong_type(): 63 | async with api.test_server_with_client() as client: 64 | with pytest.raises(ReplyError) as e: 65 | await client.awesome("abc", True, [], None) 66 | assert ( 67 | str(e.value) 68 | == 'VALIDATION_ERROR ["command", "a"] "value is not a valid integer" "type_error.integer"' 69 | ) 70 | 71 | 72 | @pytest.mark.asyncio 73 | async def test_api_wrong_command(): 74 | async with api.test_server_with_client() as client: 75 | with pytest.raises(ReplyError) as e: 76 | await client.redis.execute("ABC") 77 | assert str(e.value) == "INVALID_COMMAND ABC" 78 | -------------------------------------------------------------------------------- /tests/test_state.py: -------------------------------------------------------------------------------- 1 | from tino import Tino, ConnState 2 | from pydantic import BaseModel 3 | import datetime 4 | from typing import List, Dict, Optional 5 | import pytest 6 | from aioredis import ReplyError 7 | 8 | 9 | def state_factory(): 10 | return 0 11 | 12 | 13 | api = Tino(state_factory=state_factory) 14 | 15 | 16 | @api.command 17 | async def increment(a: int, state: ConnState) -> None: 18 | state.value += a 19 | 20 | 21 | @api.command 22 | async def get(state: ConnState) -> int: 23 | return state.value 24 | 25 | 26 | @pytest.mark.asyncio 27 | async def test_api_success(): 28 | async with api.test_server_with_client() as client: 29 | result = await client.get() 30 | assert result == 0 31 | await client.increment(1) 32 | result = await client.get() 33 | assert result == 1 34 | -------------------------------------------------------------------------------- /tino/__init__.py: -------------------------------------------------------------------------------- 1 | from .tino import * 2 | 3 | __version__ = "0.0.1" 4 | -------------------------------------------------------------------------------- /tino/config.py: -------------------------------------------------------------------------------- 1 | import uvicorn 2 | 3 | from .protocol import protocol_factory 4 | 5 | 6 | class Config(uvicorn.Config): 7 | def __init__(self, *args, **kwargs): 8 | kwargs.setdefault("lifespan", "off") 9 | kwargs.setdefault("proxy_headers", False) 10 | kwargs.setdefault("interface", "tino") 11 | kwargs.setdefault("server_name", "Tino") 12 | kwargs.setdefault("protocol_name", "redis") 13 | kwargs.setdefault("http", protocol_factory) 14 | super().__init__(*args, **kwargs) 15 | -------------------------------------------------------------------------------- /tino/protocol.py: -------------------------------------------------------------------------------- 1 | from aioredis.parser import Reader 2 | from aioredis.stream import StreamReader 3 | from asyncio.streams import StreamReaderProtocol 4 | import msgpack 5 | import json 6 | from pydantic import ValidationError 7 | from pydantic.tools import parse_obj_as 8 | 9 | 10 | from .serializer import default, pack_msgpack 11 | 12 | 13 | from .special_args import Auth, AuthRequired, ConnState 14 | 15 | MAX_CHUNK_SIZE = 65536 16 | OK = b"+OK\r\n" 17 | COMMAND_PING = b"PING" 18 | PONG = b"+PONG\r\n" 19 | COMMAND_QUIT = b"QUIT" 20 | COMMAND_AUTH = b"AUTH" 21 | COMMAND_SUBSCRIBE = b"SUBSCRIBE" 22 | COMMAND_ITER = b"ITER" 23 | COMMAND_NEXT = b"NEXT" 24 | COMMAND_SEND = b"SEND" 25 | BUILT_IN_COMMANDS = (COMMAND_PING, COMMAND_QUIT, COMMAND_AUTH, COMMAND_SUBSCRIBE) 26 | 27 | 28 | async def write_permission_denied(writer): 29 | writer.write(b"-PERMISSION_DENIED\r\n") 30 | await writer.drain() 31 | 32 | 33 | class TinoHandler: 34 | def __init__(self, config, server_state): 35 | self.config = config 36 | self.server_state = server_state 37 | self.packer = msgpack.Packer(default=default) 38 | 39 | async def handle_connection(self, reader, writer): 40 | if self.config.loaded_app.state_factory: 41 | state = ConnState(self.config.loaded_app.state_factory()) 42 | else: 43 | state = ConnState(None) 44 | 45 | auth = Auth(None) 46 | 47 | try: 48 | while True: 49 | data = await reader.readobj() 50 | if not data: 51 | break 52 | incoming_command = data[0] 53 | if incoming_command == COMMAND_QUIT: 54 | writer.write(OK) 55 | await writer.drain() 56 | break 57 | elif incoming_command == COMMAND_PING: 58 | writer.write(PONG) 59 | await writer.drain() 60 | continue 61 | elif incoming_command == COMMAND_AUTH: 62 | new_auth = await self.config.loaded_app.auth_func(*data[1:]) 63 | if new_auth: 64 | auth.value = new_auth 65 | writer.write(OK) 66 | await writer.drain() 67 | continue 68 | else: 69 | auth.value = None 70 | await write_permission_denied(writer) 71 | break 72 | else: 73 | try: 74 | command = self.config.loaded_app.commands[incoming_command] 75 | except KeyError: 76 | writer.write(b"-INVALID_COMMAND %b\r\n" % incoming_command) 77 | await writer.drain() 78 | break 79 | 80 | should_break = await self.execute( 81 | command, data[1:], writer, state, auth, self.packer 82 | ) 83 | if should_break: 84 | break 85 | finally: 86 | writer.close() 87 | await writer.wait_closed() 88 | 89 | async def build_args(self, command, redis_list, writer, state, auth): 90 | args = [] 91 | if len(redis_list) != command.num_args: 92 | writer.write(b"-NUM_ARG_MISMATCH\r\n") 93 | await writer.drain() 94 | return None 95 | 96 | pos = 0 97 | for (arg_name, arg_type) in command.signature: 98 | if arg_type == Auth: 99 | args.append(auth) 100 | continue 101 | elif arg_type == AuthRequired: 102 | if not auth.value: 103 | await write_permission_denied(writer) 104 | return None 105 | args.append(AuthRequired(auth.value)) 106 | continue 107 | elif arg_type == ConnState: 108 | args.append(state) 109 | continue 110 | 111 | redis_value = redis_list[pos] 112 | pos += 1 113 | 114 | try: 115 | raw_value = msgpack.unpackb(redis_value) 116 | except msgpack.UnpackException as e: 117 | loc = json.dumps(["command", arg_name]) 118 | msg = json.dumps("invalid msgpack") 119 | writer.write(f"-INVALID_MSGPACK {loc} {msg}\r\n".encode("utf8")) 120 | await writer.drain() 121 | return None 122 | 123 | try: 124 | obj = parse_obj_as(arg_type, raw_value) 125 | except ValidationError as e: 126 | err = e.errors()[0] 127 | loc = json.dumps(("command", arg_name) + err["loc"][1:]) 128 | msg = json.dumps(err["msg"]) 129 | t = json.dumps(err["type"]) 130 | writer.write(f"-VALIDATION_ERROR {loc} {msg} {t}\r\n".encode("utf8")) 131 | await writer.drain() 132 | return None 133 | args.append(obj) 134 | return args 135 | 136 | async def execute(self, command, redis_list, writer, state, auth, packer): 137 | try: 138 | args = await self.build_args(command, redis_list, writer, state, auth) 139 | if not args: 140 | return True 141 | result = await command.handler(*args) 142 | 143 | to_send = pack_msgpack(packer, result) 144 | writer.write(b"$%d\r\n%b\r\n" % (len(to_send), to_send)) 145 | await writer.drain() 146 | return False 147 | except Exception as e: 148 | msg = json.dumps(str(e)) 149 | writer.write(f"-UNEXPECTED_ERROR {msg}\r\n".encode("utf8")) 150 | await writer.drain() 151 | raise e 152 | 153 | 154 | def protocol_factory(config, server_state, loop=None): 155 | reader = StreamReader(limit=MAX_CHUNK_SIZE, loop=loop) 156 | reader.set_parser(Reader()) 157 | runner = TinoHandler(config, server_state) 158 | return StreamReaderProtocol(reader, runner.handle_connection, loop=loop) 159 | -------------------------------------------------------------------------------- /tino/serializer.py: -------------------------------------------------------------------------------- 1 | from typing import List 2 | import datetime 3 | from decimal import Decimal 4 | from enum import Enum 5 | from ipaddress import ( 6 | IPv4Address, 7 | IPv4Interface, 8 | IPv4Network, 9 | IPv6Address, 10 | IPv6Interface, 11 | IPv6Network, 12 | ) 13 | from pathlib import Path 14 | from types import GeneratorType 15 | from typing import Any, Callable, Dict, Type, Union 16 | from uuid import UUID 17 | from pydantic import BaseModel 18 | 19 | 20 | def isoformat(o): 21 | return o.isoformat() 22 | 23 | 24 | ENCODERS_BY_TYPE: Dict[Type[Any], Callable[[Any], Any]] = { 25 | datetime.date: isoformat, 26 | datetime.datetime: isoformat, 27 | datetime.time: isoformat, 28 | datetime.timedelta: lambda td: td.total_seconds(), 29 | Decimal: float, 30 | Enum: lambda o: o.value, 31 | frozenset: list, 32 | GeneratorType: list, 33 | IPv4Address: str, 34 | IPv4Interface: str, 35 | IPv4Network: str, 36 | IPv6Address: str, 37 | IPv6Interface: str, 38 | IPv6Network: str, 39 | Path: str, 40 | set: list, 41 | UUID: str, 42 | } 43 | 44 | 45 | def default(obj): 46 | if isinstance(obj, BaseModel): 47 | return obj.dict() 48 | if hasattr(obj, "__dict__"): 49 | return dict(obj) 50 | 51 | encoder = ENCODERS_BY_TYPE.get(type(obj)) 52 | if encoder: 53 | return encoder(obj) 54 | return obj 55 | 56 | 57 | def pack_msgpack(packer, obj): 58 | return packer.pack(obj) 59 | -------------------------------------------------------------------------------- /tino/server.py: -------------------------------------------------------------------------------- 1 | import uvicorn 2 | 3 | 4 | class Server(uvicorn.Server): 5 | async def startup(self, sockets=None): 6 | await super().startup(sockets=sockets) 7 | for f in self.config.loaded_app.startup_funcs: 8 | await f() 9 | 10 | async def shutdown(self, sockets=None): 11 | await super().shutdown(sockets=sockets) 12 | for f in self.config.loaded_app.shutdown_funcs: 13 | await f() 14 | -------------------------------------------------------------------------------- /tino/special_args.py: -------------------------------------------------------------------------------- 1 | class Auth: 2 | def __init__(self, auth_state): 3 | self.value = auth_state 4 | 5 | 6 | class AuthRequired: 7 | def __init__(self, auth_state): 8 | self.value = auth_state 9 | 10 | 11 | class ConnState: 12 | def __init__(self, value): 13 | self.value = value 14 | -------------------------------------------------------------------------------- /tino/tino.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import sys 3 | import inspect 4 | import msgpack 5 | import json 6 | import logging 7 | from contextlib import asynccontextmanager 8 | 9 | from pydantic.tools import parse_obj_as 10 | 11 | from aioredis import create_redis_pool 12 | 13 | from uvicorn.supervisors.multiprocess import Multiprocess 14 | 15 | from .special_args import Auth, AuthRequired, ConnState 16 | from .server import Server 17 | from .config import Config 18 | from .serializer import pack_msgpack, default 19 | from .protocol import BUILT_IN_COMMANDS, COMMAND_SUBSCRIBE, write_permission_denied 20 | 21 | 22 | class Command: 23 | def __init__(self, command, signature, return_type, handler): 24 | self.command = command 25 | self.signature = signature 26 | self.num_args = len( 27 | [ 28 | arg_name 29 | for arg_name, arg_type in self.signature 30 | if arg_type not in (Auth, AuthRequired, ConnState) 31 | ] 32 | ) 33 | self.handler = handler 34 | self.return_type = return_type 35 | 36 | 37 | class Tino: 38 | def __init__(self, auth_func=None, state_factory=None, loop=None): 39 | self.commands = {} 40 | self.auth_func = auth_func 41 | self.state_factory = state_factory 42 | self.startup_funcs = [] 43 | self.shutdown_funcs = [] 44 | 45 | def command(self, f): 46 | name = f.__name__.upper().encode("utf8") 47 | if name in BUILT_IN_COMMANDS: 48 | raise Exception( 49 | f'Creating a command with name "{f.__name__}" is not allowed because it conflicts with a built in command.' 50 | ) 51 | sig = inspect.signature(f) 52 | ts_ = [(k, v.annotation) for k, v in sig.parameters.items()] 53 | self.commands[name] = Command(name, ts_, sig.return_annotation, f) 54 | return f 55 | 56 | def on_startup(self, f): 57 | self.startup_funcs.append(f) 58 | return f 59 | 60 | def on_shutdown(self, f): 61 | self.shutdown_funcs.append(f) 62 | return f 63 | 64 | def run(self, **kwargs): 65 | config = Config(self, **kwargs) 66 | server = Server(config=config) 67 | server.run() 68 | 69 | async def create_server(self, loop=None, host=None, port=None, **kwargs): 70 | loop = loop or asyncio.get_event_loop() 71 | 72 | def factory(): 73 | reader = StreamReader(limit=MAX_CHUNK_SIZE, loop=loop) 74 | reader.set_parser(Reader()) 75 | return StreamReaderProtocol(reader, self.handle_connection, loop=loop) 76 | 77 | return await loop.create_server( 78 | factory, host or self.host, port or self.port, **kwargs 79 | ) 80 | 81 | @asynccontextmanager 82 | async def test_server_with_client(self, password=None, **kwargs): 83 | kwargs.setdefault("host", "localhost") 84 | kwargs.setdefault("port", 7534) 85 | 86 | config = Config(self, log_level="warning", **kwargs) 87 | server = Server(config=config) 88 | 89 | if not config.loaded: 90 | config.load() 91 | 92 | server.lifespan = config.lifespan_class(config) 93 | 94 | await server.startup(sockets=None) 95 | 96 | client_class = make_client_class(self) 97 | client = client_class() 98 | 99 | try: 100 | await client.connect( 101 | f"redis://{config.host}:{config.port}", password=password 102 | ) 103 | yield client 104 | finally: 105 | await server.shutdown(sockets=None) 106 | client.close() 107 | await client.wait_closed() 108 | 109 | def client(self): 110 | klass = make_client_class(self) 111 | return klass() 112 | 113 | 114 | class Client: 115 | def __init__(self, redis=None): 116 | self.redis = redis 117 | 118 | async def connect(self, redis_url="redis://localhost:7777", *args, **kwargs): 119 | self.redis = await create_redis_pool(redis_url, *args, **kwargs) 120 | 121 | def close(self): 122 | if self.redis: 123 | self.redis.close() 124 | 125 | async def wait_closed(self): 126 | if self.redis: 127 | await self.redis.wait_closed() 128 | 129 | 130 | def make_client_class(api: Tino): 131 | methods = {} 132 | for name, command in api.commands.items(): 133 | 134 | async def call(self, *args, command=command): 135 | packer = msgpack.Packer(default=default) 136 | packed = [pack_msgpack(packer, arg) for arg in args] 137 | result = await self.redis.execute(command.command, *packed) 138 | r = msgpack.unpackb(result) 139 | if command.return_type != None: 140 | return parse_obj_as(command.return_type, r) 141 | 142 | methods[name.lower().decode("utf8")] = call 143 | return type("BoundClient", (Client,), methods) 144 | 145 | 146 | def make_mock_client(api: Tino): 147 | methods = {} 148 | for name, command in api.commands.items(): 149 | 150 | async def call(self, *args): 151 | command.handle(*args) 152 | 153 | methods[name.lower().decode("utf8")] = call 154 | return type("BoundClient", (Client,), methods) 155 | 156 | 157 | def run(app: str, **kwargs): 158 | config = Config(app, proxy_headers=False, interface="tino", **kwargs) 159 | server = Server(config=config) 160 | 161 | if config.workers > 1: 162 | sock = config.bind_socket() 163 | supervisor = Multiprocess(config, target=server.run, sockets=[sock]) 164 | supervisor.run() 165 | else: 166 | server.run() 167 | --------------------------------------------------------------------------------