Skip to main content

Tanka

Objects form a tree
Each small part has its own place
Nothing owns the whole
Mount and catch and route and wrap
The composition is all

EO principles respected here

True Object-Oriented Python Web Framework.

Designed for humans by human.

  • Elegant
  • Fully object oriented
  • Embraces declarative style
  • Extremely maintainable

A Tanka application is a single tree of objects. There are no decorators on functions, no global registries, no configuration files. You declare what your application is by composing objects, and the framework runs that declaration.

import asyncio

from tanka import Endpoint, Get, Html, Response, Route, Routes, Tanka, Uvicorn


class Index(Endpoint):
    async def response(self, request):
        return Response(Html("<h1>Hello, world</h1>"))


asyncio.run(
    Tanka(
        Routes(
            Route(Get(), "/", Index()),
        ),
    ).run(Uvicorn("127.0.0.1", 8080)),
)

Table of Contents

  1. Installation
  2. Core Concepts
  3. Application
  4. Servers
  5. Endpoint
  6. Routing
  7. Request
  8. Response
  9. Bodies
  10. Templates
  11. Static Files
  12. Cookies
  13. Flash Messages
  14. Authentication and Authorization
  15. Error Handling
  16. CORS
  17. OpenAPI
  18. Logging
  19. Testing Your Application
  20. How It All Fits Together
  21. Development

1. Installation

With uv:

uv add tanka

With pip:

pip install tanka

Optional extras add integrations, for example uv add "tanka[uvicorn]" or pip install "tanka[uvicorn]":

Extra Adds
tanka[uvicorn] Uvicorn server
tanka[hypercorn] Hypercorn server
tanka[reload] Reload() hot reload through watchfiles
tanka[openapi] OpenApi validation through openapi-core
tanka[jinja] Jinja templates through jinja2
tanka[all] Everything above

2. Core Concepts

Concept What it is Examples
Tanka The complete runnable application Tanka(Routes(...))
Server Something that serves the application Uvicorn(...), Hypercorn(...)
Endpoint Anything that turns a Request into a Reply Index(), Routes(...), Authenticated(...), Cors(...)
Route Binds an HTTP method and a path pattern to an endpoint Route(Get(), "/users/{id}", UserPage(...))
Request The incoming HTTP message request.target().path(), await request.body().json()
Reply The outgoing HTTP message interface Response(...), Redirect(...), WithCookie(...)
Body The payload of a message Html(...), Json(...), File(...), Stream(...)
Template A named template that renders a Context into Html Template(Jinja("./templates"), "index.html")
Middleware An endpoint that wraps another endpoint Authenticated(...), Catch(...), OpenApi(...)
Identity Who is making the request Principal("42", "admin"), Anonymous()
Abort The one exception that ends a request with a status raise Abort(404, "User not found")

Every building block is small and immutable. Behaviour is added by wrapping one object into another, never by modifying an existing one.


3. Application

Tanka wraps a single endpoint. Usually that endpoint is a Routes collection, possibly wrapped in middleware.

from tanka import Delete, Get, Post, Route, Routes, Tanka

app = Tanka(
    Routes(
        Route(Get(), "/", IndexPage(...)),
        Route(Get(), "/users", UsersPage(...)),
        Route(Post(), "/users", CreateUser(...)),
        Route(Delete(), "/users/{id}", DeleteUser(...)),
    ),
)

Running with a built-in server

await app.run(Uvicorn("127.0.0.1", 8080))

With no argument, run() starts Uvicorn on 127.0.0.1:8000.

Running as ASGI

asgi() exposes a standard ASGI callable, so any ASGI server can host it.

# myapp.py
asgi = app.asgi()
uvicorn myapp:asgi

4. Servers

Server is an interface with two implementations.

from tanka import Hypercorn, Reload, Uvicorn

Uvicorn("127.0.0.1", 8080)
Hypercorn("0.0.0.0", 8443)
Uvicorn("127.0.0.1", 8080, Reload())

Reload() watches the current directory and restarts the process when a file changes. Run the script directly for it to work:

python myapp.py

5. Endpoint

Endpoint is the one interface every request handler implements. It has a single method, response, which receives a Request and returns a Reply.

