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

88 statements  

« prev     ^ index     » next       coverage.py v7.3.2, created at 2025-01-09 12:13 -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""" 

24Batch Handlers 

25""" 

26 

27import datetime 

28import os 

29import shutil 

30 

31from wuttjamaican.app import GenericHandler 

32 

33 

34class BatchHandler(GenericHandler): 

35 """ 

36 Base class and *partial* default implementation for :term:`batch 

37 handlers <batch handler>`. 

38 

39 This handler class "works as-is" but does not actually do 

40 anything. Subclass must implement logic for various things as 

41 needed, e.g.: 

42 

43 * :attr:`model_class` 

44 * :meth:`init_batch()` 

45 * :meth:`should_populate()` 

46 * :meth:`populate()` 

47 * :meth:`refresh_row()` 

48 """ 

49 

50 @property 

51 def model_class(self): 

52 """ 

53 Reference to the batch :term:`data model` class which this 

54 batch handler is meant to work with. 

55 

56 This is expected to be a subclass of 

57 :class:`~wuttjamaican.db.model.batch.BatchMixin` (among other 

58 classes). 

59 

60 Subclass must define this; default is not implemented. 

61 """ 

62 raise NotImplementedError("You must set the 'model_class' attribute " 

63 f"for class '{self.__class__.__name__}'") 

64 

65 @property 

66 def batch_type(self): 

67 """ 

68 Convenience property to return the :term:`batch type` which 

69 the current handler is meant to process. 

70 

71 This is effectively an alias to 

72 :attr:`~wuttjamaican.db.model.batch.BatchMixin.batch_type`. 

73 """ 

74 return self.model_class.batch_type 

75 

76 def make_batch(self, session, progress=None, **kwargs): 

77 """ 

78 Make and return a new batch (:attr:`model_class`) instance. 

79 

80 This will create the new batch, and auto-assign its 

81 :attr:`~wuttjamaican.db.model.batch.BatchMixin.id` value 

82 (unless caller specifies it) by calling 

83 :meth:`consume_batch_id()`. 

84 

85 It then will call :meth:`init_batch()` to perform any custom 

86 initialization needed. 

87 

88 Therefore callers should use this ``make_batch()`` method, but 

89 subclass should override :meth:`init_batch()` instead (if 

90 needed). 

91 

92 :param session: Current :term:`db session`. 

93 

94 :param progress: Optional progress indicator factory. 

95 

96 :param \**kwargs: Additional kwargs to pass to the batch 

97 constructor. 

98 

99 :returns: New batch; instance of :attr:`model_class`. 

100 """ 

101 # generate new ID unless caller specifies 

102 if 'id' not in kwargs: 

103 kwargs['id'] = self.consume_batch_id(session) 

104 

105 # make batch 

106 batch = self.model_class(**kwargs) 

107 self.init_batch(batch, session=session, progress=progress, **kwargs) 

108 return batch 

109 

110 def consume_batch_id(self, session, as_str=False): 

111 """ 

112 Fetch a new batch ID from the counter, and return it. 

113 

114 This may be called automatically from :meth:`make_batch()`. 

115 

116 :param session: Current :term:`db session`. 

117 

118 :param as_str: Indicates the return value should be a string 

119 instead of integer. 

120 

121 :returns: Batch ID as integer, or zero-padded 8-char string. 

122 """ 

123 db = self.app.get_db_handler() 

124 batch_id = db.next_counter_value(session, 'batch_id') 

125 if as_str: 

126 return f'{batch_id:08d}' 

127 return batch_id 

128 

129 def init_batch(self, batch, session=None, progress=None, **kwargs): 

130 """ 

131 Initialize a new batch. 

132 

133 This is called automatically from :meth:`make_batch()`. 

134 

135 Default logic does nothing; subclass should override if needed. 

136 

137 .. note:: 

138 *Population* of the new batch should **not** happen here; 

139 see instead :meth:`populate()`. 

140 """ 

141 

142 def get_data_path(self, batch=None, filename=None, makedirs=False): 

