Skip to content

Commit 240ac6d

Browse files
committed
Fix issues and create a FeatureParser class to consolidate parsing logic
1 parent abe5e79 commit 240ac6d

File tree

4 files changed

+116
-72
lines changed

4 files changed

+116
-72
lines changed

src/pytest_bdd/feature.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@
2929
import glob
3030
import os.path
3131

32-
from .parser import Feature, parse_feature
32+
from .parser import Feature, FeatureParser
3333

3434
# Global features dictionary
3535
features: dict[str, Feature] = {}
@@ -52,7 +52,7 @@ def get_feature(base_path: str, filename: str, encoding: str = "utf-8") -> Featu
5252
full_name = os.path.abspath(os.path.join(base_path, filename))
5353
feature = features.get(full_name)
5454
if not feature:
55-
feature = parse_feature(base_path, filename, encoding=encoding)
55+
feature = FeatureParser(base_path, filename, encoding).parse()
5656
features[full_name] = feature
5757
return feature
5858

src/pytest_bdd/parser.py

Lines changed: 67 additions & 69 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
import textwrap
77
from collections import OrderedDict
88
from dataclasses import dataclass, field
9-
from typing import Any, Iterable, List, Mapping, Optional, Sequence
9+
from typing import Any, Dict, Iterable, List, Mapping, Optional, Sequence
1010

1111
from gherkin.errors import CompositeParserException
1212
from gherkin.parser import Parser
@@ -33,36 +33,6 @@ def strip_comments(line: str) -> str:
3333
return line.strip()
3434

3535

36-
def parse_feature(basedir: str, filename: str, encoding: str = "utf-8") -> Feature:
37-
"""Parse a feature file into a Feature object.
38-
39-
Args:
40-
basedir (str): The base directory of the feature file.
41-
filename (str): The name of the feature file.
42-
encoding (str): The encoding of the feature file (default is "utf-8").
43-
44-
Returns:
45-
Feature: A Feature object representing the parsed feature file.
46-
47-
Raises:
48-
FeatureError: If there is an error parsing the feature file.
49-
"""
50-
abs_filename = os.path.abspath(os.path.join(basedir, filename))
51-
rel_filename = os.path.join(os.path.basename(basedir), filename)
52-
with open(abs_filename, encoding=encoding) as f:
53-
file_contents = f.read()
54-
try:
55-
gherkin_document = Parser().parse(TokenScanner(file_contents))
56-
except CompositeParserException as e:
57-
raise FeatureError(
58-
e.args[0],
59-
e.errors[0].location["line"],
60-
linecache.getline(abs_filename, e.errors[0].location["line"]).rstrip("\n"),
61-
abs_filename,
62-
) from e
63-
return dict_to_feature(abs_filename, rel_filename, gherkin_document)
64-
65-
6636
@dataclass(eq=False)
6737
class Feature:
6838
"""Represents a feature parsed from a feature file.
@@ -293,18 +263,19 @@ def params(self) -> tuple[str, ...]:
293263
return tuple(frozenset(STEP_PARAM_RE.findall(self.name)))
294264

295265
def render(self, context: Mapping[str, Any]) -> str:
296-
"""Render the step name with the given context.
266+
"""Render the step name with the given context, but avoid replacing text inside angle brackets if context is missing.
297267
298268
Args:
299269
context (Mapping[str, Any]): The context for rendering the step name.
300270
301271
Returns:
302-
str: The rendered step name with parameters replaced by their values from the context.
272+
str: The rendered step name with parameters replaced only if they exist in the context.
303273
"""
304274

305275
def replacer(m: re.Match) -> str:
306276
varname = m.group(1)
307-
return str(context.get(varname, f"<missing:{varname}>"))
277+
# If the context contains the variable, replace it. Otherwise, leave it unchanged.
278+
return str(context.get(varname, f"<{varname}>"))
308279

309280
return STEP_PARAM_RE.sub(replacer, self.name)
310281

@@ -333,18 +304,21 @@ def add_step(self, step: Step) -> None:
333304
self.steps.append(step)
334305

335306

