Skip to main content

Async-first web framework for Python — elegant, productive, fast.

Project description

Pytisan

Async-first web framework for Python — productive, opinionated where it matters, no unnecessary magic.

Inspired by the Laravel ecosystem: declarative routes, Active Record, Form Requests, migrations, session-based Auth, and the anvil CLI.

Português (Brasil): README.pt-BR.md


Table of contents

  1. Create a project
  2. Application structure
  3. Configuration
  4. Tutorial: Posts CRUD
  5. Validation (FormRequest)
  6. ORM relationships
  7. Authentication
  8. CLI reference
  9. Framework development

Create a project

Use uv / uvx (nothing to install upfront):

uvx pytisan new my-blog
cd my-blog
cp .env.example .env
uv run anvil migrate
uv run anvil serve

The API runs at http://127.0.0.1:8000. Starter routes are prefixed with /api (e.g. GET /api/).

Day-to-day commands:

uv run anvil serve              # dev server (reload enabled)
uv run anvil route:list         # list registered routes
uv run anvil migrate            # run pending migrations
uv run anvil migrate:status     # migration status
uv run anvil migrate:rollback   # rollback last batch
uv run anvil migrate:fresh      # drop and re-run all migrations

Application structure

The starter scaffolded by pytisan new looks like this:

my-blog/
├── main.py                     # ASGI entrypoint (uvicorn main:asgi)
├── bootstrap/app.py            # Application.configure().with_routing(...)
├── config/
│   ├── app.py                  # middleware, debug, auth
│   ├── auth.py                 # Auth.configure(model=User)
│   └── database.py             # Database.configure() from .env
├── routes/
│   └── api.py                  # HTTP routes
├── app/
│   ├── models/                 # Active Record models
│   └── http/
│       ├── controllers/
│       └── requests/           # FormRequest classes
├── database/
│   ├── database.sqlite         # created after migrate (SQLite)
│   └── migrations/
├── .env
└── pyproject.toml              # depends on pytisan-framework

Boot flow:

main.py  →  bootstrap/app.py (routes)  →  config/app.py (DB, session, auth)
         →  asgi = app.asgi()

Configuration

.env

APP_DEBUG=true

DB_CONNECTION=sqlite
DB_DATABASE=database/database.sqlite

The connection is lazy: the database connects on the first query. You do not need to call Database.connect() manually in your app.

Database

config/database.py reads .env and registers the global configuration:

from pytisan import Database
from pytisan.database.environment import database_config_from_env

Database.configure(database_config_from_env(root=Path(__file__).parent.parent))

The starter ships with a User model (users table) and migration — name, email (unique), password (hashed), timestamps.


Tutorial: Posts CRUD

This guide builds a complete REST CRUD for posts, from scratch to working endpoints.

1. Migration

Generate the migration and edit the file under database/migrations/:

uv run anvil make:migration create_posts_table
from pytisan.database.migrations import Migration, schema
from pytisan.database.schema import Blueprint


class CreatePostsTable(Migration):
    async def up(self) -> None:
        await schema.create("posts", self._define)

    async def down(self) -> None:
        await schema.drop("posts")

    @staticmethod
    def _define(table: Blueprint) -> None:
        table.id()
        table.string("title").not_null()
        table.text("body").not_null()
        table.foreign_id("user_id", references="users")
        table.timestamps()

Run the migration:

uv run anvil migrate

Blueprint — available columns

Method Description
table.id() INTEGER PRIMARY KEY AUTOINCREMENT
table.string("name") TEXT
table.text("body") TEXT
table.integer("count") INTEGER
table.boolean("active") INTEGER (0/1)
table.foreign_id("user_id", references="users") INTEGER + REFERENCES
table.timestamps() created_at, updated_at
.not_null(), .unique(), .nullable() chainable modifiers

2. Model

uv run anvil make:model Post

Edit app/models/post.py:

from pytisan import Model, belongs_to

from app.models.user import User


class Post(Model):
    __table__ = "posts"
    __fillable__ = ("user_id", "title", "body")

    id: int | None
    user_id: int
    title: str
    body: str
    created_at: str | None
    updated_at: str | None

    user = belongs_to(User)