143 """ 

144 Returns a path to batch data file(s). 

145 

146 This can be used to return any of the following, depending on 

147 how it's called: 

148 

149 * path to root data dir for handler's :attr:`batch_type` 

150 * path to data dir for specific batch 

151 * path to specific filename, for specific batch 

152 

153 For instance:: 

154 

155 # nb. assuming batch_type = 'inventory' 

156 batch = handler.make_batch(session, created_by=user) 

157 

158 handler.get_data_path() 

159 # => env/app/data/batch/inventory 

160 

161 handler.get_data_path(batch) 

162 # => env/app/data/batch/inventory/03/7721fe56c811ef9223743af49773a4 

163 

164 handler.get_data_path(batch, 'counts.csv') 

165 # => env/app/data/batch/inventory/03/7721fe56c811ef9223743af49773a4/counts.csv 

166 

167 :param batch: Optional batch instance. If specified, will 

168 return path for this batch in particular. Otherwise will 

169 return the "generic" path for handler's batch type. 

170 

171 :param filename: Optional filename, in context of the batch. 

172 If set, the returned path will include this filename. Only 

173 relevant if ``batch`` is also specified. 

174 

175 :param makedirs: Whether the folder(s) should be created, if 

176 not already present. 

177 

178 :returns: Path to root data dir for handler's batch type. 

179 """ 

180 # get root storage path 

181 rootdir = self.config.get(f'{self.config.appname}.batch.storage_path') 

182 if not rootdir: 

183 appdir = self.app.get_appdir() 

184 rootdir = os.path.join(appdir, 'data', 'batch') 

185 

186 # get path for this batch type 

187 path = os.path.join(rootdir, self.batch_type) 

188 

189 # give more precise path, if batch was specified 

190 if batch: 

191 uuid = batch.uuid.hex 

192 # nb. we use *last 2 chars* for first part of batch uuid 

193 # path. this is because uuid7 is mostly sequential, so 

194 # first 2 chars do not vary enough. 

195 path = os.path.join(path, uuid[-2:], uuid[:-2]) 

196 

197 # maybe create data dir 

198 if makedirs and not os.path.exists(path): 

199 os.makedirs(path) 

200 

201 # append filename if applicable 

202 if batch and filename: 

203 path = os.path.join(path, filename) 

204 

205 return path 

206 

207 def should_populate(self, batch): 

208 """ 

209 Must return true or false, indicating whether the given batch 

210 should be populated from initial data source(s). 

211 

212 So, true means fill the batch with data up front - by calling 

213 :meth:`do_populate()` - and false means the batch will start 

214 empty. 

215 

216 Default logic here always return false; subclass should 

217 override if needed. 

218 """ 

219 return False 

220 

221 def do_populate(self, batch, progress=None): 

222 """ 

223 Populate the batch from initial data source(s). 

224 

225 This method is a convenience wrapper, which ultimately will 

226 call :meth:`populate()` for the implementation logic. 

227 

228 Therefore callers should use this ``do_populate()`` method, 

229 but subclass should override :meth:`populate()` instead (if 

230 needed). 

231 

232 See also :meth:`should_populate()` - you should check that 

233 before calling ``do_populate()``. 

234 """ 

235 self.populate(batch, progress=progress) 

236 

237 def populate(self, batch, progress=None): 

238 """ 

239 Populate the batch from initial data source(s). 

240 

241 It is assumed that the data source(s) to be used will be known 

242 by inspecting various properties of the batch itself. 

243 

244 Subclass should override this method to provide the 

245 implementation logic. It may populate some batches 

246 differently based on the batch attributes, or it may populate 

247 them all the same. Whatever is needed. 

248 

249 Callers should always use :meth:`do_populate()` instead of 

250 calling ``populate()`` directly. 

251 """ 

252 

253 def make_row(self, **kwargs): 

254 """ 

255 Make a new row for the batch. This will be an instance of 

256 :attr:`~wuttjamaican.db.model.batch.BatchMixin.__row_class__`. 

257 

258 Note that the row will **not** be added to the batch; that 

259 should be done with :meth:`add_row()`. 

260 

261 :returns: A new row object, which does *not* yet belong to any batch. 

262 """ 

263 return self.model_class.__row_class__(**kwargs) 

264 

265 def add_row(self, batch, row): 

266 """ 

267 Add the given row to the given batch. 

268 

269 This assumes a *new* row which does not yet belong to a batch, 

270 as returned by :meth:`make_row()`. 

271 

272 It will add it to batch 

273 :attr:`~wuttjamaican.db.model.batch.BatchMixin.rows`, call 

274 :meth:`refresh_row()` for it, and update the 

275 :attr:`~wuttjamaican.db.model.batch.BatchMixin.row_count`. 

276 """ 

