diff --git a/lettuce/__init__.py b/lettuce/__init__.py index 479a12dcc..b616f3990 100644 --- a/lettuce/__init__.py +++ b/lettuce/__init__.py @@ -28,6 +28,7 @@ # python 2.5 fallback pass +from datetime import datetime import random from lettuce.core import Feature, TotalResult @@ -43,7 +44,8 @@ from lettuce.exceptions import StepLoadingError from lettuce.plugins import ( xunit_output, - autopdb + autopdb, + lxc_isolator ) from lettuce import fs from lettuce import exceptions @@ -55,6 +57,23 @@ pass +# force flush calls so lettuce output could be read from pipe +class _Stdout(object): + + def __init__(self): + self._obj = sys.stdout + + def __getattr__(self, attr): + return getattr(self._obj, attr) + + def write(self, s): + # mb flush every X bytes? + self._obj.write(s) + self._obj.flush() + +sys.stdout = _Stdout() + + __all__ = [ 'after', 'before', @@ -65,18 +84,41 @@ 'call_hook', ] -try: - terrain = fs.FileSystem._import("terrain") - reload(terrain) -except Exception, e: - if not "No module named terrain" in str(e): - string = 'Lettuce has tried to load the conventional environment ' \ - 'module "terrain"\nbut it has errors, check its contents and ' \ - 'try to run lettuce again.\n\nOriginal traceback below:\n\n' - sys.stderr.write(string) - sys.stderr.write(exceptions.traceback.format_exc(e)) - raise SystemExit(1) +terrain = None + + +def import_terrain(terrain_file="terrain"): + try: + global terrain + filename = os.path.basename(terrain_file) + dirname = os.path.dirname(terrain_file) + sys.path.insert(0, dirname) + terrain = __import__(filename.split('.')[0]) + sys.path.remove(dirname) + # commented for possible restore + # terrain = fs.FileSystem._import(terrain_file) + # reload(terrain) + except Exception, e: + if not "No module named terrain" in str(e): + string = 'Lettuce has tried to load the conventional environment ' \ + 'module "terrain"\nbut it has errors, check its contents and ' \ + 'try to run lettuce again.\n\nOriginal traceback below:\n\n' + + sys.stderr.write(string) + sys.stderr.write(exceptions.traceback.format_exc(e)) + raise SystemExit(1) + + +plugins = [] + + +def import_plugins(plugins_dir): + sys.path.insert(0, plugins_dir) + for filename in os.listdir(plugins_dir): + if not filename.startswith('_') and filename.endswith('.py'): + plugin = __import__(filename.split('.')[0]) + plugins.append(plugin) class Runner(object): @@ -87,7 +129,8 @@ class Runner(object): """ def __init__(self, base_path, scenarios=None, verbosity=0, random=False, enable_xunit=False, xunit_filename=None, tags=None, - failfast=False, auto_pdb=False): + failfast=False, auto_pdb=False, files_to_load=None, + excluded_files=None): """ lettuce.Runner will try to find a terrain.py file and import it from within `base_path` """ @@ -100,7 +143,9 @@ def __init__(self, base_path, scenarios=None, verbosity=0, random=False, base_path = os.path.dirname(base_path) sys.path.insert(0, base_path) - self.loader = fs.FeatureLoader(base_path) + self.loader = fs.FeatureLoader(base_path, + files_to_load, + excluded_files) self.verbosity = verbosity self.scenarios = scenarios and map(int, scenarios.split(",")) or None self.failfast = failfast @@ -133,6 +178,7 @@ def run(self): """ Find and load step definitions, and them find and load features under `base_path` specified on constructor """ + started_at = datetime.now() try: self.loader.find_and_load_step_definitions() except StepLoadingError, e: @@ -185,4 +231,19 @@ def run(self): if failed: raise SystemExit(2) + finished_at = datetime.now() + time_took = finished_at - started_at + + hours = time_took.seconds / 60 / 60 + minutes = time_took.seconds / 60 + seconds = time_took.seconds + if hours: + print "(finished within %d hours, %d minutes, %d seconds)" % \ + (hours, minutes % 60, seconds % 60) + elif minutes: + print "(finished within %d minutes, %d seconds)" % \ + (minutes, seconds % 60) + elif seconds: + print "(finished within %d seconds)" % seconds + return total diff --git a/lettuce/bin.py b/lettuce/bin.py index f35fb0bb8..588c27903 100755 --- a/lettuce/bin.py +++ b/lettuce/bin.py @@ -20,6 +20,39 @@ import optparse import lettuce +from fs import FeatureLoader +from core import Language + +FILES_TO_LOAD_HEADER = 'Using step definitions from:' + + +def find_files_to_load(path): + feature_files = None + if path.endswith('.feature'): + feature_files = [path] + else: + loader = FeatureLoader(path) + feature_files = loader.find_feature_files() + + result = [] + for f in feature_files: + with open(f, 'r') as fp: + while True: + line = fp.readline() + if line == '': + break + + line = line.lstrip() + if line.startswith(Language.feature): + break + + if line.startswith(FILES_TO_LOAD_HEADER): + files_to_load_str = line[len(FILES_TO_LOAD_HEADER):] + files = files_to_load_str.split(',') + result.extend([name.strip() for name in files]) + break + + return result def main(args=sys.argv[1:]): @@ -78,6 +111,42 @@ def main(args=sys.argv[1:]): action="store_true", help='Launches an interactive debugger upon error') + parser.add_option("--plugins-dir", + dest="plugins_dir", + default=None, + type="string", + help='Sets plugins directory') + + parser.add_option("--terrain-file", + dest="terrain_file", + default=None, + type="string", + help='Sets terrain file') + + parser.add_option("--files-to-load", + dest="files_to_load", + default=None, + type="string", + help='Usage: \n' + 'lettuce some/dir --files-to-load file1[,file2[,file3...]]' + '\n' + 'Defines list of .py files that needs to be loaded. ' + 'You can use regular expressions for filenames. ' + 'Use either this option or --excluded-files, ' + 'but not them both.') + + parser.add_option("--excluded-files", + dest="excluded_files", + default=None, + type="string", + help='Usage: \n' + 'lettuce some/dir --files-to-load file1[,file2[,file3...]]' + '\n' + 'Defines list of .py files that should not be loaded. ' + 'You can use regular expressions for filenames. ' + 'Use either this option, or --files-to-load, ' + 'but not them both.') + options, args = parser.parse_args(args) if args: base_path = os.path.abspath(args[0]) @@ -91,6 +160,34 @@ def main(args=sys.argv[1:]): if options.tags: tags = [tag.strip('@') for tag in options.tags] + # Terrain file loading + feature_dir = base_path if not base_path.endswith('.feature') \ + else os.path.dirname(base_path) + terrain_file = options.terrain_file or \ + os.environ.get('LETTUCE_TERRAIN_FILE', + os.path.join(feature_dir, 'terrain')) + if not os.path.exists(terrain_file + '.py'): + terrain_file = 'terrain' + lettuce.import_terrain(terrain_file) + + # Plugins loading + plugins_dir = options.plugins_dir or os.environ.get('LETTUCE_TERRAIN_FILE', + None) + if plugins_dir: + lettuce.import_plugins(options.plugins_dir) + + # Find files to load that are defined in .feature file + files_to_load = None + excluded_files = None + + if options.files_to_load: + files_to_load = options.files_to_load.split(',') + elif options.excluded_files: + excluded_files = options.excluded_files.split(',') + else: + files_to_load = find_files_to_load(base_path) + + # Create and run lettuce runner instance runner = lettuce.Runner( base_path, scenarios=options.scenarios, @@ -101,6 +198,8 @@ def main(args=sys.argv[1:]): failfast=options.failfast, auto_pdb=options.auto_pdb, tags=tags, + files_to_load=files_to_load, + excluded_files=excluded_files, ) result = runner.run() diff --git a/lettuce/core.py b/lettuce/core.py index 1e7a49027..e43b4e7ac 100644 --- a/lettuce/core.py +++ b/lettuce/core.py @@ -336,11 +336,12 @@ def _parse_remaining_lines(self, lines): keys, hashes = strings.parse_hashes(lines) return keys, hashes, multiline - def _get_match(self, ignore_case): + def _get_match(self, ignore_case, custom_sentence=None): matched, func = None, lambda: None + sentence = custom_sentence or self.sentence for regex, func in STEP_REGISTRY.items(): - matched = re.search(regex, self.sentence, ignore_case and re.I or 0) + matched = re.search(regex, sentence, ignore_case and re.I or 0) if matched: break @@ -461,10 +462,15 @@ def run_all(steps, outline=None, run_callbacks=False, ignore_case=True, failfast steps_undefined.append(e.step) except Exception, e: - if failfast: - raise steps_failed.append(step) reasons_to_fail.append(step.why) + if failfast: + # raise + return (all_steps, + steps_passed, + steps_failed, + steps_undefined, + reasons_to_fail) finally: all_steps.append(step) @@ -627,9 +633,14 @@ def matches_tags(self, tags): matched = [] if isinstance(self.tags, list): - for tag in self.tags: - if tag in tags: - return True + match = False + for tag in tags: + if tag.startswith('-') and tag[1:] in self.tags: + return False + if tag in self.tags: + match = True + if match: + return True else: self.tags = [] @@ -1183,7 +1194,14 @@ def run(self, scenarios=None, ignore_case=True, tags=None, random=False, failfas if not scenario.matches_tags(tags): continue - scenarios_ran.extend(scenario.run(ignore_case, failfast=failfast)) + if self.background: + self.background.run(ignore_case) + + scenario_run_results = scenario.run(ignore_case, failfast=failfast) + scenarios_ran.extend(scenario_run_results) + any_outline_failed = any(s.steps_failed for s in scenario_run_results) + if failfast and any_outline_failed: + break except: if failfast: call_hook('after_each', 'feature', self) diff --git a/lettuce/fs.py b/lettuce/fs.py index f081f4377..a657d123e 100644 --- a/lettuce/fs.py +++ b/lettuce/fs.py @@ -29,17 +29,32 @@ class FeatureLoader(object): """Loader class responsible for findind features and step definitions along a given path on filesystem""" - def __init__(self, base_dir): + def __init__(self, base_dir, files_to_load=None, excluded_files=None): self.base_dir = FileSystem.abspath(base_dir) + def _normalize_filenames(file_list): + return [r'.*%s\.py$' % f.split('.')[0] for f in file_list] + + # we can only have files_to_load or excluded_files, but not both + self.files_to_load = None + self.excluded_files = None + if files_to_load: + self.files_to_load = _normalize_filenames(files_to_load) + elif excluded_files: + self.excluded_files = _normalize_filenames(excluded_files) + def find_and_load_step_definitions(self): - # find steps, possibly up several directories - base_dir = self.base_dir - while base_dir != '/': - files = FileSystem.locate(base_dir, '*.py') - if files: - break - base_dir = FileSystem.join(base_dir, '..') + files = FileSystem.locate(self.base_dir, '*.py') + + def _matches_any(str_, pattern_list): + return any(map(lambda p: re.match(p, str_), pattern_list)) + + if self.files_to_load: + is_file_wanted = lambda f: _matches_any(f, self.files_to_load) + files = filter(is_file_wanted, files) + elif self.excluded_files: + is_file_wanted = lambda f: not _matches_any(f, self.excluded_files) + files = filter(is_file_wanted, files) for filename in files: root = FileSystem.dirname(filename) diff --git a/lettuce/plugins/lxc_isolator.py b/lettuce/plugins/lxc_isolator.py new file mode 100644 index 000000000..4379404d0 --- /dev/null +++ b/lettuce/plugins/lxc_isolator.py @@ -0,0 +1,252 @@ +import os +import pickle +import subprocess +import sys +import re +from time import sleep + +from lettuce import core +from lettuce import world +from lettuce.terrain import after +from lettuce.terrain import before + + +LXC_RUNNER_TAG = 'lxc' + + +def system(cmd, *args): + p = subprocess.Popen(('%s ' % cmd) + ' '.join(args), + shell=True, stdout=subprocess.PIPE) + out, err = p.communicate() + return out, err, p.returncode + + +def lxc_command(cmd, *args): + return system('lxc-%s' % cmd, *args) + + +container_rc_local_template = '''#!/bin/sh +%(exports)s +cd %(work_dir)s +/usr/local/bin/lettuce -s %(scenario)s %(feature_path)s > %(results_path)s +halt +''' + + +class LXCRunner(object): + """ + Encapsulates scenario run in LXC container. + Performs container setup, scenario run and result displaying + """ + + containers_path = '/var/lib/lxc/' + default_container_name = os.environ.get('LETTUCE_LXC_DEFAULT', 'default') + world_file_inner_path = '/root/world.dump' + run_results_inner_path = '/root/run_results' + run_results_str_regex = r'Scenario.*\n\n' + run_results_stats_regex = r'step(|s) \(.*\)' + + def get_free_container_name(self, prefix): + """ + Iterate over containers_path and find free container name + with format '.' where num is in range of 00000-99999 + """ + + containers = filter(lambda x: + x.startswith(self.default_container_name + '.'), + os.listdir(self.containers_path)) + containers.sort() + container_num = 0 + if containers: + container_num = int(containers[-1].split('.')[1]) + 1 + return '%s.%05d' % (self.default_container_name, container_num) + + def __init__(self): + super(LXCRunner, self).__init__() + self.saved_runall = None + self.container_name = None + self.scenario = None + + @property + def container_rootfs(self): + if not self.container_name: + return None + return os.path.join(self.containers_path, + self.container_name, + 'rootfs') + + def lxc_abs_path(self, path): + if path.startswith('/'): + path = path[1:] + return os.path.join(self.container_rootfs, path) + + @property + def scenario_index(self): + scenario_list = self.scenario.feature.scenarios + return scenario_list.index(self.scenario) + 1 + + def run_scenario(self, scenario): + self.scenario = scenario + self.setup_container() + world_path = self.lxc_abs_path(self.world_file_inner_path) + self.save_world(world_path) + self.run_container() + self.wait_container() + results = self.get_run_results() + self.shutdown_container() + self.display_results(results[0]) + return results[1] + + def create_container(self): + self.container_name = self.get_free_container_name(self.default_container_name) + lxc_command('clone', '-o %s -n %s' % (self.default_container_name, + self.container_name)) + + def setup_container(self): + self.create_container() + + system('cp', '-rf', + '/vagrant', + self.lxc_abs_path('/vagrant')) + container_working_dir = os.path.abspath('.') + feature_path = sys.argv[-1] + + # setup start scripts + # we assume that created container already have lettuce installed + script_path = self.lxc_abs_path('/etc/rc.local') + with open(script_path, 'w') as fp: + def _export_env_var(acc, keyvalue): + return "%sexport %s='%s'\n" % ((acc,) + keyvalue) + + export_str = reduce(_export_env_var, os.environ.items(), '') + + fp.write(container_rc_local_template % { + 'exports': export_str, + 'work_dir': container_working_dir, + 'scenario': self.scenario_index, + 'feature_path': feature_path, + 'results_path': self.run_results_inner_path}) + os.chmod(script_path, 0755) + + def save_world(self, filepath): + with open(filepath, 'w') as f: + for var in dir(world): + if not var.startswith('_') and var not in ('absorb', 'spew'): + pickle.dump((var, world.__getattribute__(var)), f) + + def load_world(self, path): + with open(path, 'r') as f: + while True: + try: + attr = pickle.load(f) + world.__setattr__(attr[0], attr[1]) + except EOFError: + break + + def run_container(self): + return_code = lxc_command('start', + '-d', + '-n ' + self.container_name)[2] + if return_code != 0: + raise BaseException('Container failed to start') + + def wait_container(self): + """ + Waits for run_results_inner_path with /proc/x poll. + """ + + while True: + ps_out = system('ps', 'auxf')[0] + + match = re.search(r'-n %s.*' % self.container_name, + ps_out, + re.DOTALL) + if not match: + return + + match = re.search(r'.*lettuce.*', match.group()) + if match: + lxc_lettuce_pid = match.group().split()[1] + while os.path.exists('/proc/%s' % lxc_lettuce_pid): + sleep(1) + return + sleep(1) + + def get_run_results(self): + """ + Reads file on run_results_inner_path. + Returns pair with string representation of step run result + and tuple of number failed, skipped and passed steps + """ + path = self.lxc_abs_path(self.run_results_inner_path) + with open(path, 'r') as fp: + lettuce_out = fp.read() + match = re.search(self.run_results_str_regex, + lettuce_out, + re.DOTALL) + results = match.group() + second_line_start = results.index('\n') + 1 + + run_result_str = results[second_line_start:].strip() + + # statistics + match = re.search(self.run_results_stats_regex, lettuce_out) + stats = match.group() + + def _get_steps_num(type_): + match = re.search(r'\d+ %s' % type_, stats) + if not match: + return 0 + match_str = match.group() + return int(match_str.split()[0]) + + failed_num = _get_steps_num('failed') + skipped_num = _get_steps_num('skipped') + passed_num = _get_steps_num('passed') + + stats_tuple = (failed_num, skipped_num, passed_num) + return (run_result_str, stats_tuple) + + def shutdown_container(self): + # lxc_command('stop', '-n ' + self.container_name) + lxc_command('destroy', '-n ' + self.container_name) + + def display_results(self, results): + '''Just print results because they are already formatted''' + print results + + +lxc_runner = LXCRunner() + + +@before.each_scenario +def handle_lxc_tag_setup(scenario): + if LXC_RUNNER_TAG in scenario.tags: + # if world dump file is presented, lettuce is runned in LXC + # so we need to restore world + if os.path.exists(lxc_runner.world_file_inner_path): + lxc_runner.load_world(lxc_runner.world_file_inner_path) + return + + for step in scenario.steps: + step.passed = True + step.run = lambda *args, **kwargs: True + step.ran = True + + lxc_runner.saved_runall = core.Step.run_all + + def run_all_mock(*args, **kwargs): + failed, skipped, passed = lxc_runner.run_scenario(scenario) + return (scenario.steps, # all + scenario.steps[:passed], # passed + scenario.steps[passed:passed + failed], # failed + [], # undefined + []) # reasons to fail + + core.Step.run_all = staticmethod(run_all_mock) + + +@after.each_scenario +def handle_lxc_tag_teardown(scenario): + if LXC_RUNNER_TAG in scenario.tags and not os.path.exists(lxc_runner.world_file_inner_path): + core.Step.run_all = staticmethod(lxc_runner.saved_runall) diff --git a/tests/functional/output_features/xunit_unicode_and_bytestring_mixing/xunit_unicode_and_bytestring_mixing.feature b/tests/functional/output_features/xunit_unicode_and_bytestring_mixing/xunit_unicode_and_bytestring_mixing.feature index 0ac8aa119..b1d3074d6 100644 --- a/tests/functional/output_features/xunit_unicode_and_bytestring_mixing/xunit_unicode_and_bytestring_mixing.feature +++ b/tests/functional/output_features/xunit_unicode_and_bytestring_mixing/xunit_unicode_and_bytestring_mixing.feature @@ -1,9 +1,9 @@ -Feature: Mixing of Unicode & bytestrings in xunit xml output - Scenario Outline: It should pass - Given non ascii characters "" in outline - Examples: - | value | - | Значение | - -Scenario Outline: It should pass too - Given non ascii characters "Тест" in step +Feature: Mixing of Unicode & bytestrings in xunit xml output + Scenario Outline: It should pass + Given non ascii characters "" in outline + Examples: + | value | + | Значение | + +Scenario Outline: It should pass too + Given non ascii characters "Тест" in step