Skip to main content

A modern Python web framework. Flask's simplicity with 2026 expectations.

Project description

Ecko

A fast, minimal & modern Python web framework.

from ecko import Ecko

app = Ecko()

@app.get("/")
def hello():
    return {"message": "Hello, World!"}
ecko run app:app

Why Ecko?

  • Simple by default - No boilerplate, no configuration, just write handlers
  • Type-safe without ceremony - Automatic validation from type hints
  • Sync and async, seamlessly - Write sync code, it runs in a threadpool automatically
  • Batteries included - CORS, sessions, OpenAPI docs built-in but optional
  • Fast - Built on ASGI with msgspec for JSON (5-10x faster than Pydantic)
  • WebSockets - Out of the box support for WebSockets

Installation

pip install ecko

Requires Python 3.12+.

Quick Start

Create a new project

ecko new myproject
cd myproject
pip install -e .
ecko run app:app

Or start from scratch

# app.py
from ecko import Ecko

app = Ecko()

@app.get("/")
def home():
    return {"message": "Hello, World!"}

@app.get("/users/{user_id}")
def get_user(user_id: int):
    return {"id": user_id, "name": f"User {user_id}"}

@app.post("/users")
def create_user(data: dict):
    return {"created": data}, 201
ecko run app:app

Open http://127.0.0.1:8000

Features

Route Parameters

Parameters are extracted automatically from type hints:

@app.get("/users/{user_id}")
def get_user(
    user_id: int,              # From path (matches {user_id})
    include_posts: bool = False # From query string
):
    return {"id": user_id, "include_posts": include_posts}
GET /users/42?include_posts=true
→ {"id": 42, "include_posts": true}

Type Coercion

Ecko automatically converts strings to the declared type:

@app.get("/search")
def search(
    q: str,                    # Required string
    page: int = 1,             # Optional int with default
    limit: int | None = None,  # Optional, None if missing
    tags: list[str] = [],      # List from ?tags=a&tags=b
):
    return {"query": q, "page": page, "limit": limit, "tags": tags}

Request Body

For POST/PUT/PATCH, dict or msgspec Struct parameters are parsed from JSON:

@app.post("/users")
def create_user(data: dict):
    return {"created": data}, 201

For typed validation, use msgspec Structs:

import msgspec

class CreateUser(msgspec.Struct):
    name: str
    email: str
    age: int | None = None

@app.post("/users")
def create_user(user: CreateUser):
    return {"name": user.name, "email": user.email}

Invalid JSON returns a 422 error automatically.

Explicit Parameter Markers

For edge cases, use explicit markers:

from ecko import Query, Path, Header, Body

@app.get("/items/{item_id}")
def get_item(
    item_id: int = Path(description="The item ID"),
    q: str = Query("", description="Search query"),
    token: str = Header(alias="x-api-token"),
):
    return {"item_id": item_id, "query": q}

Responses

Return values are automatically converted to JSON responses:

# Dict → 200 JSON
return {"data": "value"}

# Tuple → status code
return {"created": True}, 201

# Tuple with headers
return {"data": "value"}, 200, {"X-Custom": "header"}

# Explicit response
from ecko import Response, JSONResponse, HTMLResponse, RedirectResponse

return HTMLResponse("<h1>Hello</h1>")
return RedirectResponse("/login")

Async and Sync Handlers

Both work seamlessly:

@app.get("/sync")
def sync_handler():
    # Runs in threadpool automatically
    time.sleep(1)
    return {"sync": True}

@app.get("/async")
async def async_handler():
    # Native async
    await asyncio.sleep(1)
    return {"async": True}

Request Context

Access request data from anywhere without passing it around:

from ecko import context

@app.before_request
def load_user():
    token = context.request.headers.get("authorization")
    context.user = get_user_from_token(token)

@app.get("/profile")
def profile():
    return {"name": context.user.name}

Before/After Request Hooks

@app.before_request
def authenticate():
    if not is_valid_token(context.request.headers.get("authorization")):
        return {"error": "Unauthorized"}, 401  # Short-circuit

@app.after_request
def add_headers(response):
    response._headers["X-Request-ID"] = generate_id()
    return response

Exception Handling

Built-in HTTP exceptions:

from ecko import NotFound, BadRequest, Unauthorized

@app.get("/users/{user_id}")
def get_user(user_id: int):
    user = db.get_user(user_id)
    if not user:
        raise NotFound("User not found")
    return user

Custom exception handlers:

class RateLimitExceeded(Exception):
    pass

@app.exception_handler(RateLimitExceeded)
def handle_rate_limit(exc):
    return {"error": "Too many requests"}, 429

@app.exception_handler(ValueError)
def handle_value_error(exc):
    return {"error": str(exc)}, 400

