pytest-duckdb 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.
- pytest_duckdb-0.1.0/.github/workflows/ci.yml +74 -0
- pytest_duckdb-0.1.0/.gitignore +11 -0
- pytest_duckdb-0.1.0/LICENSE +21 -0
- pytest_duckdb-0.1.0/PKG-INFO +111 -0
- pytest_duckdb-0.1.0/README.md +78 -0
- pytest_duckdb-0.1.0/pyproject.toml +72 -0
- pytest_duckdb-0.1.0/src/pytest_duckdb/__init__.py +3 -0
- pytest_duckdb-0.1.0/src/pytest_duckdb/fixtures.py +50 -0
- pytest_duckdb-0.1.0/src/pytest_duckdb/plugin.py +254 -0
- pytest_duckdb-0.1.0/src/pytest_duckdb/snapshot.py +274 -0
- pytest_duckdb-0.1.0/tests/__snapshots__/test_integration_test_sql_snapshot_fixture_creates.parquet +0 -0
- pytest_duckdb-0.1.0/tests/__snapshots__/test_integration_test_sql_snapshot_fixture_matches.parquet +0 -0
- pytest_duckdb-0.1.0/tests/conftest.py +3 -0
- pytest_duckdb-0.1.0/tests/fixtures/customers.csv +6 -0
- pytest_duckdb-0.1.0/tests/fixtures/orders.csv +11 -0
- pytest_duckdb-0.1.0/tests/queries/join.sql +5 -0
- pytest_duckdb-0.1.0/tests/queries/simple.sql +1 -0
- pytest_duckdb-0.1.0/tests/test_fixtures.py +135 -0
- pytest_duckdb-0.1.0/tests/test_integration.py +139 -0
- pytest_duckdb-0.1.0/tests/test_plugin.py +7 -0
- pytest_duckdb-0.1.0/tests/test_snapshot.py +177 -0
- pytest_duckdb-0.1.0/tests/test_sql_marker.py +116 -0
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
name: CI
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
push:
|
|
5
|
+
branches: [main]
|
|
6
|
+
pull_request:
|
|
7
|
+
branches: [main]
|
|
8
|
+
release:
|
|
9
|
+
types: [published]
|
|
10
|
+
|
|
11
|
+
concurrency:
|
|
12
|
+
group: ${{ github.workflow }}-${{ github.ref }}
|
|
13
|
+
cancel-in-progress: true
|
|
14
|
+
|
|
15
|
+
jobs:
|
|
16
|
+
test:
|
|
17
|
+
runs-on: ubuntu-latest
|
|
18
|
+
strategy:
|
|
19
|
+
fail-fast: false
|
|
20
|
+
matrix:
|
|
21
|
+
python-version: ["3.9", "3.10", "3.11", "3.12", "3.13"]
|
|
22
|
+
|
|
23
|
+
steps:
|
|
24
|
+
- uses: actions/checkout@v4
|
|
25
|
+
|
|
26
|
+
- name: Set up Python ${{ matrix.python-version }}
|
|
27
|
+
uses: actions/setup-python@v5
|
|
28
|
+
with:
|
|
29
|
+
python-version: ${{ matrix.python-version }}
|
|
30
|
+
|
|
31
|
+
- name: Install dependencies
|
|
32
|
+
run: |
|
|
33
|
+
python -m pip install --upgrade pip
|
|
34
|
+
pip install -e ".[dev]"
|
|
35
|
+
|
|
36
|
+
- name: Lint
|
|
37
|
+
run: ruff check src/ tests/
|
|
38
|
+
|
|
39
|
+
- name: Test
|
|
40
|
+
run: |
|
|
41
|
+
coverage run -m pytest
|
|
42
|
+
coverage report --fail-under=80
|
|
43
|
+
coverage xml
|
|
44
|
+
|
|
45
|
+
- name: Upload coverage
|
|
46
|
+
if: matrix.python-version == '3.12'
|
|
47
|
+
uses: codecov/codecov-action@v4
|
|
48
|
+
with:
|
|
49
|
+
file: coverage.xml
|
|
50
|
+
continue-on-error: true
|
|
51
|
+
|
|
52
|
+
publish:
|
|
53
|
+
needs: test
|
|
54
|
+
runs-on: ubuntu-latest
|
|
55
|
+
if: github.event_name == 'release'
|
|
56
|
+
environment: pypi
|
|
57
|
+
permissions:
|
|
58
|
+
id-token: write
|
|
59
|
+
|
|
60
|
+
steps:
|
|
61
|
+
- uses: actions/checkout@v4
|
|
62
|
+
|
|
63
|
+
- name: Set up Python
|
|
64
|
+
uses: actions/setup-python@v5
|
|
65
|
+
with:
|
|
66
|
+
python-version: "3.12"
|
|
67
|
+
|
|
68
|
+
- name: Build
|
|
69
|
+
run: |
|
|
70
|
+
pip install build
|
|
71
|
+
python -m build
|
|
72
|
+
|
|
73
|
+
- name: Publish to PyPI
|
|
74
|
+
uses: pypa/gh-action-pypi-publish@release/v1
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Annas Mazhar
|
|
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,111 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: pytest-duckdb
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: pytest plugin for SQL pipeline testing with DuckDB — load fixtures, run queries, snapshot results
|
|
5
|
+
Project-URL: Homepage, https://github.com/AnnasMazhar/pytest-duckdb
|
|
6
|
+
Project-URL: Issues, https://github.com/AnnasMazhar/pytest-duckdb/issues
|
|
7
|
+
Project-URL: Source, https://github.com/AnnasMazhar/pytest-duckdb
|
|
8
|
+
Author-email: Annas Mazhar <annas.mazhar10@gmail.com>
|
|
9
|
+
License-Expression: MIT
|
|
10
|
+
License-File: LICENSE
|
|
11
|
+
Keywords: data-engineering,duckdb,pytest,snapshot,sql,testing
|
|
12
|
+
Classifier: Development Status :: 3 - Alpha
|
|
13
|
+
Classifier: Framework :: Pytest
|
|
14
|
+
Classifier: Intended Audience :: Developers
|
|
15
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
16
|
+
Classifier: Programming Language :: Python :: 3
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
21
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
22
|
+
Classifier: Topic :: Database
|
|
23
|
+
Classifier: Topic :: Software Development :: Testing
|
|
24
|
+
Requires-Python: >=3.9
|
|
25
|
+
Requires-Dist: duckdb>=0.9.0
|
|
26
|
+
Requires-Dist: pytest>=7.0
|
|
27
|
+
Provides-Extra: dev
|
|
28
|
+
Requires-Dist: coverage>=7.0; extra == 'dev'
|
|
29
|
+
Requires-Dist: pandas>=2.0; extra == 'dev'
|
|
30
|
+
Requires-Dist: pytest-cov>=5.0; extra == 'dev'
|
|
31
|
+
Requires-Dist: ruff>=0.4.0; extra == 'dev'
|
|
32
|
+
Description-Content-Type: text/markdown
|
|
33
|
+
|
|
34
|
+
# pytest-duckdb
|
|
35
|
+
|
|
36
|
+
[](https://github.com/AnnasMazhar/pytest-duckdb/actions/workflows/ci.yml)
|
|
37
|
+
[](https://pypi.org/project/pytest-duckdb/)
|
|
38
|
+
[](https://pypi.org/project/pytest-duckdb/)
|
|
39
|
+
|
|
40
|
+
SQL pipeline testing made trivial. Load fixture CSVs as tables, run your queries, snapshot the results.
|
|
41
|
+
|
|
42
|
+
## Install
|
|
43
|
+
|
|
44
|
+
```bash
|
|
45
|
+
pip install pytest-duckdb
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
## Quick Start
|
|
49
|
+
|
|
50
|
+
Place CSV files in `tests/fixtures/`:
|
|
51
|
+
|
|
52
|
+
```
|
|
53
|
+
tests/
|
|
54
|
+
fixtures/
|
|
55
|
+
orders.csv
|
|
56
|
+
customers.csv
|
|
57
|
+
test_queries.py
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
They become tables automatically:
|
|
61
|
+
|
|
62
|
+
```python
|
|
63
|
+
def test_revenue_query(duckdb_session, sql_snapshot):
|
|
64
|
+
result = duckdb_session.execute(
|
|
65
|
+
"SELECT * FROM orders WHERE amount > 100"
|
|
66
|
+
).fetchall()
|
|
67
|
+
|
|
68
|
+
# First run saves the snapshot, subsequent runs diff against it
|
|
69
|
+
sql_snapshot(result)
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
## Features
|
|
73
|
+
|
|
74
|
+
- **Zero config** — fixture CSVs in `tests/fixtures/` auto-load as tables
|
|
75
|
+
- **Snapshot testing** — first run saves output, subsequent runs diff
|
|
76
|
+
- **Raw SQL files** — test `.sql` files directly without dbt
|
|
77
|
+
- **Ephemeral** — each test gets a fresh in-memory DuckDB
|
|
78
|
+
- **Fast** — DuckDB is embedded, no Docker, no network
|
|
79
|
+
|
|
80
|
+
## Fixture Loading
|
|
81
|
+
|
|
82
|
+
CSVs loaded by file stem become table names. `orders.csv` → `orders` table.
|
|
83
|
+
|
|
84
|
+
```python
|
|
85
|
+
def test_join(duckdb_session):
|
|
86
|
+
result = duckdb_session.execute("""
|
|
87
|
+
SELECT c.name, o.amount
|
|
88
|
+
FROM orders o JOIN customers c ON o.customer_id = c.id
|
|
89
|
+
""").fetchall()
|
|
90
|
+
assert len(result) > 0
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
## SQL File Testing
|
|
94
|
+
|
|
95
|
+
```python
|
|
96
|
+
@pytest.mark.sql("queries/revenue_by_month.sql")
|
|
97
|
+
def test_revenue_by_month(sql_result, sql_snapshot):
|
|
98
|
+
sql_snapshot(sql_result)
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
## Snapshot Update
|
|
102
|
+
|
|
103
|
+
Regenerate all snapshots when your expected output changes:
|
|
104
|
+
|
|
105
|
+
```bash
|
|
106
|
+
pytest --snapshot-update
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
## License
|
|
110
|
+
|
|
111
|
+
MIT
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
# pytest-duckdb
|
|
2
|
+
|
|
3
|
+
[](https://github.com/AnnasMazhar/pytest-duckdb/actions/workflows/ci.yml)
|
|
4
|
+
[](https://pypi.org/project/pytest-duckdb/)
|
|
5
|
+
[](https://pypi.org/project/pytest-duckdb/)
|
|
6
|
+
|
|
7
|
+
SQL pipeline testing made trivial. Load fixture CSVs as tables, run your queries, snapshot the results.
|
|
8
|
+
|
|
9
|
+
## Install
|
|
10
|
+
|
|
11
|
+
```bash
|
|
12
|
+
pip install pytest-duckdb
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
## Quick Start
|
|
16
|
+
|
|
17
|
+
Place CSV files in `tests/fixtures/`:
|
|
18
|
+
|
|
19
|
+
```
|
|
20
|
+
tests/
|
|
21
|
+
fixtures/
|
|
22
|
+
orders.csv
|
|
23
|
+
customers.csv
|
|
24
|
+
test_queries.py
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
They become tables automatically:
|
|
28
|
+
|
|
29
|
+
```python
|
|
30
|
+
def test_revenue_query(duckdb_session, sql_snapshot):
|
|
31
|
+
result = duckdb_session.execute(
|
|
32
|
+
"SELECT * FROM orders WHERE amount > 100"
|
|
33
|
+
).fetchall()
|
|
34
|
+
|
|
35
|
+
# First run saves the snapshot, subsequent runs diff against it
|
|
36
|
+
sql_snapshot(result)
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
## Features
|
|
40
|
+
|
|
41
|
+
- **Zero config** — fixture CSVs in `tests/fixtures/` auto-load as tables
|
|
42
|
+
- **Snapshot testing** — first run saves output, subsequent runs diff
|
|
43
|
+
- **Raw SQL files** — test `.sql` files directly without dbt
|
|
44
|
+
- **Ephemeral** — each test gets a fresh in-memory DuckDB
|
|
45
|
+
- **Fast** — DuckDB is embedded, no Docker, no network
|
|
46
|
+
|
|
47
|
+
## Fixture Loading
|
|
48
|
+
|
|
49
|
+
CSVs loaded by file stem become table names. `orders.csv` → `orders` table.
|
|
50
|
+
|
|
51
|
+
```python
|
|
52
|
+
def test_join(duckdb_session):
|
|
53
|
+
result = duckdb_session.execute("""
|
|
54
|
+
SELECT c.name, o.amount
|
|
55
|
+
FROM orders o JOIN customers c ON o.customer_id = c.id
|
|
56
|
+
""").fetchall()
|
|
57
|
+
assert len(result) > 0
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
## SQL File Testing
|
|
61
|
+
|
|
62
|
+
```python
|
|
63
|
+
@pytest.mark.sql("queries/revenue_by_month.sql")
|
|
64
|
+
def test_revenue_by_month(sql_result, sql_snapshot):
|
|
65
|
+
sql_snapshot(sql_result)
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
## Snapshot Update
|
|
69
|
+
|
|
70
|
+
Regenerate all snapshots when your expected output changes:
|
|
71
|
+
|
|
72
|
+
```bash
|
|
73
|
+
pytest --snapshot-update
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
## License
|
|
77
|
+
|
|
78
|
+
MIT
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "pytest-duckdb"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "pytest plugin for SQL pipeline testing with DuckDB — load fixtures, run queries, snapshot results"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
license = "MIT"
|
|
11
|
+
requires-python = ">=3.9"
|
|
12
|
+
authors = [{ name = "Annas Mazhar", email = "annas.mazhar10@gmail.com" }]
|
|
13
|
+
keywords = ["pytest", "duckdb", "sql", "testing", "data-engineering", "snapshot"]
|
|
14
|
+
classifiers = [
|
|
15
|
+
"Development Status :: 3 - Alpha",
|
|
16
|
+
"Framework :: Pytest",
|
|
17
|
+
"Intended Audience :: Developers",
|
|
18
|
+
"License :: OSI Approved :: MIT License",
|
|
19
|
+
"Programming Language :: Python :: 3",
|
|
20
|
+
"Programming Language :: Python :: 3.9",
|
|
21
|
+
"Programming Language :: Python :: 3.10",
|
|
22
|
+
"Programming Language :: Python :: 3.11",
|
|
23
|
+
"Programming Language :: Python :: 3.12",
|
|
24
|
+
"Programming Language :: Python :: 3.13",
|
|
25
|
+
"Topic :: Database",
|
|
26
|
+
"Topic :: Software Development :: Testing",
|
|
27
|
+
]
|
|
28
|
+
dependencies = [
|
|
29
|
+
"pytest>=7.0",
|
|
30
|
+
"duckdb>=0.9.0",
|
|
31
|
+
]
|
|
32
|
+
|
|
33
|
+
[project.optional-dependencies]
|
|
34
|
+
dev = [
|
|
35
|
+
"ruff>=0.4.0",
|
|
36
|
+
"pytest-cov>=5.0",
|
|
37
|
+
"coverage>=7.0",
|
|
38
|
+
"pandas>=2.0",
|
|
39
|
+
]
|
|
40
|
+
|
|
41
|
+
[project.entry-points.pytest11]
|
|
42
|
+
duckdb = "pytest_duckdb.plugin"
|
|
43
|
+
|
|
44
|
+
[project.urls]
|
|
45
|
+
Homepage = "https://github.com/AnnasMazhar/pytest-duckdb"
|
|
46
|
+
Issues = "https://github.com/AnnasMazhar/pytest-duckdb/issues"
|
|
47
|
+
Source = "https://github.com/AnnasMazhar/pytest-duckdb"
|
|
48
|
+
|
|
49
|
+
[tool.hatch.build.targets.wheel]
|
|
50
|
+
packages = ["src/pytest_duckdb"]
|
|
51
|
+
|
|
52
|
+
[tool.pytest.ini_options]
|
|
53
|
+
testpaths = ["tests"]
|
|
54
|
+
addopts = "-v --tb=short"
|
|
55
|
+
|
|
56
|
+
[tool.ruff]
|
|
57
|
+
target-version = "py39"
|
|
58
|
+
line-length = 100
|
|
59
|
+
|
|
60
|
+
[tool.ruff.lint]
|
|
61
|
+
select = ["E", "F", "I", "W"]
|
|
62
|
+
|
|
63
|
+
[tool.coverage.run]
|
|
64
|
+
source = ["pytest_duckdb"]
|
|
65
|
+
branch = true
|
|
66
|
+
|
|
67
|
+
[tool.coverage.report]
|
|
68
|
+
exclude_lines = [
|
|
69
|
+
"pragma: no cover",
|
|
70
|
+
"if TYPE_CHECKING:",
|
|
71
|
+
"except ImportError:",
|
|
72
|
+
]
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
"""CSV fixture loading — reads CSV files from a directory into DuckDB tables."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
import duckdb
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def load_csvs(conn: duckdb.DuckDBPyConnection, fixtures_dir: str) -> None:
|
|
12
|
+
"""Load all CSV files from *fixtures_dir* as DuckDB tables.
|
|
13
|
+
|
|
14
|
+
Each CSV file becomes a table named after its stem (filename without extension).
|
|
15
|
+
Uses ``read_csv_auto`` for schema inference. Non-existent directories are
|
|
16
|
+
silently ignored so that tests without fixtures work out of the box.
|
|
17
|
+
|
|
18
|
+
Args:
|
|
19
|
+
conn: An open DuckDB connection.
|
|
20
|
+
fixtures_dir: Absolute or project-relative path to the CSV directory.
|
|
21
|
+
"""
|
|
22
|
+
path = Path(fixtures_dir)
|
|
23
|
+
if not path.is_dir():
|
|
24
|
+
return
|
|
25
|
+
|
|
26
|
+
csv_files = sorted(path.glob("*.csv"))
|
|
27
|
+
for csv_file in csv_files:
|
|
28
|
+
table_name = csv_file.stem
|
|
29
|
+
conn.execute(
|
|
30
|
+
f'CREATE TABLE "{table_name}" AS '
|
|
31
|
+
f"SELECT * FROM read_csv_auto('{csv_file}')"
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def read_sql_file(path: str) -> str:
|
|
36
|
+
"""Read and return the contents of a SQL file.
|
|
37
|
+
|
|
38
|
+
Args:
|
|
39
|
+
path: Absolute or relative path to the ``.sql`` file.
|
|
40
|
+
|
|
41
|
+
Returns:
|
|
42
|
+
The file contents as a string.
|
|
43
|
+
|
|
44
|
+
Raises:
|
|
45
|
+
FileNotFoundError: If the file does not exist.
|
|
46
|
+
"""
|
|
47
|
+
if not os.path.isfile(path):
|
|
48
|
+
raise FileNotFoundError(f"SQL file not found: {path}")
|
|
49
|
+
with open(path) as fh:
|
|
50
|
+
return fh.read()
|
|
@@ -0,0 +1,254 @@
|
|
|
1
|
+
"""pytest plugin registration — fixtures, markers, and CLI options.
|
|
2
|
+
|
|
3
|
+
Provides three fixtures:
|
|
4
|
+
|
|
5
|
+
* ``duckdb_session`` – function-scoped in-memory DuckDB with CSV fixtures
|
|
6
|
+
auto-loaded.
|
|
7
|
+
* ``sql_result`` – executes a ``.sql`` file referenced via ``@pytest.mark.sql``.
|
|
8
|
+
* ``sql_snapshot`` – callable fixture for Parquet snapshot testing.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import os
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
from typing import Any, Callable, Optional
|
|
16
|
+
|
|
17
|
+
import pytest
|
|
18
|
+
|
|
19
|
+
from .fixtures import load_csvs, read_sql_file
|
|
20
|
+
from .snapshot import compare, save
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def pytest_addoption(parser: pytest.Parser) -> None:
|
|
24
|
+
"""Register CLI options and ini-file keys."""
|
|
25
|
+
parser.addini(
|
|
26
|
+
"duckdb_fixtures_dir",
|
|
27
|
+
type="string",
|
|
28
|
+
default="tests/fixtures",
|
|
29
|
+
help="Directory containing CSV fixture files",
|
|
30
|
+
)
|
|
31
|
+
parser.addini(
|
|
32
|
+
"duckdb_snapshots_dir",
|
|
33
|
+
type="string",
|
|
34
|
+
default="tests/__snapshots__",
|
|
35
|
+
help="Directory for Parquet snapshot files",
|
|
36
|
+
)
|
|
37
|
+
group = parser.getgroup("duckdb")
|
|
38
|
+
group.addoption(
|
|
39
|
+
"--snapshot-update",
|
|
40
|
+
action="store_true",
|
|
41
|
+
default=False,
|
|
42
|
+
help="Regenerate all Parquet snapshots instead of comparing",
|
|
43
|
+
)
|
|
44
|
+
group.addoption(
|
|
45
|
+
"--duckdb-fixtures-dir",
|
|
46
|
+
action="store",
|
|
47
|
+
default=None,
|
|
48
|
+
metavar="PATH",
|
|
49
|
+
help="Directory containing CSV fixture files (default: tests/fixtures)",
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def pytest_configure(config: pytest.Config) -> None:
|
|
54
|
+
"""Register the ``sql`` marker and expand docstring."""
|
|
55
|
+
config.addinivalue_line(
|
|
56
|
+
"markers",
|
|
57
|
+
"sql(path): mark a test to run a SQL file; "
|
|
58
|
+
"the path is resolved relative to the project root.",
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
# Persist resolved config options.
|
|
62
|
+
config.duckdb_fixtures_dir = _resolve_option(
|
|
63
|
+
config,
|
|
64
|
+
cli_name="--duckdb-fixtures-dir",
|
|
65
|
+
ini_name="duckdb_fixtures_dir",
|
|
66
|
+
default="tests/fixtures",
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
config.duckdb_snapshots_dir = _resolve_ini_option(
|
|
70
|
+
config,
|
|
71
|
+
ini_name="duckdb_snapshots_dir",
|
|
72
|
+
default="tests/__snapshots__",
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
# ---------------------------------------------------------------------------
|
|
77
|
+
# Fixtures
|
|
78
|
+
# ---------------------------------------------------------------------------
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
@pytest.fixture(scope="function")
|
|
82
|
+
def duckdb_session(request: pytest.FixtureRequest):
|
|
83
|
+
"""Function-scoped DuckDB in-memory session with auto-loaded CSV fixtures.
|
|
84
|
+
|
|
85
|
+
CSVs in the configured fixtures directory are loaded as tables named
|
|
86
|
+
after the file stem. If the directory does not exist the session is
|
|
87
|
+
returned empty with no error.
|
|
88
|
+
|
|
89
|
+
Yields:
|
|
90
|
+
``duckdb.DuckDBPyConnection`` -- close on teardown.
|
|
91
|
+
"""
|
|
92
|
+
import duckdb
|
|
93
|
+
|
|
94
|
+
conn = duckdb.connect(":memory:")
|
|
95
|
+
fixtures_dir = _resolve_path(
|
|
96
|
+
request.config.duckdb_fixtures_dir,
|
|
97
|
+
request.config.rootpath,
|
|
98
|
+
)
|
|
99
|
+
load_csvs(conn, fixtures_dir)
|
|
100
|
+
yield conn
|
|
101
|
+
conn.close()
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
@pytest.fixture(scope="function")
|
|
105
|
+
def sql_result(
|
|
106
|
+
duckdb_session, # noqa: ANN001, ANN201
|
|
107
|
+
request: pytest.FixtureRequest,
|
|
108
|
+
):
|
|
109
|
+
"""Fixture that executes a SQL file referenced by ``@pytest.mark.sql``.
|
|
110
|
+
|
|
111
|
+
Usage::
|
|
112
|
+
|
|
113
|
+
@pytest.mark.sql("tests/queries/my_query.sql")
|
|
114
|
+
def test_something(sql_result):
|
|
115
|
+
assert len(sql_result) > 0
|
|
116
|
+
|
|
117
|
+
Returns a ``pandas.DataFrame`` if pandas is installed, otherwise a
|
|
118
|
+
:class:`~pytest_duckdb.snapshot.QueryResult` with ``.columns``.
|
|
119
|
+
|
|
120
|
+
Raises:
|
|
121
|
+
FileNotFoundError: The referenced SQL file does not exist.
|
|
122
|
+
"""
|
|
123
|
+
marker = request.node.get_closest_marker("sql")
|
|
124
|
+
if marker is None:
|
|
125
|
+
return None
|
|
126
|
+
|
|
127
|
+
sql_path = _resolve_marker_path(marker, request)
|
|
128
|
+
query = read_sql_file(sql_path)
|
|
129
|
+
result = duckdb_session.execute(query)
|
|
130
|
+
|
|
131
|
+
from .snapshot import _cursor_to_result
|
|
132
|
+
|
|
133
|
+
return _cursor_to_result(result)
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
@pytest.fixture(scope="function")
|
|
137
|
+
def sql_snapshot(
|
|
138
|
+
duckdb_session, # noqa: ANN001, ANN201
|
|
139
|
+
request: pytest.FixtureRequest,
|
|
140
|
+
) -> Callable[..., None]:
|
|
141
|
+
"""Callable fixture for Parquet snapshot testing.
|
|
142
|
+
|
|
143
|
+
Usage::
|
|
144
|
+
|
|
145
|
+
def test_query(sql_snapshot):
|
|
146
|
+
result = duckdb_session.execute("SELECT ...").fetchdf()
|
|
147
|
+
sql_snapshot(result)
|
|
148
|
+
|
|
149
|
+
**First run:** saves the result to ``{snapshots_dir}/{module}_{test}.parquet``
|
|
150
|
+
and passes.
|
|
151
|
+
|
|
152
|
+
**Subsequent runs:** loads the Parquet snapshot and asserts equality. Raises
|
|
153
|
+
``AssertionError`` with a clear diff on mismatch.
|
|
154
|
+
|
|
155
|
+
Pass ``--snapshot-update`` on the CLI to regenerate all snapshots.
|
|
156
|
+
"""
|
|
157
|
+
snapshots_dir = _resolve_path(
|
|
158
|
+
request.config.duckdb_snapshots_dir,
|
|
159
|
+
request.config.rootpath,
|
|
160
|
+
)
|
|
161
|
+
update = request.config.getoption("--snapshot-update", default=False)
|
|
162
|
+
|
|
163
|
+
# Build a deterministic snapshot file name.
|
|
164
|
+
module_name = _module_name(request.node)
|
|
165
|
+
test_name = _test_name(request.node)
|
|
166
|
+
snapshot_path = os.path.join(
|
|
167
|
+
snapshots_dir, f"{module_name}_{test_name}.parquet",
|
|
168
|
+
)
|
|
169
|
+
|
|
170
|
+
def _snapshot(result: Any, name: Optional[str] = None) -> None:
|
|
171
|
+
"""Save or compare *result* against the Parquet snapshot.
|
|
172
|
+
|
|
173
|
+
Args:
|
|
174
|
+
result: DataFrame or list-of-tuples to snapshot.
|
|
175
|
+
name: Optional override for the snapshot file name stem.
|
|
176
|
+
|
|
177
|
+
Raises:
|
|
178
|
+
AssertionError: On mismatch (when not updating).
|
|
179
|
+
"""
|
|
180
|
+
nonlocal snapshot_path
|
|
181
|
+
if name is not None:
|
|
182
|
+
snapshot_path = os.path.join(
|
|
183
|
+
snapshots_dir, f"{module_name}_{name}.parquet",
|
|
184
|
+
)
|
|
185
|
+
|
|
186
|
+
if update or not os.path.isfile(snapshot_path):
|
|
187
|
+
save(duckdb_session, result, snapshot_path)
|
|
188
|
+
else:
|
|
189
|
+
compare(duckdb_session, result, snapshot_path)
|
|
190
|
+
|
|
191
|
+
return _snapshot
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
# ---------------------------------------------------------------------------
|
|
195
|
+
# Internal helpers
|
|
196
|
+
# ---------------------------------------------------------------------------
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def _resolve_option(
|
|
200
|
+
config: pytest.Config,
|
|
201
|
+
cli_name: str,
|
|
202
|
+
ini_name: str,
|
|
203
|
+
default: str,
|
|
204
|
+
) -> str:
|
|
205
|
+
"""Resolve a config value from CLI → ini → default."""
|
|
206
|
+
cli_val = config.getoption(cli_name.replace("--", "").replace("-", "_"))
|
|
207
|
+
if cli_val is not None:
|
|
208
|
+
return cli_val
|
|
209
|
+
ini_val = config.getini(ini_name)
|
|
210
|
+
if ini_val:
|
|
211
|
+
return ini_val
|
|
212
|
+
return default
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def _resolve_ini_option(
|
|
216
|
+
config: pytest.Config,
|
|
217
|
+
ini_name: str,
|
|
218
|
+
default: str,
|
|
219
|
+
) -> str:
|
|
220
|
+
"""Resolve a config value from ini → default."""
|
|
221
|
+
ini_val = config.getini(ini_name)
|
|
222
|
+
if ini_val:
|
|
223
|
+
return ini_val
|
|
224
|
+
return default
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
def _resolve_path(path_str: str, root: Path) -> str:
|
|
228
|
+
"""Resolve a possibly-relative path against the project root."""
|
|
229
|
+
if not os.path.isabs(path_str):
|
|
230
|
+
return os.path.join(str(root), path_str)
|
|
231
|
+
return path_str
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
def _resolve_marker_path(marker: pytest.Mark, request: pytest.FixtureRequest) -> str:
|
|
235
|
+
"""Resolve the path from a ``@pytest.mark.sql`` marker."""
|
|
236
|
+
path = marker.args[0] if marker.args else marker.kwargs.get("path", "")
|
|
237
|
+
if not os.path.isabs(path):
|
|
238
|
+
return os.path.join(str(request.config.rootpath), path)
|
|
239
|
+
return path
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
def _module_name(node: pytest.Item) -> str:
|
|
243
|
+
"""Return a sanitised module name for snapshot file naming."""
|
|
244
|
+
# node.path gives the file path, e.g. tests/test_fixtures.py
|
|
245
|
+
fspath = getattr(node, "path", None) or getattr(node, "fspath", None)
|
|
246
|
+
if fspath:
|
|
247
|
+
stem = Path(str(fspath)).stem # e.g. test_fixtures
|
|
248
|
+
return stem
|
|
249
|
+
return "unknown_module"
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
def _test_name(node: pytest.Item) -> str:
|
|
253
|
+
"""Return a sanitised test function name."""
|
|
254
|
+
return node.name.replace("[", "_").replace("]", "_").replace("/", "_")
|