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 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 description = self._spec['description']
187 doc_parts = [f'{self.api_name}: {self.annotation}']
188 if description or default_doc:
189 doc_parts.append('---')
191 doc_parts.append(description)
193 doc_parts.append(default_doc)
195 * {' '.join(doc_parts)}
200 def __init__(self, name: str, spec: Mapping[str, Any]) -> None:
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
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)
215 def signature(self) -> inspect.Signature:
217 inspect.Parameter('self', inspect.Parameter.POSITIONAL_OR_KEYWORD),
218 *self._required_params,
219 *self._optional_params,
222 returns = get_type_annotation(self._spec['response']['$ref'])
224 returns = 'dict[str, Any]'
225 return inspect.Signature(parameters, return_annotation=returns)
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)
238 def {self.name}{self.signature()}:
239 {to_docstring(''.join(doc_lines), 8)}
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
251 desc_fmt = _SCHEMA_PYDOC
253 description += desc_fmt.format(cls_name=cls_name)
255 f"class {name}(TypedDict, total=False):",
256 to_docstring(description, 4),
258 for field_name, field_spec in spec['properties'].items():
259 field_type = get_type_annotation(field_spec['type'])
261 subtype = field_spec['items']['$ref']
265 field_type += f"[{get_type_annotation(subtype)}]"
267 field_line = f" {field_name}: {field_type!r}"
269 field_line += f" = {field_spec['default']!r}"
272 lines.append(field_line)
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`."
278 lines.append(to_docstring(field_doc, 4))
280 return '\n'.join(lines)
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
288 Method(key, meth_spec)
289 for key, meth_spec in spec['methods'].items()
290 if key not in _ALIASED_METHODS
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))}
297 def parse_arguments(arglist: Optional[Sequence[str]]) -> argparse.Namespace:
298 parser = argparse.ArgumentParser()
300 '--output-file', '-O',
303 default=STDSTREAM_PATH,
304 help="""Path to write output. Specify `-` to use stdout (the default)
308 nargs=argparse.OPTIONAL,
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.
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'
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)
329 args.out_file = args.output_file.open('w', encoding='utf-8')
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):
338 f"error getting {args.discovery_url}: server returned {discovery_file.status}",
342 discovery_document = json.load(discovery_file)
344 to_docstring(_MODULE_PYDOC, indent=0),
346 sep='\n', file=args.out_file,
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)
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)
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
364 'description': docstring,
370 print(Method(name, method_spec).doc(), file=args.out_file)
372 args.out_file.close()
375 if __name__ == '__main__':