3
0
Fork 0

Compare commits

..

5 commits

8 changed files with 313 additions and 42 deletions

View file

@ -5,6 +5,17 @@ All notable changes to WuttJamaican will be documented in this file.
The format is based on [Keep a Changelog](http://keepachangelog.com/en/1.0.0/) The format is based on [Keep a Changelog](http://keepachangelog.com/en/1.0.0/)
and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.html). and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.html).
## v0.28.0 (2025-12-28)
### Feat
- add alembic config/utility functions, for migrations admin
### Fix
- add empty migration script, to avoid test problems
- show deprecation warnings by default for 'wutt*' packages
## v0.27.1 (2025-12-21) ## v0.27.1 (2025-12-21)
### Fix ### Fix

View file

@ -116,8 +116,7 @@ invoke other app/handler logic::
# invoke secondary handler to make new user account # invoke secondary handler to make new user account
auth = app.get_auth_handler() auth = app.get_auth_handler()
user = auth.make_user(session=session, username='barney') user = auth.make_user(session=session, username='barney')
assert isinstance(user, model.User)
# commit changes to DB
session.add(user) session.add(user)
session.commit() session.commit()

View file

@ -6,7 +6,7 @@ build-backend = "hatchling.build"
[project] [project]
name = "WuttJamaican" name = "WuttJamaican"
version = "0.27.1" version = "0.28.0"
description = "Base package for Wutta Framework" description = "Base package for Wutta Framework"
readme = "README.md" readme = "README.md"
authors = [{name = "Lance Edgar", email = "lance@wuttaproject.org"}] authors = [{name = "Lance Edgar", email = "lance@wuttaproject.org"}]

View file

