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.
- behave_tables-0.1.0/.github/workflows/ci.yml +31 -0
- behave_tables-0.1.0/.github/workflows/release.yml +38 -0
- behave_tables-0.1.0/.gitignore +14 -0
- behave_tables-0.1.0/CHANGELOG.md +22 -0
- behave_tables-0.1.0/LICENSE +21 -0
- behave_tables-0.1.0/PKG-INFO +110 -0
- behave_tables-0.1.0/README.md +79 -0
- behave_tables-0.1.0/behave_tables/__init__.py +19 -0
- behave_tables-0.1.0/behave_tables/_mock.py +31 -0
- behave_tables-0.1.0/behave_tables/converters.py +35 -0
- behave_tables-0.1.0/behave_tables/exceptions.py +15 -0
- behave_tables-0.1.0/behave_tables/wrapper.py +144 -0
- behave_tables-0.1.0/pyproject.toml +68 -0
- behave_tables-0.1.0/tests/__init__.py +0 -0
- behave_tables-0.1.0/tests/conftest.py +41 -0
- behave_tables-0.1.0/tests/helpers.py +40 -0
- behave_tables-0.1.0/tests/test_exceptions.py +26 -0
- behave_tables-0.1.0/tests/test_models.py +68 -0
- behave_tables-0.1.0/tests/test_wrapper.py +214 -0
|
@@ -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,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
|