First commit.

This commit is contained in:
Yaro Kasear 2025-08-26 09:01:15 -05:00
commit a3e676a0b0
13 changed files with 346 additions and 0 deletions

12
.gitignore vendored Normal file
View file

@ -0,0 +1,12 @@
**/__pycache__/
inventory/static/uploads/*
!inventory/static/uploads/.gitkeep
.venv/
.env
*.db
*.db-journal
*.sqlite
*.sqlite3
alembic.ini
alembic/
*.egg-info/

8
crudkit/__init__.py Normal file
View file

@ -0,0 +1,8 @@
from .mixins import CrudMixin
from .dsl import QuerySpec
from .eager import default_eager_policy
from .service import CrudService
from .serialize import serialize
from .blueprint import make_blueprint
__all__ = ["CrudMixin", "QuerySpec", "default_eager_policy", "CrudService", "serialize", "make_blueprint"]

81
crudkit/blueprint.py Normal file
View file

@ -0,0 +1,81 @@
from flask import Blueprint, request, jsonify, abort
from sqlalchemy.orm import scoped_session
from .dsl import QuerySpec
from .service import CrudService
from .eager import default_eager_policy
from .serialize import serialize
def make_blueprint(db_session_factory, registry):
bp = Blueprint("crud", __name__)
def session(): return scoped_session(db_session_factory)()
@bp.get("/<model>/list")
def list_items(model):
Model = registry.get(model) or abort(404)
spec = QuerySpec(
filters=_parse_filters(request.args),
order_by=request.args.getlist("sort"),
page=request.args.get("page", type=int),
per_page=request.args.get("per_page", type=int),
expand=request.args.getlist("expand"),
fields=request.args.get("fields", type=lambda s: [x.strip() for x in s.split(",")] if s else None),
)
s = session(); svc = CrudService(s, default_eager_policy)
rows, total = svc.list(Model, spec)
data = [serialize(r, fields=spec.fields, expand=spec.expand) for r in rows]
return jsonify({"data": data, "total": total})
@bp.post("/<model>")
def create_item(model):
Model = registry.get(model) or abort(404)
payload = request.get_json() or {}
s = session(); svc = CrudService(s, default_eager_policy)
obj = svc.create(Model, payload)
s.commit()
return jsonify(serialize(obj)), 201
@bp.get("/<model>/<int:id>")
def read_item(model, id):
Model = registry.get(model) or abort(404)
spec = QuerySpec(expand=request.args.getlist("expand"),
fields=request.args.get("fields", type=lambda s: s.split(",")))
s = session(); svc = CrudService(s, default_eager_policy)
obj = svc.get(Model, id, spec) or abort(404)
return jsonify(serialize(obj, fields=spec.fields, expand=spec.expand))
@bp.patch("/<model>/<int:id>")
def update_item(model, id):
Model = registry.get(model) or abort(404)
s = session(); svc = CrudService(s, default_eager_policy)
obj = svc.get(Model, id, QuerySpec()) or abort(404)
payload = request.get_json() or {}
svc.update(obj, payload)
s.commit()
return jsonify(serialize(obj))
@bp.delete("/<model>/<int:id>")
def delete_item(model, id):
Model = registry.get(model) or abort(404)
s = session(); svc = CrudService(s, default_eager_policy)
obj = svc.get(Model, id, QuerySpec()) or abort(404)
svc.soft_delete(obj)
s.commit()
return jsonify({"status": "deleted"})
@bp.post("/<model>/<int:id>/undelete")
def undelete_item(model, id):
Model = registry.get(model) or abort(404)
s = session(); svc = CrudService(s, default_eager_policy)
obj = svc.get(Model, id, QuerySpec()) or abort(404)
svc.undelete(obj)
s.commit()
return jsonify({"status": "restored"})
return bp
def _parse_filters(args):
out = {}
for k, v in args.items():
if k in {"page", "per_page", "sort", "expand", "fields"}:
continue
out[k] = v
return out

49
crudkit/dsl.py Normal file
View file

@ -0,0 +1,49 @@
from dataclasses import dataclass, field
from typing import List, Dict, Any, Optional
from sqlalchemy import asc, desc, select, func
from sqlalchemy.orm import selectinload, joinedload, Load
@dataclass
class QuerySpec:
filters: Dict[str, Any] = field(default_factory=dict)
order_by: List[str] = field(default_factory=list)
page: Optional[int] = None
per_page: Optional[int] = None
expand: List[str] = field(default_factory=list)
fields: Optional[List[str]] = None
FILTER_OPS = {
"__eq": lambda c, v: c == v,
"__ne": lambda c, v: c != v,
"__lt": lambda c, v: c < v,
"__lte": lambda c, v: c <= v,
"__gt": lambda c, v: c > v,
"__gte": lambda c, v: c >= v,
"__ilike": lambda c, v: c.ilike(v),
"__in": lambda c, v: c.in_(v),
"__isnull": lambda c, v: (c.is_(None) if v else c.is_not(None))
}
def build_query(Model, spec: QuerySpec, eager_policy=None):
stmt = select(Model).where(Model.deleted.is_(False))
# filters
for raw_key, val in spec.filters.items():
for op in FILTER_OPS:
if raw_key.endswith(op):
colname = raw_key[: -len(op)]
col = getattr(Model, colname)
stmt = stmt.where(FILTER_OPS[op](col, val))
break
else:
stmt = stmt.where(getattr(Model, raw_key) == val)
# order_by
for key in spec.order_by:
desc_ = key.startswith("-")
col = getattr(Model, key[1:] if desc_ else key)
stmt = stmt.order_by(desc(col) if desc_ else asc(col))
# eager loading
if eager_policy:
opts = eager_policy(Model, spec.expand)
if opts:
stmt = stmt.options(*opts)
return stmt

22
crudkit/eager.py Normal file
View file

@ -0,0 +1,22 @@
from typing import List
from sqlalchemy.orm import RelationshipProperty, selectinload, joinedload, Load, class_mapper
def default_eager_policy(Model, expand: List[str]) -> List[Load]:
"""
Heuristic:
- many-to-one or one-to-one: joinedload
- one-to-many or many-to-many: selectinload
Only for fields explicitly requested in ?expand=
"""
opts = []
mapper = class_mapper(Model)
rels = {r.key: r for r in mapper.relationships}
for name in expand:
rel: RelationshipProperty = rels.get(name)
if not rel:
continue
if rel.uselist:
opts.append(selectinload(name))
else:
opts.append(joinedload(name))
return opts

23
crudkit/mixins.py Normal file
View file

@ -0,0 +1,23 @@
import datetime as dt
from sqlalchemy import Column, Integer, DateTime, Boolean
from sqlalchemy.orm import declared_attr
from sqlalchemy.ext.hybrid import hybrid_property
class CrudMixin:
id = Column(Integer, primary_key=True)
created_at = Column(DateTime, default=dt.datetime.utcnow, nullable=False)
updated_at = Column(DateTime, default=dt.datetime.utcnow, onupdate=dt.datetime.utcnow, nullable=False)
deleted = Column("deleted", Boolean, default=False, nullable=False)
version = Column(Integer, default=1, nullable=False)
@hybrid_property
def is_deleted(self):
return self.deleted
def mark_deleted(self):
self.deleted = True
self.version += 1
@declared_attr
def __mapper_args__(cls):
return {"version_id_col": cls.version}

22
crudkit/serialize.py Normal file
View file

@ -0,0 +1,22 @@
def serialize(obj, *, fields=None, expand=None):
expand = set(expand or [])
fields = set(fields or [])
out = {}
# base columns
for col in obj.__table__.columns:
name = col.key
if fields and name not in fields:
continue
out[name] = getattr(obj, name)
# expansions
for rel in obj.__mapper__.relationships:
if rel.key not in expand:
continue
val = getattr(obj, rel.key)
if val is None:
out[rel.key] = None
elif rel.uselist:
out[rel.key] = [serialize(child) for child in val]
else:
out[rel.key] = serialize(val)
return out

52
crudkit/service.py Normal file
View file

@ -0,0 +1,52 @@
from sqlalchemy import func
from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import Session
from .dsl import QuerySpec, build_query
from .eager import default_eager_policy
class CrudService:
def __init__(self, session: Session, eager_policy=default_eager_policy):
self.s = session
self.eager_policy = eager_policy
def create(self, Model, data, *, before=None, after=None):
if before: data = before(data) or data
obj = Model(**data)
self.s.add(obj)
self.s.flush()
if after: after(obj)
return obj
def get(self, Model, id, spec: QuerySpec | None = None):
spec = spec or QuerySpec()
stmt = build_query(Model, spec, self.eager_policy).where(Model.id == id)
return self.s.execute(stmt).scalars().first()
def list(self, Model, spec: QuerySpec):
stmt = build_query(Model, spec, self.eager_policy)
count_stmt = stmt.with_only_columns(func.count()).order_by(None)
total = self.s.execute(count_stmt).scalar_one()
if spec.page and spec.per_page:
stmt = stmt.limit(spec.per_page).offset((spec.page - 1) * spec.per_page)
rows = self.s.execute(stmt).scalars().all()
return rows, total
def update(self, obj, data, *, before=None, after=None):
if obj.is_deleted: raise ValueError("Cannot update a deleted record")
if before: data = before(obj, data) or data
for k, v in data.items(): setattr(obj, k, v)
obj.version += 1
if after: after(obj)
return obj
def soft_delete(self, obj, *, cascade=False, guard=None):
if guard and not guard(obj): raise ValueError("Delete blocked by guard")
# optionsl FK hygiene checks go here
obj.mark_deleted()
return obj
def undelete(self, obj):
obj.deleted = False
obj.version += 1
return obj

22
example_app/app.py Normal file
View file

@ -0,0 +1,22 @@
from flask import Flask
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
from .models import Base, Author, Book
from crudkit import make_blueprint
engine = create_engine("sqlite:///example.db", echo=True, future=True)
SessionLocal = sessionmaker(bind=engine, expire_on_commit=False)
def session_factory():
return SessionLocal()
registry = {"author": Author, "book": Book}
def create_app():
app = Flask(__name__)
Base.metadata.create_all(engine)
app.register_blueprint(make_blueprint(session_factory, registry), url_prefix="/api")
return app
if __name__ == "__main__":
create_app().run(debug=True)

18
example_app/models.py Normal file
View file

@ -0,0 +1,18 @@
from typing import List
from sqlalchemy import String, ForeignKey
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, relationship
from crudkit import CrudMixin
class Base(DeclarativeBase):
pass
class Author(CrudMixin, Base):
__tablename__ = "author"
name: Mapped[str] = mapped_column(String(200), nullable=False)
books: Mapped[List["Book"]] = relationship(back_populates="author", cascade="all, delete-orphan")
class Book(CrudMixin, Base):
__tablename__ = "book"
title: Mapped[str] = mapped_column(String(200), nullable=False)
author_id: Mapped[int] = mapped_column(ForeignKey("author.id"), nullable=False)
author: Mapped[Author] = relationship(back_populates="books")

19
example_app/seed.py Normal file
View file

@ -0,0 +1,19 @@
from .app import SessionLocal, engine
from .models import Base, Author, Book
def run():
Base.metadata.create_all(engine)
s = SessionLocal()
a1 = Author(name="Ursula K. Le Guin")
a2 = Author(name="Octavia E. Butler")
s.add_all([
a1, a2,
Book(title="The Left Hand of Darkness", author=a1),
Book(title="A Wizard of Earthsea", author=a1),
Book(title="Parable of the Sower", author=a2),
])
s.commit()
s.close()
if __name__ == "__main__":
run()

18
pyproject.toml Normal file
View file

@ -0,0 +1,18 @@
[project]
name = "crudkit"
version = "0.1.0"
description = "A Flask API for better SQLAlchemy usage."
requires-python = ">=3.9"
dependencies = [
"flask",
"flask_sqlalchemy",
"python-dotenv",
"Werkzeug"
]
[build-system]
requires = ["setuptools"]
build-backend = "setuptools.build_meta"
[tool.setuptools]
packages = ["crudkit"]

0
readme.md Normal file
View file