Middleware

async def timing_middleware(request, call_next):
    start = time.time()
    response = await call_next()
    response._headers["X-Response-Time"] = f"{time.time() - start:.3f}s"
    return response

app.use(timing_middleware)

CORS

from ecko.middleware import cors

# Allow all origins (development)
app.use(cors())

# Specific origins (production)
app.use(cors(
    origins=["https://myapp.com"],
    methods=["GET", "POST", "PUT", "DELETE"],
    allow_credentials=True,
))

Sessions

Signed cookie-based sessions:

from ecko import context
from ecko.middleware import sessions

app.use(sessions(secret="your-secret-key-min-32-chars!!"))

@app.post("/login")
def login(data: dict):
    context.session["user_id"] = data["user_id"]
    return {"logged_in": True}

@app.get("/profile")
def profile():
    user_id = context.session.get("user_id")
    if not user_id:
        return {"error": "Not logged in"}, 401
    return {"user_id": user_id}

@app.post("/logout")
def logout():
    context.session.clear()
    return {"logged_out": True}

WebSockets

Full WebSocket support with a clean async API:

from ecko import Ecko, WebSocket

app = Ecko()

@app.websocket("/ws")
async def websocket_handler(ws: WebSocket):
    await ws.accept()
    async for message in ws:
        await ws.send_text(f"Echo: {message}")

WebSocket Methods

@app.websocket("/chat/{room_id}")
async def chat(ws: WebSocket):
    # Access path parameters
    room_id = ws.path_params["room_id"]

    # Accept the connection
    await ws.accept()

    # Receive messages
    text = await ws.receive_text()
    data = await ws.receive_json()
    binary = await ws.receive_bytes()

    # Send messages
    await ws.send_text("Hello!")
    await ws.send_json({"status": "ok"})
    await ws.send_bytes(b"\x00\x01")

    # Close the connection
    await ws.close(code=1000, reason="Goodbye")

Handling Disconnections

from ecko import WebSocket, WebSocketDisconnect

@app.websocket("/ws")
async def handler(ws: WebSocket):
    await ws.accept()
    try:
        while True:
            message = await ws.receive_text()
            await ws.send_text(f"Got: {message}")
    except WebSocketDisconnect:
        print("Client disconnected")

Or use the async iterator which handles disconnections automatically:

@app.websocket("/ws")
async def handler(ws: WebSocket):
    await ws.accept()
    async for message in ws:
        await ws.send_text(f"Got: {message}")
    # Loop exits cleanly on disconnect

Lifecycle Events

@app.on_startup
async def startup():
    await database.connect()
    print("App started!")

@app.on_shutdown
async def shutdown():
    await database.disconnect()
    print("App stopped!")

OpenAPI Documentation

Auto-generated API docs with Swagger UI:

from ecko import Ecko, setup_openapi

app = Ecko()

setup_openapi(
    app,
    title="My API",
    version="1.0.0",
    description="My awesome API",
)

@app.get("/users/{user_id}")
def get_user(user_id: int):
    """Get a user by their ID."""
    return {"id": user_id}
  • GET /docs → Swagger UI
  • GET /openapi.json → OpenAPI 3.1 schema

Docstrings become descriptions. Type hints become schemas.

CLI

# Run with auto-reload (development)
ecko run app:app

# Run in production mode
ecko run app:app --prod --workers 4

# Custom host/port
ecko run app:app --host 0.0.0.0 --port 3000

# Create new project
ecko new myproject

# List all routes
ecko routes app:app

Full Example

"""A complete Ecko application."""

import msgspec
from ecko import Ecko, Query, Header, context, NotFound, setup_openapi
from ecko.middleware import cors, sessions


class CreateTodo(msgspec.Struct):
    title: str
    completed: bool = False


app = Ecko()

# Documentation
setup_openapi(app, title="Todo API", version="1.0.0")

# Middleware
app.use(cors())
app.use(sessions(secret="change-me-in-production-use-env-var"))

# In-memory storage (use a real database in production)
todos: dict[int, dict] = {}
next_id = 1


@app.on_startup
def startup():
    print("Todo API ready!")


@app.before_request
def request_logging():
    print(f"{context.request.method} {context.request.path}")


@app.get("/")
def home():
    """API information."""
    return {
        "name": "Todo API",
        "docs": "/docs",
    }


@app.get("/todos")
def list_todos(completed: bool | None = None, limit: int = Query(10)):
    """List all todos with optional filtering."""
    result = list(todos.values())
    if completed is not None:
        result = [t for t in result if t["completed"] == completed]
    return {"todos": result[:limit]}


@app.get("/todos/{todo_id}")
def get_todo(todo_id: int):
    """Get a specific todo by ID."""
    if todo_id not in todos:
        raise NotFound(f"Todo {todo_id} not found")
    return todos[todo_id]


