diff options
author | Daniel Silverstone <dsilvers+gitlab@digital-scurf.org> | 2020-05-17 09:42:46 +0000 |
---|---|---|
committer | Daniel Silverstone <dsilvers+gitlab@digital-scurf.org> | 2020-05-17 09:42:46 +0000 |
commit | 6d77fdedd7fd0f832a543b7eb530ae15984aa377 (patch) | |
tree | f354dde229b1cd41e75e3aaa76c9487766bce7c6 | |
parent | 5db9eef3e02f7e3b344becd08c74a99fb274f2ed (diff) | |
parent | f276c028dd4869a97129140cea28f02f3889ac47 (diff) | |
download | subplot-6d77fdedd7fd0f832a543b7eb530ae15984aa377.tar.gz |
Merge branch 'finally2' into 'master'
Add cleanup support to scenario steps
Closes #51
See merge request larswirzenius/subplot!42
-rw-r--r-- | src/bindings.rs | 95 | ||||
-rw-r--r-- | src/matches.rs | 4 | ||||
-rw-r--r-- | subplot.md | 185 | ||||
-rw-r--r-- | subplot.py | 30 | ||||
-rw-r--r-- | subplot.yaml | 16 | ||||
-rw-r--r-- | templates/bash/template.sh | 68 | ||||
-rw-r--r-- | templates/python/template.py | 143 |
7 files changed, 460 insertions, 81 deletions
diff --git a/src/bindings.rs b/src/bindings.rs index 946e8fb..67c45d6 100644 --- a/src/bindings.rs +++ b/src/bindings.rs @@ -17,22 +17,29 @@ use regex::{escape, Regex}; /// Contains the pattern used to match against scenario steps, /// combined with the step kind. The pattern is a regular expression /// as understood by the regex crate. -#[derive(Debug)] +#[derive(Debug, Clone)] pub struct Binding { kind: StepKind, pattern: String, regex: Regex, function: String, + cleanup: Option<String>, } impl Binding { /// Create a new Binding, from a step kind and a pattern. - pub fn new(kind: StepKind, pattern: &str, function: &str) -> Result<Binding> { + pub fn new( + kind: StepKind, + pattern: &str, + function: &str, + cleanup: Option<&str>, + ) -> Result<Binding> { Ok(Binding { kind, pattern: pattern.to_owned(), regex: Regex::new(&format!("^{}$", pattern))?, function: function.to_string(), + cleanup: cleanup.map(String::from), }) } @@ -51,6 +58,14 @@ impl Binding { &self.function } + /// Return name of function that implements cleanup. + pub fn cleanup(&self) -> Option<&str> { + match self.cleanup { + None => None, + Some(ref s) => Some(&s), + } + } + /// Return the compiled regular expression for the pattern of the /// binding. /// @@ -69,7 +84,7 @@ impl Binding { let caps = self.regex.captures(step_text)?; // If there is only one capture, it's the whole string. - let mut m = MatchedStep::new(self.kind(), &self.function); + let mut m = MatchedStep::new(self.kind(), &self.function, self.cleanup()); if caps.len() == 1 { m.append_part(PartialStep::uncaptured(step_text)); return Some(m); @@ -125,12 +140,6 @@ impl PartialEq for Binding { impl Eq for Binding {} -impl Clone for Binding { - fn clone(&self) -> Binding { - Binding::new(self.kind, self.pattern.as_str(), &self.function).unwrap() - } -} - #[cfg(test)] mod test_binding { use super::Binding; @@ -139,47 +148,65 @@ mod test_binding { use crate::StepKind; #[test] - fn creates_new() { - let b = Binding::new(StepKind::Given, "I am Tomjon", "set_name").unwrap(); + fn creates_new_without_cleanup() { + let b = Binding::new(StepKind::Given, "I am Tomjon", "set_name", None).unwrap(); + assert_eq!(b.kind(), StepKind::Given); + assert!(b.regex().is_match("I am Tomjon")); + assert!(!b.regex().is_match("I am Tomjon of Lancre")); + assert!(!b.regex().is_match("Hello, I am Tomjon")); + assert_eq!(b.function(), "set_name"); + assert_eq!(b.cleanup(), None); + } + + #[test] + fn creates_new_with_cleanup() { + let b = Binding::new( + StepKind::Given, + "I am Tomjon", + "set_name", + Some("unset_name"), + ) + .unwrap(); assert_eq!(b.kind(), StepKind::Given); assert!(b.regex().is_match("I am Tomjon")); assert!(!b.regex().is_match("I am Tomjon of Lancre")); assert!(!b.regex().is_match("Hello, I am Tomjon")); assert_eq!(b.function(), "set_name"); + assert_eq!(b.cleanup(), Some("unset_name")); } #[test] fn equal() { - let a = Binding::new(StepKind::Given, "I am Tomjon", "set_name").unwrap(); - let b = Binding::new(StepKind::Given, "I am Tomjon", "set_name").unwrap(); + let a = Binding::new(StepKind::Given, "I am Tomjon", "set_name", Some("unset")).unwrap(); + let b = Binding::new(StepKind::Given, "I am Tomjon", "set_name", Some("unset")).unwrap(); assert_eq!(a, b); } #[test] fn not_equal() { - let a = Binding::new(StepKind::Given, "I am Tomjon", "set_name").unwrap(); - let b = Binding::new(StepKind::Given, "I am Tomjon of Lancre", "set_name").unwrap(); + let a = Binding::new(StepKind::Given, "I am Tomjon", "set_name", None).unwrap(); + let b = Binding::new(StepKind::Given, "I am Tomjon of Lancre", "set_name", None).unwrap(); assert_ne!(a, b); } #[test] fn does_not_match_with_wrong_kind() { let step = ScenarioStep::new(StepKind::Given, "given", "yo"); - let b = Binding::new(StepKind::When, "yo", "do_yo").unwrap(); + let b = Binding::new(StepKind::When, "yo", "do_yo", None).unwrap(); assert!(b.match_with_step(&step).is_none()); } #[test] fn does_not_match_with_wrong_text() { let step = ScenarioStep::new(StepKind::Given, "given", "foo"); - let b = Binding::new(StepKind::Given, "bar", "yo").unwrap(); + let b = Binding::new(StepKind::Given, "bar", "yo", None).unwrap(); assert!(b.match_with_step(&step).is_none()); } #[test] fn match_with_fixed_pattern() { let step = ScenarioStep::new(StepKind::Given, "given", "foo"); - let b = Binding::new(StepKind::Given, "foo", "do_foo").unwrap(); + let b = Binding::new(StepKind::Given, "foo", "do_foo", None).unwrap(); let m = b.match_with_step(&step).unwrap(); assert_eq!(m.kind(), StepKind::Given); let mut parts = m.parts(); @@ -191,7 +218,13 @@ mod test_binding { #[test] fn match_with_regex() { let step = ScenarioStep::new(StepKind::Given, "given", "I am Tomjon, I am"); - let b = Binding::new(StepKind::Given, r"I am (?P<who>\S+), I am", "set_name").unwrap(); + let b = Binding::new( + StepKind::Given, + r"I am (?P<who>\S+), I am", + "set_name", + None, + ) + .unwrap(); let m = b.match_with_step(&step).unwrap(); assert_eq!(m.kind(), StepKind::Given); let mut parts = m.parts(); @@ -220,6 +253,7 @@ struct ParsedBinding { when: Option<String>, then: Option<String>, function: String, + cleanup: Option<String>, regex: Option<bool>, } @@ -331,7 +365,15 @@ fn from_hashmap(parsed: &ParsedBinding) -> Result<Binding> { regex_from_simple_pattern(pattern)? }; - Ok(Binding::new(kind, &pattern, &parsed.function)?) + Ok(Binding::new( + kind, + &pattern, + &parsed.function, + match parsed.cleanup { + None => None, + Some(ref s) => Some(s), + }, + )?) } #[cfg(test)] @@ -351,7 +393,8 @@ mod test_bindings { #[test] fn adds_binding() { - let binding = Binding::new(StepKind::Given, r"I am (?P<name>\S+)", "set_name").unwrap(); + let binding = + Binding::new(StepKind::Given, r"I am (?P<name>\S+)", "set_name", None).unwrap(); let mut bindings = Bindings::new(); bindings.add(&binding); assert_eq!(bindings.bindings(), &[binding]); @@ -393,7 +436,7 @@ mod test_bindings { #[test] fn does_not_find_match_for_unmatching_kind() { let step = ScenarioStep::new(StepKind::Given, "given", "I am Tomjon"); - let binding = Binding::new(StepKind::When, r"I am Tomjon", "set_foo").unwrap(); + let binding = Binding::new(StepKind::When, r"I am Tomjon", "set_foo", None).unwrap(); let mut bindings = Bindings::new(); bindings.add(&binding); assert!(bindings.find(&step).is_none()); @@ -402,7 +445,8 @@ mod test_bindings { #[test] fn does_not_find_match_for_unmatching_pattern() { let step = ScenarioStep::new(StepKind::Given, "given", "I am Tomjon"); - let binding = Binding::new(StepKind::Given, r"I am Tomjon of Lancre", "set_foo").unwrap(); + let binding = + Binding::new(StepKind::Given, r"I am Tomjon of Lancre", "set_foo", None).unwrap(); let mut bindings = Bindings::new(); bindings.add(&binding); assert!(bindings.find(&step).is_none()); @@ -411,7 +455,7 @@ mod test_bindings { #[test] fn finds_match_for_fixed_string_pattern() { let step = ScenarioStep::new(StepKind::Given, "given", "I am Tomjon"); - let binding = Binding::new(StepKind::Given, r"I am Tomjon", "set_name").unwrap(); + let binding = Binding::new(StepKind::Given, r"I am Tomjon", "set_name", None).unwrap(); let mut bindings = Bindings::new(); bindings.add(&binding); let m = bindings.find(&step).unwrap(); @@ -428,7 +472,8 @@ mod test_bindings { #[test] fn finds_match_for_regexp_pattern() { let step = ScenarioStep::new(StepKind::Given, "given", "I am Tomjon"); - let binding = Binding::new(StepKind::Given, r"I am (?P<name>\S+)", "set_name").unwrap(); + let binding = + Binding::new(StepKind::Given, r"I am (?P<name>\S+)", "set_name", None).unwrap(); let mut bindings = Bindings::new(); bindings.add(&binding); let m = bindings.find(&step).unwrap(); diff --git a/src/matches.rs b/src/matches.rs index 89cf791..27a71e1 100644 --- a/src/matches.rs +++ b/src/matches.rs @@ -46,16 +46,18 @@ pub struct MatchedStep { text: String, parts: Vec<PartialStep>, function: String, + cleanup: Option<String>, } impl MatchedStep { /// Return a new empty match. Empty means it has no step parts. - pub fn new(kind: StepKind, function: &str) -> MatchedStep { + pub fn new(kind: StepKind, function: &str, cleanup: Option<&str>) -> MatchedStep { MatchedStep { kind, text: "".to_string(), parts: vec![], function: function.to_string(), + cleanup: cleanup.map(String::from), } } @@ -691,6 +691,191 @@ then bar was done ~~~~ +## Automatic cleanup in scenarios + +A binding can define a cleanup function, which gets called at the end +of the scenario in reverse order for the successful steps. If a step +fails, all the cleanups for the successful steps are still called. We +test this for every language templat we support. + +~~~{#cleanup.yaml .file .yaml .numberLines} +- given: foo + function: foo + cleanup: foo_cleanup +- given: bar + function: bar + cleanup: bar_cleanup +- given: failure + function: failure + cleanup: failure_cleanup +~~~ + +~~~{#cleanup.py .file .python .numberLines} +def foo(ctx): + pass +def foo_cleanup(ctx): + pass +def bar(ctx): + pass +def bar_cleanup(ctx): + pass +def failure(ctx): + assert 0 +def failure_cleanup(ctx): + pass +~~~ + +~~~{#cleanup.sh .file .bash .numberLines} +foo() { + true +} +foo_cleanup() { + true +} +bar() { + true +} +bar_cleanup() { + true +} +failure() { + return 1 +} +failure_cleanup() { + true +} +~~~ + + +### Cleanup functions gets called on success (Python) + +~~~scenario +given file cleanup-success-python.md +and file cleanup.yaml +and file cleanup.py +when I run sp-codegen --run cleanup-success-python.md -o test.py +then scenario "Cleanup" was run +and step "given foo" was run, and then step "given bar" +and cleanup for "given bar" was run, and then for "given foo" +and program finished successfully +~~~ + + +~~~~~{#cleanup-success-python.md .file .markdown .numberLines} +--- +title: Cleanup +bindings: cleanup.yaml +functions: cleanup.py +template: python +... + +# Cleanup + +~~~scenario +given foo +given bar +~~~ +~~~~~ + + +### Cleanup functions get called on failure (Python) + +~~~scenario +given file cleanup-fail-python.md +and file cleanup.yaml +and file cleanup.py +when I try to run sp-codegen --run cleanup-fail-python.md -o test.py +then scenario "Cleanup" was run +and step "given foo" was run, and then step "given bar" +and cleanup for "given bar" was run, and then for "given foo" +and cleanup for "given failure" was not run +and exit code is non-zero +~~~ + +~~~~~{#cleanup-fail-python.md .file .markdown .numberLines} +--- +title: Cleanup +bindings: cleanup.yaml +functions: cleanup.py +template: python +... + +# Cleanup + +~~~scenario +given foo +given bar +given failure +~~~ +~~~~~ + + +### Cleanup functions gets called on success (Bash) + +~~~scenario +given file cleanup-success-bash.md +and file cleanup.yaml +and file cleanup.sh +when I run sp-codegen --run cleanup-success-bash.md -o test.sh +then scenario "Cleanup" was run +and step "given foo" was run, and then step "given bar" +and cleanup for "given bar" was run, and then for "given foo" +and program finished successfully +~~~ + +~~~~~{#cleanup-success-bash.md .file .markdown .numberLines} +--- +title: Cleanup +bindings: cleanup.yaml +functions: cleanup.sh +template: bash +... + +# Cleanup + +~~~scenario +given foo +given bar +~~~ +~~~~~ + + +### Cleanup functions get called on failure (Bash) + +If a step fails, all the cleanups for the preceding steps are still +called, in reverse order. + +~~~scenario +given file cleanup-fail-bash.md +and file cleanup.yaml +and file cleanup.sh +when I try to run sp-codegen --run cleanup-fail-bash.md -o test.sh +then scenario "Cleanup" was run +and step "given foo" was run, and then step "given bar" +and cleanup for "given bar" was run, and then for "given foo" +and cleanup for "given failure" was not run +and exit code is non-zero +~~~ + +~~~~~{#cleanup-fail-bash.md .file .markdown .numberLines} +--- +title: Cleanup +bindings: cleanup.yaml +functions: cleanup.sh +template: bash +... + +# Cleanup + +~~~scenario +given foo +given bar +given failure +~~~ +~~~~~ + + + ## Capturing parts of steps for functions A scenario step binding can capture parts of a scenario step, to be @@ -35,14 +35,14 @@ def run_docgen_with_date(ctx, md=None, output=None, date=None): exit_code_zero(ctx) -def try_codegen_and_program(ctx, filename=None): +def try_codegen_and_program(ctx, filename=None, testprog=None): codegen = binary("sp-codegen") tmpldir = os.path.join(srcdir, "templates") - runcmd(ctx, [codegen, filename, "-o", "test.py", "--run", "--templates", tmpldir]) + runcmd(ctx, [codegen, filename, "-o", testprog, "--run", "--templates", tmpldir]) -def run_codegen_and_program(ctx, filename=None): - try_codegen_and_program(ctx, filename=filename) +def run_codegen_and_program(ctx, filename=None, testprog=None): + try_codegen_and_program(ctx, filename=filename, testprog=testprog) exit_code_zero(ctx) @@ -111,6 +111,28 @@ def step_was_run(ctx, keyword=None, name=None): stdout_matches(ctx, pattern="\n step: {} {}\n".format(keyword, name)) +def step_was_run_and_then(ctx, keyword1=None, name1=None, keyword2=None, name2=None): + stdout_matches( + ctx, + pattern="\n step: {} {}\n step: {} {}".format( + keyword1, name1, keyword2, name2 + ), + ) + + +def cleanup_was_run(ctx, keyword1=None, name1=None, keyword2=None, name2=None): + stdout_matches( + ctx, + pattern="\n cleanup: {} {}\n cleanup: {} {}\n".format( + keyword1, name1, keyword2, name2 + ), + ) + + +def cleanup_was_not_run(ctx, keyword=None, name=None): + stdout_does_not_match(ctx, pattern="\n cleanup: {} {}\n".format(keyword, name)) + + def exit_code_zero(ctx): if ctx.get("exit") != 0: print("context:", ctx.as_dict()) diff --git a/subplot.yaml b/subplot.yaml index 607e361..8d4187f 100644 --- a/subplot.yaml +++ b/subplot.yaml @@ -17,13 +17,13 @@ - when: I try to run sp-docgen {md} -o {output} function: try_docgen -- when: I run sp-codegen --run {filename} -o test.py +- when: I run sp-codegen --run {filename} -o {testprog} function: run_codegen_and_program - when: I run sp-codegen {filename} -o {testprog} function: run_codegen -- when: I try to run sp-codegen --run {filename} -o test.py +- when: I try to run sp-codegen --run {filename} -o {testprog} function: try_codegen_and_program - when: I run python3 {testprog} {pattern} @@ -84,6 +84,18 @@ function: step_was_run regex: true +- then: step "(?P<keyword1>given|when|then) (?P<name1>.+)" was run, and then step "(?P<keyword2>given|when|then) (?P<name2>.+)" + function: step_was_run_and_then + regex: true + +- then: cleanup for "(?P<keyword1>given|when|then) (?P<name1>.+)" was run, and then for "(?P<keyword2>given|when|then) (?P<name2>.+)" + function: cleanup_was_run + regex: true + +- then: cleanup for "(?P<keyword>given|when|then) (?P<name>.+)" was not run + function: cleanup_was_not_run + regex: true + - then: program finished successfully function: exit_code_zero diff --git a/templates/bash/template.sh b/templates/bash/template.sh index c0a6e7e..bc6a51e 100644 --- a/templates/bash/template.sh +++ b/templates/bash/template.sh @@ -167,28 +167,62 @@ files_set "$filename" "$contents" ###################################### # Scenario: {{ scenario.title }} scenario_{{ loop.index }}() { - local _title _scendir _step _name _text + local title scendir step name text ret cleanups steps + declare -a cleanups + declare -a steps - _title="$(decode_base64 '{{ scenario.title | base64 }}')" - echo "scenario: $_title" + title="$(decode_base64 '{{ scenario.title | base64 }}')" + echo "scenario: $title" - _scendir="$(mktemp -d -p "$_datadir")" - cd "$_scendir" + scendir="$(mktemp -d -p "$_datadir")" + cd "$scendir" ctx_new + cleanups[0]='' + steps[0]='' + ret=0 + {% for step in scenario.steps %} - # Step: {{ step.text }} - _step="$(decode_base64 '{{ step.text | base64 }}')" - echo " step: {{ step.kind | lower }} $_step" - - cap_new - {% for part in step.parts %}{% if part.CapturedText is defined -%} - _name="$(decode_base64 '{{ part.CapturedText.name | base64 }}')" - _text="$(decode_base64 '{{ part.CapturedText.text | base64 }}')" - cap_set "$_name" "$_text" - {% endif -%} - {% endfor -%} - {{ step.function }} + if [ "$ret" = 0 ] + then + # Step: {{ step.text }} + step="{{ step.kind | lower }} $(decode_base64 '{{ step.text | base64 }}')" + echo " step: $step" + + cap_new + {% for part in step.parts %}{% if part.CapturedText is defined -%} + name="$(decode_base64 '{{ part.CapturedText.name | base64 }}')" + text="$(decode_base64 '{{ part.CapturedText.text | base64 }}')" + cap_set "$name" "$text" + {% endif -%} + {% endfor -%} + if {{ step.function }} + then + cleanup='{{ step.cleanup }}' + if [ "$cleanup" != "" ] + then + {% raw %} + i=${#cleanups} + cleanups[$i]="$cleanup" + steps[$i]="$step" + {% endraw %} + fi + else + ret=$? + fi + fi {% endfor %} + + {% raw %} + echo "${!cleanups[*]}" | tr ' ' '\n' | tac | while read i + do + step="${steps[$i]}" + func="${cleanups[$i]}" + echo " cleanup: $step" + $func + done + {% endraw %} + + return $ret } {% endfor %} diff --git a/templates/python/template.py b/templates/python/template.py index 62b1fc9..76d8239 100644 --- a/templates/python/template.py +++ b/templates/python/template.py @@ -82,36 +82,115 @@ os.chdir(_datadir) ############################################################################# # Code to implement the scenarios. + +class Step: + + def __init__(self): + self._kind = None + self._text = None + self._args = {} + self._function = None + self._cleanup = None + + def set_kind(self, kind): + self._kind = kind + + def set_text(self, text): + self._text = text + + def set_arg(self, name, value): + self._args[name] = value + + def set_function(self, function): + self._function = function + + def set_cleanup(self, cleanup): + self._cleanup = cleanup + + def do(self, ctx): + print(' step: {} {}'.format(self._kind, self._text)) + logging.info(' step: {} {}'.format(self._kind, self._text)) + self._function(ctx, **self._args) + + def cleanup(self, ctx): + if self._cleanup: + print(' cleanup: {} {}'.format(self._kind, self._text)) + logging.info(' cleanup: {} {}'.format(self._kind, self._text)) + self._cleanup(ctx) + else: + logging.info(' no cleanup defined: {} {}'.format(self._kind, self._text)) + + +class Scenario: + + def __init__(self): + self._title = None + self._steps = [] + + def get_title(self): + return self._title + + def set_title(self, title): + self._title = title + + def append_step(self, step): + self._steps.append(step) + + def run(self): + print('scenario: {}'.format(self._title)) + logging.info("Scenario: {}".format(self._title)) + + scendir = tempfile.mkdtemp(dir=_datadir) + os.chdir(scendir) + + ctx = Context() + done = [] + try: + for step in self._steps: + step.do(ctx) + done.append(step) + except Exception as e: + logging.error(str(e), exc_info=True) + for step in reversed(done): + step.cleanup(ctx) + raise + for step in reversed(done): + step.cleanup(ctx) + + {% for scenario in scenarios %} ###################################### # Scenario: {{ scenario.title }} -def scenario_{{ loop.index }}(): - title = decode_str('{{ scenario.title | base64 }}') - print('scenario: {}'.format(title)) - logging.info("Scenario: {}".format(title)) - _scendir = tempfile.mkdtemp(dir=_datadir) - os.chdir(_scendir) - ctx = Context() - {% for step in scenario.steps %} - # Step: {{ step.text }} - step = decode_str('{{ step.text | base64 }}') - print(' step: {{ step.kind | lower }} {}'.format(step)) - logging.info(' step: {{ step.kind | lower }} {}'.format(step)) - args = {} - {% for part in step.parts %}{% if part.CapturedText is defined -%} - name = decode_str('{{ part.CapturedText.name | base64 }}') - text = decode_str('{{ part.CapturedText.text | base64 }}') - args[name] = text - {% endif -%} - {% endfor -%} - {{ step.function }}(ctx, **args) +class Scenario_{{ loop.index }}(): + def __init__(self): + self._scenario = Scenario() + self._scenario.set_title(decode_str('{{ scenario.title | base64 }}')) + {% for step in scenario.steps %} + # Step: {{ step.text }} + step = Step() + step.set_kind('{{ step.kind | lower }}') + step.set_text(decode_str('{{ step.text | base64 }}')) + step.set_function({{ step.function }}) + if '{{ step.cleanup }}': + step.set_cleanup({{ step.cleanup }}) + self._scenario.append_step(step) + {% for part in step.parts %}{% if part.CapturedText is defined -%} + name = decode_str('{{ part.CapturedText.name | base64 }}') + text = decode_str('{{ part.CapturedText.text | base64 }}') + step.set_arg(name, text) + {% endif -%} + {% endfor -%} {% endfor %} -{% endfor %} -_scenarios = { -{% for scenario in scenarios %} - '{{ scenario.title }}': scenario_{{ loop.index }}, + def get_title(self): + return self._scenario.get_title() + + def run(self): + self._scenario.run() {% endfor %} + +_scenarios = { {% for scenario in scenarios %} + Scenario_{{ loop.index }}(),{% endfor %} } @@ -156,20 +235,20 @@ def main(): logging.info("patterns: {}".format(args.patterns)) if len(args.patterns) == 0: logging.info("Executing all scenarios") - funcs = list(_scenarios.values()) - random.shuffle(funcs) + todo = list(_scenarios) + random.shuffle(todo) else: logging.info("Executing requested scenarios only: {}".format(args.patterns)) patterns = [arg.lower() for arg in args.patterns] - funcs = [ - func - for title, func in _scenarios.items() - if any(pattern in title.lower() for pattern in patterns) + todo = [ + scen + for scen in _scenarios + if any(pattern in scen.get_title().lower() for pattern in patterns) ] try: - for func in funcs: - func() + for scen in todo: + scen.run() except Exception as e: logging.error(str(e), exc_info=True) if args.save_on_failure: |