3

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.

chepner
538k77 gold badges596 silver badges747 bronze badges
asked Sep 9, 2019 at 6:11
5
  • Did you try to debug your __init_subclass__? Commented Sep 9, 2019 at 6:53
  • @taras One of the original unit tests was to verify that the __init__subclass__ is doing its job. Commented Sep 9, 2019 at 7:01
  • 1
    You may wanna read this: en.wikipedia.org/wiki/Composition_over_inheritance Commented Sep 9, 2019 at 7:41
  • isinstance(self, CBlock)? Commented Sep 9, 2019 at 7:48
  • 1
    @stefan Yes. That line does nothing in theory (no assignment). But if you remove it, it changes the outcome! Commented Sep 9, 2019 at 11:50

2 Answers 2

3

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
answered Sep 9, 2019 at 14:04
Sign up to request clarification or add additional context in comments.

3 Comments

Thank you for the investigative work. I think the issue is a bug and should be reported. If you don't intend to do so, I will. May I then use your Animal/Plant example?
Seems like a bug indeed. Class creation should either work or not work, not leave the system in such an ambiguous state. Go ahead and report it; it would make more sense if you do it. I was merely intrigued by this puzzle and it gave me some insight in abstract base classes and __init_subclass__. (Currently refactoring code so old that __new__ wasn't around.)
Thanks a lot for your answer. I want to supplement that in __init_subclass__ phase, the class is already created, you can even assign it to a global variable and later use it as a normal class, so it's up to abc to garantee that _abc_cache is of the class's own rather than of a base class.
0

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()
answered Sep 9, 2019 at 7:48

1 Comment

The posted code has only one purpose - to make the problem easily reproducible. Can you explain why changing the "abstractness" of classes affects the test whether FBlock inherits from AddonDefault?

Your Answer

Draft saved
Draft discarded

Sign up or log in

Sign up using Google
Sign up using Email and Password

Post as a guest

Required, but never shown

Post as a guest

Required, but never shown

By clicking "Post Your Answer", you agree to our terms of service and acknowledge you have read our privacy policy.

Start asking to get answers

Find the answer to your question by asking.

Ask question

Explore related questions

See similar questions with these tags.