I want to implement a class property that behaves differently based on an abstract class method in Python and have its doctest. But running doctest gives an error that I want to avoid.
Here is a minimal example of what I want to accomplish.
_foo
: an abstact class methodbar
: a class property which behaves differently based on foo
inside.# main.py
import abc
class A(abc.ABC):
@classmethod
@abc.abstractmethod
def _foo(cls):
pass
@classmethod
@property
def bar(cls) -> int:
return cls._foo()
class B(A):
"""
>>> B.bar
1
"""
@classmethod
def _foo(cls) -> int:
return 1
class C(A):
"""
>>> C.bar
2
"""
@classmethod
def _foo(cls) -> int:
return 2
if __name__ == '__main__':
print(B.bar)
print(C.bar)
Running the script by $python main.py
works just fine. But running doctest throws an attribute error presumably occurred by A._foo()
.
$ python -m doctest main.py
Traceback (most recent call last):
File ".../.pyenv/versions/3.9.6/lib/python3.9/runpy.py", line 197, in _run_module_as_main
return _run_code(code, main_globals, None,
File ".../.pyenv/versions/3.9.6/lib/python3.9/runpy.py", line 87, in _run_code
exec(code, run_globals)
File ".../.pyenv/versions/3.9.6/lib/python3.9/doctest.py", line 2793, in <module>
sys.exit(_test())
File ".../.pyenv/versions/3.9.6/lib/python3.9/doctest.py", line 2783, in _test
failures, _ = testmod(m, verbose=verbose, optionflags=options)
File ".../.pyenv/versions/3.9.6/lib/python3.9/doctest.py", line 1955, in testmod
for test in finder.find(m, name, globs=globs, extraglobs=extraglobs):
File ".../.pyenv/versions/3.9.6/lib/python3.9/doctest.py", line 939, in find
self._find(tests, obj, name, module, source_lines, globs, {})
File ".../.pyenv/versions/3.9.6/lib/python3.9/doctest.py", line 1001, in _find
self._find(tests, val, valname, module, source_lines,
File ".../.pyenv/versions/3.9.6/lib/python3.9/doctest.py", line 1028, in _find
val = getattr(obj, valname).__func__
AttributeError: 'NoneType' object has no attribute '__func__'
Are there ways to avoid this error?
Using method instead of property seems to work fine.
# use_method.py
import abc
class A(abc.ABC):
@classmethod
@abc.abstractmethod
def _foo(cls):
pass
@classmethod
def bar(cls) -> int:
return cls._foo()
class B(A):
"""
>>> B.bar()
1
"""
@classmethod
def _foo(cls) -> int:
return 1
class C(A):
"""
>>> C.bar()
2
"""
@classmethod
def _foo(cls) -> int:
return 2
if __name__ == '__main__':
print(B.bar())
print(C.bar())
$ python -m doctest -v use_method.py
Trying:
B.bar()
Expecting:
1
ok
Trying:
C.bar()
Expecting:
2
ok
6 items had no tests:
use_method
use_method.A
use_method.A._foo
use_method.A.bar
use_method.B._foo
use_method.C._foo
2 items passed all tests:
1 tests in use_method.B
1 tests in use_method.C
2 tests in 8 items.
2 passed and 0 failed.
Test passed.
But it is not my preference to change API and force users to edit their code only because of test.
You're using @classmethod
to wrap a @property
. That functionality turned out to be a design mistake that caused a lot of weird problems, including this problem. The feature was removed in 3.11 due to all the problems it caused.
You should redesign your class to stop using @classmethod
with @property
. This goes beyond a test issue - if you keep using @classmethod
with @property
, you will be unable to support Python 3.11.
If you really want to keep the same API, you can use a custom @classproperty
decorator:
class classproperty:
def __init__(self, func):
self.func = func
def __get__(self, instance, owner):
return self.func(owner)
# No setter support - descriptor setters don't work on the class