@app.post("/todos")
def create_todo(todo: CreateTodo):
    """Create a new todo."""
    global next_id
    new_todo = {
        "id": next_id,
        "title": todo.title,
        "completed": todo.completed,
    }
    todos[next_id] = new_todo
    next_id += 1
    return new_todo, 201


@app.put("/todos/{todo_id}")
def update_todo(todo_id: int, data: dict):
    """Update a todo."""
    if todo_id not in todos:
        raise NotFound(f"Todo {todo_id} not found")
    todos[todo_id].update(data)
    return todos[todo_id]


@app.delete("/todos/{todo_id}")
def delete_todo(todo_id: int):
    """Delete a todo."""
    if todo_id not in todos:
        raise NotFound(f"Todo {todo_id} not found")
    del todos[todo_id]
    return {"deleted": True}


if __name__ == "__main__":
    import uvicorn
    uvicorn.run(app, host="127.0.0.1", port=8000)

API Reference

Core

Export Description
Ecko Main application class
Request Request object
WebSocket WebSocket connection
WebSocketDisconnect Exception for disconnections
context Request-scoped context
setup_openapi Enable OpenAPI docs

Parameter Markers

Export Description
Query Explicit query parameter
Path Explicit path parameter
Header Extract from headers
Body Explicit body parameter

Responses

Export Description
Response Base response
JSONResponse JSON response
HTMLResponse HTML response
RedirectResponse HTTP redirect

Exceptions

Export Description
HTTPException Base HTTP exception
BadRequest 400 error
Unauthorized 401 error
Forbidden 403 error
NotFound 404 error
MethodNotAllowed 405 error
ValidationError 422 error
InternalServerError 500 error

Middleware

from ecko.middleware import cors, sessions
Export Description
cors CORS middleware factory
sessions Session middleware factory

Comparison

Feature Ecko Flask FastAPI
Async support Native Extension Native
WebSockets Built-in Extension Built-in
Type validation Auto Manual Pydantic
OpenAPI docs Built-in Extension Built-in
Dependency injection Context g object Depends()
Learning curve Low Low Medium
Performance Fast Moderate Fast

TODO

  • Default HTML page for new projects
  • More examples
  • Styled exception pages with comprehensive stack traces
  • More middleware
  • Native OAuth support
  • Robust configuration pattern
  • Project organization and structure
  • Enriched CLI tooling for easier project management and scaffolding
  • More tests
  • More docs
  • More examples
  • Built-in authentication framework
  • Built-in event bus
  • Built-in task queue
  • Built-in caching
  • Built-in file storage interface compatible with block storage services
  • Pre-built integrations with popular services
  • Pre-configured CI/CD pipeline scripts with Github Actions
  • Pre-configured Docker images
  • Pre-configured logging and monitoring scaffolds for AWS and GCP
  • start.ecko.sh page to get started quickly

License

MIT

Links

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

ecko-0.1.0.tar.gz (28.1 kB view details)

Uploaded Source

Built Distribution

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

ecko-0.1.0-py3-none-any.whl (30.9 kB view details)

Uploaded Python 3

File details

Details for the file ecko-0.1.0.tar.gz.

File metadata

  • Download URL: ecko-0.1.0.tar.gz
  • Upload date:
  • Size: 28.1 kB
  • Tags: Source
  • Uploaded using Trusted Publishing? No
  • Uploaded via: twine/6.1.0 CPython/3.13.7

File hashes

Hashes for ecko-0.1.0.tar.gz
Algorithm Hash digest
SHA256 81dcffae01332f44fd67e1eb1f03c58abf411f23b4693173d64753a75f94a76b
MD5 a9fa3fec7a531a9390127f8dc9209062
BLAKE2b-256 d5ec6d3fd8996765b79efb093d40ae5b6bee518dea6b162f13264c4a508ed3f3

See more details on using hashes here.

File details

Details for the file ecko-0.1.0-py3-none-any.whl.

File metadata

  • Download URL: ecko-0.1.0-py3-none-any.whl
  • Upload date:
  • Size: 30.9 kB
  • Tags: Python 3
  • Uploaded using Trusted Publishing? No
  • Uploaded via: twine/6.1.0 CPython/3.13.7

File hashes

Hashes for ecko-0.1.0-py3-none-any.whl
Algorithm Hash digest
SHA256 4bd4cc95da15607067685f6d60f945131e05bc5622ff7833199dd1fc0da58e5c
MD5 30da0933e4081eba50b6284c7c2b9408
BLAKE2b-256 55e6baebfe33bcda0457b7b2a97f5ec9d936fa96eacf763f730d6b4eb1935b9a

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