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

168 statements  

« prev     ^ index     » next       coverage.py v7.16.1, created at 2026-10-02 20:08 -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""" 

24Problem Checks + Handler 

25""" 

26 

27import calendar 

28import datetime 

29import importlib 

30import logging 

31import re 

32 

33from wuttjamaican.app import GenericHandler 

34from wuttjamaican.email import EmailSetting 

35 

36log = logging.getLogger(__name__) 

37 

38 

39class ProblemCheck: 

40 """ 

41 Base class for :term:`problem checks <problem check>`. 

42 

43 Each subclass must define logic for discovery of problems, 

44 according to its purpose; see :meth:`find_problems()`. 

45 

46 If the check does find problems, and an email is to be sent, the 

47 check instance is also able to affect that email somewhat, e.g. by 

48 adding an attachment. See :meth:`get_email_context()` and 

49 :meth:`make_email_attachments()`. 

50 

51 :param config: App :term:`config object`. 

52 """ 

53 

54 def __init__(self, config): 

55 self.config = config 

56 self.app = self.config.get_app() 

57 

58 @property 

59 def system_key(self): 

60 """ 

61 Key identifying which "system" the check pertains to. 

62 

63 Many apps may only have one "system" which corresponds to the 

64 app itself. However some apps may integrate with other 

65 systems and have ability/need to check for problems on those 

66 systems as well. 

67 

68 See also :attr:`problem_key` and :attr:`title`. 

69 """ 

70 raise AttributeError(f"system_key not defined for {self.__class__}") 

71 

72 @property 

73 def problem_key(self): 

74 """ 

75 Key identifying this problem check. 

76 

77 This key must be unique within the context of the "system" it 

78 pertains to. 

79 

80 See also :attr:`system_key` and :attr:`title`. 

81 """ 

82 raise AttributeError(f"problem_key not defined for {self.__class__}") 

83 

84 @property 

85 def title(self): 

86 """ 

87 Display title for the problem check. 

88 

89 See also :attr:`system_key` and :attr:`problem_key`. 

90 """ 

91 raise AttributeError(f"title not defined for {self.__class__}") 

92 

93 def find_problems(self): 

94 """ 

95 Find all problems relevant to this check. 

96 

97 This should always return a list, although no constraint is 

98 made on what type of elements it contains. 

99 

100 :returns: List of problems found. 

101 """ 

102 return [] 

103 

104 def get_email_context(self, problems, **kwargs): # pylint: disable=unused-argument 

105 """ 

106 This can be used to add extra context for a specific check's 

107 report email template. 

108 

109 :param problems: List of problems found. 

110 

111 :returns: Context dict for email template. 

112 """ 

113 return kwargs 

114 

115 def make_email_attachments(self, context): 

116 """ 

117 Optionally generate some attachment(s) for the report email. 

118 

119 :param context: Context dict for the report email. In 

120 particular see ``context['problems']`` for main data. 

121 

122 :returns: List of attachments, if applicable. 

123 """ 

124 

125 

126class ProblemHandler(GenericHandler): 

127 """ 

128 Base class and default implementation for the :term:`problem 

129 handler`. 

130 

131 There is normally no need to instantiate this yourself; instead 

132 call :meth:`~wuttjamaican.app.AppHandler.get_problem_handler()` on 

133 the :term:`app handler`. 

134 

135 The problem handler can be used to discover and run :term:`problem 

136 checks <problem check>`. In particular see: 

137 

138 * :meth:`get_all_problem_checks()` 

139 * :meth:`filter_problem_checks()` 

140 * :meth:`run_problem_checks()` 

141 """ 

142 

143 def get_all_problem_checks(self): 

144 """ 

145 Return a list of all :term:`problem checks <problem check>` 

146 which are "available" according to config. 

147 

148 See also :meth:`filter_problem_checks()`. 

149 

150 :returns: List of :class:`ProblemCheck` classes. 

151 """ 

152 checks = [] 

153 modules = self.config.get_list( 

154 f"{self.config.appname}.problems.modules", default=["wuttjamaican.problems"] 

155 ) 

