source: main/waeup.sirp/trunk/src/waeup/sirp/utils/batching.txt @ 6265

Last change on this file since 6265 was 6244, checked in by Henrik Bettermann, 14 years ago

Fix tests.

File size: 17.0 KB
Line 
1:mod:`waeup.sirp.utils.batching` -- Batch processing
2****************************************************
3
4Batch processing is much more than pure data import.
5
6:test-layer: functional
7
8Overview
9========
10
11Basically, it means processing CSV files in order to mass-create,
12mass-remove, or mass-update data.
13
14So you can feed CSV files to importers or processors, that are part of
15the batch-processing mechanism.
16
17Importers/Processors
18--------------------
19
20Each CSV file processor
21
22* accepts a single data type identified by an interface.
23
24* knows about the places inside a site (University) where to store,
25  remove or update the data.
26
27* can check headers before processing data.
28
29* supports the mode 'create', 'update', 'remove'.
30
31* creates log entries (optional)
32
33* creates csv files containing successful and not-successful processed
34  data respectively.
35
36Output
37------
38
39The results of processing are written to loggers, if a logger was
40given. Beside this new CSV files are created during processing:
41
42* a pending CSV file, containing datasets that could not be processed
43
44* a finished CSV file, containing datasets successfully processed.
45
46The pending file is not created if everything works fine. The
47respective path returned in that case is ``None``.
48
49The pending file (if created) is a CSV file that contains the failed
50rows appended by a column ``--ERRROR--`` in which the reasons for
51processing failures are listed.
52
53The complete paths of these files are returned. They will be in a
54temporary directory created only for this purpose. It is the caller's
55responsibility to remove the temporay directories afterwards (the
56datacenters distProcessedFiles() method takes care for that).
57
58It looks like this::
59 
60     -----+      +---------+
61    /     |      |         |              +------+
62   | .csv +----->|Batch-   |              |      |
63   |      |      |processor+----changes-->| ZODB |
64   |  +------+   |         |              |      |
65   +--|      |   |         +              +------+
66      | Mode +-->|         |                 -------+
67      |      |   |         +----outputs-+-> /       |
68      |  +----+->+---------+            |  |.pending|
69      +--|Log |  ^                      |  |        |
70         +----+  |                      |  +--------+
71           +-----++                     v
72           |Inter-|                  ----------+
73           |face  |                 /          |
74           +------+                | .finished |
75                                   |           |
76                                   +-----------+
77
78
79Creating a batch processor
80==========================
81
82We create an own batch processor for an own datatype. This datatype
83must be based on an interface that the batcher can use for converting
84data.
85
86Founding Stoneville
87-------------------
88
89We start with the interface:
90
91    >>> from zope.interface import Interface
92    >>> from zope import schema
93    >>> class ICave(Interface):
94    ...   """A cave."""
95    ...   name = schema.TextLine(
96    ...     title = u'Cave name',
97    ...     default = u'Unnamed',
98    ...     required = True)
99    ...   dinoports = schema.Int(
100    ...     title = u'Number of DinoPorts (tm)',
101    ...     required = False,
102    ...     default = 1)
103    ...   owner = schema.TextLine(
104    ...     title = u'Owner name',
105    ...     required = True,
106    ...     missing_value = 'Fred Estates Inc.')
107    ...   taxpayer = schema.Bool(
108    ...     title = u'Payes taxes',
109    ...     required = True,
110    ...     default = False)
111
112Now a class that implements this interface:
113
114    >>> import grok
115    >>> class Cave(object):
116    ...   grok.implements(ICave)
117    ...   def __init__(self, name=u'Unnamed', dinoports=2,
118    ...                owner='Fred Estates Inc.', taxpayer=False):
119    ...     self.name = name
120    ...     self.dinoports = 2
121    ...     self.owner = owner
122    ...     self.taxpayer = taxpayer
123
124We also provide a factory for caves. Strictly speaking, this not
125necessary but makes the batch processor we create afterwards, better
126understandable.
127
128    >>> from zope.component import getGlobalSiteManager
129    >>> from zope.component.factory import Factory
130    >>> from zope.component.interfaces import IFactory
131    >>> gsm = getGlobalSiteManager()
132    >>> cave_maker = Factory(Cave, 'A cave', 'Buy caves here!')
133    >>> gsm.registerUtility(cave_maker, IFactory, 'Lovely Cave')
134
135Now we can create caves using a factory:
136
137    >>> from zope.component import createObject
138    >>> createObject('Lovely Cave')
139    <Cave object at 0x...>
140
141This is nice, but we still lack a place, where we can place all the
142lovely caves we want to sell.
143
144Furthermore, as a replacement for a real site, we define a place where
145all caves can be stored: Stoneville! This is a lovely place for
146upperclass cavemen (which are the only ones that can afford more than
147one dinoport).
148
149We found Stoneville:
150
151    >>> stoneville = dict()
152
153Everything in place.
154
155Now, to improve local health conditions, imagine we want to populate
156Stoneville with lots of new happy dino-hunting natives that slept on
157the bare ground in former times and had no idea of
158bathrooms. Disgusting, isn't it?
159
160Lots of cavemen need lots of caves.
161
162Of course we can do something like:
163
164    >>> cave1 = createObject('Lovely Cave')
165    >>> cave1.name = "Fred's home"
166    >>> cave1.owner = "Fred"
167    >>> stoneville[cave1.name] = cave1
168
169and Stoneville has exactly
170
171    >>> len(stoneville)
172    1
173
174inhabitant. But we don't want to do this for hundreds or thousands of
175citizens-to-be, do we?
176
177It is much easier to create a simple CSV list, where we put in all the
178data and let a batch processor do the job.
179
180The list is already here:
181
182    >>> open('newcomers.csv', 'wb').write(
183    ... """name,dinoports,owner,taxpayer
184    ... Barneys Home,2,Barney,1
185    ... Wilmas Asylum,1,Wilma,1
186    ... Freds Dinoburgers,10,Fred,0
187    ... Joeys Drive-in,110,Joey,0
188    ... """)
189
190All we need, is a batch processor now.
191
192    >>> from waeup.sirp.utils.batching import BatchProcessor
193    >>> class CaveProcessor(BatchProcessor):
194    ...   util_name = 'caveprocessor'
195    ...   grok.name(util_name)
196    ...   name = 'Cave Processor'
197    ...   iface = ICave
198    ...   location_fields = ['name']
199    ...   factory_name = 'Lovely Cave'
200    ...
201    ...   def parentsExist(self, row, site):
202    ...     return True
203    ...
204    ...   def getParent(self, row, site):
205    ...     return stoneville
206    ...
207    ...   def entryExists(self, row, site):
208    ...     return row['name'] in stoneville.keys()
209    ...
210    ...   def getEntry(self, row, site):
211    ...     if not self.entryExists(row, site):
212    ...       return None
213    ...     return stoneville[row['name']]
214    ...
215    ...   def delEntry(self, row, site):
216    ...     del stoneville[row['name']]
217    ...
218    ...   def addEntry(self, obj, row, site):
219    ...     stoneville[row['name']] = obj
220    ...
221    ...   def updateEntry(self, obj, row, site):
222    ...     # This is not strictly necessary, as the default
223    ...     # updateEntry method does exactly the same
224    ...     for key, value in row.items():
225    ...       setattr(obj, key, value)
226
227If we also want the results being logged, we must provide a logger
228(this is optional):
229
230    >>> import logging
231    >>> logger = logging.getLogger('stoneville')
232    >>> logger.setLevel(logging.DEBUG)
233    >>> logger.propagate = False
234    >>> handler = logging.FileHandler('stoneville.log', 'w')
235    >>> logger.addHandler(handler)
236
237Create the fellows:
238
239    >>> processor = CaveProcessor()
240    >>> result = processor.doImport('newcomers.csv',
241    ...                   ['name', 'dinoports', 'owner', 'taxpayer'],
242    ...                    mode='create', user='Bob', logger=logger)
243    >>> result
244    (4, 0, '/.../newcomers.finished.csv', None)
245
246The result means: four entries were processed and no warnings
247occured. Furthermore we get filepath to a CSV file with successfully
248processed entries and a filepath to a CSV file with erraneous entries.
249As everything went well, the latter is ``None``. Let's check:
250
251    >>> sorted(stoneville.keys())
252    [u'Barneys Home', ..., u'Wilmas Asylum']
253
254The values of the Cave instances have correct type:
255
256    >>> barney = stoneville['Barneys Home']
257    >>> barney.dinoports
258    2
259
260which is a number, not a string.
261
262Apparently, when calling the processor, we gave some more info than
263only the CSV filepath. What does it all mean?
264
265While the first argument is the path to the CSV file, we also have to
266give an ordered list of headernames. These replace the header field
267names that are actually in the file. This way we can override faulty
268headers.
269
270The ``mode`` paramter tells what kind of operation we want to perform:
271``create``, ``update``, or ``remove`` data.
272
273The ``user`` parameter finally is optional and only used for logging.
274
275We can, by the way, see the results of our run in a logfile if we
276provided a logger during the call:
277
278    >>> print open('stoneville.log').read()
279    --------------------
280    Bob: Batch processing finished: OK
281    Bob: Source: newcomers.csv
282    Bob: Mode: create
283    Bob: User: Bob
284    Bob: Processing time: ... s (... s/item)
285    Bob: Processed: 4 lines (4 successful/ 0 failed)
286    --------------------
287
288We cleanup the temporay dir created by doImport():
289
290    >>> import shutil
291    >>> import os
292    >>> shutil.rmtree(os.path.dirname(result[2]))
293
294As we can see, the processing was successful. Otherwise, all problems
295could be read here as we can see, if we do the same operation again:
296
297    >>> result = processor.doImport('newcomers.csv',
298    ...                   ['name', 'dinoports', 'owner', 'taxpayer'],
299    ...                    mode='create', user='Bob', logger=logger)
300    >>> result
301    (4, 4, '/.../newcomers.finished.csv', '/.../newcomers.pending.csv')
302
303This time we also get a path to a .pending file.
304
305The log file will tell us this in more detail:
306
307    >>> print open('stoneville.log').read()
308    --------------------
309    ...
310    --------------------
311    Bob: Batch processing finished: FAILED
312    Bob: Source: newcomers.csv
313    Bob: Mode: create
314    Bob: User: Bob
315    Bob: Failed datasets: newcomers.pending.csv
316    Bob: Processing time: ... s (... s/item)
317    Bob: Processed: 4 lines (0 successful/ 4 failed)
318    --------------------
319
320This time a new file was created, which keeps all the rows we could not
321process and an additional column with error messages:
322
323    >>> print open(result[3]).read()
324    owner,name,taxpayer,dinoports,--ERRORS--
325    Barney,Barneys Home,1,2,This object already exists in the same container. Skipping.
326    Wilma,Wilmas Asylum,1,1,This object already exists in the same container. Skipping.
327    Fred,Freds Dinoburgers,0,10,This object already exists in the same container. Skipping.
328    Joey,Joeys Drive-in,0,110,This object already exists in the same container. Skipping.
329
330This way we can correct the faulty entries and afterwards retry without
331having the already processed rows in the way.
332
333We also notice, that the values of the taxpayer column are returned as
334in the input file. There we wrote '1' for ``True`` and '0' for
335``False`` (which is accepted by the converters).
336
337Clean up:
338
339    >>> shutil.rmtree(os.path.dirname(result[2]))
340
341
342We can also tell to ignore some cols from input by passing
343``--IGNORE--`` as col name:
344
345    >>> result = processor.doImport('newcomers.csv', ['name',
346    ...                             '--IGNORE--', '--IGNORE--'],
347    ...                    mode='update', user='Bob')
348    >>> result
349    (4, 0, '...', None)
350
351Clean up:
352
353    >>> shutil.rmtree(os.path.dirname(result[2]))
354
355If something goes wrong during processing, the respective --IGNORE--
356cols will be populated correctly in the resulting pending file:
357
358    >>> result = processor.doImport('newcomers.csv', ['name', 'dinoports',
359    ...                             '--IGNORE--', '--IGNORE--'],
360    ...                    mode='create', user='Bob')
361    >>> result
362    (4, 4, '...', '...')
363
364    >>> print open(result[3], 'rb').read()
365    --IGNORE--,name,--IGNORE--,dinoports,--ERRORS--
366    Barney,Barneys Home,1,2,This object already exists in the same container. Skipping.
367    Wilma,Wilmas Asylum,1,1,This object already exists in the same container. Skipping.
368    Fred,Freds Dinoburgers,0,10,This object already exists in the same container. Skipping.
369    Joey,Joeys Drive-in,0,110,This object already exists in the same container. Skipping.
370
371The first ignored column ('owner') provides different contents than
372the second one ('taxpayer').
373
374Clean up:
375
376    >>> shutil.rmtree(os.path.dirname(result[2]))
377
378
379
380
381Updating entries
382----------------
383
384To update entries, we just call the batchprocessor in a different
385mode:
386
387    >>> result = processor.doImport('newcomers.csv', ['name',
388    ...                             'dinoports', 'owner'],
389    ...                    mode='update', user='Bob')
390    >>> result
391    (4, 0, '...', None)
392
393Now we want to tell, that Wilma got an extra port for her second dino:
394
395    >>> open('newcomers.csv', 'wb').write(
396    ... """name,dinoports,owner
397    ... Wilmas Asylum,2,Wilma
398    ... """)
399
400    >>> wilma = stoneville['Wilmas Asylum']
401    >>> wilma.dinoports
402    1
403
404Clean up:
405
406    >>> shutil.rmtree(os.path.dirname(result[2]))
407
408
409We start the processor:
410
411    >>> result = processor.doImport('newcomers.csv', ['name',
412    ...                    'dinoports', 'owner'], mode='update', user='Bob')
413    >>> result
414    (1, 0, '...', None)
415
416    >>> wilma = stoneville['Wilmas Asylum']
417    >>> wilma.dinoports
418    2
419
420Wilma's number of dinoports raised.
421
422Clean up:
423
424    >>> shutil.rmtree(os.path.dirname(result[2]))
425
426
427If we try to update an unexisting entry, an error occurs:
428
429    >>> open('newcomers.csv', 'wb').write(
430    ... """name,dinoports,owner
431    ... NOT-WILMAS-ASYLUM,2,Wilma
432    ... """)
433
434    >>> result = processor.doImport('newcomers.csv', ['name',
435    ...                             'dinoports', 'owner'],
436    ...                    mode='update', user='Bob')
437    >>> result
438    (1, 1, '/.../newcomers.finished.csv', '/.../newcomers.pending.csv')
439
440Clean up:
441
442    >>> shutil.rmtree(os.path.dirname(result[2]))
443
444   
445Also invalid values will be spotted:
446
447    >>> open('newcomers.csv', 'wb').write(
448    ... """name,dinoports,owner
449    ... Wilmas Asylum,NOT-A-NUMBER,Wilma
450    ... """)
451
452    >>> result = processor.doImport('newcomers.csv', ['name',
453    ...                             'dinoports', 'owner'],
454    ...                    mode='update', user='Bob')
455    >>> result
456    (1, 1, '...', '...')
457
458Clean up:
459
460    >>> shutil.rmtree(os.path.dirname(result[2]))
461
462
463We can also update only some cols, leaving some out. We skip the
464'dinoports' column in the next run:
465
466    >>> open('newcomers.csv', 'wb').write(
467    ... """name,owner
468    ... Wilmas Asylum,Barney
469    ... """)
470
471    >>> result = processor.doImport('newcomers.csv', ['name', 'owner'],
472    ...                             mode='update', user='Bob')
473    >>> result
474    (1, 0, '...', None)
475
476    >>> wilma.owner
477    u'Barney'
478
479Clean up:
480
481    >>> shutil.rmtree(os.path.dirname(result[2]))
482
483
484We can however, not leave out the 'location field' ('name' in our
485case), as this one tells us which entry to update:
486
487    >>> open('newcomers.csv', 'wb').write(
488    ... """name,dinoports,owner
489    ... 2,Wilma
490    ... """)
491
492    >>> processor.doImport('newcomers.csv', ['dinoports', 'owner'],
493    ...                    mode='update', user='Bob')
494    Traceback (most recent call last):
495    ...
496    FatalCSVError: Need at least columns 'name' for import!
497
498This time we get even an exception!
499
500We can tell to set dinoports to ``None`` although this is not a
501number, as we declared the field not required in the interface:
502
503    >>> open('newcomers.csv', 'wb').write(
504    ... """name,dinoports,owner
505    ... "Wilmas Asylum",,"Wilma"
506    ... """)
507
508    >>> result = processor.doImport('newcomers.csv', ['name',
509    ...                             'dinoports', 'owner'],
510    ...                    mode='update', user='Bob')
511    >>> result
512    (1, 0, '...', None)
513
514    >>> wilma.dinoports is None
515    True
516
517Clean up:
518
519    >>> shutil.rmtree(os.path.dirname(result[2]))
520
521Generally, empty strings are considered as ``None``:
522
523    >>> open('newcomers.csv', 'wb').write(
524    ... """name,dinoports,owner
525    ... "Wilmas Asylum","","Wilma"
526    ... """)
527
528    >>> result = processor.doImport('newcomers.csv', ['name',
529    ...                             'dinoports', 'owner'],
530    ...                    mode='update', user='Bob')
531    >>> result
532    (1, 0, '...', None)
533
534    >>> wilma.dinoports is None
535    True
536
537Clean up:
538
539    >>> shutil.rmtree(os.path.dirname(result[2]))
540
541
542Removing entries
543----------------
544
545In 'remove' mode we can delete entries. Here validity of values in
546non-location fields doesn't matter because those fields are ignored.
547
548    >>> open('newcomers.csv', 'wb').write(
549    ... """name,dinoports,owner
550    ... "Wilmas Asylum","ILLEGAL-NUMBER",""
551    ... """)
552
553    >>> result = processor.doImport('newcomers.csv', ['name',
554    ...                             'dinoports', 'owner'],
555    ...                    mode='remove', user='Bob')
556    >>> result
557    (1, 0, '...', None)
558
559    >>> sorted(stoneville.keys())
560    [u'Barneys Home', "Fred's home", u'Freds Dinoburgers', u'Joeys Drive-in']
561
562Oops! Wilma is gone.
563
564Clean up:
565
566    >>> shutil.rmtree(os.path.dirname(result[2]))
567
568
569Clean up:
570
571    >>> import os
572    >>> os.unlink('newcomers.csv')
573    >>> os.unlink('stoneville.log')
Note: See TracBrowser for help on using the repository browser.