Skip to main content

Decorator-driven CLI and database toolkit for Pydantic and SQLAlchemy.

Project description

Functionals

PyPI version Python versions License: MIT Module CLI DB Tests

Decorates is a production-oriented toolkit for two common Python surfaces:

  • functionals.cli for module-first command registration, typed arguments, and built-in help
  • functionals.db for Pydantic model persistence and additive schema operations on SQLAlchemy

The package emphasizes explicit APIs, predictable behavior, and test-backed reliability.

Install

pip install decorates  # Package name is `decorates`; module name is `functionals`

Quick Start Guide

  1. Build one CLI command with a decorator.
  2. Build one DB model with a decorator.
  3. Use Model.objects for CRUD.

CLI in minutes

from __future__ import annotations

from enum import StrEnum
from time import strftime

import functionals.cli as cli
import functionals.db as db
from functionals.db import db_field
from pydantic import BaseModel

DB_PATH = "todos.db"
TABLE = "todos"
NOW = lambda: strftime("%Y-%m-%d %H:%M:%S")


class TodoStatus(StrEnum):
    PENDING = "pending"
    COMPLETED = "completed"


@db.database_registry(DB_PATH, table_name=TABLE, key_field="id")
class TodoItem(BaseModel):
    id: int | None = None
    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=NOW)
    updated_at: str = db_field(default_factory=NOW)


@cli.register(name="add", description="Create a todo item")
@cli.argument("title", type=str, help="Todo title")
@cli.argument("description", type=str, default="", help="Todo description")
@cli.option("--add")
@cli.option("-a")
def add_todo(title: str, description: str = "") -> str:
    todo = TodoItem(title=title, description=description)
    todo.save()
    return f"Added: {todo.title} (ID: {todo.id})"


@cli.register(name="list", description="List todo items")
@cli.option("--list")
@cli.option("-l")
def list_todos() -> str:
    todos = TodoItem.objects.all()
    if not todos:
        return "No todo items found."
    return "\n".join(f"{t.id}: {t.title} [{t.status}]" for t in todos)


@cli.register(name="complete", description="Mark a todo item as completed")
@cli.argument("todo_id", type=int, help="Todo ID")
@cli.option("--complete")
@cli.option("-c")
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 = NOW()
    todo.save()
    return f"Completed todo ID {todo_id}."


@cli.register(name="update", description="Update a todo item")
@cli.argument("todo_id", type=int, help="Todo ID")
@cli.argument("title", type=str, default=None, help="New title")
@cli.argument("description", type=str, default=None, help="New description")
@cli.option("--update")
@cli.option("-u")
def update_todo(todo_id: int, title: str | None = None, description: str | None = None) -> str:
    todo = TodoItem.objects.get(id=todo_id)
    if not todo:
        return f"Todo item with ID {todo_id} not found."

    todo.title = title or ""
    todo.description = description or ""
    todo.updated_at = NOW()
    todo.save()
    return f"Updated todo ID {todo_id}."


if __name__ == "__main__":
    cli.run(
        shell_title="Todo Console",
        shell_description="Manage tasks.",
        shell_colors=None,
        shell_banner=True,
        shell_usage=True,  # Prints usage menu on startup
    )

Run it as follows:

# Add
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"

# List
python todo.py list
python todo.py --list
python todo.py -l

# Complete
python todo.py complete 1
python todo.py --complete 1
python todo.py -c 1

# Update
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 1 --title "Read two books"

Or:

# Run directly for interactive mode
python todo.py

Interactive mode:

Screenshot

functionals.fx in minutes (project-type init + health)

functionals.fx is the project tooling layer built on top of the CLI + DB modules. After local install (pip install -e .), you can run:

fx --help

Create a CLI-first project structure:

fx init cli TodoService
fx health TodoService

Expected structure:

pyproject.toml
README.md
src/todoservice/__main__.py
src/todoservice/todo.py
src/todoservice/plugins/__init__.py
tests/test_todo_cli.py
.functionals/fx.db

