Repository: lf1-io/padl (language: python)

Issue:
427 refactor
Refactored based on comments. 


Discussion / hints:

Fix patch:
diff --git a/.github/workflows/actions.yml b/.github/workflows/actions.yml
index 8b4fc9f4..2e11c222 100644
--- a/.github/workflows/actions.yml
+++ b/.github/workflows/actions.yml
@@ -24,6 +24,7 @@ jobs:
           - os: windows-latest
             python-version: 3.9
 
+
     steps:
     - uses: actions/checkout@v2
     - name: Set up Python ${{ matrix.python-version }}
diff --git a/padl/__init__.py b/padl/__init__.py
index 441c3516..e14a06eb 100644
--- a/padl/__init__.py
+++ b/padl/__init__.py
@@ -3,7 +3,8 @@
     Batchify, Unbatchify, identity, Identity, group, load, save, fulldump,
     importdump
 )
-from padl.dumptools.serialize import value, param
+from padl.dumptools.serialize import value
+from padl.dumptools.config_tools import param, params
 from padl.util_transforms import IfTrain, IfEval, IfInfer, IfInMode
 from padl.utils import pd_debug
 from padl.version import __version__
@@ -17,4 +18,4 @@
 
 __all__ = ['value', 'transform', 'Batchify', 'Unbatchify', 'Identity', 'group', 'load', 'save',
            'IfTrain', 'IfEval', 'IfInfer', 'IfInMode', 'identity', 'batch', 'unbatch', 'same',
-           '__version__', 'fulldump', 'importdump', 'param', 'pd_debug']
+           '__version__', 'fulldump', 'importdump', 'param', 'params', 'pd_debug']
diff --git a/padl/dumptools/ast_utils.py b/padl/dumptools/ast_utils.py
index 4ef2dde0..9c647b26 100644
--- a/padl/dumptools/ast_utils.py
+++ b/padl/dumptools/ast_utils.py
@@ -1,6 +1,7 @@
 import ast
 from dataclasses import dataclass
 import hashlib
+from typing import Tuple
 
 try:
     unparse = ast.unparse
@@ -25,6 +26,19 @@ class Position:
     col_offset: int
     end_col_offset: int
 
+    def __iter__(self):
+        yield from (self.lineno, self.end_lineno, self.col_offset, self.end_col_offset)
+
+
+def span_to_pos(span: Tuple[int, int], text: str):
+    """Convert a string-*span* (tuple of start- and end- character positions) to a :class:`Position`.
+    """
+    lineno = text[:span[0]].count('\n') + 1
+    end_lineno = text[:span[1]].count('\n') + 1
+    col_offset = len(text[:span[0]].split('\n')[-1])
+    end_col_offset = len(text[:span[1]].split('\n')[-1])
+    return Position(lineno, end_lineno, col_offset, end_col_offset)
+
 
 def cached_parse(source):
     """Cached version of :func:`ast.parse`.
@@ -76,3 +90,72 @@ def get_position(source, node):
     lineno, col_offset = line_numbers.offset_to_line(from_)
     end_lineno, end_col_offset = line_numbers.offset_to_line(to)
     return Position(lineno, end_lineno, col_offset, end_col_offset)
+
+
+def dump(node, annotate_fields=True, include_attributes=False, *, indent=2):
+    """
+    Ast dump with indentation, taken from python 3.9 source.
+
+    Return a formatted dump of the tree in node.  This is mainly useful for
+    debugging purposes.  If annotate_fields is true (by default),
+    the returned string will show the names and the values for fields.
+    If annotate_fields is false, the result string will be more compact by
+    omitting unambiguous field names.  Attributes such as line
+    numbers and column offsets are not dumped by default.  If this is wanted,
+    include_attributes can be set to true.  If indent is a non-negative
+    integer or string, then the tree will be pretty-printed with that indent
+    level. None (the default) selects the single line representation.
+    """
+    def _format(node, level=0):
+        if indent is not None:
+            level += 1
+            prefix = '\n' + indent * level
+            sep = ',\n' + indent * level
+        else:
+            prefix = ''
+            sep = ', '
+        if isinstance(node, ast.AST):
+            cls = type(node)
+            args = []
+            allsimple = True
+            keywords = annotate_fields
+            for name in node._fields:
+                try:
+                    value = getattr(node, name)
+                except AttributeError:
+                    keywords = True
+                    continue
+                if value is None and getattr(cls, name, ...) is None:
+                    keywords = True
+                    continue
+                value, simple = _format(value, level)
+                allsimple = allsimple and simple
+                if keywords:
+                    args.append('%s=%s' % (name, value))
+                else:
+                    args.append(value)
+            if include_attributes and node._attributes:
+                for name in node._attributes:
+                    try:
+                        value = getattr(node, name)
+                    except AttributeError:
+                        continue
+                    if value is None and getattr(cls, name, ...) is None:
+                        continue
+                    value, simple = _format(value, level)
+                    allsimple = allsimple and simple
+                    args.append('%s=%s' % (name, value))
+            if allsimple and len(args) <= 3:
+                return '%s(%s)' % (node.__class__.__name__, ', '.join(args)), not args
+            return '%s(%s%s)' % (node.__class__.__name__, prefix, sep.join(args)), False
+        elif isinstance(node, list):
+            if not node:
+                return '[]', True
+            return '[%s%s]' % (prefix, sep.join(_format(x, level)[0] for x in node)), False
+        return repr(node), True
+
+    if not isinstance(node, ast.AST):
+        raise TypeError('expected AST, got %r' % node.__class__.__name__)
+    if indent is not None and not isinstance(indent, str):
+        indent = ' ' * indent
+    return _format(node)[0]
diff --git a/padl/dumptools/config_tools.py b/padl/dumptools/config_tools.py
new file mode 100644
index 00000000..183e0ffc
--- /dev/null
+++ b/padl/dumptools/config_tools.py
@@ -0,0 +1,233 @@
+import ast
+
+from padl.dumptools import var2mod, sourceget, ast_utils, symfinder
+
+
+def param(name, val, description=None, use_default=True):
+    """Helper function for marking parameters.
+
+    Parameters can be overridden when loading. See also :func:`padl.load`.
+
+    :param name: The name of the parameter.
+    :param val: The default value of the parameter / the value before saving.
+    :param description: Description of the parameter.
+    :param use_default: If True, will use *val* when loading without specifying a different value.
+    :returns: *val*
+    """
+    return val
+
+
+def params(name, *, use_defaults=True, allow_free=False, **kwargs):
+    """Helper function for marking parameters.
+
+    Parameters can be overridden when loading. See also :func:`padl.load`.
+
+    :param name: The name of the parameter.
+    :param description: Description of the parameter.
+    :param use_defaults: If True, use passed keyword args when loading without specifying
+        different values.
+    """
+    return kwargs
+
+
+def apply_params(source, parsed_kwargs, scope):
+    """Apply param replacement in *source*.
+
+    :param source: The sourcecode in which to replace the params.
+    :param parsed_kwargs: The parsed keyword argument with params to replace.
+    :param scope: The scope.
+    """
+    param_dicts = extract_param_s(source)
+    params_dicts = extract_params_s(source)
+
+    code_graph = var2mod.CodeGraph()
+
+    done = set()
+
+    for k, v in parsed_kwargs.items():
+        if k in param_dicts:
+            code_graph_here = var2mod.CodeGraph().from_source(f'PARAM_{k} = {v}', scope, name=k)
+            if len(code_graph_here) > 1:
+                code_graph.update(code_graph_here)
+                source = change_param(source, k, f'PARAM_{k}')
+            else:
+                source = change_param(source, k, v)
+            done.add(k)
+        elif k in params_dicts:
+            value_dict = parse_dict(v)
+            repl = {}
+            for sub_k, sub_v in value_dict.items():
+                code_graph_here = var2mod.CodeGraph().from_source(f'PARAM_{k}_{sub_k} = {sub_v}',
+                                                                  scope, name=k)
+                if len(code_graph_here) > 1:
+                    code_graph.update(code_graph_here)
+                    repl[sub_k] = f'PARAM_{k}_{sub_k}'
+                else:
+                    repl[sub_k] = sub_v
+                done.add((k, sub_k))
+
+            source = change_params(source, k, **repl)
+        else:
+            raise ValueError(f'Extra parameter: {k}')
+    print(done)
+
+    missing = []
+    for k, v in param_dicts.items():
+        if not eval(v['use_default']) and k not in done:
+            missing.append(k)
+    for k, v in params_dicts.items():
+        for sub_k in v['kwargs']:
+            if not eval(v['use_defaults']) and (k, sub_k) not in done:
+                missing.append(f'{k}.{sub_k}')
+
+    if missing:
+        raise ValueError(f'Missing mandatory parameter(s): {missing}')
+
+    return code_graph.dumps().strip('\n') + source
+
+
+def extract_param_s(source: str) -> dict:
+    """Extract :func:`params` calls from *source*.
+
+    Example:
+
+    >>> source = '''a = param('param_a', x, 'a parameter')
+    ... b = param('b', 2)'''
+    >>> extract_param_s(source)  # doctest: +NORMALIZE_WHITESPACE
+    {'param_a': {'name': "'param_a'", 'val': 'x', 'description': "'a parameter'",
+                 'use_default': 'True',
+                 'position': Position(lineno=1, end_lineno=1, col_offset=21, end_col_offset=22)},
+     'b': {'name': "'b'", 'val': '2', 'description': 'None', 'use_default': 'True',
+           'position': Position(lineno=2, end_lineno=2, col_offset=15, end_col_offset=16)}}
+
+    :param source: The source code from which to extract the :func:`param`-calls.
+    :return: A dict mapping :func:`param` arguments (*name*, *val*, *description*, *use_default*)
+        to their string values. Also adds a "position" entry with the position of the parameter's
+        value in the code.
+    """
+    res = {}
+    for call in var2mod.Finder(ast.Call).find(ast.parse(source)):
+        if ast_utils.get_source_segment(source, call.func) not in ('param', 'padl.param'):
+            continue
+        position = ast_utils.get_position(source, call)
+        call_source = sourceget.cut(source, *position, one_indexed=True)
+        args, keys, _, _ = symfinder._get_call_signature(call_source)
+        signature = symfinder.Signature(argnames=['name', 'val', 'description', 'use_default'],
+                                        defaults={'description': 'None', 'use_default': 'True'})
+        assignments = signature.get_call_assignments(args, keys)
+        if call.args:
+            # value provided positionally
+            assignments['position'] = ast_utils.get_position(source, call.args[1])
+        else:
+            # value provided as keyword
+            assignments['position'] = [ast_utils.get_position(source, x.value)
+                                       for x in call.keywords if x.arg == 'val'][0]
+        res[assignments['name'].strip("'\"")] = assignments
+    return res
+
+
+def extract_params_s(source):
+    """Extract :func:`params` calls from *source*.
+
+    Example:
+
+    >>> source = '''x = X(**params('params_x', a=1, b=2))'''
+    >>> extract_params_s(source)  # doctest: +NORMALIZE_WHITESPACE
+    {'params_x':
+        {'name': "'params_x'",
+         'kwargs': {'a': '1', 'b': '2'},
+         'use_defaults': 'True',
+         'allow_free': 'False',
+         'positions': {'a': Position(lineno=1, end_lineno=1, col_offset=29, end_col_offset=30),
+                       'b': Position(lineno=1, end_lineno=1, col_offset=34, end_col_offset=35)},
+         'end_position': Position(lineno=1, end_lineno=1, col_offset=35, end_col_offset=35)}}
+
+
+    :param source: The source code from which to extract the :func:`params`-calls.
+    :return: A dict mapping :func:`params` arguments (*name*, *use_defaults* and *allow_free*)
+        to their string values. Also adds a "kwargs" entry and a "positions" entry with the position
+        of the parameter's values in the code.
+    """
+    res = {}
+    for call in var2mod.Finder(ast.Call).find(ast.parse(source)):
+        if ast_utils.get_source_segment(source, call.func) not in ('params', 'padl.params'):
+            continue
+        position = ast_utils.get_position(source, call)
+        call_source = sourceget.cut(source, *position, one_indexed=True)
+        args, keys, _, _ = symfinder._get_call_signature(call_source)
+        signature = symfinder.Signature(argnames=['name', 'use_defaults', 'allow_free'],
+                                        kwarg='kwargs',
+                                        defaults={'use_defaults': 'True', 'allow_free': 'False'})
+        assignments = signature.get_call_assignments(args, keys, dump_kwargs=False)
+        if 'kwargs' not in assignments:
+            assignments['kwargs'] = {}
+        assignments['positions'] = {
+            ke.arg: ast_utils.get_position(source, ke.value)
+            for ke in call.keywords
+            if ke.arg not in ('name', 'use_defaults', 'allow_free')
+        }
+        arg_end_lineno = max(pos.end_lineno for pos in assignments['positions'].values())
+        arg_end_col_offset = max(pos.end_col_offset for pos in assignments['positions'].values()
+                                 if pos.end_lineno == arg_end_lineno)
+        assignments['end_position'] = ast_utils.Position(arg_end_lineno, arg_end_lineno,
+                                                         arg_end_col_offset, arg_end_col_offset)
+        res[assignments['name'].strip("'\"")] = assignments
+    return res
+
+
+def change_param(source: str, param_name: str, value: str) -> str:
+    """Change the value of a param with name *param_name* in *source* to *value*.
+
+    Note that *value* is the string representation of the value.
+
+    Example:
+
+    >>> source = "x = param('myparam', 1)"
+    >>> change_param(source, "myparam", "100")
+    "x = param('myparam', 100)"
+    """
+    param_dict = extract_param_s(source)[param_name]
+    return sourceget.replace(source, value, *param_dict['position'], one_indexed=True)
+
+
+def change_params(source, param_name, **kwargs):
+    """Change the value of params with name *param_name* in *source* to values provided as *kwargs*.
+
+    Note that values must be the string representation of the values.
+
+    Example:
+
+    >>> source = "x = params('myparam', a=1, b=2)"
+    >>> change_params(source, "myparam", a="100")
+    "x = params('myparam', a=100, b=2)"
+    """
+    params_dict = extract_params_s(source)[param_name]
+    kwargs_ = {**params_dict['kwargs'], **kwargs}
+    for k, v in kwargs_.items():
+        params_dict = extract_params_s(source)[param_name]
+        if k in params_dict['kwargs']:
+            source = sourceget.replace(source, v, *params_dict['positions'][k], one_indexed=True)
+        else:
+            if not eval(params_dict['allow_free']):
+                raise ValueError(f'You provided param "{param_name}.{k}" which doesn\'t exist.')
+            source = sourceget.replace(source, f', {k}={v}', *params_dict['end_position'],
+                                       one_indexed=True)
+    return source
+
+
+def parse_dict(source):
+    """Parse a string containing a python dict.
+
+    Example:
+
+    >>> parse_dict("{'a': 1, 'b': '2', 'c': random.rand(1000)}")
+    {'a': '1', 'b': "'2'", 'c': 'random.rand(1000)'}
+
+    :param source: A string containing a python dict.
+    :return: The dict. Note that the values are strings of the values.
+    """
+    dict_node = ast.parse(source).body[0].value
+    return {
+        ast_utils.get_source_segment(source, k).strip("'\""): ast_utils.get_source_segment(source, v)
+        for k, v in zip(dict_node.keys, dict_node.values)
+    }
diff --git a/padl/dumptools/inspector.py b/padl/dumptools/inspector.py
index b457e85d..56c2c4ff 100644
--- a/padl/dumptools/inspector.py
+++ b/padl/dumptools/inspector.py
@@ -50,8 +50,10 @@ def __init__(self, origin: Literal['nextmodule', 'here'] = 'nextmodule',
             caller_frameinfo = outer_caller_frameinfo(calling_module_name)
         elif origin == 'here':
             caller_frameinfo = inspect.stack()[1]
-        self.function = caller_frameinfo.function
-        self.scope = self._determine_scope(caller_frameinfo, drop_n, ignore_scope)
+
+        self.function: str = caller_frameinfo.function
+
+        self.scope: symfinder.Scope = self._determine_scope(caller_frameinfo, drop_n, ignore_scope)
 
     def _determine_scope(self, caller_frameinfo: inspect.FrameInfo,
                          drop_n: int, ignore_scope: bool) -> symfinder.Scope:
@@ -59,7 +61,7 @@ def _determine_scope(self, caller_frameinfo: inspect.FrameInfo,
         module = _module(caller_frameinfo.frame)
 
         if self.function == '<module>' or ignore_scope:
-            return symfinder.Scope.toplevel(module)
+            return symfinder.Scope(module, inspect.getsource(caller_frameinfo.frame), [])
 
         return _get_scope_from_frame(caller_frameinfo.frame, drop_n)
 
@@ -79,16 +81,18 @@ def _get_scope_from_frame(frame, drop_n):
     # don't dive deeper for excluded modules
     if any(module.__name__.startswith(excluded_module) for excluded_module in _EXCLUDED_MODULES):
         return symfinder.Scope.toplevel(module)
-    # don't dive deeper after
+    # don't dive deeper if f_back is None
+    if frame.f_back is None:
+        return symfinder.Scope.toplevel(module)
+    # don't dive deeper after main
     if module.__name__ == '__main__' and _module(frame.f_back) != module:
         return symfinder.Scope.toplevel(module)
     # don't dive deeper if not a call
     if frame.f_code.co_name == '<module>':
-
         return symfinder.Scope.toplevel(module)
 
     try:
-        call_source = get_segment_from_frame(frame.f_back, 'call')
+        call_source, locs = get_segment_from_frame(frame.f_back, 'call', return_locs=True)
     except (RuntimeError, FileNotFoundError, AttributeError, OSError):
         return symfinder.Scope.toplevel(module)
     try:
@@ -98,7 +102,7 @@ def _get_scope_from_frame(frame, drop_n):
     calling_scope = _get_scope_from_frame(frame.f_back, 0)
     scope = symfinder.Scope.from_source(definition_source, frame.f_lineno,
                                         call_source, module, drop_n,
-                                        calling_scope)
+                                        calling_scope, frame, locs)
     assert len(scope) <= 1, 'scope longer than 1 currently not supported'
     return scope
 
@@ -122,7 +126,7 @@ def trace_this(tracefunc: Callable, frame: Optional[types.FrameType] = None, *ar
     Example:
 
     >>> def tracefunc(frame, event, arg):
-    ...     if 'event' == 'return':
+    ...     if event == 'return':
     ...         print('returning', arg)
 
     >>> def myfunction():
@@ -174,7 +178,6 @@ def _instructions_in_name(x) -> list:
 
 
 def _instructions_in_getitem(x) -> list:
-    """Get all instructions up to last CALL FUNCTION. """
     instructions = list(dis.get_instructions(x))
     return instructions[:-1]
 
@@ -211,7 +214,7 @@ def outer_caller_frameinfo(module_name: str) -> inspect.FrameInfo:
 
 
 def caller_module() -> types.ModuleType:
-    """Get the first module of the caller. """
+    """Get the module of the caller. """
     calling_module_name = inspect.currentframe().f_back.f_globals['__name__']
     return _module(outer_caller_frameinfo(calling_module_name).frame)
 
@@ -240,6 +243,7 @@ def instructions_are_the_same(instr: dis.Instruction, target_instr: dis.Instruct
         argval = frame.f_locals[target_instr.argval]
     else:
         argval = target_instr.argval
+
     # if the instruction's argval contains code objects, compare the code
     instr_argval = getattr(instr.argval, 'co_code', instr.argval)
     target_argval = getattr(target_instr.argval, 'co_code', target_instr.argval)
@@ -357,3 +361,26 @@ def get_statement_at_line(source: str, lineno: int, checker):
             if res is not None:
                 return res
     raise RuntimeError('Statement not found.')
+
+
+def get_my_call_signature():
+    """Call from within a function to get its call signature.
+
+    Returns a tuple of
+
+        - list of positional arguments
+        - dict of keyword arguments
+        - value of *args (as a string)
+        - value of **kwargs (as a string)
+
+    (see also :func:`symfinder._get_call_signature`).
+
+    Example:
+
+    >>> def f(a, *args, b=1, **kwargs): return get_my_call_signature()
+    >>> f(1, 2, *[1, 2], b="hello", c=123, **{'1': 1, '2': 2})
+    (['1', '2'], {'b': '"hello"', 'c': '123'}, '[1, 2]', "{'1': 1, '2': 2}")
+    """
+    calling_frame = inspect.currentframe().f_back.f_back
+    call_source = get_segment_from_frame(calling_frame, 'call')
+    return symfinder._get_call_signature(call_source)
diff --git a/padl/dumptools/packagefinder.py b/padl/dumptools/packagefinder.py
index 84ddb4e4..228cd67a 100644
--- a/padl/dumptools/packagefinder.py
+++ b/padl/dumptools/packagefinder.py
@@ -13,6 +13,7 @@
 
 
 def standard_lib_names_gen(include_underscored=False):
+    """Get a packages from the standard library. """
     standard_lib_dir = os.path.dirname(os.__file__)
     for filename in os.listdir(standard_lib_dir):
         if not include_underscored and filename.startswith('_'):
@@ -109,7 +110,8 @@ def dump_requirements(nodes, strict=False):
         except RequirementNotFound as exc:
             if strict and package not in _ignore_requirements:
                 raise exc
-            warn(f'The "{package}" requirement was not found.')
+            if not strict:
+                warn(f'The "{package}" requirement was not found.')
             continue
         result += f'{dist}=={version(dist)}\n'
     return result
diff --git a/padl/dumptools/serialize.py b/padl/dumptools/serialize.py
index 0c676620..96f79601 100644
--- a/padl/dumptools/serialize.py
+++ b/padl/dumptools/serialize.py
@@ -1,18 +1,17 @@
 import ast
 from collections.abc import Iterable
-import inspect
 import json
 from pathlib import Path
 import sys
 from types import ModuleType
 from typing import Any, Callable, List, Optional
 
-from padl.dumptools import inspector, sourceget, var2mod, symfinder
-from padl.dumptools.symfinder import ScopedName
+from padl.dumptools import inspector, sourceget, var2mod
+from padl.dumptools.symfinder import Scope, ScopedName
 from padl.dumptools.var2mod import CodeNode, CodeGraph
 
 
-SCOPE = symfinder.Scope.toplevel(sys.modules[__name__])
+SCOPE = Scope.toplevel(sys.modules[__name__])
 
 
 class Serializer:
@@ -39,10 +38,10 @@ def __init__(self, val: Any, save_function: Callable, load_function: callable,
         self.file_suffix = file_suffix
         if module is None:
             module = inspector.caller_module()
-        self.scope = symfinder.Scope.toplevel(module)
+        self.scope = Scope.toplevel(module)
         self.load_codegraph = \
-            var2mod.CodeGraph.build(ScopedName(load_function.__name__,
-                                               symfinder.Scope.toplevel(load_function.__module__)))
+            var2mod.CodeGraph().build(ScopedName(load_function.__name__,
+                                                 Scope.toplevel(load_function.__module__)))
         self.load_name = load_function.__name__
         super().__init__()
 
@@ -72,17 +71,16 @@ def save(self, path: Path):
                              'nothing.')
         return CodeGraph(
             {**self.load_codegraph,
-             ScopedName(self.varname, self.scope):
+             ScopedName(self.varname, Scope.empty(), pos='injected'):
                  CodeNode(source=f'{self.varname} = {self.load_name}({complete_path})',
                           globals_={ScopedName(self.load_name, self.scope)},
-                          scope=self.scope,
-                          name=self.varname),
-             ScopedName('pathlib', SCOPE):
+                          ast_node=ast.parse(f'{self.varname} = {self.load_name}({complete_path})').body[0],
+                          name=ScopedName(self.varname, self.scope, pos='injected')),
+             ScopedName('pathlib', SCOPE, pos='injected'):
                  CodeNode(source='import pathlib',
                           globals_=set(),
                           ast_node=ast.parse('import pathlib').body[0],
-                          scope=self.scope,
-                          name='pathlib')}
+                          name=ScopedName('pathlib', SCOPE, pos='injected'))}
         )
 
     @property
@@ -133,43 +131,3 @@ def value(val, serializer=None):
     sourceget.put_into_cache(caller_frameinfo.filename, sourceget.original(source),
                              _serialize(val, serializer=serializer), *locs)
     return val
-
-
-def param(val, name, description=None, use_default=True):
-    """Helper function for marking parameters.
-
-    Parameters can be overridden when loading. See also :func:`padl.load`.
-
-    :param val: The default value of the parameter / the value before saving.
-    :param name: The name of the parameter.
-    :param use_default: If True, will use *val* when loading without specifying a different value.
-    :returns: *val*
-    """
-    caller_frameinfo = inspector.outer_caller_frameinfo(__name__)
-    if not use_default and val is not None:
-        call, locs = inspector.get_segment_from_frame(caller_frameinfo.frame, 'call', True)
-        source = sourceget.get_source(caller_frameinfo.filename)
-        call, args = symfinder.split_call(call)
-        args = 'None, ' + args.split(',', 1)[1]
-        sourceget.put_into_cache(caller_frameinfo.filename, sourceget.original(source),
-                                 f'{call}({args})', *locs)
-
-    module = inspector._module(caller_frameinfo.frame)
-    if not getattr(module, '_pd_is_padl_file', False):
-        return val
-
-    module._pd_found_params[name] = val
-
-    try:
-        return module._pd_params[name]
-    except KeyError as exc:
-        if val is None and not use_default:
-            raise ValueError(f'Unfilled parameter *{name}*. \n\n'
-                             'When loading a transform, '
-                             f'provide *{name}* as a keyword '
-                             f'argument: padl.load(..., {name}=...).'
-                             + (description is not None
-                             and f'\n\nDescription: "{description}"'
-                             or '')
-                             ) from exc
-        return val
diff --git a/padl/dumptools/sourceget.py b/padl/dumptools/sourceget.py
index 9fb570ff..81b14900 100644
--- a/padl/dumptools/sourceget.py
+++ b/padl/dumptools/sourceget.py
@@ -16,6 +16,8 @@
 from types import ModuleType
 from typing import List
 
+from padl.dumptools import ast_utils
+
 
 replace_cache = {}
 
@@ -44,7 +46,7 @@ def get_source(filename: str, use_replace_cache: bool = True) -> str:
         return ''.join(linecache.cache[filename][2])
 
     # normal module
-    with open(filename) as f:
+    with open(filename, encoding='utf-8') as f:
         return f.read()
 
 
@@ -86,6 +88,7 @@ def put_into_cache(key, source: str, repl: str, *loc):
     :param *loc: The location where *repl* is to be inserted (give as *from_line*, *to_line*,
         *from_col*, *to_col*).
     """
