Coverage for .tox/coverage/lib/python3.13/site-packages/wuttjamaican/install.py: 100%

209 statements  

« prev     ^ index     » next       coverage.py v7.16.1, created at 2026-10-27 13:03 -0500

1# -*- coding: utf-8; -*- 

2################################################################################ 

3# 

4# WuttJamaican -- Base package for Wutta Framework 

5# Copyright © 2023-2026 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""" 

26 

27import os 

28import stat 

29import subprocess 

30import sys 

31 

32import rich 

33from mako.lookup import TemplateLookup 

34 

35from wuttjamaican.app import GenericHandler 

36 

37 

38class InstallHandler(GenericHandler): # pylint: disable=too-many-public-methods 

39 """ 

40 Base class and default implementation for the :term:`install 

41 handler`. 

42 

43 The installer runs interactively via command line. If 

44 installation completes okay the exit code is 0, but if not: 

45 

46 * exit code 1 indicates user canceled 

47 * exit code 2 indicates sanity check failed 

48 * other codes possible if errors occur 

49 

50 Usually an app will define e.g. ``poser install`` command which 

51 would invoke the install handler's :meth:`run()` method:: 

52 

53 app = config.get_app() 

54 install = app.get_install_handler(pkg_name="poser") 

55 install.run() 

56 

57 Note that these first 4 attributes may be specified via 

58 constructor kwargs: 

59 

60 .. attribute:: pkg_name 

61 

62 Python package name for the app, e.g. ``poser``. 

63 

64 .. attribute:: app_title 

65 

66 Display title for the app, e.g. "Poser". 

67 

68 .. attribute:: pypi_name 

69 

70 Package distribution name, e.g. for PyPI. If not specified one 

71 will be guessed. 

72 

73 .. attribute:: egg_name 

74 

75 Egg name for the app. If not specified one will be guessed. 

76 

77 """ 

78 

79 pkg_name = "poser" 

80 app_title = None 

81 pypi_name = None 

82 egg_name = None 

83 schema_installed = False 

84 

85 # nb. we prompt the user for this, unless attr already has value 

86 wants_continuum = None 

87 

88 template_paths = ["wuttjamaican:templates/install"] 

89 

90 def __init__(self, config, **kwargs): 

91 super().__init__(config) 

92 

93 # nb. caller may specify pkg_name etc. 

94 self.__dict__.update(kwargs) 

95 

96 # some package names we can generate by default 

97 if not self.app_title: 

98 self.app_title = self.pkg_name 

99 if not self.pypi_name: 

100 self.pypi_name = self.app_title 

101 if not self.egg_name: 

102 self.egg_name = self.pypi_name.replace("-", "_") 

103 

104 paths = [self.app.resource_path(p) for p in self.template_paths] 

105 

106 try: 

107 paths.insert( 

108 0, self.app.resource_path(f"{self.pkg_name}:templates/install") 

109 ) 

110 except (TypeError, ModuleNotFoundError): 

111 pass 

112 

113 self.templates = TemplateLookup(directories=paths) 

114 

115 # nb. this will eventually hold all context from user prompts etc. 

116 self.final_context = None 

117 

118 def run(self): 

119 """ 

120 Run the interactive command-line installer. 

121 

122 This does the following: 

123 

124 * check for ``prompt_toolkit`` and maybe ask to install it 

125 * call :meth:`show_welcome()` 

126 * call :meth:`sanity_check()` 

127 * call :meth:`do_install_steps()` 

128 * call :meth:`show_goodbye()` 

129 

130 Although if a problem is encountered then not all calls may 

131 happen. 

132 """ 

133 self.require_prompt_toolkit() 

134 self.show_welcome() 

135 self.sanity_check() 

136 self.schema_installed = False 

137 self.do_install_steps() 

138 self.show_goodbye() 

139 

140 def show_welcome(self): 

141 """ 

142 Show the intro/welcome message, and prompt user to begin the 

143 install. 

144 

145 This is normally called by :meth:`run()`. 

146 """ 

147 self.rprint(f"\n\t[blue]Welcome to {self.app.get_title()}![/blue]") 

