Tests: split off render report test code from Cycles tests.
[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             if len(parts) < 2:
161                 raise ValueError('Error parsing result from abcprop: %s', info.strip())
162             valtype_and_arrsize, name_and_extent = parts[1:]
163
164             # Parse name and extent
165             m = self.abcls_array.match(name_and_extent)
166             if not m:
167                 self.fail('Unparsable name/extent from abcls: %s' % name_and_extent)
168             name, extent = m.group('name'), m.group('arraysize')
169
170             if extent != '1':
171                 self.fail('Unsupported extent %s for property %s/%s' % (extent, proppath, name))
172
173             # Parse type
174             m = self.abcls_array.match(valtype_and_arrsize)
175             if not m:
176                 self.fail('Unparsable value type from abcls: %s' % valtype_and_arrsize)
177             valtype, scalarsize = m.group('name'), m.group('arraysize')
178
179             # Convert values
180             try:
181                 conv = converters[valtype]
182             except KeyError:
183                 self.fail('Unsupported type %s for property %s/%s' % (valtype, proppath, name))
184
185             def convert_single_line(linevalue):
186                 try:
187                     if scalarsize is None:
188                         return conv(linevalue)
189                     else:
190                         return [conv(v.strip()) for v in linevalue.split(',')]
191                 except ValueError as ex:
192                     return str(ex)
193
194             if proptype == 'ScalarProperty':
195                 value = lines.popleft()
196                 result[name] = convert_single_line(value)
197             elif proptype == 'ArrayProperty':
198                 arrayvalue = []
199                 # Arrays consist of a variable number of items, and end in a blank line.
200                 while True:
201                     linevalue = lines.popleft()
202                     if not linevalue:
203                         break
204                     arrayvalue.append(convert_single_line(linevalue))
205                 result[name] = arrayvalue
206             else:
207                 self.fail('Unsupported type %s for property %s/%s' % (proptype, proppath, name))
208
209         return result
210
211     def assertAlmostEqualFloatArray(self, actual, expect, places=6, delta=None):
212         """Asserts that the arrays of floats are almost equal."""
213
214         self.assertEqual(len(actual), len(expect),
215                          'Actual array has %d items, expected %d' % (len(actual), len(expect)))
216
217         for idx, (act, exp) in enumerate(zip(actual, expect)):
218             self.assertAlmostEqual(act, exp, places=places, delta=delta,
219                                    msg='%f != %f at index %d' % (act, exp, idx))
220
221
222 class HierarchicalAndFlatExportTest(AbstractAlembicTest):
223     @with_tempdir
224     def test_hierarchical_export(self, tempdir: pathlib.Path):
225         abc = tempdir / 'cubes_hierarchical.abc'
226         script = "import bpy; bpy.ops.wm.alembic_export(filepath='%s', start=1, end=1, " \
227                  "renderable_only=True, visible_layers_only=True, flatten=False)" % abc.as_posix()
228         self.run_blender('cubes-hierarchy.blend', script)
229
230         # Now check the resulting Alembic file.
231         xform = self.abcprop(abc, '/Cube/Cube_002/Cube_012/.xform')
232         self.assertEqual(1, xform['.inherits'])
233         self.assertAlmostEqualFloatArray(
234             xform['.vals'],
235             [1.0, 0.0, 0.0, 0.0,
236              0.0, 1.0, 0.0, 0.0,
237              0.0, 0.0, 1.0, 0.0,
238              3.07484, -2.92265, 0.0586434, 1.0]
239         )
240
241     @with_tempdir
242     def test_flat_export(self, tempdir: pathlib.Path):
243         abc = tempdir / 'cubes_flat.abc'
244         script = "import bpy; bpy.ops.wm.alembic_export(filepath='%s', start=1, end=1, " \
245                  "renderable_only=True, visible_layers_only=True, flatten=True)" % abc.as_posix()
246         self.run_blender('cubes-hierarchy.blend', script)
247
248         # Now check the resulting Alembic file.
249         xform = self.abcprop(abc, '/Cube_012/.xform')
250         self.assertEqual(0, xform['.inherits'])
251
252         self.assertAlmostEqualFloatArray(
253             xform['.vals'],
254             [0.343134, 0.485243, 0.804238, 0,
255              0.0, 0.856222, -0.516608, 0,
256              -0.939287, 0.177266, 0.293799, 0,
257              1, 3, 4, 1],
258         )
259
260
261 class DupliGroupExportTest(AbstractAlembicTest):
262     @with_tempdir
263     def test_hierarchical_export(self, tempdir: pathlib.Path):
264         abc = tempdir / 'dupligroup_hierarchical.abc'
265         script = "import bpy; bpy.ops.wm.alembic_export(filepath='%s', start=1, end=1, " \
266                  "renderable_only=True, visible_layers_only=True, flatten=False)" % abc.as_posix()
267         self.run_blender('dupligroup-scene.blend', script)
268
269         # Now check the resulting Alembic file.
270         xform = self.abcprop(abc, '/Real_Cube/Linked_Suzanne/Cylinder/Suzanne/.xform')
271         self.assertEqual(1, xform['.inherits'])
272         self.assertAlmostEqualFloatArray(
273             xform['.vals'],
274             [1.0, 0.0, 0.0, 0.0,
275              0.0, 1.0, 0.0, 0.0,
276              0.0, 0.0, 1.0, 0.0,
277              0.0, 2.0, 0.0, 1.0]
278         )
279
280     @with_tempdir
281     def test_flat_export(self, tempdir: pathlib.Path):
282         abc = tempdir / 'dupligroup_hierarchical.abc'
283         script = "import bpy; bpy.ops.wm.alembic_export(filepath='%s', start=1, end=1, " \
284                  "renderable_only=True, visible_layers_only=True, flatten=True)" % abc.as_posix()
285         self.run_blender('dupligroup-scene.blend', script)
286
287         # Now check the resulting Alembic file.
288         xform = self.abcprop(abc, '/Suzanne/.xform')
289         self.assertEqual(0, xform['.inherits'])
290
291         self.assertAlmostEqualFloatArray(
292             xform['.vals'],
293             [1.5, 0.0, 0.0, 0.0,
294              0.0, 1.5, 0.0, 0.0,
295              0.0, 0.0, 1.5, 0.0,
296              2.0, 3.0, 0.0, 1.0]
297         )
298
299
300 class CurveExportTest(AbstractAlembicTest):
301     @with_tempdir
302     def test_export_single_curve(self, tempdir: pathlib.Path):
303         abc = tempdir / 'single-curve.abc'
304         script = "import bpy; bpy.ops.wm.alembic_export(filepath='%s', start=1, end=1, " \
305                  "renderable_only=True, visible_layers_only=True, flatten=False)" % abc.as_posix()
306         self.run_blender('single-curve.blend', script)
307
308         # Now check the resulting Alembic file.
309         abcprop = self.abcprop(abc, '/NurbsCurve/NurbsCurveShape/.geom')
310         self.assertEqual(abcprop['.orders'], [4])
311
312         abcprop = self.abcprop(abc, '/NurbsCurve/NurbsCurveShape/.geom/.userProperties')
313         self.assertEqual(abcprop['blender:resolution'], 10)
314
315
316 class HairParticlesExportTest(AbstractAlembicTest):
317     """Tests exporting with/without hair/particles.
318
319     Just a basic test to ensure that the enabling/disabling works, and that export
320     works at all. NOT testing the quality/contents of the exported file.
321     """
322
323     def _do_test(self, tempdir: pathlib.Path, export_hair: bool, export_particles: bool) -> pathlib.Path:
324         abc = tempdir / 'hair-particles.abc'
325         script = "import bpy; bpy.ops.wm.alembic_export(filepath='%s', start=1, end=1, " \
326                  "renderable_only=True, visible_layers_only=True, flatten=False, " \
327                  "export_hair=%r, export_particles=%r, as_background_job=False)" \
328                  % (abc.as_posix(), export_hair, export_particles)
329         self.run_blender('hair-particles.blend', script)
330         return abc
331
332     @with_tempdir
333     def test_with_both(self, tempdir: pathlib.Path):
334         abc = self._do_test(tempdir, True, True)
335
336         abcprop = self.abcprop(abc, '/Suzanne/Hair system/.geom')
337         self.assertIn('nVertices', abcprop)
338
339         abcprop = self.abcprop(abc, '/Suzanne/Non-hair particle system/.geom')
340         self.assertIn('.velocities', abcprop)
341
342         abcprop = self.abcprop(abc, '/Suzanne/SuzanneShape/.geom')
343         self.assertIn('.faceIndices', abcprop)
344
345     @with_tempdir
346     def test_with_hair_only(self, tempdir: pathlib.Path):
347         abc = self._do_test(tempdir, True, False)
348
349         abcprop = self.abcprop(abc, '/Suzanne/Hair system/.geom')
350         self.assertIn('nVertices', abcprop)
351
352         self.assertRaises(AbcPropError, self.abcprop, abc,
353                           '/Suzanne/Non-hair particle system/.geom')
354
355         abcprop = self.abcprop(abc, '/Suzanne/SuzanneShape/.geom')
356         self.assertIn('.faceIndices', abcprop)
357
358     @with_tempdir
359     def test_with_particles_only(self, tempdir: pathlib.Path):
360         abc = self._do_test(tempdir, False, True)
361
362         self.assertRaises(AbcPropError, self.abcprop, abc, '/Suzanne/Hair system/.geom')
363
364         abcprop = self.abcprop(abc, '/Suzanne/Non-hair particle system/.geom')
365         self.assertIn('.velocities', abcprop)
366
367         abcprop = self.abcprop(abc, '/Suzanne/SuzanneShape/.geom')
368         self.assertIn('.faceIndices', abcprop)
369
370     @with_tempdir
371     def test_with_neither(self, tempdir: pathlib.Path):
372         abc = self._do_test(tempdir, False, False)
373
374         self.assertRaises(AbcPropError, self.abcprop, abc, '/Suzanne/Hair system/.geom')
375         self.assertRaises(AbcPropError, self.abcprop, abc,
376                           '/Suzanne/Non-hair particle system/.geom')
377
378         abcprop = self.abcprop(abc, '/Suzanne/SuzanneShape/.geom')
379         self.assertIn('.faceIndices', abcprop)
380
381
382 class LongNamesExportTest(AbstractAlembicTest):
383     @with_tempdir
384     def test_export_long_names(self, tempdir: pathlib.Path):
385         abc = tempdir / 'long-names.abc'
386         script = "import bpy; bpy.ops.wm.alembic_export(filepath='%s', start=1, end=1, " \
387                  "renderable_only=False, visible_layers_only=False, flatten=False)" % abc.as_posix()
388         self.run_blender('long-names.blend', script)
389
390         name_parts = [
391             'foG9aeLahgoh5goacee1dah6Hethaghohjaich5pasizairuWigee1ahPeekiGh',
392             'yoNgoisheedah2ua0eigh2AeCaiTee5bo0uphoo7Aixephah9racahvaingeeH4',
393             'zuthohnoi1thooS3eezoo8seuph2Boo5aefacaethuvee1aequoonoox1sookie',
394             'wugh4ciTh3dipiepeequait5uug7thiseek5ca7Eijei5ietaizokohhaecieto',
395             'up9aeheenein9oteiX6fohP3thiez6Ahvah0oohah1ep2Eesho4Beboechaipoh',
396             'coh4aehiacheTh0ue0eegho9oku1lohl4loht9ohPoongoow7dasiego6yimuis',
397             'lohtho8eigahfeipohviepajaix4it2peeQu6Iefee1nevihaes4cee2soh4noy',
398             'kaht9ahv0ieXaiyih7ohxe8bah7eeyicahjoa2ohbu7Choxua7oongah6sei4bu',
399             'deif0iPaechohkee5nahx6oi2uJeeN7ze3seunohJibe4shai0mah5Iesh3Quai',
400             'ChohDahshooNee0NeNohthah0eiDeese3Vu6ohShil1Iey9ja0uebi2quiShae6',
401             'Dee1kai7eiph2ahh2nufah3zai3eexeengohQue1caj0eeW0xeghi3eshuadoot',
402             'aeshiup3aengajoog0AhCoo5tiu3ieghaeGhie4Tu1ohh1thee8aepheingah1E',
403             'ooRa6ahciolohshaifoopeo9ZeiGhae2aech4raisheiWah9AaNga0uas9ahquo',
404             'thaepheip2aip6shief4EaXopei8ohPo0ighuiXah2ashowai9nohp4uach6Mei',
405             'ohph4yaev3quieji3phophiem3OoNuisheepahng4waithae3Naichai7aw3noo',
406             'aibeawaneBahmieyuph8ieng8iopheereeD2uu9Uyee5bei2phahXeir8eeJ8oo',
407             'ooshahphei2hoh3uth5chaen7ohsai6uutiesucheichai8ungah9Gie1Aiphie',
408             'eiwohchoo7ere2iebohn4Aapheichaelooriiyaoxaik7ooqua7aezahx0aeJei',
409             'Vah0ohgohphiefohTheshieghichaichahch5moshoo0zai5eeva7eisi4yae8T',
410             'EibeeN0fee0Gohnguz8iec6yeigh7shuNg4eingu3siph9joucahpeidoom4ree',
411             'iejiu3shohheeZahHusheimeefaihoh5eecachu5eeZie9ceisugu9taidohT3U',
412             'eex6dilakaix5Eetai7xiCh5Jaa8aiD4Ag3tuij1aijohv5fo0heevah8hohs3m',
413             'ohqueeNgahraew6uraemohtoo5qua3oojiex6ohqu6Aideibaithaiphuriquie',
414             'cei0eiN4Shiey7Aeluy3unohboo5choiphahc2mahbei5paephaiKeso1thoog1',
415             'ieghif4ohKequ7ong0jah5ooBah0eiGh1caechahnahThae9Shoo0phopashoo4',
416             'roh9er3thohwi5am8iequeequuSh3aic0voocai3ihi5nie2abahphupiegh7vu',
417             'uv3Quei7wujoo5beingei2aish5op4VaiX0aebai7iwoaPee5pei8ko9IepaPig',
418             'co7aegh5beitheesi9lu7jeeQu3johgeiphee9cheichi8aithuDehu2gaeNein',
419             'thai3Tiewoo4nuir1ohy4aithiuZ7shae1luuwei5phibohriepe2paeci1Ach8',
420             'phoi3ribah7ufuvoh8eigh1oB6deeBaiPohphaghiPieshahfah5EiCi3toogoo',
421             'aiM8geil7ooreinee4Cheiwea4yeec8eeshi7Sei4Shoo3wu6ohkaNgooQu1mai',
422             'agoo3faciewah9ZeesiXeereek7am0eigaeShie3Tisu8haReeNgoo0ci2Hae5u',
423             'Aesatheewiedohshaephaenohbooshee8eu7EiJ8isal1laech2eiHo0noaV3ta',
424             'liunguep3ooChoo4eir8ahSie8eenee0oo1TooXu8Cais8Aimo4eir6Phoo3xei',
425             'toe9heepeobein3teequachemei0Cejoomef9ujie3ohwae9AiNgiephi3ep0de',
426             'ua6xooY9uzaeB3of6sheiyaedohoiS5Eev0Aequ9ahm1zoa5Aegh3ooz9ChahDa',
427             'eevasah6Bu9wi7EiwiequumahkaeCheegh6lui8xoh4eeY4ieneavah8phaibun',
428             'AhNgei2sioZeeng6phaecheemeehiShie5eFeiTh6ooV8iiphabud0die4siep4',
429             'kushe6Xieg6ahQuoo9aex3aipheefiec1esa7OhBuG0ueziep9phai5eegh1vie',
430             'Jie5yu8aafuQuoh9shaep3moboh3Pooy7och8oC6obeik6jaew2aiLooweib3ch',
431             'ohohjajaivaiRail3odaimei6aekohVaicheip2wu7phieg5Gohsaing2ahxaiy',
432             'hahzaht6yaiYu9re9jah9loisiit4ahtoh2quoh9xohishioz4oo4phofu3ogha',
433             'pu4oorea0uh2tahB8aiZoonge1aophaes6ogaiK9ailaigeej4zoVou8ielotee',
434             'cae2thei3Luphuqu0zeeG8leeZuchahxaicai4ui4Eedohte9uW6gae8Geeh0ea',
435             'air7tuy7ohw5sho2Tahpai8aep4so5ria7eaShus5weaqu0Naquei2xaeyoo2ae',
436             'vohge4aeCh7ahwoo7Jaex6sohl0Koong4Iejisei8Coir0iemeiz9uru9Iebaep',
437             'aepeidie8aiw6waish9gie4Woolae2thuj5phae4phexux7gishaeph4Deu7ooS',
438             'vahc5ia0xohHooViT0uyuxookiaquu2ogueth0ahquoudeefohshai8aeThahba',
439             'mun3oagah2eequaenohfoo8DaigeghoozaV2eiveeQuee7kah0quaa6tiesheet',
440             'ooSet4IdieC4ugow3za0die4ohGoh1oopoh6luaPhaeng4Eechea1hae0eimie5',
441             'iedeimadaefu2NeiPaey2jooloov5iehiegeakoo4ueso7aeK9ahqu2Thahkaes',
442             'nahquah9Quuu2uuf0aJah7eishi2siegh8ue5eiJa2EeVu8ebohkepoh4dahNgo',
443             'io1bie7chioPiej5ae2oohe2fee6ooP2thaeJohjohb9Se8tang3eipaifeimai',
444             'oungoqu6dieneejiechez1xeD2Zi9iox2Ahchaiy9ithah3ohVoolu2euQuuawo',
445             'thaew0veigei4neishohd8mecaixuqu7eeshiex1chaigohmoThoghoitoTa0Eo',
446             'ahroob2phohvaiz0Ohteik2ohtakie6Iu1vitho8IyiyeeleeShae9defaiw9ki',
447             'DohHoothohzeaxolai3Toh5eJie7ahlah9reF0ohn1chaipoogain2aibahw4no',
448             'aif8lo5she4aich5cho2rie8ieJaujeem2Joongeedae4vie3tah1Leequaix1O',
449             'Aang0Shaih6chahthie1ahZ7aewei9thiethee7iuThah3yoongi8ahngiobaa5',
450             'iephoBuayoothah0Ru6aichai4aiw8deg1umongauvaixai3ohy6oowohlee8ei',
451             'ohn5shigoameer0aejohgoh8oChohlaecho9jie6shu0ahg9Bohngau6paevei9',
452             'edahghaishak0paigh1eecuich3aad7yeB0ieD6akeeliem2beifufaekee6eat',
453             'hiechahgheloh2zo7Ieghaiph0phahhu8aeyuiKie1xeipheech9zai4aeme0ee',
454             'Cube'
455         ]
456         name = '/' + '/'.join(name_parts)
457
458         # Now check the resulting Alembic file.
459         abcprop = self.abcprop(abc, '%s/.xform' % name)
460         self.assertEqual(abcprop['.vals'], [
461             1.0, 0.0, 0.0, 0.0,
462             0.0, 1.0, 0.0, 0.0,
463             0.0, 0.0, 1.0, 0.0,
464             0.0, 3.0, 0.0, 1.0,
465         ])
466
467         abcprop = self.abcprop(abc, '%s/CubeShape/.geom' % name)
468         self.assertIn('.faceCounts', abcprop)
469
470
471 if __name__ == '__main__':
472     parser = argparse.ArgumentParser()
473     parser.add_argument('--blender', required=True)
474     parser.add_argument('--testdir', required=True)
475     parser.add_argument('--alembic-root', required=True)
476     args, remaining = parser.parse_known_args()
477
478     unittest.main(argv=sys.argv[0:1] + remaining)