from tanka import Endpoint, Json, Reply, Request, Response


class UserPage(Endpoint):
    def __init__(self, db):
        self.db = db

    async def response(self, request: Request) -> Reply:
        user = await PgUsers(self.db).user(
            request.target().path().parameter("id")
        )
        return Response(Json({"id": user.id(), "name": user.name()}))

Pages, API handlers, static file servers, middleware, CORS, error catching and OpenAPI validation are all endpoints. Because they share one interface, any of them can wrap any other.


6. Routing

Route

A Route maps an HTTP method and a path to an endpoint.

Route(Get(), "/", IndexPage())
Route(Post(), "/users", CreateUser(...))

Available methods: Get, Post, Put, Patch, Delete, Head, Options, and Verb("PURGE") for anything else.

Several methods on one route

Route(Methods(Get(), Head()), "/users", UsersPage(...))

Path parameters

Paths declare parameters with curly braces. Parsing is delegated to the parse library, so its type suffixes work too.

Route(Get(), "/users/{id}", UserPage(...))
Route(Get(), "/posts/{year:d}/{slug}", PostPage(...))
request.target().path().parameter("id")    # "42"
request.target().path().parameter("year")  # 2024

Mount

Mount attaches an endpoint under a path prefix. The prefix is stripped before the inner endpoint sees the request. This is how versioned or grouped APIs are built.

Routes(
    Mount(
        "/v1",
        Routes(
            Route(Get(), "/users", UsersV1(...)),
            Route(Post(), "/users", CreateUserV1(...)),
        ),
    ),
    Mount(
        "/v2",
        Routes(
            Route(Get(), "/users", UsersV2(...)),
        ),
    ),
)

Routes are tried in order. The first one that matches wins. A request that matches nothing ends with 404.


7. Request

A Request is the incoming HTTP message.

request.method().names()                    # ["GET"]
request.target().path()                     # Path, str() gives "/users"
request.target().path().parameter("id")     # path parameter
request.target().query().parameter("page")  # first value, fails if absent
request.target().query().values("tag")      # all values, maybe empty
request.headers().header("accept")          # first value, fails if absent
request.headers().values("accept")          # all values, maybe empty
request.cookies().cookie("session")         # cookie value, fails if absent
request.identity()                          # see Authentication

The body is decoded on demand.

raw = await request.body().bytes()
text = await request.body().text()
data = await request.body().json()

You can build a Request yourself, for example in tests.

from tanka import Empty, Get, Headers, Request, Text

Request(Get(), "/users?page=2", Headers({"accept": "text/html"}), Empty())
Request(Post(), "/users", Headers(), Text('{"name": "Ann"}'))

8. Response

Reply is the interface of an outgoing message: a status, headers and a body. Response is the plain implementation.

Response(Html("<h1>Hello</h1>"))                       # status 200
Response(201, Json({"id": 7}))
Response(200, Headers({"cache-control": "no-store"}), Text("fresh"))

Redirect

Redirect("/login")          # 302
Redirect("/users", 303)

Adding headers

Replies are wrapped, never modified.

WithHeaders(
    Response(Json(items)),
    Headers({"x-total-count": str(len(items))}),
)

Cookies and flash messages are added the same way, see below.


9. Bodies

Body Purpose
Html("<p>hi</p>") HTML document
Json({"ok": True}) JSON payload
Text("plain") Plain text
Empty() No body, for 204 and redirects
Raw(b"...", "image/png") Bytes with an explicit content type
File("./report.pdf") A single file, content type guessed
Stream(chunks, "text/csv") Chunks from any async iterable

Streaming a large export without buffering it:

class Export(Endpoint):
    async def response(self, request):
        return Response(Stream(self.rows(), "text/csv"))

    async def rows(self):
        yield b"id,name\n"
        async for user in PgUsers(self.db).users():
            yield f"{user.id()},{user.name()}\n".encode()

Every body contributes its own headers, such as content-type and content-length. Headers given to Response take precedence.


10. Templates

Template renders a named template into an Html body. The engine behind it implements Templates. Jinja is the engine that ships with Tanka. It needs the jinja extra.

from tanka import Context, Jinja, Pair, Template

templates = Jinja("./templates")

Context is an immutable collection of named values. Pair names one of them.

Response(
    200,
    await Template(templates, "account.html").html(
        Context(
            Pair("user", user),
            Pair("title", "Account"),
            Pair("page", 1),
        )
    ),
)

Domain objects

Tanka does not hand arbitrary objects to the engine. A value with an async def json(self) -> dict is replaced by that dict before rendering, so account.html reads {{ user.name }}. Plain values such as str, int and dict pass through unchanged. The capability is structural: your own JsonReadable base class works without inheriting anything from Tanka.

class User:
    def __init__(self, id: str, name: str):
        self.id = id
        self.name = name

    async def json(self) -> dict:
        return {"id": self.id, "name": self.name}

Example endpoint

from tanka import (
    Context,
    Endpoint,
    Get,
    Jinja,
    Pair,
    Reply,
    Request,
    Response,
    Route,
    Routes,
    Tanka,
    Template,
    Templates,
)


class Index(Endpoint):
    def __init__(self, templates: Templates):
        self.templates = templates

    async def response(self, request: Request) -> Reply:
        return Response(
            200,
            await Template(self.templates, "index.html").html(
                Context(
                    Pair("title", "Welcome"),
                    Pair("page", 1),
                )
            ),
        )


templates = Jinja("./templates")
app = Tanka(
    Routes(
        Route(Get(), "/", Index(templates)),
    ),
)

With templates/index.html:

<h1>{{ title }}</h1>
<p>Page {{ page }}</p>

GET / returns 200 with content-type: text/html; charset=utf-8 and the rendered markup as the body.

Jinja

Jinja(directory) loads templates from a folder and escapes values in .html, .htm and .xml templates. A missing or broken template raises an Exception chained from the engine error, which ends the request with 500. Pass a ready jinja2.Environment instead of a directory when you need custom filters or another loader.

Jinja(Environment(loader=PackageLoader("myapp"), autoescape=True))

Other engines implement Templates without touching Template.

class Mako(Templates):
    async def markup(self, name: str, values: dict) -> str:
        ...

11. Static Files

Static serves files from a Files source. Directory is the source for a local folder. Combine it with Mount so paths are relative to the folder.

Routes(
    Mount("/static", Static(Directory("./public"))),
)

GET /static/css/app.css serves ./public/css/app.css. A folder serves its index.html. Paths that escape the root or point to nothing end with 404.

Other sources are added by implementing Files, without touching Static.

class S3Bucket(Files):
    def __init__(self, bucket: str):
        self.bucket = bucket

    def file(self, path: str) -> Body:
        ...

12. Cookies

Reading

session = request.cookies().cookie("session")

Writing

Wrap the reply in WithCookie.

WithCookie(
    Redirect("/"),
    Cookie(
        "session",
        token,
        HttpOnly(),
        Secure(),
        SameSite("Lax"),
        CookiePath("/"),
        Lifetime(3600),
        Domain("example.com"),
    ),
)

Deleting

WithCookie(Redirect("/login"), ForgetCookie("session", CookiePath("/")))

Several cookies are set by nesting WithCookie.


13. Flash Messages

A flash message travels to the next page through a cookie.

WithFlash(
    Redirect("/users"),
    Flash("User created", Success()),
)

Kinds: Success(), Failure(), Notice(), Alert().

The next page reads and clears it.

class UsersPage(Endpoint):
    async def response(self, request):
        notes = [
            f"<p class='{flash.kind().name()}'>{flash.text()}</p>"
            for flash in Flashes(request.cookies())
        ]
        return WithCookie(
            Response(Html("".join(notes) + await self.table())),
            ForgetCookie("flash", CookiePath("/")),
        )

14. Authentication and Authorization

Both are middleware: endpoints that wrap another endpoint.

Identity source

Tanka does not ship identity sources. Your application implements IdentitySource: given a request, return an Identity or raise Abort.

from tanka import Abort, Identity, IdentitySource, Principal


class SessionIdentity(IdentitySource):
    def __init__(self, db):
        self.db = db

    async def identity(self, request) -> Identity:
        try:
            token = request.cookies().cookie("session")
        except Exception as error:
            raise Abort(401, "Please log in") from error
        user = await PgSessions(self.db).user(token)
        return Principal(user.id(), *user.roles())

Authenticated

Authenticated asks the source for an identity, attaches it to the request and passes the request on.

Route(
    Get(),
    "/profile",
    Authenticated(ProfilePage(...), SessionIdentity(...)),
)

Inside the endpoint:

request.identity().id()      # "42"
request.identity().roles()   # ["admin", "editor"]

A request that did not pass through Authenticated carries Anonymous(): id() fails fast and roles() is empty.

Authorized

Authorized checks a requirement against the identity and ends with 403 when it is not met. Place it inside Authenticated.

Authenticated(
    Authorized(AdminPage(...), Role("admin")),
    SessionIdentity(...),
)

Requirements compose:

Role("admin")                                # exactly this role
Roles("admin", "editor")                     # any of these roles
AnyOf(Role("owner"), Roles("admin", "root")) # any requirement
AllOf(Role("staff"), Role("verified"))       # every requirement

Custom rules implement Requirement:

class Verified(Requirement):
    def matches(self, identity: Identity) -> bool:
        return "unverified" not in identity.roles()

15. Error Handling

Abort

Abort is the single exception for ending a request with an HTTP error. It is never caught inside endpoints; it propagates up to the application, which turns it into a reply.

class UserPage(Endpoint):
    async def response(self, request):
        try:
            user = await PgUsers(self.db).user(
                request.target().path().parameter("id")
            )
        except Exception as error:
            raise Abort(404, "User not found") from error
        return Response(Html(user.page()))

Abort accepts 4xx and 5xx codes from the standard table (400, 401, 403, 404, 405, ..., 500, 501, 502, 503, 504, 505). Any other code breaks the request and ends with 500.

Default behaviour

  • Any exception that is not Abort ends with 500.
  • A request that matches no route ends with 404.
  • Without a custom page, the framework answers with a small HTML page naming the error: Not Found, Internal Server Error, and so on.

Catch and On

Catch maps status codes to your own error endpoints.

Tanka(
    Catch(
        Routes(
            Route(Get(), "/", IndexPage(...)),
        ),
        On(404, NotFoundPage()),
        On((401, 403), AccessDeniedPage()),
        On(Range(500, 599), ServerErrorPage()),
        On(..., AnyErrorPage()),
    ),
)

On accepts a single code, a tuple of codes, a Range, or ... for everything. The first matching On answers. Codes nothing matches fall through to the default pages.

Fallback

An error page that needs to know what went wrong implements Fallback instead of Endpoint. Its response receives the request and an Abort carrying the status and the message, which is how a JSON API turns every failure into one error format. A plain exception arrives as Abort(500, message).

from tanka import Abort, Catch, Fallback, Json, On, Response


class Enveloped(Fallback):
    async def response(self, request, error: Abort):
        return Response(error.status(), Json({"error": str(error)}))


Catch(routes, On(..., Enveloped()))

A plain Endpoint given to On is wrapped in Indifferent, which answers the same way whatever the error was.


16. CORS

Cors wraps an endpoint with a set of policies. Preflight requests are answered without reaching the wrapped endpoint.

Tanka(
    Cors(
        Routes(
            Route(Get(), "/users", Users(...)),
            Route(Post(), "/users", CreateUser(...)),
        ),
        AllowOrigins("https://app.example.com"),
        AllowMethods(Get(), Post()),
        AllowHeaders("Content-Type", "Authorization"),
        AllowCredentials(),
        ExposeHeaders("X-Total-Count"),
        MaxAge(600),
    ),
)

AllowOrigins("*") allows every origin.


17. OpenAPI

OpenApi wraps an endpoint with an OpenAPI specification file (YAML or JSON). It needs the openapi extra.

from tanka import OpenApi

Tanka(
    OpenApi(
        "openapi.yaml",
        Routes(
            Route(Get(), "/users", Users(...)),
            Route(Post(), "/users", CreateUser(...)),
        ),
    ),
)

