diff --git a/.coveragerc b/.coveragerc index 6202e327d9c..d30006cff3a 100644 --- a/.coveragerc +++ b/.coveragerc @@ -2,7 +2,7 @@ omit = */samples/* # Issue tracked in https://github.com/googleapis/google-api-python-client/issues/2132 - describe.py + # describe.py exclude_lines = # Re-enable the standard pragma pragma: NO COVER diff --git a/googleapiclient/describe.py b/googleapiclient/describe.py new file mode 100644 index 00000000000..11779ada96a --- /dev/null +++ b/googleapiclient/describe.py @@ -0,0 +1,558 @@ +#!/usr/bin/python +# +# Copyright 2014 Google Inc. All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Create documentation for generate API surfaces. + +Command-line tool that creates documentation for all APIs listed in discovery. +The documentation is generated from a combination of the discovery document and +the generated API surface itself. +""" +from __future__ import print_function + +__author__ = "jcgregorio@google.com (Joe Gregorio)" + +import argparse +import collections +import json +import pathlib +import re +import string +import sys + +import uritemplate + +from googleapiclient.discovery import DISCOVERY_URI, build_from_document +from googleapiclient.http import build_http + +DISCOVERY_DOC_DIR = ( + pathlib.Path(__file__).resolve().parent + / "googleapiclient" + / "discovery_cache" + / "documents" +) + +CSS = """ +""" + +METHOD_TEMPLATE = """
+ $name($params) +
$doc
+
+""" + +COLLECTION_LINK = """

+ $name() +

+

Returns the $name Resource.

+""" + +METHOD_LINK = """

+ $name($params)

+

$firstline

""" + +BASE = pathlib.Path(__file__).resolve().parent / "docs" / "dyn" + +DIRECTORY_URI = "https://www.googleapis.com/discovery/v1/apis" + +parser = argparse.ArgumentParser(description=__doc__) + +parser.add_argument( + "--discovery_uri_template", + default=DISCOVERY_URI, + help="URI Template for discovery.", +) + +parser.add_argument( + "--discovery_uri", + default="", + help=( + "URI of discovery document. If supplied then only " + "this API will be documented." + ), +) + +parser.add_argument( + "--directory_uri", + default=DIRECTORY_URI, + help=("URI of directory document. Unused if --discovery_uri" " is supplied."), +) + +parser.add_argument( + "--dest", default=BASE, help="Directory name to write documents into." +) + + +def safe_version(version): + """Create a safe version of the verion string. + + Needed so that we can distinguish between versions + and sub-collections in URIs. I.e. we don't want + adsense_v1.1 to refer to the '1' collection in the v1 + version of the adsense api. + + Args: + version: string, The version string. + Returns: + The string with '.' replaced with '_'. + """ + + return version.replace(".", "_") + + +def unsafe_version(version): + """Undoes what safe_version() does. + + See safe_version() for the details. + + + Args: + version: string, The safe version string. + Returns: + The string with '_' replaced with '.'. + """ + + return version.replace("_", ".") + + +def method_params(doc): + """Document the parameters of a method. + + Args: + doc: string, The method's docstring. + + Returns: + The method signature as a string. + """ + doclines = doc.splitlines() + if "Args:" in doclines: + begin = doclines.index("Args:") + if "Returns:" in doclines[begin + 1 :]: + end = doclines.index("Returns:", begin) + args = doclines[begin + 1 : end] + else: + args = doclines[begin + 1 :] + + parameters = [] + sorted_parameters = [] + pname = None + desc = "" + + def add_param(pname, desc): + if pname is None: + return + if "(required)" not in desc: + pname = pname + "=None" + parameters.append(pname) + else: + # required params should be put straight into sorted_parameters + # to maintain order for positional args + sorted_parameters.append(pname) + + for line in args: + m = re.search(r"^\s+([a-zA-Z0-9_]+): (.*)", line) + if m is None: + desc += line + continue + add_param(pname, desc) + pname = m.group(1) + desc = m.group(2) + add_param(pname, desc) + sorted_parameters.extend(sorted(parameters)) + sorted_parameters = ", ".join(sorted_parameters) + else: + sorted_parameters = "" + return sorted_parameters + + +def method(name, doc): + """Documents an individual method. + + Args: + name: string, Name of the method. + doc: string, The methods docstring. + """ + import html + + params = method_params(doc) + doc = html.escape(doc) + return string.Template(METHOD_TEMPLATE).substitute( + name=name, params=params, doc=doc + ) + + +def breadcrumbs(path, root_discovery): + """Create the breadcrumb trail to this page of documentation. + + Args: + path: string, Dot separated name of the resource. + root_discovery: Deserialized discovery document. + + Returns: + HTML with links to each of the parent resources of this resource. + """ + parts = path.split(".") + + crumbs = [] + accumulated = [] + + for i, p in enumerate(parts): + prefix = ".".join(accumulated) + # The first time through prefix will be [], so we avoid adding in a + # superfluous '.' to prefix. + if prefix: + prefix += "." + display = p + if i == 0: + display = root_discovery.get("title", display) + crumbs.append('{}'.format(prefix + p, display)) + accumulated.append(p) + + return " . ".join(crumbs) + + +def document_collection(resource, path, root_discovery, discovery, css=CSS): + """Document a single collection in an API. + + Args: + resource: Collection or service being documented. + path: string, Dot separated name of the resource. + root_discovery: Deserialized discovery document. + discovery: Deserialized discovery document, but just the portion that + describes the resource. + css: string, The CSS to include in the generated file. + """ + collections = [] + methods = [] + resource_name = path.split(".")[-2] + html = [ + "", + css, + "

%s

" % breadcrumbs(path[:-1], root_discovery), + "

Instance Methods

", + ] + + # Which methods are for collections. + for name in dir(resource): + if not name.startswith("_") and callable(getattr(resource, name)): + if hasattr(getattr(resource, name), "__is_resource__"): + collections.append(name) + else: + methods.append(name) + + # TOC + if collections: + for name in collections: + if not name.startswith("_") and callable(getattr(resource, name)): + href = path + name + ".html" + html.append( + string.Template(COLLECTION_LINK).substitute(href=href, name=name) + ) + + if methods: + for name in methods: + if not name.startswith("_") and callable(getattr(resource, name)): + doc = getattr(resource, name).__doc__ + params = method_params(doc) + firstline = doc.splitlines()[0] + html.append( + string.Template(METHOD_LINK).substitute( + name=name, params=params, firstline=firstline + ) + ) + + if methods: + html.append("

Method Details

") + for name in methods: + dname = name.rsplit("_")[0] + html.append(method(name, getattr(resource, name).__doc__)) + + html.append("") + + return "\n".join(html) + + +def document_collection_recursive( + resource, + path, + root_discovery, + discovery, + doc_destination_dir, + artifact_destination_dir=DISCOVERY_DOC_DIR, +): + html = document_collection(resource, path, root_discovery, discovery) + + f = open(pathlib.Path(doc_destination_dir).joinpath(path + "html"), "w") + + f.write(html) + f.close() + + for name in dir(resource): + if ( + not name.startswith("_") + and callable(getattr(resource, name)) + and hasattr(getattr(resource, name), "__is_resource__") + and discovery != {} + ): + dname = name.rsplit("_")[0] + collection = getattr(resource, name)() + document_collection_recursive( + collection, + path + name + ".", + root_discovery, + discovery["resources"].get(dname, {}), + doc_destination_dir, + artifact_destination_dir, + ) + + +def document_api( + name, version, uri, doc_destination_dir, artifact_destination_dir=DISCOVERY_DOC_DIR +): + """Document the given API. + + Args: + name (str): Name of the API. + version (str): Version of the API. + uri (str): URI of the API's discovery document + doc_destination_dir (str): relative path where the reference + documentation should be saved. + artifact_destination_dir (str): relative path where the discovery + artifacts should be saved. + """ + http = build_http() + resp, content = http.request( + uri + or uritemplate.expand( + FLAGS.discovery_uri_template, {"api": name, "apiVersion": version} + ) + ) + + if resp.status == 200: + discovery = json.loads(content) + service = build_from_document(discovery) + doc_name = "{}.{}.json".format(name, version) + discovery_file_path = artifact_destination_dir / doc_name + revision = None + + pathlib.Path(discovery_file_path).touch(exist_ok=True) + + # Write discovery artifact to disk if revision equal or newer + with open(discovery_file_path, "r+") as f: + try: + json_data = json.load(f) + revision = json_data["revision"] + except json.JSONDecodeError: + revision = None + + if revision is None or discovery["revision"] >= revision: + # Reset position to the beginning + f.seek(0) + # Write the changes to disk + json.dump(discovery, f, indent=2, sort_keys=True) + # Truncate anything left as it's not needed + f.truncate() + + elif resp.status == 404: + print( + "Warning: {} {} not found. HTTP Code: {}".format(name, version, resp.status) + ) + return + else: + print( + "Warning: {} {} could not be built. HTTP Code: {}".format( + name, version, resp.status + ) + ) + return + + document_collection_recursive( + service, + "{}_{}.".format(name, safe_version(version)), + discovery, + discovery, + doc_destination_dir, + artifact_destination_dir, + ) + + +def document_api_from_discovery_document( + discovery_url, doc_destination_dir, artifact_destination_dir=DISCOVERY_DOC_DIR +): + """Document the given API. + + Args: + discovery_url (str): URI of discovery document. + doc_destination_dir (str): relative path where the reference + documentation should be saved. + artifact_destination_dir (str): relative path where the discovery + artifacts should be saved. + """ + http = build_http() + response, content = http.request(discovery_url) + discovery = json.loads(content) + + service = build_from_document(discovery) + + name = discovery["version"] + version = safe_version(discovery["version"]) + + document_collection_recursive( + service, + "{}_{}.".format(name, version), + discovery, + discovery, + doc_destination_dir, + artifact_destination_dir, + ) + + +def generate_all_api_documents( + directory_uri=DIRECTORY_URI, + doc_destination_dir=BASE, + artifact_destination_dir=DISCOVERY_DOC_DIR, +): + """Retrieve discovery artifacts and fetch reference documentations + for all apis listed in the public discovery directory. + args: + directory_uri (str): uri of the public discovery directory. + doc_destination_dir (str): relative path where the reference + documentation should be saved. + artifact_destination_dir (str): relative path where the discovery + artifacts should be saved. + """ + api_directory = collections.defaultdict(list) + http = build_http() + resp, content = http.request(directory_uri) + if resp.status == 200: + directory = json.loads(content)["items"] + for api in directory: + document_api( + api["name"], + api["version"], + api["discoveryRestUrl"], + doc_destination_dir, + artifact_destination_dir, + ) + api_directory[api["name"]].append(api["version"]) + + # sort by api name and version number + for api in api_directory: + api_directory[api] = sorted(api_directory[api]) + api_directory = collections.OrderedDict( + sorted(api_directory.items(), key=lambda x: x[0]) + ) + + markdown = [] + for api, versions in api_directory.items(): + markdown.append("## %s" % api) + for version in versions: + markdown.append( + "* [%s](http://googleapis.github.io/google-api-python-client/docs/dyn/%s_%s.html)" + % (version, api, safe_version(version)) + ) + markdown.append("\n") + + with open(doc_destination_dir / "index.md", "w") as f: + markdown = "\n".join(markdown) + f.write(markdown) + + else: + sys.exit("Failed to load the discovery document.") + + +if __name__ == "__main__": + FLAGS = parser.parse_args(sys.argv[1:]) + if FLAGS.discovery_uri: + document_api_from_discovery_document( + discovery_url=FLAGS.discovery_uri, + doc_destination_dir=FLAGS.dest, + ) + else: + generate_all_api_documents( + directory_uri=FLAGS.directory_uri, + doc_destination_dir=FLAGS.dest, + ) diff --git a/tests/test_describe.py b/tests/test_describe.py new file mode 100644 index 00000000000..e8e55d913bf --- /dev/null +++ b/tests/test_describe.py @@ -0,0 +1,125 @@ +# Copyright 2023 Google Inc. All rights reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Unit tests for describe""" + +import unittest +from googleapiclient import describe + + +# run this test file using nox -s "unit-3.8(oauth2client=None)" -- -k test_describe.py + +class TestDescribe(unittest.TestCase): + def test_safe_version_basic(self): + self.assertEqual(describe.safe_version("testingv1.3"), "testingv1_3") + + def test_safe_version_already_updated(self): + self.assertEqual(describe.safe_version("testingv1_3"), "testingv1_3") + + def test_safe_version_empty(self): + self.assertEqual(describe.safe_version(""), "") + + def test_safe_version_fail(self): + with self.assertRaises(Exception) as context: + describe.safe_version(16) + # TODO confirm the exception issomething that we expect + + def test_unsafe_version_basic(self): + self.assertEqual(describe.unsafe_version("testingv1_3"), "testingv1.3") + + def test_unsafe_version_already_updated(self): + self.assertEqual(describe.unsafe_version("testingv1.3"), "testingv1.3") + + def test_unsafe_version_empty(self): + self.assertEqual(describe.unsafe_version(""), "") + + def test_unsafe_version_fail(self): + with self.assertRaises(Exception) as context: + describe.unsafe_version(16) + # TODO confirm the exception issomething that we expect + + def test_method_params(self): + self.assertTrue(describe.method_params("This function performs a specific operation.\n\nArgs:\n test_param_1: A test parameter.\n required_param: (required) The required parameter for the function.\n test_param_2: Another test parameter.\n\nReturns:\n The result of the operation.\n\nRaises:\n\n ValueError: If the input is invalid or out of range.") == "required_param, test_param_1=None, test_param_2=None") + self.assertTrue(describe.method_params("Random test string with no Args:") == "") + self.assertFalse(describe.method_params("This function performs a specific operation.\n\nArgs:\n test_param_1: A test parameter.\n required_param: (required) The required parameter for the function.\n test_param_2: Another test parameter.\n\nReturns:\n The result of the operation.\n\nRaises:\n\n ValueError: If the input is invalid or out of range.") == "test_param_1=None, required_param, test_param_2=None") + with self.assertRaises(AttributeError): + describe.method_params(5) + + @unittest.skip("skipping until this is implemented") + def test_method(self): + # TODO + print("TODO") + + def test_breadcrumbs_basic(self): + self.assertEqual(describe.breadcrumbs( + "resource1.resource2.resource3", #resources (path) + {"title": "Document Title"} ), #root_discovery document + #returned string + 'Document Title . resource2 . resource3') + + def test_breadcrumbs_no_title(self): + self.assertEqual(describe.breadcrumbs( + "resource1.resource2.resource3", #resources (path) + {"not title": "Title"} ), #root_discovery document + #returned string + 'resource1 . resource2 . resource3') + + def test_breadcrumbs_empty_root_discovery(self): + self.assertEqual(describe.breadcrumbs( + "resource1.resource2.resource3", #resources (path) + {} ), #root_discovery document + #returned string + 'resource1 . resource2 . resource3') + + def test_breadcrumbs_empty_path(self): + self.assertEqual(describe.breadcrumbs( + "", #resources (path) + {"title": "Title"} ), #root_discovery document + #returned string + 'Title') + + @unittest.skip("skipping until this is implemented") + def test_document_collection(self): + # TODO + print("TODO") + + @unittest.skip("skipping until this is implemented") + def test_document_collection_recursive(self): + # TODO + print("TODO") + + @unittest.skip("skipping until this is implemented") + def test_document_api(self): + # TODO + print("TODO") + + @unittest.skip("skipping until this is implemented") + def test_document_api_from_discovery_document( self): + # TODO implement me to have the program work from top to bottom + discovery_url = "" + doc_destination_dir = "" + artifact_destination_dir = "" + describe.document_api_from_discovery_document(discovery_url, doc_destination_dir, artifact_destination_dir) + + @unittest.skip("skipping until this is implemented") + def test_generate_all_api_documents(self): + # TODO + print("TODO") + + + + + +if __name__ == "__main__": + unittest.main()