+    ast_utils.AST_NODE_CACHE.clear()
     val = ReplaceString(source, repl, *loc)
     try:
         replace_cache[key] = ReplaceStrings([val] + replace_cache[key].rstrings)
@@ -98,7 +101,8 @@ def original(string: str) -> str:
     return getattr(string, 'original', string)
 
 
-def cut(string: str, from_line: int, to_line: int, from_col: int, to_col: int) -> str:
+def cut(string: str, from_line: int, to_line: int, from_col: int, to_col: int,
+        one_indexed: bool = False) -> str:
     """Cut a string (can be a normal `str`, a `ReplaceString` or a `ReplaceStrings`) and return the
     resulting substring.
 
@@ -127,8 +131,13 @@ def cut(string: str, from_line: int, to_line: int, from_col: int, to_col: int) -
     :param to_line: The last line to include.
     :param from_col: The first col on *from_line* to include.
     :param to_col: The last col on *to_line* to include.
+    :param one_indexed: If *True*, count lines from 1, else from 0 (useful as many other libraries
+        work with one-indexed linenumbers).
     :returns: The cut-out string.
     """
+    if one_indexed:
+        from_line -= 1
+        to_line -= 1
     try:
         return string.cut(from_line, to_line, from_col, to_col)
     except AttributeError:
@@ -273,8 +282,15 @@ def _cut_string(string: str, from_line: int, to_line: int, from_col: int, to_col
     return '\n'.join(lines)
 
 
-def replace(string, repl, from_line, to_line, from_col, to_col):
-    """Replace a substring in *string* with *repl*. """
+def replace(string, repl, from_line, to_line, from_col, to_col, one_indexed=False):
+    """Replace a substring in *string* with *repl*.
+
+    :param one_indexed: If *True*, count lines from 1, else from 0.
+    """
+    if one_indexed:
+        from_line -= 1
+        to_line -= 1
+
     if from_line < 0 and to_line < 0:
         return string
 
diff --git a/padl/dumptools/symfinder.py b/padl/dumptools/symfinder.py
index f5abefc6..64d83952 100644
--- a/padl/dumptools/symfinder.py
+++ b/padl/dumptools/symfinder.py
@@ -19,17 +19,18 @@
 The main function to use is :func:`find`, which will find a name in a module or the current ipython
 history.
 
-This module also defines the :class:`Scope`, which represents the "location" of an python-thing,
+This module also defines the :class:`Scope`, which represents the "location" of a python-thing,
 and the :class:`ScopedName`, which is the name of a thing, with its scope.
 """
 
 import ast
-from dataclasses import dataclass
+from collections import defaultdict
+from dataclasses import dataclass, field
 from math import inf
 import sys
 from textwrap import dedent
 from types import ModuleType
-from typing import List, Tuple
+from typing import List, Tuple, Optional
 
 from padl.dumptools import ast_utils, sourceget
 
@@ -368,74 +369,130 @@ def generic_visit(self, node):
         super().generic_visit(node)
 
 
-def _get_call_assignments(args, source, values, keywords):
+@dataclass
+class Signature:
+    argnames: list = field(default_factory=lambda: [])
+    pos_only_argnames: list = field(default_factory=lambda: [])
+    defaults: dict = field(default_factory=lambda: {})
+    kwonly_defaults: dict = field(default_factory=lambda: {})
+    vararg: Optional[str] = None
+    kwarg: Optional[str] = None
+
+    @property
+    def all_argnames(self):
+        return self.pos_only_argnames + self.argnames
+
+    def get_call_assignments(self, pos_args, keyword_args, star_args=None, star_kwargs=None,
+                             dump_kwargs=True):
+        """Given a call signature, return the assignmentes to this function signature.
+
+        :param pos_args: Positional args.
+        :param keyword_args: Keyword args.
+        :param star_args: Value of *args.
+        :param star_kwargs: Value of **kwargs.
+        :param dump_kwargs: If *True*, return kwargs as a dumped string, else as a dict.
+        """
+        res = {}
+        for name, val in self.kwonly_defaults.items():
+            try:
+                res[name] = keyword_args[name]
+            except KeyError:
+                res[name] = val
+
+        for name, val in zip(self.all_argnames, pos_args):
+            res[name] = val
+
+        if self.vararg is not None:
+            res[self.vararg] = '[' + ', '.join(pos_args[len(self.all_argnames):]) + ']'
+
+        kwargs = {}
+        for name, val in keyword_args.items():
+            if name in res:
+                continue
+            if name in self.argnames:
+                res[name] = val
+            else:
+                kwargs[name] = val
+
+        if kwargs and not set(kwargs) == {None}:
+            assert self.kwarg is not None, 'Extra keyword args given, but no **kwarg present.'
+            if dump_kwargs:
+                res[self.kwarg] = '{' + ', '.join(f"'{k}': {v}" for k, v in kwargs.items()) + '}'
+            else:
+                res[self.kwarg] = kwargs
+
+        for name, val in self.defaults.items():
+            if name not in res:
+                if star_kwargs is not None:
+                    res[name] = f"{star_kwargs}.get('{name}', {val})"
+                else:
+                    res[name] = val
+
+        return res
+
+
+def _parse_def_args(args, source):
     argnames = [x.arg for x in args.args]
+
     try:
         pos_only_argnames = [x.arg for x in args.posonlyargs]
     except AttributeError:
         pos_only_argnames = []
-    all_argnames = pos_only_argnames + argnames
+
     defaults = {
         name: ast_utils.get_source_segment(source, val)
         for name, val in zip(argnames[::-1], args.defaults[::-1])
     }
+
     kwonly_defaults = {
         ast_utils.get_source_segment(source, name): ast_utils.get_source_segment(source, val)
         for name, val in zip(args.kwonlyargs, args.kw_defaults)
         if val is not None
     }
-    res = {}
-    for name, val in kwonly_defaults.items():
-        try:
-            res[name] = keywords[name]
-        except KeyError:
-            res[name] = val
-
-    for name, val in zip(all_argnames, values):
-        res[name] = val
 
     if args.vararg is not None:
-        res[args.vararg.arg] = '[' + ', '.join(values[len(all_argnames):]) + ']'
-
-    kwargs = {}
-    for name, val in keywords.items():
-        if name in res:
-            continue
-        if name in argnames:
-            res[name] = val
-        else:
-            kwargs[name] = val
-
-    if kwargs and not set(kwargs) == {None}:
-        assert args.kwarg is not None, 'Keyword args given, but no **kwarg present.'
-        res[args.kwarg.arg] = '{' + ', '.join(f"'{k}': {v}" for k, v in kwargs.items()) + '}'
+        vararg = args.vararg.arg
+    else:
+        vararg = None
 
-    for name, val in defaults.items():
-        if name not in res:
-            res[name] = val
+    if args.kwarg is not None:
+        kwarg = args.kwarg.arg
+    else:
+        kwarg = None
 
-    return res
+    return Signature(argnames, pos_only_argnames, defaults, kwonly_defaults, vararg, kwarg)
 
 
 def _get_call_signature(source: str):
     """Get the call signature of a string containing a call.
 
-    :param source: String containing a call (e.g. "a(2, b, 'f', c=100)")
-    :returns: A tuple with a list of positional arguments and a list of keyword arguments.
+    :param source: String containing a call (e.g. "a(2, b, 'f', c=100)").
+    :returns: A tuple with
+        - a list of positional arguments
+        - a list of keyword arguments
+        - the value of *args, if present, else None
+        - the value of *kwargs, if present, else None
 
     Example:
 
-    >>> _get_call_signature("a(2, b, 'f', c=100)")
-    (['2', 'b', "'f'"], {'c': '100'})
+    >>> _get_call_signature("a(2, b, 'f', c=100, *[1, 2], **kwargs)")
+    (['2', 'b', "'f'"], {'c': '100'}, '[1, 2]', 'kwargs')
     """
     call = ast.parse(source).body[0].value
     if not isinstance(call, ast.Call):
         return [], {}
-    args = [ast_utils.get_source_segment(source, arg) for arg in call.args]
+    star_args = None
+    args = []
+    for arg in call.args:
+        if isinstance(arg, ast.Starred):
+            star_args = ast_utils.get_source_segment(source, arg.value)
+            continue
+        args.append(ast_utils.get_source_segment(source, arg))
     kwargs = {
         kw.arg: ast_utils.get_source_segment(source, kw.value) for kw in call.keywords
     }
-    return args, kwargs
+    star_kwargs = kwargs.pop(None, None)
+    return args, kwargs, star_args, star_kwargs
 
 
 class Scope:
@@ -461,28 +518,38 @@ def g():
 
     It contains the module, the source string and a "scopelist".
     """
+    _counts = defaultdict(set)
 
     def __init__(self, module: ModuleType, def_source: str,
-                 scopelist: List[Tuple[str, ast.AST]]):
+                 scopelist: List[Tuple[str, ast.AST]], id_=None):
         self.module = module
         self.def_source = def_source
         self.scopelist = scopelist
+        self.id_ = id_
+        self._counts[self.dot_string()].add(id_)
 
     @classmethod
     def toplevel(cls, module):
         """Create a top-level scope (i.e. module level, no nesting). """
         if isinstance(module, str):
             module = sys.modules[module]
-        return cls(module, '', [])
+        try:
+            source = sourceget.get_module_source(module)
+        except TypeError:
+            source = ''
+        return cls(module, source, [])
 
     @classmethod
     def empty(cls):
         """Create the empty scope (a scope with no module and no nesting). """
         return cls(None, '', [])
 
+    def __deepcopy__(self, memo):
+        return Scope(self.module, self.def_source, self.scopelist, self.id_)
+
     @classmethod
     def from_source(cls, def_source, lineno, call_source, module=None, drop_n=0,
-                    calling_scope=None):
+                    calling_scope=None, frame=None, locs=None):
         """Create a `Scope` object from source code.
 
         :param def_source: The source string containing the scope.
@@ -512,9 +579,10 @@ def from_source(cls, def_source, lineno, call_source, module=None, drop_n=0,
         # b = 2
         # c = 3
         # ...
-        values, keywords = _get_call_signature(call_source)
+        pos_args, keyword_args, star_args, star_kwargs = _get_call_signature(call_source)
         args = function_defs[-1].args
-        assignments = _get_call_assignments(args, def_source, values, keywords)
+        assignments = _parse_def_args(args, def_source).get_call_assignments(pos_args, keyword_args,
+                                                                             star_args, star_kwargs)
         call_assignments = []
         for k, v in assignments.items():
             src = f'{k} = {v}'
@@ -533,7 +601,9 @@ def from_source(cls, def_source, lineno, call_source, module=None, drop_n=0,
         # add call assignments to inner scope
         scopelist[0][1].body = call_assignments + scopelist[0][1].body
 
-        return cls(module, def_source, scopelist)
+        id_ = str((frame.f_code.co_filename, locs))
+
+        return cls(module, def_source, scopelist, id_)
 
     def from_level(self, i: int) -> 'Scope':
         """Return a new scope starting at level *i* of the scope hierarchy. """
@@ -551,6 +621,9 @@ def is_global(self) -> bool:
         """*True* iff the scope is global. """
         return len(self.scopelist) == 0
 
+    def is_empty(self) -> bool:
+        return self.module is None
+
     @property
     def module_name(self) -> str:
         """The name of the scope's module. """
@@ -563,10 +636,22 @@ def unscoped(self, varname: str) -> str:
         the containing scope. """
         if not self.scopelist and self.module_name in ('', '__main__'):
             return varname
-        return f'{"_".join(x[0] for x in [(self.module_name.replace(".", "_"), 0)] + self.scopelist)}_{varname}'
+        return f'{"_".join(x[0] for x in [(self.module_name.replace(".", "_"), 0)] + self.scopelist)}{self._formatted_index()}_{varname}'
+
+    def index(self):
+        return sorted(self._counts[self.dot_string()]).index(self.id_)
+
+    def _formatted_index(self):
+        index = self.index()
+        if index == 0:
+            return ''
+        return f'_{self.index()}'
+
+    def dot_string(self):
+        return ".".join(x[0] for x in [(self.module_name, 0)] + self.scopelist[::-1])
 
     def __repr__(self):
-        return f'Scope[{".".join(x[0] for x in [(self.module_name, 0)] + self.scopelist[::-1])}]'
+        return f'Scope[{self.dot_string()}{self._formatted_index()}]'
 
     def __len__(self):
         return len(self.scopelist)
@@ -577,8 +662,10 @@ def __eq__(self, other):
     def __hash__(self):
         return hash(str(self))
 
+    def d_name(self, name, pos=None, cell_no=None):
+        return ScopedName(name, self, pos, cell_no)
+
 
-@dataclass
 class ScopedName:
     """A name with a scope and a counter. The "name" is the name of the item, the scope
     is its :class:`Scope` and the counter counts the items with the same name, in the same scope,
@@ -602,16 +689,71 @@ def f(x):
         - the function name "f", module level scope, `n = 0`
         - the "a" of `a = 1`, with `name = a`, module-level scope and `n = 1` (as it's the second
           most recent "a" in its scope).
+
+    :param name: Name of this ScopedName.
+    :param scope: Scope of this ScopedName.
+    :param pos: (optional) Maximum position (tuple of line number and col number).
+    :param cell_no: (optional) Maximum ipython cell number.
     """
-    name: str
-    scope: Scope
-    n: int = 0
+
+    def __init__(self, name, scope=None, pos=None, cell_no=None):
+        self.name = name
+        if scope is None:
+            scope = Scope.empty()
+        self.scope = scope
+        self.pos = pos
+        self.cell_no = cell_no
 
     def __hash__(self):
-        return hash((self.name, self.scope, self.n))
+        return hash((self.name, self.scope, self.pos))
 
     def __eq__(self, other):
-        return (self.name, self.scope, self.n) == (other.name, other.scope, other.n)
+        return (
+            self.scope == other.scope
+            and self.name == other.name
+            and self.pos == other.pos
+        )
+
+    @property
+    def toplevel_name(self):
+        return self.name.split('.', 1)[0]
+
+    def variants(self):
+        """Returns list of splits for input_name.
+        Example:
+
+        >>> ScopedName('a.b.c', '__main__').variants()
+        ['a', 'a.b', 'a.b.c']
+        """
+        splits = self.name.split('.')
+        out = []
+        for ind, split in enumerate(splits):
+            out.append('.'.join(splits[:ind] + [split]))
+        return out
+
+    def update_scope(self, new_scope):
+        self.scope = new_scope
+        return self
+
+    def copy(self):
+        return ScopedName(self.name, self.scope, self.pos, self.cell_no)
+
+    def __repr__(self):
+        return (
+            f"ScopedName(name='{self.name}', scope={self.scope}, pos={self.pos}, "
+            f"cell_no={self.cell_no})"
+        )
+
+
+def statements_before(source, statements, pos):
+    if pos is None:
+        return statements
+    line, col = pos
+    for i, node in enumerate(statements):
+        pos = ast_utils.get_position(source, node)
+        if pos.lineno < line or (pos.lineno == line and pos.col_offset < col):
+            return statements[i:]
+    return []
 
 
 def find_in_scope(scoped_name: ScopedName):
@@ -619,32 +761,39 @@ def find_in_scope(scoped_name: ScopedName):
     *scoped_name.name* in the scope *scoped_name.scope*.
 
     :param scoped_name: Name (with scope) of the variable to look for.
+    :return: Tuple as ((source, node), scope, name), where
+        * source: String representation of piece of code.
+        * node: Ast node for the code.
+        * scope: Scope of the code.
+        * name: Name of variable (str).
+
     """
     scope = scoped_name.scope
-    i = scoped_name.n
+    searched_name = scoped_name.copy()
     for _scopename, tree in scope.scopelist:
         try:
-            res = find_in_source(scoped_name.name, scoped_name.scope.def_source, tree=tree, i=i,
-                                 return_partial=True)
-            if isinstance(res, int):
-                i = res
-                continue
+            res = find_scopedname_in_source(searched_name, source=searched_name.scope.def_source,
+                                            tree=tree)
             source, node, name = res
             if getattr(node, '_globalscope', False):
-                scope = Scope.empty()
-
-            return (source, node), scope, name
+                name.scope = Scope.empty()
+            else:
+                name.scope = scope
+            return (source, node), name
         except NameNotFound:
             scope = scope.up()
+            searched_name.pos = None
+            searched_name.cell_no = None
             continue
     if scope.module is None:
-        raise NameNotFound(f'{scoped_name.name} not found in function hierarchy.')
-    source, node, name = find(scoped_name.name, scope.module, i)
+        raise NameNotFound(format_scoped_name_not_found(scoped_name))
+    source, node, name = find_scopedname(searched_name)
     if getattr(node, '_globalscope', False):
         scope = Scope.empty()
     else:
         scope = getattr(node, '_scope', scope.global_())
-    return (source, node), scope, name
+    name.scope = scope
+    return (source, node), name
 
 
 def replace_star_imports(tree: ast.Module):
@@ -668,35 +817,58 @@ def replace_star_imports(tree: ast.Module):
                 node.names = [ast.alias(name=name, asname=None) for name in names]
 
 
-def find_in_source(var_name: str, source: str, tree=None, i: int = 0,
-                   return_partial=False) -> Tuple[str, ast.AST]:
+def find_scopedname_in_source(scoped_name: ScopedName, source, tree=None) -> Tuple[str, ast.AST, str]:
     """Find the piece of code that assigned a value to the variable with name *var_name* in the
     source string *source*.
 
-    :param var_name: Name of the variable to look for.
+    :param scoped_name: ScopedName to look for.
+    :param tree: AST.Module to look into for scoped_name.
     :param source: Source code to search.
-    :returns: Tuple with source code segment and corresponding AST node.
+    :returns: Tuple with source code segment, corresponding AST node and variable name.
     """
     if tree is None:
         tree = ast.parse(source)
+
     replace_star_imports(tree)
     finder_clss = _ThingFinder.__subclasses__()
-    for statement in tree.body[::-1]:
-        for finder_cls in finder_clss:
-            finder = finder_cls(source, var_name)
-            finder.visit(statement)
-            if finder.found_something():
-                if i == 0:
-                    return finder.deparse(), finder.node(), var_name
-                i -= 1
-    if '.' in var_name:
-        return find_in_source(var_name.rsplit('.', 1)[0], source, tree, i, return_partial)
-    if return_partial:
-        return i
-    raise NameNotFound(f'{var_name} not found.')
-
-
-def find_in_module(var_name: str, module, i: int = 0) -> Tuple[str, ast.AST]:
+
+    for statement in statements_before(source, tree.body[::-1], scoped_name.pos):
+        for var_name in scoped_name.variants():
+            for finder_cls in finder_clss:
+                finder = finder_cls(source, var_name)
+                finder.visit(statement)
+                if finder.found_something():
+                    node = finder.node()
+                    pos = ast_utils.get_position(source, node)
+                    return (
+                        finder.deparse(),
+                        node,
+                        ScopedName(var_name, scoped_name.scope, (pos.lineno, pos.col_offset))
+                    )
+    raise NameNotFound(
+        format_scoped_name_not_found(scoped_name)
+    )
+
+
+def find_in_source(var_name: str, source: str, tree=None) -> Tuple[str, ast.AST, str]:
+    """Find the piece of code that assigned a value to the variable with name *var_name* in the
+    source string *source*.
+
+    :param var_name: Name of the variable to look for.
+    :param tree: AST.module.
+    :param source: Source code to search.
+    :returns: Tuple with (source code segment, corresponding AST node, variable name str).
+    """
+    scoped_name = ScopedName(var_name, None)
+    return find_scopedname_in_source(scoped_name, source, tree)
+
+
+def find_scopedname_in_module(scoped_name: ScopedName, module):
+    source = sourceget.get_module_source(module)
+    return find_scopedname_in_source(scoped_name, source)
+
+
+def find_in_module(var_name: str, module) -> Tuple[str, ast.AST, str]:
     """Find the piece of code that assigned a value to the variable with name *var_name* in the
     module *module*.
 
@@ -704,8 +876,8 @@ def find_in_module(var_name: str, module, i: int = 0) -> Tuple[str, ast.AST]:
     :param module: Module to search.
     :returns: Tuple with source code segment and corresponding ast node.
     """
