Merge branch 'master' into blender2.8
[blender.git] / tests / python / alembic_tests.py
1 #!/usr/bin/env python3
2 # ##### BEGIN GPL LICENSE BLOCK #####
3 #
4 #  This program is free software; you can redistribute it and/or
5 #  modify it under the terms of the GNU General Public License
6 #  as published by the Free Software Foundation; either version 2
7 #  of the License, or (at your option) any later version.
8 #
9 #  This program is distributed in the hope that it will be useful,
10 #  but WITHOUT ANY WARRANTY; without even the implied warranty of
11 #  MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
12 #  GNU General Public License for more details.
13 #
14 #  You should have received a copy of the GNU General Public License
15 #  along with this program; if not, write to the Free Software Foundation,
16 #  Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
17 #
18 # ##### END GPL LICENSE BLOCK #####
19
20 # <pep8 compliant>
21
22 import argparse
23 import functools
24 import shutil
25 import pathlib
26 import subprocess
27 import sys
28 import tempfile
29 import unittest
30
31
32 def with_tempdir(wrapped):
33     """Creates a temporary directory for the function, cleaning up after it returns normally.
34
35     When the wrapped function raises an exception, the contents of the temporary directory
36     remain available for manual inspection.
37
38     The wrapped function is called with an extra positional argument containing
39     the pathlib.Path() of the temporary directory.
40     """
41
42     @functools.wraps(wrapped)
43     def decorator(*args, **kwargs):
44         dirname = tempfile.mkdtemp(prefix='blender-alembic-test')
45         try:
46             retval = wrapped(*args, pathlib.Path(dirname), **kwargs)
47         except:
48             print('Exception in %s, not cleaning up temporary directory %s' % (wrapped, dirname))
49             raise
50         else:
51             shutil.rmtree(dirname)
52         return retval
53
54     return decorator
55
56
57 class AbcPropError(Exception):
58     """Raised when AbstractAlembicTest.abcprop() finds an error."""
59
60
61 class AbstractAlembicTest(unittest.TestCase):
62     @classmethod
63     def setUpClass(cls):
64         import re
65
66         cls.blender = args.blender
67         cls.testdir = pathlib.Path(args.testdir)
68         cls.alembic_root = pathlib.Path(args.alembic_root)
69
70         # 'abcls' outputs ANSI colour codes, even when stdout is not a terminal.
71         # See https://github.com/alembic/alembic/issues/120
72         cls.ansi_remove_re = re.compile(rb'\x1b[^m]*m')
73
74         # 'abcls' array notation, like "name[16]"
75         cls.abcls_array = re.compile(r'^(?P<name>[^\[]+)(\[(?P<arraysize>\d+)\])?$')
76
77     def run_blender(self, filepath: str, python_script: str, timeout: int=300) -> str:
78         """Runs Blender by opening a blendfile and executing a script.
79
80         Returns Blender's stdout + stderr combined into one string.
81
82         :param filepath: taken relative to self.testdir.
83         :param timeout: in seconds
84         """
85
86         blendfile = self.testdir / filepath
87
88         command = (
89             self.blender,
90             '--background',
91             '-noaudio',
92             '--factory-startup',
93             '--enable-autoexec',
94             str(blendfile),
95             '-E', 'CYCLES',
96             '--python-exit-code', '47',
97             '--python-expr', python_script,
98         )
99
100         proc = subprocess.run(command, stdout=subprocess.PIPE, stderr=subprocess.STDOUT,
101                               timeout=timeout)
102         output = proc.stdout.decode('utf8')
103         if proc.returncode:
104             self.fail('Error %d running Blender:\n%s' % (proc.returncode, output))
105
106         return output
107
108     def abcprop(self, filepath: pathlib.Path, proppath: str) -> dict:
109         """Uses abcls to obtain compound property values from an Alembic object.
110
111         A dict of subproperties is returned, where the values are Python values.
112
113         The Python bindings for Alembic are old, and only compatible with Python 2.x,
114         so that's why we can't use them here, and have to rely on other tooling.
115         """
116         import collections
117
118         abcls = self.alembic_root / 'bin' / 'abcls'
119
120         command = (str(abcls), '-vl', '%s%s' % (filepath, proppath))
121         proc = subprocess.run(command, stdout=subprocess.PIPE, stderr=subprocess.STDOUT,
122                               timeout=30)
123
124         coloured_output = proc.stdout
125         output = self.ansi_remove_re.sub(b'', coloured_output).decode('utf8')
126
127         # Because of the ANSI colour codes, we need to remove those first before
128         # decoding to text. This means that we cannot use the universal_newlines
129         # parameter to subprocess.run(), and have to do the conversion ourselves
130         output = output.replace('\r\n', '\n').replace('\r', '\n')
131
132         if proc.returncode:
133             raise AbcPropError('Error %d running abcls:\n%s' % (proc.returncode, output))
134
135         # Mapping from value type to callable that can convert a string to Python values.
136         converters = {
137             'bool_t': int,
138             'uint8_t': int,
139             'int16_t': int,
140             'int32_t': int,
141             'uint64_t': int,
142             'float64_t': float,
143             'float32_t': float,
144         }
145
146         result = {}
147
148         # Ideally we'd get abcls to output JSON, see https://github.com/alembic/alembic/issues/121
149         lines = collections.deque(output.split('\n'))
150         while lines:
151             info = lines.popleft()
152             if not info:
153                 continue
154             parts = info.split()
155             proptype = parts[0]
156
157             if proptype == 'CompoundProperty':
158                 # To read those, call self.abcprop() on it.
159                 continue
160             valtype_and_arrsize, name_and_extent = parts[1:]
161
162             # Parse name and extent
163             m = self.abcls_array.match(name_and_extent)
164             if not m:
165                 self.fail('Unparsable name/extent from abcls: %s' % name_and_extent)
166             name, extent = m.group('name'), m.group('arraysize')
167
168             if extent != '1':
169                 self.fail('Unsupported extent %s for property %s/%s' % (extent, proppath, name))
170
171             # Parse type
172             m = self.abcls_array.match(valtype_and_arrsize)
173             if not m:
174                 self.fail('Unparsable value type from abcls: %s' % valtype_and_arrsize)
175             valtype, scalarsize = m.group('name'), m.group('arraysize')
176
177             # Convert values
178             try:
179                 conv = converters[valtype]
180             except KeyError:
181                 self.fail('Unsupported type %s for property %s/%s' % (valtype, proppath, name))
182
183             def convert_single_line(linevalue):
184                 try:
185                     if scalarsize is None:
186                         return conv(linevalue)
187                     else:
188                         return [conv(v.strip()) for v in linevalue.split(',')]
189                 except ValueError as ex:
190                     return str(ex)
191
192             if proptype == 'ScalarProperty':
193                 value = lines.popleft()
194                 result[name] = convert_single_line(value)
195             elif proptype == 'ArrayProperty':
196                 arrayvalue = []
197                 # Arrays consist of a variable number of items, and end in a blank line.
198                 while True:
199                     linevalue = lines.popleft()
200                     if not linevalue:
201                         break
202                     arrayvalue.append(convert_single_line(linevalue))
203                 result[name] = arrayvalue
204             else:
205                 self.fail('Unsupported type %s for property %s/%s' % (proptype, proppath, name))
206
207         return result
208
209     def assertAlmostEqualFloatArray(self, actual, expect, places=6, delta=None):
210         """Asserts that the arrays of floats are almost equal."""
211
212         self.assertEqual(len(actual), len(expect),
213                          'Actual array has %d items, expected %d' % (len(actual), len(expect)))
214
215         for idx, (act, exp) in enumerate(zip(actual, expect)):
216             self.assertAlmostEqual(act, exp, places=places, delta=delta,
217                                    msg='%f != %f at index %d' % (act, exp, idx))
218
219
220 class HierarchicalAndFlatExportTest(AbstractAlembicTest):
221     @with_tempdir
222     def test_hierarchical_export(self, tempdir: pathlib.Path):
223         abc = tempdir / 'cubes_hierarchical.abc'
224         script = "import bpy; bpy.ops.wm.alembic_export(filepath='%s', start=1, end=1, " \
225                  "renderable_only=True, visible_layers_only=True, flatten=False)" % abc.as_posix()
226         self.run_blender('cubes-hierarchy.blend', script)
227
228         # Now check the resulting Alembic file.
229         xform = self.abcprop(abc, '/Cube/Cube_002/Cube_012/.xform')
230         self.assertEqual(1, xform['.inherits'])
231         self.assertAlmostEqualFloatArray(
232             xform['.vals'],
233             [1.0, 0.0, 0.0, 0.0,
234              0.0, 1.0, 0.0, 0.0,
235              0.0, 0.0, 1.0, 0.0,
236              3.07484, -2.92265, 0.0586434, 1.0]
237         )
238
239     @with_tempdir
240     def test_flat_export(self, tempdir: pathlib.Path):
241         abc = tempdir / 'cubes_flat.abc'
242         script = "import bpy; bpy.ops.wm.alembic_export(filepath='%s', start=1, end=1, " \
243                  "renderable_only=True, visible_layers_only=True, flatten=True)" % abc.as_posix()
244         self.run_blender('cubes-hierarchy.blend', script)
245
246         # Now check the resulting Alembic file.
247         xform = self.abcprop(abc, '/Cube_012/.xform')
248         self.assertEqual(0, xform['.inherits'])
249
250         self.assertAlmostEqualFloatArray(
251             xform['.vals'],
252             [0.343134, 0.485243, 0.804238, 0,
253              0.0, 0.856222, -0.516608, 0,
254              -0.939287, 0.177266, 0.293799, 0,
255              1, 3, 4, 1],
256         )
257
258
259 class DupliGroupExportTest(AbstractAlembicTest):
260     @with_tempdir
261     def test_hierarchical_export(self, tempdir: pathlib.Path):
262         abc = tempdir / 'dupligroup_hierarchical.abc'
263         script = "import bpy; bpy.ops.wm.alembic_export(filepath='%s', start=1, end=1, " \
264                  "renderable_only=True, visible_layers_only=True, flatten=False)" % abc.as_posix()
265         self.run_blender('dupligroup-scene.blend', script)
266
267         # Now check the resulting Alembic file.
268         xform = self.abcprop(abc, '/Real_Cube/Linked_Suzanne/Cylinder/Suzanne/.xform')
269         self.assertEqual(1, xform['.inherits'])
270         self.assertAlmostEqualFloatArray(
271             xform['.vals'],
272             [1.0, 0.0, 0.0, 0.0,
273              0.0, 1.0, 0.0, 0.0,
274              0.0, 0.0, 1.0, 0.0,
275              0.0, 2.0, 0.0, 1.0]
276         )
277
278     @with_tempdir
279     def test_flat_export(self, tempdir: pathlib.Path):
280         abc = tempdir / 'dupligroup_hierarchical.abc'
281         script = "import bpy; bpy.ops.wm.alembic_export(filepath='%s', start=1, end=1, " \
282                  "renderable_only=True, visible_layers_only=True, flatten=True)" % abc.as_posix()
283         self.run_blender('dupligroup-scene.blend', script)
284
285         # Now check the resulting Alembic file.
286         xform = self.abcprop(abc, '/Suzanne/.xform')
287         self.assertEqual(0, xform['.inherits'])
288
289         self.assertAlmostEqualFloatArray(
290             xform['.vals'],
291             [1.5, 0.0, 0.0, 0.0,
292              0.0, 1.5, 0.0, 0.0,
293              0.0, 0.0, 1.5, 0.0,
294              2.0, 3.0, 0.0, 1.0]
295         )
296
297
298 class CurveExportTest(AbstractAlembicTest):
299     @with_tempdir
300     def test_export_single_curve(self, tempdir: pathlib.Path):
301         abc = tempdir / 'single-curve.abc'
302         script = "import bpy; bpy.ops.wm.alembic_export(filepath='%s', start=1, end=1, " \
303                  "renderable_only=True, visible_layers_only=True, flatten=False)" % abc.as_posix()
304         self.run_blender('single-curve.blend', script)
305
306         # Now check the resulting Alembic file.
307         abcprop = self.abcprop(abc, '/NurbsCurve/NurbsCurveShape/.geom')
308         self.assertEqual(abcprop['.orders'], [4])
309
310         abcprop = self.abcprop(abc, '/NurbsCurve/NurbsCurveShape/.geom/.userProperties')
311         self.assertEqual(abcprop['blender:resolution'], 10)
312
313
314 class HairParticlesExportTest(AbstractAlembicTest):
315     """Tests exporting with/without hair/particles.
316
317     Just a basic test to ensure that the enabling/disabling works, and that export
318     works at all. NOT testing the quality/contents of the exported file.
319     """
320
321     def _do_test(self, tempdir: pathlib.Path, export_hair: bool, export_particles: bool) -> pathlib.Path:
322         abc = tempdir / 'hair-particles.abc'
323         script = "import bpy; bpy.ops.wm.alembic_export(filepath='%s', start=1, end=1, " \
324                  "renderable_only=True, visible_layers_only=True, flatten=False, " \
325                  "export_hair=%r, export_particles=%r, as_background_job=False)" \
326                  % (abc.as_posix(), export_hair, export_particles)
327         self.run_blender('hair-particles.blend', script)
328         return abc
329
330     @with_tempdir
331     def test_with_both(self, tempdir: pathlib.Path):
332         abc = self._do_test(tempdir, True, True)
333
334         abcprop = self.abcprop(abc, '/Suzanne/Hair system/.geom')
335         self.assertIn('nVertices', abcprop)
336
337         abcprop = self.abcprop(abc, '/Suzanne/Non-hair particle system/.geom')
338         self.assertIn('.velocities', abcprop)
339
340         abcprop = self.abcprop(abc, '/Suzanne/SuzanneShape/.geom')
341         self.assertIn('.faceIndices', abcprop)
342
343     @with_tempdir
344     def test_with_hair_only(self, tempdir: pathlib.Path):
345         abc = self._do_test(tempdir, True, False)
346
347         abcprop = self.abcprop(abc, '/Suzanne/Hair system/.geom')
348         self.assertIn('nVertices', abcprop)
349
350         self.assertRaises(AbcPropError, self.abcprop, abc,
351                           '/Suzanne/Non-hair particle system/.geom')
352
353         abcprop = self.abcprop(abc, '/Suzanne/SuzanneShape/.geom')
354         self.assertIn('.faceIndices', abcprop)
355
356     @with_tempdir
357     def test_with_particles_only(self, tempdir: pathlib.Path):
358         abc = self._do_test(tempdir, False, True)
359
360         self.assertRaises(AbcPropError, self.abcprop, abc, '/Suzanne/Hair system/.geom')
361
362         abcprop = self.abcprop(abc, '/Suzanne/Non-hair particle system/.geom')
363         self.assertIn('.velocities', abcprop)
364
365         abcprop = self.abcprop(abc, '/Suzanne/SuzanneShape/.geom')
366         self.assertIn('.faceIndices', abcprop)
367
368     @with_tempdir
369     def test_with_neither(self, tempdir: pathlib.Path):
370         abc = self._do_test(tempdir, False, False)
371
372         self.assertRaises(AbcPropError, self.abcprop, abc, '/Suzanne/Hair system/.geom')
373         self.assertRaises(AbcPropError, self.abcprop, abc,
374                           '/Suzanne/Non-hair particle system/.geom')
375
376         abcprop = self.abcprop(abc, '/Suzanne/SuzanneShape/.geom')
377         self.assertIn('.faceIndices', abcprop)
378
379
380 class LongNamesExportTest(AbstractAlembicTest):
381     @with_tempdir
382     def test_export_long_names(self, tempdir: pathlib.Path):
383         abc = tempdir / 'long-names.abc'
384         script = "import bpy; bpy.ops.wm.alembic_export(filepath='%s', start=1, end=1, " \
385                  "renderable_only=False, visible_layers_only=False, flatten=False)" % abc.as_posix()
386         self.run_blender('long-names.blend', script)
387
388         name_parts = [
389             'foG9aeLahgoh5goacee1dah6Hethaghohjaich5pasizairuWigee1ahPeekiGh',
390             'yoNgoisheedah2ua0eigh2AeCaiTee5bo0uphoo7Aixephah9racahvaingeeH4',
391             'zuthohnoi1thooS3eezoo8seuph2Boo5aefacaethuvee1aequoonoox1sookie',
392             'wugh4ciTh3dipiepeequait5uug7thiseek5ca7Eijei5ietaizokohhaecieto',
393             'up9aeheenein9oteiX6fohP3thiez6Ahvah0oohah1ep2Eesho4Beboechaipoh',
394             'coh4aehiacheTh0ue0eegho9oku1lohl4loht9ohPoongoow7dasiego6yimuis',
395             'lohtho8eigahfeipohviepajaix4it2peeQu6Iefee1nevihaes4cee2soh4noy',
396             'kaht9ahv0ieXaiyih7ohxe8bah7eeyicahjoa2ohbu7Choxua7oongah6sei4bu',
397             'deif0iPaechohkee5nahx6oi2uJeeN7ze3seunohJibe4shai0mah5Iesh3Quai',
398             'ChohDahshooNee0NeNohthah0eiDeese3Vu6ohShil1Iey9ja0uebi2quiShae6',
399             'Dee1kai7eiph2ahh2nufah3zai3eexeengohQue1caj0eeW0xeghi3eshuadoot',
400             'aeshiup3aengajoog0AhCoo5tiu3ieghaeGhie4Tu1ohh1thee8aepheingah1E',
401             'ooRa6ahciolohshaifoopeo9ZeiGhae2aech4raisheiWah9AaNga0uas9ahquo',
402             'thaepheip2aip6shief4EaXopei8ohPo0ighuiXah2ashowai9nohp4uach6Mei',
403             'ohph4yaev3quieji3phophiem3OoNuisheepahng4waithae3Naichai7aw3noo',
404             'aibeawaneBahmieyuph8ieng8iopheereeD2uu9Uyee5bei2phahXeir8eeJ8oo',
405             'ooshahphei2hoh3uth5chaen7ohsai6uutiesucheichai8ungah9Gie1Aiphie',
406             'eiwohchoo7ere2iebohn4Aapheichaelooriiyaoxaik7ooqua7aezahx0aeJei',
407             'Vah0ohgohphiefohTheshieghichaichahch5moshoo0zai5eeva7eisi4yae8T',
408             'EibeeN0fee0Gohnguz8iec6yeigh7shuNg4eingu3siph9joucahpeidoom4ree',
409             'iejiu3shohheeZahHusheimeefaihoh5eecachu5eeZie9ceisugu9taidohT3U',
410             'eex6dilakaix5Eetai7xiCh5Jaa8aiD4Ag3tuij1aijohv5fo0heevah8hohs3m',
411             'ohqueeNgahraew6uraemohtoo5qua3oojiex6ohqu6Aideibaithaiphuriquie',
412             'cei0eiN4Shiey7Aeluy3unohboo5choiphahc2mahbei5paephaiKeso1thoog1',
413             'ieghif4ohKequ7ong0jah5ooBah0eiGh1caechahnahThae9Shoo0phopashoo4',
414             'roh9er3thohwi5am8iequeequuSh3aic0voocai3ihi5nie2abahphupiegh7vu',
415             'uv3Quei7wujoo5beingei2aish5op4VaiX0aebai7iwoaPee5pei8ko9IepaPig',
416             'co7aegh5beitheesi9lu7jeeQu3johgeiphee9cheichi8aithuDehu2gaeNein',
417             'thai3Tiewoo4nuir1ohy4aithiuZ7shae1luuwei5phibohriepe2paeci1Ach8',
418             'phoi3ribah7ufuvoh8eigh1oB6deeBaiPohphaghiPieshahfah5EiCi3toogoo',
419             'aiM8geil7ooreinee4Cheiwea4yeec8eeshi7Sei4Shoo3wu6ohkaNgooQu1mai',
420             'agoo3faciewah9ZeesiXeereek7am0eigaeShie3Tisu8haReeNgoo0ci2Hae5u',
421             'Aesatheewiedohshaephaenohbooshee8eu7EiJ8isal1laech2eiHo0noaV3ta',
422             'liunguep3ooChoo4eir8ahSie8eenee0oo1TooXu8Cais8Aimo4eir6Phoo3xei',
423             'toe9heepeobein3teequachemei0Cejoomef9ujie3ohwae9AiNgiephi3ep0de',
424             'ua6xooY9uzaeB3of6sheiyaedohoiS5Eev0Aequ9ahm1zoa5Aegh3ooz9ChahDa',
425             'eevasah6Bu9wi7EiwiequumahkaeCheegh6lui8xoh4eeY4ieneavah8phaibun',
426             'AhNgei2sioZeeng6phaecheemeehiShie5eFeiTh6ooV8iiphabud0die4siep4',
427             'kushe6Xieg6ahQuoo9aex3aipheefiec1esa7OhBuG0ueziep9phai5eegh1vie',
428             'Jie5yu8aafuQuoh9shaep3moboh3Pooy7och8oC6obeik6jaew2aiLooweib3ch',
429             'ohohjajaivaiRail3odaimei6aekohVaicheip2wu7phieg5Gohsaing2ahxaiy',
430             'hahzaht6yaiYu9re9jah9loisiit4ahtoh2quoh9xohishioz4oo4phofu3ogha',
431             'pu4oorea0uh2tahB8aiZoonge1aophaes6ogaiK9ailaigeej4zoVou8ielotee',
432             'cae2thei3Luphuqu0zeeG8leeZuchahxaicai4ui4Eedohte9uW6gae8Geeh0ea',
433             'air7tuy7ohw5sho2Tahpai8aep4so5ria7eaShus5weaqu0Naquei2xaeyoo2ae',
434             'vohge4aeCh7ahwoo7Jaex6sohl0Koong4Iejisei8Coir0iemeiz9uru9Iebaep',
435             'aepeidie8aiw6waish9gie4Woolae2thuj5phae4phexux7gishaeph4Deu7ooS',
436             'vahc5ia0xohHooViT0uyuxookiaquu2ogueth0ahquoudeefohshai8aeThahba',
437             'mun3oagah2eequaenohfoo8DaigeghoozaV2eiveeQuee7kah0quaa6tiesheet',
438             'ooSet4IdieC4ugow3za0die4ohGoh1oopoh6luaPhaeng4Eechea1hae0eimie5',
439             'iedeimadaefu2NeiPaey2jooloov5iehiegeakoo4ueso7aeK9ahqu2Thahkaes',
440             'nahquah9Quuu2uuf0aJah7eishi2siegh8ue5eiJa2EeVu8ebohkepoh4dahNgo',
441             'io1bie7chioPiej5ae2oohe2fee6ooP2thaeJohjohb9Se8tang3eipaifeimai',
442             'oungoqu6dieneejiechez1xeD2Zi9iox2Ahchaiy9ithah3ohVoolu2euQuuawo',
443             'thaew0veigei4neishohd8mecaixuqu7eeshiex1chaigohmoThoghoitoTa0Eo',
444             'ahroob2phohvaiz0Ohteik2ohtakie6Iu1vitho8IyiyeeleeShae9defaiw9ki',
445             'DohHoothohzeaxolai3Toh5eJie7ahlah9reF0ohn1chaipoogain2aibahw4no',
446             'aif8lo5she4aich5cho2rie8ieJaujeem2Joongeedae4vie3tah1Leequaix1O',
447             'Aang0Shaih6chahthie1ahZ7aewei9thiethee7iuThah3yoongi8ahngiobaa5',
448             'iephoBuayoothah0Ru6aichai4aiw8deg1umongauvaixai3ohy6oowohlee8ei',
449             'ohn5shigoameer0aejohgoh8oChohlaecho9jie6shu0ahg9Bohngau6paevei9',
450             'edahghaishak0paigh1eecuich3aad7yeB0ieD6akeeliem2beifufaekee6eat',
451             'hiechahgheloh2zo7Ieghaiph0phahhu8aeyuiKie1xeipheech9zai4aeme0ee',
452             'Cube'
453         ]
454         name = '/' + '/'.join(name_parts)
455
456         # Now check the resulting Alembic file.
457         abcprop = self.abcprop(abc, '%s/.xform' % name)
458         self.assertEqual(abcprop['.vals'], [
459             1.0, 0.0, 0.0, 0.0,
460             0.0, 1.0, 0.0, 0.0,
461             0.0, 0.0, 1.0, 0.0,
462             0.0, 3.0, 0.0, 1.0,
463         ])
464
465         abcprop = self.abcprop(abc, '%s/CubeShape/.geom' % name)
466         self.assertIn('.faceCounts', abcprop)
467
468
469 if __name__ == '__main__':
470     parser = argparse.ArgumentParser()
471     parser.add_argument('--blender', required=True)
472     parser.add_argument('--testdir', required=True)
473     parser.add_argument('--alembic-root', required=True)
474     args, remaining = parser.parse_known_args()
475
476     unittest.main(argv=sys.argv[0:1] + remaining)