From 375d0be638cb3c599567592a46141f4c201cb8fe Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Thu, 11 Jul 2024 12:55:55 -0500 Subject: [PATCH] feat: add basic data model support wuttjamaican will provide a default data model with minimal tables; it's assumed each custom app will extend this model with other tables --- docs/api/wuttjamaican/db.model.base.rst | 6 ++ docs/api/wuttjamaican/db.model.rst | 6 ++ docs/api/wuttjamaican/index.rst | 2 + docs/glossary.rst | 12 ++++ pyproject.toml | 2 +- src/wuttjamaican/_version.py | 5 +- src/wuttjamaican/app.py | 67 ++++++++++++++++++- src/wuttjamaican/conf.py | 1 + src/wuttjamaican/db/__init__.py | 4 +- src/wuttjamaican/db/alembic/env.py | 67 +++++++++++++++++++ src/wuttjamaican/db/alembic/script.py.mako | 26 +++++++ .../fc3a3bcaa069_init_with_settings_table.py | 34 ++++++++++ src/wuttjamaican/db/model/__init__.py | 34 ++++++++++ src/wuttjamaican/db/model/base.py | 65 ++++++++++++++++++ src/wuttjamaican/util.py | 10 +++ tests/db/model/__init__.py | 0 tests/db/model/test_base.py | 25 +++++++ tests/test_app.py | 24 +++++++ tests/test_util.py | 11 +-- 19 files changed, 388 insertions(+), 13 deletions(-) create mode 100644 docs/api/wuttjamaican/db.model.base.rst create mode 100644 docs/api/wuttjamaican/db.model.rst create mode 100644 src/wuttjamaican/db/alembic/env.py create mode 100644 src/wuttjamaican/db/alembic/script.py.mako create mode 100644 src/wuttjamaican/db/alembic/versions/fc3a3bcaa069_init_with_settings_table.py create mode 100644 src/wuttjamaican/db/model/__init__.py create mode 100644 src/wuttjamaican/db/model/base.py create mode 100644 tests/db/model/__init__.py create mode 100644 tests/db/model/test_base.py diff --git a/docs/api/wuttjamaican/db.model.base.rst b/docs/api/wuttjamaican/db.model.base.rst new file mode 100644 index 0000000..9d10651 --- /dev/null +++ b/docs/api/wuttjamaican/db.model.base.rst @@ -0,0 +1,6 @@ + +``wuttjamaican.db.model.base`` +============================== + +.. automodule:: wuttjamaican.db.model.base + :members: diff --git a/docs/api/wuttjamaican/db.model.rst b/docs/api/wuttjamaican/db.model.rst new file mode 100644 index 0000000..e9809be --- /dev/null +++ b/docs/api/wuttjamaican/db.model.rst @@ -0,0 +1,6 @@ + +``wuttjamaican.db.model`` +========================= + +.. automodule:: wuttjamaican.db.model + :members: diff --git a/docs/api/wuttjamaican/index.rst b/docs/api/wuttjamaican/index.rst index 753b81f..452a183 100644 --- a/docs/api/wuttjamaican/index.rst +++ b/docs/api/wuttjamaican/index.rst @@ -11,6 +11,8 @@ conf db db.conf + db.model + db.model.base db.sess exc testing diff --git a/docs/glossary.rst b/docs/glossary.rst index 0237131..6845af9 100644 --- a/docs/glossary.rst +++ b/docs/glossary.rst @@ -30,6 +30,10 @@ Glossary :term:`app`. There is normally just one "global" app handler; see also :doc:`narr/handlers/app`. + app model + Python module whose namespace contains all the :term:`data + models` used by the :term:`app`. + app name Code-friendly name for the underlying app/config system (e.g. ``wutta_poser``). @@ -62,6 +66,11 @@ Glossary :term:`config object`, :term:`config setting`. See also :doc:`narr/config/index`. + config extension + A registered extension for the :term:`config object`. What + happens is, a config object is created and then extended by each + of the registered config extensions. + config file A file which contains :term:`config settings`. See also :doc:`narr/config/files`. @@ -79,6 +88,9 @@ Glossary values obtained from the :term:`settings table` as opposed to :term:`config file`. See also :doc:`narr/config/settings`. + data model + Usually, a Python class which maps to a :term:`database` table. + database Generally refers to a relational database which may be queried using SQL. More specifically, one supported by `SQLAlchemy`_. diff --git a/pyproject.toml b/pyproject.toml index 7ca3d88..565c0ef 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -32,7 +32,7 @@ dependencies = [ [project.optional-dependencies] -db = ["SQLAlchemy<2"] +db = ["SQLAlchemy<2", "alembic"] docs = ["Sphinx", "sphinxcontrib-programoutput", "furo"] tests = ["pytest-cov", "tox"] diff --git a/src/wuttjamaican/_version.py b/src/wuttjamaican/_version.py index 8d00d30..9cd05c1 100644 --- a/src/wuttjamaican/_version.py +++ b/src/wuttjamaican/_version.py @@ -1,9 +1,6 @@ # -*- coding: utf-8; -*- -try: - from importlib.metadata import version -except ImportError: # pragma: no cover - from importlib_metadata import version +from importlib.metadata import version __version__ = version('WuttJamaican') diff --git a/src/wuttjamaican/app.py b/src/wuttjamaican/app.py index 7eda920..3db9972 100644 --- a/src/wuttjamaican/app.py +++ b/src/wuttjamaican/app.py @@ -24,10 +24,11 @@ WuttJamaican - app handler """ +import importlib import os import warnings -from wuttjamaican.util import load_entry_points, load_object, parse_bool +from wuttjamaican.util import load_entry_points, load_object, make_uuid, parse_bool class AppHandler: @@ -48,11 +49,23 @@ class AppHandler: :param config: Config object for the app. This should be an instance of :class:`~wuttjamaican.conf.WuttaConfig`. + .. attribute:: model + + Reference to the :term:`app model` module. + + Note that :meth:`get_model()` is responsible for determining + which module this will point to. However you can always get + the model using this attribute (e.g. ``app.model``) and do not + need to call :meth:`get_model()` yourself - that part will + happen automatically. + .. attribute:: providers Dictionary of :class:`AppProvider` instances, as returned by :meth:`get_all_providers()`. """ + default_app_title = "WuttJamaican" + default_model_spec = 'wuttjamaican.db.model' def __init__(self, config): self.config = config @@ -75,7 +88,7 @@ class AppHandler: def __getattr__(self, name): """ Custom attribute getter, called when the app handler does not - already have an attribute named with ``name``. + already have an attribute with the given ``name``. This will delegate to the set of :term:`app providers`; the first provider with an appropriately-named @@ -85,6 +98,9 @@ class AppHandler: providers. """ + if name == 'model': + return self.get_model() + if name == 'providers': self.providers = self.get_all_providers() return self.providers @@ -113,6 +129,42 @@ class AppHandler: providers[key] = providers[key](self.config) return providers + def get_title(self, default=None): + """ + Returns the configured title for the app. + + :param default: Value to be returned if there is no app title + configured. + + :returns: Title for the app. + """ + return self.config.get(f'{self.appname}.app_title', + default=default or self.default_app_title) + + def get_model(self): + """ + Returns the :term:`app model` module. + + Note that you don't actually need to call this method; you can + get the model by simply accessing :attr:`model` + (e.g. ``app.model``) instead. + + By default this will return :mod:`wuttjamaican.db.model` + unless the config class or some :term:`config extension` has + provided another default. + + A custom app can override the default like so (within a config + extension):: + + config.setdefault('wutta.model_spec', 'poser.db.model') + """ + if 'model' not in self.__dict__: + spec = self.config.get(f'{self.appname}.model_spec', + usedb=False, + default=self.default_model_spec) + self.model = importlib.import_module(spec) + return self.model + def make_appdir(self, path, subfolders=None, **kwargs): """ Establish an :term:`app dir` at the given path. @@ -154,6 +206,17 @@ class AppHandler: return Session(**kwargs) + def make_uuid(self): + """ + Generate a new UUID value. + + By default this simply calls + :func:`wuttjamaican.util.make_uuid()`. + + :returns: UUID value as 32-character string. + """ + return make_uuid() + def short_session(self, **kwargs): """ Returns a context manager for a short-lived database session. diff --git a/src/wuttjamaican/conf.py b/src/wuttjamaican/conf.py index c6a8877..7f270fb 100644 --- a/src/wuttjamaican/conf.py +++ b/src/wuttjamaican/conf.py @@ -563,6 +563,7 @@ class WuttaConfig: log.warning("tried to configure logging, but got NoSectionError: %s", error) else: log.debug("configured logging") + log.debug("sys.argv: %s", sys.argv) finally: os.remove(path) diff --git a/src/wuttjamaican/db/__init__.py b/src/wuttjamaican/db/__init__.py index d18951b..73fabf9 100644 --- a/src/wuttjamaican/db/__init__.py +++ b/src/wuttjamaican/db/__init__.py @@ -28,8 +28,8 @@ access the following: * :class:`~wuttjamaican.db.sess.Session` * :class:`~wuttjamaican.db.sess.short_session` -* :class:`~wuttjamaican.conf.get_setting` -* :class:`~wuttjamaican.conf.get_engines` +* :class:`~wuttjamaican.db.conf.get_setting` +* :class:`~wuttjamaican.db.conf.get_engines` """ from .sess import Session, short_session diff --git a/src/wuttjamaican/db/alembic/env.py b/src/wuttjamaican/db/alembic/env.py new file mode 100644 index 0000000..2bc674c --- /dev/null +++ b/src/wuttjamaican/db/alembic/env.py @@ -0,0 +1,67 @@ +# -*- coding: utf-8; -*- + +from alembic import context + +from wuttjamaican.conf import make_config + + +# this is the Alembic Config object, which provides +# access to the values within the .ini file in use. +alembic_config = context.config + +# this is the wutta-based config +wutta_config = make_config(alembic_config.config_file_name, + usedb=False) + +# add your model's MetaData object here +# for 'autogenerate' support +app = wutta_config.get_app() +target_metadata = app.model.Base.metadata + + +def run_migrations_offline() -> None: + """Run migrations in 'offline' mode. + + This configures the context with just a URL + and not an Engine, though an Engine is acceptable + here as well. By skipping the Engine creation + we don't even need a DBAPI to be available. + + Calls to context.execute() here emit the given string to the + script output. + + """ + engine = wutta_config.appdb_engine + context.configure( + url=engine.url, + target_metadata=target_metadata, + literal_binds=True, + dialect_opts={"paramstyle": "named"}, + ) + + with context.begin_transaction(): + context.run_migrations() + + +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 = wutta_config.appdb_engine + + with connectable.connect() as connection: + context.configure( + connection=connection, target_metadata=target_metadata + ) + + with context.begin_transaction(): + context.run_migrations() + + +if context.is_offline_mode(): + run_migrations_offline() +else: + run_migrations_online() diff --git a/src/wuttjamaican/db/alembic/script.py.mako b/src/wuttjamaican/db/alembic/script.py.mako new file mode 100644 index 0000000..fbc4b07 --- /dev/null +++ b/src/wuttjamaican/db/alembic/script.py.mako @@ -0,0 +1,26 @@ +"""${message} + +Revision ID: ${up_revision} +Revises: ${down_revision | comma,n} +Create Date: ${create_date} + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +${imports if imports else ""} + +# revision identifiers, used by Alembic. +revision: str = ${repr(up_revision)} +down_revision: Union[str, None] = ${repr(down_revision)} +branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)} +depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)} + + +def upgrade() -> None: + ${upgrades if upgrades else "pass"} + + +def downgrade() -> None: + ${downgrades if downgrades else "pass"} diff --git a/src/wuttjamaican/db/alembic/versions/fc3a3bcaa069_init_with_settings_table.py b/src/wuttjamaican/db/alembic/versions/fc3a3bcaa069_init_with_settings_table.py new file mode 100644 index 0000000..c7063fa --- /dev/null +++ b/src/wuttjamaican/db/alembic/versions/fc3a3bcaa069_init_with_settings_table.py @@ -0,0 +1,34 @@ +"""init with settings table + +Revision ID: fc3a3bcaa069 +Revises: +Create Date: 2024-07-10 20:33:41.273952 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = 'fc3a3bcaa069' +down_revision: Union[str, None] = None +branch_labels: Union[str, Sequence[str], None] = ('wutta',) +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + + # setting + op.create_table('setting', + sa.Column('name', sa.String(length=255), nullable=False), + sa.Column('value', sa.Text(), nullable=True), + sa.PrimaryKeyConstraint('name') + ) + + +def downgrade() -> None: + + # setting + op.drop_table('setting') diff --git a/src/wuttjamaican/db/model/__init__.py b/src/wuttjamaican/db/model/__init__.py new file mode 100644 index 0000000..aa6877d --- /dev/null +++ b/src/wuttjamaican/db/model/__init__.py @@ -0,0 +1,34 @@ +# -*- coding: utf-8; -*- +################################################################################ +# +# WuttJamaican -- Base package for Wutta Framework +# Copyright © 2023-2024 Lance Edgar +# +# This file is part of Wutta Framework. +# +# Wutta Framework is free software: you can redistribute it and/or modify it +# under the terms of the GNU General Public License as published by the Free +# Software Foundation, either version 3 of the License, or (at your option) any +# later version. +# +# Wutta Framework is distributed in the hope that it will be useful, but +# WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +# FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for +# more details. +# +# You should have received a copy of the GNU General Public License along with +# Wutta Framework. If not, see . +# +################################################################################ +""" +WuttJamaican - database model + +For convenience, from this ``wuttjamaican.db.model`` namespace you can +access the following: + +* :class:`~wuttjamaican.db.model.base.Base` +* :class:`~wuttjamaican.db.model.base.Setting` +* :func:`~wuttjamaican.db.model.base.uuid_column()` +""" + +from .base import Base, uuid_column, Setting diff --git a/src/wuttjamaican/db/model/base.py b/src/wuttjamaican/db/model/base.py new file mode 100644 index 0000000..45a7eee --- /dev/null +++ b/src/wuttjamaican/db/model/base.py @@ -0,0 +1,65 @@ +# -*- coding: utf-8; -*- +################################################################################ +# +# WuttJamaican -- Base package for Wutta Framework +# Copyright © 2023-2024 Lance Edgar +# +# This file is part of Wutta Framework. +# +# Wutta Framework is free software: you can redistribute it and/or modify it +# under the terms of the GNU General Public License as published by the Free +# Software Foundation, either version 3 of the License, or (at your option) any +# later version. +# +# Wutta Framework is distributed in the hope that it will be useful, but +# WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +# FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for +# more details. +# +# You should have received a copy of the GNU General Public License along with +# Wutta Framework. If not, see . +# +################################################################################ +""" +WuttJamaican - base models + +.. class:: Base + + This is the base class for all data models. +""" + +import sqlalchemy as sa +from sqlalchemy import orm + +from wuttjamaican.util import make_uuid + + +Base = orm.declarative_base() + + +def uuid_column(*args, **kwargs): + """ + Returns a UUID column for use as a table's primary key. + """ + kwargs.setdefault('primary_key', True) + kwargs.setdefault('nullable', False) + kwargs.setdefault('default', make_uuid) + return sa.Column(sa.String(length=32), *args, **kwargs) + + +class Setting(Base): + """ + Represents a :term:`config setting`. + """ + __tablename__ = 'setting' + + name = sa.Column(sa.String(length=255), primary_key=True, nullable=False, doc=""" + Unique name for the setting. + """) + + value = sa.Column(sa.Text(), nullable=True, doc=""" + String value for the setting. + """) + + def __str__(self): + return self.name or "" diff --git a/src/wuttjamaican/util.py b/src/wuttjamaican/util.py index a238bfe..5ebc73a 100644 --- a/src/wuttjamaican/util.py +++ b/src/wuttjamaican/util.py @@ -27,6 +27,7 @@ WuttJamaican - utilities import importlib import logging import shlex +from uuid import uuid1 log = logging.getLogger(__name__) @@ -113,6 +114,15 @@ def load_object(spec): return getattr(module, name) +def make_uuid(): + """ + Generate a universally-unique identifier. + + :returns: A 32-character hex string. + """ + return uuid1().hex + + def parse_bool(value): """ Derive a boolean from the given string value. diff --git a/tests/db/model/__init__.py b/tests/db/model/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/db/model/test_base.py b/tests/db/model/test_base.py new file mode 100644 index 0000000..646e330 --- /dev/null +++ b/tests/db/model/test_base.py @@ -0,0 +1,25 @@ +# -*- coding: utf-8; -*- + +from unittest import TestCase + +try: + import sqlalchemy as sa + from wuttjamaican.db.model import base as model +except ImportError: + pass +else: + + class TestUUIDColumn(TestCase): + + def test_basic(self): + column = model.uuid_column() + self.assertIsInstance(column, sa.Column) + + + class TestSetting(TestCase): + + def test_basic(self): + setting = model.Setting() + self.assertEqual(str(setting), "") + setting.name = 'foo' + self.assertEqual(str(setting), "foo") diff --git a/tests/test_app.py b/tests/test_app.py index d31c563..9908c1f 100644 --- a/tests/test_app.py +++ b/tests/test_app.py @@ -88,6 +88,30 @@ class TestAppHandler(TestCase): value = self.app.get_setting(session, 'foo') self.assertEqual(value, 'bar') + def test_model(self): + try: + from wuttjamaican.db import model + except ImportError: + pytest.skip("test not relevant without sqlalchemy") + else: + self.assertNotIn('model', self.app.__dict__) + self.assertIs(self.app.model, model) + + def test_get_model(self): + try: + from wuttjamaican.db import model + except ImportError: + pytest.skip("test not relevant without sqlalchemy") + else: + self.assertIs(self.app.get_model(), model) + + def test_get_title(self): + self.assertEqual(self.app.get_title(), 'WuttJamaican') + + def test_make_uuid(self): + uuid = self.app.make_uuid() + self.assertEqual(len(uuid), 32) + class TestAppProvider(TestCase): diff --git a/tests/test_util.py b/tests/test_util.py index f02d5de..7e0bac5 100644 --- a/tests/test_util.py +++ b/tests/test_util.py @@ -4,10 +4,6 @@ import sys from unittest import TestCase from unittest.mock import patch, MagicMock -# nb. setuptools must be imported before distutils, else weird -# behavior may ensue within some of the tests below -import setuptools - import pytest from wuttjamaican import util @@ -134,6 +130,13 @@ class TestLoadObject(TestCase): self.assertIs(result, TestCase) +class TestMakeUUID(TestCase): + + def test_basic(self): + uuid = util.make_uuid() + self.assertEqual(len(uuid), 32) + + class TestParseBool(TestCase): def test_null(self):