Krax! Millennials invented the Python framework

Prologue



Hello, Habr! This article is devoted to the analysis of the pros and cons of the next Python framework, which was released about a week ago.



So, a small lyrical digression. During the well-known events, when we were a little self-isolated, we had a little more free time. Someone got to the list of literature put off for reading, someone started to study another foreign language, someone continued to press in Dotan and did not pay attention to the changes. But I (sorry, this article will contain a lot of "I", and I'm a little ashamed) decided and tried to do something useful. However, the usefulness is debatable. The obvious questions that the reader will most likely have in the first place:β€œUm, Python framework? Another? Excuse me, but why? We're not JavaScript, after all! "



Actually, this is exactly what will be discussed in this article: Is it necessary? If necessary, to whom? What is the difference from what is already there? How it can be attractive and why, for example, it can be buried without waiting for the first birthday. The article does not plan a lot of code - examples of writing an application and using individual parts can be found in the documentation (there is much more code there;)). This article is more of an overview.



Who needs it?



A somewhat selfish answer to this question - first of all, of course, myself. I have some experience in building web applications using existing frameworks and regularly catch myself thinking: β€œYes, everything is cool, but if it were like this…. And here's a commercial ... "... Most of us, one way or another, sooner or later come across the fact that some things do not like and would like (or even have to) change them. I tried to put together what I like from the tools that I have used. I hope that I am not alone in my preferences, and that there are people who will find these ideas close. The main idea behind Crax is that it doesn't impose any particular style of development as much as possible. For example, we don't need namespaces, we don't want to divide the logic into applications, we want to quickly deploy two routes and drive requests and responses. Ok, in this case we can just create a single file application and get what we want. But the opposite situation is also possible, and this will not be a problem either. The second thing Crax advocates is simplicity. A minimum of code and a minimum of reading documentation to start.If a person who is just starting to learn Python plans to work with the framework, he should be able to painlessly overcome the threshold of entry.



If you look at the number of lines of code required to pass all the

TechEmpower tests (more on that below), then Crax in an application consisting of one file is more compact than all the other participants, and there was no purpose to "shrink" this file. There's just really nothing more to write. Summarizing what was written above, we can say that Crax is suitable for a very different range of tasks and a very wide range of programmers of varying degrees of training.



Why not use existing tools?



Why not? If you know exactly which tool to use, what is most suitable for your current task, moreover, you have worked with this tool and know all the nuances. Of course, you will choose what you know and suit. There is no (and never will) goal of positioning Crax as the "% framework_name% killer". There will be no agitation type: "Throw urgently% framework_name%, rewrite everything on Crax and immediately noticeable increase member of the amount of sales" . Nothing like this. You can just note for yourself that you had another one in your toolbox a week ago. Use it or not - your business. However, why is it worth trying.



First, it's fast enough. It is written using the ASGI interface (read the specification here) and is much faster than Flask or Django 1. *, 2. *. But Crax is of course not the only Python framework to use ASGI, and preliminary tests show that it competes well with other frameworks using this technology. For comparison, we used TechEmpower Performance Rating tests . Unfortunately, Crax, like other frameworks added in the middle of the current round, will only get into the next one, and then you can see the results in the graphical issue. However, after each pull request Travis runs tests and you can see the comparative characteristics of frameworks in the Travis log. Below the link is a long footcloth of the Travis log for Python frameworks with names in alphabetical order from A to F Here... You can try to read the log and compare Crax, for example, with apidaora, it will turn out pretty good. Below on the graph is the current state of affairs in the Round of 19 tests.







Of course, we will be able to see the real results and real results only in the next round, but nevertheless.



However, we, as mentioned above, have no less fast and already proven tools.

The same asynchronous, with native support for websockets and other joys.



Let's say Starlette or FastApi. They are absolutely amazing frameworks with a large community interested in developing these products. It is worth noting that Crax is most similar to Starlette or FastAPI in its ideology, and some ideas have been stolenspied on Starlette (e.g. Response Middleware). However, there are a number of things that you might like about Crax and make you think: "Maybe try it for the next project."... For example a configuration file. Of course, Starlette also has the ability to create a configuration file, but it is somewhat complicated for a beginner, and in the end its essence boils down to the fact that all configuration variables are eventually passed to the application class initializer anyway. If you collect ALL possible variables, for example, configuring the logger, middleware, CORS, etc., it will turn out to be a bit too much. In Crax, all variables are declared in the main (configuration) file (like Django), and you don't need to pass them anywhere. Moreover, all the variables declared in the config file can always be accessed at runtime (both from the running application and from the outside, hello Django ).



from crax.utils import get_settings_variable
base_url = get_settings_variable('BASE_URL')


It would seem a dubious advantage, however, when the configuration file begins to grow overgrown with variables and settings, and we would like to have access to them, this becomes important.



The next important detail that I would like to talk about is the organization of the application structure. When you have a small project, all the logic of which can be placed in one file, this is one thing. But when you write something more global, you might want to separate views, models, route descriptions, etc., according to their logic. In this context, great Flask blueprints or Django applications come to mind. Crax talks about namespaces in this sense. Initially, your application is intended to be



a set of python packages that are included in the main project file. By the way, namespaces (your parts of the application) can be recursively nested (hello, Flask), and the names of the files in them do not matter. Why do that? And what does it give us?



First, routing. Namespaces will create uri based on the location of the namespace automatically (but this, of course, can be controlled). For instance:



from crax.urls import Route, Url, include

url_list = [
    Route(Url('/'), Home),
    Route(Url('/guest_book'), guest_view_coroutine),
    include('second_app.urls'),
    include('second_app.nested.urls'),
    include('third_app.urls')
]


Replace dots with slashes and you will get the uri to your namespace (of course, by adding a final handler). Since we have already mentioned routing, we will dwell on it in more detail.

Crax offers a couple of interesting possibilities, besides the usual work with regular expressions or the work via Django path.



# URL defined as regex with one floating (optional) parameter
Url(r"/cabinet/(?P<username>\w{0,30})/(?:(?P<optional>\w+))?", type="re_path")
# General way to define URL
Url("/v1/customer/<customer_id>/<discount_name>/")


However, it is possible to bind several Urls to one handler.



from crax.urls import Route, Url

class APIView(TemplateView):
    template = "index.html"

urls = [
    Route(
        urls=(
            Url("/"),
            Url("/v1/customers"),
            Url("/v1/discounts"),
            Url("/v1/cart"),
            Url("/v1/customer/<customer_id:int>"),
            Url("/v1/discount/<discount_id:int>/<optional:str>/"),
        ),
        handler=APIView)
    ]


You yourself can think of where it can be useful to you. And also, there is a mode of operation of the resolver in the "masquerading" mode. For example, you just want to distribute some kind of directory with templates, and do not want anything else. Perhaps this is the Sphinx documentation, or something similar. You can always do this:



import os
from crax.urls import Url, Route

class Docs(TemplateView):
    template = 'index.html'
    scope = os.listdir('docs/templates')

URL_PATTERNS = [
    Route(urls=(
        Url('/documentation', masquerade=True),
        handler=Docs),
]


Great, now all templates that are in the docs / templates directory will be successfully rendered using one handler. An inquisitive reader will say that no python is needed here at all, and all this can be done only with the help of conditional Nginx. I absolutely agree, exactly until it is necessary, for example, to distribute these templates by role or somewhere on the side, additional logic is not required.



However, back to our rams namespaces. It would be very sad if namespaces (albeit nested ones) were needed only to organize url resolving. Of course, the purpose of namespaces is a little wider. For example, working with database models and migrations.



There is no ORM in Crax. And it is not supposed. Anyway, until SQLAlchemy offers asynchronous solutions. However, work with databases (Postgres, MySQL and SQLite) is declared. This means that it is possible to write your own models based on Crax BaseTable . Under the hood, this is a very thin wrapper over SQLAlchemy Core Table , and it can do everything that Core Table can do . For what it may be needed. Perhaps to do something similar.



from crax.database.model import BaseTable
import sqlalchemy as sa

class BaseModelOne(BaseTable):
    # This model just passes it's fields to the child
    # Will not be created in database because the abstract is defined
    parent_one = sa.Column(sa.String(length=50), nullable=False)

    class Meta:
        abstract = True

class BaseModelTwo(BaseTable):
    # Also passes it's fields to the child
    # Will be created in database
    parent_two = sa.Column(sa.String(length=50), nullable=False)

class MyModel(BaseModelOne, BaseModelTwo):
    name = sa.Column(sa.String(length=50), nullable=False)

print([y.name for x in MyModel.metadata.sorted_tables for y in x._columns])
# Let's check our fields ['name', 'id', 'parent_one', 'parent_two']


And in order to be able to work with migrations. Crax migrations are a bit of code on top of SQLAlchemy Alembic. Since we are talking about namespaces and separation of logic, then,

obviously, we would like to store migrations in the same package as the other logic of this namespace. This is how Crax migrations work. All migrations will be distributed according to their namespace, and if this namespace implies working with different databases, then inside the migration directory there will be a division into directories of the corresponding databases. The same applies to offline migrations - all * .sql files will be split according to the namespace and model database. I will not paint here about writing queries - it is in the documentation, I will only say that you are still working with SQLAlchemy Core.



Again, namespaces imply convenient storage of templates (inheritance and other Jinja2 features are supported + a couple of amenities in the form of ready-made CSRF tokens or url generation). That is, all your templates are structured. Well, of course, I'm not stuck in the glorious 2007, I understand that templates (even if they are rendered asynchronously) will be in little demand in 2020. And that, most likely, you are pleased to separate the logic of the frontend and backend. Crax does an excellent job of this, the results can be viewed on Github.

HereVueJs is used as a frontend. And since we have some kind of API, we probably want to make interactive documentation. Crax can build OpenAPI (Swagger) documentation out of the box based on your route lists and your handler docstrings. All examples, of course, are in the documentation.



Before we move on to the most interesting part of our brief overview, it is worth talking a little about which useful batteries are already supplied with the Crax.



Naturally, debug mode is when the error and the full trace can be read directly in the browser, on the page where the misfortune happened. Debug mode can be disabled and customized with boring wallpaperswith their handlers. Print a unique view for each http status code. This is done very simply, however, like everything in Crax.



Built-in logger with the ability to simultaneously write to the specified file and send logs to the console (or do one thing). Ability to assign your own logger instead of the default one. Sentry support by adding two lines to the config (and, if needed, customization).



Two types of preinstalled middleware. The first is processed BEFORE the request is processed by the application, and the second AFTER.



Built-in support for CORS headers. You only need to declare CORS rules in the config.

Ability to define methods available for each handler directly on site. Each handler will work with the list of HTTP methods that is specified (+ HEAD and OPTIONS), or only with GET, HEAD and OPTIONS.



Ability to specify that this handler is available only to authorized users, or only to users from the Administrators group, or only to members of the superuser role.

There is authorization for HMAC signed sessions, for which you do not need to go into the database and a number of tools for creating and managing users. You can enable authorization backend support and get a preset user and a number of tools to work with. However, like most Crax tools, you can leave it off, use it and write your own. You can not use authorization, databases, models, migrations, views and completely write your own custom solutions. You don't need to make any effort to do this, you have not turned it on - it is not.



There are several types of Response and several types of Class Based handlers that will help you write applications faster and more concisely. In this case, your own will also work, which do not inherit from the built-in ones.



from crax.views import BaseView

# Written your own stuff
class CustomView:
    methods = ['GET', 'POST']
    def __init__(self, request):
        self.request = request
    async def __call__(self, scope, receive, send):
        if self.request.method == 'GET':
            response = TextResponse(self.request, "Hello world")
            await response(scope, receive, send)
        elif self.request.method == 'POST':
            response = JSONResponse(self.request, {"Hello": "world"})
            await response(scope, receive, send)

# Crax based stuff
class CustomView(BaseView):
    methods = ['GET', 'POST']
    async def get(self):
        response = TextResponse(self.request, "Hello world")
        return response

    async def post(self):
        response = JSONResponse(self.request, {"Hello": "world"})
        return response

class CustomersList(TemplateView):
    template = 'second.html'

    # No need return anything in case if it is TemplateView.
    # Template will be rendered with params
    async def get(self):
        self.context['params'] = self.request.params


CSRF protection support. Generating tokens, checking for the presence of a token in the request body,

disabling verification for specific handlers.



Support for ClickJacking protection (Frame, iframe, embed ... rendering policies)



Support for checking the maximum allowable body size of a request BEFORE the application starts processing it.



Native websocket support. Let's take an example from the documentation and write a simple application that can send websocket messages by broadcast, per user group, or messages to a specific user. Suppose we have groups "boys" and "girls" (it is possible to add a group "parents"). We can write something similar for an example (of course, this is not a product code).



#app.py

import asyncio
import json
import os
from base64 import b64decode
from functools import reduce

from crax.auth import login
from crax.auth.authentication import create_session_signer
from crax.auth.models import Group, UserGroup
from crax.response_types import JSONResponse
from crax.urls import Route, Url
from crax.views import TemplateView, WsView
from sqlalchemy import and_, select
from websockets import ConnectionClosedOK

BASE_URL = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
SECRET_KEY = "SuperSecret"
MIDDLEWARE = [
    "crax.auth.middleware.AuthMiddleware",
    "crax.auth.middleware.SessionMiddleware",
]

APPLICATIONS = ["ws_app"]
CLIENTS = {'boys': [], 'girls': []}


class Home(TemplateView):
    template = "index.html"
    login_required = True


class Login(TemplateView):
    template = "login.html"
    methods = ["GET", "POST"]

    async def post(self):
        credentials = json.loads(self.request.post)
        try:
            await login(self.request, **credentials)
            if hasattr(self.request.user, "first_name"):
                context = {'success': f"Welcome back, {self.request.user.username}"}
                status_code = 200
            else:
                context = {'error': f"User or password wrong"}
                status_code = 401
        except Exception as e:
            context = {'error': str(e)}
            status_code = 500
        response = JSONResponse(self.request, context)
        response.status_code = status_code
        return response


class WebSocketsHome(WsView):

    def __init__(self, request):
        super(WebSocketsHome, self).__init__(request)
        self.group_name = None

    async def on_connect(self, scope, receive, send):
        # This coroutine will be called every time a client connects.
        # So at this point we can do some useful things when we find a new connection.

        await super(WebSocketsHome, self).on_connect(scope, receive, send)
        if self.request.user.username:
            cookies = self.request.cookies
            # In our example, we want to check a group and store the user in the desired location.

            query = select([Group.c.name]).where(
                and_(UserGroup.c.user_id == self.request.user.pk, Group.c.id == UserGroup.c.group_id)
            )
            group = await Group.query.fetch_one(query=query)
            self.group_name = group['name']

            # We also want to get the username from the user's session key for future access via direct messaging

            exists = any(x for x in CLIENTS[self.group_name] if cookies['session_id'] in list(x)[0])
            signer, max_age, _, _ = create_session_signer()
            session_cookie = b64decode(cookies['session_id'])
            user = signer.unsign(session_cookie, max_age=max_age)
            user = user.decode("utf-8")
            username = user.split(":")[0]
            val = {f"{cookies['session_id']}:{cookies['ws_secret']}:{username}": receive.__self__}

            # Since we have all the information we need, we can save the user
            # The key will be session: ws_cookie: username and the value will be an instance of uvicorn.WebSocketProtocol

            if not exists:
                CLIENTS[self.group_name].append(val)
            else:
                # We should clean up our storage to prevent existence of the same clients.
                # For example due to page reloading
                [
                    CLIENTS[self.group_name].remove(x) for x in
                    CLIENTS[self.group_name] if cookies['session_id'] in list(x)[0]
                ]
                CLIENTS[self.group_name].append(val)

    async def on_disconnect(self, scope, receive, send):
        # This coroutine will be called every time a client disconnects.
        # So at this point we can do some useful things when we find a client disconnects.
        # We remove the client from the storage

        cookies = self.request.cookies
        if self.group_name:
            try:
                [
                    CLIENTS[self.group_name].remove(x) for x in
                    CLIENTS[self.group_name] if cookies['session_id'] in list(x)[0]
                ]
            except ValueError:
                pass

    async def on_receive(self, scope, receive, send):
        # This coroutine will be called every time we receive a new incoming websocket message.
        # Check the type of message received and send a response according to the message type.

        if "text" in self.kwargs:
            message = json.loads(self.kwargs["text"])
            message_text = message["text"]
            clients = []
            if message["type"] == 'BroadCast':
                clients = reduce(lambda x, y: x + y, CLIENTS.values())

            elif message["type"] == 'Group':
                clients = CLIENTS[message['group']]

            elif message["type"] == 'Direct':
                username = message["user_name"]
                client_list = reduce(lambda x, y: x + y, CLIENTS.values())
                clients = [client for client in client_list if username.lower() in list(client)[0]]
            for client in clients:
                if isinstance(client, dict):
                    client = list(client.values())[0]
                    try:
                        await client.send(message_text)
                    except (ConnectionClosedOK, asyncio.streams.IncompleteReadError):
                        await client.close()
                        clients.remove(client)


URL_PATTERNS = [Route(Url("/"), Home), Route(Url("/", scheme="websocket"), WebSocketsHome), Route(Url("/login"), Login)]
DATABASES = {
        "default": {
            "driver": "sqlite",
            "name": f"/{BASE_URL}/ws_crax.sqlite",
        },
    }
app = Crax('ws_app.app')

if __name__ == "__main__":
    if sys.argv:
        from_shell(sys.argv, app.settings)




<!-- index.html -->

<!DOCTYPE html>
<html lang="en">
    <head>
        <meta charset="UTF-8">
        <title>Crax Websockets</title>
    </head>
    <body>
        <div id="wsText"></div>
        <form>
            <input id="messageText"><br>
            <select id="targetGroup">
                <option>boys</option>
                <option>girls</option>
            </select>
            <select id="messageType">
                <option>BroadCast</option>
                <option>Group</option>
                <option>Direct</option>
            </select>
            <select id="userNames">
                <option>Greg</option>
                <option>Chuck</option>
                <option>Mike</option>
                <option>Amanda</option>
                <option>Lisa</option>
                <option>Anny</option>
            </select>
        </form>
        <a href="#" id="sendWs">Send Message</a>
        <script>
            var wsText = document.getElementById("wsText")
            var messageType = document.getElementById("messageType")
            var messageText = document.getElementById("messageText")
            var targetGroup = document.getElementById("targetGroup")
            var userName = document.getElementById("userNames")
            var sendButton = document.getElementById("sendWs")
            ws = new WebSocket("ws://127.0.0.1:8000")
            ws.onmessage = function(e){
                wsText.innerHTML+=e.data
            }

            sendButton.addEventListener("click", function (e) {
                e.preventDefault()
                var message = {type: messageType.value, text: messageText.value}
                var data
                if (messageText.value !== "") {
                    if (messageType.value === "BroadCast"){
                        // send broadcast message
                        data = message
                    }
                    else if (messageType.value === "Group"){
                        // send message to group
                        data = Object.assign(message, {group: targetGroup.value})
                    }
                    else if (messageType.value === "Direct"){
                        // send message to certain user
                        data = Object.assign(message, {user_name: userName.value})
                    }
                    ws.send(JSON.stringify(data))
                }
            })
        </script>
    </body>
    </html>


<!-- login.html -->
<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>Crax Websockets</title>
</head>
<body>
    <form>
        <input id="username">
        <input id="password" type="password">
    </form>
    <div id="loginResults"></div>
    <a href="#" id="sendLogin">Login</a>

    <script>
        var loginButton = document.getElementById("sendLogin")
        var loginResults = document.getElementById("loginResults")
        var username = document.getElementById("username")
        var password = document.getElementById("password")
        loginButton.addEventListener("click", function (e) {
            e.preventDefault()
            if (username.value !== "" && password.value !== "") {
                var xhr = new XMLHttpRequest()
                xhr.overrideMimeType("application/json")
                xhr.open("POST", "/login")
                xhr.send(JSON.stringify({username: username.value, password: password.value}))
                xhr.onload = function () {
                    var result = JSON.parse(xhr.responseText)
                    if ("success" in result){
                        loginResults.innerHTML+="<h5 style='color: green'>"+result.success+ "</h5>"
                    }
                    else if ("error" in result) {
                        loginResults.innerHTML+="<h5 style='color: red'>"+result.error+ "</h5>"
                    }
                }
            }
        })
    </script>
</body>
</html>


The complete code can be viewed in the Crax documentation.



Well, the time has come for the most interesting in this article.



Why is it unnecessary?



First, as mentioned above, there are several frameworks that do the same, and have an already formed community. Whereas Crax is a baby who is one week old. Single man army is almost a guarantee that sooner or later the project will be abandoned. It is sad, but the fact that working on the table, releasing releases and updates only for yourself and Vasily from Syktyvkar, is much longer than when the community is working on the project. In the meantime, the project does not have a number of features that are a must have in 2020. For example: no JWT support (JOSE). No out of the box support for OAuth2 tools. No GraphQL support. It is clear that you can write this yourself for your project, but Starlette or FastAPI already has it. I just have to write this (yes, it is in the plans). There will be a little about the plans in conclusion.



The developers of Netflix and Microsoft write about FastAPI. About Crax writes noname, it is not known where it appeared, and who knows where is capable of exactly the day after tomorrow abyss. They



won't call a steamer by my idiotic name.

My mother cries at night, because she gave birth to a freak ...

(c)


This is important. It's called reputation and ecosystem. Crax doesn't have either. Without these important things, the project is guaranteed to go to the landfill without ever being born.



It is worth understanding. What is written above is not an attempt to type classes and not the text of a homeless person on the train. This is a sober assessment and a warning that "production ready solutions" is not only the results of coverage of the source code by tests, it is a general assessment of the maturity of technologies, approach and solutions used in the project.



If you are just starting your acquaintance with Python and trying frameworks, you are in danger: Most likely, you will not find answers to the question on SO, perhaps more experienced comrades who, unfortunately, may not be there, will help you.



The goals



The first thing I plan to do is, of course, add some must-have things like JWT (JOSE), OAuth2 and GraphQL support. This is what will make it easier for me and interested people to work. And this is, in fact, the main goal of Crax - to make someone's job a little easier. Perhaps by then a new round at TechEmpower will begin and the benchmarks will become more evident. It is even possible that after that there will be some interest in the community.

There is an idea to write a CMS based on Crax.

If I am not mistaken (if I am mistaken - correct it), we do not yet have any asynchronous CMS in Python in our toolkit. I might change my mind and decide to write some kind of e-commerce solution. But, obviously, in order to prevent Crax from drowning before reaching the buoys, something interesting needs to be done on its base. Perhaps enthusiasts will be interested in this. Enthusiasts are when it's free. Because there is no money here and, most likely, there will not be. Crax is completely free for everyone and I didn't get a dime for this job. Thus, the development is planned for "long winter evenings" and, perhaps, in the coming year, something interesting will be born.



Conclusion



I was thinking about which group to include this article (by the way, this is my first publication on the resource). Maybe it was even worth placing it under the tag "I'm PR". What made me change my mind: first of all, the fact that it has no advertising character whatsoever.



There is no call "Boys, urgently sign up for pull requests" . There is no idea to find a sponsor here. There is not even an idea that I brought you something that you have never seen (of course, seen). You can abstract from the idea that I am the author of both articles and this tool, and perceive what has been written as a review. And, yes, that's the best way. It will be an excellent result for me if you just keep in mind that it is.

On this, perhaps, everything.



β€œSo… it's time to take the fishing rods.

- Why?

- Harris's red cap scared all the fish.

(c)


Code on GitHub

Documentation



All Articles