Skip to content

Commit c951116

Browse files
authored
Merge pull request #579 from twisted/10-c3linear
Correctly compute class method resolution order
2 parents 79d472b + f990d8f commit c951116

File tree

14 files changed

+621
-82
lines changed

14 files changed

+621
-82
lines changed

README.rst

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,7 @@ What's New?
7575

7676
in development
7777
^^^^^^^^^^^^^^
78+
* Fix long standing bugs in ``Class`` method resolution order.
7879
* Improve the extensibility of pydoctor (`more infos on extensions <https://pydoctor.readthedocs.io/en/latest/customize.html#use-a-custom-system-class>`_)
7980
* Fix line numbers in reStructuredText xref warnings.
8081
* Add support for `twisted.python.deprecated` (this was originally part of Twisted's customizations).

docs/tests/test.py

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -236,3 +236,19 @@ def getText(node: ET.Element) -> str:
236236
privacy = getText(liobj.findall('./div[@class=\'privacy\']')[0])
237237
# check that it's indeed private
238238
assert privacy == 'HIDDEN'
239+
240+
def test_missing_subclasses():
241+
"""
242+
Test for missing subclasses of ParsedDocstring, issue https://github.com/twisted/pydoctor/issues/528.
243+
"""
244+
245+
infos = ('pydoctor.epydoc.markup._types.ParsedTypeDocstring',
246+
'pydoctor.epydoc.markup.epytext.ParsedEpytextDocstring',
247+
'pydoctor.epydoc.markup.plaintext.ParsedPlaintextDocstring',
248+
'pydoctor.epydoc.markup.restructuredtext.ParsedRstDocstring',
249+
'pydoctor.epydoc2stan.ParsedStanOnly')
250+
251+
with open(BASE_DIR / 'api' / 'pydoctor.epydoc.markup.ParsedDocstring.html', 'r', encoding='utf-8') as stream:
252+
page = stream.read()
253+
for i in infos:
254+
assert i in page, page

mypy.ini

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,10 @@ ignore_missing_imports=True
5454
[mypy-pydoctor.epydoc.sre_parse36]
5555
ignore_errors=True
5656

57+
# Don't check the C3 lineartization code
58+
[mypy-pydoctor.mro.*]
59+
ignore_errors=True
60+
5761
# Don't check our test data:
5862

5963
[mypy-pydoctor.test.testpackages.*]

pydoctor/astbuilder.py

Lines changed: 14 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -229,22 +229,19 @@ def visit_ClassDef(self, node: ast.ClassDef) -> None:
229229
raise self.SkipNode()
230230

231231
rawbases = []
232-
bases = []
233-
baseobjects = []
232+
initialbases = []
233+
initialbaseobjects = []
234234

235235
for n in node.bases:
236-
if isinstance(n, ast.Name):
237-
str_base = n.id
238-
else:
239-
str_base = astor.to_source(n).strip()
240-
236+
# TODO: Handle generics in MRO
237+
str_base = '.'.join(node2dottedname(n) or [astor.to_source(n).strip()])
241238
rawbases.append(str_base)
242-
full_name = parent.expandName(str_base)
243-
bases.append(full_name)
244-
baseobj = self.system.objForFullName(full_name)
239+
expandbase = parent.expandName(str_base)
240+
baseobj = self.system.objForFullName(expandbase)
245241
if not isinstance(baseobj, model.Class):
246242
baseobj = None
247-
baseobjects.append(baseobj)
243+
initialbases.append(expandbase)
244+
initialbaseobjects.append(baseobj)
248245

249246
lineno = node.lineno
250247
if node.decorator_list:
@@ -253,8 +250,8 @@ def visit_ClassDef(self, node: ast.ClassDef) -> None:
253250
cls: model.Class = self.builder.pushClass(node.name, lineno)
254251
cls.decorators = []
255252
cls.rawbases = rawbases
256-
cls.bases = bases
257-
cls.baseobjects = baseobjects
253+
cls._initialbaseobjects = initialbaseobjects
254+
cls._initialbases = initialbases
258255

259256
if len(node.body) > 0 and isinstance(node.body[0], ast.Expr) and isinstance(node.body[0].value, ast.Str):
260257
cls.setDocstring(node.body[0].value)
@@ -279,10 +276,10 @@ def visit_ClassDef(self, node: ast.ClassDef) -> None:
279276
else:
280277
cls.decorators.append((base, args))
281278
cls.raw_decorators = node.decorator_list if node.decorator_list else []
282-
283-
for b in cls.baseobjects:
284-
if b is not None:
285-
b.subclasses.append(cls)
279+
280+
# We're not resolving the subclasses at this point yet because all
281+
# modules might not have been processed, and since subclasses are only used in the presentation,
282+
# it's better to resolve them in the post-processing instead.
286283

287284
def depart_ClassDef(self, node: ast.ClassDef) -> None:
288285
self.builder.popClass()

pydoctor/extensions/zopeinterface.py

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -62,7 +62,7 @@ def _inheritedDocsources(obj: model.Documentable) -> Iterator[model.Documentable
6262
io = obj.system.objForFullName(interface)
6363
if io is not None:
6464
assert isinstance(io, ZopeInterfaceClass)
65-
for io2 in io.allbases(include_self=True):
65+
for io2 in io.mro():
6666
if name in io2.contents:
6767
yield io2.contents[name]
6868

@@ -182,15 +182,13 @@ def _handleZopeInterfaceAssignmentInModule(self,
182182
return
183183
ob = self.visitor.system.objForFullName(funcName)
184184
if isinstance(ob, ZopeInterfaceClass) and ob.isinterfaceclass:
185-
# TODO: Process 'bases' and '__doc__' arguments.
185+
# TODO: Process 'rawbases' and '__doc__' arguments.
186186
# TODO: Currently, this implementation will create a duplicate class
187187
# with the same name as the attribute, overriding it.
188188
interface = self.visitor.builder.pushClass(target, lineno)
189189
assert isinstance(interface, ZopeInterfaceClass)
190190
interface.isinterface = True
191191
interface.implementedby_directly = []
192-
interface.bases = []
193-
interface.baseobjects = []
194192
self.visitor.builder.popClass()
195193
self.visitor.builder.currentAttr = interface
196194

pydoctor/model.py

Lines changed: 131 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@
2525
from urllib.parse import quote
2626

2727
from pydoctor.options import Options
28-
from pydoctor import factory, qnmatch, utils, linker, astutils
28+
from pydoctor import factory, qnmatch, utils, linker, astutils, mro
2929
from pydoctor.epydoc.markup import ParsedDocstring
3030
from pydoctor.sphinx import CacheT, SphinxInventory
3131

@@ -481,12 +481,69 @@ def submodules(self) -> Iterator['Module']:
481481
class Package(Module):
482482
kind = DocumentableKind.PACKAGE
483483

484+
def compute_mro(cls:'Class') -> List[Union['Class', str]]:
485+
"""
486+
Compute the method resolution order for this class.
487+
This function will also set the
488+
C{_finalbaseobjects} and C{_finalbases} attributes on
489+
this class and all it's superclasses.
490+
"""
491+
def init_finalbaseobjects(o: 'Class', path:Optional[List['Class']]=None) -> None:
492+
if not path:
493+
path = []
494+
if o in path:
495+
cycle_str = " -> ".join([o.fullName() for o in path[path.index(cls):] + [cls]])
496+
raise ValueError(f"Cycle found while computing inheritance hierarchy: {cycle_str}")
497+
path.append(o)
498+
if o._finalbaseobjects is not None:
499+
return
500+
if o.rawbases:
501+
finalbaseobjects: List[Optional[Class]] = []
502+
finalbases: List[str] = []
503+
for i,(str_base, base) in enumerate(zip(o.rawbases, o._initialbaseobjects)):
504+
if base:
505+
finalbaseobjects.append(base)
506+
finalbases.append(base.fullName())
507+
else:
508+
resolved_base = o.parent.resolveName(str_base)
509+
if isinstance(resolved_base, Class):
510+
base = resolved_base
511+
finalbaseobjects.append(base)
512+
finalbases.append(base.fullName())
513+
else:
514+
# the base object could not be resolved
515+
finalbaseobjects.append(None)
516+
finalbases.append(o._initialbases[i])
517+
if base:
518+
init_finalbaseobjects(base, path.copy())
519+
o._finalbaseobjects = finalbaseobjects
520+
o._finalbases = finalbases
521+
522+
def localbases(o:'Class') -> Iterator[Union['Class', str]]:
523+
"""
524+
Like L{Class.baseobjects} but fallback to the expanded name if the base is not resolved to a L{Class} object.
525+
"""
526+
for s,b in zip(o.bases, o.baseobjects):
527+
if isinstance(b, Class):
528+
yield b
529+
else:
530+
yield s
531+
532+
def getbases(o:Union['Class', str]) -> List[Union['Class', str]]:
533+
if isinstance(o, str):
534+
return []
535+
return list(localbases(o))
536+
537+
init_finalbaseobjects(cls)
538+
return mro.mro(cls, getbases)
484539

485540
class Class(CanContainImportsDocumentable):
486541
kind = DocumentableKind.CLASS
487542
parent: CanContainImportsDocumentable
488-
bases: List[str]
489-
baseobjects: List[Optional['Class']]
543+
_initialbaseobjects: List[Optional['Class']]
544+
_finalbaseobjects: Optional[List[Optional['Class']]] = None # set in post-processing
545+
_initialbases: List[str]
546+
_finalbases: Optional[List[str]] = None # set in post-processing
490547
decorators: Sequence[Tuple[str, Optional[Sequence[ast.expr]]]]
491548
# Note: While unused in pydoctor itself, raw_decorators is still in use
492549
# by Twisted's custom System class, to find deprecations.
@@ -501,8 +558,68 @@ def setup(self) -> None:
501558
super().setup()
502559
self.rawbases: List[str] = []
503560
self.subclasses: List[Class] = []
561+
self._initialbases = []
562+
self._initialbaseobjects = []
563+
self._mro: Optional[List[Union['Class', str]]] = None
564+
565+
def _init_mro(self) -> None:
566+
"""
567+
Compute the correct value of the method resolution order returned by L{mro()}.
568+
"""
569+
try:
570+
self._mro = compute_mro(self)
571+
except ValueError as e:
572+
self.report(str(e), 'mro')
573+
self._mro = list(self.allbases(True))
504574

575+
@overload
576+
def mro(self, include_external:'Literal[True]', include_self:bool=True) -> List[Union['Class', str]]:...
577+
@overload
578+
def mro(self, include_external:'Literal[False]'=False, include_self:bool=True) -> List['Class']:...
579+
def mro(self, include_external:bool=False, include_self:bool=True) -> List[Union['Class', str]]: # type:ignore[misc]
580+
"""
581+
Get the method resution order of this class.
582+
583+
@note: The actual correct value is only set in post-processing, if L{mro()} is called
584+
in the AST visitors, it will return the same as C{list(self.allbases(include_self))}.
585+
"""
586+
if self._mro is None:
587+
return list(self.allbases(include_self))
588+
589+
_mro: List[Union[str, Class]]
590+
if include_external is False:
591+
_mro = [o for o in self._mro if not isinstance(o, str)]
592+
else:
593+
_mro = self._mro
594+
if include_self is False:
595+
_mro = _mro[1:]
596+
return _mro
597+
598+
@property
599+
def bases(self) -> List[str]:
600+
"""
601+
Fully qualified names of the bases of this class.
602+
"""
603+
return self._finalbases if \
604+
self._finalbases is not None else self._initialbases
605+
606+
@property
607+
def baseobjects(self) -> List[Optional['Class']]:
608+
"""
609+
Base objects, L{None} value is inserted when the base class could not be found in the system.
610+
611+
@note: This property is currently computed two times, a first time when we're visiting the ClassDef and initially creating the object.
612+
It's computed another time in post-processing to try to resolve the names that could not be resolved the first time. This is needed when there are import cycles.
613+
614+
Meaning depending on the state of the system, this property can return either the initial objects or the final objects
615+
"""
616+
return self._finalbaseobjects if \
617+
self._finalbaseobjects is not None else self._initialbaseobjects
618+
505619
def allbases(self, include_self: bool = False) -> Iterator['Class']:
620+
"""
621+
Iterate on all base objects of this class and it's super classes. Doesn't comply with MRO.
622+
"""
506623
if include_self:
507624
yield self
508625
for b in self.baseobjects:
@@ -514,7 +631,7 @@ def find(self, name: str) -> Optional[Documentable]:
514631
515632
@return: the object with the given name, or L{None} if there isn't one
516633
"""
517-
for base in self.allbases(include_self=True):
634+
for base in self.mro():
518635
obj: Optional[Documentable] = base.contents.get(name)
519636
if obj is not None:
520637
return obj
@@ -554,7 +671,7 @@ def docsources(self) -> Iterator[Documentable]:
554671
yield self
555672
if not isinstance(self.parent, Class):
556673
return
557-
for b in self.parent.allbases(include_self=False):
674+
for b in self.parent.mro(include_self=False):
558675
if self.name in b.contents:
559676
yield b.contents[self.name]
560677

@@ -1006,8 +1123,6 @@ def _introspectThing(self, thing: object, parent: Documentable, parentMod: _Modu
10061123
self.addObject(f)
10071124
elif isinstance(v, type):
10081125
c = self.Class(self, k, parent)
1009-
c.bases = []
1010-
c.baseobjects = []
10111126
c.rawbases = []
10121127
c.parentMod = parentMod
10131128
c.docstring = v.__doc__
@@ -1162,6 +1277,15 @@ def postProcess(self) -> None:
11621277
without the risk of drawing incorrect conclusions because modules
11631278
were not fully processed yet.
11641279
"""
1280+
1281+
# default post-processing includes processing of subclasses and MRO computing.
1282+
for cls in self.objectsOfType(Class):
1283+
cls._init_mro()
1284+
for b in cls.baseobjects:
1285+
if b is not None:
1286+
b.subclasses.append(cls)
1287+
1288+
11651289
for post_processor in self._post_processors:
11661290
post_processor(self)
11671291

0 commit comments

Comments
 (0)