Coverage for .tox/coverage/lib/python3.11/site-packages/wuttjamaican/install.py: 100%
178 statements
« prev ^ index » next coverage.py v7.3.2, created at 2025-01-06 17:01 -0600
« prev ^ index » next coverage.py v7.3.2, created at 2025-01-06 17:01 -0600
1# -*- coding: utf-8; -*-
2################################################################################
3#
4# WuttJamaican -- Base package for Wutta Framework
5# Copyright © 2023-2024 Lance Edgar
6#
7# This file is part of Wutta Framework.
8#
9# Wutta Framework is free software: you can redistribute it and/or modify it
10# under the terms of the GNU General Public License as published by the Free
11# Software Foundation, either version 3 of the License, or (at your option) any
12# later version.
13#
14# Wutta Framework is distributed in the hope that it will be useful, but
15# WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
16# FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
17# more details.
18#
19# You should have received a copy of the GNU General Public License along with
20# Wutta Framework. If not, see <http://www.gnu.org/licenses/>.
21#
22################################################################################
23"""
24Install Handler
25"""
27import os
28import shutil
29import stat
30import subprocess
31import sys
33import rich
34from mako.lookup import TemplateLookup
36from wuttjamaican.app import GenericHandler
39class InstallHandler(GenericHandler):
40 """
41 Base class and default implementation for the :term:`install
42 handler`.
44 See also
45 :meth:`~wuttjamaican.app.AppHandler.get_install_handler()`.
47 The installer runs interactively via command line, prompting the
48 user for various config settings etc.
50 If installation completes okay the exit code is 0, but if not:
52 * exit code 1 indicates user canceled
53 * exit code 2 indicates sanity check failed
54 * other codes possible if errors occur
56 Usually an app will define e.g. ``poser install`` command which
57 would invoke the install handler's :meth:`run()` method::
59 app = config.get_app()
60 install = app.get_install_handler(pkg_name='poser')
61 install.run()
63 Note that these first 4 attributes may be specified via
64 constructor kwargs:
66 .. attribute:: pkg_name
68 Python package name for the app, e.g. ``poser``.
70 .. attribute:: app_title
72 Display title for the app, e.g. "Poser".
74 .. attribute:: pypi_name
76 Package distribution name, e.g. for PyPI. If not specified one
77 will be guessed.
79 .. attribute:: egg_name
81 Egg name for the app. If not specified one will be guessed.
83 """
84 pkg_name = 'poser'
85 app_title = None
86 pypi_name = None
87 egg_name = None
89 def __init__(self, config, **kwargs):
90 super().__init__(config)
92 # nb. caller may specify pkg_name etc.
93 self.__dict__.update(kwargs)
95 # some package names we can generate by default
96 if not self.app_title:
97 self.app_title = self.pkg_name
98 if not self.pypi_name:
99 self.pypi_name = self.app_title
100 if not self.egg_name:
101 self.egg_name = self.pypi_name.replace('-', '_')
103 def run(self):
104 """
105 Run the interactive command-line installer.
107 This does the following:
109 * check for ``prompt_toolkit`` and maybe ask to install it
110 * define the template lookup paths, for making config files
111 * call :meth:`show_welcome()`
112 * call :meth:`sanity_check()`
113 * call :meth:`do_install_steps()`
114 * call :meth:`show_goodbye()`
116 Although if a problem is encountered then not all calls may
117 happen.
118 """
119 self.require_prompt_toolkit()
121 paths = [
122 self.app.resource_path('wuttjamaican:templates/install'),
123 ]
125 try:
126 paths.insert(0, self.app.resource_path(f'{self.pkg_name}:templates/install'))
127 except (TypeError, ModuleNotFoundError):
128 pass
130 self.templates = TemplateLookup(directories=paths)
132 self.show_welcome()
133 self.sanity_check()
134 self.schema_installed = False
135 self.do_install_steps()
136 self.show_goodbye()
138 def show_welcome(self):
139 """
140 Show the intro/welcome message, and prompt user to begin the
141 install.
143 This is normally called by :meth:`run()`.
144 """
145 self.rprint("\n\t[blue]Welcome to {}![/blue]".format(self.app.get_title()))
146 self.rprint("\n\tThis tool will install and configure the app.")
147 self.rprint("\n\t[italic]NB. You should already have created the database in PostgreSQL or MySQL.[/italic]")
149 # shall we continue?
150 if not self.prompt_bool("continue?", True):
151 self.rprint()
152 sys.exit(1)
154 def sanity_check(self):
155 """
156 Perform various sanity checks before doing the install. If
157 any problem is found the installer should exit with code 2.
159 This is normally called by :meth:`run()`.
160 """
161 # appdir must not yet exist
162 appdir = os.path.join(sys.prefix, 'app')
163 if os.path.exists(appdir):
164 self.rprint(f"\n\t[bold red]appdir already exists:[/bold red] {appdir}\n")
165 sys.exit(2)
167 def do_install_steps(self):
168 """
169 Perform the real installation steps.
171 This method is called by :meth:`run()` and does the following:
173 * call :meth:`get_dbinfo()` to get DB info from user, and test connection
174 * call :meth:`make_template_context()` to use when generating output
175 * call :meth:`make_appdir()` to create app dir with config files
176 * call :meth:`install_db_schema()` to (optionally) create tables in DB
177 """
178 # prompt user for db info
179 dbinfo = self.get_dbinfo()
181 # get context for generated app files
182 context = self.make_template_context(dbinfo)
184 # make the appdir
185 self.make_appdir(context)
187 # install db schema if user likes
188 self.schema_installed = self.install_db_schema(dbinfo['dburl'])
190 def get_dbinfo(self):
191 """
192 Collect DB connection info from the user, and test the
193 connection. If connection fails, exit the install.
195 This method is normally called by :meth:`do_install_steps()`.
197 :returns: Dict of DB info collected from user.
198 """
199 dbinfo = {}
201 # get db info
202 dbinfo['dbtype'] = self.prompt_generic('db type', 'postgresql')
203 dbinfo['dbhost'] = self.prompt_generic('db host', 'localhost')
204 default_port = '3306' if dbinfo['dbtype'] == 'mysql' else '5432'
205 dbinfo['dbport'] = self.prompt_generic('db port', default_port)
206 dbinfo['dbname'] = self.prompt_generic('db name', self.pkg_name)
207 dbinfo['dbuser'] = self.prompt_generic('db user', self.pkg_name)
209 # get db password
210 dbinfo['dbpass'] = None
211 while not dbinfo['dbpass']:
212 dbinfo['dbpass'] = self.prompt_generic('db pass', is_password=True)
214 # test db connection
215 self.rprint("\n\ttesting db connection... ", end='')
216 dbinfo['dburl'] = self.make_db_url(dbinfo['dbtype'],
217 dbinfo['dbhost'],
218 dbinfo['dbport'],
219 dbinfo['dbname'],
220 dbinfo['dbuser'],
221 dbinfo['dbpass'])
222 error = self.test_db_connection(dbinfo['dburl'])
223 if error:
224 self.rprint("[bold red]cannot connect![/bold red] ..error was:")
225 self.rprint("\n{}".format(error))
226 self.rprint("\n\t[bold yellow]aborting mission[/bold yellow]\n")
227 sys.exit(1)
228 self.rprint("[bold green]good[/bold green]")
230 return dbinfo
232 def make_db_url(self, dbtype, dbhost, dbport, dbname, dbuser, dbpass):
233 from sqlalchemy.engine import URL
235 if dbtype == 'mysql':
236 drivername = 'mysql+mysqlconnector'
237 else:
238 drivername = 'postgresql+psycopg2'
240 return URL.create(drivername=drivername,
241 username=dbuser,
242 password=dbpass,
243 host=dbhost,
244 port=dbport,
245 database=dbname)
247 def test_db_connection(self, url):
248 import sqlalchemy as sa
250 engine = sa.create_engine(url)
252 # check for random table; does not matter if it exists, we
253 # just need to test interaction and this is a neutral way
254 try:
255 sa.inspect(engine).has_table('whatever')
256 except Exception as error:
257 return str(error)
259 def make_template_context(self, dbinfo, **kwargs):
260 """
261 This must return a dict to be used as global template context
262 when generating output (e.g. config) files.
264 This method is normally called by :meth:`do_install_steps()`.
265 The ``context`` returned is then passed to
266 :meth:`render_mako_template()`.
268 :param dbinfo: Dict of DB connection info as obtained from
269 :meth:`get_dbinfo()`.
271 :param \**kwargs: Extra template context.
273 :returns: Dict for global template context.
275 The context dict will include:
277 * ``envdir`` - value from :data:`python:sys.prefix`
278 * ``envname`` - "last" dirname from ``sys.prefix``
279 * ``pkg_name`` - value from :attr:`pkg_name`
280 * ``app_title`` - value from :attr:`app_title`
281 * ``pypi_name`` - value from :attr:`pypi_name`
282 * ``egg_name`` - value from :attr:`egg_name`
283 * ``appdir`` - ``app`` folder under ``sys.prefix``
284 * ``db_url`` - value from ``dbinfo['dburl']``
285 """
286 envname = os.path.basename(sys.prefix)
287 appdir = os.path.join(sys.prefix, 'app')
288 context = {
289 'envdir': sys.prefix,
290 'envname': envname,
291 'pkg_name': self.pkg_name,
292 'app_title': self.app_title,
293 'pypi_name': self.pypi_name,
294 'appdir': appdir,
295 'db_url': dbinfo['dburl'],
296 'egg_name': self.egg_name,
297 }
298 context.update(kwargs)
299 return context
301 def make_appdir(self, context, appdir=None):
302 """
303 Create the app folder structure and generate config files.
305 This method is normally called by :meth:`do_install_steps()`.
307 :param context: Template context dict, i.e. from
308 :meth:`make_template_context()`.
310 The default logic will create a structure as follows, assuming
311 ``/venv`` is the path to the virtual environment:
313 .. code-block:: none
315 /venv/
316 └── app/
317 ├── cache/
318 ├── data/
319 ├── log/
320 ├── work/
321 ├── wutta.conf
322 ├── web.conf
323 └── upgrade.sh
325 File templates for this come from
326 ``wuttjamaican:templates/install`` by default.
327 """
328 # app handler makes appdir proper
329 appdir = appdir or self.app.get_appdir()
330 self.app.make_appdir(appdir)
332 # but then we also generate some files...
334 # wutta.conf
335 self.make_config_file('wutta.conf.mako',
336 os.path.join(appdir, 'wutta.conf'),
337 **context)
339 # web.conf
340 web_context = dict(context)
341 web_context.setdefault('beaker_key', context.get('pkg_name', 'poser'))
342 web_context.setdefault('beaker_secret', 'TODO_YOU_SHOULD_CHANGE_THIS')
343 web_context.setdefault('pyramid_host', '0.0.0.0')
344 web_context.setdefault('pyramid_port', '9080')
345 self.make_config_file('web.conf.mako',
346 os.path.join(appdir, 'web.conf'),
347 **web_context)
349 # upgrade.sh
350 template = self.templates.get_template('upgrade.sh.mako')
351 output_path = os.path.join(appdir, 'upgrade.sh')
352 self.render_mako_template(template, context,
353 output_path=output_path)
354 os.chmod(output_path, stat.S_IRWXU
355 | stat.S_IRGRP
356 | stat.S_IXGRP
357 | stat.S_IROTH
358 | stat.S_IXOTH)
360 self.rprint(f"\n\tappdir created at: [bold green]{appdir}[/bold green]")
362 def render_mako_template(
363 self,
364 template,
365 context,
366 output_path=None,
367 ):
368 """
369 Convenience wrapper around
370 :meth:`~wuttjamaican.app.AppHandler.render_mako_template()`.
372 :param template: :class:`~mako:mako.template.Template`
373 instance, or name of one to fetch via lookup.
375 This method allows specifying the template by name, in which
376 case the real template object is fetched via lookup.
378 Other args etc. are the same as for the wrapped app handler
379 method.
380 """
381 if isinstance(template, str):
382 template = self.templates.get_template(template)
384 return self.app.render_mako_template(template, context,
385 output_path=output_path)
387 def make_config_file(self, template, output_path, **kwargs):
388 """
389 Write a new config file to the given path, using the given
390 template and context.
392 :param template: :class:`~mako:mako.template.Template`
393 instance, or name of one to fetch via lookup.
395 :param output_path: Path to which output should be written.
397 :param \**kwargs: Extra context for the template.
399 Some context will be provided automatically for the template,
400 but these may be overridden via the ``**kwargs``:
402 * ``app_title`` - value from
403 :meth:`~wuttjamaican.app.AppHandler.get_title()`.
404 * ``appdir`` - value from
405 :meth:`~wuttjamaican.app.AppHandler.get_appdir()`.
406 * ``db_url`` - poser/dummy value
407 * ``os`` - reference to :mod:`os` module
409 This method is mostly about sorting out the context dict.
410 Once it does that it calls :meth:`render_mako_template()`.
411 """
412 context = {
413 'app_title': self.app.get_title(),
414 'appdir': self.app.get_appdir(),
415 'db_url': 'postresql://user:pass@localhost/poser',
416 'os': os,
417 }
418 context.update(kwargs)
419 self.render_mako_template(template, context,
420 output_path=output_path)
421 return output_path
423 def install_db_schema(self, db_url, appdir=None):
424 """
425 First prompt the user, but if they agree then apply all
426 Alembic migrations to the configured database.
428 This method is normally called by :meth:`do_install_steps()`.
429 The end result should be a complete schema, ready for the app
430 to use.
432 :param db_url: :class:`sqlalchemy:sqlalchemy.engine.URL`
433 instance.
434 """
435 from alembic.util.messaging import obfuscate_url_pw
437 if not self.prompt_bool("install db schema?", True):
438 return False
440 self.rprint()
442 # install db schema
443 appdir = appdir or self.app.get_appdir()
444 cmd = [os.path.join(sys.prefix, 'bin', 'alembic'),
445 '-c', os.path.join(appdir, 'wutta.conf'),
446 'upgrade', 'heads']
447 subprocess.check_call(cmd)
449 self.rprint("\n\tdb schema installed to: [bold green]{}[/bold green]".format(
450 obfuscate_url_pw(db_url)))
451 return True
453 def show_goodbye(self):
454 """
455 Show the final message; this assumes setup completed okay.
457 This is normally called by :meth:`run()`.
458 """
459 self.rprint("\n\t[bold green]initial setup is complete![/bold green]")
461 if self.schema_installed:
462 self.rprint("\n\tyou can run the web app with:")
463 self.rprint(f"\n\t[blue]cd {sys.prefix}[/blue]")
464 self.rprint("\t[blue]bin/wutta -c app/web.conf webapp -r[/blue]")
466 self.rprint()
468 ##############################
469 # console utility functions
470 ##############################
472 def require_prompt_toolkit(self, answer=None):
473 try:
474 import prompt_toolkit
475 except ImportError:
476 value = answer or input("\nprompt_toolkit is not installed. shall i install it? [Yn] ")
477 value = value.strip()
478 if value and not self.config.parse_bool(value):
479 sys.stderr.write("prompt_toolkit is required; aborting\n")
480 sys.exit(1)
482 subprocess.check_call([sys.executable, '-m', 'pip',
483 'install', 'prompt_toolkit'])
485 # nb. this should now succeed
486 import prompt_toolkit
488 def rprint(self, *args, **kwargs):
489 """
490 Convenience wrapper for :func:`rich:rich.print()`.
491 """
492 rich.print(*args, **kwargs)
494 def get_prompt_style(self):
495 from prompt_toolkit.styles import Style
497 # message formatting styles
498 return Style.from_dict({
499 '': '',
500 'bold': 'bold',
501 })
503 def prompt_generic(
504 self,
505 info,
506 default=None,
507 is_password=False,
508 is_bool=False,
509 required=False,
510 ):
511 """
512 Prompt the user to get their input.
514 See also :meth:`prompt_bool()`.
516 :param info: String to display (in bold) as prompt text.
518 :param default: Default value to assume if user just presses
519 Enter without providing a value.
521 :param is_bool: Whether the prompt is for a boolean (Y/N)
522 value, vs. a normal text value.
524 :param is_password: Whether the prompt is for a "password" or
525 other sensitive text value. (User input will be masked.)
527 :param required: Whether the value is required (user must
528 provide a value before continuing).
530 :returns: String value provided by the user (or the default),
531 unless ``is_bool`` was requested in which case ``True`` or
532 ``False``.
533 """
534 from prompt_toolkit import prompt
536 # build prompt message
537 message = [
538 ('', '\n'),
539 ('class:bold', info),
540 ]
541 if default is not None:
542 if is_bool:
543 message.append(('', ' [{}]: '.format('Y' if default else 'N')))
544 else:
545 message.append(('', ' [{}]: '.format(default)))
546 else:
547 message.append(('', ': '))
549 # prompt user for input
550 style = self.get_prompt_style()
551 try:
552 text = prompt(message, style=style, is_password=is_password)
553 except (KeyboardInterrupt, EOFError):
554 self.rprint("\n\t[bold yellow]operation canceled by user[/bold yellow]\n",
555 file=sys.stderr)
556 sys.exit(1)
558 if is_bool:
559 if text == '':
560 return default
561 elif text.upper() == 'Y':
562 return True
563 elif text.upper() == 'N':
564 return False
565 self.rprint("\n\t[bold yellow]ambiguous, please try again[/bold yellow]\n")
566 return self.prompt_generic(info, default, is_bool=True)
568 if required and not text and not default:
569 return self.prompt_generic(info, default, is_password=is_password,
570 required=True)
572 return text or default
574 def prompt_bool(self, info, default=None):
575 """
576 Prompt the user for a boolean (Y/N) value.
578 Convenience wrapper around :meth:`prompt_generic()` with
579 ``is_bool=True``..
581 :returns: ``True`` or ``False``.
582 """
583 return self.prompt_generic(info, is_bool=True, default=default)