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
- Create a project
- Application structure
- Configuration
- Tutorial: Posts CRUD
- API Resources
- Validation (FormRequest)
- ORM relationships
- Authentication
- CLI reference
- 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. API Resources
Resources keep controllers clean — no manual to_dict() or relation juggling. Pass a resource to json() and it serializes automatically.
uv run anvil make:resource UserResource --model User
uv run anvil make:resource post/post_resource --model Post
app/http/resources/user_resource.py:
from pytisan import JsonResource
class UserResource(JsonResource):
def to_array(self) -> dict:
return self.array(
id=self.resource.pk,
name=self.resource.name,
)
app/http/resources/post/post_resource.py:
from pytisan import JsonResource
from app.http.resources.user_resource import UserResource
class PostResource(JsonResource):
def to_array(self) -> dict:
return self.array(
id=self.resource.pk,
title=self.resource.title,
body=self.resource.body,
user=self.when_loaded(
"user",
lambda: UserResource.make(self.related("user")).to_array(),
),
)
| Method | Usage |
|---|---|
PostResource.make(post) |
Single model → resource |
PostResource.collection(posts) |
List of models → collection |
self.when_loaded("user", ...) |
Include relation only when eager-loaded |
self.array(...) |
Build output dict, omitting unloaded relations |
when_loaded checks Model.relation_loaded() — the relation must be eager-loaded with .with_() or await post.load("user"). Unloaded relations are omitted from the JSON (not null).
json() accepts resources directly — no .to_array() in controllers:
return json({"post": PostResource.make(post)})
return json({"posts": PostResource.collection(posts)})
Models inside json() are also serialized automatically (respecting __hidden__).
5. 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.http.resources.post.post_resource import PostResource
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": PostResource.collection(posts)})
async def show(self, post: Post):
await post.load("user")
return json({"post": PostResource.make(post)})
async def store(self, request: CreatePostRequest):
data = await request.validated()
data["user_id"] = Auth.id()
post = await Post.create(**data)
return json({"post": PostResource.make(post)}, 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": PostResource.make(post)})
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.
6. 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 |
Route.post("/posts", handler).middleware("auth") |
Per-route middleware (chainable) |
Route.post("/posts", handler, middleware=["auth"]) |
Per-route middleware (keyword) |
Parameter converters: {id:int}, {slug:str}, {uuid:uuid}.
Per-route middleware stacks with group middleware:
with Route.group(middleware=["auth"]):
Route.get("/posts", [PostController, "index"]) # auth only
Route.post("/posts", [PostController, "store"]).middleware("verified") # auth + verified
Route.delete("/posts/{id:int}", [PostController, "destroy"], middleware=["admin"])
7. 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": { ... } }.
API Resources
JSON serializers for models — Laravel-style make() / collection() with conditional relations via when_loaded().
from pytisan import JsonResource
class PostResource(JsonResource):
def to_array(self) -> dict:
return self.array(
id=self.resource.pk,
title=self.resource.title,
user=self.when_loaded(
"user",
lambda: UserResource.make(self.related("user")).to_array(),
),
comments=self.when_loaded(
"comments",
lambda: CommentResource.collection(self.related("comments")).to_array(),
),
)
# Single resource
PostResource.make(post)
# Collection
PostResource.collection(posts)
# In controllers — json() serializes automatically
return json(PostResource.make(post))
return json({"posts": PostResource.collection(posts)})
Generate with:
anvil make:resource PostResource --model Post
anvil make:resource post/post_resource --model Post
Validation (FormRequest)
Automatic injection via type hint on the handler:
async def store(self, request: CreatePostRequest):
data = await request.validated()
authorize()returnsFalse→ 403 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_session—pytisan_sessioncookie,sessionstableauthenticate— 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 use the auth middleware (via group, keyword, or .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
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file pytisan_framework-0.6.9.tar.gz.
File metadata
- Download URL: pytisan_framework-0.6.9.tar.gz
- Upload date:
- Size: 81.8 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
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
ff37edf093b483624cc724679b49ca8865e9e4964ec2e3d1cbad59347a5513fa
|
|
| MD5 |
e33a470d93af76b1a46b58514732f123
|
|
| BLAKE2b-256 |
2a31f0a7351d9d6b68d3cbb892f7bcbb65e60855dd2bb8bde2898064277cf001
|
File details
Details for the file pytisan_framework-0.6.9-py3-none-any.whl.
File metadata
- Download URL: pytisan_framework-0.6.9-py3-none-any.whl
- Upload date:
- Size: 77.8 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
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
2a7ad2e227e3616478a300465c527ed889deeac5ee6e4e3c64772dd0a9af6d63
|
|
| MD5 |
8c6b7f612d85b8caff2aa08cdf479534
|
|
| BLAKE2b-256 |
d1ca177fc8db2f187946b32fab94e1b446487a8e2905e74956a85c91165c0b73
|