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
27from collections import OrderedDict
28import argparse
29import collections
30import json
31import os
32import re
33import string
34import sys
35
36from googleapiclient.discovery import DISCOVERY_URI
37from googleapiclient.discovery import build
38from googleapiclient.discovery import build_from_document
39from googleapiclient.discovery import UnknownApiNameOrVersion
40from googleapiclient.http import build_http
41import uritemplate
42
43CSS = """<style>
44
45body, h1, h2, h3, div, span, p, pre, a {
46  margin: 0;
47  padding: 0;
48  border: 0;
49  font-weight: inherit;
50  font-style: inherit;
51  font-size: 100%;
52  font-family: inherit;
53  vertical-align: baseline;
54}
55
56body {
57  font-size: 13px;
58  padding: 1em;
59}
60
61h1 {
62  font-size: 26px;
63  margin-bottom: 1em;
64}
65
66h2 {
67  font-size: 24px;
68  margin-bottom: 1em;
69}
70
71h3 {
72  font-size: 20px;
73  margin-bottom: 1em;
74  margin-top: 1em;
75}
76
77pre, code {
78  line-height: 1.5;
79  font-family: Monaco, 'DejaVu Sans Mono', 'Bitstream Vera Sans Mono', 'Lucida Console', monospace;
80}
81
82pre {
83  margin-top: 0.5em;
84}
85
86h1, h2, h3, p {
87  font-family: Arial, sans serif;
88}
89
90h1, h2, h3 {
91  border-bottom: solid #CCC 1px;
92}
93
94.toc_element {
95  margin-top: 0.5em;
96}
97
98.firstline {
99  margin-left: 2 em;
100}
101
102.method  {
103  margin-top: 1em;
104  border: solid 1px #CCC;
105  padding: 1em;
106  background: #EEE;
107}
108
109.details {
110  font-weight: bold;
111  font-size: 14px;
112}
113
114</style>
115"""
116
117METHOD_TEMPLATE = """<div class="method">
118    <code class="details" id="$name">$name($params)</code>
119  <pre>$doc</pre>
120</div>
121"""
122
123COLLECTION_LINK = """<p class="toc_element">
124  <code><a href="$href">$name()</a></code>
125</p>
126<p class="firstline">Returns the $name Resource.</p>
127"""
128
129METHOD_LINK = """<p class="toc_element">
130  <code><a href="#$name">$name($params)</a></code></p>
131<p class="firstline">$firstline</p>"""
132
133BASE = "docs/dyn"
134
135DIRECTORY_URI = "https://www.googleapis.com/discovery/v1/apis"
136
137parser = argparse.ArgumentParser(description=__doc__)
138
139parser.add_argument(
140    "--discovery_uri_template",
141    default=DISCOVERY_URI,
142    help="URI Template for discovery.",
143)
144
145parser.add_argument(
146    "--discovery_uri",
147    default="",
148    help=(
149        "URI of discovery document. If supplied then only "
150        "this API will be documented."
151    ),
152)
153
154parser.add_argument(
155    "--directory_uri",
156    default=DIRECTORY_URI,
157    help=("URI of directory document. Unused if --discovery_uri" " is supplied."),
158)
159
160parser.add_argument(
161    "--dest", default=BASE, help="Directory name to write documents into."
162)
163
164
165def safe_version(version):
166    """Create a safe version of the verion string.
167
168  Needed so that we can distinguish between versions
169  and sub-collections in URIs. I.e. we don't want
170  adsense_v1.1 to refer to the '1' collection in the v1
171  version of the adsense api.
172
173  Args:
174    version: string, The version string.
175  Returns:
176    The string with '.' replaced with '_'.
177  """
178
179    return version.replace(".", "_")
180
181
182def unsafe_version(version):
183    """Undoes what safe_version() does.
184
185  See safe_version() for the details.
186
187
188  Args:
189    version: string, The safe version string.
190  Returns:
191    The string with '_' replaced with '.'.
192  """
193
194    return version.replace("_", ".")
195
196
197def method_params(doc):
198    """Document the parameters of a method.
199
200  Args:
201    doc: string, The method's docstring.
202
203  Returns:
204    The method signature as a string.
205  """
206    doclines = doc.splitlines()
207    if "Args:" in doclines:
208        begin = doclines.index("Args:")
209        if "Returns:" in doclines[begin + 1 :]:
210            end = doclines.index("Returns:", begin)
211            args = doclines[begin + 1 : end]
212        else:
213            args = doclines[begin + 1 :]
214
215        parameters = []
216        pname = None
217        desc = ""
218
219        def add_param(pname, desc):
220            if pname is None:
221                return
222            if "(required)" not in desc:
223                pname = pname + "=None"
224            parameters.append(pname)
225
226        for line in args:
227            m = re.search("^\s+([a-zA-Z0-9_]+): (.*)", line)
228            if m is None:
229                desc += line
230                continue
231            add_param(pname, desc)
232            pname = m.group(1)
233            desc = m.group(2)
234        add_param(pname, desc)
235        parameters = ", ".join(parameters)
236    else:
237        parameters = ""
238    return parameters
239
240
241def method(name, doc):
242    """Documents an individual method.
243
244  Args:
245    name: string, Name of the method.
246    doc: string, The methods docstring.
247  """
248
249    params = method_params(doc)
250    return string.Template(METHOD_TEMPLATE).substitute(
251        name=name, params=params, doc=doc
252    )
253
254
255def breadcrumbs(path, root_discovery):
256    """Create the breadcrumb trail to this page of documentation.
257
258  Args:
259    path: string, Dot separated name of the resource.
260    root_discovery: Deserialized discovery document.
261
262  Returns:
263    HTML with links to each of the parent resources of this resource.
264  """
265    parts = path.split(".")
266
267    crumbs = []
268    accumulated = []
269
270    for i, p in enumerate(parts):
271        prefix = ".".join(accumulated)
272        # The first time through prefix will be [], so we avoid adding in a
273        # superfluous '.' to prefix.
274        if prefix:
275            prefix += "."
276        display = p
277        if i == 0:
278            display = root_discovery.get("title", display)
279        crumbs.append('<a href="%s.html">%s</a>' % (prefix + p, display))
280        accumulated.append(p)
281
282    return " . ".join(crumbs)
283
284
285def document_collection(resource, path, root_discovery, discovery, css=CSS):
286    """Document a single collection in an API.
287
288  Args:
289    resource: Collection or service being documented.
290    path: string, Dot separated name of the resource.
291    root_discovery: Deserialized discovery document.
292    discovery: Deserialized discovery document, but just the portion that
293      describes the resource.
294    css: string, The CSS to include in the generated file.
295  """
296    collections = []
297    methods = []
298    resource_name = path.split(".")[-2]
299    html = [
300        "<html><body>",
301        css,
302        "<h1>%s</h1>" % breadcrumbs(path[:-1], root_discovery),
303        "<h2>Instance Methods</h2>",
304    ]
305
306    # Which methods are for collections.
307    for name in dir(resource):
308        if not name.startswith("_") and callable(getattr(resource, name)):
309            if hasattr(getattr(resource, name), "__is_resource__"):
310                collections.append(name)
311            else:
312                methods.append(name)
313
314    # TOC
315    if collections:
316        for name in collections:
317            if not name.startswith("_") and callable(getattr(resource, name)):
318                href = path + name + ".html"
319                html.append(
320                    string.Template(COLLECTION_LINK).substitute(href=href, name=name)
321                )
322
323    if methods:
324        for name in methods:
325            if not name.startswith("_") and callable(getattr(resource, name)):
326                doc = getattr(resource, name).__doc__
327                params = method_params(doc)
328                firstline = doc.splitlines()[0]
329                html.append(
330                    string.Template(METHOD_LINK).substitute(
331                        name=name, params=params, firstline=firstline
332                    )
333                )
334
335    if methods:
336        html.append("<h3>Method Details</h3>")
337        for name in methods:
338            dname = name.rsplit("_")[0]
339            html.append(method(name, getattr(resource, name).__doc__))
340
341    html.append("</body></html>")
342
343    return "\n".join(html)
344
345
346def document_collection_recursive(resource, path, root_discovery, discovery):
347
348    html = document_collection(resource, path, root_discovery, discovery)
349
350    f = open(os.path.join(FLAGS.dest, path + "html"), "w")
351    f.write(html.encode("utf-8"))
352    f.close()
353
354    for name in dir(resource):
355        if (
356            not name.startswith("_")
357            and callable(getattr(resource, name))
358            and hasattr(getattr(resource, name), "__is_resource__")
359            and discovery != {}
360        ):
361            dname = name.rsplit("_")[0]
362            collection = getattr(resource, name)()
363            document_collection_recursive(
364                collection,
365                path + name + ".",
366                root_discovery,
367                discovery["resources"].get(dname, {}),
368            )
369
370
371def document_api(name, version):
372    """Document the given API.
373
374  Args:
375    name: string, Name of the API.
376    version: string, Version of the API.
377  """
378    try:
379        service = build(name, version)
380    except UnknownApiNameOrVersion as e:
381        print("Warning: {} {} found but could not be built.".format(name, version))
382        return
383
384    http = build_http()
385    response, content = http.request(
386        uritemplate.expand(
387            FLAGS.discovery_uri_template, {"api": name, "apiVersion": version}
388        )
389    )
390    discovery = json.loads(content)
391
392    version = safe_version(version)
393
394    document_collection_recursive(
395        service, "%s_%s." % (name, version), discovery, discovery
396    )
397
398
399def document_api_from_discovery_document(uri):
400    """Document the given API.
401
402  Args:
403    uri: string, URI of discovery document.
404  """
405    http = build_http()
406    response, content = http.request(FLAGS.discovery_uri)
407    discovery = json.loads(content)
408
409    service = build_from_document(discovery)
410
411    name = discovery["version"]
412    version = safe_version(discovery["version"])
413
414    document_collection_recursive(
415        service, "%s_%s." % (name, version), discovery, discovery
416    )
417
418
419if __name__ == "__main__":
420    FLAGS = parser.parse_args(sys.argv[1:])
421    if FLAGS.discovery_uri:
422        document_api_from_discovery_document(FLAGS.discovery_uri)
423    else:
424        api_directory = collections.defaultdict(list)
425        http = build_http()
426        resp, content = http.request(
427            FLAGS.directory_uri, headers={"X-User-IP": "0.0.0.0"}
428        )
429        if resp.status == 200:
430            directory = json.loads(content)["items"]
431            for api in directory:
432                document_api(api["name"], api["version"])
433                api_directory[api["name"]].append(api["version"])
434
435            # sort by api name and version number
436            for api in api_directory:
437                api_directory[api] = sorted(api_directory[api])
438            api_directory = OrderedDict(
439                sorted(api_directory.items(), key=lambda x: x[0])
440            )
441
442            markdown = []
443            for api, versions in api_directory.items():
444                markdown.append("## %s" % api)
445                for version in versions:
446                    markdown.append(
447                        "* [%s](http://googleapis.github.io/google-api-python-client/docs/dyn/%s_%s.html)"
448                        % (version, api, version)
449                    )
450                markdown.append("\n")
451
452            with open("docs/dyn/index.md", "w") as f:
453                f.write("\n".join(markdown).encode("utf-8"))
454
455        else:
456            sys.exit("Failed to load the discovery document.")
457