|
| 1 | +# Copyright 2026 Google LLC |
| 2 | +# |
| 3 | +# Licensed under the Apache License, Version 2.0 (the "License"); |
| 4 | +# you may not use this file except in compliance with the License. |
| 5 | +# You may obtain a copy of the License at |
| 6 | +# |
| 7 | +# http://www.apache.org/licenses/LICENSE-2.0 |
| 8 | +# |
| 9 | +# Unless required by applicable law or agreed to in writing, software |
| 10 | +# distributed under the License is distributed on an "AS IS" BASIS, |
| 11 | +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
| 12 | +# See the License for the specific language governing permissions and |
| 13 | +# limitations under the License. |
| 14 | +# |
| 15 | + |
| 16 | +"""Offline checks that the README examples still match the public SDK. |
| 17 | +
|
| 18 | +Running the README needs credentials and network access, so these tests only |
| 19 | +parse its Python blocks (and the copy in docs/source/index.rst) and check, with |
| 20 | +no API call, that every `types.X(...)` and `types.X.method(...)` call is valid |
| 21 | +for the installed SDK: the name exists, keyword arguments are model fields, and |
| 22 | +method calls bind to the real signature. |
| 23 | +""" |
| 24 | + |
| 25 | +import ast |
| 26 | +import inspect |
| 27 | +import pathlib |
| 28 | +import re |
| 29 | +import textwrap |
| 30 | +from typing import Any, Callable |
| 31 | +import unittest |
| 32 | + |
| 33 | +import pydantic |
| 34 | + |
| 35 | +from ... import types |
| 36 | + |
| 37 | +_REPO_ROOT = pathlib.Path(__file__).resolve().parents[4] |
| 38 | +_MARKDOWN_PYTHON_BLOCK = re.compile(r'```python\n(.*?)```', re.DOTALL) |
| 39 | +# An indented reStructuredText block: blank lines or lines with 4+ spaces. |
| 40 | +_RST_PYTHON_BLOCK = re.compile( |
| 41 | + r'^\.\. code:: python\n\n((?:(?:[ ]{4}.*)?\n)+)', re.MULTILINE |
| 42 | +) |
| 43 | + |
| 44 | + |
| 45 | +def _markdown_blocks(text: str) -> list[str]: |
| 46 | + return _MARKDOWN_PYTHON_BLOCK.findall(text) |
| 47 | + |
| 48 | + |
| 49 | +def _rst_blocks(text: str) -> list[str]: |
| 50 | + return [textwrap.dedent(b) for b in _RST_PYTHON_BLOCK.findall(text)] |
| 51 | + |
| 52 | + |
| 53 | +def _types_path(func: ast.expr) -> tuple[str, ...]: |
| 54 | + """Returns ('Video', 'from_file') for `types.Video.from_file`, else ().""" |
| 55 | + names = [] |
| 56 | + while isinstance(func, ast.Attribute): |
| 57 | + names.append(func.attr) |
| 58 | + func = func.value |
| 59 | + if isinstance(func, ast.Name) and func.id == 'types' and names: |
| 60 | + return tuple(reversed(names)) |
| 61 | + return () |
| 62 | + |
| 63 | + |
| 64 | +def _check_call(name: str, obj: Any, node: ast.Call) -> list[str]: |
| 65 | + if inspect.isclass(obj) and issubclass(obj, pydantic.BaseModel): |
| 66 | + fields = set(obj.model_fields) |
| 67 | + fields.update(f.alias for f in obj.model_fields.values() if f.alias) |
| 68 | + return [ |
| 69 | + f'{name}() has no field {kw.arg!r}' |
| 70 | + for kw in node.keywords |
| 71 | + if kw.arg not in fields |
| 72 | + ] |
| 73 | + if not callable(obj): |
| 74 | + return [] |
| 75 | + try: |
| 76 | + signature = inspect.signature(obj) |
| 77 | + except (TypeError, ValueError): |
| 78 | + return [] |
| 79 | + try: |
| 80 | + signature.bind( |
| 81 | + *[None] * len(node.args), **{kw.arg: None for kw in node.keywords} |
| 82 | + ) |
| 83 | + except TypeError as e: |
| 84 | + return [f'{name}(): {e}'] |
| 85 | + return [] |
| 86 | + |
| 87 | + |
| 88 | +def _find_problems(source: str) -> tuple[int, list[str]]: |
| 89 | + """Returns (number of calls checked, problems found) for one code block.""" |
| 90 | + try: |
| 91 | + tree = ast.parse(source) |
| 92 | + except SyntaxError: |
| 93 | + return 0, [] # Fragments such as a bare list of contents cannot be parsed. |
| 94 | + checked = 0 |
| 95 | + problems = [] |
| 96 | + for node in ast.walk(tree): |
| 97 | + if not isinstance(node, ast.Call): |
| 98 | + continue |
| 99 | + if any(isinstance(a, ast.Starred) for a in node.args) or any( |
| 100 | + kw.arg is None for kw in node.keywords |
| 101 | + ): |
| 102 | + continue |
| 103 | + path = _types_path(node.func) |
| 104 | + if not path: |
| 105 | + continue |
| 106 | + name = '.'.join(('types',) + path) |
| 107 | + obj: Any = types |
| 108 | + for attribute in path: |
| 109 | + obj = getattr(obj, attribute, None) |
| 110 | + if obj is None: |
| 111 | + problems.append(f'{name} does not exist') |
| 112 | + break |
| 113 | + else: |
| 114 | + checked += 1 |
| 115 | + problems.extend(_check_call(name, obj, node)) |
| 116 | + return checked, problems |
| 117 | + |
| 118 | + |
| 119 | +class ReadmeExamplesTest(unittest.TestCase): |
| 120 | + |
| 121 | + def _assert_examples_match_sdk( |
| 122 | + self, relative_path: str, extract: Callable[[str], list[str]] |
| 123 | + ) -> None: |
| 124 | + path = _REPO_ROOT / relative_path |
| 125 | + if not path.is_file(): |
| 126 | + self.skipTest(f'{relative_path} is not part of this checkout.') |
| 127 | + checked = 0 |
| 128 | + problems = [] |
| 129 | + for block in extract(path.read_text(encoding='utf-8')): |
| 130 | + block_checked, block_problems = _find_problems(block) |
| 131 | + checked += block_checked |
| 132 | + problems.extend(block_problems) |
| 133 | + self.assertGreater(checked, 0, f'No examples found in {relative_path}.') |
| 134 | + self.assertEqual(problems, [], f'Stale examples in {relative_path}.') |
| 135 | + |
| 136 | + def test_readme_examples_match_sdk(self): |
| 137 | + self._assert_examples_match_sdk('README.md', _markdown_blocks) |
| 138 | + |
| 139 | + def test_docs_index_examples_match_sdk(self): |
| 140 | + self._assert_examples_match_sdk('docs/source/index.rst', _rst_blocks) |
| 141 | + |
| 142 | + def test_accepts_valid_calls(self): |
| 143 | + source = ( |
| 144 | + 'types.Video.from_file(location="a.mp4")\n' |
| 145 | + 'types.Part.from_text(text="hi")\n' |
| 146 | + 'types.GenerateContentConfig(temperature=0.1)\n' |
| 147 | + ) |
| 148 | + self.assertEqual(_find_problems(source), (3, [])) |
| 149 | + |
| 150 | + def test_flags_positional_argument_to_keyword_only_classmethod(self): |
| 151 | + _, problems = _find_problems('types.Video.from_file("a.mp4")') |
| 152 | + self.assertEqual(len(problems), 1) |
| 153 | + self.assertIn('types.Video.from_file', problems[0]) |
| 154 | + |
| 155 | + def test_flags_unknown_model_field(self): |
| 156 | + _, problems = _find_problems('types.GenerateContentConfig(no_such_field=1)') |
| 157 | + self.assertEqual( |
| 158 | + problems, ["types.GenerateContentConfig() has no field 'no_such_field'"] |
| 159 | + ) |
| 160 | + |
| 161 | + def test_flags_unknown_type(self): |
| 162 | + _, problems = _find_problems('types.NoSuchType(x=1)') |
| 163 | + self.assertEqual(problems, ['types.NoSuchType does not exist']) |
| 164 | + |
| 165 | + def test_skips_fragments_that_do_not_parse(self): |
| 166 | + self.assertEqual(_find_problems('[\n types.Part(text="a"),\n'), (0, [])) |
| 167 | + |
| 168 | + def test_extracts_rst_code_blocks(self): |
| 169 | + text = ( |
| 170 | + 'Title\n=====\n\n.. code:: python\n\n x = types.Part()\n\n' |
| 171 | + ' y = 1\n\nNot code.\n' |
| 172 | + ) |
| 173 | + self.assertEqual(_rst_blocks(text), ['x = types.Part()\n\ny = 1\n\n']) |
| 174 | + |
| 175 | + |
| 176 | +if __name__ == '__main__': |
| 177 | + unittest.main() |
0 commit comments