Repository: caikit/caikit
Pull request #273 (2023-06-23 21:46:04)

Add a basic base factory to codify factory keys and semantics
This PR adds a simple `Factory` that can be reused across different config-oriented factory objects. There's not a lot of code, but it does make explicit that the `"type"` and `"config"` strings should be used and how they should be organized, so it's worth standardizing as the number of factories grows.

Supports: https://github.com/caikit/caikit/issues/54


Patch:
diff --git a/caikit/core/toolkit/factory.py b/caikit/core/toolkit/factory.py
new file mode 100644
index 00000000..ec41fe15
--- /dev/null
+++ b/caikit/core/toolkit/factory.py
@@ -0,0 +1,102 @@
+# Copyright The Caikit Authors
+#
+# 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.
+"""
+This toolkit utility provides common factory construction semantics and a common
+base class for classes that can be constructed via caikit config
+"""
+
+# Standard
+from typing import Type
+import abc
+
+# First Party
+import aconfig
+import alog
+
+# Local
+from .errors import error_handler
+
+log = alog.use_channel("FCTRY")
+error = error_handler.get(log)
+
+
+class FactoryConstructible(abc.ABC):
+    """A class can be constructed by a factory if its constructor takes exactly
+    one argument that is an aconfig.Config object and it has a name to identify
+    itself with the factory.
+    """
+
+    @property
+    @classmethod
+    @abc.abstractmethod
+    def name(cls) -> str:
+        """This is the name of this constructible type that will be used by
+        the factory to identify this class
+        """
+
+    @abc.abstractmethod
+    def __init__(self, config: aconfig.Config):
+        """A FactoryConstructible object must be constructed with a config
+        object that it uses to pull in all configuration
+        """
+
+
+class Factory:
+    """The base factory class implements all common factory functionality to
+    read a designated portion of config and instantiate an instance of the
+    registered classes.
+    """
+
+    # The keys in the instance config
+    TYPE_KEY = "type"
+    CONFIG_KEY = "config"
+
+    def __init__(self, name: str):
+        """Construct with the path in the global config where this factory's
+        configuration lives.
+        """
+        self._name = name
+        self._registered_types = {}
+
+    @property
+    def name(self) -> str:
+        return self._name
+
+    def register(self, constructible: Type[FactoryConstructible]):
+        """Register the given constructible"""
+        current = self._registered_types.get(constructible.name)
+        error.value_check(
+            "<COR27473932E>",
+            current is None or current is constructible,
+            "Conflicting registration of {}",
+            constructible.name,
+        )
+        self._registered_types[constructible.name] = constructible
+
+    def construct(self, instance_config: dict) -> FactoryConstructible:
+        """Construct an instance of the given type"""
+        inst_type = instance_config.get(self.__class__.TYPE_KEY)
+        inst_cfg = aconfig.Config(
+            instance_config.get(self.__class__.CONFIG_KEY, {}),
+            override_env_vars=False,
+        )
+        inst_cls = self._registered_types.get(inst_type)
+        error.value_check(
+            "<COR41162423E>",
+            inst_cls is not None,
+            "No {} class registered for type {}",
+            self.name,
+            inst_type,
+        )
+        return inst_cls(inst_cfg)


Test patch:
diff --git a/tests/core/toolkit/test_factory.py b/tests/core/toolkit/test_factory.py
new file mode 100644
index 00000000..4571835a
--- /dev/null
+++ b/tests/core/toolkit/test_factory.py
@@ -0,0 +1,87 @@
+# Copyright The Caikit Authors
+#
+# 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.
+"""
+Tests for the common factory implementation
+"""
+
+# Third Party
+import pytest
+
+# First Party
+import aconfig
+
+# Local
+from caikit.core.toolkit import factory
+
+## Helpers #####################################################################
+
+
+class TypeOne(factory.FactoryConstructible):
+    name = "one"
+
+    def __init__(self, config):
+        self.config = config
+
+
+class TypeTwo(factory.FactoryConstructible):
+    name = "two"
+
+    def __init__(self, config):
+        self.config = config
+
+
+class TypeOtherOne(factory.FactoryConstructible):
+    name = "one"
+
+    def __init__(self, config):
+        self.config = config
+
+
+## Tests #######################################################################
+
+
+def test_factory_happy_path():
+    """Make sure that a factory works when used correctly"""
+    fact = factory.Factory("Test")
+    fact.register(TypeOne)
+    fact.register(TypeTwo)
+
+    inst_one = fact.construct({"type": "one"})
+    assert isinstance(inst_one, TypeOne)
+    assert isinstance(inst_one.config, aconfig.Config)
+
+    inst_two = fact.construct({"type": "two", "config": {"foo": 1}})
+    assert isinstance(inst_two, TypeTwo)
+    assert isinstance(inst_two.config, aconfig.Config)
+    assert inst_two.config.foo == 1
+
+
+def test_factory_unregistered_error():
+    """Make sure that asking to instantiate an unregistered type raises a
+    ValueError
+    """
+    fact = factory.Factory("Test")
+    with pytest.raises(ValueError):
+        fact.construct({"type": "one"})
+
+
+def test_factory_duplicate_registration():
+    """Make sure that double registering a type is ok, but conflicting
+    registration is not
+    """
+    fact = factory.Factory("Test")
+    fact.register(TypeOne)
+    fact.register(TypeOne)
+    with pytest.raises(ValueError):
+        fact.register(TypeOtherOne)