Merge branch 'jszlenk/create_new_subproject' refs #21937
[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 reference documentation
81
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.
88
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
92 by the API server.
93 '''
94 _SCHEMA_PYDOC = '''
95
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.
102 '''
103
104 _MODULE_PRELUDE = '''
105 import googleapiclient.discovery
106 import googleapiclient.http
107 import httplib2
108 import sys
109 from typing import Any, Dict, Generic, List, Optional, TypeVar
110 if sys.version_info < (3, 8):
111     from typing_extensions import TypedDict
112 else:
113     from typing import TypedDict
114
115 # ST represents an API response type
116 ST = TypeVar('ST', bound=TypedDict)
117 '''
118 _REQUEST_CLASS = '''
119 class ArvadosAPIRequest(googleapiclient.http.HttpRequest, Generic[ST]):
120     """Generic API request object
121
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
126     `ArvadosAPIRequest`.
127     """
128
129     def execute(self, http: Optional[httplib2.Http]=None, num_retries: int=0) -> ST:
130         """Execute this request and return the response
131
132         Arguments:
133
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.
137
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.
143         """
144
145 '''
146
147 # Annotation represents a valid Python type annotation. Future development
148 # could expand this to include other valid types like `type`.
149 Annotation = str
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.
153     'Array': 'List',
154     'array': 'List',
155     'boolean': 'bool',
156     # datetime fields are strings in ISO 8601 format.
157     'datetime': 'str',
158     'Hash': 'Dict[str, Any]',
159     'integer': 'int',
160     'object': 'Dict[str, Any]',
161     'string': 'str',
162     'text': 'str',
163 }
164
165 def get_type_annotation(name: str) -> str:
166     return _TYPE_MAP.get(name, name)
167
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)
172     s = s.strip()
173     if '\n' in s:
174         return f'{prefix}"""{s}\n{prefix}"""'
175     else:
176         return f'{prefix}"""{s}"""'
177
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('_'))
180
181 def classify_name(s: str) -> str:
182     return transform_name(s, '', TITLECASE)
183
184 def humanize_name(s: str) -> str:
185     return transform_name(s, ' ', LOWERCASE)
186
187 class Parameter(inspect.Parameter):
188     def __init__(self, name: str, spec: Mapping[str, Any]) -> None:
189         self.api_name = name
190         self._spec = spec
191         if keyword.iskeyword(name):
192             name += '_'
193         super().__init__(
194             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
203             # the default value.
204             default=inspect.Parameter.empty,
205         )
206
207     def default_value(self) -> object:
208         try:
209             src_value: str = self._spec['default']
210         except KeyError:
211             return None
212         if src_value == 'true':
213             return True
214         elif src_value == 'false':
215             return False
216         elif src_value.isdigit():
217             return int(src_value)
218         else:
219             return src_value
220
221     def is_required(self) -> bool:
222         return self._spec['required']
223
224     def doc(self) -> str:
225         default_value = self.default_value()
226         if default_value is None:
227             default_doc = ''
228         else:
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('---')
234             if description:
235                 doc_parts.append(description)
236             if default_doc:
237                 doc_parts.append(default_doc)
238         return f'''
239 * {' '.join(doc_parts)}
240 '''
241
242
243 class Method:
244     def __init__(
245             self,
246             name: str,
247             spec: Mapping[str, Any],
248             annotate: Callable[[Annotation], Annotation]=str,
249     ) -> None:
250         self.name = name
251         self._spec = spec
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
259             else:
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)
264
265     def signature(self) -> inspect.Signature:
266         parameters = [
267             inspect.Parameter('self', inspect.Parameter.POSITIONAL_OR_KEYWORD),
268             *self._required_params,
269             *self._optional_params,
270         ]
271         try:
272             returns = get_type_annotation(self._spec['response']['$ref'])
273         except KeyError:
274             returns = 'Dict[str, Any]'
275         returns = self._annotate(returns)
276         return inspect.Signature(parameters, return_annotation=returns)
277
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)
288         return f'''
289     def {self.name}{self.signature()}:
290 {to_docstring(''.join(doc_lines), 8)}
291 '''
292
293
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
300         cls_name = name[:-4]
301     else:
302         desc_fmt = _SCHEMA_PYDOC
303         cls_name = name
304     description += desc_fmt.format(cls_name=cls_name)
305     lines = [
306         f"class {name}(TypedDict, total=False):",
307         to_docstring(description, 4),
308     ]
309     for field_name, field_spec in spec['properties'].items():
310         field_type = get_type_annotation(field_spec['type'])
311         try:
312             subtype = field_spec['items']['$ref']
313         except KeyError:
314             pass
315         else:
316             field_type += f"[{get_type_annotation(subtype)}]"
317
318         field_line = f"    {field_name}: {field_type!r}"
319         try:
320             field_line += f" = {field_spec['default']!r}"
321         except KeyError:
322             pass
323         lines.append(field_line)
324
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`."
328         if field_doc:
329             lines.append(to_docstring(field_doc, 4))
330     lines.append('\n')
331     return '\n'.join(lines)
332
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
338     methods = [
339         Method(key, meth_spec, 'ArvadosAPIRequest[{}]'.format)
340         for key, meth_spec in spec['methods'].items()
341         if key not in _ALIASED_METHODS
342     ]
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))}
346 '''
347
348 def parse_arguments(arglist: Optional[Sequence[str]]) -> argparse.Namespace:
349     parser = argparse.ArgumentParser()
350     parser.add_argument(
351         '--output-file', '-O',
352         type=pathlib.Path,
353         metavar='PATH',
354         default=STDSTREAM_PATH,
355         help="""Path to write output. Specify `-` to use stdout (the default)
356 """)
357     parser.add_argument(
358         'discovery_url',
359         nargs=argparse.OPTIONAL,
360         metavar='URL',
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.
364 """)
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'
372     else:
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)
379     else:
380         args.out_file = args.output_file.open('w', encoding='utf-8')
381     return args
382
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):
388             print(
389                 f"error getting {args.discovery_url}: server returned {discovery_file.status}",
390                 file=sys.stderr,
391             )
392             return os.EX_IOERR
393         discovery_document = json.load(discovery_file)
394     print(
395         to_docstring(_MODULE_PYDOC, indent=0),
396         _MODULE_PRELUDE,
397         sep='\n', file=args.out_file,
398     )
399
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)
403
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)
407
408     print(
409         _REQUEST_CLASS,
410         '''class ArvadosAPIClient(googleapiclient.discovery.Resource):''',
411         sep='\n', file=args.out_file,
412     )
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
418         method_spec = {
419             'description': docstring,
420             'parameters': {},
421             'response': {
422                 '$ref': class_name,
423             },
424         }
425         print(Method(name, method_spec).doc(), file=args.out_file)
426
427     args.out_file.close()
428     return os.EX_OK
429
430 if __name__ == '__main__':
431     sys.exit(main())