diff --git a/Taskfile.yml b/Taskfile.yml index 1eb41d6ac53..b30fdad1e38 100644 --- a/Taskfile.yml +++ b/Taskfile.yml @@ -400,7 +400,7 @@ tasks: # --- Testing --- test: - desc: Run unit and acceptance tests + desc: Run unit, acceptance, and doctests # Run the legs as sequential cmds (not parallel deps) so the concatenating # cat can be a `defer`, which runs even when a leg fails. When the legs are # deps, the cat in cmds is skipped on failure, so test-output.json is never @@ -412,11 +412,19 @@ tasks: - test-output.json cmds: - defer: cat test-output-unit.json test-output-acc.json > test-output.json + - task: doctest - task: test-unit - task: test-acc vars: ACCEPTANCE_TEST_FILTER: "{{.ACCEPTANCE_TEST_FILTER}}" + doctest: + desc: Run doctests for all Python files in the repo + sources: + - "**/*.py" + cmds: + - 'uv run -p ">=3.11" --no-project python tools/run_doctests.py' + test-unit: desc: Run unit tests across all Go modules (root, tools, codegen) sources: diff --git a/acceptance/bin/json_in_json_normalize.py b/acceptance/bin/json_in_json_normalize.py index dca1fd12616..57ab3361652 100755 --- a/acceptance/bin/json_in_json_normalize.py +++ b/acceptance/bin/json_in_json_normalize.py @@ -24,7 +24,7 @@ def normalize_json_field(line, fields): Normalize JSON in a field if the field name matches. >>> normalize_json_field(' "foo": "{\\"a\\": 1}",', {'foo'}) - ' "foo": "{\\"a\\":1}",' + ' "foo": "{\\"a\\":1}",\n' >>> normalize_json_field(' "foo": "{\\"a\\": 1}",', {'bar'}) ' "foo": "{\\"a\\": 1}",' >>> normalize_json_field(' "other": "plain string",', {'foo'}) diff --git a/tools/run_doctests.py b/tools/run_doctests.py new file mode 100644 index 00000000000..b4f77d94ae9 --- /dev/null +++ b/tools/run_doctests.py @@ -0,0 +1,45 @@ +#!/usr/bin/env python3 +"""Run doctests for every Python file in the repo that has them. + +Most Python files here are scripts with no doctests, and many can't even be +imported standalone (SDK-generated modules, notebooks using injected globals, +scripts with import-time side effects). So we git grep for files that actually +contain a `>>>` prompt and only run the doctest runner on those; a file with +none produces no tests anyway. --untracked also scans new files not yet +committed while still honoring .gitignore, so generated/ignored files stay out. +Each file runs in its own subprocess with cwd set to the file's directory, so +sibling imports (e.g. gron.py's `from print_requests import ...`) resolve as +they do when the script is invoked normally. +""" + +import subprocess +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parent.parent + + +def main() -> int: + out = subprocess.check_output( + ["git", "grep", "-l", "--untracked", "--no-color", "-F", ">>>", "--", "*.py"], + cwd=ROOT, + text=True, + ) + files = sorted(ROOT / line for line in out.splitlines()) + failed = [] + for path in files: + result = subprocess.run( + [sys.executable, "-m", "doctest", path.name], + cwd=path.parent, + ) + if result.returncode != 0: + failed.append(path.relative_to(ROOT)) + + print(f"\n{len(files)} files with doctests, {len(failed)} failed") + for rel in failed: + print(f"FAIL {rel}") + return 1 if failed else 0 + + +if __name__ == "__main__": + sys.exit(main())