diff --git a/appveyor.yml b/appveyor.yml index 06038554..c5a9035f 100644 --- a/appveyor.yml +++ b/appveyor.yml @@ -43,7 +43,6 @@ build_script: - cmd: unit_tests.exe - cmd: cd ../../tests/ - cmd: set EXIV2_EXT=.exe - - cmd: set EXIV2_CAT=type - cmd: c:\Python36\python.exe runner.py -v cache: diff --git a/tests/bugfixes/github/test_issue_283.py b/tests/bugfixes/github/test_issue_283.py index fc9df793..b5bd8e1b 100644 --- a/tests/bugfixes/github/test_issue_283.py +++ b/tests/bugfixes/github/test_issue_283.py @@ -1,9 +1,9 @@ # -*- coding: utf-8 -*- -import system_tests -import os.path +from system_tests import CaseMeta, path, check_no_ASAN_UBSAN_errors -class TestFirstPoC(metaclass=system_tests.CaseMeta): + +class TestFirstPoC(metaclass=CaseMeta): """ Regression test for the bug described in: https://github.com/Exiv2/exiv2/issues/283 @@ -16,16 +16,15 @@ class TestFirstPoC(metaclass=system_tests.CaseMeta): Here we want to also check that the two last lines of got_stderr have the expected_stderr """ - system_tests.check_no_ASAN_UBSAN_errors(self, i, command, got_stderr, expected_stderr) + check_no_ASAN_UBSAN_errors(self, i, command, got_stderr, expected_stderr) self.assertListEqual(expected_stderr.splitlines(), got_stderr.splitlines()[-2:]) - filename = os.path.join("$data_path", "pocIssue283.jpg") + filename = path("$data_path/pocIssue283.jpg") commands = ["$exiv2 $filename"] stdout = [""] stderr = [ - """$exiv2_exception_message """ + filename + """: + """$exiv2_exception_message $filename: $kerCorruptedMetadata """] compare_stderr = check_no_ASAN_UBSAN_errors retval = [1] - diff --git a/tests/bugfixes/github/test_pr_317.py b/tests/bugfixes/github/test_pr_317.py index 53265342..bdc6855a 100644 --- a/tests/bugfixes/github/test_pr_317.py +++ b/tests/bugfixes/github/test_pr_317.py @@ -1,17 +1,17 @@ # -*- coding: utf-8 -*- -import system_tests +from system_tests import CaseMeta, path -class CanonEOSM100(metaclass=system_tests.CaseMeta): +class CanonEOSM100(metaclass=CaseMeta): - filename = "$data_path/exiv2-pr317.exv" + filename = path("$data_path/exiv2-pr317.exv") commands = ["$exiv2 -pa --grep model/i $filename"] stdout = ["""Exif.Image.Model Ascii 15 Canon EOS M100 Exif.Canon.ModelID Long 1 EOS M100 Exif.Photo.LensModel Ascii 29 EF-M15-45mm f/3.5-6.3 IS STM """ - ] + ] stderr = [""] - retval = [0] + retval = [0] diff --git a/tests/bugfixes/redmine/test_issue_751.py b/tests/bugfixes/redmine/test_issue_751.py index 3170112c..f2366cbc 100644 --- a/tests/bugfixes/redmine/test_issue_751.py +++ b/tests/bugfixes/redmine/test_issue_751.py @@ -1,33 +1,34 @@ # -*- coding: utf-8 -*- -import system_tests -import os.path +from system_tests import DeleteFiles, CopyFiles, CaseMeta, path -@system_tests.DeleteFiles("$xmpname") -@system_tests.CopyFiles("$data_path/exiv2-empty.jpg") -class AdobeXmpNamespace(metaclass=system_tests.CaseMeta): +@DeleteFiles("$xmpname") +@CopyFiles("$data_path/exiv2-empty.jpg") +class AdobeXmpNamespace(metaclass=CaseMeta): url = "http://dev.exiv2.org/issues/751" - filename = os.path.join("$data_path", "exiv2-empty_copy.jpg") - xmpname = os.path.join("$data_path", "exiv2-empty_copy.xmp") + filename = path("$data_path/exiv2-empty_copy.jpg") + xmpname = path("$data_path/exiv2-empty_copy.xmp") commands = [ """$exiv2 -v -M"reg imageapp orig/" -M "set Xmp.imageapp.uuid abcd" $filename""", "$exiv2 -f -eX $filename", - "$cat $xmpname", """$exiv2 -v -M"reg imageapp dest/" -M "set Xmp.imageapp.uuid abcd" $filename""", "$exiv2 -f -eX $filename", - "$cat $xmpname", ] - stdout = [ - """File 1/1: $filename -Reg imageapp="orig/" -Set Xmp.imageapp.uuid "abcd" (XmpText) -""", - "", + def post_command_hook(self, i, command): + def read_xmpfile(): + with open(self.xmpname, "r", encoding='utf-8') as xmp: + return xmp.read(-1) + + if i == 2 or i == 4: + self.assertMultiLineEqual(self.xmp_packets[i//2 - 1], read_xmpfile()) + + + xmp_packets = [ """ @@ -37,11 +38,6 @@ Set Xmp.imageapp.uuid "abcd" (XmpText) """, - """File 1/1: $filename -Reg imageapp="dest/" -Set Xmp.imageapp.uuid "abcd" (XmpText) -""", - "", """ @@ -51,16 +47,26 @@ Set Xmp.imageapp.uuid "abcd" (XmpText) """ + ] + stdout = [ + """File 1/1: $filename +Reg imageapp="orig/" +Set Xmp.imageapp.uuid "abcd" (XmpText) +""", + "", + """File 1/1: $filename +Reg imageapp="dest/" +Set Xmp.imageapp.uuid "abcd" (XmpText) +""", + "", ] stderr = [ - "", "", "", """Warning: Updating namespace URI for imageapp from orig/ to dest/ """, """Warning: Updating namespace URI for imageapp from dest/ to orig/ """, - "" ] - retval = [0] * 6 + retval = [0] * 4 diff --git a/tests/bugfixes/redmine/test_issue_799.py b/tests/bugfixes/redmine/test_issue_799.py index 573421e3..c9d20a6b 100644 --- a/tests/bugfixes/redmine/test_issue_799.py +++ b/tests/bugfixes/redmine/test_issue_799.py @@ -1,19 +1,18 @@ # -*- coding: utf-8 -*- -import system_tests -import os +from system_tests import DeleteFiles, CopyFiles, CaseMeta, path -@system_tests.DeleteFiles("$xmpfile") -@system_tests.CopyFiles("$data_path/exiv2-empty.jpg") -class WrongXmpTypeForNestedXmpKeys(metaclass=system_tests.CaseMeta): +@DeleteFiles("$xmpfile") +@CopyFiles("$data_path/exiv2-empty.jpg") +class WrongXmpTypeForNestedXmpKeys(metaclass=CaseMeta): url = "http://dev.exiv2.org/issues/$num" num = 799 - cmdfile = os.path.join("$data_path", "bug$num.cmd") + cmdfile = path("$data_path/bug$num.cmd") - filename_common = os.path.join("$data_path", "exiv2-empty_copy") + filename_common = path("$data_path/exiv2-empty_copy") filename = "$filename_common.jpg" xmpfile = "$filename_common.xmp" @@ -21,50 +20,13 @@ class WrongXmpTypeForNestedXmpKeys(metaclass=system_tests.CaseMeta): "$exiv2 -v -m $cmdfile $filename", "$exiv2 -v -pa $filename", "$exiv2 -f -eX $filename", - "$cat $xmpfile", ] - stdout = [ - """File 1/1: $filename -Set Xmp.MP.RegionInfo/MPRI:Regions "" (XmpBag) -Set Xmp.MP.RegionInfo/MPRI:Regions[1]/MPReg:Rectangle "0.11, 0.22, 0.33, 0.44" (XmpText) -Set Xmp.MP.RegionInfo/MPRI:Regions[1]/MPReg:PersonDisplayName "Baby Gnu" (XmpText) -Set Xmp.mwg-rs.Regions/mwg-rs:AppliedToDimensions/stDim:w "1600" (XmpText) -Set Xmp.mwg-rs.Regions/mwg-rs:AppliedToDimensions/stDim:h "800" (XmpText) -Set Xmp.mwg-rs.Regions/mwg-rs:AppliedToDimensions/stDim:unit "pixel" (XmpText) -Set Xmp.mwg-rs.Regions/mwg-rs:RegionList "" (XmpBag) -Set Xmp.mwg-rs.Regions/mwg-rs:RegionList[1]/mwg-rs:Name "Baby Gnu" (XmpText) -Set Xmp.mwg-rs.Regions/mwg-rs:RegionList[1]/mwg-rs:Type "Face" (XmpText) -Set Xmp.mwg-rs.Regions/mwg-rs:RegionList[1]/mwg-rs:Area/stArea:x "0.275312" (XmpText) -Set Xmp.mwg-rs.Regions/mwg-rs:RegionList[1]/mwg-rs:Area/stArea:y "0.3775" (XmpText) -Set Xmp.mwg-rs.Regions/mwg-rs:RegionList[1]/mwg-rs:Area/stArea:w "0.164375" (XmpText) -Set Xmp.mwg-rs.Regions/mwg-rs:RegionList[1]/mwg-rs:Area/stArea:h "0.28125" (XmpText) -Set Xmp.mwg-rs.Regions/mwg-rs:RegionList[1]/mwg-rs:Area/stArea:unit "normalized" (XmpText) -""", - """File 1/1: $filename -Xmp.MP.RegionInfo XmpText 0 type="Struct" -Xmp.MP.RegionInfo/MPRI:Regions XmpText 0 type="Bag" -Xmp.MP.RegionInfo/MPRI:Regions[1] XmpText 0 type="Struct" -Xmp.MP.RegionInfo/MPRI:Regions[1]/MPReg:Rectangle XmpText 22 0.11, 0.22, 0.33, 0.44 -Xmp.MP.RegionInfo/MPRI:Regions[1]/MPReg:PersonDisplayName XmpText 8 Baby Gnu -Xmp.mwg-rs.Regions XmpText 0 type="Struct" -Xmp.mwg-rs.Regions/mwg-rs:AppliedToDimensions XmpText 0 type="Struct" -Xmp.mwg-rs.Regions/mwg-rs:AppliedToDimensions/stDim:w XmpText 4 1600 -Xmp.mwg-rs.Regions/mwg-rs:AppliedToDimensions/stDim:h XmpText 3 800 -Xmp.mwg-rs.Regions/mwg-rs:AppliedToDimensions/stDim:unit XmpText 5 pixel -Xmp.mwg-rs.Regions/mwg-rs:RegionList XmpText 0 type="Bag" -Xmp.mwg-rs.Regions/mwg-rs:RegionList[1] XmpText 0 type="Struct" -Xmp.mwg-rs.Regions/mwg-rs:RegionList[1]/mwg-rs:Name XmpText 8 Baby Gnu -Xmp.mwg-rs.Regions/mwg-rs:RegionList[1]/mwg-rs:Type XmpText 4 Face -Xmp.mwg-rs.Regions/mwg-rs:RegionList[1]/mwg-rs:Area XmpText 0 type="Struct" -Xmp.mwg-rs.Regions/mwg-rs:RegionList[1]/mwg-rs:Area/stArea:x XmpText 8 0.275312 -Xmp.mwg-rs.Regions/mwg-rs:RegionList[1]/mwg-rs:Area/stArea:y XmpText 6 0.3775 -Xmp.mwg-rs.Regions/mwg-rs:RegionList[1]/mwg-rs:Area/stArea:w XmpText 8 0.164375 -Xmp.mwg-rs.Regions/mwg-rs:RegionList[1]/mwg-rs:Area/stArea:h XmpText 7 0.28125 -Xmp.mwg-rs.Regions/mwg-rs:RegionList[1]/mwg-rs:Area/stArea:unit XmpText 10 normalized -""", - "", -""" + def post_tests_hook(self): + with open(self.xmpfile, "r", encoding='utf-8') as xmp_file: + self.assertMultiLineEqual(self.xmp_packet, xmp_file.read(-1)) + + xmp_packet = """ """ + + stdout = [ + """File 1/1: $filename +Set Xmp.MP.RegionInfo/MPRI:Regions "" (XmpBag) +Set Xmp.MP.RegionInfo/MPRI:Regions[1]/MPReg:Rectangle "0.11, 0.22, 0.33, 0.44" (XmpText) +Set Xmp.MP.RegionInfo/MPRI:Regions[1]/MPReg:PersonDisplayName "Baby Gnu" (XmpText) +Set Xmp.mwg-rs.Regions/mwg-rs:AppliedToDimensions/stDim:w "1600" (XmpText) +Set Xmp.mwg-rs.Regions/mwg-rs:AppliedToDimensions/stDim:h "800" (XmpText) +Set Xmp.mwg-rs.Regions/mwg-rs:AppliedToDimensions/stDim:unit "pixel" (XmpText) +Set Xmp.mwg-rs.Regions/mwg-rs:RegionList "" (XmpBag) +Set Xmp.mwg-rs.Regions/mwg-rs:RegionList[1]/mwg-rs:Name "Baby Gnu" (XmpText) +Set Xmp.mwg-rs.Regions/mwg-rs:RegionList[1]/mwg-rs:Type "Face" (XmpText) +Set Xmp.mwg-rs.Regions/mwg-rs:RegionList[1]/mwg-rs:Area/stArea:x "0.275312" (XmpText) +Set Xmp.mwg-rs.Regions/mwg-rs:RegionList[1]/mwg-rs:Area/stArea:y "0.3775" (XmpText) +Set Xmp.mwg-rs.Regions/mwg-rs:RegionList[1]/mwg-rs:Area/stArea:w "0.164375" (XmpText) +Set Xmp.mwg-rs.Regions/mwg-rs:RegionList[1]/mwg-rs:Area/stArea:h "0.28125" (XmpText) +Set Xmp.mwg-rs.Regions/mwg-rs:RegionList[1]/mwg-rs:Area/stArea:unit "normalized" (XmpText) +""", + """File 1/1: $filename +Xmp.MP.RegionInfo XmpText 0 type="Struct" +Xmp.MP.RegionInfo/MPRI:Regions XmpText 0 type="Bag" +Xmp.MP.RegionInfo/MPRI:Regions[1] XmpText 0 type="Struct" +Xmp.MP.RegionInfo/MPRI:Regions[1]/MPReg:Rectangle XmpText 22 0.11, 0.22, 0.33, 0.44 +Xmp.MP.RegionInfo/MPRI:Regions[1]/MPReg:PersonDisplayName XmpText 8 Baby Gnu +Xmp.mwg-rs.Regions XmpText 0 type="Struct" +Xmp.mwg-rs.Regions/mwg-rs:AppliedToDimensions XmpText 0 type="Struct" +Xmp.mwg-rs.Regions/mwg-rs:AppliedToDimensions/stDim:w XmpText 4 1600 +Xmp.mwg-rs.Regions/mwg-rs:AppliedToDimensions/stDim:h XmpText 3 800 +Xmp.mwg-rs.Regions/mwg-rs:AppliedToDimensions/stDim:unit XmpText 5 pixel +Xmp.mwg-rs.Regions/mwg-rs:RegionList XmpText 0 type="Bag" +Xmp.mwg-rs.Regions/mwg-rs:RegionList[1] XmpText 0 type="Struct" +Xmp.mwg-rs.Regions/mwg-rs:RegionList[1]/mwg-rs:Name XmpText 8 Baby Gnu +Xmp.mwg-rs.Regions/mwg-rs:RegionList[1]/mwg-rs:Type XmpText 4 Face +Xmp.mwg-rs.Regions/mwg-rs:RegionList[1]/mwg-rs:Area XmpText 0 type="Struct" +Xmp.mwg-rs.Regions/mwg-rs:RegionList[1]/mwg-rs:Area/stArea:x XmpText 8 0.275312 +Xmp.mwg-rs.Regions/mwg-rs:RegionList[1]/mwg-rs:Area/stArea:y XmpText 6 0.3775 +Xmp.mwg-rs.Regions/mwg-rs:RegionList[1]/mwg-rs:Area/stArea:w XmpText 8 0.164375 +Xmp.mwg-rs.Regions/mwg-rs:RegionList[1]/mwg-rs:Area/stArea:h XmpText 7 0.28125 +Xmp.mwg-rs.Regions/mwg-rs:RegionList[1]/mwg-rs:Area/stArea:unit XmpText 10 normalized +""", + "", ] stderr = [ @@ -117,6 +120,5 @@ Xmp.mwg-rs.Regions/mwg-rs:RegionList[1]/mwg-rs:Area/stArea:unit XmpText 10 n $filename: No IPTC data found in the file """, "", - "" ] - retval = [0] * 4 + retval = [0] * 3 diff --git a/tests/doc.md b/tests/doc.md index 5f9c887a..82780968 100644 --- a/tests/doc.md +++ b/tests/doc.md @@ -220,10 +220,14 @@ following a `$` with variables either defined in this class alongside (like configuration file. Please note that defining a variable with the same name as a variable in the suite's configuration file will result in an error (otherwise one of the variables would take precedence leading to unexpected results). The -substitution of values is performed using the template module from Python's -string library via `safe_substitute`. +variables defined in the test suites configuration file are also available in +the `system_tests` namespace. In the above example it would be therefore +possible to access `abort_exit_value` via `system_tests.abort_exit_value` +(please be aware that all values will be strings though). -In the above example the command would thus expand to: +The substitution of values is performed using the template module from Python's +string library via `safe_substitute`. In the above example the command would +thus expand to: ``` shell /path/to/the/dir/build/bin/binary -c /path/to/the/dir/conf/main.cfg -i invalid_input_file ``` @@ -232,14 +236,23 @@ and similarly for `stdout` and `stderr`. Once the substitution is performed, each command is run using Python's `subprocess` module, its output is compared to the values in `stdout` and `stderr` and its return value to `retval`. Please note that for portability -reasons the subprocess module is run with `shell=False`, thus shell expansions -or pipes will not work. +reasons the subprocess module is run with `shell=False`, thus shell expansions, +pipes and redirections into files will not work. As the test cases are implemented in Python, one can take full advantage of Python for the construction of the necessary lists. For example when 10 commands should be run and all return 0, one can write `retval = 10 * [0]` instead of writing 0 ten times. The same is of course possible for strings. + +### Multiline strings + +It is generally recommended to use Python's multiline strings (strings starting +and ending with three `"` instead of one `"`) for the elements of the `commands` +list, especially when the commands include `"` or escape sequences. Proper +escaping is tricky to get right in a platform independent way, as it depends on +the terminal that is used. Using multiline strings circumvents this issue. + There are however some peculiarities with multiline strings in Python. Normal strings start and end with a single `"` but multiline strings start with three `"`. Also, while the variable names must be indented, new lines in multiline @@ -267,6 +280,31 @@ as the indentation might have suggested. Also note that in this example the string will not be terminated with a newline character. To achieve that put the `"""` on the following line. +### Paths + +Some test cases require the specification of paths (e.g. to the location of test +cases). This can be problematic when working with the Windows operating system, +as it sometimes exhibits problems with `/` as path separators instead of `\`, +which cannot be used on every other platform. + +This can be circumvented by creating the paths via `os.path.join`, but that is +quite verbose. A slightly simpler alternative is the function `path` from +`system_tests` which converts all `/` inside your string into the platform's +default path separator: + +``` python +# -*- coding: utf-8 -*- + +from system_tests import CaseMeta, path + + +class AnInformativeName(metaclass=CaseMeta): + + filename = path("$path_to_test_files/invalid_input_file") + + # the rest of your test case +``` + ## Advanced test cases @@ -321,6 +359,7 @@ the test suite features a decorator which creates a copy of the supplied files and deletes the copies after the test ran. Example: + ``` python # -*- coding: utf-8 -*- @@ -358,10 +397,12 @@ cases, one can customize how stdout and stderr checked for errors. The `system_tests.Case` class has two public functions for the check of stdout & stderr: `compare_stdout` & `compare_stderr`. They have the following interface: + ``` python compare_stdout(self, i, command, got_stdout, expected_stdout) compare_stderr(self, i, command, got_stderr, expected_stderr) ``` + with the parameters: - i: index of the command in the `commands` list - command: a string of the actually invoked command @@ -382,6 +423,7 @@ errors from AddressSanitizer and undefined behavior sanitizer are not present in the obtained output to standard error **and nothing else**. This is useful for test cases where stderr is filled with warnings that are not worth being tracked by the test suite. It can be used in the following way: + ``` python # -*- coding: utf-8 -*- @@ -411,6 +453,7 @@ variable substitution using the test suite's configuration file. Unfortunately, it has to run in a class member function. The `setUp()` function can be used for this, as it is run before each test. For example like this: + ``` python class SomeName(metaclass=system_tests.CaseMeta): @@ -425,6 +468,7 @@ This example will work, as the test runner reads the data for `commands`, `stderr`, `stdout` and `retval` from the class instance. What however will not work is creating a new member in `setUp()` and trying to use it as a variable for expansion, like this: + ``` python class SomeName(metaclass=system_tests.CaseMeta): @@ -451,6 +495,53 @@ class SomeName(metaclass=system_tests.CaseMeta): will result in `another_string` being "foo" and not "bar". +### Hooks + +The `Case` class provides two hooks that are run after each command and after +all commands, respectively. The hook which is run after each successful command +has the following signature: + +``` Python +post_command_hook(self, i, command) +``` +with the following parameters: +- `i`: index of the command in the `commands` list +- `command`: a string of the actually invoked command + +The hook which is run after all test takes no parameters except `self`: + +``` Python +post_tests_hook(self) +``` + +By default, these hooks do nothing. They can be used to implement custom checks +after certain commands, e.g. to check if a file was created. Such a test can be +implemented as follows: + +``` Python +# -*- coding: utf-8 -*- + +import system_tests + + +class AnInformativeName(metaclass=system_tests.CaseMeta): + + filename = "input_file" + output = "out" + commands = ["$binary -o output -i $filename"] + retval = [0] + stdout = [""] + stderr = [""] + + output_contents = """Hello World! +""" + + def post_tests_hook(self): + with open(self.output, "r") as out: + self.assertMultiLineEqual(self.output_contents, out.read(-1)) +``` + + ### Possible pitfalls - Do not provide a custom `setUpClass()` function for the test diff --git a/tests/suite.conf b/tests/suite.conf index 502be7bb..38bff7f2 100644 --- a/tests/suite.conf +++ b/tests/suite.conf @@ -4,11 +4,9 @@ timeout: 1 [ENV] exiv2_path: EXIV2_PATH binary_extension: EXIV2_EXT -cat: EXIV2_CAT [ENV fallback] exiv2_path: ../build/bin -cat: cat [paths] exiv2: ${ENV:exiv2_path}/exiv2${ENV:binary_extension} @@ -29,4 +27,3 @@ addition_overflow_message: Overflow in addition exiv2_exception_message: Exiv2 exception in print action for file exiv2_overflow_exception_message: std::overflow_error exception in print action for file exception_in_extract: Exiv2 exception in extract action for file -cat: ${ENV:cat} diff --git a/tests/system_tests.py b/tests/system_tests.py index 8dc30d01..e6d61f9c 100644 --- a/tests/system_tests.py +++ b/tests/system_tests.py @@ -177,6 +177,12 @@ def configure_suite(config_file): ) _parameters[key] = abs_path + for key in _parameters: + if key in globals(): + raise ValueError("Variable name {!s} already used.") + + globals()[key] = _parameters[key] + class FileDecoratorBase(object): """ @@ -468,6 +474,24 @@ class DeleteFiles(FileDecoratorBase): return expanded_file_name +def path(path_string): + r""" + Converts a path which uses ``/`` as a separator into a path which uses the + path separator of the current operating system. + + Example + ------- + + >>> import platform + >>> sep = "\\" if platform.system() == "Windows" else "/" + >>> path("a/b") == "a" + sep + "b" + True + >>> path("a/more/complex/path") == sep.join(['a', 'more', 'complex', 'path']) + True + """ + return os.path.join(*path_string.split('/')) + + def test_run(self): """ This function reads in the members commands, retval, stdout, stderr and runs @@ -560,6 +584,10 @@ def test_run(self): retval, proc.returncode, msg="Return value does not match" ) + self.post_command_hook(i, command) + + self.post_tests_hook() + class Case(unittest.TestCase): """ @@ -622,6 +650,34 @@ class Case(unittest.TestCase): return string.Template(str(unexpanded_string))\ .safe_substitute(**self.variable_dict) + def post_command_hook(self, i, command): + """ Function that is run after the successful execution of one command. + + It is invoked with the following parameters: + i - the index of the current command that is run in self.commands + command - the command that was run + + It should return nothing. + + This function can be overridden to perform additional checks after the + command ran, for instance it can check whether files were created. + + The default implementation does nothing. + """ + pass + + def post_tests_hook(self): + """ + Function that is run after the successful execution all commands. It + should return nothing. + + This function can be overridden to run additional checks that only make + sense after all commands ran. + + The default implementation does nothing. + """ + pass + class CaseMeta(type): """ System tests generation metaclass.