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

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""" 

26 

27import os 

28import shutil 

29import stat 

30import subprocess 

31import sys 

32 

33import rich 

34from mako.lookup import TemplateLookup 

35 

36from wuttjamaican.app import GenericHandler 

37 

38 

39class InstallHandler(GenericHandler): 

40 """ 

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

42 handler`. 

43 

44 See also 

45 :meth:`~wuttjamaican.app.AppHandler.get_install_handler()`. 

46 

47 The installer runs interactively via command line, prompting the 

48 user for various config settings etc. 

49 

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

51 

52 * exit code 1 indicates user canceled 

53 * exit code 2 indicates sanity check failed 

54 * other codes possible if errors occur 

55 

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

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

58 

59 app = config.get_app() 

60 install = app.get_install_handler(pkg_name='poser') 

61 install.run() 

62 

63 Note that these first 4 attributes may be specified via 

64 constructor kwargs: 

65 

66 .. attribute:: pkg_name 

67 

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

69 

70 .. attribute:: app_title 

71 

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

73 

74 .. attribute:: pypi_name 

75 

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

77 will be guessed. 

78 

79 .. attribute:: egg_name 

80 

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

82 

83 """ 

84 pkg_name = 'poser' 

85 app_title = None 

86 pypi_name = None 

87 egg_name = None 

88 

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

90 super().__init__(config) 

91 

92 # nb. caller may specify pkg_name etc. 

93 self.__dict__.update(kwargs) 

94 

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('-', '_') 

102 

103 def run(self): 

104 """ 

105 Run the interactive command-line installer. 

106 

107 This does the following: 

108 

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()` 

115 

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

117 happen. 

118 """ 

119 self.require_prompt_toolkit() 

120 

121 paths = [ 

122 self.app.resource_path('wuttjamaican:templates/install'), 

123 ] 

124 

125 try: 

126 paths.insert(0, self.app.resource_path(f'{self.pkg_name}:templates/install')) 

127 except (TypeError, ModuleNotFoundError): 

128 pass 

129 

130 self.templates = TemplateLookup(directories=paths) 

131 

132 self.show_welcome() 

133 self.sanity_check() 

134 self.schema_installed = False 

135 self.do_install_steps() 

136 self.show_goodbye() 

137 

138 def show_welcome(self): 

139 """ 

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

141 install. 

142 

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]") 

148 

149 # shall we continue? 

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

151 self.rprint() 

152 sys.exit(1) 

153 

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. 

158 

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) 

166 

167 def do_install_steps(self): 

168 """ 

169 Perform the real installation steps. 

170 

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

172 

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() 

180 

181 # get context for generated app files 

182 context = self.make_template_context(dbinfo) 

183 

184 # make the appdir 

185 self.make_appdir(context) 

186 

187 # install db schema if user likes 

188 self.schema_installed = self.install_db_schema(dbinfo['dburl']) 

189 

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. 

194 

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

196 

197 :returns: Dict of DB info collected from user. 

198 """ 

199 dbinfo = {} 

200 

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) 

208 

209 # get db password 

210 dbinfo['dbpass'] = None 

211 while not dbinfo['dbpass']: 

212 dbinfo['dbpass'] = self.prompt_generic('db pass', is_password=True) 

213 

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]") 

229 

230 return dbinfo 

231 

232 def make_db_url(self, dbtype, dbhost, dbport, dbname, dbuser, dbpass): 

233 from sqlalchemy.engine import URL 

234 

235 if dbtype == 'mysql': 

236 drivername = 'mysql+mysqlconnector' 

237 else: 

238 drivername = 'postgresql+psycopg2' 

239 

240 return URL.create(drivername=drivername, 

241 username=dbuser, 

242 password=dbpass, 

243 host=dbhost, 

244 port=dbport, 

245 database=dbname) 

246 

247 def test_db_connection(self, url): 

248 import sqlalchemy as sa 

249 

250 engine = sa.create_engine(url) 

251 

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) 

258 

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. 

263 

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

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

266 :meth:`render_mako_template()`. 

267 

268 :param dbinfo: Dict of DB connection info as obtained from 

269 :meth:`get_dbinfo()`. 

270 

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

272 

273 :returns: Dict for global template context. 

274 

275 The context dict will include: 

276 

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 

300 

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

302 """ 

303 Create the app folder structure and generate config files. 

304 

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

306 

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

308 :meth:`make_template_context()`. 

309 

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

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

312 

313 .. code-block:: none 

314 

315 /venv/ 

316 └── app/ 

317 ├── cache/ 

318 ├── data/ 

319 ├── log/ 

320 ├── work/ 

321 ├── wutta.conf 

322 ├── web.conf 

323 └── upgrade.sh 

324 

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) 

331 

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

333 

334 # wutta.conf 

335 self.make_config_file('wutta.conf.mako', 

336 os.path.join(appdir, 'wutta.conf'), 

337 **context) 

338 

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) 

348 

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) 

359 

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

361 

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()`. 

371 

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

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

374 

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

376 case the real template object is fetched via lookup. 

377 

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) 

383 

384 return self.app.render_mako_template(template, context, 

385 output_path=output_path) 

386 

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. 

391 

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

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

394 

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

396 

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

398 

399 Some context will be provided automatically for the template, 

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

401 

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 

408 

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 

422 

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. 

427 

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. 

431 

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

433 instance. 

434 """ 

435 from alembic.util.messaging import obfuscate_url_pw 

436 

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

438 return False 

439 

440 self.rprint() 

441 

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) 

448 

449 self.rprint("\n\tdb schema installed to: [bold green]{}[/bold green]".format( 

450 obfuscate_url_pw(db_url))) 

451 return True 

452 

453 def show_goodbye(self): 

454 """ 

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

456 

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

458 """ 

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

460 

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]") 

465 

466 self.rprint() 

467 

468 ############################## 

469 # console utility functions 

470 ############################## 

471 

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) 

481 

482 subprocess.check_call([sys.executable, '-m', 'pip', 

483 'install', 'prompt_toolkit']) 

484 

485 # nb. this should now succeed 

486 import prompt_toolkit 

487 

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

489 """ 

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

491 """ 

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

493 

494 def get_prompt_style(self): 

495 from prompt_toolkit.styles import Style 

496 

497 # message formatting styles 

498 return Style.from_dict({ 

499 '': '', 

500 'bold': 'bold', 

501 }) 

502 

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. 

513 

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

515 

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

517 

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

519 Enter without providing a value. 

520 

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

522 value, vs. a normal text value. 

523 

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

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

526 

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

528 provide a value before continuing). 

529 

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 

535 

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(('', ': ')) 

548 

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) 

557 

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) 

567 

568 if required and not text and not default: 

569 return self.prompt_generic(info, default, is_password=is_password, 

570 required=True) 

571 

572 return text or default 

573 

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

575 """ 

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

577 

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

579 ``is_bool=True``.. 

580 

581 :returns: ``True`` or ``False``. 

582 """ 

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