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 = '''
52 .. WARNING:: Deprecated
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 reference documentation
82 This module provides reference documentation for the interface of the
83 Arvados API client, including method signatures and type information for
84 returned objects. However, the functions in `arvados.api` will return
85 different classes at runtime that are generated dynamically from the Arvados
86 API discovery document. The classes in this module do not have any
87 implementation, and you should not instantiate them in your code.
89 If you're just starting out, `ArvadosAPIClient` documents the methods
90 available from the client object. From there, you can follow the trail into
91 resource methods, request objects, and finally the data dictionaries returned
96 This is the dictionary object that represents a single {cls_name} in Arvados
97 and is returned by most `{cls_name}s` methods.
98 The keys of the dictionary are documented below, along with their types.
99 Not every key may appear in every dictionary returned by an API call.
100 When a method doesn't return all the data, you can use its `select` parameter
101 to list the specific keys you need. Refer to the API documentation for details.
104 _MODULE_PRELUDE = '''
105 import googleapiclient.discovery
106 import googleapiclient.http
109 from typing import Any, Dict, Generic, List, Optional, TypeVar
110 if sys.version_info < (3, 8):
111 from typing_extensions import TypedDict
113 from typing import TypedDict
115 # ST represents an API response type
116 ST = TypeVar('ST', bound=TypedDict)
119 class ArvadosAPIRequest(googleapiclient.http.HttpRequest, Generic[ST]):
120 """Generic API request object
122 When you call an API method in the Arvados Python SDK, it returns a
123 request object. You usually call `execute()` on this object to submit the
124 request to your Arvados API server and retrieve the response. `execute()`
125 will return the type of object annotated in the subscript of
129 def execute(self, http: Optional[httplib2.Http]=None, num_retries: int=0) -> ST:
130 """Execute this request and return the response
134 * http: httplib2.Http | None --- The HTTP client object to use to
135 execute the request. If not specified, uses the HTTP client object
136 created with the API client object.
138 * num_retries: int --- The maximum number of times to retry this
139 request if the server returns a retryable failure. The API client
140 object also has a maximum number of retries specified when it is
141 instantiated (see `arvados.api.api_client`). This request is run
142 with the larger of that number and this argument. Default 0.
147 # Annotation represents a valid Python type annotation. Future development
148 # could expand this to include other valid types like `type`.
150 _TYPE_MAP: Mapping[str, Annotation] = {
151 # Map the API's JavaScript-based type names to Python annotations.
152 # Some of these may disappear after Arvados issue #19795 is fixed.
156 # datetime fields are strings in ISO 8601 format.
158 'Hash': 'Dict[str, Any]',
160 'object': 'Dict[str, Any]',
165 def get_type_annotation(name: str) -> str:
166 return _TYPE_MAP.get(name, name)
168 def to_docstring(s: str, indent: int) -> str:
169 prefix = ' ' * indent
170 s = s.replace('"""', '""\"')
171 s = re.sub(r'(\n+)', r'\1' + prefix, s)
174 return f'{prefix}"""{s}\n{prefix}"""'
176 return f'{prefix}"""{s}"""'
178 def transform_name(s: str, sep: str, fix_part: Callable[[str], str]) -> str:
179 return sep.join(fix_part(part) for part in s.split('_'))
181 def classify_name(s: str) -> str:
182 return transform_name(s, '', TITLECASE)
184 def humanize_name(s: str) -> str:
185 return transform_name(s, ' ', LOWERCASE)
187 class Parameter(inspect.Parameter):
188 def __init__(self, name: str, spec: Mapping[str, Any]) -> None:
191 if keyword.iskeyword(name):
195 inspect.Parameter.KEYWORD_ONLY,
196 annotation=get_type_annotation(self._spec['type']),
197 # In normal Python the presence of a default tells you whether or
198 # not an argument is required. In the API the `required` flag tells
199 # us that, and defaults are specified inconsistently. Don't show
200 # defaults in the signature: it adds noise and makes things more
201 # confusing for the reader about what's required and what's
202 # optional. The docstring can explain in better detail, including
204 default=inspect.Parameter.empty,
207 def default_value(self) -> object:
209 src_value: str = self._spec['default']
212 if src_value == 'true':
214 elif src_value == 'false':
216 elif src_value.isdigit():
217 return int(src_value)
221 def is_required(self) -> bool:
222 return self._spec['required']
224 def doc(self) -> str:
225 default_value = self.default_value()
226 if default_value is None:
229 default_doc = f"Default {default_value!r}."
230 description = self._spec['description']
231 doc_parts = [f'{self.api_name}: {self.annotation}']
232 if description or default_doc:
233 doc_parts.append('---')
235 doc_parts.append(description)
237 doc_parts.append(default_doc)
239 * {' '.join(doc_parts)}
247 spec: Mapping[str, Any],
248 annotate: Callable[[Annotation], Annotation]=str,
252 self._annotate = annotate
253 self._required_params = []
254 self._optional_params = []
255 for param_name, param_spec in spec['parameters'].items():
256 param = Parameter(param_name, param_spec)
257 if param.is_required():
258 param_list = self._required_params
260 param_list = self._optional_params
261 param_list.append(param)
262 self._required_params.sort(key=NAME_KEY)
263 self._optional_params.sort(key=NAME_KEY)
265 def signature(self) -> inspect.Signature:
267 inspect.Parameter('self', inspect.Parameter.POSITIONAL_OR_KEYWORD),
268 *self._required_params,
269 *self._optional_params,
272 returns = get_type_annotation(self._spec['response']['$ref'])
274 returns = 'Dict[str, Any]'
275 returns = self._annotate(returns)
276 return inspect.Signature(parameters, return_annotation=returns)
278 def doc(self, doc_slice: slice=slice(None)) -> str:
279 doc_lines = self._spec['description'].splitlines(keepends=True)[doc_slice]
280 if not doc_lines[-1].endswith('\n'):
281 doc_lines.append('\n')
282 if self._required_params:
283 doc_lines.append("\nRequired parameters:\n")
284 doc_lines.extend(param.doc() for param in self._required_params)
285 if self._optional_params:
286 doc_lines.append("\nOptional parameters:\n")
287 doc_lines.extend(param.doc() for param in self._optional_params)
289 def {self.name}{self.signature()}:
290 {to_docstring(''.join(doc_lines), 8)}
294 def document_schema(name: str, spec: Mapping[str, Any]) -> str:
295 description = spec['description']
296 if name in _DEPRECATED_SCHEMAS:
297 description += _DEPRECATED_NOTICE
298 if name.endswith('List'):
299 desc_fmt = _LIST_PYDOC
302 desc_fmt = _SCHEMA_PYDOC
304 description += desc_fmt.format(cls_name=cls_name)
306 f"class {name}(TypedDict, total=False):",
307 to_docstring(description, 4),
309 for field_name, field_spec in spec['properties'].items():
310 field_type = get_type_annotation(field_spec['type'])
312 subtype = field_spec['items']['$ref']
316 field_type += f"[{get_type_annotation(subtype)}]"
318 field_line = f" {field_name}: {field_type!r}"
320 field_line += f" = {field_spec['default']!r}"
323 lines.append(field_line)
325 field_doc: str = field_spec.get('description', '')
326 if field_spec['type'] == 'datetime':
327 field_doc += "\n\nString in ISO 8601 datetime format. Pass it to `ciso8601.parse_datetime` to build a `datetime.datetime`."
329 lines.append(to_docstring(field_doc, 4))
331 return '\n'.join(lines)
333 def document_resource(name: str, spec: Mapping[str, Any]) -> str:
334 class_name = classify_name(name)
335 docstring = f"Methods to query and manipulate Arvados {humanize_name(name)}"
336 if class_name in _DEPRECATED_RESOURCES:
337 docstring += _DEPRECATED_NOTICE
339 Method(key, meth_spec, 'ArvadosAPIRequest[{}]'.format)
340 for key, meth_spec in spec['methods'].items()
341 if key not in _ALIASED_METHODS
343 return f'''class {class_name}:
344 {to_docstring(docstring, 4)}
345 {''.join(method.doc(slice(1)) for method in sorted(methods, key=NAME_KEY))}
348 def parse_arguments(arglist: Optional[Sequence[str]]) -> argparse.Namespace:
349 parser = argparse.ArgumentParser()
351 '--output-file', '-O',
354 default=STDSTREAM_PATH,
355 help="""Path to write output. Specify `-` to use stdout (the default)
359 nargs=argparse.OPTIONAL,
361 help="""URL or file path of a discovery document to load.
362 Specify `-` to use stdin.
363 If not provided, retrieved dynamically from Arvados client configuration.
365 args = parser.parse_args(arglist)
366 if args.discovery_url is None:
367 from arvados.api import api_kwargs_from_config
368 discovery_fmt = api_kwargs_from_config('v1')['discoveryServiceUrl']
369 args.discovery_url = discovery_fmt.format(api='arvados', apiVersion='v1')
370 elif args.discovery_url == '-':
371 args.discovery_url = 'file:///dev/stdin'
373 parts = urllib.parse.urlsplit(args.discovery_url)
374 if not (parts.scheme or parts.netloc):
375 args.discovery_url = pathlib.Path(args.discovery_url).resolve().as_uri()
376 # Our output is Python source, so it should be UTF-8 regardless of locale.
377 if args.output_file == STDSTREAM_PATH:
378 args.out_file = open(sys.stdout.fileno(), 'w', encoding='utf-8', closefd=False)
380 args.out_file = args.output_file.open('w', encoding='utf-8')
383 def main(arglist: Optional[Sequence[str]]=None) -> int:
384 args = parse_arguments(arglist)
385 with urllib.request.urlopen(args.discovery_url) as discovery_file:
386 status = discovery_file.getcode()
387 if not (status is None or 200 <= status < 300):
389 f"error getting {args.discovery_url}: server returned {discovery_file.status}",
393 discovery_document = json.load(discovery_file)
395 to_docstring(_MODULE_PYDOC, indent=0),
397 sep='\n', file=args.out_file,
400 schemas = sorted(discovery_document['schemas'].items())
401 for name, schema_spec in schemas:
402 print(document_schema(name, schema_spec), file=args.out_file)
404 resources = sorted(discovery_document['resources'].items())
405 for name, resource_spec in resources:
406 print(document_resource(name, resource_spec), file=args.out_file)
410 '''class ArvadosAPIClient(googleapiclient.discovery.Resource):''',
411 sep='\n', file=args.out_file,
413 for name, _ in resources:
414 class_name = classify_name(name)
415 docstring = f"Return an instance of `{class_name}` to call methods via this client"
416 if class_name in _DEPRECATED_RESOURCES:
417 docstring += _DEPRECATED_NOTICE
419 'description': docstring,
425 print(Method(name, method_spec).doc(), file=args.out_file)
427 args.out_file.close()
430 if __name__ == '__main__':