@ -31,6 +31,7 @@ import logging.config
import os import os
import sys import sys
import tempfile import tempfile
import warnings
import config as configuration import config as configuration
@ -1020,41 +1021,54 @@ def make_config( # pylint: disable=too-many-arguments,too-many-positional-argum
:returns: The new config object. :returns: The new config object.
""" """
# collect file paths
files = get_config_paths(
files=files,
plus_files=plus_files,
appname=appname,
env_files_name=env_files_name,
env_plus_files_name=env_plus_files_name,
env=env,
default_files=default_files,
winsvc=winsvc,
)
# make config object # nb. always show deprecation warnings when making config
if not factory: with warnings.catch_warnings():
factory = WuttaConfig warnings.filterwarnings("default", category=DeprecationWarning, module=r"^wutt")
config = factory(files, appname=appname, usedb=usedb, preferdb=preferdb, **kwargs)
# maybe extend config object # collect file paths
if extend: files = get_config_paths(
if not extension_entry_points: files=files,
# nb. must not use appname here, entry points must be plus_files=plus_files,
# consistent regardless of appname appname=appname,
extension_entry_points = "wutta.config.extensions" env_files_name=env_files_name,
env_plus_files_name=env_plus_files_name,
env=env,
default_files=default_files,
winsvc=winsvc,
)
# apply all registered extensions # make config object
# TODO: maybe let config disable some extensions? if not factory:
extensions = load_entry_points(extension_entry_points) factory = WuttaConfig
extensions = [ext() for ext in extensions.values()] config = factory(
for extension in extensions: files, appname=appname, usedb=usedb, preferdb=preferdb, **kwargs
log.debug("applying config extension: %s", extension.key) )
extension.configure(config)
# let extensions run startup hooks if needed # maybe extend config object
for extension in extensions: if extend:
extension.startup(config) if not extension_entry_points:
# nb. must not use appname here, entry points must be
# consistent regardless of appname
extension_entry_points = "wutta.config.extensions"
# apply all registered extensions
# TODO: maybe let config disable some extensions?
extensions = load_entry_points(extension_entry_points)
extensions = [ext() for ext in extensions.values()]
for extension in extensions:
log.debug("applying config extension: %s", extension.key)
extension.configure(config)
# let extensions run startup hooks if needed
for extension in extensions:
extension.startup(config)
# maybe show deprecation warnings from now on
if config.get_bool(
f"{config.appname}.show_deprecation_warnings", usedb=False, default=True
):
warnings.filterwarnings("default", category=DeprecationWarning, module=r"^wutt")
return config return config

View file

@ -0,0 +1,39 @@
"""empty migration
Revision ID: 4d3696b894d5
Revises: b59a34266288
Create Date: 2025-12-28 13:56:20.900043
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
import wuttjamaican.db.util
# revision identifiers, used by Alembic.
revision: str = "4d3696b894d5"
down_revision: Union[str, None] = "b59a34266288"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
# NOTE: this empty revision exists simply to ensure that its down
# revision (b59a34266288) is not the branch head. and that is needed
# because some of the tests in wuttaweb now run commands like these:
#
# alembic downgrade wutta@-1
# alembic upgrade heads
#
# which is actually fine for postgres but not so for sqlite, due to
# the particular contents of the b59a34266288 revision.
def upgrade() -> None:
pass
def downgrade() -> None:
pass

View file

@ -2,7 +2,7 @@
################################################################################ ################################################################################
# #
# WuttJamaican -- Base package for Wutta Framework # WuttJamaican -- Base package for Wutta Framework
# Copyright © 2023-2024 Lance Edgar # Copyright © 2023-2025 Lance Edgar
# #
# This file is part of Wutta Framework. # This file is part of Wutta Framework.
# #
@ -27,6 +27,9 @@ WuttJamaican - database configuration
from collections import OrderedDict from collections import OrderedDict
import sqlalchemy as sa import sqlalchemy as sa
from alembic.config import Config as AlembicConfig
from alembic.script import ScriptDirectory
from alembic.migration import MigrationContext
from wuttjamaican.util import load_object, parse_bool, parse_list from wuttjamaican.util import load_object, parse_bool, parse_list
@ -150,3 +153,93 @@ def make_engine_from_config(config_dict, prefix="sqlalchemy.", **kwargs):
engine = sa.engine_from_config(config_dict, prefix, **kwargs) engine = sa.engine_from_config(config_dict, prefix, **kwargs)
return engine return engine
##############################
# alembic functions
##############################
def make_alembic_config(config):
"""
Make and return a new Alembic config object, based on current app
config.
This tries to set the following on the Alembic config:
* :attr:`~alembic:alembic.config.Config.config_file_name` - set to
app's primary config file
* main option ``script_location``
* main option ``version_locations``
The latter 2 are read normally from app config, then set on the
Alembic config via
:meth:`~alembic:alembic.config.Config.set_main_option()`.
.. note::
IIUC, Alembic should not need to attempt to read config values
from file, as long as we're able to set the above explicitly.
However we set the ``config_file_name`` "just in case" Alembic
needs it, but also to ensure it is discoverable from within the
``env.py`` script...
When a migration script runs, code within ``env.py`` will call
:func:`make_config()` using the filename which it inspects from
the Alembic config.
(Confused yet?!)
:returns: :class:`alembic:alembic.config.Config` instance
"""
alembic_config = AlembicConfig()
# TODO: not sure what we can do here besides assume the "primary"
# config file should be used?
if config.files_read:
alembic_config.config_file_name = config.get_prioritized_files()[0]
if script_location := config.get("alembic.script_location", usedb=False):
alembic_config.set_main_option("script_location", script_location)
if version_locations := config.get("alembic.version_locations", usedb=False):
alembic_config.set_main_option("version_locations", version_locations)
return alembic_config
def get_alembic_scriptdir(config, alembic_config=None):
"""
Get a "Script Directory" object for Alembic.
This allows for inspection of the migration scripts.
:param config: App config object.
:param alembic_config: Alembic config object, if you have one.
Otherwise :func:`make_alembic_config()` will be called.
:returns: :class:`~alembic:alembic.script.ScriptDirectory` instance
"""
if not alembic_config:
alembic_config = make_alembic_config(config)
return ScriptDirectory.from_config(alembic_config)
def check_alembic_current(config, alembic_config=None):
"""
Compare the current revisions in the :term:`app database` to those
found in the migration scripts.
:param config: App config object.
:param alembic_config: Alembic config object, if you have one.
Otherwise :func:`make_alembic_config()` will be called.
:returns: ``True`` if the DB already has all migrations applied;
``False`` if not.
"""
script = get_alembic_scriptdir(config, alembic_config)
with config.appdb_engine.begin() as conn:
context = MigrationContext.configure(conn)
return set(context.get_current_heads()) == set(script.get_heads())

View file

@ -176,9 +176,10 @@ class ConfigTestCase(FileTestCase):
""" """
self.teardown_files() self.teardown_files()
def make_config(self, **kwargs): # pylint: disable=empty-docstring def make_config( # pylint: disable=missing-function-docstring
""" """ self, files=None, **kwargs
return WuttaConfig(**kwargs) ):
return WuttaConfig(files, **kwargs)
class DataTestCase(ConfigTestCase): class DataTestCase(ConfigTestCase):
@ -213,6 +214,8 @@ class DataTestCase(ConfigTestCase):
teardown methods, as this class handles that automatically. teardown methods, as this class handles that automatically.
""" """
sqlite_engine_url = "sqlite://"
def setUp(self): # pylint: disable=empty-docstring def setUp(self): # pylint: disable=empty-docstring
""" """ """ """
self.setup_db() self.setup_db()
@ -237,8 +240,7 @@ class DataTestCase(ConfigTestCase):
""" """
self.teardown_config() self.teardown_config()
def make_config(self, **kwargs): # pylint: disable=empty-docstring def make_config(self, files=None, **kwargs):
""" """
defaults = kwargs.setdefault("defaults", {}) defaults = kwargs.setdefault("defaults", {})
defaults.setdefault("wutta.db.default.url", "sqlite://") defaults.setdefault("wutta.db.default.url", self.sqlite_engine_url)
return super().make_config(**kwargs) return super().make_config(files, **kwargs)

View file

@ -6,13 +6,18 @@ import tempfile
from unittest import TestCase from unittest import TestCase
from wuttjamaican.conf import WuttaConfig from wuttjamaican.conf import WuttaConfig
from wuttjamaican.testing import ConfigTestCase, DataTestCase
try: try:
import sqlalchemy as sa import sqlalchemy as sa
from sqlalchemy import orm from sqlalchemy import orm
from sqlalchemy.engine import Engine from sqlalchemy.engine import Engine
from sqlalchemy.pool import NullPool from sqlalchemy.pool import NullPool
from alembic import command as alembic_command
from alembic.config import Config as AlembicConfig
from alembic.script import ScriptDirectory
from wuttjamaican.db import conf from wuttjamaican.db import conf
from wuttjamaican.db import conf as mod
except ImportError: except ImportError:
pass pass
else: else:
@ -154,3 +159,111 @@ else:
} }
) )
self.assertTrue(engine.pool._pre_ping) self.assertTrue(engine.pool._pre_ping)
class TestMakeAlembicConfig(ConfigTestCase):
def test_defaults(self):
# without config file
self.assertFalse(self.config.files_read)
alembic = mod.make_alembic_config(self.config)
self.assertIsInstance(alembic, AlembicConfig)
self.assertIsNone(alembic.config_file_name)
self.assertIsNone(alembic.get_main_option("script_location"))
self.assertIsNone(alembic.get_main_option("version_locations"))
# with config file
path = self.write_file("test.ini", "[alembic]")
self.config.files_read = [path]
alembic = mod.make_alembic_config(self.config)
self.assertIsInstance(alembic, AlembicConfig)
self.assertEqual(alembic.config_file_name, path)
self.assertIsNone(alembic.get_main_option("script_location"))
self.assertIsNone(alembic.get_main_option("version_locations"))
def test_configured(self):
self.config.setdefault("alembic.script_location", "wuttjamaican.db:alembic")
self.config.setdefault(
"alembic.version_locations", "wuttjamaican.db:alembic/versions"
)
alembic = mod.make_alembic_config(self.config)
self.assertIsInstance(alembic, AlembicConfig)
self.assertEqual(
alembic.get_main_option("script_location"), "wuttjamaican.db:alembic"
)
self.assertEqual(
alembic.get_main_option("version_locations"),
"wuttjamaican.db:alembic/versions",
)
class TestGetAlembicScriptdir(ConfigTestCase):
def test_basic(self):
self.config.setdefault("alembic.script_location", "wuttjamaican.db:alembic")
self.config.setdefault(
"alembic.version_locations", "wuttjamaican.db:alembic/versions"
)
# can provide alembic config
alembic = mod.make_alembic_config(self.config)
script = mod.get_alembic_scriptdir(self.config, alembic)
self.assertIsInstance(script, ScriptDirectory)
# but also can omit it
script = mod.get_alembic_scriptdir(self.config)
self.assertIsInstance(script, ScriptDirectory)
class TestCheckAlembicCurrent(DataTestCase):
def make_config(self, **kwargs):
sqlite_path = self.write_file("test.sqlite", "")
self.sqlite_engine_url = f"sqlite:///{sqlite_path}"
config_path = self.write_file(
"test.ini",
f"""
[wutta.db]
default.url = {self.sqlite_engine_url}
[alembic]
script_location = wuttjamaican.db:alembic
version_locations = wuttjamaican.db:alembic/versions
""",
)
return super().make_config([config_path], **kwargs)
def test_basic(self):
alembic = mod.make_alembic_config(self.config)
self.assertIsNotNone(alembic.get_main_option("script_location"))
self.assertIsNotNone(alembic.get_main_option("version_locations"))
# false by default, since tests use MetaData.create_all()
# instead of migrations for setup
self.assertFalse(mod.check_alembic_current(self.config, alembic))
# and to further prove the point, alembic_version table is missing
self.assertEqual(
self.session.execute(sa.text("select count(*) from person")).scalar(),
0,
)
self.assertRaises(
sa.exc.OperationalError,
self.session.execute,
sa.text("select count(*) from alembic_version"),
)
# but we can 'stamp' the DB to declare its current revision
alembic_command.stamp(alembic, "heads")
# now the alembic_version table exists
self.assertEqual(
self.session.execute(
sa.text("select count(*) from alembic_version")
).scalar(),
1,
)
# and now Alembic knows we are current
self.assertTrue(mod.check_alembic_current(self.config, alembic))