1#!/usr/bin/python 2# 3# Copyright 2014 Google Inc. All Rights Reserved. 4# 5# Licensed under the Apache License, Version 2.0 (the "License"); 6# you may not use this file except in compliance with the License. 7# You may obtain a copy of the License at 8# 9# http://www.apache.org/licenses/LICENSE-2.0 10# 11# Unless required by applicable law or agreed to in writing, software 12# distributed under the License is distributed on an "AS IS" BASIS, 13# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 14# See the License for the specific language governing permissions and 15# limitations under the License. 16 17"""Create documentation for generate API surfaces. 18 19Command-line tool that creates documentation for all APIs listed in discovery. 20The documentation is generated from a combination of the discovery document and 21the generated API surface itself. 22""" 23from __future__ import print_function 24 25__author__ = "jcgregorio@google.com (Joe Gregorio)" 26 27import argparse 28import collections 29import json 30import pathlib 31import re 32import string 33import sys 34 35from googleapiclient.discovery import DISCOVERY_URI 36from googleapiclient.discovery import build 37from googleapiclient.discovery import build_from_document 38from googleapiclient.http import build_http 39 40import uritemplate 41 42DISCOVERY_DOC_DIR = ( 43 pathlib.Path(__file__).parent.resolve() 44 / "googleapiclient" 45 / "discovery_cache" 46 / "documents" 47) 48 49CSS = """<style> 50 51body, h1, h2, h3, div, span, p, pre, a { 52 margin: 0; 53 padding: 0; 54 border: 0; 55 font-weight: inherit; 56 font-style: inherit; 57 font-size: 100%; 58 font-family: inherit; 59 vertical-align: baseline; 60} 61 62body { 63 font-size: 13px; 64 padding: 1em; 65} 66 67h1 { 68 font-size: 26px; 69 margin-bottom: 1em; 70} 71 72h2 { 73 font-size: 24px; 74 margin-bottom: 1em; 75} 76 77h3 { 78 font-size: 20px; 79 margin-bottom: 1em; 80 margin-top: 1em; 81} 82 83pre, code { 84 line-height: 1.5; 85 font-family: Monaco, 'DejaVu Sans Mono', 'Bitstream Vera Sans Mono', 'Lucida Console', monospace; 86} 87 88pre { 89 margin-top: 0.5em; 90} 91 92h1, h2, h3, p { 93 font-family: Arial, sans serif; 94} 95 96h1, h2, h3 { 97 border-bottom: solid #CCC 1px; 98} 99 100.toc_element { 101 margin-top: 0.5em; 102} 103 104.firstline { 105 margin-left: 2 em; 106} 107 108.method { 109 margin-top: 1em; 110 border: solid 1px #CCC; 111 padding: 1em; 112 background: #EEE; 113} 114 115.details { 116 font-weight: bold; 117 font-size: 14px; 118} 119 120</style> 121""" 122 123METHOD_TEMPLATE = """<div class="method"> 124 <code class="details" id="$name">$name($params)</code> 125 <pre>$doc</pre> 126</div> 127""" 128 129COLLECTION_LINK = """<p class="toc_element"> 130 <code><a href="$href">$name()</a></code> 131</p> 132<p class="firstline">Returns the $name Resource.</p> 133""" 134 135METHOD_LINK = """<p class="toc_element"> 136 <code><a href="#$name">$name($params)</a></code></p> 137<p class="firstline">$firstline</p>""" 138 139BASE = pathlib.Path(__file__).parent.resolve() / "docs" / "dyn" 140 141DIRECTORY_URI = "https://www.googleapis.com/discovery/v1/apis" 142 143parser = argparse.ArgumentParser(description=__doc__) 144 145parser.add_argument( 146 "--discovery_uri_template", 147 default=DISCOVERY_URI, 148 help="URI Template for discovery.", 149) 150 151parser.add_argument( 152 "--discovery_uri", 153 default="", 154 help=( 155 "URI of discovery document. If supplied then only " 156 "this API will be documented." 157 ), 158) 159 160parser.add_argument( 161 "--directory_uri", 162 default=DIRECTORY_URI, 163 help=("URI of directory document. Unused if --discovery_uri" " is supplied."), 164) 165 166parser.add_argument( 167 "--dest", default=BASE, help="Directory name to write documents into." 168) 169 170 171def safe_version(version): 172 """Create a safe version of the verion string. 173 174 Needed so that we can distinguish between versions 175 and sub-collections in URIs. I.e. we don't want 176 adsense_v1.1 to refer to the '1' collection in the v1 177 version of the adsense api. 178 179 Args: 180 version: string, The version string. 181 Returns: 182 The string with '.' replaced with '_'. 183 """ 184 185 return version.replace(".", "_") 186 187 188def unsafe_version(version): 189 """Undoes what safe_version() does. 190 191 See safe_version() for the details. 192 193 194 Args: 195 version: string, The safe version string. 196 Returns: 197 The string with '_' replaced with '.'. 198 """ 199 200 return version.replace("_", ".") 201 202 203def method_params(doc): 204 """Document the parameters of a method. 205 206 Args: 207 doc: string, The method's docstring. 208 209 Returns: 210 The method signature as a string. 211 """ 212 doclines = doc.splitlines() 213 if "Args:" in doclines: 214 begin = doclines.index("Args:") 215 if "Returns:" in doclines[begin + 1 :]: 216 end = doclines.index("Returns:", begin) 217 args = doclines[begin + 1 : end] 218 else: 219 args = doclines[begin + 1 :] 220 221 parameters = [] 222 sorted_parameters = [] 223 pname = None 224 desc = "" 225 226 def add_param(pname, desc): 227 if pname is None: 228 return 229 if "(required)" not in desc: 230 pname = pname + "=None" 231 parameters.append(pname) 232 else: 233 # required params should be put straight into sorted_parameters 234 # to maintain order for positional args 235 sorted_parameters.append(pname) 236 237 for line in args: 238 m = re.search(r"^\s+([a-zA-Z0-9_]+): (.*)", line) 239 if m is None: 240 desc += line 241 continue 242 add_param(pname, desc) 243 pname = m.group(1) 244 desc = m.group(2) 245 add_param(pname, desc) 246 sorted_parameters.extend(sorted(parameters)) 247 sorted_parameters = ", ".join(sorted_parameters) 248 else: 249 sorted_parameters = "" 250 return sorted_parameters 251 252 253def method(name, doc): 254 """Documents an individual method. 255 256 Args: 257 name: string, Name of the method. 258 doc: string, The methods docstring. 259 """ 260 import html 261 262 params = method_params(doc) 263 doc = html.escape(doc) 264 return string.Template(METHOD_TEMPLATE).substitute( 265 name=name, params=params, doc=doc 266 ) 267 268 269def breadcrumbs(path, root_discovery): 270 """Create the breadcrumb trail to this page of documentation. 271 272 Args: 273 path: string, Dot separated name of the resource. 274 root_discovery: Deserialized discovery document. 275 276 Returns: 277 HTML with links to each of the parent resources of this resource. 278 """ 279 parts = path.split(".") 280 281 crumbs = [] 282 accumulated = [] 283 284 for i, p in enumerate(parts): 285 prefix = ".".join(accumulated) 286 # The first time through prefix will be [], so we avoid adding in a 287 # superfluous '.' to prefix. 288 if prefix: 289 prefix += "." 290 display = p 291 if i == 0: 292 display = root_discovery.get("title", display) 293 crumbs.append('<a href="{}.html">{}</a>'.format(prefix + p, display)) 294 accumulated.append(p) 295 296 return " . ".join(crumbs) 297 298 299def document_collection(resource, path, root_discovery, discovery, css=CSS): 300 """Document a single collection in an API. 301 302 Args: 303 resource: Collection or service being documented. 304 path: string, Dot separated name of the resource. 305 root_discovery: Deserialized discovery document. 306 discovery: Deserialized discovery document, but just the portion that 307 describes the resource. 308 css: string, The CSS to include in the generated file. 309 """ 310 collections = [] 311 methods = [] 312 resource_name = path.split(".")[-2] 313 html = [ 314 "<html><body>", 315 css, 316 "<h1>%s</h1>" % breadcrumbs(path[:-1], root_discovery), 317 "<h2>Instance Methods</h2>", 318 ] 319 320 # Which methods are for collections. 321 for name in dir(resource): 322 if not name.startswith("_") and callable(getattr(resource, name)): 323 if hasattr(getattr(resource, name), "__is_resource__"): 324 collections.append(name) 325 else: 326 methods.append(name) 327 328 # TOC 329 if collections: 330 for name in collections: 331 if not name.startswith("_") and callable(getattr(resource, name)): 332 href = path + name + ".html" 333 html.append( 334 string.Template(COLLECTION_LINK).substitute(href=href, name=name) 335 ) 336 337 if methods: 338 for name in methods: 339 if not name.startswith("_") and callable(getattr(resource, name)): 340 doc = getattr(resource, name).__doc__ 341 params = method_params(doc) 342 firstline = doc.splitlines()[0] 343 html.append( 344 string.Template(METHOD_LINK).substitute( 345 name=name, params=params, firstline=firstline 346 ) 347 ) 348 349 if methods: 350 html.append("<h3>Method Details</h3>") 351 for name in methods: 352 dname = name.rsplit("_")[0] 353 html.append(method(name, getattr(resource, name).__doc__)) 354 355 html.append("</body></html>") 356 357 return "\n".join(html) 358 359 360def document_collection_recursive( 361 resource, path, root_discovery, discovery, doc_destination_dir 362): 363 html = document_collection(resource, path, root_discovery, discovery) 364 365 f = open(pathlib.Path(doc_destination_dir).joinpath(path + "html"), "w") 366 367 f.write(html) 368 f.close() 369 370 for name in dir(resource): 371 if ( 372 not name.startswith("_") 373 and callable(getattr(resource, name)) 374 and hasattr(getattr(resource, name), "__is_resource__") 375 and discovery != {} 376 ): 377 dname = name.rsplit("_")[0] 378 collection = getattr(resource, name)() 379 document_collection_recursive( 380 collection, 381 path + name + ".", 382 root_discovery, 383 discovery["resources"].get(dname, {}), 384 doc_destination_dir, 385 ) 386 387 388def document_api(name, version, uri, doc_destination_dir): 389 """Document the given API. 390 391 Args: 392 name (str): Name of the API. 393 version (str): Version of the API. 394 uri (str): URI of the API's discovery document 395 doc_destination_dir (str): relative path where the reference 396 documentation should be saved. 397 """ 398 http = build_http() 399 resp, content = http.request( 400 uri 401 or uritemplate.expand( 402 FLAGS.discovery_uri_template, {"api": name, "apiVersion": version} 403 ) 404 ) 405 406 if resp.status == 200: 407 discovery = json.loads(content) 408 service = build_from_document(discovery) 409 doc_name = "{}.{}.json".format(name, version) 410 discovery_file_path = DISCOVERY_DOC_DIR / doc_name 411 revision = None 412 413 pathlib.Path(discovery_file_path).touch(exist_ok=True) 414 415 # Write discovery artifact to disk if revision equal or newer 416 with open(discovery_file_path, "r+") as f: 417 try: 418 json_data = json.load(f) 419 revision = json_data["revision"] 420 except json.JSONDecodeError: 421 revision = None 422 423 if revision is None or discovery["revision"] >= revision: 424 # Reset position to the beginning 425 f.seek(0) 426 # Write the changes to disk 427 json.dump(discovery, f, indent=2, sort_keys=True) 428 # Truncate anything left as it's not needed 429 f.truncate() 430 431 elif resp.status == 404: 432 print( 433 "Warning: {} {} not found. HTTP Code: {}".format(name, version, resp.status) 434 ) 435 return 436 else: 437 print( 438 "Warning: {} {} could not be built. HTTP Code: {}".format( 439 name, version, resp.status 440 ) 441 ) 442 return 443 444 document_collection_recursive( 445 service, 446 "{}_{}.".format(name, safe_version(version)), 447 discovery, 448 discovery, 449 doc_destination_dir, 450 ) 451 452 453def document_api_from_discovery_document(discovery_url, doc_destination_dir): 454 """Document the given API. 455 456 Args: 457 discovery_url (str): URI of discovery document. 458 doc_destination_dir (str): relative path where the reference 459 documentation should be saved. 460 """ 461 http = build_http() 462 response, content = http.request(discovery_url) 463 discovery = json.loads(content) 464 465 service = build_from_document(discovery) 466 467 name = discovery["version"] 468 version = safe_version(discovery["version"]) 469 470 document_collection_recursive( 471 service, 472 "{}_{}.".format(name, version), 473 discovery, 474 discovery, 475 doc_destination_dir, 476 ) 477 478 479def generate_all_api_documents(directory_uri=DIRECTORY_URI, doc_destination_dir=BASE): 480 """Retrieve discovery artifacts and fetch reference documentations 481 for all apis listed in the public discovery directory. 482 args: 483 directory_uri (str): uri of the public discovery directory. 484 doc_destination_dir (str): relative path where the reference 485 documentation should be saved. 486 """ 487 api_directory = collections.defaultdict(list) 488 http = build_http() 489 resp, content = http.request(directory_uri) 490 if resp.status == 200: 491 directory = json.loads(content)["items"] 492 for api in directory: 493 document_api( 494 api["name"], 495 api["version"], 496 api["discoveryRestUrl"], 497 doc_destination_dir, 498 ) 499 api_directory[api["name"]].append(api["version"]) 500 501 # sort by api name and version number 502 for api in api_directory: 503 api_directory[api] = sorted(api_directory[api]) 504 api_directory = collections.OrderedDict( 505 sorted(api_directory.items(), key=lambda x: x[0]) 506 ) 507 508 markdown = [] 509 for api, versions in api_directory.items(): 510 markdown.append("## %s" % api) 511 for version in versions: 512 markdown.append( 513 "* [%s](http://googleapis.github.io/google-api-python-client/docs/dyn/%s_%s.html)" 514 % (version, api, safe_version(version)) 515 ) 516 markdown.append("\n") 517 518 with open(BASE / "index.md", "w") as f: 519 markdown = "\n".join(markdown) 520 f.write(markdown) 521 522 else: 523 sys.exit("Failed to load the discovery document.") 524 525 526if __name__ == "__main__": 527 FLAGS = parser.parse_args(sys.argv[1:]) 528 if FLAGS.discovery_uri: 529 document_api_from_discovery_document( 530 discovery_url=FLAGS.discovery_uri, doc_destination_dir=FLAGS.dest 531 ) 532 else: 533 generate_all_api_documents( 534 directory_uri=FLAGS.directory_uri, doc_destination_dir=FLAGS.dest 535 ) 536