3
0
Fork 0

Compare commits

..

No commits in common. "d3213c577315fff8aaaf742f7ec57175e7cdc9dc" and "d018d4e764fd2b0a573d7bab3e95be4fb253760a" have entirely different histories.

5 changed files with 98 additions and 265 deletions

View file

@ -5,13 +5,6 @@ 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.5 (2026-01-04)
### Fix
- prompt for continuum support in app installer
- make pylint happy
## v0.28.4 (2026-01-03) ## v0.28.4 (2026-01-03)
### Fix ### Fix

View file

@ -6,7 +6,7 @@ build-backend = "hatchling.build"
[project] [project]
name = "WuttJamaican" name = "WuttJamaican"
version = "0.28.5" version = "0.28.4"
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

@ -87,9 +87,6 @@ class InstallHandler(GenericHandler): # pylint: disable=too-many-public-methods
egg_name = None egg_name = None
schema_installed = False schema_installed = False
# nb. we prompt the user for this, unless attr already has value
wants_continuum = None
template_paths = ["wuttjamaican:templates/install"] template_paths = ["wuttjamaican:templates/install"]
def __init__(self, config, **kwargs): def __init__(self, config, **kwargs):
@ -188,97 +185,35 @@ class InstallHandler(GenericHandler): # pylint: disable=too-many-public-methods
This method is called by :meth:`run()` and does the following: This method is called by :meth:`run()` and does the following:
* call :meth:`prompt_user_for_context()` to collect DB info etc. * call :meth:`get_dbinfo()` to get DB info from user, and test connection
* call :meth:`make_template_context()` to use when generating output * call :meth:`make_template_context()` to use when generating output
* call :meth:`make_appdir()` to create app dir with config files * call :meth:`make_appdir()` to create app dir with config files
* call :meth:`install_db_schema()` to (optionally) create tables in DB * call :meth:`install_db_schema()` to (optionally) create tables in DB
""" """
# prompt user / get context # prompt user for db info
context = self.prompt_user_for_context() dbinfo = self.get_dbinfo()
context = self.make_template_context(**context)
# get context for generated app files
context = self.make_template_context(dbinfo)
# make the appdir # make the appdir
self.make_appdir(context) self.make_appdir(context)
# install db schema if user likes # install db schema if user likes
self.schema_installed = self.install_db_schema(context["db_url"]) self.schema_installed = self.install_db_schema(dbinfo["dburl"])
def prompt_user_for_context(self): def get_dbinfo(self):
""" """
This is responsible for initial user prompts. Collect DB connection info from the user, and test the
connection. If connection fails, exit the install.
This happens early in the install, so this method can verify This method is normally called by :meth:`do_install_steps()`.
the info, e.g. test the DB connection, but should not write
any files as the app dir may not exist yet.
Default logic calls :meth:`get_db_url()` for the DB :returns: Dict of DB info collected from user.
connection, then may ask about Wutta-Continuum data
versioning. (The latter is skipped if the package is
missing.)
Subclass should override this method if they need different
prompting logic. The return value should always include at
least these 2 items:
* ``db_url`` - URL for the DB connection
* ``wants_continuum`` - whether data versioning should be enabled
:returns: Dict of template context
""" """
# db info
db_url = self.get_db_url()
# continuum
if self.wants_continuum is None:
try:
import wutta_continuum
except ImportError:
self.wants_continuum = False
else:
self.wants_continuum = self.prompt_bool(
"use continuum for data versioning?", default=False
)
return {"db_url": db_url, "wants_continuum": self.wants_continuum}
def get_db_url(self):
"""
This must return the DB engine URL.
Default logic will prompt the user for hostname, port, DB name
and credentials. It then assembles the URL from those parts.
This method will also test the DB connection. If it fails,
the install is aborted.
This method is normally called by
:meth:`prompt_user_for_context()`.
:returns: SQLAlchemy engine URL (as object or string)
"""
# get db info/url
dbinfo = self.get_dbinfo()
if "db_url" in dbinfo:
db_url = dbinfo["db_url"]
else:
db_url = self.make_db_url(dbinfo)
# test db connection
self.rprint("\n\ttesting db connection... ", end="")
error = self.test_db_connection(db_url)
if error:
self.rprint("[bold red]cannot connect![/bold red] ..error was:")
self.rprint(f"\n{error}")
self.rprint("\n\t[bold yellow]aborting mission[/bold yellow]\n")
sys.exit(1)
self.rprint("[bold green]good[/bold green]")
return db_url
def get_dbinfo(self): # pylint: disable=missing-function-docstring
dbinfo = {} dbinfo = {}
# main info # get db info
dbinfo["dbtype"] = self.prompt_generic("db type", "postgresql") dbinfo["dbtype"] = self.prompt_generic("db type", "postgresql")
dbinfo["dbhost"] = self.prompt_generic("db host", "localhost") dbinfo["dbhost"] = self.prompt_generic("db host", "localhost")
default_port = "3306" if dbinfo["dbtype"] == "mysql" else "5432" default_port = "3306" if dbinfo["dbtype"] == "mysql" else "5432"
@ -286,29 +221,49 @@ class InstallHandler(GenericHandler): # pylint: disable=too-many-public-methods
dbinfo["dbname"] = self.prompt_generic("db name", self.pkg_name) dbinfo["dbname"] = self.prompt_generic("db name", self.pkg_name)
dbinfo["dbuser"] = self.prompt_generic("db user", self.pkg_name) dbinfo["dbuser"] = self.prompt_generic("db user", self.pkg_name)
# password # get db password
dbinfo["dbpass"] = None dbinfo["dbpass"] = None
while not dbinfo["dbpass"]: while not dbinfo["dbpass"]:
dbinfo["dbpass"] = self.prompt_generic("db pass", is_password=True) dbinfo["dbpass"] = self.prompt_generic("db pass", is_password=True)
# test db connection
self.rprint("\n\ttesting db connection... ", end="")
dbinfo["dburl"] = self.make_db_url(
dbinfo["dbtype"],
dbinfo["dbhost"],
dbinfo["dbport"],
dbinfo["dbname"],
dbinfo["dbuser"],
dbinfo["dbpass"],
)
error = self.test_db_connection(dbinfo["dburl"])
if error:
self.rprint("[bold red]cannot connect![/bold red] ..error was:")
self.rprint(f"\n{error}")
self.rprint("\n\t[bold yellow]aborting mission[/bold yellow]\n")
sys.exit(1)
self.rprint("[bold green]good[/bold green]")
return dbinfo return dbinfo
def make_db_url(self, dbinfo): # pylint: disable=empty-docstring def make_db_url(
self, dbtype, dbhost, dbport, dbname, dbuser, dbpass
): # pylint: disable=empty-docstring,too-many-arguments,too-many-positional-arguments
""" """ """ """
from sqlalchemy.engine import URL # pylint: disable=import-outside-toplevel from sqlalchemy.engine import URL # pylint: disable=import-outside-toplevel
if dbinfo["dbtype"] == "mysql": if dbtype == "mysql":
drivername = "mysql+mysqlconnector" drivername = "mysql+mysqlconnector"
else: else:
drivername = "postgresql+psycopg2" drivername = "postgresql+psycopg2"
return URL.create( return URL.create(
drivername=drivername, drivername=drivername,
username=dbinfo["dbuser"], username=dbuser,
password=dbinfo["dbpass"], password=dbpass,
host=dbinfo["dbhost"], host=dbhost,
port=dbinfo["dbport"], port=dbport,
database=dbinfo["dbname"], database=dbname,
) )
def test_db_connection(self, url): # pylint: disable=empty-docstring def test_db_connection(self, url): # pylint: disable=empty-docstring
@ -325,7 +280,7 @@ class InstallHandler(GenericHandler): # pylint: disable=too-many-public-methods
return str(error) return str(error)
return None return None
def make_template_context(self, **kwargs): def make_template_context(self, dbinfo, **kwargs):
""" """
This must return a dict to be used as global template context This must return a dict to be used as global template context
when generating output (e.g. config) files. when generating output (e.g. config) files.
@ -334,19 +289,14 @@ class InstallHandler(GenericHandler): # pylint: disable=too-many-public-methods
The ``context`` returned is then passed to The ``context`` returned is then passed to
:meth:`render_mako_template()`. :meth:`render_mako_template()`.
Note these first 2 params are not explicitly listed in the :param dbinfo: Dict of DB connection info as obtained from
method signature; they are required nonetheless. :meth:`get_dbinfo()`.
:param db_url: This must be a string URL for the DB engine.
:param wants_continuum: Whether data versioning should be
enabled within the config.
:param \\**kwargs: Extra template context. :param \\**kwargs: Extra template context.
:returns: Dict for global template context. :returns: Dict for global template context.
The final context dict should include at least: The context dict will include:
* ``envdir`` - value from :data:`python:sys.prefix` * ``envdir`` - value from :data:`python:sys.prefix`
* ``envname`` - "last" dirname from ``sys.prefix`` * ``envname`` - "last" dirname from ``sys.prefix``
@ -355,16 +305,13 @@ class InstallHandler(GenericHandler): # pylint: disable=too-many-public-methods
* ``pypi_name`` - value from :attr:`pypi_name` * ``pypi_name`` - value from :attr:`pypi_name`
* ``egg_name`` - value from :attr:`egg_name` * ``egg_name`` - value from :attr:`egg_name`
* ``appdir`` - ``app`` folder under ``sys.prefix`` * ``appdir`` - ``app`` folder under ``sys.prefix``
* ``db_url`` - value from ``kwargs`` * ``db_url`` - value from ``dbinfo['dburl']``
* ``wants_continuum`` - value from ``kwargs``
""" """
envname = os.path.basename(sys.prefix) envname = os.path.basename(sys.prefix)
appdir = os.path.join(sys.prefix, "app") appdir = os.path.join(sys.prefix, "app")
dburl = dbinfo["dburl"]
db_url = kwargs.pop("db_url") if not isinstance(dburl, str):
if not isinstance(db_url, str): dburl = dburl.render_as_string(hide_password=False)
db_url = db_url.render_as_string(hide_password=False)
context = { context = {
"envdir": sys.prefix, "envdir": sys.prefix,
"envname": envname, "envname": envname,
@ -372,8 +319,8 @@ class InstallHandler(GenericHandler): # pylint: disable=too-many-public-methods
"app_title": self.app_title, "app_title": self.app_title,
"pypi_name": self.pypi_name, "pypi_name": self.pypi_name,
"appdir": appdir, "appdir": appdir,
"db_url": dburl,
"egg_name": self.egg_name, "egg_name": self.egg_name,
"db_url": db_url,
} }
context.update(kwargs) context.update(kwargs)
return context return context

View file

@ -56,11 +56,8 @@ preferdb = true
<%def name="section_wutta_db()"> <%def name="section_wutta_db()">
[wutta.db] [wutta.db]
default.url = ${db_url} default.url = ${db_url}
## TODO
% if wants_continuum: ## versioning.enabled = true
[wutta_continuum]
enable_versioning = true
% endif
</%def> </%def>
<%def name="section_wutta_mail()"> <%def name="section_wutta_mail()">
@ -97,11 +94,7 @@ templates = wuttjamaican:templates/mail
<%def name="section_alembic()"> <%def name="section_alembic()">
[alembic] [alembic]
script_location = wuttjamaican.db:alembic script_location = wuttjamaican.db:alembic
% if wants_continuum:
version_locations = ${pkg_name}.db:alembic/versions wutta_continuum.db:alembic/versions wuttjamaican.db:alembic/versions
% else:
version_locations = ${pkg_name}.db:alembic/versions wuttjamaican.db:alembic/versions version_locations = ${pkg_name}.db:alembic/versions wuttjamaican.db:alembic/versions
% endif
</%def> </%def>
<%def name="sectiongroup_logging()"> <%def name="sectiongroup_logging()">

View file

@ -64,119 +64,25 @@ class TestInstallHandler(ConfigTestCase):
def test_do_install_steps(self): def test_do_install_steps(self):
handler = self.make_handler() handler = self.make_handler()
db_url = f"sqlite:///{self.tempdir}/poser.sqlite" handler.templates = TemplateLookup(
directories=[
with patch.object(handler, "prompt_user_for_context") as prompt_user: self.app.resource_path("wuttjamaican:templates/install"),
prompt_user.return_value = {"db_url": db_url, "wants_continuum": False} ]
with patch.object(handler, "make_appdir") as make_appdir: )
with patch.object(handler, "install_db_schema") as install_schema:
# nb. just for sanity/coverage
self.assertFalse(handler.schema_installed)
install_schema.return_value = True
handler.do_install_steps()
prompt_user.assert_called_once()
make_appdir.assert_called_once()
install_schema.assert_called_once_with(db_url)
self.assertTrue(handler.schema_installed)
def test_prompt_user_for_context(self):
db_url = f"sqlite:///{self.tempdir}/poser.sqlite"
with patch.object(mod.InstallHandler, "get_db_url", return_value=db_url):
# should prompt for continuum by default
handler = self.make_handler()
with patch.object(handler, "prompt_bool") as prompt_bool:
prompt_bool.return_value = True
context = handler.prompt_user_for_context()
prompt_bool.assert_called_once_with(
"use continuum for data versioning?", default=False
)
self.assertEqual(context, {"db_url": db_url, "wants_continuum": True})
# should not prompt if continuum flag already true
handler = self.make_handler()
with patch.object(handler, "wants_continuum", new=True):
with patch.object(handler, "prompt_bool") as prompt_bool:
context = handler.prompt_user_for_context()
prompt_bool.assert_not_called()
self.assertEqual(
context, {"db_url": db_url, "wants_continuum": True}
)
# should not prompt if continuum flag already false
handler = self.make_handler()
with patch.object(handler, "wants_continuum", new=False):
with patch.object(handler, "prompt_bool") as prompt_bool:
context = handler.prompt_user_for_context()
prompt_bool.assert_not_called()
self.assertEqual(
context, {"db_url": db_url, "wants_continuum": False}
)
# should not prompt if continuum pkg missing...
handler = self.make_handler()
with patch("builtins.__import__", side_effect=ImportError):
with patch.object(handler, "prompt_bool") as prompt_bool:
context = handler.prompt_user_for_context()
prompt_bool.assert_not_called()
self.assertEqual(
context, {"db_url": db_url, "wants_continuum": False}
)
def test_get_db_url(self):
try:
import sqlalchemy
from wuttjamaican.db.util import SA2
except ImportError:
pytest.skip("test is not relevant without sqlalchemy")
handler = self.make_handler()
# url from dbinfo is returned, if present
dbinfo = {"db_url": "sqlite:///"}
with patch.object(handler, "get_dbinfo", return_value=dbinfo):
db_url = handler.get_db_url()
self.assertEqual(db_url, "sqlite:///")
# or url will be assembled from dbinfo parts
dbinfo = { dbinfo = {
"dbtype": "postgresql", "dburl": f"sqlite:///{self.tempdir}/poser.sqlite",
"dbhost": "localhost",
"dbport": 5432,
"dbname": "poser",
"dbuser": "poser",
"dbpass": "seekrit",
} }
with patch.object(handler, "get_dbinfo", return_value=dbinfo): with patch.object(handler, "get_dbinfo", return_value=dbinfo):
with patch.object(handler, "test_db_connection", return_value=None): with patch.object(handler, "make_appdir") as make_appdir:
db_url = handler.get_db_url() with patch.object(handler, "install_db_schema") as install_db_schema:
seekrit = "***" if SA2 else "seekrit" # nb. just for sanity/coverage
self.assertEqual( install_db_schema.return_value = True
str(db_url), self.assertFalse(handler.schema_installed)
f"postgresql+psycopg2://poser:{seekrit}@localhost:5432/poser", handler.do_install_steps()
) self.assertTrue(make_appdir.called)
self.assertTrue(handler.schema_installed)
# now we test the "test db connection" feature install_db_schema.assert_called_once_with(dbinfo["dburl"])
dbinfo = {"db_url": "sqlite:///"}
with patch.object(handler, "get_dbinfo", return_value=dbinfo):
with patch.object(handler, "test_db_connection") as test_db_connection:
with patch.object(handler, "rprint") as rprint:
with patch.object(mod, "sys") as sys:
# pretend user gave bad dbinfo; should exit
test_db_connection.return_value = "bad dbinfo"
sys.exit.side_effect = RuntimeError
self.assertRaises(RuntimeError, handler.get_db_url)
sys.exit.assert_called_once_with(1)
# pretend user gave good dbinfo
sys.exit.reset_mock()
test_db_connection.return_value = None
db_url = handler.get_db_url()
self.assertFalse(sys.exit.called)
rprint.assert_called_with("[bold green]good[/bold green]")
self.assertEqual(str(db_url), "sqlite:///")
def test_get_dbinfo(self): def test_get_dbinfo(self):
try: try:
@ -195,20 +101,28 @@ class TestInstallHandler(ConfigTestCase):
return "seekrit" return "seekrit"
return default return default
with patch.object(handler, "prompt_generic", side_effect=prompt_generic): with patch.object(mod, "sys") as sys:
with patch.object(handler, "prompt_generic", side_effect=prompt_generic):
with patch.object(handler, "test_db_connection") as test_db_connection:
with patch.object(handler, "rprint") as rprint:
# bad dbinfo
test_db_connection.return_value = "bad dbinfo"
sys.exit.side_effect = RuntimeError
self.assertRaises(RuntimeError, handler.get_dbinfo)
sys.exit.assert_called_once_with(1)
dbinfo = handler.get_dbinfo() seekrit = "***" if SA2 else "seekrit"
self.assertEqual(
dbinfo, # good dbinfo
{ sys.exit.reset_mock()
"dbtype": "postgresql", test_db_connection.return_value = None
"dbhost": "localhost", dbinfo = handler.get_dbinfo()
"dbport": "5432", self.assertFalse(sys.exit.called)
"dbname": "poser", rprint.assert_called_with("[bold green]good[/bold green]")
"dbuser": "poser", self.assertEqual(
"dbpass": "seekrit", str(dbinfo["dburl"]),
}, f"postgresql+psycopg2://poser:{seekrit}@localhost:5432/poser",
) )
def test_make_db_url(self): def test_make_db_url(self):
try: try:
@ -222,28 +136,14 @@ class TestInstallHandler(ConfigTestCase):
seekrit = "***" if SA2 else "seekrit" seekrit = "***" if SA2 else "seekrit"
url = handler.make_db_url( url = handler.make_db_url(
dict( "postgresql", "localhost", "5432", "poser", "poser", "seekrit"
dbtype="postgresql",
dbhost="localhost",
dbport="5432",
dbname="poser",
dbuser="poser",
dbpass="seekrit",
)
) )
self.assertEqual( self.assertEqual(
str(url), f"postgresql+psycopg2://poser:{seekrit}@localhost:5432/poser" str(url), f"postgresql+psycopg2://poser:{seekrit}@localhost:5432/poser"
) )
url = handler.make_db_url( url = handler.make_db_url(
dict( "mysql", "localhost", "3306", "poser", "poser", "seekrit"
dbtype="mysql",
dbhost="localhost",
dbport="3306",
dbname="poser",
dbuser="poser",
dbpass="seekrit",
)
) )
self.assertEqual( self.assertEqual(
str(url), f"mysql+mysqlconnector://poser:{seekrit}@localhost:3306/poser" str(url), f"mysql+mysqlconnector://poser:{seekrit}@localhost:3306/poser"
@ -272,8 +172,8 @@ class TestInstallHandler(ConfigTestCase):
handler = self.make_handler() handler = self.make_handler()
# can handle dburl as string # can handle dburl as string
db_url = "sqlite:///poser.sqlite" dbinfo = {"dburl": "sqlite:///poser.sqlite"}
context = handler.make_template_context(db_url=db_url) context = handler.make_template_context(dbinfo)
self.assertEqual(context["envdir"], sys.prefix) self.assertEqual(context["envdir"], sys.prefix)
self.assertEqual(context["pkg_name"], "poser") self.assertEqual(context["pkg_name"], "poser")
self.assertEqual(context["app_title"], "poser") self.assertEqual(context["app_title"], "poser")
@ -288,8 +188,8 @@ class TestInstallHandler(ConfigTestCase):
pytest.skip("remainder of test is not relevant without sqlalchemy") pytest.skip("remainder of test is not relevant without sqlalchemy")
# but also can handle dburl as object # but also can handle dburl as object
db_url = sa.create_engine("sqlite:///poser.sqlite").url dbinfo = {"dburl": sa.create_engine("sqlite:///poser.sqlite").url}
context = handler.make_template_context(db_url=db_url) context = handler.make_template_context(dbinfo)
self.assertEqual(context["envdir"], sys.prefix) self.assertEqual(context["envdir"], sys.prefix)
self.assertEqual(context["pkg_name"], "poser") self.assertEqual(context["pkg_name"], "poser")
self.assertEqual(context["app_title"], "poser") self.assertEqual(context["app_title"], "poser")
@ -305,8 +205,8 @@ class TestInstallHandler(ConfigTestCase):
self.app.resource_path("wuttjamaican:templates/install"), self.app.resource_path("wuttjamaican:templates/install"),
] ]
) )
db_url = "sqlite:///poser.sqlite" dbinfo = {"dburl": "sqlite:///poser.sqlite"}
context = handler.make_template_context(db_url=db_url) context = handler.make_template_context(dbinfo)
handler.make_appdir(context, appdir=self.tempdir) handler.make_appdir(context, appdir=self.tempdir)
wutta_conf = os.path.join(self.tempdir, "wutta.conf") wutta_conf = os.path.join(self.tempdir, "wutta.conf")
with open(wutta_conf, "rt") as f: with open(wutta_conf, "rt") as f: