From d8819478fca7e4025ba80d567a89e2fed133561a Mon Sep 17 00:00:00 2001 From: "biao716.wang" Date: Sat, 21 Dec 2019 02:16:33 +0900 Subject: [PATCH] merge code from devel to master Change-Id: I0ae7d537d384800e595794afe35303da9e98a27b Signed-off-by: biao716.wang --- MANIFEST.in | 4 + README.md | 121 ++++++ debian/changelog | 73 ++++ debian/compat | 1 + debian/control | 27 ++ debian/copyright | 7 + debian/itest-core.install | 7 + debian/nosexcase.install | 1 + debian/rules | 10 + debian/spm.install | 4 + etc/unimportant.json | 27 ++ imgdiff/__init__.py | 1 + imgdiff/cleanup.py | 47 +++ imgdiff/diff.py | 93 +++++ imgdiff/info.py | 184 ++++++++++ imgdiff/trivial.py | 128 +++++++ imgdiff/unified.py | 298 +++++++++++++++ imgdiff/unpack.py | 176 +++++++++ itest/__init__.py | 1 + itest/__main__.py | 4 + itest/case.py | 407 +++++++++++++++++++++ itest/conf/__init__.py | 50 +++ itest/conf/global_settings.py | 55 +++ itest/fixture.py | 92 +++++ itest/loader.py | 195 ++++++++++ itest/main.py | 132 +++++++ itest/result.py | 99 +++++ itest/utils.py | 69 ++++ itest/xmlparser.py | 130 +++++++ nosexcase/__init__.py | 0 nosexcase/xcase.py | 234 ++++++++++++ packaging/.test-requires | 13 + packaging/Makefile | 19 + packaging/itest-core.changes | 406 ++++++++++++++++++++ packaging/itest-core.spec | 92 +++++ requirements.txt | 4 + scripts/imgdiff | 103 ++++++ scripts/runtest | 8 + scripts/runtest_pty | 46 +++ scripts/spm | 5 + setup.py | 30 ++ spm/__init__.py | 1 + spm/cli.py | 146 ++++++++ spm/conf.py | 11 + spm/core.py | 196 ++++++++++ spm/spm.yml | 50 +++ spm/templates/report.html | 44 +++ test-requirements.txt | 2 + tests/__init__.py | 0 tests/data/cases/cdata.xml | 12 + tests/data/cases/content_fixture.xml | 10 + tests/data/cases/qa.xml | 12 + tests/data/cases/setup.xml | 9 + tests/data/cases/setup_failed.xml | 6 + tests/data/cases/simple.xml | 9 + tests/data/cases/simple_false.xml | 4 + tests/data/cases/teardown.xml | 10 + tests/data/cases/unicode.xml | 7 + tests/data/cases/unicode_false.xml | 8 + tests/data/cases/vars.xml | 17 + tests/data/cases/vars_in_setup.xml | 13 + .../data/sample_project/cases/copy_dir_fixture.xml | 12 + .../cases/copy_dir_with_tailing_slash.xml | 12 + tests/data/sample_project/cases/copy_fixture.xml | 9 + .../cases/copy_part_of_dir_fixture.xml | 10 + .../data/sample_project/cases/template_fixture.xml | 13 + tests/data/sample_project/fixtures/dir1/a | 0 tests/data/sample_project/fixtures/dir1/dir2/b | 0 tests/data/sample_project/fixtures/empty | 0 tests/data/sample_project/fixtures/template | 5 + tests/data/sample_project/fixtures/template_base | 2 + tests/data/sample_project/settings.py | 0 tests/functional/__init__.py | 0 tests/functional/base.py | 75 ++++ tests/functional/test_in_project.py | 29 ++ tests/functional/test_setup_teardown.py | 28 ++ tests/functional/test_simple.py | 36 ++ tests/functional/test_xunit.py | 34 ++ tests/unit/__init__.py | 0 tests/unit/test_xmlparser.py | 77 ++++ tox.ini | 22 ++ 81 files changed, 4334 insertions(+) create mode 100644 MANIFEST.in create mode 100644 README.md create mode 100644 debian/changelog create mode 100644 debian/compat create mode 100644 debian/control create mode 100644 debian/copyright create mode 100644 debian/itest-core.install create mode 100644 debian/nosexcase.install create mode 100644 debian/rules create mode 100644 debian/spm.install create mode 100644 etc/unimportant.json create mode 100644 imgdiff/__init__.py create mode 100644 imgdiff/cleanup.py create mode 100644 imgdiff/diff.py create mode 100644 imgdiff/info.py create mode 100644 imgdiff/trivial.py create mode 100644 imgdiff/unified.py create mode 100644 imgdiff/unpack.py create mode 100644 itest/__init__.py create mode 100644 itest/__main__.py create mode 100644 itest/case.py create mode 100644 itest/conf/__init__.py create mode 100644 itest/conf/global_settings.py create mode 100644 itest/fixture.py create mode 100644 itest/loader.py create mode 100644 itest/main.py create mode 100644 itest/result.py create mode 100644 itest/utils.py create mode 100644 itest/xmlparser.py create mode 100644 nosexcase/__init__.py create mode 100644 nosexcase/xcase.py create mode 100644 packaging/.test-requires create mode 100644 packaging/Makefile create mode 100644 packaging/itest-core.changes create mode 100644 packaging/itest-core.spec create mode 100644 requirements.txt create mode 100644 scripts/imgdiff create mode 100644 scripts/runtest create mode 100644 scripts/runtest_pty create mode 100644 scripts/spm create mode 100644 setup.py create mode 100644 spm/__init__.py create mode 100644 spm/cli.py create mode 100644 spm/conf.py create mode 100644 spm/core.py create mode 100644 spm/spm.yml create mode 100644 spm/templates/report.html create mode 100644 test-requirements.txt create mode 100644 tests/__init__.py create mode 100644 tests/data/cases/cdata.xml create mode 100644 tests/data/cases/content_fixture.xml create mode 100644 tests/data/cases/qa.xml create mode 100644 tests/data/cases/setup.xml create mode 100644 tests/data/cases/setup_failed.xml create mode 100644 tests/data/cases/simple.xml create mode 100644 tests/data/cases/simple_false.xml create mode 100644 tests/data/cases/teardown.xml create mode 100644 tests/data/cases/unicode.xml create mode 100644 tests/data/cases/unicode_false.xml create mode 100644 tests/data/cases/vars.xml create mode 100644 tests/data/cases/vars_in_setup.xml create mode 100644 tests/data/sample_project/cases/copy_dir_fixture.xml create mode 100644 tests/data/sample_project/cases/copy_dir_with_tailing_slash.xml create mode 100644 tests/data/sample_project/cases/copy_fixture.xml create mode 100644 tests/data/sample_project/cases/copy_part_of_dir_fixture.xml create mode 100644 tests/data/sample_project/cases/template_fixture.xml create mode 100644 tests/data/sample_project/fixtures/dir1/a create mode 100644 tests/data/sample_project/fixtures/dir1/dir2/b create mode 100644 tests/data/sample_project/fixtures/empty create mode 100644 tests/data/sample_project/fixtures/template create mode 100644 tests/data/sample_project/fixtures/template_base create mode 100644 tests/data/sample_project/settings.py create mode 100644 tests/functional/__init__.py create mode 100644 tests/functional/base.py create mode 100644 tests/functional/test_in_project.py create mode 100644 tests/functional/test_setup_teardown.py create mode 100644 tests/functional/test_simple.py create mode 100644 tests/functional/test_xunit.py create mode 100644 tests/unit/__init__.py create mode 100644 tests/unit/test_xmlparser.py create mode 100644 tox.ini diff --git a/MANIFEST.in b/MANIFEST.in new file mode 100644 index 0000000..2586713 --- /dev/null +++ b/MANIFEST.in @@ -0,0 +1,4 @@ +include tests/cases/*.xml +recursive-include tests/tproj *.py *.xml +prune tests/tproj/fixtures +include spm/templates/*.html diff --git a/README.md b/README.md new file mode 100644 index 0000000..bcddc4b --- /dev/null +++ b/README.md @@ -0,0 +1,121 @@ +install +======= +install itest +------------- +sudo python setup.py install + +===== + +prepare for test environment +---------------------------- +# itest will use this password to run sudo +export ITEST_SUDO_PASSWORD +export http_proxy, https_proxy, no_proxy + +running gbs test cases +---------------------- +1. run all test cases + $ runtest + +2. print detail message when running test cases + $ runtest -v + +3. print log when runing test cases, useful for debuging + $ runtest -vv data/auto/changelog/test_changelog_since.gbs + +4. run test suites + $ runtest chroot export + +5. run single test case and test suites + $ runtest data/auto/build/test_build_commit_ia32.gbs import submit + +6. check test results + $ runtest chroot submit changelog auto/build/test_build_help.gbs +........................ + +Ran 24 tests in 0h 00 min 10s + +OK + +Details +--------------------------------- +Component Passed Failed +build 1 0 +remotebuild 0 0 +changelog 7 0 +chroot 2 0 +import 0 0 +export 0 0 +submit 14 0 +conf 0 0 + + +Syntax of case +============== + +\_\_steps\_\_ +------------- + +*steps* is the core section of a case. It consist of command lines and +comments. A lines starting with '>' is called command line. Others are all +treated as comments. Comments are only for reading, they will be ignored in +running. + +Each command line runs one by one in the same order as they occur in case. If +any command exit with nonzero, the whole case will exit immediately and is +treated as failed. The only condition that a case pass is when the last command +exit with code 0. + +For example: + + > echo 1 + > false | echo 2 + > echo 3 + +"echo 3" never run, it fail in the second line. + +When you want to assert a command will fail, add "!" before it, and enclose with +parenthesis(subshell syntax). + + > echo 1 + > (! false | echo 2) + > echo 3 + +This case pass, because the designer assert that the second will fail via "!". +Parenthesis are required, which makes the whole line a subshell and the subshell +exit with 0. When parenthesis are missing, this case will fail in the second +line(same as the above example). + +NOTE: Itest use "bash -xe" and "set -o pipefall" to implement this, please refer +bash manual for more detail. + +\_\_setup\_\_ +------------- +This is an optional section which can be used to set up environment need +by following steps. Its content should be valid shell code. + +Variables declared in this section can also be used in *steps* and *teardown* +sections. In constract, variables defined in *steps* can't be seen in the +scope of *teardown*, so if there are common variables, they should be set +in this section. + +For example: + + __vars__: + temp_project_name=test_$(date +%Y%m%d)_$RANDOM + touch another_temp_file + + __steps__: + > gbs remotebuild -T $temp_project_name + + __teardown__: + rm -f another_temp_file + osc delete $temp_project + +\_\_teardown\_\_ +---------------- +This is also an optional section which can be used to clean up environment +after *steps* finish. Its content should be valid shell code. + +Whatever *steps* failed or successed, this section gaurantee to be run. +Result of this section doesn't affect result of the case. diff --git a/debian/changelog b/debian/changelog new file mode 100644 index 0000000..95f50c0 --- /dev/null +++ b/debian/changelog @@ -0,0 +1,73 @@ +itest-core (1.7) unstable; urgency=high + * Upgrade to itest1.7, which contains the following bug fixing & features: + * #1125: Image diff tool + * #1430: Make compatible for pexpect-2.5 + + -- Huang, Hao Fri, 29 Nov 2013 12:02:00 +0800 + +itest-core (1.6) unstable; urgency=high + * Upgrade to itest1.6, which contains the following bug fixing & features: + * #1369: Raise Timeout error if no output for a while. + * Print log to sys.stdout instead of /dev/fd/1 + * #1099: ctrl-c can't break runtest + * #1086: Fix dependency issue of itest-core on centos + * #1128: "__conditions__" can not work with "distwhitelist: opensuse / distblacklist: opensuse12.1-i586". + * #961: support selecting platforms in cases + * #1065: support JUnit XML format of report + + -- Huang, Hao Fri, 29 Nov 2013 12:00:00 +0800 + +itest-core (1.5) unstable; urgency=high + * Upgrade to itest1.5, which contains the following bug fixing & features: + * #942: Mark case as failure for a period of time. + * #943: Retry if particular error occurs in case + + -- Huang, Hao Fri, 31 May 2013 12:00:00 +0800 + +itest-core (1.4) unstable; urgency=high + * Upgrade to itest1.4, which contains the following bug fixing & features: + * #666: itest installion + * #827: Run relative cases according to a gbs or gbp patch + * #824: Change search order of env path + * #823: Display time cost for each test + * #804: Show tips if copyed directory like 'fixtures' is very large + * #870: Itest exit without printing "steps finish" + * #860: Report URL is too long + * #800: Itest could not print information which include chinese characters or signs + + -- Huang, Hao Fri, 26 Apr 2013 12:00:00 +0800 + +itest-core (1.3) unstable; urgency=high + * Upgrade to itest1.3, which contains the following bug fixing & features: + * Redesign test report. Add failed summary report + * Add datetime info to log file when cases begin and end + * Set timezone in test env + * Add itest dependencies + + -- Junchun Guan Thu, 24 Jan 2013 11:45:08 +0800 + +itest-core (1.2) unstable; urgency=high + * Upgrade to itest1.2, which contains the following bug fixing & features: + * Exit with nonzero value when any case failed + * Reduce output log when run auto sync + * Support setup and teardown section in test case + * Support coverage report + + -- Junchun Guan Thu, 24 Jan 2013 11:45:08 +0800 + +itest-core (1.1) unstable; urgency=high + * Upgrade to itest1.1, which contains the following bug fixing & features: + * Refactor HTML report and deploy to web server + * Support html template using python-bottle + * Anto upload html report to web server + * Support mic function test + + -- Junchun Guan Thu, 24 Jan 2013 11:45:08 +0800 + +itest-core (1.0) unstable; urgency=high + * Init release + * Support gbs functional test + * Generate local report and simple html report + + -- Junchun Guan Tue, 6 Nov 2012 09:54:46 +0800 + diff --git a/debian/compat b/debian/compat new file mode 100644 index 0000000..7f8f011 --- /dev/null +++ b/debian/compat @@ -0,0 +1 @@ +7 diff --git a/debian/control b/debian/control new file mode 100644 index 0000000..8ef053a --- /dev/null +++ b/debian/control @@ -0,0 +1,27 @@ +Source: itest-core +Section: devel +Priority: extra +Maintainer: Junchun Guan +Build-Depends: debhelper, python (>= 2.6), python-support, python-setuptools +Standards-Version: 3.8.0 +X-Python-Version: >= 2.6 +Homepage: http://www.tizen.org + +Package: itest-core +Architecture: all +Depends: ${misc:Depends}, ${python:Depends}, + python-pexpect, python-coverage, python-jinja2, python-unittest2, spm +Description: functional test framework for gbs and mic + +Package: spm +Architecture: all +Depends: ${misc:Depends}, ${python:Depends}, + python-jinja2, python-yaml +Description: Smart package management tool on Linux + A wrapper of yum, apt-get, zypper command. Support Redhat, Debian, SuSE + +Package: nosexcase +Architecture: all +Depends: ${misc:Depends}, ${python:Depends}, + itest-core, python-nose +Description: A nose plugin that supports running test cases defined in XML format diff --git a/debian/copyright b/debian/copyright new file mode 100644 index 0000000..1c2c739 --- /dev/null +++ b/debian/copyright @@ -0,0 +1,7 @@ +Upstream Authors: + + Intel Inc. + +Copyright: + + Copyright (C) 2012 Intel Inc. diff --git a/debian/itest-core.install b/debian/itest-core.install new file mode 100644 index 0000000..0ff6eb9 --- /dev/null +++ b/debian/itest-core.install @@ -0,0 +1,7 @@ +usr/lib/python*/*packages/itest/*.py +usr/lib/python*/*packages/itest/conf/*.py +usr/lib/python*/*packages/imgdiff/*.py +usr/lib/python*/*packages/itest-*.egg-info +usr/bin/runtest +usr/bin/runtest_pty +usr/bin/imgdiff diff --git a/debian/nosexcase.install b/debian/nosexcase.install new file mode 100644 index 0000000..a84efef --- /dev/null +++ b/debian/nosexcase.install @@ -0,0 +1 @@ +usr/lib/python*/*packages/nosexcase/*.py diff --git a/debian/rules b/debian/rules new file mode 100644 index 0000000..37dbe9d --- /dev/null +++ b/debian/rules @@ -0,0 +1,10 @@ +#!/usr/bin/make -f + +%: + dh $@ + +override_dh_auto_install: + python setup.py install --root=debian/tmp --prefix=/usr + +override_dh_auto_test: + @echo 'Skipping autotests' diff --git a/debian/spm.install b/debian/spm.install new file mode 100644 index 0000000..4f48cb4 --- /dev/null +++ b/debian/spm.install @@ -0,0 +1,4 @@ +usr/lib/python*/*packages/spm/*.py +usr/lib/python*/*packages/spm/templates/*.html +usr/bin/spm +etc/spm.yml diff --git a/etc/unimportant.json b/etc/unimportant.json new file mode 100644 index 0000000..370130f --- /dev/null +++ b/etc/unimportant.json @@ -0,0 +1,27 @@ +{ + "ignoreFiles": [ + "*.log", + "*.cache", + "machine-id", + "*/zypp/AnonymousUniqueId", + "/var/cache/*", + "/etc/shadow*", + "/var/lib/rpm/*", + "/boot/extlinux/ldlinux.sys" + ,"/dev/*" + ,"/var/lib/random-seed" + ,"/opt/usr/dbspace/*" + ,"/opt/dbspace/*" + ,"/boot/vmlinuz" + ], + "ignoreLines": [{ + "Files": ["/etc/machine-id"], + "Lines": ["UUID=.*"] + }, { + "Files": ["extlinux.conf"], + "Lines": ["^label .*", "^[ \\t]*linux .*", "^[ \t]*append.*root=.*"] + }, { + "Files": ["/etc/os-release", "/etc/system-release", "/etc/tizen-release"], + "Lines": ["^BUILD_ID=.*"] + }] +} diff --git a/imgdiff/__init__.py b/imgdiff/__init__.py new file mode 100644 index 0000000..4016a4e --- /dev/null +++ b/imgdiff/__init__.py @@ -0,0 +1 @@ +"Module imgdiff" diff --git a/imgdiff/cleanup.py b/imgdiff/cleanup.py new file mode 100644 index 0000000..4296954 --- /dev/null +++ b/imgdiff/cleanup.py @@ -0,0 +1,47 @@ +#!/usr/bin/env python +'''This script will cleanup resources allocated by unpack_image.py +''' +import os +import sys +from subprocess import call + + +def umount(path): + '''Umount a mount point at path + ''' + if not os.path.isdir(path) or not os.path.ismount(path): + return + + cmd = ['sudo', 'umount', '-l', path] + print "Umounting", path, "..." + return call(cmd) + + +def loopdel(val): + '''Release loop dev at val + ''' + devloop, filename = val.split(':', 1) + print "Releasing %s(%s)" % (devloop, filename), "..." + + +def main(): + '''Main''' + # cleanup mountpoint in reverse order + lines = sys.stdin.readlines() + lines.sort(reverse=1) + + handler = { + 'mountpoint': umount, + 'loopdev': loopdel, + } + + for line in lines: + key, val = line.strip().split(':', 1) + if key in handler: + handler[key](val) + else: + print >> sys.stderr, "Have no idea to release:", line, + + +if __name__ == '__main__': + main() diff --git a/imgdiff/diff.py b/imgdiff/diff.py new file mode 100644 index 0000000..b677cab --- /dev/null +++ b/imgdiff/diff.py @@ -0,0 +1,93 @@ +#!/usr/bin/env python +'''This script parse diff result from stdin filter out trivial differences +defined in config file and print out the left +''' +import re +import os +import sys +import argparse +from itertools import imap, ifilter + +from imgdiff.trivial import Conf, Rules +from imgdiff.unified import parse + + +PATTERN_PREFIX = re.compile(r'.*?img[12](%(sep)sroot)?(%(sep)s.*)' % + {'sep': os.path.sep}) + + +def strip_prefix(filename): + '''Strip prefix added by imgdiff script. + For example: + img1/partition_table.txt -> partition_table.txt + img1/root/tmp/file -> /tmp/file + ''' + match = PATTERN_PREFIX.match(filename) + return match.group(2) if match else filename + + +def fix_filename(onefile): + '''Fix filename''' + onefile['filename'] = strip_prefix(onefile['filename']) + return onefile + + +class Mark(object): + '''Mark one file and its content as nontrivial + ''' + def __init__(self, conf_filename): + self.rules = Rules(Conf.load(conf_filename)) + + def __call__(self, onefile): + self.rules.check_and_mark(onefile) + return onefile + + +def nontrivial(onefile): + '''Filter out nontrivial''' + return not('ignore' in onefile and onefile['ignore']) + + +def parse_and_mark(stream, conf_filename=None): + ''' + Parse diff from stream and mark nontrivial defined + by conf_filename + ''' + stream = parse(stream) + stream = imap(fix_filename, stream) + + if conf_filename: + mark_trivial = Mark(conf_filename) + stream = imap(mark_trivial, stream) + return stream + + +def parse_args(): + '''parse arguments''' + parser = argparse.ArgumentParser() + parser.add_argument('-c', '--conf-filename', + help='conf for defining unimportant difference') + return parser.parse_args() + + +def main(): + "Main" + args = parse_args() + stream = parse_and_mark(sys.stdin, args.conf_filename) + stream = ifilter(nontrivial, stream) + cnt = 0 + for each in stream: + print each + cnt += 1 + return cnt + + +if __name__ == '__main__': + try: + sys.exit(main()) + except Exception: + # normally python exit 1 for exception + # we change it to 255 to avoid confusion with 1 difference + import traceback + traceback.print_exc() + sys.exit(255) diff --git a/imgdiff/info.py b/imgdiff/info.py new file mode 100644 index 0000000..cbc1e06 --- /dev/null +++ b/imgdiff/info.py @@ -0,0 +1,184 @@ +'''Get image information, such as partition table, block id fstab etc. +''' +import re +import os +import sys +from subprocess import check_output, CalledProcessError +from itertools import ifilter, islice, chain + + +def parted(img): + "Parse output of parted command" + column = re.compile(r'([A-Z][a-z\s]*?)((?=[A-Z])|$)') + + def parse(output): + '''Example: + Model: (file) + Disk /home/xxx/tmp/images/small.raw: 839909376B + Sector size (logical/physical): 512B/512B + Partition Table: msdos + + Number Start End Size Type File system Flags + 1 1048576B 34602495B 33553920B primary ext4 boot + 2 34603008B 839909375B 805306368B primary ext4 + ''' + state = 'header' + headers = {} + parts = [] + for line in output.splitlines(): + if state == 'header': + if line == '': + state = 'title' + else: + key, val = line.split(':', 1) + headers[key.lower()] = val.strip() + elif state == 'title': + titles = [] + start = 0 + for col, _ in column.findall(line): + title = col.rstrip().lower() + getter = slice(start, start+len(col)) + titles.append((title, getter)) + start += len(col) + state = 'parts' + elif line.strip(): + part = dict([(title, line[getter].strip()) + for title, getter in titles]) + for title in ('start',): # start, end, size + part[title] = int(part[title][:-1]) # remove tailing "B" + part['number'] = int(part['number']) + parts.append(part) + return parts + + cmd = ['parted', img, '-s', 'unit B print'] + output = check_output(cmd) + return parse(output) + + +def blkid(img, offset_in_bytes): + "Parse output of blkid command" + def parse(output): + '''Example: + sdb.raw: LABEL="boot" UUID="2995b233-ff79-4719-806d-d7f42b34a133" \ + VERSION="1.0" TYPE="ext4" USAGE="filesystem" + ''' + output = output.splitlines()[0].split(': ', 1)[1] + info = {} + for item in output.split(): + key, val = item.split('=', 1) + info[key.lower()] = val[1:-1] # remove double quotes + return info + + cmd = ['blkid', '-p', '-O', str(offset_in_bytes), '-o', 'full', img] + output = check_output(cmd) + return parse(output) + + +def gdisk(img): + "Parse output of gdisk" + cmd = ['gdisk', '-l', img] + + def parse(output): + """Example: + GPT fdisk (gdisk) version 0.8.1 + + Partition table scan: + MBR: protective + BSD: not present + APM: not present + GPT: present + + Found valid GPT with protective MBR; using GPT. + Disk tizen_20131115.3_ivi-efi-i586-sdb.raw: 7809058 sectors, 3.7 GiB + Logical sector size: 512 bytes + Disk identifier (GUID): 4A6D60CE-C42D-4A81-B82B-120624CE867E + Partition table holds up to 128 entries + First usable sector is 34, last usable sector is 7809024 + Partitions will be aligned on 2048-sector boundaries + Total free space is 2049 sectors (1.0 MiB) + + Number Start (sector) End (sector) Size Code Name + 1 2048 133085 64.0 MiB EF00 primary + 2 133120 7809023 3.7 GiB 0700 primary + """ + lines = output.splitlines() + + line = [i for i in lines if i.startswith('Logical sector size:')] + if not line: + raise Exception("Can't find sector size from gdisk output:%s:%s" + % (" ".join(cmd), output)) + size = int(line[0].split(':', 1)[1].strip().split()[0]) + + parts = [] + lines.reverse() + for line in lines: + if not line.startswith(' ') or \ + not line.lstrip().split()[0].isdigit(): + break + number, start, _ = line.lstrip().split(None, 2) + parts.append(dict(number=int(number), start=int(start)*size)) + return parts + + output = check_output(cmd) + return parse(output) + + +class FSTab(dict): + ''' + A dict representing fstab file. + Key is , corresponding value is its whole entry + ''' + def __init__(self, filename): + with open(filename) as stream: + output = stream.read() + data = self._parse(output) + super(FSTab, self).__init__(data) + + FS = re.compile(r'/dev/sd[a-z](\d+)|UUID=(.*)') + + def _parse(self, output): + '''Parse fstab in this format: + + ''' + mountpoints = {} + for line in output.splitlines(): + fstype, mountpoint, _ = line.split(None, 2) + mres = self.FS.match(fstype) + if not mres: + continue + + number, uuid = mres.group(1), mres.group(2) + if number: + item = {"number": number} + else: + item = {"uuid": uuid} + item["entry"] = line + mountpoints[mountpoint] = item + return mountpoints + + @classmethod + def guess(cls, paths): + '''Guess fstab location from all partitions of the image + ''' + guess1 = (os.path.join(path, 'etc', 'fstab') for path in paths) + guess2 = (os.path.join(path, 'fstab') for path in paths) + guesses = chain(guess1, guess2) + exists = ifilter(os.path.exists, guesses) + one = list(islice(exists, 1)) + return cls(one[0]) if one else None + + +def get_partition_info(img): + '''Get partition table information of image''' + try: + parts = parted(img) + except CalledProcessError as err: + print >> sys.stderr, err + # Sometimes parted could failed with error + # like this, then we try gdisk. + # "Error during translation: Invalid or incomplete + # multibyte or wide character" + parts = gdisk(img) + for part in parts: + part['blkid'] = blkid(img, part['start']) + return parts diff --git a/imgdiff/trivial.py b/imgdiff/trivial.py new file mode 100644 index 0000000..6517cbc --- /dev/null +++ b/imgdiff/trivial.py @@ -0,0 +1,128 @@ +"""This module provides classes to deal with +unimportant difference in diff result. +""" +import os +import re +import json +import fnmatch + + +class Conf(dict): + """ + Configuration defining unimportant difference + """ + + @classmethod + def load(cls, filename): + "Load config from file" + with open(filename) as reader: + txt = reader.read() + txt.replace(os.linesep, '') + data = json.loads(txt) + return cls(data) + + +class Rules(object): + """ + Unimportant rules + """ + def __init__(self, conf): + self._rules = self._compile(conf) + + def check_and_mark(self, item): + """Check if there are unimportant differences in item. + Mark them as ignore + """ + for matcher, rule in self._rules: + if matcher(item['filename']): + rule(item) + break + + @staticmethod + def _compile(conf): + """Compile config item to matching rules + """ + def new_matcher(pattern): + """Supported file name pattern like: + *.log + partition_tab.txt + /tmp/a.txt + /dev/ + some/file.txt + """ + if pattern.endswith(os.path.sep): # direcotry name + pattern = pattern + '*' + + bname = os.path.basename(pattern) + if bname == pattern: # only basename, ignore dirname + def matcher(filename): + "Matcher" + return fnmatch.fnmatch(os.path.basename(filename), pattern) + else: + def matcher(filename): + "Matcher" + return fnmatch.fnmatch(filename, pattern) + + matcher.__docstring__ = 'Match filename with pattern %s' % pattern + return matcher + + rules = [] + for pat in conf.get('ignoreFiles', []): + matcher = new_matcher(pat) + rules.append((matcher, ignore_file)) + + for entry in conf.get('ignoreLines', []): + files = entry['Files'] + lines = entry['Lines'] + if isinstance(files, basestring): + files = [files] + if isinstance(lines, basestring): + lines = [lines] + ignore = IgnoreLines(lines) + for pat in files: + matcher = new_matcher(pat) + rules.append((matcher, ignore)) + + return rules + + +def ignore_file(onefile): + """Mark whole file as trivial difference + """ + onefile['ignore'] = True + + +class IgnoreLines(object): + """Mark certain lines in a file as trivial + differences according to given patterns + """ + def __init__(self, patterns): + self.patterns = [re.compile(p) for p in patterns] + + def is_unimportant(self, line): + "Is this line trivial" + for pat in self.patterns: + if pat.match(line['text']): + return True + + def __call__(self, onefile): + "Mark lines as trivial" + if onefile['type'] != 'onefilediff': + return + + def should_ignore(line): + "Is this line trivial" + if line['type'] in ('insert', 'delete'): + return self.is_unimportant(line) + # else: context, no_newline_at_eof + return True + + all_ignored = True + for section in onefile['sections']: + for line in section['hunks']: + line['ignore'] = should_ignore(line) + all_ignored = all_ignored and line['ignore'] + + # if all lines are unimportant then the whole file is unimportant + if all_ignored: + onefile['ignore'] = True diff --git a/imgdiff/unified.py b/imgdiff/unified.py new file mode 100644 index 0000000..0abba1a --- /dev/null +++ b/imgdiff/unified.py @@ -0,0 +1,298 @@ +'''This module contains parser which understand unified diff result''' +import os +import re +import sys + + +class LookAhead(object): + '''Iterable but can also push back''' + def __init__(self, iterable): + self.iterable = iterable + self.stack = [] + + def push_back(self, token): + "push token back to this iterable" + self.stack.append(token) + + def next(self): + "next token" + if self.stack: + return self.stack.pop() + return self.iterable.next() + + def __iter__(self): + "iterable" + return self + + +class MessageParser(object): + '''Message in diff result. This class is a abstract class. All its + children should implement its interface: + + Attr: self.PATTERN + Method: parse(self, line, match) + ''' + + # it should be implemented by subclasses + PATTERN = None + + def parse(self, line, mres): + "it should be implemented by subclass" + raise NotImplementedError + + def match(self, line): + '''determine whether the line is a message''' + mres = self.PATTERN.match(line) + return self.parse(line, mres) if mres else None + + +class OnlyInOneSide(MessageParser): + '''Message like this: + Only in img2/root/home/tizen: .bash_profile + ''' + + PATTERN = re.compile(r'Only in (.*?): (.*)') + + def parse(self, line, match): + '''Return the concrete message''' + side = 'left' if match.group(1).startswith('img1/') else 'right' + filename = os.path.join(match.group(1), match.group(2)) + return { + 'type': 'message', + 'filetype': 'Only in %s side' % side, + 'message': line[:-1], + 'filename': filename, + 'side': side, + } + + +class SpecialFile(MessageParser): + '''Message like this: + File img1/partx/p2/dev/full is a character special file while file + img2/partx/p2/dev/full is a character special file + ''' + + PATTERN = re.compile(r'File (.*?) is a (.*) while file (.*?) is a (.*)') + + def parse(self, line, match): + '''Return the concrete message''' + fromfile, tofile = match.group(1), match.group(3) + return { + 'type': 'message', + 'filetype': match.group(2), + 'message': line[:-1], # strip the last \n + 'fromfile': fromfile, + 'tofile': tofile, + 'filename': fromfile, + } + + +class BinaryFile(MessageParser): + '''Message like this: + Binary files img1/partx/p2/var/lib/random-seed and + img2/partx/p2/var/lib/random-seed differ + ''' + + PATTERN = re.compile(r'Binary files (.*?) and (.*?) differ') + + def parse(self, line, match): + '''Return the concrete message''' + fromfile, tofile = match.group(1), match.group(2) + return { + 'type': 'message', + 'filetype': 'Binary files', + 'message': line[:-1], # strip the last \n + 'fromfile': fromfile, + 'tofile': tofile, + 'filename': fromfile, + } + + +MESSAGE_PARSERS = [obj() for name, obj in globals().items() + if hasattr(obj, '__bases__') and + MessageParser in obj.__bases__] + + +class Message(dict): + """ + Message that file can't be compare, such as binary, device files + """ + + @classmethod + def parse(cls, stream): + "Parse message text into dict" + line = stream.next() + for parser in MESSAGE_PARSERS: + data = parser.match(line) + if data: + return cls(data) + stream.push_back(line) + + def __str__(self): + "to message text" + return self['message'] + + +class OneFileDiff(dict): + """ + Diff result for one same file name in two sides + """ + + @classmethod + def parse(cls, stream): + '''Parse a patch which should contains following parts: + Start line + Two lines header + Serveral sections which of each is consist of: + Range: start and count + Hunks: context and different text + + Example: + diff -r -u /home/xxx/tmp/images/img1/partition_table.txt + /home/xxx/tmp/images/img2/partition_table.txt + --- img1/partition_tab.txt 2013-10-28 11:05:11.814220566 +0800 + +++ img2/partition_tab.txt 2013-10-28 11:05:14.954220642 +0800 + @@ -1,5 +1,5 @@ + Model: (file) + -Disk /home/xxx/tmp/images/192.raw: 3998237696B + +Disk /home/xxx/tmp/images/20.raw: 3998237696B + Sector size (logical/physical): 512B/512B + Partition Table: gpt + ''' + line = stream.next() + if not line.startswith('diff '): + stream.push_back(line) + return + + startline = line[:-1] + cols = ('path', 'date', 'time', 'timezone') + + def parse_header(line): + '''header''' + return dict(zip(cols, line.rstrip().split()[1:])) + + fromfile = parse_header(stream.next()) + tofile = parse_header(stream.next()) + sections = cls._parse_sections(stream) + return cls({ + 'type': 'onefilediff', + 'startline': startline, + 'sections': sections, + 'fromfile': fromfile, + 'tofile': tofile, + 'filename': fromfile['path'], + }) + + def __str__(self): + "back to unified format" + header = '%(path)s\t%(date)s %(time)s %(timezone)s' + fromfile = '--- ' + (header % self['fromfile']) + tofile = '+++ ' + (header % self['tofile']) + sections = [] + + def start_count(start, count): + "make start count string" + return str(start) if count <= 1 else '%d,%d' % (start, count) + + for i in self['sections']: + sec = ['@@ -%s +%s @@' % + (start_count(*i['range']['delete']), + start_count(*i['range']['insert'])) + ] + for j in i['hunks']: + typ, txt = j['type'], j['text'] + if typ == 'context': + sec.append(' ' + txt) + elif typ == 'delete': + sec.append('-' + txt) + elif typ == 'insert': + sec.append('+' + txt) + elif typ == 'no_newline_at_eof': + sec.append('\\' + txt) + else: + sec.append(txt) + sections.append('\n'.join(sec)) + return '\n'.join([self['startline'], + fromfile, + tofile, + '\n'.join(sections), + ]) + + @classmethod + def _parse_sections(cls, stream): + '''Range and Hunks''' + sections = [] + for line in stream: + if not line.startswith('@@ '): + stream.push_back(line) + return sections + + range_ = cls._parse_range(line) + hunks = cls._parse_hunks(stream) + sections.append({'range': range_, + 'hunks': hunks, + }) + return sections + + @classmethod + def _parse_range(cls, line): + '''Start and Count''' + def parse_start_count(chars): + '''Count ommit when it's 1''' + start, count = (chars[1:] + ',1').split(',')[:2] + return int(start), int(count) + + _, delete, insert, _ = line.split() + return { + 'delete': parse_start_count(delete), + 'insert': parse_start_count(insert), + } + + @classmethod + def _parse_hunks(cls, stream): + '''Hunks''' + hunks = [] + for line in stream: + if line.startswith(' '): + type_ = 'context' + elif line.startswith('-'): + type_ = 'delete' + elif line.startswith('+'): + type_ = 'insert' + elif line.startswith('\\ No newline at end of file'): + type_ = 'no_newline_at_eof' + else: + stream.push_back(line) + break + text = line[1:-1] # remove the last \n + hunks.append({'type': type_, 'text': text}) + return hunks + + +def parse(stream): + ''' + Unified diff result parser + Reference: http://www.gnu.org/software/diffutils/manual/html_node/Detailed-Unified.html#Detailed-Unified # flake8: noqa + + ''' + stream = LookAhead(stream) + while 1: + try: + one = Message.parse(stream) or \ + OneFileDiff.parse(stream) + except StopIteration: + break + + if one: + yield one + continue + + try: + line = stream.next() + except StopIteration: + # one equals None means steam hasn't stop but no one can + # understand the input. If we are here there must be bug + # in previous parsing logic + raise Exception('Unknown error in parsing diff output') + else: + print >> sys.stderr, '[WARN] Unknown diff output:', line, diff --git a/imgdiff/unpack.py b/imgdiff/unpack.py new file mode 100644 index 0000000..56a2b49 --- /dev/null +++ b/imgdiff/unpack.py @@ -0,0 +1,176 @@ +#!/usr/bin/env python +'''This script unpack a whole image into a directory +''' +import os +import sys +import errno +import argparse +from subprocess import check_call + +from imgdiff.info import get_partition_info, FSTab + + +def mkdir_p(path): + '''Same as mkdir -p''' + try: + os.makedirs(path) + except OSError as err: + if err.errno != errno.EEXIST: + raise + + +class ResourceList(object): + ''' + Record all resource allocated into a file + ''' + def __init__(self, filename): + self.filename = filename + + def umount(self, path): + '''record a mount point''' + line = 'mountpoint:%s%s' % (os.path.abspath(path), os.linesep) + with open(self.filename, 'a') as writer: + writer.write(line) + + +class Mount(object): + ''' + Mount image partions + ''' + def __init__(self, limited_to_dir, resourcelist): + self.limited_to_dir = limited_to_dir + self.resourcelist = resourcelist + + def _check_path(self, path): + '''Check whether path is ok to mount''' + if not path.startswith(self.limited_to_dir): + raise ValueError("Try to mount outside of jar: " + path) + if os.path.ismount(path): + raise Exception("Not allowed to override an exists " + "mountpoint: " + path) + + self.resourcelist.umount(path) + mkdir_p(path) + + def mount(self, image, offset, fstype, path): + '''Mount a partition starting from perticular + position of a image to a direcotry + ''' + self._check_path(path) + cmd = ['sudo', 'mount', + '-o', 'ro,offset=%d' % offset, + '-t', fstype, + image, path] + print 'Mounting', '%d@%s' % (offset, image), '->', path, '...' + check_call(cmd) + + def move(self, source, target): + '''Remove mount point to another path''' + self._check_path(target) + cmd = ['sudo', 'mount', '--make-runbindable', '/'] + print 'Make runbindable ...', ' '.join(cmd) + check_call(cmd) + cmd = ['sudo', 'mount', '-M', source, target] + print 'Moving mount point from', source, 'to', target, '...' + check_call(cmd) + + +class Image(object): + '''A raw type image''' + def __init__(self, image): + self.image = image + self.partab = get_partition_info(self.image) + + @staticmethod + def _is_fs_supported(fstype): + '''Only support ext? and *fat*. + Ignore others such as swap, tmpfs etc. + ''' + return fstype.startswith('ext') or 'fat' in fstype + + def _mount_to_temp(self, basedir, mount): + '''Mount all partitions into temp dirs like partx/p? + ''' + num2temp, uuid2temp = {}, {} + for part in self.partab: + number = str(part['number']) + fstype = part['blkid']['type'] + if not self._is_fs_supported(fstype): + print >> sys.stderr, \ + "ignore partition %s of type %s" % (number, fstype) + continue + + path = os.path.join(basedir, 'partx', 'p'+number) + mount.mount(self.image, part['start'], fstype, path) + + num2temp[number] = path + uuid2temp[part['blkid']['uuid']] = path + return num2temp, uuid2temp + + @staticmethod + def _move_to_root(fstab, num2temp, uuid2temp, basedir, mount): + '''Move partitions to their correct mount points according to fstab + ''' + pairs = [] + for mountpoint in sorted(fstab.keys()): + item = fstab[mountpoint] + if 'number' in item and item['number'] in num2temp: + source = num2temp[item['number']] + elif 'uuid' in item and item['uuid'] in uuid2temp: + source = uuid2temp[item['uuid']] + else: + print >> sys.stderr, "fstab mismatch with partition table:", \ + item["entry"] + return + + # remove heading / otherwise the path will reduce to root + target = os.path.join(basedir, 'root', + mountpoint.lstrip(os.path.sep)) + pairs.append((source, target)) + + for source, target in pairs: + mount.move(source, target) + return True + + def unpack(self, basedir, resourcelist): + '''Unpack self into the basedir and record all resource used + into resourcelist + ''' + mount = Mount(basedir, resourcelist) + + num2temp, uuid2temp = self._mount_to_temp(basedir, mount) + + fstab = FSTab.guess(num2temp.values()) + if not fstab: + print >> sys.stderr, "Can't find fstab file from image" + return + return self._move_to_root(fstab, + num2temp, uuid2temp, + basedir, mount) + + +def parse_args(): + "Parse arguments" + parser = argparse.ArgumentParser() + parser.add_argument('image', type=os.path.abspath, + help='image file to unpack. Only raw format is ' + 'supported') + parser.add_argument('basedir', type=os.path.abspath, + help='directory to unpack the image') + parser.add_argument('resourcelist_filename', type=os.path.abspath, + help='will record each mount point when unpacking ' + 'the image. Make sure call cleanup script with this ' + 'file name to release all allocated resources.') + return parser.parse_args() + + +def main(): + "Main" + args = parse_args() + img = Image(args.image) + resfile = ResourceList(args.resourcelist_filename) + return 0 if img.unpack(args.basedir, resfile) else 1 + + +if __name__ == '__main__': + sys.exit(main()) diff --git a/itest/__init__.py b/itest/__init__.py new file mode 100644 index 0000000..218f431 --- /dev/null +++ b/itest/__init__.py @@ -0,0 +1 @@ +__version__ = '1.7' diff --git a/itest/__main__.py b/itest/__main__.py new file mode 100644 index 0000000..1cec30e --- /dev/null +++ b/itest/__main__.py @@ -0,0 +1,4 @@ +from itest.main import main + + +main() diff --git a/itest/case.py b/itest/case.py new file mode 100644 index 0000000..de3511a --- /dev/null +++ b/itest/case.py @@ -0,0 +1,407 @@ +import os +import sys +import time +import uuid +import platform + +try: + import unittest2 as unittest + from unittest2 import SkipTest +except ImportError: + import unittest + from unittest import SkipTest + +import pexpect +if hasattr(pexpect, 'spawnb'): # pexpect-u-2.5 + spawn = pexpect.spawnb +else: + spawn = pexpect.spawn + +from itest.conf import settings +from itest.utils import now, cd, get_machine_labels +from itest.fixture import Fixture + + +def id_split(idstring): + parts = idstring.split('.') + if len(parts) > 1: + return '.'.join(parts[:-1]), parts[-1] + return '', idstring + + +class TimeoutError(Exception): + pass + + +def pcall(cmd, args=(), expecting=(), output=None, + eof_timeout=None, output_timeout=None, **spawn_opts): + '''call cmd with expecting + expecting: list of pairs, first is expecting string, second is send string + output: redirect cmd stdout and stderr to file object + eof_timeout: timeout for whole cmd in seconds. None means block forever + output_timeout: timeout if no output in seconds. Disabled by default + spawn_opts: keyword arguments passed to spawn call + ''' + question = [pexpect.EOF, pexpect.TIMEOUT] + question.extend([pair[0] for pair in expecting]) + if output_timeout: + question.append(r'\r|\n') + answer = [None]*2 + [i[1] for i in expecting] + + start = time.time() + child = spawn(cmd, list(args), **spawn_opts) + if output: + child.logfile_read = output + + timeout = output_timeout if output_timeout else eof_timeout + try: + while True: + if output_timeout: + cost = time.time() - start + if cost >= eof_timeout: + msg = 'Run out of time in %s seconds!:%s %s' % \ + (cost, cmd, ' '.join(args)) + raise TimeoutError(msg) + + i = child.expect(question, timeout=timeout) + if i == 0: # EOF + break + elif i == 1: # TIMEOUT + if output_timeout: + msg = 'Hanging for %s seconds!:%s %s' + else: + msg = 'Run out of time in %s seconds!:%s %s' + raise TimeoutError(msg % (timeout, cmd, ' '.join(args))) + elif output_timeout and i == len(question)-1: + # new line, stands for any output + # do nothing, just flush timeout counter + pass + else: + child.sendline(answer[i]) + finally: + child.close() + + return child.exitstatus + + +# enumerate patterns for all distributions +# fedora16-64: +# [sudo] password for itestuser5707: +# suse121-32b +# root's password: +# suse122-32b +# itestuser23794's password: +# u1110-32b +# [sudo] password for itester: +SUDO_PASS_PROMPT_PATTERN = "\[sudo\] password for .*?:|" \ + "root's password:|" \ + ".*?'s password:" + +SUDO_PASS_PROMPT_PATTERN_FEDORA_20_i586 = "\[sudo\] password for .*?:|" \ + "root's password:" + +class Tee(object): + + '''data write to original will write to another as well''' + + def __init__(self, original, another=None): + self.original = original + if another is None: + self.another = sys.stderr + else: + self.another = another + + def write(self, data): + self.another.write(data) + return self.original.write(data) + + def flush(self): + self.another.flush() + return self.original.flush() + + def close(self): + self.original.close() + + +class Meta(object): + """ + Meta information of a test case + + All meta information are put in a .meta/ directory under case running + path. Scripts `setup`, `steps` and `teardown` are in this meta path. + """ + + meta = '.meta' + + def __init__(self, rundir, test): + self.rundir = rundir + self.test = test + + self.logname = None + self.logfile = None + self.setup_script = None + self.steps_script = None + self.teardown_script = None + + def begin(self): + """ + Begin to run test. Generate meta scripts and open log file. + """ + os.mkdir(self.meta) + + self.logname = os.path.join(self.rundir, self.meta, 'log') + self.logfile = open(self.logname, 'a') + if settings.verbosity >= 3: + self.logfile = Tee(self.logfile) + + if self.test.setup: + self.setup_script = self._make_setup_script() + self.steps_script = self._make_steps_script() + if self.test.teardown: + self.teardown_script = self._make_teardown_script() + + def end(self): + """ + Test finished, do some cleanup. + """ + if not self.logfile: + return + + self.logfile.close() + self.logfile = None + + # FIXME: it's a little hack here + # delete color code + os.system("sed -i 's/\x1b\[[0-9]*m//g' %s" % self.logname) + os.system("sed -i 's/\x1b\[[0-9]*K//g' %s" % self.logname) + + def setup(self): + code = 0 + if self.setup_script: + self.log('setup start') + code = self._psh(self.setup_script) + self.log('setup finish') + return code + + def steps(self): + self.log('steps start') + code = self._psh(self.steps_script, self.test.qa) + self.log('steps finish') + return code + + def teardown(self): + if self.teardown_script: + self.log('teardown start') + self._psh(self.teardown_script) + self.log('teardown finish') + + def log(self, msg, level="INFO"): + self.logfile.write('%s %s: %s\n' % (now(), level, msg)) + + def _make_setup_script(self): + code = '''cd %(rundir)s +(set -o posix; set) > %(var_old)s +set -x +%(setup)s +__exitcode__=$? +set +x +(set -o posix; set) > %(var_new)s +diff --unchanged-line-format= --old-line-format= --new-line-format='%%L' \\ + %(var_old)s %(var_new)s > %(var_out)s +exit ${__exitcode__} +''' % { + 'rundir': self.rundir, + 'var_old': os.path.join(self.meta, 'var.old'), + 'var_new': os.path.join(self.meta, 'var.new'), + 'var_out': os.path.join(self.meta, 'var.out'), + 'setup': self.test.setup, + } + return self._make_code('setup', code) + + def _make_steps_script(self): + code = '''cd %(rundir)s +if [ -f %(var_out)s ]; then + . %(var_out)s +fi +set -o pipefail +set -ex +%(steps)s +''' % { + 'rundir': self.rundir, + 'var_out': os.path.join(self.meta, 'var.out'), + 'steps': self.test.steps, + } + return self._make_code('steps', code) + + def _make_teardown_script(self): + code = '''cd %(rundir)s +if [ -f %(var_out)s ]; then + . %(var_out)s +fi +set -x +%(teardown)s +''' % { + 'rundir': self.rundir, + 'var_out': os.path.join(self.meta, 'var.out'), + 'teardown': self.test.teardown, + } + return self._make_code('teardown', code) + + def _make_code(self, name, code): + """Write `code` into `name`""" + path = os.path.join(self.meta, name) + data = code.encode('utf8') if isinstance(code, unicode) else code + with open(path, 'w') as f: + f.write(data) + return path + + def _psh(self, script, more_expecting=()): + if (platform.linux_distribution()[0] == 'Fedora') and \ + (platform.linux_distribution()[1] == '20') and \ + (platform.architecture()[0] == '32bit'): + pat = SUDO_PASS_PROMPT_PATTERN_FEDORA_20_i586 + else: + pat = SUDO_PASS_PROMPT_PATTERN + + expecting = [(pat, settings.SUDO_PASSWD)] + \ + list(more_expecting) + try: + return pcall('/bin/bash', + [script], + expecting=expecting, + output=self.logfile, + eof_timeout=float(settings.RUN_CASE_TIMEOUT), + output_timeout=float(settings.HANGING_TIMEOUT), + ) + except Exception as err: + self.log('pcall error:%s\n%s' % (script, err), 'ERROR') + return -1 + + +class TestCase(unittest.TestCase): + '''Single test case''' + + count = 1 + was_skipped = False + was_successful = False + + def __init__(self, filename, fields): + super(TestCase, self).__init__() + self.filename = filename + + # Fields from case definition + self.version = fields.get('version') + self.summary = fields.get('summary') + self.steps = fields.get('steps') + self.setup = fields.get('setup') + self.teardown = fields.get('teardown') + self.qa = fields.get('qa', ()) + self.tracking = fields.get('tracking', {}) + self.conditions = fields.get('conditions', {}) + self.fixtures = [Fixture(os.path.dirname(self.filename), + i) + for i in fields.get('fixtures', ())] + + self.component = self._guess_component(self.filename) + + def id(self): + """ + This id attribute is used in xunit file. + + classname.name + """ + if settings.env_root: + retpath = self.filename[len(settings.cases_dir):]\ + .lstrip(os.path.sep) + base = os.path.splitext(retpath)[0] + else: + base = os.path.splitext(os.path.basename(self.filename))[0] + return base.replace(os.path.sep, '.') + + def __eq__(self, that): + if type(self) is not type(that): + return NotImplemented + return self.id() == that.id() + + def __hash__(self): + return hash((type(self), self.filename)) + + def __str__(self): + cls, name = id_split(self.id()) + if cls: + return "%s (%s)" % (name, cls) + return name + + def __repr__(self): + return '<%s %s>' % (self.__class__.__name__, self.id()) + + def setUp(self): + self._check_conditions() + self.rundir = rundir = self._new_rundir() + self._copy_fixtures() + + self.meta = meta = Meta(rundir, self) + with cd(rundir): + meta.begin() + meta.log('case start to run!') + if self.setup: + code = meta.setup() + if code != 0: + msg = "setup failed. Exit %d, see log: %s" % ( + code, meta.logname) + raise Exception(msg) + + def tearDown(self): + meta = self.meta + if meta: + with cd(self.rundir): + meta.teardown() + meta.log('case is finished!') + meta.end() + + def runTest(self): + meta = self.meta + with cd(self.rundir): + code = meta.steps() + + msg = "Exit Nonzero %d. See log: %s" % (code, self.meta.logname) + self.assertEqual(0, code, msg) + + def _check_conditions(self): + '''Check if conditions match, raise SkipTest if some conditions are + defined but not match. + ''' + labels = set((i.lower() for i in get_machine_labels())) + # blacklist has higher priority, if it match both black and white + # lists, it will be skipped + if self.conditions.get('blacklist'): + intersection = labels & set(self.conditions.get('blacklist')) + if intersection: + raise SkipTest('by distribution blacklist:%s' % + ','.join(intersection)) + + kw = 'whitelist' + if self.conditions.get(kw): + intersection = labels & set(self.conditions[kw]) + if not intersection: + raise SkipTest('not in distribution whitelist:%s' % + ','.join(self.conditions[kw])) + + def _guess_component(self, filename): + # assert that filename is absolute path + if not settings.env_root or \ + not filename.startswith(settings.cases_dir): + return 'unknown' + relative = filename[len(settings.cases_dir)+1:].split(os.sep) + # >1 means [0] is an dir name + return relative[0] if len(relative) > 1 else 'unknown' + + def _new_rundir(self): + hash_ = str(uuid.uuid4()).replace('-', '') + path = os.path.join(settings.WORKSPACE, hash_) + os.mkdir(path) + return path + + def _copy_fixtures(self): + for item in self.fixtures: + item.copy(self.rundir) diff --git a/itest/conf/__init__.py b/itest/conf/__init__.py new file mode 100644 index 0000000..276dd01 --- /dev/null +++ b/itest/conf/__init__.py @@ -0,0 +1,50 @@ +''' +These LazyObject, LazySettings and Settings are mainly copied from Django +''' + +import os +import imp +import time + + +class Settings(object): + + def __init__(self): + self.env_root = None + self.cases_dir = None + self.fixtures_dir = None + + def load(self, mod): + for name in dir(mod): + if name == name.upper(): + setattr(self, name, getattr(mod, name)) + + if hasattr(self, 'TZ') and self.TZ: + os.environ['TZ'] = self.TZ + time.tzset() + + def setup_test_project(self, test_project_root): + self.env_root = os.path.abspath(test_project_root) + self.cases_dir = os.path.join(self.env_root, self.CASES_DIR) + self.fixtures_dir = os.path.join(self.env_root, self.FIXTURES_DIR) + + +settings = Settings() + + +def load_settings(test_project_root=None): + global settings + + mod = __import__('itest.conf.global_settings', + fromlist=['global_settings']) + settings.load(mod) + + if test_project_root: + settings_py = os.path.join(test_project_root, 'settings.py') + try: + mod = imp.load_source('settings', settings_py) + except (ImportError, IOError), e: + raise ImportError("Could not import settings '%s' (Is it on " + "sys.path?): %s" % (settings_py, e)) + settings.load(mod) + settings.setup_test_project(test_project_root) diff --git a/itest/conf/global_settings.py b/itest/conf/global_settings.py new file mode 100644 index 0000000..ec94a99 --- /dev/null +++ b/itest/conf/global_settings.py @@ -0,0 +1,55 @@ +''' +Global settings for test ENV + +This file contains default values for all settings and can be overwrite in +individual env's settings.py +''' + +import os + + +WORKSPACE = os.path.expanduser('~/testspace') + + +CASES_DIR = 'cases' + +FIXTURES_DIR = 'fixtures' + +# All case text is actually JinJa2 template. Default template directories +# will include the dirname of the case file and CASES_DIR. You can set +# external template directories here, it should be a list string of path. +TEMPLATE_DIRS = () + +# Mapping from suite name to a list of cases. +# For example, an ENV can have special suite names such as "Critical" and +# "CasesUpdatedThisWeek", which include different set of cases. +# Then refer it in command line as: +# $ runtest Critical +# $ runtest CasesUpdatedThisWeek +SUITES = {} + + +# Define testing target name and version. They can be showed in console info +# or title or HTML report. But if TARGET_NAME is None, it will show nothing +TARGET_NAME = None + +# If TARGET_NAME is not None, but TARGET_VERSION is None, version will be got +# by querying package TARGET_NAME. If TARGET_VERSION is not None, simply use it +TARGET_VERSION = None + +# List of package names as dependencies. This info can be show in report. +DEPENDENCIES = [] + + +# Password to run sudo. +SUDO_PASSWD = os.environ.get('ITEST_SUDO_PASSWD') + + +# Timeout(in seconds) for running a single case +RUN_CASE_TIMEOUT = 30 * 60 # half an hour + +# Timeout(in seconds) for no output +HANGING_TIMEOUT = 5 * 60 # 5 minutes + +# Time zone +TZ = None diff --git a/itest/fixture.py b/itest/fixture.py new file mode 100644 index 0000000..e018bfa --- /dev/null +++ b/itest/fixture.py @@ -0,0 +1,92 @@ +import os +import shutil +from jinja2 import Environment, FileSystemLoader + +from itest.conf import settings +from itest.utils import makedirs + + +def Fixture(casedir, item): + typ = item.pop('type') + cls = globals().get(typ) + if not cls: + raise Exception("Unknown fixture type: %s" % typ) + return cls(casedir, **item) + + +def guess_source(casedir, src): + source = os.path.join(casedir, src) + if not os.path.exists(source) and settings.fixtures_dir: + source = os.path.join(settings.fixtures_dir, src) + return source + + +def guess_target(todir, src, target): + if target: + return os.path.join(todir, target) + return os.path.join(todir, os.path.basename(src)) + + +class copy(object): + + def __init__(self, casedir, src, target=None): + self.source = guess_source(casedir, src) + self.target = target + if not os.path.isfile(self.source): + raise Exception("Fixutre '%s' doesn't exist" % src) + + def copy(self, todir): + target = guess_target(todir, self.source, self.target) + makedirs(os.path.dirname(target)) + shutil.copy(self.source, target) + + +class copydir(object): + + def __init__(self, casedir, src, target=None): + self.source = guess_source(casedir, src.rstrip(os.path.sep)) + self.target = target + if not os.path.isdir(self.source): + raise Exception("Fixture '%s' doesn't exist" % src) + + def copy(self, todir): + target = guess_target(todir, + self.source, + self.target).rstrip(os.path.sep) + makedirs(os.path.dirname(target)) + shutil.copytree(self.source, target) + + +class content(object): + + def __init__(self, casedir, target, text): + self.target = target + self.text = text + + def copy(self, todir): + target = os.path.join(todir, self.target) + makedirs(os.path.dirname(target)) + with open(target, 'w') as writer: + writer.write(self.text) + + +class template(object): + + def __init__(self, casedir, src, target=None): + self.source = guess_source(casedir, src) + self.target = target + + def copy(self, todir): + target = guess_target(todir, self.source, self.target) + + template_dirs = [os.path.abspath(os.path.dirname(self.source))] + if settings.fixtures_dir: + template_dirs.append(settings.fixtures_dir) + + jinja2_env = Environment(loader=FileSystemLoader(template_dirs)) + template = jinja2_env.get_template(os.path.basename(self.source)) + text = template.render() + + makedirs(os.path.dirname(target)) + with open(target, 'w') as writer: + writer.write(text) diff --git a/itest/loader.py b/itest/loader.py new file mode 100644 index 0000000..365b14c --- /dev/null +++ b/itest/loader.py @@ -0,0 +1,195 @@ +import os +import logging + +try: + import unittest2 as unittest +except ImportError: + import unittest +from jinja2 import Environment, FileSystemLoader + +from itest import xmlparser +from itest.conf import settings +from itest.case import TestCase + +log = logging.getLogger(os.path.splitext(os.path.basename(__file__))[0]) + + +def load_case(sel): + ''' + Load tests from a single test select pattern `sel` + ''' + suiteClass = unittest.TestSuite + def _is_test(ret): + return isinstance(ret, suiteClass) or \ + isinstance(ret, TestCase) + + suite = suiteClass() + stack = [sel] + while stack: + sel = stack.pop() + for pattern in suite_patterns.all(): + if callable(pattern): + pattern = pattern() + + ret = pattern.load(sel) + if not ret: + continue + + if _is_test(ret): + suite.addTest(ret) + elif isinstance(ret, list): + stack.extend(ret) + else: + stack.append(ret) + break + + return suite + + +class TestLoader(unittest.TestLoader): + + def loadTestsFromModule(self, _module, _use_load_tests=True): + if settings.env_root: + return load_case(settings.env_root) + return self.suiteClass() + + def loadTestsFromName(self, name, module=None): + return load_case(name) + + +class AliasPattern(object): + '''dict key of settings.SUITES is alias for its value''' + + def load(self, sel): + if sel in settings.SUITES: + return settings.SUITES[sel] + + +class FilePattern(object): + '''test from file name''' + + def load(self, name): + if not os.path.isfile(name): + return + + template_dirs = [os.path.abspath(os.path.dirname(name))] + if settings.cases_dir: + template_dirs.append(settings.cases_dir) + jinja2_env = Environment(loader=FileSystemLoader(template_dirs)) + template = jinja2_env.get_template(os.path.basename(name)) + text = template.render() + + if isinstance(text, unicode): + text = text.encode('utf8') + # template returns unicode + # but xml parser only accepts str + # And we can only assume it's utf8 here + + data = xmlparser.Parser().parse(text) + if not data: + raise Exception("Can't load test case from %s" % name) + return TestCase(os.path.abspath(name), data) + + +class DirPattern(object): + '''find all tests recursively in a dir''' + + def load(self, top): + if os.path.isdir(top): + return list(self._walk(top)) + + def _walk(self, top): + for current, _dirs, nondirs in os.walk(top): + for name in nondirs: + if name.endswith('.case'): + yield os.path.join(current, name) + + +class ComponentPattern(object): + '''tests from a component name''' + + _components = None + + @staticmethod + def guess_components(): + if not settings.env_root: + return () + comp = [] + for base in os.listdir(settings.cases_dir): + full = os.path.join(settings.cases_dir, base) + if os.path.isdir(full): + comp.append(base) + return set(comp) + + @classmethod + def is_component(cls, comp): + if cls._components is None: + cls._components = cls.guess_components() + return comp in cls._components + + def load(self, comp): + if self.is_component(comp): + return os.path.join(settings.cases_dir, comp) + + +class InversePattern(object): + '''string starts with "!" is the inverse of string[1:]''' + + def load(self, sel): + if sel.startswith('!'): + comp = sel[1:] + comps = ComponentPattern.guess_components() + if ComponentPattern.is_component(comp): + return [c for c in comps if c != comp] + # if the keyword isn't a component name, then it is useless + return list(comps) + + +class IntersectionPattern(object): + '''use && load intersection set of many parts''' + + loader_class = TestLoader + + def load(self, sel): + if sel.find('&&') <= 0: + return + + def intersection(many): + inter = None + for each in many: + if inter is None: + inter = set(each) + else: + inter.intersection_update(each) + return inter + + loader = self.loader_class() + many = [load_case(part) for part in sel.split('&&')] + + return loader.suiteClass(intersection(many)) + + +class _SuitePatternRegister(object): + + def __init__(self): + self._patterns = [] + + def register(self, cls): + self._patterns.append(cls) + + def all(self): + return self._patterns + + +def register_default_patterns(): + for pattern in (AliasPattern, + FilePattern, + DirPattern, + IntersectionPattern, + ComponentPattern, + InversePattern, + ): + suite_patterns.register(pattern) + +suite_patterns = _SuitePatternRegister() +register_default_patterns() diff --git a/itest/main.py b/itest/main.py new file mode 100644 index 0000000..f088802 --- /dev/null +++ b/itest/main.py @@ -0,0 +1,132 @@ +import os +import sys +import argparse + +try: + import unittest2 as unittest + from unittest2 import TextTestResult +except ImportError: + import unittest + from unittest import TextTestResult + +from itest import conf +from itest.utils import makedirs +from itest.loader import TestLoader +from itest import __version__ + + +ENVIRONMENT_VARIABLE = "ITEST_ENV_PATH" + + +def find_test_project_from_cwd(): + ''' + Returns test project root directory or None + ''' + path = os.getcwd() + while 1: + name = os.path.join(path, 'settings.py') + if os.path.exists(name): + return path + + if path == '/': + return + path = os.path.dirname(path) + + +class TestProgram(unittest.TestProgram): + + def parseArgs(self, argv): + if len(argv) > 1 and argv[1].lower() == 'discover': + self._do_discovery(argv[2:]) + return + + parser = argparse.ArgumentParser() + parser.add_argument('-V', '--version', action='version', + version=__version__) + parser.add_argument('-q', '--quiet', action='store_true', + help="minimal output") + parser.add_argument('-v', '--verbose', action='count', + help="verbose output") + parser.add_argument('-f', '--failfast', action='store_true', + help="stop on the first failure") + parser.add_argument('-c', '--catch', action='store_true', + help="catch ctrl-c and display results") + parser.add_argument('-b', '--buffer', action='store_true', + help="buffer stdout and stderr during test runs") + parser.add_argument('tests', nargs='*') + parser.add_argument('--test-project-path', + default=os.environ.get(ENVIRONMENT_VARIABLE), + help='set test project path where settings.py ' + 'locate. [%s]' % ENVIRONMENT_VARIABLE) + parser.add_argument('--test-workspace', type=os.path.abspath, + help='set test workspace path') + parser.add_argument('--with-xunit', action='store_true', + help='provides test resutls in standard XUnit XML ' + 'format') + parser.add_argument('--xunit-file', + type=os.path.abspath, default='xunit.xml', + help='Path to xml file to store the xunit report.' + 'Default is xunit.xml in the working directory') + + opts = parser.parse_args() + + # super class options + if opts.quiet: + self.verbosity = 0 + elif opts.verbose: + # default verbosity is 1 + self.verbosity = opts.verbose + 1 + self.failfast = opts.failfast + self.catchbreak = opts.catch + self.buffer = opts.buffer + + # additional options + if opts.with_xunit: + if not os.access(os.path.dirname(opts.xunit_file), os.W_OK): + print >> sys.stderr, "Permission denied:", opts.xunit_file + sys.exit(1) + from itest.result import XunitTestResult + self.testRunner.resultclass = XunitTestResult + self.testRunner.resultclass.xunit_file = opts.xunit_file + else: + self.testRunner.resultclass = TextTestResult + + if opts.test_project_path: + conf.load_settings(opts.test_project_path) + else: + conf.load_settings(find_test_project_from_cwd()) + + conf.settings.verbosity = self.verbosity + + if opts.test_workspace: + conf.settings.WORKSPACE = opts.test_workspace + makedirs(conf.settings.WORKSPACE) + + # copy from super class + if len(opts.tests) == 0 and self.defaultTest is None: + # createTests will load tests from self.module + self.testNames = None + elif len(opts.tests) > 0: + self.testNames = opts.tests + if __name__ == '__main__': + # to support python -m unittest ... + self.module = None + else: + self.testNames = (self.defaultTest,) + self.createTests() + + +class TextTestRunner(unittest.TextTestRunner): + + def __init__(self, stream=None, descriptions=True, verbosity=1, + failfast=False, buffer=False, resultclass=None): + if stream is None: + stream = sys.stderr + super(TextTestRunner, self).__init__(stream, descriptions, verbosity, + failfast, buffer, resultclass) + + +def main(): + import logging + logging.basicConfig() + TestProgram(testLoader=TestLoader(), testRunner=TextTestRunner) diff --git a/itest/result.py b/itest/result.py new file mode 100644 index 0000000..c9a8aae --- /dev/null +++ b/itest/result.py @@ -0,0 +1,99 @@ +import re +import time +import xml.etree.ElementTree as ET + +try: + from unittest2 import TextTestResult +except ImportError: + from unittest import TextTestResult + +from itest.case import id_split + +SHELL_COLOR_PATTERN = re.compile(r'\x1b\[[0-9]*[mK]') + + +class XunitTestResult(TextTestResult): + + xunit_file = 'xunit.xml' + + def __init__(self, *args, **kw): + super(XunitTestResult, self).__init__(*args, **kw) + self.testsuite = ET.Element('testsuite') + self._timer = time.time() + + def startTest(self, test): + "Called when the given test is about to be run" + super(XunitTestResult, self).startTest(test) + self._timer = time.time() + + def _time_taken(self): + if hasattr(self, '_timer'): + taken = time.time() - self._timer + else: + # test died before it ran (probably error in setup()) + # or success/failure added before test started probably + # due to custom TestResult munging + taken = 0.0 + return taken + + def addError(self, test, err): + """Called when an error has occurred. 'err' is a tuple of values as + returned by sys.exc_info(). + """ + super(XunitTestResult, self).addError(test, err) + self._add_failure(test, err) + + def addFailure(self, test, err): + """Called when an error has occurred. 'err' is a tuple of values as + returned by sys.exc_info().""" + super(XunitTestResult, self).addFailure(test, err) + self._add_failure(test, err) + + def _add_failure(self, test, err): + cls, name = id_split(test.id()) + + def get_log(): + with open(test.meta.logname) as reader: + content = reader.read() + content = content.replace('\r', '\n').replace('\x00', '') + content = SHELL_COLOR_PATTERN.sub('', content) + return content.decode('utf8', 'ignore') + + if hasattr(test, 'meta'): + content = get_log() + else: + content = "Log file isn't available!" + + testcase = ET.SubElement(self.testsuite, 'testcase', + classname=cls, + name=name, + time="%.3f" % self._time_taken()) + failure = ET.SubElement(testcase, 'failure', + message=str(err)) + failure.text = content + + def addSuccess(self, test): + "Called when a test has completed successfully" + super(XunitTestResult, self).addSuccess(test) + cls, name = id_split(test.id()) + ET.SubElement(self.testsuite, 'testcase', + classname=cls, + name=name, + taken="%.3f" % self._time_taken()) + + def stopTestRun(self): + """Called once after all tests are executed. + + See stopTest for a method called after each test. + """ + super(XunitTestResult, self).stopTestRun() + + ts = self.testsuite + ts.set("tests", str(self.testsRun)) + ts.set("errors", str(len(self.errors))) + ts.set("failures", str(len(self.failures))) + ts.set("skip", str(len(self.skipped))) + xml = ET.tostring(ts) + + with open(self.xunit_file, 'w') as fp: + fp.write(xml) diff --git a/itest/utils.py b/itest/utils.py new file mode 100644 index 0000000..2adccae --- /dev/null +++ b/itest/utils.py @@ -0,0 +1,69 @@ +import os +import datetime +import platform +import subprocess +from contextlib import contextmanager + + +def now(): + return datetime.datetime.now().strftime('%Y-%m-%d %H:%M:%S') + + +def get_machine_labels(): + '''Get machine labels for localhost. The label are strings in format of + -. Such as "Fedora", "Fedora17", + "Fedora17-x86_64", "Ubuntu", "Ubuntu12.04", "Ubuntun12.10-i586". + ''' + dist_name, dist_ver = \ + [i.strip() for i in platform.linux_distribution()[:2]] + arch = platform.machine().strip() + return (dist_name, + arch, + '%s%s' % (dist_name, dist_ver), + '%s-%s' % (dist_name, arch), + '%s%s-%s' % (dist_name, dist_ver, arch), + ) + + +def check_output(*popenargs, **kwargs): + if hasattr(subprocess, 'check_output'): + return subprocess.check_output(*popenargs, **kwargs) + return _check_output(*popenargs, **kwargs) + + +def _check_output(*popenargs, **kwargs): + r"""Run command with arguments and return its output as a byte string. + """ + if 'stdout' in kwargs: + raise ValueError('stdout argument not allowed, it will be overridden.') + process = subprocess.Popen(stdout=subprocess.PIPE, *popenargs, **kwargs) + return process.communicate()[0] + + +@contextmanager +def cd(path): + '''cd to given path and get back when it finish + ''' + old_path = os.getcwd() + os.chdir(path) + yield + os.chdir(old_path) + + +def makedirs(path): + """ + Recursively create `path`, do nothing if it exists + """ + try: + os.makedirs(path) + except OSError as err: + import errno + if err.errno != errno.EEXIST: + raise + + +def in_dir(child, parent): + """ + Check whether `child` is inside `parent` + """ + return os.path.realpath(child).startswith(os.path.realpath(parent)) diff --git a/itest/xmlparser.py b/itest/xmlparser.py new file mode 100644 index 0000000..525faff --- /dev/null +++ b/itest/xmlparser.py @@ -0,0 +1,130 @@ +""" +Parser of XML format of case file +""" +import os +import logging +import xml.etree.ElementTree as ET + +try: + from xml.etree.ElementTree import ParseError +except ImportError: + from xml.parsers.expat import ExpatError as ParseError + +log = logging.getLogger(os.path.splitext(os.path.basename(__file__))[0]) + + +class Parser(object): + """ + The XML case parser + """ + + def parse(self, xmldoc): + """ + Returns a dict represent a case + """ + data = {} + try: + root = ET.fromstring(xmldoc) + except ParseError as err: + log.warn("Case syntax error: %s", str(err)) + return + + for child in root: + method = '_on_' + child.tag + if hasattr(self, method): + value = getattr(self, method)(child) + data[child.tag] = value + return data + + def _text(self, element): + """ + Returns stripped text of `element` + """ + return element.text.strip() if element.text else '' + + _on_formatversion = _text + _on_summary = _text + _on_setup = _text + _on_steps = _text + _on_teardown = _text + + def _on_tracking(self, element): + """ + Subelement can be a Gerrit `change` or a Redmine `ticket`. + + 90125 + 5150 + + """ + return [(child.tag, self._text(child)) + for child in element + if child.tag in ('change', 'ticket')] + + def _on_qa(self, element): + """ + A seqence of and . + + Are you sure [N/y]? + y + + """ + data = [] + state = 0 + for node in element: + if state == 0: + if node.tag == 'prompt': + prompt = self._text(node) + state = 1 + else: + raise Exception("Case syntax error: expects " + "rather than %s" % node.tag) + elif state == 1: + if node.tag == 'answer': + answer = self._text(node) + data.append((prompt, answer)) + state = 0 + else: + raise Exception("Case syntax error: expects " + "rather than %s" % node.tag) + if state == 1: + raise Exception("Case syntax error: expects rather than " + "closing") + return data + + def _on_conditions(self, element): + """ + Platform white list and black list + + + OpenSuse-64bit + Ubuntu12.04 + + + Fedora19-x86_64 + + + """ + def _platforms(key): + return [self._text(n) + for n in element.findall('./%s/platform' % key)] + return { + 'whitelist': _platforms('whitelist'), + 'blacklist': _platforms('blacklist'), + } + + def _on_fixtures(self, element): + """ + + +