Skip to content

Commit 0c75e8f

Browse files
committed
docs: pull guide snippets from runnable, CI-tested example scripts
Previously every ```python fence in the guide was inline text. `zensical build` only renders those fences, so a snippet that went stale against the API would render fine while being broken — the guide's code was never executed. Move each snippet into a runnable script under examples/guide/ and include it into the page with pymdownx.snippets (`--8<-- "examples/guide/<page>.py: <section>"`), mirroring the substrait-java approach. Then: - Enable pymdownx.snippets in zensical.toml with check_paths=true, so a bad include path or section name fails the docs build. - Add tests/docs/test_guide_snippets.py, which executes every examples/guide/*.py end to end. Snippets that build a DataFrame call .to_plan(), so the test validates plan construction *and* resolution; this surfaced and fixed several previously-broken examples (e.g. joins referencing non-existent columns). - Document the convention in CONTRIBUTING.md. - The two engine-handoff snippets (DuckDB/ADBC tabs) stay inline: they need network + an external engine and are exercised by examples/{duckdb,adbc}_ example.py (run by example.yml). Also add dataframe_example.py to that workflow's matrix (it was omitted).
1 parent f08f4a1 commit 0c75e8f

38 files changed

Lines changed: 1132 additions & 368 deletions

.github/workflows/example.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ jobs:
1616
matrix:
1717
example:
1818
- builder_example.py
19+
- dataframe_example.py
1920
- duckdb_example.py
2021
- adbc_example.py
2122
- pyarrow_example.py

CONTRIBUTING.md

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,3 +43,23 @@ When editing docstrings that appear in the reference, prefer Markdown (fenced
4343
code blocks and backticked names) over reStructuredText so they render cleanly.
4444
Every pull request runs a docs build-check; versioned docs are published to
4545
GitHub Pages on release (see `.github/workflows/docs-deploy.yml`).
46+
47+
## Guide code snippets
48+
49+
Code snippets in the guide are **not written inline**. Each `python` fence pulls
50+
its code from a runnable script under `examples/guide/` via
51+
[`pymdownx.snippets`](https://facelessuser.github.io/pymdown-extensions/extensions/snippets/):
52+
53+
````markdown
54+
```python
55+
--8<-- "examples/guide/<page>.py:<section>"
56+
```
57+
````
58+
59+
The referenced code lives between `# --8<-- [start:<section>]` and
60+
`# --8<-- [end:<section>]` markers in that script. `tests/docs/test_guide_snippets.py`
61+
runs every `examples/guide/*.py` end to end, so a documented example that no longer
62+
builds a valid plan fails CI instead of silently rendering; `check_paths` in
63+
`zensical.toml` additionally fails the build if an include path or section name is
64+
wrong. To change a snippet, edit the `.py` file (adding a new `[start]`/`[end]`
65+
section for a new fence) rather than the Markdown.

docs/aggregations.md

Lines changed: 5 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -6,20 +6,14 @@ Summarize rows with `group_by(...).agg(...)`. Aggregate functions come from the
66
## Group by and aggregate
77

88
```python
9-
import substrait.dataframe as sub
10-
11-
df.group_by("region").agg(
12-
sub.f.sum(sub.col("amount")).alias("total"),
13-
sub.f.count(sub.col("amount")).alias("n"),
14-
)
9+
--8<-- "examples/guide/aggregations.py:group_agg"
1510
```
1611

1712
Group by multiple keys by passing several, and group the whole frame (a grand
1813
total) by passing none:
1914

2015
```python
21-
df.group_by("region", "product").agg(sub.f.sum(sub.col("amount")).alias("total"))
22-
df.group_by().agg(sub.f.count(sub.col("id")).alias("rows"))
16+
--8<-- "examples/guide/aggregations.py:multi_key_and_grand_total"
2317
```
2418

2519
`group_by` keys may be column names or expressions.
@@ -30,18 +24,15 @@ df.group_by().agg(sub.f.count(sub.col("id")).alias("rows"))
3024
the measures:
3125

3226
```python
33-
df.aggregate("region", sub.f.sum(sub.col("amount")).alias("total"))
34-
df.aggregate(["region", "product"], sub.f.count(sub.col("id")).alias("n"))
27+
--8<-- "examples/guide/aggregations.py:one_shot"
3528
```
3629

