A lightweight zero external dependencies, Python web framework for building APIs
Project description
🐾 Zoe
A lightweight Python web framework. Zero dependencies, type-aware injection, aggregated validation.
pip install zoe-framework
Full documentation at zoe-framework.dev
Why Zoe?
- Zero dependencies — pure Python standard library
- Fully async — built on
asyncio.start_server, supports async handlers and middlewares - Type-aware injection — body, services, and request resolved automatically via type hints
- Aggregated validation — all field errors returned at once, never just the first
- Three DI lifecycles —
@Singleton,@Transient, and@Scopedout of the box - Field generators — auto-generate UUIDs, timestamps, tokens, slugs and more
- Computed fields — derive field values from other fields at request time
- Multipart support — file uploads with typed field access, no setup required
- Rich middleware stack — Logger, CORS, Limiter, Guard, Helmet, StaticFiles, BodyLimiter
Quick Start
from zoe import App, Server, Router, Request, Response, HttpCode
router = Router(prefix="/")
@router.get("/hello")
async def hello(req: Request) -> Response:
return Response.text(HttpCode.OK, text="Hello, world!")
if __name__ == "__main__":
app = App()
app.use(router)
Server(application=app).run()
Routing
Routes can be registered using decorators or the explicit router.add / Route API.
Decorator style (function-based)
from zoe import Router, Request, Response, HttpCode
router = Router(prefix="/users")
@router.get("/")
async def list_users(req: Request) -> Response:
return Response.json(HttpCode.OK, body=[])
@router.post("/")
async def create_user(req: Request) -> Response: ...
@router.get("/{user_id}")
async def get_user(req: Request) -> Response:
user_id = req.path_params.get("user_id")
return Response.json(HttpCode.OK, body={"id": user_id})
@router.put("/{user_id}")
async def update_user(req: Request) -> Response: ...
@router.patch("/{user_id}")
async def patch_user(req: Request) -> Response: ...
@router.delete("/{user_id}")
async def delete_user(req: Request) -> Response: ...
Decorator style (class-based)
from zoe import Handler, Request, Response, HttpCode
router = Router(prefix="/users")
@router.get("/{user_id}")
class GetUserHandler(Handler):
async def handle(self, request: Request) -> Response:
user_id = request.path_params.get("user_id")
return Response.json(HttpCode.OK, body={"id": user_id})
Explicit registration
from zoe import Router, Route
router = Router(prefix="/users")
router.add(Route.get(endpoint="/{user_id}", handler=GetUserHandler()))
router.add(Route.post(endpoint="/", handler=CreateUserHandler()))
router.add(Route.delete(endpoint="/{user_id}", handler=DeleteUserHandler()))
Handlers
Every handler receives a Request and must return a Response. Handlers can be sync or async.
Function-based
@router.get("/{user_id}")
async def get_user(req: Request) -> Response:
user_id = req.path_params.get("user_id")
return Response.json(HttpCode.OK, body={"id": user_id})
The Request parameter can be named anything as long as it is typed as Request.
Class-based
class GetUserHandler(Handler):
async def handle(self, request: Request) -> Response:
user_id = request.path_params.get("user_id")
return Response.json(HttpCode.OK, body={"id": user_id})
The Request parameter in class-based handlers must be named request — Zoe uses this name to distinguish it from injected services.
Request
@router.get("/example/{id}")
async def example(req: Request) -> Response:
# Path params
user_id = req.path_params.get("id")
# Query params — with optional type coercion and default
page = req.query_params.get("page", type_=int, default=1)
limit = req.query_params.get("limit", type_=int, default=10)
# Headers
content_type = req.headers.get("content-type")
# Auth
token = req.auth.bearer_token
credentials = req.auth.basic_credentials # (username, password)
api_key = req.auth.api_key
return Response.json(HttpCode.OK, body={})
Response
# JSON — accepts dicts, lists, Model instances
Response.json(HttpCode.OK, body={"key": "value"})
# Plain text
Response.text(HttpCode.OK, text="Hello")
# HTML
Response.html(HttpCode.OK, html_content="<h1>Hello</h1>")
# Redirect
Response.redirect(HttpCode.FOUND, redirect_to="/new-path")
# File — inline or forced download
Response.file(HttpCode.OK, filename="report.pdf", directory="./files")
Response.file(HttpCode.OK, filename="data.csv", force_download=True)
Models and Validation
Extend Model and annotate fields with Field and validators. Zoe validates the request body automatically and returns all errors at once.
Basic usage
from zoe import Model, Field, NotNull, Email, Min, Max, Password, Pattern, OneOf, Assert
class CreateUserDto(Model):
name: str = Field(NotNull())
email: str = Field(NotNull(), Email())
age: int = Field(NotNull(), Min(18), Max(120))
password: str = Field(NotNull(), Password())
role: str = Field(NotNull(), OneOf("admin", "user", "guest"))
@router.post("/users")
async def create_user(req: Request, body: CreateUserDto) -> Response:
return Response.json(HttpCode.CREATED, body=body.to_dict())
Optional fields and defaults
class UpdateUserDto(Model):
name: str | None = Field() # optional, no default
email: str | None = Field() # optional, no default
role: str = Field(default="user") # required type, default value
Field generators
Generators automatically produce values for fields that are absent from the request body.
from zoe import UUID, Date, Token, Slug, DateFormat
class CreateUserDto(Model):
id: str = Field(generator=UUID())
created_at: datetime = Field(generator=Date.Now())
token: str = Field(generator=Token(token_size=32))
slug: str = Field(generator=Slug("user", random_part_size=8))
# Date generators
Date.Now() # datetime with local timezone
Date.Now(timezone=timezone.utc) # datetime with UTC timezone
Date.Now(as_=DateFormat.STRING) # ISO 8601 string
Date.Now(as_=DateFormat.UNIX_TIMESTAMP) # Unix timestamp (int)
Date.Today() # date only (no time)
Date.After(days=30) # datetime 30 days from now
Date.After(weeks=2, hours=6) # datetime 2 weeks and 6 hours from now
Computed fields
Derive a field value from other fields at request time.
from zoe import ComputedField
class CreateProductDto(Model):
name: str = Field(NotNull())
price: int = Field(NotNull())
slug: str = ComputedField(lambda fields: fields["name"].lower().replace(" ", "-"))
Custom validators and generators
from zoe import FieldValidator, FieldGenerator, Field
from zoe_exceptions.schemas_exceptions.exc_validator import SchemaValidatorException
class EvenNumber(FieldValidator):
def validate(self, value, field_name):
if value % 2 != 0:
raise SchemaValidatorException(field_name=field_name, message=f"{field_name} must be even.")
class PrefixedID(FieldGenerator):
def __init__(self, prefix: str):
self.prefix = prefix
def generate(self):
import uuid
return f"{self.prefix}-{uuid.uuid4()}"
class MyDto(Model):
count: int = Field(EvenNumber())
id: str = Field(generator=PrefixedID("usr"))
Required validator
Required() differs from NotNull() in intent: it asserts that the field must be explicitly present in the body, regardless of type.
class CreatePostDto(Model):
title: str = Field(Required())
content: str = Field(Required())
Nested models
class AddressDto(Model):
street: str = Field(NotNull())
city: str = Field(NotNull())
zip: str = Field(NotNull(), Pattern(r"^\d{5}$"))
class CreateUserDto(Model):
name: str = Field(NotNull())
email: str = Field(NotNull(), Email())
address: AddressDto = Field(NotNull())
Nested models are validated recursively. Errors from nested fields are included in the same aggregated response.
Strict mode
from zoe import Strict
@Strict
class StrictDto(Model):
name: str = Field(NotNull())
# extra fields in the body will return a 400 error
Validation error response
{
"error": {
"type": "SCHEMA_VALIDATION_ERROR",
"model": "CreateUserDto",
"count": 2,
"errors": [
{ "field": "email", "code": "INVALID_FORMAT", "message": "..." },
{ "field": "password", "code": "WEAK_PASSWORD", "message": "..." }
]
}
}
Available validators
| Validator | Description |
|---|---|
NotNull() |
Value must not be null |
Required() |
Field must be present in the body |
Email() |
Must be a valid email address |
Password() |
Must meet password strength requirements |
Min(n) |
Minimum numeric value or string/list length |
Max(n) |
Maximum numeric value or string/list length |
Range(min, max) |
Numeric range |
Pattern(regex) |
Must match a regex pattern |
OneOf(*values) |
Must be one of the given values |
Assert(fn, msg) |
Custom assertion function |
Dependency Injection
Register services with @Singleton, @Transient, or @Scoped. Zoe resolves them by type in handler parameters.
from zoe import Singleton, Transient, Scoped
@Singleton()
class Database:
def __init__(self):
self.conn = connect(...)
@Transient()
class EmailService:
def send(self, to: str, subject: str): ...
@router.post("/users")
async def create_user(req: Request, body: CreateUserDto, db: Database, email: EmailService) -> Response:
user = db.create(body.name, body.email)
email.send(user.email, "Welcome")
return Response.json(HttpCode.CREATED, body={"id": user.id})
You can pass constructor arguments directly in the decorator:
@Singleton(host="localhost", port=5432)
class Database:
def __init__(self, host: str, port: int):
self.conn = connect(host, port)
Lifecycle comparison
| Decorator | Behavior |
|---|---|
@Singleton() |
One instance shared across the entire application |
@Transient() |
New instance created on every injection |
@Scoped() |
One instance per request, shared within that request |
File Uploads
@router.post("/upload")
async def upload(req: Request) -> Response:
photo = req.multipart.file("photo") # UploadFile | None
attachments = req.multipart.files("attachments") # list[UploadFile] | None
title = req.multipart.field("title") # str | None
count = req.multipart.field("count", type_=int)
saved = photo.save(path="./uploads", create_dirs=True)
return Response.json(HttpCode.OK, body={"saved": str(saved)})
UploadFile properties:
| Property | Description |
|---|---|
filename |
Original filename from the form |
file_type |
MIME type (e.g. image/jpeg) |
size |
File size in bytes |
data_bytes |
Raw bytes |
text |
Decoded content as UTF-8 (text files only) |
Middlewares
from zoe import Logger, Limiter, CORS, Helmet, BodyLimiter, Guard, BearerStrategy, StaticFiles
app = App()
app.use(CORS(allowed_origins=["https://mysite.com"]))
app.use(Logger())
app.use(Limiter(max_requests=100, window_seconds=60))
app.use(Helmet())
app.use(BodyLimiter(max_size_mb=5))
app.use(StaticFiles(directory="./public", prefix="/static"))
app.use(Guard(strategy=BearerStrategy(secret="my-secret")))
app.use(router)
Built-in middlewares
| Middleware | Description |
|---|---|
Logger() |
Color-coded request logs with timing |
Limiter(max_requests, window_seconds) |
IP-based rate limiting |
CORS(allowed_origins, ...) |
CORS headers with preflight support |
Helmet() |
Security headers (XSS, HSTS, etc.) |
BodyLimiter(max_size_mb) |
Reject oversized request bodies |
Guard(strategy) |
Auth enforcement — Bearer, Basic, ApiKey |
StaticFiles(directory, prefix) |
Serve static files |
Guard strategies
from zoe import Guard, BearerStrategy, BasicStrategy, ApiKeyStrategy, AnyStrategy, AllStrategy
Guard(BearerStrategy(secret="token"))
Guard(ApiKeyStrategy(key="key123"))
Guard(AnyStrategy([BearerStrategy(secret="token"), ApiKeyStrategy(key="key123")]))
Guard(AllStrategy([BearerStrategy(secret="token"), ApiKeyStrategy(key="key123")]))
Attach Guard to a specific Router to protect only those routes:
admin_router = Router(prefix="/admin")
admin_router.use(Guard(BearerStrategy(secret="secret")))
Custom middleware
from zoe import AsyncMiddleware, Request, Response
class TimingMiddleware(AsyncMiddleware):
async def process(self, request: Request, next) -> Response:
import time
start = time.time()
response = await next(request)
response.headers.add("X-Response-Time", f"{time.time() - start:.3f}s")
return response
Lifecycle Hooks
app = App()
@app.on_startup()
def on_start():
print("Connecting to database...")
@app.on_shutdown()
def on_stop():
print("Closing connections...")
Server(application=app).run()
Environment
from zoe import Env
db_url = Env.get("DATABASE_URL")
port = Env.get("PORT", default="8080")
debug = Env.get_bool("DEBUG", default=False)
Exceptions
from zoe import ZoeHttpException, NotFoundException, HttpCode
raise ZoeHttpException(message="Forbidden", status_code=HttpCode.FORBIDDEN)
raise NotFoundException(resource="User", identifier=user_id)
Full Example
from zoe import App, Server, Router, Request, Response, HttpCode
from zoe import Model, Field, NotNull, Email, Min, Max
from zoe import UUID, Date
from zoe import Singleton, Logger, CORS, Limiter
from datetime import datetime
@Singleton()
class UserRepository:
def __init__(self):
self._users: dict = {}
def create(self, id: str, name: str, email: str) -> dict:
self._users[id] = {"id": id, "name": name, "email": email}
return self._users[id]
def find_all(self) -> list:
return list(self._users.values())
def find(self, uid: str) -> dict | None:
return self._users.get(uid)
class CreateUserDto(Model):
id: str = Field(generator=UUID())
created_at: datetime = Field(generator=Date.Now())
name: str = Field(NotNull())
email: str = Field(NotNull(), Email())
age: int = Field(NotNull(), Min(18), Max(120))
router = Router(prefix="/users")
@router.post("/")
async def create_user(req: Request, body: CreateUserDto, repo: UserRepository) -> Response:
user = repo.create(body.id, body.name, body.email)
return Response.json(HttpCode.CREATED, body=user)
@router.get("/")
async def list_users(req: Request, repo: UserRepository) -> Response:
return Response.json(HttpCode.OK, body=repo.find_all())
@router.get("/{user_id}")
async def get_user(req: Request, repo: UserRepository) -> Response:
user = repo.find(req.path_params.get("user_id"))
if user is None:
return Response.json(HttpCode.NOT_FOUND, body={"error": "User not found"})
return Response.json(HttpCode.OK, body=user)
if __name__ == "__main__":
app = App()
app.use(CORS()).use(Logger()).use(Limiter(max_requests=100, window_seconds=60)).use(router)
@app.on_startup()
def on_start():
print("Server ready.")
Server(application=app).run()
Project Structure
your-project/
├── main.py
├── routers/
│ ├── user_router.py
│ └── post_router.py
├── dtos/
│ ├── user_dto.py
│ └── post_dto.py
├── services/
│ ├── user_repository.py
│ └── email_service.py
└── database/
└── connection.py
Roadmap
| Version | Focus | Status |
|---|---|---|
v0.1.0-alpha |
Core routing, validation, basic DI, middlewares | released |
v0.2.0 |
3 DI lifecycles, multipart, all response types, Guard, Helmet, StaticFiles, nested models, Env | released |
v0.3.0 |
Full async support, field generators, ComputedField, early config validation, unit tests | current |
v0.4.0 |
Global exception handler, test client, Container.reset() | planned |
v0.5.0 |
Auto-documentation (/docs route, OpenAPI/JSON output) |
planned |
v1.0.0 |
Stable API, full documentation, production-ready | planned |
License
MIT — Lucas Silva Brites
Project details
Release history Release notifications | RSS feed
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 zoe_framework-0.4.8.tar.gz.
File metadata
- Download URL: zoe_framework-0.4.8.tar.gz
- Upload date:
- Size: 237.0 kB
- Tags: Source
- Uploaded using Trusted Publishing? No
- Uploaded via: twine/6.2.0 CPython/3.13.7
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
d66b3098642d5f7b2adb8281761c98b4bdaab779a26568ff060a3eb795013e9c
|
|
| MD5 |
4dffcac9144305bf4c63116a882dca3e
|
|
| BLAKE2b-256 |
005e5e6de6140c9f4ec5da004f853436facbf50ec2915dcb7adc5a86560fadfd
|
File details
Details for the file zoe_framework-0.4.8-py3-none-any.whl.
File metadata
- Download URL: zoe_framework-0.4.8-py3-none-any.whl
- Upload date:
- Size: 267.7 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? No
- Uploaded via: twine/6.2.0 CPython/3.13.7
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
7a173a70093288af27da1f3f733c601ea76a67456550978f5c1b5e9fe64a839a
|
|
| MD5 |
168f997959b498a9750e101df73db5ff
|
|
| BLAKE2b-256 |
a9b013b6f8c9333fa3d6a83654990e49812afe1a8423c38f14ee2b68a69a1b27
|