148 self.rprint("\n\tThis tool will install and configure the app.") 

149 self.rprint( 

150 "\n\t[bold italic]NB. You should already have created " 

151 "the database in PostgreSQL or MySQL.[/bold italic]" 

152 ) 

153 

154 # shall we continue? 

155 if not self.prompt_bool("continue?", True): 

156 self.rprint() 

157 sys.exit(1) 

158 

159 def sanity_check(self): 

160 """ 

161 Perform various sanity checks before doing the install. If 

162 any problem is found the installer should exit with code 2. 

163 

164 This is normally called by :meth:`run()`. 

165 

166 The default logic here just calls :meth:`check_appdir()`. 

167 """ 

168 self.check_appdir() 

169 

170 def check_appdir(self): 

171 """ 

172 Check if the :term:`app dir` already exists; exit with code 2 

173 if so. 

174 

175 This is normally called from :meth:`sanity_check()`. 

176 """ 

177 # appdir must not yet exist 

178 appdir = os.path.join(sys.prefix, "app") 

179 if os.path.exists(appdir): 

180 self.rprint(f"\n\t[bold red]appdir already exists:[/bold red] {appdir}\n") 

181 sys.exit(2) 

182 

183 def do_install_steps(self): 

184 """ 

185 Perform the real installation steps. 

186 

187 This method is called by :meth:`run()` and does the following: 

188 

189 * call :meth:`prompt_user_for_context()` to collect DB info etc. 

190 * call :meth:`make_template_context()` to use when generating output 

191 * call :meth:`make_appdir()` to create app dir with config files 

192 * call :meth:`install_db_schema()` to (optionally) create tables in DB 

193 """ 

194 # prompt user / get context 

195 context = self.prompt_user_for_context() 

196 context = self.make_template_context(**context) 

197 

198 # keep a reference to this in case subsequent logic needs it 

199 self.final_context = context 

200 

201 # make the appdir 

202 self.make_appdir(context) 

203 

204 # install db schema if user likes 

205 self.schema_installed = self.install_db_schema(context["db_url"]) 

206 

207 def prompt_user_for_context(self): 

208 """ 

209 This is responsible for initial user prompts. 

210 

211 This happens early in the install, so this method can verify 

212 the info, e.g. test the DB connection, but should not write 

213 any files as the app dir may not exist yet. 

214 

215 Default logic calls :meth:`get_db_url()` for the DB 

216 connection, then may ask about Wutta-Continuum data 

217 versioning. (The latter is skipped if the package is 

218 missing.) 

219 

220 Subclass should override this method if they need different 

221 prompting logic. The return value should always include at 

222 least these 2 items: 

223 

224 * ``db_url`` - URL for the DB connection 

225 * ``wants_continuum`` - whether data versioning should be enabled 

226 

227 :returns: Dict of template context 

228 """ 

229 # db info 

230 db_url = self.get_db_url() 

231 

232 # continuum 

233 if self.wants_continuum is None: 

234 try: 

235 import wutta_continuum # pylint: disable=import-outside-toplevel,unused-import 

236 except ImportError: 

237 self.wants_continuum = False 

238 else: 

239 self.wants_continuum = self.prompt_bool( 

240 "use continuum for data versioning?", default=True 

241 ) 

242 

243 return {"db_url": db_url, "wants_continuum": self.wants_continuum} 

244 

245 def get_db_url(self): 

246 """ 

247 This must return the DB engine URL. 

248 

249 Default logic will prompt the user for hostname, port, DB name 

250 and credentials. It then assembles the URL from those parts. 

251 

252 This method will also test the DB connection. If it fails, 

253 the install is aborted. 

254 

255 This method is normally called by 

256 :meth:`prompt_user_for_context()`. 

257 

258 :returns: SQLAlchemy engine URL (as object or string) 

259 """ 

260 # get db info/url 

261 dbinfo = self.get_dbinfo() 

262 db_url = dbinfo.get("db_url") 

263 if not db_url: 

264 db_url = self.make_db_url(dbinfo) 

265 

266 # test db connection 

267 self.rprint("\n\ttesting db connection... ", end="") 

268 error = self.test_db_connection(db_url) 

269 if error: 

270 self.rprint("[bold red]cannot connect![/bold red] ..error was:") 

271 self.rprint(f"\n{error}") 

272 self.rprint("\n\t[bold yellow]aborting mission[/bold yellow]\n") 

273 sys.exit(1) 

274 self.rprint("[bold green]good[/bold green]") 

275 

276 return db_url 

277 

278 def get_dbinfo(self): # pylint: disable=missing-function-docstring 

279 dbinfo = {} 

280 

281 # main info 

282 dbinfo["dbtype"] = self.prompt_generic("db type", "postgresql") 

283 dbinfo["dbhost"] = self.prompt_generic("db host", "localhost") 

284 default_port = "3306" if dbinfo["dbtype"] == "mysql" else "5432" 

285 dbinfo["dbport"] = self.prompt_generic("db port", default_port) 

286 dbinfo["dbname"] = self.prompt_generic("db name", self.pkg_name) 

287 dbinfo["dbuser"] = self.prompt_generic("db user", self.pkg_name) 

288 

289 # password 

290 dbinfo["dbpass"] = None 

291 while not dbinfo["dbpass"]: 

292 dbinfo["dbpass"] = self.prompt_generic("db pass", is_password=True) 

293 

294 return dbinfo 

295 

296 def make_db_url(self, dbinfo): # pylint: disable=empty-docstring 

297 """ """ 

298 from sqlalchemy.engine import URL # pylint: disable=import-outside-toplevel 

299 

300 if dbinfo["dbtype"] == "mysql": 

301 drivername = "mysql+mysqlconnector" 

302 else: 

303 drivername = "postgresql+psycopg" 

304 

305 return URL.create( 

306 drivername=drivername, 

307 username=dbinfo["dbuser"], 

308 password=dbinfo["dbpass"], 

309 host=dbinfo["dbhost"], 

310 port=dbinfo["dbport"], 

311 database=dbinfo["dbname"], 

312 ) 

313 

314 def test_db_connection(self, url): # pylint: disable=empty-docstring 

315 """ """ 

316 import sqlalchemy as sa # pylint: disable=import-outside-toplevel 

317 

318 engine = sa.create_engine(url) 

319 

320 # check for random table; does not matter if it exists, we 

321 # just need to test interaction and this is a neutral way 

322 try: 

323 sa.inspect(engine).has_table("whatever") 

324 except Exception as error: # pylint: disable=broad-exception-caught 

325 return str(error) 

326 return None 

327 

328 def make_template_context(self, **kwargs): 

329 """ 

330 This must return a dict to be used as global template context 

331 when generating output (e.g. config) files. 

332 

333 This method is normally called by :meth:`do_install_steps()`. 

334 The ``context`` returned is then passed to 

335 :meth:`render_mako_template()`. 

336 

337 Note these first 2 params are not explicitly listed in the 

338 method signature; they are required nonetheless. 

339 

340 :param db_url: This must be a string URL for the DB engine. 

341 

342 :param wants_continuum: Whether data versioning should be 

343 enabled within the config. 

344 

345 :param \\**kwargs: Extra template context. 

346 

347 :returns: Dict for global template context. 

348 

349 The final context dict should include at least: 

350 

351 * ``envdir`` - value from :data:`python:sys.prefix` 

352 * ``envname`` - "last" dirname from ``sys.prefix`` 

353 * ``pkg_name`` - value from :attr:`pkg_name` 

354 * ``app_title`` - value from :attr:`app_title` 

355 * ``pypi_name`` - value from :attr:`pypi_name` 

356 * ``egg_name`` - value from :attr:`egg_name` 

357 * ``appdir`` - ``app`` folder under ``sys.prefix`` 

358 * ``db_url`` - value from ``kwargs`` 

359 * ``wants_continuum`` - value from ``kwargs`` 

360 """ 

361 envname = os.path.basename(sys.prefix) 

362 appdir = os.path.join(sys.prefix, "app") 

363 

364 db_url = kwargs.pop("db_url") 

365 if not isinstance(db_url, str): 

366 db_url = db_url.render_as_string(hide_password=False) 