336-
def dict_to_feature(abs_filename: str, rel_filename: str, data: dict) -> Feature:
337-
"""Convert a dictionary representation of a feature into a Feature object.
307+
class FeatureParser:
308+
"""Converts a feature file into a Feature object.
338309
339310
Args:
340-
abs_filename (str): The absolute path of the feature file.
341-
rel_filename (str): The relative path of the feature file.
342-
data (dict): The dictionary containing the feature data.
343-
344-
Returns:
345-
Feature: A Feature object representing the parsed feature data.
311+
basedir (str): The basedir for locating feature files.
312+
filename (str): The filename of the feature file.
313+
encoding (str): File encoding of the feature file to parse.
346314
"""
347315

316+
def __init__(self, basedir: str, filename: str, encoding: str = "utf-8"):
317+
self.abs_filename = os.path.abspath(os.path.join(basedir, filename))
318+
self.rel_filename = os.path.join(os.path.basename(basedir), filename)
319+
self.encoding = encoding
320+
321+
@staticmethod
348322
def get_tag_names(tag_data: list[dict]) -> set[str]:
349323
"""Extract tag names from tag data.
350324
@@ -356,6 +330,7 @@ def get_tag_names(tag_data: list[dict]) -> set[str]:
356330
"""
357331
return {tag["name"].lstrip("@") for tag in tag_data}
358332

333+
@staticmethod
359334
def get_step_type(keyword: str) -> str | None:
360335
"""Map a step keyword to its corresponding type.
361336
@@ -371,7 +346,7 @@ def get_step_type(keyword: str) -> str | None:
371346
"then": THEN,
372347
}.get(keyword)
373348

374-
def parse_steps(steps_data: list[dict]) -> list[Step]:
349+
def parse_steps(self, steps_data: list[dict]) -> list[Step]:
375350
"""Parse a list of step data into Step objects.
376351
377352
Args:
@@ -384,7 +359,7 @@ def parse_steps(steps_data: list[dict]) -> list[Step]:
384359
current_step_type = None
385360
for step_data in steps_data:
386361
keyword = step_data["keyword"].strip().lower()
387-
current_step_type = get_step_type(keyword) or current_step_type
362+
current_step_type = self.get_step_type(keyword) or current_step_type
388363
name = strip_comments(step_data["text"])
389364
if "docString" in step_data:
390365
doc_string = textwrap.dedent(step_data["docString"]["content"])
@@ -400,7 +375,7 @@ def parse_steps(steps_data: list[dict]) -> list[Step]:
400375
)
401376
return steps
402377

403-
def parse_scenario(scenario_data: dict, feature: Feature) -> ScenarioTemplate:
378+
def parse_scenario(self, scenario_data: dict, feature: Feature) -> ScenarioTemplate:
404379
"""Parse a scenario data dictionary into a ScenarioTemplate object.
405380
406381
Args:
@@ -415,10 +390,10 @@ def parse_scenario(scenario_data: dict, feature: Feature) -> ScenarioTemplate:
415390
name=strip_comments(scenario_data["name"]),
416391
line_number=scenario_data["location"]["line"],
417392
templated=False,
418-
tags=get_tag_names(scenario_data["tags"]),
393+
tags=self.get_tag_names(scenario_data["tags"]),
419394
description=textwrap.dedent(scenario_data.get("description", "")),
420395
)
421-
for step in parse_steps(scenario_data["steps"]):
396+
for step in self.parse_steps(scenario_data["steps"]):
422397
scenario.add_step(step)
423398

424399
if "examples" in scenario_data:
@@ -436,31 +411,54 @@ def parse_scenario(scenario_data: dict, feature: Feature) -> ScenarioTemplate:
436411

437412
return scenario
438413

439-
def parse_background(background_data: dict, feature: Feature) -> Background:
414+
def parse_background(self, background_data: dict, feature: Feature) -> Background:
440415
background = Background(
441416
feature=feature,
442417
line_number=background_data["location"]["line"],
443418
)
444-
background.steps = parse_steps(background_data["steps"])
419+
background.steps = self.parse_steps(background_data["steps"])
445420
return background
446421

