blob: f17404b9c699d1c16f363d61fb23534b3bd7feec [file]
#!/usr/bin/env python3
#
# Licensed to the Apache Software Foundation (ASF) under one or more
# contributor license agreements. See the NOTICE file distributed with
# this work for additional information regarding copyright ownership.
# The ASF licenses this file to You under the Apache License, Version 2.0
# (the "License"); you may not use this file except in compliance with
# the License. You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
#
"""Compile the ``rust`` code snippets in the docs against the crate.
The published guides drifted out of sync with the API (examples that used `lit`
without importing it, or `SparkSessionBuilder` without a `use`), and nothing in CI
caught it because the docs workflow only *deploys*. This type-checks every
```rust``` block so a doc example can't reference a symbol the crate doesn't export.
Each block is wrapped in a small harness that binds ``spark`` and ``df`` (so the
common "continue from an earlier block" fragments type-check) and is run through
``rustc`` against the already-built ``apache-spark-connect`` rlib. Only compilation
(type-checking) is performed - snippets are never executed, so no server is needed.
A block that must be skipped (pseudo-code, a deliberate compile-error example, or
a snippet that needs a feature/macro the snippet harness doesn't build - e.g. the
`wasm-udf` `#[spark_wasm_udf]` examples) should be fenced as ```{.rust .no-run}```.
That brace form still renders as a Rust code block in mkdocs/superfences (unlike
```rust,ignore```, whose comma makes superfences drop the fence entirely and print
the backticks literally), and it is not matched by the checker below, so it is not
compiled.
Usage:
cargo build -p apache-spark-connect
python scripts/check_doc_snippets.py
"""
import glob
import re
import subprocess
import sys
import tempfile
from pathlib import Path
REPO = Path(__file__).resolve().parent.parent
# cargo places the primary lib without a hash in target/debug; its dependencies
# (arrow, prost, ...) live in target/debug/deps and are found via -L dependency.
RLIB = REPO / "target" / "debug" / "libspark_connect.rlib"
DEPS = REPO / "target" / "debug" / "deps"
# ```rust``` (checked) vs ```{.rust .no-run}``` (rendered but skipped). We only check
# plain ```rust``` fences; any info string with a comma/space/brace is left alone.
_BLOCK = re.compile(r"^```rust\s*$\n(.*?)^```\s*$", re.DOTALL | re.MULTILINE)
# Harness: bind the identifiers the fragments assume from earlier blocks. `use`
# statements are legal inside a fn body, so a block can carry its own imports; a
# block that re-binds `spark`/`df` just shadows these (harmless).
HARNESS = """#![allow(unused, unused_imports, dead_code, clippy::all)]
fn main() -> Result<(), Box<dyn std::error::Error>> {{
let spark = spark_connect::SparkSession::builder()
.remote("sc://localhost:15002")
.get_or_create()?;
// The ambient names guide fragments assume from a preceding block.
let df = spark.range(10)?;
let df1 = spark.range(10)?;
let df2 = spark.range(10)?;
{{
{body}
}}
Ok(())
}}
"""
def check_block(code: str, tmpdir: Path, idx: int) -> tuple[bool, str]:
indented = "\n".join(" " + ln if ln.strip() else ln for ln in code.splitlines())
src = tmpdir / f"snippet_{idx}.rs"
src.write_text(HARNESS.format(body=indented))
out = tmpdir / f"snippet_{idx}.out"
r = subprocess.run(
[
"rustc",
"--edition",
"2021",
"--crate-type",
"bin",
"-L",
f"dependency={DEPS}",
"--extern",
f"spark_connect={RLIB}",
"-o",
str(out),
str(src),
],
capture_output=True,
text=True,
)
return r.returncode == 0, r.stderr
def main() -> int:
if not RLIB.exists():
print(f"!! {RLIB} not found - run `cargo build -p apache-spark-connect` first")
return 2
failures = []
checked = 0
with tempfile.TemporaryDirectory() as td:
tmpdir = Path(td)
idx = 0
for md in sorted(glob.glob(str(REPO / "docs" / "**" / "*.md"), recursive=True)):
text = Path(md).read_text()
for m in _BLOCK.finditer(text):
idx += 1
checked += 1
ok, err = check_block(m.group(1), tmpdir, idx)
if not ok:
rel = Path(md).relative_to(REPO)
# First rustc error line is the most informative.
first = next(
(ln for ln in err.splitlines() if ln.startswith("error")), err[:200]
)
failures.append((str(rel), first))
print(f"Checked {checked} rust snippet(s); {len(failures)} failed to compile.")
for rel, err in failures:
print(f" {rel}: {err}")
return 1 if failures else 0
if __name__ == "__main__":
sys.exit(main())