behave-tables 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,31 @@
1
+ name: CI
2
+
3
+ on:
4
+ push:
5
+ branches: [main]
6
+ tags: ["v*"]
7
+ pull_request:
8
+ branches: [main]
9
+
10
+ permissions:
11
+ contents: read
12
+
13
+ jobs:
14
+ test:
15
+ runs-on: ubuntu-latest
16
+ strategy:
17
+ matrix:
18
+ python-version: ["3.11", "3.12", "3.13"]
19
+ steps:
20
+ - uses: actions/checkout@v4
21
+ - uses: actions/setup-python@v5
22
+ with:
23
+ python-version: ${{ matrix.python-version }}
24
+ - run: pip install -e ".[dev,pydantic]"
25
+ - run: ruff check .
26
+ - run: pytest tests/ -v --cov --cov-report=xml
27
+ - uses: codecov/codecov-action@v4
28
+ if: matrix.python-version == '3.12'
29
+ with:
30
+ file: ./coverage.xml
31
+
@@ -0,0 +1,38 @@
1
+ name: Release
2
+
3
+ on:
4
+ push:
5
+ tags: ["v*"]
6
+
7
+ permissions:
8
+ contents: read
9
+
10
+ jobs:
11
+ build:
12
+ runs-on: ubuntu-latest
13
+ steps:
14
+ - uses: actions/checkout@v4
15
+ - uses: actions/setup-python@v5
16
+ with:
17
+ python-version: "3.12"
18
+ - run: pip install build
19
+ - run: python -m build
20
+ - uses: actions/upload-artifact@v4
21
+ with:
22
+ name: dist
23
+ path: dist/
24
+
25
+ publish:
26
+ needs: build
27
+ runs-on: ubuntu-latest
28
+ environment: pypi
29
+ permissions:
30
+ id-token: write
31
+ steps:
32
+ - uses: actions/download-artifact@v4
33
+ with:
34
+ name: dist
35
+ path: dist/
36
+ - uses: pypa/gh-action-pypi-publish@release/v1
37
+ with:
38
+ attestations: true
@@ -0,0 +1,14 @@
1
+ __pycache__/
2
+ *.py[cod]
3
+ *.egg-info/
4
+ dist/
5
+ build/
6
+ .eggs/
7
+ *.egg
8
+ .pytest_cache/
9
+ .coverage
10
+ htmlcov/
11
+ coverage.xml
12
+ .tox/
13
+ .mypy_cache/
14
+ .ruff_cache/
@@ -0,0 +1,22 @@
1
+ # Changelog
2
+
3
+ All notable changes to this project will be documented in this file.
4
+
5
+ ## [0.1.0] - 2026-07-13
6
+
7
+ ### Added
8
+
9
+ - `TableWrapper` class wrapping `behave.model.Table`
10
+ - `wrap()` convenience function
11
+ - `as_dicts()` — rows as list of dicts
12
+ - `as_models(model)` — rows as Pydantic or dataclass instances
13
+ - `column(name)` — extract single column values
14
+ - `find_row(**filters)` — find first matching row
15
+ - `validate_columns(*expected)` — column validation
16
+ - `transpose()` — swap rows and columns
17
+ - `to_csv()` — CSV export
18
+ - `to_json()` — JSON export
19
+ - `headers` property
20
+ - `__iter__`, `__getitem__`, `__len__` for dict-like iteration
21
+ - `ColumnMismatchError` exception
22
+ - Zero required dependencies (pydantic optional)
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Mathias Paulenko
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,110 @@
1
+ Metadata-Version: 2.4
2
+ Name: behave-tables
3
+ Version: 0.1.0
4
+ Summary: Polished API for Behave Data Tables — as_dicts, as_models, transpose, to_csv, to_json
5
+ Project-URL: Homepage, https://github.com/MathiasPaulenko/behave-tables
6
+ Project-URL: Repository, https://github.com/MathiasPaulenko/behave-tables
7
+ Project-URL: Issues, https://github.com/MathiasPaulenko/behave-tables/issues
8
+ Project-URL: Changelog, https://github.com/MathiasPaulenko/behave-tables/blob/main/CHANGELOG.md
9
+ Author: Mathias Paulenko
10
+ License-Expression: MIT
11
+ License-File: LICENSE
12
+ Keywords: bdd,behave,cucumber,data-tables,gherkin,tables,testing
13
+ Classifier: Development Status :: 4 - Beta
14
+ Classifier: Intended Audience :: Developers
15
+ Classifier: License :: OSI Approved :: MIT License
16
+ Classifier: Programming Language :: Python :: 3
17
+ Classifier: Programming Language :: Python :: 3.11
18
+ Classifier: Programming Language :: Python :: 3.12
19
+ Classifier: Programming Language :: Python :: 3.13
20
+ Classifier: Topic :: Software Development :: Libraries
21
+ Classifier: Topic :: Software Development :: Testing
22
+ Requires-Python: >=3.11
23
+ Provides-Extra: dev
24
+ Requires-Dist: behave>=1.2.6; extra == 'dev'
25
+ Requires-Dist: pytest-cov>=5.0; extra == 'dev'
26
+ Requires-Dist: pytest>=8.0; extra == 'dev'
27
+ Requires-Dist: ruff>=0.5; extra == 'dev'
28
+ Provides-Extra: pydantic
29
+ Requires-Dist: pydantic>=2.0; extra == 'pydantic'
30
+ Description-Content-Type: text/markdown
31
+
32
+ # behave-tables
33
+
34
+ Polished API for Behave Data Tables.
35
+
36
+ ## Why?
37
+
38
+ Behave's `Table` requires manual iteration: `for row in table.rows: name = row["name"]`. No conversion to dicts, models, CSV, or JSON. Every project reimplements helpers.
39
+
40
+ ## Install
41
+
42
+ ```bash
43
+ pip install behave-tables
44
+ # Optional: pydantic for as_models() with validation
45
+ pip install behave-tables[pydantic]
46
+ ```
47
+
48
+ ## Quick start
49
+
50
+ ```python
51
+ from behave_tables import wrap
52
+
53
+ # In a step definition
54
+ @then("the users should be")
55
+ def step_impl(context):
56
+ table = wrap(context.table)
57
+
58
+ # As dicts
59
+ users = table.as_dicts()
60
+ # [{"name": "Alice", "age": "30"}, {"name": "Bob", "age": "25"}]
61
+
62
+ # As models (Pydantic or dataclass)
63
+ users = table.as_models(User)
64
+
65
+ # Column extraction
66
+ names = table.column("name") # ["Alice", "Bob"]
67
+
68
+ # Find first matching row
69
+ alice = table.find_row(name="Alice") # {"name": "Alice", "age": "30"}
70
+
71
+ # Export
72
+ csv_str = table.to_csv()
73
+ json_str = table.to_json()
74
+
75
+ # Transpose
76
+ transposed = table.transpose()
77
+
78
+ # Iterate as dicts
79
+ for row in table:
80
+ print(row["name"])
81
+
82
+ # Index
83
+ table[0] # {"name": "Alice", "age": "30"}
84
+ len(table) # 2
85
+ ```
86
+
87
+ ## API
88
+
89
+ | Method | Returns | Description |
90
+ |---|---|---|
91
+ | `as_dicts()` | `list[dict[str, str]]` | All rows as dicts |
92
+ | `as_models(model)` | `list[Model]` | Rows as Pydantic or dataclass instances |
93
+ | `column(name)` | `list[str]` | Values for a single column |
94
+ | `find_row(**filters)` | `dict[str, str] \| None` | First row matching all filters |
95
+ | `validate_columns(*expected)` | `None` | Raise `ColumnMismatchError` if columns missing |
96
+ | `transpose()` | `TableWrapper` | New wrapper with rows and columns swapped |
97
+ | `to_csv()` | `str` | CSV string |
98
+ | `to_json()` | `str` | JSON string |
99
+ | `headers` | `list[str]` | Column names |
100
+ | `__iter__` | `Iterator[dict]` | Iterate rows as dicts |
101
+ | `__getitem__(i)` | `dict[str, str]` | Row at index as dict |
102
+ | `__len__` | `int` | Number of rows |
103
+
104
+ ## Zero dependencies
105
+
106
+ `behave-tables` has no required dependencies. `as_models()` works with stdlib `dataclasses` out of the box. Install `pydantic` optionally for validation.
107
+
108
+ ## License
109
+
110
+ MIT
@@ -0,0 +1,79 @@
1
+ # behave-tables
2
+
3
+ Polished API for Behave Data Tables.
4
+
5
+ ## Why?
6
+
7
+ Behave's `Table` requires manual iteration: `for row in table.rows: name = row["name"]`. No conversion to dicts, models, CSV, or JSON. Every project reimplements helpers.
8
+
9
+ ## Install
10
+
11
+ ```bash
12
+ pip install behave-tables
13
+ # Optional: pydantic for as_models() with validation
14
+ pip install behave-tables[pydantic]
15
+ ```
16
+
17
+ ## Quick start
18
+
19
+ ```python
20
+ from behave_tables import wrap
21
+
22
+ # In a step definition
23
+ @then("the users should be")
24
+ def step_impl(context):
25
+ table = wrap(context.table)
26
+
27
+ # As dicts
28
+ users = table.as_dicts()
29
+ # [{"name": "Alice", "age": "30"}, {"name": "Bob", "age": "25"}]
30
+
31
+ # As models (Pydantic or dataclass)
32
+ users = table.as_models(User)
33
+
34
+ # Column extraction
35
+ names = table.column("name") # ["Alice", "Bob"]
36
+
37
+ # Find first matching row
38
+ alice = table.find_row(name="Alice") # {"name": "Alice", "age": "30"}
39
+
40
+ # Export
41
+ csv_str = table.to_csv()
42
+ json_str = table.to_json()
43
+
44
+ # Transpose
45
+ transposed = table.transpose()
46
+
47
+ # Iterate as dicts
48
+ for row in table:
49
+ print(row["name"])
50
+
51
+ # Index
52
+ table[0] # {"name": "Alice", "age": "30"}
53
+ len(table) # 2
54
+ ```
55
+
56
+ ## API
57
+
58
+ | Method | Returns | Description |
59
+ |---|---|---|
60
+ | `as_dicts()` | `list[dict[str, str]]` | All rows as dicts |
61
+ | `as_models(model)` | `list[Model]` | Rows as Pydantic or dataclass instances |
62
+ | `column(name)` | `list[str]` | Values for a single column |
63
+ | `find_row(**filters)` | `dict[str, str] \| None` | First row matching all filters |
64
+ | `validate_columns(*expected)` | `None` | Raise `ColumnMismatchError` if columns missing |
65
+ | `transpose()` | `TableWrapper` | New wrapper with rows and columns swapped |
66
+ | `to_csv()` | `str` | CSV string |
67
+ | `to_json()` | `str` | JSON string |
68
+ | `headers` | `list[str]` | Column names |
69
+ | `__iter__` | `Iterator[dict]` | Iterate rows as dicts |
70
+ | `__getitem__(i)` | `dict[str, str]` | Row at index as dict |
71
+ | `__len__` | `int` | Number of rows |
72
+
73
+ ## Zero dependencies
74
+
75
+ `behave-tables` has no required dependencies. `as_models()` works with stdlib `dataclasses` out of the box. Install `pydantic` optionally for validation.
76
+
77
+ ## License
78
+
79
+ MIT
@@ -0,0 +1,19 @@
1
+ """behave-tables — Polished API for Behave Data Tables."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from .exceptions import ColumnMismatchError
6
+ from .wrapper import TableWrapper
7
+
8
+ __version__ = "0.1.0"
9
+
10
+ __all__ = ["TableWrapper", "wrap", "ColumnMismatchError", "__version__"]
11
+
12
+
13
+ def wrap(table: object) -> TableWrapper:
14
+ """Wrap a behave.model.Table with TableWrapper.
15
+
16
+ Convenience function: ``wrap(context.table)`` instead of
17
+ ``TableWrapper(context.table)``.
18
+ """
19
+ return TableWrapper(table)
@@ -0,0 +1,31 @@
1
+ """Internal mock table for transpose() and testing."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass, field
6
+ from typing import Any
7
+
8
+
9
+ @dataclass
10
+ class MockRow:
11
+ """A minimal row implementation compatible with TableWrapper."""
12
+
13
+ _data: dict[str, str]
14
+
15
+ def as_dict(self) -> dict[str, str]:
16
+ return dict(self._data)
17
+
18
+ def __getitem__(self, key: str) -> str:
19
+ return self._data[key]
20
+
21
+
22
+ @dataclass
23
+ class MockTable:
24
+ """A minimal table implementation compatible with TableWrapper."""
25
+
26
+ headings: list[str]
27
+ rows_data: list[dict[str, str]] = field(default_factory=list)
28
+
29
+ @property
30
+ def rows(self) -> list[MockRow]:
31
+ return [MockRow(d) for d in self.rows_data]
@@ -0,0 +1,35 @@
1
+ """Type conversion logic for as_models()."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import dataclasses
6
+ from typing import Any
7
+
8
+
9
+ def is_pydantic_model(model: type) -> bool:
10
+ """Check if a class is a Pydantic v2 BaseModel subclass."""
11
+ try:
12
+ from pydantic import BaseModel
13
+
14
+ return isinstance(model, type) and issubclass(model, BaseModel)
15
+ except ImportError:
16
+ return False
17
+
18
+
19
+ def is_dataclass_type(model: type) -> bool:
20
+ """Check if a class is a dataclass."""
21
+ return isinstance(model, type) and dataclasses.is_dataclass(model)
22
+
23
+
24
+ def convert_row_to_model(row: dict[str, str], model: type) -> Any:
25
+ """Convert a dict row to a model instance.
26
+
27
+ Detects Pydantic v2 BaseModel or stdlib dataclass automatically.
28
+ """
29
+ if is_pydantic_model(model):
30
+ return model(**row)
31
+ if is_dataclass_type(model):
32
+ return model(**row)
33
+ raise TypeError(
34
+ f"model must be a Pydantic BaseModel or dataclass, got {model!r}"
35
+ )
@@ -0,0 +1,15 @@
1
+ """Custom exceptions for behave-tables."""
2
+
3
+ from __future__ import annotations
4
+
5
+
6
+ class ColumnMismatchError(ValueError):
7
+ """Raised when expected columns are not found in the table."""
8
+
9
+ def __init__(self, missing: list[str], extra: list[str] | None = None) -> None:
10
+ parts = [f"missing columns: {missing}"] if missing else []
11
+ if extra:
12
+ parts.append(f"unexpected columns: {extra}")
13
+ super().__init__("; ".join(parts))
14
+ self.missing = missing
15
+ self.extra = extra or []
@@ -0,0 +1,144 @@
1
+ """TableWrapper — polished API for Behave Data Tables."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import csv
6
+ import io
7
+ import json
8
+ from typing import Any, Iterator, Protocol
9
+
10
+ from .converters import convert_row_to_model
11
+ from .exceptions import ColumnMismatchError
12
+
13
+
14
+ class TableLike(Protocol):
15
+ """Protocol for behave.model.Table or any table-like object."""
16
+
17
+ headings: list[str]
18
+
19
+ @property
20
+ def rows(self) -> list[Any]: ...
21
+
22
+
23
+ class RowLike(Protocol):
24
+ """Protocol for behave.model.Row or any row-like object."""
25
+
26
+ def as_dict(self) -> dict[str, str]: ...
27
+
28
+ def __getitem__(self, key: str) -> str: ...
29
+
30
+
31
+ class TableWrapper:
32
+ """Wraps a behave.model.Table with an ergonomic API.
33
+
34
+ Zero dependencies. Works with any table-like object that has
35
+ ``headings`` (list[str]) and ``rows`` (list of objects with
36
+ ``as_dict()`` or ``__getitem__``).
37
+ """
38
+
39
+ def __init__(self, table: TableLike) -> None:
40
+ self._table = table
41
+ self._rows: list[dict[str, str]] = self._extract_rows(table)
42
+
43
+ @staticmethod
44
+ def _extract_rows(table: TableLike) -> list[dict[str, str]]:
45
+ """Extract rows as list of dicts from any table-like object."""
46
+ rows: list[dict[str, str]] = []
47
+ for row in table.rows:
48
+ if hasattr(row, "as_dict"):
49
+ rows.append(dict(row.as_dict()))
50
+ elif hasattr(row, "__getitem__"):
51
+ rows.append({h: row[h] for h in table.headings})
52
+ else:
53
+ raise TypeError(f"Unsupported row type: {type(row)!r}")
54
+ return rows
55
+
56
+ @property
57
+ def headers(self) -> list[str]:
58
+ """Return column headers."""
59
+ return list(self._table.headings)
60
+
61
+ def as_dicts(self) -> list[dict[str, str]]:
62
+ """Return all rows as a list of dicts (copies)."""
63
+ return [dict(row) for row in self._rows]
64
+
65
+ def as_models(self, model: type) -> list[Any]:
66
+ """Convert all rows to model instances.
67
+
68
+ Supports Pydantic v2 BaseModel and stdlib dataclasses.
69
+ """
70
+ return [convert_row_to_model(row, model) for row in self._rows]
71
+
72
+ def column(self, name: str) -> list[str]:
73
+ """Return all values for a single column.
74
+
75
+ Raises KeyError if the column does not exist.
76
+ """
77
+ if name not in self.headers:
78
+ raise KeyError(
79
+ f"Column {name!r} not found. Available: {self.headers}"
80
+ )
81
+ return [row[name] for row in self._rows]
82
+
83
+ def find_row(self, **filters: str) -> dict[str, str] | None:
84
+ """Return the first row matching all filters, or None."""
85
+ if not filters:
86
+ return self._rows[0] if self._rows else None
87
+ for row in self._rows:
88
+ if all(row.get(k) == v for k, v in filters.items()):
89
+ return row
90
+ return None
91
+
92
+ def validate_columns(self, *expected: str) -> None:
93
+ """Raise ColumnMismatchError if expected columns are missing."""
94
+ headers = set(self.headers)
95
+ missing = [col for col in expected if col not in headers]
96
+ if missing:
97
+ raise ColumnMismatchError(missing=missing)
98
+
99
+ def transpose(self) -> TableWrapper:
100
+ """Return a new TableWrapper with rows and columns swapped.
101
+
102
+ Original headers become the first column.
103
+ Original row indices become the new headers.
104
+ """
105
+ from behave_tables._mock import MockTable
106
+
107
+ new_headers = [str(i) for i in range(len(self._rows))]
108
+ new_rows: list[dict[str, str]] = []
109
+
110
+ for col_idx, header in enumerate(self.headers):
111
+ row_dict: dict[str, str] = {"_column": header}
112
+ for row_idx, row in enumerate(self._rows):
113
+ row_dict[str(row_idx)] = row[header]
114
+ new_rows.append(row_dict)
115
+
116
+ mock = MockTable(
117
+ headings=["_column"] + new_headers,
118
+ rows_data=new_rows,
119
+ )
120
+ return TableWrapper(mock)
121
+
122
+ def to_csv(self) -> str:
123
+ """Return table as a CSV string."""
124
+ output = io.StringIO()
125
+ writer = csv.DictWriter(output, fieldnames=self.headers)
126
+ writer.writeheader()
127
+ writer.writerows(self._rows)
128
+ return output.getvalue().replace("\r\n", "\n").rstrip("\n")
129
+
130
+ def to_json(self, indent: int = 2) -> str:
131
+ """Return table as a JSON string (list of objects)."""
132
+ return json.dumps(self._rows, indent=indent, ensure_ascii=False)
133
+
134
+ def __iter__(self) -> Iterator[dict[str, str]]:
135
+ return iter(self._rows)
136
+
137
+ def __getitem__(self, index: int) -> dict[str, str]:
138
+ return self._rows[index]
139
+
140
+ def __len__(self) -> int:
141
+ return len(self._rows)
142
+
143
+ def __repr__(self) -> str:
144
+ return f"TableWrapper(headers={self.headers!r}, rows={len(self)})"
@@ -0,0 +1,68 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "behave-tables"
7
+ version = "0.1.0"
8
+ description = "Polished API for Behave Data Tables — as_dicts, as_models, transpose, to_csv, to_json"
9
+ readme = "README.md"
10
+ license = "MIT"
11
+ requires-python = ">=3.11"
12
+ authors = [
13
+ { name = "Mathias Paulenko" },
14
+ ]
15
+ keywords = ["behave", "bdd", "cucumber", "gherkin", "tables", "data-tables", "testing"]
16
+ classifiers = [
17
+ "Development Status :: 4 - Beta",
18
+ "Intended Audience :: Developers",
19
+ "License :: OSI Approved :: MIT License",
20
+ "Programming Language :: Python :: 3",
21
+ "Programming Language :: Python :: 3.11",
22
+ "Programming Language :: Python :: 3.12",
23
+ "Programming Language :: Python :: 3.13",
24
+ "Topic :: Software Development :: Testing",
25
+ "Topic :: Software Development :: Libraries",
26
+ ]
27
+ dependencies = []
28
+
29
+ [project.optional-dependencies]
30
+ pydantic = ["pydantic>=2.0"]
31
+ dev = [
32
+ "pytest>=8.0",
33
+ "pytest-cov>=5.0",
34
+ "ruff>=0.5",
35
+ "behave>=1.2.6",
36
+ ]
37
+
38
+ [project.urls]
39
+ Homepage = "https://github.com/MathiasPaulenko/behave-tables"
40
+ Repository = "https://github.com/MathiasPaulenko/behave-tables"
41
+ Issues = "https://github.com/MathiasPaulenko/behave-tables/issues"
42
+ Changelog = "https://github.com/MathiasPaulenko/behave-tables/blob/main/CHANGELOG.md"
43
+
44
+ [tool.hatch.build.targets.wheel]
45
+ packages = ["behave_tables"]
46
+
47
+ [tool.ruff]
48
+ target-version = "py311"
49
+ line-length = 100
50
+
51
+ [tool.ruff.lint]
52
+ select = ["E", "F", "W", "I", "N", "UP", "B", "SIM"]
53
+
54
+ [tool.ruff.lint.isort]
55
+ known-first-party = ["behave_tables"]
56
+
57
+ [tool.pytest.ini_options]
58
+ testpaths = ["tests"]
59
+ addopts = "-ra --strict-markers"
60
+ pythonpath = ["."]
61
+
62
+ [tool.coverage.run]
63
+ source = ["behave_tables"]
64
+ omit = ["*/tests/*"]
65
+
66
+ [tool.coverage.report]
67
+ show_missing = true
68
+ fail_under = 90
File without changes
@@ -0,0 +1,41 @@
1
+ """Test fixtures — mock behave Table and Row objects."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass, field
6
+ from typing import Any
7
+
8
+
9
+ @dataclass
10
+ class FakeRow:
11
+ """Mimics behave.model.Row."""
12
+
13
+ cells: list[str]
14
+ headings: list[str]
15
+
16
+ def as_dict(self) -> dict[str, str]:
17
+ return dict(zip(self.headings, self.cells))
18
+
19
+ def __getitem__(self, key: str) -> str:
20
+ idx = self.headings.index(key)
21
+ return self.cells[idx]
22
+
23
+
24
+ @dataclass
25
+ class FakeTable:
26
+ """Mimics behave.model.Table."""
27
+
28
+ headings: list[str]
29
+ rows_cells: list[list[str]] = field(default_factory=list)
30
+
31
+ @property
32
+ def rows(self) -> list[FakeRow]:
33
+ return [FakeRow(cells=c, headings=self.headings) for c in self.rows_cells]
34
+
35
+
36
+ def make_table(
37
+ headings: list[str],
38
+ rows: list[list[str]],
39
+ ) -> FakeTable:
40
+ """Build a FakeTable for testing."""
41
+ return FakeTable(headings=headings, rows_cells=rows)
@@ -0,0 +1,40 @@
1
+ """Test helpers — mock behave Table and Row objects."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass, field
6
+
7
+
8
+ @dataclass
9
+ class FakeRow:
10
+ """Mimics behave.model.Row."""
11
+
12
+ cells: list[str]
13
+ headings: list[str]
14
+
15
+ def as_dict(self) -> dict[str, str]:
16
+ return dict(zip(self.headings, self.cells))
17
+
18
+ def __getitem__(self, key: str) -> str:
19
+ idx = self.headings.index(key)
20
+ return self.cells[idx]
21
+
22
+
23
+ @dataclass
24
+ class FakeTable:
25
+ """Mimics behave.model.Table."""
26
+
27
+ headings: list[str]
28
+ rows_cells: list[list[str]] = field(default_factory=list)
29
+
30
+ @property
31
+ def rows(self) -> list[FakeRow]:
32
+ return [FakeRow(cells=c, headings=self.headings) for c in self.rows_cells]
33
+
34
+
35
+ def make_table(
36
+ headings: list[str],
37
+ rows: list[list[str]],
38
+ ) -> FakeTable:
39
+ """Build a FakeTable for testing."""
40
+ return FakeTable(headings=headings, rows_cells=rows)
@@ -0,0 +1,26 @@
1
+ """Tests for exceptions."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import pytest
6
+
7
+ from behave_tables import ColumnMismatchError
8
+
9
+
10
+ class TestColumnMismatchError:
11
+ def test_missing_only(self):
12
+ err = ColumnMismatchError(missing=["email", "phone"])
13
+ assert "email" in str(err)
14
+ assert "phone" in str(err)
15
+ assert err.missing == ["email", "phone"]
16
+ assert err.extra == []
17
+
18
+ def test_missing_and_extra(self):
19
+ err = ColumnMismatchError(missing=["email"], extra=["id"])
20
+ assert "missing" in str(err)
21
+ assert "unexpected" in str(err)
22
+ assert err.extra == ["id"]
23
+
24
+ def test_is_value_error(self):
25
+ err = ColumnMismatchError(missing=["x"])
26
+ assert isinstance(err, ValueError)
@@ -0,0 +1,68 @@
1
+ """Tests for as_models() with dataclasses and Pydantic."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+
7
+ import pytest
8
+
9
+ from behave_tables import wrap
10
+ from helpers import make_table
11
+
12
+
13
+ @dataclass
14
+ class UserDC:
15
+ name: str
16
+ age: int
17
+
18
+
19
+ class TestAsModelsDataclass:
20
+ def test_basic(self):
21
+ table = make_table(["name", "age"], [["Alice", "30"], ["Bob", "25"]])
22
+ wt = wrap(table)
23
+ users = wt.as_models(UserDC)
24
+ assert len(users) == 2
25
+ assert users[0].name == "Alice"
26
+ assert users[1].name == "Bob"
27
+
28
+ def test_empty_table(self):
29
+ table = make_table(["name", "age"], [])
30
+ wt = wrap(table)
31
+ assert wt.as_models(UserDC) == []
32
+
33
+ def test_type_error_on_non_model(self):
34
+ table = make_table(["name"], [["Alice"]])
35
+ wt = wrap(table)
36
+ with pytest.raises(TypeError, match="Pydantic BaseModel or dataclass"):
37
+ wt.as_models(dict)
38
+
39
+
40
+ class TestAsModelsPydantic:
41
+ def test_basic(self):
42
+ pydantic = pytest.importorskip("pydantic")
43
+ from pydantic import BaseModel
44
+
45
+ class UserPyd(BaseModel):
46
+ name: str
47
+ age: int
48
+
49
+ table = make_table(["name", "age"], [["Alice", "30"], ["Bob", "25"]])
50
+ wt = wrap(table)
51
+ users = wt.as_models(UserPyd)
52
+ assert len(users) == 2
53
+ assert users[0].name == "Alice"
54
+ assert users[0].age == 30
55
+ assert isinstance(users[0], BaseModel)
56
+
57
+ def test_pydantic_validation_error(self):
58
+ pytest.importorskip("pydantic")
59
+ from pydantic import BaseModel, ValidationError
60
+
61
+ class StrictUser(BaseModel):
62
+ name: str
63
+ age: int
64
+
65
+ table = make_table(["name", "age"], [["Alice", "not_a_number"]])
66
+ wt = wrap(table)
67
+ with pytest.raises(ValidationError):
68
+ wt.as_models(StrictUser)
@@ -0,0 +1,214 @@
1
+ """Tests for TableWrapper core methods."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+
7
+ import pytest
8
+
9
+ from behave_tables import TableWrapper, wrap, ColumnMismatchError
10
+ from helpers import make_table
11
+
12
+
13
+ class TestAsDicts:
14
+ def test_basic(self):
15
+ table = make_table(["name", "age"], [["Alice", "30"], ["Bob", "25"]])
16
+ wt = wrap(table)
17
+ assert wt.as_dicts() == [
18
+ {"name": "Alice", "age": "30"},
19
+ {"name": "Bob", "age": "25"},
20
+ ]
21
+
22
+ def test_empty_table(self):
23
+ table = make_table(["name", "age"], [])
24
+ wt = wrap(table)
25
+ assert wt.as_dicts() == []
26
+
27
+ def test_single_row(self):
28
+ table = make_table(["x"], [["1"]])
29
+ wt = wrap(table)
30
+ assert wt.as_dicts() == [{"x": "1"}]
31
+
32
+ def test_single_column(self):
33
+ table = make_table(["only"], [["a"], ["b"], ["c"]])
34
+ wt = wrap(table)
35
+ assert wt.as_dicts() == [{"only": "a"}, {"only": "b"}, {"only": "c"}]
36
+
37
+ def test_returns_copy(self):
38
+ table = make_table(["name"], [["Alice"]])
39
+ wt = wrap(table)
40
+ dicts = wt.as_dicts()
41
+ dicts[0]["name"] = "modified"
42
+ assert wt.as_dicts()[0]["name"] == "Alice"
43
+
44
+
45
+ class TestColumn:
46
+ def test_basic(self):
47
+ table = make_table(["name", "age"], [["Alice", "30"], ["Bob", "25"]])
48
+ wt = wrap(table)
49
+ assert wt.column("name") == ["Alice", "Bob"]
50
+ assert wt.column("age") == ["30", "25"]
51
+
52
+ def test_missing_column(self):
53
+ table = make_table(["name"], [["Alice"]])
54
+ wt = wrap(table)
55
+ with pytest.raises(KeyError, match="not found"):
56
+ wt.column("email")
57
+
58
+ def test_empty_table(self):
59
+ table = make_table(["name"], [])
60
+ wt = wrap(table)
61
+ assert wt.column("name") == []
62
+
63
+
64
+ class TestFindRow:
65
+ def test_single_filter(self):
66
+ table = make_table(["name", "age"], [["Alice", "30"], ["Bob", "25"]])
67
+ wt = wrap(table)
68
+ assert wt.find_row(name="Alice") == {"name": "Alice", "age": "30"}
69
+
70
+ def test_multiple_filters(self):
71
+ table = make_table(["name", "age"], [["Alice", "30"], ["Bob", "25"]])
72
+ wt = wrap(table)
73
+ assert wt.find_row(name="Alice", age="30") == {"name": "Alice", "age": "30"}
74
+
75
+ def test_no_match(self):
76
+ table = make_table(["name", "age"], [["Alice", "30"]])
77
+ wt = wrap(table)
78
+ assert wt.find_row(name="Charlie") is None
79
+
80
+ def test_no_filters_returns_first(self):
81
+ table = make_table(["name"], [["Alice"], ["Bob"]])
82
+ wt = wrap(table)
83
+ assert wt.find_row() == {"name": "Alice"}
84
+
85
+ def test_no_filters_empty_table(self):
86
+ table = make_table(["name"], [])
87
+ wt = wrap(table)
88
+ assert wt.find_row() is None
89
+
90
+
91
+ class TestValidateColumns:
92
+ def test_all_present(self):
93
+ table = make_table(["name", "age", "email"], [])
94
+ wt = wrap(table)
95
+ wt.validate_columns("name", "age")
96
+
97
+ def test_missing(self):
98
+ table = make_table(["name"], [])
99
+ wt = wrap(table)
100
+ with pytest.raises(ColumnMismatchError, match="missing columns"):
101
+ wt.validate_columns("name", "email", "phone")
102
+
103
+ def test_empty_expected(self):
104
+ table = make_table(["name"], [])
105
+ wt = wrap(table)
106
+ wt.validate_columns()
107
+
108
+
109
+ class TestTranspose:
110
+ def test_basic(self):
111
+ table = make_table(["name", "age"], [["Alice", "30"], ["Bob", "25"]])
112
+ wt = wrap(table)
113
+ transposed = wt.transpose()
114
+ assert transposed.headers == ["_column", "0", "1"]
115
+ dicts = transposed.as_dicts()
116
+ assert dicts[0] == {"_column": "name", "0": "Alice", "1": "Bob"}
117
+ assert dicts[1] == {"_column": "age", "0": "30", "1": "25"}
118
+
119
+ def test_original_unchanged(self):
120
+ table = make_table(["name", "age"], [["Alice", "30"]])
121
+ wt = wrap(table)
122
+ transposed = wt.transpose()
123
+ assert wt.as_dicts() == [{"name": "Alice", "age": "30"}]
124
+ assert transposed.headers != wt.headers
125
+
126
+ def test_empty_table(self):
127
+ table = make_table(["name", "age"], [])
128
+ wt = wrap(table)
129
+ transposed = wt.transpose()
130
+ assert len(transposed) == 2
131
+ assert transposed.headers == ["_column"]
132
+
133
+
134
+ class TestToCsv:
135
+ def test_basic(self):
136
+ table = make_table(["name", "age"], [["Alice", "30"], ["Bob", "25"]])
137
+ wt = wrap(table)
138
+ csv_str = wt.to_csv()
139
+ lines = csv_str.split("\n")
140
+ assert lines[0] == "name,age"
141
+ assert lines[1] == "Alice,30"
142
+ assert lines[2] == "Bob,25"
143
+
144
+ def test_empty_table(self):
145
+ table = make_table(["name", "age"], [])
146
+ wt = wrap(table)
147
+ csv_str = wt.to_csv()
148
+ assert csv_str == "name,age"
149
+
150
+
151
+ class TestToJson:
152
+ def test_basic(self):
153
+ table = make_table(["name", "age"], [["Alice", "30"], ["Bob", "25"]])
154
+ wt = wrap(table)
155
+ json_str = wt.to_json()
156
+ parsed = json.loads(json_str)
157
+ assert parsed == [
158
+ {"name": "Alice", "age": "30"},
159
+ {"name": "Bob", "age": "25"},
160
+ ]
161
+
162
+ def test_empty_table(self):
163
+ table = make_table(["name"], [])
164
+ wt = wrap(table)
165
+ json_str = wt.to_json()
166
+ assert json.loads(json_str) == []
167
+
168
+
169
+ class TestIteration:
170
+ def test_iter(self):
171
+ table = make_table(["name"], [["Alice"], ["Bob"]])
172
+ wt = wrap(table)
173
+ rows = [r for r in wt]
174
+ assert rows == [{"name": "Alice"}, {"name": "Bob"}]
175
+
176
+ def test_getitem(self):
177
+ table = make_table(["name", "age"], [["Alice", "30"], ["Bob", "25"]])
178
+ wt = wrap(table)
179
+ assert wt[0] == {"name": "Alice", "age": "30"}
180
+ assert wt[1] == {"name": "Bob", "age": "25"}
181
+
182
+ def test_len(self):
183
+ table = make_table(["name"], [["Alice"], ["Bob"], ["Charlie"]])
184
+ wt = wrap(table)
185
+ assert len(wt) == 3
186
+
187
+ def test_len_empty(self):
188
+ table = make_table(["name"], [])
189
+ wt = wrap(table)
190
+ assert len(wt) == 0
191
+
192
+
193
+ class TestHeaders:
194
+ def test_basic(self):
195
+ table = make_table(["name", "age", "email"], [])
196
+ wt = wrap(table)
197
+ assert wt.headers == ["name", "age", "email"]
198
+
199
+ def test_returns_copy(self):
200
+ table = make_table(["name"], [])
201
+ wt = wrap(table)
202
+ headers = wt.headers
203
+ headers.append("extra")
204
+ assert wt.headers == ["name"]
205
+
206
+
207
+ class TestRepr:
208
+ def test_repr(self):
209
+ table = make_table(["name", "age"], [["Alice", "30"], ["Bob", "25"]])
210
+ wt = wrap(table)
211
+ r = repr(wt)
212
+ assert "TableWrapper" in r
213
+ assert "name" in r
214
+ assert "rows=2" in r