2 # Copyright (C) The Arvados Authors. All rights reserved.
4 # SPDX-License-Identifier: Apache-2.0
5 """discovery2pydoc - Build skeleton Python from the Arvados discovery document
7 This tool reads the Arvados discovery document and writes a Python source file
8 with classes and methods that correspond to the resources that
9 google-api-python-client builds dynamically. This source does not include any
10 implementation, but it does include real method signatures and documentation
11 strings, so it's useful as documentation for tools that read Python source,
12 including pydoc and pdoc.
14 If you run this tool with the path to a discovery document, it uses no
15 dependencies outside the Python standard library. If it needs to read
16 configuration to find the discovery document dynamically, it'll load the
17 `arvados` module to do that.
40 LOWERCASE = operator.methodcaller('lower')
41 NAME_KEY = operator.attrgetter('name')
42 STDSTREAM_PATH = pathlib.Path('-')
43 TITLECASE = operator.methodcaller('title')
45 _ALIASED_METHODS = frozenset([
50 _DEPRECATED_NOTICE = '''
53 This resource is deprecated in the Arvados API.
55 _DEPRECATED_RESOURCES = frozenset([
66 _DEPRECATED_SCHEMAS = frozenset([
67 *(name[:-1] for name in _DEPRECATED_RESOURCES),
68 *(f'{name[:-1]}List' for name in _DEPRECATED_RESOURCES),
73 This is the dictionary object returned when you call `{cls_name}s.list`.
74 If you just want to iterate all objects that match your search criteria,
75 consider using `arvados.util.keyset_list_all`.
76 If you work with this raw object, the keys of the dictionary are documented
77 below, along with their types. The `items` key maps to a list of matching
80 _MODULE_PYDOC = '''Arvados API client documentation skeleton
82 This module documents the methods and return types provided by the Arvados API
83 client. Start with `ArvadosAPIClient`, which documents the methods available
84 from the API client objects constructed by `arvados.api`. The implementation is
85 generated dynamically at runtime when the client object is built.
89 This is the dictionary object that represents a single {cls_name} in Arvados.
90 The keys of the dictionary are documented below, along with their types.
91 Not every key may appear in every dictionary returned by an API call.
92 Refer to the API documentation for details about how to retrieve specific keys
97 # Map the API's JavaScript-based type names to Python annotations.
98 # Some of these may disappear after Arvados issue #19795 is fixed.
102 # datetime fields are strings in ISO 8601 format.
104 'Hash': 'dict[str, Any]',
106 'object': 'dict[str, Any]',
111 def get_type_annotation(name: str) -> str:
112 return _TYPE_MAP.get(name, name)
114 def to_docstring(s: str, indent: int) -> str:
115 prefix = ' ' * indent
116 s = s.replace('"""', '""\"')
117 s = re.sub(r'(\n+)', r'\1' + prefix, s)
120 return f'{prefix}"""{s}\n{prefix}"""'
122 return f'{prefix}"""{s}"""'
124 def transform_name(s: str, sep: str, fix_part: Callable[[str], str]) -> str:
125 return sep.join(fix_part(part) for part in s.split('_'))
127 def classify_name(s: str) -> str:
128 return transform_name(s, '', TITLECASE)
130 def humanize_name(s: str) -> str:
131 return transform_name(s, ' ', LOWERCASE)
133 class Parameter(inspect.Parameter):
134 def __init__(self, name: str, spec: Mapping[str, Any]) -> None:
137 if keyword.iskeyword(name):
141 inspect.Parameter.KEYWORD_ONLY,
142 annotation=get_type_annotation(self._spec['type']),
143 # In normal Python the presence of a default tells you whether or
144 # not an argument is required. In the API the `required` flag tells
145 # us that, and defaults are specified inconsistently. Don't show
146 # defaults in the signature: it adds noise and makes things more
147 # confusing for the reader about what's required and what's
148 # optional. The docstring can explain in better detail, including
150 default=inspect.Parameter.empty,
153 def default_value(self) -> object:
155 src_value: str = self._spec['default']
158 if src_value == 'true':
160 elif src_value == 'false':
162 elif src_value.isdigit():
163 return int(src_value)
167 def is_required(self) -> bool:
168 return self._spec['required']
170 def doc(self) -> str:
171 default_value = self.default_value()
172 if default_value is None:
175 default_doc = f" Default {default_value!r}."
176 # If there is no description, use a zero-width space to help Markdown
177 # parsers retain the definition list structure.
178 description = self._spec['description'] or '\u200b'
180 {self.api_name}: {self.annotation}
181 : {description}{default_doc}
186 def __init__(self, name: str, spec: Mapping[str, Any]) -> None:
189 self._required_params = []
190 self._optional_params = []
191 for param_name, param_spec in spec['parameters'].items():
192 param = Parameter(param_name, param_spec)
193 if param.is_required():
194 param_list = self._required_params
196 param_list = self._optional_params
197 param_list.append(param)
198 self._required_params.sort(key=NAME_KEY)
199 self._optional_params.sort(key=NAME_KEY)
201 def signature(self) -> inspect.Signature:
203 inspect.Parameter('self', inspect.Parameter.POSITIONAL_ONLY),
204 *self._required_params,
205 *self._optional_params,
208 returns = get_type_annotation(self._spec['response']['$ref'])
210 returns = 'dict[str, Any]'
211 return inspect.Signature(parameters, return_annotation=returns)
213 def doc(self, doc_slice: slice=slice(None)) -> str:
214 doc_lines = self._spec['description'].splitlines(keepends=True)[doc_slice]
215 if not doc_lines[-1].endswith('\n'):
216 doc_lines.append('\n')
217 if self._required_params:
218 doc_lines.append("\nRequired parameters:\n")
219 doc_lines.extend(param.doc() for param in self._required_params)
220 if self._optional_params:
221 doc_lines.append("\nOptional parameters:\n")
222 doc_lines.extend(param.doc() for param in self._optional_params)
224 def {self.name}{self.signature()}:
225 {to_docstring(''.join(doc_lines), 8)}
229 def document_schema(name: str, spec: Mapping[str, Any]) -> str:
230 description = spec['description']
231 if name in _DEPRECATED_SCHEMAS:
232 description += _DEPRECATED_NOTICE
233 if name.endswith('List'):
234 desc_fmt = _LIST_PYDOC
237 desc_fmt = _SCHEMA_PYDOC
239 description += desc_fmt.format(cls_name=cls_name)
241 f"class {name}(TypedDict, total=False):",
242 to_docstring(description, 4),
244 for field_name, field_spec in spec['properties'].items():
245 field_type = get_type_annotation(field_spec['type'])
247 subtype = field_spec['items']['$ref']
251 field_type += f"[{get_type_annotation(subtype)}]"
253 field_line = f" {field_name}: {field_type!r}"
255 field_line += f" = {field_spec['default']!r}"
258 lines.append(field_line)
260 field_doc: str = field_spec.get('description', '')
261 if field_spec['type'] == 'datetime':
262 field_doc += "\n\nString in ISO 8601 datetime format. Pass it to `ciso8601.parse_datetime` to build a `datetime.datetime`."
264 lines.append(to_docstring(field_doc, 4))
266 return '\n'.join(lines)
268 def document_resource(name: str, spec: Mapping[str, Any]) -> str:
269 class_name = classify_name(name)
270 docstring = f"Methods to query and manipulate Arvados {humanize_name(name)}"
271 if class_name in _DEPRECATED_RESOURCES:
272 docstring += _DEPRECATED_NOTICE
274 Method(key, meth_spec)
275 for key, meth_spec in spec['methods'].items()
276 if key not in _ALIASED_METHODS
278 return f'''class {class_name}:
279 {to_docstring(docstring, 4)}
280 {''.join(method.doc(slice(1)) for method in sorted(methods, key=NAME_KEY))}
283 def parse_arguments(arglist: Optional[Sequence[str]]) -> argparse.Namespace:
284 parser = argparse.ArgumentParser()
286 '--output-file', '-O',
289 default=STDSTREAM_PATH,
290 help="""Path to write output. Specify `-` to use stdout (the default)
294 nargs=argparse.OPTIONAL,
296 help="""URL or file path of a discovery document to load.
297 Specify `-` to use stdin.
298 If not provided, retrieved dynamically from Arvados client configuration.
300 args = parser.parse_args(arglist)
301 if args.discovery_url is None:
302 from arvados.api import api_kwargs_from_config
303 discovery_fmt = api_kwargs_from_config('v1')['discoveryServiceUrl']
304 args.discovery_url = discovery_fmt.format(api='arvados', apiVersion='v1')
305 elif args.discovery_url == '-':
306 args.discovery_url = 'file:///dev/stdin'
308 parts = urllib.parse.urlsplit(args.discovery_url)
309 if not (parts.scheme or parts.netloc):
310 args.discovery_url = pathlib.Path(args.discovery_url).resolve().as_uri()
311 if args.output_file == STDSTREAM_PATH:
312 args.out_file = sys.stdout
314 args.out_file = args.output_file.open('w')
317 def main(arglist: Optional[Sequence[str]]=None) -> int:
318 args = parse_arguments(arglist)
319 with urllib.request.urlopen(args.discovery_url) as discovery_file:
320 if not (discovery_file.status is None or 200 <= discovery_file.status < 300):
322 f"error getting {args.discovery_url}: server returned {discovery_file.status}",
326 discovery_document = json.load(discovery_file)
328 to_docstring(_MODULE_PYDOC, indent=0),
329 '''from typing import Any, TypedDict''',
330 sep='\n\n', end='\n\n', file=args.out_file,
333 schemas = sorted(discovery_document['schemas'].items())
334 for name, schema_spec in schemas:
335 print(document_schema(name, schema_spec), file=args.out_file)
337 resources = sorted(discovery_document['resources'].items())
338 for name, resource_spec in resources:
339 print(document_resource(name, resource_spec), file=args.out_file)
341 print('''class ArvadosAPIClient:''', file=args.out_file)
342 for name, _ in resources:
343 class_name = classify_name(name)
344 docstring = f"Return an instance of `{class_name}` to call methods via this client"
345 if class_name in _DEPRECATED_RESOURCES:
346 docstring += _DEPRECATED_NOTICE
348 'description': docstring,
354 print(Method(name, method_spec).doc(), file=args.out_file)
358 if __name__ == '__main__':