Skip to content

Commit 035e5c5

Browse files
committed
Add datatables
Fix casing of attributes of some parser classes (camel -> snake case) Fix mistake that datatables are parsed the same as example tables. Make data_table fixture only generated if a data table exists for a given step, otherwise the fixture is not generated. Added methods to translate data table to a map/dict (using the first row as keys) and the ability to transpose the datatable for the case of vertical vs. horizontal data tables.
1 parent b3956b3 commit 035e5c5

File tree

6 files changed

+447
-178
lines changed

6 files changed

+447
-178
lines changed

src/pytest_bdd/gherkin_parser.py

Lines changed: 74 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -101,22 +101,83 @@ def from_dict(cls, data: dict[str, Any]) -> Self:
101101

102102

103103
@dataclass
104-
class DataTable:
104+
class ExamplesTable:
105105
location: Location
106106
name: str | None = None
107-
tableHeader: Row | None = None
108-
tableBody: list[Row] | None = field(default_factory=list)
107+
table_header: Row | None = None
108+
table_body: list[Row] | None = field(default_factory=list)
109109

110110
@classmethod
111111
def from_dict(cls, data: dict[str, Any]) -> Self:
112112
return cls(
113113
location=Location.from_dict(data["location"]),
114114
name=data.get("name"),
115-
tableHeader=Row.from_dict(data["tableHeader"]) if data.get("tableHeader") else None,
116-
tableBody=[Row.from_dict(row) for row in data.get("tableBody", [])],
115+
table_header=Row.from_dict(data["tableHeader"]) if data.get("tableHeader") else None,
116+
table_body=[Row.from_dict(row) for row in data.get("tableBody", [])],
117117
)
118118

119119

120+
@dataclass
121+
class DataTable:
122+
location: Location
123+
rows: list[Row]
124+
125+
@classmethod
126+
def from_dict(cls, data: dict[str, Any]) -> Self:
127+
return cls(
128+
location=Location.from_dict(data["location"]), rows=[Row.from_dict(row) for row in data.get("rows", [])]
129+
)
130+
131+
def transpose(self) -> Self:
132+
# Transpose the cells, turning rows into columns
133+
if not self.rows:
134+
return self # Return itself if there are no rows to transpose
135+
136+
# Get the list of lists of cell values (i.e., extract all row cells)
137+
cells_matrix = [row.cells for row in self.rows]
138+
139+
# Check the maximum number of columns (to handle different row lengths)
140+
max_columns = max(len(row) for row in cells_matrix)
141+
142+
# Create a list to store the transposed cells
143+
transposed_cells = []
144+
145+
for col_idx in range(max_columns):
146+
# Create a new list for each transposed column
147+
transposed_column = []
148+
for row in cells_matrix:
149+
if col_idx < len(row): # Ensure we don't go out of bounds
150+
transposed_column.append(row[col_idx])
151+
else:
152+
transposed_column.append(Cell(location=Location(0, 0), value="")) # Empty cell
153+
154+
# Create a new row from the transposed column
155+
transposed_row = Row(id=str(col_idx), location=self.location, cells=transposed_column)
156+
transposed_cells.append(transposed_row)
157+
158+
# Return a new DataTable with transposed rows
159+
return DataTable(location=self.location, rows=transposed_cells)
160+
161+
def to_dict(self) -> dict[str, list[str]]:
162+
# Ensure there are at least two rows: one for the header and one for the values
163+
if len(self.rows) < 2:
164+
raise ValueError("DataTable needs at least two rows: one for headers and one for values")
165+
166+
# Extract the header row (first row)
167+
header = [cell.value for cell in self.rows[0].cells]
168+
169+
# Extract the values from subsequent rows
170+
values_rows = [[cell.value for cell in row.cells] for row in self.rows[1:]]
171+
172+
# Transpose the values so that each column corresponds to a header key
173+
transposed_values = list(zip(*values_rows))
174+
175+
# Map each header to the corresponding list of values
176+
result_dict = {header[i]: list(transposed_values[i]) for i in range(len(header))}
177+
178+
return result_dict
179+
180+
120181
@dataclass
121182
class DocString:
122183
content: str
@@ -136,22 +197,22 @@ def from_dict(cls, data: dict[str, Any]) -> Self:
136197
class Step:
137198
id: str
138199
keyword: str
139-
keywordType: str
200+
keyword_type: str
140201
location: Location
141202
text: str
142-
dataTable: DataTable | None = None
143-
docString: DocString | None = None
203+
data_table: DataTable | None = None
204+
doc_string: DocString | None = None
144205

145206
@classmethod
146207
def from_dict(cls, data: dict[str, Any]) -> Self:
147208
return cls(
148209
id=data["id"],
149210
keyword=data["keyword"].strip(),
150-
keywordType=data["keywordType"],
211+
keyword_type=data["keywordType"],
151212
location=Location.from_dict(data["location"]),
152213
text=data["text"],
153-
dataTable=DataTable.from_dict(data["dataTable"]) if data.get("dataTable") else None,
154-
docString=DocString.from_dict(data["docString"]) if data.get("docString") else None,
214+
data_table=DataTable.from_dict(data["dataTable"]) if data.get("dataTable") else None,
215+
doc_string=DocString.from_dict(data["docString"]) if data.get("docString") else None,
155216
)
156217

157218

@@ -175,7 +236,7 @@ class Scenario:
175236
description: str
176237
steps: list[Step]
177238
tags: list[Tag]
178-
examples: list[DataTable] = field(default_factory=list)
239+
examples: list[ExamplesTable] = field(default_factory=list)
179240

180241
@classmethod
181242
def from_dict(cls, data: dict[str, Any]) -> Self:
@@ -187,7 +248,7 @@ def from_dict(cls, data: dict[str, Any]) -> Self:
187248
description=data["description"],
188249
steps=[Step.from_dict(step) for step in data["steps"]],
189250
tags=[Tag.from_dict(tag) for tag in data["tags"]],
190-
examples=[DataTable.from_dict(example) for example in data["examples"]],
251+
examples=[ExamplesTable.from_dict(example) for example in data["examples"]],
191252
)
192253

193254

src/pytest_bdd/parser.py

Lines changed: 14 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99

1010
from .exceptions import StepError
1111
from .gherkin_parser import Background as GherkinBackground
12+
from .gherkin_parser import DataTable, ExamplesTable
1213
from .gherkin_parser import Feature as GherkinFeature
1314
from .gherkin_parser import GherkinDocument
1415
from .gherkin_parser import Scenario as GherkinScenario
@@ -170,6 +171,7 @@ def render(self, context: Mapping[str, Any]) -> Scenario:
170171
indent=step.indent,
171172
line_number=step.line_number,
172173
keyword=step.keyword,
174+
data_table=step.data_table,
173175
)
174176
for step in self._steps
175177
]
@@ -225,11 +227,14 @@ class Step:
225227
line_number: int
226228
indent: int
227229
keyword: str
230+
data_table: DataTable | None = None
228231
failed: bool = field(init=False, default=False)
229232
scenario: ScenarioTemplate | None = field(init=False, default=None)
230233
background: Background | None = field(init=False, default=None)
231234

232-
def __init__(self, name: str, type: str, indent: int, line_number: int, keyword: str) -> None:
235+
def __init__(
236+
self, name: str, type: str, indent: int, line_number: int, keyword: str, data_table: DataTable | None = None
237+
) -> None:
233238
"""Initialize a step.
234239
235240
Args:
@@ -244,6 +249,7 @@ def __init__(self, name: str, type: str, indent: int, line_number: int, keyword:
244249
self.indent = indent
245250
self.line_number = line_number
246251
self.keyword = keyword
252+
self.data_table = data_table
247253

248254
def __str__(self) -> str:
249255
"""Return a string representation of the step.
@@ -342,8 +348,8 @@ def parse_steps(self, steps_data: list[GherkinStep]) -> list[Step]:
342348

343349
def get_step_content(_gherkin_step: GherkinStep) -> str:
344350
step_name = strip_comments(_gherkin_step.text)
345-
if _gherkin_step.docString:
346-
step_name = f"{step_name}\n{_gherkin_step.docString.content}"
351+
if _gherkin_step.doc_string:
352+
step_name = f"{step_name}\n{_gherkin_step.doc_string.content}"
347353
return step_name
348354

349355
if not steps_data:
@@ -372,6 +378,7 @@ def get_step_content(_gherkin_step: GherkinStep) -> str:
372378
indent=step.location.column - 1,
373379
line_number=step.location.line,
374380
keyword=step.keyword.title(),
381+
data_table=step.data_table,
375382
)
376383
)
377384
return steps
@@ -403,11 +410,11 @@ def parse_scenario(self, scenario_data: GherkinScenario, feature: Feature) -> Sc
403410
line_number=example_data.location.line,
404411
name=example_data.name,
405412
)
406-
if example_data.tableHeader is not None:
407-
param_names = [cell.value for cell in example_data.tableHeader.cells]
413+
if example_data.table_header is not None:
414+
param_names = [cell.value for cell in example_data.table_header.cells]
408415
examples.set_param_names(param_names)
409-
if example_data.tableBody is not None:
410-
for row in example_data.tableBody:
416+
if example_data.table_body is not None:
417+
for row in example_data.table_body:
411418
values = [cell.value or "" for cell in row.cells]
412419
examples.add_example(values)
413420
scenario.examples = examples

src/pytest_bdd/scenario.py

Lines changed: 20 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -152,29 +152,27 @@ def get_fixture_path(fixture_def: FixtureDef) -> list[str]:
152152

153153

154154
def get_step_function(request: FixtureRequest, step: Step) -> StepFunctionContext | None:
155-
"""Get the step function (context) for the given step.
156-
157-
We first figure out what's the step fixture name that we have to inject.
158-
159-
Then we let `patch_argumented_step_functions` find out what step definition fixtures can parse the current step,
160-
and it will inject them for the step fixture name.
161-
162-
Finally we let request.getfixturevalue(...) fetch the step definition fixture.
163-
"""
155+
"""Get the step function (context) for the given step, considering data_table."""
164156
__tracebackhide__ = True
165157
bdd_name = get_step_fixture_name(step=step)
166158

167159
with inject_fixturedefs_for_step(step=step, fixturemanager=request._fixturemanager, node=request.node):
168160
try:
169-
return cast(StepFunctionContext, request.getfixturevalue(bdd_name))
161+
context = cast(StepFunctionContext, request.getfixturevalue(bdd_name))
162+
163+
# Attach data_table if present
164+
if step.data_table:
165+
context.data_table = step.data_table
166+
167+
return context
170168
except pytest.FixtureLookupError:
171169
return None
172170

173171

174172
def _execute_step_function(
175173
request: FixtureRequest, scenario: Scenario, step: Step, context: StepFunctionContext
176174
) -> None:
177-
"""Execute step function."""
175+
"""Execute step function, with support for data_table."""
178176
__tracebackhide__ = True
179177
kw = {
180178
"request": request,
@@ -193,10 +191,16 @@ def _execute_step_function(
193191
args = get_args(context.step_func)
194192

195193
try:
194+
# Parse arguments, including handling for data_table
196195
parsed_args = context.parser.parse_arguments(step.name)
197196
assert parsed_args is not None, (
198197
f"Unexpected `NoneType` returned from " f"parse_arguments(...) in parser: {context.parser!r}"
199198
)
199+
200+
# Handle data_table if it exists
201+
if step.data_table:
202+
kwargs["data_table"] = step.data_table
203+
200204
for arg, value in parsed_args.items():
201205
if arg in converters:
202206
value = converters[arg](value)
@@ -217,6 +221,11 @@ def _execute_step_function(
217221

218222
request.config.hook.pytest_bdd_after_step(**kw)
219223

224+
if context.target_fixture is not None:
225+
inject_fixture(request, context.target_fixture, return_value)
226+
227+
request.config.hook.pytest_bdd_after_step(**kw)
228+
220229

221230
def _execute_scenario(feature: Feature, scenario: Scenario, request: FixtureRequest) -> None:
222231
"""Execute the scenario.

tests/datatable/__init__.py

Whitespace-only changes.

0 commit comments

Comments
 (0)