Model — common options

Attribute Purpose
__table__ Table name (default: pluralized class name)
__fillable__ Fields allowed in create() / save()
__hidden__ Fields omitted from to_dict()
__casts__ Type casting ("bool", "hashed", "datetime", "json")
__guarded__ Blocked fields (default: ("id",))

Model API

post = await Post.create(title="Hello", body="...", user_id=1)
post = await Post.find(1)
posts = await Post.query().where("user_id", 1).order_by("-id").get()
post.title = "New title"
await post.save()
await post.delete()
await post.refresh()
data = post.to_dict()

3. Form Requests

Laravel-style validation — declarative rules, automatic 422 responses.

uv run anvil make:request post/create_post_request
uv run anvil make:request post/update_post_request

app/http/requests/post/create_post_request.py:

from pytisan import FormRequest, Rule


class CreatePostRequest(FormRequest):
    async def authorize(self) -> bool:
        return True

    def rules(self) -> dict:
        return {
            "title": "required|string|min:3|max:255",
            "body": "required|string|min:10",
        }

    def messages(self) -> dict[str, str]:
        return {
            "title.required": "The title field is required.",
        }

app/http/requests/post/update_post_request.py:

from pytisan import FormRequest


class UpdatePostRequest(FormRequest):
    def rules(self) -> dict:
        return {
            "title": "sometimes|required|string|min:3",
            "body": "sometimes|required|string|min:10",
        }

Available rules

Rule Example
required "email": "required|email"
sometimes Only validates when the field is present (PATCH)
string, integer, boolean, email Basic types
min, max String length, numeric value, or array size
in "status": "in:draft,published"
array "items": "required|array"
items.*.qty Nested validation (wildcard)
unique "email": "unique:users,email"
exists "user_id": "exists:users,id"

unique with ignore (update):

Rule.unique("users", "email", ignore=user.id)
# or string: f"unique:users,email,{user.id},id"

The router validates the FormRequest before calling the handler. In the controller:

data = await request.validated()

4. Controller

uv run anvil make:controller post/post_controller

app/http/controllers/post/post_controller.py:

from pytisan import Auth, json

from app.http.requests.post.create_post_request import CreatePostRequest
from app.http.requests.post.update_post_request import UpdatePostRequest
from app.models.post import Post


class PostController:
    async def index(self):
        posts = await Post.query().with_("user").order_by("-id").get()
        return json({
            "posts": [
                {**post.to_dict(), "author": (await post.user).to_dict() if await post.user else None}
                for post in posts
            ],
        })

    async def show(self, post: Post):
        await post.load("user")
        author = await post.user
        return json({
            "post": post.to_dict(),
            "author": author.to_dict() if author else None,
        })

    async def store(self, request: CreatePostRequest):
        data = await request.validated()
        data["user_id"] = Auth.id()
        post = await Post.create(**data)
        return json({"post": post.to_dict()}, status_code=201)

    async def update(self, request: UpdatePostRequest, post: Post):
        for key, value in (await request.validated()).items():
            post._set_attribute(key, value, mark_dirty=True)
        await post.save()
        return json({"post": post.to_dict()})

    async def destroy(self, post: Post):
        await post.delete()
        return json({"message": "Post deleted."})

Route model binding: when a route parameter is type-hinted with a Model, the framework loads the record automatically. Missing records return 404.


5. Routes

Edit routes/api.py:

from pytisan import Route

from app.http.controllers.post.post_controller import PostController


@Route.get("/")
async def index():
    from pytisan import json
    return json({"message": "API online"})


with Route.group(middleware=["auth"]):
    with Route.prefix("/posts").group():
        Route.get("/", [PostController, "index"])
        Route.post("/", [PostController, "store"])
        Route.get("/{id:int}", [PostController, "show"])
        Route.put("/{id:int}", [PostController, "update"])
        Route.patch("/{id:int}", [PostController, "update"])
        Route.delete("/{id:int}", [PostController, "destroy"])

Verify routes:

uv run anvil route:list

Route patterns

Pattern Description
Route.get("/users") GET
Route.post("/users") POST
Route.put("/users/{id:int}") PUT
Route.patch("/users/{id:int}") PATCH
Route.delete("/users/{id:int}") DELETE
Route.prefix("/posts").group(): Prefix group
Route.group(middleware=["auth"]): Auth-protected group

Parameter converters: {id:int}, {slug:str}, {uuid:uuid}.


6. Test the CRUD

uv run anvil serve

Login (create a user via script/seed first; example assumes a login endpoint exists):

curl -X POST http://127.0.0.1:8000/api/login \
  -H "Content-Type: application/json" \
  -d '{"email":"ana@example.com","password":"secret"}' \
  -c cookies.txt

# List posts (authenticated)
curl http://127.0.0.1:8000/api/posts -b cookies.txt

# Create post
curl -X POST http://127.0.0.1:8000/api/posts \
  -H "Content-Type: application/json" \
  -b cookies.txt \
  -d '{"title":"My post","body":"Content with more than ten characters."}'

# Show post
curl http://127.0.0.1:8000/api/posts/1 -b cookies.txt

# Update
curl -X PATCH http://127.0.0.1:8000/api/posts/1 \
  -H "Content-Type: application/json" \
  -b cookies.txt \
  -d '{"title":"Updated title"}'

# Delete
curl -X DELETE http://127.0.0.1:8000/api/posts/1 -b cookies.txt

Validation failures return 422 with { "message": "...", "errors": { ... } }.


Validation (FormRequest)

Automatic injection via type hint on the handler:

async def store(self, request: CreatePostRequest):
    data = await request.validated()
  • authorize() returns False403 Forbidden
  • Rules fail → 422 ValidationException with per-field errors
  • await request.validated() is cached for the same request

Correct import in your app (starter includes pyrightconfig.json):

from app.http.requests.post.create_post_request import CreatePostRequest

ORM relationships

Define on the model with class-level descriptors:

from pytisan import belongs_to, has_many, has_one

class User(Model):
    # ...

class Post(Model):
    user = belongs_to(User)

class Comment(Model):
    post = belongs_to(Post)

# When Post did not exist yet when User was defined:
User.posts = has_many(Post)
Post.comments = has_many(Comment)

Usage:

author = await post.user              # belongs_to
posts = await user.posts              # has_many
post = await user.posts.create(title="...", body="...")
profile = await user.has_one(Profile).get()  # has_one

Eager loading (avoids N+1):

posts = await Post.query().with_("user", "comments").get()
await post.load("user")

Foreign keys in migrations:

table.foreign_id("user_id", references="users")
table.foreign_id("post_id", references="posts", nullable=True)

Authentication

The starter includes User, session handling, and middleware in config/app.py.

Setup

config/auth.py:

from pytisan import Auth
from app.models.user import User

Auth.configure(model=User)

Global middleware (already registered in the starter):

  • start_sessionpytisan_session cookie, sessions table
  • authenticate — restores the user from the session on each request

Login controller

from pytisan import Auth, json


class AuthController:
    async def login(self, request: LoginRequest):
        credentials = await request.validated()
        if not await Auth.attempt(credentials):
            return json({"message": "Invalid credentials."}, status_code=422)
        return json({"message": "Logged in."})

    async def logout(self):
        await Auth.logout()
        return json({"message": "Logged out."})

    async def me(self):
        return json({"user": Auth.user().to_dict()})

Routes:

Route.post("/login", [AuthController, "login"])
Route.post("/logout", [AuthController, "logout"])

with Route.group(middleware=["auth"]):
    Route.get("/me", [AuthController, "me"])

Auth API

Method Description
await Auth.attempt(credentials, remember=False) Validate email/password and log in
await Auth.login(user) Manual login (called by attempt)
await Auth.logout() End session
Auth.check() True when authenticated
Auth.id() User ID (requires auth middleware on the route)
Auth.user() User instance (requires auth middleware)

Auth.user() and Auth.id() require the route to be inside Route.group(middleware=["auth"]). Otherwise a 500 is returned with a message explaining correct usage.

Unauthenticated access to protected routes returns 401 Unauthorized.

Hash

from pytisan import Hash

hashed = Hash.make("secret-password")
Hash.check("secret-password", hashed)  # True

On the model, use __casts__ = {"password": "hashed"} — passwords are hashed automatically on create() / save().


CLI reference

Installer (new projects)

uvx pytisan new my-app
uvx pytisan new my-app --path /tmp
uvx pytisan new my-app --force

Anvil (inside a project)

Command Description
anvil serve ASGI dev server (default main:asgi)
anvil serve --host 0.0.0.0 --port 8080 Host/port options
anvil migrate Run migrations
anvil migrate:status Status
anvil migrate:rollback Rollback last batch
anvil migrate:fresh Recreate database
anvil route:list List routes
anvil make:model Post -m Model + migration
anvil make:migration create_x_table Empty migration
anvil make:controller post/post_controller Controller
anvil make:request post/create_post_request FormRequest
anvil make:resource PostResource --model Post API Resource

Laravel-style paths in generators:

anvil make:request Post/CreatePost
anvil make:request post/create_post_request
anvil make:controller post/post_controller

Query Builder

Direct access without Model:

from pytisan import Database

rows = await Database.table("users").where("active", 1).order_by("name").get()
user = await Database.table("users").where("email", email).first()
await Database.table("users").where("id", 1).update({"name": "Ana"})
await Database.table("users").where("id", 1).delete()
count = await Database.table("users").count()

Framework development

PyPI package pytisan-framework (import pytisan):

cd framework
uv sync --group dev
uv run python -m pytest
uv run ruff check .

Install locally into a test app:

cd ../my-blog
uv sync --reinstall-package pytisan-framework

Package layout

framework/
├── core/           # Application, ApplicationBuilder
├── web/            # Request, Response, ASGI, middleware
├── routing/        # Router, Route, model binding
├── orm/            # Model, relations (belongs_to, has_many, …)
├── database/       # SQLite async, migrations, schema blueprint
├── validation/     # FormRequest, Rule
├── auth/           # Auth, Hash, session, middleware
├── query/          # Query builder
├── console/        # anvil CLI, generators, migrate
└── tests/

License

MIT

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

pytisan_framework-0.6.2.tar.gz (77.7 kB view details)

Uploaded Source

Built Distribution

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

pytisan_framework-0.6.2-py3-none-any.whl (75.0 kB view details)

Uploaded Python 3

File details

Details for the file pytisan_framework-0.6.2.tar.gz.

File metadata

  • Download URL: pytisan_framework-0.6.2.tar.gz
  • Upload date:
  • Size: 77.7 kB
  • Tags: Source
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: uv/0.11.17 {"installer":{"name":"uv","version":"0.11.17","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}

File hashes

Hashes for pytisan_framework-0.6.2.tar.gz
Algorithm Hash digest
SHA256 7ab2d0560eb3781be56c96f097d650ba8dac6a741e7db22526c0b0c9947784c5
MD5 54b5fbaca9185de5ae5cb42f6a0ccbfa
BLAKE2b-256 eafddc8a001c159b1013a044cafe393b8c05e3c95da32a6a086cad60b2cc1a3b

See more details on using hashes here.

File details

Details for the file pytisan_framework-0.6.2-py3-none-any.whl.

File metadata

  • Download URL: pytisan_framework-0.6.2-py3-none-any.whl
  • Upload date:
  • Size: 75.0 kB
  • Tags: Python 3
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: uv/0.11.17 {"installer":{"name":"uv","version":"0.11.17","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}

File hashes

Hashes for pytisan_framework-0.6.2-py3-none-any.whl
Algorithm Hash digest
SHA256 273f772e13c5ba9c6e058592d278ff72575f552e595e805978b879fa1a48c66f
MD5 42f88ca4e74d587776fb0b18a05558f8
BLAKE2b-256 c5e38c6aed05abfcb8011666a7d071c370e25444786176b76b45b48c6d89e43e

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