3730
## Modifying a measure
3831

3932
Aggregate measures support several modifiers, which chain:
4033

4134
```python
42-
sub.f.count(sub.col("customer")).distinct().alias("unique_customers")
43-
sub.f.sum(sub.col("amount")).filter(sub.col("status") == "paid").alias("paid_total")
44-
sub.f.string_agg(sub.col("name"), sub.lit(", ")).order_by("name").alias("names")
35+
--8<-- "examples/guide/aggregations.py:measure_modifiers"
4536
```
4637

4738
- **`.distinct()`** — operate on distinct inputs (`COUNT(DISTINCT x)`).
@@ -59,15 +50,7 @@ For multiple grouping levels in one aggregation, pass explicit `grouping_sets`
5950
shortcuts:
6051

6152
```python
62-
# explicit grouping sets: by (region, product), by (region), and the grand total
63-
df.group_by("region", "product", grouping_sets=[["region", "product"], ["region"], []]) \
64-
.agg(sub.f.sum(sub.col("amount")).alias("total"))
65-
66-
# ROLLUP: (region, product), (region), ()
67-
df.rollup("region", "product").agg(sub.f.sum(sub.col("amount")).alias("total"))
68-
69-
# CUBE: every subset of the keys
70-
df.cube("region", "product").agg(sub.f.sum(sub.col("amount")).alias("total"))
53+
--8<-- "examples/guide/aggregations.py:grouping_sets"
7154
```
7255

7356
## Next

docs/consuming-plans.md

Lines changed: 3 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -9,16 +9,7 @@ substrait-python **produces** plans; it does not execute them. Once you have a
99
into the bytes a consumer accepts:
1010

1111
```python
12-
import substrait.dataframe as sub
13-
14-
plan = (
15-
sub.read_named_table("customer", {"c_name": sub.string, "c_nationkey": sub.i32})
16-
.filter(sub.col("c_nationkey") == 3)
17-
.select("c_name")
18-
.to_plan()
19-
)
20-
21-
payload = plan.SerializeToString()
12+
--8<-- "examples/guide/consuming_plans.py:materialize"
2213
```
2314

2415
`DataFrame.to_substrait(registry=...)` is an alias kept for parity with the
@@ -31,9 +22,7 @@ The bundled pretty printer renders the plan as a compact tree — far more
3122
readable than the raw protobuf text:
3223

3324
```python
34-
from substrait.utils.display import pretty_print_plan
35-
36-
pretty_print_plan(plan, use_colors=True)
25+
--8<-- "examples/guide/consuming_plans.py:pretty_print"
3726
```
3827

3928
## Handing off to an engine
@@ -80,10 +69,7 @@ A serialized plan loads back with the generated protobuf class — useful for
8069
tests and for consuming plans other producers emit:
8170

8271
```python
83-
from substrait.proto import Plan
84-
85-
restored = Plan()
86-
restored.ParseFromString(payload)
72+
--8<-- "examples/guide/consuming_plans.py:roundtrip"
8773
```
8874

8975
## Relationship to Narwhals

docs/custom-extensions.md

Lines changed: 6 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -13,19 +13,14 @@ across frames. To use custom functions, build your own registry and register
1313
extensions on it:
1414

1515
```python
16-
import substrait.dataframe as sub
17-
18-
reg = sub.ExtensionRegistry(load_default_extensions=True)
19-
reg.register_extension_yaml("my_functions.yaml") # from a YAML file
20-
# or, from an already-parsed dict (must contain a "urn" field):
21-
reg.register_extension_dict(definitions)
16+
--8<-- "examples/guide/custom_extensions.py:registry_setup"
2217
```
2318

2419
Pass the registry to a [source function](data-sources.md) so the whole chain
2520
uses it:
2621

2722
```python
28-
df = sub.read_named_table("t", {"x": sub.i64}, registry=reg)
23+
--8<-- "examples/guide/custom_extensions.py:pass_registry"
2924
```
3025

3126
### Reaching custom functions by name
@@ -35,11 +30,7 @@ registry, build a bound namespace with `functions_for`, or use `df.f` (which is
3530
bound to that frame's registry automatically):
3631

3732
```python
38-
myf = sub.functions_for(reg)
39-
myf.my_double(sub.col("x"))
40-
41-
# equivalently, off a frame built with `reg`:
42-
df.f.my_double(sub.col("x"))
33+
--8<-- "examples/guide/custom_extensions.py:reach_functions"
4334
```
4435

4536
See [The function namespace](functions.md) for more.
@@ -62,41 +53,15 @@ Each requires `to_any()`, `from_any(cls, detail)`, and `derive_schema(...)`, plu
6253
a `type_url` identifying the payload.
6354

6455
```python
65-
import substrait.dataframe as sub
66-
import substrait.type_pb2 as stt
67-
from google.protobuf.any_pb2 import Any
68-
69-
70-
class MyLeaf(sub.ExtensionLeafDetail):
71-
type_url = "example.com/my.LeafDetail"
72-
73-
def to_any(self) -> Any:
74-
payload = Any()
75-
payload.type_url = self.type_url
76-
# payload.value = ... serialize your fields ...
77-
return payload
78-
79-
@classmethod
80-
def from_any(cls, detail: Any) -> "MyLeaf":
81-
return cls() # ... deserialize your fields ...
82-
83-
def derive_schema(self) -> stt.NamedStruct:
84-
return sub.named_struct(names=["x"], struct=sub.struct([sub.i64.non_null]))
56+
--8<-- "examples/guide/custom_extensions.py:detail_class"
8557
```
8658

8759
### Building the relation
8860

8961
Use the frame verbs / entry point matching the arity:
9062

9163
```python
92-
# leaf (a source): starts a new DataFrame
93-
df = sub.extension_leaf(MyLeaf())
94-
95-
# single-input: applied to an existing frame
96-
df = base.extension(MySingle(...))
97-
98-
# multi-input: this frame plus others
99-
df = base.extension_multi([other1, other2], MyMulti(...))
64+
--8<-- "examples/guide/custom_extensions.py:build_relation"
10065
```
10166

10267
`DataFrame.extension` also accepts a raw `google.protobuf.Any` directly, in
@@ -110,7 +75,7 @@ class — then inference reconstructs it from the plan's `Any` and calls
11075
`derive_schema`:
11176

11277
```python
113-
reg.register_extension_relation(MyLeaf)
78+
--8<-- "examples/guide/custom_extensions.py:register_relation"
11479
```
11580

11681
Registration is process-global (type URLs are globally unique), so inference

docs/data-sources.md

Lines changed: 7 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -15,17 +15,13 @@ The most common source — a table the consumer resolves by name (a `ReadRel`
1515
with a `NamedTable`):
1616

1717
```python
18-
import substrait.dataframe as sub
19-
20-
people = sub.read_named_table(
21-
"people", {"id": sub.i64.non_null, "name": sub.string, "age": sub.i64}
22-
)
18+
--8<-- "examples/guide/data_sources.py:named_table"
2319
```
2420

2521
A multi-part name (catalog / schema / table) is given as a list:
2622

2723
```python
28-
sub.read_named_table(["main", "public", "people"], {"id": sub.i64})
24+
--8<-- "examples/guide/data_sources.py:multipart_name"
2925
```
3026

3127
## Inline rows (VALUES)
@@ -35,14 +31,7 @@ like SQL `VALUES`. Rows may be dicts keyed by column name or positional
3531
sequences aligned to the schema; `None` becomes a typed null:
3632

3733
```python
38-
df = sub.from_records(
39-
[
40-
{"id": 1, "name": "Ada"},
41-
{"id": 2, "name": "Alan"},
42-
(3, None), # positional; name is a typed null
43-
],
44-
{"id": sub.i64.non_null, "name": sub.string},
45-
)
34+
--8<-- "examples/guide/data_sources.py:from_records"
4635
```
4736

4837
Each value is typed according to its schema column, so `from_records` is handy
@@ -54,20 +43,13 @@ Read local files by path (or a list of paths) plus a schema. These build a
5443
`ReadRel` over `LocalFiles`, one entry per path:
5544

5645
```python
57-
sub.read_parquet("data/events.parquet", {"ts": sub.i64, "kind": sub.string})
58-
sub.read_orc(["a.orc", "b.orc"], {"x": sub.i64})
59-
sub.read_arrow("table.arrow", {"x": sub.i64})
46+
--8<-- "examples/guide/data_sources.py:files"
6047
```
6148

6249
CSV/TSV reads take a couple of extra knobs:
6350

6451
```python
65-
sub.read_csv(
66-
"data/people.csv",
67-
{"id": sub.i64, "name": sub.string},
68-
delimiter=",", # use "\t" for TSV
69-
header_lines_to_skip=1, # skip the header row
70-
)
52+
--8<-- "examples/guide/data_sources.py:csv"
7153
```
7254

7355
!!! note "Schemas are declared, not inferred"
@@ -85,7 +67,7 @@ there are two extension entry points. Both are covered in detail under
8567
opaque `google.protobuf.Any` (`detail`). You still declare the output schema.
8668

8769
```python
88-
sub.read_extension_table({"x": sub.i64}, my_any_detail)
70+
--8<-- "examples/guide/data_sources.py:read_extension_table"
8971
```
9072

9173
- **`extension_leaf(detail)`** — a fully custom leaf relation
@@ -95,7 +77,7 @@ there are two extension entry points. Both are covered in detail under
9577
is needed.
9678

9779
```python
98-
sub.extension_leaf(MyLeafDetail(...))
80+
--8<-- "examples/guide/data_sources.py:extension_leaf"
9981
```
10082

10183
## Next

docs/ddl-and-writes.md

Lines changed: 4 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -10,15 +10,7 @@ materialize it.
1010
write operation and `mode` controls what happens when the table already exists:
1111

1212
```python
13-
import substrait.dataframe as sub
14-
15-
summary = (
16-
sub.read_named_table("orders", {"region": sub.string, "amount": sub.fp64})
17-
.group_by("region")
18-
.agg(sub.f.sum(sub.col("amount")).alias("total"))
19-
)
20-
21-
plan = summary.write_named_table("region_totals", op="ctas", mode="replace").to_plan()
13+
--8<-- "examples/guide/ddl_and_writes.py:write_named_table"
2214
```
2315

2416
- **`op`**`ctas` (create-table-as-select, default) or `insert`.
@@ -28,26 +20,15 @@ plan = summary.write_named_table("region_totals", op="ctas", mode="replace").to_
2820
## Create table / view
2921

3022
```python
31-
# CREATE TABLE region_totals (region string, total fp64)
32-
sub.create_table("region_totals", {"region": sub.string, "total": sub.fp64})
33-
34-
# CREATE OR REPLACE
35-
sub.create_table("region_totals", {"region": sub.string}, replace=True)
36-
37-
# CREATE VIEW backed by a query (a DataFrame)
38-
big_orders = sub.read_named_table("orders", {"amount": sub.fp64}) \
39-
.filter(sub.col("amount") > 1000)
40-
sub.create_view("big_orders", big_orders)
23+
--8<-- "examples/guide/ddl_and_writes.py:create_table_view"
4124
```
4225

4326
## Drop table / view
4427

4528
Pass `if_exists=True` for the `IF EXISTS` variant:
4629

4730
```python
48-
sub.drop_table("region_totals")
49-
sub.drop_table("region_totals", if_exists=True)
50-
sub.drop_view("big_orders", if_exists=True)
31+
--8<-- "examples/guide/ddl_and_writes.py:drop"
5132
```
5233

5334
## Update
@@ -57,12 +38,7 @@ sub.drop_view("big_orders", if_exists=True)
5738
rows if omitted):
5839

5940
```python
60-
sub.update_table(
61-
"orders",
62-
{"id": sub.i64, "amount": sub.fp64, "status": sub.string},
63-
assignments={"amount": sub.col("amount") * 1.1},
64-
where=sub.col("status") == "pending",
65-
)
41+
--8<-- "examples/guide/ddl_and_writes.py:update"
6642
```
6743

6844
The schema you pass describes the target table so the assignment targets and

0 commit comments

Comments
 (0)