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 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.
99 if sys.version_info < (3, 8):
100 from typing import Any
101 from typing_extensions import TypedDict
103 from typing import Any, TypedDict
107 # Map the API's JavaScript-based type names to Python annotations.
108 # Some of these may disappear after Arvados issue #19795 is fixed.
112 # datetime fields are strings in ISO 8601 format.
114 'Hash': 'dict[str, Any]',
116 'object': 'dict[str, Any]',
121 def get_type_annotation(name: str) -> str:
122 return _TYPE_MAP.get(name, name)
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)
130 return f'{prefix}"""{s}\n{prefix}"""'
132 return f'{prefix}"""{s}"""'
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('_'))
137 def classify_name(s: str) -> str:
138 return transform_name(s, '', TITLECASE)
140 def humanize_name(s: str) -> str:
141 return transform_name(s, ' ', LOWERCASE)
143 class Parameter(inspect.Parameter):
144 def __init__(self, name: str, spec: Mapping[str, Any]) -> None:
147 if keyword.iskeyword(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
160 default=inspect.Parameter.empty,
163 def default_value(self) -> object:
165 src_value: str = self._spec['default']
168 if src_value == 'true':
170 elif src_value == 'false':
172 elif src_value.isdigit():
173 return int(src_value)
177 def is_required(self) -> bool:
178 return self._spec['required']
180 def doc(self) -> str:
181 default_value = self.default_value()
182 if default_value is None:
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'
190 {self.api_name}: {self.annotation}
191 : {description}{default_doc}
196 def __init__(self, name: str, spec: Mapping[str, Any]) -> None:
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
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)
211 def signature(self) -> inspect.Signature:
213 inspect.Parameter('self', inspect.Parameter.POSITIONAL_OR_KEYWORD),
214 *self._required_params,
215 *self._optional_params,
218 returns = get_type_annotation(self._spec['response']['$ref'])
220 returns = 'dict[str, Any]'
221 return inspect.Signature(parameters, return_annotation=returns)
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)
234 def {self.name}{self.signature()}:
235 {to_docstring(''.join(doc_lines), 8)}
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
247 desc_fmt = _SCHEMA_PYDOC
249 description += desc_fmt.format(cls_name=cls_name)
251 f"class {name}(TypedDict, total=False):",
252 to_docstring(description, 4),
254 for field_name, field_spec in spec['properties'].items():
255 field_type = get_type_annotation(field_spec['type'])
257 subtype = field_spec['items']['$ref']
261 field_type += f"[{get_type_annotation(subtype)}]"
263 field_line = f" {field_name}: {field_type!r}"
265 field_line += f" = {field_spec['default']!r}"
268 lines.append(field_line)
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`."
274 lines.append(to_docstring(field_doc, 4))
276 return '\n'.join(lines)
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
284 Method(key, meth_spec)
285 for key, meth_spec in spec['methods'].items()
286 if key not in _ALIASED_METHODS
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))}
293 def parse_arguments(arglist: Optional[Sequence[str]]) -> argparse.Namespace:
294 parser = argparse.ArgumentParser()
296 '--output-file', '-O',
299 default=STDSTREAM_PATH,
300 help="""Path to write output. Specify `-` to use stdout (the default)
304 nargs=argparse.OPTIONAL,
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.
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'
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)
325 args.out_file = args.output_file.open('w', encoding='utf-8')
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):
334 f"error getting {args.discovery_url}: server returned {discovery_file.status}",
338 discovery_document = json.load(discovery_file)
340 to_docstring(_MODULE_PYDOC, indent=0),
342 sep='\n', file=args.out_file,
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)
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)
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
360 'description': docstring,
366 print(Method(name, method_spec).doc(), file=args.out_file)
368 args.out_file.close()
371 if __name__ == '__main__':