From 5302052db2ad145078379e4c8b58bf70c863bf2e Mon Sep 17 00:00:00 2001 From: tristanlatr Date: Sun, 31 Mar 2024 20:17:36 -0400 Subject: [PATCH 1/8] Add support for type alias statements --- pydoctor/astbuilder.py | 8 +++++ pydoctor/model.py | 30 ++++++++++++++----- .../templatewriter/pages/attributechild.py | 19 ++++++++---- pydoctor/test/test_astbuilder.py | 15 ++++++++++ 4 files changed, 58 insertions(+), 14 deletions(-) diff --git a/pydoctor/astbuilder.py b/pydoctor/astbuilder.py index 00fbe0e8f..567983380 100644 --- a/pydoctor/astbuilder.py +++ b/pydoctor/astbuilder.py @@ -766,6 +766,14 @@ def visit_Assign(self, node: ast.Assign) -> None: def visit_AnnAssign(self, node: ast.AnnAssign) -> None: annotation = unstring_annotation(node.annotation, self.builder.current) self._handleAssignment(node.target, annotation, node.value, node.lineno) + + def visit_TypeAlias(self, node: ast.TypeAlias) -> None: + if isinstance(node.name, ast.Name): + annotation = ast.Attribute( + value=ast.Name(id='typing', ctx=ast.Load()), + attr='TypeAlias', + ctx=ast.Load()) + self._handleAssignment(node.name, annotation, node.value, node.lineno) def visit_AugAssign(self, node:ast.AugAssign) -> None: self._handleAssignment(node.target, None, node.value, diff --git a/pydoctor/model.py b/pydoctor/model.py index 31c30ac91..b77530122 100644 --- a/pydoctor/model.py +++ b/pydoctor/model.py @@ -65,6 +65,12 @@ class LineFromAst(int): class LineFromDocstringField(int): "Simple L{int} wrapper for linenumbers coming from docstrings." +class AttributeValueDisplay(Enum): + HIDDEN = 0 + AS_CODE_BLOCK = 1 + # Not used yet. Default values of Attrs-like classes' attributes will one day (and maybe others). + #INLINE = 2 + class DocLocation(Enum): OWN_PAGE = 1 PARENT_PAGE = 2 @@ -906,7 +912,7 @@ def import_mod_from_file_location(module_full_name:str, path: Path) -> types.Mod class System: """A collection of related documentable objects. - PyDoctor documents collections of objects, often the contents of a + Pydoctor documents collections of objects, often the contents of a package. """ @@ -928,13 +934,6 @@ class System: Additional list of extensions to load alongside default extensions. """ - show_attr_value = (DocumentableKind.CONSTANT, - DocumentableKind.TYPE_VARIABLE, - DocumentableKind.TYPE_ALIAS) - """ - What kind of attributes we should display the value for? - """ - def __init__(self, options: Optional['Options'] = None): self.allobjects: Dict[str, Documentable] = {} self.rootobjects: List[_ModuleT] = [] @@ -1120,6 +1119,21 @@ def objectsOfType(self, cls: Union[Type['DocumentableT'], str]) -> Iterator['Doc if isinstance(o, cls): yield o + # What kind of attributes we should pydoctor display the value for? + _show_attr_value = set((DocumentableKind.CONSTANT, + DocumentableKind.TYPE_VARIABLE, + DocumentableKind.TYPE_ALIAS)) + + def showAttrValue(self, ob: Attribute) -> AttributeValueDisplay: + """ + Whether to display the value of the given attribute. + """ + + if ob.kind not in self._show_attr_value or ob.value is None: + return AttributeValueDisplay.HIDDEN + # Attribute is a constant/type alias (with a value), then display it's value + return AttributeValueDisplay.AS_CODE_BLOCK + def privacyClass(self, ob: Documentable) -> PrivacyClass: ob_fullName = ob.fullName() cached_privacy = self._privacyClassCache.get(ob_fullName) diff --git a/pydoctor/templatewriter/pages/attributechild.py b/pydoctor/templatewriter/pages/attributechild.py index e22e84fc2..ee3534409 100644 --- a/pydoctor/templatewriter/pages/attributechild.py +++ b/pydoctor/templatewriter/pages/attributechild.py @@ -5,7 +5,7 @@ from twisted.web.iweb import ITemplateLoader from twisted.web.template import Tag, renderer, tags -from pydoctor.model import Attribute +from pydoctor.model import Attribute, AttributeValueDisplay, DocumentableKind from pydoctor import epydoc2stan from pydoctor.templatewriter import TemplateElement, util from pydoctor.templatewriter.pages import format_decorators @@ -55,9 +55,13 @@ def decorator(self, request: object, tag: Tag) -> "Flattenable": @renderer def attribute(self, request: object, tag: Tag) -> "Flattenable": - attr: List["Flattenable"] = [tags.span(self.ob.name, class_='py-defname')] + is_type_alias = self.ob.kind is DocumentableKind.TYPE_ALIAS + attr: List["Flattenable"] = [] + if is_type_alias: + attr += [tags.span('type', class_='py-keyword'), ' ',] + attr += [tags.span(self.ob.name, class_='py-defname')] _type = self.docgetter.get_type(self.ob) - if _type: + if _type and not is_type_alias: attr.extend([': ', _type]) return attr @@ -78,7 +82,10 @@ def functionBody(self, request: object, tag: Tag) -> "Flattenable": @renderer def constantValue(self, request: object, tag: Tag) -> "Flattenable": - if self.ob.kind not in self.ob.system.show_attr_value or self.ob.value is None: + showval = self.ob.system.showAttrValue(self.ob) + if showval is AttributeValueDisplay.HIDDEN: return tag.clear() - # Attribute is a constant/type alias (with a value), then display it's value - return epydoc2stan.format_constant_value(self.ob) + elif showval is AttributeValueDisplay.AS_CODE_BLOCK: + return epydoc2stan.format_constant_value(self.ob) + else: + assert False diff --git a/pydoctor/test/test_astbuilder.py b/pydoctor/test/test_astbuilder.py index 95ac3d803..3a50a7a39 100644 --- a/pydoctor/test/test_astbuilder.py +++ b/pydoctor/test/test_astbuilder.py @@ -6,6 +6,7 @@ from pydoctor import epydoc2stan from pydoctor.epydoc.markup import DocstringLinker, ParsedDocstring from pydoctor.options import Options +from pydoctor.astutils import unparse from pydoctor.stanutils import flatten, html2stan, flatten_text from pydoctor.epydoc.markup.epytext import Element, ParsedEpytextDocstring from pydoctor.epydoc2stan import format_summary, get_parsed_type @@ -2142,6 +2143,20 @@ def __init__(self): assert mod.contents['F'].contents['Pouet'].kind == model.DocumentableKind.INSTANCE_VARIABLE assert mod.contents['F'].contents['Q'].kind == model.DocumentableKind.INSTANCE_VARIABLE +@pytest.mark.skipif(sys.version_info < (3,12), reason='Type variable introduced in Python 3.12') +@systemcls_param +def test_type_alias_definition(systemcls: Type[model.System]) -> None: + src = ''' + import typing as t + type One = t.Literal['1', 1] + ''' + mod = fromText(src, systemcls=systemcls) + attr = mod.contents['One'] + assert isinstance(attr, model.Attribute) + assert attr.kind == model.DocumentableKind.TYPE_ALIAS + assert unparse(attr.value).strip() == "t.Literal['1', 1]" + + @systemcls_param def test_typevartuple(systemcls: Type[model.System]) -> None: """ From 7f9fb551f0d8bf04a782b4ec9a271313f62d2015 Mon Sep 17 00:00:00 2001 From: tristanlatr Date: Sun, 31 Mar 2024 20:43:58 -0400 Subject: [PATCH 2/8] Relax is_using_annotations() checks to work better with manually created nodes --- pydoctor/astutils.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/pydoctor/astutils.py b/pydoctor/astutils.py index 1eb4c1c15..0397a3fef 100644 --- a/pydoctor/astutils.py +++ b/pydoctor/astutils.py @@ -189,20 +189,20 @@ def is_using_typing_classvar(expr: Optional[ast.AST], return is_using_annotations(expr, ('typing.ClassVar', "typing_extensions.ClassVar"), ctx) def is_using_annotations(expr: Optional[ast.AST], - annotations:Sequence[str], + annotations: Collection[str], ctx:'model.Documentable') -> bool: """ Detect if this expr is firstly composed by one of the specified annotation(s)' full name. """ - full_name = node2fullname(expr, ctx) - if full_name in annotations: + full_name, dotted_name = node2fullname(expr, ctx), '.'.join(node2dottedname(expr)) + if full_name in annotations or dotted_name in annotations: return True if isinstance(expr, ast.Subscript): # Final[...] or typing.Final[...] expressions if isinstance(expr.value, (ast.Name, ast.Attribute)): value = expr.value - full_name = node2fullname(value, ctx) - if full_name in annotations: + full_name, dotted_name = node2fullname(value, ctx), '.'.join(node2dottedname(value)) + if full_name in annotations or dotted_name in annotations: return True return False From 81d919a0382d3fd86b720604e6b057a3c686811a Mon Sep 17 00:00:00 2001 From: tristanlatr Date: Sun, 31 Mar 2024 22:34:20 -0400 Subject: [PATCH 3/8] Fix type error --- pydoctor/astutils.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/pydoctor/astutils.py b/pydoctor/astutils.py index 0397a3fef..62b917895 100644 --- a/pydoctor/astutils.py +++ b/pydoctor/astutils.py @@ -194,14 +194,14 @@ def is_using_annotations(expr: Optional[ast.AST], """ Detect if this expr is firstly composed by one of the specified annotation(s)' full name. """ - full_name, dotted_name = node2fullname(expr, ctx), '.'.join(node2dottedname(expr)) + full_name, dotted_name = node2fullname(expr, ctx), '.'.join(node2dottedname(expr) or []) if full_name in annotations or dotted_name in annotations: return True if isinstance(expr, ast.Subscript): # Final[...] or typing.Final[...] expressions if isinstance(expr.value, (ast.Name, ast.Attribute)): value = expr.value - full_name, dotted_name = node2fullname(value, ctx), '.'.join(node2dottedname(value)) + full_name, dotted_name = node2fullname(value, ctx), '.'.join(node2dottedname(value) or []) if full_name in annotations or dotted_name in annotations: return True return False From 4a8db898d052e36521f4a7f55afe8365dd307d3d Mon Sep 17 00:00:00 2001 From: tristanlatr Date: Sun, 31 Mar 2024 22:38:53 -0400 Subject: [PATCH 4/8] Fix mypy --- pydoctor/model.py | 3 ++- pydoctor/test/test_astbuilder.py | 1 + 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/pydoctor/model.py b/pydoctor/model.py index b77530122..a4c190971 100644 --- a/pydoctor/model.py +++ b/pydoctor/model.py @@ -880,6 +880,7 @@ class Attribute(Inheritable): # Work around the attributes of the same name within the System class. _ModuleT = Module _PackageT = Package +_AttributeT = Attribute T = TypeVar('T') @@ -1124,7 +1125,7 @@ def objectsOfType(self, cls: Union[Type['DocumentableT'], str]) -> Iterator['Doc DocumentableKind.TYPE_VARIABLE, DocumentableKind.TYPE_ALIAS)) - def showAttrValue(self, ob: Attribute) -> AttributeValueDisplay: + def showAttrValue(self, ob: _AttributeT) -> AttributeValueDisplay: """ Whether to display the value of the given attribute. """ diff --git a/pydoctor/test/test_astbuilder.py b/pydoctor/test/test_astbuilder.py index 3a50a7a39..de2b6b354 100644 --- a/pydoctor/test/test_astbuilder.py +++ b/pydoctor/test/test_astbuilder.py @@ -2154,6 +2154,7 @@ def test_type_alias_definition(systemcls: Type[model.System]) -> None: attr = mod.contents['One'] assert isinstance(attr, model.Attribute) assert attr.kind == model.DocumentableKind.TYPE_ALIAS + assert attr.value assert unparse(attr.value).strip() == "t.Literal['1', 1]" From 485847964e0b8cf0176664447bfce566f23551a0 Mon Sep 17 00:00:00 2001 From: tristanlatr Date: Sun, 31 Mar 2024 22:48:53 -0400 Subject: [PATCH 5/8] Revert "Relax is_using_annotations() checks to work better with manually created nodes" This reverts commit 7f9fb551f0d8bf04a782b4ec9a271313f62d2015. --- pydoctor/astutils.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/pydoctor/astutils.py b/pydoctor/astutils.py index 62b917895..1eb4c1c15 100644 --- a/pydoctor/astutils.py +++ b/pydoctor/astutils.py @@ -189,20 +189,20 @@ def is_using_typing_classvar(expr: Optional[ast.AST], return is_using_annotations(expr, ('typing.ClassVar', "typing_extensions.ClassVar"), ctx) def is_using_annotations(expr: Optional[ast.AST], - annotations: Collection[str], + annotations:Sequence[str], ctx:'model.Documentable') -> bool: """ Detect if this expr is firstly composed by one of the specified annotation(s)' full name. """ - full_name, dotted_name = node2fullname(expr, ctx), '.'.join(node2dottedname(expr) or []) - if full_name in annotations or dotted_name in annotations: + full_name = node2fullname(expr, ctx) + if full_name in annotations: return True if isinstance(expr, ast.Subscript): # Final[...] or typing.Final[...] expressions if isinstance(expr.value, (ast.Name, ast.Attribute)): value = expr.value - full_name, dotted_name = node2fullname(value, ctx), '.'.join(node2dottedname(value) or []) - if full_name in annotations or dotted_name in annotations: + full_name = node2fullname(value, ctx) + if full_name in annotations: return True return False From 03d336d6daf804d2ae34b5fd9f37c0d09f62750c Mon Sep 17 00:00:00 2001 From: tristanlatr Date: Sun, 31 Mar 2024 22:56:19 -0400 Subject: [PATCH 6/8] Support typer aliases at module level in the main ast builder. --- pydoctor/astbuilder.py | 31 +++++++++++++++++++------------ 1 file changed, 19 insertions(+), 12 deletions(-) diff --git a/pydoctor/astbuilder.py b/pydoctor/astbuilder.py index 567983380..0611afdba 100644 --- a/pydoctor/astbuilder.py +++ b/pydoctor/astbuilder.py @@ -553,18 +553,22 @@ def _handleModuleVar(self, expr: Optional[ast.expr], lineno: int, augassign:Optional[ast.operator], + typealiasdef:bool|None=None, ) -> None: if target in MODULE_VARIABLES_META_PARSERS: # This is metadata, not a variable that needs to be documented, # and therefore doesn't need an Attribute instance. return + default_kind = (model.DocumentableKind.VARIABLE + if not typealiasdef else + model.DocumentableKind.TYPE_ALIAS) parent = self.builder.current obj = parent.contents.get(target) if obj is None: if augassign: return obj = self.builder.addAttribute(name=target, - kind=model.DocumentableKind.VARIABLE, + kind=default_kind, parent=parent) # If it's not an attribute it means that the name is already denifed as function/class @@ -586,7 +590,7 @@ def _handleModuleVar(self, obj.setLineNumber(lineno) self._handleConstant(obj, annotation, expr, lineno, - model.DocumentableKind.VARIABLE) + defaultKind=default_kind) self._storeAttrValue(obj, expr, augassign) self._storeCurrentAttr(obj, augassign) @@ -596,11 +600,14 @@ def _handleAssignmentInModule(self, expr: Optional[ast.expr], lineno: int, augassign:Optional[ast.operator], + typealiasdef:bool, ) -> None: module = self.builder.current assert isinstance(module, model.Module) if not _handleAliasing(module, target, expr): - self._handleModuleVar(target, annotation, expr, lineno, augassign=augassign) + self._handleModuleVar(target, annotation, expr, lineno, + augassign=augassign, + typealiasdef=typealiasdef) def _handleClassVar(self, name: str, @@ -724,16 +731,19 @@ def warn(msg: str) -> None: def _handleAssignment(self, targetNode: ast.expr, - annotation: Optional[ast.expr], - expr: Optional[ast.expr], + annotation: ast.expr|None, + expr: ast.expr|None, lineno: int, - augassign:Optional[ast.operator]=None, + augassign:ast.operator|None=None, + typealiasdef:bool=False, ) -> None: if isinstance(targetNode, ast.Name): target = targetNode.id scope = self.builder.current if isinstance(scope, model.Module): - self._handleAssignmentInModule(target, annotation, expr, lineno, augassign=augassign) + self._handleAssignmentInModule(target, annotation, expr, lineno, + augassign=augassign, + typealiasdef=typealiasdef) elif isinstance(scope, model.Class): if augassign or not self._handleOldSchoolMethodDecoration(target, expr): self._handleAssignmentInClass(target, annotation, expr, lineno, augassign=augassign) @@ -769,11 +779,8 @@ def visit_AnnAssign(self, node: ast.AnnAssign) -> None: def visit_TypeAlias(self, node: ast.TypeAlias) -> None: if isinstance(node.name, ast.Name): - annotation = ast.Attribute( - value=ast.Name(id='typing', ctx=ast.Load()), - attr='TypeAlias', - ctx=ast.Load()) - self._handleAssignment(node.name, annotation, node.value, node.lineno) + self._handleAssignment(node.name, None, node.value, + node.lineno, typealiasdef=True) def visit_AugAssign(self, node:ast.AugAssign) -> None: self._handleAssignment(node.target, None, node.value, From 3a3a7ae89f004dfc1e0df5106fc43c694e065c1f Mon Sep 17 00:00:00 2001 From: tristanlatr Date: Sun, 31 Mar 2024 23:07:01 -0400 Subject: [PATCH 7/8] Add a readme entry --- README.rst | 1 + 1 file changed, 1 insertion(+) diff --git a/README.rst b/README.rst index d0f199647..9e0ac8644 100644 --- a/README.rst +++ b/README.rst @@ -80,6 +80,7 @@ This is the last major release to support Python 3.7. * Drop support for Python 3.6. * Add support for Python 3.12 and Python 3.13. +* Add support for the ``type`` statement introduced in Python 3.12. * Astor is no longer a requirement starting at Python 3.9. * `ExtRegistrar.register_post_processor()` now supports a `priority` argument that is an int. Highest priority callables will be called first during post-processing. From 28be080782bf2ce1e7fe4ed5a64600950fc3dbb5 Mon Sep 17 00:00:00 2001 From: tristanlatr Date: Tue, 2 Apr 2024 20:50:10 -0400 Subject: [PATCH 8/8] Initial code for pep 695 support --- README.rst | 3 +- mypy.ini | 3 + pydoctor/astbuilder.py | 241 ++++++++++-------- pydoctor/epydoc/markup/_pyval_repr.py | 31 ++- pydoctor/epydoc2stan.py | 18 +- pydoctor/model.py | 49 +++- pydoctor/templatewriter/pages/__init__.py | 34 ++- .../templatewriter/pages/attributechild.py | 5 +- pydoctor/templatewriter/util.py | 2 +- pydoctor/test/epydoc/test_pyval_repr.py | 38 +++ pydoctor/test/test_astbuilder.py | 14 +- pydoctor/test/test_epydoc2stan.py | 1 + pydoctor/test/test_templatewriter.py | 123 +++++++++ pydoctor/test/test_zopeinterface.py | 3 + 14 files changed, 438 insertions(+), 127 deletions(-) diff --git a/README.rst b/README.rst index 9e0ac8644..12c2b7543 100644 --- a/README.rst +++ b/README.rst @@ -80,7 +80,8 @@ This is the last major release to support Python 3.7. * Drop support for Python 3.6. * Add support for Python 3.12 and Python 3.13. -* Add support for the ``type`` statement introduced in Python 3.12. +* Add support for the ``type`` statement introduced in Python 3.12 (PEP-695). +* Add support for the embedded type variable declarations in class or function introduced in Python 3.12 (PEP-695). * Astor is no longer a requirement starting at Python 3.9. * `ExtRegistrar.register_post_processor()` now supports a `priority` argument that is an int. Highest priority callables will be called first during post-processing. diff --git a/mypy.ini b/mypy.ini index 854f7a6be..1e7841636 100644 --- a/mypy.ini +++ b/mypy.ini @@ -13,6 +13,9 @@ warn_unused_configs=True warn_unused_ignores=True plugins=mypy_zope:plugin +exclude = (?x)( + ^pydoctor\/test\/testpackages\/ + ) # The following modules are currently only partially annotated: diff --git a/pydoctor/astbuilder.py b/pydoctor/astbuilder.py index 0611afdba..16800131c 100644 --- a/pydoctor/astbuilder.py +++ b/pydoctor/astbuilder.py @@ -255,6 +255,8 @@ def visit_ClassDef(self, node: ast.ClassDef) -> None: cls: model.Class = self.builder.pushClass(node.name, lineno) cls.decorators = [] cls.rawbases = rawbases + if sys.version_info >= (3,12): + cls.typevars = node.type_params cls._initialbaseobjects = initialbaseobjects cls._initialbases = initialbases @@ -553,14 +555,14 @@ def _handleModuleVar(self, expr: Optional[ast.expr], lineno: int, augassign:Optional[ast.operator], - typealiasdef:bool|None=None, + typevars:Sequence[ast.type_param] | None, ) -> None: if target in MODULE_VARIABLES_META_PARSERS: # This is metadata, not a variable that needs to be documented, # and therefore doesn't need an Attribute instance. return default_kind = (model.DocumentableKind.VARIABLE - if not typealiasdef else + if typevars is None else model.DocumentableKind.TYPE_ALIAS) parent = self.builder.current obj = parent.contents.get(target) @@ -593,6 +595,9 @@ def _handleModuleVar(self, defaultKind=default_kind) self._storeAttrValue(obj, expr, augassign) self._storeCurrentAttr(obj, augassign) + + if typevars is not None and obj.kind is model.DocumentableKind.TYPE_ALIAS: + obj.typevars = typevars def _handleAssignmentInModule(self, target: str, @@ -600,14 +605,14 @@ def _handleAssignmentInModule(self, expr: Optional[ast.expr], lineno: int, augassign:Optional[ast.operator], - typealiasdef:bool, + typevars:Sequence[ast.type_param] | None, ) -> None: module = self.builder.current assert isinstance(module, model.Module) if not _handleAliasing(module, target, expr): self._handleModuleVar(target, annotation, expr, lineno, augassign=augassign, - typealiasdef=typealiasdef) + typevars=typevars) def _handleClassVar(self, name: str, @@ -735,7 +740,7 @@ def _handleAssignment(self, expr: ast.expr|None, lineno: int, augassign:ast.operator|None=None, - typealiasdef:bool=False, + typevars:Sequence[ast.type_param] | None=None, ) -> None: if isinstance(targetNode, ast.Name): target = targetNode.id @@ -743,7 +748,7 @@ def _handleAssignment(self, if isinstance(scope, model.Module): self._handleAssignmentInModule(target, annotation, expr, lineno, augassign=augassign, - typealiasdef=typealiasdef) + typevars=typevars) elif isinstance(scope, model.Class): if augassign or not self._handleOldSchoolMethodDecoration(target, expr): self._handleAssignmentInClass(target, annotation, expr, lineno, augassign=augassign) @@ -778,9 +783,16 @@ def visit_AnnAssign(self, node: ast.AnnAssign) -> None: self._handleAssignment(node.target, annotation, node.value, node.lineno) def visit_TypeAlias(self, node: ast.TypeAlias) -> None: + # what we do with type_params is (and this true for functions and classes as well): + # - map it to a dict of name to type var, the names are supposed to be unique like arguments of a function. + # we do not trigger warnings if the names clashes: pydoctor is not a checker + # - when the annotations are rendered, manually link the typevar names to the definition. + # so in the case of a class the type variables the bases will link to the class it self, and for all + # methods the class's type var are accumulated with the eventual method's. if isinstance(node.name, ast.Name): self._handleAssignment(node.name, None, node.value, - node.lineno, typealiasdef=True) + node.lineno, + typevars=node.type_params) def visit_AugAssign(self, node:ast.AugAssign) -> None: self._handleAssignment(node.target, None, node.value, @@ -874,7 +886,7 @@ def _handleFunctionDef(self, else: func = self.builder.pushFunction(func_name, lineno) - func.is_async = is_async + func.is_async = is_async # if one overload is async, all overloads should be. if doc_node is not None: # Docstring not allowed on overload if is_overload_func: @@ -882,7 +894,7 @@ def _handleFunctionDef(self, func.report(f'{func.fullName()} overload has docstring, unsupported', lineno_offset=docline-func.linenumber) else: func.setDocstring(doc_node) - func.decorators = node.decorator_list + if is_staticmethod: if is_classmethod: func.report(f'{func.fullName()} is both classmethod and staticmethod') @@ -891,59 +903,24 @@ def _handleFunctionDef(self, elif is_classmethod: func.kind = model.DocumentableKind.CLASS_METHOD - # Position-only arguments were introduced in Python 3.8. - posonlyargs: Sequence[ast.arg] = getattr(node.args, 'posonlyargs', ()) - - num_pos_args = len(posonlyargs) + len(node.args.args) - defaults = node.args.defaults - default_offset = num_pos_args - len(defaults) - annotations = self._annotations_from_function(node) - - def get_default(index: int) -> Optional[ast.expr]: - assert 0 <= index < num_pos_args, index - index -= default_offset - return None if index < 0 else defaults[index] - - parameters: List[Parameter] = [] - def add_arg(name: str, kind: Any, default: Optional[ast.expr]) -> None: - default_val = Parameter.empty if default is None else _ValueFormatter(default, ctx=func) - # this cast() is safe since we're checking if annotations.get(name) is None first - annotation = Parameter.empty if annotations.get(name) is None else _AnnotationValueFormatter(cast(ast.expr, annotations[name]), ctx=func) - parameters.append(Parameter(name, kind, default=default_val, annotation=annotation)) - - for index, arg in enumerate(posonlyargs): - add_arg(arg.arg, Parameter.POSITIONAL_ONLY, get_default(index)) - - for index, arg in enumerate(node.args.args, start=len(posonlyargs)): - add_arg(arg.arg, Parameter.POSITIONAL_OR_KEYWORD, get_default(index)) - - vararg = node.args.vararg - if vararg is not None: - add_arg(vararg.arg, Parameter.VAR_POSITIONAL, None) - - assert len(node.args.kwonlyargs) == len(node.args.kw_defaults) - for arg, default in zip(node.args.kwonlyargs, node.args.kw_defaults): - add_arg(arg.arg, Parameter.KEYWORD_ONLY, default) - - kwarg = node.args.kwarg - if kwarg is not None: - add_arg(kwarg.arg, Parameter.VAR_KEYWORD, None) - - return_type = annotations.get('return') - return_annotation = Parameter.empty if return_type is None or is_none_literal(return_type) else _AnnotationValueFormatter(return_type, ctx=func) - try: - signature = Signature(parameters, return_annotation=return_annotation) - except ValueError as ex: - func.report(f'{func.fullName()} has invalid parameters: {ex}') - signature = Signature() - - func.annotations = annotations - # Only set main function signature if it is a non-overload if is_overload_func: - func.overloads.append(model.FunctionOverload(primary=func, signature=signature, decorators=node.decorator_list)) + func_model: model.FunctionOverload | model.Function = model.FunctionOverload(primary=func) + func.overloads.append(cast(model.FunctionOverload, func_model)) else: - func.signature = signature + func_model = func + # typevars must be set before calling signature_from_function such that the + # names defined in these new scopes will refer the class or function that hold definition of them. + if sys.version_info >= (3,12): + func_model.typevars = node.type_params + + annotations, signature = signature_from_functiondef(node, func_model) + + func_model.signature = signature + func_model.decorators = node.decorator_list + if isinstance(func_model, model.Function): + func_model.annotations = annotations + def depart_AsyncFunctionDef(self, node: ast.AsyncFunctionDef) -> None: self.builder.popFunction() @@ -988,59 +965,113 @@ def _handlePropertyDef(self, return attr - def _annotations_from_function( - self, func: Union[ast.AsyncFunctionDef, ast.FunctionDef] - ) -> Mapping[str, Optional[ast.expr]]: - """Get annotations from a function definition. - @param func: The function definition's AST. - @return: Mapping from argument name to annotation. - The name C{return} is used for the return type. - Unannotated arguments are omitted. - """ - def _get_all_args() -> Iterator[ast.arg]: - base_args = func.args - # New on Python 3.8 -- handle absence gracefully - try: - yield from base_args.posonlyargs - except AttributeError: - pass - yield from base_args.args - varargs = base_args.vararg - if varargs: - varargs.arg = epydoc2stan.VariableArgument(varargs.arg) - yield varargs - yield from base_args.kwonlyargs - kwargs = base_args.kwarg - if kwargs: - kwargs.arg = epydoc2stan.KeywordArgument(kwargs.arg) - yield kwargs - def _get_all_ast_annotations() -> Iterator[Tuple[str, Optional[ast.expr]]]: - for arg in _get_all_args(): - yield arg.arg, arg.annotation - returns = func.returns - if returns: - yield 'return', returns - return { - # Include parameter names even if they're not annotated, so that - # we can use the key set to know which parameters exist and warn - # when non-existing parameters are documented. - name: None if value is None else unstring_annotation(value, self.builder.current) - for name, value in _get_all_ast_annotations() - } - +def _annotations_from_function( + func: Union[ast.AsyncFunctionDef, ast.FunctionDef], + ctx: model.Function | model.FunctionOverload, + ) -> Mapping[str, Optional[ast.expr]]: + """Get annotations from a function definition. + @param func: The function definition's AST. + @param ctx: The function object. + @return: Mapping from argument name to annotation. + The name C{return} is used for the return type. + Unannotated arguments are omitted. + """ + def _get_all_args() -> Iterator[ast.arg]: + base_args = func.args + # New on Python 3.8 -- handle absence gracefully + try: + yield from base_args.posonlyargs + except AttributeError: + pass + yield from base_args.args + varargs = base_args.vararg + if varargs: + varargs.arg = epydoc2stan.VariableArgument(varargs.arg) + yield varargs + yield from base_args.kwonlyargs + kwargs = base_args.kwarg + if kwargs: + kwargs.arg = epydoc2stan.KeywordArgument(kwargs.arg) + yield kwargs + def _get_all_ast_annotations() -> Iterator[Tuple[str, Optional[ast.expr]]]: + for arg in _get_all_args(): + yield arg.arg, arg.annotation + returns = func.returns + if returns: + yield 'return', returns + return { + # Include parameter names even if they're not annotated, so that + # we can use the key set to know which parameters exist and warn + # when non-existing parameters are documented. + name: None if value is None else unstring_annotation(value, ctx.primary + if isinstance(ctx, model.FunctionOverload) else ctx) + for name, value in _get_all_ast_annotations() + } + +def signature_from_functiondef(node: Union[ast.AsyncFunctionDef, ast.FunctionDef], + ctx: model.Function | model.FunctionOverload) -> Tuple[Mapping[str, Optional[ast.expr]], Signature]: + # Position-only arguments were introduced in Python 3.8. + posonlyargs: Sequence[ast.arg] = getattr(node.args, 'posonlyargs', ()) + + num_pos_args = len(posonlyargs) + len(node.args.args) + defaults = node.args.defaults + default_offset = num_pos_args - len(defaults) + annotations = _annotations_from_function(node, ctx) + + def get_default(index: int) -> Optional[ast.expr]: + assert 0 <= index < num_pos_args, index + index -= default_offset + return None if index < 0 else defaults[index] + + parameters: List[Parameter] = [] + def add_arg(name: str, kind: Any, default: Optional[ast.expr]) -> None: + default_val = Parameter.empty if default is None else _ValueFormatter(default, ctx=ctx) + # this cast() is safe since we're checking if annotations.get(name) is None first + annotation = Parameter.empty if annotations.get(name) is None else _AnnotationValueFormatter(cast(ast.expr, annotations[name]), ctx=ctx) + parameters.append(Parameter(name, kind, default=default_val, annotation=annotation)) + + for index, arg in enumerate(posonlyargs): + add_arg(arg.arg, Parameter.POSITIONAL_ONLY, get_default(index)) + + for index, arg in enumerate(node.args.args, start=len(posonlyargs)): + add_arg(arg.arg, Parameter.POSITIONAL_OR_KEYWORD, get_default(index)) + + vararg = node.args.vararg + if vararg is not None: + add_arg(vararg.arg, Parameter.VAR_POSITIONAL, None) + + assert len(node.args.kwonlyargs) == len(node.args.kw_defaults) + for arg, default in zip(node.args.kwonlyargs, node.args.kw_defaults): + add_arg(arg.arg, Parameter.KEYWORD_ONLY, default) + + kwarg = node.args.kwarg + if kwarg is not None: + add_arg(kwarg.arg, Parameter.VAR_KEYWORD, None) + + return_type = annotations.get('return') + return_annotation = Parameter.empty if return_type is None or is_none_literal(return_type) else _AnnotationValueFormatter(return_type, ctx=ctx) + try: + signature = Signature(parameters, return_annotation=return_annotation) + except ValueError as ex: + ctx.report(f'{ctx.fullName()} has invalid parameters: {ex}') + signature = Signature() + + return annotations, signature + class _ValueFormatter: """ Class to encapsulate a python value and translate it to HTML when calling L{repr()} on the L{_ValueFormatter}. Used for presenting default values of parameters. """ - def __init__(self, value: ast.expr, ctx: model.Documentable): + def __init__(self, value: ast.expr, ctx: model.Documentable | model.FunctionOverload): self._colorized = colorize_inline_pyval(value) """ The colorized value as L{ParsedDocstring}. """ - self._linker = ctx.docstring_linker + self._linker = (ctx.primary if isinstance(ctx, + model.FunctionOverload) else ctx).docstring_linker """ Linker. """ @@ -1059,9 +1090,11 @@ class _AnnotationValueFormatter(_ValueFormatter): """ Special L{_ValueFormatter} for function annotations. """ - def __init__(self, value: ast.expr, ctx: model.Function): - super().__init__(value, ctx) - self._linker = linker._AnnotationLinker(ctx) + def __init__(self, value: ast.expr, ctx: model.Function | model.FunctionOverload): + self._colorized = colorize_inline_pyval(value, + refmap=model.gather_type_params_refs(ctx)) + self._linker = linker._AnnotationLinker(ctx.primary + if isinstance(ctx, model.FunctionOverload) else ctx) def __repr__(self) -> str: """ diff --git a/pydoctor/epydoc/markup/_pyval_repr.py b/pydoctor/epydoc/markup/_pyval_repr.py index e3f4ef304..f1eb10649 100644 --- a/pydoctor/epydoc/markup/_pyval_repr.py +++ b/pydoctor/epydoc/markup/_pyval_repr.py @@ -40,7 +40,7 @@ import functools import sys from inspect import signature -from typing import Any, AnyStr, Union, Callable, Dict, Iterable, Sequence, Optional, List, Tuple, cast +from typing import Any, AnyStr, Union, Callable, Dict, Iterable, Mapping, Sequence, Optional, List, Tuple, cast import attr from docutils import nodes @@ -195,7 +195,7 @@ def __init__(self, document: nodes.document, is_complete: bool, warnings: List[s def to_stan(self, docstring_linker: DocstringLinker) -> Tag: return Tag('code')(super().to_stan(docstring_linker)) -def colorize_pyval(pyval: Any, linelen:Optional[int], maxlines:int, linebreakok:bool=True, refmap:Optional[Dict[str, str]]=None) -> ColorizedPyvalRepr: +def colorize_pyval(pyval: Any, linelen:Optional[int], maxlines:int, linebreakok:bool=True, refmap:Optional[Mapping[str, str]]=None) -> ColorizedPyvalRepr: """ Get a L{ColorizedPyvalRepr} instance for this piece of ast. @@ -207,7 +207,7 @@ def colorize_pyval(pyval: Any, linelen:Optional[int], maxlines:int, linebreakok: """ return PyvalColorizer(linelen=linelen, maxlines=maxlines, linebreakok=linebreakok, refmap=refmap).colorize(pyval) -def colorize_inline_pyval(pyval: Any, refmap:Optional[Dict[str, str]]=None) -> ColorizedPyvalRepr: +def colorize_inline_pyval(pyval: Any, refmap:Optional[Mapping[str, str]]=None) -> ColorizedPyvalRepr: """ Used to colorize type annotations and parameters default values. @returns: C{L{colorize_pyval}(pyval, linelen=None, linebreakok=False)} @@ -255,12 +255,14 @@ def enc(c: str) -> str: def _bytes_escape(b: bytes) -> str: return repr(b)[2:-1] +PY_312_PLUS = sys.version_info >= (3, 12) + class PyvalColorizer: """ Syntax highlighter for Python values. """ - def __init__(self, linelen:Optional[int], maxlines:int, linebreakok:bool=True, refmap:Optional[Dict[str, str]]=None): + def __init__(self, linelen:Optional[int], maxlines:int, linebreakok:bool=True, refmap:Optional[Mapping[str, str]]=None): self.linelen: Optional[int] = linelen if linelen!=0 else None self.maxlines: Union[int, float] = maxlines if maxlines!=0 else float('inf') self.linebreakok = linebreakok @@ -590,6 +592,12 @@ def _colorize_ast(self, pyval: ast.AST, state: _ColorizerState) -> None: else: self._output('**', None, state) self._colorize_ast(pyval.value, state) + elif PY_312_PLUS and isinstance(pyval, ast.TypeVar): + self._colorize_ast_typevar(pyval, state) + elif PY_312_PLUS and isinstance(pyval, ast.TypeVarTuple): + self._colorize_ast_typevartuple(pyval, state) + elif PY_312_PLUS and isinstance(pyval, ast.ParamSpec): + self._colorize_ast_paramspec(pyval, state) else: self._colorize_ast_generic(pyval, state) assert state.stack.pop() is pyval @@ -764,6 +772,21 @@ def _colorize_ast_re(self, node:ast.Call, state: _ColorizerState) -> None: self._output(')', self.GROUP_TAG, state) + # Python 3.12 + def _colorize_ast_typevar(self, pyval:ast.TypeVar, state: _ColorizerState) -> None: + self._output(pyval.name, self.LINK_TAG, state, link=True) + if pyval.bound: + self._output(': ', self.COLON_TAG, state) + self._colorize_ast(pyval.bound, state) + # Python 3.12 + def _colorize_ast_typevartuple(self, pyval:ast.TypeVarTuple, state: _ColorizerState) -> None: + self._output('*', None, state) + self._output(pyval.name, self.LINK_TAG, state, link=True) + # Python 3.12 + def _colorize_ast_paramspec(self, pyval:ast.ParamSpec, state: _ColorizerState) -> None: + self._output('**', None, state) + self._output(pyval.name, self.LINK_TAG, state, link=True) + def _colorize_ast_generic(self, pyval: ast.AST, state: _ColorizerState) -> None: try: # Always wrap the expression inside parenthesis because we can't be sure diff --git a/pydoctor/epydoc2stan.py b/pydoctor/epydoc2stan.py index 395c8bf25..fbb66174e 100644 --- a/pydoctor/epydoc2stan.py +++ b/pydoctor/epydoc2stan.py @@ -269,10 +269,13 @@ def __init__(self, obj: model.Documentable): def set_param_types_from_annotations( self, annotations: Mapping[str, Optional[ast.expr]] ) -> None: + """This method MUST only be called for Function instances.""" + assert isinstance(self.obj, model.Function) _linker = linker._AnnotationLinker(self.obj) formatted_annotations = { name: None if value is None - else ParamType(safe_to_stan(colorize_inline_pyval(value), _linker, + else ParamType(safe_to_stan(colorize_inline_pyval(value, + refmap=model.gather_type_params_refs(self.obj)), _linker, self.obj, fallback=colorized_pyval_fallback, section='annotation', report=False), # don't spam the log, invalid annotation are going to be reported when the signature gets colorized origin=FieldOrigin.FROM_AST) @@ -861,7 +864,7 @@ def format_undocumented(obj: model.Documentable) -> Tag: return tag -def type2stan(obj: model.Documentable) -> Optional[Tag]: +def type2stan(obj: model.Attribute) -> Optional[Tag]: """ Get the formatted type of this attribute. """ @@ -874,7 +877,7 @@ def type2stan(obj: model.Documentable) -> Optional[Tag]: return safe_to_stan(parsed_type, _linker, obj, fallback=colorized_pyval_fallback, section='annotation') -def get_parsed_type(obj: model.Documentable) -> Optional[ParsedDocstring]: +def get_parsed_type(obj: model.Attribute) -> Optional[ParsedDocstring]: """ Get the type of this attribute as parsed docstring. """ @@ -885,7 +888,7 @@ def get_parsed_type(obj: model.Documentable) -> Optional[ParsedDocstring]: # Only Attribute instances have the 'annotation' attribute. annotation: Optional[ast.expr] = getattr(obj, 'annotation', None) if annotation is not None: - return colorize_inline_pyval(annotation) + return colorize_inline_pyval(annotation, refmap=model.gather_type_params_refs(obj)) return None @@ -992,9 +995,14 @@ def _format_constant_value(obj: model.Attribute) -> Iterator["Flattenable"]: # yield the first row. yield row + refmap: Mapping[str, str] | None = None + if obj.kind is model.DocumentableKind.TYPE_ALIAS: + refmap = model.gather_type_params_refs(obj) + doc = colorize_pyval(obj.value, linelen=obj.system.options.pyvalreprlinelen, - maxlines=obj.system.options.pyvalreprmaxlines) + maxlines=obj.system.options.pyvalreprmaxlines, + refmap=refmap) value_repr = safe_to_stan(doc, obj.docstring_linker, obj, fallback=colorized_pyval_fallback, section='rendering of constant') diff --git a/pydoctor/model.py b/pydoctor/model.py index a4c190971..88038afa3 100644 --- a/pydoctor/model.py +++ b/pydoctor/model.py @@ -634,10 +634,38 @@ def _find_dunder_constructor(cls:'Class') -> Optional['Function']: return _init return None +def gather_type_params_refs(ob: Class | Function | Attribute | FunctionOverload) -> Mapping[str, str]: + """ + Starting with Python 3.12 classes and functions can include defintions of type variable -likes. + This function returns all mapping of all type variables- like defined in the context of the given object + to the full name of the class or function that defines it. + + Type variables are not documentables so we need to treat them separately. + """ + curr = ob + typevar_sources: list[Class | Function | Attribute | FunctionOverload] = [] + while True: + typevar_sources += [curr] + # For class members re-exported this will be wrong but it will be fixed + # by adding a .parsed_typevars attribute alongside PR https://github.com/twisted/pydoctor/pull/723 + # It can only be a class at the moment + if isinstance(curr.parent, (Module, type(None))): + break + else: + curr = cast(Class, curr.parent) + + refmap = {t.name:o.fullName() for o in + reversed(typevar_sources) for t in o.typevars or [] + # the condition is only for mypy since type_params + # can only be one of these three types at the moment + if isinstance(t, (ast.TypeVar, ast.TypeVarTuple, ast.ParamSpec))} + return refmap + class Class(CanContainImportsDocumentable): kind = DocumentableKind.CLASS parent: CanContainImportsDocumentable decorators: Sequence[Tuple[str, Optional[Sequence[ast.expr]]]] + typevars: Sequence[ast.type_param] | None = None # set in post-processing: _finalbaseobjects: Optional[List[Optional['Class']]] = None @@ -849,6 +877,7 @@ class Function(Inheritable): decorators: Optional[Sequence[ast.expr]] signature: Optional[Signature] overloads: List['FunctionOverload'] + typevars: Sequence[ast.type_param] | None = None def setup(self) -> None: super().setup() @@ -863,8 +892,23 @@ class FunctionOverload: @note: This is not an actual documentable type. """ primary: Function - signature: Signature - decorators: Sequence[ast.expr] + signature: Signature = attr.ib(factory=Signature) + decorators: Sequence[ast.expr] = attr.ib(factory=list) + typevars: Sequence[ast.type_param] | None = None + + @property + def parent(self) -> CanContainImportsDocumentable: + return self.primary.parent + + @property + def module(self) -> Module: + return self.primary.module + + def fullName(self) -> str: + return self.primary.fullName() + + def report(self, msg:str, **kw:str | int) -> None: + self.primary.report(msg, **kw) # type:ignore class Attribute(Inheritable): kind: Optional[DocumentableKind] = DocumentableKind.ATTRIBUTE @@ -876,6 +920,7 @@ class Attribute(Inheritable): None value means the value is not initialized at the current point of the the process. """ + typevars: Sequence[ast.type_param] | None = None # Work around the attributes of the same name within the System class. _ModuleT = Module diff --git a/pydoctor/templatewriter/pages/__init__.py b/pydoctor/templatewriter/pages/__init__.py index 2f57084c0..4afbad09b 100644 --- a/pydoctor/templatewriter/pages/__init__.py +++ b/pydoctor/templatewriter/pages/__init__.py @@ -51,7 +51,25 @@ def format_decorators(obj: Union[model.Function, model.Attribute, model.Function epydoc2stan.reportWarnings(documentable_obj, doc.warnings, section='colorize decorator') yield '@', stan.children, tags.br() -def format_signature(func: Union[model.Function, model.FunctionOverload]) -> "Flattenable": +def format_type_params(ob: model.Function | model.FunctionOverload | model.Class | model.Attribute) -> Iterator[Flattenable]: + if not ob.typevars: + return + ctx = ob.primary if isinstance(ob, model.FunctionOverload) else ob + _linker = linker._AnnotationLinker(ctx) + refmap = model.gather_type_params_refs(ob) + stan: list[Flattenable] = [] + for t in ob.typevars: + if stan: + stan += [', '] + stan += [*epydoc2stan.safe_to_stan( + colorize_inline_pyval(t, refmap=refmap), _linker, ctx, + fallback=epydoc2stan.colorized_pyval_fallback, + section='rendering of type params').children] + yield '[' + yield from stan + yield ']' + +def format_signature(func: Union[model.Function, model.FunctionOverload]) -> Flattenable: """ Return a stan representation of a nicely-formatted source-like function signature for the given L{Function}. Arguments default values are linked to the appropriate objects when possible. @@ -65,16 +83,17 @@ def format_signature(func: Union[model.Function, model.FunctionOverload]) -> "Fl [epydoc2stan.get_to_stan_error(e)], section='signature') return broken -def format_class_signature(cls: model.Class) -> "Flattenable": +def format_class_signature(cls: model.Class) -> Flattenable: """ The class signature is the formatted list of bases this class extends. It's not the class constructor. """ - r: List["Flattenable"] = [] + r: List["Flattenable"] = [*format_type_params(cls),] # the linker will only be used to resolve the generic arguments of the base classes, # it won't actually resolve the base classes (see comment few lines below). # this is why we're using the annotation linker. _linker = linker._AnnotationLinker(cls) + base_refmap = model.gather_type_params_refs(cls) if cls.rawbases: r.append('(') @@ -88,13 +107,13 @@ def format_class_signature(cls: model.Class) -> "Flattenable": # a class with the same name as a base class confused pydoctor and it would link # to it self: https://github.com/twisted/pydoctor/issues/662 - refmap = None + refmap = {} if base_obj is not None: refmap = {str_base:base_obj.fullName()} # link to external class, using the colorizer here # to link to classes with generics (subscripts and other AST expr). - stan = epydoc2stan.safe_to_stan(colorize_inline_pyval(base_node, refmap=refmap), _linker, cls, + stan = epydoc2stan.safe_to_stan(colorize_inline_pyval(base_node, refmap={**base_refmap, **refmap}), _linker, cls, fallback=epydoc2stan.colorized_pyval_fallback, section='rendering of class signature') r.extend(stan.children) @@ -127,7 +146,10 @@ def format_function_def(func_name: str, is_async: bool, r.extend([ tags.span(def_stmt, class_='py-keyword'), ' ', tags.span(func_name, class_='py-defname'), - tags.span(format_signature(func), class_='function-signature'), ':', + + tags.span([*format_type_params(func), + format_signature(func)], + class_='function-signature'), ':', ]) return r diff --git a/pydoctor/templatewriter/pages/attributechild.py b/pydoctor/templatewriter/pages/attributechild.py index ee3534409..ed447aa47 100644 --- a/pydoctor/templatewriter/pages/attributechild.py +++ b/pydoctor/templatewriter/pages/attributechild.py @@ -8,7 +8,7 @@ from pydoctor.model import Attribute, AttributeValueDisplay, DocumentableKind from pydoctor import epydoc2stan from pydoctor.templatewriter import TemplateElement, util -from pydoctor.templatewriter.pages import format_decorators +from pydoctor.templatewriter.pages import format_decorators, format_type_params if TYPE_CHECKING: from twisted.web.template import Flattenable @@ -59,7 +59,8 @@ def attribute(self, request: object, tag: Tag) -> "Flattenable": attr: List["Flattenable"] = [] if is_type_alias: attr += [tags.span('type', class_='py-keyword'), ' ',] - attr += [tags.span(self.ob.name, class_='py-defname')] + attr += [tags.span(self.ob.name, class_='py-defname'), + *format_type_params(self.ob)] _type = self.docgetter.get_type(self.ob) if _type and not is_type_alias: attr.extend([': ', _type]) diff --git a/pydoctor/templatewriter/util.py b/pydoctor/templatewriter/util.py index 902cda9f1..d0465479d 100644 --- a/pydoctor/templatewriter/util.py +++ b/pydoctor/templatewriter/util.py @@ -20,7 +20,7 @@ def get(self, ob: model.Documentable, summary: bool = False) -> Tag: return epydoc2stan.format_summary(ob) else: return epydoc2stan.format_docstring(ob) - def get_type(self, ob: model.Documentable) -> Optional[Tag]: + def get_type(self, ob: model.Attribute) -> Optional[Tag]: return epydoc2stan.type2stan(ob) def get_toc(self, ob: model.Documentable) -> Optional[Tag]: return epydoc2stan.format_toc(ob) diff --git a/pydoctor/test/epydoc/test_pyval_repr.py b/pydoctor/test/epydoc/test_pyval_repr.py index 99f422486..cedf8f925 100644 --- a/pydoctor/test/epydoc/test_pyval_repr.py +++ b/pydoctor/test/epydoc/test_pyval_repr.py @@ -1575,3 +1575,41 @@ def test_expressions_parens(subtests:Any) -> None: check_src("{**({} == {})}") check_src("{**{'y': 2}, 'x': 1, None: True}") check_src("{**{'y': 2}, **{'x': 1}}") + +if sys.version_info >= (3, 12): + def test_type_params() -> None: + src = dedent(''' + type IntFunc[**P] = Callable[P, int] # ParamSpec + type LabeledTuple[*Ts] = tuple[str, *Ts] # TypeVarTuple + type HashableSequence[T: Hashable] = Sequence[T] # TypeVar with bound + type IntOrStrSequence[T: (int, str)] = Sequence[T] # TypeVar with constraints + ''') + + t1, t2, t3, t4 = ast.parse(src).body + tv1, tv2, tv3, tv4 = (t1.type_params[0], t2.type_params[0], #type:ignore + t3.type_params[0], t4.type_params[0]) #type:ignore + assert color(tv1) == '\n'.join(['', + ' **', + ' ', + ' P\n']) + assert color(tv2) == '\n'.join(['', + ' *', + ' ', + ' Ts\n']) + assert color(tv3) == '\n'.join(['', + ' ', + ' T', + ' : ', + ' ', + ' Hashable\n']) + assert color(tv4) == '\n'.join(['', + ' ', + ' T', + ' : ', + ' (', ' ', + ' ', + ' int', + ' , ', ' ', + ' ', + ' str', + ' )\n',]) \ No newline at end of file diff --git a/pydoctor/test/test_astbuilder.py b/pydoctor/test/test_astbuilder.py index de2b6b354..d25fe1dcd 100644 --- a/pydoctor/test/test_astbuilder.py +++ b/pydoctor/test/test_astbuilder.py @@ -120,7 +120,7 @@ def type2str(type_expr: Optional[ast.expr]) -> Optional[str]: assert isinstance(src, str) return src.strip() -def type2html(obj: model.Documentable) -> str: +def type2html(obj: model.Attribute) -> str: """ Uses the NotFoundLinker. """ @@ -1342,30 +1342,39 @@ def __init__(self): C = mod.contents['C'] a = C.contents['a'] assert unwrap(a.parsed_docstring) == """first""" + assert isinstance(a, model.Attribute) assert type2html(a) == 'string' b = C.contents['b'] assert unwrap(b.parsed_docstring) == """second""" + assert isinstance(b, model.Attribute) assert type2html(b) == 'string' c = C.contents['c'] assert c.docstring == """third""" + assert isinstance(c, model.Attribute) assert type2html(c) == 'str' d = C.contents['d'] assert d.docstring == """fourth""" + assert isinstance(d, model.Attribute) assert type2html(d) == 'str' e = C.contents['e'] assert e.docstring == """fifth""" + assert isinstance(e, model.Attribute) assert type2html(e) == 'List[C]' f = C.contents['f'] assert f.docstring == """sixth""" + assert isinstance(f, model.Attribute) assert type2html(f) == 'List[C]' g = C.contents['g'] assert g.docstring == """seventh""" + assert isinstance(g, model.Attribute) assert type2html(g) == 'List[C]' s = C.contents['s'] assert s.docstring == """instance""" + assert isinstance(s, model.Attribute) assert type2html(s) == 'List[str]' m = mod.contents['m'] assert m.docstring == """module-level""" + assert isinstance(m, model.Attribute) assert type2html(m) == 'bytes' @typecomment @@ -2578,6 +2587,7 @@ def __init__(self, thing): ''' mod = fromText(src, systemcls=systemcls, modname='mod') thing = mod.system.allobjects['mod.Stuff.thing'] + assert isinstance(thing, model.Attribute) assert epydoc2stan.type2stan(thing) is None @@ -2606,6 +2616,7 @@ def __init__(self, thing): builder.addModuleString(src2, 'mod') builder.buildModules() thing = system.allobjects['mod.Stuff.thing'] + assert isinstance(thing, model.Attribute) assert epydoc2stan.type2stan(thing) is None @systemcls_param @@ -2734,4 +2745,3 @@ def test_typealias_unstring(systemcls: Type[model.System]) -> None: with pytest.raises(StopIteration): # there is not Constant nodes in the type alias anymore next(n for n in ast.walk(typealias.value) if isinstance(n, ast.Constant)) - diff --git a/pydoctor/test/test_epydoc2stan.py b/pydoctor/test/test_epydoc2stan.py index 828edccfb..04dacc7e2 100644 --- a/pydoctor/test/test_epydoc2stan.py +++ b/pydoctor/test/test_epydoc2stan.py @@ -1449,6 +1449,7 @@ def test_annotation_formatting(annotation: str) -> None: value: {expected_text} ''') obj = mod.contents['value'] + assert isinstance(obj, model.Attribute) parsed = epydoc2stan.get_parsed_type(obj) assert parsed is not None linker = RecordingAnnotationLinker() diff --git a/pydoctor/test/test_templatewriter.py b/pydoctor/test/test_templatewriter.py index dbc143967..97f87c9fe 100644 --- a/pydoctor/test/test_templatewriter.py +++ b/pydoctor/test/test_templatewriter.py @@ -910,3 +910,126 @@ class Stuff(socket): index = flatten(ClassIndexPage(mod.system, TemplateLookup(template_dir))) assert 'href="https://docs.python.org/3/library/socket.html#socket.socket"' in index +# Cases for pep-0695 +@systemcls_param +def test_pep_695_generic_classes(systemcls: Type[model.System]) -> None: + src = ''' + # The following generates no compiler error, but a type checker + # should generate an error because an upper bound type must be concrete, + # and ``Sequence[S]`` is generic. Future extensions to the type system may + # eliminate this limitation. + class ClassA[S, T: Sequence[S]]: ... + + # The following generates no compiler error, because the bound for ``S`` + # is lazily evaluated. However, type checkers should generate an error. + # But pydoctor is not a checker + class ClassB[S: Sequence[T], T]: ... + ''' + mod = fromText(src, systemcls=systemcls, modname='t') + assert '[S, T: Sequence[S]]' in flatten(pages.format_class_signature(mod.contents['ClassA'])) # type:ignore + assert '[S: Sequence[T], T]' in flatten(pages.format_class_signature(mod.contents['ClassB'])) # type:ignore + +@systemcls_param +def test_pep_695_generic_classes_and_methods(systemcls: Type[model.System]) -> None: + src = ''' + class ClassA[T](Sequence[T]): + T = 1 + # That's a type checker error, by pydoctor is not a checker. + def method3[T](self, x: T = T): # Parameter 'x' has type T (scoped to method3) + # default value is 1 + ... + ''' + + mod = fromText(src, systemcls=systemcls, modname='t') + meth = mod.contents['ClassA'].contents['method3'] + assert isinstance(meth, model.Function) + assert model.gather_type_params_refs(meth) == {'T': 't.ClassA.method3'} + html = flatten(pages.format_function_def('ClassA', False, meth)) + assert 'x: T' in html + assert '= T' in html + + +@systemcls_param +def test_pep_695_generic_functions(systemcls: Type[model.System]) -> None: + src = ''' + def func1[T](a: T) -> T: ... # OK + + V = bool(T) # Runtime error: 'T' is not defined + + def func2[T](a = list[T]): ... # Runtime error: 'T' is not defined + + @dec(list[T]) # Runtime error: 'T' is not defined + def func3[T](): ... + ''' + mod = fromText(src, systemcls=systemcls, modname='t') + f1, f2, f3 = (mod.contents['func1'], + mod.contents['func2'], + mod.contents['func3']) + v1 = mod.contents['V'] + + htmlv1 = flatten(epydoc2stan.format_constant_value(v1)) # type:ignore + assert 'T' in html1 + assert '-> T' in html1 + + html2 = flatten(pages.format_signature(f2)) # type:ignore + assert ' Inner[T]: + return a + ''' + +@systemcls_param +def test_pep_695_nested_generics_bis(systemcls: Type[model.System]) -> None: + src = ''' + T = 0 + + # T refers to the global variable + print(T) # Prints 0 + + class Outer[T]: + T = 1 + + # T refers to the local variable scoped to class 'Outer' + print(T) # Prints 1 + + class Inner1: + T = 2 + + # T refers to the local type variable within 'Inner1' + print(T) # Prints 2 + + def inner_method(self): + # T refers to the type parameter scoped to class 'Outer'; + # If 'Outer' did not use the new type parameter syntax, + # this would instead refer to the global variable 'T' + print(T) # Prints 'T' + + def outer_method(self): + T = 3 + + # T refers to the local variable within 'outer_method' + print(T) # Prints 3 + + def inner_func(): + # T refers to the variable captured from 'outer_method' + print(T) # Prints 3 + ''' \ No newline at end of file diff --git a/pydoctor/test/test_zopeinterface.py b/pydoctor/test/test_zopeinterface.py index 9c9654286..1beb7bcf8 100644 --- a/pydoctor/test/test_zopeinterface.py +++ b/pydoctor/test/test_zopeinterface.py @@ -196,14 +196,17 @@ class IMyInterface(interface.Interface): mod = fromText(src, modname='mod', systemcls=systemcls) text = mod.contents['IMyInterface'].contents['text'] assert text.docstring == 'fun in a bun' + assert isinstance(text, model.Attribute) assert type2html(text)== "schema.TextLine" assert text.kind is model.DocumentableKind.SCHEMA_FIELD undoc = mod.contents['IMyInterface'].contents['undoc'] assert undoc.docstring is None + assert isinstance(undoc, model.Attribute) assert type2html(undoc) == "schema.Bool" assert undoc.kind is model.DocumentableKind.SCHEMA_FIELD bad = mod.contents['IMyInterface'].contents['bad'] assert bad.docstring is None + assert isinstance(bad, model.Attribute) assert type2html(bad) == "schema.ASCII" assert bad.kind is model.DocumentableKind.SCHEMA_FIELD captured = capsys.readouterr().out