Pythonic web development
Project description
µHTTP
Pythonic web development
µHTTP emerged from the need of a simple, hassle-free web framework. It's great for microservices, single page applications, AND monolithic monsters.
In µHTTP there is no hidden logic. Everything is what it seems.
Why
- Stupid simple, seriously there are maybe 15 lines of "real" code in it. No external dependencies.
- Extremely modular, entire extensions can just follow the simple App pattern.
- Flexible, say what you will about wrong responses, they work.
- Fast, because it doesn't really do much.
- Very opinionated, to the point where it has no opinions.
- Not typist.
Installation
µHTTP is on PyPI.
pip install uhttp
You might also need an ASGI server. I recommend Uvicorn.
pip install uvicorn
Hello, world!
#!/usr/bin/env python3
from uhttp import App
app = App()
@app.get('/')
def hello(request):
return 'Hello, world!'
if __name__ == '__main__':
import uvicorn
uvicorn.run('__main__:app')
Inspirations
- Flask:
from flask import *
- FastAPI:
Union[Any, None]
- Sanic: A walking contradiction
- Bottle: One file, 3500+ LOC
- Django
TODO
- Tests
- Multipart requests
- Tutorial
API Reference
Application
class App:
_routes: dict
_startup: list
_shutdown: list
_before: list
_after: list
_max_content: int
def mount(self, app, prefix=''):
self._startup += app._startup
self._shutdown += app._shutdown
self._before += app._before
self._after += app._after
self._routes.update({prefix + k: v for k, v in app._routes.items()})
self._max_content = max(self._max_content, app._max_content)
In particular, this Django-like pattern is possible:
app = App(
startup=[open_db, dance],
before=[auth],
routes={
'/': {
'GET': index,
'POST': filter
},
'/users/': {
'GET': users,
'PUT': users
}
},
after=[logger],
shutdown=[close_db]
)
app.mount
is µHTTP's modularity.
In users.py
you have:
from uhttp import App
app = App()
@app.before
def auth(request):
...
@app.route('/', methods=('GET', 'PUT'))
def users(request):
...
In db.py
:
from uhttp import App
app = App()
@app.startup
async def open_db(state):
...
@app.shutdown
async def close_db(state):
...
Finally, in main.py
:
from uhttp import App
import users
import db
app = App()
app.mount(users.app, prefix='/users')
app.mount(db.app)
@app.get('/')
def index(request):
...
Entire extensions can be just apps!
Lifespan functions
Lifespan functions are based on the Lifespan Protocol.
There are two decorators: @app.startup
and @app.shutdown
. Decorated functions receive one argument: state
.
This is a great place to setup database connections and other dependencies that your application might need.
A shallow copy of the state is passed to each request.
Middleware
µHTTP provides two decorators @app.before
and @app.after
.
Any value returned from the decorated functions will set the response and break the control flow.
@app.before
functions receive only a request argument. They are called before a response is made, i.e. before the route function (if there is one). Particularly, request.params
is still empty at this point. This is a great place to handle bad requests. The early response pattern:
from uhttp import App, Response
app = App()
@app.before
def auth(request):
if 'user' not in requet.state:
raise Response(401)
if request.state['user']['credits'] < 1:
return 402
@app.after
functions receive a request
and a response
. They are called after a response is made. You should modify the response here.
@app.after
def dancing(request, response):
response.cookies['dancing'] = 'in the street'
...
Route functions
The main route decorator is @app.route(path, methods=('GET',))
. There's also route decorators for all the standard methods: @app.get, @app.head, @app.post, @app.put, @app.delete, @app.connect, @app.options, @app.trace, @app.patch
.
The path
parameter is present on all decorators. µHTTP handles paths as regular expressions. To define path parameters like /user/<id>
you can use named groups:
@app.get('/users/(?P<id>\d+)')
def users(request):
user_id = request.params['id']
return {'user': request.state['db']['user_id']}
To improve performance, all path regular expressions are compiled at startup.
Route functions will only be called if no @app.before
middleware has set the response.
The response comes from the return value of the decorated function. If there is no return, the response defaults to 204 No Content
. The return values can be: int
(status), str
(body), bytes
(raw body), dict
(JSON) and Response
.
If the request doesn't match any path, response is set to 404 Not Found
. If the request doesn't match any method of the path, response is set to 405 Method Not Allowed
.
Static files
µHTTP doesn't support static files. It shouldn't (a real web server like Unit should handle them). But in development they might come handy:
import os
import mimetypes
@app.startup
def static(state): # Non-recursive, keeps files in memory
for entry in os.scandir('static'):
if entry.is_file():
with open(entry.path, 'rb') as f:
content = f.read()
content_type, _ = mimetypes.guess_type(entry.path)
app._routes['/' + entry.path] = {
'GET': lambda _: Response(
status=200,
body=content,
headers={'content-type': content_type or ''}
)
}
Requests
class Request:
method: str
path: str
params: dict
args: MultiDict
cookies: SimpleCookie
body: bytes
json: Any
form: MultiDict
state: dict
Multipart requests
Currently, µHTTP doesn't support multipart/form-data
requests. Here's an implementation with multipart
:
from io import BytesIO
from multipart import MultipartError, MultipartParser, parse_options_header
@app.before
def parse_multipart(request):
content_type = request.headers.get('content-type', '')
content_type, options = parse_options_header(content_type)
content_length = int(request.headers.get('content-length', '-1'))
if content_type == 'multipart/form-data':
request.form['files'] = {}
try:
stream = BytesIO(request.body)
boundary = options.get('boundary', '')
if not boundary:
raise MultipartError
for part in MultipartParser(stream, boundary, content_length):
if part.filename:
request.form['files'][part.name] = part.raw
else:
request.form[part.name] = part.value
except MultipartError:
raise Response(400)
Responses
class Response(Exception):
status: int
description: str
headers: MultiDict
cookies: SimpleCookie
body: bytes
def from_any(any: Any) -> Response:
...
The fact that Response
inherits from Exception
is what makes µHTTP so flexible.
def pay(request, cost):
user = request.state.get('user')
if not user:
raise Response(401)
if user.money < cost:
raise Response(402, body=b'Insufficient funds!')
user.money -= cost
return user
@app.get('/buy/bananas')
def see_bananas(request):
return "Hey there, see any bananas that you'd like?"
@app.post('/buy/bananas')
def buy_bananas(request):
user = pay(request, 5)
return f'Congratulations! {user.name} just bought a banana!'
Templates
µHTTP doesn't support templating engines. However, implementing Jinja is very easy:
import jinja2
@app.startup
def load_jinja(state):
state['jinja'] = jinja2.Environment(
loader=jinja2.FileSystemLoader('templates')
)
@app.route('/')
def hello(request):
template = request.state['jinja'].get_template('hello.html')
return template.render(name=request.args.get('name'))
Internals
async def asyncfy(func, /, *args, **kwargs):
if iscoroutinefunction(func):
return await func(*args, **kwargs)
else:
return await to_thread(func, *args, **kwargs)
µHTTP runs all synchronous code in a separate thread, so as to not block the main loop. As long as your code is thread-safe things should be fine. E.g. instead of opening one sqlite3
connection at startup, consider opening one for every request or just using aiosqlite
.
class MultiDict(dict):
...
Because of HTTP 1.1 quirks, µHTTP has a MultiDict implementation. But, you shouldn't really care about that.
Contributing
Feel free to fork, complain, improve, document, write extensions.
License
Released under the MIT License.
Project details
Release history Release notifications | RSS feed
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.