Skip to content

[behave] Add stubs for model.Table and model.Row - #16721

Merged
srittau merged 4 commits into
python:mainfrom
Dhairya-007:behave-table-row
Nov 10, 2026
Merged

srittau merged 4 commits into
python:mainfrom
Dhairya-007:behave-table-row

Conversation

@Dhairya-007

Copy link
Copy Markdown
Contributor

behave.model had no stubs at all, so context.table — the object almost every step implementation that uses a Gherkin data table touches — was Incomplete, and everything reached through it was Any:

@given("a table of users")
def step_impl(context: Context) -> None:
    for row in context.table:        # Any
        name = row["name"]           # Any

This adds behave/model.pyi covering Table and Row, plus behave/model_core.pyi covering Replayable, which Table derives from. Both keep the module-level __getattr__ escape hatch, so the rest of behave.model is untouched and the package stays a partial stub.

Context is wired up to the new types:

attribute was now assigned from
table Incomplete | None Table | None Step.table in Step.run
active_outline Incomplete Row | None scenario._row in ScenarioOutline.run

Details worth a second look

Signatures were read off behave 1.3.3 and checked against the runtime. The cases that needed care:

  • Table.__getitem__ is overloaded — table[0] gives a Row, but table[0:2] gives list[Row], since it delegates to self.rows[index].
  • Row.items() returns a zip object, not a list or a dict view.
  • Row.as_dict() returns an OrderedDict.
  • Row.get is overloaded on its default, like Mapping.get.
  • Table.assert_equals is NoReturn — it asserts the comparison and then unconditionally raises NotImplementedError.
  • Replayable.type is None on the base and a string on subclasses, so it is ClassVar[str | None] there and ClassVar[str] on Table.
  • Table.from_data returns Table, not Self — one branch constructs Table directly rather than cls.
  • Row.from_dict's **kwargs are spelled out as the keyword-only line and comments that Row.__init__ actually accepts.

Row.comments stays Incomplete | None: nothing in behave constructs a Row with it, and there is no Comment type in the package, so I did not want to guess.

Validation

  • stubtest: Success: no issues found in 84 modules
  • mypy --strict: a representative step implementation reading headings, iterating rows, and using named, indexed and sliced access reported 14 Any errors before this change and none after. Every assert_type for str, int, list[str], list[Row] and OrderedDict[str, str] now holds.

`behave.model` had no stubs at all, so `context.table` — the object
almost every step implementation that uses a Gherkin data table touches
— was `Incomplete`, and everything reached through it was `Any`.

Adds `behave/model.pyi` covering `Table` and `Row`, and
`behave/model_core.pyi` covering `Replayable`, which `Table` derives
from. Both modules keep the module-level `__getattr__` escape hatch, so
the rest of `behave.model` is unaffected and the package stays a partial
stub.

`Context` is wired up to the new types:

- `table: Table | None`, assigned from `Step.table` in
  `Step.run`, and None at root
- `active_outline: Row | None`, assigned from `scenario._row` in
  `ScenarioOutline.run` and reset to None afterwards

Signatures were read off behave 1.3.3 and checked at runtime, including
the cases that needed care:

- `Table.__getitem__` is overloaded, since `table[0]` returns a `Row`
  while `table[0:2]` returns `list[Row]`
- `Row.items()` returns a `zip` object, not a list or a view
- `Row.as_dict()` returns an `OrderedDict`
- `Row.get` is overloaded on its default, like `Mapping.get`
- `Table.assert_equals` is `NoReturn`: it asserts, then unconditionally
  raises `NotImplementedError`
- `Replayable.type` is `None` on the base class and a string on
  subclasses, so it is `ClassVar[str | None]` there and
  `ClassVar[str]` on `Table`
- `Table.from_data` returns `Table` rather than `Self`, because one
  branch constructs `Table` directly instead of `cls`

Verified with stubtest (84 modules, no issues) and `mypy --strict`: a
representative step implementation that reads headings, iterates rows,
and uses named, indexed and sliced access reported 14 `Any` errors
before this change and none after.
@github-actions

This comment has been minimized.

@Dhairya-007

Copy link
Copy Markdown
Contributor Author

Hii @srittau , requesting for a review and merge

flake8-pyi Y067: an argument whose type is unknown and defaults to None is
written as comments=None rather than comments: Incomplete | None = None. The
class attribute keeps its annotation. Black then fits both signatures on one
line.
@github-actions

This comment has been minimized.

@srittau srittau left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks, two comments below.

Comment on lines +42 to +47
@classmethod
def from_dicts(cls, headings: list[str], table_data: Sequence[Mapping[str, str]], line: int | None = None) -> Self: ...
@classmethod
def from_data(
cls, headings: list[str], table_data: Sequence[Mapping[str, str] | _RowData] | None = None, line: int = 0
) -> Table: ...

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Mapping too narrow (there is an explicit isinstance check):

Suggested change
@classmethod
def from_dicts(cls, headings: list[str], table_data: Sequence[Mapping[str, str]], line: int | None = None) -> Self: ...
@classmethod
def from_data(
cls, headings: list[str], table_data: Sequence[Mapping[str, str] | _RowData] | None = None, line: int = 0
) -> Table: ...
@classmethod
def from_dicts(cls, headings: list[str], table_data: Sequence[dict[str, str]], line: int | None = None) -> Self: ...
@classmethod
def from_data(
cls, headings: list[str], table_data: Sequence[dict[str, str] | _RowData] | None = None, line: int = 0
) -> Table: ...

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done in a2f1e41 — both now take Sequence[dict[str, str]] / Sequence[dict[str, str] | _RowData].

from_dicts raises TypeError("%r (expected: List[dict]") when table_data[0] is not a dict, and from_data takes its else-branch and passes the data straight to Table(headings, rows=table_data), so a non-dict Mapping is rejected by one and silently mishandled by the other.

Left Row.from_dict on Mapping: it only does row_data.get(name), no isinstance check.

Comment thread stubs/behave/behave/model.pyi Outdated
from _typeshed import Incomplete
from collections import OrderedDict
from collections.abc import Iterable, Iterator, Mapping, Sequence
from typing import ClassVar, NoReturn, TypeAlias, TypeVar, overload

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Use Never from typing_extensions. NoReturn is deprecated.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done in b7eb5c6 — Never from typing_extensions, and NoReturn is dropped from the typing import.

Both methods gate on isinstance(table_data[0], dict): from_dicts raises
TypeError for a non-dict, and from_data silently takes the other branch
and passes the data to Table(rows=...). A Mapping that is not a dict is
not accepted by either, so annotate the element type as dict[str, str].

Row.from_dict keeps Mapping: it only calls row_data.get(name).
Table.assert_equals asserts and then raises NotImplementedError, so it
never returns; Never is the non-deprecated spelling for that.
@github-actions

Copy link
Copy Markdown
Contributor

According to mypy_primer, this change has no effect on the checked open source code. 🤖🎉

@Dhairya-007
Dhairya-007 requested a review from srittau November 10, 2026 08:54
@Dhairya-007

Copy link
Copy Markdown
Contributor Author

Hii @srittau , good for re-review

@srittau
srittau merged commit e894654 into python:main Nov 10, 2026
74 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants