• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
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