diff --git a/pydoctor/astbuilder.py b/pydoctor/astbuilder.py index 1df74cd9e..129c76bbe 100644 --- a/pydoctor/astbuilder.py +++ b/pydoctor/astbuilder.py @@ -17,7 +17,7 @@ from pydoctor.epydoc.markup._pyval_repr import colorize_inline_pyval from pydoctor.astutils import (is_none_literal, is_typing_annotation, is_using_annotations, is_using_typing_final, node2dottedname, node2fullname, is__name__equals__main__, unstring_annotation, upgrade_annotation, iterassign, extract_docstring_linenum, infer_type, get_parents, - get_docstring_node, unparse, NodeVisitor, Parentage, Str) + get_docstring_node, get_assign_docstring_node, unparse, NodeVisitor, Parentage, Str) def parseFile(path: Path) -> ast.Module: @@ -31,7 +31,6 @@ def parseFile(path: Path) -> ast.Module: else: _parse = ast.parse - def _maybeAttribute(cls: model.Class, name: str) -> bool: """Check whether a name is a potential attribute of the given class. This is used to prevent an assignment that wraps a method from @@ -43,6 +42,10 @@ def _maybeAttribute(cls: model.Class, name: str) -> bool: obj = cls.find(name) return obj is None or isinstance(obj, model.Attribute) +class IgnoreAssignment(Exception): + """ + A control flow exception meaning that the assignment should not be further proccessed. + """ def _handleAliasing( ctx: model.CanContainImportsDocumentable, @@ -540,12 +543,6 @@ def _storeAttrValue(obj:model.Attribute, new_value:Optional[ast.expr], else: obj.value = new_value - def _storeCurrentAttr(self, obj:model.Attribute, - augassign:Optional[object]=None) -> None: - if not augassign: - self.builder.currentAttr = obj - else: - self.builder.currentAttr = None def _handleModuleVar(self, target: str, @@ -557,7 +554,7 @@ def _handleModuleVar(self, 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 + raise IgnoreAssignment() parent = self.builder.current obj = parent.contents.get(target) if obj is None: @@ -565,7 +562,8 @@ def _handleModuleVar(self, return obj = self.builder.addAttribute(name=target, kind=model.DocumentableKind.VARIABLE, - parent=parent) + parent=parent, + lineno=lineno) # If it's not an attribute it means that the name is already denifed as function/class # probably meaning that this attribute is a bound callable. @@ -579,7 +577,7 @@ def _handleModuleVar(self, # that are in reality not existing because they have values in a partial() call for instance. if not isinstance(obj, model.Attribute): - return + raise IgnoreAssignment() self._setAttributeAnnotation(obj, annotation) @@ -588,7 +586,6 @@ def _handleModuleVar(self, self._handleConstant(obj, annotation, expr, lineno, model.DocumentableKind.VARIABLE) self._storeAttrValue(obj, expr, augassign) - self._storeCurrentAttr(obj, augassign) def _handleAssignmentInModule(self, target: str, @@ -601,6 +598,8 @@ def _handleAssignmentInModule(self, assert isinstance(module, model.Module) if not _handleAliasing(module, target, expr): self._handleModuleVar(target, annotation, expr, lineno, augassign=augassign) + else: + raise IgnoreAssignment() def _handleClassVar(self, name: str, @@ -609,10 +608,11 @@ def _handleClassVar(self, lineno: int, augassign:Optional[ast.operator], ) -> None: + cls = self.builder.current assert isinstance(cls, model.Class) if not _maybeAttribute(cls, name): - return + raise IgnoreAssignment() # Class variables can only be Attribute, so it's OK to cast obj = cast(Optional[model.Attribute], cls.contents.get(name)) @@ -620,39 +620,35 @@ def _handleClassVar(self, if obj is None: if augassign: return - obj = self.builder.addAttribute(name=name, kind=None, parent=cls) + obj = self.builder.addAttribute(name=name, kind=None, parent=cls, lineno=lineno) if obj.kind is None: obj.kind = model.DocumentableKind.CLASS_VARIABLE self._setAttributeAnnotation(obj, annotation) - + obj.setLineNumber(lineno) self._handleConstant(obj, annotation, expr, lineno, model.DocumentableKind.CLASS_VARIABLE) self._storeAttrValue(obj, expr, augassign) - self._storeCurrentAttr(obj, augassign) + def _handleInstanceVar(self, name: str, annotation: Optional[ast.expr], expr: Optional[ast.expr], lineno: int ) -> None: - func = self.builder.current - if not isinstance(func, model.Function): - return - cls = func.parent - if not isinstance(cls, model.Class): - return + if not (cls:=self._getClassFromMethodContext()): + raise IgnoreAssignment() if not _maybeAttribute(cls, name): - return + raise IgnoreAssignment() # Class variables can only be Attribute, so it's OK to cast because we used _maybeAttribute() above. obj = cast(Optional[model.Attribute], cls.contents.get(name)) if obj is None: - obj = self.builder.addAttribute(name=name, kind=None, parent=cls) + obj = self.builder.addAttribute(name=name, kind=None, parent=cls, lineno=lineno) self._setAttributeAnnotation(obj, annotation) @@ -660,7 +656,6 @@ def _handleInstanceVar(self, # undonditionnaly set the kind to ivar obj.kind = model.DocumentableKind.INSTANCE_VARIABLE self._storeAttrValue(obj, expr) - self._storeCurrentAttr(obj) def _handleAssignmentInClass(self, target: str, @@ -673,6 +668,8 @@ def _handleAssignmentInClass(self, assert isinstance(cls, model.Class) if not _handleAliasing(cls, target, expr): self._handleClassVar(target, annotation, expr, lineno, augassign=augassign) + else: + raise IgnoreAssignment() def _handleDocstringUpdate(self, targetNode: ast.expr, @@ -729,6 +726,9 @@ def _handleAssignment(self, lineno: int, augassign:Optional[ast.operator]=None, ) -> None: + """ + @raises IgnoreAssignment: If the assignemnt should not be further processed. + """ if isinstance(targetNode, ast.Name): target = targetNode.id scope = self.builder.current @@ -741,8 +741,11 @@ def _handleAssignment(self, value = targetNode.value if targetNode.attr == '__doc__': self._handleDocstringUpdate(value, expr, lineno) + raise IgnoreAssignment() elif isinstance(value, ast.Name) and value.id == 'self': self._handleInstanceVar(targetNode.attr, annotation, expr, lineno) + else: + raise IgnoreAssignment() def visit_Assign(self, node: ast.Assign) -> None: lineno = node.lineno @@ -756,33 +759,96 @@ def visit_Assign(self, node: ast.Assign) -> None: ast.Constant(type_comment, lineno=lineno), self.builder.current), self.builder.current) for target in node.targets: - if isinstance(target, ast.Tuple): - for elem in target.elts: - # Note: We skip type and aliasing analysis for this case, - # but we do record line numbers. - self._handleAssignment(elem, None, None, lineno) + try: + if isTupleAssignment:=isinstance(target, ast.Tuple): + # TODO: Only one level of nested tuple is taken into account... + # ideally we would extract al the names declared in the lhs, not + # only the first level ones. + for elem in target.elts: + # Note: We skip type and aliasing analysis for this case, + # but we do record line numbers. + self._handleAssignment(elem, None, None, lineno) + else: + self._handleAssignment(target, annotation, expr, lineno) + except IgnoreAssignment: + continue else: - self._handleAssignment(target, annotation, expr, lineno) + if not isTupleAssignment: + self._handleInlineDocstrings(node, target) + else: + for elem in cast(ast.Tuple, target).elts: # mypy is not as smart as pyright yet. + self._handleInlineDocstrings(node, elem) def visit_AnnAssign(self, node: ast.AnnAssign) -> None: annotation = upgrade_annotation(unstring_annotation( node.annotation, self.builder.current), self.builder.current) - self._handleAssignment(node.target, annotation, node.value, node.lineno) + try: + self._handleAssignment(node.target, annotation, node.value, node.lineno) + except IgnoreAssignment: + return + else: + self._handleInlineDocstrings(node, node.target) + + def _getClassFromMethodContext(self) -> Optional[model.Class]: + func = self.builder.current + if not isinstance(func, model.Function): + return None + cls = func.parent + if not isinstance(cls, model.Class): + return None + return cls + + def _contextualizeTarget(self, target:ast.expr) -> Tuple[model.Documentable, str]: + """ + Find out the documentatble wich is the parent of the assignment's target as well as it's name. + + @returns: Tuple C{parent, name}. + @raises ValueError: if the target does not bind a new variable. + """ + dottedname = node2dottedname(target) + if not dottedname or len(dottedname) > 2: + raise ValueError('does not bind a new variable') + parent: model.Documentable + if len(dottedname) == 2 and dottedname[0] == 'self': + # an instance variable. + # TODO: This currently only works if the first argument of methods + # is named 'self'. + if (maybe_cls:=self._getClassFromMethodContext()) is None: + raise ValueError('using self in unsupported context') + dottedname = dottedname[1:] + parent = maybe_cls + elif len(dottedname) != 1: + raise ValueError('does not bind a new variable') + else: + parent = self.builder.current + return parent, dottedname[0] + + def _handleInlineDocstrings(self, assign:Union[ast.Assign, ast.AnnAssign], target:ast.expr) -> None: + # Process the inline docstrings + try: + parent, name = self._contextualizeTarget(target) + except ValueError: + return + + docstring_node = get_assign_docstring_node(assign) + if docstring_node: + # fetch the target of the inline docstring + attr = parent.contents.get(name) + if attr: + attr.setDocstring(docstring_node) def visit_AugAssign(self, node:ast.AugAssign) -> None: - self._handleAssignment(node.target, None, node.value, - node.lineno, augassign=node.op) + try: + self._handleAssignment(node.target, None, node.value, + node.lineno, augassign=node.op) + except IgnoreAssignment: + pass + def visit_Expr(self, node: ast.Expr) -> None: - value = node.value - if isinstance(value, Str): - attr = self.builder.currentAttr - if attr is not None: - attr.setDocstring(value) - self.builder.currentAttr = None + # Visit's ast.Expr.value with the visitor, used by extensions to visit top-level calls. self.generic_visit(node) - def visit_AsyncFunctionDef(self, node: ast.AsyncFunctionDef) -> None: self._handleFunctionDef(node, is_async=True) @@ -946,7 +1012,8 @@ def _handlePropertyDef(self, attr = self.builder.addAttribute(name=node.name, kind=model.DocumentableKind.PROPERTY, - parent=self.builder.current) + parent=self.builder.current, + lineno=lineno) attr.setLineNumber(lineno) if doc_node is not None: @@ -1066,28 +1133,33 @@ class ASTBuilder: def __init__(self, system: model.System): self.system = system - self.current = cast(model.Documentable, None) # current visited object - self.currentMod: Optional[model.Module] = None # module, set when visiting ast.Module - self.currentAttr: Optional[model.Documentable] = None # recently visited attribute object + self.current = cast(model.Documentable, None) # current visited object. + self.currentMod: Optional[model.Module] = None # current module, set when visiting ast.Module. self._stack: List[model.Documentable] = [] self.ast_cache: Dict[Path, Optional[ast.Module]] = {} - - def _push(self, cls: Type[DocumentableT], name: str, lineno: int) -> DocumentableT: + def _push(self, + cls: Type[DocumentableT], + name: str, + lineno: int, + parent:Optional[model.Documentable]=None) -> DocumentableT: """ Create and enter a new object of the given type and add it to the system. + + @param parent: Parent of the new documentable instance, it will use self.current if unspecified. + Used for attributes declared in methods, typically ``__init__``. """ - obj = cls(self.system, name, self.current) - self.push(obj, lineno) + obj = cls(self.system, name, parent or self.current) + self.push(obj, lineno) + # make sure push() is called before addObject() since addObject() can trigger a warning for duplicates + # and this relies on the correct parentMod attribute, which is set in push(). self.system.addObject(obj) - self.currentAttr = None return obj def _pop(self, cls: Type[model.Documentable]) -> None: assert isinstance(self.current, cls) self.pop(self.current) - self.currentAttr = None def push(self, obj: model.Documentable, lineno: int) -> None: """ @@ -1142,18 +1214,17 @@ def popFunction(self) -> None: self._pop(self.system.Function) def addAttribute(self, - name: str, kind: Optional[model.DocumentableKind], parent: model.Documentable + name: str, + kind: Optional[model.DocumentableKind], + parent: model.Documentable, + lineno: int ) -> model.Attribute: """ - Add a new attribute to the system, attributes cannot be "entered". + Add a new attribute to the system. """ - system = self.system - parentMod = self.currentMod - attr = system.Attribute(system, name, parent) + attr = self._push(self.system.Attribute, name, lineno, parent=parent) + self._pop(self.system.Attribute) attr.kind = kind - attr.parentMod = parentMod - system.addObject(attr) - self.currentAttr = attr return attr diff --git a/pydoctor/astutils.py b/pydoctor/astutils.py index 850414c05..203735ff0 100644 --- a/pydoctor/astutils.py +++ b/pydoctor/astutils.py @@ -216,6 +216,47 @@ def is_using_annotations(expr: Optional[ast.AST], return True return False +def get_node_block(node: ast.AST) -> tuple[ast.AST, str]: + """ + Tell in wich block the given node lives in. + + A block is defined by a tuple: (parent node, fieldname) + """ + try: + parent = next(get_parents(node)) + except StopIteration: + raise ValueError(f'node has no parents: {node}') + for fieldname, value in ast.iter_fields(parent): + if value is node or (isinstance(value, (list, tuple)) and node in value): + break + else: + raise ValueError(f"node {node} not found in {parent}") + return parent, fieldname + +def get_assign_docstring_node(assign:ast.Assign | ast.AnnAssign) -> Str | None: + """ + Get the docstring for a L{ast.Assign} or L{ast.AnnAssign} node. + + This helper function relies on the non-standard C{.parent} attribute on AST nodes + to navigate upward in the tree and determine this node direct siblings. + """ + # if this call raises an ValueError it means that we're doing something nasty with the ast... + parent_node, fieldname = get_node_block(assign) + statements = getattr(parent_node, fieldname, None) + + if isinstance(statements, Sequence): + # it must be a sequence if it's not None since an assignment + # can only be a part of a compound statement. + assign_index = statements.index(assign) + try: + right_sibling = statements[assign_index+1] + except IndexError: + return None + if isinstance(right_sibling, ast.Expr) and \ + get_str_value(right_sibling.value) is not None: + return cast(Str, right_sibling.value) + return None + def is_none_literal(node: ast.expr) -> bool: """Does this AST node represent the literal constant None?""" if sys.version_info >= (3,8): diff --git a/pydoctor/extensions/zopeinterface.py b/pydoctor/extensions/zopeinterface.py index a321ae975..a687184c7 100644 --- a/pydoctor/extensions/zopeinterface.py +++ b/pydoctor/extensions/zopeinterface.py @@ -183,15 +183,25 @@ def _handleZopeInterfaceAssignmentInModule(self, return ob = self.visitor.system.objForFullName(funcName) if isinstance(ob, ZopeInterfaceClass) and ob.isinterfaceclass: - # TODO: Process 'rawbases' and '__doc__' arguments. - # TODO: Currently, this implementation will create a duplicate class - # with the same name as the attribute, overriding it. + # TODO: Process 'bases' and '__doc__' arguments. + + # Fetch older attr documentable + old_attr = self.visitor.builder.current.contents.get(target) + if old_attr: + self.visitor.builder.system._remove(old_attr) # avoid duplicate warning by simply removing the old item + interface = self.visitor.builder.pushClass(target, lineno) assert isinstance(interface, ZopeInterfaceClass) + + # the docstring node has already been attached to the documentable + # by the time the zopeinterface extension is run, so we fetch the right docstring info from old documentable. + if old_attr: + interface.docstring = old_attr.docstring + interface.docstring_lineno = old_attr.docstring_lineno + interface.isinterface = True interface.implementedby_directly = [] self.visitor.builder.popClass() - self.visitor.builder.currentAttr = interface def _handleZopeInterfaceAssignmentInClass(self, target: str, diff --git a/pydoctor/test/test_astbuilder.py b/pydoctor/test/test_astbuilder.py index 053342be4..e456eae5f 100644 --- a/pydoctor/test/test_astbuilder.py +++ b/pydoctor/test/test_astbuilder.py @@ -8,7 +8,7 @@ from pydoctor.options import Options 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 +from pydoctor.epydoc2stan import _get_docformat, format_summary, get_parsed_type from pydoctor.test.test_packages import processPackage from pydoctor.utils import partialclass @@ -2792,4 +2792,113 @@ class B2: ''' fromText(src, systemcls=systemcls) # TODO: handle doc comments.x - assert capsys.readouterr().out == ':18: Existing docstring at line 14 is overriden\n' \ No newline at end of file + assert capsys.readouterr().out == ':18: Existing docstring at line 14 is overriden\n' + +@systemcls_param +def test_inline_docstring_multiple_assigments(systemcls: Type[model.System], capsys: CapSys) -> None: + # TODO: this currently does not support nested tuple assignments. + src = ''' + class C: + def __init__(self): + self.x, x = 1, 1; 'x docs' + self.y = x = 1; 'y docs' + x,y = 1,1; 'x and y docs' + v = w = 1; 'v and w docs' + ''' + mod = fromText(src, systemcls=systemcls) + assert not capsys.readouterr().out + assert mod.contents['x'].docstring == 'x and y docs' + assert mod.contents['y'].docstring == 'x and y docs' + assert mod.contents['v'].docstring == 'v and w docs' + assert mod.contents['w'].docstring == 'v and w docs' + assert mod.contents['C'].contents['x'].docstring == 'x docs' + assert mod.contents['C'].contents['y'].docstring == 'y docs' + + +@systemcls_param +def test_does_not_misinterpret_string_as_documentation(systemcls: Type[model.System], capsys: CapSys) -> None: + # exmaple from numpy/distutils/ccompiler_opt.py + src = ''' + __docformat__ = 'numpy' + class C: + """ + Attributes + ---------- + cc_noopt : bool + docs + """ + def __init__(self): + self.cc_noopt = x + + if True: + """ + this is not documentation + """ + ''' + + mod = fromText(src, systemcls=systemcls) + assert _get_docformat(mod) == 'numpy' + assert not capsys.readouterr().out + assert mod.contents['C'].contents['cc_noopt'].docstring is None + # The docstring is None... this is the sad side effect of processing ivar fields :/ + + assert to_html(mod.contents['C'].contents['cc_noopt'].parsed_docstring) == 'docs' #type:ignore + +@systemcls_param +def test_unsupported_usage_of_self(systemcls: Type[model.System], capsys: CapSys) -> None: + src = ''' + class C: + ... + def C_init(self): + self.x = True; 'not documentation' + self.y += False # erroneous usage of augassign; 'not documentation' + C.__init__ = C_init + + self = object() + self.x = False + """ + not documentation + """ + ''' + mod = fromText(src, systemcls=systemcls) + assert not capsys.readouterr().out + assert list(mod.contents['C'].contents) == [] + assert not mod.contents['self'].docstring + +@systemcls_param +def test_inline_docstring_at_wrong_place(systemcls: Type[model.System], capsys: CapSys) -> None: + src = ''' + a = objetc() + a.b = True + """ + not documentation + """ + b = object() + b.x: bool = False + """ + still not documentation + """ + c = {} + c[1] = True + """ + Again not documenatation + """ + d = {} + d[1].__init__ = True + """ + Again not documenatation + """ + e = {} + e[1].__init__ += True + """ + Again not documenatation + """ + ''' + mod = fromText(src, systemcls=systemcls) + assert not capsys.readouterr().out + assert list(mod.contents) == ['a', 'b', 'c', 'd', 'e'] + assert not mod.contents['a'].docstring + assert not mod.contents['b'].docstring + assert not mod.contents['c'].docstring + assert not mod.contents['d'].docstring + assert not mod.contents['e'].docstring \ No newline at end of file diff --git a/pydoctor/test/test_astutils.py b/pydoctor/test/test_astutils.py new file mode 100644 index 000000000..8dfe1c912 --- /dev/null +++ b/pydoctor/test/test_astutils.py @@ -0,0 +1,47 @@ +import ast +from textwrap import dedent +from pydoctor import astutils + +def test_parentage() -> None: + tree = ast.parse('class f(b):...') + astutils.Parentage().visit(tree) + assert tree.body[0].parent == tree # type:ignore + assert tree.body[0].body[0].parent == tree.body[0] # type:ignore + assert tree.body[0].bases[0].parent == tree.body[0] # type:ignore + +def test_get_assign_docstring_node() -> None: + tree = ast.parse('var = 1\n\n\n"inline docs"') + astutils.Parentage().visit(tree) + assert astutils.get_str_value(astutils.get_assign_docstring_node(tree.body[0])) == "inline docs" # type:ignore + + tree = ast.parse('var:int = 1\n\n\n"inline docs"') + astutils.Parentage().visit(tree) + assert astutils.get_str_value(astutils.get_assign_docstring_node(tree.body[0])) == "inline docs" # type:ignore + + +def test_get_assign_docstring_node_not_in_body() -> None: + src = dedent(''' + if True: pass + else: + v = True; 'inline docs' + ''') + tree = ast.parse(src) + astutils.Parentage().visit(tree) + assert astutils.get_str_value(astutils.get_assign_docstring_node(tree.body[0].orelse[0])) == "inline docs" # type:ignore + + src = dedent(''' + try: + raise ValueError() + except: + v = True; 'inline docs' + else: + w = True; 'inline docs' + finally: + x = True; 'inline docs' + ''') + tree = ast.parse(src) + astutils.Parentage().visit(tree) + assert astutils.get_str_value(astutils.get_assign_docstring_node(tree.body[0].handlers[0].body[0])) == "inline docs" # type:ignore + assert astutils.get_str_value(astutils.get_assign_docstring_node(tree.body[0].orelse[0])) == "inline docs" # type:ignore + assert astutils.get_str_value(astutils.get_assign_docstring_node(tree.body[0].finalbody[0])) == "inline docs" # type:ignore + diff --git a/pydoctor/test/test_epydoc2stan.py b/pydoctor/test/test_epydoc2stan.py index b35a57062..3610c9a48 100644 --- a/pydoctor/test/test_epydoc2stan.py +++ b/pydoctor/test/test_epydoc2stan.py @@ -2162,4 +2162,36 @@ def create_repository(self) -> repository.Repository: mod = fromText(code, ) docstring2html(mod.contents['Settings']) captured = capsys.readouterr().out - assert captured == ':15: Cannot find link target for "TypeError"\n' \ No newline at end of file + assert captured == ':15: Cannot find link target for "TypeError"\n' + +def test_does_not_loose_type_linenumber(capsys: CapSys) -> None: + # exmaple from numpy/distutils/ccompiler_opt.py + src = ''' + class C: + """ + Some docs bla + bla + bla + bla + + @ivar one: trash + @type cc_noopt: L{bool} + @ivar cc_noopt: docs + """ + def __init__(self): + self.cc_noopt = True + """ + docs again + """ + ''' + + system = model.System(model.Options.from_args('-q')) + mod = fromText(src, system=system) + assert mod.contents['C'].contents['cc_noopt'].docstring == 'docs again' + + from pydoctor.test.test_templatewriter import getHTMLOf + # we use this function as a shortcut to trigger + # the link not found warnings. + getHTMLOf(mod.contents['C']) + assert capsys.readouterr().out == (':16: Existing docstring at line 10 is overriden\n' + ':10: Cannot find link target for "bool"\n') \ No newline at end of file diff --git a/pydoctor/test/test_zopeinterface.py b/pydoctor/test/test_zopeinterface.py index 9c9654286..f4de8929f 100644 --- a/pydoctor/test/test_zopeinterface.py +++ b/pydoctor/test/test_zopeinterface.py @@ -159,7 +159,7 @@ class C(zi.Interface): assert captured == 'mod:5: definition of attribute "bad_attr" should have docstring as its sole argument\n' @zope_interface_systemcls_param -def test_interfaceclass(systemcls: Type[model.System]) -> None: +def test_interfaceclass(systemcls: Type[model.System], capsys: CapSys) -> None: system = processPackage('interfaceclass', systemcls=systemcls) mod = system.allobjects['interfaceclass.mod'] I = mod.contents['MyInterface'] @@ -171,6 +171,8 @@ def test_interfaceclass(systemcls: Type[model.System]) -> None: assert isinstance(J, ZopeInterfaceClass) assert J.isinterface + assert 'interfaceclass.mod duplicate' not in capsys.readouterr().out + @zope_interface_systemcls_param def test_warnerproofing(systemcls: Type[model.System]) -> None: src = '''