diff --git a/CHANGELOG.md b/CHANGELOG.md index 2b7c6c1..86fd41f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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/) 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) ### Fix diff --git a/pyproject.toml b/pyproject.toml index 8062bd9..7fc6e5f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -6,7 +6,7 @@ build-backend = "hatchling.build" [project] name = "WuttJamaican" -version = "0.28.5" +version = "0.28.4" description = "Base package for Wutta Framework" readme = "README.md" authors = [{name = "Lance Edgar", email = "lance@wuttaproject.org"}] diff --git a/src/wuttjamaican/install.py b/src/wuttjamaican/install.py index a6d004a..0f79676 100644 --- a/src/wuttjamaican/install.py +++ b/src/wuttjamaican/install.py @@ -87,9 +87,6 @@ class InstallHandler(GenericHandler): # pylint: disable=too-many-public-methods egg_name = None schema_installed = False - # nb. we prompt the user for this, unless attr already has value - wants_continuum = None - template_paths = ["wuttjamaican:templates/install"] 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: - * 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_appdir()` to create app dir with config files * call :meth:`install_db_schema()` to (optionally) create tables in DB """ - # prompt user / get context - context = self.prompt_user_for_context() - context = self.make_template_context(**context) + # prompt user for db info + dbinfo = self.get_dbinfo() + + # get context for generated app files + context = self.make_template_context(dbinfo) # make the appdir self.make_appdir(context) # 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 - the info, e.g. test the DB connection, but should not write - any files as the app dir may not exist yet. + This method is normally called by :meth:`do_install_steps()`. - Default logic calls :meth:`get_db_url()` for the DB - 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 + :returns: Dict of DB info collected from user. """ - # 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 = {} - # main info + # get db info dbinfo["dbtype"] = self.prompt_generic("db type", "postgresql") dbinfo["dbhost"] = self.prompt_generic("db host", "localhost") 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["dbuser"] = self.prompt_generic("db user", self.pkg_name) - # password + # get db password dbinfo["dbpass"] = None while not dbinfo["dbpass"]: 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 - 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 - if dbinfo["dbtype"] == "mysql": + if dbtype == "mysql": drivername = "mysql+mysqlconnector" else: drivername = "postgresql+psycopg2" return URL.create( drivername=drivername, - username=dbinfo["dbuser"], - password=dbinfo["dbpass"], - host=dbinfo["dbhost"], - port=dbinfo["dbport"], - database=dbinfo["dbname"], + username=dbuser, + password=dbpass, + host=dbhost, + port=dbport, + database=dbname, ) 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 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 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 :meth:`render_mako_template()`. - Note these first 2 params are not explicitly listed in the - method signature; they are required nonetheless. - - :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 dbinfo: Dict of DB connection info as obtained from + :meth:`get_dbinfo()`. :param \\**kwargs: Extra 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` * ``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` * ``egg_name`` - value from :attr:`egg_name` * ``appdir`` - ``app`` folder under ``sys.prefix`` - * ``db_url`` - value from ``kwargs`` - * ``wants_continuum`` - value from ``kwargs`` + * ``db_url`` - value from ``dbinfo['dburl']`` """ envname = os.path.basename(sys.prefix) appdir = os.path.join(sys.prefix, "app") - - db_url = kwargs.pop("db_url") - if not isinstance(db_url, str): - db_url = db_url.render_as_string(hide_password=False) - + dburl = dbinfo["dburl"] + if not isinstance(dburl, str): + dburl = dburl.render_as_string(hide_password=False) context = { "envdir": sys.prefix, "envname": envname, @@ -372,8 +319,8 @@ class InstallHandler(GenericHandler): # pylint: disable=too-many-public-methods "app_title": self.app_title, "pypi_name": self.pypi_name, "appdir": appdir, + "db_url": dburl, "egg_name": self.egg_name, - "db_url": db_url, } context.update(kwargs) return context diff --git a/src/wuttjamaican/templates/install/wutta.conf.mako b/src/wuttjamaican/templates/install/wutta.conf.mako index 477b476..d360f8b 100644 --- a/src/wuttjamaican/templates/install/wutta.conf.mako +++ b/src/wuttjamaican/templates/install/wutta.conf.mako @@ -56,11 +56,8 @@ preferdb = true <%def name="section_wutta_db()"> [wutta.db] default.url = ${db_url} - -% if wants_continuum: -[wutta_continuum] -enable_versioning = true -% endif +## TODO +## versioning.enabled = true <%def name="section_wutta_mail()"> @@ -97,11 +94,7 @@ templates = wuttjamaican:templates/mail <%def name="section_alembic()"> [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 -% endif <%def name="sectiongroup_logging()"> diff --git a/tests/test_install.py b/tests/test_install.py index 178683b..603485c 100644 --- a/tests/test_install.py +++ b/tests/test_install.py @@ -64,119 +64,25 @@ class TestInstallHandler(ConfigTestCase): def test_do_install_steps(self): handler = self.make_handler() - db_url = f"sqlite:///{self.tempdir}/poser.sqlite" - - with patch.object(handler, "prompt_user_for_context") as prompt_user: - 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 + handler.templates = TemplateLookup( + directories=[ + self.app.resource_path("wuttjamaican:templates/install"), + ] + ) dbinfo = { - "dbtype": "postgresql", - "dbhost": "localhost", - "dbport": 5432, - "dbname": "poser", - "dbuser": "poser", - "dbpass": "seekrit", + "dburl": f"sqlite:///{self.tempdir}/poser.sqlite", } + with patch.object(handler, "get_dbinfo", return_value=dbinfo): - with patch.object(handler, "test_db_connection", return_value=None): - db_url = handler.get_db_url() - seekrit = "***" if SA2 else "seekrit" - self.assertEqual( - str(db_url), - f"postgresql+psycopg2://poser:{seekrit}@localhost:5432/poser", - ) - - # now we test the "test db connection" feature - 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:///") + with patch.object(handler, "make_appdir") as make_appdir: + with patch.object(handler, "install_db_schema") as install_db_schema: + # nb. just for sanity/coverage + install_db_schema.return_value = True + self.assertFalse(handler.schema_installed) + handler.do_install_steps() + self.assertTrue(make_appdir.called) + self.assertTrue(handler.schema_installed) + install_db_schema.assert_called_once_with(dbinfo["dburl"]) def test_get_dbinfo(self): try: @@ -195,20 +101,28 @@ class TestInstallHandler(ConfigTestCase): return "seekrit" 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() - self.assertEqual( - dbinfo, - { - "dbtype": "postgresql", - "dbhost": "localhost", - "dbport": "5432", - "dbname": "poser", - "dbuser": "poser", - "dbpass": "seekrit", - }, - ) + seekrit = "***" if SA2 else "seekrit" + + # good dbinfo + sys.exit.reset_mock() + test_db_connection.return_value = None + dbinfo = handler.get_dbinfo() + self.assertFalse(sys.exit.called) + rprint.assert_called_with("[bold green]good[/bold green]") + self.assertEqual( + str(dbinfo["dburl"]), + f"postgresql+psycopg2://poser:{seekrit}@localhost:5432/poser", + ) def test_make_db_url(self): try: @@ -222,28 +136,14 @@ class TestInstallHandler(ConfigTestCase): seekrit = "***" if SA2 else "seekrit" url = handler.make_db_url( - dict( - dbtype="postgresql", - dbhost="localhost", - dbport="5432", - dbname="poser", - dbuser="poser", - dbpass="seekrit", - ) + "postgresql", "localhost", "5432", "poser", "poser", "seekrit" ) self.assertEqual( str(url), f"postgresql+psycopg2://poser:{seekrit}@localhost:5432/poser" ) url = handler.make_db_url( - dict( - dbtype="mysql", - dbhost="localhost", - dbport="3306", - dbname="poser", - dbuser="poser", - dbpass="seekrit", - ) + "mysql", "localhost", "3306", "poser", "poser", "seekrit" ) self.assertEqual( str(url), f"mysql+mysqlconnector://poser:{seekrit}@localhost:3306/poser" @@ -272,8 +172,8 @@ class TestInstallHandler(ConfigTestCase): handler = self.make_handler() # can handle dburl as string - db_url = "sqlite:///poser.sqlite" - context = handler.make_template_context(db_url=db_url) + dbinfo = {"dburl": "sqlite:///poser.sqlite"} + context = handler.make_template_context(dbinfo) self.assertEqual(context["envdir"], sys.prefix) self.assertEqual(context["pkg_name"], "poser") self.assertEqual(context["app_title"], "poser") @@ -288,8 +188,8 @@ class TestInstallHandler(ConfigTestCase): pytest.skip("remainder of test is not relevant without sqlalchemy") # but also can handle dburl as object - db_url = sa.create_engine("sqlite:///poser.sqlite").url - context = handler.make_template_context(db_url=db_url) + dbinfo = {"dburl": sa.create_engine("sqlite:///poser.sqlite").url} + context = handler.make_template_context(dbinfo) self.assertEqual(context["envdir"], sys.prefix) self.assertEqual(context["pkg_name"], "poser") self.assertEqual(context["app_title"], "poser") @@ -305,8 +205,8 @@ class TestInstallHandler(ConfigTestCase): self.app.resource_path("wuttjamaican:templates/install"), ] ) - db_url = "sqlite:///poser.sqlite" - context = handler.make_template_context(db_url=db_url) + dbinfo = {"dburl": "sqlite:///poser.sqlite"} + context = handler.make_template_context(dbinfo) handler.make_appdir(context, appdir=self.tempdir) wutta_conf = os.path.join(self.tempdir, "wutta.conf") with open(wutta_conf, "rt") as f: