diff --git a/CHANGELOG.md b/CHANGELOG.md index 89990cf..3508534 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,14 +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.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/docs/api/wuttjamaican/cli.base.rst b/docs/api/wuttjamaican/cli.base.rst deleted file mode 100644 index c937040..0000000 --- a/docs/api/wuttjamaican/cli.base.rst +++ /dev/null @@ -1,6 +0,0 @@ - -``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 deleted file mode 100644 index 8350d6e..0000000 --- a/docs/api/wuttjamaican/cli.make_uuid.rst +++ /dev/null @@ -1,6 +0,0 @@ - -``wuttjamaican.cli.make_uuid`` -============================== - -.. automodule:: wuttjamaican.cli.make_uuid - :members: diff --git a/docs/api/wuttjamaican/cli.rst b/docs/api/wuttjamaican/cli.rst deleted file mode 100644 index c0bb811..0000000 --- a/docs/api/wuttjamaican/cli.rst +++ /dev/null @@ -1,6 +0,0 @@ - -``wuttjamaican.cli`` -==================== - -.. automodule:: wuttjamaican.cli - :members: diff --git a/docs/api/wuttjamaican/index.rst b/docs/api/wuttjamaican/index.rst index 1f8456b..0c9d7c0 100644 --- a/docs/api/wuttjamaican/index.rst +++ b/docs/api/wuttjamaican/index.rst @@ -9,9 +9,6 @@ app auth - cli - cli.base - cli.make_uuid conf db db.conf @@ -26,7 +23,6 @@ email.message enum exc - install people progress testing diff --git a/docs/api/wuttjamaican/install.rst b/docs/api/wuttjamaican/install.rst deleted file mode 100644 index 6abd27e..0000000 --- a/docs/api/wuttjamaican/install.rst +++ /dev/null @@ -1,6 +0,0 @@ - -``wuttjamaican.install`` -======================== - -.. automodule:: wuttjamaican.install - :members: diff --git a/docs/conf.py b/docs/conf.py index be9bc2f..cbee050 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -29,13 +29,11 @@ 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 2eab2c6..3b87762 100644 --- a/docs/glossary.rst +++ b/docs/glossary.rst @@ -145,12 +145,6 @@ 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 @@ -169,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 with a - custom arg set. See also :doc:`narr/cli/index`. + the real workhorse; each can perform a different function. 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 1e9e36b..d2a6bd4 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -22,10 +22,8 @@ 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 deleted file mode 100644 index 0eafb2b..0000000 --- a/docs/narr/cli/builtin.rst +++ /dev/null @@ -1,41 +0,0 @@ - -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 new file mode 100644 index 0000000..de73794 --- /dev/null +++ b/docs/narr/cli/commands.rst @@ -0,0 +1,23 @@ + +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 deleted file mode 100644 index d893ba4..0000000 --- a/docs/narr/cli/custom.rst +++ /dev/null @@ -1,105 +0,0 @@ - -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 fd45a34..2070234 100644 --- a/docs/narr/cli/index.rst +++ b/docs/narr/cli/index.rst @@ -2,24 +2,9 @@ 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 - builtin - custom + overview + commands scripts diff --git a/docs/narr/cli/overview.rst b/docs/narr/cli/overview.rst new file mode 100644 index 0000000..df69f9c --- /dev/null +++ b/docs/narr/cli/overview.rst @@ -0,0 +1,15 @@ + +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 769a231..cb6cc7d 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.get_title()) + print('from', app.appname) if __name__ == '__main__': config = make_config('my.conf') @@ -81,7 +81,7 @@ Output should now be different: $ python hello.py hello George - from WuttJamaican + from wutta 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.get_title()) + print('from', app.appname) 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.get_title()) + print('from', app.appname) log.debug("about to do something") if something(config): diff --git a/docs/narr/install/quickstart.rst b/docs/narr/install/quickstart.rst index 1679267..c8be0e3 100644 --- a/docs/narr/install/quickstart.rst +++ b/docs/narr/install/quickstart.rst @@ -2,56 +2,6 @@ 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`. @@ -117,7 +67,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 7aeeb6e..a970992 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -6,7 +6,7 @@ build-backend = "hatchling.build" [project] name = "WuttJamaican" -version = "0.14.0" +version = "0.13.3" description = "Base package for Wutta Framework" readme = "README.md" authors = [{name = "Lance Edgar", email = "lance@edbob.org"}] @@ -28,23 +28,18 @@ requires-python = ">= 3.8" dependencies = [ 'importlib-metadata; python_version < "3.10"', "importlib_resources ; python_version < '3.9'", - "Mako", "progress", "python-configuration", - "typer", ] [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"] -[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/app.py b/src/wuttjamaican/app.py index 8e4d891..98aa8ab 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, - progress_loop, resource_path) + make_title, make_uuid, parse_bool, + progress_loop) class AppHandler: @@ -82,7 +82,6 @@ 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): @@ -419,52 +418,20 @@ class AppHandler: :param subfolders: Optional list of subfolder names to create within the app dir. If not specified, defaults will be: - ``['cache', 'data', 'log', 'work']``. + ``['data', 'log', 'work']``. """ appdir = path if not os.path.exists(appdir): os.makedirs(appdir) if not subfolders: - subfolders = ['cache', 'data', 'log', 'work'] + subfolders = ['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 @@ -670,19 +637,6 @@ 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`. @@ -773,7 +727,7 @@ class GenericHandler: instance of :class:`~wuttjamaican.conf.WuttaConfig`. """ - def __init__(self, config): + def __init__(self, config, **kwargs): self.config = config self.app = self.config.get_app() diff --git a/src/wuttjamaican/cli/__init__.py b/src/wuttjamaican/cli/__init__.py deleted file mode 100644 index 9707748..0000000 --- a/src/wuttjamaican/cli/__init__.py +++ /dev/null @@ -1,37 +0,0 @@ -# -*- 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 deleted file mode 100644 index 05e3454..0000000 --- a/src/wuttjamaican/cli/base.py +++ /dev/null @@ -1,108 +0,0 @@ -# -*- 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 deleted file mode 100644 index 21b9cf7..0000000 --- a/src/wuttjamaican/cli/make_uuid.py +++ /dev/null @@ -1,44 +0,0 @@ -# -*- 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/src/wuttjamaican/conf.py b/src/wuttjamaican/conf.py index 785ce7b..a54c38d 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 self.parse_list(requires): + for path in 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 self.parse_list(includes): + for path in parse_list(includes): path = path % {'here': here} self._load_ini_configs(path, configs, require=False) @@ -475,10 +475,11 @@ 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 :meth:`parse_bool()`. + it will be coerced to boolean via + :func:`~wuttjamaican.util.parse_bool()`. """ value = self.get(*args, **kwargs) - return self.parse_bool(value) + return parse_bool(value) def get_int(self, *args, **kwargs): """ @@ -497,14 +498,15 @@ 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 :meth:`parse_list()`. + it will be coerced to list via + :func:`~wuttjamaican.util.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 self.parse_list(value) + return parse_list(value) def get_dict(self, prefix): """ @@ -547,20 +549,6 @@ 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/src/wuttjamaican/install.py b/src/wuttjamaican/install.py deleted file mode 100644 index 2212fa2..0000000 --- a/src/wuttjamaican/install.py +++ /dev/null @@ -1,583 +0,0 @@ -# -*- 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 deleted file mode 100755 index aadc31a..0000000 --- a/src/wuttjamaican/templates/install/upgrade.sh.mako +++ /dev/null @@ -1,29 +0,0 @@ -#!/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 deleted file mode 100644 index 4d2b3c7..0000000 --- a/src/wuttjamaican/templates/install/web.conf.mako +++ /dev/null @@ -1,90 +0,0 @@ -## -*- 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 deleted file mode 100644 index d360f8b..0000000 --- a/src/wuttjamaican/templates/install/wutta.conf.mako +++ /dev/null @@ -1,160 +0,0 @@ -## -*- 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 5d34960..cb81ad7 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("FileTestCase.setup_file_config() is deprecated; " + warnings.warn("FileConfigTestCase.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("FileTestCase.teardown_file_config() is deprecated; " + warnings.warn("FileConfigTestCase.teardown_file_config() is deprecated; " "please use teardown_files() instead", DeprecationWarning, stacklevel=2) self.teardown_files() @@ -112,58 +112,6 @@ 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/cli/__init__.py b/tests/cli/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/tests/cli/example.conf b/tests/cli/example.conf deleted file mode 100644 index e69de29..0000000 diff --git a/tests/cli/test_base.py b/tests/cli/test_base.py deleted file mode 100644 index 214843e..0000000 --- a/tests/cli/test_base.py +++ /dev/null @@ -1,39 +0,0 @@ -# -*- 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 deleted file mode 100644 index 05c5c8b..0000000 --- a/tests/cli/test_make_uuid.py +++ /dev/null @@ -1,20 +0,0 @@ -# -*- 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() diff --git a/tests/test_app.py b/tests/test_app.py index 31d266d..720f59e 100644 --- a/tests/test_app.py +++ b/tests/test_app.py @@ -9,10 +9,9 @@ from unittest import TestCase from unittest.mock import patch, MagicMock import pytest -from mako.template import Template import wuttjamaican.enum -from wuttjamaican import app as mod +from wuttjamaican import app from wuttjamaican.progress import ProgressBase from wuttjamaican.conf import WuttaConfig from wuttjamaican.util import UNSPECIFIED @@ -24,7 +23,7 @@ class TestAppHandler(FileTestCase): def setUp(self): self.setup_files() self.config = WuttaConfig(appname='wuttatest') - self.app = mod.AppHandler(self.config) + self.app = app.AppHandler(self.config) self.config.app = self.app def test_init(self): @@ -84,7 +83,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)), 4) + self.assertEqual(len(os.listdir(appdir)), 3) shutil.rmtree(tempdir) # subfolders still added if appdir already exists @@ -92,28 +91,9 @@ 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)), 4) + self.assertEqual(len(os.listdir(tempdir)), 3) 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 @@ -431,17 +411,16 @@ app_title = WuttaTest 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 @@ -449,6 +428,11 @@ app_title = WuttaTest 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: @@ -460,13 +444,13 @@ class TestAppProvider(TestCase): def setUp(self): self.config = WuttaConfig(appname='wuttatest') - self.app = mod.AppHandler(self.config) + self.app = app.AppHandler(self.config) self.config._app = self.app def test_constructor(self): # config object is expected - provider = mod.AppProvider(self.config) + provider = app.AppProvider(self.config) self.assertIs(provider.config, self.config) self.assertIs(provider.app, self.app) self.assertEqual(provider.appname, 'wuttatest') @@ -474,13 +458,13 @@ class TestAppProvider(TestCase): # but can pass app handler instead with warnings.catch_warnings(): warnings.filterwarnings('ignore', category=DeprecationWarning) - provider = mod.AppProvider(self.app) + provider = app.AppProvider(self.app) self.assertIs(provider.config, self.config) self.assertIs(provider.app, self.app) def test_get_all_providers(self): - class FakeProvider(mod.AppProvider): + class FakeProvider(app.AppProvider): pass # nb. we specify *classes* here @@ -498,7 +482,7 @@ class TestAppProvider(TestCase): def test_hasattr(self): - class FakeProvider(mod.AppProvider): + class FakeProvider(app.AppProvider): def fake_foo(self): pass @@ -515,7 +499,7 @@ class TestAppProvider(TestCase): # now we test that providers are loaded... - class FakeProvider(mod.AppProvider): + class FakeProvider(app.AppProvider): def fake_foo(self): return 42 @@ -557,11 +541,11 @@ class TestGenericHandler(TestCase): def setUp(self): self.config = WuttaConfig(appname='wuttatest') - self.app = mod.AppHandler(self.config) + self.app = app.AppHandler(self.config) self.config._app = self.app def test_constructor(self): - handler = mod.GenericHandler(self.config) + handler = app.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_conf.py b/tests/test_conf.py index 9ef533e..690cee1 100644 --- a/tests/test_conf.py +++ b/tests/test_conf.py @@ -17,9 +17,6 @@ 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') @@ -413,111 +410,6 @@ 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 diff --git a/tests/test_install.py b/tests/test_install.py deleted file mode 100644 index d4c7844..0000000 --- a/tests/test_install.py +++ /dev/null @@ -1,452 +0,0 @@ -# -*- 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 4c184e5..61f9f4f 100644 --- a/tox.ini +++ b/tox.ini @@ -3,8 +3,7 @@ envlist = py38, py39, py310, py311, nox [testenv] -extras = db,docs,tests -deps = prompt_toolkit +extras = db,email,docs,tests commands = pytest {posargs} [testenv:nox]