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
« 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"""
27import os
28import shutil
30from wuttjamaican.app import GenericHandler
33class BatchHandler(GenericHandler): # pylint: disable=too-many-public-methods
34 """
35 Base class and *partial* default implementation for :term:`batch
36 handlers <batch handler>`.
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.:
42 * :attr:`model_class`
43 * :meth:`init_batch()`
44 * :meth:`should_populate()`
45 * :meth:`populate()`
46 * :meth:`refresh_row()`
47 """
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.
55 This is expected to be a subclass of
56 :class:`~wuttjamaican.db.model.batch.BatchMixin` (among other
57 classes).
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 )
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.
72 This is effectively an alias to
73 :attr:`~wuttjamaican.db.model.batch.BatchMixin.batch_type`.
74 """
75 return self.model_class.batch_type
77 def make_batch(self, session, progress=None, **kwargs):
78 """
79 Make and return a new batch (:attr:`model_class`) instance.
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()`.
86 It then will call :meth:`init_batch()` to perform any custom
87 initialization needed.
89 Therefore callers should use this ``make_batch()`` method, but
90 subclass should override :meth:`init_batch()` instead (if
91 needed).
93 :param session: Current :term:`db session`.
95 :param progress: Optional progress indicator factory.
97 :param \\**kwargs: Additional kwargs to pass to the batch
98 constructor.
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)
106 # make batch
107 batch = self.model_class(**kwargs)
108 self.init_batch(batch, session=session, progress=progress, **kwargs)
109 return batch
111 def consume_batch_id(self, session, as_str=False):
112 """
113 Fetch a new batch ID from the counter, and return it.
115 This may be called automatically from :meth:`make_batch()`.
117 :param session: Current :term:`db session`.
119 :param as_str: Indicates the return value should be a string
120 instead of integer.
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
130 def init_batch(self, batch, session=None, progress=None, **kwargs):
131 """
132 Initialize a new batch.
134 This is called automatically from :meth:`make_batch()`.
136 Default logic does nothing; subclass should override if needed.
138 .. note::
139 *Population* of the new batch should **not** happen here;
140 see instead :meth:`populate()`.
141 """
143 def get_data_path(self, batch=None, filename=None, makedirs=False):
144 """
145 Returns a path to batch data file(s).
147 This can be used to return any of the following, depending on
148 how it's called:
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
154 For instance::
156 # nb. assuming batch_type = 'inventory'
157 batch = handler.make_batch(session, created_by=user)
159 handler.get_data_path()
160 # => env/app/data/batch/inventory
162 handler.get_data_path(batch)
163 # => env/app/data/batch/inventory/03/7721fe56c811ef9223743af49773a4
165 handler.get_data_path(batch, 'counts.csv')
166 # => env/app/data/batch/inventory/03/7721fe56c811ef9223743af49773a4/counts.csv
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.
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.
176 :param makedirs: Whether the folder(s) should be created, if
177 not already present.
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")
187 # get path for this batch type
188 path = os.path.join(rootdir, self.batch_type)
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])
198 # maybe create data dir
199 if makedirs and not os.path.exists(path):
200 os.makedirs(path)
202 # append filename if applicable
203 if batch and filename:
204 path = os.path.join(path, filename)
206 return path
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).
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.
217 Default logic here always return false; subclass should
218 override if needed.
219 """
220 return False
222 def do_populate(self, batch, progress=None):
223 """
224 Populate the batch from initial data source(s).
226 This method is a convenience wrapper, which ultimately will
227 call :meth:`populate()` for the implementation logic.
229 Therefore callers should use this ``do_populate()`` method,
230 but subclass should override :meth:`populate()` instead (if
231 needed).
233 See also :meth:`should_populate()` - you should check that
234 before calling ``do_populate()``.
235 """
236 self.populate(batch, progress=progress)
238 def populate(self, batch, progress=None):
239 """
240 Populate the batch from initial data source(s).
242 It is assumed that the data source(s) to be used will be known
243 by inspecting various properties of the batch itself.
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.
250 Callers should always use :meth:`do_populate()` instead of
251 calling ``populate()`` directly.
252 """
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__`.
259 Note that the row will **not** be added to the batch; that
260 should be done with :meth:`add_row()`.
262 :returns: A new row object, which does *not* yet belong to any batch.
263 """
264 return self.model_class.__row_class__(**kwargs)
266 def add_row(self, batch, row):
267 """
268 Add the given row to the given batch.
270 This assumes a *new* row which does not yet belong to a batch,
271 as returned by :meth:`make_row()`.
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
284 def refresh_row(self, row):
285 """
286 Update the given batch row as needed, to reflect latest data.
288 This method is a bit of a catch-all in that it could be used
289 to do any of the following (etc.):
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
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 """
300 def do_remove_row(self, row):
301 """
302 Remove a row from its batch. This will:
304 * call :meth:`remove_row()`
305 * decrement the batch
306 :attr:`~wuttjamaican.db.model.batch.BatchMixin.row_count`
307 * call :meth:`refresh_batch_status()`
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)
315 self.remove_row(row)
317 if batch.row_count is not None:
318 batch.row_count -= 1
320 self.refresh_batch_status(batch)
321 session.flush()
323 def remove_row(self, row):
324 """
325 Remove a row from its batch.
327 Callers should use :meth:`do_remove_row()` instead, which
328 calls this method automatically.
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)
338 def refresh_batch_status(self, batch):
339 """
340 Update the batch status as needed.
342 This method is called when some row data has changed for the
343 batch, e.g. from :meth:`do_remove_row()`.
345 It does nothing by default; subclass may override to set these
346 attributes on the batch:
348 * :attr:`~wuttjamaican.db.model.batch.BatchMixin.status_code`
349 * :attr:`~wuttjamaican.db.model.batch.BatchMixin.status_text`
350 """
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.
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.
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.)
369 If no text is returned, the assumption will be made that this
370 batch is safe to execute.
372 :param batch: The batch in question; potentially eligible for
373 execution.
375 :param user: :class:`~wuttjamaican.db.model.auth.User` who
376 might choose to execute the batch.
378 :param \\**kwargs: Execution kwargs for the batch, if known.
379 Should be similar to those for :meth:`execute()`.
381 :returns: Text reason to prevent execution, or ``None``.
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.
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
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.
398 Note that Markdown is supported here, e.g.::
400 def describe_execution(self, batch, **kwargs):
401 return \"""
403 This batch does some crazy things!
405 **you cannot possibly fathom it**
407 here are a few of them:
409 - first
410 - second
411 - third
412 \"""
414 Nothing is returned by default; subclass should define.
416 :param batch: The batch in question; eligible for execution.
418 :param user: Reference to current user who might choose to
419 execute the batch.
421 :param \\**kwargs: Execution kwargs for the batch; should be
422 similar to those for :meth:`execute()`.
424 :returns: Markdown text describing batch execution.
425 """
427 def get_effective_rows(self, batch):
428 """
429 This should return a list of "effective" rows for the batch.
431 In other words, which rows should be "acted upon" when the
432 batch is executed.
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
440 def do_execute(self, batch, user, progress=None, **kwargs):
441 """
442 Perform the execution steps for a batch.
444 This first calls :meth:`why_not_execute()` to make sure this
445 is even allowed.
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.
452 So, callers should use ``do_execute()``, and subclass should
453 override :meth:`execute()`.
455 :param batch: The :term:`batch` to execute; instance of
456 :class:`~wuttjamaican.db.model.batch.BatchMixin` (among
457 other classes).
459 :param user: :class:`~wuttjamaican.db.model.auth.User` who is
460 executing the batch.
462 :param progress: Optional progress indicator factory.
464 :param \\**kwargs: Additional kwargs as needed. These are
465 passed as-is to :meth:`why_not_execute()` and
466 :meth:`execute()`.
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}")
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}")
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
487 def execute(
488 self, batch, user=None, progress=None, **kwargs
489 ): # pylint: disable=unused-argument
490 """
491 Execute the given batch.
493 Callers should use :meth:`do_execute()` instead, which calls
494 this method automatically.
496 This does nothing by default; subclass must define logic.
498 :param batch: A :term:`batch`; instance of
499 :class:`~wuttjamaican.db.model.batch.BatchMixin` (among
500 other classes).
502 :param user: :class:`~wuttjamaican.db.model.auth.User` who is
503 executing the batch.
505 :param progress: Optional progress indicator factory.
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.
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
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.
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)
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)
533 # remove batch proper
534 session.delete(batch)