-    source = sourceget.get_module_source(module)
-    return find_in_source(var_name, source, i=i)
+    scoped_name = ScopedName(var_name, None)
+    return find_scopedname_in_module(scoped_name, module)
 
 
 def _find_branch(tree, lineno, source):
@@ -736,30 +908,68 @@ def _find_branch(tree, lineno, source):
     return [tree]
 
 
-def find_in_ipython(var_name: str, i: int = 0) -> Tuple[str, ast.AST]:
-    """Find the piece of code that assigned a value to the variable with name *var_name* in the
-    ipython history.
+def find_scopedname_in_ipython(scoped_name: ScopedName) ->Tuple[str, ast.AST, str]:
+    """Find ScopedName in ipython
 
-    :param var_name: Name of the variable to look for.
-    :returns: Tuple with source code segment and the corresponding ast node.
+    :param scoped_name: ScopedName to find.
+    :returns: Tuple with source code segment and corresponding ast node.
     """
-    source = node = None
-    for cell in sourceget._ipython_history()[::-1]:
+    source = node = name = None
+    cells = list(enumerate(sourceget._ipython_history()))
+    if scoped_name.cell_no is None:
+        start = len(cells) - 1
+    else:
+        start = scoped_name.cell_no
+    for i, cell in cells[start::-1]:
+        if i == start:
+            name_to_find = scoped_name
+        else:
+            name_to_find = scoped_name.copy()
+            name_to_find.pos = None
         try:
-            res = find_in_source(var_name, cell, i=i, return_partial=True)
-            if isinstance(res, int):
-                i = res
-                continue
-            source, node, name = res
+            source, node, name = find_scopedname_in_source(name_to_find, cell)
+            name.cell_no = i
         except (NameNotFound, SyntaxError):
             continue
         break
     if source is None:
-        raise NameNotFound(f'"{var_name}" not found.')
+        raise NameNotFound(format_scoped_name_not_found(scoped_name))
     return source, node, name
 
 
-def find(var_name: str, module=None, i: int = 0) -> Tuple[str, ast.AST]:
+def find_in_ipython(var_name: str) -> Tuple[str, ast.AST, str]:
+    """Find the piece of code that assigned a value to the variable with name *var_name* in the
+    ipython history.
+
+    :param var_name: Name of the variable to look for.
+    :returns: Tuple with source code segment and the corresponding ast node.
+    """
+    scoped_name = ScopedName(var_name, None)
+    return find_scopedname_in_ipython(scoped_name)
+
+
+def find_scopedname(scoped_name: ScopedName) -> Tuple[str, ast.AST, str]:
+    """Find the piece of code that assigned a value to the variable with name *scoped_name* in the
+    module *module*.
+
+    If *module* is not specified, this uses `__main__`. In that case, the ipython history will
+    be searched as well.
+
+    :param scoped_name: Name of the variable to look for.
+    :returns: Tuple with source code segment, corresponding ast node and variable name.
+    """
+    module = scoped_name.scope.module
+    if module is None:
+        module = sys.modules['__main__']
+    try:
+        return find_scopedname_in_module(scoped_name, module)
+    except TypeError as exc:
+        if module is not sys.modules['__main__']:
+            raise NameNotFound(format_scoped_name_not_found(scoped_name)) from exc
+        return find_scopedname_in_ipython(scoped_name)
+
+
+def find(var_name: str, module=None) -> Tuple[str, ast.AST, str]:
     """Find the piece of code that assigned a value to the variable with name *var_name* in the
     module *module*.
 
@@ -768,22 +978,37 @@ def find(var_name: str, module=None, i: int = 0) -> Tuple[str, ast.AST]:
 
     :param var_name: Name of the variable to look for.
     :param module: Module to search (defaults to __main__).
-    :returns: Tuple with source code segment and corresponding ast node.
+    :returns: Tuple with source code segment, corresponding ast node and variable name.
     """
     if module is None:
         module = sys.modules['__main__']
     try:
-        return find_in_module(var_name, module, i)
+        return find_in_module(var_name, module)
     except TypeError as exc:
         if module is not sys.modules['__main__']:
             raise NameNotFound(f'"{var_name}" not found.') from exc
-        return find_in_ipython(var_name, i)
+        return find_in_ipython(var_name)
 
 
 class NameNotFound(Exception):
     """Exception indicating that a name could not be found. """
 
 
+def format_scoped_name_not_found(scoped_name):
+    """Produce a nice error message for the case a :class:`ScopedName` isn't found. """
+    variants = scoped_name.variants()
+    if len(variants) > 1:
+        joined = ', '.join(f'"{v}"' for v in variants[:-2])
+        joined += f' or "{variants[-2]}"'
+        variant_str = f'(or one of its variants: {joined})'
+    else:
+        variant_str = ''
+    return (
+        f'Could not find "{scoped_name.name}" in scope "{scoped_name.scope.dot_string()}".\n\n'
+        f'Please make sure that "{scoped_name.name}" is defined {variant_str}.'
+    )
+
+
 def split_call(call_source):
     """Split the function of a call from its arguments.
 
diff --git a/padl/dumptools/var2mod.py b/padl/dumptools/var2mod.py
index 44e08b95..0a91a9a7 100644
--- a/padl/dumptools/var2mod.py
+++ b/padl/dumptools/var2mod.py
@@ -3,13 +3,14 @@
 import ast
 import builtins
 from collections import Counter, namedtuple
-from collections.abc import Iterable
+import copy
 from dataclasses import dataclass
-from typing import Dict, Optional, List, Tuple, Set
+import re
+from typing import Dict, Optional, List, Tuple, Set, Union
 
 from padl.dumptools import ast_utils
-from padl.dumptools.ast_utils import unparse
-from padl.dumptools.symfinder import find_in_scope, ScopedName, Scope
+from padl.dumptools import sourceget
+from padl.dumptools.symfinder import find_in_scope, Scope, ScopedName
 
 
 class Finder(ast.NodeVisitor):
@@ -58,13 +59,21 @@ def get_source_segments(self, source):
         ]
 
 
-def _join_attr(node):
-    if not isinstance(node, (ast.Attribute, ast.Name)):
+def _join_attr(ast_node):
+    """Get list of base object name and attribute names.
+
+    if ast_node is a ast.Attribute node with name 'f.pd_to', output is
+    ['f', 'pd_to']
+
+    :param ast_node: ast.node
+    :return: List of strings representing name of base object and the name of attributes accessed
+    """
+    if not isinstance(ast_node, (ast.Attribute, ast.Name)):
         raise TypeError()
     try:
-        return [node.id]
+        return [ast_node.id]
     except AttributeError:
-        return _join_attr(node.value) + [node.attr]
+        return _join_attr(ast_node.value) + [ast_node.attr]
 
 
 Vars = namedtuple('Vars', 'globals locals')
@@ -84,14 +93,16 @@ class _VarFinder(ast.NodeVisitor):
     ...     z = np.array(x + b)
     ...     return str(z)
     ... '''
-    >>> _VarFinder().find_in_source(source) == Vars(globals={ScopedName('str', None, 0),
-    ...                                                      ScopedName('np.array', None, 0),
-    ...                                                      ScopedName('a', None, 0),
-    ...                                                      ScopedName('b', None, 0)},
-    ...                                             locals={ScopedName('x', None, 0),
-    ...                                                     ScopedName('z', None, 0),
-    ...                                                     ScopedName('y', None, 0)})
+    >>> _VarFinder().find_in_source(source) == Vars(globals={ScopedName('str', None),
+    ...                                                      ScopedName('np.array', None),
+    ...                                                      ScopedName('a', None),
+    ...                                                      ScopedName('b', None)},
+    ...                                             locals={ScopedName('x', None),
+    ...                                                     ScopedName('z', None),
+    ...                                                     ScopedName('y', None)})
     True
+
+    Attributes `globals` and `locals` are sets of ScopedName.
     """
 
     def __init__(self):
@@ -99,11 +110,15 @@ def __init__(self):
         self.globals = set()
         self.locals = set()
 
+    def add_to_locals(self, elements):
+        self.locals.update(elements)
+        self.globals = self.globals - self.locals
+
     def find(self, node):
         """Find all globals and locals in an AST-node.
 
         :param node: An ast node to search.
-        :returns: Tuple of sets with names of globals and locals.
+        :returns: Tuple of sets with ScopedName of globals and locals.
         """
         if isinstance(node, list):
             for node_ in node:
@@ -129,22 +144,22 @@ def _find_in_function_def(self, node):
         ...     z = np.array(x + b)
         ...     return str(z)
         ... '''
-        >>> _VarFinder().find_in_source(source) == Vars(globals={ScopedName('str', None, 0),
-        ...                                                      ScopedName('np.array', None, 0),
-        ...                                                      ScopedName('a', None, 0),
-        ...                                                      ScopedName('b', None, 0)},
-        ...                                             locals={ScopedName('x', None, 0),
-        ...                                                     ScopedName('z', None, 0),
-        ...                                                     ScopedName('y', None, 0)})
+        >>> _VarFinder().find_in_source(source) == Vars(globals={ScopedName('str', None),
+        ...                                                      ScopedName('np.array', None),
+        ...                                                      ScopedName('a', None),
+        ...                                                      ScopedName('b', None)},
+        ...                                             locals={ScopedName('x', None),
+        ...                                                     ScopedName('z', None),
+        ...                                                     ScopedName('y', None)})
         True
         """
         posonlyargs = getattr(node.args, 'posonlyargs', [])
         for arg in node.args.args + posonlyargs + node.args.kwonlyargs:
-            self.locals.add(ScopedName(arg.arg, None, 0))
+            self.add_to_locals([ScopedName(arg.arg, None)])
         if node.args.vararg is not None:
-            self.locals.add(ScopedName(node.args.vararg.arg, None, 0))
+            self.add_to_locals([ScopedName(node.args.vararg.arg, None)])
         if node.args.kwarg is not None:
-            self.locals.add(ScopedName(node.args.kwarg.arg, None, 0))
+            self.add_to_locals([ScopedName(node.args.kwarg.arg, None)])
         for n in ast.iter_child_nodes(node):
             self.visit(n)
         return Vars(self.globals, self.locals)
@@ -155,10 +170,10 @@ def visit_Name(self, node):
         Example:
 
         >>> _VarFinder().find_in_source('x')
-        Vars(globals={ScopedName(name='x', scope=None, n=0)}, locals=set())
+        Vars(globals={ScopedName(name='x', scope=Scope[], pos=None, cell_no=None)}, locals=set())
         """
         scope = getattr(node, '_scope', None)
-        name = ScopedName(node.id, scope, 0)
+        name = ScopedName(node.id, scope)
         if not self.in_locals(name):
             self.globals.add(name)
 
@@ -168,7 +183,7 @@ def visit_Attribute(self, node):
         Example:
 
         >>> _VarFinder().find_in_source('x')
-        Vars(globals={ScopedName(name='x', scope=None, n=0)}, locals=set())
+        Vars(globals={ScopedName(name='x', scope=Scope[], pos=None, cell_no=None)}, locals=set())
         """
         try:
             path = _join_attr(node)
@@ -177,7 +192,7 @@ def visit_Attribute(self, node):
             return
 
         scope = getattr(node, '_scope', None)
-        name = ScopedName('.'.join(path), scope, 0)
+        name = ScopedName('.'.join(path), scope)
         if self.in_locals(name):
             return
         self.globals.add(name)
@@ -185,16 +200,9 @@ def visit_Attribute(self, node):
     def in_locals(self, name):
         return self.in_ignoring_attributes(name, self.locals)
 
-    def in_ignoring_attributes(self, name, name_set):
-        if name in name_set:
-            return True
-        if '.' not in name.name:
-            return False
-
-        path = name.name.split('.')
-        for i in range(len(path), 0, -1):
-            subname = '.'.join(path[:i])
-            if ScopedName(subname, name.scope, 0) in name_set:
+    def in_ignoring_attributes(self, name: ScopedName, name_set: Set[ScopedName]):
+        for name_ in name.variants():
+            if ScopedName(name_, name.scope) in name_set:
                 return True
         return False
 
@@ -208,11 +216,11 @@ def visit_withitem(self, node):
         ...     ...
         ... '''
         >>> _VarFinder().find_in_source(source)
-        Vars(globals={ScopedName(name='open', scope=None, n=0)}, locals={ScopedName(name='f', scope=None, n=0)})
+        Vars(globals={ScopedName(name='open', scope=Scope[], pos=None, cell_no=None)}, locals={ScopedName(name='f', scope=Scope[], pos=None, cell_no=None)})
         """
         self.visit(node.context_expr)
         if node.optional_vars is not None:
-            self.locals.add(ScopedName(node.optional_vars.id, None, 0))
+            self.add_to_locals([ScopedName(node.optional_vars.id, None)])
 
     def visit_Assign(self, node):
         """Assignments - Their targets are locals, their values are globals.
@@ -220,7 +228,7 @@ def visit_Assign(self, node):
         Example:
 
         >>> _VarFinder().find_in_source('x = y')
-        Vars(globals={ScopedName(name='y', scope=None, n=0)}, locals={ScopedName(name='x', scope=None, n=0)})
+        Vars(globals={ScopedName(name='y', scope=Scope[], pos=None, cell_no=None)}, locals={ScopedName(name='x', scope=Scope[], pos=None, cell_no=None)})
         """
         # collect targets (the 'x' in 'x = a', can be multiple due to 'x = y = a')
         targets = set()
@@ -228,25 +236,22 @@ def visit_Assign(self, node):
             # exclude assignment to subscript ('x[1] = a')
             if isinstance(target, ast.Subscript):
                 continue
+            scope = getattr(target, '_scope', None)
             # exclude assignment to attribute ('x.y = a')
             if isinstance(target, ast.Attribute):
                 continue
-            scope = getattr(target, '_scope', None)
             targets.update(
-                {ScopedName(x.id, scope, 0) for x in Finder(ast.Name).find(target)}
+                {ScopedName(x.id, scope) for x in Finder(ast.Name).find(target)}
             )
         # find globals in RHS
-        sub_globals = {name for name in find_globals(node.value) if not self.in_locals(name)}
+        sub_globals = {name for name in find_globals(node.value, filter_builtins=False) if not self.in_locals(name)}
         sub_dependencies = set()
         # if a variable on the RHS is one of the targets, increase its counter
         for name in sub_globals:
             if self.in_locals(name):
                 continue
-            if name in targets:
-                sub_dependencies.add(ScopedName(name.name, name.scope, name.n + 1))
-            else:
-                sub_dependencies.add(name)
-        self.locals.update(targets)
+            sub_dependencies.add(name)
+        self.add_to_locals(targets)
         self.globals.update(sub_dependencies)
 
     def visit_For(self, node):
@@ -259,16 +264,16 @@ def visit_For(self, node):
         ...     ...
         ... '''
         >>> _VarFinder().find_in_source(source)
-        Vars(globals={ScopedName(name='range', scope=None, n=0)}, locals={ScopedName(name='x', scope=None, n=0)})
+        Vars(globals={ScopedName(name='range', scope=Scope[], pos=None, cell_no=None)}, locals={ScopedName(name='x', scope=Scope[], pos=None, cell_no=None)})
         """
