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
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 UIGET /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
- Author: https://github.com/sn
- Website: https://ecko.sh
- GitHub: https://github.com/ecko-sh/ecko
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 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
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
81dcffae01332f44fd67e1eb1f03c58abf411f23b4693173d64753a75f94a76b
|
|
| MD5 |
a9fa3fec7a531a9390127f8dc9209062
|
|
| BLAKE2b-256 |
d5ec6d3fd8996765b79efb093d40ae5b6bee518dea6b162f13264c4a508ed3f3
|
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
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
4bd4cc95da15607067685f6d60f945131e05bc5622ff7833199dd1fc0da58e5c
|
|
| MD5 |
30da0933e4081eba50b6284c7c2b9408
|
|
| BLAKE2b-256 |
55e6baebfe33bcda0457b7b2a97f5ec9d936fa96eacf763f730d6b4eb1935b9a
|