156 for module_path in modules: 

157 module = importlib.import_module(module_path) 

158 for name in dir(module): 

159 obj = getattr(module, name) 

160 if ( 

161 isinstance(obj, type) 

162 and issubclass(obj, ProblemCheck) 

163 and obj is not ProblemCheck 

164 ): 

165 checks.append(obj) 

166 return checks 

167 

168 def get_problem_check(self, system_key, problem_key): 

169 """ 

170 Retrieve a particular problem check. Note that this returns 

171 the *class*, not the instance. 

172 

173 :param system_key: The :attr:`~ProblemCheck.system_key` for 

174 the desired problem check. 

175 

176 :param problem_key: The :attr:`~ProblemCheck.problem_key` for 

177 the desired problem check. 

178 

179 :returns: :class:`ProblemCheck` subclass, or ``None`` 

180 """ 

181 checks = self.filter_problem_checks( 

182 systems=[system_key], problems=[problem_key] 

183 ) 

184 if len(checks) > 1: 

185 log.warning( 

186 "found %s checks matching system/problem key: %s/%s", 

187 len(checks), 

188 system_key, 

189 problem_key, 

190 ) 

191 return checks[0] if checks else None 

192 

193 def filter_problem_checks(self, systems=None, problems=None): 

194 """ 

195 Return a list of all :term:`problem checks <problem check>` 

196 which match the given criteria. 

197 

198 This first calls :meth:`get_all_problem_checks()` and then 

199 filters the result according to params. 

200 

201 :param systems: Optional list of "system keys" which a problem check 

202 must match, in order to be included in return value. 

203 

204 :param problems: Optional list of "problem keys" which a problem check 

205 must match, in order to be included in return value. 

206 

207 :returns: List of :class:`ProblemCheck` classes; may be an 

208 empty list. 

209 """ 

210 all_checks = self.get_all_problem_checks() 

211 if not (systems or problems): 

212 return all_checks 

213 

214 matches = [] 

215 for check in all_checks: 

216 if not systems or check.system_key in systems: 

217 if not problems or check.problem_key in problems: 

218 matches.append(check) 

219 return matches 

220 

221 def get_supported_systems(self, checks=None): 

222 """ 

223 Returns list of keys for all systems which are supported by 

224 any of the problem checks. 

225 

226 :param checks: Optional list of :class:`ProblemCheck` classes. 

227 If not specified, calls :meth:`get_all_problem_checks()`. 

228 

229 :returns: List of system keys. 

230 """ 

231 checks = self.get_all_problem_checks() 

232 return sorted({check.system_key for check in checks}) 

233 

234 def get_system_title(self, system_key): 

235 """ 

236 Returns the display title for a given system. 

237 

238 The default logic returns the ``system_key`` as-is; subclass 

239 may override as needed. 

240 

241 :param system_key: Key identifying a checked system. 

242 

243 :returns: Display title for the system. 

244 """ 

245 return system_key 

246 

247 def is_enabled(self, check): 

248 """ 

249 Returns boolean indicating if the given problem check is 

250 enabled, per config. 

251 

252 :param check: :class:`ProblemCheck` class or instance. 

253 

254 :returns: ``True`` if enabled; ``False`` if not. 

255 """ 

256 key = f"{check.system_key}.{check.problem_key}" 

257 enabled = self.config.get_bool(f"{self.config.appname}.problems.{key}.enabled") 

258 if enabled is not None: 

259 return enabled 

260 return True 

261 

262 def should_run_for_weekday(self, check, weekday): 

263 """ 

264 Returns boolean indicating if the given problem check is 

265 configured to run for the given weekday. 

266 

267 :param check: :class:`ProblemCheck` class or instance. 

268 

269 :param weekday: Integer corresponding to a particular weekday. 

270 Uses the same conventions as Python itself, i.e. Monday is 

271 represented as 0 and Sunday as 6. 

272 

273 :returns: ``True`` if check should run; ``False`` if not. 

274 """ 

275 key = f"{check.system_key}.{check.problem_key}" 

276 enabled = self.config.get_bool( 

277 f"{self.config.appname}.problems.{key}.day{weekday}" 

278 ) 

279 if enabled is not None: 

280 return enabled 

281 return True 

282 

283 def organize_problem_checks(self, checks): 

284 """ 

285 Organize the problem checks by grouping them according to 

286 their :attr:`~ProblemCheck.system_key`. 

287 

288 :param checks: List of :class:`ProblemCheck` classes. 

289 

290 :returns: Dict with "system" keys; each value is a list of 

291 problem checks pertaining to that system. 

292 """ 

293 organized = {} 

294 

295 for check in checks: 

296 system = organized.setdefault(check.system_key, {}) 

297 system[check.problem_key] = check 

298 

299 return organized 

300 

301 def run_problem_checks(self, checks, force=False): 

302 """ 

303 Run the given problem checks. 

304 

305 This calls :meth:`run_problem_check()` for each, so config is 

306 consulted to determine if each check should actually run - 

307 unless ``force=True``. 

308 

309 :param checks: List of :class:`ProblemCheck` classes. 

310 

311 :param force: If true, run the checks regardless of whether 

312 each is configured to run. 

313 """ 

314 organized = self.organize_problem_checks(checks) 

315 for system_key in sorted(organized): 

316 system = organized[system_key] 

317 for problem_key in sorted(system): 

318 check = system[problem_key] 

319 self.run_problem_check(check, force=force) 

320 

321 def run_problem_check(self, check, force=False): 

322 """ 

323 Run the given problem check, if it is enabled and configured 

324 to run for the current weekday. 

325 

326 Running a check involves calling :meth:`find_problems()` and 

327 possibly :meth:`send_problem_report()`. This method will also 

328 update the "last run time" (saved setting) for the check. 

329 

330 See also :meth:`run_problem_checks()`. 

331 

332 :param check: :class:`ProblemCheck` class. 

333 

334 :param force: If true, run the check regardless of whether it 

335 is configured to run. 

336 

337 :returns: List of problems as obtained from 

338 :meth:`find_problems()`, or ``None`` if the check was not 

339 configured to run. 

340 """ 

341 key = f"{check.system_key}.{check.problem_key}" 

342 log.info("running problem check: %s", key) 

343 

344 if not self.is_enabled(check): 

345 log.debug("problem check is not enabled: %s", key) 

346 if not force: 

347 return None 

348 

349 weekday = datetime.date.today().weekday() 

350 if not self.should_run_for_weekday(check, weekday): 

351 log.debug( 

352 "problem check is not scheduled for %s: %s", 

353 calendar.day_name[weekday], 

354 key, 

355 ) 

356 if not force: 

357 return None 

358 

359 check_instance = check(self.config) 

360 problems = self.find_problems(check_instance) 

361 log.info("found %s problems", len(problems)) 

362 if problems: 

363 self.send_problem_report(check_instance, problems) 

364 

365 # save 'lastrun' setting if sqlalchemy is configured 

366 try: 

367 session = self.app.make_session() 

368 except ModuleNotFoundError: 

369 pass 

370 else: 

371 if session.bind: 

372 now = self.app.make_utc().isoformat() 

373 self.app.save_setting( 

374 session, f"{self.config.appname}.problems.{key}.lastrun", now 

375 ) 

376 session.commit() 

377 session.close() 

378 

379 return problems 

380 

381 def find_problems(self, check): 

382 """ 

383 Execute the given check to find relevant problems. 

384 

385 This mostly calls :meth:`ProblemCheck.find_problems()` 

386 although subclass may override if needed. 

387 

388 This should always return a list, although no constraint is 

389 made on what type of elements it contains. 

390 

391 :param check: :class:`ProblemCheck` instance. 

392 

393 :returns: List of problems found. 

394 """ 

395 return check.find_problems() or [] 

396 

397 def get_email_key(self, check): 

398 """ 

399 Return the "email key" to be used when sending report email 

400 resulting from the given problem check. 

401 

402 This follows a convention using the check's 

403 :attr:`~ProblemCheck.system_key` and 

404 :attr:`~ProblemCheck.problem_key`. 

405 

406 This is called by :meth:`send_problem_report()`. 

407 

408 :param check: :class:`ProblemCheck` class or instance. 

409 

410 :returns: Config key for problem report email message. 

411 """ 

412 return f"{check.system_key}_problems_{check.problem_key}" 

413 

414 def send_problem_report(self, check, problems): 

415 """ 

416 Send an email with details of the given problem check report. 

417 

418 This calls :meth:`get_email_key()` to determine which key to 

419 use for sending email. 

420 

421 It also calls :meth:`get_full_email_context()` to build the 

422 email template context. 

423 

424 And it calls :meth:`ProblemCheck.make_email_attachments()` to 

425 allow the check to provide message attachments. 

426 

427 :param check: :class:`ProblemCheck` instance. 

428 

429 :param problems: List of problems found. 

430 """ 

431 email_key = self.get_email_key(check) 

432 context = self.get_full_email_context(check, problems) 

433 attachments = check.make_email_attachments(context) 

434 self.app.send_email( 

435 email_key, context, default_subject=check.title, attachments=attachments 

436 ) 

437 

438 def get_full_email_context(self, check, problems): 

439 """ 

440 This method collects *all* context for the problem report 

441 email template. It will call: 

442 

443 * :meth:`get_global_email_context()` 

444 * :meth:`get_check_email_context()` 

445 

446 :param check: :class:`ProblemCheck` instance. 

447 

448 :param problems: List of problems found. 

449 

450 :returns: Context dict for the email template. 

451 """ 

452 context = self.get_global_email_context() 

453 context = self.get_check_email_context(check, problems, **context) 

454 context.update( 

455 { 

456 "config": self.config, 

457 "app": self.app, 

458 "check": check, 

459 "problems": problems, 

460 } 

461 ) 

462 return context 

463 

464 def get_global_email_context(self, **kwargs): 

465 """ 

466 This can be used to add extra context for all email report 

467 templates, regardless of which problem check is involved. 

468 

469 :returns: Context dict for all email templates. 

470 """ 

471 return kwargs 

472 

473 def get_check_email_context(self, check, problems, **kwargs): 

474 """ 

475 This can be used to add extra context for a specific check's 

476 report email template. 

477 

478 Note that this calls :meth:`ProblemCheck.get_email_context()` 

479 and in many cases that is where customizations should live. 

480 

481 :param check: :class:`ProblemCheck` instance. 

482 

483 :param problems: List of problems found. 

484 

485 :returns: Context dict for email template. 

486 """ 

487 kwargs["system_title"] = self.get_system_title(check.system_key) 

488 kwargs = check.get_email_context(problems, **kwargs) 

489 return kwargs 

490 

491 

492class ProblemEmailSetting(EmailSetting): 

493 """ 

494 Base class for :term:`problem check` report email settings. This 

495 inherits from :class:`~wuttjamaican.email.EmailSetting`. 

496 

497 Each subclass should declare which problem check it pertains to, 

498 in one of 3 ways: 

499 

500 * class is named like ``f"{system_key}_problems_{problem_key}"`` 

501 

502 * :attr:`system_key` and :attr:`problem_key` attributes are set 

503 

504 * :attr:`problem_spec` attribute is set 

505 

506 See :meth:`get_problem_check()` for more info about that. 

507 

508 .. attribute:: system_key 

509 

510 This is optional, depending on how you declare the related 

511 problem check. If set, it should match the desired 

512 :attr:`ProblemCheck.system_key`. 

513 

514 .. attribute:: problem_key 

515 

516 This is optional, depending on how you declare the related 

517 problem check. If set, it should match the desired 

518 :attr:`ProblemCheck.problem_key`. 

519 

520 .. attribute:: problem_spec 

521 

522 This is optional, depending on how you declare the related 

523 problem check. If set, it should be the full :term:`spec` 

524 string for the desired problem check class. 

525 """ 

