Skip to content

Commit 5953cc8

Browse files
committed
test: add offline check that README examples match the types API
Parse the Python blocks in README.md and docs/source/index.rst and check, without any API call, that every types.X(...) and types.X.method(...) call names an existing type, passes only model fields, and binds to the real signature. It reports the Video.from_file example fixed in the previous commit, which no existing test covered.
1 parent 3ac1780 commit 5953cc8

2 files changed

Lines changed: 193 additions & 0 deletions

File tree

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
# Copyright 2025 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+
Lines changed: 177 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,177 @@
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

Comments
 (0)