From abe3914c7691829c83faf5e0c978e7e62d247b6b Mon Sep 17 00:00:00 2001 From: Harish Anantharaj Date: Mon, 15 Jun 2026 22:51:25 +0530 Subject: [PATCH 1/2] ci: port docs example compilation check to all client languages --- .github/workflows/check-go-examples.yml | 71 ++++++++ .github/workflows/check-java-examples.yml | 80 +++++++++ .github/workflows/check-node-examples.yml | 76 +++++++++ .github/workflows/check-php-examples.yml | 71 ++++++++ .github/workflows/check-python-examples.yml | 68 ++++++++ scripts/README.md | 136 ++++++++++++++++ scripts/check_csharp_examples.py | 126 ++++----------- scripts/check_examples.py | 171 ++++++++++++++++++++ scripts/extract_examples.py | 165 +++++++++++++++++++ 9 files changed, 865 insertions(+), 99 deletions(-) create mode 100644 .github/workflows/check-go-examples.yml create mode 100644 .github/workflows/check-java-examples.yml create mode 100644 .github/workflows/check-node-examples.yml create mode 100644 .github/workflows/check-php-examples.yml create mode 100644 .github/workflows/check-python-examples.yml create mode 100644 scripts/README.md create mode 100644 scripts/check_examples.py create mode 100644 scripts/extract_examples.py diff --git a/.github/workflows/check-go-examples.yml b/.github/workflows/check-go-examples.yml new file mode 100644 index 00000000..e846e7b8 --- /dev/null +++ b/.github/workflows/check-go-examples.yml @@ -0,0 +1,71 @@ +name: Check Go Examples + +# Validates that the Go code examples in the docs compile. +# +# Mirrors the C# check: this repo extracts the snippets (scripts/check_examples.py) +# and the upstream Go client provides dev/scripts/validate_examples.py to wrap + +# compile them. Until that upstream validator exists, the "Probe" step skips this +# check (a green run with a notice). See scripts/README.md. + +on: + pull_request: + branches: [main] + workflow_dispatch: + +permissions: + contents: read + +env: + UPSTREAM_REPO: valkey-io/valkey-glide + # Expected location of the validator, relative to the upstream repo root. + VALIDATOR_REPO_PATH: go/dev/scripts/validate_examples.py + +jobs: + check-go-examples: + runs-on: ubuntu-latest + steps: + - name: Checkout valkey-glide-docs + uses: actions/checkout@v4 + + - name: Probe for upstream validator + id: probe + env: + GH_TOKEN: ${{ github.token }} + run: | + if gh api "repos/${UPSTREAM_REPO}/contents/${VALIDATOR_REPO_PATH}" --silent 2>/dev/null; then + echo "present=true" >> "$GITHUB_OUTPUT" + else + echo "::notice::Upstream Go example validator not found at ${UPSTREAM_REPO}/${VALIDATOR_REPO_PATH}. Skipping Go example validation until it lands upstream (see scripts/README.md)." + echo "present=false" >> "$GITHUB_OUTPUT" + fi + + # --------------------------------------------------------------------- + # The steps below run only once the upstream validator exists. The + # validator resolves the client via the Go module system (go.mod), so no + # built artifact needs to be passed. + # --------------------------------------------------------------------- + - name: Checkout valkey-glide + if: steps.probe.outputs.present == 'true' + uses: actions/checkout@v4 + with: + repository: ${{ env.UPSTREAM_REPO }} + path: valkey-glide + + - name: Setup Go + if: steps.probe.outputs.present == 'true' + uses: actions/setup-go@v5 + with: + go-version: "stable" + + - name: Setup Python + if: steps.probe.outputs.present == 'true' + uses: actions/setup-python@v5 + with: + python-version: "3.x" + + - name: Run Go example validation + if: steps.probe.outputs.present == 'true' + run: | + python scripts/check_examples.py \ + --language go \ + --validator "valkey-glide/${VALIDATOR_REPO_PATH}" diff --git a/.github/workflows/check-java-examples.yml b/.github/workflows/check-java-examples.yml new file mode 100644 index 00000000..09fa2867 --- /dev/null +++ b/.github/workflows/check-java-examples.yml @@ -0,0 +1,80 @@ +name: Check Java Examples + +# Validates that the Java code examples in the docs compile. +# +# Mirrors the C# check: this repo extracts the snippets (scripts/check_examples.py) +# and the upstream Java client provides dev/scripts/validate_examples.py to wrap + +# compile them with javac against the client jar. Until that upstream validator +# exists, the "Probe" step skips this check (a green run with a notice). See +# scripts/README.md. + +on: + pull_request: + branches: [main] + workflow_dispatch: + +permissions: + contents: read + +env: + UPSTREAM_REPO: valkey-io/valkey-glide + # Expected location of the validator, relative to the upstream repo root. + VALIDATOR_REPO_PATH: java/dev/scripts/validate_examples.py + +jobs: + check-java-examples: + runs-on: ubuntu-latest + steps: + - name: Checkout valkey-glide-docs + uses: actions/checkout@v4 + + - name: Probe for upstream validator + id: probe + env: + GH_TOKEN: ${{ github.token }} + run: | + if gh api "repos/${UPSTREAM_REPO}/contents/${VALIDATOR_REPO_PATH}" --silent 2>/dev/null; then + echo "present=true" >> "$GITHUB_OUTPUT" + else + echo "::notice::Upstream Java example validator not found at ${UPSTREAM_REPO}/${VALIDATOR_REPO_PATH}. Skipping Java example validation until it lands upstream (see scripts/README.md)." + echo "present=false" >> "$GITHUB_OUTPUT" + fi + + # --------------------------------------------------------------------- + # The steps below run only once the upstream validator exists. Finalize + # the jar build path / artifact argument when that lands (only the Java + # classes are needed for javac, so the native core can be skipped). + # --------------------------------------------------------------------- + - name: Checkout valkey-glide + if: steps.probe.outputs.present == 'true' + uses: actions/checkout@v4 + with: + repository: ${{ env.UPSTREAM_REPO }} + path: valkey-glide + + - name: Setup Java + if: steps.probe.outputs.present == 'true' + uses: actions/setup-java@v4 + with: + distribution: "temurin" + java-version: "17" + + - name: Setup Python + if: steps.probe.outputs.present == 'true' + uses: actions/setup-python@v5 + with: + python-version: "3.x" + + - name: Build Java client jar + if: steps.probe.outputs.present == 'true' + working-directory: valkey-glide/java + run: ./gradlew :client:jar + + - name: Run Java example validation + if: steps.probe.outputs.present == 'true' + run: | + JAR="$(find valkey-glide/java/client/build/libs -name '*.jar' | head -n1)" + python scripts/check_examples.py \ + --language java \ + --validator "valkey-glide/${VALIDATOR_REPO_PATH}" \ + -- --glide-jar "${JAR}" diff --git a/.github/workflows/check-node-examples.yml b/.github/workflows/check-node-examples.yml new file mode 100644 index 00000000..a006dc40 --- /dev/null +++ b/.github/workflows/check-node-examples.yml @@ -0,0 +1,76 @@ +name: Check Node Examples + +# Validates that the Node.js code examples in the docs compile (TypeScript and +# JavaScript fences). +# +# Mirrors the C# check: this repo extracts the snippets (scripts/check_examples.py) +# and the upstream Node client provides dev/scripts/validate_examples.py to wrap + +# type-check them with tsc. Until that upstream validator exists, the "Probe" step +# skips this check (a green run with a notice). See scripts/README.md. + +on: + pull_request: + branches: [main] + workflow_dispatch: + +permissions: + contents: read + +env: + UPSTREAM_REPO: valkey-io/valkey-glide + # Expected location of the validator, relative to the upstream repo root. + VALIDATOR_REPO_PATH: node/dev/scripts/validate_examples.py + +jobs: + check-node-examples: + runs-on: ubuntu-latest + steps: + - name: Checkout valkey-glide-docs + uses: actions/checkout@v4 + + - name: Probe for upstream validator + id: probe + env: + GH_TOKEN: ${{ github.token }} + run: | + if gh api "repos/${UPSTREAM_REPO}/contents/${VALIDATOR_REPO_PATH}" --silent 2>/dev/null; then + echo "present=true" >> "$GITHUB_OUTPUT" + else + echo "::notice::Upstream Node example validator not found at ${UPSTREAM_REPO}/${VALIDATOR_REPO_PATH}. Skipping Node example validation until it lands upstream (see scripts/README.md)." + echo "present=false" >> "$GITHUB_OUTPUT" + fi + + # --------------------------------------------------------------------- + # The steps below run only once the upstream validator exists. The + # validator resolves the client from node_modules after `npm install`, so + # no built artifact path needs to be passed. + # --------------------------------------------------------------------- + - name: Checkout valkey-glide + if: steps.probe.outputs.present == 'true' + uses: actions/checkout@v4 + with: + repository: ${{ env.UPSTREAM_REPO }} + path: valkey-glide + + - name: Setup Node.js + if: steps.probe.outputs.present == 'true' + uses: actions/setup-node@v4 + with: + node-version: 20 + + - name: Install client and TypeScript + if: steps.probe.outputs.present == 'true' + run: npm install @valkey/valkey-glide typescript + + - name: Setup Python + if: steps.probe.outputs.present == 'true' + uses: actions/setup-python@v5 + with: + python-version: "3.x" + + - name: Run Node example validation + if: steps.probe.outputs.present == 'true' + run: | + python scripts/check_examples.py \ + --language node \ + --validator "valkey-glide/${VALIDATOR_REPO_PATH}" diff --git a/.github/workflows/check-php-examples.yml b/.github/workflows/check-php-examples.yml new file mode 100644 index 00000000..86e92c9b --- /dev/null +++ b/.github/workflows/check-php-examples.yml @@ -0,0 +1,71 @@ +name: Check PHP Examples + +# Validates the PHP code examples in the docs. +# +# Mirrors the C# check: this repo extracts the snippets (scripts/check_examples.py) +# and the upstream PHP client provides dev/scripts/validate_examples.py. Because +# the PHP client is a native (C) extension, the validator is expected to perform +# lint-level validation (`php -l`) rather than full compilation. Until that +# upstream validator exists, the "Probe" step skips this check (a green run with +# a notice). See scripts/README.md. + +on: + pull_request: + branches: [main] + workflow_dispatch: + +permissions: + contents: read + +env: + UPSTREAM_REPO: valkey-io/valkey-glide-php + # Expected location of the validator, relative to the upstream repo root. + VALIDATOR_REPO_PATH: dev/scripts/validate_examples.py + +jobs: + check-php-examples: + runs-on: ubuntu-latest + steps: + - name: Checkout valkey-glide-docs + uses: actions/checkout@v4 + + - name: Probe for upstream validator + id: probe + env: + GH_TOKEN: ${{ github.token }} + run: | + if gh api "repos/${UPSTREAM_REPO}/contents/${VALIDATOR_REPO_PATH}" --silent 2>/dev/null; then + echo "present=true" >> "$GITHUB_OUTPUT" + else + echo "::notice::Upstream PHP example validator not found at ${UPSTREAM_REPO}/${VALIDATOR_REPO_PATH}. Skipping PHP example validation until it lands upstream (see scripts/README.md)." + echo "present=false" >> "$GITHUB_OUTPUT" + fi + + # --------------------------------------------------------------------- + # The steps below run only once the upstream validator exists. + # --------------------------------------------------------------------- + - name: Checkout valkey-glide-php + if: steps.probe.outputs.present == 'true' + uses: actions/checkout@v4 + with: + repository: ${{ env.UPSTREAM_REPO }} + path: valkey-glide-php + + - name: Setup PHP + if: steps.probe.outputs.present == 'true' + uses: shivammathur/setup-php@v2 + with: + php-version: "8.2" + + - name: Setup Python + if: steps.probe.outputs.present == 'true' + uses: actions/setup-python@v5 + with: + python-version: "3.x" + + - name: Run PHP example validation + if: steps.probe.outputs.present == 'true' + run: | + python scripts/check_examples.py \ + --language php \ + --validator "valkey-glide-php/${VALIDATOR_REPO_PATH}" diff --git a/.github/workflows/check-python-examples.yml b/.github/workflows/check-python-examples.yml new file mode 100644 index 00000000..3d96559b --- /dev/null +++ b/.github/workflows/check-python-examples.yml @@ -0,0 +1,68 @@ +name: Check Python Examples + +# Validates that the Python code examples in the docs compile. +# +# Mirrors the C# check: this repo extracts the snippets (scripts/check_examples.py) +# and the upstream Python client provides dev/scripts/validate_examples.py to +# wrap + compile them. Until that upstream validator exists, the "Probe" step +# skips this check (a green run with a notice). See scripts/README.md. + +on: + pull_request: + branches: [main] + workflow_dispatch: + +permissions: + contents: read + +env: + UPSTREAM_REPO: valkey-io/valkey-glide + # Expected location of the validator, relative to the upstream repo root. + VALIDATOR_REPO_PATH: python/dev/scripts/validate_examples.py + +jobs: + check-python-examples: + runs-on: ubuntu-latest + steps: + - name: Checkout valkey-glide-docs + uses: actions/checkout@v4 + + - name: Probe for upstream validator + id: probe + env: + GH_TOKEN: ${{ github.token }} + run: | + if gh api "repos/${UPSTREAM_REPO}/contents/${VALIDATOR_REPO_PATH}" --silent 2>/dev/null; then + echo "present=true" >> "$GITHUB_OUTPUT" + else + echo "::notice::Upstream Python example validator not found at ${UPSTREAM_REPO}/${VALIDATOR_REPO_PATH}. Skipping Python example validation until it lands upstream (see scripts/README.md)." + echo "present=false" >> "$GITHUB_OUTPUT" + fi + + # --------------------------------------------------------------------- + # The steps below run only once the upstream validator exists. Finalize + # the install/build and artifact argument when that lands. + # --------------------------------------------------------------------- + - name: Checkout valkey-glide + if: steps.probe.outputs.present == 'true' + uses: actions/checkout@v4 + with: + repository: ${{ env.UPSTREAM_REPO }} + path: valkey-glide + + - name: Setup Python + if: steps.probe.outputs.present == 'true' + uses: actions/setup-python@v5 + with: + python-version: "3.x" + + - name: Install valkey-glide + if: steps.probe.outputs.present == 'true' + run: pip install valkey-glide + + - name: Run Python example validation + if: steps.probe.outputs.present == 'true' + run: | + python scripts/check_examples.py \ + --language python \ + --validator "valkey-glide/${VALIDATOR_REPO_PATH}" diff --git a/scripts/README.md b/scripts/README.md new file mode 100644 index 00000000..26c69b75 --- /dev/null +++ b/scripts/README.md @@ -0,0 +1,136 @@ +# Documentation example validation + +These scripts check that the code examples embedded in the docs +(`src/content/docs/**/*.mdx`) actually compile, so a broken snippet is +caught in CI instead of by a reader. + +The work is split in two, exactly as it was first built for C#: + +| Half | Lives in | Responsibility | +| ------------------------- | ---------------------- | ---------------------------------------------------------------------------- | +| **Extraction + plumbing** | this repo (`scripts/`) | Pull the fenced code blocks for a language out of the MDX and hand them off. | +| **Wrapping + compiling** | each client's own repo | Wrap each snippet, compile it against the real client build, report errors. | + +This repo owns only the first half. Each language is validated by a +`validate_examples.py` script that lives in that language's client +repository, because that repo is the one that knows how to wrap a snippet +and build the client. The C# validator already exists in +[`valkey-glide-csharp`](https://github.com/valkey-io/valkey-glide-csharp); +the other languages activate automatically once their upstream validator +lands (see [Status](#per-language-status)). + +## Scripts + +| Script | What it does | +| -------------------------- | ------------------------------------------------------------------------------------------------------------------------------ | +| `extract_examples.py` | Shared extractor. Walks the docs and returns `{ ":": "" }` for one language. Library **and** CLI. | +| `check_examples.py` | Generic orchestrator. Extracts a language's examples, writes them to a temp JSON file, and runs the upstream validator. | +| `check_csharp_examples.py` | Thin backwards-compatible wrapper around `check_examples.py --language csharp`, kept so the existing C# workflow is unchanged. | + +### Local usage + +```bash +# List the languages and the Markdown fences each one matches. +python scripts/extract_examples.py --list-languages + +# Dump the extracted examples for a language (debugging). +python scripts/extract_examples.py --language go --out /tmp/go.json + +# Run a language's validator (requires a checkout of that client repo +# and a build of its artifact): +python scripts/check_examples.py \ + --language csharp \ + --validator ../valkey-glide-csharp/dev/scripts/validate_examples.py \ + -- --glide-dll ../valkey-glide-csharp/sources/Valkey.Glide/bin/Release/net8.0/Valkey.Glide.dll +``` + +Everything after `--` is forwarded verbatim to the validator, so each +language passes whatever artifact reference it needs. + +## The validator contract + +To enable validation for a language, add a `validate_examples.py` to that +language's client repository that honors this contract. The docs-side +orchestrator (`check_examples.py`) invokes it as: + +```bash +python --examples [language-specific args...] +``` + +**Input** — `--examples` points at a JSON object mapping a source location +to a code snippet: + +```json +{ + "src/content/docs/getting-started/quickstart.mdx:342": "using Valkey.Glide;\n// ...\n", + "src/content/docs/how-to/security/tls.mdx:445": "// ...\n" +} +``` + +The key is `":"`; the value +is the snippet's raw text. + +**Responsibilities of the validator:** + +1. **Normalize indentation.** Snippets nested inside MDX `` + components carry leading indentation. Dedent before compiling + (whitespace-insensitive languages like C# can ignore this; Python, Go, + etc. must dedent). +2. **Wrap each snippet** into a compilable unit — e.g. hoist `import`/`using` + directives, then place the remaining statements inside a function/method + body. The C# validator wraps each snippet in a `Run()` method on a + uniquely-named class and injects common client imports. +3. **Compile** against the real client build (the artifact passed via the + language-specific argument), not just a syntax check. +4. **Report failures** mapped back to the original `":"` key so a + reader can find the broken snippet. + +**Exit code:** `0` when every example compiles, non-zero when any example +fails or a required input is missing. + +**Recommended artifact arguments** (the orchestrator forwards these +verbatim; the exact flag is up to each validator): + +| Language | Artifact passed to the validator | +| -------- | --------------------------------------------------------------------------- | +| C# | `--glide-dll ` (already implemented) | +| Java | `--glide-jar ` | +| Go | resolved via the module (`go.mod`); no artifact path needed | +| Node | resolved from `node_modules` after `npm install`; no artifact path needed | +| Python | resolved from the installed `valkey-glide` package; no artifact path needed | +| PHP | the loaded `valkey_glide` extension; lint-level only (`php -l`) | + +## Per-language status + +| Language | Fences matched | Upstream validator | Status | +| -------- | -------------------------------------- | -------------------------------------------------------------- | ---------------------- | +| C# | `csharp` | `valkey-glide-csharp/dev/scripts/validate_examples.py` | **Live** | +| Java | `java` | `valkey-glide/java/dev/scripts/validate_examples.py` _(TBD)_ | Skipped until upstream | +| Python | `python`, `py` | `valkey-glide/python/dev/scripts/validate_examples.py` _(TBD)_ | Skipped until upstream | +| Go | `go` | `valkey-glide/go/dev/scripts/validate_examples.py` _(TBD)_ | Skipped until upstream | +| Node | `typescript`, `javascript`, `ts`, `js` | `valkey-glide/node/dev/scripts/validate_examples.py` _(TBD)_ | Skipped until upstream | +| PHP | `php` | `valkey-glide-php/dev/scripts/validate_examples.py` _(TBD)_ | Skipped until upstream | + +The upstream paths above are the **expected convention** (mirroring C#'s +`dev/scripts/` location); confirm and adjust the path in the matching +workflow when the validator lands. The CI workflows for not-yet-supported +languages probe for the validator and skip cleanly (a green check with a +notice) until it exists, then start enforcing automatically. + +## Adding / enabling a language + +1. Confirm the language's fences are listed in `LANGUAGE_FENCES` in + `extract_examples.py` (add aliases if the docs use new ones). +2. Implement `validate_examples.py` in that language's client repo per the + [contract](#the-validator-contract) above. +3. Nothing else is required to activate it. Each + `.github/workflows/check--examples.yml` probes the upstream repo + for the validator on every run: once it exists, the probe passes and the + gated build + validation steps run automatically. Just confirm the + workflow's `VALIDATOR_REPO_PATH`, build steps, and artifact argument match + what the validator actually expects. + +> The orchestrator also accepts a `--skip-if-no-validator` flag (used for +> local runs) that turns a missing validator into a notice + exit 0 instead +> of an error. The CI workflows rely on the probe step instead, so they don't +> spin up a build runner until the validator exists. diff --git a/scripts/check_csharp_examples.py b/scripts/check_csharp_examples.py index 42757e4a..5a062746 100644 --- a/scripts/check_csharp_examples.py +++ b/scripts/check_csharp_examples.py @@ -1,10 +1,14 @@ #!/usr/bin/env python3 -"""Extract C# examples from MDX docs and validate them. +"""Extract C# examples from the MDX docs and validate them. -1. Extracts C# code blocks from MDX files. -2. Writes them to a temporary JSON file. -3. Runs the validator script from 'valkey-glide-csharp'. -4. Cleans up and propagates the exit code. +This is a thin, backwards-compatible wrapper around the generic +:mod:`check_examples` orchestrator (``--language csharp``). It is kept so the +existing ``Check C# Examples`` workflow keeps working unchanged; new +languages should call ``check_examples.py`` directly. + +The shared extraction logic now lives in :mod:`extract_examples`, and the +orchestration (write temp JSON, run the upstream validator, propagate the +exit code) lives in :mod:`check_examples`. Usage: python scripts/check_csharp_examples.py @@ -12,53 +16,19 @@ --glide-dll Options: - --validator Path to the validate_examples.py script from the 'valkey-glide-csharp' repository. - --glide-dll Path to the built Valkey.Glide.dll to reference during compilation. + --validator Path to the validate_examples.py script from the + 'valkey-glide-csharp' repository. + --glide-dll Path to the built Valkey.Glide.dll to reference during + compilation. """ +from __future__ import annotations + import argparse -import json import os -import re -import subprocess import sys -import tempfile - -# Repository root is one level up from this script's directory (scripts/). -_REPO_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) -_DOCS_DIR = os.path.join(_REPO_ROOT, "src", "content", "docs") - -# Matches a ```csharp ... ``` fenced code block, capturing the content. -# Allows leading whitespace before fences (common in MDX tab components). -_CSHARP_BLOCK_RE = re.compile( - r"^\s*```csharp\s*\n(.*?)^\s*```\s*$", - re.MULTILINE | re.DOTALL, -) - - -def extract_all(docs_dir: str) -> dict[str, str]: - """Recursively walk docs_dir for .mdx files and extract C# blocks. - Returns a dict mapping ":" to the - code string. - """ - examples: dict[str, str] = {} - - for root, _dirs, files in os.walk(docs_dir): - for fname in sorted(files): - if not fname.endswith(".mdx"): - continue - - filepath = os.path.join(root, fname) - with open(filepath, encoding="utf-8") as fh: - content = fh.read() - - for match in _CSHARP_BLOCK_RE.finditer(content): - key_path = os.path.relpath(filepath, _REPO_ROOT) - line_number = content[: match.start()].count("\n") + 1 - examples[f"{key_path}:{line_number}"] = match.group(1) - - return examples +import check_examples def main() -> None: @@ -77,66 +47,24 @@ def main() -> None: ) args = parser.parse_args() - validator_path = os.path.abspath(args.validator) dll_path = os.path.abspath(args.glide_dll) - - # Validate inputs - if not os.path.isfile(validator_path): - print(f"Error: validator script not found: {validator_path}", file=sys.stderr) - sys.exit(1) - if not os.path.isfile(dll_path): print(f"Error: DLL not found: {dll_path}", file=sys.stderr) sys.exit(1) - if not os.path.isdir(_DOCS_DIR): - print(f"Error: docs directory not found: {_DOCS_DIR}", file=sys.stderr) - sys.exit(1) - - # Verify we can run the validator with Python - result = subprocess.run( - [sys.executable, validator_path, "--help"], - capture_output=True, - ) - if result.returncode != 0: - print(f"Error: cannot run validator script: {validator_path}", file=sys.stderr) - sys.exit(1) - - # Step 1: Extract examples from MDX files - examples = extract_all(_DOCS_DIR) - print(f"Extracted {len(examples)} C# code example(s).") - - if not examples: - sys.exit(0) - - # Step 2: Write to temp file and validate. - tmp_file = tempfile.NamedTemporaryFile( - mode="w", - encoding="utf-8", - prefix="csharp_examples_", - suffix=".json", - delete=False, - ) - tmp_path = tmp_file.name - - try: - with tmp_file as fh: - json.dump(examples, fh, indent=2, sort_keys=True) - - result = subprocess.run( + sys.exit( + check_examples.main( [ - sys.executable, - validator_path, - "--examples", tmp_path, - "--glide-dll", dll_path, - ], - check=False, + "--language", + "csharp", + "--validator", + args.validator, + "--", + "--glide-dll", + dll_path, + ] ) - sys.exit(result.returncode) - - finally: - if os.path.exists(tmp_path): - os.unlink(tmp_path) + ) if __name__ == "__main__": diff --git a/scripts/check_examples.py b/scripts/check_examples.py new file mode 100644 index 00000000..937c8da0 --- /dev/null +++ b/scripts/check_examples.py @@ -0,0 +1,171 @@ +#!/usr/bin/env python3 +"""Validate documentation code examples for a given client language. + +This generalises the original C#-only checker so the same flow works for +every Valkey GLIDE client language. It: + +1. Extracts the code blocks for the requested language from the MDX docs + (via :mod:`extract_examples`). +2. Writes them to a temporary JSON file keyed by ``":"``. +3. Hands that file to the language's upstream ``validate_examples.py`` + script (which wraps + compiles each snippet against the real client + build), forwarding any extra arguments such as the path to the built + artifact. +4. Cleans up the temporary file and propagates the validator's exit code. + +The contract with the upstream validator (established by +``valkey-glide-csharp``) is:: + + python --examples [language-specific args...] + +where ```` maps ``":" -> code`` and the +validator exits ``0`` when every example compiles or non-zero when any +example fails. See ``scripts/README.md`` for the full contract. + +Usage:: + + python scripts/check_examples.py \\ + --language csharp \\ + --validator valkey-glide-csharp/dev/scripts/validate_examples.py \\ + -- --glide-dll path/to/Valkey.Glide.dll + +Everything after ``--`` is forwarded verbatim to the validator, so each +language can pass whatever artifact reference it needs (``--glide-dll``, +``--glide-jar``, ``--glide-package``, ...). + +When a language's upstream validator has not been implemented yet, pass +``--skip-if-no-validator`` so the check reports a notice and exits ``0`` +instead of failing. CI workflows for not-yet-supported languages use this +so they stay green until the upstream validator lands, then begin enforcing +automatically. +""" + +from __future__ import annotations + +import argparse +import json +import os +import subprocess +import sys +import tempfile + +from extract_examples import DOCS_DIR, LANGUAGE_FENCES, extract_examples + + +def _parse_args(argv: list[str]) -> tuple[argparse.Namespace, list[str]]: + parser = argparse.ArgumentParser( + description=( + "Extract code examples for a language from the MDX docs and " + "validate them with the language's upstream validate_examples.py." + ) + ) + parser.add_argument( + "--language", + required=True, + choices=sorted(LANGUAGE_FENCES), + help="Client language whose code examples to validate.", + ) + parser.add_argument( + "--validator", + required=True, + help="Path to the upstream validate_examples.py script.", + ) + parser.add_argument( + "--skip-if-no-validator", + action="store_true", + help=( + "Exit 0 with a notice (instead of failing) when the validator " + "script does not exist. Use for languages whose upstream " + "validator has not landed yet." + ), + ) + # Anything after "--" is forwarded to the validator unchanged. + return parser.parse_known_args(argv) + + +def main(argv: list[str] | None = None) -> int: + args, validator_args = _parse_args( + argv if argv is not None else sys.argv[1:] + ) + + # argparse keeps the leading "--" separator in the remainder; drop it. + if validator_args and validator_args[0] == "--": + validator_args = validator_args[1:] + + validator_path = os.path.abspath(args.validator) + + # The validator may not exist yet for this language. + if not os.path.isfile(validator_path): + message = ( + f"Validator script not found for '{args.language}': " + f"{validator_path}" + ) + if args.skip_if_no_validator: + # GitHub Actions surfaces ::notice:: annotations in the UI. + print( + f"::notice::{message}. Skipping {args.language} example " + f"validation until the upstream validator lands " + f"(see scripts/README.md)." + ) + return 0 + print(f"Error: {message}", file=sys.stderr) + return 1 + + if not os.path.isdir(DOCS_DIR): + print(f"Error: docs directory not found: {DOCS_DIR}", file=sys.stderr) + return 1 + + # Soft preflight: confirm the validator is runnable. A failure here is a + # warning rather than fatal so validators that do not implement --help + # still work; real problems surface during the actual run below. + preflight = subprocess.run( + [sys.executable, validator_path, "--help"], + capture_output=True, + ) + if preflight.returncode != 0: + print( + f"Warning: '{validator_path} --help' exited " + f"{preflight.returncode}; continuing anyway.", + file=sys.stderr, + ) + + # Step 1: extract examples for this language. + examples = extract_examples(args.language) + # Flush so this line precedes the validator subprocess output in CI logs. + print(f"Extracted {len(examples)} {args.language} code example(s).", flush=True) + + if not examples: + return 0 + + # Step 2: write to a temp file and hand it to the validator. + tmp_file = tempfile.NamedTemporaryFile( + mode="w", + encoding="utf-8", + prefix=f"{args.language}_examples_", + suffix=".json", + delete=False, + ) + tmp_path = tmp_file.name + + try: + with tmp_file as fh: + json.dump(examples, fh, indent=2, sort_keys=True) + + result = subprocess.run( + [ + sys.executable, + validator_path, + "--examples", + tmp_path, + *validator_args, + ], + check=False, + ) + return result.returncode + finally: + if os.path.exists(tmp_path): + os.unlink(tmp_path) + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/scripts/extract_examples.py b/scripts/extract_examples.py new file mode 100644 index 00000000..dba1acac --- /dev/null +++ b/scripts/extract_examples.py @@ -0,0 +1,165 @@ +#!/usr/bin/env python3 +"""Extract fenced code examples for a given language from the MDX docs. + +This is the shared extraction layer used by every per-language example +checker (see ``check_examples.py``). It walks ``src/content/docs`` for +``.mdx`` files, pulls out each fenced code block whose info string names the +requested language, and returns a mapping of +``":" -> code``. + +The mapping format matches the contract expected by the upstream +``validate_examples.py`` scripts that live in each client repository +(originating with ``valkey-glide-csharp``): the keys identify where each +snippet lives in the docs so that compiler errors can be reported against +the source. + +Use it as a library:: + + from extract_examples import extract_examples, LANGUAGE_FENCES + blocks = extract_examples("python") + +or as a CLI for debugging / local runs:: + + python scripts/extract_examples.py --language go --out blocks.json + python scripts/extract_examples.py --language python # prints JSON + python scripts/extract_examples.py --list-languages +""" + +from __future__ import annotations + +import argparse +import json +import os +import re +import sys + +# Repository root is one level up from this script's directory (scripts/). +_REPO_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +DOCS_DIR = os.path.join(_REPO_ROOT, "src", "content", "docs") + +# Maps a canonical language key to the Markdown fence "info string" tokens +# used for that language across the docs. The first token is the canonical +# one; the rest are aliases that appear in practice. +# +# Keep these in sync with the fences actually used in src/content/docs. +# (Node groups the TypeScript and JavaScript fences because the docs use +# both for the Node.js client examples.) +LANGUAGE_FENCES: dict[str, list[str]] = { + "csharp": ["csharp"], + "java": ["java"], + "python": ["python", "py"], + "go": ["go"], + "node": ["typescript", "javascript", "ts", "js"], + "php": ["php"], +} + + +def _block_regex(fences: list[str]) -> "re.Pattern[str]": + """Build a regex matching a fenced code block for the given fence tokens. + + Mirrors the behavior of the original C#-only extractor: leading + horizontal whitespace before the fences is allowed (common inside MDX + ````/```` components), and the info string must be exactly + one of ``fences`` (optionally followed by trailing whitespace) so that, + e.g., ``go`` does not match a ``goat`` fence. + + The opening fence is matched with ``[ \\t]*`` (horizontal whitespace + only) rather than ``\\s*`` so the captured line number points at the + fence itself instead of a preceding blank line. + """ + # Longest token first so the alternation is unambiguous (e.g. ``python`` + # is tried before ``py``). + alternation = "|".join( + re.escape(tok) for tok in sorted(fences, key=len, reverse=True) + ) + return re.compile( + rf"^[ \t]*```(?:{alternation})[ \t]*\n(.*?)^[ \t]*```[ \t]*$", + re.MULTILINE | re.DOTALL, + ) + + +def extract_examples( + language: str, docs_dir: str = DOCS_DIR +) -> dict[str, str]: + """Extract every code block for ``language`` from ``docs_dir``. + + Returns a dict mapping ``":"`` to the + raw code string, where ``line_number`` is the 1-based line of the opening + fence. Snippets are returned with whatever indentation they carry in the + MDX source (e.g. when nested inside ````); the language's + validator is responsible for normalizing indentation (dedent) before + wrapping and compiling, exactly as the C# validator does today. + """ + if language not in LANGUAGE_FENCES: + known = ", ".join(sorted(LANGUAGE_FENCES)) + raise ValueError(f"Unknown language '{language}'. Known: {known}.") + + pattern = _block_regex(LANGUAGE_FENCES[language]) + examples: dict[str, str] = {} + + for root, _dirs, files in os.walk(docs_dir): + for fname in sorted(files): + if not fname.endswith(".mdx"): + continue + + filepath = os.path.join(root, fname) + with open(filepath, encoding="utf-8") as fh: + content = fh.read() + + for match in pattern.finditer(content): + key_path = os.path.relpath(filepath, _REPO_ROOT) + line_number = content[: match.start()].count("\n") + 1 + examples[f"{key_path}:{line_number}"] = match.group(1) + + return examples + + +def main() -> None: + parser = argparse.ArgumentParser( + description="Extract fenced code examples for a language from the docs." + ) + parser.add_argument( + "--language", + choices=sorted(LANGUAGE_FENCES), + help="Language whose code blocks to extract.", + ) + parser.add_argument( + "--out", + help="Write the examples JSON to this path (default: stdout).", + ) + parser.add_argument( + "--list-languages", + action="store_true", + help="List the supported languages and their fence tokens, then exit.", + ) + args = parser.parse_args() + + if args.list_languages: + for lang in sorted(LANGUAGE_FENCES): + fences = ", ".join(LANGUAGE_FENCES[lang]) + print(f"{lang}: {fences}") + return + + if not args.language: + parser.error("--language is required (unless --list-languages is given)") + + if not os.path.isdir(DOCS_DIR): + print(f"Error: docs directory not found: {DOCS_DIR}", file=sys.stderr) + sys.exit(1) + + examples = extract_examples(args.language) + payload = json.dumps(examples, indent=2, sort_keys=True) + + if args.out: + with open(args.out, "w", encoding="utf-8") as fh: + fh.write(payload) + print( + f"Extracted {len(examples)} {args.language} example(s) -> {args.out}", + file=sys.stderr, + ) + else: + print(payload) + + +if __name__ == "__main__": + main() From 3a4f62c2ffbfd0d23ba8f52d1d5412189688b757 Mon Sep 17 00:00:00 2001 From: Harish Anantharaj Date: Thu, 18 Jun 2026 12:01:02 +0530 Subject: [PATCH 2/2] feat(): Previously only C# code examples were compile-checked. Generalize the same extract-and-validate flow to every GLIDE client language so broken snippets are caught in CI instead of by readers. --- .github/workflows/check-go-examples.yml | 48 +--- .github/workflows/check-java-examples.yml | 57 +---- .github/workflows/check-node-examples.yml | 59 +---- .github/workflows/check-php-examples.yml | 48 +--- .github/workflows/check-python-examples.yml | 50 +---- scripts/README.md | 206 +++++++++--------- scripts/check_examples.py | 20 +- scripts/extract_examples.py | 9 +- scripts/validators/_common.py | 131 +++++++++++ scripts/validators/go.py | 87 ++++++++ scripts/validators/java.py | 121 ++++++++++ scripts/validators/node.py | 56 +++++ scripts/validators/php.py | 56 +++++ scripts/validators/python.py | 36 +++ scripts/validators/ts_syntax_check.mjs | 60 +++++ src/content/docs/commands/valkey-string.mdx | 2 +- .../client-features/batch-commands.mdx | 11 +- .../how-to/publish-and-subscribe-messages.mdx | 2 +- .../docs/how-to/security/iam-integration.mdx | 11 +- .../docs/how-to/send-batch-commands.mdx | 2 +- 20 files changed, 713 insertions(+), 359 deletions(-) create mode 100644 scripts/validators/_common.py create mode 100644 scripts/validators/go.py create mode 100644 scripts/validators/java.py create mode 100644 scripts/validators/node.py create mode 100644 scripts/validators/php.py create mode 100644 scripts/validators/python.py create mode 100644 scripts/validators/ts_syntax_check.mjs diff --git a/.github/workflows/check-go-examples.yml b/.github/workflows/check-go-examples.yml index e846e7b8..177dbe24 100644 --- a/.github/workflows/check-go-examples.yml +++ b/.github/workflows/check-go-examples.yml @@ -1,11 +1,8 @@ name: Check Go Examples -# Validates that the Go code examples in the docs compile. -# -# Mirrors the C# check: this repo extracts the snippets (scripts/check_examples.py) -# and the upstream Go client provides dev/scripts/validate_examples.py to wrap + -# compile them. Until that upstream validator exists, the "Probe" step skips this -# check (a green run with a notice). See scripts/README.md. +# Syntax-validates the Go code examples in the docs. Extraction lives in +# scripts/check_examples.py; the bundled validator (scripts/validators/go.py) +# parses each snippet with `gofmt -e` (syntax only). See scripts/README.md. on: pull_request: @@ -15,11 +12,6 @@ on: permissions: contents: read -env: - UPSTREAM_REPO: valkey-io/valkey-glide - # Expected location of the validator, relative to the upstream repo root. - VALIDATOR_REPO_PATH: go/dev/scripts/validate_examples.py - jobs: check-go-examples: runs-on: ubuntu-latest @@ -27,45 +19,15 @@ jobs: - name: Checkout valkey-glide-docs uses: actions/checkout@v4 - - name: Probe for upstream validator - id: probe - env: - GH_TOKEN: ${{ github.token }} - run: | - if gh api "repos/${UPSTREAM_REPO}/contents/${VALIDATOR_REPO_PATH}" --silent 2>/dev/null; then - echo "present=true" >> "$GITHUB_OUTPUT" - else - echo "::notice::Upstream Go example validator not found at ${UPSTREAM_REPO}/${VALIDATOR_REPO_PATH}. Skipping Go example validation until it lands upstream (see scripts/README.md)." - echo "present=false" >> "$GITHUB_OUTPUT" - fi - - # --------------------------------------------------------------------- - # The steps below run only once the upstream validator exists. The - # validator resolves the client via the Go module system (go.mod), so no - # built artifact needs to be passed. - # --------------------------------------------------------------------- - - name: Checkout valkey-glide - if: steps.probe.outputs.present == 'true' - uses: actions/checkout@v4 - with: - repository: ${{ env.UPSTREAM_REPO }} - path: valkey-glide - - name: Setup Go - if: steps.probe.outputs.present == 'true' uses: actions/setup-go@v5 with: go-version: "stable" - name: Setup Python - if: steps.probe.outputs.present == 'true' uses: actions/setup-python@v5 with: python-version: "3.x" - - name: Run Go example validation - if: steps.probe.outputs.present == 'true' - run: | - python scripts/check_examples.py \ - --language go \ - --validator "valkey-glide/${VALIDATOR_REPO_PATH}" + - name: Validate Go examples + run: python scripts/check_examples.py --language go diff --git a/.github/workflows/check-java-examples.yml b/.github/workflows/check-java-examples.yml index 09fa2867..5d2b7acd 100644 --- a/.github/workflows/check-java-examples.yml +++ b/.github/workflows/check-java-examples.yml @@ -1,12 +1,9 @@ name: Check Java Examples -# Validates that the Java code examples in the docs compile. -# -# Mirrors the C# check: this repo extracts the snippets (scripts/check_examples.py) -# and the upstream Java client provides dev/scripts/validate_examples.py to wrap + -# compile them with javac against the client jar. Until that upstream validator -# exists, the "Probe" step skips this check (a green run with a notice). See -# scripts/README.md. +# Syntax-validates the Java code examples in the docs. Extraction lives in +# scripts/check_examples.py; the bundled validator (scripts/validators/java.py) +# compiles each snippet with `javac` and treats only syntax-category +# diagnostics as failures. See scripts/README.md. on: pull_request: @@ -16,11 +13,6 @@ on: permissions: contents: read -env: - UPSTREAM_REPO: valkey-io/valkey-glide - # Expected location of the validator, relative to the upstream repo root. - VALIDATOR_REPO_PATH: java/dev/scripts/validate_examples.py - jobs: check-java-examples: runs-on: ubuntu-latest @@ -28,53 +20,16 @@ jobs: - name: Checkout valkey-glide-docs uses: actions/checkout@v4 - - name: Probe for upstream validator - id: probe - env: - GH_TOKEN: ${{ github.token }} - run: | - if gh api "repos/${UPSTREAM_REPO}/contents/${VALIDATOR_REPO_PATH}" --silent 2>/dev/null; then - echo "present=true" >> "$GITHUB_OUTPUT" - else - echo "::notice::Upstream Java example validator not found at ${UPSTREAM_REPO}/${VALIDATOR_REPO_PATH}. Skipping Java example validation until it lands upstream (see scripts/README.md)." - echo "present=false" >> "$GITHUB_OUTPUT" - fi - - # --------------------------------------------------------------------- - # The steps below run only once the upstream validator exists. Finalize - # the jar build path / artifact argument when that lands (only the Java - # classes are needed for javac, so the native core can be skipped). - # --------------------------------------------------------------------- - - name: Checkout valkey-glide - if: steps.probe.outputs.present == 'true' - uses: actions/checkout@v4 - with: - repository: ${{ env.UPSTREAM_REPO }} - path: valkey-glide - - name: Setup Java - if: steps.probe.outputs.present == 'true' uses: actions/setup-java@v4 with: distribution: "temurin" java-version: "17" - name: Setup Python - if: steps.probe.outputs.present == 'true' uses: actions/setup-python@v5 with: python-version: "3.x" - - name: Build Java client jar - if: steps.probe.outputs.present == 'true' - working-directory: valkey-glide/java - run: ./gradlew :client:jar - - - name: Run Java example validation - if: steps.probe.outputs.present == 'true' - run: | - JAR="$(find valkey-glide/java/client/build/libs -name '*.jar' | head -n1)" - python scripts/check_examples.py \ - --language java \ - --validator "valkey-glide/${VALIDATOR_REPO_PATH}" \ - -- --glide-jar "${JAR}" + - name: Validate Java examples + run: python scripts/check_examples.py --language java diff --git a/.github/workflows/check-node-examples.yml b/.github/workflows/check-node-examples.yml index a006dc40..b5ed2746 100644 --- a/.github/workflows/check-node-examples.yml +++ b/.github/workflows/check-node-examples.yml @@ -1,12 +1,9 @@ name: Check Node Examples -# Validates that the Node.js code examples in the docs compile (TypeScript and -# JavaScript fences). -# -# Mirrors the C# check: this repo extracts the snippets (scripts/check_examples.py) -# and the upstream Node client provides dev/scripts/validate_examples.py to wrap + -# type-check them with tsc. Until that upstream validator exists, the "Probe" step -# skips this check (a green run with a notice). See scripts/README.md. +# Syntax-validates the Node.js (TypeScript / JavaScript) code examples in the +# docs. Extraction lives in scripts/check_examples.py; the bundled validator +# (scripts/validators/node.py) checks each snippet with the TypeScript +# compiler's transpileModule (syntax only). See scripts/README.md. on: pull_request: @@ -16,11 +13,6 @@ on: permissions: contents: read -env: - UPSTREAM_REPO: valkey-io/valkey-glide - # Expected location of the validator, relative to the upstream repo root. - VALIDATOR_REPO_PATH: node/dev/scripts/validate_examples.py - jobs: check-node-examples: runs-on: ubuntu-latest @@ -28,49 +20,22 @@ jobs: - name: Checkout valkey-glide-docs uses: actions/checkout@v4 - - name: Probe for upstream validator - id: probe - env: - GH_TOKEN: ${{ github.token }} - run: | - if gh api "repos/${UPSTREAM_REPO}/contents/${VALIDATOR_REPO_PATH}" --silent 2>/dev/null; then - echo "present=true" >> "$GITHUB_OUTPUT" - else - echo "::notice::Upstream Node example validator not found at ${UPSTREAM_REPO}/${VALIDATOR_REPO_PATH}. Skipping Node example validation until it lands upstream (see scripts/README.md)." - echo "present=false" >> "$GITHUB_OUTPUT" - fi - - # --------------------------------------------------------------------- - # The steps below run only once the upstream validator exists. The - # validator resolves the client from node_modules after `npm install`, so - # no built artifact path needs to be passed. - # --------------------------------------------------------------------- - - name: Checkout valkey-glide - if: steps.probe.outputs.present == 'true' - uses: actions/checkout@v4 - with: - repository: ${{ env.UPSTREAM_REPO }} - path: valkey-glide - - name: Setup Node.js - if: steps.probe.outputs.present == 'true' uses: actions/setup-node@v4 with: node-version: 20 - - name: Install client and TypeScript - if: steps.probe.outputs.present == 'true' - run: npm install @valkey/valkey-glide typescript + - name: Install TypeScript + # Installed to a separate prefix and exposed via NODE_PATH so it does + # not interfere with the repo's pnpm-managed node_modules. + run: | + npm install --prefix "$RUNNER_TEMP/tsdeps" typescript + echo "NODE_PATH=$RUNNER_TEMP/tsdeps/node_modules" >> "$GITHUB_ENV" - name: Setup Python - if: steps.probe.outputs.present == 'true' uses: actions/setup-python@v5 with: python-version: "3.x" - - name: Run Node example validation - if: steps.probe.outputs.present == 'true' - run: | - python scripts/check_examples.py \ - --language node \ - --validator "valkey-glide/${VALIDATOR_REPO_PATH}" + - name: Validate Node examples + run: python scripts/check_examples.py --language node diff --git a/.github/workflows/check-php-examples.yml b/.github/workflows/check-php-examples.yml index 86e92c9b..bb324369 100644 --- a/.github/workflows/check-php-examples.yml +++ b/.github/workflows/check-php-examples.yml @@ -1,13 +1,8 @@ name: Check PHP Examples -# Validates the PHP code examples in the docs. -# -# Mirrors the C# check: this repo extracts the snippets (scripts/check_examples.py) -# and the upstream PHP client provides dev/scripts/validate_examples.py. Because -# the PHP client is a native (C) extension, the validator is expected to perform -# lint-level validation (`php -l`) rather than full compilation. Until that -# upstream validator exists, the "Probe" step skips this check (a green run with -# a notice). See scripts/README.md. +# Syntax-validates the PHP code examples in the docs. Extraction lives in +# scripts/check_examples.py; the bundled validator (scripts/validators/php.py) +# lints each snippet with `php -l` (syntax only). See scripts/README.md. on: pull_request: @@ -17,11 +12,6 @@ on: permissions: contents: read -env: - UPSTREAM_REPO: valkey-io/valkey-glide-php - # Expected location of the validator, relative to the upstream repo root. - VALIDATOR_REPO_PATH: dev/scripts/validate_examples.py - jobs: check-php-examples: runs-on: ubuntu-latest @@ -29,43 +19,15 @@ jobs: - name: Checkout valkey-glide-docs uses: actions/checkout@v4 - - name: Probe for upstream validator - id: probe - env: - GH_TOKEN: ${{ github.token }} - run: | - if gh api "repos/${UPSTREAM_REPO}/contents/${VALIDATOR_REPO_PATH}" --silent 2>/dev/null; then - echo "present=true" >> "$GITHUB_OUTPUT" - else - echo "::notice::Upstream PHP example validator not found at ${UPSTREAM_REPO}/${VALIDATOR_REPO_PATH}. Skipping PHP example validation until it lands upstream (see scripts/README.md)." - echo "present=false" >> "$GITHUB_OUTPUT" - fi - - # --------------------------------------------------------------------- - # The steps below run only once the upstream validator exists. - # --------------------------------------------------------------------- - - name: Checkout valkey-glide-php - if: steps.probe.outputs.present == 'true' - uses: actions/checkout@v4 - with: - repository: ${{ env.UPSTREAM_REPO }} - path: valkey-glide-php - - name: Setup PHP - if: steps.probe.outputs.present == 'true' uses: shivammathur/setup-php@v2 with: php-version: "8.2" - name: Setup Python - if: steps.probe.outputs.present == 'true' uses: actions/setup-python@v5 with: python-version: "3.x" - - name: Run PHP example validation - if: steps.probe.outputs.present == 'true' - run: | - python scripts/check_examples.py \ - --language php \ - --validator "valkey-glide-php/${VALIDATOR_REPO_PATH}" + - name: Validate PHP examples + run: python scripts/check_examples.py --language php diff --git a/.github/workflows/check-python-examples.yml b/.github/workflows/check-python-examples.yml index 3d96559b..3e6c3d62 100644 --- a/.github/workflows/check-python-examples.yml +++ b/.github/workflows/check-python-examples.yml @@ -1,11 +1,8 @@ name: Check Python Examples -# Validates that the Python code examples in the docs compile. -# -# Mirrors the C# check: this repo extracts the snippets (scripts/check_examples.py) -# and the upstream Python client provides dev/scripts/validate_examples.py to -# wrap + compile them. Until that upstream validator exists, the "Probe" step -# skips this check (a green run with a notice). See scripts/README.md. +# Syntax-validates the Python code examples in the docs. Extraction lives in +# scripts/check_examples.py; the bundled validator (scripts/validators/python.py) +# compiles each snippet with Python's compile(). See scripts/README.md. on: pull_request: @@ -15,11 +12,6 @@ on: permissions: contents: read -env: - UPSTREAM_REPO: valkey-io/valkey-glide - # Expected location of the validator, relative to the upstream repo root. - VALIDATOR_REPO_PATH: python/dev/scripts/validate_examples.py - jobs: check-python-examples: runs-on: ubuntu-latest @@ -27,42 +19,10 @@ jobs: - name: Checkout valkey-glide-docs uses: actions/checkout@v4 - - name: Probe for upstream validator - id: probe - env: - GH_TOKEN: ${{ github.token }} - run: | - if gh api "repos/${UPSTREAM_REPO}/contents/${VALIDATOR_REPO_PATH}" --silent 2>/dev/null; then - echo "present=true" >> "$GITHUB_OUTPUT" - else - echo "::notice::Upstream Python example validator not found at ${UPSTREAM_REPO}/${VALIDATOR_REPO_PATH}. Skipping Python example validation until it lands upstream (see scripts/README.md)." - echo "present=false" >> "$GITHUB_OUTPUT" - fi - - # --------------------------------------------------------------------- - # The steps below run only once the upstream validator exists. Finalize - # the install/build and artifact argument when that lands. - # --------------------------------------------------------------------- - - name: Checkout valkey-glide - if: steps.probe.outputs.present == 'true' - uses: actions/checkout@v4 - with: - repository: ${{ env.UPSTREAM_REPO }} - path: valkey-glide - - name: Setup Python - if: steps.probe.outputs.present == 'true' uses: actions/setup-python@v5 with: python-version: "3.x" - - name: Install valkey-glide - if: steps.probe.outputs.present == 'true' - run: pip install valkey-glide - - - name: Run Python example validation - if: steps.probe.outputs.present == 'true' - run: | - python scripts/check_examples.py \ - --language python \ - --validator "valkey-glide/${VALIDATOR_REPO_PATH}" + - name: Validate Python examples + run: python scripts/check_examples.py --language python diff --git a/scripts/README.md b/scripts/README.md index 26c69b75..59785296 100644 --- a/scripts/README.md +++ b/scripts/README.md @@ -1,33 +1,80 @@ # Documentation example validation These scripts check that the code examples embedded in the docs -(`src/content/docs/**/*.mdx`) actually compile, so a broken snippet is -caught in CI instead of by a reader. - -The work is split in two, exactly as it was first built for C#: - -| Half | Lives in | Responsibility | -| ------------------------- | ---------------------- | ---------------------------------------------------------------------------- | -| **Extraction + plumbing** | this repo (`scripts/`) | Pull the fenced code blocks for a language out of the MDX and hand them off. | -| **Wrapping + compiling** | each client's own repo | Wrap each snippet, compile it against the real client build, report errors. | - -This repo owns only the first half. Each language is validated by a -`validate_examples.py` script that lives in that language's client -repository, because that repo is the one that knows how to wrap a snippet -and build the client. The C# validator already exists in -[`valkey-glide-csharp`](https://github.com/valkey-io/valkey-glide-csharp); -the other languages activate automatically once their upstream validator -lands (see [Status](#per-language-status)). +(`src/content/docs/**/*.mdx`) are valid, so a broken snippet is caught in CI +instead of by a reader. There is one check per client language, run by its own +GitHub workflow (`.github/workflows/check--examples.yml`). + +## How it works + +```` +extract_examples.py pull ``` blocks from the MDX -> { ":": code } + | + v +check_examples.py orchestrator: extract for one language, write a temp JSON, + | run that language's validator, propagate its exit code + v +validators/.py validate each snippet (and report failures by ":") +```` + +| Script | Role | +| -------------------------- | ---------------------------------------------------------------------------------------- | +| `extract_examples.py` | Shared extractor (library + CLI). | +| `check_examples.py` | Generic orchestrator. `--validator` defaults to `scripts/validators/.py`. | +| `validators/.py` | Bundled per-language validators + `_common.py` harness and `ts_syntax_check.mjs` helper. | +| `check_csharp_examples.py` | Thin wrapper kept so the existing C# workflow is unchanged (see [C#](#c) below). | + +## What is validated: syntax + +Most snippets in the docs are **fragments** — they reference an undefined +`client`, omit imports, and are not standalone programs. So the bundled +validators check **syntax / parse validity**, not full compilation against the +client library. This catches the breakage that actually happens when examples +are edited (typos, unbalanced braces, bad indentation, missing semicolons) +without false-failing on undefined symbols or unresolved imports. + +| Language | Validator | Tool needed | Check | +| -------- | -------------------------------------------- | -------------------- | -------------------------------------------------------------- | +| Python | `validators/python.py` | `python` | `compile()` (snippet wrapped in an `async def`) | +| Node | `validators/node.py` + `ts_syntax_check.mjs` | `node`, `typescript` | TypeScript `transpileModule` (syntactic diagnostics only) | +| Go | `validators/go.py` | `gofmt` | `gofmt -e` parse (snippet wrapped into a complete file) | +| Java | `validators/java.py` | `javac` | `javac`, treating only syntax-category diagnostics as failures | +| PHP | `validators/php.py` | `php` | `php -l` lint | + +### C\# + +C# is the exception. Its docs snippets are written self-contained, and a +**full-compilation** validator already exists upstream in +[`valkey-glide-csharp`](https://github.com/valkey-io/valkey-glide-csharp) +(`dev/scripts/validate_examples.py`), which compiles each snippet against the +real `Valkey.Glide.dll`. The C# workflow keeps using that upstream validator +via `check_csharp_examples.py`, so C# gets a stronger guarantee than the +syntax-only checks above. + +## Opting a block out of validation + +Only blocks fenced with the **bare language token** (e.g. ` ```python `) are +extracted and validated. A snippet that is intentionally not runnable — an +API-signature illustration, for example — opts out simply by **not** using a +language fence. The convention in these docs is to fence such blocks as +` ```text ` (this is how the C# signature examples already do it): + +````markdown +```text +// Standalone Mode +public async exec(batch: Batch, raiseOnError: boolean) +``` +```` -## Scripts +> A block whose info string carries any meta (e.g. ` ```python title="..." `) +> is likewise not matched, so be aware that adding a title to a **runnable** +> example silently excludes it from validation. -| Script | What it does | -| -------------------------- | ------------------------------------------------------------------------------------------------------------------------------ | -| `extract_examples.py` | Shared extractor. Walks the docs and returns `{ ":": "" }` for one language. Library **and** CLI. | -| `check_examples.py` | Generic orchestrator. Extracts a language's examples, writes them to a temp JSON file, and runs the upstream validator. | -| `check_csharp_examples.py` | Thin backwards-compatible wrapper around `check_examples.py --language csharp`, kept so the existing C# workflow is unchanged. | +For an example that is real code but shows omitted sections, write the +omission as a comment (`// ...` / `# ...`) instead of a bare `...`, so the +snippet stays valid and keeps getting checked. -### Local usage +## Local usage ```bash # List the languages and the Markdown fences each one matches. @@ -36,101 +83,44 @@ python scripts/extract_examples.py --list-languages # Dump the extracted examples for a language (debugging). python scripts/extract_examples.py --language go --out /tmp/go.json -# Run a language's validator (requires a checkout of that client repo -# and a build of its artifact): +# Run a language's check (needs that language's toolchain on PATH). +python scripts/check_examples.py --language python +python scripts/check_examples.py --language node # needs `typescript` resolvable +python scripts/check_examples.py --language go # needs gofmt +python scripts/check_examples.py --language java # needs a JDK (javac) +python scripts/check_examples.py --language php # needs php + +# C# (needs a checkout + build of the client repo): python scripts/check_examples.py \ --language csharp \ --validator ../valkey-glide-csharp/dev/scripts/validate_examples.py \ -- --glide-dll ../valkey-glide-csharp/sources/Valkey.Glide/bin/Release/net8.0/Valkey.Glide.dll ``` -Everything after `--` is forwarded verbatim to the validator, so each -language passes whatever artifact reference it needs. - ## The validator contract -To enable validation for a language, add a `validate_examples.py` to that -language's client repository that honors this contract. The docs-side -orchestrator (`check_examples.py`) invokes it as: +`check_examples.py` invokes any validator the same way (the convention comes +from the upstream C# validator): ```bash -python --examples [language-specific args...] +python --examples [extra args...] ``` -**Input** — `--examples` points at a JSON object mapping a source location -to a code snippet: +- `` maps `":"` + to the snippet's raw text. +- Anything after `--` on the `check_examples.py` command line is forwarded + verbatim (e.g. `--glide-dll` for C#). +- Exit `0` when every example is valid, non-zero when any fails or a required + tool is missing. -```json -{ - "src/content/docs/getting-started/quickstart.mdx:342": "using Valkey.Glide;\n// ...\n", - "src/content/docs/how-to/security/tls.mdx:445": "// ...\n" -} -``` +The bundled validators dedent each snippet and ignore unknown forwarded +arguments, so they share this signature. + +## Adding / changing a language -The key is `":"`; the value -is the snippet's raw text. - -**Responsibilities of the validator:** - -1. **Normalize indentation.** Snippets nested inside MDX `` - components carry leading indentation. Dedent before compiling - (whitespace-insensitive languages like C# can ignore this; Python, Go, - etc. must dedent). -2. **Wrap each snippet** into a compilable unit — e.g. hoist `import`/`using` - directives, then place the remaining statements inside a function/method - body. The C# validator wraps each snippet in a `Run()` method on a - uniquely-named class and injects common client imports. -3. **Compile** against the real client build (the artifact passed via the - language-specific argument), not just a syntax check. -4. **Report failures** mapped back to the original `":"` key so a - reader can find the broken snippet. - -**Exit code:** `0` when every example compiles, non-zero when any example -fails or a required input is missing. - -**Recommended artifact arguments** (the orchestrator forwards these -verbatim; the exact flag is up to each validator): - -| Language | Artifact passed to the validator | -| -------- | --------------------------------------------------------------------------- | -| C# | `--glide-dll ` (already implemented) | -| Java | `--glide-jar ` | -| Go | resolved via the module (`go.mod`); no artifact path needed | -| Node | resolved from `node_modules` after `npm install`; no artifact path needed | -| Python | resolved from the installed `valkey-glide` package; no artifact path needed | -| PHP | the loaded `valkey_glide` extension; lint-level only (`php -l`) | - -## Per-language status - -| Language | Fences matched | Upstream validator | Status | -| -------- | -------------------------------------- | -------------------------------------------------------------- | ---------------------- | -| C# | `csharp` | `valkey-glide-csharp/dev/scripts/validate_examples.py` | **Live** | -| Java | `java` | `valkey-glide/java/dev/scripts/validate_examples.py` _(TBD)_ | Skipped until upstream | -| Python | `python`, `py` | `valkey-glide/python/dev/scripts/validate_examples.py` _(TBD)_ | Skipped until upstream | -| Go | `go` | `valkey-glide/go/dev/scripts/validate_examples.py` _(TBD)_ | Skipped until upstream | -| Node | `typescript`, `javascript`, `ts`, `js` | `valkey-glide/node/dev/scripts/validate_examples.py` _(TBD)_ | Skipped until upstream | -| PHP | `php` | `valkey-glide-php/dev/scripts/validate_examples.py` _(TBD)_ | Skipped until upstream | - -The upstream paths above are the **expected convention** (mirroring C#'s -`dev/scripts/` location); confirm and adjust the path in the matching -workflow when the validator lands. The CI workflows for not-yet-supported -languages probe for the validator and skip cleanly (a green check with a -notice) until it exists, then start enforcing automatically. - -## Adding / enabling a language - -1. Confirm the language's fences are listed in `LANGUAGE_FENCES` in - `extract_examples.py` (add aliases if the docs use new ones). -2. Implement `validate_examples.py` in that language's client repo per the - [contract](#the-validator-contract) above. -3. Nothing else is required to activate it. Each - `.github/workflows/check--examples.yml` probes the upstream repo - for the validator on every run: once it exists, the probe passes and the - gated build + validation steps run automatically. Just confirm the - workflow's `VALIDATOR_REPO_PATH`, build steps, and artifact argument match - what the validator actually expects. - -> The orchestrator also accepts a `--skip-if-no-validator` flag (used for -> local runs) that turns a missing validator into a notice + exit 0 instead -> of an error. The CI workflows rely on the probe step instead, so they don't -> spin up a build runner until the validator exists. +1. Make sure the language's fences are listed in `LANGUAGE_FENCES` in + `extract_examples.py` (add aliases if the docs start using new ones). +2. Add `scripts/validators/.py` using the `_common.run(...)` harness, + or point the workflow at an external validator with `--validator`. +3. Add `.github/workflows/check--examples.yml` that sets up the + toolchain and runs `python scripts/check_examples.py --language `. diff --git a/scripts/check_examples.py b/scripts/check_examples.py index 937c8da0..fe135072 100644 --- a/scripts/check_examples.py +++ b/scripts/check_examples.py @@ -56,7 +56,8 @@ def _parse_args(argv: list[str]) -> tuple[argparse.Namespace, list[str]]: parser = argparse.ArgumentParser( description=( "Extract code examples for a language from the MDX docs and " - "validate them with the language's upstream validate_examples.py." + "validate them with that language's validate_examples.py " + "(the bundled scripts/validators/.py by default)." ) ) parser.add_argument( @@ -67,8 +68,11 @@ def _parse_args(argv: list[str]) -> tuple[argparse.Namespace, list[str]]: ) parser.add_argument( "--validator", - required=True, - help="Path to the upstream validate_examples.py script.", + default=None, + help=( + "Path to the validate_examples.py script to run. Defaults to the " + "bundled validator at scripts/validators/.py." + ), ) parser.add_argument( "--skip-if-no-validator", @@ -92,7 +96,15 @@ def main(argv: list[str] | None = None) -> int: if validator_args and validator_args[0] == "--": validator_args = validator_args[1:] - validator_path = os.path.abspath(args.validator) + # Default to the bundled syntax validator for this language. + validator = args.validator + if validator is None: + validator = os.path.join( + os.path.dirname(os.path.abspath(__file__)), + "validators", + f"{args.language}.py", + ) + validator_path = os.path.abspath(validator) # The validator may not exist yet for this language. if not os.path.isfile(validator_path): diff --git a/scripts/extract_examples.py b/scripts/extract_examples.py index dba1acac..1cb219e2 100644 --- a/scripts/extract_examples.py +++ b/scripts/extract_examples.py @@ -78,9 +78,7 @@ def _block_regex(fences: list[str]) -> "re.Pattern[str]": ) -def extract_examples( - language: str, docs_dir: str = DOCS_DIR -) -> dict[str, str]: +def extract_examples(language: str, docs_dir: str = DOCS_DIR) -> dict[str, str]: """Extract every code block for ``language`` from ``docs_dir``. Returns a dict mapping ``":"`` to the @@ -89,6 +87,11 @@ def extract_examples( MDX source (e.g. when nested inside ````); the language's validator is responsible for normalizing indentation (dedent) before wrapping and compiling, exactly as the C# validator does today. + + Only blocks fenced with the bare language token (e.g. ` ```python `) are + extracted. A block fenced as ` ```text ` or with any info-string meta + (e.g. a ``title="..."``) is not matched, which is how non-runnable + snippets such as API-signature illustrations opt out of validation. """ if language not in LANGUAGE_FENCES: known = ", ".join(sorted(LANGUAGE_FENCES)) diff --git a/scripts/validators/_common.py b/scripts/validators/_common.py new file mode 100644 index 00000000..d7ed188b --- /dev/null +++ b/scripts/validators/_common.py @@ -0,0 +1,131 @@ +#!/usr/bin/env python3 +"""Shared harness for the bundled documentation example validators. + +Each bundled validator (``python.py``, ``node.py``, ``go.py``, ``java.py``, +``php.py``) performs *syntax* validation for one language and plugs into this +harness. The harness implements the same ``--examples`` JSON contract used by +the upstream C# ``validate_examples.py`` so that ``scripts/check_examples.py`` +can drive every validator identically: + + python scripts/validators/.py --examples + +It reads the examples, dedents each snippet, runs the language's check, prints +a per-source failure report, and exits 0 (all valid) or 1 (one or more +failed / a required tool is missing). +""" + +from __future__ import annotations + +import argparse +import json +import os +import shutil +import subprocess +import sys +import textwrap +from typing import Callable, Optional + +# A validator either checks one snippet at a time (check_one) or the whole +# batch at once (check_all, e.g. to amortize an expensive interpreter startup). +CheckOne = Callable[[str, str], "list[str]"] +CheckAll = Callable[["dict[str, str]"], "dict[str, list[str]]"] + + +def require_tool(name: str, version_args: "list[str] | None" = None) -> None: + """Exit(1) with a clear message if an external tool is unavailable. + + Mirrors how the C# validator bails out when ``dotnet`` is missing. If + ``version_args`` is given, the tool is also invoked to confirm it actually + runs (e.g. the macOS ``javac`` shim exists on PATH but fails without a JDK). + """ + if shutil.which(name) is None: + print( + f"Error: required tool '{name}' is not installed or not on PATH.", + file=sys.stderr, + ) + sys.exit(1) + if version_args is not None: + try: + result = subprocess.run( + [name, *version_args], capture_output=True, text=True + ) + except OSError as exc: + print(f"Error: failed to run '{name}': {exc}", file=sys.stderr) + sys.exit(1) + if result.returncode != 0: + detail = (result.stderr or result.stdout).strip() + print( + f"Error: '{name}' is present but not working: {detail}", + file=sys.stderr, + ) + sys.exit(1) + + +def run( + language: str, + *, + check_one: Optional[CheckOne] = None, + check_all: Optional[CheckAll] = None, + preflight: Optional[Callable[[], None]] = None, +) -> None: + """Drive validation for ``language`` and exit with the appropriate code. + + Provide exactly one of ``check_one`` (per-snippet) or ``check_all`` + (whole-batch). Both receive already-dedented code and return error message + lists; an empty list means the snippet is valid. + """ + if (check_one is None) == (check_all is None): + raise ValueError("Provide exactly one of check_one or check_all.") + + parser = argparse.ArgumentParser( + description=f"Syntax-validate {language} documentation examples." + ) + parser.add_argument( + "--examples", + required=True, + help="Path to a JSON file mapping ':' to code.", + ) + # Tolerate (and ignore) any artifact flags forwarded by the orchestrator, + # so the bundled validators share the upstream validator's call signature. + args, _ignored = parser.parse_known_args() + + if not os.path.isfile(args.examples): + print(f"Error: '{args.examples}' not found", file=sys.stderr) + sys.exit(1) + + with open(args.examples, encoding="utf-8") as fh: + examples = json.load(fh) + + if not examples: + print("No examples found in the provided file.") + sys.exit(0) + + if preflight is not None: + preflight() + + print(f"Validating {len(examples)} {language} example(s)...", flush=True) + + dedented = {source: textwrap.dedent(code) for source, code in examples.items()} + + if check_all is not None: + errors = {s: msgs for s, msgs in check_all(dedented).items() if msgs} + else: + errors = {} + for source, code in dedented.items(): + msgs = check_one(source, code) + if msgs: + errors[source] = msgs + + if errors: + bar = "=" * 60 + print(f"\n{bar}\nFAILURES ({len(errors)} of {len(examples)} examples)\n{bar}\n") + for source, messages in errors.items(): + print(f" FAIL: {source}") + for message in messages: + print(f" {message}") + print() + print(f"{len(examples) - len(errors)} passed, {len(errors)} failed") + sys.exit(1) + + print(f"\nAll {len(examples)} {language} examples are syntactically valid.") + sys.exit(0) diff --git a/scripts/validators/go.py b/scripts/validators/go.py new file mode 100644 index 00000000..cfe5ac0e --- /dev/null +++ b/scripts/validators/go.py @@ -0,0 +1,87 @@ +#!/usr/bin/env python3 +"""Syntax-validate Go documentation examples via ``gofmt -e``. + +``gofmt`` parses a complete Go source file and reports syntax errors, but does +not resolve imports or symbols, so undefined identifiers like ``client`` and +unused imports are intentionally not flagged. Because most snippets are +fragments, each is wrapped into a complete file before parsing. Imports are +hoisted to the top, and the remaining code is tried both as ``func main`` +statements and as package-level declarations; the snippet passes if *any* +wrapping is syntactically valid. +""" + +from __future__ import annotations + +import os +import re +import subprocess +import sys +import textwrap + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +import _common # noqa: E402 + +_PACKAGE = re.compile(r"^\s*package\s") +_IMPORT_BLOCK_START = re.compile(r"^\s*import\s*\(") +_IMPORT_SINGLE = re.compile(r"^\s*import\s+(?:\w+\s+|\.\s+)?[\"`]") + + +def _preflight() -> None: + _common.require_tool("gofmt") + + +def _split_imports(code: str) -> "tuple[str, str]": + """Separate import declarations (single and block form) from the rest.""" + imports, body, lines = [], [], code.splitlines() + i = 0 + while i < len(lines): + line = lines[i] + if _IMPORT_BLOCK_START.match(line): + imports.append(line) + i += 1 + while i < len(lines): + imports.append(lines[i]) + if re.match(r"^\s*\)", lines[i]): + break + i += 1 + elif _IMPORT_SINGLE.match(line): + imports.append(line) + else: + body.append(line) + i += 1 + return "\n".join(imports), "\n".join(body).strip("\n") + + +def _candidates(code: str) -> "list[str]": + """Return source-file wrappings to try, most likely first.""" + snippet = code.strip("\n") + if _PACKAGE.match(snippet): + return [snippet + "\n"] + + imports, body = _split_imports(snippet) + header = f"package main\n\n{imports}\n" if imports else "package main\n" + statements = f"{header}\nfunc main() {{\n{textwrap.indent(body, chr(9))}\n}}\n" + declarations = f"{header}\n{body}\n" + return [statements, declarations] + + +def check_one(source: str, code: str) -> "list[str]": + first: "list[str]" = [] + for candidate in _candidates(code): + proc = subprocess.run( + ["gofmt", "-e"], input=candidate, capture_output=True, text=True + ) + if proc.returncode == 0: + return [] + messages = [ + line.replace(":", "line ").strip() + for line in proc.stderr.splitlines() + if line.strip() + ] + if not first: + first = messages or [f"gofmt failed (exit {proc.returncode})"] + return first + + +if __name__ == "__main__": + _common.run("go", check_one=check_one, preflight=_preflight) diff --git a/scripts/validators/java.py b/scripts/validators/java.py new file mode 100644 index 00000000..18f758d8 --- /dev/null +++ b/scripts/validators/java.py @@ -0,0 +1,121 @@ +#!/usr/bin/env python3 +"""Syntax-validate Java documentation examples via ``javac``. + +Java has no pure-syntax compile mode, so each snippet is compiled with no +classpath and only *syntactic* diagnostics are treated as failures — +"cannot find symbol", "package ... does not exist", type-mismatch errors, etc. +are ignored, so undefined identifiers like ``client`` and the missing client +jar do not cause failures. + +Because snippets may be bare statements, class members, or whole type +declarations, a few wrappings are tried and the snippet passes if *any* of them +is syntactically valid. +""" + +from __future__ import annotations + +import os +import re +import subprocess +import sys +import tempfile +import textwrap + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +import _common # noqa: E402 + +_IMPORT_LINE = re.compile(r"^\s*import\s+(?:static\s+)?[\w.*]+\s*;") +_TYPE_DECL = re.compile(r"(?m)^\s*(?:public\s+|final\s+|abstract\s+|sealed\s+)*(?:class|interface|enum|record)\s+\w+") +_PUBLIC_TYPE = re.compile( + r"(?m)^\s*public\s+(?:final\s+|abstract\s+|sealed\s+|non-sealed\s+)*" + r"(?:class|interface|enum|record)\s+(\w+)" +) + +# Substrings that identify a *syntactic* javac error (as opposed to a semantic +# one such as "cannot find symbol"). Matched case-insensitively. +_SYNTAX_HINTS = ( + "expected", + "illegal start of", + "reached end of file while parsing", + "not a statement", + "unclosed", + "illegal character", + "malformed", + "premature end of file", + "empty character literal", + "unexpected type", + "> expected", +) + + +def _preflight() -> None: + _common.require_tool("javac", ["-version"]) + + +def _split_imports(code: str) -> "tuple[list[str], str]": + imports, body = [], [] + for line in code.splitlines(): + (imports if _IMPORT_LINE.match(line) else body).append(line) + return imports, "\n".join(body).strip("\n") + + +_PACKAGE_DECL = re.compile(r"(?m)^\s*package\s+[\w.]+\s*;") + + +def _candidates(code: str) -> "list[tuple[str, str]]": + """Return (filename, source) wrappings to try, most likely first.""" + snippet = code.strip("\n") + + # A complete (or near-complete) compilation unit — has its own package + # and/or top-level type declaration — is compiled as-is so the required + # package-before-imports ordering is preserved. + if _PACKAGE_DECL.search(snippet) or _TYPE_DECL.search(snippet): + m = _PUBLIC_TYPE.search(snippet) + filename = f"{m.group(1)}.java" if m else "GlideDocExample.java" + return [(filename, snippet + "\n")] + + # Otherwise it is a fragment: hoist imports and try wrapping the rest as + # method-body statements, then as class members. + imports, body = _split_imports(snippet) + header = "\n".join(imports) + header = header + "\n\n" if header else "" + method = f"{header}class GlideDocExample {{\n void __run() throws Throwable {{\n{textwrap.indent(body, ' ')}\n }}\n}}\n" + members = f"{header}class GlideDocExample {{\n{textwrap.indent(body, ' ')}\n}}\n" + return [("GlideDocExample.java", method), ("GlideDocExample.java", members)] + + +def _syntax_errors(filename: str, source: str) -> "list[str]": + """Compile one candidate; return its syntax-category error messages.""" + with tempfile.TemporaryDirectory() as tmp: + path = os.path.join(tmp, filename) + with open(path, "w", encoding="utf-8") as fh: + fh.write(source) + proc = subprocess.run( + ["javac", "-proc:none", "-implicit:none", "-nowarn", "-d", tmp, path], + capture_output=True, + text=True, + ) + if proc.returncode == 0: + return [] + messages = [] + for line in proc.stderr.splitlines(): + low = line.lower() + if ": error:" in low and any(h in low for h in _SYNTAX_HINTS): + # Keep just the "error: ..." part, dropping the temp path prefix. + messages.append(line.split(": error:", 1)[1].strip()) + return messages + + +def check_one(source: str, code: str) -> "list[str]": + first: "list[str]" = [] + for filename, candidate in _candidates(code): + errors = _syntax_errors(filename, candidate) + if not errors: + return [] # syntactically valid under this wrapping + if not first: + first = errors + return first + + +if __name__ == "__main__": + _common.run("java", check_one=check_one, preflight=_preflight) diff --git a/scripts/validators/node.py b/scripts/validators/node.py new file mode 100644 index 00000000..e857bd38 --- /dev/null +++ b/scripts/validators/node.py @@ -0,0 +1,56 @@ +#!/usr/bin/env python3 +"""Syntax-validate Node (TypeScript / JavaScript) documentation examples. + +Delegates to ``ts_syntax_check.mjs``, which uses the TypeScript compiler's +``transpileModule`` to report syntax-only diagnostics. The whole batch is sent +to a single Node process to amortize interpreter / compiler startup. + +Requires Node.js and the ``typescript`` package (the workflow installs it with +``npm install typescript``). +""" + +from __future__ import annotations + +import json +import os +import subprocess +import sys + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +import _common # noqa: E402 + +_HELPER = os.path.join(os.path.dirname(os.path.abspath(__file__)), "ts_syntax_check.mjs") + + +def _preflight() -> None: + _common.require_tool("node", ["--version"]) + if not os.path.isfile(_HELPER): + print(f"Error: helper not found: {_HELPER}", file=sys.stderr) + sys.exit(1) + + +def check_all(examples: "dict[str, str]") -> "dict[str, list[str]]": + proc = subprocess.run( + ["node", _HELPER], + input=json.dumps(examples), + capture_output=True, + text=True, + ) + if proc.returncode != 0: + # Internal failure (e.g. typescript not installed) — fail clearly. + detail = (proc.stderr or proc.stdout).strip() + print(f"Error running Node syntax checker: {detail}", file=sys.stderr) + sys.exit(1) + try: + return json.loads(proc.stdout or "{}") + except json.JSONDecodeError: + print( + f"Error: Node syntax checker produced unparseable output: " + f"{proc.stdout[:200]!r}", + file=sys.stderr, + ) + sys.exit(1) + + +if __name__ == "__main__": + _common.run("node", check_all=check_all, preflight=_preflight) diff --git a/scripts/validators/php.py b/scripts/validators/php.py new file mode 100644 index 00000000..9394ca2a --- /dev/null +++ b/scripts/validators/php.py @@ -0,0 +1,56 @@ +#!/usr/bin/env python3 +"""Syntax-validate PHP documentation examples via ``php -l``. + +``php -l`` is a pure lint: it reports parse errors but does not resolve +classes/functions, so undefined symbols like ``$client`` and the missing +``valkey_glide`` extension are intentionally not flagged. A leading `` None: + _common.require_tool("php", ["--version"]) + + +def check_one(source: str, code: str) -> "list[str]": + php = code if code.lstrip().startswith(" on line N". + messages = [ + line.strip() + for line in (proc.stderr + proc.stdout).splitlines() + if "error" in line.lower() + ] + # Strip the temp-file path so the message is about the snippet, not the file. + messages = [m.replace(path, "") for m in messages] + return messages or [f"php -l failed (exit {proc.returncode})"] + + +if __name__ == "__main__": + _common.run("php", check_one=check_one, preflight=_preflight) diff --git a/scripts/validators/python.py b/scripts/validators/python.py new file mode 100644 index 00000000..667fd264 --- /dev/null +++ b/scripts/validators/python.py @@ -0,0 +1,36 @@ +#!/usr/bin/env python3 +"""Syntax-validate Python documentation examples. + +Each snippet is wrapped in an ``async def`` (so the top-level ``await`` calls +common in the docs are legal) and handed to the built-in :func:`compile`. This +catches syntax and indentation errors without needing the ``valkey-glide`` +package installed; undefined names like ``client`` are runtime concerns and are +intentionally not flagged. +""" + +from __future__ import annotations + +import os +import sys +import textwrap + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +import _common # noqa: E402 + + +def check_one(source: str, code: str) -> "list[str]": + code = code.strip("\n") + body = textwrap.indent(code, " ") if code.strip() else " pass" + # The trailing 'pass' guarantees a statement even if the body is all comments. + wrapped = f"async def __example__():\n{body}\n pass\n" + try: + compile(wrapped, source, "exec") + except SyntaxError as exc: + # exc.lineno is in wrapped coordinates; the wrapper adds one line on top. + line = max((exc.lineno or 1) - 1, 1) + return [f"{type(exc).__name__}: {exc.msg} (around line {line})"] + return [] + + +if __name__ == "__main__": + _common.run("python", check_one=check_one) diff --git a/scripts/validators/ts_syntax_check.mjs b/scripts/validators/ts_syntax_check.mjs new file mode 100644 index 00000000..ab6b9ac8 --- /dev/null +++ b/scripts/validators/ts_syntax_check.mjs @@ -0,0 +1,60 @@ +// Syntax-only validator for Node (TypeScript / JavaScript) doc examples. +// +// Reads a JSON object { ":": "" } from stdin and writes a +// JSON object { ":": ["", ...] } to stdout. Uses the +// TypeScript compiler's transpileModule, which reports *syntactic* diagnostics +// only — it does not type-check, so undefined identifiers like `client` and +// unresolved imports are intentionally not flagged. +// +// Requires the `typescript` package to be resolvable (the workflow installs it +// with `npm install typescript`). Exits non-zero only on an internal error; +// per-snippet syntax errors are reported in the output object. + +import fs from "fs"; +import { createRequire } from "module"; + +// Resolve `typescript` via CommonJS resolution (honors NODE_PATH and the +// node_modules walk-up), so the workflow can install it anywhere. +const require = createRequire(import.meta.url); +let ts; +try { + ts = require("typescript"); +} catch (err) { + process.stderr.write( + "Error: cannot load the 'typescript' package. Run `npm install typescript`.\n", + ); + process.exit(2); +} + +function readStdin() { + return fs.readFileSync(0, "utf8"); +} + +const examples = JSON.parse(readStdin()); +const compilerOptions = { + module: ts.ModuleKind.ESNext, + target: ts.ScriptTarget.ESNext, + // Keep it permissive: we only care about syntax, not module resolution. + isolatedModules: false, +}; + +const result = {}; +for (const [source, code] of Object.entries(examples)) { + const out = ts.transpileModule(code, { + reportDiagnostics: true, + compilerOptions, + }); + const messages = (out.diagnostics || []) + .filter((d) => d.category === ts.DiagnosticCategory.Error) + .map((d) => { + const text = ts.flattenDiagnosticMessageText(d.messageText, "\n"); + if (typeof d.start === "number" && d.file) { + const { line } = d.file.getLineAndCharacterOfPosition(d.start); + return `${text} (around line ${line + 1})`; + } + return text; + }); + result[source] = messages; +} + +process.stdout.write(JSON.stringify(result)); diff --git a/src/content/docs/commands/valkey-string.mdx b/src/content/docs/commands/valkey-string.mdx index 338bba21..0344bba9 100644 --- a/src/content/docs/commands/valkey-string.mdx +++ b/src/content/docs/commands/valkey-string.mdx @@ -135,7 +135,7 @@ Valkey strings store sequences of bytes, which may include text, serialized obje Here is an example of command implementation to outline arguments using [`DecoderOption`](https://github.com/valkey-io/valkey-glide/blob/79fdec50bbef4310cb12e46a69f14c18378afb44/node/src/BaseClient.ts#L295) and response type: - ```typescript + ```text public getdel( key: GlideString, options?: DecoderOption, diff --git a/src/content/docs/concepts/client-features/batch-commands.mdx b/src/content/docs/concepts/client-features/batch-commands.mdx index 633614ef..ac730dca 100644 --- a/src/content/docs/concepts/client-features/batch-commands.mdx +++ b/src/content/docs/concepts/client-features/batch-commands.mdx @@ -175,7 +175,7 @@ Determines how errors are surfaced when calling `exec(...)`. It is passed direct - ```python + ```text # Standalone Mode async def exec( self, @@ -195,7 +195,7 @@ Determines how errors are surfaced when calling `exec(...)`. It is passed direct - ```java + ```text // Standalone Mode CompletableFuture exec(Batch batch, boolean raiseOnError); CompletableFuture exec(Batch batch, boolean raiseOnError, BatchOptions options); @@ -206,7 +206,7 @@ Determines how errors are surfaced when calling `exec(...)`. It is passed direct - ```typescript + ```text // Standalone Mode public async exec( batch: Batch, @@ -224,7 +224,7 @@ Determines how errors are surfaced when calling `exec(...)`. It is passed direct - ```go + ```text // Standalone Mode Exec(ctx context.Context, batch pipeline.StandaloneBatch, raiseOnError bool) ([]any, error) ExecWithOptions(ctx context.Context, batch pipeline.StandaloneBatch, raiseOnError bool, options pipeline.StandaloneBatchOptions) ([]any, error) @@ -611,8 +611,7 @@ The ClusterBatchRetryStrategy configuration is only for non-atomic cluster batch WithTimeout(1 * time.Second). // 1-second timeout WithRetryStrategy(pipeline.NewClusterBatchRetryStrategy(). WithRetryConnectionError(true). - WithRetryServerError(true) - ). + WithRetryServerError(true)). WithRoute(config.RandomRoute) ``` diff --git a/src/content/docs/how-to/publish-and-subscribe-messages.mdx b/src/content/docs/how-to/publish-and-subscribe-messages.mdx index 005e57f5..dfc2aba6 100644 --- a/src/content/docs/how-to/publish-and-subscribe-messages.mdx +++ b/src/content/docs/how-to/publish-and-subscribe-messages.mdx @@ -353,7 +353,7 @@ For GLIDE versions prior to 2.3, or when your subscriptions are known at startup .subscription(EXACT, "ch2") .subscription(PATTERN, "chat*") .callback(callback, context) - .build() + .build(); GlideClientConfiguration config = GlideClientConfiguration.builder() .address(NodeAddress.builder().port(6379).build()) diff --git a/src/content/docs/how-to/security/iam-integration.mdx b/src/content/docs/how-to/security/iam-integration.mdx index 480cd65d..7d94e640 100644 --- a/src/content/docs/how-to/security/iam-integration.mdx +++ b/src/content/docs/how-to/security/iam-integration.mdx @@ -98,7 +98,6 @@ For GLIDE versions below 2.2, see the [guide](/how-to/security/iam-integration-u ```java - import java.util.Collections; import java.util.List; @@ -107,7 +106,7 @@ For GLIDE versions below 2.2, see the [guide](/how-to/security/iam-integration-u import glide.api.models.configuration.AwsIamConfig.ServiceType; import glide.api.models.configuration.GlideClusterClientConfiguration; import glide.api.models.configuration.NodeAddress; - ... + // ... List nodeList = Collections.singletonList( @@ -130,7 +129,7 @@ For GLIDE versions below 2.2, see the [guide](/how-to/security/iam-integration-u .build(); GlideClusterClient client = GlideClusterClient.createClient(config).get(); - ... + // ... ``` @@ -162,7 +161,7 @@ For GLIDE versions below 2.2, see the [guide](/how-to/security/iam-integration-u "github.com/valkey-io/valkey-glide/go/glide/api" "github.com/valkey-io/valkey-glide/go/glide/config" ) - ... + // ... // Configure IAM authentication // Automatically regenerates the token every 5 mins (default: 300 seconds) @@ -183,7 +182,7 @@ For GLIDE versions below 2.2, see the [guide](/how-to/security/iam-integration-u } client, err := api.NewGlideClusterClient(&clientConfig) - ... + // ... ``` @@ -211,7 +210,7 @@ For GLIDE versions below 2.2, see the [guide](/how-to/security/iam-integration-u $value = $client->get('key'); $client->close(); - ... + // ... ``` diff --git a/src/content/docs/how-to/send-batch-commands.mdx b/src/content/docs/how-to/send-batch-commands.mdx index 82b153a9..9505166f 100644 --- a/src/content/docs/how-to/send-batch-commands.mdx +++ b/src/content/docs/how-to/send-batch-commands.mdx @@ -56,7 +56,7 @@ A transaction is an all-or-nothing set of commands sent in a single request to V results = await client.exec(atomic_batch, raise_on_error=True, options=options) print("Atomic Batch Results:", results) # Expected output: Atomic Batch Results: ['OK', 'OK', 50, 50, '50'] - except RequestError as e: + except RequestError as e: print(f"Batch failed:", e) ```