It provides three things:

  • Documentation at /docs, rendered with Stoplight Elements, and the specification itself at /openapi.json. Its "Try It" panel keeps and sends cookies for same-origin calls, so cookie-based flows work from the page.
  • Request validation: a request that violates its operation schema ends with 400 before reaching the endpoint; one that misses a declared security requirement ends with 401.
  • Response validation: a reply that violates the schema ends with 500.

Paths the specification does not describe pass through untouched. The endpoint itself stays unaware of validation.


18. Logging

Unhandled exceptions are logged with their traceback before the 500 page is sent. Tanka logs through the standard logging module under the tanka logger by default. Pass a Log to change that.

Tanka(routes, Logging("myapp.http"))   # a named stdlib logger
Tanka(routes, Silence())               # nothing, handy in tests

19. Testing Your Application

Endpoints are plain objects. Call them with a hand-made Request.

async def test_greets_by_name():
    reply = await Route(Get(), "/hello/{name}", Greeting()).response(
        Request(Get(), "/hello/Ann", Headers(), Empty())
    )
    assert_that(await Body.Smart(reply.body()).text(), equal_to("Hi, Ann"))

For the whole tree, drive the ASGI application with httpx.

async def test_serves_index():
    async with httpx.AsyncClient(
        transport=httpx.ASGITransport(app=Tanka(routes, Silence()).asgi()),
        base_url="http://test",
    ) as http:
        assert_that((await http.get("/")).status_code, equal_to(200))

Replace real dependencies with fake objects that implement the same interfaces, such as a fake IdentitySource or a fake Files.


20. How It All Fits Together

Every piece of Tanka is an Endpoint or wraps one. An application is a tree where each layer adds one concern and delegates the rest inward.

Tanka
└── Catch                    error pages
    └── Cors                 cross-origin policy
        └── OpenApi          documentation + validation
            └── Routes       dispatch by method + path
                ├── Route → Authenticated → Authorized → AdminPage
                ├── Mount("/static") → Static(Directory("./public"))
                └── Mount("/v1") → Routes → ...

Replies follow the same idea. A plain Response is wrapped to add cookies, headers or flash messages.

WithFlash
└── WithCookie
    └── Redirect("/users")

The result is an application you can read top-down as a single expression, and change by swapping or wrapping one object at a time.


21. Development

uv sync
make help
Command Purpose
make unit Unit tests with coverage
make deep Integration tests against live servers
make lint black, flake8 and ruff

Release files for tanka 0.0.2

For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.

Source distribution (sdist)

Source distribution for tanka 0.0.2
File Size Uploaded
tanka-0.0.2.tar.gz 145.3 kB Details

Built distribution (wheel)

Table of built distributions (wheels) for tanka 0.0.2
File Interpreter ABI Platform
tanka-0.0.2-py3-none-any.whl Python 3 none any Details

Total release size: 174.0 kB

Release files / tanka-0.0.2.tar.gz

Download URL tanka-0.0.2.tar.gz
Size 145.3 kB
Tags Source
SHA-256 checksum
How to use checksums
cd7708ff48c82857a30ed023f05584ded2fec18b207c2e1ba5fffd0e174ed705
BLAKE2b-256 checksum
How to use checksums
97222798b925b7f46875f79cadd8f1638c767be938fba0f5a76a187932a0a8d7
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via uv/0.12.13 {"installer":{"name":"uv","version":"0.12.13","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"Ubuntu","version":"24.04","id":"noble","libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":true}

Release files / tanka-0.0.2-py3-none-any.whl

Download URL tanka-0.0.2-py3-none-any.whl
Size 28.7 kB
Tags Python 3
SHA-256 checksum
How to use checksums
4bd69ca41887c16d3e974a657a9fce2b69f88b1fbff2ba1135475cc4fdc1ca7c
BLAKE2b-256 checksum
How to use checksums
48927c8d4c341a74910ed2dfc2050f2dcd15c37cb764fcfab83789872f01e0b5
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via uv/0.12.13 {"installer":{"name":"uv","version":"0.12.13","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"Ubuntu","version":"24.04","id":"noble","libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":true}

Release history Release notifications | RSS feed

0.0.3

2 release files

This release

0.0.2 This release

2 release files

0.0.1

2 release files

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page