-        self.locals.update([ScopedName(x.id, getattr(x, '_scope', None), 0)
+        self.add_to_locals([ScopedName(x.id, getattr(x, '_scope', None))
                             for x in Finder(ast.Name).find(node.target)])
         for child in node.body:
             self.visit(child)
         self.visit(node.iter)
 
     def visit_NamedExpr(self, node):
-        """The walrus operator - it's targets become locals.
+        """The walrus operator - its targets become locals.
 
         Example:
 
@@ -277,10 +282,10 @@ def visit_NamedExpr(self, node):
         ... while a := l.pop():
         ...     ...
         ... '''
-        >>> sys.version.startswith('3.7') or str(_VarFinder().find_in_source(source)) == "Vars(globals={ScopedName(name='l.pop', scope=None, n=0)}, locals={ScopedName(name='a', scope=None, n=0)})"
+        >>> sys.version.startswith('3.7') or str(_VarFinder().find_in_source(source))=="Vars(globals={ScopedName(name='l.pop', scope=Scope[], pos=None, cell_no=None)}, locals={ScopedName(name='a', scope=Scope[], pos=None, cell_no=None)})"
         True
         """
-        self.locals.update([ScopedName(x.id, getattr(x, '_scope', None), 0)
+        self.add_to_locals([ScopedName(x.id, getattr(x, '_scope', None))
                             for x in Finder(ast.Name).find(node.target)])
         self.visit(node.value)
 
@@ -289,8 +294,9 @@ def handle_comprehension(self, node):
         targets = set()
         for gen in node.generators:
             for name in Finder(ast.Name).find(gen.target):
-                targets.add(ScopedName(name.id, getattr(name, '_scope', None), 0))
-        sub_globals = set.union(*[find_globals(n) for n in ast.iter_child_nodes(node)])
+                targets.add(ScopedName(name.id, getattr(name, '_scope', None)))
+        sub_globals = set.union(*[find_globals(n, filter_builtins=False)
+                                  for n in ast.iter_child_nodes(node)])
         sub_globals = {n for n in sub_globals
                        if not self.in_locals(n)
                        and not self.in_ignoring_attributes(n, targets)}
@@ -302,7 +308,7 @@ def visit_DictComp(self, node):
         Example:
 
         >>> _VarFinder().find_in_source('{k: v for k, v in foo}')
-        Vars(globals={ScopedName(name='foo', scope=None, n=0)}, locals=set())
+        Vars(globals={ScopedName(name='foo', scope=Scope[], pos=None, cell_no=None)}, locals=set())
         """
         self.handle_comprehension(node)
 
@@ -312,7 +318,7 @@ def visit_ListComp(self, node):
         Example:
 
         >>> _VarFinder().find_in_source('[x for x in foo]')
-        Vars(globals={ScopedName(name='foo', scope=None, n=0)}, locals=set())
+        Vars(globals={ScopedName(name='foo', scope=Scope[], pos=None, cell_no=None)}, locals=set())
         """
         self.handle_comprehension(node)
 
@@ -322,7 +328,7 @@ def visit_SetComp(self, node):
         Example:
 
         >>> _VarFinder().find_in_source('{x for x in foo}')
-        Vars(globals={ScopedName(name='foo', scope=None, n=0)}, locals=set())
+        Vars(globals={ScopedName(name='foo', scope=Scope[], pos=None, cell_no=None)}, locals=set())
         """
         self.handle_comprehension(node)
 
@@ -332,7 +338,7 @@ def visit_GeneratorExp(self, node):
         Example:
 
         >>> _VarFinder().find_in_source('(x for x in foo)')
-        Vars(globals={ScopedName(name='foo', scope=None, n=0)}, locals=set())
+        Vars(globals={ScopedName(name='foo', scope=Scope[], pos=None, cell_no=None)}, locals=set())
         """
         self.handle_comprehension(node)
 
@@ -342,28 +348,29 @@ def visit_Lambda(self, node):
         Example:
 
         >>> vars = _VarFinder().find_in_source('lambda x, y: x + y + foo')
-        >>> vars == Vars(globals={ScopedName('foo', None, 0)}, locals={ScopedName('x', None, 0),
-        ...                                                            ScopedName('y', None, 0)})
+        >>> vars == Vars(globals={ScopedName('foo', None)},
+        ...         locals={ScopedName('x', None),
+        ...         ScopedName('y', None)})
         True
         """
         scope = getattr(node, '_scope', None)
         posonlyargs = getattr(node.args, 'posonlyargs', [])
         for arg in node.args.args + posonlyargs + node.args.kwonlyargs:
-            self.locals.add(ScopedName(arg.arg, scope, 0))
+            self.add_to_locals([ScopedName(arg.arg, scope)])
         self.visit(node.body)
 
     def visit_FunctionDef(self, node):
         """Function definitions. """
         scope = getattr(node, '_scope', None)
-        self.locals.add(ScopedName(node.name, scope, 0))
-        inner_globals = find_globals(node)
+        self.add_to_locals([ScopedName(node.name, scope)])
+        inner_globals = find_globals(node, filter_builtins=False)
         self.globals.update(v for v in inner_globals if not self.in_locals(v))
 
     def visit_ClassDef(self, node):
         """Class definitions. """
         scope = getattr(node, '_scope', None)
-        self.locals.add(ScopedName(node.name, scope, 0))
-        inner_globals = find_globals(node)
+        self.add_to_locals([ScopedName(node.name, scope)])
+        inner_globals = find_globals(node, filter_builtins=False)
         self.globals.update(v for v in inner_globals if not self.in_locals(v))
 
     def visit_Import(self, node):
@@ -372,16 +379,16 @@ def visit_Import(self, node):
         Example:
 
         >>> _VarFinder().find_in_source('import foo')
-        Vars(globals=set(), locals={ScopedName(name='foo', scope=None, n=0)})
+        Vars(globals=set(), locals={ScopedName(name='foo', scope=Scope[], pos=None, cell_no=None)})
         >>> _VarFinder().find_in_source('import foo as bar')
-        Vars(globals=set(), locals={ScopedName(name='bar', scope=None, n=0)})
+        Vars(globals=set(), locals={ScopedName(name='bar', scope=Scope[], pos=None, cell_no=None)})
         """
         scope = getattr(node, '_scope', None)
         for name in node.names:
             if name.asname is None:
-                self.locals.add(ScopedName(name.name, scope, 0))
+                self.add_to_locals([ScopedName(name.name, scope)])
             else:
-                self.locals.add(ScopedName(name.asname, scope, 0))
+                self.add_to_locals([ScopedName(name.asname, scope)])
 
     def visit_ImportFrom(self, node):
         """Import-from statements.
@@ -389,133 +396,227 @@ def visit_ImportFrom(self, node):
         Example:
 
         >>> _VarFinder().find_in_source('from foo import bar')
-        Vars(globals=set(), locals={ScopedName(name='bar', scope=None, n=0)})
+        Vars(globals=set(), locals={ScopedName(name='bar', scope=Scope[], pos=None, cell_no=None)})
         >>> _VarFinder().find_in_source('from foo import bar as baz')
-        Vars(globals=set(), locals={ScopedName(name='baz', scope=None, n=0)})
+        Vars(globals=set(), locals={ScopedName(name='baz', scope=Scope[], pos=None, cell_no=None)})
         """
         scope = getattr(node, '_scope', None)
         for name in node.names:
             if name.asname is None:
-                self.locals.add(ScopedName(name.name, scope, 0))
+                self.add_to_locals([ScopedName(name.name, scope)])
             else:
-                self.locals.add(ScopedName(name.asname, scope, 0))
+                self.add_to_locals([ScopedName(name.asname, scope)])
 
 
-class _Renamer(ast.NodeTransformer):
+def add_scope_and_pos(variables: List[ScopedName], scoped_name: ScopedName, node):
+    """Add missing scope and position information to a list of :class:`ScopedNames` objects.
+    """
+    result = set()
+
+    for var in variables:
+        if var.scope.is_empty():
+            var.scope = scoped_name.scope
+            if isinstance(node, (ast.ClassDef, ast.FunctionDef, ast.AsyncFunctionDef)):
+                position = ast_utils.get_position(scoped_name.scope.def_source, node)
+                var.pos = (position.end_lineno, position.end_col_offset)  # why this?
+            else:
+                var.pos = scoped_name.pos
+        var.cell_no = scoped_name.cell_no
+        result.add(var)
+
+    return result
+
+
+class _Renamer(ast.NodeVisitor):
     # pylint: disable=invalid-name
     """An :class:`ast.NodeTransformer` for renaming things. Used in :func:`rename`.
 
-    :param from_: Rename everything called this ...
-    :param to: ... to this.
-    :param rename_locals: If *True*, rename local things, else, only rename globals.
+    :param renaming_function: Function that takes a node and a name and returns a new name.
+    :param source: Sourcecode to rename in.
     """
 
-    def __init__(self, from_, to, rename_locals):
-        self.from_ = from_
-        self.to = to
+    def __init__(self, renaming_function, source, rename_locals=False, candidates=None):
+        self.renaming_function = renaming_function
+        self.source = source
+        self.res = []
+        self.candidates = candidates
         self.rename_locals = rename_locals
 
-    def visit_arg(self, node):
-        """Arguments.
+    def is_candidate(self, name):
+        if self.candidates is None:
+            return True
+        return name in self.candidates
 
-        Example:
+    def check(self, name, node):
+        if not self.is_candidate(name):
+            return
+        new_name = self.renaming_function(name, node)
+        if new_name:
+            self.res.append((ast_utils.get_position(self.source, node), new_name))
 
-        >>> node = ast.parse('''
-        ... def f(a, b):
-        ...    ...
-        ... ''').body[0]
-        >>> unparse(rename(node, 'a', 'c', rename_locals=True)).strip()
-        'def f(c, b):\\n    ...'
-        """
-        if node.arg == self.from_:
-            return ast.arg(**{**node.__dict__, 'arg': self.to})
-        return node
+    def generic_visit(self, node):
+        if not self.rename_locals:
+            globals_ = {x.name for x in find_globals(node, filter_builtins=False)}
+            if self.candidates is None:
+                candidates = globals_
+            else:
+                candidates = self.candidates.intersection(globals_)
+        else:
+            candidates = self.candidates
+
+        for child in ast.iter_child_nodes(node):
+            sub = _Renamer(self.renaming_function, self.source, False, candidates)
+            sub.visit(child)
+            self.res += sub.res
+
+    def visit_Module(self, node):
+        for child in ast.iter_child_nodes(node):
+            self.visit(child)
+
+    def visit_Attribute(self, node):
+        name = ast_utils.get_source_segment(self.source, node)
+        print(name)
+        if not self.is_candidate(name.split('.', 1)[0]):
+            return
+
+        new_name = self.renaming_function(name, node)
+        if new_name:
+            self.res.append((ast_utils.get_position(self.source, node), new_name))
+            return
+
+        for child in ast.iter_child_nodes(node):
+            self.visit(child)
 
     def visit_Name(self, node):
         """Names.
 
         Example:
 
-        >>> node = ast.parse('x')
-        >>> unparse(rename(node, 'x', 'y', True)).strip()
+        >>> code = 'x'
+        >>> rename(code, from_='x', to='y').strip()
         'y'
         """
-        if node.id == self.from_:
-            return ast.Name(**{**node.__dict__, 'id': self.to})
-        return node
+        self.check(node.id, node)
 
     def visit_FunctionDef(self, node):
         """Function definitions.
 
         Example:
 
-        >>> node = ast.parse('''
+        >>> code = '''
         ... def f(a, b):
-        ...    y = p(z)
-        ... ''').body[0]
-        >>> unparse(rename(node, 'z', 'u', rename_locals=True)).strip()
+        ...     y = p(z)
+        ... '''
+        >>> rename(code, from_='z', to='u').strip()
         'def f(a, b):\\n    y = p(u)'
         """
-        if self.rename_locals and node.name == self.from_:
-            name = self.to
+        new_name = (
+            self.rename_locals
+            and self.is_candidate(node.name)
+            and self.renaming_function(node.name, node)
+        )
+        if new_name:
+            source_segment = ast_utils.get_source_segment(self.source, node)
+            full_position = ast_utils.get_position(self.source, node)
+            span = re.search(rf'def ({node.name})\(.*\):.*', source_segment).span(1)
+            position = ast_utils.span_to_pos(span, source_segment)
+            position.lineno += full_position.lineno - 1
+            position.end_lineno += full_position.lineno - 1
+            position.col_offset += full_position.col_offset
+            if position.lineno == position.end_lineno:
+                position.end_col_offset += full_position.col_offset
+            self.res.append((position, new_name))
+
+        for arg in node.args.defaults:
+            self.visit(arg)
+        for sub_node in node.decorator_list:
+            self.visit(sub_node)
+
+        globals_ = {x.name for x in find_globals(node, filter_builtins=False)}
+        if self.candidates is None:
+            candidates = globals_
         else:
-            name = node.name
-        if not self.rename_locals:
-            globals_ = find_globals(node)
-            if self.from_ not in {x.name for x in globals_}:
-                return node
-        return ast.FunctionDef(**{**node.__dict__,
-                                  'args': rename(node.args, self.from_, self.to,
-                                                 self.rename_locals),
-                                  'body': rename(node.body, self.from_, self.to,
-                                                 self.rename_locals),
-                                  'name': name})
+            candidates = self.candidates.intersection(globals_)
+
+        for sub_node in node.body:
+            sub = _Renamer(self.renaming_function, self.source, False, candidates)
+            sub.visit(sub_node)
+            self.res += sub.res
 
     def visit_ClassDef(self, node):
         """Class definitions.
 
         Example:
 
-        >>> node = ast.parse('''
+        >>> code = '''
         ... class Foo:
         ...     def __init__(self, a, b):
         ...         y = p(z)
-        ... ''').body[0]
-        >>> unparse(rename(node, 'z', 'u', rename_locals=True)).strip()  # doctest: +SKIP
-        'class Foo:\\n\\n    def __init__(self, a, b):\\n        y = p(u)'
+        ... '''
+        >>> rename(code, from_='z', to='u').strip()
+        'class Foo:\\n    def __init__(self, a, b):\\n        y = p(u)'
         """
-        if self.rename_locals and node.name == self.from_:
-            name = self.to
-        else:
-            name = node.name
-        if not self.rename_locals:
-            globals_ = find_globals(node)
-            if self.from_ not in {x.name for x in globals_}:
-                return node
-        return ast.ClassDef(**{**node.__dict__,
-                               'body': rename(node.body, self.from_, self.to,
-                                              self.rename_locals),
-                               'name': name})
+        new_name = (
+            self.rename_locals
+            and self.is_candidate(node.name)
+            and self.renaming_function(node.name, node)
+        )
+        if new_name:
+            source_segment = ast_utils.get_source_segment(self.source, node)
+            full_position = ast_utils.get_position(self.source, node)
+            span = re.search(rf'class ({node.name}).*:.*', source_segment).span(1)
+            position = ast_utils.span_to_pos(span, source_segment)
+            position.lineno += full_position.lineno - 1
+            position.end_lineno += full_position.lineno - 1
+            position.col_offset += full_position.col_offset
+            if position.lineno == position.end_lineno:
+                position.end_col_offset += full_position.col_offset
+            self.res.append((position, new_name))
+
+        for sub_node in node.bases:
+            self.visit(sub_node)
+        for sub_node in node.decorator_list:
+            self.visit(sub_node)
+
+        for sub_node in node.body:
+            globals_ = {x.name for x in find_globals(node, filter_builtins=False)}
+            if self.candidates is None:
+                candidates = globals_
+            else:
+                candidates = self.candidates.intersection(globals_)
+            sub = _Renamer(self.renaming_function, self.source, False, candidates)
+            sub.visit(sub_node)
+            self.res += sub.res
 
 
-def rename(tree: ast.AST, from_: str, to: str, rename_locals: bool = False):
+def rename(source, tree=None, from_=None, to=None, renaming_function=None, rename_locals=True):
     """Rename things in an AST tree.
 
-    :param tree: The tree in which to rename things.
+    :param source: Source in which to rename things.
+    :param tree: The tree in which to rename things (optional, will be parsed from *source* if
+        needed).
     :param from_: Rename everything called this ...
-    :param to: ... to this.
+    :param to: ... to this (optional, alternatively provide a *renaming_function*, see below).
+    :param renaming_function: Function that takes an ast node and a name and returns a new name
+        if the name should be changed or *False* else.
     :param rename_locals: If *True*, rename local things, else, only rename globals.
     """
-    if not rename_locals:
-        globals_ = find_globals(tree)
-        if from_ not in {x.name for x in globals_}:
-            return tree
-    if isinstance(tree, Iterable) and not isinstance(tree, str):
-        return [rename(x, from_, to, rename_locals) for x in tree]
-    if not isinstance(tree, ast.AST):
-        return tree
-    renamer = _Renamer(from_, to, rename_locals)
-    return renamer.visit(tree)
+    if tree is None:
+        tree = ast.parse(source)
+
+    if renaming_function is None:
+        assert from_ is not None and to is not None, 'Must provide either *from_* and *to* or *renaming_function*.'
+
+        def renaming_function(name, _node):
+            if name == from_:
+                return to
+            return False
+
+    renamer = _Renamer(renaming_function, source, rename_locals=rename_locals)
+    renamer.visit(tree)
+    for pos, name in sorted(renamer.res, key=lambda x: tuple(x[0]), reverse=True):
+        source = sourceget.replace(source, name, *pos, one_indexed=True)
+    return source
 
 
 class _MethodFinder(ast.NodeVisitor):
@@ -542,14 +643,19 @@ def __init__(self):
     def visit_FunctionDef(self, node):
         self.methods[node.name] = node
 
+    def visit_ClassDef(self, node):
+        """Don't visit children of nested classes."""
+
     def find(self, node: ast.ClassDef) -> Dict['str', ast.FunctionDef]:
         """Do find the methods. """
-        self.visit(node)
+        for child in ast.iter_child_nodes(node):
+            self.visit(child)
         return self.methods
 
 
 def _filter_builtins(names):
-    return {name for name in names if name.name not in builtins.__dict__}
+    return {name for name in names if name.toplevel_name not in builtins.__dict__}
+
 
 def _find_globals_in_classdef(node: ast.ClassDef, filter_builtins: bool = True):
     """Find globals used below a ClassDef node. """
@@ -578,12 +684,13 @@ def find_globals(node: ast.AST, filter_builtins: bool = True) -> Set[Tuple[str,
     ...     return str(z)
     ... '''
     >>> node = ast.parse(source).body[0]
-    >>> find_globals(node) == {ScopedName('a', None, 0), ScopedName('b', None, 0),
-    ...                        ScopedName('np.array', None, 0)}
+    >>> find_globals(node) == {ScopedName('a', None), ScopedName('b', None),
+    ...                        ScopedName('np.array', None)}
     True
 
     :param node: AST node to search in.
     :param filter_builtins: If *True*, filter out builtins.
+    :return: Set of global ScopedNames.
     """
     if isinstance(node, ast.ClassDef):
         return _find_globals_in_classdef(node)
@@ -593,37 +700,9 @@ def find_globals(node: ast.AST, filter_builtins: bool = True) -> Set[Tuple[str,
     return globals_
 
 
-def increment_same_name_var(variables: List[ScopedName], scoped_name: ScopedName):
-    """Go through *variables* and increment the the counter for those with the same name as
-    *scoped_name* by *scoped_name.n*.
-
-    Example:
-
-    >>> import padl as somemodule
-    >>> out = increment_same_name_var({ScopedName('a', None, 1), ScopedName('b', None, 2)},
-    ...                               ScopedName('b', somemodule, 2))
-    >>> isinstance(out, set)
-    True
-    >>> {(x.name, x.n) for x in out} == {('a', 1), ('b', 4)}
-    True
-    """
-    result = set()
-    for var in variables:
-        if var.scope is None:
-            scope = scoped_name.scope
-        else:
-            scope = var.scope
-
-        if var.name == scoped_name.name:
-            result.add(ScopedName(var.name, scope, var.n + scoped_name.n))
-        else:
-            result.add(ScopedName(var.name, scope, var.n))
-    return result
-
-
-def find_codenode(name: ScopedName, full_dump_module_names=None):
+def find_codenode(name: ScopedName, full_dump_module_names=None) -> "CodeNode":
     """Find the :class:`CodeNode` corresponding to a :class:`ScopedName` *name*. """
-    (source, node), scope_of_next_var, found_name = find_in_scope(name)
+    (source, node), found_name = find_in_scope(name)
 
     module_name = None
     if full_dump_module_names:
@@ -631,18 +710,16 @@ def find_codenode(name: ScopedName, full_dump_module_names=None):
             module_name = node.names[0].name
         if isinstance(node, ast.ImportFrom):
             module_name = node.module
+
     if module_name is not None:
         if any(module_name.startswith(mod) for mod in full_dump_module_names):
-            return find_codenode(ScopedName(name.name, Scope.toplevel(module_name), name.n),
+            return find_codenode(ScopedName(name.name,
+                                            Scope.toplevel(module_name)),
                                  full_dump_module_names)
-
     # find dependencies
     globals_ = find_globals(node)
-    next_name = ScopedName(name.name, scope_of_next_var, name.n)
-    globals_ = increment_same_name_var(globals_, next_name)
-
-    return CodeNode(source=source, globals_=globals_, ast_node=node, scope=scope_of_next_var,
-                    name=found_name, n=name.n)
+    globals_ = add_scope_and_pos(globals_, found_name, node)
+    return CodeNode(source=source, globals_=globals_, ast_node=node, name=found_name)
 
 
 def _get_nodes_without_in_edges(graph):
@@ -703,7 +780,7 @@ def _topsort(graph: dict) -> List[set]:
     while graph:
         nextlevel, graph = _get_nodes_without_in_edges(graph)
         if graphlen == len(graph):  # graph didn't shrink
-            raise RuntimeError('Graph has a circle or dangling roots.')
+            raise RuntimeError('Graph has a circle or dangling roots.')  # TODO: improve message
         graphlen = len(graph)
         levels.append(nextlevel)
     return levels
@@ -722,35 +799,39 @@ class CodeNode:
     """
     source: str
     globals_: set
-    name: str
-    scope: Optional[Scope] = None
+    name: ScopedName
     ast_node: Optional[ast.AST] = None
-    n: int = 0
 
     @classmethod
     def from_source(cls, source, scope, name):
         """Build a `CodeNode` from a source string. """
-        node = ast_utils.cached_parse(source).body[0]
-        globals_ = {
-            ScopedName(name.name, scope, name.n)
-            for name in find_globals(node)
-        }
+        scoped_name = ScopedName(name, scope, pos=(0, 0))
+        node = ast_utils.ast.parse(source).body[0]
+        globals_ = find_globals(node)
+        for var in globals_:
+            if var.scope.is_empty():
+                var.update_scope(scope)
 
         return cls(
             source=source,
             ast_node=node,
+            name=scoped_name,
             globals_=globals_,
-            scope=scope,
-            name=name
         )
 
+    def update_globals(self):
+        self.globals_ = find_globals(self.ast_node)
+        for var in self.globals_:
+            if var.scope is None:
+                var.update_scope(self.name.scope)
+
     def __hash__(self):
-        return hash((self.name, self.scope, self.n))
+        return hash((self.name, tuple(sorted([hash(x) for x in self.globals_]))))
 
     def __eq__(self, other):
         return (
-            (self.name, self.scope, self.n)
-            == (other.name, other.scope, other.n)
+            (self.name, self.globals_)
+            == (other.name, other.globals_)
         )
 
 
@@ -760,9 +841,9 @@ def _sort(unscoped_graph):
     def sortkey(x):
         val = unscoped_graph[x]
         try:
-            return _PRECEDENCE[val.ast_node.__class__](x[0], val)
+            return _PRECEDENCE[val.ast_node.__class__](val.name.name, val)
         except KeyError:
-            return 'zz' + x[0].lower()
+            return 'zz' + x.name.lower()
 
     res = []
     for level in top:
@@ -770,22 +851,33 @@ def sortkey(x):
     return res
 
 
+def _deduplicate(keys, graph):
+    done = set()
+    res = []
+    for k in keys:
+        if graph[k] in done:
+            continue
+        done.add(graph[k])
+        res.append(k)
+    return res
+
+
+def _remove_empty(keys, graph):
+    return [k for k in keys if graph[k].source.strip()]
+
+
 def _dumps_unscoped(unscoped_graph):
     """Dump an unscoped (see :meth:`CodeGraph.unscope`) :class:`CodeGraph` to a python source
     string. """
-    sorted_ = _sort(unscoped_graph)
+    keys = _sort(unscoped_graph)
+    keys = _deduplicate(keys, unscoped_graph)
+    keys = _remove_empty(keys, unscoped_graph)
     res = ''
-    done = set()
-    for i, name in enumerate(sorted_):
+    for i, name in enumerate(keys):
         here = unscoped_graph[name]
-        if here in done:
-            continue
-        if not here.source.strip():
-            continue
-        done.add(here)
         res += here.source
-        if i < len(sorted_) - 1:
-            next_ = unscoped_graph[sorted_[i + 1]]
+        if i < len(keys) - 1:
+            next_ = unscoped_graph[keys[i + 1]]
             if isinstance(here.ast_node, (ast.Import, ast.ImportFrom)) \
                     and isinstance(next_.ast_node, (ast.Import, ast.ImportFrom)):
                 res += '\n'
@@ -794,7 +886,9 @@ def _dumps_unscoped(unscoped_graph):
                 res += '\n'
             else:
                 res += '\n\n\n'
-    return res + '\n'
+    if res:
+        return res + '\n'
+    return res
 
 
 class CodeGraph(dict):
@@ -828,50 +922,104 @@ def f(x):
     def _unscoped(self):
         """Create a version of *self* where all non-top level variables are renamed (by prepending
         the scope) to prevent conflicts."""
-        name_scope = {(k.name, v.scope) for k, v in self.items()}
+        name_scope = {(v.name.toplevel_name, v.name.scope) for k, v in self.items()}
         counts = Counter(x[0] for x in name_scope)
         to_rename = set(k for k, c in counts.items() if c > 1)
 
+        for v in self.values():
+            assert v.name.pos is not None
+
+        rename_map_k = {
+            k: v.name.scope.unscoped(k.name)
+            if k.toplevel_name in to_rename else k.name
+            for k, v in self.items()
+        }
+
+        rename_map_v = {
+            v.name: v.name.scope.unscoped(v.name.name)
+            if v.name.toplevel_name in to_rename else v.name.name
+            for v in self.values()
+        }
+
+        ''' WHAT TO DO WITH THIS?
+        name_scope_source = defaultdict(dict)
+        counts = Counter()
+        for k, v in self.items():
+            len_ = name_scope_source[v.name.name, v.name.scope].get(
+                v.source,
+                len(name_scope_source[v.name.name, v.name.scope])
+            )
+            name_scope_source[v.name.name, v.name.scope][v.source] = len_
+            if len_ != 0:
+                rename_map[k] = f'{rename_map[k]}_{len_}'
+                to_rename.add(v.name.name)
+        '''
+
         def unscope(name, scope):
             if name in to_rename:
                 return scope.unscoped(name)
             return name
 
+        def renaming_function(name, node, k, k_unscoped):
+            if hasattr(node, '_scope') and node._scope != k.scope:
+                return False
+            if name != k.name:
+                return False
+            if name == k_unscoped:
+                return False
+            return k_unscoped
+
         res = {}
         for k, v in self.items():
-            changed = False
-            k_unscoped = unscope(k.name, k.scope)
-            v_unscoped = unscope(v.name, k.scope)
-            changed = changed or k_unscoped != k.name
-            code = v.source
-            tree = ast_utils.cached_parse(code)
-            rename(tree, k.name, k_unscoped, rename_locals=True)
-            vars_ = set()
-            for var in list(v.globals_):
-                var_unscoped = unscope(var.name, var.scope)
-                changed = changed or var_unscoped != var.name
-                rename(tree, var.name, var_unscoped)
-                vars_.add((var_unscoped, var.n))
-            if changed:
-                code = unparse(tree).strip('\n')
-            res[k_unscoped, k.n] = CodeNode(code, vars_, ast_node=v.ast_node, name=v_unscoped,
-                                            n=v.n)
+            v_unscoped = rename_map_v[v.name]
+            parsed = ast.parse(v.source).body[0]
+            copied = copy.deepcopy(v.ast_node)
+            for a, b in zip(ast.walk(parsed), ast.walk(copied)):
+                ast.copy_location(b, a)
+                assert type(a) == type(b)
+
+            def rn(name, node):
+                new_name = renaming_function(name, node, v.name, v_unscoped)
+                if new_name:
+                    return new_name
+                for n in list(v.globals_):
+                    new_name = renaming_function(name, node, n, rename_map_k[n])
+                    if new_name:
+                        return new_name
+                return False
+
+            code = rename(v.source, copied, renaming_function=rn, rename_locals=True)
+            res[k] = CodeNode(code, v.globals_, ast_node=v.ast_node,
+                              name=ScopedName(v_unscoped, Scope.empty()))
         return res
 
     def dumps(self):
         """Create a python source string with the contents of the graph. """
         return _dumps_unscoped(self._unscoped())
 
-    @classmethod
-    def build(cls, scoped_name: ScopedName):
-        """Build a codegraph corresponding to a `ScopedName`.
+    def real_names(self):
+        return {v.name: v for v in self.values()}
 
-        The name will be searched for in its scope.
+    def from_source(self, target: str, scope: Optional[Scope] = None, name='__out'):
+        if scope is None:
+            scope = Scope.toplevel('__main__')
+        start = CodeNode.from_source(target, scope, name)
+        graph = self.build(list(start.globals_))
+        graph[ScopedName(name, scope=scope)] = start
+        return graph
+
+    def build(self, target: Union[List[ScopedName], ScopedName]):
+        """Build a codegraph corresponding to a :class:`ScopedName` or a list of
+        :class:`ScopedName`s.
+
+        The name(s) will be searched for in its (their) scope.
         """
-        graph = cls()
         done = set()
 
-        todo = {scoped_name}
+        if isinstance(target, list):
+            todo = set(target)
+        else:
+            todo = {target}
 
         while todo:
             # we know this already - go on
@@ -882,22 +1030,24 @@ def build(cls, scoped_name: ScopedName):
 
             # find how next_var came into being
             next_codenode = find_codenode(next_name)
-            graph[next_name] = next_codenode
+            self[next_name] = next_codenode
 
             todo.update(next_codenode.globals_)
             done.add(next_name)
 
-        return graph
+        return self
 
     def print(self):
         """Print the graph (for debugging). """
         for k, v in self.items():
-            print(f'{k.name} {k.scope} {k.n}:')
+            print(f'{k}:')
+            print()
+            print(v.name)
             print()
             print(v.source)
             print()
             for dep in v.globals_:
-                print(f'{dep.name} {dep.scope} {dep.n}:')
+                print(f'  {dep}')
             print()
             print('--------')
             print()
diff --git a/padl/transforms.py b/padl/transforms.py
index a606591f..b3f5dbae 100644
--- a/padl/transforms.py
+++ b/padl/transforms.py
@@ -2,6 +2,7 @@
 
 Transforms should be created using the `padl.transform` wrap-function.
 """
