Skip to main content

Pythonic web development

Project description

µHTTP

Pythonic web development

About

µ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.

Installation

µHTTP is on PyPI.

pip install uhttp

You might also need a web server. µHTTP follows the ASGI specification. A nice implementation is 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')

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.
  • Very opinionated, to the point where it has no opinions.
  • Fast, because it doesn't really do much.
  • Not about types.

Motivations

If there is such a thing as "web framework hopping", I've done it in the past few months.

I was writing this very simple application part of a bigger project. All it did was compile information from a whole bunch of APIs.

First, I tried really hard to make WSGI, more specifically Flask, to work. Now, if you install flask[async], it allows you to use co-routines in routes, which is awesome. The best way to make a whole bunch API calls through HTTP is to make them concurrently. But the response times were high (even with concurrency), sometimes ugly Jo's API would take 20s to answer. So that made it really hard for WSGI. Well, I suppose, If using WSGI was REALLY important, maybe for backwards compatibility, I could go with something like gevent or eventlet. But the project was new, and I just wanted something simple and clean.

Naturally, as I really liked Flask, I went for Quart. Well... AFAIK Phil Jones (author of Quart) is a magician, and the future for Flask (when it finally supports ASGI properly) looks promising. But, for now, things just look extra-hacky. E.g.: to access a 'name' field in a form in Flask you do: request.form["name"]; In Quart (one-liner) is (await request.form)["name"]. Getters shouldn't be coroutines. Quart is trying really hard to push things forward, but it has too much baggage.

Then I looked at Sanic. It promised to be unopinionated and flexible. After spending three days modifying the default behavior, I gave up. Really, it is all but unopinionated. It just feels like a "brand" web framework, if there is such a thing. It does all whole bunch of stuff that you don't need and all that you need it doesn't do. Also, weird things were happening with the built-in server.

Now, at that point, I just about had it. Oh, the frustration... So, I decided to see what ASGI was all about, and why was it so hard to write a proper framework based on it. After reading that tiny spec, my mind was just blown. WTF is it really that simple?! After two hours, the first iteration of µHTTP, thonny, came to life. I used it on our project. And for a while there, I could swear that the air felt lighter. But, it was a company project, and thonny just couldn't handle another shitty feature.

So came Starlette and FastAPI. Wow, I mean, wow! All other frameworks look like toys compared to Starlette. Starlette is unopinionated and flexible. But, it is not simple. So, FastAPI solved that, making it really easy. After rewriting part of the code base to play well with typing notations, things worked really well.

But, I just couldn't forget thonny. I wanted to KISS him so bad. So, in a two-day haze I turned thonny into µHTTP.

I think it solved, cleanly and simply, all of the imaginary problems I had in web development.

Reference

Application

In µHTTP everything is an app.

class App

Attributes:

  • routes: A dict of your routes, following: {'/path': {'METHOD': func}}
  • startup: A list of functions that run at the beginning of the lifespan
  • shutdown: A list of functions that run at the end of the lifespan
  • before: A list of functions that run before the response
  • after: A list of functions that run after the response
  • max_content: An int, sets the request body size limit (defaults to 1 MB)

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(other_app, prefix='')

app.mount is µHTTP modularity. What it does:

  1. Appends other_app middleware and lifespan functions to app
  2. Maps other_app routes to app with prefix
  3. Sets app.max_content as a max between other_app and app

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 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, HTTPException

app = App()

@app.before
def auth(request):
    if 'user' not in requet.state:
        raise HTTPException(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. HTTPException shouldn't be raised 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

No, you don't need to import them.

class Request

Attributes:

  • method: str
  • path: str
  • params: dict
  • args: MultiDict
  • headers: MultiDict
  • cookies: SimpleCookie
  • body: bytes
  • json: dict
  • 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 HTTPException(400)

Responses

Relax, they already know.

class Response

Attributes:

  • status: int
  • headers: MultiDict
  • cookies: SimpleCookie
  • body: bytes

response.from_any(any)

Returns a response based on any.

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'))

HTTPException

Raise 'em, don't, what do I care?

class HTTPException(Exception)

Attributes:

  • status: int
  • description: str

They should be raised at @app.before or @app.route functions.

MultiDict

I wish you hadn't been born.

class MultiDict(dict)

Shares the same attributes as dict. Required because of HTTP 1.1 quirks. You should probably forget its existance.

asyncfy(func, /, *args, **kwargs)

Simply beautiful.

The function that allows for synchronous code in µHTTP. As long as you use thread-safe code things should be ok. E.g. instead of opening one sqlite3 connection at startup, consider opening one for every request or just using aiosqlite.

More

Read the source code. It will cost you all of 5 minutes.

Contributing

Feel free to fork, complain, improve, document, fix typos...

Tests

Well, I don't really see a need for them. But, if you do, feel free to contribute.

License

Released under the MIT License.

Project details


Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distribution

uhttp-1.1.1.tar.gz (9.1 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

uhttp-1.1.1-py3-none-any.whl (9.6 kB view details)

Uploaded Python 3

File details

Details for the file uhttp-1.1.1.tar.gz.

File metadata

  • Download URL: uhttp-1.1.1.tar.gz
  • Upload date:
  • Size: 9.1 kB
  • Tags: Source
  • Uploaded using Trusted Publishing? No
  • Uploaded via: poetry/1.7.1 CPython/3.11.2 Linux/6.1.0-rpi7-rpi-v8

File hashes

Hashes for uhttp-1.1.1.tar.gz
Algorithm Hash digest
SHA256 5a0d28f41e938acd3fdc0821d8e5c2616ef95b09b5faf47963f55aca6b6ef72a
MD5 bc735168931b7454af9f77cdf50f275d
BLAKE2b-256 807460b72f6fbb8580e64281e15f3383ff909f3a0875bd9366bf585839921708

See more details on using hashes here.

File details

Details for the file uhttp-1.1.1-py3-none-any.whl.

File metadata

  • Download URL: uhttp-1.1.1-py3-none-any.whl
  • Upload date:
  • Size: 9.6 kB
  • Tags: Python 3
  • Uploaded using Trusted Publishing? No
  • Uploaded via: poetry/1.7.1 CPython/3.11.2 Linux/6.1.0-rpi7-rpi-v8

File hashes

Hashes for uhttp-1.1.1-py3-none-any.whl
Algorithm Hash digest
SHA256 93c43d41b490d3c4aeec2f78ac77d8cc7fe22ddf7aad9ddc93fa47133639a37f
MD5 1a64e33ddd2644959d06067705ef6793
BLAKE2b-256 81200496ae27a53b707595e2bc7e6cc680855f783cd2d595d5e9dfee29855922

See more details on using hashes here.

Supported by

AWS Cloud computing and Security Sponsor Datadog Monitoring Depot Continuous Integration Fastly CDN Google Download Analytics Pingdom Monitoring Sentry Error logging StatusPage Status page