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

88 statements  

« prev     ^ index     » next       coverage.py v7.11.0, created at 2025-12-15 16:29 -0600

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

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

3# 

4# WuttJamaican -- Base package for Wutta Framework 

5# Copyright © 2023-2025 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 os 

28import shutil 

29 

30from wuttjamaican.app import GenericHandler 

31 

32 

33class BatchHandler(GenericHandler): # pylint: disable=too-many-public-methods 

34 """ 

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

36 handlers <batch handler>`. 

37 

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

39 anything. Subclass must implement logic for various things as 

40 needed, e.g.: 

41 

42 * :attr:`model_class` 

43 * :meth:`init_batch()` 

44 * :meth:`should_populate()` 

45 * :meth:`populate()` 

46 * :meth:`refresh_row()` 

47 """ 

48 

49 @property 

50 def model_class(self): 

51 """ 

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

53 batch handler is meant to work with. 

54 

55 This is expected to be a subclass of 

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

57 classes). 

58 

59 Subclass must define this; default is not implemented. 

60 """ 

61 raise NotImplementedError( 

62 "You must set the 'model_class' attribute " 

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

64 ) 

65 

66 @property 

67 def batch_type(self): 

68 """ 

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

70 the current handler is meant to process. 

71 

72 This is effectively an alias to 

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

74 """ 

75 return self.model_class.batch_type 

76 

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

78 """ 

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

80 

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

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

83 (unless caller specifies it) by calling 

84 :meth:`consume_batch_id()`. 

85 

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

87 initialization needed. 

88 

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

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

91 needed). 

92 

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

94 

95 :param progress: Optional progress indicator factory. 

96 

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

98 constructor. 

99 

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

101 """ 

102 # generate new ID unless caller specifies 

103 if "id" not in kwargs: 

104 kwargs["id"] = self.consume_batch_id(session) 

105 

106 # make batch 

107 batch = self.model_class(**kwargs) 

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

109 return batch 

110 

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

112 """ 

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

114 

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

116 

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

118 

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

120 instead of integer. 

121 

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

123 """ 

124 db = self.app.get_db_handler() 

125 batch_id = db.next_counter_value(session, "batch_id") 

126 if as_str: 

127 return f"{batch_id:08d}" 

128 return batch_id 

129 

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

131 """ 

132 Initialize a new batch. 

133 

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

135 

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

137 

138 .. note:: 

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

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

141 """ 

142 

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

144 """ 

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

146 

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

148 how it's called: 

149 

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

151 * path to data dir for specific batch 

152 * path to specific filename, for specific batch 

153 

154 For instance:: 

155 

156 # nb. assuming batch_type = 'inventory' 

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

158 

159 handler.get_data_path() 

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

161 

162 handler.get_data_path(batch) 

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

164 

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

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

167 

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

169 return path for this batch in particular. Otherwise will 

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

171 

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

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

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

175 

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

177 not already present. 

178 

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

180 """ 

181 # get root storage path 

182 rootdir = self.config.get(f"{self.config.appname}.batch.storage_path") 

183 if not rootdir: 

184 appdir = self.app.get_appdir() 

185 rootdir = os.path.join(appdir, "data", "batch") 

186 

187 # get path for this batch type 

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

189 

190 # give more precise path, if batch was specified 

191 if batch: 

192 uuid = batch.uuid.hex 

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

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

195 # first 2 chars do not vary enough. 

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

197 

198 # maybe create data dir 

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

200 os.makedirs(path) 

201 

202 # append filename if applicable 

203 if batch and filename: 

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

205 

206 return path 

207 

208 def should_populate(self, batch): # pylint: disable=unused-argument 

209 """ 

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

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

212 

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

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

215 empty. 

216 

217 Default logic here always return false; subclass should 

218 override if needed. 

219 """ 

220 return False 

221 

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

223 """ 

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

225 

226 This method is a convenience wrapper, which ultimately will 

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

228 

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

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

231 needed). 

232 

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

234 before calling ``do_populate()``. 

235 """ 

236 self.populate(batch, progress=progress) 

237 

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

239 """ 

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

241 

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

243 by inspecting various properties of the batch itself. 

244 

245 Subclass should override this method to provide the 

246 implementation logic. It may populate some batches 

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

248 them all the same. Whatever is needed. 

249 

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

251 calling ``populate()`` directly. 

252 """ 

253 

254 def make_row(self, **kwargs): 

255 """ 

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

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

258 

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

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

261 

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

263 """ 

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

265 

266 def add_row(self, batch, row): 

267 """ 

268 Add the given row to the given batch. 

269 

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

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

272 

273 It will add it to batch 

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

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

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

277 """ 

278 session = self.app.get_session(batch) 

279 with session.no_autoflush: 

280 batch.rows.append(row) 

281 self.refresh_row(row) 

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

283 

284 def refresh_row(self, row): 

285 """ 

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

287 

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

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

290 

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

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

293 * set row status based on other row attributes 

294 

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

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

297 that depending on the workflow. 

298 """ 

299 

300 def do_remove_row(self, row): 

301 """ 

302 Remove a row from its batch. This will: 

303 

304 * call :meth:`remove_row()` 

305 * decrement the batch 

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

307 * call :meth:`refresh_batch_status()` 

308 

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

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

311 """ 

312 batch = row.batch 

313 session = self.app.get_session(batch) 

314 

315 self.remove_row(row) 

316 

317 if batch.row_count is not None: 

318 batch.row_count -= 1 

319 

320 self.refresh_batch_status(batch) 

321 session.flush() 

322 

323 def remove_row(self, row): 

324 """ 

325 Remove a row from its batch. 

326 

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

328 calls this method automatically. 

329 

330 Subclass can override this method; the default logic just 

331 deletes the row. 

332 """ 

333 session = self.app.get_session(row) 

334 batch = row.batch 

335 batch.rows.remove(row) 

336 session.delete(row) 

337 

338 def refresh_batch_status(self, batch): 

339 """ 

340 Update the batch status as needed. 

341 

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

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

344 

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

346 attributes on the batch: 

347 

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

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

350 """ 

351 

352 def why_not_execute( 

353 self, batch, user=None, **kwargs 

354 ): # pylint: disable=unused-argument 

355 """ 

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

357 should *not* be executed. 

358 

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

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

361 more restrictive; hence this method. 

362 

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

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

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

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

367 handles that.) 

368 

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

370 batch is safe to execute. 

371 

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

373 execution. 

374 

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

376 might choose to execute the batch. 

377 

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

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

380 

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

382 

383 The user interface should normally check this and if it 

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

385 prevented from executing the batch. 

386 

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

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

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

390 """ 

391 return None 

392 

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

394 """ 

395 This should return some text which briefly describes what will 

396 happen when the given batch is executed. 

397 

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

399 

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

401 return \""" 

402 

403 This batch does some crazy things! 

404 

405 **you cannot possibly fathom it** 

406 

407 here are a few of them: 

408 

409 - first 

410 - second 

411 - third 

412 \""" 

413 

414 Nothing is returned by default; subclass should define. 

415 

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

417 

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

419 execute the batch. 

420 

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

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

423 

424 :returns: Markdown text describing batch execution. 

425 """ 

426 

427 def get_effective_rows(self, batch): 

428 """ 

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

430 

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

432 batch is executed. 

433 

434 The default logic returns the full list of batch 

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

436 subclass may need to filter by status code etc. 

437 """ 

438 return batch.rows 

439 

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

441 """ 

442 Perform the execution steps for a batch. 

443 

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

445 is even allowed. 

446 

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

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

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

450 the batch, to reflect current time+user. 

451 

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

453 override :meth:`execute()`. 

454 

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

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

457 other classes). 

458 

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

460 executing the batch. 

461 

462 :param progress: Optional progress indicator factory. 

463 

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

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

466 :meth:`execute()`. 

467 

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

469 ``None``. 

470 """ 

471 if batch.executed: 

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

473 

474 reason = self.why_not_execute( # pylint: disable=assignment-from-none 

475 batch, user=user, **kwargs 

476 ) 

477 if reason: 

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

479 

480 result = self.execute( # pylint: disable=assignment-from-none 

481 batch, user=user, progress=progress, **kwargs 

482 ) 

483 batch.executed = self.app.make_utc() 

484 batch.executed_by = user 

485 return result 

486 

487 def execute( 

488 self, batch, user=None, progress=None, **kwargs 

489 ): # pylint: disable=unused-argument 

490 """ 

491 Execute the given batch. 

492 

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

494 this method automatically. 

495 

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

497 

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

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

500 other classes). 

501 

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

503 executing the batch. 

504 

505 :param progress: Optional progress indicator factory. 

506 

507 :param \\**kwargs: Additional kwargs which may affect the 

508 batch execution behavior. There are none by default, but 

509 some handlers may declare/use them. 

510 

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

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

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

514 """ 

515 return None 

516 

517 def do_delete( 

518 self, batch, user, dry_run=False, progress=None, **kwargs 

519 ): # pylint: disable=unused-argument 

520 """ 

521 Delete the given batch entirely. 

522 

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

524 files which may be associated with it. 

525 """ 

526 session = self.app.get_session(batch) 

527 

528 # remove data files 

529 path = self.get_data_path(batch) 

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

531 shutil.rmtree(path) 

532 

533 # remove batch proper 

534 session.delete(batch)