+import ast
 import re
 from copy import copy
 from collections import Counter, namedtuple, OrderedDict
@@ -11,7 +12,7 @@
 from importlib.util import module_from_spec
 import inspect
 from itertools import chain
-from os import remove
+from os import remove, path as ospath
 from pathlib import Path
 from shutil import rmtree, copytree
 import textwrap
@@ -31,9 +32,9 @@
 import torch
 from torch.utils.data import DataLoader
 
-from padl.dumptools import symfinder, inspector
+from padl.dumptools import symfinder, inspector, config_tools
 from padl.dumptools.var2mod import CodeGraph, CodeNode, find_codenode
-from padl.dumptools.symfinder import ScopedName
+from padl.dumptools.symfinder import ScopedName, _SetAttribute
 from padl.dumptools.serialize import Serializer
 
 from padl.dumptools.packagefinder import dump_requirements, RequirementNotFound
@@ -119,6 +120,15 @@ def _move_to_device(args, device):
     return args
 
 
+def _set_local_varname(frame, event, _args, scope, m=0):
+    if event == 'return':
+        for k, v in frame.f_locals.items():
+            try:
+                v._pd_varname[scope] = k
+            except AttributeError:
+                continue
+
+
 class _GeneratorWithLength:
     """A generator with a length. """
 
@@ -151,7 +161,8 @@ def __init__(self, call_info: Optional[inspector.CallInfo] = None,
                  pd_name: Optional[str] = None):
         if call_info is None:
             # if the call was from here (i.e. transform.py), ignore the scope
-            calling_module_name = inspector.non_init_caller_frameinfo().frame.f_globals['__name__']
+            caller_frameinfo = inspector.non_init_caller_frameinfo()
+            calling_module_name = caller_frameinfo.frame.f_globals['__name__']
             call_info = inspector.CallInfo(ignore_scope=calling_module_name == __name__)
         self._pd_call_info = call_info
         self._pd_varname = {}
@@ -284,7 +295,7 @@ def __rshift__(self, other: "Transform") -> "Compose":
         Example:
             t = a >> b >> c
         """
-        return Compose([self, other])
+        return Compose([self, other], call_info=self._pd_call_info)
 
     def __add__(self, other: "Transform") -> "Rollout":
         """Rollout with *other*.
@@ -292,7 +303,7 @@ def __add__(self, other: "Transform") -> "Rollout":
         Example:
             t = a + b + c
         """
-        return Rollout([self, other])
+        return Rollout([self, other], call_info=self._pd_call_info)
 
     def __truediv__(self, other: "Transform") -> "Parallel":
         """Parallel with *other*.
@@ -300,7 +311,7 @@ def __truediv__(self, other: "Transform") -> "Parallel":
         Example:
             t = a / b / c
         """
-        return Parallel([self, other])
+        return Parallel([self, other], call_info=self._pd_call_info)
 
     def __sub__(self, name: str) -> "Transform":
         """Create a named clone of the transform.
@@ -319,7 +330,7 @@ def __invert__(self) -> "Map":
         Example:
             t = ~a
         """
-        return Map(self)
+        return Map(self, call_info=self._pd_call_info)
 
     def pd_pre_save(self, path: Path, i: int, options: Optional[dict] = None):
         """Method that is called on each transform before saving.
