VPfB
VPfB

Reputation: 17247

Why this test fails?

After adding a new unit test I started to get failures in unrelated test run after the new test. I could not understand why.

I have simplified the case to the code below. I still do not understand what is going on. I am surprised that commenting out seemingly unrelated lines of code affects the result: removing the call to isinstance in Block.__init__ changes the result of isinstance(blk, AddonDefault) in test_addons.

import abc

class Addon:
    pass

class AddonDefault(Addon, metaclass=abc.ABCMeta):
    pass

class Block:
    def __init__(self):
        isinstance(self, CBlock)

class CBlock(Block, metaclass=abc.ABCMeta):
    def __init_subclass__(cls, *args, **kwargs):
        if issubclass(cls, Addon):
            raise TypeError("Do not mix Addons and CBlocks!")
        super().__init_subclass__(*args, **kwargs)

class FBlock(CBlock):
    pass

def test_addons():
    try:
        class CBlockWithAddon(CBlock, AddonDefault):
            pass
    except TypeError:
        pass

    blk = FBlock()
    assert not isinstance(blk, AddonDefault), "TEST FAIL"
    print("OK")

test_addons()

When I run python3 test.py I get the TEST FAIL exception. But FBlock is derived from CBlock which is derived from Block. How can it be an instance of AddonDefault?


UPDATE: I'd like to emphasize that the only purpose of the posted code is to demonstrate the behaviour I cannot understand. It was created by reducing a much larger program as much as I was able to. During this process any logic it had before was lost, so please take it as it is and focus on the question why it gives an apparantly incorrect answer.

Upvotes: 3

Views: 334

Answers (2)

BlackShift
BlackShift

Reputation: 2406

Not a full answer, but some hints.

It seems that CBlockWithAddon is still seen as a subclass of AddonDefault. E.g. add two print statements to your test_addons():

def test_addons():
    print(AddonDefault.__subclasses__())
    try:
        class CBlockWithAddon(CBlock, AddonDefault):
            pass
    except TypeError:
        pass

    print(AddonDefault.__subclasses__())
    blk = FBlock()
    assert not isinstance(blk, AddonDefault), "TEST FAIL"
    print("OK")

results in

[]
[<class '__main__.test_addons.<locals>.CBlockWithAddon'>]
...
AssertionError: TEST FAIL

_py_abc tests for this:

    # Check if it's a subclass of a subclass (recursive)
    for scls in cls.__subclasses__():
        if issubclass(subclass, scls):
            cls._abc_cache.add(subclass)
            return True

This will return True when cls=AddonDefault, subclass=FBlock and scls=CBlockWithAddon.

So it seems two things are going wrong:

  • The improperly created CBlockWithAddon is still seen as a subclass of AddonDefault.
  • But CBlockWithAddon is somehow created such that it seems to be a superclass of FBlock.

Perhaps the broken CBlockWithAddon is effectively identical to CBlock, and is therefore a superclass of FBlock.

This is enough for me now. Maybe it helps your investigation.

(I had to use import _py_abc as abc for this analysis. It doesn't seem to matter.)


Edit1: My hunch about CBlockWithAddon resembling its superclass CBlock seems correct:

CBWA = AddonDefault.__subclasses__()[0]
print(CBWA)
print(CBWA.__dict__.keys())
print(CBlock.__dict__.keys())
print(CBWA._abc_cache is CBlock._abc_cache)

gives

<class '__main__.test_addons.<locals>.CBlockWithAddon'>
dict_keys(['__module__', '__doc__'])
dict_keys(['__module__', '__init_subclass__', '__doc__', '__abstractmethods__', '_abc_registry', '_abc_cache', '_abc_negative_cache', '_abc_negative_cache_version'])
True

So CBlockWithAddon is not properly created, e.g. its cache registry is not properly set. So accessing those attributes will access those of the (first) super class, in this case CBlock. The not-so dummy isinstance(self, CBlock) will populate the cache when blk is created, because FBlock is indeed a subclass of CBlock. This cache is then incorrectly reused when isinstance(blk, AddonDefault) is called.

I think this answers the question as is. Now the next question would be: why does CBlockWithAddon become a subclass of CBlock when it was never properly defined?


Edit2: Simpler Proof of Concept.

from abc import ABCMeta

class Animal(metaclass=ABCMeta):
    pass

class Plant(metaclass=ABCMeta):
    def __init_subclass__(cls):
        assert not issubclass(cls, Animal), "Plants cannot be Animals"

class Dog(Animal):
    pass

try:
    class Triffid(Animal, Plant):
        pass
except Exception:
    pass

print("Dog is Animal?", issubclass(Dog, Animal))
print("Dog is Plant?", issubclass(Dog, Plant))

will result in

Dog is Animal? True
Dog is Plant? True

Note that changing the order of the print statements will result in

Dog is Plant? False
Dog is Animal? False

Upvotes: 3

collerek
collerek

Reputation: 261

Why are you making sub classes abstract instead of the base classes? Is there some kind of logic behind this?

If you move abstraction one layer up it works as intended otherwise you mix type and abc metaclasses:

import abc

class Addon(metaclass=abc.ABCMeta):
    pass

class AddonDefault(Addon):
    pass

class Block(metaclass=abc.ABCMeta):
    def __init__(self):
        isinstance(self, CBlock)

class CBlock(Block):
    def __init_subclass__(cls, *args, **kwargs):
        if issubclass(cls, Addon):
            raise TypeError("Do not mix Addons and CBlocks!")
        super().__init_subclass__(*args, **kwargs)

class FBlock(CBlock):
    pass

def test_addons():
    try:
        class CBlockWithAddon(CBlock, AddonDefault):
            pass
    except TypeError:
        pass

    blk = FBlock()
    assert not isinstance(blk, AddonDefault), "TEST FAIL"
    print("OK")

test_addons()

Upvotes: 0

Related Questions