Skip to content

Commit 951d100

Browse files
committed
add papermill-based notebook tests for pyiceberg examples
1 parent 6e68c9c commit 951d100

5 files changed

Lines changed: 281 additions & 2 deletions

File tree

‎Makefile‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@
1616
# under the License.
1717
.PHONY: help install install-uv check-license lint \
1818
test test-integration test-integration-setup test-integration-exec test-integration-cleanup test-integration-rebuild \
19-
test-s3 test-adls test-gcs test-coverage coverage-report \
19+
test-s3 test-adls test-gcs test-coverage coverage-report test test-notebook\
2020
docs-serve docs-build notebook notebook-infra \
2121
clean
2222

@@ -150,6 +150,9 @@ coverage-report: ## Combine and report coverage
150150
uv run $(PYTHON_ARG) coverage html
151151
uv run $(PYTHON_ARG) coverage xml
152152

153+
test-notebook: ## Run notebook tests (pyiceberg_example and spark_integration_example) via papermill
154+
$(TEST_RUNNER) pytest tests/notebooks/test_pyiceberg_example.py tests/notebooks/test_spark_integration_example.py -m notebook $(PYTEST_ARGS)
155+
153156
# ================
154157
# Documentation
155158
# ================

‎pyproject.toml‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -122,6 +122,9 @@ dev = [
122122
"google-cloud-bigquery>=3.33.0,<4",
123123
"pyarrow-stubs>=20.0.0.20251107", # Remove when pyarrow >= 23.0.0 https://github.com/apache/arrow/pull/47609
124124
"sqlalchemy>=2.0.18,<3",
125+
"papermill>=2.6.0",
126+
"nbformat>=5.10.0",
127+
"ipykernel>=6.29.0",
125128
]
126129
# for mkdocs
127130
docs = [
@@ -161,6 +164,7 @@ markers = [
161164
"integration: marks integration tests against Apache Spark",
162165
"gcs: marks a test as requiring access to gcs compliant storage (use with --gs.token, --gs.project, and --gs.endpoint)",
163166
"benchmark: collection of tests to validate read/write performance before and after a change",
167+
"notebook: marks tests that execute Jupyter notebooks via papermill",
164168
]
165169

166170
# Turns a warning into an error
Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
from pathlib import Path
2+
3+
import nbformat
4+
import papermill as pm
5+
import pytest
6+
7+
pytestmark = pytest.mark.notebook
8+
9+
NOTEBOOK_PATH = Path(__file__).parents[2] / "notebooks" / "pyiceberg_example.ipynb"
10+
11+
12+
def get_all_stdout(nb: nbformat.NotebookNode) -> str:
13+
"""Concatenate all stdout streams from every executed cell."""
14+
return "".join(
15+
out.get("text", "")
16+
for cell in nb.cells
17+
for out in cell.get("outputs", [])
18+
if out.get("output_type") == "stream" and out.get("name") == "stdout"
19+
)
20+
21+
22+
@pytest.fixture(scope="session")
23+
def pyiceberg_nb(tmp_path_factory: pytest.TempPathFactory) -> nbformat.NotebookNode:
24+
out = tmp_path_factory.mktemp("nb_out") / "pyiceberg_example_out.ipynb"
25+
return pm.execute_notebook(str(NOTEBOOK_PATH), str(out), kernel_name="python3")
26+
27+
28+
class TestSmoke:
29+
def test_notebook_completes_without_error(self, pyiceberg_nb: nbformat.NotebookNode) -> None:
30+
"""papermill raises PapermillExecutionError if any cell fails."""
31+
assert pyiceberg_nb is not None
32+
33+
def test_all_code_cells_executed(self, pyiceberg_nb: nbformat.NotebookNode) -> None:
34+
for cell in pyiceberg_nb.cells:
35+
if cell.cell_type == "code":
36+
assert cell.get("execution_count") is not None, f"Cell not executed:\n{cell.source[:80]}"
37+
38+
39+
class TestCellOutputs:
40+
def test_pyiceberg_version_printed(self, pyiceberg_nb: nbformat.NotebookNode) -> None:
41+
assert "PyIceberg version:" in get_all_stdout(pyiceberg_nb)
42+
43+
def test_warehouse_location_printed(self, pyiceberg_nb: nbformat.NotebookNode) -> None:
44+
stdout = get_all_stdout(pyiceberg_nb)
45+
assert "Warehouse location:" in stdout
46+
assert "iceberg_warehouse_" in stdout
47+
48+
def test_catalog_loaded_successfully(self, pyiceberg_nb: nbformat.NotebookNode) -> None:
49+
assert "Catalog loaded successfully!" in get_all_stdout(pyiceberg_nb)
50+
51+
def test_namespace_default_created(self, pyiceberg_nb: nbformat.NotebookNode) -> None:
52+
assert "default" in get_all_stdout(pyiceberg_nb)
53+
54+
def test_rows_written_is_five(self, pyiceberg_nb: nbformat.NotebookNode) -> None:
55+
assert "Rows written: 5" in get_all_stdout(pyiceberg_nb)
56+
57+
def test_schema_evolved_message(self, pyiceberg_nb: nbformat.NotebookNode) -> None:
58+
assert "Schema evolved!" in get_all_stdout(pyiceberg_nb)
59+
60+
def test_tip_per_mile_column_present_after_evolution(self, pyiceberg_nb: nbformat.NotebookNode) -> None:
61+
assert "tip_per_mile" in get_all_stdout(pyiceberg_nb)
62+
63+
def test_filter_result_is_positive(self, pyiceberg_nb: nbformat.NotebookNode) -> None:
64+
"""The notebook prints 'Rows with tip_per_mile > 1.0: N' — N must be > 0."""
65+
stdout = get_all_stdout(pyiceberg_nb)
66+
assert "Rows with tip_per_mile > 1.0:" in stdout
67+
for line in stdout.splitlines():
68+
if "Rows with tip_per_mile > 1.0:" in line:
69+
count = int(line.split(":")[-1].strip())
70+
assert count > 0
71+
break
72+
73+
def test_snapshot_id_printed(self, pyiceberg_nb: nbformat.NotebookNode) -> None:
74+
assert "Current snapshot ID:" in get_all_stdout(pyiceberg_nb)
75+
76+
def test_table_history_has_entries(self, pyiceberg_nb: nbformat.NotebookNode) -> None:
77+
stdout = get_all_stdout(pyiceberg_nb)
78+
assert "Table history:" in stdout
79+
assert "Snapshot:" in stdout
80+
81+
def test_warehouse_contains_parquet_and_metadata_files(self, pyiceberg_nb: nbformat.NotebookNode) -> None:
82+
stdout = get_all_stdout(pyiceberg_nb)
83+
assert ".parquet" in stdout
84+
assert ".metadata.json" in stdout
Lines changed: 153 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,153 @@
1+
import textwrap
2+
from pathlib import Path
3+
4+
import nbformat
5+
import papermill as pm
6+
import pytest
7+
8+
pytestmark = pytest.mark.notebook
9+
10+
NOTEBOOK_PATH = Path(__file__).parents[2] / "notebooks" / "spark_integration_example.ipynb"
11+
12+
# ---------------------------------------------------------------------------
13+
# Mock pyspark
14+
# Replaces pyspark.sql.SparkSession with a fake one
15+
# ---------------------------------------------------------------------------
16+
_MOCK_PYSPARK = textwrap.dedent("""\
17+
import sys
18+
import types
19+
from unittest.mock import MagicMock
20+
21+
def _make_fake_pyspark():
22+
pyspark_mod = types.ModuleType("pyspark")
23+
sql_mod = types.ModuleType("pyspark.sql")
24+
pyspark_mod.sql = sql_mod
25+
sys.modules.setdefault("pyspark", pyspark_mod)
26+
sys.modules.setdefault("pyspark.sql", sql_mod)
27+
return pyspark_mod, sql_mod
28+
29+
_pyspark, _sql = _make_fake_pyspark()
30+
31+
_SHOW_CATALOGS = (
32+
"+-------------+\\n"
33+
"|catalogName |\\n"
34+
"+-------------+\\n"
35+
"|spark_catalog|\\n"
36+
"|local |\\n"
37+
"+-------------+\\n"
38+
)
39+
_SHOW_NAMESPACES = (
40+
"+---------+\\n"
41+
"|namespace|\\n"
42+
"+---------+\\n"
43+
"|default |\\n"
44+
"+---------+\\n"
45+
)
46+
_SHOW_TABLES = (
47+
"+---------+-----------+-----------+\\n"
48+
"|namespace|tableName |isTemporary|\\n"
49+
"+---------+-----------+-----------+\\n"
50+
"|default |test_all |false |\\n"
51+
"+---------+-----------+-----------+\\n"
52+
)
53+
_DESCRIBE_TABLE = (
54+
"+--------------------+---------+-------+\\n"
55+
"|col_name |data_type|comment|\\n"
56+
"+--------------------+---------+-------+\\n"
57+
"|boolean_col |boolean |null |\\n"
58+
"|integer_col |integer |null |\\n"
59+
"+--------------------+---------+-------+\\n"
60+
)
61+
_SQL_RESPONSES = {
62+
"SHOW CATALOGS": _SHOW_CATALOGS,
63+
"SHOW NAMESPACES": _SHOW_NAMESPACES,
64+
"SHOW TABLES FROM default": _SHOW_TABLES,
65+
"DESCRIBE TABLE default.test_all_types": _DESCRIBE_TABLE,
66+
}
67+
68+
def _make_df(output):
69+
df = MagicMock()
70+
df.show.side_effect = lambda *a, **kw: print(output, end="")
71+
return df
72+
73+
class _FakeBuilder:
74+
def remote(self, url): return self
75+
def getOrCreate(self): return _FakeSession()
76+
77+
class _FakeSession:
78+
builder = _FakeBuilder()
79+
def sql(self, query):
80+
key = query.strip().rstrip(";")
81+
output = _SQL_RESPONSES.get(key, "+------+\\n| col |\\n+------+\\n| val |\\n+------+\\n")
82+
return _make_df(output)
83+
84+
_FakeSparkSession = MagicMock(spec=object)
85+
_FakeSparkSession.builder = _FakeBuilder()
86+
_sql.SparkSession = _FakeSparkSession
87+
""")
88+
89+
90+
def get_all_stdout(nb: nbformat.NotebookNode) -> str:
91+
"""Concatenate all stdout streams from every executed cell."""
92+
return "".join(
93+
out.get("text", "")
94+
for cell in nb.cells
95+
for out in cell.get("outputs", [])
96+
if out.get("output_type") == "stream" and out.get("name") == "stdout"
97+
)
98+
99+
100+
def _inject_mock_and_execute(notebook_path: Path, output_path: Path) -> nbformat.NotebookNode:
101+
"""
102+
Load the real notebook, prepend the mock-pyspark setup cell, write to a
103+
temporary copy and execute it with papermill.
104+
"""
105+
nb = nbformat.read(str(notebook_path), as_version=4)
106+
107+
mock_cell = nbformat.v4.new_code_cell(_MOCK_PYSPARK)
108+
mock_cell.metadata["tags"] = ["injected-mock"]
109+
nb.cells.insert(0, mock_cell)
110+
111+
patched_path = output_path.parent / "spark_patched.ipynb"
112+
nbformat.write(nb, str(patched_path))
113+
114+
return pm.execute_notebook(str(patched_path), str(output_path), kernel_name="python3")
115+
116+
117+
@pytest.fixture(scope="session")
118+
def spark_nb(tmp_path_factory: pytest.TempPathFactory) -> nbformat.NotebookNode:
119+
out = tmp_path_factory.mktemp("nb_out") / "spark_integration_example_out.ipynb"
120+
return _inject_mock_and_execute(NOTEBOOK_PATH, out)
121+
122+
123+
class TestSmoke:
124+
def test_notebook_completes_without_error(self, spark_nb: nbformat.NotebookNode) -> None:
125+
assert spark_nb is not None
126+
127+
def test_all_code_cells_executed(self, spark_nb: nbformat.NotebookNode) -> None:
128+
for cell in spark_nb.cells:
129+
if cell.cell_type == "code":
130+
assert cell.get("execution_count") is not None, f"Cell not executed:\n{cell.source[:80]}"
131+
132+
133+
class TestCellOutputs:
134+
def test_show_catalogs_lists_spark_catalog_and_local(self, spark_nb: nbformat.NotebookNode) -> None:
135+
stdout = get_all_stdout(spark_nb)
136+
assert "spark_catalog" in stdout
137+
assert "local" in stdout
138+
139+
def test_show_namespaces_contains_default(self, spark_nb: nbformat.NotebookNode) -> None:
140+
assert "default" in get_all_stdout(spark_nb)
141+
142+
def test_show_tables_produces_tabular_output(self, spark_nb: nbformat.NotebookNode) -> None:
143+
assert "+---------+-----------+-----------+" in get_all_stdout(spark_nb)
144+
145+
def test_describe_table_lists_column_names(self, spark_nb: nbformat.NotebookNode) -> None:
146+
assert "col_name" in get_all_stdout(spark_nb)
147+
148+
def test_describe_table_lists_data_types(self, spark_nb: nbformat.NotebookNode) -> None:
149+
stdout = get_all_stdout(spark_nb)
150+
assert "boolean" in stdout or "integer" in stdout
151+
152+
def test_show_tables_includes_test_table_row(self, spark_nb: nbformat.NotebookNode) -> None:
153+
assert "test_all" in get_all_stdout(spark_nb)

‎uv.lock‎

Lines changed: 36 additions & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)