277 session = self.app.get_session(batch) 

278 with session.no_autoflush: 

279 batch.rows.append(row) 

280 self.refresh_row(row) 

281 batch.row_count = (batch.row_count or 0) + 1 

282 

283 def refresh_row(self, row): 

284 """ 

285 Update the given batch row as needed, to reflect latest data. 

286 

287 This method is a bit of a catch-all in that it could be used 

288 to do any of the following (etc.): 

289 

290 * fetch latest "live" data for comparison with batch input data 

291 * (re-)calculate row values based on latest data 

292 * set row status based on other row attributes 

293 

294 This method is called when the row is first added to the batch 

295 via :meth:`add_row()` - but may be called multiple times after 

296 that depending on the workflow. 

297 """ 

298 

299 def do_remove_row(self, row): 

300 """ 

301 Remove a row from its batch. This will: 

302 

303 * call :meth:`remove_row()` 

304 * decrement the batch 

305 :attr:`~wuttjamaican.db.model.batch.BatchMixin.row_count` 

306 * call :meth:`refresh_batch_status()` 

307 

308 So, callers should use ``do_remove_row()``, but subclass 

309 should (usually) override :meth:`remove_row()` etc. 

310 """ 

311 batch = row.batch 

312 session = self.app.get_session(batch) 

313 

314 self.remove_row(row) 

315 

316 if batch.row_count is not None: 

317 batch.row_count -= 1 

318 

319 self.refresh_batch_status(batch) 

320 session.flush() 

321 

322 def remove_row(self, row): 

323 """ 

324 Remove a row from its batch. 

325 

326 Callers should use :meth:`do_remove_row()` instead, which 

327 calls this method automatically. 

328 

329 Subclass can override this method; the default logic just 

330 deletes the row. 

331 """ 

332 session = self.app.get_session(row) 

333 batch = row.batch 

334 batch.rows.remove(row) 

335 session.delete(row) 

336 

337 def refresh_batch_status(self, batch): 

338 """ 

339 Update the batch status as needed. 

340 

341 This method is called when some row data has changed for the 

342 batch, e.g. from :meth:`do_remove_row()`. 

343 

344 It does nothing by default; subclass may override to set these 

345 attributes on the batch: 

346 

347 * :attr:`~wuttjamaican.db.model.batch.BatchMixin.status_code` 

348 * :attr:`~wuttjamaican.db.model.batch.BatchMixin.status_text` 

349 """ 

350 

351 def why_not_execute(self, batch, user=None, **kwargs): 

352 """ 

353 Returns text indicating the reason (if any) that a given batch 

354 should *not* be executed. 

355 

356 By default the only reason a batch cannot be executed, is if 

357 it has already been executed. But in some cases it should be 

358 more restrictive; hence this method. 

359 

360 A "brief but descriptive" message should be returned, which 

361 may be displayed to the user e.g. so they understand why the 

362 execute feature is not allowed for the batch. (There is no 

363 need to check if batch is already executed since other logic 

364 handles that.) 

365 

366 If no text is returned, the assumption will be made that this 

367 batch is safe to execute. 

368 

369 :param batch: The batch in question; potentially eligible for 

370 execution. 

371 

372 :param user: :class:`~wuttjamaican.db.model.auth.User` who 

373 might choose to execute the batch. 

374 

375 :param \**kwargs: Execution kwargs for the batch, if known. 

376 Should be similar to those for :meth:`execute()`. 

377 

378 :returns: Text reason to prevent execution, or ``None``. 

379 

380 The user interface should normally check this and if it 

381 returns anything, that should be shown and the user should be 

382 prevented from executing the batch. 

383 

384 However :meth:`do_execute()` will also call this method, and 

385 raise a ``RuntimeError`` if text was returned. This is done 

386 out of safety, to avoid relying on the user interface. 

387 """ 

388 

389 def describe_execution(self, batch, user=None, **kwargs): 

