2025, Dec 11 19:00

Singletons with defaults in Python: avoid metaclass __call__ pitfalls and use a cleaner pattern

Build a Python singleton with required parameters and defaults: fix metaclass __call__ blocking __init__ defaults, and prefer a module-level instance approach.

When you need a singleton-like object that accepts required parameters but also supports a default value, it’s easy to trip over Python’s instantiation mechanics. A common pitfall is intercepting construction in a metaclass and accidentally shadowing the class’s own defaults, which makes “no-arg” construction fail even though the class defines a default in its __init__.

Problem

The goal is straightforward: create a class with required parameters and a default value, keep it singleton, and update the existing instance on subsequent instantiation attempts. The initial approach below uses a metaclass to enforce a single instance, but calling the class without arguments still raises an error despite the default in __init__.

class MapMeta(type):
    cache = {}
    def __init__(self, payload, *args, **kwargs):
        super().__init__(payload, *args, **kwargs)

    def __call__(cls, payload, *args, **kwargs):
        if cls not in cls.cache:
            cls.cache[cls] = super().__call__(payload, *args, **kwargs)
            return cls.cache[cls]
        else:
            cls.cache[cls].refresh(payload)
            return cls.cache[cls]


class PanelSR(metaclass=MapMeta):
    BASE = 0x40002000
    
    def __init__(self, payload=b'\x20\x00\x00\x00'):
        self.refresh(payload)

    def refresh(self, payload):
        self.word = int.from_bytes(payload, 'little')
        self.ENABLE = self.word & 1
        self.SOF = (self.word >> 1) & 1
        self.UDR = (self.word >> 2) & 1
        self.UDD = (self.word >> 3) & 1
        self.RDY = (self.word >> 4) & 1
        self.FCRSF = (self.word >> 5) & 1
        
    def __repr__(self):
        return ''.join([
            'ENS | ' if self.ENABLE else '',
            'SOF | ' if self.SOF else '',
            'UDR | ' if self.UDR else '',
            'UDD | ' if self.UDD else '',
            'RDY | ' if self.RDY else '',
            'FCRSF' if self.FCRSF else '\b\b\b   ',
        ])

if __name__ == '__main__':
    obj = PanelSR()  # fails even though __init__ has a default

Why it breaks

Instantiation goes through the metaclass’s __call__ first. Here, __call__ declares payload as a required argument. That short-circuits Python’s usual argument handling and prevents the class’s __init__ default from applying. You never reach a point where the default in PanelSR.__init__ can be used, because the metaclass already rejected the call.

There is also unnecessary complexity in MapMeta.__init__, which doesn’t add anything. Metaclasses are often overkill for singletons and can make the code harder to reason about.

Fix with the metaclass approach

The metaclass should not prescribe arguments at all. Let the class’s own __init__ handle defaults by forwarding everything as-is via *args and **kwargs. When the instance already exists, call its __init__ to reuse the same argument handling and to update state.

class MapMeta(type):
    cache = {}

    def __call__(cls, *args, **kwargs):
        if cls not in cls.cache:
            inst = cls.cache[cls] = super().__call__(*args, **kwargs)
        else:
            inst = cls.cache[cls]
            inst.__init__(*args, **kwargs)
        return inst


class PanelSR(metaclass=MapMeta):
    BASE = 0x40002000

    def __init__(self, payload=b'\x20\x00\x00\x00'):
        self.refresh(payload)

    def refresh(self, payload):
        self.word = int.from_bytes(payload, 'little')
        self.ENABLE = self.word & 1
        self.SOF = (self.word >> 1) & 1
        self.UDR = (self.word >> 2) & 1
        self.UDD = (self.word >> 3) & 1
        self.RDY = (self.word >> 4) & 1
        self.FCRSF = (self.word >> 5) & 1

    def __repr__(self):
        return ''.join([
            'ENS | ' if self.ENABLE else '',
            'SOF | ' if self.SOF else '',
            'UDR | ' if self.UDR else '',
            'UDD | ' if self.UDD else '',
            'RDY | ' if self.RDY else '',
            'FCRSF' if self.FCRSF else '\b\b\b   ',
        ])

This preserves the singleton behavior and fixes the default argument issue without changing the class’s semantics.

Preferred design without a metaclass

Singletons in Python do not require metaclasses. A cleaner and more maintainable approach is to create a single instance at module level and use it everywhere. If you want the same callable ergonomics as with class invocation, give the instance a __call__ that updates its state.

class _PanelSR:
    ADDR = 0x40002000
    
    def __init__(self, payload=b'\x20\x00\x00\x00'):
        self.refresh(payload)

    def refresh(self, payload):
        self.word = int.from_bytes(payload, 'little')
        self.ENABLE = self.word & 1
        self.SOF = (self.word >> 1) & 1
        self.UDR = (self.word >> 2) & 1
        self.UDD = (self.word >> 3) & 1
        self.RDY = (self.word >> 4) & 1
        self.FCRSF = (self.word >> 5) & 1

    def __call__(self, payload=b'\x20\x00\x00\x00'):
        self.refresh(payload)
        return self

    def __repr__(self):
        return ''.join([
            'ENS | ' if self.ENABLE else '',
            'SOF | ' if self.SOF else '',
            'UDR | ' if self.UDR else '',
            'UDD | ' if self.UDD else '',
            'RDY | ' if self.RDY else '',
            'FCRSF' if self.FCRSF else '\b\b\b   ',
        ])

PANEL_SR = _PanelSR()

del _PanelSR

if __name__ == '__main__':
    s = PANEL_SR()

This keeps the object model simple. You always interact with PANEL_SR, and calling it updates the data, mirroring the “re-instantiation updates the state” requirement without any metaclass machinery.

Why this matters

Understanding that metaclass __call__ runs before a class’s __init__ helps avoid subtle bugs where defaults appear to vanish. Allowing Python’s argument machinery to do its work by forwarding *args and **kwargs keeps construction rules in one place. Just as important, singletons can be implemented without metaclasses, reducing cognitive overhead and minimizing the likelihood of hard-to-debug lifecycle issues.

Takeaways

If you truly need a metaclass-enforced singleton, don’t specify parameters on the metaclass’s __call__; forward everything and reuse the instance’s __init__ to update state. If you can, prefer a plain class with a single module-level instance and an instance-level __call__ for updates. Keeping the creation path straightforward aligns with Python’s strengths and results in code that is easier to maintain.