2
0
Fork 0

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
This commit is contained in:
Lance Edgar 2024-07-11 12:55:55 -05:00
parent 7012409e1e
commit 375d0be638
19 changed files with 388 additions and 13 deletions

View file

@ -0,0 +1,6 @@
``wuttjamaican.db.model.base``
==============================
.. automodule:: wuttjamaican.db.model.base
:members:

View file

@ -0,0 +1,6 @@
``wuttjamaican.db.model``
=========================
.. automodule:: wuttjamaican.db.model
:members:

View file

@ -11,6 +11,8 @@
conf
db
db.conf
db.model
db.model.base
db.sess
exc
testing

View file

@ -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<data model>` 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<config setting>`.
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`_.

View file

@ -32,7 +32,7 @@ dependencies = [
[project.optional-dependencies]
db = ["SQLAlchemy<2"]
db = ["SQLAlchemy<2", "alembic"]
docs = ["Sphinx", "sphinxcontrib-programoutput", "furo"]
tests = ["pytest-cov", "tox"]

View file

@ -1,9 +1,6 @@
# -*- coding: utf-8; -*-
try:
from importlib.metadata import version
except ImportError: # pragma: no cover
from importlib_metadata import version
__version__ = version('WuttJamaican')

View file

@ -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<app
provider>`; 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.

View file

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

View file

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

View file

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

View file

@ -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"}

View file

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

View file

@ -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 <http://www.gnu.org/licenses/>.
#
################################################################################
"""
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

View file

@ -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 <http://www.gnu.org/licenses/>.
#
################################################################################
"""
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 ""

View file

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

View file

View file

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

View file

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

View file

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