@@ -416,7 +427,7 @@ def pd_save(self, path: Union[Path, str], force_overwrite: bool = False,
                 raise FileExistsError(f'{path} exists, call with *force_overwrite* to overwrite.')
 
             with TemporaryDirectory('.padl') as dirname:
-                self.pd_save(dirname, False)
+                self.pd_save(dirname, False, strict_requirements=strict_requirements)
                 rmtree(path)
                 copytree(dirname, path)
 
@@ -430,9 +441,9 @@ def pd_save(self, path: Union[Path, str], force_overwrite: bool = False,
                                             strict_requirements=strict_requirements,
                                             path=path)
 
-        with open(path / 'transform.py', 'w') as f:
+        with open(path / 'transform.py', 'w', encoding='utf-8') as f:
             f.write(code)
-        with open(path / 'requirements.txt', 'w') as f:
+        with open(path / 'requirements.txt', 'w', encoding='utf-8') as f:
             f.write(requirements)
 
     def _pd_codegraph_add_startnodes(self, graph, name: Union[str, None]) -> Set:
@@ -441,27 +452,44 @@ def _pd_codegraph_add_startnodes(self, graph, name: Union[str, None]) -> Set:
 
         Returns a set of dependencies (scoped names the start-node depends on).
         """
-        scope = self._pd_call_info.scope
-
-        nodes = []
+        my_scope = self._pd_call_info.scope
 
         start_source = f'{name or "_pd_dummy"} = {self._pd_evaluable_repr()}'
-        start = CodeNode.from_source(start_source, scope, name=name or "_pd_dummy")
-        nodes.append(start)
+        start = CodeNode.from_source(start_source, my_scope, name=name or "_pd_dummy")
+
+        # label all nodes with their correct scope
+        self._pd_label_ast_node(start.ast_node.value, graph)
+        # update the global dependencies with the scope
+        start.update_globals()
+
+        # if the own name is the same as something the transform depends on, skip one
+        # name
+        if self.pd_varname() is not None:
+            my_name = ScopedName(self.pd_varname(), my_scope)
+            if (len(start.globals_) > 1 and any(x == my_name for x in start.globals_)):
+                next_codenode = find_codenode(my_name,
+                                              self._pd_external_full_dump_modules)
+
+                start.globals_ = set(next_codenode.globals_)
+            if my_name.name == name and any(x == my_name for x in start.globals_):
+                next_codenode = find_codenode(my_name,
+                                              self._pd_external_full_dump_modules)
+
+                start = next_codenode
 
         # if name is given, add the node to the CodeGraph, otherwise only use the dependencies
         if name is not None:
-            graph[ScopedName(name, scope, 0)] = start
+            graph[ScopedName(name, my_scope)] = start
 
-        dependencies = set()
-        for node in nodes:
-            dependencies.update(node.globals_)
-        return dependencies
+        return set(start.globals_)
+
+    def _pd_label_ast_node(self, ast_node, graph):
+        _SetAttribute('_scope', self._pd_call_info.scope).visit(ast_node)
 
     @property
     def _pd_closurevars(self) -> Tuple[dict, dict]:
         """Return the closurevars (globals and nonlocals) the transform depends on. """
-        return {}, {}
+        return globals(), {}
 
     def _pd_build_codegraph(self, graph: Optional[dict] = None,
                             name: Optional[str] = None) -> Tuple[dict, dict]:
@@ -525,11 +553,15 @@ def f(x):
 
         todo = self._pd_codegraph_add_startnodes(graph, new_name)
 
+        self._pd_codegraph_find_dependencies(graph, todo)
+
+        return graph
+
+    def _pd_codegraph_find_dependencies(self, graph, todo):
         # if this transform has closurevars, get them (if there are transforms in the closure, we
         # want to allow them to build their codegraph themselves, see below)
         globals_dict, nonlocals_dict = self._pd_closurevars
         all_vars_dict = {**globals_dict, **nonlocals_dict}
-
         # find dependencies
         while todo:
             next_name = todo.pop()
@@ -540,30 +572,34 @@ def f(x):
 
             # ignoring this (it comes from the serializer)
             if next_name.name.startswith('PADL_VALUE'):
+                next_name.pos = 'injected'
+                next_name.scope = symfinder.Scope.empty()
                 continue
 
+            # Only triggered if KeyError or AttributeError is raised
+            # find how *next_name* came into being
+            next_codenode = find_codenode(next_name,
+                                          self._pd_external_full_dump_modules)
+
             # see if the object itself knows how to generate its codegraph
             try:
                 if next_name.scope.is_global():
-                    next_obj = globals_dict[next_name.name]
+                    next_obj = globals_dict[next_codenode.name.name]
                 else:
-                    next_obj = all_vars_dict[next_name.name]
+                    next_obj = all_vars_dict[next_codenode.name.name]
                 # pylint: disable=protected-access
-                next_obj._pd_build_codegraph(graph, next_name.name)
+                next_obj._pd_build_codegraph(graph, next_codenode.name.name)
             except (KeyError, AttributeError):
                 pass
             else:
+                next_name.pos = None
+                next_name.scope = next_obj._pd_call_info.scope
                 continue
 
-            # find how *next_name* came into being
-            next_codenode = find_codenode(next_name, self._pd_external_full_dump_modules)
-
             graph[next_name] = next_codenode
 
             todo.update(next_codenode.globals_)
 
-        return graph
-
     def _pd_process_traceback(self):
         """Find where the Transform was defined (file, lineno, file) given the traceback. """
         a_tb = None
@@ -697,8 +733,10 @@ def _pd_find_varname(self, scopedict: dict) -> Optional[str]:
         """
         try:
             return [
-                k for k, v in scopedict.items()
-                if v is self and not k.startswith('_')
+                k for k, v in list(scopedict.items())[::-1]
+                if v is self
+                and not k.startswith('_')
+                and not k.startswith('@')
             ][0]
         except IndexError:
             return None
@@ -720,7 +758,13 @@ def pd_varname(self, scope=None) -> Optional[str]:
         if scope is None:
             scope = self._pd_call_info.scope
         if scope not in self._pd_varname:
-            self._pd_varname[scope] = self._pd_find_varname(scope.module.__dict__)
+            frame = inspector.caller_frame()
+            if len(scope) > 0 and frame.f_code.co_name == scope.scopelist[-1][0]:
+                frame = inspector.caller_frame()
+                self._pd_varname[scope] = \
+                    self._pd_find_varname({**frame.f_globals, **frame.f_locals})
+            else:
+                self._pd_varname[scope] = self._pd_find_varname(scope.module.__dict__)
         return self._pd_varname[scope]
 
     def pd_forward_device_check(self) -> bool:
@@ -1132,37 +1176,49 @@ def __init__(self, function: Callable, call_info: inspector.CallInfo,
     def _pd_evaluable_repr_inner(self, indent: int = 0) -> str:
         if not self._pd_full_dump and self._wrap_type == 'inline':
             return f'transform({self.__name__})'
-        return self._pd_call
+        if self._wrap_type == 'lambda' or self.pd_varname() is None:
+            return self._pd_call
+        return self.pd_varname()
+
+    @property
+    def _pd_full_dump(self):
+        return (
+            super()._pd_full_dump
+            or self._wrap_type in ('module', 'lambda')
+            or (self._wrap_type != 'inline' and bool(self._pd_call_info.scope.scopelist))
+        )
 
     def _pd_codegraph_add_startnodes(self, graph, name):
-        if (self._pd_full_dump
-                or self._wrap_type in ('module', 'lambda')
-                or (self._wrap_type != 'inline' and self._pd_call_info.scope.scopelist)):
+        if self._pd_full_dump:
             return super()._pd_codegraph_add_startnodes(graph, name)
-        module = inspector.caller_module()
-        scope = symfinder.Scope.toplevel(module)
+
+        my_scope = self._pd_call_info.scope
         source = f'from {self.__module__} import {self.__name__}'
 
         if self._wrap_type == 'inline':
-            node = CodeNode.from_source(source, scope, name=self.__name__)
-            graph[ScopedName(self.__name__, scope, 0)] = node
-            emptyscope = symfinder.Scope.empty()
-            graph[ScopedName('transform', emptyscope, 0)] = \
-                CodeNode.from_source('from padl import transform', emptyscope, name='transform')
-
-            start_source = f'{name or "_pd_dummy"} = transform({self.__name__})'
-            start = CodeNode.from_source(start_source, scope, name=name or "_pd_dummy")
+            graph[ScopedName(self.__name__, my_scope)] = \
+                CodeNode.from_source(source,
+                                     my_scope,
+                                     name=self.__name__)
+            graph[ScopedName('transform', my_scope)] = \
+                CodeNode.from_source('from padl import transform',
+                                     symfinder.Scope.empty(),
+                                     name='transform')
+
+            start = CodeNode.from_source(f'{name or "_pd_dummy"} = transform({self.__name__})',
+                                         my_scope,
+                                         name=name or "_pd_dummy")
             if name is not None:
-                graph[ScopedName(name, scope, 0)] = start
-            return {}
+                graph[ScopedName(name, my_scope)] = start
+            return set()
 
         if name is not None:
             source += f' as {name}'
         else:
             name = self.__name__
-        node = CodeNode.from_source(source, scope, name=name)
-        graph[ScopedName(name, scope, 0)] = node
-        return {}
+
+        graph[ScopedName(name, my_scope)] = CodeNode.from_source(source, my_scope, name=name)
+        return set()
 
     @property
     def source(self) -> str:
@@ -1257,6 +1313,8 @@ def __init__(self, pd_name: str = None, ignore_scope: bool = False,
                  arguments: Optional[OrderedDict] = None):
         caller_frameinfo = inspector.non_init_caller_frameinfo()
         call_info = inspector.CallInfo(caller_frameinfo, ignore_scope=ignore_scope)
+        if call_info.function != '<module>' and not ignore_scope:
+            inspector.trace_this(_set_local_varname, caller_frameinfo.frame, scope=call_info.scope)
         call = inspector.get_segment_from_frame(caller_frameinfo.frame, 'call')
         call = re.sub(r'\n\s*', ' ', call)
         self._pd_arguments = arguments
@@ -1282,10 +1340,10 @@ def _pd_codegraph_add_startnodes_import_var(self, graph, name):
         instance_scope = self._pd_call_info.scope
         varname = self.pd_varname(instance_scope)
 
-        import_source = f'from {self.__module__} import {varname}'
+        import_source = f'from {instance_scope.module_name} import {varname}'
         import_node = CodeNode.from_source(import_source, instance_scope, name=varname)
 
-        graph[ScopedName(varname, instance_scope, 0)] = import_node
+        graph[ScopedName(varname, instance_scope)] = import_node
 
         if name != varname:
             start_source = f'{name or "_pd_dummy"} = {varname}'
@@ -1293,26 +1351,26 @@ def _pd_codegraph_add_startnodes_import_var(self, graph, name):
                                               instance_scope,
                                               name=name or "_pd_dummy")
             if name is not None:
-                graph[ScopedName(name, instance_scope, 0)] = start_node
+                graph[ScopedName(name, instance_scope)] = start_node
 
         return set()
 
     def _pd_codegraph_add_startnodes_import(self, graph, name):
         instance_scope = self._pd_call_info.scope
 
-        # instance creation and class definition are in separate modules
-        if instance_scope.module_name != self.__class__.__module__:
-            return super()._pd_codegraph_add_startnodes(graph, name)
-
         # the instance has a varname - just import the instance
         if self.pd_varname(instance_scope) is not None:
             return self._pd_codegraph_add_startnodes_import_var(graph, name)
 
+        # instance creation and class definition are in separate modules
+        if instance_scope.module_name != self.__class__.__module__:
+            return super()._pd_codegraph_add_startnodes(graph, name)
+
         # import the class
         import_source = f'from {self.__class__.__module__} import {self.__class__.__name__}'
         import_node = CodeNode.from_source(import_source, instance_scope,
                                            name=self.__class__.__name__)
-        graph[ScopedName(self.__class__.__name__, instance_scope, 0)] = import_node
+        graph[ScopedName(self.__class__.__name__, instance_scope)] = import_node
         nodes = [import_node]
 
         # make the call
@@ -1321,7 +1379,7 @@ def _pd_codegraph_add_startnodes_import(self, graph, name):
         start_source = f'{name or "_pd_dummy"} = {call}'
         start_node = CodeNode.from_source(start_source, instance_scope, name=name or "_pd_dummy")
         if name is not None:
-            graph[ScopedName(name, call_scope, 0)] = start_node
+            graph[ScopedName(name, call_scope)] = start_node
         nodes.append(start_node)
 
         dependencies = set()
@@ -1339,10 +1397,10 @@ def _pd_codegraph_add_startnodes_full(self, graph, name):
         start_source = f'{name or "_pd_dummy"} = {call}'
         start_node = CodeNode.from_source(start_source, call_scope, name=name or '_pd_dummy')
         if name is not None:
-            graph[ScopedName(name, call_scope, 0)] = start_node
+            graph[ScopedName(name, call_scope)] = start_node
 
         for scoped_name in start_node.globals_:
-            if scoped_name.name == self.__class__.__name__:
+            if scoped_name.toplevel_name == self.__class__.__name__:
                 scoped_name.scope = class_scope
 
         return set(start_node.globals_)
@@ -1355,7 +1413,7 @@ def _pd_codegraph_add_startnodes(self, graph, name):
     @property
     def source(self) -> str:
         """The class source code. """
-        (body_msg, _), _, _ = symfinder.find_in_scope(ScopedName(self.__class__.__name__,
+        (body_msg, _), _ = symfinder.find_in_scope(ScopedName(self.__class__.__name__,
                                                                  self._pd_call_info.scope))
         try:
             return 'class ' + body_msg.split('class ', 1)[1]
@@ -1491,6 +1549,8 @@ class Map(Transform):
 
     def __init__(self, transform: Transform, call_info: Optional[inspector.CallInfo] = None,
                  pd_name: Optional[str] = None):
+        if call_info is None:
+            call_info = transform._pd_call_info
         super().__init__(call_info, pd_name)
 
         self.transform: Transform = transform
@@ -1545,7 +1605,7 @@ def _pd_splits(self, input_components=0) -> Tuple[Union[int, List],
         # .. and combine them as a Parallel
         return (
             output_components,
-            tuple(Parallel(s) if s else identity for s in splits),
+            tuple(Parallel(s, call_info=self._pd_call_info) if s else identity for s in splits),
             has_batchify,
             has_unbatchify
         )
@@ -1597,6 +1657,12 @@ class Pipeline(Transform):
     display_op = NotImplemented
 
     def __init__(self, transforms, call_info=None, pd_name=None, pd_group=False):
+        if call_info is None:
+            call_info = inspector.CallInfo()
+            if call_info.function != '<module>':
+                call_frame = inspector.caller_frame()
+                inspector.trace_this(_set_local_varname, call_frame, scope=call_info.scope)
+
         super().__init__(call_info, pd_name)
 
         self._pd_group = True if pd_name is not None else pd_group
@@ -1674,6 +1740,34 @@ def _defined_somewhere_else(self):
             and defined_as is not None
         )
 
+    def _map_binop_to_list(self, node, list_):
+        if len(list_) == 1:
+            return [(list_[-1], node)]
+        return self._map_binop_to_list(node.left, list_[:-1]) + [(list_[-1], node.right)]
+
+    def _pd_label_ast_node(self, ast_node, graph):
+        super()._pd_label_ast_node(ast_node, graph)
+        while not isinstance(ast_node, ast.BinOp) or isinstance(ast_node.op, ast.Sub):
+            if isinstance(ast_node, ast.Call):
+                assert self._pd_group
+                assert ast_node.func.value.id == 'padl'
+                assert ast_node.func.attr == 'group'
+                ast_node = ast_node.args[0]
+                emptyscope = symfinder.Scope.empty()
+                graph[ScopedName('padl.group', self._pd_call_info.scope)] = \
+                    CodeNode.from_source('import padl', emptyscope, name='padl')
+
+            if isinstance(ast_node, ast.Name):
+                super()._pd_label_ast_node(ast_node, graph)
+                return
+
+            if isinstance(ast_node.op, ast.Sub):
+                assert self._pd_name is not None
+                ast_node = ast_node.left
+
+        for sub_transform, node in self._map_binop_to_list(ast_node, self.transforms):
+            sub_transform._pd_label_ast_node(node, graph)
+
     def _codegraph_add_import_startnode(self, graph, name):
         module = inspector.caller_module()
         scope = symfinder.Scope.toplevel(module)
@@ -1684,7 +1778,7 @@ def _codegraph_add_import_startnode(self, graph, name):
         else:
             name = defined_as
         node = CodeNode.from_source(source, scope, name=name)
-        graph[ScopedName(name, scope, 0)] = node
+        graph[ScopedName(name, scope)] = node
 
     def _pd_build_codegraph(self, graph=None, name=None):
         """Build a codegraph defining the transform.
@@ -1701,19 +1795,15 @@ def _pd_build_codegraph(self, graph=None, name=None):
             self._codegraph_add_import_startnode(graph, name)
             return graph
 
-        self._pd_codegraph_add_startnodes(graph, name)
-
-        if self._pd_group and 'padl' not in graph:
-            emptyscope = symfinder.Scope.empty()
-            graph[ScopedName('padl.group', emptyscope, 0)] = CodeNode.from_source('import padl',
-                                                                                  emptyscope,
-                                                                                  name='padl')
+        todo = self._pd_codegraph_add_startnodes(graph, name)
 
         # iterate over sub-transforms and update the codegraph with their codegraphs
         for transform in self.transforms:
             varname = transform.pd_varname(self._pd_call_info.scope)
             # pylint: disable=protected-access
             transform._pd_build_codegraph(graph, varname)
+
+        self._pd_codegraph_find_dependencies(graph, todo)
         return graph
 
     def _pd_longrepr(self, formatting=True, marker=None):
@@ -1913,7 +2003,7 @@ def _pd_splits(self, input_components=0) -> Tuple[Union[int, List],
         final_splits = []
         for split in cleaned_splits:
             if len(split) > 1:  # combine sub_splits
-                final_splits.append(Compose(split))
+                final_splits.append(Compose(split, call_info=self._pd_call_info))
             elif len(split) == 1:  # if it's just one, no need to combine
                 if isinstance(split[0], Compose):
                     final_splits.append(group(split[0]))
@@ -2189,9 +2279,9 @@ def _pd_splits(self, input_components=0) -> Tuple[Union[int, List],
         for i, s in enumerate(cleaned_splits):
             if isinstance(s, list):
                 if i == first_non_identity:
-                    final_splits.append(Rollout(s))
+                    final_splits.append(Rollout(s, call_info=self._pd_call_info))
                 else:
-                    final_splits.append(Parallel(s))
+                    final_splits.append(Parallel(s, call_info=self._pd_call_info))
             else:
                 final_splits.append(s)
 
@@ -2298,7 +2388,7 @@ def _pd_splits(self, input_components=0) -> Tuple[Union[int, List],
             for split in splits
         )
 
-        final_splits = tuple(Parallel(s) if isinstance(s, list) else s for s in cleaned_splits)
+        final_splits = tuple(Parallel(s, call_info=self._pd_call_info) if isinstance(s, list) else s for s in cleaned_splits)
 
         res = []
         for split in final_splits:
@@ -2362,6 +2452,10 @@ def horizontal(n):
 class BuiltinTransform(ClassTransform):
     """A builtin transform will simply always be imported, never fully dumped. """
 
+    def __init_subclass__(cls, *_args, **_kwargs):
+        cls._pd_class_call_info = inspector.CallInfo()
+        cls._pd_class_call_info.scope = symfinder.Scope.toplevel('padl')
+
     def _pd_longrepr(self, formatting=True, marker=None):
         out = self._pd_call.split('padl.')[-1]
         if marker:
@@ -2504,54 +2598,106 @@ def save(transform: Transform, path: Union[Path, str], force_overwrite: bool = F
         transform.pd_save(path, force_overwrite, strict_requirements)
 
 
+def run(path, **kwargs):
+    if kwargs:
+        _, parsed_kwargs, _, _ = inspector.get_my_call_signature()
+    else:
+        parsed_kwargs = {}
+    run_noparse(path, parsed_kwargs)
+
+
+def run_noparse(path, parsed_kwargs):
+    with open(path, encoding='utf-8') as f:
+        source = f.read()
+    scope = inspector._get_scope_from_frame(inspector.caller_frame(), 0)
+
+    if parsed_kwargs:
+        source = config_tools.apply_params(source, parsed_kwargs, scope)
+
+    module_name = str(path).replace('/', ospath.sep).lstrip('.').rstrip('.py')
+
+    class _EmptyLoader(Loader):
+        def create_module(self, spec):
+            return types.ModuleType(spec.name)
+
+    spec = ModuleSpec(module_name, _EmptyLoader())
+    module = module_from_spec(spec)
+    if parsed_kwargs:
+        tempdir = TemporaryDirectory()
+        module_path = Path(tempdir.name) / path.name
+        with open(module_path, 'w', encoding='utf-8') as f:
+            f.write(source)
+        module.__dict__['_pd_tempdir'] = tempdir
+    else:
+        module_path = path
+
+    module.__dict__.update({
+        '_pd_is_padl_file': True,
+        '_pd_source': source,
+        '_pd_module': module,
+        '_pd_full_dump': True,
+    })
+
+    code = compile(source, module_path, 'exec')
+    # pylint: disable=exec-used
+    exec(code, module.__dict__)
+
+
 def load(path, **kwargs):
     """Load a transform (as saved with padl.save) from *path*.
 
     Use keyword arguments to override params (see :func:`padl.param`).
     """
+    if kwargs:
+        _, parsed_kwargs, _, _ = inspector.get_my_call_signature()
+    else:
+        parsed_kwargs = {}
+    return load_noparse(path, parsed_kwargs)
+
+
+def load_noparse(path, parsed_kwargs):
     if Path(path).is_file():
         return _zip_load(path)
     path = Path(path)
-    with open(path / 'transform.py') as f:
+
+    with open(path / 'transform.py', encoding='utf-8') as f:
         source = f.read()
 
+    scope = inspector._get_scope_from_frame(inspector.caller_frame(), 0)
+    source = config_tools.apply_params(source, parsed_kwargs, scope)
+
     class _EmptyLoader(Loader):
         def create_module(self, spec):
             return types.ModuleType(spec.name)
 
-    module_name = str(path).replace('/', '.').lstrip('.') + 'transform'
+    module_name = str(path).replace('/', ospath.sep).lstrip('.') + '.transform'
     spec = ModuleSpec(module_name, _EmptyLoader())
     module = module_from_spec(spec)
 
-    pd_found_params = {}
     module.__dict__.update({
         '_pd_is_padl_file': True,
         '_pd_source': source,
         '_pd_module': module,
         '_pd_full_dump': True,
-        '_pd_params': kwargs,
-        '_pd_found_params': pd_found_params,
-        '__file__': str(path / 'transform.py')
     })
 
-    code = compile(source, path / 'transform.py', 'exec')
+    if parsed_kwargs:
+        tempdir = TemporaryDirectory()
+        module_path = Path(tempdir.name) / 'transform.py'
+        with open(module_path, 'w', encoding='utf-8') as f:
+            f.write(source)
+        module.__dict__['_pd_tempdir'] = tempdir
+    else:
+        module_path = path / 'transform.py'
+
+    module.__dict__['__file__'] = str(module_path)
+
+    code = compile(source, module_path, 'exec')
 
     # pylint: disable=exec-used
     exec(code, module.__dict__)
 
-    for k in kwargs:
-        if k not in pd_found_params:
-            msg = (
-                f'Parameter {k} does not exist.\n\n' +
-                'Available parameters:\n' +
-                '\n'.join(f'  {n} (default: {d})' if d is not None
-                          else 'f'
-                          for n, d in pd_found_params.items())
-            )
-            raise ValueError(msg)
-
     # pylint: disable=no-member,protected-access
-
     transform = module._pd_main
     for i, subtrans in enumerate(transform._pd_all_transforms()):
         if hasattr(transform, 'pd_save_options'):
diff --git a/padl/util_transforms.py b/padl/util_transforms.py
index 005fc0a2..4ddec33a 100644
--- a/padl/util_transforms.py
+++ b/padl/util_transforms.py
@@ -4,10 +4,10 @@
 from collections import OrderedDict
 from typing import Optional, Union, List, Tuple
 
-from padl.transforms import ClassTransform, Identity, Transform, Mode, identity
+from padl.transforms import BuiltinTransform, Identity, Transform, Mode, identity
 
 
-class IfInMode(ClassTransform):
+class IfInMode(BuiltinTransform):
     """Perform *if_* if called in mode *target_mode*, else perform *else_*.
 
     Example:
@@ -116,7 +116,7 @@ def __init__(self, if_: Transform, else_: Optional[Transform] = None):
         super().__init__(if_, 'train', else_)
 
 
-class Try(ClassTransform):
+class Try(BuiltinTransform):
     """ Perform *transform*. If this fails with any exception from *exceptions*, perform
     *catch_transform*. If *transform* is completed successfully, *else_transform* is performed
     with the output of *transform*. Regardless of any error occurring on the other Transform
diff --git a/padl/wrap.py b/padl/wrap.py
index 0bf18ba8..ce41d18a 100644
--- a/padl/wrap.py
+++ b/padl/wrap.py
@@ -15,19 +15,10 @@
 from padl.dumptools import ast_utils, var2mod, inspector
 from padl.dumptools.sourceget import cut, get_source, original
 from padl.transforms import (
-    AtomicTransform, ClassTransform, FunctionTransform, TorchModuleTransform
+    AtomicTransform, ClassTransform, FunctionTransform, TorchModuleTransform, _set_local_varname
 )
 
 
-def _set_local_varname(frame, event, _args, scope):
-    if event == 'return':
-        for k, v in frame.f_locals.items():
-            try:
-                v._pd_varname[scope] = k
-            except AttributeError:
-                continue
-
-
 def _wrap_function(fun, ignore_scope=False, call_info: inspector.CallInfo = None):
     """Wrap *fun* in a Transform. Don't use directly, use `transform` instead.
 
@@ -55,7 +46,7 @@ def _wrap_function(fun, ignore_scope=False, call_info: inspector.CallInfo = None
                 call = None
                 wrap_type = 'decorator'
 
-    # if this is the decorator case we drop one leven from the scope (this is the decorated
+    # if this is the decorator case we drop one level from the scope (this is the decorated
     # function itself)
     drop_n = 1 if call is None else 0
     if call_info is None:


Test patch:
diff --git a/tests/material/dumps/mutated_recursive_transform.txt b/tests/material/dumps/mutated_recursive_transform.txt
new file mode 100644
index 00000000..dea3cf87
--- /dev/null
+++ b/tests/material/dumps/mutated_recursive_transform.txt
@@ -0,0 +1,12 @@
+from padl import transform
+
+
+@transform
+def mutated_recursive_transform(arg):
+    if arg == 0:
+        return arg
+    return 1 + mutated_recursive_transform(arg - 1)
+
+
+mutated_recursive_transform = mutated_recursive_transform.pd_to('cpu')
+_pd_main = mutated_recursive_transform
diff --git a/tests/material/dumps/mutated_transform.txt b/tests/material/dumps/mutated_transform.txt
new file mode 100644
index 00000000..7ea39e39
--- /dev/null
+++ b/tests/material/dumps/mutated_transform.txt
@@ -0,0 +1,10 @@
+from padl import transform
+
+
+@transform
+def mutated_transform(arg):
+    return arg * 2
+
+
+mutated_transform = mutated_transform.pd_to('cpu')
+_pd_main = mutated_transform
diff --git a/tests/material/dumps/nested_e.txt b/tests/material/dumps/nested_e.txt
new file mode 100644
index 00000000..5adab4e6
--- /dev/null
+++ b/tests/material/dumps/nested_e.txt
@@ -0,0 +1,12 @@
+from padl import transform
+
+
+CONST = 20
+
+
+@transform
+def z(x):
+    return x*CONST
+
+
+_pd_main = z
diff --git a/tests/material/dumps/othermodule_full_makeclasstransform_with_transforms.txt b/tests/material/dumps/othermodule_full_makeclasstransform_with_transforms.txt
index 467f0fce..6495ea59 100644
--- a/tests/material/dumps/othermodule_full_makeclasstransform_with_transforms.txt
+++ b/tests/material/dumps/othermodule_full_makeclasstransform_with_transforms.txt
@@ -4,21 +4,21 @@ from padl import transform
 B = 2
 C = 3
 tests_material_transforms_in_module_CONST = 123
-tests_unittests_test_dump_test_full_makeclasstransform_with_constants_CONST = 1
+tests_unittests_test_dump_CONST = 1
 
 
 def k(y):
     return y
 
 
-a = tests_unittests_test_dump_test_full_makeclasstransform_with_constants_CONST
+a = tests_unittests_test_dump_CONST
 b = B
 c = C
 
 
 @transform
 def y(y):
-    return (tests_material_transforms_in_module_CONST + k(y))
+    return tests_material_transforms_in_module_CONST + k(y)
 
 
 @transform
diff --git a/tests/material/dumps/othermodule_full_makeclasstransform_with_transforms38.txt b/tests/material/dumps/othermodule_full_makeclasstransform_with_transforms38.txt
index 0d96f782..6495ea59 100644
--- a/tests/material/dumps/othermodule_full_makeclasstransform_with_transforms38.txt
+++ b/tests/material/dumps/othermodule_full_makeclasstransform_with_transforms38.txt
@@ -4,14 +4,14 @@ from padl import transform
 B = 2
 C = 3
 tests_material_transforms_in_module_CONST = 123
-tests_unittests_test_dump_test_full_makeclasstransform_with_constants_CONST = 1
+tests_unittests_test_dump_CONST = 1
 
 
 def k(y):
     return y
 
 
-a = tests_unittests_test_dump_test_full_makeclasstransform_with_constants_CONST
+a = tests_unittests_test_dump_CONST
 b = B
 c = C
 
diff --git a/tests/material/dumps/varname_same_as_arg.txt b/tests/material/dumps/varname_same_as_arg.txt
new file mode 100644
index 00000000..dd06a30e
--- /dev/null
+++ b/tests/material/dumps/varname_same_as_arg.txt
@@ -0,0 +1,16 @@
+from padl import transform
+
+
+b = 1
+
+
+@transform
+class C:
+    def __init__(self, x):
+        ...
+
+    def __call__(self, x):
+        return x
+
+
+_pd_main = C(b)
diff --git a/tests/material/find_codenode_from_import.py b/tests/material/find_codenode_from_import.py
new file mode 100644
index 00000000..e9bf41b1
--- /dev/null
+++ b/tests/material/find_codenode_from_import.py
@@ -0,0 +1,1 @@
+from padl.dumptools.var2mod import find_codenode
diff --git a/tests/material/find_codenode_module_import.py b/tests/material/find_codenode_module_import.py
new file mode 100644
index 00000000..f300efba
--- /dev/null
+++ b/tests/material/find_codenode_module_import.py
@@ -0,0 +1,1 @@
+from padl.dumptools import var2mod
diff --git a/tests/material/outer_caller.py b/tests/material/outer_caller.py
new file mode 100644
index 00000000..6b97ad80
--- /dev/null
+++ b/tests/material/outer_caller.py
@@ -0,0 +1,13 @@
+from padl.dumptools import inspector
+
+
+def here():
+    return inspector.outer_caller_frameinfo(__name__)
+
+
+def caller_module():
+    return inspector.caller_module()
+
+
+def caller_frame():
+    return inspector.caller_frame()
diff --git a/tests/material/outer_caller_1.py b/tests/material/outer_caller_1.py
new file mode 100644
index 00000000..c45e3130
--- /dev/null
+++ b/tests/material/outer_caller_1.py
@@ -0,0 +1,5 @@
+from .outer_caller import here
+
+
+def there():
+    return here()
diff --git a/tests/material/transforms_in_module.py b/tests/material/transforms_in_module.py
index e39e4349..5042b6ef 100644
--- a/tests/material/transforms_in_module.py
+++ b/tests/material/transforms_in_module.py
@@ -21,6 +21,9 @@ def __call__(self, y):
 pipeline = obj >> obj + function >> function / function
 
 
+lambdatrans = transform(lambda x: y)
+
+
 def makefunction():
     @transform
     def z(x):
@@ -34,6 +37,9 @@ def k(y):
     return y
 
 
+tk = transform(k)
+
+
 @transform
 def y(y):
     return CONST + k(y)
diff --git a/tests/unittests/dumptools/test_ast_utils.py b/tests/unittests/dumptools/test_ast_utils.py
new file mode 100644
index 00000000..5f3b931a
--- /dev/null
+++ b/tests/unittests/dumptools/test_ast_utils.py
@@ -0,0 +1,149 @@
+import ast
+from textwrap import dedent
+
+from padl.dumptools import ast_utils
+from padl.dumptools.sourceget import cut
+
+
+class TestPosition:
+    def test_init(self):
+        pos = ast_utils.Position(1, 2, 3, 4)
+        assert pos.lineno == 1
+        assert pos.end_lineno == 2
+        assert pos.col_offset == 3
+        assert pos.end_col_offset == 4
+
+    def test_iter(self):
+        pos = ast_utils.Position(1, 2, 3, 4)
+        assert list(pos) == [1, 2, 3, 4]
+
+
+class TestCachedParse:
+    def test_same_as_ast_parse(self):
+        source = dedent('''
+            a = 1
+            b = 2
+
+            class X:
+                def haha(self):
+                    return [x for x in range(123)]
+        ''')
+        assert ast_utils.dump(ast_utils.cached_parse(source)) == \
+            ast_utils.dump(ast_utils.ast.parse(source))
+
+    def test_cache(self):
+        source = dedent('''
+            a = 1
+            b = 2
+
+            class X:
+                def haha(self):
+                    return [x for x in range(123)]
+        ''')
+        assert ast_utils.cached_parse(source) is ast_utils.cached_parse(source)
+
+
+class TestGetSourceSegment:
+    def test_get_function_def(self):
+        function = dedent('''\
+            def haha(self):
+                return [x for x in range(123)]
+        ''')
+        source = (
+            'a = 1\n'
+            'b = 2\n'
+            + function +
+            'c = 3'
+        )
+        parsed = ast.parse(source).body[2]
+        assert ast_utils.get_source_segment(source, parsed).strip() == function.strip()
+
+    def test_get_assign(self):
+        assign = dedent('''\
+            d = 123
+        ''')
+        source = (
+            'a = 1\n'
+            'b = 2\n'
+            + assign +
+            'c = 3'
+        )
+        parsed = ast.parse(source).body[2]
+        assert ast_utils.get_source_segment(source, parsed).strip() == assign.strip()
+
+    def test_get_nested_equal(self):
+        equal = 'x == 123'
+        source = (
+            'a = 1\n'
+            'b = 2\n'
+            f'o = [x for x in bla if {equal}]\n'
+            'c = 3'
+        )
+        parsed = ast.parse(source).body[2].value.generators[0].ifs[0]
+
+        assert ast_utils.get_source_segment(source, parsed).strip() == equal.strip()
+
+
+class TestGetPosition:
+    def test_get_function_def(self):
+        function = dedent('''\
+            def haha(self):
+                return [x for x in range(123)]
+        ''')
+        source = (
+            'a = 1\n'
+            'b = 2\n'
+            + function +
+            'c = 3'
+        )
+        parsed = ast.parse(source).body[2]
+        assert cut(source, *ast_utils.get_position(source, parsed), one_indexed=True) == \
+            function.strip()
+
+    def test_get_assign(self):
+        assign = dedent('''\
+            d = 123
+        ''')
+        source = (
+            'a = 1\n'
+            'b = 2\n'
+            + assign +
+            'c = 3'
+        )
+        parsed = ast.parse(source).body[2]
+        assert cut(source, *ast_utils.get_position(source, parsed), one_indexed=True) == \
+            assign.strip()
+
+    def test_get_nested_equal(self):
+        equal = 'x == 123'
+        source = (
+            'a = 1\n'
+            'b = 2\n'
+            f'o = [x for x in bla if {equal}]\n'
+            'c = 3'
+        )
+        parsed = ast.parse(source).body[2].value.generators[0].ifs[0]
+
+        assert cut(source, *ast_utils.get_position(source, parsed), one_indexed=True) == \
+            equal.strip()
+
+
+class TestSpanToPos:
+    def test_single_line(self):
+        text = '0123456789'
+        assert ast_utils.span_to_pos((1, 3), text) == \
+            ast_utils.Position(1, 1, 1, 3)
+
+    def test_single_line_in_multiline_text(self):
+        text = ('0123456789\n'
+                '0123456789\n'
+                '0123456789')
+        assert ast_utils.span_to_pos((12, 14), text) == \
+            ast_utils.Position(2, 2, 1, 3)
+
+    def test_multiline(self):
+        text = ('0123456789\n'
+                '0123456789\n'
+                '0123456789')
+        assert ast_utils.span_to_pos((12, 25), text) == \
+            ast_utils.Position(2, 3, 1, 3)
diff --git a/tests/unittests/dumptools/test_config_tools.py b/tests/unittests/dumptools/test_config_tools.py
new file mode 100644
index 00000000..f368c6ad
--- /dev/null
+++ b/tests/unittests/dumptools/test_config_tools.py
@@ -0,0 +1,269 @@
+from textwrap import dedent
+
+from padl.dumptools import config_tools as ct
+from padl.dumptools.ast_utils import Position
+
+
+class TestParam:
+    def test_input_is_output(self):
+        assert 1 == ct.param('a', 1)
+
+
+class TestParams:
+    def test_output_is_dict(self):
+        assert {'a': 1, 'b': 2, 'c': 3} == ct.params('a', a=1, b=2, c=3)
+
+
+class TestExtractParamS:
+    def test_multiple(self):
+        s = dedent('''
+            a = param('a', "1"); c = param('c', 1, 'hello', False)
+            def x():
+                b = param('bb', x)
+        ''')
+        p = ct.extract_param_s(s)
+        assert p['a'] == {
+            'name': "'a'",
+            'val': '"1"',
+            'description': 'None',
+            'use_default': 'True',
+            'position': Position(lineno=2, end_lineno=2, col_offset=15, end_col_offset=18)
+        }
+        assert p['c'] == {
+            'name': "'c'",
+            'val': '1',
+            'description': "'hello'",
+            'use_default': 'False',
+            'position': Position(lineno=2, end_lineno=2, col_offset=36, end_col_offset=37)
+        }
+        assert p['bb'] == {
+            'name': "'bb'",
+            'val': 'x',
+            'description': 'None',
+            'use_default': 'True',
+            'position': Position(lineno=4, end_lineno=4, col_offset=20, end_col_offset=21)
+        }
+
+    def test_defaults_are_filled_in(self):
+        s = 'param("a", 1)'
+        p = ct.extract_param_s(s)
+        assert p['a'] == {
+            'name': '"a"',
+            'val': '1',
+            'description': 'None',
+            'use_default': 'True',
+            'position': Position(lineno=1, end_lineno=1, col_offset=11, end_col_offset=12)
+        }
+
+    def test_defaults_can_be_overridden(self):
+        s = 'param("a", 1, "bla", False)'
+        p = ct.extract_param_s(s)
+        assert p['a'] == {
+            'name': '"a"',
+            'val': '1',
+            'description': '"bla"',
+            'use_default': 'False',
+            'position': Position(lineno=1, end_lineno=1, col_offset=11, end_col_offset=12)
+        }
+
+    def test_defaults_can_be_overridden_via_keywords(self):
+        s = 'param(use_default=False, description="bla", name="a", val=1)'
+        p = ct.extract_param_s(s)
+        assert p['a'] == {
+            'name': '"a"',
+            'val': '1',
+            'description': '"bla"',
+            'use_default': 'False',
+            'position': Position(lineno=1, end_lineno=1, col_offset=58, end_col_offset=59)
+        }
+
+    def test_correct_keys(self):
+        s = 'param("a", 1)'
+        p = ct.extract_param_s(s)
+        assert set(p.keys()) == {'a'}
+        assert set(p['a'].keys()) == {'name', 'val', 'description', 'use_default', 'position'}
+
+    def test_complex_values_work(self):
+        s = 'param("a", np.random.rand(100 ** 10))'
+        p = ct.extract_param_s(s)
+        assert p['a']['val'] == 'np.random.rand(100 ** 10)'
+
+    def test_attribute_works(self):
+        s = 'padl.param("a", 1)'
+        p = ct.extract_param_s(s)
+        assert p['a'] == {
+            'name': '"a"',
+            'val': '1',
+            'description': 'None',
+            'use_default': 'True',
+            'position': Position(lineno=1, end_lineno=1, col_offset=16, end_col_offset=17)
+        }
+
+    def test_finds_nested(self):
+        s = dedent('''
+            class X:
+                def f(self):
+                    def o():
+                        return [x for x in param('x', [1, 2, 3])]
+        ''')
+        p = ct.extract_param_s(s)
+        assert 'x' in p
+
+
+class TestExtractParamsS:
+    def test_defaults_are_filled_in(self):
+        s = 'params("a", x=1)'
+        p = ct.extract_params_s(s)
+        assert p['a'] == {
+            'name': '"a"',
+            'kwargs': {'x': '1'},
+            'use_defaults': 'True',
+            'allow_free': 'False',
+            'positions': {'x': Position(lineno=1, end_lineno=1, col_offset=14, end_col_offset=15)},
+            'end_position': Position(lineno=1, end_lineno=1, col_offset=15, end_col_offset=15)
+        }
+
+    def test_defaults_can_be_overridded(self):
+        s = 'params("a", x=1, use_defaults=False, allow_free=True)'
+        p = ct.extract_params_s(s)
+        assert p['a'] == {
+            'name': '"a"',
+            'kwargs': {'x': '1'},
+            'use_defaults': 'False',
+            'allow_free': 'True',
+            'positions': {'x': Position(lineno=1, end_lineno=1, col_offset=14, end_col_offset=15)},
+            'end_position': Position(lineno=1, end_lineno=1, col_offset=15, end_col_offset=15)
+        }
+
+    def test_keywords_can_be_used_for_everything(self):
+        s = 'params(use_defaults=False, name="a", x=1, allow_free=True)'
+        p = ct.extract_params_s(s)
+        assert p['a'] == {
+            'name': '"a"',
+            'kwargs': {'x': '1'},
+            'use_defaults': 'False',
+            'allow_free': 'True',
+            'positions': {'x': Position(lineno=1, end_lineno=1, col_offset=39, end_col_offset=40)},
+            'end_position': Position(lineno=1, end_lineno=1, col_offset=40, end_col_offset=40)
+        }
+
+    def test_correct_keys(self):
+        s = 'params("a", x=1)'
+        p = ct.extract_params_s(s)
+        assert set(p.keys()) == {'a'}
+        assert set(p['a'].keys()) == {'name', 'kwargs', 'allow_free', 'use_defaults', 'positions',
+                                      'end_position'}
+
+    def test_complex_values_work(self):
+        s = 'params("a", x=np.random.rand(100 ** 10))'
+        p = ct.extract_params_s(s)
+        assert p['a']['kwargs']['x'] == 'np.random.rand(100 ** 10)'
+
+    def test_attribute_works(self):
+        s = 'padl.params("a", x=1)'
+        p = ct.extract_params_s(s)
+        assert 'a' in p
+
+    def test_finds_nested(self):
+        s = dedent('''
+            class X:
+                def f(self):
+                    def o():
+                        return [x for x in params('x', o=[1, 2, 3])['o']]
+        ''')
+        p = ct.extract_params_s(s)
+        assert 'x' in p
+
+
+class TestChangeParam:
+    def test_simple(self):
+        s = 'param("x", 1)'
+        assert ct.change_param(s, 'x', '100') == 'param("x", 100)'
+
+    def test_attribute_works(self):
+        s = 'padl.param("x", 1)'
+        assert ct.change_param(s, 'x', '100') == 'padl.param("x", 100)'
+
+    def test_other_arguments_stay(self):
+        s = 'param("x", 1, "this is a parameter", use_default=True)'
+        assert ct.change_param(s, 'x', '100') == 'param("x", 100, "this is a parameter", use_default=True)'
+
+    def test_weird_formatting_works(self):
+        s = dedent('''
+            param("x", 1,
+                        "this is a parameter",
+                    use_default=True
+
+                )
+        ''')
+        target = dedent('''
+            param("x", 100,
+                        "this is a parameter",
+                    use_default=True
+
+                )
+        ''')
+        assert ct.change_param(s, 'x', '100') == target
+
+    def test_nested_works(self):
+        s = dedent('''
+            def f():
+                class X:
+                    def __init__(self, y):
+                        o = param("x", 1, "this is a parameter", use_default=True)
+        ''')
+        target = dedent('''
+            def f():
+                class X:
+                    def __init__(self, y):
+                        o = param("x", 100, "this is a parameter", use_default=True)
+        ''')
+        assert ct.change_param(s, 'x', '100') == target
+
+
+class TestChangeParams:
+    def test_simple(self):
+        s = 'params("x", x=1)'
+        assert ct.change_params(s, 'x', x='100') == 'params("x", x=100)'
+
+    def test_attribute_works(self):
+        s = 'padl.params("x", x=1)'
+        assert ct.change_params(s, 'x', x='100') == 'padl.params("x", x=100)'
+
+    def test_multiple(self):
+        s = 'padl.params("x", x=1, y=2, z=3)'
+        assert ct.change_params(s, 'x', x='100', z='1000') == 'padl.params("x", x=100, y=2, z=1000)'
+
+    def test_other_arguments_stay(self):
+        s = 'params("x", x=1, use_defaults=True, allow_free=False)'
+        assert ct.change_params(s, 'x', x='100') == 'params("x", x=100, use_defaults=True, allow_free=False)'
+
+    def test_weird_formatting_works(self):
+        s = dedent('''
+            params("x", x=1,
+                    use_default=True
+
+                )
+        ''')
+        target = dedent('''
+            params("x", x=100,
+                    use_default=True
+
+                )
+        ''')
+        assert ct.change_params(s, 'x', x='100') == target
+
+    def test_nested_works(self):
+        s = dedent('''
+            def f():
+                class X:
+                    def __init__(self, y):
+                        o = params("x", x=1, use_default=True)
+        ''')
+        target = dedent('''
+            def f():
+                class X:
+                    def __init__(self, y):
+                        o = params("x", x=100, use_default=True)
+        ''')
+        assert ct.change_params(s, 'x', x='100') == target
diff --git a/tests/unittests/dumptools/test_inspector.py b/tests/unittests/dumptools/test_inspector.py
index 2d0d7344..4556d342 100644
--- a/tests/unittests/dumptools/test_inspector.py
+++ b/tests/unittests/dumptools/test_inspector.py
@@ -1,9 +1,22 @@
+import inspect
 import pytest
+import sys
 
-from padl.dumptools import inspector
+from padl.dumptools import inspector, ast_utils
+from padl.dumptools.sourceget import get_source
 
 
 toplevel_callinfo = inspector.CallInfo('here')
+toplevel_frame = inspect.currentframe()
+
+
+def nested_frame(a, b, c=3, **kwargs):
+    f = inspect.stack()[0].frame
+    return inspector._get_scope_from_frame(f, 0)
+
+
+def chain_nested(x=1):
+    return nested_frame(x, 1)
 
 
 def nested_builder():
@@ -16,17 +29,187 @@ def inner_builder():
     return inner_builder()
 
 
-class TestCallerInfo:
+class TestCallInfo:
     def test_toplevel(self):
         assert toplevel_callinfo.module.__name__ == __name__
         assert toplevel_callinfo.scope.scopelist == []
+        assert toplevel_callinfo.function == '<module>'
 
     def test_nested(self):
         nested_callinfo = nested_builder()
         assert nested_callinfo.module.__name__ == __name__
         assert len(nested_callinfo.scope) == 1
         assert nested_callinfo.scope.scopelist[0][0] == 'nested_builder'
+        assert nested_callinfo.function == 'nested_builder'
 
     def test_double_nested(self):
         with pytest.raises(AssertionError):
             double_nested_builder()
+
+
+class Test_GetScopeFromFrame:
+    def test_toplevel(self):
+        scope = inspector._get_scope_from_frame(toplevel_frame, 0)
+        assert scope.is_global()
+        assert scope == 'Scope[tests.unittests.dumptools.test_inspector]'
+
+    def test_nested(self):
+        e = 1
+        scope = nested_frame(e, 2, u=123)
+        assert scope.def_source == get_source(__file__)
+        assigns = scope.scopelist[0][1].body[:4]
+        unparsed = [ast_utils.unparse(x).strip() for x in assigns]
+        assert 'a = e' in unparsed
+        assert 'b = 2' in unparsed
+        assert 'c = 3' in unparsed
+        assert "kwargs = {'u': 123}" in unparsed
+        assert all(x.value._scope == 'Scope[tests.unittests.dumptools.test_inspector.test_nested]'
+                   for x in assigns)
+
+    def test_chain_nested(self):
+        e = 1
+        scope = chain_nested(e)
+        assert scope == \
+            'Scope[tests.unittests.dumptools.test_inspector.nested_frame]'
+        chain_scope = scope.scopelist[0][1].body[0].value._scope
+        assert chain_scope == \
+            'Scope[tests.unittests.dumptools.test_inspector.chain_nested]'
+        outer_scope = chain_scope.scopelist[0][1].body[0].value._scope
+        assert outer_scope == \
+            'Scope[tests.unittests.dumptools.test_inspector.test_chain_nested]'
+        assign = outer_scope.scopelist[0][1].body[0]
+        assert ast_utils.unparse(assign).strip() == 'e = 1'
+        assign = chain_scope.scopelist[0][1].body[0]
+        assert ast_utils.unparse(assign).strip() == 'x = e'
+        assign = scope.scopelist[0][1].body[0]
+        assert ast_utils.unparse(assign).strip() == 'a = x'
+
+    def test_scope_too_long_raises(self):
+        def x():
+            def y():
+                def z():
+                    f = inspect.stack()[0].frame
+                    return inspector._get_scope_from_frame(f, 0)
+                return z
+            return y
+
+        with pytest.raises(AssertionError):
+            x()()()
+
+
+class A:
+    def __init__(self):
+        self.frameinfo = inspector.non_init_caller_frameinfo()
+
+
+class B(A):
+    def __init__(self):
+        super().__init__()
+
+
+class TestNonInitCallerFrameinfo:
+    def test_trivial(self):
+        here = inspect.stack()[0]
+        there = inspector.non_init_caller_frameinfo()
+        assert here.filename == there.filename
+        assert here.function == there.function
+
+    def test_class_init(self):
+        here = inspect.stack()[0]
+        there = A().frameinfo
+        assert here.filename == there.filename
+        assert here.function == there.function
+
+    def test_child_class_init(self):
+        here = inspect.stack()[0]
+        there = B().frameinfo
+        assert here.filename == there.filename
+        assert here.function == there.function
+
+
+class TestTraceThis:
+    def test_works(self):
+        import sys
+        if sys.gettrace() is not None and 'coverage' in str(sys.gettrace()):
+            return  # this doesn't work with coverage -- disabling
+
+        def tracefunc(frame, event, arg):
+            if event == 'return':
+                assert arg == 123
+                assert 0, 'this works'
+
+        def to_trace(x):
+            inspector.trace_this(tracefunc)
+            return x
+
+        with pytest.raises(AssertionError) as exc_info:
+            to_trace(123)
+
+        exc_info.match('this works')
+
+
+class Test_InstructionsUpToCall:
+    def test_from_string(self):
+        s = 'b = 1; a(); f = 123'
+        ix = inspector._instructions_up_to_call(s)
+        assert [i.opname for i in ix] == ['LOAD_CONST', 'STORE_NAME', 'LOAD_NAME', 'CALL_FUNCTION']
+
+    def test_from_code(self):
+        def f():
+            return inspector._instructions_up_to_call(inspect.currentframe().f_back.f_code)
+
+        a = 1
+        b = 2
+        ix = f()
+
+        assert ix[-1].opname == 'CALL_FUNCTION'
+
+
+class Test_InstructionsInName:
+    def test_from_string(self):
+        s = 'a.b.c.d'
+        ix = inspector._instructions_in_name(s)
+        assert [i.opname for i in ix] == ['LOAD_NAME', 'LOAD_ATTR', 'LOAD_ATTR', 'LOAD_ATTR']
+
+
+class Test_InstructionsInGetitem:
+    ...
+
+
+class Test_InstructionsUpToOffset:
+    def test_from_string(self):
+        s = 'b = 1; a(); f = 123'
+        ix = inspector._instructions_up_to_offset(s, 6)
+        assert ix[-1].offset == 6
+        ix = inspector._instructions_up_to_offset(s, 2)
+        assert ix[-1].offset == 2
+
+
+class Test_Module:
+    def test_works(self):
+        m = inspector._module(toplevel_frame)
+        assert m == sys.modules[__name__]
+
+
+class TestOuterCallerFrameinfo:
+    def test_here(self):
+        from tests.material import outer_caller
+        m = outer_caller.here()
+        assert m.function == 'test_here'
+
+    def test_there(self):
+        from tests.material import outer_caller_1
+        m = outer_caller_1.there()
+        assert m.function == 'there'
+
+
+class TestCallerModule:
+    def test_here(self):
+        from tests.material import outer_caller
+        assert outer_caller.caller_module().__name__ == __name__
+
+
+class TestCallerFrame:
+    def test_here(self):
+        from tests.material import outer_caller
+        assert outer_caller.caller_frame().f_code.co_filename == __file__
diff --git a/tests/unittests/dumptools/test_packagefinder.py b/tests/unittests/dumptools/test_packagefinder.py
index b3ca01ea..58bbff9c 100644
--- a/tests/unittests/dumptools/test_packagefinder.py
+++ b/tests/unittests/dumptools/test_packagefinder.py
@@ -8,6 +8,25 @@
 from padl.dumptools import packagefinder
 
 
+class TestStandardLibNameGen:
+    def test_works(self):
+        res = list(packagefinder.standard_lib_names_gen())
+        for x in res:
+            assert isinstance(x, str)
+            assert not x.startswith('_')
+        assert 'dis' in res
+        assert 'random' in res
+        assert 'datetime' in res
+
+    def test_include_underscored(self):
+        res = list(packagefinder.standard_lib_names_gen(True))
+
+        for x in res:
+            assert isinstance(x, str)
+
+        assert any(x.startswith('_') for x in res)
+
+
 class TestGetDistributionName:
     def test_same_name(self):
         assert packagefinder.get_distribution_name('torch') == 'torch'
diff --git a/tests/unittests/dumptools/test_symfinder.py b/tests/unittests/dumptools/test_symfinder.py
index 0c283c1b..a8fbb659 100644
--- a/tests/unittests/dumptools/test_symfinder.py
+++ b/tests/unittests/dumptools/test_symfinder.py
@@ -3,14 +3,112 @@
 
 import pytest
 
-from padl.dumptools import symfinder
+from padl.dumptools import symfinder, ast_utils, sourceget
 
 
 def test_annassign():
     source = dedent('''
         a: int = 1
     ''')
-    code, node, name = symfinder.find_in_source('a', source)
+    scoped_name = symfinder.ScopedName('a', None)
+    code, node, name = symfinder.find_scopedname_in_source(scoped_name, source)
     assert code == 'a: int = 1'
     assert isinstance(node, ast.AnnAssign)
-    assert name == 'a'
+    assert name.name == 'a'
+
+
+class TestFindScopedNameInSource:
+    def test_simple(self):
+        source = dedent('''
+            a = 1
+            b = a
+            a = b
+            ''')
+        scoped_name = symfinder.ScopedName('a', '__main__')
+        snippet, node, name = symfinder.find_scopedname_in_source(scoped_name, source)
+        assert snippet == 'a = b'
+        assert isinstance(node, ast.Assign)
+        assert name.scope == '__main__'
+        pos = ast_utils.get_position(source, node)
+        assert name.pos == (pos.lineno, pos.col_offset)
+
+    def test_above_line(self):
+        source = dedent('''
+            a = 1
+            b = a
+            a = b
+            ''')
+        scoped_name = symfinder.ScopedName('a', '__main__', (4, 0))
+        snippet, node, name = symfinder.find_scopedname_in_source(scoped_name, source)
+        assert snippet == 'a = 1'
+        assert isinstance(node, ast.Assign)
+        assert name.scope == '__main__'
+        pos = ast_utils.get_position(source, node)
+        assert name.pos == (pos.lineno, pos.col_offset)
+
+
+class TestFindScopedNameInIpython:
+    def test_simple(self, monkeypatch):
+        def _ipython_history():
+            return [
+                dedent('''
+                    a = 1
+                    b = a
+                '''),
+                dedent('''
+                    a = b
+                ''')
+            ]
+        monkeypatch.setattr(sourceget, '_ipython_history', _ipython_history)
+        scoped_name = symfinder.ScopedName('a', symfinder.Scope.toplevel('__main__'))
+        snippet, node, name = symfinder.find_scopedname_in_ipython(scoped_name)
+        assert snippet == 'a = b'
+        assert isinstance(node, ast.Assign)
+        assert name.scope == symfinder.Scope.toplevel('__main__')
+        pos = ast_utils.get_position(_ipython_history()[1], node)
+        assert name.pos == (pos.lineno, pos.col_offset)
+        assert name.cell_no == 1
+
+    def test_second_cell(self, monkeypatch):
+        def _ipython_history():
+            return [
+                dedent('''
+                    a = 1
+                    b = a
+                '''),
+                dedent('''
+                    a = b
+                ''')
+            ]
+        monkeypatch.setattr(sourceget, '_ipython_history', _ipython_history)
+        scoped_name = symfinder.ScopedName('a', symfinder.Scope.toplevel('__main__'), pos=(2, 0), cell_no=1)
+        snippet, node, name = symfinder.find_scopedname_in_ipython(scoped_name)
+        assert snippet == 'a = 1'
+        assert isinstance(node, ast.Assign)
+        assert name.scope == symfinder.Scope.toplevel('__main__')
+        pos = ast_utils.get_position(_ipython_history()[0], node)
+        assert name.pos == (pos.lineno, pos.col_offset)
+        assert name.cell_no == 0
+
+
+class Test_GetCallSignature:
+    def test_pos_args(self):
+        pos, kw, star_args, star_kwargs = symfinder._get_call_signature('f(1, 2, "3", x)')
+        assert pos == ['1', '2', '"3"', 'x']
+
+    def test_kw_args(self):
+        pos, kw, star_args, star_kwargs = symfinder._get_call_signature('f(x=1, y=2, z="3", a=x)')
+        assert kw == {'x': '1', 'y': '2', 'z': '"3"', 'a': 'x'}
+
+    def test_pos_and_kw_args(self):
+        pos, kw, star_args, star_kwargs = symfinder._get_call_signature('f(1, 2, "3", x, x=1, y=2, z="3", a=x)')
+        assert pos == ['1', '2', '"3"', 'x']
+        assert kw == {'x': '1', 'y': '2', 'z': '"3"', 'a': 'x'}
+
+    def test_star_args(self):
+        pos, kw, star_args, star_kwargs = symfinder._get_call_signature('f(*[1, 2, 3])')
+        assert star_args == '[1, 2, 3]'
+
+    def test_star_kwargs(self):
+        pos, kw, star_args, star_kwargs = symfinder._get_call_signature('f(**{"a": 1})')
+        assert star_kwargs == '{"a": 1}'
diff --git a/tests/unittests/dumptools/test_var2mod.py b/tests/unittests/dumptools/test_var2mod.py
index f4a0780f..ecf89d64 100644
--- a/tests/unittests/dumptools/test_var2mod.py
+++ b/tests/unittests/dumptools/test_var2mod.py
@@ -1,21 +1,126 @@
 import ast
+import builtins
+from collections import defaultdict
+from textwrap import dedent
+
+import pytest
 
 from padl.dumptools import var2mod
 from padl.dumptools.var2mod import ScopedName
 
 
+SOURCE = dedent('''\
+    aaa = 1
+
+    def ooo():
+        o = aaa  # global - should be renamed
+
+    class CCC:
+        o = aaa
+
+        def __init__(self, aaa):  # local - shouldn't be renamed
+            self.aaa = aaa  # local
+
+        def aaa(self):  # local
+            self.aaa = 123  # attribute - shoudn't be renamed
+
+        def bbb(self):
+            return aaa + self.aaa  # global - should be renamed
+
+    def bbb(aaa):  # local
+        aaa = 1
+        def ccc(bbb):
+            return 1 + aaa
+
+    def ccc():
+        def ddd():
+            return aaa  # global
+        return ddd
+
+    def fff(aaa, bbb=aaa):  # default is global
+        return x
+
+    o = [aaa for aaa in bla]  # local
+    ''')
+
+TARGET = dedent('''\
+    xxx = 1
+
+    def ooo():
+        o = xxx  # global - should be renamed
+
+    class CCC:
+        o = xxx
+
+        def __init__(self, aaa):  # local - shouldn't be renamed
+            self.aaa = aaa  # local
+
+        def aaa(self):  # local
+            self.aaa = 123  # attribute - shoudn't be renamed
+
+        def bbb(self):
+            return xxx + self.aaa  # global - should be renamed
+
+    def bbb(aaa):  # local
+        aaa = 1
+        def ccc(bbb):
+            return 1 + aaa
+
+    def ccc():
+        def ddd():
+            return xxx  # global
+        return ddd
+
+    def fff(aaa, bbb=xxx):  # default is global
+        return x
+
+    o = [aaa for aaa in bla]  # local
+    ''')
+
+
+class TestFinder:
+    def test_find_function_def_explicit(self):
+        res = var2mod.Finder(ast.FunctionDef).find(ast.parse(SOURCE))
+        assert all(isinstance(x, ast.FunctionDef) for x in res)
+        assert {x.name for x in res} == {'ooo', '__init__', 'aaa', 'bbb', 'ccc', 'fff', 'ddd'}
+
+    def test_find_all(self):
+        parsed = ast.parse(SOURCE)
+        all_nodes = list(ast.walk(parsed))
+        filtered = defaultdict(set)
+        for x in all_nodes:
+            filtered[type(x)].add(x)
+
+        for type_, instances in filtered.items():
+            res = var2mod.Finder(type_).find(parsed)
+            assert set(res) == instances
+
+
+class Test_JoinAttr:
+    def test_attribute_chain(self):
+        assert var2mod._join_attr(ast.parse('a.b.c.d.e.f.g').body[0].value) == \
+            ['a', 'b', 'c', 'd', 'e', 'f', 'g']
+
+    def test_single_name(self):
+        assert var2mod._join_attr(ast.parse('a').body[0].value) == ['a']
+
+    def test_raises(self):
+        with pytest.raises(TypeError):
+            var2mod._join_attr(ast.parse('f()').body[0].value)
+
+
 class TestFindGlobals:
     def test_find_same_name(self):
         statement = 'a = run(a)'
         tree = ast.parse(statement)
         res = var2mod.find_globals(tree)
-        assert res == {ScopedName('a', None, 1), ScopedName('run', None, 0)}
+        assert res == {ScopedName('a', None), ScopedName('run', None)}
 
     def test_find_in_assignment(self):
         statement = 'a = run'
         tree = ast.parse(statement)
         res = var2mod.find_globals(tree)
-        assert res == {ScopedName('run', None, 0)}
+        assert res == {ScopedName('run', None)}
 
     def test_dots(self):
         statement = (
@@ -25,7 +130,7 @@ def test_dots(self):
             '    return a.b.c(x)\n'
         )
         res = var2mod.find_globals(ast.parse(statement))
-        assert res == {ScopedName('a.b.c', None, 0), ScopedName('y.x', None, 0)}
+        assert res == {ScopedName('a.b.c', None), ScopedName('y.x', None)}
 
     def test_attribute(self):
         statement = (
@@ -36,8 +141,8 @@ def test_attribute(self):
             '    return a.b.c(x)\n'
         )
         res = var2mod.find_globals(ast.parse(statement))
-        assert res == {ScopedName('a.b.c', None, 0), ScopedName('y.x', None, 0),
-                       ScopedName('aa', None, 0), ScopedName('bb', None, 0)}
+        assert res == {ScopedName('a.b.c', None), ScopedName('y.x', None),
+                       ScopedName('aa', None), ScopedName('bb', None)}
 
     def test_complex_statement_1(self):
         statement = (
@@ -46,4 +151,406 @@ def test_complex_statement_1(self):
             '    (255 * (x * 0.5 + 0.5)).numpy().astype(numpy.uint8)\n'
         )
         res = var2mod.find_globals(ast.parse(statement))
-        assert res == {ScopedName('numpy.uint8', None, 0), ScopedName('transform', None, 0)}
+        assert res == {ScopedName('numpy.uint8', None), ScopedName('transform', None)}
+
+    def test_nested_function(self):
+        statement = dedent('''\
+            def f():
+                def g():
+                    x = aaa
+        ''')
+        res = var2mod.find_globals(ast.parse(statement))
+        assert res == {ScopedName('aaa', None)}
+
+    def test_nested_function_nonlocal(self):
+        statement = dedent('''\
+            def f():
+                def g():
+                    x = aaa
+                aaa = 1
+        ''')
+        res = var2mod.find_globals(ast.parse(statement))
+        assert res == set()
+
+
+class TestRename:
+
+    def test_complex_example(self):
+        renamed = var2mod.rename(SOURCE, from_='aaa', to='xxx')
+        assert renamed == TARGET
+
+    def test_assignment_1(self):
+        source = 'aaa = aaa'
+        target = 'xxx = xxx'
+        renamed = var2mod.rename(source, from_='aaa', to='xxx')
+        assert renamed == target
+
+    def test_assignment_2(self):
+        source = 'aaa = bbb'
+        target = 'xxx = bbb'
+        renamed = var2mod.rename(source, from_='aaa', to='xxx')
+        assert renamed == target
+
+    def test_assignment_3(self):
+        source = 'bbb = aaa'
+        target = 'bbb = xxx'
+        renamed = var2mod.rename(source, from_='aaa', to='xxx')
+        assert renamed == target
+
+    def test_function_body(self):
+        source = dedent('''\
+            def f():
+                bbb = aaa
+        ''')
+        target = dedent('''\
+            def f():
+                bbb = xxx
+        ''')
+        renamed = var2mod.rename(source, from_='aaa', to='xxx')
+        assert renamed == target
+
+    def test_function_name(self):
+        source = dedent('''\
+            def aaa():
+                ...
+        ''')
+        target = dedent('''\
+            def xxx():
+                ...
+        ''')
+        renamed = var2mod.rename(source, from_='aaa', to='xxx')
+        assert renamed == target
+
+    def test_function_name_no_locals(self):
+        source = dedent('''\
+            def aaa():
+                ...
+        ''')
+        target = dedent('''\
+            def aaa():
+                ...
+        ''')
+        renamed = var2mod.rename(source, from_='aaa', to='xxx', rename_locals=False)
+        assert renamed == target
+
+    def test_nested_function_body(self):
+        source = dedent('''\
+            def f():
+                def g():
+                    bbb = aaa
+        ''')
+        target = dedent('''\
+            def f():
+                def g():
+                    bbb = xxx
+        ''')
+        renamed = var2mod.rename(source, from_='aaa', to='xxx')
+        assert renamed == target
+
+    def test_comprehension(self):
+        source = 'bbb = [aaa for aaa in bla]'
+        target = 'bbb = [aaa for aaa in bla]'
+        renamed = var2mod.rename(source, from_='aaa', to='xxx')
+        assert renamed == target
+
+    def test_class_name(self):
+        source = dedent('''\
+            class aaa():
+                ...
+        ''')
+        target = dedent('''\
+            class xxx():
+                ...
+        ''')
+        renamed = var2mod.rename(source, from_='aaa', to='xxx')
+        assert renamed == target
+
+    def test_class_name_no_locals(self):
+        source = dedent('''\
+            class aaa():
+                ...
+        ''')
+        target = dedent('''\
+            class aaa():
+                ...
+        ''')
+        renamed = var2mod.rename(source, from_='aaa', to='xxx', rename_locals=False)
+        assert renamed == target
+
+    def test_class_body(self):
+        source = dedent('''\
+            class bbb:
+                ooo = aaa
+        ''')
+        target = dedent('''\
+            class bbb:
+                ooo = xxx
+        ''')
+        renamed = var2mod.rename(source, from_='aaa', to='xxx')
+        assert renamed == target
+
+
+class TestAddScopeAndPos:
+    def test_only_change_empty_scope(self):
+        scope1 = var2mod.Scope.toplevel(var2mod)
+        scope2 = var2mod.Scope.toplevel(ast)
+        vars = [var2mod.ScopedName('x', scope=scope1)]
+        name = var2mod.ScopedName('y', scope=scope2)
+        var2mod.add_scope_and_pos(vars, name, ast.Name())
+        assert [var.scope == scope1 for var in vars]
+
+    def test_add_scope(self):
+        scope2 = var2mod.Scope.toplevel(ast)
+        vars = [var2mod.ScopedName('x')]
+        name = var2mod.ScopedName('y', scope=scope2)
+        var2mod.add_scope_and_pos(vars, name, ast.Name())
+        assert [var.scope == scope2 for var in vars]
+
+    def test_add_pos(self):
+        vars = [var2mod.ScopedName('x')]
+        name = var2mod.ScopedName('y', pos=(1, 1))
+        var2mod.add_scope_and_pos(vars, name, ast.Name())
+        assert [var.pos == (1, 1) for var in vars]
+
+
+class Test_MethodFinder:
+    def test_simple(self):
+        source = dedent('''\
+            class X:
+                def a(self):
+                    ...
+
+                def b(self):
+                    ...
+
+                def c(self):
+                    ...
+        ''')
+        res = var2mod._MethodFinder().find(ast.parse(source).body[0])
+        assert set(res.keys()) == {'a', 'b', 'c'}
+
+    def test_classmethod(self):
+        source = dedent('''\
+            class X:
+                @classmethod
+                def a(self):
+                    ...
+
+                @classmethod
+                def b(cls):
+                    ...
+
+                @classmethod
+                def c(cls):
+                    ...
+        ''')
+        res = var2mod._MethodFinder().find(ast.parse(source).body[0])
+        assert set(res.keys()) == {'a', 'b', 'c'}
+
+    def test_staticmethod(self):
+        source = dedent('''\
+            class X:
+                @staticmethod
+                def a(self):
+                    ...
+
+                @staticmethod
+                def b(cls):
+                    ...
+
+                @staticmethod
+                def c(cls):
+                    ...
+        ''')
+        res = var2mod._MethodFinder().find(ast.parse(source).body[0])
+        assert set(res.keys()) == {'a', 'b', 'c'}
+
+    def test_mixed(self):
+        source = dedent('''\
+            class X:
+                def a(self):
+                    ...
+
+                @staticmethod
+                def b(cls):
+                    ...
+
+                @classmethod
+                def c(cls):
+                    ...
+        ''')
+        res = var2mod._MethodFinder().find(ast.parse(source).body[0])
+        assert set(res.keys()) == {'a', 'b', 'c'}
+
+    def test_nested_function(self):
+        source = dedent('''\
+            class X:
+                def a(self):
+                    def aa():
+                        ...
+
+                @staticmethod
+                def b(cls):
+                    def bb():
+                        ...
+
+                @classmethod
+                def c(cls):
+                    def cc():
+                        ...
+        ''')
+        res = var2mod._MethodFinder().find(ast.parse(source).body[0])
+        assert set(res.keys()) == {'a', 'b', 'c'}
+
+    def test_nested_class(self):
+        source = dedent('''\
+            class X:
+                def a(self):
+                    ...
+
+                @staticmethod
+                def b(cls):
+                    ...
+
+                @classmethod
+                def c(cls):
+                    ...
+
+                class X:
+                    def oh(self):
+                        ...
+        ''')
+        res = var2mod._MethodFinder().find(ast.parse(source).body[0])
+        assert set(res.keys()) == {'a', 'b', 'c'}
+
+
+class Test_FilterBuiltins:
+    def test_works(self):
+        names = [var2mod.ScopedName(x) for x in builtins.__dict__.keys()]
+        names.insert(0, var2mod.ScopedName('bla'))
+        names.insert(3, var2mod.ScopedName('ble'))
+        names.insert(12, var2mod.ScopedName('bli'))
+        names.insert(22, var2mod.ScopedName('blo'))
+        names.append(var2mod.ScopedName('blu'))
+        assert var2mod._filter_builtins(names) == {var2mod.ScopedName(x)
+                                                   for x in {'bla', 'ble', 'bli', 'blo', 'blu'}}
+
+
+class Test_FindGlobalsInClassdef:
+    def test_finds_in_function_def(self):
+        source = dedent('''\
+            class A:
+                def a(self):
+                    o = bla
+
+                def b(self):
+                    o = ble
+
+                def c(self, bli):
+                    o = bli
+        ''')
+        res = var2mod._find_globals_in_classdef(ast.parse(source).body[0])
+        assert {x.name for x in res} == {'bla', 'ble'}
+
+    def test_finds_in_body(self):
+        source = dedent('''\
+            class A:
+                x = bla
+                y = [f for f in ble]
+                blu = bla
+        ''')
+        res = var2mod._find_globals_in_classdef(ast.parse(source).body[0])
+        assert {x.name for x in res} == {'bla', 'ble'}
+
+    def test_keeps_builtins(self):
+        source = dedent('''\
+            class A:
+                def a(self):
+                    o = int
+
+                def b(self):
+                    o = ble
+
+                def c(self, bli):
+                    o = bli
+        ''')
+        res = var2mod._find_globals_in_classdef(ast.parse(source).body[0], filter_builtins=False)
+        assert {x.name for x in res} == {'int', 'ble'}
+
+    def test_filters_builtins(self):
+        source = dedent('''\
+            class A:
+                def a(self):
+                    o = int
+
+                def b(self):
+                    o = ble
+
+                def c(self, bli):
+                    o = bli
+        ''')
+        res = var2mod._find_globals_in_classdef(ast.parse(source).body[0])
+        assert {x.name for x in res} == {'ble'}
+
+    def test_nested_class(self):
+        source = dedent('''\
+            class A:
+                class B:
+                    def a(self):
+                        o = bla
+
+                    def b(self):
+                        o = ble
+
+                    def c(self, bli):
+                        o = bli
+        ''')
+        res = var2mod._find_globals_in_classdef(ast.parse(source).body[0], filter_builtins=False)
+        assert {x.name for x in res} == {'bla', 'ble'}
+
+
+class TestFindCodenode:
+    def test_find_toplevel(self):
+        scope = var2mod.Scope.toplevel(var2mod)
+        node = var2mod.ScopedName('find_codenode', scope=scope)
+        res = var2mod.find_codenode(node)
+        assert res.source.startswith('def find_codenode')
+        assert isinstance(res.ast_node, ast.FunctionDef)
+        assert res.ast_node.name == 'find_codenode'
+        assert res.name.name == 'find_codenode'
+        assert res.name.scope == scope
+        assert res.name.pos is not None
+
+    def test_find_from_import(self):
+        from tests.material import find_codenode_from_import
+        scope = var2mod.Scope.toplevel(find_codenode_from_import)
+        node = var2mod.ScopedName('find_codenode', scope=scope)
+        res = var2mod.find_codenode(node)
+        assert res.source == 'from padl.dumptools.var2mod import find_codenode'
+        assert isinstance(res.ast_node, ast.ImportFrom)
+        assert res.name.name == 'find_codenode'
+        assert res.name.scope == var2mod.Scope.empty()
+        assert res.name.pos is not None
+
+    def test_find_from_import_fulldump(self):
+        from tests.material import find_codenode_from_import
+        scope = var2mod.Scope.toplevel(find_codenode_from_import)
+        node = var2mod.ScopedName('find_codenode', scope=scope)
+        res = var2mod.find_codenode(node, full_dump_module_names='padl.dumptools.var2mod')
+        assert res.source.startswith('def find_codenode')
+        assert isinstance(res.ast_node, ast.FunctionDef)
+        assert res.ast_node.name == 'find_codenode'
+        assert res.name.name == 'find_codenode'
+        assert res.name.scope == var2mod.Scope.toplevel(var2mod)
+        assert res.name.pos is not None
+
+    def test_find_module_import(self):
+        from tests.material import find_codenode_module_import
+        scope = var2mod.Scope.toplevel(find_codenode_module_import)
+        node = var2mod.ScopedName('var2mod.find_codenode', scope=scope)
+        res = var2mod.find_codenode(node)
+        assert res.source == 'from padl.dumptools import var2mod'
+        assert isinstance(res.ast_node, ast.ImportFrom)
+        assert res.name.name == 'var2mod'
+        assert res.name.scope == var2mod.Scope.empty()
+        assert res.name.pos is not None
diff --git a/tests/unittests/test_dump.py b/tests/unittests/test_dump.py
index 4fc1651f..a63fc7c6 100644
--- a/tests/unittests/test_dump.py
+++ b/tests/unittests/test_dump.py
@@ -73,12 +73,38 @@ def z(x):
     return z
 
 
+def maketransform2():
+    CONST = 20
+    @transform
+    def z(x):
+        return x*CONST
+    return z
+
+
 @transform
 def hangin_indent(a=1,
                   b=2):
     return a, b
 
 
+@transform
+def mutated_transform(arg):
+    return arg * 2
+
+
+mutated_transform = mutated_transform.pd_to('cpu')
+
+
+@transform
+def mutated_recursive_transform(arg):
+    if arg == 0:
+        return arg
+    return 1 + mutated_recursive_transform(arg - 1)
+
+
+mutated_recursive_transform = mutated_recursive_transform.pd_to('cpu')
+
+
 @transform
 class MyClassTransform:
     def __init__(self, a, b, c):
@@ -176,6 +202,11 @@ def test_nested_dump_d():
     assert t._pd_dumps() == read_dump('nested_d')
 
 
+def test_nested_dump_e():
+    t = maketransform2()
+    assert t._pd_dumps() == read_dump('nested_e')
+
+
 c_a = x >> y >> x
 c_b = x >> y >> x / x + c_a
 c_c = (x
@@ -228,6 +259,14 @@ def test_recursive():
     assert recursive._pd_dumps() == read_dump('recursive')
 
 
+def test_mutated_transform():
+    assert mutated_transform._pd_dumps() == read_dump('mutated_transform')
+
+
+def test_mutated_recursive_transform():
+    assert mutated_recursive_transform._pd_dumps() == read_dump('mutated_recursive_transform')
+
+
 def test_with_raises():
     with open(__file__) as f:
         x = f.read()
@@ -271,6 +310,19 @@ def test_dump_hanging_indent():
     assert dump == read_dump('hanging_indent')
 
 
+def test_varname_same_as_arg():
+    @transform
+    class C:
+        def __init__(self, x):
+            ...
+
+        def __call__(self, x):
+            return x
+    b = 1
+    b = C(b)
+    assert b._pd_dumps() == read_dump('varname_same_as_arg')
+
+
 class TestOtherModule:
     def test_import_function(self):
         importdump(tim)
diff --git a/tests/unittests/test_thingfinder.py b/tests/unittests/test_thingfinder.py
index 4e5cfd82..4eb4045f 100644
--- a/tests/unittests/test_thingfinder.py
+++ b/tests/unittests/test_thingfinder.py
@@ -7,52 +7,52 @@
 def test__get_call_signature_a():
     source = 'a(x, y, z)'
     signature = symfinder._get_call_signature(source)
-    assert signature == (['x', 'y', 'z'], {})
+    assert signature == (['x', 'y', 'z'], {}, None, None)
 
 
 def test__get_call_signature_b():
     source = 'a(x, y, z=1)'
     signature = symfinder._get_call_signature(source)
-    assert signature == (['x', 'y'], {'z': '1'})
+    assert signature == (['x', 'y'], {'z': '1'}, None, None)
 
 
 def test__get_call_assignments_a():
     source = 'def f(x, y, z=1):pass'
     args = ast.parse(source).body[0].args
-    values, keywords = symfinder._get_call_signature('f(1, 2, 3)')
-    assignments = symfinder._get_call_assignments(args, source, values, keywords)
+    values, keywords, _, _ = symfinder._get_call_signature('f(1, 2, 3)')
+    assignments = symfinder._parse_def_args(args, source).get_call_assignments(values, keywords)
     assert assignments == {'x': '1', 'y': '2', 'z': '3'}
 
 
 def test__get_call_assignments_b():
     source = 'def f(x, y, z=1):pass'
     args = ast.parse(source).body[0].args
-    values, keywords = symfinder._get_call_signature('f(1, 2)')
-    assignments = symfinder._get_call_assignments(args, source, values, keywords)
+    values, keywords, _, _ = symfinder._get_call_signature('f(1, 2)')
+    assignments = symfinder._parse_def_args(args, source).get_call_assignments(values, keywords)
     assert assignments == {'x': '1', 'y': '2', 'z': '1'}
 
 
 def test__get_call_assignments_c():
     source = 'def f(*args, z=1):pass'
     args = ast.parse(source).body[0].args
-    values, keywords = symfinder._get_call_signature('f(1, 2, 3, z=4)')
-    assignments = symfinder._get_call_assignments(args, source, values, keywords)
+    values, keywords, _, _ = symfinder._get_call_signature('f(1, 2, 3, z=4)')
+    assignments = symfinder._parse_def_args(args, source).get_call_assignments(values, keywords)
     assert assignments == {'args': str([1, 2, 3]), 'z': '4'}
 
 
 def test__get_call_assignments_d():
     source = 'def f(a, b, *args, z=1):pass'
     args = ast.parse(source).body[0].args
-    values, keywords = symfinder._get_call_signature('f(1, 2, 3, z=4)')
-    assignments = symfinder._get_call_assignments(args, source, values, keywords)
+    values, keywords, _, _ = symfinder._get_call_signature('f(1, 2, 3, z=4)')
+    assignments = symfinder._parse_def_args(args, source).get_call_assignments(values, keywords)
     assert assignments == {'a': '1', 'b': '2', 'args': '[3]', 'z': '4'}
 
 
 def test__get_call_assignments_e():
     source = 'def f(a, b, *args, z=1, **kwargs):pass'
     args = ast.parse(source).body[0].args
-    values, keywords = symfinder._get_call_signature('f(1, 2, 3, z=4, u=7, f=8)')
-    assignments = symfinder._get_call_assignments(args, source, values, keywords)
+    values, keywords, _, _ = symfinder._get_call_signature('f(1, 2, 3, z=4, u=7, f=8)')
+    assignments = symfinder._parse_def_args(args, source).get_call_assignments(values, keywords)
     assert assignments == {'a': '1',
                            'b': '2',
                            'args': '[3]',
@@ -66,8 +66,8 @@ def test__get_call_assignments_f():
         return
     source = 'def f(a, b, /, c):pass'
     args = ast.parse(source).body[0].args
-    values, keywords = symfinder._get_call_signature('f(1, 2, 3)')
-    assignments = symfinder._get_call_assignments(args, source, values, keywords)
+    values, keywords, _, _ = symfinder._get_call_signature('f(1, 2, 3)')
+    assignments = symfinder._parse_def_args(args, source).get_call_assignments(values, keywords)
     assert assignments == {'a': '1',
                            'b': '2',
                            'c': '3'}
diff --git a/tests/unittests/test_transforms.py b/tests/unittests/test_transforms.py
index 6d991daf..7ccaea6a 100644
--- a/tests/unittests/test_transforms.py
+++ b/tests/unittests/test_transforms.py
@@ -2,10 +2,12 @@
 from collections import OrderedDict
 import pytest
 import torch
+
+import padl
 from padl import transforms as pd, transform, Identity, batch, unbatch, group
 from padl.transforms import Batchify, Unbatchify, TorchModuleTransform, RequirementNotFound
 from padl.dumptools.serialize import value
-import padl
+from padl.dumptools import var2mod
 from collections import namedtuple
 from padl.transforms import load
 
@@ -802,6 +804,90 @@ def test_save_and_load(self, tmp_path, ignore_padl_requirement):
         t3 = pd.load(tmp_path / 'test.padl')
         assert t3.infer_apply(5) == 10
 
+    def test_startnode_fulldump(self):
+        graph = var2mod.CodeGraph()
+        todo = plus_global._pd_codegraph_add_startnodes(graph, 'testing')
+        assert len(graph) == 1
+        k, v = list(graph.items())[0]
+        assert k.name == 'testing'
+        assert k.scope == 'Scope[tests.unittests.test_transforms]'
+        assert k.pos is None
+        assert v.source == 'testing = plus_global'
+        assert len(todo) == 1
+        next = list(todo)[0]
+        assert next.name == 'plus_global'
+        assert next.scope == 'Scope[tests.unittests.test_transforms]'
+        assert next.pos is None
+
+    def test_startnode_importdump(self):
+        from tests.material import transforms_in_module as tim
+        graph = var2mod.CodeGraph()
+        todo = tim.y._pd_codegraph_add_startnodes(graph, 'testing')
+
+        assert len(graph) == 1
+        k, v = list(graph.items())[0]
+        assert k.name == 'testing'
+        assert k.scope == 'Scope[tests.material.transforms_in_module]'
+        assert k.pos is None
+        assert v.source == 'from tests.material.transforms_in_module import y as testing'
+
+        assert len(todo) == 0
+
+    def test_startnode_importdump_no_name(self):
+        from tests.material import transforms_in_module as tim
+        graph = var2mod.CodeGraph()
+        todo = tim.y._pd_codegraph_add_startnodes(graph, None)
+
+        assert len(graph) == 1
+        k, v = list(graph.items())[0]
+        assert k.name == 'y'
+        assert k.scope == 'Scope[tests.material.transforms_in_module]'
+        assert k.pos is None
+        assert v.source == 'from tests.material.transforms_in_module import y'
+
+        assert len(todo) == 0
+
+    def test_startnode_importdump_inline(self):
+        from tests.material import transforms_in_module as tim
+        graph = var2mod.CodeGraph()
+        todo = transform(tim.tk)._pd_codegraph_add_startnodes(graph, 'testing')
+
+        assert len(graph) == 3
+        items = list(graph.items())
+
+        k, v = [x for x in items if x[0].name == 'testing'][0]
+        assert k.scope == 'Scope[tests.material.transforms_in_module]'
+        assert k.pos is None
+        assert v.source == 'testing = transform(k)'
+
+        k, v = [x for x in items if x[0].name == 'transform'][0]
+        assert k.scope == 'Scope[tests.material.transforms_in_module]'
+        assert k.pos is None
+        assert v.source == 'from padl import transform'
+
+        k, v = [x for x in items if x[0].name == 'k'][0]
+        assert k.scope == 'Scope[tests.material.transforms_in_module]'
+        assert k.pos is None
+        assert v.source == 'from tests.material.transforms_in_module import k'
+
+        assert len(todo) == 0
+
+    def test_here_is_fulldump(self):
+        assert plus_global._pd_full_dump
+
+    def test_othermodule_is_importdump(self):
+        from tests.material import transforms_in_module as tim
+        assert not tim.y._pd_full_dump
+
+    def test_lambda_is_fulldump(self):
+        from tests.material import transforms_in_module as tim
+        assert tim.lambdatrans._pd_full_dump
+
+    def test_from_functionscope_is_fulldump(self):
+        from tests.material import transforms_in_module as tim
+        f = tim.makefunction()
+        assert f._pd_full_dump
+
 
 def test_name():
     assert (plus_one - 'p1')._pd_name == 'p1'
@@ -1088,7 +1174,7 @@ def test_identity_compose_saves(tmp_path, ignore_padl_requirement):
 
 class TestParam:
     def test_param_works(self, tmp_path, ignore_padl_requirement):
-        x = padl.param(1, 'x')
+        x = padl.param('x', 1)
         t = SimpleClassTransform(x)
         assert t(1) == 2
         t.pd_save(tmp_path / 'test.padl')
@@ -1098,8 +1184,8 @@ def test_param_works(self, tmp_path, ignore_padl_requirement):
         assert t_2(1) == 3
 
     def test_no_default(self, tmp_path, ignore_padl_requirement):
-        x = padl.param(1, 'x', use_default=False)
-        t = SimpleClassTransform(x)
+        xurgh = padl.param('x', 1, use_default=False)
+        t = SimpleClassTransform(xurgh)
         assert t(1) == 2
         t.pd_save(tmp_path / 'test.padl')
         with pytest.raises(ValueError):
@@ -1108,7 +1194,7 @@ def test_no_default(self, tmp_path, ignore_padl_requirement):
         assert t_2(1) == 3
 
     def test_wrong_param(self, tmp_path, ignore_padl_requirement):
-        x = padl.param(1, 'x')
+        x = padl.param('x', 1)
         t = SimpleClassTransform(x)
         assert t(1) == 2
         t.pd_save(tmp_path / 'test.padl')
@@ -1139,7 +1225,8 @@ def pre_save(self, *args, **kwargs):  # this will crash saving
     with pytest.raises(ZeroDivisionError):
         pd.save(X(), tmp_path, force_overwrite=True)  # doesn't work
 
-    assert load(str(tmp_path) + '.padl')._pd_call == 'f'  # location still contains f
+    call = load(str(tmp_path) + '.padl')._pd_call
+    assert call == 'f'  # location still contains f
 
 
 def test_successful_save_overwrites(tmp_path, ignore_padl_requirement):
@@ -1155,7 +1242,8 @@ class X:
 
     pd.save(X(), tmp_path, force_overwrite=True)  # works
 
-    assert load(str(tmp_path) + '.padl')._pd_call == 'X()'  # location contains X
+    call = load(str(tmp_path) + '.padl')._pd_call
+    assert call == 'X()'  # location contains X
 
 
 def test_missing_package(tmp_path):