bpo-32713: Fix tarfile.itn for large/negative float values. (GH-5434)
[python-delta-tar] / testing / test_multivol_compression_sizes.py
1 #!/usr/bin/env python3
2
3 """ Test size of volumes when using multiple volumes and compression is on
4
5 Uses random files from disc as input. That is not very time-efficient but
6 provides a realistic setting for the nature of input data (file sizes,
7 randomness of data, ...)
8
9 Not a unittest, will probably take too long
10 """
11
12 from tempfile import mkstemp, mkdtemp
13 from shutil import rmtree
14 import random
15 import os
16 from os.path import isdir, dirname, abspath, join as pjoin
17 from glob import iglob
18 import stat
19 import sys
20 from traceback import print_exc
21
22 if __name__ == '__main__':
23     # ensure we are importing the "right" deltatar
24     parent_dir = dirname(dirname(abspath(__file__)))
25     sys.path.insert(0, parent_dir)
26     print('pre-prended {} to sys path'.format(parent_dir))
27 import deltatar
28 from deltatar.tarfile import TarFile, BLOCKSIZE
29 import deltatar.crypto as crypto
30
31
32 #: tolerances of volume sizes
33 KiB = 1024
34 MiB = KiB * KiB
35 SIZE_TOLERANCE_GZ = 32*KiB   # Gzip compression object buffer
36 SIZE_TOLERANCE_BZ2 = MiB
37 SIZE_TOLERANCE_XZ = 72*KiB
38 SIZE_TOLERANCE_NONE = 3*BLOCKSIZE   # should be little
39
40 #: variables for find_random_files
41 DIR_RETURN_MIN_REC = 5
42 DIR_RETURN_PROB = 0.0  # disabled
43 DIR_MAX_REC = 20
44 START_DIR = '/'
45
46 #: subdirs of START_DIR that might contain volatile or network-mounted data
47 EXCLUDE_DIRS = 'var', 'proc', 'dev', 'tmp', 'media', 'mnt', 'sys'
48
49 OK_MODES = stat.S_ISREG, stat.S_ISDIR, stat.S_ISFIFO, stat.S_ISLNK, \
50            stat.S_ISCHR, stat.S_ISBLK
51
52
53 def _get_random_file(dir_name, rec_level):
54     """ recursive helper for find_random_files """
55
56     if rec_level > DIR_MAX_REC:
57         return None
58
59     #print('_get_random_file in {}, level {}'.format(dir_name, rec_level))
60     try:
61         contents = os.listdir(dir_name)
62     except PermissionError:
63         return None
64     if not contents:
65         return None
66
67     entry = pjoin(dir_name, random.choice(contents))
68
69     if isdir(entry):
70         if rec_level > DIR_RETURN_MIN_REC and \
71                 random.random() < DIR_RETURN_PROB:
72             return entry    # with a small probability return a dir
73         else:
74             return _get_random_file(entry, rec_level + 1)
75     else:
76         return entry
77
78
79 def find_random_files(min_file_size=100):
80     """ generator over random file names
81
82     Checks if files are readable by user (os.access) and excludes dirs with
83     most volatile files and mounts; will still yield links or -- with a small
84     probablility -- names of dirs with many parents (no '/usr' but maybe
85     /usr/local/lib/python/site-packages/deltatar)
86
87     :param int min_file_size: size (in bytes) that returned files have to have
88                               at least
89     """
90
91     # prepare list of dirs in START_DIR that are not EXCLUDE_DIRS
92     start_contents = [pjoin(START_DIR, dirn) for dirn in os.listdir(START_DIR)]
93     for excl in EXCLUDE_DIRS:
94         try:
95             start_contents.remove(pjoin(START_DIR, excl))
96         except ValueError:
97             pass
98
99     # infinite main loop
100     while True:
101         #print('_get_random_file in {}, level {}'.format(START_DIR, 0))
102         entry = random.choice(start_contents)
103         if isdir(entry):
104             next_result = _get_random_file(entry, 1)
105         else:
106             next_result = entry
107             #print('found non-dir in START_DIR: {}'.format(next_result))
108         if next_result is None:
109             #print('received None, try next')
110             continue
111         if not os.access(next_result, os.R_OK):
112             #print('cannot access {}, try next'.format(next_result))
113             continue
114         statres = os.lstat(next_result)
115         if statres.st_size < min_file_size:
116             #print('file {} too small'.format(next_result))
117             continue
118         mode = statres.st_mode
119         if not any(mode_test(mode) for mode_test in OK_MODES):
120             #print('mode not accepted for {}, try next'.format(next_result))
121             continue
122         yield next_result
123
124
125 def new_volume_handler(tarobj, base_name, volume_number,
126                        prefix='', debug_level=0):
127     """ called when creating a new volume from TarFile.addfile """
128
129     if debug_level:
130         print(prefix + 'new volume handler called with {} and new vol {}'
131                        .format(base_name, volume_number))
132
133     # close current volume file object
134     tarobj.fileobj.close()
135
136     # create name for next volume file
137     idx = base_name.rindex('.0.')
138     new_vol_path = '{}.{}.{}'.format(base_name[:idx], volume_number,
139                                      base_name[idx+3:])
140
141     tarobj.open_volume(new_vol_path)
142
143
144 def test(volume_size, input_size_factor, mode, password, temp_dir, prefix='',
145          clean_up_if_error=False, debug_level=0):
146     """ create TarFile with given vol_size, add vol_size*input_size
147
148     :param volume_size: in MiB
149     :param str prefix: optional output prefix
150     :param str mode: compression mode for TarFile's mode argument
151     :param bool clean_up_if_error: True will ensure there are no files left;
152                                    False (default): leave volumes if error
153     :param int debug_level: 0-3 where 0=no debug output, 3=lots of debug output
154                             (forwarded to TarFile constructor)
155     :returns: True if test failed (some size wrong, file missing, ...)
156     """
157
158     input_size = volume_size * input_size_factor * MiB
159     something_strange = False
160
161     if 'gz' in mode:
162         suffix = 'tgz'
163         size_tolerance = SIZE_TOLERANCE_GZ
164     elif 'bz' in mode:
165         suffix = 'tbz'
166         size_tolerance = SIZE_TOLERANCE_BZ2
167     elif 'xz' in mode:
168         suffix = 'txz'
169         size_tolerance = SIZE_TOLERANCE_XZ
170     else:
171         suffix = 'tar'
172         size_tolerance = SIZE_TOLERANCE_NONE
173
174
175     temp_name = None
176     file_handle = None
177     base_name = None
178     try:
179         # create temp file
180         file_handle, temp_name = mkstemp(dir=temp_dir, suffix='.0.' + suffix)
181         os.close(file_handle)
182         file_handle = None
183
184         # preparations
185         base_name = temp_name.replace('.0.' + suffix, '')
186         if debug_level:
187             print(prefix + 'tarfile: ' + temp_name)
188
189         volume_prefix = prefix + 'vol={}MiB, in=*{}, mode={}: ' \
190                                  .format(volume_size, input_size_factor, mode)
191         def vol_handler(a,b,c):
192             return new_volume_handler(a,b,c, volume_prefix, debug_level)
193
194         # create tar object
195
196         encryptor = None
197         if password is not None:
198             encryptor = crypto.Encrypt (1, 1, password=password)
199
200         tarobj = TarFile.open(temp_name, mode=mode,
201                               max_volume_size=volume_size*MiB,
202                               new_volume_handler=vol_handler,
203                               encryption=encryptor, debug=debug_level)
204
205         # add data
206         added_size = 0
207         new_size = 0
208         files_added = []
209         for count, file_name in enumerate(find_random_files()):
210             if file_name.startswith(base_name):
211                 continue    # do not accidentally add self
212             new_size = os.lstat(file_name).st_size
213             if new_size > max(volume_size*MiB, input_size-added_size):
214                 continue    # add at most one volume_size too much
215             new_name = '{}_{:04d}_{}_{:09d}' \
216                        .format(base_name, count,
217                                file_name.replace('/','_')[:200],
218                                new_size)
219             tarobj.add(file_name, arcname=new_name)
220             files_added.append(new_name)
221             added_size += new_size
222             if debug_level > 2:
223                 print('{}vol={}MiB, in=*{}, mode={}: added {:.1f}MiB/{:.1f}MiB'
224                       .format(prefix, volume_size, input_size_factor, mode,
225                               added_size/MiB, input_size/MiB))
226             if added_size > input_size:
227                 break
228         tarobj.close()
229
230         # check volume files
231         n_wrong_size = 0
232         n_volumes = 0
233         volume_size_sum = 0
234         for file_name in iglob(pjoin(temp_dir, base_name + '*')):
235             n_volumes += 1
236             vol_size = os.lstat(file_name).st_size
237             volume_size_sum += vol_size
238             if debug_level:
239                 print('{} - {}: {:.3f}'.format(prefix, file_name,
240                                                vol_size/MiB))
241             if abs(vol_size - volume_size*MiB) > size_tolerance:
242                 n_wrong_size += 1
243
244         if debug_level:
245             print(prefix + 'compression ratio (input/compressed size): {:.2f}'
246                            .format(added_size/volume_size_sum))
247
248         if n_wrong_size > 1:
249             print(prefix + 'wrong size!')
250             something_strange = True
251         if n_volumes == 0:
252             print(prefix + 'no volumes!')
253             something_strange = True
254
255         # extract data
256         if debug_level:
257             print(prefix + 'extracting:')
258         decryptor = None
259         if password is not None:
260             decryptor = crypto.Decrypt (password=password)
261         tarobj = TarFile.open(temp_name, mode=mode.replace('w', 'r'),
262                               new_volume_handler=new_volume_handler,
263                               encryption=decryptor, debug=debug_level)
264         tarobj.extractall(path='/')
265         tarobj.close()
266
267         # check whether all original files are accounted for
268         n_files_found = 0
269         files_found = [False for _ in files_added]
270
271         for file_name in iglob(pjoin(temp_dir, base_name + '_*')):
272             n_files_found += 1
273             orig_size = int(file_name[-9:])
274             if os.lstat(file_name).st_size != orig_size:
275                 print(prefix + 'wrong size: {} instead of {} for {}!'
276                                .format(os.lstat(file_name).st_size, orig_size,
277                                        file_name))
278                 something_strange = True
279             try:
280                 idx = files_added.index(file_name)
281             except ValueError:
282                 print(prefix + 'extracted file that was not added: '
283                       + file_name)
284                 something_strange = True
285             else:
286                 files_found[idx] = True
287
288         not_found = [file_name
289                      for file_name, found in zip(files_added, files_found)
290                      if not found]
291
292         for file_name in not_found:
293             print(prefix + 'original file not found: ' + file_name)
294             something_strange = True
295
296         if n_files_found != len(files_added):
297             print(prefix + 'added {} files but extracted {}!'
298                            .format(len(files_added), n_files_found))
299             something_strange = True
300     except Exception as exc:
301         print('caught exception {}'.format(exc))
302         print_exc()
303         something_strange = True
304     finally:
305         if file_handle:
306             os.close(file_handle)
307
308         # clean up
309         if base_name:
310             for file_name in iglob(base_name + '*'):
311                 if clean_up_if_error:
312                     os.unlink(file_name)
313                 elif something_strange and file_name.endswith('.' + suffix):
314                     continue   # skip
315                 else:
316                     os.unlink(file_name)   # remove
317             if debug_level and something_strange and not clean_up_if_error:
318                 print(prefix + 'leaving volume files ' + base_name
319                       + '.*.'+suffix)
320
321     # summarize
322     if something_strange:
323         print('{}test with volume_size={}, input_factor={}, mode={} failed!'
324               .format(prefix, volume_size, input_size_factor, mode))
325     elif debug_level:
326         print(prefix + 'test succeeded')
327
328     return something_strange
329
330
331 def test_lots(fast_fail=False, debug_level=0, clean_up_if_error=False):
332     """ Tests a lot of combinations of volume_size, input_size and mode
333
334     :param bool fast_fail: set to True to stop after first error
335     :retuns: number of failed tests
336     """
337
338     # volume sizes in MiB
339     volume_sizes = 10, 100
340
341     # input size factor (multiplied with volume size)
342     input_size_factors = 3, 10, 30
343
344     # compression modes (including uncompressed as comparison)
345     modes = ('w|gz' , None) \
346           , ('w|bz2', None) \
347           , ('w|xz' , None) \
348           , ('w#gz' , None) \
349           , ('w#gz' , "test1234") \
350           , ('w#'   , "test1234")
351
352     # create a temp dir for all input and output data
353     temp_dir = mkdtemp(prefix='deltatar_cmprs_tst_')
354     n_errs = 0
355     n_tests = len(volume_sizes) * len(input_size_factors) * len(modes)
356     test_idx = 0
357     stop_now = False
358     for volume_size in volume_sizes:
359         if stop_now:
360             break
361         for input_size_factor in input_size_factors:
362             if stop_now:
363                 break
364             for mode, password in modes:
365                 test_idx += 1
366                 prefix = 'test{:d}: '.format(test_idx)
367                 something_strange = test(volume_size, input_size_factor, mode,
368                                          password,
369                                          temp_dir, prefix,
370                                          clean_up_if_error=False,
371                                          debug_level=debug_level)
372                 if something_strange:
373                     n_errs += 1
374                     if fast_fail:
375                         stop_now = True
376                         break
377                 print('after running test {:3d}/{} have {} errs'
378                       .format(test_idx, n_tests, n_errs))
379     if n_errs == 0:
380         print('removing temp dir {}'.format(temp_dir))
381         rmtree(temp_dir)
382     else:
383         print('leaving temp dir {}'.format(temp_dir))
384
385     return n_errs
386
387
388 if __name__ == '__main__':
389     # run test
390     n_errs = test_lots()
391
392     # forward number of errors to shell
393     sys.exit(n_errs)