Decorator-driven persistence registry for Pydantic models and CLI tooling
Project description
Decorates
Decorator-driven tooling for Python:
decorates.clifor ergonomic command-line appsdecorates.dbfor Pydantic + SQLAlchemy persistence
The philosophy is simple: minimal setup, predictable behavior, and a fast path to shipping.
Install
pip install decorates
Quick Start Guide
- Build one CLI command with a decorator.
- Build one DB model with a decorator.
- Use
Model.objectsfor CRUD.
CLI in 60 seconds
import time
import decorates.cli as cli
import decorates.db as db
from decorates.db import db_field
from pydantic import BaseModel
from enum import Enum
DATABASE = "todos.db"
TABLE_NAME = "todos"
class TodoStatus(str, Enum):
PENDING = "pending"
COMPLETED = "completed"
@db.database_registry(
DATABASE,
table_name=TABLE_NAME,
key_field="id"
)
class TodoItem(BaseModel):
id: int | None = None # Required id=None for autoincrement
title: str = db_field(index=True)
description: str = db_field(default="")
status: TodoStatus = db_field(default=TodoStatus.PENDING.value)
created_at: str = db_field(default_factory=lambda: time.strftime("%Y-%m-%d %H:%M:%S"))
updated_at: str = db_field(default_factory=lambda: time.strftime("%Y-%m-%d %H:%M:%S"))
@cli.register(description="Add a new todo item")
@cli.argument("title", type=str, help="Title of the todo item")
@cli.argument("description", type=str, help="Description of the todo item", default="")
@cli.option("--add", help="Add a new todo item")
@cli.option("-a", help="Add a new todo item")
def add_todo(title: str, description: str = "") -> str:
todo = TodoItem(title=title, description=description)
todo.save()
return f"Added todo: {todo.title} (ID: {todo.id})"
@cli.register(description="List all todo items")
@cli.option("--list", help="List all todo items")
@cli.option("-l", help="List all todo items")
def list_todos() -> str:
todos = TodoItem.objects.all()
if not todos:
return "No todo items found."
return "\n".join([f"{todo.id}: {todo.title} - {todo.status}" for todo in todos])
@cli.register(description="Mark a todo item as completed")
@cli.argument("todo_id", type=int, help="ID of the todo item to mark as completed")
def complete_todo(todo_id: int) -> str:
todo = TodoItem.objects.get(id=todo_id)
if not todo:
return f"Todo item with ID {todo_id} not found."
todo.status = TodoStatus.COMPLETED.value
todo.updated_at = time.strftime("%Y-%m-%d %H:%M:%S")
todo.save()
return f"Marked todo ID {todo_id} as completed."
@cli.register(description="Update a todo item")
@cli.argument("todo_id", type=int, help="ID of the todo item to update")
@cli.argument("title", type=str, help="New title of the todo item", default=None)
@cli.argument("description", type=str, help="New description of the todo item", default=None)
@cli.option("--update", help="Update a todo item")
@cli.option("-u", help="Update a todo item")
def update_todo(todo_id: int, title: str = None, description: str = None) -> str:
todo = TodoItem.objects.get(id=todo_id)
if not todo:
return f"Todo item with ID {todo_id} not found."
if title:
todo.title = title
if description:
todo.description = description
todo.updated_at = time.strftime("%Y-%m-%d %H:%M:%S")
todo.save()
return f"Updated todo ID {todo_id}."
if __name__ == "__main__":
cli.run()
Add Todo
python todo.py add "Buy groceries" "Milk, eggs, bread"
python todo.py --add "Buy groceries" "Milk, eggs, bread"
python todo.py -a "Buy groceries" "Milk, eggs, bread"
python todo.py add --title "Buy groceries" --description "Milk, eggs, bread"
python todo.py --add --title "Buy groceries" --description "Milk, eggs, bread"
List Todos
python todo.py list
python todo.py --list
python todo.py -l
Complete Todo
python todo.py complete_todo 1
python todo.py complete_todo --todo_id 1
python todo.py complete_todo --todo-id 1
Update Todo
python todo.py update 1 "Read two books" "Finish both novels this week"
python todo.py update 1 --title "Read two books" --description "Finish both novels this week"
python todo.py update --todo_id 1 --title "Read two books"
python todo.py update --todo-id 1 --description "Finish both novels this week"
Notes
- Wrap multi-word values in quotes.
addandlisthave aliases (--add/-a,--list/-l).- The function name decorated with
@cli.register(...)will be used as an option argument if no@cli.option(...)is specified.
### Database + FastAPI in 5 minutes
```python
from contextlib import asynccontextmanager
from fastapi import FastAPI, HTTPException
from pydantic import BaseModel
from decorates.db import (
RecordNotFoundError,
UniqueConstraintError,
database_registry,
)
DB_URL = "sqlite:///shop.db"
@database_registry(DB_URL, table_name="customers", unique_fields=["email"])
class Customer(BaseModel):
id: int | None = None
name: str
email: str
@database_registry(DB_URL, table_name="products")
class Product(BaseModel):
id: int | None = None
name: str
price: float
@database_registry(DB_URL, table_name="orders")
class Order(BaseModel):
id: int | None = None
customer_id: int
product_id: int
quantity: int
total: float
class CreateCustomer(BaseModel):
name: str
email: str
class CreateProduct(BaseModel):
name: str
price: float
class CreateOrder(BaseModel):
customer_id: int
product_id: int
quantity: int
@asynccontextmanager
async def lifespan(app: FastAPI):
for model in (Customer, Product, Order):
model.create_schema()
yield
for model in (Customer, Product, Order):
model.objects.dispose()
app = FastAPI(lifespan=lifespan)
@app.post("/customers", response_model=Customer, status_code=201)
def create_customer(payload: CreateCustomer):
try:
return Customer.objects.create(**payload.model_dump())
except UniqueConstraintError:
raise HTTPException(status_code=409, detail="Email already exists")
@app.get("/customers/{customer_id}", response_model=Customer)
def get_customer(customer_id: int):
try:
return Customer.objects.require(customer_id)
except RecordNotFoundError:
raise HTTPException(status_code=404, detail="Customer not found")
@app.post("/products", response_model=Product, status_code=201)
def create_product(payload: CreateProduct):
return Product.objects.create(**payload.model_dump())
@app.post("/orders", response_model=Order, status_code=201)
def create_order(payload: CreateOrder):
customer = Customer.objects.get(payload.customer_id)
if customer is None:
raise HTTPException(status_code=404, detail="Customer not found")
product = Product.objects.get(payload.product_id)
if product is None:
raise HTTPException(status_code=404, detail="Product not found")
return Order.objects.create(
customer_id=customer.id,
product_id=product.id,
quantity=payload.quantity,
total=product.price * payload.quantity,
)
@app.get("/orders/desc", response_model=list[Order])
def list_orders_desc(limit: int = 20, offset: int = 0): # Filter by oldest (1, 2, 3...n)
return Order.objects.filter(order_by="id", limit=limit, offset=offset)
@app.get("/orders/asc", response_model=list[Order])
def list_orders_asc(limit: int = 20, offset: int = 0): # Filter by newest (n...3, 2, 1)
return Order.objects.filter(order_by="-id", limit=limit, offset=offset)
Core Concepts
decorates.cli
- Register functions with module-level decorators:
@register,@argument,@option. - Run command handlers through the module registry via
decorates.cli.run(). - Support positional + named argument forms (for non-bool args), with bool flags as
--flag. - Command aliases are declared with
@option("-x")/@option("--long"). - Built-in help command is always available:
help,--help, and-h. - Runtime wraps unexpected handler crashes as
CommandExecutionError(with original exception chaining). - Operational logs use standard Python logging namespaces under
decorates.cli.*.
decorates.db
- Register
BaseModelclasses with@database_registry(...). - Access all persistence through
Model.objects. id: int | None = Nonegives database-managed autoincrement IDs.- Schema helpers are available as class methods:
create_schema,drop_schema,schema_exists,truncate. - Unexpected SQLAlchemy runtime failures are normalized into
SchemaErrorfor cleaner, predictable error handling. - Operational logs use standard Python logging namespaces under
decorates.db.*. - DB exceptions provide structured metadata (
exc.context,exc.to_dict()) for production diagnostics.
decorates.db Usage Snapshot
# Filtering operators
Order.objects.filter(total__gte=100)
Customer.objects.filter(email__ilike="%@example.com")
Order.objects.filter(quantity__in=[1, 2, 3])
# Sorting and pagination
Order.objects.filter(order_by="-id", limit=20, offset=0)
# Bulk writes
Product.objects.bulk_create([...])
Product.objects.bulk_upsert([...])
# Additive migration helpers
Customer.objects.ensure_column("phone", str | None, nullable=True)
Customer.objects.rename_table("customers_archive")
After rename_table(...) succeeds, the same Model.objects manager and
schema helpers are immediately bound to the new table name.
If your model contains a field named password, password values are automatically hashed on write, and instances receive verify_password(...).
Documentation
- DB guide:
src/decorates/db/USAGE.md - CLI source API:
src/decorates/cli - DB source API:
src/decorates/db
Requirements
- Python 3.10+
pydantic>=2.0sqlalchemy>=2.0
Testing
- Default
pytestincludes SQLite plus PostgreSQL/MySQL rename-state integration tests. - Start Docker Desktop (or another Docker engine) before running tests so
docker-compose.test-db.ymlservices can boot. - The decorates is backed by a rigorous, production-focused test suite (170+ tests) that covers unit, edge-case, and multi-dialect integration behavior.
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 decorates-4.0.2.tar.gz.
File metadata
- Download URL: decorates-4.0.2.tar.gz
- Upload date:
- Size: 54.8 kB
- Tags: Source
- Uploaded using Trusted Publishing? Yes
- Uploaded via: twine/6.1.0 CPython/3.13.12
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
69e35fce7a358ea700fb9c3b8b3ff87154e214fd002bd11422563b6a8c70acaa
|
|
| MD5 |
5415411e78ba4a790878f0c354296a44
|
|
| BLAKE2b-256 |
5ae2c51a1956b22ec8de4a13a3dc9f01aa779535f548b6255f556eb45481c1f1
|
Provenance
The following attestation bundles were made for decorates-4.0.2.tar.gz:
Publisher:
publish.yml on nexustech101/decorates
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
decorates-4.0.2.tar.gz -
Subject digest:
69e35fce7a358ea700fb9c3b8b3ff87154e214fd002bd11422563b6a8c70acaa - Sigstore transparency entry: 1313313442
- Sigstore integration time:
-
Permalink:
nexustech101/decorates@0eae0141a9806722d9f8e5bae58605a8f85db24e -
Branch / Tag:
refs/tags/v4.0.2 - Owner: https://github.com/nexustech101
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
publish.yml@0eae0141a9806722d9f8e5bae58605a8f85db24e -
Trigger Event:
release
-
Statement type:
File details
Details for the file decorates-4.0.2-py3-none-any.whl.
File metadata
- Download URL: decorates-4.0.2-py3-none-any.whl
- Upload date:
- Size: 49.3 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? Yes
- Uploaded via: twine/6.1.0 CPython/3.13.12
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
a99cd7d8645e1cc2b70fea7f7cdbe0527a1bfabd7fc2034270f1e3d46ac4ddb0
|
|
| MD5 |
171e8948b66b238d809baec945b0638e
|
|
| BLAKE2b-256 |
4051a626637cdcd0c4b820263c14d2f280731d0945b9e67d65c835b6f0bd34ff
|
Provenance
The following attestation bundles were made for decorates-4.0.2-py3-none-any.whl:
Publisher:
publish.yml on nexustech101/decorates
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
decorates-4.0.2-py3-none-any.whl -
Subject digest:
a99cd7d8645e1cc2b70fea7f7cdbe0527a1bfabd7fc2034270f1e3d46ac4ddb0 - Sigstore transparency entry: 1313313555
- Sigstore integration time:
-
Permalink:
nexustech101/decorates@0eae0141a9806722d9f8e5bae58605a8f85db24e -
Branch / Tag:
refs/tags/v4.0.2 - Owner: https://github.com/nexustech101
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
publish.yml@0eae0141a9806722d9f8e5bae58605a8f85db24e -
Trigger Event:
release
-
Statement type: