Feature/api version support (#109)
[osm/N2VC.git] / juju / client / facade.py
index a6fd9f6..5f37c27 100644 (file)
@@ -1,10 +1,13 @@
 import argparse
 import builtins
+from collections import defaultdict
 import functools
+from glob import glob
 import json
 import keyword
 from pathlib import Path
 import pprint
+import re
 import textwrap
 from typing import Sequence, Mapping, TypeVar, Any, Union
 import typing
@@ -13,6 +16,13 @@ from . import codegen
 
 _marker = object()
 
+JUJU_VERSION = re.compile('[0-9]+\.[0-9-]+[\.\-][0-9a-z]+(\.[0-9]+)?')
+VERSION_MAP = defaultdict(dict)
+# Workaround for https://bugs.launchpad.net/juju/+bug/1683906
+NAUGHTY_CLASSES = ['ClientFacade', 'Client', 'FullStatus', 'ModelStatusInfo',
+                   'ModelInfo']
+
+
 # Map basic types to Python's typing with a callable
 SCHEMA_TO_PYTHON = {
     'string': str,
@@ -24,6 +34,66 @@ SCHEMA_TO_PYTHON = {
 }
 
 
+# Friendly warning message to stick at the top of generated files.
+HEADER = """\
+# DO NOT CHANGE THIS FILE! This file is auto-generated by facade.py.
+# Changes will be overwritten/lost when the file is regenerated.
+
+"""
+
+
+# Classes and helper functions that we'll write to _client.py
+LOOKUP_FACADE = '''
+def lookup_facade(name, version):
+        """
+        Given a facade name and version, attempt to pull that facade out
+        of the correct client<version>.py file.
+
+        """
+        try:
+            facade = getattr(CLIENTS[str(version)], name)
+        except KeyError:
+            raise ImportError("No facades found for version {}".format(version))
+        except AttributeError:
+            raise ImportError(
+                "No facade with name '{}' in version {}".format(name, version))
+        return facade
+
+
+'''
+
+TYPE_FACTORY = '''
+class TypeFactory:
+    @classmethod
+    def from_connection(cls, connection):
+        """
+        Given a connected Connection object, return an initialized and
+        connected instance of an API Interface matching the name of
+        this class.
+
+        @param connection: initialized Connection object.
+
+        """
+        version = connection.facades[cls.__name__[:-6]]
+
+        c = lookup_facade(cls.__name__, version)
+        c = c()
+        c.connect(connection)
+
+        return c
+
+
+'''
+
+CLIENT_TABLE = '''
+CLIENTS = {{
+    {clients}
+}}
+
+
+'''
+
+
 class KindRegistry(dict):
     def register(self, name, version, obj):
         self[name] = {version: {
@@ -60,7 +130,8 @@ class TypeRegistry(dict):
 
 _types = TypeRegistry()
 _registry = KindRegistry()
-classes = {}
+CLASSES = {}
+factories = codegen.Capture()
 
 
 def booler(v):
@@ -183,14 +254,16 @@ class Args(list):
 
 
 def buildTypes(schema, capture):
-    global classes
     INDENT = "    "
     for kind in sorted((k for k in _types if not isinstance(k, str)),
                        key=lambda x: str(x)):
         name = _types[kind]
-        if name in classes:
+        if name in capture and not name in NAUGHTY_CLASSES:
             continue
         args = Args(kind)
+        # Write Factory class for _client.py
+        make_factory(name)
+        # Write actual class
         source = ["""
 class {}(Type):
     _toSchema = {}
@@ -247,13 +320,14 @@ class {}(Type):
                     source.append("{}self.{} = {}".format(INDENT * 2, arg_name, arg_name))
 
         source = "\n".join(source)
-        capture.write(source)
-        capture.write("\n\n")
+        capture.clear(name)
+        capture[name].write(source)
+        capture[name].write("\n\n")
         co = compile(source, __name__, "exec")
         ns = _getns()
         exec(co, ns)
         cls = ns[name]
-        classes[name] = cls
+        CLASSES[name] = cls
 
 
 def retspec(defs):
@@ -320,7 +394,7 @@ def ReturnMapping(cls):
             if cls is None:
                 return reply
             if 'error' in reply:
-                cls = classes['Error']
+                cls = CLASSES['Error']
             if issubclass(cls, typing.Sequence):
                 result = []
                 item_cls = cls.__parameters__[0]
@@ -328,7 +402,7 @@ def ReturnMapping(cls):
                     result.append(item_cls.from_json(item))
                     """
                     if 'error' in item:
-                        cls = classes['Error']
+                        cls = CLASSES['Error']
                     else:
                         cls = item_cls
                     result.append(cls.from_json(item))
@@ -390,7 +464,7 @@ def buildMethods(cls, capture):
     for methodname in sorted(properties):
         method, source = _buildMethod(cls, methodname)
         setattr(cls, methodname, method)
-        capture.write(source, depth=1)
+        capture["{}Facade".format(cls.__name__)].write(source, depth=1)
 
 
 def _buildMethod(cls, name):
@@ -401,11 +475,7 @@ def _buildMethod(cls, name):
         prop = method['properties']
         spec = prop.get('Params')
         if spec:
-            result = _types.get(spec['$ref'])
-            if '$ref' in spec:
-                result = _types.get(spec['$ref'])
-            else:
-                result = SCHEMA_TO_PYTHON[spec['type']]
+            params = _types.get(spec['$ref'])
         spec = prop.get('Result')
         if spec:
             if '$ref' in spec:
@@ -504,7 +574,7 @@ class Schema(dict):
         if not defs:
             return
         for d, data in defs.items():
-            if d in _registry:
+            if d in _registry and not d in NAUGHTY_CLASSES:
                 continue
             node = self.deref(data, d)
             kind = node.get("type")
@@ -586,48 +656,148 @@ def _getns():
     return ns
 
 
-
-def generate_facacdes(options):
-    global classes
-    schemas = json.loads(Path(options.schema).read_text("utf-8"))
-    capture = codegen.CodeWriter()
-    capture.write(textwrap.dedent("""\
-        # DO NOT CHANGE THIS FILE! This file is auto-generated by facade.py.
-        # Changes will be overwritten/lost when the file is regenerated.
-
-        from juju.client.facade import Type, ReturnMapping
-
-    """))
-    schemas = [Schema(s) for s in schemas]
-
-    for schema in schemas:
-        schema.buildDefinitions()
-        buildTypes(schema, capture)
-
-    for schema in schemas:
-        # TODO generate class now with a metaclass that takes the schema
-        # the generated class has the right name and it in turn uses
-        # the metaclass to populate cls
-        cls, source = buildFacade(schema)
-        capture.write(source)
-        buildMethods(cls, capture)
-        classes[schema.name] = cls
-
-    return capture
+def make_factory(name):
+    if name in factories:
+        del factories[name]
+    factories[name].write("class {}(TypeFactory):\n    pass\n\n".format(name))
+
+
+def write_facades(captures, options):
+    """
+    Write the Facades to the appropriate _client<version>.py
+
+    """
+    for version in sorted(captures.keys()):
+        filename = "{}/_client{}.py".format(options.output_dir, version)
+        with open(filename, "w") as f:
+            f.write(HEADER)
+            f.write("from juju.client.facade import Type, ReturnMapping\n")
+            f.write("from juju.client._definitions import *\n\n")
+            for key in sorted(
+                    [k for k in captures[version].keys() if "Facade" in k]):
+                print(captures[version][key], file=f)
+
+    # Return the last (most recent) version for use in other routines.
+    return version
+
+
+def write_definitions(captures, options, version):
+    """
+    Write auxillary (non versioned) classes to
+    _definitions.py The auxillary classes currently get
+    written redudantly into each capture object, so we can look in
+    one of them -- we just use the last one from the loop above.
+
+    """
+    with open("{}/_definitions.py".format(options.output_dir), "w") as f:
+        f.write(HEADER)
+        f.write("from juju.client.facade import Type, ReturnMapping\n\n")
+        for key in sorted(
+                [k for k in captures[version].keys() if "Facade" not in k]):
+            print(captures[version][key], file=f)
+
+
+def write_client(captures, options):
+    """
+    Write the TypeFactory classes to _client.py, along with some
+    imports and tables so that we can look up versioned Facades.
+
+    """
+    with open("{}/_client.py".format(options.output_dir), "w") as f:
+        f.write(HEADER)
+        f.write("from juju.client._definitions import *\n\n")
+        clients = ", ".join("_client{}".format(v) for v in captures)
+        f.write("from juju.client import " + clients + "\n\n")
+        f.write(CLIENT_TABLE.format(clients=",\n    ".join(
+            ['"{}": _client{}'.format(v, v) for v in captures])))
+        f.write(LOOKUP_FACADE)
+        f.write(TYPE_FACTORY)
+        for key in sorted([k for k in factories.keys() if "Facade" in k]):
+            print(factories[key], file=f)
+
+
+def write_version_map(options):
+    """
+    In order to work around
+    https://bugs.launchpad.net/juju/+bug/1682925, we build a map of
+    the facades that each version supports, and write it to disk here.
+
+    """
+    with open("{}/version_map.py".format(options.output_dir), "w") as f:
+        f.write(HEADER)
+        f.write("VERSION_MAP = {\n")
+        for juju_version in sorted(VERSION_MAP.keys()):
+            f.write('    "{}": {{\n'.format(juju_version))
+            for key in VERSION_MAP[juju_version]:
+                f.write('        "{}": {},\n'.format(
+                    key, VERSION_MAP[juju_version][key]))
+            f.write('    },\n')
+        f.write("}\n")
+
+
+def generate_facades(options):
+    captures = defaultdict(codegen.Capture)
+    schemas = {}
+    for p in sorted(glob(options.schema)):
+        if 'latest' in p:
+            juju_version = 'latest'
+        else:
+            try:
+                juju_version = re.search(JUJU_VERSION, p).group()
+            except AttributeError:
+                print("Cannot extract a juju version from {}".format(p))
+                print("Schemas must include a juju version in the filename")
+                raise SystemExit(1)
+
+        new_schemas = json.loads(Path(p).read_text("utf-8"))
+        schemas[juju_version] = [Schema(s) for s in new_schemas]
+
+    # Build all of the auxillary (unversioned) classes
+    # TODO: get rid of some of the excess trips through loops in the
+    # called functions.
+    for juju_version in sorted(schemas.keys()):
+        for schema in schemas[juju_version]:
+            schema.buildDefinitions()
+            buildTypes(schema, captures[schema.version])
+            VERSION_MAP[juju_version][schema.name] = schema.version
+
+    # Build the Facade classes
+    for juju_version in sorted(schemas.keys()):
+        for schema in schemas[juju_version]:
+            cls, source = buildFacade(schema)
+            cls_name = "{}Facade".format(schema.name)
+
+            captures[schema.version].clear(cls_name)
+            # Make the factory class for _client.py
+            make_factory(cls_name)
+            # Make the actual class
+            captures[schema.version][cls_name].write(source)
+            # Build the methods for each Facade class.
+            buildMethods(cls, captures[schema.version])
+            # Mark this Facade class as being done for this version --
+            # helps mitigate some excessive looping.
+            CLASSES[schema.name] = cls
+
+    return captures
 
 def setup():
     parser = argparse.ArgumentParser()
-    parser.add_argument("-s", "--schema", default="schemas.json")
-    parser.add_argument("-o", "--output", default="client.py")
+    parser.add_argument("-s", "--schema", default="juju/client/schemas*")
+    parser.add_argument("-o", "--output_dir", default="juju/client")
     options = parser.parse_args()
     return options
 
 def main():
     options = setup()
-    capture = generate_facacdes(options)
-    with open(options.output, "w") as fp:
-        print(capture, file=fp)
 
+    # Generate some text blobs
+    captures = generate_facades(options)
+
+    # ... and write them out
+    last_version = write_facades(captures, options)
+    write_definitions(captures, options, last_version)
+    write_client(captures, options)
+    write_version_map(options)
 
 if __name__ == '__main__':
     main()