390 """ 

391 This should return some text which briefly describes what will 

392 happen when the given batch is executed. 

393 

394 Note that Markdown is supported here, e.g.:: 

395 

396 def describe_execution(self, batch, **kwargs): 

397 return \""" 

398 

399 This batch does some crazy things! 

400 

401 **you cannot possibly fathom it** 

402 

403 here are a few of them: 

404 

405 - first 

406 - second 

407 - third 

408 \""" 

409 

410 Nothing is returned by default; subclass should define. 

411 

412 :param batch: The batch in question; eligible for execution. 

413 

414 :param user: Reference to current user who might choose to 

415 execute the batch. 

416 

417 :param \**kwargs: Execution kwargs for the batch; should be 

418 similar to those for :meth:`execute()`. 

419 

420 :returns: Markdown text describing batch execution. 

421 """ 

422 

423 def get_effective_rows(self, batch): 

424 """ 

425 This should return a list of "effective" rows for the batch. 

426 

427 In other words, which rows should be "acted upon" when the 

428 batch is executed. 

429 

430 The default logic returns the full list of batch 

431 :attr:`~wuttjamaican.db.model.batch.BatchMixin.rows`, but 

432 subclass may need to filter by status code etc. 

433 """ 

434 return batch.rows 

435 

436 def do_execute(self, batch, user, progress=None, **kwargs): 

437 """ 

438 Perform the execution steps for a batch. 

439 

440 This first calls :meth:`why_not_execute()` to make sure this 

441 is even allowed. 

442 

443 If so, it calls :meth:`execute()` and then updates 

444 :attr:`~wuttjamaican.db.model.batch.BatchMixin.executed` and 

445 :attr:`~wuttjamaican.db.model.batch.BatchMixin.executed_by` on 

446 the batch, to reflect current time+user. 

447 

448 So, callers should use ``do_execute()``, and subclass should 

449 override :meth:`execute()`. 

450 

451 :param batch: The :term:`batch` to execute; instance of 

452 :class:`~wuttjamaican.db.model.batch.BatchMixin` (among 

453 other classes). 

454 

455 :param user: :class:`~wuttjamaican.db.model.auth.User` who is 

456 executing the batch. 

457 

458 :param progress: Optional progress indicator factory. 

459 

460 :param \**kwargs: Additional kwargs as needed. These are 

461 passed as-is to :meth:`why_not_execute()` and 

462 :meth:`execute()`. 

463 

464 :returns: Whatever was returned from :meth:`execute()` - often 

465 ``None``. 

466 """ 

467 if batch.executed: 

468 raise ValueError(f"batch has already been executed: {batch}") 

469 

470 reason = self.why_not_execute(batch, user=user, **kwargs) 

471 if reason: 

472 raise RuntimeError(f"batch execution not allowed: {reason}") 

473 

474 result = self.execute(batch, user=user, progress=progress, **kwargs) 

475 batch.executed = datetime.datetime.now() 

476 batch.executed_by = user 

477 return result 

478 

479 def execute(self, batch, user=None, progress=None, **kwargs): 

480 """ 

481 Execute the given batch. 

482 

483 Callers should use :meth:`do_execute()` instead, which calls 

484 this method automatically. 

485 

486 This does nothing by default; subclass must define logic. 

487 

488 :param batch: A :term:`batch`; instance of 

489 :class:`~wuttjamaican.db.model.batch.BatchMixin` (among 

490 other classes). 

491 

492 :param user: :class:`~wuttjamaican.db.model.auth.User` who is 

493 executing the batch. 

494 

495 :param progress: Optional progress indicator factory. 

496 

497 :param \**kwargs: Additional kwargs which may affect the batch 

498 execution behavior. There are none by default, but some 

499 handlers may declare/use them. 

500 

501 :returns: ``None`` by default, but subclass can return 

502 whatever it likes, in which case that will be also returned 

503 to the caller from :meth:`do_execute()`. 

504 """ 

505 

506 def do_delete(self, batch, user, dry_run=False, progress=None, **kwargs): 

507 """ 

508 Delete the given batch entirely. 

509 

510 This will delete the batch proper, all data rows, and any 

511 files which may be associated with it. 

512 """ 

513 session = self.app.get_session(batch) 

514 

515 # remove data files 

516 path = self.get_data_path(batch) 

517 if os.path.exists(path) and not dry_run: 

518 shutil.rmtree(path) 

519 

520 # remove batch proper 

521 session.delete(batch)