Repository: caikit/caikit (python)

Issue:
Support Functional Inheritance
## Describe the bug

When creating a `class` that inherits from a `DataObjectBase` and overrides parent functions without adding or changing any of the schema, an exception is raised:

```
Traceback (most recent call last):
  File "<stdin>", line 2, in <module>
  File "/Users/ghart/Projects/github/caikit/caikit2/caikit/core/data_model/dataobject.py", line 88, in __new__
    raise TypeError(
TypeError: All DataObjectBase classes must follow dataclass syntax
```

## Sample Code

```py
from caikit.core import DataObjectBase, dataobject

@dataobject
class Base(DataObjectBase):
    foo: int
    def doit(self):
        return self.foo * 2

@dataobject
class Derived(Base):
    def doit(self):
        return self.foo * 3
```

## Expected behavior

```py
b_inst = Base(1)
assert b_inst.doit() == 2

d_inst = Derived(1)
assert d_inst.doit() == 3
```

## Observed behavior

```
Traceback (most recent call last):
  File "<stdin>", line 2, in <module>
  File "/Users/ghart/Projects/github/caikit/caikit2/caikit/core/data_model/dataobject.py", line 88, in __new__
    raise TypeError(
TypeError: All DataObjectBase classes must follow dataclass syntax
```

## Additional context

This will be very important for allowing content libraries to share common data structures with custom backend, construction, and functional interfaces


Pull request:
Dm function inheritance 210
## Description

Closes #210 

This PR makes it possible for classes to inherit from `@dataobject` classes and only override functionality without overriding the fields.

## Open Question

As written, the `_proto_class` for the derived class will be a different protobuf object with a matching schema. If the goal here is to be able to have private server-side overrides with public client-side schemas (which I believe is what @tharapalanivel and @prashantgupta24 need), we may need to dig further on whether we can fully reuse the `_proto_class`. This would potentially cause conflicts when trying to look up the data model class for a given proto, though.

Patch:
diff --git a/caikit/core/data_model/dataobject.py b/caikit/core/data_model/dataobject.py
index 00bc7750..efa08924 100644
--- a/caikit/core/data_model/dataobject.py
+++ b/caikit/core/data_model/dataobject.py
@@ -84,10 +84,25 @@ class _DataObjectBaseMetaClass(_DataBaseMetaClass):
         # Get the annotations that will go into the dataclass
         if name != "DataObjectBase":
             field_names = attrs.get("__annotations__")
-            if field_names is None:
+            parent_dataobjects = [
+                base for base in bases if isinstance(base, _DataBaseMetaClass)
+            ]
+            field_name_sets = [base.fields for base in parent_dataobjects]
+            if field_names is not None:
+                field_name_sets = [field_names] + field_name_sets
+
+            # We need at least one field name set!
+            if not field_name_sets:
                 raise TypeError(
                     "All DataObjectBase classes must follow dataclass syntax"
                 )
+
+            # Flatten the field names
+            field_names = {
+                field_name
+                for field_name_set in field_name_sets
+                for field_name in field_name_set
+            }
             attrs[_DataBaseMetaClass._FWD_DECL_FIELDS] = field_names
 
         # Delegate to the base metaclass


Test patch:
diff --git a/tests/core/data_model/test_dataobject.py b/tests/core/data_model/test_dataobject.py
index 3048a146..c606d8af 100644
--- a/tests/core/data_model/test_dataobject.py
+++ b/tests/core/data_model/test_dataobject.py
@@ -876,3 +876,27 @@ def test_dataobject_inheritance(temp_dpool):
     assert inst.foo == 1
     assert inst.bar == "asdf"
     assert inst.baz == "qwer"
+
+
+def test_dataobject_function_inheritance(temp_dpool):
+    """Make sure inheritance works to override functionality without changing
+    the schema of the parent
+    """
+
+    @dataobject
+    class Base(DataObjectBase):
+        foo: int
+
+        def doit(self):
+            return self.foo * 2
+
+    @dataobject
+    class Derived(Base):
+        def doit(self):
+            return self.foo * 3
+
+    b_inst = Base(1)
+    assert b_inst.doit() == 2
+
+    d_inst = Derived(1)
+    assert d_inst.doit() == 3