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
This commit is contained in:
parent
49e77d7407
commit
ceeff7e911
|
@ -26,6 +26,7 @@
|
|||
email.message
|
||||
enum
|
||||
exc
|
||||
install
|
||||
people
|
||||
progress
|
||||
testing
|
||||
|
|
6
docs/api/wuttjamaican/install.rst
Normal file
6
docs/api/wuttjamaican/install.rst
Normal file
|
@ -0,0 +1,6 @@
|
|||
|
||||
``wuttjamaican.install``
|
||||
========================
|
||||
|
||||
.. automodule:: wuttjamaican.install
|
||||
:members:
|
|
@ -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),
|
||||
}
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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).
|
||||
|
||||
|
|
|
@ -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"]
|
||||
|
||||
|
|
|
@ -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()
|
||||
|
||||
|
|
583
src/wuttjamaican/install.py
Normal file
583
src/wuttjamaican/install.py
Normal file
|
@ -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 <http://www.gnu.org/licenses/>.
|
||||
#
|
||||
################################################################################
|
||||
"""
|
||||
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)
|
29
src/wuttjamaican/templates/install/upgrade.sh.mako
Executable file
29
src/wuttjamaican/templates/install/upgrade.sh.mako
Executable file
|
@ -0,0 +1,29 @@
|
|||
#!/bin/sh -e
|
||||
<%text>##################################################</%text>
|
||||
#
|
||||
# ${app_title} - upgrade script
|
||||
#
|
||||
<%text>##################################################</%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
|
90
src/wuttjamaican/templates/install/web.conf.mako
Normal file
90
src/wuttjamaican/templates/install/web.conf.mako
Normal file
|
@ -0,0 +1,90 @@
|
|||
## -*- mode: conf; -*-
|
||||
|
||||
<%text>############################################################</%text>
|
||||
#
|
||||
# ${app_title} - web app config
|
||||
#
|
||||
<%text>############################################################</%text>
|
||||
|
||||
|
||||
<%text>##############################</%text>
|
||||
# wutta
|
||||
<%text>##############################</%text>
|
||||
|
||||
${self.section_wutta_config()}
|
||||
|
||||
|
||||
<%text>##############################</%text>
|
||||
# pyramid
|
||||
<%text>##############################</%text>
|
||||
|
||||
${self.section_app_main()}
|
||||
|
||||
${self.section_server_main()}
|
||||
|
||||
|
||||
<%text>##############################</%text>
|
||||
# logging
|
||||
<%text>##############################</%text>
|
||||
|
||||
${self.sectiongroup_logging()}
|
||||
|
||||
|
||||
######################################################################
|
||||
## section templates below
|
||||
######################################################################
|
||||
|
||||
<%def name="section_wutta_config()">
|
||||
[wutta.config]
|
||||
require = %(here)s/wutta.conf
|
||||
</%def>
|
||||
|
||||
<%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>
|
||||
|
||||
<%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>
|
||||
|
||||
<%def name="sectiongroup_logging()">
|
||||
[handler_console]
|
||||
level = INFO
|
||||
|
||||
[handler_file]
|
||||
args = (${repr(os.path.join(appdir, 'log', 'web.log'))}, 'a', 1000000, 100, 'utf_8')
|
||||
</%def>
|
160
src/wuttjamaican/templates/install/wutta.conf.mako
Normal file
160
src/wuttjamaican/templates/install/wutta.conf.mako
Normal file
|
@ -0,0 +1,160 @@
|
|||
## -*- mode: conf; -*-
|
||||
|
||||
<%text>############################################################</%text>
|
||||
#
|
||||
# ${app_title} - base config
|
||||
#
|
||||
<%text>############################################################</%text>
|
||||
|
||||
|
||||
<%text>##############################</%text>
|
||||
# wutta
|
||||
<%text>##############################</%text>
|
||||
|
||||
${self.section_wutta()}
|
||||
|
||||
${self.section_wutta_config()}
|
||||
|
||||
${self.section_wutta_db()}
|
||||
|
||||
${self.section_wutta_mail()}
|
||||
|
||||
${self.section_wutta_upgrades()}
|
||||
|
||||
|
||||
<%text>##############################</%text>
|
||||
# alembic
|
||||
<%text>##############################</%text>
|
||||
|
||||
${self.section_alembic()}
|
||||
|
||||
|
||||
<%text>##############################</%text>
|
||||
# logging
|
||||
<%text>##############################</%text>
|
||||
|
||||
${self.sectiongroup_logging()}
|
||||
|
||||
|
||||
######################################################################
|
||||
## section templates below
|
||||
######################################################################
|
||||
|
||||
<%def name="section_wutta()">
|
||||
[wutta]
|
||||
app_title = ${app_title}
|
||||
</%def>
|
||||
|
||||
<%def name="section_wutta_config()">
|
||||
[wutta.config]
|
||||
#require = /etc/wutta/wutta.conf
|
||||
configure_logging = true
|
||||
usedb = true
|
||||
preferdb = true
|
||||
</%def>
|
||||
|
||||
<%def name="section_wutta_db()">
|
||||
[wutta.db]
|
||||
default.url = ${db_url}
|
||||
## TODO
|
||||
## versioning.enabled = true
|
||||
</%def>
|
||||
|
||||
<%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>
|
||||
|
||||
<%def name="section_wutta_upgrades()">
|
||||
## TODO
|
||||
## [wutta.upgrades]
|
||||
## command = ${os.path.join(appdir, 'upgrade.sh')} --verbose
|
||||
## files = ${os.path.join(appdir, 'data', 'upgrades')}
|
||||
</%def>
|
||||
|
||||
<%def name="section_alembic()">
|
||||
[alembic]
|
||||
script_location = wuttjamaican.db:alembic
|
||||
version_locations = ${pkg_name}.db:alembic/versions wuttjamaican.db:alembic/versions
|
||||
</%def>
|
||||
|
||||
<%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
|
||||
</%def>
|
|
@ -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.
|
||||
|
|
|
@ -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')
|
||||
|
|
452
tests/test_install.py
Normal file
452
tests/test_install.py
Normal file
|
@ -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)
|
Loading…
Reference in a new issue