diff --git a/.github/workflows/check-go-examples.yml b/.github/workflows/check-go-examples.yml new file mode 100644 index 00000000..177dbe24 --- /dev/null +++ b/.github/workflows/check-go-examples.yml @@ -0,0 +1,33 @@ +name: Check Go Examples + +# 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: + branches: [main] + workflow_dispatch: + +permissions: + contents: read + +jobs: + check-go-examples: + runs-on: ubuntu-latest + steps: + - name: Checkout valkey-glide-docs + uses: actions/checkout@v4 + + - name: Setup Go + uses: actions/setup-go@v5 + with: + go-version: "stable" + + - name: Setup Python + uses: actions/setup-python@v5 + with: + python-version: "3.x" + + - 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 new file mode 100644 index 00000000..5d2b7acd --- /dev/null +++ b/.github/workflows/check-java-examples.yml @@ -0,0 +1,35 @@ +name: Check Java Examples + +# 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: + branches: [main] + workflow_dispatch: + +permissions: + contents: read + +jobs: + check-java-examples: + runs-on: ubuntu-latest + steps: + - name: Checkout valkey-glide-docs + uses: actions/checkout@v4 + + - name: Setup Java + uses: actions/setup-java@v4 + with: + distribution: "temurin" + java-version: "17" + + - name: Setup Python + uses: actions/setup-python@v5 + with: + python-version: "3.x" + + - 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 new file mode 100644 index 00000000..b5ed2746 --- /dev/null +++ b/.github/workflows/check-node-examples.yml @@ -0,0 +1,41 @@ +name: Check Node Examples + +# 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: + branches: [main] + workflow_dispatch: + +permissions: + contents: read + +jobs: + check-node-examples: + runs-on: ubuntu-latest + steps: + - name: Checkout valkey-glide-docs + uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: 20 + + - 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 + uses: actions/setup-python@v5 + with: + python-version: "3.x" + + - 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 new file mode 100644 index 00000000..bb324369 --- /dev/null +++ b/.github/workflows/check-php-examples.yml @@ -0,0 +1,33 @@ +name: Check PHP Examples + +# 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: + branches: [main] + workflow_dispatch: + +permissions: + contents: read + +jobs: + check-php-examples: + runs-on: ubuntu-latest + steps: + - name: Checkout valkey-glide-docs + uses: actions/checkout@v4 + + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + php-version: "8.2" + + - name: Setup Python + uses: actions/setup-python@v5 + with: + python-version: "3.x" + + - 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 new file mode 100644 index 00000000..3e6c3d62 --- /dev/null +++ b/.github/workflows/check-python-examples.yml @@ -0,0 +1,28 @@ +name: Check Python Examples + +# 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: + branches: [main] + workflow_dispatch: + +permissions: + contents: read + +jobs: + check-python-examples: + runs-on: ubuntu-latest + steps: + - name: Checkout valkey-glide-docs + uses: actions/checkout@v4 + + - name: Setup Python + uses: actions/setup-python@v5 + with: + python-version: "3.x" + + - name: Validate Python examples + run: python scripts/check_examples.py --language python diff --git a/scripts/README.md b/scripts/README.md new file mode 100644 index 00000000..59785296 --- /dev/null +++ b/scripts/README.md @@ -0,0 +1,126 @@ +# Documentation example validation + +These scripts check that the code examples embedded in the docs +(`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) +``` +```` + +> 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. + +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 + +```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 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 +``` + +## The validator contract + +`check_examples.py` invokes any validator the same way (the convention comes +from the upstream C# validator): + +```bash +python --examples [extra args...] +``` + +- `` 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. + +The bundled validators dedent each snippet and ignore unknown forwarded +arguments, so they share this signature. + +## Adding / changing a language + +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_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..fe135072 --- /dev/null +++ b/scripts/check_examples.py @@ -0,0 +1,183 @@ +#!/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 that language's validate_examples.py " + "(the bundled scripts/validators/.py by default)." + ) + ) + parser.add_argument( + "--language", + required=True, + choices=sorted(LANGUAGE_FENCES), + help="Client language whose code examples to validate.", + ) + parser.add_argument( + "--validator", + 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", + 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:] + + # 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): + 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..1cb219e2 --- /dev/null +++ b/scripts/extract_examples.py @@ -0,0 +1,168 @@ +#!/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. + + 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)) + 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() 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) ```