Merge branch 'main' from workbench2.git
[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 .. WARNING:: 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         description = self._spec['description']
187         doc_parts = [f'{self.api_name}: {self.annotation}']
188         if description or default_doc:
189             doc_parts.append('---')
190             if description:
191                 doc_parts.append(description)
192             if default_doc:
193                 doc_parts.append(default_doc)
194         return f'''
195 * {' '.join(doc_parts)}
196 '''
197
198
199 class Method:
200     def __init__(self, name: str, spec: Mapping[str, Any]) -> None:
201         self.name = name
202         self._spec = spec
203         self._required_params = []
204         self._optional_params = []
205         for param_name, param_spec in spec['parameters'].items():
206             param = Parameter(param_name, param_spec)
207             if param.is_required():
208                 param_list = self._required_params
209             else:
210                 param_list = self._optional_params
211             param_list.append(param)
212         self._required_params.sort(key=NAME_KEY)
213         self._optional_params.sort(key=NAME_KEY)
214
215     def signature(self) -> inspect.Signature:
216         parameters = [
217             inspect.Parameter('self', inspect.Parameter.POSITIONAL_OR_KEYWORD),
218             *self._required_params,
219             *self._optional_params,
220         ]
221         try:
222             returns = get_type_annotation(self._spec['response']['$ref'])
223         except KeyError:
224             returns = 'dict[str, Any]'
225         return inspect.Signature(parameters, return_annotation=returns)
226
227     def doc(self, doc_slice: slice=slice(None)) -> str:
228         doc_lines = self._spec['description'].splitlines(keepends=True)[doc_slice]
229         if not doc_lines[-1].endswith('\n'):
230             doc_lines.append('\n')
231         if self._required_params:
232             doc_lines.append("\nRequired parameters:\n")
233             doc_lines.extend(param.doc() for param in self._required_params)
234         if self._optional_params:
235             doc_lines.append("\nOptional parameters:\n")
236             doc_lines.extend(param.doc() for param in self._optional_params)
237         return f'''
238     def {self.name}{self.signature()}:
239 {to_docstring(''.join(doc_lines), 8)}
240 '''
241
242
243 def document_schema(name: str, spec: Mapping[str, Any]) -> str:
244     description = spec['description']
245     if name in _DEPRECATED_SCHEMAS:
246         description += _DEPRECATED_NOTICE
247     if name.endswith('List'):
248         desc_fmt = _LIST_PYDOC
249         cls_name = name[:-4]
250     else:
251         desc_fmt = _SCHEMA_PYDOC
252         cls_name = name
253     description += desc_fmt.format(cls_name=cls_name)
254     lines = [
255         f"class {name}(TypedDict, total=False):",
256         to_docstring(description, 4),
257     ]
258     for field_name, field_spec in spec['properties'].items():
259         field_type = get_type_annotation(field_spec['type'])
260         try:
261             subtype = field_spec['items']['$ref']
262         except KeyError:
263             pass
264         else:
265             field_type += f"[{get_type_annotation(subtype)}]"
266
267         field_line = f"    {field_name}: {field_type!r}"
268         try:
269             field_line += f" = {field_spec['default']!r}"
270         except KeyError:
271             pass
272         lines.append(field_line)
273
274         field_doc: str = field_spec.get('description', '')
275         if field_spec['type'] == 'datetime':
276             field_doc += "\n\nString in ISO 8601 datetime format. Pass it to `ciso8601.parse_datetime` to build a `datetime.datetime`."
277         if field_doc:
278             lines.append(to_docstring(field_doc, 4))
279     lines.append('\n')
280     return '\n'.join(lines)
281
282 def document_resource(name: str, spec: Mapping[str, Any]) -> str:
283     class_name = classify_name(name)
284     docstring = f"Methods to query and manipulate Arvados {humanize_name(name)}"
285     if class_name in _DEPRECATED_RESOURCES:
286         docstring += _DEPRECATED_NOTICE
287     methods = [
288         Method(key, meth_spec)
289         for key, meth_spec in spec['methods'].items()
290         if key not in _ALIASED_METHODS
291     ]
292     return f'''class {class_name}:
293 {to_docstring(docstring, 4)}
294 {''.join(method.doc(slice(1)) for method in sorted(methods, key=NAME_KEY))}
295 '''
296
297 def parse_arguments(arglist: Optional[Sequence[str]]) -> argparse.Namespace:
298     parser = argparse.ArgumentParser()
299     parser.add_argument(
300         '--output-file', '-O',
301         type=pathlib.Path,
302         metavar='PATH',
303         default=STDSTREAM_PATH,
304         help="""Path to write output. Specify `-` to use stdout (the default)
305 """)
306     parser.add_argument(
307         'discovery_url',
308         nargs=argparse.OPTIONAL,
309         metavar='URL',
310         help="""URL or file path of a discovery document to load.
311 Specify `-` to use stdin.
312 If not provided, retrieved dynamically from Arvados client configuration.
313 """)
314     args = parser.parse_args(arglist)
315     if args.discovery_url is None:
316         from arvados.api import api_kwargs_from_config
317         discovery_fmt = api_kwargs_from_config('v1')['discoveryServiceUrl']
318         args.discovery_url = discovery_fmt.format(api='arvados', apiVersion='v1')
319     elif args.discovery_url == '-':
320         args.discovery_url = 'file:///dev/stdin'
321     else:
322         parts = urllib.parse.urlsplit(args.discovery_url)
323         if not (parts.scheme or parts.netloc):
324             args.discovery_url = pathlib.Path(args.discovery_url).resolve().as_uri()
325     # Our output is Python source, so it should be UTF-8 regardless of locale.
326     if args.output_file == STDSTREAM_PATH:
327         args.out_file = open(sys.stdout.fileno(), 'w', encoding='utf-8', closefd=False)
328     else:
329         args.out_file = args.output_file.open('w', encoding='utf-8')
330     return args
331
332 def main(arglist: Optional[Sequence[str]]=None) -> int:
333     args = parse_arguments(arglist)
334     with urllib.request.urlopen(args.discovery_url) as discovery_file:
335         status = discovery_file.getcode()
336         if not (status is None or 200 <= status < 300):
337             print(
338                 f"error getting {args.discovery_url}: server returned {discovery_file.status}",
339                 file=sys.stderr,
340             )
341             return os.EX_IOERR
342         discovery_document = json.load(discovery_file)
343     print(
344         to_docstring(_MODULE_PYDOC, indent=0),
345         _MODULE_PRELUDE,
346         sep='\n', file=args.out_file,
347     )
348
349     schemas = sorted(discovery_document['schemas'].items())
350     for name, schema_spec in schemas:
351         print(document_schema(name, schema_spec), file=args.out_file)
352
353     resources = sorted(discovery_document['resources'].items())
354     for name, resource_spec in resources:
355         print(document_resource(name, resource_spec), file=args.out_file)
356
357     print('''class ArvadosAPIClient:''', file=args.out_file)
358     for name, _ in resources:
359         class_name = classify_name(name)
360         docstring = f"Return an instance of `{class_name}` to call methods via this client"
361         if class_name in _DEPRECATED_RESOURCES:
362             docstring += _DEPRECATED_NOTICE
363         method_spec = {
364             'description': docstring,
365             'parameters': {},
366             'response': {
367                 '$ref': class_name,
368             },
369         }
370         print(Method(name, method_spec).doc(), file=args.out_file)
371
372     args.out_file.close()
373     return os.EX_OK
374
375 if __name__ == '__main__':
376     sys.exit(main())