From d458672c4b5832e984ae9d933d4dcae4caf2b143 Mon Sep 17 00:00:00 2001 From: Yaro Kasear Date: Mon, 8 Sep 2025 11:45:57 -0500 Subject: [PATCH] Config feature added. --- crudkit/__init__.py | 7 + crudkit/_sqlite.py | 14 ++ crudkit/config.py | 230 ++++++++++++++++++++++++++++++++ crudkit/engines.py | 43 ++++++ crudkit/integrations/fastapi.py | 24 ++++ crudkit/integrations/flask.py | 20 +++ 6 files changed, 338 insertions(+) create mode 100644 crudkit/_sqlite.py create mode 100644 crudkit/config.py create mode 100644 crudkit/engines.py create mode 100644 crudkit/integrations/fastapi.py create mode 100644 crudkit/integrations/flask.py diff --git a/crudkit/__init__.py b/crudkit/__init__.py index e69de29..c654971 100644 --- a/crudkit/__init__.py +++ b/crudkit/__init__.py @@ -0,0 +1,7 @@ +from .config import Config, DevConfig, TestConfig, ProdConfig, get_config, build_database_url +from .engines import CRUDKitRuntime, build_engine, build_sessionmaker + +__all__ = [ + "Config", "DevConfig", "TestConfig", "ProdConfig", "get_config", "build_database_url", + "CRUDKitRuntime", "build_engine", "build_sessionmaker" +] diff --git a/crudkit/_sqlite.py b/crudkit/_sqlite.py new file mode 100644 index 0000000..159156f --- /dev/null +++ b/crudkit/_sqlite.py @@ -0,0 +1,14 @@ +from __future__ import annotations +from sqlalchemy import event +from sqlalchemy.engine import Engine + +def apply_sqlite_pragmas(engine: Engine, pragmas: dict[str, str]) -> None: + if not str(engine.url).startswith("sqlite://"): + return + + @event.listens_for(engine, "connect") + def set_sqlite_pragma(dbapi_connection, connection_record): + cursor = dbapi_connection.cursor() + for key, value in pragmas.items(): + cursor.execute(f"PRAGMA {key}={value}") + cursor.close() diff --git a/crudkit/config.py b/crudkit/config.py new file mode 100644 index 0000000..5ffaa9e --- /dev/null +++ b/crudkit/config.py @@ -0,0 +1,230 @@ +from __future__ import annotations +import os +from typing import Dict, Any, Optional, Type +from urllib.parse import quote_plus +from pathlib import Path + +try: + from dotenv import load_dotenv +except Exception: + load_dotenv = None + +def _load_dotenv_if_present() -> None: + """ + Load .env once if present. Priority rules: + 1) CRUDKIT_DOTENV points to a file + 2) Project root's .env (two dirs up from this file) + 3) Current working directory .env + + Env already present in the process takes precedence. + """ + if load_dotenv is None: + return + + path_hint = os.getenv("CRUDKIT_DOTENV") + if path_hint: + p = Path(path_hint).resolve() + if p.exists(): + load_dotenv(dotenv_path=p, override=False) + return + + repo_env = Path(__file__).resolve().parents[2] / ".env" + if repo_env.exists(): + load_dotenv(dotenv_path=repo_env, override=False) + return + + cwd_env = Path.cwd() / ".env" + if cwd_env.exists(): + load_dotenv(dotenv_path=cwd_env, override=False) + +def build_database_url( + *, + backend: Optional[str] = None, + url: Optional[str] = None, + user: Optional[str] = None, + password: Optional[str] = None, + host: Optional[str] = None, + port: Optional[str] = None, + database: Optional[str] = None, + driver: Optional[str] = None, + dsn: Optional[str] = None, + trusted: Optional[bool] = None, + options: Optional[Dict[str, str]] = None, +) -> str: + """ + Build a SQLAlchemy URL string. If "url" is provided, it wins. + Supported: sqlite, postgresql, mysql, mssql (pyodbc) + """ + + if url: + return url + + backend = (backend or "").lower().strip() + optional = options or {} + + if backend == "sqlite": + db_path = database or "app.db" + if db_path == ":memory:": + return "sqlite:///:memory:" + return f"sqlite:///{db_path}" + + if backend in {"postgres", "postgresql"}: + driver = driver or "psycopg" + user = user or "" + password = password or "" + creds = f"{quote_plus(user)}:{quote_plus(password)}@" if user or password else "" + host = host or "localhost" + port = port or "5432" + database = database or "app" + qs = "" + if options: + qs = "?" + "&".join(f"{k}={quote_plus(v)}" for k, v in options.items()) + return f"postgresel+{driver}://{creds}{host}:{port}/{database}{qs}" + + if backend == "mysql": + driver = driver or "pymysql" + user = user or "" + password = password or "" + creds = f"{quote_plus(user)}:{quote_plus(password)}@" if user or password else "" + host = host or "localhost" + port = port or "3306" + database = database or "app" + qs = "" + if options: + qs = "?" + "&".join(f"{k}={quote_plus(v)}" for k, v in options.items()) + return f"mysql+{driver}://{creds}{host}:{port}/{database}{qs}" + + if backend in {"mssql", "sqlserver", "sqlsrv"}: + if dsn: + qs = "" + if options: + qs = "?" + "&".join(f"{k}={quote_plus(v)}" for k, v in options.items()) + return f"mssql+pyodbc://@{quote_plus(dsn)}{qs}" + + driver = driver or "ODBC Driver 18 for SQL Server" + host = host or "localhost" + port = port or "1433" + database = database or "app" + + if trusted: + base_opts = { + "driver": driver, + "Trusted_Connection": "yes", + "Encrypt": "yes", + "TrustServerCertificate": "yes", + } + base_opts.update(options) + qs = "?" + "&".join(f"{k}={quote_plus(v)}" for k, v in base_opts.items()) + return f"mssql+pyodbc://{host}:{port}/{database}{qs}" + + user = user or "" + password = password or "" + creds = f"{quote_plus(user)}:{quote_plus(password)}@" if user or password else "" + base_opts = { + "driver": driver, + "Encrypt": "yes", + "TrustServerCertificate": "yes", + } + base_opts.update(options) + qs = "?" + "&".join(f"{k}={quote_plus(v)}" for k, v in base_opts.items()) + return f"mssql+pyodbc://{creds}{host}:{port}/{database}{qs}" + + raise ValueError(f"Unsupported backend: {backend!r}") + +class Config: + """ + CRUDKit config: environment-first with sane defaults. + Designed to be subclassed by apps, but fine as-is. + """ + + _dotenv_loaded = False + + DEBUG = False + TESTING = False + SECRET_KEY = os.getenv("SECRET_KEY", "dev-not-secret") + + if not _dotenv_loaded: + _load_dotenv_if_present() + _dotenv_loaded = True + + DATABASE_URL = build_database_url( + url=os.getenv("DATABASE_URL"), + backend=os.getenv("DB_BACKEND"), + user=os.getenv("DB_USER"), + password=os.getenv("DB_PASS"), + host=os.getenv("DB_HOST"), + port=os.getenv("DB_PORT"), + database=os.getenv("DB_NAME"), + driver=os.getenv("DB_DRIVER"), + dsn=os.getenv("DB_DSN"), + trusted=bool(int(os.getenv("DB_TRUSTED", "0"))), + options=None, + ) + + SQLALCHEMY_ECHO = bool(int(os.getenv("DB_ECHO", "0"))) + POOL_SIZE = int(os.getenv("DB_POOL_SIZE", "5")) + MAX_OVERFLOW = int(os.getenv("DB_MAX_OVERFLOW", "10")) + POOL_TIMEOUT = int(os.getenv("DB_POOL_TIMEOUT", "30")) + POOL_RECYCLE = int(os.getenv("DB_POOL_RECYCLE", "1000")) + POOL_PRE_PING = True + + SQLITE_PRAGMAS = { + "journal_mode": os.getenv("SQLITE_JOURNAL_MODE", "WAL"), + "foreign_keys": os.getenv("SQLITE_FOREIGN_KEYS", "ON"), + "synchronous": os.getenv("SQLITE_SYNCHRONOUS": "NORMAL"), + } + + @classmethod + def engine_kwargs(cls) -> Dict[str, Any]: + url = cls.DATABASE_URL + kwargs: Dict[str, Any] = { + "echo": cls.SQLALCHEMY_ECHO, + "pool_pre_ping": cls.POOL_PRE_PING, + "future": True, + } + + if url.startswith("sqlite://"): + kwargs["connect_args"] = {"check_same_thread": False} + + kwargs.update( + { + "pool_size": cls.POOL_SIZE, + "max_overflow": cls.MAX_OVERFLOW, + "pool_timeout": cls.POOL_TIMEOUT, + "pool_recycle": cls.POOL_RECYCLE, + } + ) + return kwargs + + @classmethod + def session_kwargs(cls) -> Dict[str, Any]: + return { + "autoflush": False, + "autocommit": False, + "expire_on_commit": False, + "future": True, + } + +class DevConfig(Config): + DEBUG = True + SQLALCHEMY_ECHO = bool(int(os.getenv("DB_ECHO", "1"))) + +class TestConfig(Config): + TESTING = True + DATABASE_URL = build_database_url(backend="sqlite", database=":memory:") + SQLALCHEMY_ECHO = False + +class ProdConfig(Config): + DEBUG = False + SQLALCHEMY_ECHO = bool(int(os.getenv("DB_ECHO", "0"))) + +def get_config(name: str | None) -> Type[Config]: + """ + Resolve config by name. None -> environment variable CRUDKIT_END or 'dev'. + """ + env = (name or os.getenv("CRUDKIT_ENV") or "dev").lower() + if env in {"prod", "production"}: + return ProdConfig + if env in {"test", "testing", "ci"}: + return TestConfig + return DevConfig diff --git a/crudkit/engines.py b/crudkit/engines.py new file mode 100644 index 0000000..25984bd --- /dev/null +++ b/crudkit/engines.py @@ -0,0 +1,43 @@ +from __future__ import annotations +from typing import Type, Optional +from sqlalchemy import create_engine +from sqlalchemy.orm import sessionmaker +from .config import Config, get_config +from ._sqlite import apply_sqlite_pragmas + +def build_engine(config_cls: Type[Config] | None = None): + config_cls = config_cls or get_config(None) + engine = create_engine(config_cls.DATABASE_URL, **config_cls.engine_kwargs()) + apply_sqlite_pragmas(engine, config_cls.SQLITE_PRAGMAS) + return engine + +def build_sessionmaker(config_cls: Type[Config] | None = None, engine=None): + config_cls = config_cls or get_config(None) + engine = engine or build_engine(config_cls) + return sessionmaker(bind=engine, **config_cls.session_kwargs()) + +class CRUDKitRuntime: + """ + Lightweight container so CRUDKit can be given either: + - prebuild engine/sessionmaker, or + - a Config to build them lazily + """ + def __init__(self, *, engine=None, session_factory=None, config: Optional[Type[Config]] = None): + if engine is None and session_factory is None and config is None: + config = get_config(None) + self._config = config + self._engine = engine or (build_engine(config) if config else None) + self._session_factory = session_factory or (build_sessionmaker(config, self._engine) if config else None) + + @property + def engine(self): + if self._engine is None and self._config: + self._engine = build_engine(self._config) + return self._engine + + @property + def session_factory(self): + if self._session_factory is None: + if self._config and self._engine: + self._session_factory = build_sessionmaker(self._config, self._engine) + return self._session_factory diff --git a/crudkit/integrations/fastapi.py b/crudkit/integrations/fastapi.py new file mode 100644 index 0000000..c62f940 --- /dev/null +++ b/crudkit/integrations/fastapi.py @@ -0,0 +1,24 @@ +from __future__ import annotations +from contextlib import contextmanager +from fastapi import Depends +from sqlalchemy.orm import Session +from ..engines import CRUDKitRuntime + +_runtime = CRUDKitRuntime() + +@contextmanager +def _session_scope(): + SessionLocal = _runtime.session_factory + session: Session = SessionLocal() + try: + yield session + session.commit() + except Exception: + session.rollback() + raise + finally: + session.close() + +def get_db(): + with _session_scope() as s: + yield s diff --git a/crudkit/integrations/flask.py b/crudkit/integrations/flask.py new file mode 100644 index 0000000..3cc319b --- /dev/null +++ b/crudkit/integrations/flask.py @@ -0,0 +1,20 @@ +from __future__ import annotations +from flask import Flask +from sqlalchemy.orm import scoped_session +from ..engines import CRUDKitRuntime +from ..config import Config + +def init_app(app: Flask, *, runtime: CRUDKitRuntime | None = None, config: type[Config] | None == None): + """ + Initializes CRUDKit for a Flask app. Provies `app.extensions['crudkit']` + with a runtime (engine + session_factory). Caller manages session lifecycle. + """ + runtime = runtime or CRUDKitRuntime(config=config) + app.extensions.setdefault("crudkit", {}) + app.extensions["crudkit"]["runtime"] = runtime + + Session = runtime.session_factory + if Session is not None: + app.extensions["crudkit"]["Session"] = scoped_session(Session) + + return runtime