526 

527 system_key = None 

528 problem_key = None 

529 problem_spec = None 

530 

531 def get_problem_check(self): 

532 """ 

533 Retrieve the problem check instance related to this email 

534 setting, if possible. 

535 

536 This email setting's class name and/or attributes will 

537 determine how the problem check class is located. (See also 

538 notes in the :class:`ProblemEmailSetting` section above.) 

539 

540 If :attr:`problem_spec` is set, that will be used to load the 

541 specified check class, which is then instantiated. 

542 

543 Or, if :attr:`system_key` and :attr:`problem_key` are set, 

544 they will be used to load the check, via 

545 :meth:`ProblemHandler.get_problem_check()`. 

546 

547 But the "default" behavior is to parse the system key and 

548 problem key from this email setting's *class name*. For 

549 instance:: 

550 

551 from wuttjamaican.problems import ProblemEmailSetting 

552 

553 # email setting whose name conforms to expected pattern 

554 # does not need to specify explicit problem check 

555 class poser_problems_water_too_wet(ProblemEmailSetting): 

556 pass 

557 

558 # but if email setting name does not conform, keys for 

559 # problem check can be declared, so handler can do lookup 

560 class water_too_wet(ProblemEmailSetting): 

561 system_key = "poser" 

562 problem_key = "water_too_wet" 

563 

564 # alternatively, declare full spec for the problem check 

565 class water_too_wet_two(ProblemEmailSetting): 

566 problem_spec = "poser.problems:WaterTooWet" 

567 

568 :returns: :class:`ProblemCheck` instance, or ``None`` 

569 """ 

570 

571 # prefer explicit spec, if set 

572 if self.problem_spec: 

573 return self.app.load_object(self.problem_spec)(self.config) 

574 

575 handler = self.app.get_problem_handler() 

576 

577 # next try spec lookup, if keys set 

578 if self.system_key and self.problem_key: 

579 return handler.get_problem_check(self.system_key, self.problem_key)( 

580 self.config 

581 ) 

582 

583 # or maybe try spec lookup based on setting class name 

584 class_name = self.__class__.__name__ 

585 if match := re.match( 

586 r"^(?P<system>\S+)_problems_(?P<problem>\S+)$", class_name 

587 ): 

588 if check := handler.get_problem_check(match["system"], match["problem"]): 

589 return check(self.config) 

590 

591 return None 

592 

593 def get_description(self): # pylint: disable=empty-docstring 

594 """ """ 

595 # prefer email setting docstring, if set 

596 if docstring := self.__class__.__doc__: 

597 if desc := docstring.strip(): 

598 return desc 

599 

600 # then try problem check docstring 

601 if check := self.get_problem_check(): 

602 if docstring := check.__doc__: 

603 if desc := docstring.strip(): 

604 return desc 

605 

606 # generic fallback 

607 title = check.title if check else "??" 

608 return f"Sent when problems are found for check: {title}" 

609 

610 def get_default_subject(self): # pylint: disable=empty-docstring 

611 """ """ 

612 # prefer email setting's default subject, if set 

613 if self.default_subject: 

614 return self.default_subject 

615 

616 # otherwise use problem check title 

617 if check := self.get_problem_check(): 

618 return check.title 

619 

620 return None 

621 

622 def get_sample_data(self): # pylint: disable=empty-docstring 

623 """ """ 

624 if check := self.get_problem_check(): 

625 problems = self.get_sample_problems() 

626 handler = self.app.get_problem_handler() 

627 return handler.get_full_email_context(check, problems) 

628 return {} 

629 

630 def get_sample_problems(self): 

631 """ 

632 To improve the relevance of sample data for the email template 

633 preview, subclass can override this method to declare 

634 "problems found". 

635 

636 See also 

637 :meth:`~wuttjamaican.email.EmailSetting.get_sample_data()`. 

638 

639 :returns: List of problems found. 

640 """ 

641 return []