9f7f87d988e64534f196ff087a366be21dd6e9ff
[arvados.git] / sdk / python / discovery2pydoc.py
1 #!/usr/bin/env python3
2 # Copyright (C) The Arvados Authors. All rights reserved.
3 #
4 # SPDX-License-Identifier: Apache-2.0
5 """discovery2pydoc - Build skeleton Python from the Arvados discovery document
6
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.
13
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.
18 """
19
20 import argparse
21 import inspect
22 import json
23 import keyword
24 import operator
25 import os
26 import pathlib
27 import re
28 import sys
29 import urllib.parse
30 import urllib.request
31
32 from typing import (
33     Any,
34     Callable,
35     Mapping,
36     Optional,
37     Sequence,
38 )
39
40 LOWERCASE = operator.methodcaller('lower')
41 NAME_KEY = operator.attrgetter('name')
42 STDSTREAM_PATH = pathlib.Path('-')
43 TITLECASE = operator.methodcaller('title')
44
45 _ALIASED_METHODS = frozenset([
46     'destroy',
47     'index',
48     'show',
49 ])
50 _DEPRECATED_NOTICE = '''
51
52 !!! deprecated
53     This resource is deprecated in the Arvados API.
54 '''
55 _DEPRECATED_RESOURCES = frozenset([
56     'Humans',
57     'JobTasks',
58     'Jobs',
59     'KeepDisks',
60     'Nodes',
61     'PipelineInstances',
62     'PipelineTemplates',
63     'Specimens'
64     'Traits',
65 ])
66 _DEPRECATED_SCHEMAS = frozenset([
67     *(name[:-1] for name in _DEPRECATED_RESOURCES),
68     *(f'{name[:-1]}List' for name in _DEPRECATED_RESOURCES),
69 ])
70
71 _LIST_PYDOC = '''
72
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
78 `{cls_name}` objects.
79 '''
80 _MODULE_PYDOC = '''Arvados API client documentation skeleton
81
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.
86 '''
87 _SCHEMA_PYDOC = '''
88
89 This is the dictionary object that represents a single {cls_name} in Arvados
90 and is returned by most `{cls_name}s` methods.
91 The keys of the dictionary are documented below, along with their types.
92 Not every key may appear in every dictionary returned by an API call.
93 When a method doesn't return all the data, you can use its `select` parameter
94 to list the specific keys you need. Refer to the API documentation for details.
95 '''
96
97 _MODULE_PRELUDE = '''
98 import sys
99 if sys.version_info < (3, 8):
100     from typing import Any
101     from typing_extensions import TypedDict
102 else:
103     from typing import Any, TypedDict
104 '''
105
106 _TYPE_MAP = {
107     # Map the API's JavaScript-based type names to Python annotations.
108     # Some of these may disappear after Arvados issue #19795 is fixed.
109     'Array': 'list',
110     'array': 'list',
111     'boolean': 'bool',
112     # datetime fields are strings in ISO 8601 format.
113     'datetime': 'str',
114     'Hash': 'dict[str, Any]',
115     'integer': 'int',
116     'object': 'dict[str, Any]',
117     'string': 'str',
118     'text': 'str',
119 }
120
121 def get_type_annotation(name: str) -> str:
122     return _TYPE_MAP.get(name, name)
123
124 def to_docstring(s: str, indent: int) -> str:
125     prefix = ' ' * indent
126     s = s.replace('"""', '""\"')
127     s = re.sub(r'(\n+)', r'\1' + prefix, s)
128     s = s.strip()
129     if '\n' in s:
130         return f'{prefix}"""{s}\n{prefix}"""'
131     else:
132         return f'{prefix}"""{s}"""'
133
134 def transform_name(s: str, sep: str, fix_part: Callable[[str], str]) -> str:
135     return sep.join(fix_part(part) for part in s.split('_'))
136
137 def classify_name(s: str) -> str:
138     return transform_name(s, '', TITLECASE)
139
140 def humanize_name(s: str) -> str:
141     return transform_name(s, ' ', LOWERCASE)
142
143 class Parameter(inspect.Parameter):
144     def __init__(self, name: str, spec: Mapping[str, Any]) -> None:
145         self.api_name = name
146         self._spec = spec
147         if keyword.iskeyword(name):
148             name += '_'
149         super().__init__(
150             name,
151             inspect.Parameter.KEYWORD_ONLY,
152             annotation=get_type_annotation(self._spec['type']),
153             # In normal Python the presence of a default tells you whether or
154             # not an argument is required. In the API the `required` flag tells
155             # us that, and defaults are specified inconsistently. Don't show
156             # defaults in the signature: it adds noise and makes things more
157             # confusing for the reader about what's required and what's
158             # optional. The docstring can explain in better detail, including
159             # the default value.
160             default=inspect.Parameter.empty,
161         )
162
163     def default_value(self) -> object:
164         try:
165             src_value: str = self._spec['default']
166         except KeyError:
167             return None
168         if src_value == 'true':
169             return True
170         elif src_value == 'false':
171             return False
172         elif src_value.isdigit():
173             return int(src_value)
174         else:
175             return src_value
176
177     def is_required(self) -> bool:
178         return self._spec['required']
179
180     def doc(self) -> str:
181         default_value = self.default_value()
182         if default_value is None:
183             default_doc = ''
184         else:
185             default_doc = f" Default {default_value!r}."
186         # If there is no description, use a zero-width space to help Markdown
187         # parsers retain the definition list structure.
188         description = self._spec['description'] or '\u200b'
189         return f'''
190 {self.api_name}: {self.annotation}
191 : {description}{default_doc}
192 '''
193
194
195 class Method:
196     def __init__(self, name: str, spec: Mapping[str, Any]) -> None:
197         self.name = name
198         self._spec = spec
199         self._required_params = []
200         self._optional_params = []
201         for param_name, param_spec in spec['parameters'].items():
202             param = Parameter(param_name, param_spec)
203             if param.is_required():
204                 param_list = self._required_params
205             else:
206                 param_list = self._optional_params
207             param_list.append(param)
208         self._required_params.sort(key=NAME_KEY)
209         self._optional_params.sort(key=NAME_KEY)
210
211     def signature(self) -> inspect.Signature:
212         parameters = [
213             inspect.Parameter('self', inspect.Parameter.POSITIONAL_OR_KEYWORD),
214             *self._required_params,
215             *self._optional_params,
216         ]
217         try:
218             returns = get_type_annotation(self._spec['response']['$ref'])
219         except KeyError:
220             returns = 'dict[str, Any]'
221         return inspect.Signature(parameters, return_annotation=returns)
222
223     def doc(self, doc_slice: slice=slice(None)) -> str:
224         doc_lines = self._spec['description'].splitlines(keepends=True)[doc_slice]
225         if not doc_lines[-1].endswith('\n'):
226             doc_lines.append('\n')
227         if self._required_params:
228             doc_lines.append("\nRequired parameters:\n")
229             doc_lines.extend(param.doc() for param in self._required_params)
230         if self._optional_params:
231             doc_lines.append("\nOptional parameters:\n")
232             doc_lines.extend(param.doc() for param in self._optional_params)
233         return f'''
234     def {self.name}{self.signature()}:
235 {to_docstring(''.join(doc_lines), 8)}
236 '''
237
238
239 def document_schema(name: str, spec: Mapping[str, Any]) -> str:
240     description = spec['description']
241     if name in _DEPRECATED_SCHEMAS:
242         description += _DEPRECATED_NOTICE
243     if name.endswith('List'):
244         desc_fmt = _LIST_PYDOC
245         cls_name = name[:-4]
246     else:
247         desc_fmt = _SCHEMA_PYDOC
248         cls_name = name
249     description += desc_fmt.format(cls_name=cls_name)
250     lines = [
251         f"class {name}(TypedDict, total=False):",
252         to_docstring(description, 4),
253     ]
254     for field_name, field_spec in spec['properties'].items():
255         field_type = get_type_annotation(field_spec['type'])
256         try:
257             subtype = field_spec['items']['$ref']
258         except KeyError:
259             pass
260         else:
261             field_type += f"[{get_type_annotation(subtype)}]"
262
263         field_line = f"    {field_name}: {field_type!r}"
264         try:
265             field_line += f" = {field_spec['default']!r}"
266         except KeyError:
267             pass
268         lines.append(field_line)
269
270         field_doc: str = field_spec.get('description', '')
271         if field_spec['type'] == 'datetime':
272             field_doc += "\n\nString in ISO 8601 datetime format. Pass it to `ciso8601.parse_datetime` to build a `datetime.datetime`."
273         if field_doc:
274             lines.append(to_docstring(field_doc, 4))
275     lines.append('\n')
276     return '\n'.join(lines)
277
278 def document_resource(name: str, spec: Mapping[str, Any]) -> str:
279     class_name = classify_name(name)
280     docstring = f"Methods to query and manipulate Arvados {humanize_name(name)}"
281     if class_name in _DEPRECATED_RESOURCES:
282         docstring += _DEPRECATED_NOTICE
283     methods = [
284         Method(key, meth_spec)
285         for key, meth_spec in spec['methods'].items()
286         if key not in _ALIASED_METHODS
287     ]
288     return f'''class {class_name}:
289 {to_docstring(docstring, 4)}
290 {''.join(method.doc(slice(1)) for method in sorted(methods, key=NAME_KEY))}
291 '''
292
293 def parse_arguments(arglist: Optional[Sequence[str]]) -> argparse.Namespace:
294     parser = argparse.ArgumentParser()
295     parser.add_argument(
296         '--output-file', '-O',
297         type=pathlib.Path,
298         metavar='PATH',
299         default=STDSTREAM_PATH,
300         help="""Path to write output. Specify `-` to use stdout (the default)
301 """)
302     parser.add_argument(
303         'discovery_url',
304         nargs=argparse.OPTIONAL,
305         metavar='URL',
306         help="""URL or file path of a discovery document to load.
307 Specify `-` to use stdin.
308 If not provided, retrieved dynamically from Arvados client configuration.
309 """)
310     args = parser.parse_args(arglist)
311     if args.discovery_url is None:
312         from arvados.api import api_kwargs_from_config
313         discovery_fmt = api_kwargs_from_config('v1')['discoveryServiceUrl']
314         args.discovery_url = discovery_fmt.format(api='arvados', apiVersion='v1')
315     elif args.discovery_url == '-':
316         args.discovery_url = 'file:///dev/stdin'
317     else:
318         parts = urllib.parse.urlsplit(args.discovery_url)
319         if not (parts.scheme or parts.netloc):
320             args.discovery_url = pathlib.Path(args.discovery_url).resolve().as_uri()
321     # Our output is Python source, so it should be UTF-8 regardless of locale.
322     if args.output_file == STDSTREAM_PATH:
323         args.out_file = open(sys.stdout.fileno(), 'w', encoding='utf-8', closefd=False)
324     else:
325         args.out_file = args.output_file.open('w', encoding='utf-8')
326     return args
327
328 def main(arglist: Optional[Sequence[str]]=None) -> int:
329     args = parse_arguments(arglist)
330     with urllib.request.urlopen(args.discovery_url) as discovery_file:
331         status = discovery_file.getcode()
332         if not (status is None or 200 <= status < 300):
333             print(
334                 f"error getting {args.discovery_url}: server returned {discovery_file.status}",
335                 file=sys.stderr,
336             )
337             return os.EX_IOERR
338         discovery_document = json.load(discovery_file)
339     print(
340         to_docstring(_MODULE_PYDOC, indent=0),
341         _MODULE_PRELUDE,
342         sep='\n', file=args.out_file,
343     )
344
345     schemas = sorted(discovery_document['schemas'].items())
346     for name, schema_spec in schemas:
347         print(document_schema(name, schema_spec), file=args.out_file)
348
349     resources = sorted(discovery_document['resources'].items())
350     for name, resource_spec in resources:
351         print(document_resource(name, resource_spec), file=args.out_file)
352
353     print('''class ArvadosAPIClient:''', file=args.out_file)
354     for name, _ in resources:
355         class_name = classify_name(name)
356         docstring = f"Return an instance of `{class_name}` to call methods via this client"
357         if class_name in _DEPRECATED_RESOURCES:
358             docstring += _DEPRECATED_NOTICE
359         method_spec = {
360             'description': docstring,
361             'parameters': {},
362             'response': {
363                 '$ref': class_name,
364             },
365         }
366         print(Method(name, method_spec).doc(), file=args.out_file)
367
368     args.out_file.close()
369     return os.EX_OK
370
371 if __name__ == '__main__':
372     sys.exit(main())