From 2deba45588777df01514c2f31878eb6f04a4ff47 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Sat, 23 Nov 2024 11:48:28 -0600 Subject: [PATCH 1/4] feat: add `wutta` top-level command with `make-uuid` subcommand i think it only makes sense to have an "opinion" for command line interface in this project, and we probably need more `wutta` subcommands too but we'll see. main motivation for this currently is to allow poser apps to define their own CLI, in particular e.g. `poser install` --- docs/api/wuttjamaican/cli.base.rst | 6 ++ docs/api/wuttjamaican/cli.make_uuid.rst | 6 ++ docs/api/wuttjamaican/cli.rst | 6 ++ docs/api/wuttjamaican/index.rst | 3 + docs/glossary.rst | 4 +- docs/index.rst | 2 + docs/narr/cli/builtin.rst | 41 +++++++++ docs/narr/cli/commands.rst | 23 ----- docs/narr/cli/custom.rst | 105 +++++++++++++++++++++++ docs/narr/cli/index.rst | 19 ++++- docs/narr/cli/overview.rst | 15 ---- docs/narr/cli/scripts.rst | 10 +-- pyproject.toml | 5 ++ src/wuttjamaican/cli/__init__.py | 37 ++++++++ src/wuttjamaican/cli/base.py | 108 ++++++++++++++++++++++++ src/wuttjamaican/cli/make_uuid.py | 44 ++++++++++ tests/cli/__init__.py | 0 tests/cli/example.conf | 0 tests/cli/test_base.py | 39 +++++++++ tests/cli/test_make_uuid.py | 20 +++++ 20 files changed, 446 insertions(+), 47 deletions(-) create mode 100644 docs/api/wuttjamaican/cli.base.rst create mode 100644 docs/api/wuttjamaican/cli.make_uuid.rst create mode 100644 docs/api/wuttjamaican/cli.rst create mode 100644 docs/narr/cli/builtin.rst delete mode 100644 docs/narr/cli/commands.rst create mode 100644 docs/narr/cli/custom.rst delete mode 100644 docs/narr/cli/overview.rst create mode 100644 src/wuttjamaican/cli/__init__.py create mode 100644 src/wuttjamaican/cli/base.py create mode 100644 src/wuttjamaican/cli/make_uuid.py create mode 100644 tests/cli/__init__.py create mode 100644 tests/cli/example.conf create mode 100644 tests/cli/test_base.py create mode 100644 tests/cli/test_make_uuid.py diff --git a/docs/api/wuttjamaican/cli.base.rst b/docs/api/wuttjamaican/cli.base.rst new file mode 100644 index 0000000..c937040 --- /dev/null +++ b/docs/api/wuttjamaican/cli.base.rst @@ -0,0 +1,6 @@ + +``wuttjamaican.cli.base`` +========================= + +.. automodule:: wuttjamaican.cli.base + :members: diff --git a/docs/api/wuttjamaican/cli.make_uuid.rst b/docs/api/wuttjamaican/cli.make_uuid.rst new file mode 100644 index 0000000..8350d6e --- /dev/null +++ b/docs/api/wuttjamaican/cli.make_uuid.rst @@ -0,0 +1,6 @@ + +``wuttjamaican.cli.make_uuid`` +============================== + +.. automodule:: wuttjamaican.cli.make_uuid + :members: diff --git a/docs/api/wuttjamaican/cli.rst b/docs/api/wuttjamaican/cli.rst new file mode 100644 index 0000000..c0bb811 --- /dev/null +++ b/docs/api/wuttjamaican/cli.rst @@ -0,0 +1,6 @@ + +``wuttjamaican.cli`` +==================== + +.. automodule:: wuttjamaican.cli + :members: diff --git a/docs/api/wuttjamaican/index.rst b/docs/api/wuttjamaican/index.rst index 0c9d7c0..816593d 100644 --- a/docs/api/wuttjamaican/index.rst +++ b/docs/api/wuttjamaican/index.rst @@ -9,6 +9,9 @@ app auth + cli + cli.base + cli.make_uuid conf db db.conf diff --git a/docs/glossary.rst b/docs/glossary.rst index 3b87762..805de3f 100644 --- a/docs/glossary.rst +++ b/docs/glossary.rst @@ -163,8 +163,8 @@ Glossary subcommand A top-level :term:`command` may expose one or more subcommands, for the overall command line interface. Subcommands are usually - the real workhorse; each can perform a different function. See - also :doc:`narr/cli/index`. + the real workhorse; each can perform a different function with a + custom arg set. See also :doc:`narr/cli/index`. virtual environment This term comes from the broader Python world and refers to an diff --git a/docs/index.rst b/docs/index.rst index d2a6bd4..1e9e36b 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -22,8 +22,10 @@ Features * flexible configuration, using config files and/or DB settings table * flexible architecture, abstracting various portions of the overall app +* flexible command line interface, using `Typer`_ * flexible database support, using `SQLAlchemy`_ +.. _Typer: https://typer.tiangolo.com .. _SQLAlchemy: https://www.sqlalchemy.org See also these projects which build on WuttJamaican: diff --git a/docs/narr/cli/builtin.rst b/docs/narr/cli/builtin.rst new file mode 100644 index 0000000..0eafb2b --- /dev/null +++ b/docs/narr/cli/builtin.rst @@ -0,0 +1,41 @@ + +Built-in Commands +================= + +WuttJamaican comes with one top-level :term:`command`, and some +:term:`subcommands`. + +It uses `Typer`_ for the underlying CLI framework. + +.. _Typer: https://typer.tiangolo.com/ + + +``wutta`` +--------- + +This is the top-level command. Its purpose is to expose subcommands +pertaining to WuttJamaican. + +It is installed to the virtual environment in the ``bin`` folder (or +``Scripts`` on Windows): + +.. code-block:: sh + + cd /path/to/venv + bin/wutta --help + +Defined in: :mod:`wuttjamaican.cli` + +.. program-output:: wutta --help + + +.. _wutta-make-uuid: + +``wutta make-uuid`` +------------------- + +Print a new universally-unique identifier to standard output. + +Defined in: :mod:`wuttjamaican.cli.make_uuid` + +.. program-output:: wutta make-uuid --help diff --git a/docs/narr/cli/commands.rst b/docs/narr/cli/commands.rst deleted file mode 100644 index de73794..0000000 --- a/docs/narr/cli/commands.rst +++ /dev/null @@ -1,23 +0,0 @@ - -Commands -======== - -WuttJamaican in fact does not directly provide a way to define a -command line interface for your app. - -The reason is that several good frameworks exist already. You are -encouraged to use one of the following to define -:term:`commands` and :term:`subcommands` as -needed: - -* `Typer `_ -* `Click `_ -* :mod:`python:argparse` - -For even more options see: - -* `awesome-cli-framework `_ -* `Hitchhiker’s Guide to Python `_ -* `Python Wiki `_ - -Or if that is overkill you can always just use :doc:`scripts`. diff --git a/docs/narr/cli/custom.rst b/docs/narr/cli/custom.rst new file mode 100644 index 0000000..d893ba4 --- /dev/null +++ b/docs/narr/cli/custom.rst @@ -0,0 +1,105 @@ + +Custom Commands +=============== + +WuttJamaican comes with :doc:`/narr/cli/builtin`. + +Using the same framework, each :term:`package` can define additional +top-level :term:`command(s)` and +:term:`subcommands` as needed. + + +Top-Level Command +----------------- + +You must "define" *and* "register" your top-level command. Assuming a +basic Poser example: + +.. code-block:: none + + poser-project + ├── poser + │ ├── __init__.py + │ └── cli.py + └── pyproject.toml + +Add the command definition to the ``poser.cli`` module:: + + from wuttjamaican.cli import make_typer + + poser_typer = make_typer( + name='poser', + help="Poser - the killer app" + ) + +Then register the command as script in ``pyproject.toml``: + +.. code-block:: toml + + [project.scripts] + poser = "poser.cli:poser_typer" + +Then reinstall your project: + +.. code-block:: sh + + pip install -e ~/src/poser + +And now you can run your command: + +.. code-block:: sh + + poser --help + +But it won't really do anything until you add subcommands. + + +Subcommands +----------- + +You must "define" the subcommand of course, but do not need to +"register" it. (That happens via function decorator; see below.) + +However you *do* need to ensure all modules containing subcommands are +"eagerly imported" so the runtime discovery process finds everything. + +Here we'll define the ``poser hello`` subcommand, by adding it to our +``poser.cli`` module (from example above):: + + import sys + import typer + from wuttjamaican.cli import make_typer + + # top-level command + poser_typer = make_typer( + name='poser', + help="Poser - the killer app" + ) + + # nb. function decorator will auto-register the subcommand + @poser_typer.command() + def hello( + ctx: typer.Context, + ): + """ + Hello world example + """ + config = ctx.parent.wutta_config + app = config.get_app() + + name = config.get('hello.name', default="WhoAreYou") + sys.stdout.write(f'hello {name}\n') + + title = app.get_title() + sys.stdout.write(f'from {title}\n') + + # TODO: you may need to import other modules here, if they contain + # subcommands and would not be automatically imported otherwise. + # nb. *this* current module *is* automatically imported, only + # because of the top-level command registration in pyproject.toml + +No need to re-install, you can now use the subcommand: + +.. code-block:: sh + + poser hello --help diff --git a/docs/narr/cli/index.rst b/docs/narr/cli/index.rst index 2070234..fd45a34 100644 --- a/docs/narr/cli/index.rst +++ b/docs/narr/cli/index.rst @@ -2,9 +2,24 @@ Command Line Interface ====================== +Most apps will need some sort of command line usage, via cron or +otherwise. There are two main aspects to it: + +There is a proper CLI framework based on `Typer`_, with top-level +:term:`commands` and :term:`subcommands`. The +``wutta`` command is built-in and includes some subcommands, but each +app can define more of either as needed. Such (sub)commands are +installed as part of a :term:`package`. + +.. _Typer: https://typer.tiangolo.com + +But sometimes you just need an :term:`ad hoc script` which is a single +file and can be placed anywhere, usually *not* installed as part of a +package. + .. toctree:: :maxdepth: 2 - overview - commands + builtin + custom scripts diff --git a/docs/narr/cli/overview.rst b/docs/narr/cli/overview.rst deleted file mode 100644 index df69f9c..0000000 --- a/docs/narr/cli/overview.rst +++ /dev/null @@ -1,15 +0,0 @@ - -Overview -======== - -Many apps will need some sort of command line usage, via cron or -otherwise. There are two main aspects to it: - -First there is the :term:`ad hoc script` which is a single file and -can be placed anywhere, but is not installed as part of a -:term:`package`. See :doc:`scripts`. - -But a "true" command line interface may define -:term:`commands` and :term:`subcommands`, which -are then installed as part of a package. See :doc:`commands` for more -about that. diff --git a/docs/narr/cli/scripts.rst b/docs/narr/cli/scripts.rst index cb6cc7d..769a231 100644 --- a/docs/narr/cli/scripts.rst +++ b/docs/narr/cli/scripts.rst @@ -33,7 +33,7 @@ Run that like so: Better Standards ----------------- +~~~~~~~~~~~~~~~~ Keeping it simple, but improving that script per recommended patterns:: @@ -69,7 +69,7 @@ that this also gives you access to the :term:`app handler`:: def hello(config): app = config.get_app() print('hello', config.get('hello.name')) - print('from', app.appname) + print('from', app.get_title()) if __name__ == '__main__': config = make_config('my.conf') @@ -81,7 +81,7 @@ Output should now be different: $ python hello.py hello George - from wutta + from WuttJamaican You are likely to need more imports; it is generally wise to do those *within the function* as opposed to the top of the module. This is to @@ -97,7 +97,7 @@ before all packages are imported:: app = config.get_app() print('hello', config.get('hello.name')) - print('from', app.appname) + print('from', app.get_title()) something(config) @@ -143,7 +143,7 @@ Here is the script with logging incorporated:: log.debug("saying hello") app = config.get_app() print('hello', config.get('hello.name')) - print('from', app.appname) + print('from', app.get_title()) log.debug("about to do something") if something(config): diff --git a/pyproject.toml b/pyproject.toml index a970992..caffa3b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -30,6 +30,7 @@ dependencies = [ "importlib_resources ; python_version < '3.9'", "progress", "python-configuration", + "typer", ] @@ -40,6 +41,10 @@ docs = ["Sphinx", "sphinxcontrib-programoutput", "enum-tools[sphinx]", "furo"] tests = ["pytest-cov", "tox"] +[project.scripts] +wutta = "wuttjamaican.cli:wutta_typer" + + [project.urls] Homepage = "https://wuttaproject.org/" Repository = "https://forgejo.wuttaproject.org/wutta/wuttjamaican" diff --git a/src/wuttjamaican/cli/__init__.py b/src/wuttjamaican/cli/__init__.py new file mode 100644 index 0000000..9707748 --- /dev/null +++ b/src/wuttjamaican/cli/__init__.py @@ -0,0 +1,37 @@ +# -*- 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 - command line interface + +See also :doc:`/narr/cli/index`. + +This (``wuttjamaican.cli``) namespace exposes the following: + +* :func:`~wuttjamaican.cli.base.make_typer` +* :data:`~wuttjamaican.cli.base.wutta_typer` (top-level command) +""" + +from .base import wutta_typer, make_typer + +# TODO: is this the best we can do, to register available commands? +from . import make_uuid diff --git a/src/wuttjamaican/cli/base.py b/src/wuttjamaican/cli/base.py new file mode 100644 index 0000000..05e3454 --- /dev/null +++ b/src/wuttjamaican/cli/base.py @@ -0,0 +1,108 @@ +# -*- 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 - core command logic + +See also :doc:`/narr/cli/index`. + +.. data:: wutta_typer + + This is the top-level ``wutta`` :term:`command`, using the Typer + framework. + + See also :func:`make_typer()`. +""" + +import logging +from pathlib import Path +from typing import List, Optional + +import typer +from typing_extensions import Annotated + +from wuttjamaican.conf import make_config + + +def make_cli_config(ctx: typer.Context): + """ + Make a :term:`config object` according to the command-line context + (params). + + This function is normally called by :func:`typer_callback()`. + + This function calls :func:`~wuttjamaican.conf.make_config()` using + config files specified via command line (if any). + + :param ctx: ``typer.Context`` instance + + :returns: :class:`~wuttjamaican.conf.WuttaConfig` instance + """ + logging.basicConfig() + return make_config(files=ctx.params.get('config_paths') or None) + + +def typer_callback( + ctx: typer.Context, + + config_paths: Annotated[ + Optional[List[Path]], + typer.Option('--config', '-c', + exists=True, + help="Config path (may be specified more than once)")] = None, +): + """ + Generic callback for use with top-level commands. This adds some + top-level args: + + * ``--config`` (and ``-c``) + + This callback is responsible for creating the :term:`config + object` for the command. (It calls :func:`make_cli_config()` for + that.) It then attaches it to the context as + ``ctx.wutta_config``. + """ + ctx.wutta_config = make_cli_config(ctx) + + +def make_typer(**kwargs): + """ + Create a Typer command instance, per Wutta conventions. + + This function is used to create the top-level ``wutta`` command, + :data:`wutta_typer`. You can use it to create additional + top-level commands for your app if needed. (And don't forget to + register; see :doc:`/narr/cli/custom`.) + + :param callback: Override for the ``Typer.callback`` param. If + not specified, :func:`typer_callback` is used. + + :returns: ``typer.Typer`` instance + """ + kwargs.setdefault('callback', typer_callback) + return typer.Typer(**kwargs) + + +wutta_typer = make_typer( + name='wutta', + help="Wutta Software Framework" +) diff --git a/src/wuttjamaican/cli/make_uuid.py b/src/wuttjamaican/cli/make_uuid.py new file mode 100644 index 0000000..21b9cf7 --- /dev/null +++ b/src/wuttjamaican/cli/make_uuid.py @@ -0,0 +1,44 @@ +# -*- 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 . +# +################################################################################ +""" +See also: :ref:`wutta-make-uuid` +""" + +import sys + +import typer + +from .base import wutta_typer + + +@wutta_typer.command() +def make_uuid( + ctx: typer.Context, +): + """ + Generate a new UUID + """ + config = ctx.parent.wutta_config + app = config.get_app() + uuid = app.make_uuid() + sys.stdout.write(f"{uuid}\n") diff --git a/tests/cli/__init__.py b/tests/cli/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/cli/example.conf b/tests/cli/example.conf new file mode 100644 index 0000000..e69de29 diff --git a/tests/cli/test_base.py b/tests/cli/test_base.py new file mode 100644 index 0000000..214843e --- /dev/null +++ b/tests/cli/test_base.py @@ -0,0 +1,39 @@ +# -*- coding: utf-8; -*- + +import os +from unittest import TestCase +from unittest.mock import MagicMock + +import typer + +from wuttjamaican.conf import WuttaConfig +from wuttjamaican.cli import base as mod + + +here = os.path.dirname(__file__) +example_conf = os.path.join(here, 'example.conf') + + +class TestMakeCliConfig(TestCase): + + def test_basic(self): + ctx = MagicMock(params={'config_paths': [example_conf]}) + config = mod.make_cli_config(ctx) + self.assertIsInstance(config, WuttaConfig) + self.assertEqual(config.files_read, [example_conf]) + + +class TestTyperCallback(TestCase): + + def test_basic(self): + ctx = MagicMock(params={'config_paths': [example_conf]}) + mod.typer_callback(ctx) + self.assertIsInstance(ctx.wutta_config, WuttaConfig) + self.assertEqual(ctx.wutta_config.files_read, [example_conf]) + + +class TestMakeTyper(TestCase): + + def test_basic(self): + typr = mod.make_typer() + self.assertIsInstance(typr, typer.Typer) diff --git a/tests/cli/test_make_uuid.py b/tests/cli/test_make_uuid.py new file mode 100644 index 0000000..05c5c8b --- /dev/null +++ b/tests/cli/test_make_uuid.py @@ -0,0 +1,20 @@ +# -*- coding: utf-8; -*- + +import os +from unittest import TestCase +from unittest.mock import MagicMock, patch + +from wuttjamaican.cli import make_uuid as mod + + +here = os.path.dirname(__file__) +example_conf = os.path.join(here, 'example.conf') + + +class TestMakeUuid(TestCase): + + def test_basic(self): + ctx = MagicMock(params={'config_paths': [example_conf]}) + with patch.object(mod, 'sys') as sys: + mod.make_uuid(ctx) + sys.stdout.write.assert_called_once() From 49e77d74078cefbcd297a2f875639b0a7cfe7954 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Sat, 23 Nov 2024 15:33:00 -0600 Subject: [PATCH 2/4] feat: add `parse_bool()` and `parse_list()` methods for config object --- src/wuttjamaican/conf.py | 28 +++++++--- tests/test_conf.py | 108 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 128 insertions(+), 8 deletions(-) diff --git a/src/wuttjamaican/conf.py b/src/wuttjamaican/conf.py index a54c38d..785ce7b 100644 --- a/src/wuttjamaican/conf.py +++ b/src/wuttjamaican/conf.py @@ -268,14 +268,14 @@ class WuttaConfig: # bring in any "required" files requires = config.get(f'{self.appname}.config.require') if requires: - for path in parse_list(requires): + for path in self.parse_list(requires): path = path % {'here': here} self._load_ini_configs(path, configs, require=True) # bring in any "included" files includes = config.get(f'{self.appname}.config.include') if includes: - for path in parse_list(includes): + for path in self.parse_list(includes): path = path % {'here': here} self._load_ini_configs(path, configs, require=False) @@ -475,11 +475,10 @@ class WuttaConfig: Retrieve a boolean value from config. Accepts same params as :meth:`get()` but if a value is found, - it will be coerced to boolean via - :func:`~wuttjamaican.util.parse_bool()`. + it will be coerced to boolean via :meth:`parse_bool()`. """ value = self.get(*args, **kwargs) - return parse_bool(value) + return self.parse_bool(value) def get_int(self, *args, **kwargs): """ @@ -498,15 +497,14 @@ class WuttaConfig: Retrieve a list value from config. Accepts same params as :meth:`get()` but if a value is found, - it will be coerced to list via - :func:`~wuttjamaican.util.parse_list()`. + it will be coerced to list via :meth:`parse_list()`. :returns: If a value is found, a list is returned. If no value, returns ``None``. """ value = self.get(*args, **kwargs) if value is not None: - return parse_list(value) + return self.parse_list(value) def get_dict(self, prefix): """ @@ -549,6 +547,20 @@ class WuttaConfig: return values.as_dict() + def parse_bool(self, value): + """ + Convenience wrapper for + :func:`wuttjamaican.util.parse_bool()`. + """ + return parse_bool(value) + + def parse_list(self, value): + """ + Convenience wrapper for + :func:`wuttjamaican.util.parse_list()`. + """ + return parse_list(value) + def _configure_logging(self): """ This will save the current config parser defaults to a diff --git a/tests/test_conf.py b/tests/test_conf.py index 690cee1..9ef533e 100644 --- a/tests/test_conf.py +++ b/tests/test_conf.py @@ -17,6 +17,9 @@ from wuttjamaican.testing import FileTestCase class TestWuttaConfig(FileTestCase): + def make_config(self, **kwargs): + return mod.WuttaConfig(**kwargs) + def test_contstructor_basic(self): config = conf.WuttaConfig() self.assertEqual(config.appname, 'wutta') @@ -410,6 +413,111 @@ configure_logging = true config.setdefault('foo.bar', 'hello world') self.assertEqual(config.get_list('foo.bar'), ['hello', 'world']) + def test_parse_bool_null(self): + config = self.make_config() + self.assertIsNone(config.parse_bool(None)) + + def test_parse_bool_bool(self): + config = self.make_config() + self.assertTrue(config.parse_bool(True)) + self.assertFalse(config.parse_bool(False)) + + def test_parse_bool_string_true(self): + config = self.make_config() + self.assertTrue(config.parse_bool('true')) + self.assertTrue(config.parse_bool('yes')) + self.assertTrue(config.parse_bool('y')) + self.assertTrue(config.parse_bool('on')) + self.assertTrue(config.parse_bool('1')) + + def test_parse_bool_string_false(self): + config = self.make_config() + self.assertFalse(config.parse_bool('false')) + self.assertFalse(config.parse_bool('no')) + self.assertFalse(config.parse_bool('n')) + self.assertFalse(config.parse_bool('off')) + self.assertFalse(config.parse_bool('0')) + # nb. assume false for unrecognized input + self.assertFalse(config.parse_bool('whatever-else')) + + def test_parse_list_null(self): + config = self.make_config() + value = config.parse_list(None) + self.assertIsInstance(value, list) + self.assertEqual(len(value), 0) + + def test_parse_list_list_instance(self): + config = self.make_config() + mylist = [] + value = config.parse_list(mylist) + self.assertIs(value, mylist) + + def test_parse_list_single_value(self): + config = self.make_config() + value = config.parse_list('foo') + self.assertEqual(len(value), 1) + self.assertEqual(value[0], 'foo') + + def test_parse_list_single_value_padded_by_spaces(self): + config = self.make_config() + value = config.parse_list(' foo ') + self.assertEqual(len(value), 1) + self.assertEqual(value[0], 'foo') + + def test_parse_list_slash_is_not_a_separator(self): + config = self.make_config() + value = config.parse_list('/dev/null') + self.assertEqual(len(value), 1) + self.assertEqual(value[0], '/dev/null') + + def test_parse_list_multiple_values_separated_by_whitespace(self): + config = self.make_config() + value = config.parse_list('foo bar baz') + self.assertEqual(len(value), 3) + self.assertEqual(value[0], 'foo') + self.assertEqual(value[1], 'bar') + self.assertEqual(value[2], 'baz') + + def test_parse_list_multiple_values_separated_by_commas(self): + config = self.make_config() + value = config.parse_list('foo,bar,baz') + self.assertEqual(len(value), 3) + self.assertEqual(value[0], 'foo') + self.assertEqual(value[1], 'bar') + self.assertEqual(value[2], 'baz') + + def test_parse_list_multiple_values_separated_by_whitespace_and_commas(self): + config = self.make_config() + value = config.parse_list(' foo, bar baz') + self.assertEqual(len(value), 3) + self.assertEqual(value[0], 'foo') + self.assertEqual(value[1], 'bar') + self.assertEqual(value[2], 'baz') + + def test_parse_list_multiple_values_separated_by_whitespace_and_commas_with_some_quoting(self): + config = self.make_config() + value = config.parse_list(""" + foo + "C:\\some path\\with spaces\\and, a comma", + baz + """) + self.assertEqual(len(value), 3) + self.assertEqual(value[0], 'foo') + self.assertEqual(value[1], 'C:\\some path\\with spaces\\and, a comma') + self.assertEqual(value[2], 'baz') + + def test_parse_list_multiple_values_separated_by_whitespace_and_commas_with_single_quotes(self): + config = self.make_config() + value = config.parse_list(""" + foo + 'C:\\some path\\with spaces\\and, a comma', + baz + """) + self.assertEqual(len(value), 3) + self.assertEqual(value[0], 'foo') + self.assertEqual(value[1], 'C:\\some path\\with spaces\\and, a comma') + self.assertEqual(value[2], 'baz') + def test_get_app(self): # default handler From ceeff7e911c909364e98ba28a63a7b54878d31fd Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Sun, 24 Nov 2024 10:13:56 -0600 Subject: [PATCH 3/4] feat: add install handler and related logic - Mako is now a core dependency - therefore no more 'email' extra - add `get_install_handler()` method for app handler - add `render_mako_template()` method for app handler - add `resource_path()` method for app handler - install handler thus far can: - confirm db connection - make appdir plus config/scripts: - wutta.conf - web.conf - upgrade.sh - upgrade db schema to create tables - from there web app can run, create admin user - quick start docs now describe "generated code" option --- docs/api/wuttjamaican/index.rst | 1 + docs/api/wuttjamaican/install.rst | 6 + docs/conf.py | 2 + docs/glossary.rst | 6 + docs/narr/install/quickstart.rst | 52 +- pyproject.toml | 2 +- src/wuttjamaican/app.py | 56 +- src/wuttjamaican/install.py | 583 ++++++++++++++++++ .../templates/install/upgrade.sh.mako | 29 + .../templates/install/web.conf.mako | 90 +++ .../templates/install/wutta.conf.mako | 160 +++++ src/wuttjamaican/testing.py | 56 +- tests/test_app.py | 60 +- tests/test_install.py | 452 ++++++++++++++ tox.ini | 3 +- 15 files changed, 1526 insertions(+), 32 deletions(-) create mode 100644 docs/api/wuttjamaican/install.rst create mode 100644 src/wuttjamaican/install.py create mode 100755 src/wuttjamaican/templates/install/upgrade.sh.mako create mode 100644 src/wuttjamaican/templates/install/web.conf.mako create mode 100644 src/wuttjamaican/templates/install/wutta.conf.mako create mode 100644 tests/test_install.py diff --git a/docs/api/wuttjamaican/index.rst b/docs/api/wuttjamaican/index.rst index 816593d..1f8456b 100644 --- a/docs/api/wuttjamaican/index.rst +++ b/docs/api/wuttjamaican/index.rst @@ -26,6 +26,7 @@ email.message enum exc + install people progress testing diff --git a/docs/api/wuttjamaican/install.rst b/docs/api/wuttjamaican/install.rst new file mode 100644 index 0000000..6abd27e --- /dev/null +++ b/docs/api/wuttjamaican/install.rst @@ -0,0 +1,6 @@ + +``wuttjamaican.install`` +======================== + +.. automodule:: wuttjamaican.install + :members: diff --git a/docs/conf.py b/docs/conf.py index cbee050..be9bc2f 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -29,11 +29,13 @@ templates_path = ['_templates'] exclude_patterns = ['_build', 'Thumbs.db', '.DS_Store'] intersphinx_mapping = { + 'mako': ('https://docs.makotemplates.org/en/latest/', None), 'packaging': ('https://packaging.python.org/en/latest/', None), 'python': ('https://docs.python.org/3/', None), 'python-configuration': ('https://python-configuration.readthedocs.io/en/latest/', None), 'rattail': ('https://rattailproject.org/docs/rattail/', None), 'rattail-manual': ('https://rattailproject.org/docs/rattail-manual/', None), + 'rich': ('https://rich.readthedocs.io/en/latest/', None), 'sqlalchemy': ('http://docs.sqlalchemy.org/en/latest/', None), 'wutta-continuum': ('https://rattailproject.org/docs/wutta-continuum/', None), } diff --git a/docs/glossary.rst b/docs/glossary.rst index 805de3f..2eab2c6 100644 --- a/docs/glossary.rst +++ b/docs/glossary.rst @@ -145,6 +145,12 @@ Glossary Similar to a "plugin" concept but only *one* handler may be used for a given purpose. See also :doc:`narr/handlers/index`. + install handler + The :term:`handler` responsible for installing a new instance of + the :term:`app`. + + Default is :class:`~wuttjamaican.install.InstallHandler`. + package Generally refers to a proper Python package, i.e. a collection of modules etc. which is installed via ``pip``. See also diff --git a/docs/narr/install/quickstart.rst b/docs/narr/install/quickstart.rst index c8be0e3..1679267 100644 --- a/docs/narr/install/quickstart.rst +++ b/docs/narr/install/quickstart.rst @@ -2,6 +2,56 @@ Quick Start =========== +We have two varieties of "quick start" instructions: + +* :ref:`quick-start-generated` +* :ref:`quick-start-manual` + + +.. _quick-start-generated: + +From Generated Code +------------------- + +Note that this section describes an app based on WuttaWeb (i.e. not +just WuttJamaican). + +There is a tool to `generate new project code`_, on the Rattail Demo +site. Use it to download a ZIP file (e.g. ``poser.zip``) for your +project. + +.. _generate new project code: https://demo.rattailproject.org/generated-projects/new/wutta + +Make a local :term:`virtual environment` for your project. + +Also make a new e.g. ``poser`` database in PostgreSQL (or MySQL). + +Unzip and install the source code to the virtual environment, then run +the app installer: + +.. code-block:: sh + + mkdir -p ~/src + unzip ~/Downloads/poser.zip -d ~/src + + cd /path/to/venv + bin/pip install -e ~/src/poser + bin/poser install + +Assuming all goes well, you can run the web app with: + +.. code-block:: sh + + bin/pserve --reload file+ini:app/web.conf + +And browse it at http://localhost:9080 + + +.. _quick-start-manual: + +From Scratch +------------ + This shows the *minimum* use case, basically how to make/use the :term:`config object` and :term:`app handler`. @@ -67,7 +117,7 @@ For more info see: .. _db-setup: Database Setup -============== +~~~~~~~~~~~~~~ You should already have the package installed (see previous section). diff --git a/pyproject.toml b/pyproject.toml index caffa3b..cf69df5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -28,6 +28,7 @@ requires-python = ">= 3.8" dependencies = [ 'importlib-metadata; python_version < "3.10"', "importlib_resources ; python_version < '3.9'", + "Mako", "progress", "python-configuration", "typer", @@ -36,7 +37,6 @@ dependencies = [ [project.optional-dependencies] db = ["SQLAlchemy<2", "alembic", "alembic-postgresql-enum", "passlib"] -email = ["Mako"] docs = ["Sphinx", "sphinxcontrib-programoutput", "enum-tools[sphinx]", "furo"] tests = ["pytest-cov", "tox"] diff --git a/src/wuttjamaican/app.py b/src/wuttjamaican/app.py index 98aa8ab..8e4d891 100644 --- a/src/wuttjamaican/app.py +++ b/src/wuttjamaican/app.py @@ -30,8 +30,8 @@ import sys import warnings from wuttjamaican.util import (load_entry_points, load_object, - make_title, make_uuid, parse_bool, - progress_loop) + make_title, make_uuid, + progress_loop, resource_path) class AppHandler: @@ -82,6 +82,7 @@ class AppHandler: default_enum_spec = 'wuttjamaican.enum' default_auth_handler_spec = 'wuttjamaican.auth:AuthHandler' default_email_handler_spec = 'wuttjamaican.email:EmailHandler' + default_install_handler_spec = 'wuttjamaican.install:InstallHandler' default_people_handler_spec = 'wuttjamaican.people:PeopleHandler' def __init__(self, config): @@ -418,20 +419,52 @@ class AppHandler: :param subfolders: Optional list of subfolder names to create within the app dir. If not specified, defaults will be: - ``['data', 'log', 'work']``. + ``['cache', 'data', 'log', 'work']``. """ appdir = path if not os.path.exists(appdir): os.makedirs(appdir) if not subfolders: - subfolders = ['data', 'log', 'work'] + subfolders = ['cache', 'data', 'log', 'work'] for name in subfolders: path = os.path.join(appdir, name) if not os.path.exists(path): os.mkdir(path) + def render_mako_template( + self, + template, + context, + output_path=None, + ): + """ + Convenience method to render a Mako template. + + :param template: :class:`~mako:mako.template.Template` + instance. + + :param context: Dict of context for the template. + + :param output_path: Optional path to which output should be + written. + + :returns: Rendered output as string. + """ + output = template.render(**context) + if output_path: + with open(output_path, 'wt') as f: + f.write(output) + return output + + def resource_path(self, path): + """ + Convenience wrapper for + :func:`wuttjamaican.util.resource_path()`. + """ + return resource_path(path) + def make_session(self, **kwargs): """ Creates a new SQLAlchemy session for the app DB. By default @@ -637,6 +670,19 @@ class AppHandler: self.handlers['email'] = factory(self.config, **kwargs) return self.handlers['email'] + def get_install_handler(self, **kwargs): + """ + Get the configured :term:`install handler`. + + :rtype: :class:`~wuttjamaican.install.handler.InstallHandler` + """ + if 'install' not in self.handlers: + spec = self.config.get(f'{self.appname}.install.handler', + default=self.default_install_handler_spec) + factory = self.load_object(spec) + self.handlers['install'] = factory(self.config, **kwargs) + return self.handlers['install'] + def get_people_handler(self, **kwargs): """ Get the configured "people" :term:`handler`. @@ -727,7 +773,7 @@ class GenericHandler: instance of :class:`~wuttjamaican.conf.WuttaConfig`. """ - def __init__(self, config, **kwargs): + def __init__(self, config): self.config = config self.app = self.config.get_app() diff --git a/src/wuttjamaican/install.py b/src/wuttjamaican/install.py new file mode 100644 index 0000000..2212fa2 --- /dev/null +++ b/src/wuttjamaican/install.py @@ -0,0 +1,583 @@ +# -*- 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 . +# +################################################################################ +""" +Install Handler +""" + +import os +import shutil +import stat +import subprocess +import sys + +import rich +from mako.lookup import TemplateLookup + +from wuttjamaican.app import GenericHandler + + +class InstallHandler(GenericHandler): + """ + Base class and default implementation for the :term:`install + handler`. + + See also + :meth:`~wuttjamaican.app.AppHandler.get_install_handler()`. + + The installer runs interactively via command line, prompting the + user for various config settings etc. + + If installation completes okay the exit code is 0, but if not: + + * exit code 1 indicates user canceled + * exit code 2 indicates sanity check failed + * other codes possible if errors occur + + Usually an app will define e.g. ``poser install`` command which + would invoke the install handler's :meth:`run()` method:: + + app = config.get_app() + install = app.get_install_handler(pkg_name='poser') + install.run() + + Note that these first 4 attributes may be specified via + constructor kwargs: + + .. attribute:: pkg_name + + Python package name for the app, e.g. ``poser``. + + .. attribute:: app_title + + Display title for the app, e.g. "Poser". + + .. attribute:: pypi_name + + Package distribution name, e.g. for PyPI. If not specified one + will be guessed. + + .. attribute:: egg_name + + Egg name for the app. If not specified one will be guessed. + + """ + pkg_name = 'poser' + app_title = None + pypi_name = None + egg_name = None + + def __init__(self, config, **kwargs): + super().__init__(config) + + # nb. caller may specify pkg_name etc. + self.__dict__.update(kwargs) + + # some package names we can generate by default + if not self.app_title: + self.app_title = self.pkg_name + if not self.pypi_name: + self.pypi_name = self.app_title + if not self.egg_name: + self.egg_name = self.pypi_name.replace('-', '_') + + def run(self): + """ + Run the interactive command-line installer. + + This does the following: + + * check for ``prompt_toolkit`` and maybe ask to install it + * define the template lookup paths, for making config files + * call :meth:`show_welcome()` + * call :meth:`sanity_check()` + * call :meth:`do_install_steps()` + * call :meth:`show_goodbye()` + + Although if a problem is encountered then not all calls may + happen. + """ + self.require_prompt_toolkit() + + paths = [ + self.app.resource_path('wuttjamaican:templates/install'), + ] + + try: + paths.insert(0, self.app.resource_path(f'{self.pkg_name}:templates/install')) + except ModuleNotFoundError: + pass + + self.templates = TemplateLookup(directories=paths) + + self.show_welcome() + self.sanity_check() + self.schema_installed = False + self.do_install_steps() + self.show_goodbye() + + def show_welcome(self): + """ + Show the intro/welcome message, and prompt user to begin the + install. + + This is normally called by :meth:`run()`. + """ + self.rprint("\n\t[blue]Welcome to {}![/blue]".format(self.app.get_title())) + self.rprint("\n\tThis tool will install and configure the app.") + self.rprint("\n\t[italic]NB. You should already have created the database in PostgreSQL or MySQL.[/italic]") + + # shall we continue? + if not self.prompt_bool("continue?", True): + self.rprint() + sys.exit(1) + + def sanity_check(self): + """ + Perform various sanity checks before doing the install. If + any problem is found the installer should exit with code 2. + + This is normally called by :meth:`run()`. + """ + # appdir must not yet exist + appdir = os.path.join(sys.prefix, 'app') + if os.path.exists(appdir): + self.rprint(f"\n\t[bold red]appdir already exists:[/bold red] {appdir}\n") + sys.exit(2) + + def do_install_steps(self): + """ + Perform the real installation steps. + + This method is called by :meth:`run()` and does the following: + + * 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 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(dbinfo['dburl']) + + def get_dbinfo(self): + """ + Collect DB connection info from the user, and test the + connection. If connection fails, exit the install. + + This method is normally called by :meth:`do_install_steps()`. + + :returns: Dict of DB info collected from user. + """ + dbinfo = {} + + # 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' + dbinfo['dbport'] = self.prompt_generic('db port', default_port) + dbinfo['dbname'] = self.prompt_generic('db name', self.pkg_name) + dbinfo['dbuser'] = self.prompt_generic('db user', self.pkg_name) + + # 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("\n{}".format(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, dbtype, dbhost, dbport, dbname, dbuser, dbpass): + from sqlalchemy.engine import URL + + if dbtype == 'mysql': + drivername = 'mysql+mysqlconnector' + else: + drivername = 'postgresql+psycopg2' + + return URL.create(drivername=drivername, + username=dbuser, + password=dbpass, + host=dbhost, + port=dbport, + database=dbname) + + def test_db_connection(self, url): + import sqlalchemy as sa + + engine = sa.create_engine(url) + + # check for random table; does not matter if it exists, we + # just need to test interaction and this is a neutral way + try: + sa.inspect(engine).has_table('whatever') + except Exception as error: + return str(error) + + 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. + + This method is normally called by :meth:`do_install_steps()`. + The ``context`` returned is then passed to + :meth:`render_mako_template()`. + + :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 context dict will include: + + * ``envdir`` - value from :data:`python:sys.prefix` + * ``envname`` - "last" dirname from ``sys.prefix`` + * ``pkg_name`` - value from :attr:`pkg_name` + * ``app_title`` - value from :attr:`app_title` + * ``pypi_name`` - value from :attr:`pypi_name` + * ``egg_name`` - value from :attr:`egg_name` + * ``appdir`` - ``app`` folder under ``sys.prefix`` + * ``db_url`` - value from ``dbinfo['dburl']`` + """ + envname = os.path.basename(sys.prefix) + appdir = os.path.join(sys.prefix, 'app') + context = { + 'envdir': sys.prefix, + 'envname': envname, + 'pkg_name': self.pkg_name, + 'app_title': self.app_title, + 'pypi_name': self.pypi_name, + 'appdir': appdir, + 'db_url': dbinfo['dburl'], + 'egg_name': self.egg_name, + } + context.update(kwargs) + return context + + def make_appdir(self, context, appdir=None): + """ + Create the app folder structure and generate config files. + + This method is normally called by :meth:`do_install_steps()`. + + :param context: Template context dict, i.e. from + :meth:`make_template_context()`. + + The default logic will create a structure as follows, assuming + ``/venv`` is the path to the virtual environment: + + .. code-block:: none + + /venv/ + └── app/ + ├── cache/ + ├── data/ + ├── log/ + ├── work/ + ├── wutta.conf + ├── web.conf + └── upgrade.sh + + File templates for this come from + ``wuttjamaican:templates/install`` by default. + """ + # app handler makes appdir proper + appdir = appdir or self.app.get_appdir() + self.app.make_appdir(appdir) + + # but then we also generate some files... + + # wutta.conf + self.make_config_file('wutta.conf.mako', + os.path.join(appdir, 'wutta.conf'), + **context) + + # web.conf + web_context = dict(context) + web_context.setdefault('beaker_key', context.get('pkg_name', 'poser')) + web_context.setdefault('beaker_secret', 'TODO_YOU_SHOULD_CHANGE_THIS') + web_context.setdefault('pyramid_host', '0.0.0.0') + web_context.setdefault('pyramid_port', '9080') + self.make_config_file('web.conf.mako', + os.path.join(appdir, 'web.conf'), + **web_context) + + # upgrade.sh + template = self.templates.get_template('upgrade.sh.mako') + output_path = os.path.join(appdir, 'upgrade.sh') + self.render_mako_template(template, context, + output_path=output_path) + os.chmod(output_path, stat.S_IRWXU + | stat.S_IRGRP + | stat.S_IXGRP + | stat.S_IROTH + | stat.S_IXOTH) + + self.rprint(f"\n\tappdir created at: [bold green]{appdir}[/bold green]") + + def render_mako_template( + self, + template, + context, + output_path=None, + ): + """ + Convenience wrapper around + :meth:`~wuttjamaican.app.AppHandler.render_mako_template()`. + + :param template: :class:`~mako:mako.template.Template` + instance, or name of one to fetch via lookup. + + This method allows specifying the template by name, in which + case the real template object is fetched via lookup. + + Other args etc. are the same as for the wrapped app handler + method. + """ + if isinstance(template, str): + template = self.templates.get_template(template) + + return self.app.render_mako_template(template, context, + output_path=output_path) + + def make_config_file(self, template, output_path, **kwargs): + """ + Write a new config file to the given path, using the given + template and context. + + :param template: :class:`~mako:mako.template.Template` + instance, or name of one to fetch via lookup. + + :param output_path: Path to which output should be written. + + :param \**kwargs: Extra context for the template. + + Some context will be provided automatically for the template, + but these may be overridden via the ``**kwargs``: + + * ``app_title`` - value from + :meth:`~wuttjamaican.app.AppHandler.get_title()`. + * ``appdir`` - value from + :meth:`~wuttjamaican.app.AppHandler.get_appdir()`. + * ``db_url`` - poser/dummy value + * ``os`` - reference to :mod:`os` module + + This method is mostly about sorting out the context dict. + Once it does that it calls :meth:`render_mako_template()`. + """ + context = { + 'app_title': self.app.get_title(), + 'appdir': self.app.get_appdir(), + 'db_url': 'postresql://user:pass@localhost/poser', + 'os': os, + } + context.update(kwargs) + self.render_mako_template(template, context, + output_path=output_path) + return output_path + + def install_db_schema(self, db_url, appdir=None): + """ + First prompt the user, but if they agree then apply all + Alembic migrations to the configured database. + + This method is normally called by :meth:`do_install_steps()`. + The end result should be a complete schema, ready for the app + to use. + + :param db_url: :class:`sqlalchemy:sqlalchemy.engine.URL` + instance. + """ + from alembic.util.messaging import obfuscate_url_pw + + if not self.prompt_bool("install db schema?", True): + return False + + self.rprint() + + # install db schema + appdir = appdir or self.app.get_appdir() + cmd = [os.path.join(sys.prefix, 'bin', 'alembic'), + '-c', os.path.join(appdir, 'wutta.conf'), + 'upgrade', 'heads'] + subprocess.check_call(cmd) + + self.rprint("\n\tdb schema installed to: [bold green]{}[/bold green]".format( + obfuscate_url_pw(db_url))) + return True + + def show_goodbye(self): + """ + Show the final message; this assumes setup completed okay. + + This is normally called by :meth:`run()`. + """ + self.rprint("\n\t[bold green]initial setup is complete![/bold green]") + + if self.schema_installed: + self.rprint("\n\tyou can run the web app with:") + self.rprint(f"\n\t[blue]cd {sys.prefix}[/blue]") + self.rprint("\t[blue]bin/pserve file+ini:app/web.conf[/blue]") + + self.rprint() + + ############################## + # console utility functions + ############################## + + def require_prompt_toolkit(self, answer=None): + try: + import prompt_toolkit + except ImportError: + value = answer or input("\nprompt_toolkit is not installed. shall i install it? [Yn] ") + value = value.strip() + if value and not self.config.parse_bool(value): + sys.stderr.write("prompt_toolkit is required; aborting\n") + sys.exit(1) + + subprocess.check_call([sys.executable, '-m', 'pip', + 'install', 'prompt_toolkit']) + + # nb. this should now succeed + import prompt_toolkit + + def rprint(self, *args, **kwargs): + """ + Convenience wrapper for :func:`rich:rich.print()`. + """ + rich.print(*args, **kwargs) + + def get_prompt_style(self): + from prompt_toolkit.styles import Style + + # message formatting styles + return Style.from_dict({ + '': '', + 'bold': 'bold', + }) + + def prompt_generic( + self, + info, + default=None, + is_password=False, + is_bool=False, + required=False, + ): + """ + Prompt the user to get their input. + + See also :meth:`prompt_bool()`. + + :param info: String to display (in bold) as prompt text. + + :param default: Default value to assume if user just presses + Enter without providing a value. + + :param is_bool: Whether the prompt is for a boolean (Y/N) + value, vs. a normal text value. + + :param is_password: Whether the prompt is for a "password" or + other sensitive text value. (User input will be masked.) + + :param required: Whether the value is required (user must + provide a value before continuing). + + :returns: String value provided by the user (or the default), + unless ``is_bool`` was requested in which case ``True`` or + ``False``. + """ + from prompt_toolkit import prompt + + # build prompt message + message = [ + ('', '\n'), + ('class:bold', info), + ] + if default is not None: + if is_bool: + message.append(('', ' [{}]: '.format('Y' if default else 'N'))) + else: + message.append(('', ' [{}]: '.format(default))) + else: + message.append(('', ': ')) + + # prompt user for input + style = self.get_prompt_style() + try: + text = prompt(message, style=style, is_password=is_password) + except (KeyboardInterrupt, EOFError): + self.rprint("\n\t[bold yellow]operation canceled by user[/bold yellow]\n", + file=sys.stderr) + sys.exit(1) + + if is_bool: + if text == '': + return default + elif text.upper() == 'Y': + return True + elif text.upper() == 'N': + return False + self.rprint("\n\t[bold yellow]ambiguous, please try again[/bold yellow]\n") + return self.prompt_generic(info, default, is_bool=True) + + if required and not text and not default: + return self.prompt_generic(info, default, is_password=is_password, + required=True) + + return text or default + + def prompt_bool(self, info, default=None): + """ + Prompt the user for a boolean (Y/N) value. + + Convenience wrapper around :meth:`prompt_generic()` with + ``is_bool=True``.. + + :returns: ``True`` or ``False``. + """ + return self.prompt_generic(info, is_bool=True, default=default) diff --git a/src/wuttjamaican/templates/install/upgrade.sh.mako b/src/wuttjamaican/templates/install/upgrade.sh.mako new file mode 100755 index 0000000..aadc31a --- /dev/null +++ b/src/wuttjamaican/templates/install/upgrade.sh.mako @@ -0,0 +1,29 @@ +#!/bin/sh -e +<%text>################################################## +# +# ${app_title} - upgrade script +# +<%text>################################################## + +if [ "$1" = "--verbose" ]; then + VERBOSE='--verbose' + QUIET= +else + VERBOSE= + QUIET='--quiet' +fi + +cd ${envdir} + +PIP='bin/pip' +ALEMBIC='bin/alembic' + +# upgrade pip and friends +$PIP install $QUIET --disable-pip-version-check --upgrade pip +$PIP install $QUIET --upgrade setuptools wheel + +# upgrade app proper +$PIP install $QUIET --upgrade --upgrade-strategy eager '${pypi_name}' + +# migrate schema +$ALEMBIC -c app/wutta.conf upgrade heads diff --git a/src/wuttjamaican/templates/install/web.conf.mako b/src/wuttjamaican/templates/install/web.conf.mako new file mode 100644 index 0000000..4d2b3c7 --- /dev/null +++ b/src/wuttjamaican/templates/install/web.conf.mako @@ -0,0 +1,90 @@ +## -*- mode: conf; -*- + +<%text>############################################################ +# +# ${app_title} - web app config +# +<%text>############################################################ + + +<%text>############################## +# wutta +<%text>############################## + +${self.section_wutta_config()} + + +<%text>############################## +# pyramid +<%text>############################## + +${self.section_app_main()} + +${self.section_server_main()} + + +<%text>############################## +# logging +<%text>############################## + +${self.sectiongroup_logging()} + + +###################################################################### +## section templates below +###################################################################### + +<%def name="section_wutta_config()"> +[wutta.config] +require = %(here)s/wutta.conf + + +<%def name="section_app_main()"> +[app:main] +#use = egg:wuttaweb +use = egg:${egg_name} + +pyramid.reload_templates = true +pyramid.debug_all = true +pyramid.default_locale_name = en +#pyramid.includes = pyramid_debugtoolbar + +beaker.session.type = file +beaker.session.data_dir = %(here)s/cache/sessions/data +beaker.session.lock_dir = %(here)s/cache/sessions/lock +beaker.session.secret = ${beaker_secret} +beaker.session.key = ${beaker_key} + +exclog.extra_info = true + +# required for wuttaweb +wutta.config = %(__file__)s + + +<%def name="section_server_main()"> +[server:main] +use = egg:waitress#main +host = ${pyramid_host} +port = ${pyramid_port} + +# NOTE: this is needed for local reverse proxy stuff to work with HTTPS +# https://docs.pylonsproject.org/projects/waitress/en/latest/reverse-proxy.html +# https://docs.pylonsproject.org/projects/waitress/en/latest/arguments.html +trusted_proxy = 127.0.0.1 +trusted_proxy_headers = x-forwarded-for x-forwarded-host x-forwarded-proto x-forwarded-port +clear_untrusted_proxy_headers = True + +# TODO: leave this empty if proxy serves as root site, e.g. https://wutta.example.com/ +# url_prefix = + +# TODO: or, if proxy serves as subpath of root site, e.g. https://wutta.example.com/backend/ +# url_prefix = /backend + + +<%def name="sectiongroup_logging()"> +[handler_console] +level = INFO + +[handler_file] +args = (${repr(os.path.join(appdir, 'log', 'web.log'))}, 'a', 1000000, 100, 'utf_8') + diff --git a/src/wuttjamaican/templates/install/wutta.conf.mako b/src/wuttjamaican/templates/install/wutta.conf.mako new file mode 100644 index 0000000..d360f8b --- /dev/null +++ b/src/wuttjamaican/templates/install/wutta.conf.mako @@ -0,0 +1,160 @@ +## -*- mode: conf; -*- + +<%text>############################################################ +# +# ${app_title} - base config +# +<%text>############################################################ + + +<%text>############################## +# wutta +<%text>############################## + +${self.section_wutta()} + +${self.section_wutta_config()} + +${self.section_wutta_db()} + +${self.section_wutta_mail()} + +${self.section_wutta_upgrades()} + + +<%text>############################## +# alembic +<%text>############################## + +${self.section_alembic()} + + +<%text>############################## +# logging +<%text>############################## + +${self.sectiongroup_logging()} + + +###################################################################### +## section templates below +###################################################################### + +<%def name="section_wutta()"> +[wutta] +app_title = ${app_title} + + +<%def name="section_wutta_config()"> +[wutta.config] +#require = /etc/wutta/wutta.conf +configure_logging = true +usedb = true +preferdb = true + + +<%def name="section_wutta_db()"> +[wutta.db] +default.url = ${db_url} +## TODO +## versioning.enabled = true + + +<%def name="section_wutta_mail()"> +[wutta.mail] + +# this is the global email shutoff switch +#send_emails = false + +# recommended setup is to always talk to postfix on localhost and then +# it can handle any need complexities, e.g. sending to relay +smtp.server = localhost + +# by default only email templates from wuttjamaican are used +templates = wuttjamaican:templates/mail + +## TODO +## # this is the "default" email profile, from which all others initially +## # inherit, but most/all profiles will override these values +## default.prefix = [${app_title}] +## default.from = wutta@localhost +## default.to = root@localhost +# nb. in test environment it can be useful to disable by default, and +# then selectively enable certain (e.g. feedback, upgrade) emails +#default.enabled = false + + +<%def name="section_wutta_upgrades()"> +## TODO +## [wutta.upgrades] +## command = ${os.path.join(appdir, 'upgrade.sh')} --verbose +## files = ${os.path.join(appdir, 'data', 'upgrades')} + + +<%def name="section_alembic()"> +[alembic] +script_location = wuttjamaican.db:alembic +version_locations = ${pkg_name}.db:alembic/versions wuttjamaican.db:alembic/versions + + +<%def name="sectiongroup_logging()"> +[loggers] +keys = root, beaker, exc_logger, sqlalchemy, txn + +[handlers] +keys = file, console, email + +[formatters] +keys = generic, console + +[logger_root] +handlers = file, console +level = DEBUG + +[logger_beaker] +qualname = beaker +handlers = +level = INFO + +[logger_exc_logger] +qualname = exc_logger +handlers = email +level = ERROR + +[logger_sqlalchemy] +qualname = sqlalchemy.engine +handlers = +# handlers = file +# level = INFO + +[logger_txn] +qualname = txn +handlers = +level = INFO + +[handler_file] +class = handlers.RotatingFileHandler +args = (${repr(os.path.join(appdir, 'log', 'wutta.log'))}, 'a', 1000000, 100, 'utf_8') +formatter = generic + +[handler_console] +class = StreamHandler +args = (sys.stderr,) +formatter = console +# formatter = generic +# level = INFO +# level = WARNING + +[handler_email] +class = handlers.SMTPHandler +args = ('localhost', 'wutta@localhost', ['root@localhost'], "[${app_title}] Logging") +formatter = generic +level = ERROR + +[formatter_generic] +format = %(asctime)s %(levelname)-5.5s [%(name)s][%(threadName)s] %(funcName)s: %(message)s +datefmt = %Y-%m-%d %H:%M:%S + +[formatter_console] +format = %(levelname)-5.5s [%(name)s][%(threadName)s] %(funcName)s: %(message)s + diff --git a/src/wuttjamaican/testing.py b/src/wuttjamaican/testing.py index cb81ad7..5d34960 100644 --- a/src/wuttjamaican/testing.py +++ b/src/wuttjamaican/testing.py @@ -65,7 +65,7 @@ class FileTestCase(TestCase): def setup_file_config(self): # pragma: no cover """ """ - warnings.warn("FileConfigTestCase.setup_file_config() is deprecated; " + warnings.warn("FileTestCase.setup_file_config() is deprecated; " "please use setup_files() instead", DeprecationWarning, stacklevel=2) self.setup_files() @@ -82,7 +82,7 @@ class FileTestCase(TestCase): def teardown_file_config(self): # pragma: no cover """ """ - warnings.warn("FileConfigTestCase.teardown_file_config() is deprecated; " + warnings.warn("FileTestCase.teardown_file_config() is deprecated; " "please use teardown_files() instead", DeprecationWarning, stacklevel=2) self.teardown_files() @@ -112,6 +112,58 @@ class FileTestCase(TestCase): FileConfigTestCase = FileTestCase +class ConfigTestCase(FileTestCase): + """ + Base class for test suites requiring a config object. + + It inherits from :class:`FileTestCase` so also has the + file-related methods. + + The running test has these attributes: + + .. attribute:: config + + Reference to the config object. + + .. attribute:: app + + Reference to the app handler. + + .. note:: + + If you subclass this directly and need to override + setup/teardown, please be sure to call the corresponding + methods for this class. + """ + + def setUp(self): + """ """ + self.setup_config() + + def setup_config(self): + """ + Perform config setup operations for the test. + """ + self.setup_files() + self.config = self.make_config() + self.app = self.config.get_app() + + def tearDown(self): + """ """ + self.teardown_config() + + def teardown_config(self): + """ + Perform config teardown operations for the test. + """ + self.teardown_files() + + def make_config(self, **kwargs): + """ """ + return WuttaConfig(**kwargs) + + +# TODO: this should inherit from ConfigTestCase class DataTestCase(FileTestCase): """ Base class for test suites requiring a full (typical) database. diff --git a/tests/test_app.py b/tests/test_app.py index 720f59e..31d266d 100644 --- a/tests/test_app.py +++ b/tests/test_app.py @@ -9,9 +9,10 @@ from unittest import TestCase from unittest.mock import patch, MagicMock import pytest +from mako.template import Template import wuttjamaican.enum -from wuttjamaican import app +from wuttjamaican import app as mod from wuttjamaican.progress import ProgressBase from wuttjamaican.conf import WuttaConfig from wuttjamaican.util import UNSPECIFIED @@ -23,7 +24,7 @@ class TestAppHandler(FileTestCase): def setUp(self): self.setup_files() self.config = WuttaConfig(appname='wuttatest') - self.app = app.AppHandler(self.config) + self.app = mod.AppHandler(self.config) self.config.app = self.app def test_init(self): @@ -83,7 +84,7 @@ class TestAppHandler(FileTestCase): self.assertFalse(os.path.exists(appdir)) self.app.make_appdir(appdir) self.assertTrue(os.path.exists(appdir)) - self.assertEqual(len(os.listdir(appdir)), 3) + self.assertEqual(len(os.listdir(appdir)), 4) shutil.rmtree(tempdir) # subfolders still added if appdir already exists @@ -91,9 +92,28 @@ class TestAppHandler(FileTestCase): self.assertTrue(os.path.exists(tempdir)) self.assertEqual(len(os.listdir(tempdir)), 0) self.app.make_appdir(tempdir) - self.assertEqual(len(os.listdir(tempdir)), 3) + self.assertEqual(len(os.listdir(tempdir)), 4) shutil.rmtree(tempdir) + def test_render_mako_template(self): + output_conf = self.write_file('output.conf', '') + template = Template("""\ +[wutta] +app_title = WuttaTest +""") + output = self.app.render_mako_template(template, {}, output_path=output_conf) + self.assertEqual(output, """\ +[wutta] +app_title = WuttaTest +""") + + with open(output_conf, 'rt') as f: + self.assertEqual(f.read(), output) + + def test_resource_path(self): + result = self.app.resource_path('wuttjamaican:templates') + self.assertEqual(result, os.path.join(os.path.dirname(mod.__file__), 'templates')) + def test_make_session(self): try: from wuttjamaican import db @@ -411,16 +431,17 @@ class TestAppHandler(FileTestCase): self.assertIsInstance(auth, AuthHandler) def test_get_email_handler(self): - try: - import mako - except ImportError: - pytest.skip("test not relevant without mako") - from wuttjamaican.email import EmailHandler mail = self.app.get_email_handler() self.assertIsInstance(mail, EmailHandler) + def test_get_install_handler(self): + from wuttjamaican.install import InstallHandler + + install = self.app.get_install_handler() + self.assertIsInstance(install, InstallHandler) + def test_get_people_handler(self): from wuttjamaican.people import PeopleHandler @@ -428,11 +449,6 @@ class TestAppHandler(FileTestCase): self.assertIsInstance(people, PeopleHandler) def test_send_email(self): - try: - import mako - except ImportError: - pytest.skip("test not relevant without mako") - from wuttjamaican.email import EmailHandler with patch.object(EmailHandler, 'send_email') as send_email: @@ -444,13 +460,13 @@ class TestAppProvider(TestCase): def setUp(self): self.config = WuttaConfig(appname='wuttatest') - self.app = app.AppHandler(self.config) + self.app = mod.AppHandler(self.config) self.config._app = self.app def test_constructor(self): # config object is expected - provider = app.AppProvider(self.config) + provider = mod.AppProvider(self.config) self.assertIs(provider.config, self.config) self.assertIs(provider.app, self.app) self.assertEqual(provider.appname, 'wuttatest') @@ -458,13 +474,13 @@ class TestAppProvider(TestCase): # but can pass app handler instead with warnings.catch_warnings(): warnings.filterwarnings('ignore', category=DeprecationWarning) - provider = app.AppProvider(self.app) + provider = mod.AppProvider(self.app) self.assertIs(provider.config, self.config) self.assertIs(provider.app, self.app) def test_get_all_providers(self): - class FakeProvider(app.AppProvider): + class FakeProvider(mod.AppProvider): pass # nb. we specify *classes* here @@ -482,7 +498,7 @@ class TestAppProvider(TestCase): def test_hasattr(self): - class FakeProvider(app.AppProvider): + class FakeProvider(mod.AppProvider): def fake_foo(self): pass @@ -499,7 +515,7 @@ class TestAppProvider(TestCase): # now we test that providers are loaded... - class FakeProvider(app.AppProvider): + class FakeProvider(mod.AppProvider): def fake_foo(self): return 42 @@ -541,11 +557,11 @@ class TestGenericHandler(TestCase): def setUp(self): self.config = WuttaConfig(appname='wuttatest') - self.app = app.AppHandler(self.config) + self.app = mod.AppHandler(self.config) self.config._app = self.app def test_constructor(self): - handler = app.GenericHandler(self.config) + handler = mod.GenericHandler(self.config) self.assertIs(handler.config, self.config) self.assertIs(handler.app, self.app) self.assertEqual(handler.appname, 'wuttatest') diff --git a/tests/test_install.py b/tests/test_install.py new file mode 100644 index 0000000..d4c7844 --- /dev/null +++ b/tests/test_install.py @@ -0,0 +1,452 @@ +# -*- coding: utf-8; -*- + +import os +import sys +from unittest.mock import patch, MagicMock + +import pytest +from mako.lookup import TemplateLookup + +from wuttjamaican import install as mod +from wuttjamaican.testing import ConfigTestCase + + +class TestInstallHandler(ConfigTestCase): + + def make_handler(self, **kwargs): + return mod.InstallHandler(self.config, **kwargs) + + def test_constructor(self): + handler = self.make_handler() + self.assertEqual(handler.pkg_name, 'poser') + self.assertEqual(handler.app_title, 'poser') + self.assertEqual(handler.pypi_name, 'poser') + self.assertEqual(handler.egg_name, 'poser') + + def test_run(self): + handler = self.make_handler() + with patch.object(handler, 'show_welcome') as show_welcome: + with patch.object(handler, 'sanity_check') as sanity_check: + with patch.object(handler, 'do_install_steps') as do_install_steps: + handler.run() + show_welcome.assert_called_once_with() + sanity_check.assert_called_once_with() + do_install_steps.assert_called_once_with() + + def test_show_welcome(self): + handler = self.make_handler() + with patch.object(mod, 'sys') as sys: + with patch.object(handler, 'rprint') as rprint: + with patch.object(handler, 'prompt_bool') as prompt_bool: + + # user continues + prompt_bool.return_value = True + handler.show_welcome() + self.assertFalse(sys.exit.called) + + # user aborts + prompt_bool.return_value = False + handler.show_welcome() + sys.exit.assert_called_once_with(1) + + def test_sanity_check(self): + handler = self.make_handler() + with patch.object(mod, 'sys') as sys: + with patch.object(mod, 'os') as os: + with patch.object(handler, 'rprint') as rprint: + + # pretend appdir does not exist + os.path.exists.return_value = False + handler.sanity_check() + self.assertFalse(sys.exit.called) + + # pretend appdir does exist + os.path.exists.return_value = True + handler.sanity_check() + sys.exit.assert_called_once_with(2) + + def test_do_install_steps(self): + handler = self.make_handler() + handler.templates = TemplateLookup(directories=[ + self.app.resource_path('wuttjamaican:templates/install'), + ]) + dbinfo = { + 'dburl': f'sqlite:///{self.tempdir}/poser.sqlite', + } + + orig_import = __import__ + mock_prompt = MagicMock() + + def mock_import(name, globals=None, locals=None, fromlist=(), level=0): + if name == 'prompt_toolkit': + if fromlist == ('prompt',): + return MagicMock(prompt=mock_prompt) + return orig_import(name, globals, locals, fromlist, level) + + with patch('builtins.__import__', side_effect=mock_import): + with patch.object(handler, 'get_dbinfo', return_value=dbinfo): + with patch.object(handler, 'install_db_schema') as install_db_schema: + + # nb. just for sanity/coverage + install_db_schema.return_value = True + self.assertFalse(hasattr(handler, 'schema_installed')) + handler.do_install_steps() + self.assertTrue(handler.schema_installed) + install_db_schema.assert_called_once_with(dbinfo['dburl']) + + def test_get_dbinfo(self): + try: + import sqlalchemy + except ImportError: + pytest.skip("test is not relevant without sqlalchemy") + + handler = self.make_handler() + + def prompt_generic(info, default=None, is_password=False): + if info in ('db name', 'db user'): + return 'poser' + if is_password: + return 'seekrit' + return default + + 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) + + # 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']), + 'postgresql+psycopg2://poser:seekrit@localhost:5432/poser') + + def test_make_db_url(self): + try: + import sqlalchemy + except ImportError: + pytest.skip("test is not relevant without sqlalchemy") + + handler = self.make_handler() + + url = handler.make_db_url('postgresql', 'localhost', '5432', 'poser', 'poser', 'seekrit') + self.assertEqual(str(url), 'postgresql+psycopg2://poser:seekrit@localhost:5432/poser') + + url = handler.make_db_url('mysql', 'localhost', '3306', 'poser', 'poser', 'seekrit') + self.assertEqual(str(url), 'mysql+mysqlconnector://poser:seekrit@localhost:3306/poser') + + def test_test_db_connection(self): + try: + import sqlalchemy as sa + except ImportError: + pytest.skip("test is not relevant without sqlalchemy") + + handler = self.make_handler() + + # db does not exist + result = handler.test_db_connection('sqlite:///bad/url/should/not/exist') + self.assertIn('unable to open database file', result) + + # db is setup + url = f'sqlite:///{self.tempdir}/db.sqlite' + engine = sa.create_engine(url) + with engine.begin() as cxn: + cxn.execute(sa.text("create table whatever (id int primary key);")) + self.assertIsNone(handler.test_db_connection(url)) + + def test_make_template_context(self): + handler = self.make_handler() + 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') + self.assertEqual(context['pypi_name'], 'poser') + self.assertEqual(context['egg_name'], 'poser') + self.assertEqual(context['appdir'], os.path.join(sys.prefix, 'app')) + self.assertEqual(context['db_url'], 'sqlite:///poser.sqlite') + + def test_make_appdir(self): + handler = self.make_handler() + handler.templates = TemplateLookup(directories=[ + self.app.resource_path('wuttjamaican:templates/install'), + ]) + 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: + self.assertIn('default.url = sqlite:///poser.sqlite', f.read()) + + def test_install_db_schema(self): + try: + import sqlalchemy as sa + except ImportError: + pytest.skip("test is not relevant without sqlalchemy") + + handler = self.make_handler() + db_url = f'sqlite:///{self.tempdir}/poser.sqlite' + + wutta_conf = self.write_file('wutta.conf', f""" +[wutta.db] +default.url = {db_url} +""") + + # convert to proper URL object + db_url = sa.create_engine(db_url).url + + with patch.object(mod, 'subprocess') as subprocess: + + # user declines offer to install schema + with patch.object(handler, 'prompt_bool', return_value=False): + self.assertFalse(handler.install_db_schema(db_url, appdir=self.tempdir)) + + # user agrees to install schema + with patch.object(handler, 'prompt_bool', return_value=True): + self.assertTrue(handler.install_db_schema(db_url, appdir=self.tempdir)) + subprocess.check_call.assert_called_once_with([ + os.path.join(sys.prefix, 'bin', 'alembic'), + '-c', wutta_conf, 'upgrade', 'heads']) + + def test_show_goodbye(self): + handler = self.make_handler() + with patch.object(handler, 'rprint') as rprint: + handler.schema_installed = True + handler.show_goodbye() + rprint.assert_any_call("\n\t[bold green]initial setup is complete![/bold green]") + rprint.assert_any_call("\t[blue]bin/pserve file+ini:app/web.conf[/blue]") + + def test_require_prompt_toolkit_installed(self): + # nb. this assumes we *do* have prompt_toolkit installed + handler = self.make_handler() + with patch.object(mod, 'subprocess') as subprocess: + handler.require_prompt_toolkit(answer='Y') + self.assertFalse(subprocess.check_call.called) + + def test_require_prompt_toolkit_missing(self): + handler = self.make_handler() + orig_import = __import__ + stuff = {'attempts': 0} + + def mock_import(name, globals=None, locals=None, fromlist=(), level=0): + if name == 'prompt_toolkit': + # nb. pretend this is not installed + raise ImportError + return orig_import(name, globals, locals, fromlist, level) + + # prompt_toolkit not installed, and user declines offer to install + with patch('builtins.__import__', side_effect=mock_import): + with patch.object(mod, 'subprocess') as subprocess: + with patch.object(mod, 'sys') as sys: + sys.exit.side_effect = RuntimeError + self.assertRaises(RuntimeError, handler.require_prompt_toolkit, answer='N') + self.assertFalse(subprocess.check_call.called) + sys.stderr.write.assert_called_once_with("prompt_toolkit is required; aborting\n") + sys.exit.assert_called_once_with(1) + + def test_require_prompt_toolkit_missing_then_installed(self): + handler = self.make_handler() + orig_import = __import__ + stuff = {'attempts': 0} + + def mock_import(name, globals=None, locals=None, fromlist=(), level=0): + if name == 'prompt_toolkit': + stuff['attempts'] += 1 + if stuff['attempts'] == 1: + # nb. pretend this is not installed + raise ImportError + return orig_import('prompt_toolkit') + return orig_import(name, globals, locals, fromlist, level) + + # prompt_toolkit not installed, and user declines offer to install + with patch('builtins.__import__', side_effect=mock_import): + with patch.object(mod, 'subprocess') as subprocess: + with patch.object(mod, 'sys') as sys: + sys.executable = 'python' + handler.require_prompt_toolkit(answer='Y') + subprocess.check_call.assert_called_once_with(['python', '-m', 'pip', + 'install', 'prompt_toolkit']) + self.assertFalse(sys.exit.called) + self.assertEqual(stuff['attempts'], 2) + + def test_prompt_generic(self): + handler = self.make_handler() + style = handler.get_prompt_style() + orig_import = __import__ + mock_prompt = MagicMock() + + def mock_import(name, globals=None, locals=None, fromlist=(), level=0): + if name == 'prompt_toolkit': + if fromlist == ('prompt',): + return MagicMock(prompt=mock_prompt) + return orig_import(name, globals, locals, fromlist, level) + + with patch('builtins.__import__', side_effect=mock_import): + with patch.object(handler, 'get_prompt_style', return_value=style): + with patch.object(handler, 'rprint') as rprint: + + # no input or default value + mock_prompt.return_value = '' + result = handler.prompt_generic('foo') + self.assertIsNone(result) + mock_prompt.assert_called_once_with([('', '\n'), + ('class:bold', 'foo'), + ('', ': ')], + style=style, is_password=False) + + # fallback to default value + mock_prompt.reset_mock() + mock_prompt.return_value = '' + result = handler.prompt_generic('foo', default='baz') + self.assertEqual(result, 'baz') + mock_prompt.assert_called_once_with([('', '\n'), + ('class:bold', 'foo'), + ('', ' [baz]: ')], + style=style, is_password=False) + + # text input value + mock_prompt.reset_mock() + mock_prompt.return_value = 'bar' + result = handler.prompt_generic('foo') + self.assertEqual(result, 'bar') + mock_prompt.assert_called_once_with([('', '\n'), + ('class:bold', 'foo'), + ('', ': ')], + style=style, is_password=False) + + # bool value (no default; true input) + mock_prompt.reset_mock() + mock_prompt.return_value = 'Y' + result = handler.prompt_generic('foo', is_bool=True) + self.assertTrue(result) + mock_prompt.assert_called_once_with([('', '\n'), + ('class:bold', 'foo'), + ('', ': ')], + style=style, is_password=False) + + # bool value (no default; false input) + mock_prompt.reset_mock() + mock_prompt.return_value = 'N' + result = handler.prompt_generic('foo', is_bool=True) + self.assertFalse(result) + mock_prompt.assert_called_once_with([('', '\n'), + ('class:bold', 'foo'), + ('', ': ')], + style=style, is_password=False) + + # bool value (default; no input) + mock_prompt.reset_mock() + mock_prompt.return_value = '' + result = handler.prompt_generic('foo', is_bool=True, default=True) + self.assertTrue(result) + mock_prompt.assert_called_once_with([('', '\n'), + ('class:bold', 'foo'), + ('', ' [Y]: ')], + style=style, is_password=False) + + # bool value (bad input) + mock_prompt.reset_mock() + counter = {'attempts': 0} + def omg(*args, **kwargs): + counter['attempts'] += 1 + if counter['attempts'] == 1: + # nb. bad input first time we ask + return 'doesnotmakesense' + # nb. but good input after that + return 'N' + mock_prompt.side_effect = omg + result = handler.prompt_generic('foo', is_bool=True) + self.assertFalse(result) + # nb. user was prompted twice + self.assertEqual(mock_prompt.call_count, 2) + + # Ctrl+C + mock_prompt.reset_mock() + mock_prompt.side_effect = KeyboardInterrupt + with patch.object(mod, 'sys') as sys: + sys.exit.side_effect = RuntimeError + self.assertRaises(RuntimeError, handler.prompt_generic, 'foo') + sys.exit.assert_called_once_with(1) + + # Ctrl+D + mock_prompt.reset_mock() + mock_prompt.side_effect = EOFError + with patch.object(mod, 'sys') as sys: + sys.exit.side_effect = RuntimeError + self.assertRaises(RuntimeError, handler.prompt_generic, 'foo') + sys.exit.assert_called_once_with(1) + + # missing required value + mock_prompt.reset_mock() + counter = {'attempts': 0} + def omg(*args, **kwargs): + counter['attempts'] += 1 + if counter['attempts'] == 1: + # nb. no input first time we ask + return '' + # nb. but good input after that + return 'bar' + mock_prompt.side_effect = omg + result = handler.prompt_generic('foo', required=True) + self.assertEqual(result, 'bar') + # nb. user was prompted twice + self.assertEqual(mock_prompt.call_count, 2) + + def test_prompt_bool(self): + handler = self.make_handler() + orig_import = __import__ + mock_prompt = MagicMock() + + def mock_import(name, globals=None, locals=None, fromlist=(), level=0): + if name == 'prompt_toolkit': + if fromlist == ('prompt',): + return MagicMock(prompt=mock_prompt) + return orig_import(name, globals, locals, fromlist, level) + + with patch('builtins.__import__', side_effect=mock_import): + with patch.object(handler, 'rprint') as rprint: + + # no default; true input + mock_prompt.reset_mock() + mock_prompt.return_value = 'Y' + result = handler.prompt_bool('foo') + self.assertTrue(result) + mock_prompt.assert_called_once() + + # no default; false input + mock_prompt.reset_mock() + mock_prompt.return_value = 'N' + result = handler.prompt_bool('foo') + self.assertFalse(result) + mock_prompt.assert_called_once() + + # default; no input + mock_prompt.reset_mock() + mock_prompt.return_value = '' + result = handler.prompt_bool('foo', default=True) + self.assertTrue(result) + mock_prompt.assert_called_once() + + # bad input + mock_prompt.reset_mock() + counter = {'attempts': 0} + def omg(*args, **kwargs): + counter['attempts'] += 1 + if counter['attempts'] == 1: + # nb. bad input first time we ask + return 'doesnotmakesense' + # nb. but good input after that + return 'N' + mock_prompt.side_effect = omg + result = handler.prompt_bool('foo') + self.assertFalse(result) + # nb. user was prompted twice + self.assertEqual(mock_prompt.call_count, 2) diff --git a/tox.ini b/tox.ini index 61f9f4f..4c184e5 100644 --- a/tox.ini +++ b/tox.ini @@ -3,7 +3,8 @@ envlist = py38, py39, py310, py311, nox [testenv] -extras = db,email,docs,tests +extras = db,docs,tests +deps = prompt_toolkit commands = pytest {posargs} [testenv:nox] From 7afb67b4a0d07588ccc4e677eb3513e888f16dec Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Sun, 24 Nov 2024 10:27:54 -0600 Subject: [PATCH 4/4] =?UTF-8?q?bump:=20version=200.13.3=20=E2=86=92=200.14?= =?UTF-8?q?.0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 8 ++++++++ pyproject.toml | 2 +- 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3508534..89990cf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,14 @@ 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.14.0 (2024-11-24) + +### Feat + +- add install handler and related logic +- add `parse_bool()` and `parse_list()` methods for config object +- add `wutta` top-level command with `make-uuid` subcommand + ## v0.13.3 (2024-08-30) ### Fix diff --git a/pyproject.toml b/pyproject.toml index cf69df5..7aeeb6e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -6,7 +6,7 @@ build-backend = "hatchling.build" [project] name = "WuttJamaican" -version = "0.13.3" +version = "0.14.0" description = "Base package for Wutta Framework" readme = "README.md" authors = [{name = "Lance Edgar", email = "lance@edbob.org"}]