Repository: twisted/pydoctor

Issue:
Detect and use docstrings set after-the-fact.
Twisted has some code that does something like

``` python
if _PY3:
   def func(args):
       pass
else:
    def func(args):
        pass

func.__doc__ = """
func does some stuff.
```

to avoid duplicating a docstring. Unfortunately, pydoctor doesn't pick this up, so it appears that `func` is undocumented.


Comments:

Patch:
diff --git a/pydoctor/astbuilder.py b/pydoctor/astbuilder.py
index bb60cc65..cb724835 100644
--- a/pydoctor/astbuilder.py
+++ b/pydoctor/astbuilder.py
@@ -312,6 +312,41 @@ class ModuleVistor(ast.NodeVisitor):
         if not self._handleAliasing(target, expr):
             self._handleClassVar(target, annotation, lineno)
 
+    def _handleDocstringUpdate(self, targetNode, expr, lineno):
+        def warn(msg):
+            self.system.msg('ast', "%s:%d: %s" % (
+                    getattr(self.builder.currentMod, 'filepath', '<unknown>'),
+                    lineno, msg))
+
+        # Figure out target object.
+        full_name = node2fullname(targetNode, self.builder.current)
+        if full_name is None:
+            warn("Unable to figure out target for __doc__ assignment")
+            # Don't return yet: we might have to warn about the value too.
+            obj = None
+        else:
+            obj = self.system.objForFullName(full_name)
+            if obj is None:
+                warn("Unable to figure out target for __doc__ assignment: "
+                     "computed full name not found: " + full_name)
+
+        # Determine docstring value.
+        try:
+            docstring = ast.literal_eval(expr)
+        except ValueError:
+            warn("Unable to figure out value for __doc__ assignment, "
+                 "maybe too complex")
+            return
+        if not isinstance(docstring, string_types):
+            warn("Ignoring value assigned to __doc__: not a string")
+            return
+
+        if obj is not None:
+            obj.docstring = docstring
+            # TODO: It might be better to not perform docstring parsing until
+            #       we have the final docstrings for all objects.
+            obj.parsed_docstring = None
+
     def _handleAssignment(self, targetNode, annotation, expr, lineno):
         if isinstance(targetNode, ast.Name):
             target = targetNode.id
@@ -323,7 +358,9 @@ class ModuleVistor(ast.NodeVisitor):
                     self._handleAssignmentInClass(target, annotation, expr, lineno)
         elif isinstance(targetNode, ast.Attribute):
             value = targetNode.value
-            if isinstance(value, ast.Name) and value.id == 'self':
+            if targetNode.attr == '__doc__':
+                self._handleDocstringUpdate(value, expr, lineno)
+            elif isinstance(value, ast.Name) and value.id == 'self':
                 self._handleInstanceVar(targetNode.attr, annotation, lineno)
 
     def visit_Assign(self, node):


Test patch:
diff --git a/pydoctor/test/test_astbuilder.py b/pydoctor/test/test_astbuilder.py
index ce99e9f4..dca78a4f 100644
--- a/pydoctor/test/test_astbuilder.py
+++ b/pydoctor/test/test_astbuilder.py
@@ -536,6 +536,57 @@ def test_inline_docstring_annotated_instancevar():
     b = C.contents['b']
     assert b.docstring == """inline doc for b"""
 
+def test_docstring_assignment(capsys):
+    mod = fromText('''
+    def fun():
+        pass
+
+    class CLS:
+
+        def method1():
+            """Temp docstring."""
+            pass
+
+        def method2():
+            pass
+
+        method1.__doc__ = "Updated docstring #1"
+
+    fun.__doc__ = "Happy Happy Joy Joy"
+    CLS.__doc__ = "Clears the screen"
+    CLS.method2.__doc__ = "Updated docstring #2"
+
+    None.__doc__ = "Free lunch!"
+    real.__doc__ = "Second breakfast"
+    fun.__doc__ = codecs.encode('Pnrfne fnynq', 'rot13')
+    CLS.method1.__doc__ = 4
+    ''')
+    fun = mod.contents['fun']
+    assert fun.kind == 'Function'
+    assert fun.docstring == """Happy Happy Joy Joy"""
+    CLS = mod.contents['CLS']
+    assert CLS.kind == 'Class'
+    assert CLS.docstring == """Clears the screen"""
+    method1 = CLS.contents['method1']
+    assert method1.kind == 'Method'
+    assert method1.docstring == "Updated docstring #1"
+    method2 = CLS.contents['method2']
+    assert method2.kind == 'Method'
+    assert method2.docstring == "Updated docstring #2"
+    captured = capsys.readouterr()
+    lines = captured.out.split('\n')
+    assert len(lines) > 0 and lines[0] == \
+        "<unknown>:20: Unable to figure out target for __doc__ assignment"
+    assert len(lines) > 1 and lines[1] == \
+        "<unknown>:21: Unable to figure out target for __doc__ assignment: " \
+        "computed full name not found: real"
+    assert len(lines) > 2 and lines[2] == \
+        "<unknown>:22: Unable to figure out value for __doc__ assignment, " \
+        "maybe too complex"
+    assert len(lines) > 3 and lines[3] == \
+        "<unknown>:23: Ignoring value assigned to __doc__: not a string"
+    assert len(lines) == 5 and lines[-1] == ''
+
 def test_variable_scopes():
     mod = fromText('''
     l = 1