Factory / Builder Design Pattern in Python Situation: I have implemented a factory design pattern in Python as below. There are different screens which have different configurations. One can register the screen in the factory and get the screen in case correct configuration is passed to respective factory class method.
Question: I am wondering if this is a clean solution as each screen class (Screen1, Screen2, ..) has a main-method which calls in a specific order the respective private class methods. Is there a better/cleaner way to handle the main methods in each class? Is it cleaner to implement a combination of factory and builder design pattern?
Highly appreciate any comments and improvements!!
Code:
import numpy as np
import pandas as pd

from abc import ABCMeta, abstractmethod 

df = pd.DataFrame({&quot;ident&quot;: [&quot;A1&quot;, &quot;A2&quot;, &quot;B3&quot;, &quot;B4&quot;], &quot;other_col&quot;: np.random.randint(1, 6, 4)})


class IScreens(metaclass=ABCMeta):

    @abstractmethod
    def main(self):
        pass 
    

class Screen1(IScreens):
    
    def __init__(self, data, config):
        self.data = data
        self.batch = config[&quot;cfg1&quot;]    
    
    def __get_letter(self):
        self.data[&quot;campaign&quot;] = self.data[self.batch].str[:1]

        return self.data
    
    def __get_number(self):
        self.data[&quot;num&quot;] = self.data[self.batch].str[1:]

        return self.data
    
    def __some_other_stuff(self):
        self.data[&quot;other_stuff&quot;] = self.data[&quot;num&quot;].astype(int) * 100 / 2

        return self.data
    
    def main(self):
        self.data = self.__get_letter()
        self.data = self.__get_number()
        self.data = self.__some_other_stuff()
        # some more processing steps follow 

        return self.data
    
    
class Screen2(IScreens):
    
    def __init__(self, data, config):
        self.data = data
        self.batch = config[&quot;cfg1&quot;]    
    
    def __some_cool_stuff(self):
        self.data[&quot;cool_other_stuff&quot;] = self.data[self.batch] + &quot;_COOOOL&quot;

        return self.data
    
    def main(self):
        self.data = self.__some_cool_stuff()
        # some more processing steps follow 
        
        return self.data
        

class ScreenFactory:

    def __init__(self):
        self._screens = {}

    def register_screen(self, screen_name, screen_class):
        self._screens[screen_name] = screen_class
    
    def get_screen(self, data, config):
        
        if &quot;screen_indicator&quot; not in config:
            raise AssertionError(&quot;Your config file does not include 'screen_indicator' key&quot;)
        
        screen = self._screens.get(config[&quot;screen_indicator&quot;])
        if not screen:
            raise AssertionError(&quot;screen not implemented&quot;)
        return screen(data=data, config=config)
    

factory = ScreenFactory()

factory.register_screen(screen_name=&quot;s1&quot;, screen_class=Screen1)
config = {&quot;screen_indicator&quot;: &quot;s1&quot;, &quot;cfg1&quot;: &quot;ident&quot;}
factory.get_screen(data=df, config=config).main()

factory.register_screen(screen_name=&quot;s2&quot;, screen_class=Screen2)
config = {&quot;screen_indicator&quot;: &quot;s2&quot;, &quot;cfg1&quot;: &quot;ident&quot;}
factory.get_screen(data=df, config=config).main()
```
IScreens is a callable
Actually, your IScreens class (interface) contains only one abstract method (the main method), so this class is a kind of callable.
Instead of creating a specific class, you can use collections.Callable:
import collections

IScreens = collections.Callable

To use that class, you can do:
class Screen1(IScreens):
    def __init__(self, data, config):
          ...

    def __call__(self, *args, **kwargs):  # instead of `main`
        return ...

s1 = Screen1(data=..., config=...)
result = s1()  # call the callable s1

IScreens can contain common data
I see that the data and config variables are common to Screen1 and Screen2.
It's a good practice to factorize this in the base class:
class IScreens(collections.Callable):
    def __init__(self, data, config):
        self.data = data
        self.batch = config[&quot;cfg1&quot;]


NOTE: Here, I don't understand why you pass a config dictionary, but only get the batch (config[&quot;cfg1&quot;]).

Avoid extra complexity
Private methods with double underscores, like __get_letter, are more difficult to debug,
because their names are disguised into _{className}__{attr_name}.
For instance:
class C:
    def __init__(self):
        self.__secret = &quot;foo&quot;

obj = C()
print(obj._C__secret)
# 'foo'

Very short functions like __get_letter should be avoided, unless reused several times.
Here, you add complexity (and extra computation time) with them.
Getters should not mutate the instance
A getter should not change the class instance state.
Here, in your __get_letter method, you are extracting a value (self.data[self.batch].str[:1])
and modifying the internal state (self.data[&quot;campaign&quot;]). Is it intentional?
It is better to initialize everything in your constructor:
class Screen1(IScreens):
    def __init__(self, data, config):
        self.data = data
        self.batch = config[&quot;cfg1&quot;]
        self.data[&quot;campaign&quot;] = self.data[self.batch].str[:1]
        self.data[&quot;num&quot;] = self.data[self.batch].str[1:]
        self.data[&quot;other_stuff&quot;] = self.data[&quot;num&quot;].astype(int) * 100 / 2

Factory registration
There are two different ways to implement factories:

The factory (or the main function) knows all its classes and is responsible for the class registration,
Each class knows the factory and it can auto-register itself, like a plugin.

It seems that you choose the first case. You may also need to add a method to unregister.
In the second case, registration is usually final: its more difficult to unregister…
Both are good, so.
To register a class in a factory, we used to use the class name (or a class instance) instead of an arbitrary name.
Example using the class name:
class ScreenFactory:
    def __init__(self):
        self._screens = {}

    def register_screen(self, screen_class):
        self._screens[screen_class.__name__] = screen_class

    def get_screen_class(self, class_name: str):
        return self._screens[class_name]

Example using the class attribute:
class IScreens:
    kind = &quot;unknown&quot;


class Screen1(IScreens):
    kind = &quot;s1&quot;


class ScreenFactory:
    def __init__(self):
        self._screens = {}

    def register_screen(self, screen_class):
        self._screens[screen_class.kind] = screen_class

    def get_screen_class(self, kind: str) -&gt; IScreens:
        return self._screens[kind]

The second method is interesting if you want to select an implementation (a IScreens class)
according to a tag/label extracted from textual data…

NOTE: you may have noticed that I added a get_screen_class method.

Factory access

This is the difficult part

In your get_screen method, you are checking if a specific configuration is valid (existence of &quot;screen_indicator&quot;)
and if there is a suitable class which satisfy a second condition (config[&quot;screen_indicator&quot;] is a kind of IScreens).
I am OK with that, but it can be simplified. Here is a solution with error handling:
class ScreenNotImplementedError(Exception):
    fmt = &quot;Screen not implemented for this configuration: {config}&quot;

    def __init__(self, *, config):
        msg = self.fmt.format(config=config)
        super(ScreenNotImplementedError, self).__init__(msg)


class ScreenFactory:
    ...

    def get_screen(self, data, config):
        try:
            kind = config[&quot;screen_indicator&quot;]  # may raise KeyError
            cls = self.get_screen_class(kind)  # may raise KeyError
        except KeyError:
            raise ScreenNotImplementedError(config=config)
        else:
            screen = cls(data=data, config=config)
            return screen