367 

368 context = { 

369 "envdir": sys.prefix, 

370 "envname": envname, 

371 "pkg_name": self.pkg_name, 

372 "app_title": self.app_title, 

373 "pypi_name": self.pypi_name, 

374 "appdir": appdir, 

375 "egg_name": self.egg_name, 

376 "db_url": db_url, 

377 } 

378 context.update(kwargs) 

379 return context 

380 

381 def make_appdir(self, context, appdir=None): 

382 """ 

383 Create the app folder structure and generate config files. 

384 

385 This method is normally called by :meth:`do_install_steps()`. 

386 

387 :param context: Template context dict, i.e. from 

388 :meth:`make_template_context()`. 

389 

390 The default logic will create a structure as follows, assuming 

391 ``/venv`` is the path to the virtual environment: 

392 

393 .. code-block:: none 

394 

395 /venv/ 

396 └── app/ 

397 ├── cache/ 

398 ├── data/ 

399 ├── log/ 

400 ├── work/ 

401 ├── wutta.conf 

402 ├── web.conf 

403 └── upgrade.sh 

404 

405 File templates for this come from 

406 ``wuttjamaican:templates/install`` by default. 

407 

408 This method calls 

409 :meth:`~wuttjamaican.app.AppHandler.make_appdir()` for the 

410 basic structure and then :meth:`write_all_config_files()` for 

411 the gory details. 

412 """ 

413 # app handler makes appdir proper 

414 appdir = appdir or self.app.get_appdir() 

415 self.app.make_appdir(appdir) 

416 

417 # but then we also generate some files... 

418 self.write_all_config_files(appdir, context) 

419 

420 self.rprint(f"\n\tappdir created at: [bold green]{appdir}[/bold green]") 

421 

422 def write_all_config_files(self, appdir, context): 

423 """ 

424 This method should write all config files within the app dir. 

425 It's called from :meth:`make_appdir()`. 

426 

427 Subclass can override this for specialized installers. 

428 

429 Note that the app dir may or may not be newly-created, when 

430 this method is called. Some installers may support a 

431 "refresh" of the existing app dir. 

432 

433 Default logic (over)writes 3 files: 

434 

435 * ``wutta.conf`` 

436 * ``web.cof`` 

437 * ``upgrade.sh`` 

438 """ 

439 self.write_wutta_conf(appdir, context) 

440 self.write_web_conf(appdir, context) 

441 self.write_upgrade_sh(appdir, context) 

442 

443 def write_wutta_conf( 

444 self, appdir, context 

445 ): # pylint: disable=missing-function-docstring 

446 self.make_config_file( 

447 "wutta.conf.mako", os.path.join(appdir, "wutta.conf"), **context 

448 ) 

449 

450 def write_web_conf( 

451 self, appdir, context 

452 ): # pylint: disable=missing-function-docstring 

453 web_context = dict(context) 

454 web_context.setdefault("beaker_key", context.get("pkg_name", "poser")) 

455 web_context.setdefault("beaker_secret", "TODO_YOU_SHOULD_CHANGE_THIS") 

456 web_context.setdefault("pyramid_host", "0.0.0.0") 

457 web_context.setdefault("pyramid_port", "9080") 

458 self.make_config_file( 

459 "web.conf.mako", os.path.join(appdir, "web.conf"), **web_context 

460 ) 

461 

462 def write_upgrade_sh( 

463 self, appdir, context 

464 ): # pylint: disable=missing-function-docstring 

465 template = self.templates.get_template("upgrade.sh.mako") 

466 output_path = os.path.join(appdir, "upgrade.sh") 

467 self.render_mako_template(template, context, output_path=output_path) 

468 os.chmod( 

469 output_path, 

470 stat.S_IRWXU | stat.S_IRGRP | stat.S_IXGRP | stat.S_IROTH | stat.S_IXOTH, 

471 ) 

472 

473 def render_mako_template( 

474 self, 

475 template, 

476 context, 

477 output_path=None, 

478 ): 

