diff --git a/.travis.yml b/.travis.yml
index 5f81eeb..ae1a7f7 100644
--- a/.travis.yml
+++ b/.travis.yml
@@ -4,7 +4,6 @@ dist: xenial
 # ---
 language: python
 python:
-  - "3.6"
   - "3.7"
   - "3.8"
 install:
@@ -14,5 +13,5 @@ install:
   - pip install .
   # (note: pytest already available in travis, breaks if we install our own)
 script:
-  - mypy --ignore-missing-imports --show-error-codes waterloo
+  - mypy --ignore-missing-imports waterloo
   - pytest tests/
diff --git a/README.md b/README.md
index 9d1b051..36bacee 100644
--- a/README.md
+++ b/README.md
@@ -5,7 +5,6 @@
 [![Build Status](https://travis-ci.org/anentropic/python-waterloo.svg?branch=master)](https://travis-ci.org/anentropic/python-waterloo)
 [![Latest PyPI version](https://badge.fury.io/py/waterloo.svg)](https://pypi.python.org/pypi/waterloo/)
 
-![Python 3.6](https://img.shields.io/badge/Python%203.6--brightgreen.svg)
 ![Python 3.7](https://img.shields.io/badge/Python%203.7--brightgreen.svg)
 ![Python 3.8](https://img.shields.io/badge/Python%203.8--brightgreen.svg)  
 (...but for running on Python 2.7 code)
diff --git a/setup.py b/setup.py
index 0605cd1..ac7f4d5 100644
--- a/setup.py
+++ b/setup.py
@@ -50,7 +50,7 @@ setup(
         'Programming Language :: Python :: 3',
         'Topic :: Text Processing',
     ],
-    python_requires='~=3.6',
+    python_requires='~=3.7',
     install_requires=[
         'megaparsy>=0.1.4,<0.2.0',
         'bowler>=0.8.0,<0.9.0',
diff --git a/waterloo/annotator/__init__.py b/waterloo/annotator/__init__.py
index db451de..d38da2e 100644
--- a/waterloo/annotator/__init__.py
+++ b/waterloo/annotator/__init__.py
@@ -10,7 +10,11 @@ from fissix.pygram import python_symbols as syms
 from fissix.pytree import Leaf, Node
 
 from waterloo.parsers.napoleon import docstring_parser
-from waterloo.annotator.utils import get_type_comment, get_import_lines
+from waterloo.annotator.utils import (
+    get_type_comment,
+    get_import_lines,
+    remove_types,
+)
 from waterloo.utils import StylePrinter
 
 
@@ -100,7 +104,7 @@ def f_has_docstring(node: LN, capture: Capture, filename: Filename) -> bool:
         result = bool(IS_DOCSTRING_RE.search(docstring_node.value))
     except (AttributeError, IndexError):
         return False
-    capture['docstring'] = docstring_node.value
+    capture['docstring_node'] = docstring_node
     return result
 
 
@@ -116,7 +120,7 @@ def m_add_type_comment(node: LN, capture: Capture, filename: Filename) -> LN:
     initial_indent = capture['initial_indent_node'][0]
 
     # TODO: error handling
-    signature = docstring_parser.parse(capture['docstring'])
+    signature = docstring_parser.parse(capture['docstring_node'].value)
     if not signature.has_types:
         return node
 
@@ -134,6 +138,14 @@ def m_add_type_comment(node: LN, capture: Capture, filename: Filename) -> LN:
     type_comment = get_type_comment(signature)
     initial_indent.prefix = f"{initial_indent}{type_comment}\n"
     threadlocals.comment_count += 1
+
+    new_docstring_node = capture['docstring_node'].clone()
+    new_docstring_node.value = remove_types(
+        docstring=capture['docstring_node'].value,
+        signature=signature,
+    )
+    capture['docstring_node'].replace(new_docstring_node)
+
     return node
 
 
diff --git a/waterloo/annotator/utils.py b/waterloo/annotator/utils.py
index 419ee60..2f48317 100644
--- a/waterloo/annotator/utils.py
+++ b/waterloo/annotator/utils.py
@@ -1,12 +1,14 @@
 import typing
+from enum import Enum
 from itertools import chain
 from operator import itemgetter
-from typing import cast, Dict, Iterable, Set, Tuple
+from typing import cast, Dict, Iterable, List, Set, Tuple
 
 from waterloo.types import (
     ImportsForTypes,
     SourcePos,
     TypeAtom,
+    TypeDef,
     Types,
     TypeSignature,
 )
@@ -28,9 +30,9 @@ def get_type_comment(signature: TypeSignature) -> str:
     Returns:
         a mypy py2 type comment for a function
     """
-    if signature.args and signature.args.is_fully_typed:
+    if signature.arg_types and signature.arg_types.is_fully_typed:
         args = _join_type_atoms(
-            cast(TypeAtom, atom) for atom in signature.args.args.values()
+            cast(TypeAtom, atom) for atom in signature.arg_types.args.values()
         )
     else:
         # TODO:
@@ -39,8 +41,8 @@ def get_type_comment(signature: TypeSignature) -> str:
         # then we should emit a warning here instead of this which
         # is like an implicit `Any` for all args
         args = Types.ELLIPSIS
-    if signature.returns and signature.returns.type_def:
-        returns = signature.returns.type_def.to_annotation()
+    if signature.return_type and signature.return_type.type_def:
+        returns = signature.return_type.type_def.to_annotation()
     else:
         # TODO:
         # this is a reasonable default but it should be a configurable error
@@ -132,7 +134,7 @@ def slice_by_pos(val: str, start: SourcePos, end: SourcePos) -> str:
     """
     if "\n" in val:
         lines = val.split("\n")
-        if start.row < end.row:
+        if end.row > start.row:
             top = lines[start.row][start.col:]
             filling = lines[start.row + 1: end.row]
             bottom = lines[end.row][:end.col]
@@ -143,3 +145,97 @@ def slice_by_pos(val: str, start: SourcePos, end: SourcePos) -> str:
             return lines[start.row][start.col:end.col]
     else:
         return val[start.col:end.col]
+
+
+class TypeDefRole(Enum):
+    ARG = 0
+    RETURN = 1
+
+
+def _remove_type_def(
+    lines: List[str], type_def: TypeDef, offset: SourcePos, role: TypeDefRole
+) -> List[str]:
+    expand = 0
+    if role is TypeDefRole.ARG:
+        # we can assume the TypeDef is surrounded by parentheses
+        expand = 1
+
+    start_pos = type_def.start_pos + offset
+    start_line = lines[start_pos.row]
+    prefix = start_line[:start_pos.col - expand]
+    if role is TypeDefRole.ARG:
+        # remove preceding whitespace after arg name before open paren
+        prefix = prefix.rstrip()
+
+    end_pos = type_def.end_pos + offset
+    end_line = lines[end_pos.row]
+    _end = end_pos.col + expand
+    if role is TypeDefRole.RETURN:
+        if len(end_line) > _end and end_line[_end] == ":":
+            _end += 1
+    suffix = end_line[_end:].lstrip()
+
+    replaced_line = f"{prefix}{suffix}"
+
+    _pre = start_pos.row
+    _post = end_pos.row + 1
+
+    def has_return_description():
+        # TODO: this would be easier if the parser captured a full AST...
+        return (
+            replaced_line.strip()
+            or (lines[_post:]
+                and lines[_post].startswith(prefix)  # is indented (else not part of returns section)
+                and lines[_post].strip())
+        )
+
+    if role is TypeDefRole.RETURN:
+        if not has_return_description():
+            # if the return type had no description, remove the section header and
+            # empty return type line, plus any preceding blank lines before header
+            # NOTE: relies on current parser allows no lines between head + body
+            _pre -= 1
+            while _pre > 0 and lines[_pre - 1].strip() == "":
+                _pre -= 1
+            segments = [lines[:_pre], lines[_post:]]
+        else:
+            if replaced_line.strip():
+                segments = [lines[:_pre], [replaced_line], lines[_post:]]
+            else:
+                segments = [lines[:_pre], lines[_post:]]
+    else:
+        segments = [lines[:_pre], [replaced_line], lines[_post:]]
+
+    return [
+        line
+        for line in chain.from_iterable(segments)
+    ]
+
+
+def remove_types(docstring: str, signature: TypeSignature) -> str:
+    """
+    Returns:
+        `docstring` with its type annotations removed
+    """
+    lines = docstring.split("\n")
+    original_line_count = len(lines)
+    offset = SourcePos(0, 0)
+    if signature.arg_types:
+        for type_def in signature.arg_types.args.values():
+            if type_def is not None:
+                lines = _remove_type_def(
+                    lines=lines,
+                    type_def=type_def,
+                    offset=offset,
+                    role=TypeDefRole.ARG,
+                )
+                line_count = len(lines)
+                offset = SourcePos(line_count - original_line_count, 0)
+    if signature.return_type and signature.return_type.type_def is not None:
+        lines = _remove_type_def(
+            lines=lines,
+            type_def=signature.return_type.type_def,
+            offset=offset,
+            role=TypeDefRole.RETURN,
+        )
+    return "\n".join(lines)
diff --git a/waterloo/parsers/napoleon.py b/waterloo/parsers/napoleon.py
index 5212fd2..27f1ef5 100644
--- a/waterloo/parsers/napoleon.py
+++ b/waterloo/parsers/napoleon.py
@@ -243,8 +243,8 @@ ignored_line = (
 # THE PARSER
 docstring_parser: parsy.Parser = (
     parsy.seq(
-        args=(ignored_line.many() >> p_arg_list).optional(),
-        returns=(ignored_line.many() >> p_returns_block).optional(),
+        arg_types=(ignored_line.many() >> p_arg_list).optional(),
+        return_type=(ignored_line.many() >> p_returns_block).optional(),
     ).combine_dict(
         TypeSignature.factory
     )
diff --git a/waterloo/types.py b/waterloo/types.py
index 478cdd7..1dc2b10 100644
--- a/waterloo/types.py
+++ b/waterloo/types.py
@@ -228,41 +228,41 @@ class ReturnType:
 
 @dataclass(frozen=True)
 class TypeSignature:
-    args: Optional[ArgTypes]
-    returns: Optional[ReturnType]
+    arg_types: Optional[ArgTypes]
+    return_type: Optional[ReturnType]
 
     has_types: bool
     is_fully_typed: bool
 
     @classmethod
     def factory(
-        cls, args: Optional[ArgTypes], returns: Optional[ReturnType]
+        cls, arg_types: Optional[ArgTypes], return_type: Optional[ReturnType]
     ) -> 'TypeSignature':
         """
-        If `returns is None` we can (optionally) assume the signature should
-        be `-> None`. For everything else we require `is_fully_typed`.
+        If `return_type is None` we can (optionally) assume the signature
+        should be `-> None`. For everything else we require `is_fully_typed`.
         """
-        has_types = bool(args or returns)
+        has_types = bool(arg_types or return_type)
         is_fully_typed = bool(
-            args and args.is_fully_typed
+            arg_types and arg_types.is_fully_typed
             and (
-                returns is None
-                or returns.is_fully_typed
+                return_type is None
+                or return_type.is_fully_typed
             )
         )
         return cls(
-            args=args,
-            returns=returns,
+            arg_types=arg_types,
+            return_type=return_type,
             has_types=has_types,
             is_fully_typed=is_fully_typed,
         )
 
     def type_names(self) -> Set[str]:
         names: Set[str] = set()
-        if self.args:
-            names |= self.args.type_names()
-        if self.returns:
-            names |= self.returns.type_names()
+        if self.arg_types:
+            names |= self.arg_types.type_names()
+        if self.return_type:
+            names |= self.return_type.type_names()
         return names
 
 

TODO: remove types from docstring
The point of having type annotations rather than a docstring is you can run `mypy` as a build-step and it will fail if the annotations are out of sync with the code.

This implies that when adding type annotations we should also remove them from the docstring.

If you are using your docstrings to generate actual docs, with type info included, then you can still do that by using:
https://pypi.org/project/sphinx-autodoc-typehints/#using-type-hint-comments

diff --git a/tests/annotator/__init__.py b/tests/annotator/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/tests/test_utils.py b/tests/annotator/test_utils.py
similarity index 53%
rename from tests/test_utils.py
rename to tests/annotator/test_utils.py
index 48e46d5..bb89bf0 100644
--- a/tests/test_utils.py
+++ b/tests/annotator/test_utils.py
@@ -2,6 +2,8 @@ from collections import OrderedDict
 
 import pytest
 
+from waterloo.annotator.utils import get_type_comment, remove_types
+from waterloo.parsers.napoleon import docstring_parser
 from waterloo.types import (
     ArgsSection,
     ArgTypes,
@@ -10,13 +12,12 @@ from waterloo.types import (
     TypeAtom,
     TypeSignature,
 )
-from waterloo.utils import get_type_comment
 
 
 @pytest.mark.parametrize('example,expected', [
     (
         TypeSignature.factory(
-            args=ArgTypes.factory(
+            arg_types=ArgTypes.factory(
                 name=ArgsSection.ARGS,
                 args=OrderedDict([
                     ('key', TypeAtom('str', [])),
@@ -26,7 +27,7 @@ from waterloo.utils import get_type_comment
                      TypeAtom('Optional', [TypeAtom('float', [])])),
                 ])
             ),
-            returns=ReturnType.factory(
+            return_type=ReturnType.factory(
                 name=ReturnsSection.RETURNS,
                 type_def=TypeAtom('bool', []),
             ),
@@ -35,7 +36,7 @@ from waterloo.utils import get_type_comment
     ),
     (
         TypeSignature.factory(
-            args=ArgTypes.factory(
+            arg_types=ArgTypes.factory(
                 name=ArgsSection.ARGS,
                 args=OrderedDict([
                     ('key', TypeAtom('str', [])),
@@ -45,7 +46,7 @@ from waterloo.utils import get_type_comment
                      TypeAtom('Optional', [TypeAtom('float', [])])),
                 ])
             ),
-            returns=ReturnType.factory(
+            return_type=ReturnType.factory(
                 name=ReturnsSection.RETURNS,
                 type_def=TypeAtom("Tuple", [
                     TypeAtom("int", []),
@@ -60,3 +61,84 @@ from waterloo.utils import get_type_comment
 def test_get_type_comment(example, expected):
     val = get_type_comment(example)
     assert val == expected
+
+
+@pytest.mark.parametrize('example,expected', [
+    (
+        """Kwargs:
+  A (A)
+Return:
+  A
+""",
+        'Kwargs:\n  A\n'
+    ),
+    (
+        """Args:
+  A (A): whatever
+Return:
+  B: whatever
+""",
+        """Args:
+  A: whatever
+Return:
+  whatever
+"""
+    ),
+    (
+        """Args:
+  A (A):
+    whatever
+Return:
+  B:
+    whatever
+""",
+        """Args:
+  A:
+    whatever
+Return:
+    whatever
+"""
+    ),
+    (
+        """0""",
+        '0'
+    ),
+    (
+        ' Return:\n  A\n    \n',
+        '    \n'
+    ),
+    (
+        ' Return:\n  A\n\n',
+        '\n'
+    ),
+    (
+        'Args:\n  A (A)\nReturn:\n  A\n      \n',
+        'Args:\n  A\n      \n'
+    ),
+    (
+        'Return:\n  A\n0',
+        '0'
+    ),
+    (
+        '\n\nReturn:\n  A\n',
+        ''
+    ),
+    (
+        '\n Args:\n  A (A)\n Return:\n  A\n    0\n',
+        '\n Args:\n  A\n Return:\n    0\n'
+    ),
+    (
+        '\nReturn:\n  A\n',
+        ''
+    ),
+    (
+        '\nReturn:\n  A\n      0\n',
+        '\nReturn:\n      0\n'
+    )
+])
+def test_remove_types(example, expected):
+    signature = docstring_parser.parse(example)
+    result = remove_types(example, signature)
+
+    print(repr(result))
+    assert result == expected
diff --git a/tests/annotator/test_utils_pbt.py b/tests/annotator/test_utils_pbt.py
new file mode 100644
index 0000000..5c1e8b2
--- /dev/null
+++ b/tests/annotator/test_utils_pbt.py
@@ -0,0 +1,26 @@
+import pprint
+
+from hypothesis import given, note
+
+from waterloo.annotator.utils import remove_types
+from waterloo.parsers.napoleon import docstring_parser
+
+from tests.parsers import strategies
+
+
+@given(strategies.napoleon_docstring_f())
+def test_remove_types(docstring):
+    example, context = docstring
+
+    signature = docstring_parser.parse(example)
+    result = remove_types(example, signature)
+
+    expected = context['example_no_type']
+
+    note(f"signature: {pprint.pformat(signature)}")
+    note(f"context: {pprint.pformat(context)}")
+    note(f"example: {example!r}")
+    note(f"result: {result!r}")
+    note(f"expected: {expected!r}")
+
+    assert result == expected
diff --git a/tests/parsers/strategies.py b/tests/parsers/strategies.py
new file mode 100644
index 0000000..0b602ba
--- /dev/null
+++ b/tests/parsers/strategies.py
@@ -0,0 +1,607 @@
+import re
+from functools import partial
+
+from hypothesis import assume, strategies as st
+from typing import Any, Dict, NamedTuple
+
+from waterloo.types import (
+    TypeAtom,
+    SourcePos,
+    VALID_ARGS_SECTION_NAMES,
+    VALID_RETURNS_SECTION_NAMES,
+)
+
+
+class Example(NamedTuple):
+    example: Any
+    context: Dict[str, Any]
+
+
+"""
+A 'composite' strategy in Hypothesis effectively gives you a strategy factory.
+
+To draw from a strategy you pass the strategy func, i.e. `draw(my_strategy).
+To draw from a composite strategy you have to call it, i.e. `draw(composite())`
+
+So to help differentiate plain strategies from composite ones I have named all
+the composite strategies with `_f` suffix, for "factory".
+"""
+
+whitespace_char = st.text(' \t', min_size=1, max_size=1)
+
+small_lists_f = partial(st.lists, min_size=0, max_size=3)
+small_lists_nonempty_f = partial(st.lists, min_size=1, max_size=4)
+
+
+@st.composite
+def whitespace_f(draw, min_size=0, max_size=10):
+    """
+    homogenous whitespace of random type (space or tab) and random length
+    (including zero, i.e. '')
+    """
+    n = draw(st.integers(min_value=min_size, max_value=max_size))
+    ws = draw(whitespace_char)
+    return ws * n
+
+
+@st.composite
+def strip_whitespace_f(draw, *args, **kwargs):
+    """
+    Drawn from (by default) the full range of Hypothesis' `text` strategy
+    but eliminating initial/trailing whitespace chars, including \n, but
+    not from middle of string.
+    """
+    kwargs['alphabet'] = st.characters(blacklist_characters="\n\r")
+    val = draw(st.text(*args, **kwargs))
+    assume(val.strip() == val)
+    return val
+
+
+optional_newline = st.one_of(
+    st.just(''),
+    st.just('\n'),
+)
+
+invalid_section_head_template = st.one_of(
+    st.just("{leading_whitespace}{section_name}:{trailing_whitespace}"),
+    st.just("{leading_whitespace}{section_name}{trailing_whitespace}\n"),
+)
+
+valid_args_section_name = st.one_of(
+    *(st.just(word) for word in VALID_ARGS_SECTION_NAMES)
+)
+
+invalid_args_section_name = st.text().filter(
+    lambda t: t not in VALID_ARGS_SECTION_NAMES
+)
+
+
+VALID_SECTION_HEAD_TEMPLATE = "{section_name}:{trailing_whitespace}\n"
+
+
+@st.composite
+def valid_args_head_f(draw):
+    section_name = draw(valid_args_section_name)
+    trailing_whitespace = draw(whitespace_f())
+    return VALID_SECTION_HEAD_TEMPLATE.format(
+        section_name=section_name,
+        trailing_whitespace=trailing_whitespace,
+    )
+
+
+@st.composite
+def invalid_args_head_bad_name_f(draw):
+    section_name = draw(invalid_args_section_name)
+    trailing_whitespace = draw(whitespace_f())
+    return VALID_SECTION_HEAD_TEMPLATE.format(
+        section_name=section_name,
+        trailing_whitespace=trailing_whitespace,
+    )
+
+
+@st.composite
+def invalid_args_head_bad_template_f(draw):
+    leading_whitespace = draw(whitespace_f())
+    section_name = draw(valid_args_section_name)
+    trailing_whitespace = draw(whitespace_f())
+    template = draw(invalid_section_head_template)
+    return template.format(
+        leading_whitespace=leading_whitespace,
+        section_name=section_name,
+        trailing_whitespace=trailing_whitespace,
+    )
+
+
+valid_returns_section_name = st.one_of(
+    *(st.just(word) for word in VALID_RETURNS_SECTION_NAMES)
+)
+
+invalid_returns_section_name = st.text().filter(
+    lambda t: t not in VALID_RETURNS_SECTION_NAMES
+)
+
+
+@st.composite
+def valid_returns_head_f(draw):
+    section_name = draw(valid_returns_section_name)
+    trailing_whitespace = draw(whitespace_f())
+    return VALID_SECTION_HEAD_TEMPLATE.format(
+        section_name=section_name,
+        trailing_whitespace=trailing_whitespace,
+    )
+
+
+@st.composite
+def invalid_returns_head_bad_name_f(draw):
+    section_name = draw(invalid_returns_section_name)
+    trailing_whitespace = draw(whitespace_f())
+    return VALID_SECTION_HEAD_TEMPLATE.format(
+        section_name=section_name,
+        trailing_whitespace=trailing_whitespace,
+    )
+
+
+@st.composite
+def invalid_returns_head_bad_template_f(draw):
+    leading_whitespace = draw(whitespace_f())
+    section_name = draw(valid_returns_section_name)
+    trailing_whitespace = draw(whitespace_f())
+    template = draw(invalid_section_head_template)
+    return template.format(
+        leading_whitespace=leading_whitespace,
+        section_name=section_name,
+        trailing_whitespace=trailing_whitespace,
+    )
+
+
+python_identifier = (
+    st
+    .from_regex(r'[^\W0-9][\w]*', fullmatch=True)
+    .filter(lambda s: s.isidentifier())
+)
+
+
+@st.composite
+def dotted_var_path_f(draw):
+    segments = draw(small_lists_nonempty_f(python_identifier))
+    return '.'.join(segments)
+
+
+@st.composite
+def noargs_typeatom_f(draw):
+    return TypeAtom(
+        name=draw(dotted_var_path_f()),
+        args=(),
+    )
+
+
+@st.composite
+def generic_typeatom_f(draw, children):
+    """
+    A type var with params (i.e. is 'generic'), without being one of the
+    special cases such as homogenous tuple, callable (others?)
+
+    Args:
+        draw: provided by @st.composite
+        children: another hypothesis strategy to draw from
+            (first arg to function returned by decorator)
+    """
+    return TypeAtom(
+        name=draw(dotted_var_path_f()),
+        args=draw(small_lists_f(children))
+    )
+
+
+@st.composite
+def homogenous_tuple_typeatom_f(draw, children):
+    return TypeAtom(
+        name='Tuple',
+        args=(draw(children), TypeAtom('...', []))
+    )
+
+
+@st.composite
+def callable_typeatom_f(draw, children):
+    args_param = draw(small_lists_nonempty_f(children))
+    returns_param = draw(children)
+    return TypeAtom(
+        name='Callable',
+        args=(args_param, returns_param)
+    )
+
+
+@st.composite
+def type_atom_f(draw):
+    """
+    Generate arbitrarily nested TypeAtom instances
+    """
+    example = draw(
+        st.recursive(
+            noargs_typeatom_f(),
+            lambda children: st.one_of(
+                children,
+                generic_typeatom_f(children),
+                homogenous_tuple_typeatom_f(children),
+                callable_typeatom_f(children),
+            ),
+            max_leaves=5,
+        )
+    )
+    return example
+
+
+def _add_arbitrary_whitespace(segment, whitespace, newline):
+    """
+    Adds arbitrary whitespace and optional newlines to a segment
+    from a split TypeAtom string
+    """
+    if segment.startswith('['):
+        return f'{segment}{newline}{whitespace}'
+    elif segment.startswith(','):
+        return f'{segment}{newline}{whitespace}'
+    elif segment.startswith(']'):
+        return f'{newline}{whitespace}{segment}'
+    else:
+        return segment
+
+
+@st.composite
+def napoleon_type_annotation_f(draw):
+    """
+    Generate a type annotation that you might find in a napoleon docstring
+    made from a valid TypeAtom but with arbitrary whitespace in valid locations
+
+    - after a `[`
+    - after a `,`
+    - before a `]`
+    """
+    type_atom = draw(type_atom_f())
+    annotation = type_atom.to_annotation(False)
+    return ''.join(
+        _add_arbitrary_whitespace(
+            segment=segment.strip(),
+            whitespace=draw(whitespace_f()),
+            newline=draw(optional_newline),
+        )
+        for segment in re.split(r'([\[\]\,])', annotation)
+    )
+
+
+rest_of_line = strip_whitespace_f(min_size=0, max_size=80)
+rest_of_line_nonempty = strip_whitespace_f(min_size=1, max_size=80)
+
+
+@st.composite
+def arg_description_start_f(draw):
+    """
+    The rest-of-line that optionally follows an "arg (type)"
+
+    Example:
+        ": blah blah blah"
+    """
+    ws = draw(whitespace_f())
+    trailer = draw(rest_of_line)
+    return f":{ws}{trailer}"
+
+
+@st.composite
+def arg_name_f(draw):
+    splat = draw(st.text('*', min_size=0, max_size=2))
+    name = draw(python_identifier)
+    return f"{splat}{name}"
+
+
+@st.composite
+def annotated_arg_f(draw):
+    arg_name = draw(arg_name_f())
+    whitespace = draw(whitespace_f(min_size=1))
+    type_annotation = draw(napoleon_type_annotation_f())
+    trailer = draw(
+        st.one_of(
+            st.just(''),
+            arg_description_start_f(),
+        )
+    )
+    type_lines = type_annotation.split("\n")
+    start_pos = SourcePos(
+        row=0,
+        col=len(arg_name) + len(whitespace) + 1
+    )
+    end_pos = SourcePos(
+        row=0 + len(type_lines) - 1,
+        col=len(type_lines[-1])
+    )
+    return Example(
+        f"{arg_name}{whitespace}({type_annotation}){trailer}",
+        {
+            'arg_name': arg_name,
+            'whitespace': whitespace,
+            'type_annotation': type_annotation,
+            'trailer': trailer,
+            'start_pos': start_pos,
+            'end_pos': end_pos,
+            'example_no_type': f"{arg_name}{trailer}"
+        }
+    )
+
+
+@st.composite
+def rest_of_line_with_insertion_f(draw):
+    """
+    Insert something that looks like an args head in the middle of a line
+    which should be ignored
+    """
+    line = draw(strip_whitespace_f(min_size=1, max_size=80))
+    insertion_index = draw(st.integers(min_value=0, max_value=len(line)))
+    head_name = draw(valid_args_section_name)
+    return f"{line[:insertion_index]}{head_name}: {line[insertion_index:]}"
+
+
+ignored_line = st.one_of(
+    rest_of_line_nonempty,
+    rest_of_line_with_insertion_f(),
+)
+
+
+@st.composite
+def annotated_arg_full_f(draw, initial_indent, indent, row_offset=0):
+    first_line, arg_context = draw(annotated_arg_f())
+    wrapped_lines = draw(small_lists_f(rest_of_line_nonempty))
+
+    offset = SourcePos(row_offset, len(initial_indent) + len(indent))
+    arg_context['start_pos'] += offset
+    arg_context['end_pos'] += offset
+
+    if wrapped_lines:
+        continuation = "\n".join(
+            f"{initial_indent}{indent*2}{indent}{line}"
+            for line in wrapped_lines
+        )
+        arg_context['example_no_type'] = (
+            f"{initial_indent}{indent}{arg_context['example_no_type']}\n"
+            f"{continuation}\n"
+        )
+        return Example(
+            f"{initial_indent}{indent}{first_line}\n{continuation}\n",
+            arg_context
+        )
+    else:
+        arg_context['example_no_type'] = (
+            f"{initial_indent}{indent}{arg_context['example_no_type']}\n"
+        )
+        return Example(
+            f"{initial_indent}{indent}{first_line}\n",
+            arg_context
+        )
+
+
+@st.composite
+def args_section_f(draw, initial_indent=None, row_offset=0):
+    """
+    Header plus a list of annotated args
+    """
+    if initial_indent is None:
+        initial_indent = draw(whitespace_f())
+    if initial_indent:
+        indent = initial_indent
+    else:
+        indent = draw(whitespace_f(min_size=2))
+
+    args_head = draw(valid_args_head_f())  # includes line-break
+    annotated_args = draw(
+        small_lists_nonempty_f(
+            annotated_arg_full_f(
+                initial_indent=initial_indent,
+                indent=indent,
+                row_offset=row_offset + 1,
+            ),
+            unique_by=lambda a: a.context['arg_name']
+        )
+    )
+    args_str = "\n".join(arg[0] for arg in annotated_args)
+
+    args_str_no_types = "\n".join(
+        arg.context['example_no_type']
+        for arg in annotated_args
+    )
+    return Example(
+        f"{initial_indent}{args_head}{args_str}",
+        {
+            'args_head': args_head,
+            'annotated_args': annotated_args,
+            'example_no_type': f"{initial_indent}{args_head}{args_str_no_types}",
+        }
+    )
+
+
+@st.composite
+def annotated_return_f(draw):
+    type_annotation = draw(napoleon_type_annotation_f())
+    trailer = draw(
+        st.one_of(
+            st.just(''),
+            arg_description_start_f(),
+        )
+    )
+    type_lines = type_annotation.split("\n")
+    start_pos = SourcePos(
+        row=0,
+        col=0
+    )
+    end_pos = SourcePos(
+        row=0 + len(type_lines) - 1,
+        col=len(type_lines[-1])
+    )
+    return Example(
+        f"{type_annotation}{trailer}",
+        {
+            'type_annotation': type_annotation,
+            'trailer': trailer,
+            'start_pos': start_pos,
+            'end_pos': end_pos,
+            'example_no_type': f"{trailer.lstrip(':').lstrip()}"
+        }
+    )
+
+
+@st.composite
+def annotated_return_full_f(draw, initial_indent, indent, row_offset=0):
+    """
+    Adds indent prefix and wrap-around continuation lines suffix to
+    `annotated_return_f`
+    """
+    first_line, context = draw(annotated_return_f())
+    wrapped_lines = draw(small_lists_f(rest_of_line_nonempty))
+
+    offset = SourcePos(row_offset, len(initial_indent) + len(indent))
+    context['start_pos'] += offset
+    context['end_pos'] += offset
+
+    if wrapped_lines:
+        continuation = "\n".join(
+            f"{initial_indent}{indent*2}{indent}{line}"
+            for line in wrapped_lines
+        )
+        if context['example_no_type']:
+            context['example_no_type'] = (
+                f"{initial_indent}{indent}{context['example_no_type']}\n"
+                f"{continuation}\n"
+            )
+        else:
+            # if the type-stripped line is empty, omit it, leaving only
+            # the continuation
+            context['example_no_type'] = f"{continuation}\n"
+        context['continuation'] = continuation
+        return Example(
+            f"{initial_indent}{indent}{first_line}\n{continuation}\n",
+            context
+        )
+    else:
+        context['example_no_type'] = (
+            f"{initial_indent}{indent}{context['example_no_type']}\n"
+        )
+        context['continuation'] = ''
+        return Example(
+            f"{initial_indent}{indent}{first_line}\n",
+            context
+        )
+
+
+@st.composite
+def returns_section_f(draw, initial_indent=None, row_offset=0):
+    """
+    Header plus the return type with optional description
+    """
+    if initial_indent is None:
+        initial_indent = draw(whitespace_f())
+    if initial_indent:
+        indent = initial_indent
+    else:
+        indent = draw(whitespace_f(min_size=2))
+
+    returns_head = draw(valid_returns_head_f())
+    annotated_return = draw(
+        annotated_return_full_f(
+            initial_indent=initial_indent,
+            indent=indent,
+            row_offset=row_offset + 1,
+        )
+    )
+
+    # if return section has no description then it will be empty
+    # after types are stripped, so whole section should be omitted
+    if annotated_return.context['example_no_type'].strip():
+        no_type_str = annotated_return.context['example_no_type']
+        example_no_type = f"{initial_indent}{returns_head}{no_type_str}"
+    else:
+        example_no_type = ''
+
+    return Example(
+        f"{initial_indent}{returns_head}{annotated_return.example}",
+        {
+            'returns_head': returns_head,
+            'annotated_return': annotated_return,
+            'example_no_type': example_no_type,
+        }
+    )
+
+
+@st.composite
+def napoleon_docstring_f(draw):
+    initial_indent = draw(whitespace_f())
+
+    _intro_lines = draw(small_lists_f(ignored_line))
+    intro = "\n".join(_intro_lines)
+    # last intro has no trailing \n
+    gap_1 = draw(st.text('\n', min_size=1, max_size=3))
+
+    row_offset = len(_intro_lines) + len(gap_1)
+    args_section = draw(
+        st.one_of(
+            args_section_f(initial_indent, row_offset=row_offset),
+            st.just(Example('', {'example_no_type': ''}))
+        )
+    )
+    gap_2 = (
+        draw(st.text('\n', min_size=0, max_size=2))
+        if args_section.example
+        else ''
+    )
+
+    row_offset += len(args_section.example.split("\n")) + len(gap_2)
+    returns_section = draw(
+        st.one_of(
+            returns_section_f(initial_indent, row_offset=row_offset),
+            st.just(Example('', {'example_no_type': ''}))
+        )
+    )
+    gap_3 = (
+        draw(st.text('\n', min_size=0, max_size=2))
+        if returns_section.example
+        else ''
+    )
+
+    following = "\n".join(
+        draw(small_lists_f(ignored_line))
+    )
+
+    example = (
+        f"{intro}"
+        f"{gap_1}"
+        f"{args_section.example}"
+        f"{gap_2}"
+        f"{returns_section.example}"
+        f"{gap_3}"
+        f"{following}"
+    )
+
+    gap_1_nt = gap_1
+    gap_2_nt = gap_2
+    returns_section_no_type = returns_section.context['example_no_type']
+    if returns_section.example and not returns_section_no_type:
+        if not args_section.example:
+            gap_1_nt = '\n' if intro else ''  # collapsed by `_remove_type_def`
+        gap_2_nt = ''
+    example_no_type = (
+        f"{intro}"
+        f"{gap_1_nt}"
+        f"{args_section.context['example_no_type']}"
+        f"{gap_2_nt}"
+        f"{returns_section_no_type}"
+        f"{gap_3}"
+        f"{following}"
+    )
+
+    return Example(
+        example=example,
+        context={
+            'args_section': args_section,
+            'returns_section': returns_section,
+            'example_no_type': example_no_type,
+            'intro': intro,
+            'gap_1': gap_1,
+            'gap_2': gap_2,
+            'gap_3': gap_3,
+            'following': following,
+        }
+    )
diff --git a/tests/parsers/test_napoleon.py b/tests/parsers/test_napoleon.py
index 4340636..62ffe59 100644
--- a/tests/parsers/test_napoleon.py
+++ b/tests/parsers/test_napoleon.py
@@ -24,7 +24,7 @@ from waterloo.types import (
     TypeDef,
     TypeSignature,
 )
-from waterloo.utils import slice_by_pos
+from waterloo.annotator.utils import slice_by_pos
 
 """
 Manually-constructed test-cases for the parsers
@@ -271,7 +271,7 @@ def test_docstring_parser():
                 (False if timed out)
         """
     expected = TypeSignature.factory(
-        args=ArgTypes.factory(
+        arg_types=ArgTypes.factory(
             name=ArgsSection.ARGS,
             args=OrderedDict([
                 ('key',
@@ -300,7 +300,7 @@ def test_docstring_parser():
                  )),
             ]),
         ),
-        returns=ReturnType.factory(
+        return_type=ReturnType.factory(
             name=ReturnsSection.RETURNS,
             type_def=TypeDef.from_tuples(
                 (13, 12),
@@ -335,7 +335,7 @@ def test_docstring_parser2():
             ]
         """
     expected = TypeSignature.factory(
-        args=ArgTypes.factory(
+        arg_types=ArgTypes.factory(
             name=ArgsSection.ARGS,
             args=OrderedDict([
                 ('key',
@@ -364,7 +364,7 @@ def test_docstring_parser2():
                  )),
             ]),
         ),
-        returns=ReturnType.factory(
+        return_type=ReturnType.factory(
             name=ReturnsSection.RETURNS,
             type_def=TypeDef.from_tuples(
                 (13, 12),
@@ -392,8 +392,8 @@ def test_docstring_parser_no_annotations():
             (False if timed out)
         """
     expected = TypeSignature.factory(
-        args=None,
-        returns=None,
+        arg_types=None,
+        return_type=None,
     )
 
     result = docstring_parser.parse(example)
diff --git a/tests/parsers/test_napoleon_pbt.py b/tests/parsers/test_napoleon_pbt.py
index afc3c36..b57d6aa 100644
--- a/tests/parsers/test_napoleon_pbt.py
+++ b/tests/parsers/test_napoleon_pbt.py
@@ -1,11 +1,10 @@
 import re
 from collections import Counter, OrderedDict
-from functools import partial
 
 import parsy
 import pytest
-from hypothesis import assume, given, note, strategies as st
-from typing import Any, Dict, NamedTuple
+from hypothesis import given, note, strategies as st
+from typing import Dict
 
 from waterloo.parsers.napoleon import (
     args_head,
@@ -23,12 +22,11 @@ from waterloo.parsers.napoleon import (
 from waterloo.types import (
     ArgsSection,
     TypeAtom,
-    TypeDef,
-    SourcePos,
-    VALID_ARGS_SECTION_NAMES,
     VALID_RETURNS_SECTION_NAMES,
 )
-from waterloo.utils import slice_by_pos
+from waterloo.annotator.utils import slice_by_pos
+
+from tests.parsers import strategies
 
 
 """
@@ -40,154 +38,22 @@ They are also reassuringly slow to run ;)
 """
 
 
-class Example(NamedTuple):
-    example: Any
-    context: Dict[str, Any]
-
-
-st_whitespace_char = st.text(' \t', min_size=1, max_size=1)
-
-st_small_lists = partial(st.lists, min_size=0, max_size=3)
-st_small_lists_nonempty = partial(st.lists, min_size=1, max_size=4)
-
-
-@st.composite
-def st_whitespace(draw, min_size=0, max_size=10):
-    """
-    homogenous whitespace of random type (space or tab) and random length
-    (including zero, i.e. '')
-    """
-    n = draw(st.integers(min_value=min_size, max_value=max_size))
-    ws = draw(st_whitespace_char)
-    return ws * n
-
-
-@st.composite
-def st_strip_whitespace(draw, *args, **kwargs):
-    """
-    Drawn from (by default) the full range of Hypothesis' `text` strategy
-    but eliminating initial/trailing whitespace chars, including \n, but
-    not from middle of string.
-    """
-    kwargs['alphabet'] = st.characters(blacklist_characters="\n\r")
-    val = draw(st.text(*args, **kwargs))
-    assume(val.strip() == val)
-    return val
-
-
-st_optional_newline = st.one_of(
-    st.just(''),
-    st.just('\n'),
-)
-
-
-VALID_SECTION_HEAD_TEMPLATE = "{section_name}:{trailing_whitespace}\n"
-
-st_invalid_section_head_template = st.one_of(
-    st.just("{leading_whitespace}{section_name}:{trailing_whitespace}"),
-    st.just("{leading_whitespace}{section_name}{trailing_whitespace}\n"),
-)
-
-st_valid_args_section_name = st.one_of(
-    *(st.just(word) for word in VALID_ARGS_SECTION_NAMES)
-)
-
-st_invalid_args_section_name = st.text().filter(
-    lambda t: t not in VALID_ARGS_SECTION_NAMES
-)
-
-
-@st.composite
-def st_valid_args_head(draw):
-    section_name = draw(st_valid_args_section_name)
-    trailing_whitespace = draw(st_whitespace())
-    return VALID_SECTION_HEAD_TEMPLATE.format(
-        section_name=section_name,
-        trailing_whitespace=trailing_whitespace,
-    )
-
-
-@st.composite
-def st_invalid_args_head_bad_name(draw):
-    section_name = draw(st_invalid_args_section_name)
-    trailing_whitespace = draw(st_whitespace())
-    return VALID_SECTION_HEAD_TEMPLATE.format(
-        section_name=section_name,
-        trailing_whitespace=trailing_whitespace,
-    )
-
-
-@st.composite
-def st_invalid_args_head_bad_template(draw):
-    leading_whitespace = draw(st_whitespace())
-    section_name = draw(st_valid_args_section_name)
-    trailing_whitespace = draw(st_whitespace())
-    template = draw(st_invalid_section_head_template)
-    return template.format(
-        leading_whitespace=leading_whitespace,
-        section_name=section_name,
-        trailing_whitespace=trailing_whitespace,
-    )
-
-
-@given(st_valid_args_head())
+@given(strategies.valid_args_head_f())
 def test_valid_args_head(example):
     result = args_head.parse(example)
     assert result == "Args"  # (Section name has been normalised)
 
 
 @given(st.one_of(
-    st_invalid_args_head_bad_name(),
-    st_invalid_args_head_bad_template(),
+    strategies.invalid_args_head_bad_name_f(),
+    strategies.invalid_args_head_bad_template_f(),
 ))
 def test_invalid_args_head(example):
     with pytest.raises(parsy.ParseError):
         args_head.parse(example)
 
 
-st_valid_returns_section_name = st.one_of(
-    *(st.just(word) for word in VALID_RETURNS_SECTION_NAMES)
-)
-
-st_invalid_returns_section_name = st.text().filter(
-    lambda t: t not in VALID_RETURNS_SECTION_NAMES
-)
-
-
-@st.composite
-def st_valid_returns_head(draw):
-    section_name = draw(st_valid_returns_section_name)
-    trailing_whitespace = draw(st_whitespace())
-    return VALID_SECTION_HEAD_TEMPLATE.format(
-        section_name=section_name,
-        trailing_whitespace=trailing_whitespace,
-    )
-
-
-@st.composite
-def st_invalid_returns_head_bad_name(draw):
-    section_name = draw(st_invalid_returns_section_name)
-    trailing_whitespace = draw(st_whitespace())
-    return VALID_SECTION_HEAD_TEMPLATE.format(
-        section_name=section_name,
-        trailing_whitespace=trailing_whitespace,
-    )
-
-
-@st.composite
-def st_invalid_returns_head_bad_template(draw):
-    leading_whitespace = draw(st_whitespace())
-    section_name = draw(st_valid_returns_section_name)
-    trailing_whitespace = draw(st_whitespace())
-    template = draw(st_invalid_section_head_template)
-    return template.format(
-        leading_whitespace=leading_whitespace,
-        section_name=section_name,
-        trailing_whitespace=trailing_whitespace,
-    )
-
-
-@given(st_valid_returns_head())
+@given(strategies.valid_returns_head_f())
 def test_valid_returns_head(example):
     result = returns_head.parse(example)
     assert result in VALID_RETURNS_SECTION_NAMES
@@ -196,8 +62,8 @@ def test_valid_returns_head(example):
 
 
 @given(st.one_of(
-    st_invalid_returns_head_bad_name(),
-    st_invalid_returns_head_bad_template(),
+    strategies.invalid_returns_head_bad_name_f(),
+    strategies.invalid_returns_head_bad_template_f(),
 ))
 def test_invalid_returns_head(example):
     with pytest.raises(parsy.ParseError):
@@ -206,8 +72,8 @@ def test_invalid_returns_head(example):
 
 @given(
     splat=st.text('*', min_size=0, max_size=4),
-    name=st_strip_whitespace(min_size=0, max_size=10),
-    trailing_ws=st_whitespace(),
+    name=strategies.strip_whitespace_f(min_size=0, max_size=10),
+    trailing_ws=strategies.whitespace_f(),
     newline=st.one_of(st.just(''), st.just('\n')),
 )
 def test_arg_name(splat, name, trailing_ws, newline):
@@ -227,8 +93,8 @@ def test_arg_name(splat, name, trailing_ws, newline):
 
 
 @given(
-    segments=st_small_lists_nonempty(st.text(min_size=0, max_size=8)),
-    trailing_ws=st_whitespace(),
+    segments=strategies.small_lists_nonempty_f(st.text(min_size=0, max_size=8)),
+    trailing_ws=strategies.whitespace_f(),
     newline=st.one_of(st.just(''), st.just('\n')),
 )
 def test_dotted_var_path(segments, trailing_ws, newline):
@@ -253,119 +119,6 @@ def test_dotted_var_path(segments, trailing_ws, newline):
             dotted_var_path.parse(example)
 
 
-st_python_identifier = (
-    st
-    .from_regex(r'[^\W0-9][\w]*', fullmatch=True)
-    .filter(lambda s: s.isidentifier())
-)
-
-
-@st.composite
-def st_dotted_var_path(draw):
-    segments = draw(st_small_lists_nonempty(st_python_identifier))
-    return '.'.join(segments)
-
-
-@st.composite
-def st_noargs_typeatom(draw):
-    return TypeAtom(
-        name=draw(st_dotted_var_path()),
-        args=(),
-    )
-
-
-@st.composite
-def st_generic_typeatom(draw, st_children):
-    """
-    A type var with params (i.e. is 'generic'), without being one of the
-    special cases such as homogenous tuple, callable (others?)
-
-    Args:
-        draw: provided by @st.composite
-        st_children: another hypothesis strategy to draw from
-            (first arg to function returned by decorator)
-    """
-    return TypeAtom(
-        name=draw(st_dotted_var_path()),
-        args=draw(st_small_lists(st_children))
-    )
-
-
-@st.composite
-def st_homogenous_tuple_typeatom(draw, st_children):
-    return TypeAtom(
-        name='Tuple',
-        args=(draw(st_children), TypeAtom('...', []))
-    )
-
-
-@st.composite
-def st_callable_typeatom(draw, st_children):
-    args_param = draw(st_small_lists_nonempty(st_children))
-    returns_param = draw(st_children)
-    return TypeAtom(
-        name='Callable',
-        args=(args_param, returns_param)
-    )
-
-
-@st.composite
-def st_type_atom(draw):
-    """
-    Generate arbitrarily nested TypeAtom instances
-    """
-    example = draw(
-        st.recursive(
-            st_noargs_typeatom(),
-            lambda st_children: st.one_of(
-                st_children,
-                st_generic_typeatom(st_children),
-                st_homogenous_tuple_typeatom(st_children),
-                st_callable_typeatom(st_children),
-            ),
-            max_leaves=5,
-        )
-    )
-    return example
-
-
-def _add_arbitrary_whitespace(segment, whitespace, newline):
-    """
-    Adds arbitrary whitespace and optional newlines to a segment
-    from a split TypeAtom string
-    """
-    if segment.startswith('['):
-        return f'{segment}{newline}{whitespace}'
-    elif segment.startswith(','):
-        return f'{segment}{newline}{whitespace}'
-    elif segment.startswith(']'):
-        return f'{newline}{whitespace}{segment}'
-    else:
-        return segment
-
-
-@st.composite
-def st_napoleon_type_annotation(draw):
-    """
-    Generate a type annotation that you might find in a napoleon docstring
-    made from a valid TypeAtom but with arbitrary whitespace in valid locations
-
-    - after a `[`
-    - after a `,`
-    - before a `]`
-    """
-    type_atom = draw(st_type_atom())
-    annotation = type_atom.to_annotation(False)
-    return ''.join(
-        _add_arbitrary_whitespace(
-            segment=segment.strip(),
-            whitespace=draw(st_whitespace()),
-            newline=draw(st_optional_newline),
-        )
-        for segment in re.split(r'([\[\]\,])', annotation)
-    )
-
-
 def _add_normalised_whitespace(segment):
     """
     Assuming `segment` has already been stirpped, re-add normalised
@@ -393,7 +146,7 @@ def assert_annotation_roundtrip(example: str, result: TypeAtom):
     assert normalised == result.to_annotation(False)
 
 
-@given(st_napoleon_type_annotation())
+@given(strategies.napoleon_type_annotation_f())
 def test_type_atom(example):
     """
     Generate an arbitrary ('dirty') type annotation and check that we can
@@ -403,63 +156,7 @@ def test_type_atom(example):
     assert_annotation_roundtrip(example, result)
 
 
-st_rest_of_line = st_strip_whitespace(min_size=0, max_size=80)
-
-
-@st.composite
-def st_arg_description_start(draw):
-    """
-    The rest-of-line that optionally follows an "arg (type)"
-
-    Example:
-        ": blah blah blah"
-    """
-    ws = draw(st_whitespace())
-    trailer = draw(st_rest_of_line)
-    return f":{ws}{trailer}"
-
-
-@st.composite
-def st_arg_name(draw):
-    splat = draw(st.text('*', min_size=0, max_size=2))
-    name = draw(st_python_identifier)
-    return f"{splat}{name}"
-
-
-@st.composite
-def st_annotated_arg(draw):
-    arg_name = draw(st_arg_name())
-    whitespace = draw(st_whitespace(min_size=1))
-    type_annotation = draw(st_napoleon_type_annotation())
-    trailer = draw(
-        st.one_of(
-            st.just(''),
-            st_arg_description_start(),
-        )
-    )
-    type_lines = type_annotation.split("\n")
-    start_pos = SourcePos(
-        row=0,
-        col=len(arg_name) + len(whitespace) + 1
-    )
-    end_pos = SourcePos(
-        row=0 + len(type_lines) - 1,
-        col=len(type_lines[-1])
-    )
-    return Example(
-        f"{arg_name}{whitespace}({type_annotation}){trailer}",
-        {
-            'arg_name': arg_name,
-            'whitespace': whitespace,
-            'type_annotation': type_annotation,
-            'trailer': trailer,
-            'start_pos': start_pos,
-            'end_pos': end_pos,
-        }
-    )
-
-
-@given(annotated_arg_example=st_annotated_arg())
+@given(annotated_arg_example=strategies.annotated_arg_f())
 def test_arg_type_annotated(annotated_arg_example):
     """
     Test that we can parse the first line of an arg and its description
@@ -476,10 +173,10 @@ def test_arg_type_annotated(annotated_arg_example):
 
 
 @given(
-    arg_name=st_arg_name(),
+    arg_name=strategies.arg_name_f(),
     trailer=st.one_of(
         st.just(''),
-        st_arg_description_start(),
+        strategies.arg_description_start_f(),
     )
 )
 def test_arg_type_no_annotation(arg_name, trailer):
@@ -493,27 +190,9 @@ def test_arg_type_no_annotation(arg_name, trailer):
     assert result['arg'] == arg_name
 
 
-@st.composite
-def st_rest_of_line_with_insertion(draw):
-    """
-    Insert something that looks like an args head in the middle of a line
-    which should be ignored
-    """
-    line = draw(st_strip_whitespace(min_size=1, max_size=80))
-    insertion_index = draw(st.integers(min_value=0, max_value=len(line)))
-    head_name = draw(st_valid_args_section_name)
-    return f"{line[:insertion_index]}{head_name}: {line[insertion_index:]}"
-
-
-st_ignored_line = st.one_of(
-    st_rest_of_line,
-    st_rest_of_line_with_insertion(),
-)
-
-
 @given(
-    indent=st_whitespace(),
-    line_to_ignore=st_ignored_line,
+    indent=strategies.whitespace_f(),
+    line_to_ignore=strategies.ignored_line,
 )
 def test_ignored_line(indent, line_to_ignore):
     """
@@ -527,9 +206,9 @@ def test_ignored_line(indent, line_to_ignore):
 
 
 @given(
-    indent=st_whitespace(),
+    indent=strategies.whitespace_f(),
     blank_lines=st.text('\n', min_size=0, max_size=2),
-    ignored_lines=st_small_lists(st_ignored_line),
+    ignored_lines=strategies.small_lists_f(strategies.ignored_line),
 )
 def test_ignored_lines(
     indent,
@@ -549,64 +228,6 @@ def test_ignored_lines(
     assert remainder == unconsumed
 
 
-@st.composite
-def st_annotated_arg_full(draw, initial_indent, indent, row_offset=0):
-    first_line, arg_context = draw(st_annotated_arg())
-    wrapped_lines = draw(st_small_lists(st_rest_of_line))
-
-    offset = SourcePos(row_offset, len(initial_indent) + len(indent))
-    arg_context['start_pos'] += offset
-    arg_context['end_pos'] += offset
-
-    if wrapped_lines:
-        continuation = "\n".join(
-            f"{initial_indent}{indent*2}{indent}{line}"
-            for line in wrapped_lines
-        )
-        return Example(
-            f"{initial_indent}{indent}{first_line}\n{continuation}\n",
-            arg_context
-        )
-    else:
-        return Example(
-            f"{initial_indent}{indent}{first_line}\n",
-            arg_context
-        )
-
-
-@st.composite
-def st_args_section(draw, initial_indent=None, row_offset=0):
-    """
-    Header plus a list of annotated args
-    """
-    if initial_indent is None:
-        initial_indent = draw(st_whitespace())
-    if initial_indent:
-        indent = initial_indent
-    else:
-        indent = draw(st_whitespace(min_size=2))
-
-    args_head = draw(st_valid_args_head())  # includes line-break
-    annotated_args = draw(
-        st_small_lists_nonempty(
-            st_annotated_arg_full(
-                initial_indent=initial_indent,
-                indent=indent,
-                row_offset=row_offset + 1,
-            ),
-            unique_by=lambda a: a.context['arg_name']
-        )
-    )
-    args_str = "\n".join(arg[0] for arg in annotated_args)
-    return Example(
-        f"{initial_indent}{args_head}{args_str}",
-        {
-            'args_head': args_head,
-            'annotated_args': annotated_args,
-        }
-    )
-
-
 def _validate_args_section(example, result, context):
     # normalised "<any valid>" -> "Args" (Enum)
     assert result.name == ArgsSection.ARGS
@@ -625,97 +246,13 @@ def _validate_args_section(example, result, context):
         assert slice_by_pos(example, start, end) == type_annotation_str
 
 
-@given(st_args_section())
+@given(strategies.args_section_f())
 def test_p_arg_list(args_section):
     example, context = args_section
     result = p_arg_list.parse(example)
     _validate_args_section(example, result, context)
 
 
-@st.composite
-def st_annotated_return(draw):
-    type_annotation = draw(st_napoleon_type_annotation())
-    trailer = draw(
-        st.one_of(
-            st.just(''),
-            st_arg_description_start(),
-        )
-    )
-    type_lines = type_annotation.split("\n")
-    start_pos = SourcePos(
-        row=0,
-        col=0
-    )
-    end_pos = SourcePos(
-        row=0 + len(type_lines) - 1,
-        col=len(type_lines[-1])
-    )
-    return Example(
-        f"{type_annotation}{trailer}",
-        {
-            'type_annotation': type_annotation,
-            'trailer': trailer,
-            'start_pos': start_pos,
-            'end_pos': end_pos,
-        }
-    )
-
-
-@st.composite
-def st_annotated_return_full(draw, initial_indent, indent, row_offset=0):
-    first_line, context = draw(st_annotated_return())
-    wrapped_lines = draw(st_small_lists(st_rest_of_line))
-
-    offset = SourcePos(row_offset, len(initial_indent) + len(indent))
-    context['start_pos'] += offset
-    context['end_pos'] += offset
-
-    if wrapped_lines:
-        continuation = "\n".join(
-            f"{initial_indent}{indent*2}{indent}{line}"
-            for line in wrapped_lines
-        )
-        return Example(
-            f"{initial_indent}{indent}{first_line}\n{continuation}\n",
-            context
-        )
-    else:
-        return Example(
-            f"{initial_indent}{indent}{first_line}\n",
-            context
-        )
-
-
-@st.composite
-def st_returns_section(draw, initial_indent=None, row_offset=0):
-    """
-    Header plus the return type with optional description
-    """
-    if initial_indent is None:
-        initial_indent = draw(st_whitespace())
-    if initial_indent:
-        indent = initial_indent
-    else:
-        indent = draw(st_whitespace(min_size=2))
-
-    returns_head = draw(st_valid_returns_head())
-    annotated_return = draw(
-        st_annotated_return_full(
-            initial_indent=initial_indent,
-            indent=indent,
-            row_offset=row_offset + 1,
-        )
-    )
-
-    return Example(
-        f"{initial_indent}{returns_head}{annotated_return[0]}",
-        {
-            'returns_head': returns_head,
-            'annotated_return': annotated_return,
-        }
-    )
-
-
 def _validate_returns_section(example, result, context):
     assert result.name in VALID_RETURNS_SECTION_NAMES
     assert result.name == VALID_RETURNS_SECTION_NAMES[result.name][1]
@@ -727,71 +264,14 @@ def _validate_returns_section(example, result, context):
     assert slice_by_pos(example, start, end) == type_annotation_str
 
 
-@given(st_returns_section())
+@given(strategies.returns_section_f())
 def test_p_returns_block(returns_section):
     example, context = returns_section
     result = p_returns_block.parse(example)
     _validate_returns_section(example, result, context)
 
 
-@st.composite
-def st_napoleon_docstring(draw):
-    initial_indent = draw(st_whitespace())
-
-    _intro_lines = draw(st_small_lists(st_ignored_line))
-    intro = "\n".join(_intro_lines)
-    # last ignored_line has no trailing \n
-    gap_1 = draw(st.text('\n', min_size=1, max_size=3)) if intro else ''
-
-    row_offset = len(_intro_lines) + len(gap_1)
-    args_section = draw(
-        st.one_of(
-            st_args_section(initial_indent, row_offset=row_offset),
-            st.just(Example('', {}))
-        )
-    )
-    gap_2 = (
-        draw(st.text('\n', min_size=0, max_size=2))
-        if args_section[0]
-        else ''
-    )
-
-    row_offset += len(args_section[0].split("\n")) + len(gap_2)
-    returns_section = draw(
-        st.one_of(
-            st_returns_section(initial_indent, row_offset=row_offset),
-            st.just(Example('', {}))
-        )
-    )
-    gap_3 = (
-        draw(st.text('\n', min_size=0, max_size=2))
-        if returns_section[0]
-        else ''
-    )
-
-    following = "\n".join(
-        draw(st_small_lists(st_ignored_line))
-    )
-
-    example = (
-        f"{intro}"
-        f"{gap_1}"
-        f"{args_section[0]}"
-        f"{gap_2}"
-        f"{returns_section[0]}"
-        f"{gap_3}"
-        f"{following}"
-    )
-    return Example(
-        example=example,
-        context={
-            'args_section': args_section,
-            'returns_section': returns_section,
-        }
-    )
-
-
-@given(st_napoleon_docstring())
+@given(strategies.napoleon_docstring_f())
 def test_docstring_parser(docstring):
     example, context = docstring
 
@@ -801,7 +281,7 @@ def test_docstring_parser(docstring):
         note(f"args_section: {context['args_section'][0]}")
         _validate_args_section(
             example,
-            result.args,
+            result.arg_types,
             context['args_section'].context,
         )
 
@@ -809,6 +289,6 @@ def test_docstring_parser(docstring):
         note(f"returns_section: {context['returns_section'][0]}")
         _validate_returns_section(
             example,
-            result.returns,
+            result.return_type,
             context['returns_section'].context,
         )
diff --git a/tests/test_types.py b/tests/test_types.py
index 3d0552a..b1af8c9 100644
--- a/tests/test_types.py
+++ b/tests/test_types.py
@@ -183,24 +183,24 @@ def test_return_type_type_names(expected, example):
 @pytest.mark.parametrize('expected,example', [
     (set(),
      TypeSignature.factory(
-        args=None,
-        returns=None
+        arg_types=None,
+        return_type=None
      )),
     ({"str", "datetime"},
      TypeSignature.factory(
-        args=ArgTypes.factory(
+        arg_types=ArgTypes.factory(
             name=ArgsSection.ARGS,
             args=OrderedDict([
                 ('a', TypeAtom("str", [])),
                 ('b', TypeAtom("datetime", [])),
             ]),
         ),
-        returns=None
+        return_type=None
      )),
     ({"Dict", "int", "str"},
      TypeSignature.factory(
-        args=None,
-        returns=ReturnType.factory(
+        arg_types=None,
+        return_type=ReturnType.factory(
             name=ReturnsSection.RETURNS,
             type_def=TypeAtom("Dict", [
                 TypeAtom("int", []), TypeAtom("str", [])
@@ -209,14 +209,14 @@ def test_return_type_type_names(expected, example):
      )),
     ({"Dict", "int", "str", "datetime"},
      TypeSignature.factory(
-        args=ArgTypes.factory(
+        arg_types=ArgTypes.factory(
             name=ArgsSection.ARGS,
             args=OrderedDict([
                 ('a', TypeAtom("str", [])),
                 ('b', TypeAtom("datetime", [])),
             ]),
         ),
-        returns=ReturnType.factory(
+        return_type=ReturnType.factory(
             name=ReturnsSection.RETURNS,
             type_def=TypeAtom("Dict", [
                 TypeAtom("int", []), TypeAtom("str", [])

pip install -e .[dev]
parse_log_pytest
requirements.txt
pytest hypothesis mypy
apt-get update apt-get install -y gcc
3.9
requirements/base.txt
pytest --no-header -rA --tb=line --color=no -p no:cacheprovider -W ignore::DeprecationWarning
appdirs==1.4.4
attrs==25.3.0
bowler==0.8.0
click==8.1.8
exceptiongroup==1.2.2
fissix==24.4.24
hypothesis==6.130.5
iniconfig==2.1.0
megaparsy==0.1.4
mypy==1.15.0
mypy-extensions==1.0.0
packaging==24.2
parsy==1.2.0
pluggy==1.5.0
prompt_toolkit==3.0.50
pytest==8.3.5
sh==2.2.2
sortedcontainers==2.4.0
tomli==2.2.1
typing_extensions==4.13.0
-e git+https://github.com/anentropic/python-waterloo.git@7b381d8d08695e1424d35514aa6b7ce1afcae5a8#egg=waterloo
wcwidth==0.2.13

name: python-waterloo
channels:
  - defaults
  - https://repo.anaconda.com/pkgs/main
  - https://repo.anaconda.com/pkgs/r
  - conda-forge
dependencies:
  - _libgcc_mutex=0.1=main
  - _openmp_mutex=5.1=1_gnu
  - ca-certificates=2025.2.25=h06a4308_0
  - ld_impl_linux-64=2.40=h12ee557_0
  - libffi=3.4.4=h6a678d5_1
  - libgcc-ng=11.2.0=h1234567_1
  - libgomp=11.2.0=h1234567_1
  - libstdcxx-ng=11.2.0=h1234567_1
  - ncurses=6.4=h6a678d5_0
  - openssl=3.0.16=h5eee18b_0
  - pip=25.0=py39h06a4308_0
  - python=3.9.21=he870216_1
  - readline=8.2=h5eee18b_0
  - setuptools=75.8.0=py39h06a4308_0
  - sqlite=3.45.3=h5eee18b_0
  - tk=8.6.14=h39e8969_0
  - tzdata=2025a=h04d1e81_0
  - wheel=0.45.1=py39h06a4308_0
  - xz=5.6.4=h5eee18b_1
  - zlib=1.2.13=h5eee18b_1
  - pip:
      - appdirs==1.4.4
      - attrs==25.3.0
      - bowler==0.8.0
      - click==8.1.8
      - exceptiongroup==1.2.2
      - fissix==24.4.24
      - hypothesis==6.130.5
      - iniconfig==2.1.0
      - megaparsy==0.1.4
      - mypy==1.15.0
      - mypy-extensions==1.0.0
      - packaging==24.2
      - parsy==1.2.0
      - pluggy==1.5.0
      - prompt-toolkit==3.0.50
      - pytest==8.3.5
      - sh==2.2.2
      - sortedcontainers==2.4.0
      - tomli==2.2.1
      - typing-extensions==4.13.0
      - wcwidth==0.2.13
prefix: /opt/conda/envs/python-waterloo

tests/annotator/test_utils.py::test_get_type_comment[example0-# tests/annotator/test_utils.py::test_get_type_comment[example1-# tests/annotator/test_utils.py::test_remove_types[Kwargs:\n tests/annotator/test_utils.py::test_remove_types[Args:\n tests/annotator/test_utils.py::test_remove_types[0-0] tests/annotator/test_utils.py::test_remove_types[ tests/annotator/test_utils.py::test_remove_types[Return:\n tests/annotator/test_utils.py::test_remove_types[\n\nReturn:\n tests/annotator/test_utils.py::test_remove_types[\n tests/annotator/test_utils.py::test_remove_types[\nReturn:\n tests/annotator/test_utils_pbt.py::test_remove_types tests/parsers/test_napoleon.py::test_args_head tests/parsers/test_napoleon.py::test_var_name_valid[str] tests/parsers/test_napoleon.py::test_var_name_valid[Dict] tests/parsers/test_napoleon.py::test_var_name_valid[var_1_2_abc] tests/parsers/test_napoleon.py::test_var_name_valid[ClassName] tests/parsers/test_napoleon.py::test_var_name_invalid[dotted.path] tests/parsers/test_napoleon.py::test_var_name_invalid[1name] tests/parsers/test_napoleon.py::test_var_name_invalid[no-hyphens] tests/parsers/test_napoleon.py::test_var_name_invalid[one tests/parsers/test_napoleon.py::test_type_atom_valid[str-expected0] tests/parsers/test_napoleon.py::test_type_atom_valid[Dict-expected1] tests/parsers/test_napoleon.py::test_type_atom_valid[Dict[int, tests/parsers/test_napoleon.py::test_type_atom_valid[my.generic.Container[int]-expected4] tests/parsers/test_napoleon.py::test_type_atom_valid[Tuple[int, tests/parsers/test_napoleon.py::test_type_atom_valid[Callable[[int, tests/parsers/test_napoleon.py::test_type_atom_valid[Tuple[\n tests/parsers/test_napoleon.py::test_type_atom_invalid[Dict[int, tests/parsers/test_napoleon.py::test_arg_type[key tests/parsers/test_napoleon.py::test_ignored_line[\n] tests/parsers/test_napoleon.py::test_ignored_line[Builds tests/parsers/test_napoleon.py::test_ignored_line[ tests/parsers/test_napoleon.py::test_ignored_line[of tests/parsers/test_napoleon.py::test_ignored_line[Args: tests/parsers/test_napoleon.py::test_ignored_lines tests/parsers/test_napoleon.py::test_p_arg_list tests/parsers/test_napoleon.py::test_p_returns_block tests/parsers/test_napoleon.py::test_docstring_parser tests/parsers/test_napoleon.py::test_docstring_parser2 tests/parsers/test_napoleon.py::test_docstring_parser_no_annotations tests/parsers/test_napoleon_pbt.py::test_valid_args_head tests/parsers/test_napoleon_pbt.py::test_invalid_args_head tests/parsers/test_napoleon_pbt.py::test_valid_returns_head tests/parsers/test_napoleon_pbt.py::test_invalid_returns_head tests/parsers/test_napoleon_pbt.py::test_arg_name tests/parsers/test_napoleon_pbt.py::test_dotted_var_path tests/parsers/test_napoleon_pbt.py::test_type_atom tests/parsers/test_napoleon_pbt.py::test_arg_type_annotated tests/parsers/test_napoleon_pbt.py::test_arg_type_no_annotation tests/parsers/test_napoleon_pbt.py::test_ignored_line tests/parsers/test_napoleon_pbt.py::test_ignored_lines tests/parsers/test_napoleon_pbt.py::test_p_arg_list tests/parsers/test_napoleon_pbt.py::test_p_returns_block tests/parsers/test_napoleon_pbt.py::test_docstring_parser tests/test_types.py::test_type_atom_to_annotation[str-example0] tests/test_types.py::test_type_atom_to_annotation[Dict-example1] tests/test_types.py::test_type_atom_to_annotation[Dict[int, tests/test_types.py::test_type_atom_to_annotation[my.generic.Container[int]-example4] tests/test_types.py::test_type_atom_to_annotation[Tuple[int, tests/test_types.py::test_type_atom_to_annotation[Callable[[int, tests/test_types.py::test_type_atom_to_annotation_fix_dotted_paths[str-example0] tests/test_types.py::test_type_atom_to_annotation_fix_dotted_paths[Dict-example1] tests/test_types.py::test_type_atom_to_annotation_fix_dotted_paths[Dict[int, tests/test_types.py::test_type_atom_to_annotation_fix_dotted_paths[Container[int]-example4] tests/test_types.py::test_type_atom_to_annotation_fix_dotted_paths[Tuple[int, tests/test_types.py::test_type_atom_to_annotation_fix_dotted_paths[Callable[[int, tests/test_types.py::test_type_atom_type_names[expected0-example0] tests/test_types.py::test_type_atom_type_names[expected1-example1] tests/test_types.py::test_type_atom_type_names[expected2-example2] tests/test_types.py::test_type_atom_type_names[expected3-example3] tests/test_types.py::test_type_atom_type_names[expected4-example4] tests/test_types.py::test_type_atom_type_names[expected5-example5] tests/test_types.py::test_type_atom_type_names[expected6-example6] tests/test_types.py::test_arg_types_type_names[expected0-example0] tests/test_types.py::test_arg_types_type_names[expected1-example1] tests/test_types.py::test_arg_types_type_names[expected2-example2] tests/test_types.py::test_arg_types_type_names[expected3-example3] tests/test_types.py::test_return_type_type_names[expected0-example0] tests/test_types.py::test_return_type_type_names[expected1-example1] tests/test_types.py::test_return_type_type_names[expected2-example2] tests/test_types.py::test_type_signature_type_names[expected0-example0] tests/test_types.py::test_type_signature_type_names[expected1-example1] tests/test_types.py::test_type_signature_type_names[expected2-example2] tests/test_types.py::test_type_signature_type_names[expected3-example3]
waterloo/parsers/napoleon.py waterloo/annotator/__init__.py setup.py .travis.yml README.md waterloo/annotator/utils.py waterloo/types.py
waterloo/parsers/napoleon.py waterloo/annotator/__init__.py setup.py .travis.yml README.md waterloo/annotator/utils.py waterloo/types.py