Here is my take at a lightweight interface implementation, where I focus on discoverability of suitable classes from strings (for simplicity, class name is used as an id). Each interface has it's own "registry" of implementations.
My choice of abc
module is due to presence of registration method there, better support from IDEs, and the fact that it's a part of Python standard library. I have not that much explored typing, but there is still one illustration for it as well.
My examples are borrowed from this https://realpython.com/python-interface/#formal-interfaces, though I've enhanced the original.
I am mostly interested critiques towards the interface_meta module. The rest of the code is an illustration.
Targeting Python 3.8+ here.
from __future__ import annotations
##### interface_meta module
import inspect
from abc import ABCMeta
from abc import abstractmethod
from typing import Type
def _registration_hook(cls, subclass):
subclass_name = subclass.__name__
if subclass_name in cls._all_classes:
print(f"Already registered {subclass_name}")
cls._all_classes[subclass_name] = subclass
def all_classes(cls):
return cls._all_classes
def for_id(cls, an_id):
return cls._all_classes.get(an_id)
def comparable(cls, subclass, attr):
cls_attr = getattr(cls, attr, None)
if not hasattr(subclass, attr):
return f"Attribute {attr!r} of {cls.__name__!r} not implemented in {subclass.__name__!r}"
subclass_attr = getattr(subclass, attr, None)
if callable(cls_attr) == callable(subclass_attr) == False:
return
cls_argspec = inspect.getfullargspec(cls_attr)
subclass_argspec = inspect.getfullargspec(subclass_attr)
if cls_argspec != subclass_argspec:
return (f"\nSignature mismatch '{cls.__name__}.{attr}' <-> '{subclass.__name__}.{attr}'."
f"\nIn the interface : {cls_argspec}."
f"\nIn concrete class: {subclass_argspec}")
def subclasshook(cls, subclass):
cls._registration_hook(cls, subclass)
errors = [comparable(cls, subclass, am) for am in cls.__abstractmethods__]
if any(errors):
raise TypeError("".join(e for e in errors if e))
return True
class InterfaceMeta(ABCMeta):
def __new__(mcs, *args, **kwargs):
i = super().__new__(mcs, *args, **kwargs)
i._all_classes = {}
i._registration_hook = _registration_hook
i.__subclasshook__ = classmethod(subclasshook)
i.all_classes = classmethod(all_classes)
i.for_id = classmethod(for_id)
return i
##### examples of interfaces in different modules
class FormalParserInterface(metaclass=InterfaceMeta):
@abstractmethod
def load_data_source(self, path: str, file_name: str) -> str:
"""Load in the data set"""
@abstractmethod
def extract_text(self, full_file_path: str) -> dict:
"""Extract text from the data set"""
FormalParserInterfaceType = Type[FormalParserInterface]
class SuccessfulSubFormalParserInterface(FormalParserInterface):
@abstractmethod
def extract_html(self, full_file_path: str) -> dict:
"""Extract html from the data set"""
return {}
#####
class ParserClassificationInterface(metaclass=InterfaceMeta):
@property
@abstractmethod
def category(self):
"""For classification"""
def get_name(self):
"""Example of concrete method. OK to provide concrete methods when then depend only on other methods
in the same interface"""
return self.__class__.__name__
ParserClassificationInterfaceType = Type[ParserClassificationInterface]
##### Implementation modules
@FormalParserInterface.register
@ParserClassificationInterface.register
class EmlParserNew(FormalParserInterface, ParserClassificationInterface):
"""Extract text from an email."""
category = "EML"
def load_data_source(self, path: str, file_name: str) -> str:
"""Overrides FormalParserInterface.load_data_source()"""
print(self.__class__, "load_data_source", path, file_name)
return ""
def extract_text(self, full_file_path: str) -> dict:
"""A method defined only in EmlParser.
Does not override FormalParserInterface.extract_text()
"""
return {}
#####
@ParserClassificationInterface.register
@FormalParserInterface.register
class PdfParserNew(FormalParserInterface, ParserClassificationInterface):
"""Extract text from a PDF."""
category = "PDF"
def load_data_source(self, path: str, file_name: str) -> str:
"""Overrides FormalParserInterface.load_data_source()"""
print(self.__class__, "load_data_source", path, file_name)
return "does not matter"
def extract_text(self, full_file_path: str) -> dict:
"""Overrides FormalParserInterface.extract_text()"""
print(self.__class__, "extract_text", full_file_path)
return {"k": "does not matter"}
@ParserClassificationInterface.register
@FormalParserInterface.register
class PdfParserNewest(PdfParserNew):
"""Extract text from a PDF."""
def load_data_source(self, path: str, file_name: str) -> str:
"""Overrides FormalParserInterface.load_data_source()"""
print(self.__class__, "load_data_source", path, file_name)
return "does not matter"
##### Usage examples
def get_classification() -> (ParserClassificationInterface | FormalParserInterface):
return ParserClassificationInterface.for_id("PdfParserNew")()
if __name__ == "__main__":
assert issubclass(PdfParserNew, FormalParserInterface) is True
assert issubclass(PdfParserNew, ParserClassificationInterface) is True
assert issubclass(SuccessfulSubFormalParserInterface, FormalParserInterface) is True
try:
issubclass(SuccessfulSubFormalParserInterface, ParserClassificationInterface)
except TypeError as e:
assert str(e) == """Attribute 'category' of 'ParserClassificationInterface'
not implemented in 'SuccessfulSubFormalParserInterface'"""
pdf_parser = PdfParserNew()
pdf_parser.load_data_source("", "")
pdf_parser2 = PdfParserNewest()
pdf_parser2.load_data_source("", "")
eml_parser = EmlParserNew()
eml_parser.load_data_source("", "")
print(FormalParserInterface.all_classes())
print(ParserClassificationInterface.all_classes())
some_parser = get_classification()
print(some_parser.load_data_source("", ""))
assert pdf_parser.category == "PDF"
assert pdf_parser2.category == "PDF"
assert eml_parser.category == "EML"
assert eml_parser.get_name() == "EmlParserNew"
assert isinstance(pdf_parser2, ParserClassificationInterface)
assert isinstance(pdf_parser2, FormalParserInterface)
assert issubclass(PdfParserNew, ParserClassificationInterface)
assert issubclass(PdfParserNewest, FormalParserInterface)
# Ability to find implementation by string id.
print(FormalParserInterface.for_id("PdfParserNew")().load_data_source("", ""))
assert FormalParserInterface.for_id("PdfParserHtml") is None
I am aware of this question, but in my implementation I wanted to to implement dispatching. And yes I am aware of zope.interface, but this implementation is supposed to be lightweight.
Answering some questions. Demo has two lines of examples: One from the Real Python article, and an example of "classification", purpose of which is just to add an attribute or property. There is no greater purpose.
The for_id
is runtime discovery of specific implementation, this is the main reason for the whole library. String id can be easily stored or communicated between systems, making it easy to implement completely declarative code, for example, for rules engine.
My code at the time of this review can be found on github.
InterfaceMeta
, but why not build this ontyping.Protocol
instead? The interface-checking logic itself would be more or less the same, but with the added benefit of users being able to re-use yourProtocol
for static type hinting. \$\endgroup\$__subclasshook__
behavior withtyping.Protocol
. I'll have to ponder this. \$\endgroup\$