Create a DB-first project structure:

fx init db DataService
fx health DataService

Expected structure:

pyproject.toml
README.md
src/dataservice/__main__.py
src/dataservice/api.py
src/dataservice/models.py
src/dataservice/plugins/__init__.py
tests/test_user_api.py
.functionals/fx.db

Screenshot Screenshot

Notes:

  • fx init <project_name> still works and defaults to cli.
  • If root is omitted, fx init uses <project_name> as the project directory.
  • fx health is the canonical check command (--doctor is kept as a compatibility alias).

Additional FX commands:

# Show installed fx version
fx --version

# Run project entrypoint (auto-detected)
fx run TodoService

# Editable install (active env or project venv)
fx install TodoService
fx install TodoService --venv .venv --extras dev

# Update decorates package source
fx update TodoService                          # source=pypi
fx update TodoService --source git --repo https://github.com/nexustech101/functionals.git --ref main
fx update TodoService --source path --path ../framework

# Pull plugins safely from a git repository
fx pull https://github.com/example/plugins-repo.git TodoService --ref main --subdir plugins

fx worktree is currently spec-defined only and planned for a later release after the graph/tree data-structure layer is implemented.

Database + FastAPI in 5 minutes

from contextlib import asynccontextmanager
from fastapi import FastAPI, HTTPException
from pydantic import BaseModel
from functionals.db import (
    RecordNotFoundError,
    UniqueConstraintError,
    database_registry,
)

DB_URL = "sqlite:///shop.db"

# --- Models ---

@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

# --- App ---

@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)

# --- Routes ---

@app.post("/customers", response_model=Customer, status_code=201)
def create_customer(name: str, email: str):
    return Customer.objects.create(name=name, email=email)

@app.get("/customers/{customer_id}", response_model=Customer)
def get_customer(customer_id: int):
    return Customer.objects.require(customer_id)

@app.post("/products", response_model=Product, status_code=201)
def create_product(name: str, price: float):
    return Product.objects.create(name=name, price=price)

@app.post("/orders", response_model=Order, status_code=201)
def create_order(customer_id: int, product_id: int, quantity: int):
    product = Product.objects.require(product_id)
    return Order.objects.create(
        customer_id=customer_id,
        product_id=product_id,
        quantity=quantity,
        total=product.price * 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)
# POST /customers
curl -X POST "http://localhost:8000/customers" \
  -H "Content-Type: application/json" \
  -d '{"name": "Alice Johnson", "email": "alice@example.com"}'

# Response
{"id": 1, "name": "Alice Johnson", "email": "alice@example.com"}


# GET /customers/1
curl "http://localhost:8000/customers/1"

# Response
{"id": 1, "name": "Alice Johnson", "email": "alice@example.com"}


# POST /products
curl -X POST "http://localhost:8000/products" \
  -H "Content-Type: application/json" \
  -d '{"name": "Wireless Keyboard", "price": 49.99}'

# Response
{"id": 1, "name": "Wireless Keyboard", "price": 49.99}


# POST /orders
curl -X POST "http://localhost:8000/orders" \
  -H "Content-Type: application/json" \
  -d '{"customer_id": 1, "product_id": 1, "quantity": 2}'

# Response
{"id": 1, "customer_id": 1, "product_id": 1, "quantity": 2, "total": 99.98}


# GET /orders/asc  (oldest first)
curl "http://localhost:8000/orders/asc?limit=20&offset=0"

# Response
[
  {"id": 1, "customer_id": 1, "product_id": 1, "quantity": 2, "total": 99.98}
]


# GET /orders/desc  (newest first)
curl "http://localhost:8000/orders/desc?limit=20&offset=0"

# Response
[
  {"id": 1, "customer_id": 1, "product_id": 1, "quantity": 2, "total": 99.98}
]

Core Concepts

functionals.cli

  • Register functions with module-level decorators: @register, @argument, @option.
  • Run command handlers through the module registry via functionals.cli.run(). With no argv in an interactive terminal, run() enters REPL mode.
  • 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.
  • help <command> prints command purpose, invocation tokens, usage, and argument-level invocation forms.
  • Interactive mode can be entered explicitly with --interactive / -i or programmatically via functionals.cli.run_shell().
  • Interactive shell banners use pyfiglet automatically when installed, with a clean built-in ASCII fallback when it is not.
  • Interactive shell output supports terminal colors (auto-detected) and uses a dedicated in-shell help menu via help.
  • Interactive shell branding is configurable (shell_title, shell_description) for custom app consoles.
  • functionals.cli.run(...) accepts shell options too, so one entrypoint can serve both normal CLI args and interactive mode. Options include shell_prompt, shell_title, shell_description, shell_banner, shell_colors, shell_input_fn, and shell_usage.
  • Runtime wraps unexpected handler crashes as CommandExecutionError (with original exception chaining).
  • Operational logs use standard Python logging namespaces under functionals.cli.*.

functionals.db

  • Register BaseModel classes with @database_registry(...).
  • Access all persistence through Model.objects.
  • id: int | None = None gives 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 SchemaError for cleaner, predictable error handling.
  • Operational logs use standard Python logging namespaces under functionals.db.*.
  • DB exceptions provide structured metadata (exc.context, exc.to_dict()) for production diagnostics.

functionals.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/functionals/db/USAGE.md
  • FX guide: src/functionals/fx/USAGE.md
  • CLI source API: src/functionals/cli
  • DB source API: src/functionals/db

Requirements

  • Python 3.10+
  • pydantic>=2.0
  • sqlalchemy>=2.0

Testing

  • The default pytest suite includes SQLite coverage along with PostgreSQL/MySQL integration tests for rename-state behavior.
  • Run Docker Desktop, or another compatible Docker engine, before executing the backend integration suite so the services in docker-compose.test-db.yml can boot successfully.
  • The package is backed by a rigorous, production-focused test suite (170+ tests) covering unit behavior, edge cases, and multi-dialect integration scenarios.

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

decorates-5.0.0.tar.gz (63.2 kB view details)

Uploaded Source

Built Distribution

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

decorates-5.0.0-py3-none-any.whl (76.6 kB view details)

Uploaded Python 3

File details

Details for the file decorates-5.0.0.tar.gz.

File metadata

  • Download URL: decorates-5.0.0.tar.gz
  • Upload date:
  • Size: 63.2 kB
  • Tags: Source
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: twine/6.1.0 CPython/3.13.12

File hashes

Hashes for decorates-5.0.0.tar.gz
Algorithm Hash digest
SHA256 e1bae971bfcb74a14899ecf2831e5e22a01d1cf573030ed55b12c3d79b33e6cc
MD5 447b05c31e8562cd244b27b6cbbd0a6e
BLAKE2b-256 59177dfea89e494bd2d1ac770d6a8457bc582b1da4eb9d153a2c07e31abd6fd3

See more details on using hashes here.

Provenance

The following attestation bundles were made for decorates-5.0.0.tar.gz:

Publisher: publish.yml on nexustech101/functionals

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

File details

Details for the file decorates-5.0.0-py3-none-any.whl.

File metadata

  • Download URL: decorates-5.0.0-py3-none-any.whl
  • Upload date:
  • Size: 76.6 kB
  • Tags: Python 3
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: twine/6.1.0 CPython/3.13.12

File hashes

Hashes for decorates-5.0.0-py3-none-any.whl
Algorithm Hash digest
SHA256 2dfda6d1785baaac1d3adc814aaad7d3184a00c3675aa48e21d27debe1711fde
MD5 d7826be1b5331d2c8b6f00ce51d42d59
BLAKE2b-256 3b5a3eaa50be23e1a094e42f4ecc64c7b2005111a81b11c1d28247de05144822

See more details on using hashes here.

Provenance

The following attestation bundles were made for decorates-5.0.0-py3-none-any.whl:

Publisher: publish.yml on nexustech101/functionals

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

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