diff --git a/pyproject.toml b/pyproject.toml index 505490d..7d2b426 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -47,12 +47,17 @@ lint.ignore = [ "T201", # (perm) I don't care about print statements dir "ERA001", # (perm) I don't care about print statements dir ] - "python/splendor/**" = [ "S311", # (perm) there is no security issue here "T201", # (perm) I don't care about print statements dir "PLR2004", # (temps) need to think about this ] +"python/orm/**" = [ + "TC003", # (perm) this creates issues because sqlalchemy uses these at runtime +] +"python/alembic/**" = [ + "INP001", # (perm) this creates LSP issues for alembic +] [tool.ruff.lint.pydocstyle] convention = "google" diff --git a/python/alembic.ini b/python/alembic.ini new file mode 100644 index 0000000..e835cde --- /dev/null +++ b/python/alembic.ini @@ -0,0 +1,109 @@ +# A generic, single database configuration. + +[alembic] +# path to migration scripts +# Use forward slashes (/) also on windows to provide an os agnostic path +script_location = python/alembic + +# template used to generate migration file names; The default value is %%(rev)s_%%(slug)s +# Uncomment the line below if you want the files to be prepended with date and time +# see https://alembic.sqlalchemy.org/en/latest/tutorial.html#editing-the-ini-file +# for all available tokens +file_template = %%(year)d_%%(month).2d_%%(day).2d-%%(slug)s_%%(rev)s + +# sys.path path, will be prepended to sys.path if present. +# defaults to the current working directory. +prepend_sys_path = . + +# timezone to use when rendering the date within the migration file +# as well as the filename. +# If specified, requires the python>=3.9 or backports.zoneinfo library and tzdata library. +# Any required deps can installed by adding `alembic[tz]` to the pip requirements +# string value is passed to ZoneInfo() +# leave blank for localtime +# timezone = + +# max length of characters to apply to the "slug" field +# truncate_slug_length = 40 + +# set to 'true' to run the environment during +# the 'revision' command, regardless of autogenerate +# revision_environment = false + +# set to 'true' to allow .pyc and .pyo files without +# a source .py file to be detected as revisions in the +# versions/ directory +# sourceless = false + +# version location specification; This defaults +# to alembic/versions. When using multiple version +# directories, initial revisions must be specified with --version-path. +# The path separator used here should be the separator specified by "version_path_separator" below. +# version_locations = %(here)s/bar:%(here)s/bat:alembic/versions + +# version path separator; As mentioned above, this is the character used to split +# version_locations. The default within new alembic.ini files is "os", which uses os.pathsep. +# If this key is omitted entirely, it falls back to the legacy behavior of splitting on spaces and/or commas. +# Valid values for version_path_separator are: +# +# version_path_separator = : +# version_path_separator = ; +# version_path_separator = space +# version_path_separator = newline +# +# Use os.pathsep. Default configuration used for new projects. +version_path_separator = os + +# set to 'true' to search source files recursively +# in each "version_locations" directory +# new in Alembic version 1.10 +# recursive_version_locations = false + +# the output encoding used when revision files +# are written from script.py.mako +# output_encoding = utf-8 + +sqlalchemy.url = driver://user:pass@localhost/dbname + +revision_environment = true + +[post_write_hooks] + +hooks = dynamic_schema,ruff +dynamic_schema.type = dynamic_schema + +ruff.type = ruff + +[loggers] +keys = root,sqlalchemy,alembic + +[handlers] +keys = console + +[formatters] +keys = generic + +[logger_root] +level = WARNING +handlers = console +qualname = + +[logger_sqlalchemy] +level = WARNING +handlers = +qualname = sqlalchemy.engine + +[logger_alembic] +level = INFO +handlers = +qualname = alembic + +[handler_console] +class = StreamHandler +args = (sys.stderr,) +level = NOTSET +formatter = generic + +[formatter_generic] +format = %(levelname)-5.5s [%(name)s] %(message)s +datefmt = %H:%M:%S diff --git a/python/alembic/env.py b/python/alembic/env.py new file mode 100644 index 0000000..bc2502d --- /dev/null +++ b/python/alembic/env.py @@ -0,0 +1,93 @@ +"""Alembic.""" + +from __future__ import annotations + +import logging +import sys +from pathlib import Path +from typing import TYPE_CHECKING, Any, Literal + +from alembic import context +from alembic.script import write_hooks + +from python.common import bash_wrapper +from python.orm import RichieBase +from python.orm.base import get_postgres_engine + +if TYPE_CHECKING: + from collections.abc import MutableMapping + +# this is the Alembic Config object, which provides +# access to the values within the .ini file in use. +config = context.config + + +target_metadata = RichieBase.metadata +logging.basicConfig( + level="DEBUG", + datefmt="%Y-%m-%dT%H:%M:%S%z", + format="%(asctime)s %(levelname)s %(filename)s:%(lineno)d - %(message)s", + handlers=[logging.StreamHandler(sys.stdout)], +) + + +@write_hooks.register("dynamic_schema") +def dynamic_schema(filename: str, _options: dict[Any, Any]) -> None: + """Dynamic schema.""" + original_file = Path(filename).read_text() + dynamic_schema_file_part1 = original_file.replace(f"schema='{RichieBase.schema_name}'", "schema=schema") + dynamic_schema_file = dynamic_schema_file_part1.replace(f"'{RichieBase.schema_name}.", "f'{schema}.") + Path(filename).write_text(dynamic_schema_file) + + +@write_hooks.register("ruff") +def ruff_check_and_format(filename: str, _options: dict[Any, Any]) -> None: + """Docstring for ruff_check_and_format.""" + bash_wrapper(f"ruff check --fix {filename}") + bash_wrapper(f"ruff format {filename}") + + +def include_name( + name: str | None, + type_: Literal["schema", "table", "column", "index", "unique_constraint", "foreign_key_constraint"], + _parent_names: MutableMapping[Literal["schema_name", "table_name", "schema_qualified_table_name"], str | None], +) -> bool: + """This filter table to be included in the migration. + + Args: + name (str): The name of the table. + type_ (str): The type of the table. + parent_names (list[str]): The names of the parent tables. + + Returns: + bool: True if the table should be included, False otherwise. + + """ + if type_ == "schema": + return name == target_metadata.schema + return True + + +def run_migrations_online() -> None: + """Run migrations in 'online' mode. + + In this scenario we need to create an Engine + and associate a connection with the context. + + """ + connectable = get_postgres_engine() + + with connectable.connect() as connection: + context.configure( + connection=connection, + target_metadata=target_metadata, + include_schemas=True, + version_table_schema=RichieBase.schema_name, + include_name=include_name, + ) + + with context.begin_transaction(): + context.run_migrations() + + +run_migrations_online() diff --git a/python/alembic/script.py.mako b/python/alembic/script.py.mako new file mode 100644 index 0000000..6d49669 --- /dev/null +++ b/python/alembic/script.py.mako @@ -0,0 +1,36 @@ +"""${message}. + +Revision ID: ${up_revision} +Revises: ${down_revision | comma,n} +Create Date: ${create_date} + +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +import sqlalchemy as sa + +from alembic import op +from python.orm import RichieBase + +if TYPE_CHECKING: + from collections.abc import Sequence + +# revision identifiers, used by Alembic. +revision: str = ${repr(up_revision)} +down_revision: str | None = ${repr(down_revision)} +branch_labels: str | Sequence[str] | None = ${repr(branch_labels)} +depends_on: str | Sequence[str] | None = ${repr(depends_on)} + +schema=RichieBase.schema_name + +def upgrade() -> None: + """Upgrade.""" + ${upgrades if upgrades else "pass"} + + +def downgrade() -> None: + """Downgrade.""" + ${downgrades if downgrades else "pass"} diff --git a/python/alembic/versions/2026_01_10-base_7bd2bdc231dc.py b/python/alembic/versions/2026_01_10-base_7bd2bdc231dc.py new file mode 100644 index 0000000..2d3bac3 --- /dev/null +++ b/python/alembic/versions/2026_01_10-base_7bd2bdc231dc.py @@ -0,0 +1,49 @@ +"""base. + +Revision ID: 7bd2bdc231dc +Revises: +Create Date: 2026-01-10 13:12:13.764467 + +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +import sqlalchemy as sa +from alembic import op + +from python.orm import RichieBase + +if TYPE_CHECKING: + from collections.abc import Sequence + +# revision identifiers, used by Alembic. +revision: str = "7bd2bdc231dc" +down_revision: str | None = None +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + +schema = RichieBase.schema_name + + +def upgrade() -> None: + """Upgrade.""" + # ### commands auto generated by Alembic - please adjust! ### + op.create_table( + "temp", + sa.Column("name", sa.String(length=255), nullable=False), + sa.Column("id", sa.Integer(), nullable=False), + sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False), + sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False), + sa.PrimaryKeyConstraint("id", name=op.f("pk_temp")), + schema=schema, + ) + # ### end Alembic commands ### + + +def downgrade() -> None: + """Downgrade.""" + # ### commands auto generated by Alembic - please adjust! ### + op.drop_table("temp", schema=schema) + # ### end Alembic commands ### diff --git a/python/orm/__init__.py b/python/orm/__init__.py new file mode 100644 index 0000000..053f98b --- /dev/null +++ b/python/orm/__init__.py @@ -0,0 +1,8 @@ +"""ORM package exports.""" + +from __future__ import annotations + +from python.orm.base import RichieBase, TableBase +from python.orm.temp import Temp + +__all__ = ["RichieBase", "TableBase", "Temp"] diff --git a/python/orm/base.py b/python/orm/base.py new file mode 100644 index 0000000..0f49a57 --- /dev/null +++ b/python/orm/base.py @@ -0,0 +1,86 @@ +"""Base ORM definitions.""" + +from __future__ import annotations + +from datetime import datetime +from os import getenv + +from sqlalchemy import DateTime, MetaData, create_engine, func +from sqlalchemy.engine import URL, Engine +from sqlalchemy.ext.declarative import AbstractConcreteBase +from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column + + +class RichieBase(DeclarativeBase): + """Base class for all ORM models.""" + + schema_name = "main" + + metadata = MetaData( + schema=schema_name, + naming_convention={ + "ix": "ix_%(table_name)s_%(column_0_name)s", + "uq": "uq_%(table_name)s_%(column_0_name)s", + "ck": "ck_%(table_name)s_%(constraint_name)s", + "fk": "fk_%(table_name)s_%(column_0_name)s_%(referred_table_name)s", + "pk": "pk_%(table_name)s", + }, + ) + + +class TableBase(AbstractConcreteBase, RichieBase): + """Abstract concrete base for tables with IDs and timestamps.""" + + __abstract__ = True + + id: Mapped[int] = mapped_column(primary_key=True) + created_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), + server_default=func.now(), + ) + updated_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), + server_default=func.now(), + onupdate=func.now(), + ) + + +def get_connection_info() -> tuple[str, str, str, str, str | None]: + """Get connection info from environment variables.""" + database = getenv("POSTGRES_DB") + host = getenv("POSTGRES_HOST") + port = getenv("POSTGRES_PORT") + username = getenv("POSTGRES_USER") + password = getenv("POSTGRES_PASSWORD") + + if None in (database, host, port, username): + error = ( + "Missing environment variables for Postgres connection.\n" + f"{database=}\n" + f"{host=}\n" + f"{port=}\n" + f"{username=}\n" + f"password{'***' if password else None}\n" + ) + raise ValueError(error) + return database, host, port, username, password + + +def get_postgres_engine(*, pool_pre_ping: bool = True) -> Engine: + """Create a SQLAlchemy engine from environment variables.""" + database, host, port, username, password = get_connection_info() + + url = URL.create( + drivername="postgresql+psycopg", + username=username, + password=password, + host=host, + port=int(port), + database=database, + ) + + return create_engine( + url=url, + pool_pre_ping=pool_pre_ping, + pool_recycle=1800, + ) diff --git a/python/orm/temp.py b/python/orm/temp.py new file mode 100644 index 0000000..9db674e --- /dev/null +++ b/python/orm/temp.py @@ -0,0 +1,16 @@ +"""Temporary ORM model.""" + +from __future__ import annotations + +from sqlalchemy import String +from sqlalchemy.orm import Mapped, mapped_column + +from python.orm.base import TableBase + + +class Temp(TableBase): + """Temporary table for initial testing.""" + + __tablename__ = "temp" + + name: Mapped[str] = mapped_column(String(255), nullable=False) diff --git a/users/richie/home/gui/vscode/settings.json b/users/richie/home/gui/vscode/settings.json index 19d49fe..4348629 100644 --- a/users/richie/home/gui/vscode/settings.json +++ b/users/richie/home/gui/vscode/settings.json @@ -73,7 +73,13 @@ "cSpell.enabled": true, "cSpell.language": "en,en-US", "cSpell.enableFiletypes": ["bat", "csv", "nix", "toml"], - "cSpell.userWords": ["Cahill", "Corvidae", "syncthing"], + "cSpell.userWords": [ + "Cahill", + "Corvidae", + "drivername", + "fastapi", + "syncthing" + ], // nix "nix.enableLanguageServer": true,