479 """ 

480 Convenience wrapper around 

481 :meth:`~wuttjamaican.app.AppHandler.render_mako_template()`. 

482 

483 :param template: :class:`~mako:mako.template.Template` 

484 instance, or name of one to fetch via lookup. 

485 

486 This method allows specifying the template by name, in which 

487 case the real template object is fetched via lookup. 

488 

489 Other args etc. are the same as for the wrapped app handler 

490 method. 

491 """ 

492 if isinstance(template, str): 

493 template = self.templates.get_template(template) 

494 

495 return self.app.render_mako_template(template, context, output_path=output_path) 

496 

497 def make_config_file(self, template, output_path, **kwargs): 

498 """ 

499 Write a new config file to the given path, using the given 

500 template and context. 

501 

502 :param template: :class:`~mako:mako.template.Template` 

503 instance, or name of one to fetch via lookup. 

504 

505 :param output_path: Path to which output should be written. 

506 

507 :param \\**kwargs: Extra context for the template. 

508 

509 Some context will be provided automatically for the template, 

510 but these may be overridden via the ``**kwargs``: 

511 

512 * ``app_title`` - value from 

513 :meth:`~wuttjamaican.app.AppHandler.get_title()`. 

514 * ``appdir`` - value from 

515 :meth:`~wuttjamaican.app.AppHandler.get_appdir()`. 

516 * ``db_url`` - poser/dummy value 

517 * ``os`` - reference to :mod:`os` module 

518 

519 This method is mostly about sorting out the context dict. 

520 Once it does that it calls :meth:`render_mako_template()`. 

521 """ 

522 context = { 

523 "app_title": self.app.get_title(), 

524 "appdir": self.app.get_appdir(), 

525 "db_url": "postresql://user:pass@localhost/poser", 

526 "os": os, 

527 } 

528 context.update(kwargs) 

529 self.render_mako_template(template, context, output_path=output_path) 

530 return output_path 

531 

532 def install_db_schema(self, db_url, appdir=None): 

533 """ 

534 First prompt the user; if they agree then apply all Alembic 

535 migrations to the database. 

536 

537 This method is normally called by :meth:`do_install_steps()`. 

538 The end result should be a complete schema, ready for the app 

539 to use. 

540 

541 :param db_url: :class:`sqlalchemy:sqlalchemy.engine.URL` 

542 instance. 

543 

544 :param appdir: Optional override for the :term:`app dir`. 

545 Probably only useful in tests; this determines where config 

546 files should be found. If not specified, 

547 :meth:`~wuttjamaican.app.AppHandler.get_appdir()` is called 

548 to get it. 

549 

550 :returns: ``True`` if user confirmed and schema was installed; 

551 otherwise ``False``. 

552 """ 

553 from alembic.util.messaging import ( # pylint: disable=import-outside-toplevel 

554 obfuscate_url_pw, 

555 ) 

556 

557 if not self.prompt_bool("install db schema?", True): 

558 return False 

559 

560 self.rprint() 

561 

562 # install db schema 

563 appdir = appdir or self.app.get_appdir() 

564 cmd = [ 

565 os.path.join(sys.prefix, "bin", "alembic"), 

566 "-c", 

567 os.path.join(appdir, "wutta.conf"), 

568 "upgrade", 

569 "heads", 

570 ] 

571 subprocess.check_call(cmd) 

572 

573 self.rprint( 

574 "\n\tdb schema installed to: " 

575 f"[bold green]{obfuscate_url_pw(db_url)}[/bold green]" 

576 ) 

577 return True 

578 

579 def show_goodbye(self): 

580 """ 

581 Show the final message; this assumes setup completed okay. 

582 

583 This is normally called by :meth:`run()`. 

584 """ 

585 self.rprint("\n\t[bold green]initial setup is complete![/bold green]") 

586 

587 if self.schema_installed: 

588 self.rprint("\n\tyou can run the web app with:") 

589 self.rprint(f"\n\t[blue]cd {sys.prefix}[/blue]") 

590 self.rprint("\t[blue]bin/wutta -c app/web.conf webapp -r[/blue]") 

591 

592 self.rprint() 

593 

594 ############################## 

595 # console utility functions 

596 ############################## 

597 

598 def require_prompt_toolkit(self, answer=None): # pylint: disable=empty-docstring 

599 """ """ 

600 try: 

601 import prompt_toolkit # pylint: disable=unused-import,import-outside-toplevel 

602 except ImportError: 

603 value = answer or input( 

604 "\nprompt_toolkit is not installed. shall i install it? [Yn] " 

605 ) 

606 value = value.strip() 

607 if value and not self.config.parse_bool(value): 

608 sys.stderr.write("prompt_toolkit is required; aborting\n") 

609 sys.exit(1) 

610 

611 subprocess.check_call( 

612 [sys.executable, "-m", "pip", "install", "prompt_toolkit"] 

613 ) 

614 

615 # nb. this should now succeed 

616 import prompt_toolkit # pylint: disable=import-outside-toplevel 

617 

618 def rprint(self, *args, **kwargs): 

619 """ 

620 Convenience wrapper for :func:`rich:rich.print()`. 

621 """ 

622 rich.print(*args, **kwargs) 

623 

624 def get_prompt_style(self): # pylint: disable=empty-docstring 

625 """ """ 

626 from prompt_toolkit.styles import ( # pylint: disable=import-outside-toplevel 

627 Style, 

628 ) 

629 

630 # message formatting styles 

631 return Style.from_dict( 

632 { 

633 "": "", 

634 "bold": "bold", 

635 } 

636 ) 

637 

638 def prompt_generic( # pylint: disable=too-many-arguments,too-many-positional-arguments 

639 self, 

640 info, 

641 default=None, 

642 is_password=False, 

643 is_bool=False, 

644 required=False, 

645 ): 

646 """ 

647 Prompt the user to get their input. 

648 

649 See also :meth:`prompt_bool()`. 

650 

651 :param info: String to display (in bold) as prompt text. 

652 

653 :param default: Default value to assume if user just presses 

654 Enter without providing a value. 

655 

656 :param is_bool: Whether the prompt is for a boolean (Y/N) 

657 value, vs. a normal text value. 

658 

659 :param is_password: Whether the prompt is for a "password" or 

660 other sensitive text value. (User input will be masked.) 

661 

662 :param required: Whether the value is required (user must 

663 provide a value before continuing). 

664 

665 :returns: String value provided by the user (or the default), 

666 unless ``is_bool`` was requested in which case ``True`` or 

667 ``False``. 

668 """ 

669 from prompt_toolkit import prompt # pylint: disable=import-outside-toplevel 

670 

671 # build prompt message 

672 message = [ 

673 ("", "\n"), 

674 ("class:bold", info), 

675 ] 

676 if default is not None: 

677 if is_bool: 

678 message.append(("", f' [{"Y" if default else "N"}]: ')) 

679 else: 

680 message.append(("", f" [{default}]: ")) 

681 else: 

682 message.append(("", ": ")) 

683 

684 # prompt user for input 

685 style = self.get_prompt_style() 

686 try: 

687 text = prompt(message, style=style, is_password=is_password) 

688 except (KeyboardInterrupt, EOFError): 

689 self.rprint( 

690 "\n\t[bold yellow]operation canceled by user[/bold yellow]\n", 

691 file=sys.stderr, 

692 ) 

693 sys.exit(1) 

694 

695 if is_bool: 

696 if text == "": 

697 return default 

698 if text.upper() == "Y": 

699 return True 

700 if text.upper() == "N": 

701 return False 

702 self.rprint("\n\t[bold yellow]ambiguous, please try again[/bold yellow]\n") 

703 return self.prompt_generic(info, default, is_bool=True) 

704 

705 if required and not text and not default: 

706 return self.prompt_generic( 

707 info, default, is_password=is_password, required=True 

708 ) 

709 

710 return text or default 

711 

712 def prompt_bool(self, info, default=None): 

713 """ 

714 Prompt the user for a boolean (Y/N) value. 

715 

716 Convenience wrapper around :meth:`prompt_generic()` with 

717 ``is_bool=True``.. 

718 

719 :returns: ``True`` or ``False``. 

720 """ 

721 return self.prompt_generic(info, is_bool=True, default=default)