447-
feature_data = data["feature"]
448-
feature = Feature(
449-
scenarios=OrderedDict(),
450-
filename=abs_filename,
451-
rel_filename=rel_filename,
452-
name=strip_comments(feature_data["name"]),
453-
tags=get_tag_names(feature_data["tags"]),
454-
background=None,
455-
line_number=feature_data["location"]["line"],
456-
description=textwrap.dedent(feature_data.get("description", "")),
457-
)
458-
459-
for child in feature_data["children"]:
460-
if "background" in child:
461-
feature.background = parse_background(child["background"], feature)
462-
elif "scenario" in child:
463-
scenario = parse_scenario(child["scenario"], feature)
464-
feature.scenarios[scenario.name] = scenario
465-
466-
return feature
422+
def _parse_feature_file(self) -> dict:
423+
"""Parse a feature file into a Feature object.
424+
425+
Returns:
426+
Dict: A Gherkin document representation of the feature file.
427+
428+
Raises:
429+
FeatureError: If there is an error parsing the feature file.
430+
"""
431+
with open(self.abs_filename, encoding=self.encoding) as f:
432+
file_contents = f.read()
433+
try:
434+
return Parser().parse(TokenScanner(file_contents))
435+
except CompositeParserException as e:
436+
raise FeatureError(
437+
e.args[0],
438+
e.errors[0].location["line"],
439+
linecache.getline(self.abs_filename, e.errors[0].location["line"]).rstrip("\n"),
440+
self.abs_filename,
441+
) from e
442+
443+
def parse(self):
444+
data = self._parse_feature_file()
445+
feature_data = data["feature"]
446+
feature = Feature(
447+
scenarios=OrderedDict(),
448+
filename=self.abs_filename,
449+
rel_filename=self.rel_filename,
450+
name=strip_comments(feature_data["name"]),
451+
tags=self.get_tag_names(feature_data["tags"]),
452+
background=None,
453+
line_number=feature_data["location"]["line"],
454+
description=textwrap.dedent(feature_data.get("description", "")),
455+
)
456+
457+
for child in feature_data["children"]:
458+
if "background" in child:
459+
feature.background = self.parse_background(child["background"], feature)
460+
elif "scenario" in child:
461+
scenario = self.parse_scenario(child["scenario"], feature)
462+
feature.scenarios[scenario.name] = scenario
463+
464+
return feature

tests/feature/test_scenario.py

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -146,3 +146,49 @@ def _():
146146
)
147147
result = pytester.runpytest_subprocess(*pytest_params)
148148
result.assert_outcomes(passed=1)
149+
150+
151+
def test_angular_brackets_are_not_parsed(pytester):
152+
"""Test that angular brackets are not parsed for "Scenario"s.
153+
154+
(They should be parsed only when used in "Scenario Outline")
155+
156+
"""
157+
pytester.makefile(
158+
".feature",
159+
simple="""
160+
Feature: Simple feature
161+
Scenario: Simple scenario
162+
Given I have a <tag>
163+
Then pass
164+
165+
Scenario Outline: Outlined scenario
166+
Given I have a templated <foo>
167+
Then pass
168+
169+
Examples:
170+
| foo |
171+
| bar |
172+
""",
173+
)
174+
pytester.makepyfile(
175+
"""
176+
from pytest_bdd import scenarios, given, then, parsers
177+
178+
scenarios("simple.feature")
179+
180+
@given("I have a <tag>")
181+
def _():
182+
return "tag"
183+
184+
@given(parsers.parse("I have a templated {foo}"))
185+
def _(foo):
186+
return "foo"
187+
188+
@then("pass")
189+
def _():
190+
pass
191+
"""
192+
)
193+
result = pytester.runpytest()
194+
result.assert_outcomes(passed=2)

tests/feature/test_steps.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -523,7 +523,7 @@ def test_when_not_found():
523523
pass
524524
525525
@when('foo')
526-
def foo():
526+
def _():
527527
return 'foo'
528528
529529
@scenario('test.feature', 'When step validation error happens')

0 commit comments

Comments
 (0)