Skip to main content

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

Python Version License

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 @Scoped out 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


Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distribution

zoe_framework-0.4.1.tar.gz (96.5 kB view details)

Uploaded Source

Built Distribution

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

zoe_framework-0.4.1-py3-none-any.whl (127.9 kB view details)

Uploaded Python 3

File details

Details for the file zoe_framework-0.4.1.tar.gz.

File metadata

  • Download URL: zoe_framework-0.4.1.tar.gz
  • Upload date:
  • Size: 96.5 kB
  • Tags: Source
  • Uploaded using Trusted Publishing? No
  • Uploaded via: twine/6.2.0 CPython/3.13.7

File hashes

Hashes for zoe_framework-0.4.1.tar.gz
Algorithm Hash digest
SHA256 541f0f46e66834a0dc7341f0640b1db1bf5462e8dc8435bf07241ebfdb0ebe37
MD5 99c6ef69bdee84c71270ae924831a341
BLAKE2b-256 683447617a080086020e4b6b28af8c343a9cdd3b7f66019ee718459329f45589

See more details on using hashes here.

File details

Details for the file zoe_framework-0.4.1-py3-none-any.whl.

File metadata

  • Download URL: zoe_framework-0.4.1-py3-none-any.whl
  • Upload date:
  • Size: 127.9 kB
  • Tags: Python 3
  • Uploaded using Trusted Publishing? No
  • Uploaded via: twine/6.2.0 CPython/3.13.7

File hashes

Hashes for zoe_framework-0.4.1-py3-none-any.whl
Algorithm Hash digest
SHA256 5fbae4859b64b519633daf7ee7fc11cef954e52288b8a584dae82e9df52771b6
MD5 6ab2c57791ea1f57ed80e6e6baac5e25
BLAKE2b-256 c8527dfad8f9fe90da3fd16b97bde5c4a1da1178e936ada2e1e5d7a5d71ed3d7

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