Skip to content

Legacy

validate_into_flag

validate_into_flag(parser, args, nonconsec_dest='into')

Awkward handling procedure to raise an error if two consecutive flags are both -i/--into, i.e. if the pop-then-append operations will overwrite the previous one's.

Source code in src/mvdef/legacy/cli.py
def validate_into_flag(parser, args, nonconsec_dest="into"):
    """
    Awkward handling procedure to raise an error if two consecutive flags are both
    `-i`/`--into`, i.e. if the pop-then-append operations will overwrite the previous
    one's.
    """
    opt_actions = parser._option_string_actions
    opts = {
        v.dest: [
            {o: [i for i, a in enumerate(args) if a == o]} for o in v.option_strings
        ]
        for v in opt_actions.values()
        if any(o in args for o in v.option_strings)
    }
    opt_i = {o: [] for o in opts}
    for k, v in opts.items():
        idx = [list(chain.from_iterable(i for d in v for i in d.values() if i))]
        opt_i.get(k).extend(*idx)

    flag_pos_i_sorted = sorted([*chain.from_iterable(v for v in opt_i.values())])
    dest_i = [flag_pos_i_sorted.index(a) for a in opt_i.get(nonconsec_dest)]
    invalid = [(dest_i[i] - dest_i[i - 1]) == 1 for i, _ in enumerate(dest_i) if i > 0]
    is_invalid = any(invalid)
    invalid_arg_i = [
        (dest_i[i - 1], dest_i[i])
        for i, _ in enumerate(dest_i)
        if i > 0
        if invalid[i - 1]
    ]
    invalid_arg_pos = [
        (flag_pos_i_sorted[a], flag_pos_i_sorted[b]) for (a, b) in invalid_arg_i
    ]
    invalid_arg_values = [tuple(args[a : b + 2]) for (a, b) in invalid_arg_pos]
    if is_invalid:
        raise argparse.ArgumentError(
            None,
            f"Consecutive '{nonconsec_dest}' flags {invalid_arg_values} are invalid."
            " Please pass -m/--mv then up to one -i/--into flag (never multiple).",
        )

agenda_util

pprint_agenda_desc

pprint_agenda_desc(category, entry_key, entry_dict, extra_message='')

Pretty print an edit agenda entry according to the agenda category. The 7 categories are: move, keep, copy, lose, take, echo, stay.

Source code in src/mvdef/legacy/agenda_util.py
def pprint_agenda_desc(category, entry_key, entry_dict, extra_message=""):
    """
    Pretty print an edit agenda entry according to the agenda category.
    The 7 categories are: move, keep, copy, lose, take, echo, stay.
    """
    entry_desc = describe_def_name_dict(entry_key, entry_dict)
    if category == "move":
        m = colour("green", f" ⇢ MOVE  ⇢ {entry_desc}" + extra_message)
    elif category == "keep":
        m = colour("dark_gray", f"⇠  KEEP ⇠  {entry_desc}" + extra_message)
    elif category == "copy":
        m = colour("light_blue", f"⇠⇢ COPY ⇠⇢ {entry_desc}" + extra_message)
    elif category == "lose":
        m = colour("red", f" ✘ LOSE ✘  {entry_desc}" + extra_message)
    elif category == "take":
        m = colour("green", f" ⇢ TAKE  ⇢ {entry_desc}" + extra_message)
    elif category == "echo":
        m = colour("light_blue", f"⇠⇢ ECHO ⇠⇢ {entry_desc}" + extra_message)
    elif category == "stay":
        m = colour("dark_gray", f"⇠  STAY ⇠  {entry_desc}" + extra_message)
    else:
        raise ValueError(f"Unknown agenda category: {category}")
    print(m)
    return

describe_def_name_dict

describe_def_name_dict(name, name_dict)

Wrapper function that returns a string presenting the content of a dict entry with import statement indexes, line number, and import source path. These fields are instantiated within get_def_names, which in turn is assigned to the variable mvdef_names within parse_mv_funcs.

The output of parse_mv_funcs gets passed to process_ast, which iterates over the subsets within the output of parse_mv_funcs, at which stage it's necessary to produce a nice readable output, calling describe_mvdef_name_dict.

Source code in src/mvdef/legacy/agenda_util.py
def describe_def_name_dict(name, name_dict):
    """
    Wrapper function that returns a string presenting the content of a dict entry
    with import statement indexes, line number, and import source path. These
    fields are instantiated within `get_def_names`, which in turn is assigned to
    the variable `mvdef_names` within `parse_mv_funcs`.

    The output of `parse_mv_funcs` gets passed to `process_ast`, which iterates over
    the subsets within the output of `parse_mv_funcs`, at which stage it's
    necessary to produce a nice readable output, calling `describe_mvdef_name_dict`.
    """
    # Extract: import index; intra-import index; line number; import source
    n, n_i, ln, imp_src = (name_dict.get(x) for x in ["n", "n_i", "line", "import"])
    desc = f"(import {n}:{n_i} on line {ln}) {name} ⇒ <{imp_src}>"
    return desc

ast_tokens

get_imports

get_imports(tr, index_list=None, trunk_only=True)

Using the asttokens-tokenised AST tree body ("trunk"), get the top-level import statements. Alternatively, get imports at any level by walking the full tree rather than just the trunk.

Source code in src/mvdef/legacy/ast_tokens.py
def get_imports(tr, index_list=None, trunk_only=True):
    """
    Using the `asttokens`-tokenised AST tree body ("trunk"), get the
    top-level import statements. Alternatively, get imports at any
    level by walking the full tree rather than just the trunk.
    """
    if trunk_only:
        imports = [t for t in tr if type(t) in (IType, IFType)]
    else:
        imports = [t for t in walk(tr) if type(t) in (IType, IFType)]
    if index_list is not None:
        for n, n_i in index_list:
            return [imports[i] for i in index_list]
    return imports

get_defs_and_classes

get_defs_and_classes(tr, trunk_only=True)

List the funcdefs and classdefs of the AST top-level trunk (tr), walking it if trunk_only is False (default: True), else just list top-level trunk nodes.

Source code in src/mvdef/legacy/ast_tokens.py
def get_defs_and_classes(tr, trunk_only=True):
    """
    List the funcdefs and classdefs of the AST top-level trunk (`tr`), walking it if
    `trunk_only` is `False` (default: `True`), else just list top-level `trunk` nodes.
    """
    defs = [t for t in (tr if trunk_only else walk(tr)) if type(t) is FunctionDef]
    classes = [t for t in (tr if trunk_only else walk(tr)) if type(t) is ClassDef]
    return defs, classes

get_to_node

get_to_node(to, into_path, dst_funcs, dst_classes)

Annotate the parsed into_path with a .node attribute, which will be used later when determining the line to insert the newly moved funcdef at in the DstFile.

Source code in src/mvdef/legacy/ast_tokens.py
def get_to_node(to, into_path, dst_funcs, dst_classes):
    """
    Annotate the parsed `into_path` with a `.node` attribute, which will be used later
    when determining the line to insert the newly moved funcdef at in the `DstFile`.
    """
    if to:
        # Cannot use `check_against_linkedfile` without type of leaf node
        # (since it's only a defined method for typed DefPath classes)
        into_path_root, into_path_leaf = into_path.parts[0], into_path.parts[-1]
        if len(into_path.parts) > 1:
            # More than 1 part therefore can detect leaf node type from sep
            i_root_type = into_path_root.part_type
            i_leaf_type = into_path_leaf.part_type
            if i_leaf_type in DefPathTypeEnum._member_names_:
                i_leaf_defpath_type = DefPathTypeEnum[i_leaf_type].value
            elif i_leaf_type in IntraDefPathTypeEnum._member_names_:
                i_leaf_defpath_type = IntraDefPathTypeEnum[i_leaf_type].value
            else:
                part_types = [p.part_type for p in into_path.parts]
                raise NotImplementedError(f"{to=} has {part_types=}. Leaf unsupported")
            if i_root_type in DefPathTypeEnum._member_names_:
                base_type_name = i_root_type  # either "Func" or "Class"
            else:
                base_type_name = get_base_type_name(i_root_type)
            into_path = i_leaf_defpath_type(to)
            target_defs = dst_classes if base_type_name == "Class" else dst_funcs
            if not target_defs:
                msg = "Trying to check against an empty list will fail"
                helper = f"are you sure this path {to} is correct?"
                raise ValueError(f"{msg} ({helper})")
            into_path.node = into_path.check_against_defs(target_defs)
        else:
            # Cannot detect, must check dst_funcs and dst_classes
            # into_path will be UntypedPathStr so the part will be UntypedPathPart
            # (so access .string rather than the part directly)
            matched_f = [f for f in dst_funcs if into_path_leaf.string == f.name]
            matched_c = [c for c in dst_classes if into_path_leaf.string == c.name]
            matches = [matched_f, matched_c]
            if all(matches):
                raise NameError(f"Ambiguous whether {into_path_leaf=} is a cls/func")
            elif not any(matches):
                raise NameError(f"{into_path_leaf=} is not an extant cls/def name")
            d_root = into_path.parts[0]
            if not isinstance(d_root, str):
                d_root = d_root.string
            initial_def = _find_node(matched_f if matched_f else matched_c, d_root)
            into_path.node = initial_def
    else:
        into_path.node = to  # propagate None
    return into_path  # now annotated with `.node` attribute

set_defs_to_move

set_defs_to_move(src, dst, trunk_only=True)

Using the asttokens-tokenised AST tree body ("trunk"), get the top-level function definition statements. Alternatively, get function definitions at any level by walking the full tree rather than just the trunk.

Source code in src/mvdef/legacy/ast_tokens.py
def set_defs_to_move(src, dst, trunk_only=True):
    """
    Using the `asttokens`-tokenised AST tree body ("trunk"), get the top-level
    function definition statements. Alternatively, get function definitions
    at any level by walking the full tree rather than just the trunk.
    """
    def_list = src.mvdefs
    into_list = src.into_paths
    get_cls = src.classes_only
    if not trunk_only:
        raise NotImplementedError("Won't work (see src_funcs/classes/_find_node below)")
    src_funcs, src_classes = get_defs_and_classes(src.trunk, trunk_only=trunk_only)
    dst_funcs, dst_classes = get_defs_and_classes(dst.trunk, trunk_only=trunk_only)
    # Note that you may want to set `into_path.node` even if the mvdef is top-level!
    target_defs = []
    for d, to in zip(def_list, into_list):
        path_parsed = UntypedPathStr(d)  # not final: may actually be a ClassDef!
        if path_parsed.is_unsupported:
            supported = "inner funcdefs and methods of global-scope classes"
            raise NotImplementedError(f"Currently only supporting {supported}")
        elif len(path_parsed.parts) == 1:
            path_parsed = ClassPath(d) if get_cls else FuncPath(d)
        into_path_parsed = UntypedPathStr(to) if to else NullPathStr()
        into_path_parsed = get_to_node(to, into_path_parsed, dst_funcs, dst_classes)
        d_root = path_parsed.parts[0]
        if not isinstance(d_root, str):
            d_root = d_root.string
        # -------------- was a try block
        src_defs = src_classes if has_clsdef_base(d_root, intradef=False) else src_funcs
        initial_def = _find_node(src_defs, d_root)
        sd = reduce(per_path_part_finder, path_parsed.parts[1:], initial_def)
        sd.path = path_parsed
        sd.into_path = into_path_parsed
        target_defs.append(sd)
    # To be consistent with the trivial case below, the defs must remain in
    # the same order they appeared in the AST, i.e. in ascending line order
    target_defs = sorted(target_defs, key=lambda d: d.lineno)
    src.defs_to_move = target_defs

ast_util

PathGetterMixin

Used in all Def classes (FuncDef, ClsDef and all subclasses) to provide a generic way to retrieve the inner funcdef/clsdef so as to retrieve a path to a leaf funcdef/clsdef.

(I don't think this even needs to be mixed in but it's handy to access if it is)

retrieve_def

retrieve_def(path_part)

Attempt to retrieve the next funcdef/clsdef indicated by path_part (an instance one of the PathPart classes iterated over via reduce in check_against_linkedfile from one of the Path classes).

Source code in src/mvdef/legacy/ast_util.py
def retrieve_def(self, path_part):
    """
    Attempt to retrieve the next funcdef/clsdef indicated by `path_part`
    (an instance one of the `PathPart` classes iterated over via `reduce`
    in `check_against_linkedfile` from one of the `Path` classes).
    """
    # can use either IntraDefPathTypeEnum/IntraDefTypeEnum (latter more consistent)
    if path_part.part_type not in IntraDefTypeEnum._member_map_:
        if path_part in DefTypeEnum._member_map_:
            # handle global defs in check_against_linkedfile to get an initial def
            msg = f"{path_part=} is not supposed to be a top-level def type"
        else:
            msg = f"{path_part=} is not a recognised def type"
        raise TypeError(f"{msg} (path_part.part_type=)")
    def_is_a_cls = has_clsdef_base(path_part)
    intras = self.intra_classes if def_is_a_cls else self.intra_funcs
    retrieved_def = _find_node(intras, path_part)
    return retrieved_def

retrieve_def_from_body

retrieve_def_from_body(path_part)

Attempt to retrieve the next funcdef/clsdef indicated by path_part (an instance one of the PathPart classes iterated over via reduce in check_against_linkedfile from one of the Path classes). Use the body rather than intra_classes and intra_funcs

Source code in src/mvdef/legacy/ast_util.py
def retrieve_def_from_body(self, path_part):
    """
    Attempt to retrieve the next funcdef/clsdef indicated by `path_part`
    (an instance one of the `PathPart` classes iterated over via `reduce`
    in `check_against_linkedfile` from one of the `Path` classes). Use the
    body rather than `intra_classes` and `intra_funcs`
    """
    # can use either IntraDefPathTypeEnum/IntraDefTypeEnum (latter more consistent)
    if path_part.part_type not in IntraDefTypeEnum._member_map_:
        if path_part in DefTypeEnum._member_map_:
            # handle global defs in check_against_linkedfile to get an initial def
            msg = f"{path_part=} is not supposed to be a top-level def type"
        else:
            msg = f"{path_part=} is not a recognised def type"
        raise TypeError(f"{msg} (path_part.part_type=)")
    def_is_a_cls = has_clsdef_base(path_part)
    finder = _find_cls if def_is_a_cls else _find_def
    retrieved_def = finder(self, path_part)
    return retrieved_def

FuncPath

FuncPath(path_string, parent_type_name=None)

Bases: FuncDefPathStr, LinkFileCheckerMixin

A FuncDefPathStr for a top-level function definition

Source code in src/mvdef/legacy/ast_util.py
def __init__(self, path_string, parent_type_name=None):
    if parent_type_name:
        self.parent_type_name = parent_type_name
    super().__init__(path_string)

InnerFuncPath

InnerFuncPath(path_string, parent_type_name=None)

Bases: InnerFuncDefPathStr, LinkFileCheckerMixin

An InnerFuncDefPathStr which has a top level funcdef, an 'intradef' inner func (checked on super().init), and potentially one or more inner functions below that, which must be reachable as direct descendants of the AST at each step (i.e. no intervening nodes between descendant inner functions in the path when checking against the LinkedFile AST).

Source code in src/mvdef/legacy/ast_util.py
def __init__(self, path_string, parent_type_name=None):
    if parent_type_name:
        self.parent_type_name = parent_type_name
    super().__init__(path_string)

ClassPath

ClassPath(path_string, parent_type_name=None)

Bases: ClassDefPathStr, LinkFileCheckerMixin

A ClassDefPathStr for a top-level class

Source code in src/mvdef/legacy/ast_util.py
def __init__(self, path_string, parent_type_name=None):
    if parent_type_name:
        self.parent_type_name = parent_type_name
    super().__init__(path_string)

InnerClassPath

InnerClassPath(path_string, parent_type_name=None)

Bases: InnerClassDefPathStr, LinkFileCheckerMixin

A ClassDefPathStr for a class within another class.

Source code in src/mvdef/legacy/ast_util.py
def __init__(self, path_string, parent_type_name=None):
    if parent_type_name:
        self.parent_type_name = parent_type_name
    super().__init__(path_string)

HigherOrderClassPath

HigherOrderClassPath(path_string, parent_type_name=None)

Bases: HigherOrderClassDefPathStr, LinkFileCheckerMixin

A ClassDefPathStr for a class within another class.

Source code in src/mvdef/legacy/ast_util.py
def __init__(self, path_string, parent_type_name=None):
    if parent_type_name:
        self.parent_type_name = parent_type_name
    super().__init__(path_string)

MethodPath

MethodPath(path_string, parent_type_name=None)

Bases: MethodDefPathStr, LinkFileCheckerMixin

A MethodDefPathStr which has a top level class, which contains a method (checked on super().init), and potentially one or more inner functions below that, which must be reachable as direct descendants of the AST at each step (i.e. no intervening nodes between descendant inner functions in the path when checking against the LinkedFile AST). [TODO: confirm against finished implementation if this is the case RE: inner funcs]

Source code in src/mvdef/legacy/ast_util.py
def __init__(self, path_string, parent_type_name=None):
    if parent_type_name:
        self.parent_type_name = parent_type_name
    super().__init__(path_string)

RecursiveIdSetterMixin

Mixin class to provide a common interface method for both ClsDef and FuncDef (and by extension all inheriting subclasses).

Note that methods have been omitted (but are trivial to obtain from the result)

ClsDef

ClsDef(clsdef, classes_only, ast_cls_ids=None, ast_fun_ids=None, is_inner=False)

Bases: ClassDef, RecursiveIdSetterMixin, PathGetterMixin

Wrap ast.ClassDef to permit recursive search for methods and inner classes upon creation in parse_mv_funcs.

Source code in src/mvdef/legacy/ast_util.py
def __init__(
    self,
    clsdef,
    classes_only,
    ast_cls_ids=None,
    ast_fun_ids=None,
    is_inner=False,
):
    super().__init__(**vars(clsdef))
    self.classes_only = classes_only
    self.global_cd_ids = ast_cls_ids
    self.global_fd_ids = ast_fun_ids
    self.is_inner = is_inner
    if not self.is_inner:
        self.check_for_inner_defs()

HOClsDef

HOClsDef(cd, parent_fd)

Bases: ClsDef, NamespaceIdSetterMixin

Wrap ClsDef (in turn wrapping ast.ClassDef) to store a reference to the parent classdef's line range on a higher order class (i.e. a class in a funcdef.

Source code in src/mvdef/legacy/ast_util.py
def __init__(self, cd, parent_fd):
    self.classes_only = parent_fd.classes_only
    self.parent_name = parent_fd.name
    self.parent_path = parent_fd.path
    self.parent_line_range = parent_fd.line_range
    super().__init__(
        cd,
        self.classes_only,
        is_inner=True,
    )  # I moved this to the end to get it to run

InnerClsDef

InnerClsDef(cd, parent_cd)

Bases: ClsDef, NamespaceIdSetterMixin

Wrap ClsDef (in turn wrapping ast.ClassDef) to store a reference to the parent classdef's line range on an inner class.

Source code in src/mvdef/legacy/ast_util.py
def __init__(self, cd, parent_cd):
    self.classes_only = parent_cd.classes_only
    self.parent_name = parent_cd.name
    self.parent_path = parent_cd.path
    self.parent_line_range = parent_cd.line_range
    super().__init__(
        cd,
        self.classes_only,
        is_inner=True,
    )  # I moved this to the end to get it to run

FuncDef

FuncDef(funcdef, classes_only, ast_cls_ids=None, ast_fun_ids=None, is_inner=False)

Bases: FunctionDef, RecursiveIdSetterMixin, PathGetterMixin

Wrap ast.FunctionDef to permit recursive search for inner functions upon creation in parse_mv_funcs.

Source code in src/mvdef/legacy/ast_util.py
def __init__(
    self,
    funcdef,
    classes_only,
    ast_cls_ids=None,
    ast_fun_ids=None,
    is_inner=False,
):
    super().__init__(**vars(funcdef))
    self.classes_only = classes_only
    self.global_cd_ids = ast_cls_ids
    self.global_fd_ids = ast_fun_ids
    self.is_inner = is_inner
    if not self.is_inner:
        self.check_for_inner_defs()

MethodDef

MethodDef(fd, parent_cd)

Bases: FuncDef

Wrap FuncDef (in turn wrapping ast.FunctionDef) to store a reference to the parent classdef's line range on a method.

Source code in src/mvdef/legacy/ast_util.py
def __init__(self, fd, parent_cd):
    self.classes_only = parent_cd.classes_only
    self.parent_name = parent_cd.name
    self.parent_path = parent_cd.path
    self.parent_line_range = parent_cd.line_range
    # moved to end to match ClsDef subclasses
    super().__init__(fd, self.classes_only, is_inner=True)

InnerFuncDef

InnerFuncDef(fd, parent_fd)

Bases: FuncDef

Wrap FuncDef (in turn wrapping ast.FunctionDef) to store a reference to the parent funcdef's line range on an inner function.

Source code in src/mvdef/legacy/ast_util.py
def __init__(self, fd, parent_fd):
    self.classes_only = parent_fd.classes_only
    self.parent_name = parent_fd.name
    self.parent_path = parent_fd.path
    self.parent_line_range = parent_fd.line_range
    super().__init__(
        fd,
        self.classes_only,
        is_inner=True,
    )  # moved to end to match ClsDef subclasses

retrieve_ast_agenda

retrieve_ast_agenda(linkfile, transfers=None)

Build and parse the Abstract Syntax Tree (AST) of a Python file, and either return a report of what changes would be required to move the mvdefs subset of all function definitions out of it, or a report of the imports and funcdefs in general if no linkfile.mvdefs is provided (taken to indicate that the file is the target funcdefs are moving to), or make changes to the file (either newly creating one if no such file exists, or editing in place according to the reported import statement differences).

If the Python file linkfile.path doesn't exist (which can be checked directly as it's a Path object), it's being newly created by the move and obviously no report can be made on it: it has no funcdefs and no import statements, so all the ones being moved will be newly created.

mvdefs should be given if the file is the source of moved functions, and left empty (default: None which --> []) if the file is the destination to move them to.

If linkfile.report is True, returns a string describing the changes to be made (if False, nothing is returned).

If backup is True, files will be changed in place by calling mvdef.backup.backup (obviously, be careful switching this setting off if report is True, as any changes made cannot be restored afterwards from this backup file).

Source code in src/mvdef/legacy/ast_util.py
def retrieve_ast_agenda(linkfile, transfers=None):
    """
    Build and parse the Abstract Syntax Tree (AST) of a Python file, and either return
    a report of what changes would be required to move the mvdefs subset of all
    function definitions out of it, or a report of the imports and funcdefs in general
    if no `linkfile.mvdefs` is provided (taken to indicate that the file is the target funcdefs
    are moving to), or make changes to the file (either newly creating one if no such
    file exists, or editing in place according to the reported import statement
    differences).

    If the Python file `linkfile.path` doesn't exist (which can be checked directly as it's a
    Path object), it's being newly created by the move and obviously no report can
    be made on it: it has no funcdefs and no import statements, so all the ones being
    moved will be newly created.

    mvdefs should be given if the file is the source of moved functions, and left
    empty (default: `None` which --> `[]`) if the file is the destination to move them to.

    If `linkfile.report` is True, returns a string describing the changes
    to be made (if False, nothing is returned).

    If backup is True, files will be changed in place by calling `mvdef.backup.backup`
    (obviously, be careful switching this setting off if report is True, as any
    changes made cannot be restored afterwards from this backup file).
    """
    if linkfile.is_extant:
        with open(linkfile.path) as f:
            fc = f.read()
            trunk = ast.parse(fc).body

        # print("Next running process_ast from retrieve_ast_agenda")
        linkfile.process_ast(trunk, transfers)  # sets linkfile.edits
    elif type(linkfile).__name__ == "DstFile":
        # An `isinstance` call would require a circular import, hence the __name__ check
        #
        # Not extant so file doesn't exist (cannot produce a parsed AST) however the
        # linkfile is the destination (no `mvdefs` to remove), so return None.
        # This will be picked up by the assert in SrcFile.validate_edits
        # (but skipped for DstFile.validate_edits)
        assert linkfile.mvdefs is None, "Unexpected mvdefs list for non-extant DstFile"
        linkfile.edits = None
    else:
        msg = f"Can't move {linkfile.mvdefs=} from {linkfile.path=} – it doesn't exist!"
        raise ValueError(msg)

process_ast

process_ast(linkfile, trunk, transfers=None)

Handle the hand-off to dedicated functions to go from the mvdefs of functions to move, first deriving lists of imported names which belong to the mvdefs and the non-mvdefs functions (using parse_mv_funcs), then constructing an 'edit agenda' (using process_ast) which describes [and optionally reports] the changes to be made at the file level, in terms of move/keep/copy operations on individual import statements between the source and destination Python files.

mvdefs: List of functions to be moved trunk: Tree body of the file's AST, which will be separated into function definitions, import statements, and anything else. transfers: List of transfers already determined to be made from the src to the dst file (from the first call to ast_parse) report: Whether to print a report during the program (default: True)


First, given the lists of mvdef names (linkfile.mvdef_names) and non-mvdef names (linkfile.nonmvdef_names), construct the subsets:

mv_imps: imported names used by the functions to move (only in mvdef_names), nm_imps: imported names used by the functions not to move (only in nonmvdef_names), mu_imps: imported names used by both the functions to move and the functions not to move (in both mvdef_names and nonmvdef_names)

Potentially 'as a dry run' (if this is being called by process_ast and its parameter edit is False), report how to remove the import statements or statement sections which import mv_inames, do nothing to the import statements which import nonmv_inames, and copy the import statements which import mutual_inames (as both src and dst need them).

Additionally, accept 'transfers' from a previously determined edit agenda, so as to "take" the "move" names, and "echo" the "copy" names (i.e. when receiving names marked by "move" and "copy", distinguish them to indicate they are being received by transfer [from src⇒dst file], for clarity).

For clarity, note that this function does not edit anything itself, it just describes how it would be possible to carry out the required edits at the level of Python file changes. As such it is computed even on a 'dry run'.

Source code in src/mvdef/legacy/ast_util.py
def process_ast(linkfile, trunk, transfers=None):
    """
    Handle the hand-off to dedicated functions to go from the mvdefs of functions
    to move, first deriving lists of imported names which belong to the mvdefs and
    the non-mvdefs functions (using `parse_mv_funcs`), then constructing an
    'edit agenda' (using `process_ast`) which describes [and optionally
    reports] the changes to be made at the file level, in terms of move/keep/copy
    operations on individual import statements between the source and destination
    Python files.

      mvdefs:     List of functions to be moved
      trunk:      Tree body of the file's AST, which will be separated into
                  function definitions, import statements, and anything else.
      transfers:  List of transfers already determined to be made from the src
                  to the dst file (from the first call to ast_parse)
      report:     Whether to print a report during the program (default: True)

    -------------------------------------------------------------------------------

    First, given the lists of mvdef names (linkfile.mvdef_names) and non-mvdef names
    (linkfile.nonmvdef_names), construct the subsets:

      mv_imps:  imported names used by the functions to move (only in mvdef_names),
      nm_imps:  imported names used by the functions not to move (only in
                nonmvdef_names),
      mu_imps:  imported names used by both the functions to move and the
                functions not to move (in both mvdef_names and nonmvdef_names)

    Potentially 'as a dry run' (if this is being called by process_ast and its
    parameter edit is False), report how to remove the import statements or statement
    sections which import mv_inames, do nothing to the import statements which import
    nonmv_inames, and copy the import statements which import mutual_inames (as both
    src and dst need them).

    Additionally, accept 'transfers' from a previously determined edit agenda,
    so as to "take" the "move" names, and "echo" the "copy" names (i.e. when
    receiving names marked by "move" and "copy", distinguish them to indicate
    they are being received by transfer [from src⇒dst file], for clarity).

    For clarity, note that this function does **not** edit anything itself, it just
    describes how it would be possible to carry out the required edits at the level
    of Python file changes. As such it is computed even on a 'dry run'.
    """
    linkfile.parse_mv_funcs(trunk)  # sets ast_funcs, {mv,nonmv,extra,un}def_names
    imported_names = get_imported_name_sources(trunk, report=linkfile.report)
    if linkfile.report:
        print(f"• Determining edit agenda for {linkfile.path.name}:", file=stderr)
    linkfile.edits = EditAgenda()
    linkfile.imp_def_subsets()  # sets mv_imports, nonmv_imports, mutual_imports
    # Iterate over each imported name, i, in the subset of import names to move

    linkfile.edits.add_imports(linkfile.mv_imports, "move", linkfile.mvdef_names)
    linkfile.edits.add_imports(linkfile.nonmv_imports, "keep", linkfile.nonmvdef_names)
    linkfile.edits.add_imports(linkfile.mutual_imports, "copy", linkfile.mvdef_names)
    for i in linkfile.undef_names:
        i_dict = linkfile.undef_names.get(i)
        linkfile.edits.add_entry(category="lose", key_val_pair=(i, i_dict))
    if not transfers:
        # Returning without transfers if None (would also catch empty dict `{}`)
        if linkfile.report:
            pprint_agenda(linkfile.edits)
        return
    # elif linkfile.report:
    #    if len(linkfile.edits.get("lose")) > 0:
    #        print("• Resolving edit agenda conflicts:")
    # i is 'ready made' from a previous call to ast_parse, and just needs reporting
    for i in transfers.get("take"):
        k, i_dict = next(iter(i.items()))
        linkfile.edits.add_entry(category="take", key_val_pair=(k, i_dict))
    for i in transfers.get("echo"):
        k, i_dict = next(iter(i.items()))
        linkfile.edits.add_entry(category="echo", key_val_pair=(k, i_dict))
    # Resolve agenda conflicts: if any imports marked 'lose' are cancelled out
    # by any identically named imports marked 'take' or 'echo', change to 'stay'
    for i in linkfile.edits.get("lose"):
        k, i_dict = next(iter(i.items()))
        imp_src = i_dict.get("import")
        if k in [list(x)[0] for x in linkfile.edits.get("take")]:
            t_i_dict = next(x.get(k) for x in linkfile.edits.get("take") if k in x)
            take_imp_src = t_i_dict.get("import")
            if imp_src != take_imp_src:
                continue
            # Deduplicate 'lose'/'take' k: replace both with 'stay'
            linkfile.edits.add_entry(category="stay", key_val_pair=(k, i_dict))
            linkfile.edits.remove_entry(category="lose", entry_value=i_dict)
            linkfile.edits.remove_entry(category="take", entry_value=t_i_dict)
        elif k in [list(x)[0] for x in linkfile.edits.get("echo")]:
            e_i_dict = next(x.get(k) for x in linkfile.edits.get("echo") if k in x)
            echo_imp_src = e_i_dict.get("import")
            if imp_src != echo_imp_src:
                continue
            # Deduplicate 'lose'/'echo' k: replace both with 'stay'
            linkfile.edits.add_entry(category="stay", key_val_pair=(k, i_dict))
            linkfile.edits.remove_entry(category="lose", entry_value=i_dict)
            linkfile.edits.remove_entry(category="echo", entry_value=e_i_dict)
    # Resolve agenda conflicts: if any imports marked 'take' or 'echo' are cancelled
    # out by any identically named imports already present, change to 'stay'
    for i in linkfile.edits.get("take"):
        k, i_dict = next(iter(i.items()))
        take_imp_src = i_dict.get("import")
        if k in imported_names:
            # Check the import source and asnames match
            imp_src = imported_names.get(k)[0]
            if imp_src != take_imp_src:
                # This means that the same name is being used by a different function
                raise ValueError(
                    f"Cannot move imported name '{k}', it is already "
                    + f"in use in {linkfile.path.name} ({take_imp_src} clashes with {imp_src})",
                )
                # (N.B. could rename automatically as future feature)
            # Otherwise there is simply a duplicate import statement, so the demand
            # to 'take' the imported name is already fulfilled.
            # Replace unnecessary 'take' with 'stay'
            linkfile.edits.add_entry(category="stay", key_val_pair=(k, i_dict))
            linkfile.edits.remove_entry(category="take", entry_value=i_dict)
    for i in linkfile.edits.get("echo"):
        k, i_dict = next(iter(i.items()))
        echo_imp_src = i_dict.get("import")
        if k in imported_names:
            # Check the import source and asnames match
            imp_src = imported_names.get(k)[0]
            if imp_src != echo_imp_src:
                # This means that the same name is being used by a different function
                raise ValueError(
                    f"Cannot move imported name '{k}', it is already "
                    + f"in use in {linkfile.path.name} ({echo_imp_src} clashes with {imp_src})",
                )
                # (N.B. could rename automatically as future feature)
            # Otherwise there is simply a duplicate import statement, so the demand
            # to 'echo' the imported name is already fulfilled.
            # Replace unnecessary 'echo' with 'stay'
            linkfile.edits.add_entry(category="stay", key_val_pair=(k, i_dict))
            linkfile.edits.remove_entry(category="echo", entry_value=i_dict)
    if linkfile.report:
        pprint_agenda(linkfile.edits)
    return

find_assigned_args

find_assigned_args(fd)

Produce a list of the names in a function definition fd which are created by assignment operations (as identified via the function definition's AST).

Source code in src/mvdef/legacy/ast_util.py
def find_assigned_args(fd):
    """
    Produce a list of the names in a function definition `fd` which are created
    by assignment operations (as identified via the function definition's AST).
    """
    args_indiv = []  # Arguments assigned individually, e.g. x = 1
    args_multi = []  # Arguments assigned from a tuple, e.g. x, y = (1,2)
    for a in ast.walk(fd):
        if type(a) is ast.Assign:
            # Handle explicit assignments from use of the equals symbol
            assert len(a.targets) == 1, "Expected 1 target per ast.Assign"
            if type(a.targets[0]) is ast.Name:
                args_indiv.append(a.targets[0].id)
            elif type(a.targets[0]) is ast.Tuple:
                args_multi.extend([x.id for x in a.targets[0].elts])
        elif type(a) is ast.For:
            # Handle implicit assignments (ctx = Store) within for loops
            if type(a.target) is ast.Name:
                args_indiv.append(a.target.id)
            elif type(a.target) is ast.Tuple:
                for x in a.target.elts:
                    assert type(x) in [ast.Name, ast.Tuple], f"Unexpected target.elts"
                    if type(x) is ast.Name:
                        args_multi.append(x.id)
                    else:  # type(x) is ast.Tuple
                        for y in x.elts:
                            assert type(y) is ast.Name, f"Unexpected target.elts tuple"
                            args_multi.append(y.id)
            else:
                raise ValueError(f"{a.target} lacks the expected ast.Name statements")
        elif type(a) in (ast.ListComp, ast.SetComp, ast.GeneratorExp, ast.DictComp):
            # Handle implicit assignments within comprehensions
            for g in a.generators:
                assert type(g) is ast.comprehension, f"{a} not ast.comprehension type"
                if type(g.target) is ast.Name:
                    args_indiv.append(g.target.id)
                elif type(g.target) is ast.Tuple:
                    args_multi.extend([x.id for x in g.target.elts])
                else:
                    raise ValueError(f"{g.target} lacks expected ast.Name statements")
        elif type(a) is ast.Lambda:
            if len(a.args.args) > 1:
                args_multi.extend([r.arg for r in a.args.args])
            else:
                assert len(a.args.args) == 1, "A lambda can't assign no names (can it?)"
                args_indiv.append(a.args.args[0].arg)
        elif type(a) is ast.NamedExpr:
            # This is the walrus operator `:=` added in Python 3.8
            assert type(a.target) is ast.Name, f"Expected a name for {a.target}"
            args_indiv.append(a.target.id)
            # I haven't seen a multiple assignment from a walrus operator, asked SO:
            # https://stackoverflow.com/q/59567172/2668831
    assigned_args = args_indiv + args_multi
    return assigned_args

set_extradef_names

set_extradef_names(linkfile, extra_nodes)

Return the names used in the AST trunk nodes which are outside of both function definitions and import statements, so as to distinguish the unused names from those which are just used outside of function definitions.

Source code in src/mvdef/legacy/ast_util.py
def set_extradef_names(linkfile, extra_nodes):
    """
    Return the names used in the AST trunk nodes which are outside of both function
    definitions and import statements, so as to distinguish the unused names from
    those which are just used outside of function definitions.
    """
    linkfile.extradef_names = set()
    for node in extra_nodes:
        node_names = [x.id for x in ast.walk(node) if type(x) is ast.Name]
        for n in node_names:
            linkfile.extradef_names.add(n)
    return

get_base_type_name

get_base_type_name(type_name)

Take a type name from the IntraDefTypeEnum names and return the name of the base type, e.g. input the MethodDef type and get out the name "Func", indicating that a method is a function

Source code in src/mvdef/legacy/ast_util.py
def get_base_type_name(type_name):
    """
    Take a type name from the IntraDefTypeEnum names and return the name of the base
    type, e.g. input the MethodDef type and get out the name "Func", indicating that a
    method is a function
    """
    msg = f"{type_name} is not an inner def type name (i.e. a name in IntraDefTypeEnum)"
    if type_name in IntraDefTypeEnum._member_names_:
        def_bases = DefTypeEnum._value2member_map_
        type_class = IntraDefTypeEnum[type_name].value
        base_type = next(b for b in type_class.__bases__ if b in def_bases)
        base_type_name = DefTypeEnum(base_type).name
    else:
        if type_name in DefTypeEnum._member_names_:
            base_type_name = type_name  # already a base type name ("Class" or "Func")
        else:
            raise ValueError(msg)
    return base_type_name

get_def_names

get_def_names(linkfile, def_list, import_annos)

Given the def_list list of strings (must be empty in the negative case rather than None, and is prepared as such from LinkedFile.mvdefs in parse_mv_funcs), return a dict from its keys whose entries are the names of its descendant AST nodes.

Source code in src/mvdef/legacy/ast_util.py
def get_def_names(linkfile, def_list, import_annos):
    """
    Given the `def_list` list of strings (must be empty in the negative case rather
    than None, and is prepared as such from `LinkedFile.mvdefs` in `parse_mv_funcs`),
    return a dict from its keys whose entries are the names of its descendant AST nodes.
    """
    get_cls = linkfile.classes_only
    imp_name_lines, imp_name_dicts = import_annos
    def_namedict = NameDict(def_list)
    extradef_names = linkfile.extradef_names
    # ast_funcs/ast_classes parameterised as "selected" based on linkfile.classes_only
    sel_nodes, nosel_nodes = linkfile.sel_nodes, linkfile.nosel_nodes
    sel_ids, nosel_ids = linkfile.sel_ids, linkfile.nosel_ids  # nosel_ids not used?
    for m in def_list:
        m_type = "class" if get_cls else "function"
        sd_names = set()
        m_parsed = UntypedPathStr(m)  # will be remade in InnerFuncPath but it's fast
        if len(m_parsed.parts) > 1:
            root_node = m_parsed.parts[0]
            # use pen/ultimate nodes in the path (i.e. leaf and its parent)
            leaf_parent_node, leaf_node = m_parsed.parts[-2:]
            if get_cls:
                valid_leaf_types = ["InnerClass", "HigherOrderClass"]
            else:
                valid_leaf_types = ["Method", "InnerFunc"]
            msg = f"'{leaf_node.part_type}' is not a valid {m_type} part type"
            assert leaf_node.part_type in valid_leaf_types, msg
            leaf_par_type_name = getattr(
                DefTypeToParentTypeEnum,
                leaf_node.part_type,
            ).value
            m_path_type = getattr(IntraDefPathTypeEnum, leaf_node.part_type).value
            m_path = m_path_type(m, parent_type_name=leaf_par_type_name)
            # retrieve {func|cls}def from AST
            sel_def = m_path.check_against_linkedfile(linkfile)
            sel_ids = (
                sel_def.all_ns_cd_ids if get_cls else sel_def.all_ns_fd_ids
                # TODO make this a property on the type (which class though?)
            )  # inner {func|cls}def IDs, includes global def namespace
        elif m in sel_ids:
            sel_def = sel_nodes[sel_ids.index(m)]
        else:
            raise NameError(f"No {m_type} '{m}' is defined")
        # def params (sel_def may be intra)
        sd_params = [] if get_cls else [a.arg for a in sel_def.args.args]
        assigned = find_assigned_args(sel_def)
        for ast_statement in sel_def.body:
            exc = dir(builtins) + sel_ids + sd_params + assigned + [*extradef_names]
            for node in ast.walk(ast_statement):
                if type(node) == ast.Name:
                    n_id = node.id
                    if n_id not in exc:
                        sd_names.add(n_id)
        def_namedict[m] = NameEntryDict(sd_names, sort=True)
        # All names successfully found and can finish if remaining names are
        # in the set of funcdef names, comparing them tothe import statements
        unknowns = [n for n in sd_names if n not in imp_name_lines]
        if unknowns:
            raise ValueError(f"These names could not be sourced: {unknowns}")
        # mv_imp_refs is the subset of imp_name_lines for movable funcdef names
        # These refs will lead to import statements being copied and/or moved
        mv_imp_refs = {n: imp_name_lines.get(n) for n in sd_names}
        update_def_names_from_imports(m, mv_imp_refs, imp_name_dicts, def_namedict)
    return def_namedict

parse_mv_funcs

parse_mv_funcs(linkfile, trunk)

mvdefs: the list of functions to move (string list of function names) trunk: AST body for the file (via ast.parse(fc).body) report: whether to print [minimal, readable] 'reporting' output

Produce a dictionary, mvdef_names, whose keys are the list of functions to move (i.e. the list mvdefs becomes the list of keys of mvdef_names), and the value of which at each key (for a key m which indicates the name of one of the functions given in mvdefs to move) is another dictionary, keyed by the full set of names used in that function (m) which rely upon import statements (i.e. are not builtin names nor passed as parameters to the function, nor assigned in the body of the function), and the value of which at each name is a final nested dictionary whose keys are always: n: The [0-based] index of the name's source import statement in the AST list of all ast.Import and ast.ImportFrom. n_i: The [0-based] index of the name's source import statement within the one or more names imported by the source import statement at index n (e.g. for pi in from numpy import e, pi, n_i = 1). line: The [1-based] line number of the import statement as given in its corresponding AST entry, which indicates the line number of the import call, not [necessarily] that of the name (i.e. the name may not be located there in the file for multi-line imports). import: The path of the import statement, which may contain multiple parts conjoined by . (e.g. matplotlib.pyplot)

I.e. the dictionary with entries accessed as mvdef_names.get(m).get(k) for m in mvdefs and k in the subset of AST-identified imported names in the function with if f.name not in mvdefs name m in the list of function definitions defs. This access is handed off to the helper function get_def_names.

For the names that were imported but not used, the dictionary is not keyed by function (as there are no associated functions), and instead the entries are accessed as nondef_names.get(k) for k in unused_names. This access is handed off to the helper function set_nondef_names.

Source code in src/mvdef/legacy/ast_util.py
def parse_mv_funcs(linkfile, trunk):
    """
    mvdefs:  the list of functions to move (string list of function names)
    trunk:   AST body for the file (via `ast.parse(fc).body`)
    report:  whether to print [minimal, readable] 'reporting' output

    Produce a dictionary, `mvdef_names`, whose keys are the list of functions
    to move (i.e. the list `mvdefs` becomes the list of keys of `mvdef_names`),
    and the value of which at each key (for a key `m` which indicates the name
    of one of the functions given in `mvdefs` to move) is another dictionary,
    keyed by the full set of names used in that function (`m`) which rely upon
    import statements (i.e. are not builtin names nor passed as parameters to
    the function, nor assigned in the body of the function), and the value
    of which at each name is a final nested dictionary whose keys are always:
      n:      The [0-based] index of the name's source import statement in the
              AST list of all ast.Import and ast.ImportFrom.
      n_i:    The [0-based] index of the name's source import statement within
              the one or more names imported by the source import statement at
              index n (e.g. for `pi` in `from numpy import e, pi`, `n_i` = 1).
      line:   The [1-based] line number of the import statement as given in its
              corresponding AST entry, which indicates the line number of the
              `import` call, not [necessarily] that of the name (i.e. the name
              may not be located there in the file for multi-line imports).
      import: The path of the import statement, which may contain multiple
              parts conjoined by `.` (e.g. `matplotlib.pyplot`)

    I.e. the dictionary with entries accessed as `mvdef_names.get(m).get(k)`
    for `m` in `mvdefs` and `k` in the subset of AST-identified imported names
    in the function with  if f.name not in mvdefs name `m` in the list of
    function definitions `defs`. This access is handed off to the helper
    function `get_def_names`.

    For the names that were imported but not used, the dictionary is not keyed
    by function (as there are no associated functions), and instead the entries
    are accessed as `nondef_names.get(k)` for `k` in `unused_names`. This access
    is handed off to the helper function `set_nondef_names`.
    """
    mvdefs = linkfile.mvdefs
    get_cls = linkfile.classes_only
    if mvdefs is None:
        mvdefs = []  # prepare for `get_def_names`, don't pass a `None` directly
    report_VERBOSE = False  # Silencing debug print statements
    import_types = [ast.Import, ast.ImportFrom]
    imports = [n for n in trunk if type(n) in import_types]
    ast_funcdefs = [n for n in trunk if type(n) is ast.FunctionDef]
    ast_fd_ids = [f.name for f in ast_funcdefs]
    ast_clsdefs = [n for n in trunk if type(n) is ast.ClassDef]
    ast_defs = ast_clsdefs if get_cls else ast_funcdefs
    ast_cd_ids = [c.name for c in ast_clsdefs]
    def_params = {
        "ast_cls_ids": ast_cd_ids,
        "ast_fun_ids": ast_fd_ids,
        "classes_only": linkfile.classes_only,
    }
    linkfile.ast_funcs = [FuncDef(n, **def_params) for n in ast_funcdefs]
    linkfile.ast_classes = [ClsDef(n, **def_params) for n in ast_clsdefs]
    # Any nodes in the AST that aren't imports or ast_defs are 'extra' (as in 'outside')
    ast_sel_type = ClassDef if get_cls else FunctionDef
    extra = [n for n in trunk if type(n) not in [*import_types, ast_sel_type]]
    # Omit names used outside of function definitions so as not to remove them
    linkfile.set_extradef_names(extra)  # sets extradef_names by walking the extra nodes
    if report_VERBOSE:
        print("extra:", extra, file=stderr)
    import_annos = annotate_imports(imports, report=linkfile.report)
    linkfile.mvdef_names = linkfile.get_def_names(mvdefs, import_annos)
    if report_VERBOSE:
        print("mvdef names:", file=stderr)
        pprint_def_names(linkfile.mvdef_names)
    # ------------------------------------------------------------------------ #
    # Next obtain nonmvdef_names
    linkfile_defs = linkfile.ast_classes if get_cls else linkfile.ast_funcs
    nomvdefs = [f.name for f in linkfile_defs if f.name not in mvdefs]
    linkfile.nonmvdef_names = linkfile.get_def_names(nomvdefs, import_annos)
    if report_VERBOSE:
        print("non-mvdef names:", file=stderr)
        pprint_def_names(nonmvdef_names)
    # ------------------------------------------------------------------------ #
    # Next obtain unused_names
    mv_set = set().union(
        *[linkfile.mvdef_names.get(x).keys() for x in linkfile.mvdef_names],
    )
    nomv_set = set().union(
        *[linkfile.nonmvdef_names.get(x).keys() for x in linkfile.nonmvdef_names],
    )
    unused_names = list(set(list(import_annos[0].keys())) - mv_set - nomv_set)
    linkfile.set_nondef_names(unused_names, import_annos)  # sets nondef_names
    linkfile.set_undef_names()  # set undef_names using nondef_names
    if report_VERBOSE:
        print("non-def names (imported but not used in any function def):")
        pprint_def_names(nondefs, no_funcdef_list=True)
    return

backup

backup

backup(filepath, dry_run=False, suffix='.backup', hidden=True)

Given a filename, copy it to a backup before making changes and confirm success to allow an in place edit to proceed safely. If a file with the same name exists, add an incrementing integer. If dry_run is True, all the checks for the possibility of creating the backup will be run, but no files will be opened or touched. (To report on the funcdef/import statements to be moved without having to adhere to these requirements, don't call mvdef.backup.backup before calling mvdef.ast_util.run_ast_parse).

Source code in src/mvdef/legacy/backup.py
def backup(filepath, dry_run=False, suffix=".backup", hidden=True):
    """
    Given a filename, copy it to a backup before making changes and confirm success to
    allow an in place edit to proceed safely. If a file with the same name exists, add
    an incrementing integer. If dry_run is True, all the checks for the possibility of
    creating the backup will be run, but no files will be opened or touched. (To report
    on the funcdef/import statements to be moved without having to adhere to these
    requirements, don't call `mvdef.backup.backup` before calling `mvdef.ast_util.run_ast_parse`).
    """
    fd = filepath.parent
    assert fd.exists() and fd.is_dir(), f"Can't backup {filepath}: {fd} doesn't exist"
    hid_prefix = "." * int(hidden)  # Empty string if hidden is False
    if not filepath.exists():
        # This file is a dst to be created, make an empty backup (indicates no restore)
        assert not filepath.exists()
        empty_msg = f"# EMPTY BACKUP FOR {filepath.name} CREATED BY `mvdef⠶backup()`"
        b_file = fd / f"{hid_prefix}{filepath.name}.backup"
        # Do not tolerate even a single backup file for a file to be newly created
        assert not b_file.exists(), f"Backup {b_file} exists for non-existing file!"
        if not dry_run:
            with open(b_file, "w") as f:
                f.write(f"{empty_msg}\n")
        return True
    else:
        assert filepath.exists() and filepath.is_file() and filepath.suffix == ".py"
    bname = f"{hid_prefix}{filepath.name}{suffix}"
    if fd / bname in fd.iterdir():
        for i in range(0, 12):
            bname_i = fd / f"{bname}{i}"
            if bname_i not in fd.iterdir():
                break
            i += 1
            if i > 10:
                raise ValueError("There are over 10 backups, something's wrong")
        assert not (fd / bname_i).exists()
        # Use the filename {bname_i} for the backup
        bname = bname_i
    assert not (fd / bname).exists()
    if not dry_run:
        with open(filepath) as o:
            original = o.read()
        with open(fd / bname, "w") as b:
            b.write(original)
    return True

cli

validate_into_flag

validate_into_flag(parser, args, nonconsec_dest='into')

Awkward handling procedure to raise an error if two consecutive flags are both -i/--into, i.e. if the pop-then-append operations will overwrite the previous one's.

Source code in src/mvdef/legacy/cli.py
def validate_into_flag(parser, args, nonconsec_dest="into"):
    """
    Awkward handling procedure to raise an error if two consecutive flags are both
    `-i`/`--into`, i.e. if the pop-then-append operations will overwrite the previous
    one's.
    """
    opt_actions = parser._option_string_actions
    opts = {
        v.dest: [
            {o: [i for i, a in enumerate(args) if a == o]} for o in v.option_strings
        ]
        for v in opt_actions.values()
        if any(o in args for o in v.option_strings)
    }
    opt_i = {o: [] for o in opts}
    for k, v in opts.items():
        idx = [list(chain.from_iterable(i for d in v for i in d.values() if i))]
        opt_i.get(k).extend(*idx)

    flag_pos_i_sorted = sorted([*chain.from_iterable(v for v in opt_i.values())])
    dest_i = [flag_pos_i_sorted.index(a) for a in opt_i.get(nonconsec_dest)]
    invalid = [(dest_i[i] - dest_i[i - 1]) == 1 for i, _ in enumerate(dest_i) if i > 0]
    is_invalid = any(invalid)
    invalid_arg_i = [
        (dest_i[i - 1], dest_i[i])
        for i, _ in enumerate(dest_i)
        if i > 0
        if invalid[i - 1]
    ]
    invalid_arg_pos = [
        (flag_pos_i_sorted[a], flag_pos_i_sorted[b]) for (a, b) in invalid_arg_i
    ]
    invalid_arg_values = [tuple(args[a : b + 2]) for (a, b) in invalid_arg_pos]
    if is_invalid:
        raise argparse.ArgumentError(
            None,
            f"Consecutive '{nonconsec_dest}' flags {invalid_arg_values} are invalid."
            " Please pass -m/--mv then up to one -i/--into flag (never multiple).",
        )

colours

get_colour_codes

get_colour_codes(colour=None)

Return a dictionary of ANSI colour codes if no colour specified, if a colour is given then check it is in the list of keys and return a start and end ANSI code.

Source code in src/mvdef/legacy/colours.py
def get_colour_codes(colour=None):
    """
    Return a dictionary of ANSI colour codes if no colour specified,
    if a colour is given then check it is in the list of keys and
    return a start and end ANSI code.
    """
    cols = [
        ["black", "\033[0;30m"],
        ["blue", "\033[0;34m"],
        ["brown", "\033[0;33m"],
        ["cyan", "\033[0;36m"],
        ["dark_gray", "\033[1;30m"],
        ["green", "\033[0;32m"],
        ["light_blue", "\033[1;34m"],
        ["light_cyan", "\033[1;36m"],
        ["light_gray", "\033[0;37m"],
        ["light_green", "\033[1;32m"],
        ["light_purple", "\033[1;35m"],
        ["light_red", "\033[1;31m"],
        ["light_white", "\033[1;37m"],
        ["purple", "\033[0;35m"],
        ["red", "\033[0;31m"],
        ["yellow", "\033[1;33m"],
    ]
    ending = "\033[0m"
    colour_dict = {x[0]: {"on": x[1], "off": ending} for x in sorted(cols)}
    if colour is None:
        return colour_dict
    else:
        assert colour in colour_dict, f"{colour} not in {list(colour_dict.keys())}"
        return colour_dict.get(colour)

get_effect_codes

get_effect_codes(effect=None)

Return a dictionary of ANSI text effect codes if no effect specified, if a text effect is given then check it is in the list of keys and return a start and end ANSI code.

Source code in src/mvdef/legacy/colours.py
def get_effect_codes(effect=None):
    """
    Return a dictionary of ANSI text effect codes if no effect specified,
    if a text effect is given then check it is in the list of keys and return
    a start and end ANSI code.
    """
    effects = [
        ["bold", "\033[1m"],
        ["faint", "\033[2m"],
        ["italic", "\033[3m"],
        ["underline", "\033[4m"],
        ["blink", "\033[5m"],
        ["negative", "\033[7m"],
        ["crossed", "\033[9m"],
    ]
    ending = "\033[0m"
    effect_dict = {x[0]: {"on": x[1], "off": ending} for x in sorted(effects)}
    if effect is None:
        return effect_dict
    else:
        assert effect in effect_dict, f"{effect} not in {list(effect_dict.keys())}"
        return effect_dict.get(effect)

colour_str

colour_str(colour, text, end=True)

Apply the ANSI colour code to a string, and optionally append the end code. Only use end=False when combining ANSI codes (i.e. in which case the end code would apply to all modifiers so as to prevent their combination).

Source code in src/mvdef/legacy/colours.py
def colour_str(colour, text, end=True):
    """
    Apply the ANSI colour code to a string, and optionally append the end code.
    Only use `end=False` when combining ANSI codes (i.e. in which case the end
    code would apply to all modifiers so as to prevent their combination).
    """
    if platform().lower() not in ["linux", "darwin"]:
        return text
    colour_on, colour_off = get_colour_codes(colour).values()
    colourful = f"{colour_on}{text}{colour_off if end else ''}"
    return colourful

effect_str

effect_str(effect, text, end=True)

Apply the ANSI effect code to a string, and optionally append the end code. Only use end=False when combining ANSI codes (i.e. in which case the end code would apply to all modifiers so as to prevent their combination).

Source code in src/mvdef/legacy/colours.py
def effect_str(effect, text, end=True):
    """
    Apply the ANSI effect code to a string, and optionally append the end code.
    Only use `end=False` when combining ANSI codes (i.e. in which case the end
    code would apply to all modifiers so as to prevent their combination).
    """
    if platform().lower() not in ["linux", "darwin"]:
        return text
    effect_on, effect_off = get_effect_codes(effect).values()
    effectful = f"{effect_on}{text}{effect_off if end else ''}"
    return effectful

underline

underline(text, end=True)

Apply the underline effect ANSI code to a text string.

Source code in src/mvdef/legacy/colours.py
def underline(text, end=True):
    """
    Apply the underline effect ANSI code to a text string.
    """
    return effect_str("underline", text, end)

colour_effect_str

colour_effect_str(colour, effect, text)

Apply multiple ANSI colour codes to a string, ending only the outer one, e.g. to both underline and colour, or underline and bold, etc.

The effect must be applied after a colour code, otherwise it will be 'overridden' by the colour code (as far as I have tested).

Note that multiple effects seem to cancel out (with the latter ANSI code taking priority, 'overriding' the other), i.e. multi-effect not possible.

Source code in src/mvdef/legacy/colours.py
def colour_effect_str(colour, effect, text):
    """
    Apply multiple ANSI colour codes to a string, ending only the outer one,
    e.g. to both underline and colour, or underline and bold, etc.

    The effect must be applied after a colour code, otherwise it will be
    'overridden' by the colour code (as far as I have tested).

    Note that multiple effects seem to cancel out (with the latter ANSI code
    taking priority, 'overriding' the other), i.e. multi-effect not possible.
    """
    if platform().lower() not in ["linux", "darwin"]:
        return text
    effectful = effect_str(effect, text, end=False)
    combo = colour_str(colour, effectful)
    return combo

def_helpers

def_path_util

TokenisedStr

TokenisedStr(path_string)
Source code in src/mvdef/legacy/def_path_util.py
def __init__(self, path_string):
    self.string = path_string
    self.parse_from_string()  # sets ._tokens and .parts
    self.check_part_types()

PathSepEnum

Bases: Enum

Path separator symbols (1 to 2 characters)

PathPartEnum

Bases: Enum

Parts to parse tokens into

DefTypeToParentTypeEnum

Bases: Enum

Duplicate of the enum in ast_util.py (TODO: refactor)

NullPathStr

NullPathStr()

Bases: TokenisedStr, UntypedMixin

The empty path, used for moving into the global namespace.

Source code in src/mvdef/legacy/def_path_util.py
def __init__(self):
    super().__init__(path_string="")

UntypedPathStr

UntypedPathStr(path_string)

Bases: UntypedMixin, TokenisedStr

A path without type checks (only to be used when determining path type).

Source code in src/mvdef/legacy/def_path_util.py
def __init__(self, path_string):
    self.string = path_string
    self.parse_from_string()  # sets ._tokens and .parts
    self.check_part_types()

FuncDefPathStr

FuncDefPathStr(path_string)

Bases: TokenisedStr, LeafMixin

A path denoting an AST path to a function (which may be nested as an inner function in a funcdef; or as a method in a classdef), or any further nesting therein (e.g. the inner function of an inner function, etc.).

Subclasses should be used to indicate such nested classes, this class itself should only be instantiated for a 'top-level' funcdef, i.e. in the trunk of the AST.

Source code in src/mvdef/legacy/def_path_util.py
def __init__(self, path_string):
    self.string = path_string
    self.parse_from_string()  # sets ._tokens and .parts
    self.check_part_types()

InnerFuncDefPathStr

InnerFuncDefPathStr(path_string)

Bases: FuncDefPathStr, ParentedMixin

A FuncDefPathStr in which both the leaf and the leaf's parent are funcdefs. This is checked on init.

This class should be subclassed for checking against the (separate) ASTs used in either ast_util or asttokens (the first for generating the inner function indexes, the latter for line numbering associated with the AST nodes).

Source code in src/mvdef/legacy/def_path_util.py
def __init__(self, path_string):
    self.string = path_string
    self.parse_from_string()  # sets ._tokens and .parts
    self.check_part_types()

ClassDefPathStr

ClassDefPathStr(path_string)

Bases: TokenisedStr, LeafMixin

A path denoting an AST path to a class (which may be nested as an inner class in a classdef; or as a higher order class in a funcdef), or any further nesting therein (e.g. the inner class of an inner class, etc.). Subclasses should be used to indicate such nested classes, this class itself should only be instantiated for a 'top-level' classdef, i.e. in the trunk of the AST.

Source code in src/mvdef/legacy/def_path_util.py
def __init__(self, path_string):
    self.string = path_string
    self.parse_from_string()  # sets ._tokens and .parts
    self.check_part_types()

HigherOrderClassDefPathStr

HigherOrderClassDefPathStr(path_string)

Bases: ClassDefPathStr, ParentedMixin

A ClassDefPathStr whose leaf is a classdef and whose leaf's parent is a funcdef. This is checked on init.

This class should be subclassed for checking against the (separate) ASTs used in either ast_util or asttokens.

Source code in src/mvdef/legacy/def_path_util.py
def __init__(self, path_string):
    super().__init__(path_string)
    self.check_part_types()

InnerClassDefPathStr

InnerClassDefPathStr(path_string)

Bases: ClassDefPathStr, ParentedMixin

A ClassDefPathStr in which both the leaf and the leaf's parent are classdefs. This is checked on init.

This class should be subclassed for checking against the (separate) ASTs used in either ast_util or asttokens (the first for generating the inner function indexes, the latter for line numbering associated with the AST nodes).

Source code in src/mvdef/legacy/def_path_util.py
def __init__(self, path_string):
    super().__init__(path_string)
    self.check_part_types()

MethodDefPathStr

MethodDefPathStr(path_string)

Bases: FuncDefPathStr, ParentedMixin

A FuncDefPathStr whose leaf's parent is a class (and whose leaf is the method func). These are checked on init.

This class should be subclassed for checking against the (separate) ASTs used in either ast_util or asttokens (the first for generating the inner function indexes, the latter for line numbering associated with the AST nodes).

Source code in src/mvdef/legacy/def_path_util.py
def __init__(self, path_string):
    super().__init__(path_string)
    self.check_part_types()

editor

nix_surplus_imports

nix_surplus_imports(self, record_removed_import_n=False)

Remove imports marked in the agenda as "lose" (src/dst) or "move" (src only).

Bound as a method of SrcFile/DstFile classes, and used within transfer_mvdefs in step 1 part 1 (dst: removed_import_n) & step 5 part 1 (src: no removed_import_n).

Source code in src/mvdef/legacy/editor.py
def nix_surplus_imports(self, record_removed_import_n=False):
    """
    Remove imports marked in the agenda as "lose" (src/dst) or "move" (src only).

    Bound as a method of `SrcFile`/`DstFile` classes, and used within `transfer_mvdefs`
    in step 1 part 1 (dst: removed_import_n) & step 5 part 1 (src: no removed_import_n).
    """
    # print("Step 1: Remove imports marked dst⠶lose")
    # print("Step 5: Remove imports marked src⠶{move,lose}")
    self.removed_import_n = []
    for rm_i in self.rm_agenda:  # sets .rm_agenda
        # Remove rm_i (imported name marked "lose" for dst, or marked "move"/"lose" for
        # dst) from the destination or source file using the line numbers of `self.trunk`,
        # computed as `dst.imports` by `get_imports`
        # (a destructive operation, so line numbers of `self.trunk` no longer valid),
        # if the removal of the imported name leaves no other imports on a line,
        # otherwise shorten that line by removing the import alias(es) marked "lose"
        info = self.rm_agenda.get(rm_i)
        imp_src_ending = info.get("import").split(".")[-1]
        # Retrieve the index of the line in import list
        rm_i_n = info.get("n")
        rm_i_linecount = self.import_counts[rm_i_n]
        if rm_i_linecount > 1:
            # This means there is ≥1 other import alias in the import statement
            # for this name, so remove it from it (i.e. "shorten" the statement.
            info["shorten"] = info.get("n_i")
        else:
            # This means there is nothing from this module being imported yet, so
            # must remove entire import statement (i.e. delete entire line range)
            if record_removed_import_n:
                # self is DstFile
                self.removed_import_n.append(rm_i_n)
            else:
                # self is SrcFile
                info["shorten"] = None
            imp_startline = self.imports[rm_i_n].first_token.start[0]
            imp_endline = self.imports[rm_i_n].last_token.end[0]
            imp_linerange = [imp_startline - 1, imp_endline]
            for i in range(*imp_linerange):
                self.lines[i] = None
            info["shorten"] = None

shorten_imports

shorten_imports(self, record_removed_import_n=False)

Shorten the imports based on the annotations set by nix_surplus_imports (potentially removing an import statement entirely if its list of imported names becomes shortened to 0).

Bound as a method of SrcFile/DstFile classes, and used within transfer_mvdefs in step 1 part 2 (dst: removed_import_n) & step 5 part 2 (src: no removed_import_n).

Source code in src/mvdef/legacy/editor.py
def shorten_imports(self, record_removed_import_n=False):
    """
    Shorten the imports based on the annotations set by `nix_surplus_imports`
    (potentially removing an import statement entirely if its list of imported
    names becomes shortened to 0).

    Bound as a method of `SrcFile`/`DstFile` classes, and used within `transfer_mvdefs`
    in step 1 part 2 (dst: removed_import_n) & step 5 part 2 (src: no removed_import_n).
    """
    self.to_shorten = {}
    for rm_i in self.rm_agenda:
        if self.rm_agenda.get(rm_i).get("shorten") is not None:
            self.to_shorten.update({rm_i: self.rm_agenda.get(rm_i)})
    self.n_to_short = {self.to_shorten.get(x).get("n") for x in self.to_shorten}
    # Group all names being shortened that are of a common import statement
    for n in self.n_to_short:
        names_to_short = [
            x for x in self.to_shorten if self.to_shorten.get(x).get("n") == n
        ]
        n_i_to_short = [self.to_shorten.get(a).get("n_i") for a in names_to_short]
        # Rewrite `self.imports[n]` with all aliases except those in `names_to_short`
        imp_module = self.modules[n]
        pre_imp = self.imports[n]
        shortened_alias_list = [(a.name, a.asname) for a in pre_imp.names]
        # Proceed backwards from the end to the start, permitting deletions by index
        for name, asname in shortened_alias_list[::-1]:
            if asname is None and name not in names_to_short:
                continue
            elif asname is not None and asname not in names_to_short:
                continue
            del_i = shortened_alias_list.index((name, asname))
            del shortened_alias_list[del_i]
        if len(shortened_alias_list) == 0:
            if record_removed_import_n:
                # All dst import aliases were removed, so remove entire import statement
                self.removed_import_n.append(n)
            imp_startline = pre_imp.first_token.start[0]
            imp_endline = pre_imp.last_token.end[0]
            imp_linerange = [imp_startline - 1, imp_endline]
            for i in range(*imp_linerange):
                self.lines[i] = None
        else:
            imp_stmt_str = get_import_stmt_str(shortened_alias_list, imp_module)
            overwrite_import(pre_imp, imp_stmt_str, self.lines)

receive_imports

receive_imports(link)

Receive imports marked in the link.dst.rcv_agenda.

Bound as a method of the FileLink class, and used in step 2 of transfer_mvdefs.

Source code in src/mvdef/legacy/editor.py
def receive_imports(link):
    """
    Receive imports marked in the `link.dst.rcv_agenda`.

    Bound as a method of the `FileLink` class, and used in step 2 of `transfer_mvdefs`.
    """
    # print("Step 2: Add imports marked dst⠶{move,copy}")
    for rc_i in link.dst.rcv_agenda:  # sets rcv_agenda
        # Transfer mv_i into the destination file: receive "move" as "take"
        # Transfer cp_i into the destination file: receive "copy" as "echo"
        dst_info = link.dst.rcv_agenda.get(rc_i)
        imp_src_ending = dst_info.get("import").split(".")[-1]
        # Use name/asname to retrieve the index of the line in import list to get
        # the module which is at the same index in the list of src modules:
        rc_i_n = dst_info.get("n")
        rc_i_module = link.src.modules[rc_i_n]
        # Compare the imported name to the module if one exists
        if rc_i_module is not None:
            dst_module_set = set(link.dst.modules).difference({None})
            if rc_i_module in dst_module_set:
                # This means there is already ≥1 ast.ImportFrom statement (i.e. a
                # line) which imports from the same module as the to-be-added import
                # name does, so combine it with this existing line. Assume the 1st
                # such ImportFrom is to be extended (ignoring other possible ones).
                dst_info["extend"] = link.dst.modules.index(rc_i_module)
            else:
                # This means there is nothing from this module being imported yet,
                # so must create a new ImportFrom statement (i.e. a new line)
                dst_info["extend"] = None
        else:
            # This means `rc_i` is an ast.Import statement, not ImportFrom
            # (PEP8 recommends separate imports, so do not extend another)
            dst_info["extend"] = None
    link.dst.to_extend = {}
    for rc_i in link.dst.rcv_agenda:
        if link.dst.rcv_agenda.get(rc_i).get("extend") is not None:
            link.dst.to_extend.update({rc_i: link.dst.rcv_agenda.get(rc_i)})
    link.dst.n_to_extend = {
        link.dst.to_extend.get(x).get("n") for x in link.dst.to_extend
    }
    # Group all names being added as extensions that are of a common import statement
    for n in link.dst.n_to_extend:
        names_to_extend = [
            x for x in link.dst.to_extend if link.dst.to_extend.get(x).get("n") == n
        ]
        # Rewrite `link.dst.imports[n]` to include the aliases in `names_to_extend`
        imp_module = link.dst.modules[n]
        pre_imp = link.dst.imports[n]
        extended_alias_list = [(a.name, a.asname) for a in pre_imp.names]
        for rc_i in names_to_extend:
            dst_info = link.dst.to_extend.get(rc_i)
            imp_src = dst_info.get("import")
            imp_src_ending = imp_src.split(".")[-1]
            if rc_i == imp_src_ending:
                rc_i_name, rc_i_as = rc_i, None
            elif imp_module is not None:
                rc_i_name, rc_i_as = imp_src_ending, rc_i
            else:
                rc_i_name, rc_i_as = imp_src, rc_i
            extended_alias_list.append((rc_i_name, rc_i_as))
        imp_stmt_str = get_import_stmt_str(extended_alias_list, imp_module)
        overwrite_import(pre_imp, imp_stmt_str, link.dst.lines)
    # Next, put any import names marked "take" or "echo" that are not extensions
    # of existing import statements into new lines (this breaks the line index).
    #
    # Firstly, find the insertion point for new import statements by re-processing
    # the list of lines (default to start of file if it has no import statements)
    link.dst.import_n = [
        n for n, _ in enumerate(link.dst.imports) if n not in link.dst.removed_import_n
    ]
    # sets .imports ⇢ sets .trunk
    if len(link.dst.import_n) == 0:
        # Place any new import statements at the start of the file, as none exist yet
        link.dst.last_imp_end = (
            0  # 1-based index logic: this means "before the first line"
        )
    else:
        last_import = link.dst.imports[link.dst.import_n[-1]]
        link.dst.last_imp_end = last_import.last_token.end[0]  # Leave in 1-based index
    # Collect import statements to insert after the last one
    link.dst._ins_imp_stmts = []
    link.dst._seen_multimodule_imports = set()
    for rc_i in link.dst.rcv_agenda:
        dst_info = link.dst.rcv_agenda.get(rc_i)
        if (
            rc_i in link.dst._seen_multimodule_imports
            or dst_info.get("extend") is not None
        ):
            continue
        imp_src = dst_info.get("import")
        imp_src_ending = imp_src.split(".")[-1]
        rc_i_n = dst_info.get("n")
        rc_i_module = link.src.modules[rc_i_n]
        if rc_i == imp_src_ending:
            rc_i_name, rc_i_as = rc_i, None
        elif rc_i_module is not None:
            rc_i_name, rc_i_as = imp_src_ending, rc_i
        else:
            rc_i_name, rc_i_as = imp_src, rc_i
        alias_list = [(rc_i_name, rc_i_as)]
        for r in link.dst.rcv_agenda:
            r_src_module = link.src.modules[link.dst.rcv_agenda.get(r).get("n")]
            if r == rc_i or None in [rc_i_module, r_src_module]:
                continue
            if r_src_module == rc_i_module:
                link.dst._seen_multimodule_imports.add(r)
                r_dst_info = link.dst.rcv_agenda.get(r)
                r_imp_src = r_dst_info.get("import")
                r_imp_src_ending = r_imp_src.split(".")[-1]
                r_n = r_dst_info.get("n")
                r_module = link.src.modules[r_n]
                if r == r_imp_src_ending:
                    r_name, r_as = r, None
                elif r_module is not None:
                    r_name, r_as = r_imp_src_ending, r
                else:
                    r_name, r_as = r_dst_info.get("import"), r
                alias_list.append((rc_i_name, rc_i_as))
        # Create the Import or ImportFrom statement
        imp_stmt_str = get_import_stmt_str(alias_list, rc_i_module)
        link.dst._ins_imp_stmts.append(imp_stmt_str)
    link.dst.lines = (
        link.dst.lines[: link.dst.last_imp_end]
        + link.dst._ins_imp_stmts
        + link.dst.lines[link.dst.last_imp_end :]
    )

copy_src_defs_to_dst

copy_src_defs_to_dst(link)

Transfer mvdef into the destination file i.e. 'receive mvdef', where mvdef is an ast.FunctionDefinition node with start/end position annotations using the line numbers of link.src.trunk, computed as link.src.defs_to_move by .ast_tokens.get_defs (in the hasattr check block of the .transfer.SrcFile.defs_to_move property itself). This is an append operation, so line numbers from link.src.trunk remain valid.

Bound as a method of the FileLink class, and used in step 3 of transfer_mvdefs.

Source code in src/mvdef/legacy/editor.py
def copy_src_defs_to_dst(link):
    """
    Transfer mvdef into the destination file i.e. 'receive mvdef', where mvdef is an
    `ast.FunctionDefinition` node with start/end position annotations using the line
    numbers of `link.src.trunk`, computed as `link.src.defs_to_move` by
    `.ast_tokens.get_defs` (in the `hasattr` check block of the
    `.transfer.SrcFile.defs_to_move` property itself). This is an append operation, so
    line numbers from `link.src.trunk` remain valid.

    Bound as a method of the `FileLink` class, and used in step 3 of `transfer_mvdefs`.
    """
    # print("Step 3: copy function definitions {mvdefs} from src to dst")
    # The following line sets .defs_to_move ⇢ sets .trunk ⇢ sets .lines
    link.set_src_defs_to_move()
    for mvdef in link.src.defs_to_move:
        indent = 4
        # Simply add an indent for each AST path part (i.e. per classdef/funcdef)
        dst_col_offset = indent * len(mvdef.into_path.parts) if mvdef.into_path else 0
        # Transfer mvdef into the destination file: receive mvdef
        # print(f"{mvdef=}")
        def_startline, def_endline = get_defrange(mvdef)
        deflines = link.src.lines[def_startline:def_endline]
        # get_def_lines prepares the lines (whitespace and indentation)
        indent_delta = dst_col_offset - mvdef.col_offset
        if mvdef.into_path.string:
            if not hasattr(mvdef.into_path, "node"):
                raise NotImplementedError(f"{mvdef.into_path.string} has no node")
            into_end = mvdef.into_path.node.end_lineno
            pre_lines = link.dst.lines[:into_end]
            post_lines = link.dst.lines[into_end:]
            new_lines = get_def_lines(deflines, link.dst.lines, True, indent_delta)
            if [l for l in post_lines if l]:  # check non-empty, ignoring `None` values
                if len(post_lines) > 1:
                    it = iter(map(str.rstrip, post_lines))
                    if any(it):  # consume generator up to the first nonblank line
                        first_nonblank_i = len(post_lines) - len([*it]) - 1
                        window_size = 1 if post_lines[first_nonblank_i][0] == " " else 2
                        if first_nonblank_i < window_size:
                            # if first following nonblank line is indented, 1 else 2
                            window_filler = window_size - first_nonblank_i
                            for _ in range(window_filler):
                                post_lines.insert(0, "\n")
                    else:
                        pass  # all remaining lines are blank!
            link.dst.lines = pre_lines + new_lines + post_lines
        else:
            append_lines = get_def_lines(deflines, link.dst.lines, False, indent_delta)
            if not link.dst.lines:
                # file may be new, don't prepend newlines if nothing to keep gap between
                e = ValueError("You're appending blank lines to an empty file!")
                append_lines = trim_whitespace_lines_pre(append_lines, else_error=e)
            else:
                # if lines in DstFile are all blank, trim and don't prefix whitespace
                if link.dst.lines and not [l for l in link.dst.lines if l.rstrip()]:
                    link.dst.lines = []
                    append_lines = trim_whitespace_lines_pre(append_lines)
            link.dst.lines += append_lines
        if not link.dst.is_edited:
            link.dst.is_edited = True

remove_copied_defs

remove_copied_defs(src)

Remove function definitions marked as to move from the source file, i.e. after copying them in step 3.

Bound as a method of the SrcFile class, and used within transfer_mvdefs.

Source code in src/mvdef/legacy/editor.py
def remove_copied_defs(src):
    """
    Remove function definitions marked as to move from the source file, i.e.
    after copying them in step 3.

    Bound as a method of the `SrcFile` class, and used within `transfer_mvdefs`.
    """
    # print("Step 4: Remove function definitions {mvdefs} from src")
    for mvdef in sorted(
        src.defs_to_move,
        key=lambda d: d.last_token.end[0],
        reverse=True,
    ):
        # Remove mvdef (function def. marked "mvdef") from the source file
        excise_def_lines(mvdef, src.lines)
        if not src.is_edited:
            src.is_edited = True

editor_util

get_defrange

get_defrange(def_node)
An ast.FunctionDefinition node with start/end position annotations

from the asttokens library.

Source code in src/mvdef/legacy/editor_util.py
def get_defrange(def_node):
    """
    def_node:    An ast.FunctionDefinition node with start/end position annotations
                 from the asttokens library.
    """
    def_startline = def_node.first_token.start[0] - 1  # Subtract 1 to get 0-base index
    def_endline = def_node.last_token.end[0]  # Don't subtract 1, to include full range
    defrange = [def_startline, def_endline]
    return defrange

get_defstring

get_defstring(def_node, file_lines)
An ast.FunctionDefinition node with start/end position annotations

from the asttokens library.

file_lines: A list from readlines (should contain the appropriate file system newlines, the list will be joined with a blank string).

Source code in src/mvdef/legacy/editor_util.py
def get_defstring(def_node, file_lines):
    """
    def_node:    An ast.FunctionDefinition node with start/end position annotations
                 from the asttokens library.
    file_lines:  A list from readlines (should contain the appropriate file system
                 newlines, the list will be joined with a blank string).
    """
    def_startline, def_endline = get_defrange(def_node)
    deflines = file_lines[def_startline:def_endline]
    defstring = "".join(deflines)
    return defstring

append_def_to_file

append_def_to_file(defstring, dst_path)

Insert the lines of a function defintion into a file (in future this function may permit insertion at a certain order position in the file's function definitions).

defstring must be a string containing appropriate newlines for a Python file.

Source code in src/mvdef/legacy/editor_util.py
def append_def_to_file(defstring, dst_path):
    """
    Insert the lines of a function defintion into a file (in future this function may
    permit insertion at a certain order position in the file's function definitions).

    `defstring` must be a string containing appropriate newlines for a Python file.
    """
    # Assess the whitespace, leave at least 2
    end_blanklines = terminal_whitespace(dst_path)
    append_newlines = max((0, 2 - end_blanklines)) * nl
    with open(dst_path, "a") as f:
        f.write(append_newlines + defstring)
    return

get_def_lines

get_def_lines(deflines, dst_lines, is_inner=False, indent_delta=0)

Get the list of lines of a func. def. suitable to be appended to a set of lines.

deflines must be a list of strings containing appropriate newlines for a Python file (the lines will not be joined with newlines, they must be already supplied).

Source code in src/mvdef/legacy/editor_util.py
def get_def_lines(deflines, dst_lines, is_inner=False, indent_delta=0):
    """
    Get the list of lines of a func. def. suitable to be appended to a set of lines.

    `deflines` must be a list of strings containing appropriate newlines for a Python
    file (the lines will not be joined with newlines, they must be already supplied).
    """
    # Assess the whitespace, leave at least 2 if outer, 1 if inner
    window_size = 1 if is_inner else 2
    end_blanklines = terminal_whitespace(dst_lines, from_file=False)
    append_newlines = [nl for _ in range(max((0, window_size - end_blanklines)))]
    if indent_delta > 0:
        # Indent
        indent = " " * indent_delta
        deflines = [f"{indent}{l}" for l in deflines]
    elif indent_delta < 0:
        # Deindent
        deindent = abs(indent_delta)
        deflines = [l[deindent:] for l in deflines]
    return append_newlines + deflines

excise_def_from_file

excise_def_from_file(def_node, py_path, return_def=True)

Either cut or delete a function definition using its AST node (via asttokens).

If return_def is True, modify the file at py_path to remove the lines from def_node.first_token.start[0] to def_node.last_token.end[0], and return the string read from the lines from py_path which contained it (i.e. a "cut" operation).

If return_def is False, modify the file at py_path to remove the lines that contain the function called def_name, return None (i.e. a delete operation).

Source code in src/mvdef/legacy/editor_util.py
def excise_def_from_file(def_node, py_path, return_def=True):
    """
    Either cut or delete a function definition using its AST node (via asttokens).

    If `return_def` is True, modify the file at `py_path` to remove the lines from
    `def_node.first_token.start[0]` to `def_node.last_token.end[0]`, and return the
    string read from the lines from `py_path` which contained it (i.e. a "cut" operation).

    If `return_def` is False, modify the file at `py_path` to remove the lines that
    contain the function called `def_name`, return `None` (i.e. a delete operation).
    """
    with open(py_path, "w+") as f:
        lines = f.readlines()
        # Inkeeping with convention, range is inclusive at start, exclusive at end i.e. [)
        def_startline = def_node.first_token.start[0] - 1
        def_endline = def_node.last_token.end[0]
        defrange = [def_startline, def_endline]
        pre = (def_startline - 2, def_startline)
        post = (def_endline, def_endline + 2)
        # Count whitespace above and below the function definition
        wspace_a = [x == nl for x in lines[pre[0] : pre[1]]]
        wspace_b = [x == nl for x in lines[post[0] : post[1]]]
        wspace_count = (wspace_a + wspace_b).count(True)
        # wspace_added = "" # Don't think I actually need to implement this
        if wspace_count > 2:
            # Remove whitespace: get list of indexes of lines which are blank
            prepost = [p for p in range(*pre)] + [p for p in range(*post)]
            ws_li = [prepost[i] for (i, x) in enumerate(ws_a + ws_b) if x]
            # Take as many as reduce the whitespace count to 2
            remove_li = [ws_li[n] for n in range(wspace_count - 2)]
            for li in remove_li:
                if li < min(defrange):
                    defrange[0] = li
                elif li > max(defrange):
                    defrange[1] = li
                # Otherwise li is intermediate (already processed a past li, continue)
        # Whitespace count of less than 2 could only happen when a def is at the end of
        # a file, in which case no need to add whitespace, so no need to check for it.
        excised_lines = lines.copy()
        for i in reversed(range(*defrange)):
            del excised_lines[i]  # Delete lines backwards from end of file line range
    if return_def:
        deflines = lines[def_startline:def_endline]
        return deflines
    else:
        # Edit file in place (N.B. will not use this actually)
        return

excise_def_lines

excise_def_lines(def_node, lines)

Delete a function definition using its AST node (via asttokens) from a list of lines which originated from a single, entire, Python file.

If the deleted function was the only element in the body of its parent, then replace it with a pass statement to ensure the file remains valid.

Ensure 2 lines between global-level nodes, and 1 line between inner nodes.

Source code in src/mvdef/legacy/editor_util.py
def excise_def_lines(def_node, lines):
    """
    Delete a function definition using its AST node (via asttokens) from a list of lines
    which originated from a single, entire, Python file.

    If the deleted function was the only element in the body of its parent, then replace
    it with a `pass` statement to ensure the file remains valid.

    Ensure 2 lines between global-level nodes, and 1 line between inner nodes.
    """
    window_size = 2  # used in get_borders
    inner_window_size = 1  # used in repairing method-excised empty classdefs
    def_startline = def_node.first_token.start[0]
    def_endline = def_node.last_token.end[0]
    # Subtract 1 from start line index for 0-based "inclusive start/exclusive end"
    defrange = [def_startline - 1, def_endline]
    pre, post = get_borders(defrange, lines, window_size=window_size)
    # Count whitespace above and below the function definition
    # Reverse the order of `pre` otherwise redefining the range start to be the 1st
    # would also necessarily include the 2nd elem of `pre` within the range
    wspace_pre = [lines[p] == nl for p in pre[::-1]]
    wspace_post = [lines[p] == nl for p in post]
    wspace_count = (wspace_pre + wspace_post).count(True)
    if wspace_count > window_size:
        # Remove whitespace: get list of indexes of lines which are blank
        # Reverse pre so as to match the index of `wspace_pre` as above
        pp = pre[::-1] + post
        ws_li = [pp[i] for (i, x) in enumerate(wspace_pre + wspace_post) if x]
        # Take as many as reduce the whitespace count to window size (2)
        remove_li = [ws_li[n] for n in range(wspace_count - window_size)]
        for li in remove_li:
            # Reduce whitespace border by extending defrange to include it
            if li < min(defrange):
                defrange[0] = li
            elif li > max(defrange):
                defrange[1] = li
            # Otherwise li is intermediate (already processed a past li, continue)
    # Whitespace count of less than 2 could only happen when a def is at the end of
    # a file, in which case no need to add whitespace, so no need to check for it.
    if hasattr(def_node, "has_siblings") and not def_node.has_siblings:
        repair_lines = [f"{' ' * def_node.col_offset}pass"]
        repair_lines.extend([""] * inner_window_size)  # leave the window of 1 line
    else:
        repair_lines = []
    for i, r in enumerate(range(*defrange)):
        if i < len(repair_lines):
            # print(f"Replacing '{lines[r]}' with '{repair_lines[i]}'")
            lines[r] = repair_lines[i]
        else:
            # print(f"Deleting '{lines[r]}'")
            lines[r] = None  # Mark lines as deleted by setting the string to `None`
    return

get_borders

get_borders(defrange, lines, window_size=2)

Given the range corresponding to a function definition, and the list of lines this range is in reference to, and the "window size" of the number of lines to compare on each [out]side of this range, return the list of indices of lines which are not None.

This is necessary when some lines have been 'nullified' by replacing the string at that index with None, so as to conserve the line numbering while removing lines (i.e. after excising import names and/or function definitions).

The expected value of the range is to follow Python's convention for ranges, i.e. "inclusive start, exclusive end" - mathematically written as [).

Will return two lists of integers which represent the line indexes of the pre- and post-function definition non-None lines (i.e. the lines above and below, ignoring any lines which have previously been removed). If the window_size of lines above and/or below is not found, the maximum number of lines will be given (i.e. returns empty lists if nothing is found above/below the defrange).

Source code in src/mvdef/legacy/editor_util.py
def get_borders(defrange, lines, window_size=2):
    """
    Given the range corresponding to a function definition, and the list of lines
    this range is in reference to, and the "window size" of the number of lines
    to compare on each [out]side of this range, return the list of indices of
    lines which are not `None`.

    This is necessary when some lines have been 'nullified' by replacing the string
    at that index with `None`, so as to conserve the line numbering while removing
    lines (i.e. after excising import names and/or function definitions).

    The expected value of the range is to follow Python's convention for ranges,
    i.e. "inclusive start, exclusive end" - mathematically written as `[)`.

    Will return two lists of integers which represent the line indexes of the pre-
    and post-function definition non-`None` lines (i.e. the lines above and below,
    ignoring any lines which have previously been removed). If the `window_size`
    of lines above and/or below is not found, the maximum number of lines will be
    given (i.e. returns empty lists if nothing is found above/below the `defrange`).
    """
    d_start, d_end = defrange
    # Get non-`None` line indexes to a max. of `window_size` away from the start
    where_pre = [i for i, l in enumerate(lines[:d_start][::-1]) if l is not None]
    # Reverse list of index offset from d_start back to normal order, get abs. index
    pre = [d_start - 1 - idx for idx in where_pre[:window_size][::-1]]
    where_post = [i for i, l in enumerate(lines[d_end:]) if l is not None]
    post = [d_end + idx for idx in where_post[:window_size]]
    return pre, post

overwrite_import

overwrite_import(imp_node, replacement_str, lines)

Similar to excise_def_from_lines, this function overwrites the line or lines which correspond to an import statement (the AST node of which is given as imp_node), either replacing each line with the equivalent of replacement_str (the replacement import statement string generated by get_import_stmt_str) if the replacement is the same number of lines long, or if the number of lines has changed, it will not split the lines and simply place the entire replacement import statement string in one entry of the lines list, and set the "spare" entries over the range previously occupied by the import statement to None.

Source code in src/mvdef/legacy/editor_util.py
def overwrite_import(imp_node, replacement_str, lines):
    """
    Similar to `excise_def_from_lines`, this function overwrites the line or lines
    which correspond to an import statement (the AST node of which is given as
    `imp_node`), either replacing each line with the equivalent of `replacement_str`
    (the replacement import statement string generated by `get_import_stmt_str`)
    if the replacement is the same number of lines long, or if the number of lines
    has changed, it will not split the lines and simply place the entire replacement
    import statement string in one entry of the `lines` list, and set the "spare"
    entries over the range previously occupied by the import statement to `None`.
    """
    if not replacement_str.endswith(nl):
        replacement_str += nl
    pre_startline = imp_node.first_token.start[0] - 1
    pre_endline = imp_node.last_token.end[0]
    len_pre = pre_endline - pre_startline + 2
    replacement_lines = [f"{r}{nl}" for r in replacement_str.rstrip(nl).split(nl)]
    len_post = len(replacement_lines)
    if len_pre >= len_post:
        # Replace all lines of pre with lines of post, set any remainder to `None`
        for i in range(len_pre):
            if i in range(len_post):
                post_line = replacement_lines[i]
                lines[pre_startline + i] = post_line
            else:
                lines[pre_startline + i] = None
    else:  # len_pre < len_post
        # Set the original line range to `None`, except for the first line of the
        # range which is replaced with the entire replacement string
        for i in range(len_pre):
            if i > 0:
                lines[pre_startline + i] = None
            else:
                lines[pre_startline] = replacement_str
    return

example

test

test_demo

list_failing_tests
list_failing_tests()

Returns None if no failing tests, otherwise returns a list of the names of the functions whose tests failed (N.B. not the name of the functions doing the testing, but the ones they test).

Source code in src/mvdef/legacy/example/test/test_demo.py
def list_failing_tests():
    """
    Returns None if no failing tests, otherwise returns a list of
    the names of the functions whose tests failed (N.B. not the name
    of the functions doing the testing, but the ones they test).
    """
    exceptions = get_test_failures()
    if exceptions:
        failed_funcs = []
        for e in exceptions:
            e_msg = getattr(e, "message", str(e))
            if e_msg.startswith("Test failed:"):
                fail_func = e_msg.split(":")[1][1:]
                failed_funcs.append(fail_func)
        return failed_funcs
test_report
test_report(verbose=True)

Give an error if any tests are failing, otherwise return None.

Source code in src/mvdef/legacy/example/test/test_demo.py
def test_report(verbose=True):
    """
    Give an error if any tests are failing, otherwise return None.
    """
    failing = list_failing_tests()
    assert failing is None, f"Tests failed for {failing}"
    if verbose:
        print("✔ All tests pass", file=stderr)
    return

import_util

get_import_stmt_str

get_import_stmt_str(alias_list, import_src=None, max_linechars=88)

Construct an import statement by building an AST, convert it to source using astor.to_source, and then return the string.

alias_list: List of strings to use as ast.alias name, and optionally also asname entries. If only one name is listed per item in the alias_list, theasnamewill be instantiated as None. import_src: If provided, the import statement will be use theast.ImportFromclass, otherwise it will useast.Import. Relative imports are permitted for "import from" statements (such asfrom ..foo import bar) however absolute imports (such asfrom foo import bar) are recommended in PEP8. max_linechars: Maximum linewidth, beyond which the import statement string will be multilined withmultilinify_import_stmt_str`.

Source code in src/mvdef/legacy/import_util.py
def get_import_stmt_str(alias_list, import_src=None, max_linechars=88):
    """
    Construct an import statement by building an AST, convert it to source using
    astor.to_source, and then return the string.

      alias_list:     List of strings to use as ast.alias `name`, and optionally also
                      `asname entries. If only one name is listed per item in the
                      alias_list, the `asname` will be instantiated as None.
      import_src:     If provided, the import statement will be use the
                      `ast.ImportFrom` class, otherwise it will use `ast.Import`.
                      Relative imports are permitted for "import from" statements
                      (such as `from ..foo import bar`) however absolute imports
                      (such as `from foo import bar`) are recommended in PEP8.
      max_linechars:  Maximum linewidth, beyond which the import statement string will
                      be multilined with `multilinify_import_stmt_str`.
    """
    alias_obj_list = []
    assert type(alias_list) is list, "alias_list must be a list"
    for alias_pair in alias_list:
        if type(alias_pair) is str:
            alias_pair = [alias_pair]
        assert len(alias_pair) > 0, "Cannot import using an empty string"
        assert type(alias_pair[0]) is str, "Import alias name must be a string"
        if len(alias_pair) < 2:
            alias_pair.append(None)
        al = ast.alias(*alias_pair[0:2])
        alias_obj_list.append(al)
    if import_src is None:
        ast_imp_stmt = ast.Import(alias_obj_list)
    else:
        import_level = len(import_src) - len(import_src.lstrip("."))
        import_src = import_src.lstrip(".")
        ast_imp_stmt = ast.ImportFrom(import_src, alias_obj_list, level=import_level)
    import_stmt_str = to_source(ast.Module([ast_imp_stmt]))
    if len(import_stmt_str.rstrip(nl)) > max_linechars:
        return multilinify_import_stmt_str(import_stmt_str)
    else:
        return import_stmt_str

multilinify_import_stmt_str

multilinify_import_stmt_str(import_stmt_str, indent_spaces=4, trailing_comma=True)

Takes a single line import statement and turns it into a multiline string. Will raise a ValueError if given a multiline string (a newline at the end of the string is permitted).

This function is written in expectation of the output of get_import_stmt_str, and is not intended to process all potential ways of writing an import statement.

import_stmt_str:  String of Python code carrying out an import statement.
indent_spaces:    Number of spaces to indent by in multiline format.
trailing_comma:   Whether to add a trailing comma to the final alias in a
                  multiline list of import aliases (default: True)
Source code in src/mvdef/legacy/import_util.py
def multilinify_import_stmt_str(import_stmt_str, indent_spaces=4, trailing_comma=True):
    """
    Takes a single line import statement and turns it into a multiline string.
    Will raise a `ValueError` if given a multiline string (a newline at the end
    of the string is permitted).

    This function is written in expectation of the output of `get_import_stmt_str`,
    and is not intended to process all potential ways of writing an import statement.

        import_stmt_str:  String of Python code carrying out an import statement.
        indent_spaces:    Number of spaces to indent by in multiline format.
        trailing_comma:   Whether to add a trailing comma to the final alias in a
                          multiline list of import aliases (default: True)
    """
    import_stmt_str = import_stmt_str.rstrip(nl)
    n_nl = import_stmt_str.count(nl)
    if n_nl > 0:
        raise ValueError(f"{import_stmt_str} is not a single line string")
    imp_ast = ast.parse(import_stmt_str)
    assert type(imp_ast.body[0]) in [IType, IFType], "Not a valid import statement"
    tko = ASTTokens(import_stmt_str)
    first_tok = tko.tokens[0]
    import_tok = tko.find_token(first_tok, tok_type=1, tok_str="import")
    assert import_tok.type > 0, f"Unable to find import token in the given string"
    imp_preamble_str = import_stmt_str[: import_tok.endpos]
    post_import_tok = tko.tokens[import_tok.index + 1]
    imp_names_str = import_stmt_str[post_import_tok.startpos :]
    aliases = [(x.name, x.asname) for x in imp_ast.body[0].names]
    seen_comma_tok = None
    multiline_import_stmt_str = imp_preamble_str
    multiline_import_stmt_str += " (" + nl
    for al_i, (a_n, a_as) in enumerate(aliases):
        is_final_alias = al_i + 1 == len(aliases)
        if seen_comma_tok is None:
            # Get start of alias by either full name or first part of .-separated name
            al_n_tok = tko.find_token(import_tok, 1, tok_str=a_n.split(".")[0])
            assert al_n_tok.type > 0, f"Unable to find the token for {a_n}"
        else:
            al_n_tok = tko.find_token(seen_comma_tok, 1, tok_str=a_n.split(".")[0])
            assert al_n_tok.type > 0, f"Unable to find the token for {a_n}"
        al_startpos = al_n_tok.startpos
        if a_as is None:
            if is_final_alias:
                # There won't be a comma after this (it is the last import name token)
                al_endpos = al_n_tok.endpos
            else:
                comma_tok = tko.find_token(al_n_tok, tok_type=53, tok_str=",")
                if comma_tok.type == 0:
                    # Due to an error in asttokens, sometimes tok_type is given as 54
                    # although this should be an error (the failure tok_type is 0)
                    comma_tok = tko.find_token(al_n_tok, tok_type=54, tok_str=",")
                assert comma_tok.type > 0, f"Unable to find comma token"
                al_endpos = comma_tok.endpos
        else:
            al_as_tok = tko.find_token(al_n_tok, tok_type=1, tok_str=a_as)
            assert al_as_tok.type > 0, f"Unable to find the token for {a_as}"
            if is_final_alias:
                # There won't be a comma after this (it's the last import asname token)
                al_endpos = al_as_tok.endpos
            else:
                comma_tok = tko.find_token(al_as_tok, tok_type=53, tok_str=",")
                if comma_tok.type == 0:
                    # Due to an error in asttokens, sometimes tok_type is given as 54
                    # although this should be an error (the failure tok_type is 0)
                    comma_tok = tko.find_token(al_n_tok, tok_type=54, tok_str=",")
                assert comma_tok.type > 0, f"Unable to find comma token"
                al_endpos = comma_tok.endpos
        alias_chunk = import_stmt_str[al_startpos:al_endpos]
        if is_final_alias:
            if trailing_comma:
                alias_chunk += ","
        else:
            seen_comma_tok = comma_tok
        multiline_import_stmt_str += (" " * indent_spaces) + alias_chunk + nl
    # Finally, verify that the end of the tokenised string was reached
    assert al_endpos == tko.tokens[-1].endpos, "Did not tokenise to the end of string"
    # No need to further slice the input string, return the final result
    multiline_import_stmt_str += ")" + nl
    return multiline_import_stmt_str

colour_imp_stmt

colour_imp_stmt(imp_stmt, lines)

Summary: get a string which when printed will show the separate parts of an import statement in different colours (preamble in blue, alias names in red, alias asnames in purple, the word "as" itself in yellow, commas between import aliases in light green, and post-matter (a bracket) in light red.

For an import statement within an asttokens-annotated AST, which comes with all subnodes annotated with first and last token start/end positional information, access all the tokens corresponding to the import statement name(s) and asname(s).

Do this using a list of lines (i.e. a list of strings, each of which is a line), the subset of which corresponding to the import statement imp_stmt are given by its first_token.start and last_token.end attributes (in each case, the attribute is a tuple of (line, column) numbers, and it is conventional to store these as a 1-based index, so to cross-reference to a 0-based index of the list of lines we decrement this value and store as imp_startln and imp_endln). The subset of lines corresponding to imp_stmt is then assigned as nodelines, and we join this into a single string as nodestring.

Then a new ASTTokens object, tko, can be made by parsing nodestring, on which the find_tokens method provides access to each name/asname one at a time, when matched to the name/asname string. These name/asname strings are available within the imp_stmt object via its names attribute, which is a list of ast.alias class instances, each of which has both a name and asname attribute (the latter of which is None if no asname is given for the import name).

find_tokens returns a token with attribute type of value 1 for a name (1 is the index of "NAME" in the token.tok_name dictionary), and startpos/endpos attributes (integers which indicate the string offsets within nodestring).

These startpos integers are an efficient way to store this list of tokens (the "NAME" tokens corresponding to import statement alias names and asnames), and so even though it would be possible to store all tokens, I choose to simply re-access them with the tko.get_token_from_offset(startpos) method.

At the moment, I only re-access these tokens to retrieve their endpos (end position offset), which is also an integer and could also be stored easily without much problem, however for the sake of clarity I prefer to re-access the entire token and not have to construct an arbitrary data structure for storing the start and end positions (which could get confusing).

Lastly, I construct a colourful string representation of the import statement by using these start positions and re-retrieved end positions to pull out and modify (using the mvdef.colourscolour_str function) the names and asnames (names are coloured red, asnames are coloured purple), and use string slicing to swap the ranges that the names and asnames were in in the original nodestring for these colourful replacements.

The end result, modified_nodestring is then returned, which will then display in colour on Linux and OSX (I don't think Windows supports ANSI codes, so I made colour_str only apply on these platforms).

Source code in src/mvdef/legacy/import_util.py
def colour_imp_stmt(imp_stmt, lines):
    """
    Summary: get a string which when printed will show the separate parts of an
    import statement in different colours (preamble in blue, alias names in red,
    alias asnames in purple, the word "as" itself in yellow, commas between import
    aliases in light green, and post-matter (a bracket) in light red.

    For an import statement within an asttokens-annotated AST, which comes with
    all subnodes annotated with first and last token start/end positional information,
    access all the tokens corresponding to the import statement name(s) and asname(s).

    Do this using a list of lines (i.e. a list of strings, each of which is a line),
    the subset of which corresponding to the import statement `imp_stmt` are given
    by its `first_token.start` and `last_token.end` attributes (in each case, the
    attribute is a tuple of `(line, column)` numbers, and it is conventional to store
    these as a 1-based index, so to cross-reference to a 0-based index of the list
    of lines we decrement this value and store as `imp_startln` and `imp_endln`).
    The subset of lines corresponding to `imp_stmt` is then assigned as `nodelines`,
    and we join this into a single string as `nodestring`.

    Then a new ASTTokens object, `tko`, can be made by parsing `nodestring`, on which
    the `find_tokens` method provides access to each name/asname one at a time, when
    matched to the name/asname string. These name/asname strings are available
    within the `imp_stmt` object via its `names` attribute, which is a list of
    `ast.alias` class instances, each of which has both a `name` and `asname` attribute
    (the latter of which is `None` if no asname is given for the import name).

    `find_tokens` returns a token with attribute `type` of value `1` for a name (1 is
    the index of "NAME" in the `token.tok_name` dictionary), and `startpos`/`endpos`
    attributes (integers which indicate the string offsets within `nodestring`).

    These `startpos` integers are an efficient way to store this list of tokens
    (the "NAME" tokens corresponding to import statement alias names and asnames),
    and so even though it would be possible to store all tokens, I choose to simply
    re-access them with the `tko.get_token_from_offset(startpos)` method.

    At the moment, I only re-access these tokens to retrieve their `endpos` (end
    position offset), which is also an integer and could also be stored easily
    without much problem, however for the sake of clarity I prefer to re-access
    the entire token and not have to construct an arbitrary data structure for
    storing the start and end positions (which could get confusing).

    Lastly, I construct a colourful string representation of the import statement
    by using these start positions and re-retrieved end positions to pull out
    and modify (using the `mvdef.colours`⠶`colour_str` function) the names and asnames
    (names are coloured red, asnames are coloured purple), and use string slicing
    to swap the ranges that the names and asnames were in in the original
    `nodestring` for these colourful replacements.

    The end result, `modified_nodestring` is then returned, which will then
    display in colour on Linux and OSX (I don't think Windows supports ANSI codes,
    so I made `colour_str` only apply on these platforms).
    """
    assert "first_token" in imp_stmt.__dir__(), "Not an asttokens-annotated AST node"
    assert type(imp_stmt) in [IType, IFType], "Not an import statement"
    is_from = type(imp_stmt) is IFType
    imp_startln = imp_stmt.first_token.start[0] - 1  # Use 0-based line index
    imp_endln = imp_stmt.last_token.end[0] - 1  # to match list of lines
    nodelines = lines[imp_startln : (imp_endln + 1)]
    n_implines = len(nodelines)
    nodestring = "".join(nodelines)
    tko = ASTTokens(nodestring)
    new_nodelines = [list() for _ in range(n_implines)]
    # Subtract the import statement start position from the name or asname
    # token start position to get the offset, then use the offset to extract
    # a range of text from the re-parsed ASTTokens object for the nodestring
    # corresponding to the import name or asname in question.
    imp_startpos = imp_stmt.first_token.startpos
    alias_starts = []
    for alias in imp_stmt.names:
        al_n, al_as = alias.name, alias.asname
        # 1 is the key for "NAME" in Python's tokens.tok_name
        s = [tko.find_token(tko.tokens[0], 1, tok_str=al_n).startpos]
        if al_as is not None:
            s.append(tko.find_token(tko.tokens[0], 1, tok_str=al_as).startpos)
        alias_starts.append(s)
    assert len(alias_starts) > 0, "An import statement cannot import no names!"
    assert alias_starts[0][0] > 0, "An import statement cannot begin with a name!"
    modified_nodestring = ""
    # -------------------------------------------------------------------------
    # Now set up colour definitions for the modified import statement string
    name_colour, asname_colour = ["red", "purple"]
    pre_colour, post_colour = ["light_blue", "light_red"]
    as_string_colour = "yellow"
    comma_colour = "light_green"
    # -------------------------------------------------------------------------
    first_import_name_startpos = alias_starts[0][0]
    pre_str = nodestring[:first_import_name_startpos]
    modified_nodestring += colour(pre_colour, pre_str)
    seen_endpos = first_import_name_startpos
    # (Could add a try/except here to verify colours are in colour dict if modifiable)
    for al_i, alias_start_list in enumerate(alias_starts):
        for al_j, al_start in enumerate(alias_start_list):
            if seen_endpos < al_start:
                # There is an intervening string, append it to modified_nodestring
                intervening_str = nodestring[seen_endpos:al_start]
                if al_j > 0:
                    # This is the word "as", which comes between a name and an asname
                    modified_nodestring += colour(as_string_colour, intervening_str)
                else:
                    if al_i > 0:
                        assert "," in intervening_str, "Import aliases not comma-sep.?"
                        modified_nodestring += colour(comma_colour, intervening_str)
                    else:
                        modified_nodestring += intervening_str
            # Possible here to distinguish between names and asnames by al_j if needed
            is_asname = bool(al_j)  # al_j is 0 if name, 1 if asname
            name_tok = tko.get_token_from_offset(al_start)
            assert name_tok.type > 0, f"No import name at {al_start} in {nodestring}"
            al_endpos = name_tok.endpos
            imp_name = nodestring[al_start:al_endpos]
            cstr_colour = [name_colour, asname_colour][al_j]
            cstr = colour(cstr_colour, imp_name)
            modified_nodestring += cstr
            seen_endpos = al_endpos
    end_str = nodestring[seen_endpos:]
    modified_nodestring += colour(post_colour, end_str)
    return modified_nodestring

count_imported_names

count_imported_names(nodes)

Return an integer for a single node (0 if not an import statement), else return a list of integers for a list of AST nodes.

Source code in src/mvdef/legacy/import_util.py
def count_imported_names(nodes):
    """
    Return an integer for a single node (0 if not an import statement),
    else return a list of integers for a list of AST nodes.
    """
    if type(nodes) is not list:
        if type(nodes) in [IType, IFType]:
            return len(nodes.names)
        else:
            assert ast.stmt in type(nodes).mro(), f"{nodes} is not an AST statement"
            return 0
    counts = []
    for node in nodes:
        if type(node) in [IType, IFType]:
            counts.append(len(node.names))
        else:
            assert ast.stmt in type(nodes).mro(), f"{nodes} is not an AST statement"
            counts.append(0)
    return counts

annotate_imports

annotate_imports(imports, report=True)

Produce two data structures from the list of import statements (the statements of type ast.Import and ast.ImportFrom in the source program's AST), imp_name_linedict: A dictionary whose keys are all the names imported by the program (i.e. the names which they are imported as: the asname if one is used), and whose value for each name is a dictionary of keys (n, line): n: [0-based] index of the import statement importing the name, over the set of all import statements. line: [1-based] line number of the file of the import statement importing the name. Note that it may not correspond to the line number on which the name is given, only to the import function call. imp_name_dict_list: List of one dict per import statement, whose keys are the full import path (with multi-part paths conjoined by a period .) and the values of which are the names that these import paths are imported as (either the asname or else just the terminal part of the import path). The dict preserves the per-line order of the imported names.

Source code in src/mvdef/legacy/import_util.py
def annotate_imports(imports, report=True):
    """
    Produce two data structures from the list of import statements (the statements
    of type ast.Import and ast.ImportFrom in the source program's AST),
      imp_name_linedict:  A dictionary whose keys are all the names imported by the
                          program (i.e. the names which they are imported as: the
                          asname if one is used), and whose value for each name
                          is a dictionary of keys (`n`, `line`):
                            n:    [0-based] index of the import statement importing
                                  the name, over the set of all import statements.
                            line: [1-based] line number of the file of the import
                                  statement importing the name. Note that it may
                                  not correspond to the line number on which the
                                  name is given, only to the import function call.
      imp_name_dict_list: List of one dict per import statement, whose keys
                          are the full import path (with multi-part paths conjoined
                          by a period `.`) and the values of which are the names
                          that these import paths are imported as (either the asname
                          or else just the terminal part of the import path). The
                          dict preserves the per-line order of the imported
                          names.
    """
    report_VERBOSE = False  # Silencing debug print statements
    # This dictionary gives the import line it's on for cross-ref with either
    # the imports list above or the per-line imported_name_dict
    imp_name_linedict = dict()  # Stores all names and their asnames
    imp_name_dict_list = []  # Stores one dict per AST import statement
    for imp_no, imp_line in enumerate(imports):
        imp_name_dict = dict()
        for imported_names in imp_line.names:
            name, asname = imported_names.name, imported_names.asname
            if type(imp_line) == IFType:
                assert imp_line.level == 0, "I've only encountered level 0 imports"
                fullname = ".".join([imp_line.module, name])
            else:
                fullname = name
            if asname is None:
                imp_name_dict[fullname] = name
                # Store both which import in the list of imports it's in
                # and the line number it's found on in the parsed file
                imp_name_linedict[name] = {"n": imp_no, "line": imp_line.lineno}
            else:
                imp_name_dict[fullname] = asname
                imp_name_linedict[asname] = {"n": imp_no, "line": imp_line.lineno}
        imp_name_dict_list.append(imp_name_dict)
    # Ensure that they each got all the names
    assert len(imp_name_dict_list) == len(imports)
    assert sum([len(d) for d in imp_name_dict_list]) == len(imp_name_linedict)
    if report_VERBOSE:
        print("The import name line dict is:", file=stderr)
        for ld in imp_name_linedict:
            # print(f"  {ld}: {imp_name_linedict.get(ld)}")
            pass
        print("The import name dict list is:", file=stderr)
        for ln in imp_name_dict_list:
            print(ln, file=stderr)
    return imp_name_linedict, imp_name_dict_list

imp_def_subsets

imp_def_subsets(linkfile)

Given the list of mvdef_names and nonmvdef_names, construct the subsets: mv_imports: imported names used by the functions to move, nonmv_imports: imported names used by the functions not to move, mutual_imports: imported names used by both the functions to move and the functions not to move

Source code in src/mvdef/legacy/import_util.py
def imp_def_subsets(linkfile):
    """
    Given the list of mvdef_names and nonmvdef_names, construct the subsets:
      mv_imports:      imported names used by the functions to move,
      nonmv_imports:   imported names used by the functions not to move,
      mutual_imports:  imported names used by both the functions to move and
                        the functions not to move
    """
    # report = linkfile.report
    report_VERBOSE = False  # Silencing debug print statements
    mvdef_dicts = linkfile.mvdef_names  # rename to emphasise that these are dicts
    mvdef_names = set().union(
        *[list(mvdef_dicts[x]) for x in mvdef_dicts],
    )  # funcdef names
    nonmvdef_dicts = linkfile.nonmvdef_names  # (as for mvdef_dicts)
    nonmvdef_names = set().union(*[list(nonmvdef_dicts[x]) for x in nonmvdef_dicts])
    linkfile.mv_imports = mvdef_names - nonmvdef_names
    linkfile.nonmv_imports = nonmvdef_names - mvdef_names
    linkfile.mutual_imports = mvdef_names.intersection(nonmvdef_names)
    assert linkfile.mv_imports.isdisjoint(
        linkfile.nonmv_imports,
    ), "mv/nonmv_imports intersect!"
    assert linkfile.mv_imports.isdisjoint(
        linkfile.mutual_imports,
    ), "mv/mutual imports intersect!"
    assert linkfile.nonmv_imports.isdisjoint(
        linkfile.mutual_imports,
    ), "nonmv/mutual imports intersect!"
    if report_VERBOSE:
        print(
            f"mv_imports: {linkfile.mv_imports}",
            f", nonmv_imports: {linkfile.nonmv_imports}",
            f", mutual_imports: {linkfile.mutual_imports}",
            sep="",
            file=stderr,
        )
    all_defnames = set().union(*[mvdef_names, nonmvdef_names])
    all_def_imports = set().union(
        *[linkfile.mv_imports, linkfile.nonmv_imports, linkfile.mutual_imports],
    )
    assert sorted(all_defnames) == sorted(all_def_imports), "Defnames =/= import names"
    return

io_util

terminal_whitespace

terminal_whitespace(inputfile, from_file=True)

Return the number of whitespace newlines in the file.

If the file contains no newlines, returns 0.

Source code in src/mvdef/legacy/io_util.py
def terminal_whitespace(inputfile, from_file=True):
    """
    Return the number of whitespace newlines in the file.

    If the file contains no newlines, returns 0.
    """
    if from_file:
        with open(inputfile) as f:
            lines = f.readlines()
    else:
        lines = inputfile
    terminal_i = -1
    for i, line in enumerate(reversed(lines)):
        if line.rstrip(nl) == "":
            if i - terminal_i > 1:
                break
            else:
                # Accumulate consecutive whitespace index until returning
                terminal_i = i
    return terminal_i + 1

transfer

LinkedFile

LinkedFile(path, report, nochange, use_backup, mvdefs, into_paths, copy_only, classes_only)
Source code in src/mvdef/legacy/transfer.py
def __init__(
    self,
    path,
    report,
    nochange,
    use_backup,
    mvdefs,
    into_paths,
    copy_only,
    classes_only,
):
    self.path = path
    self.report = report
    self.nochange = nochange
    self.use_backup = use_backup
    self.mvdefs = mvdefs
    self.into_paths = into_paths
    self.copy_only = copy_only
    self.classes_only = classes_only

undef_names property writable

undef_names

undef_names contains only those names that are imported but never used

ast_parse

ast_parse(transfers=None)

Create edit agendas from the parsed AST of source and destination files

Source code in src/mvdef/legacy/transfer.py
def ast_parse(self, transfers=None):
    "Create edit agendas from the parsed AST of source and destination files"
    assert self.path, f"'{type(self).__name__}.path' not set"
    self.retrieve_ast_agenda(transfers)  # parse AST, populate the edits property
    self.validate_edits()
    if self.report:
        self.report_edits()
    return

get_merged_dicts

get_merged_dicts(categories)

Return the first item in each agenda category (without list expanding). Used when we no longer need to keep a record of a name's annotation.

Source code in src/mvdef/legacy/transfer.py
def get_merged_dicts(self, categories):
    """
    Return the first item in each agenda category (without list expanding).
    Used when we no longer need to keep a record of a name's annotation.
    """
    return dict(
        [
            next(iter(a.items()))
            for a in [*chain.from_iterable(map(self.edits.get, categories))]
        ],
    )

SrcFile

SrcFile(path, report, nochange, use_backup, mvdefs, into_paths, copy_only, classes_only)

Bases: LinkedFile

Source code in src/mvdef/legacy/transfer.py
def __init__(
    self,
    path,
    report,
    nochange,
    use_backup,
    mvdefs,
    into_paths,
    copy_only,
    classes_only,
):
    self.path = path
    self.report = report
    self.nochange = nochange
    self.use_backup = use_backup
    self.mvdefs = mvdefs
    self.into_paths = into_paths
    self.copy_only = copy_only
    self.classes_only = classes_only

set_rm_agenda

set_rm_agenda()

Merge lose/move lists of info dicts into dict of to-be-removed names/info

Source code in src/mvdef/legacy/transfer.py
def set_rm_agenda(self):
    "Merge lose/move lists of info dicts into dict of to-be-removed names/info"
    self.rm_agenda = self.get_merged_dicts(["move", "lose"])

DstFile

DstFile(path, report, nochange, use_backup, mvdefs, into_paths, copy_only, classes_only)

Bases: LinkedFile

Source code in src/mvdef/legacy/transfer.py
def __init__(
    self,
    path,
    report,
    nochange,
    use_backup,
    mvdefs,
    into_paths,
    copy_only,
    classes_only,
):
    self.path = path
    self.report = report
    self.nochange = nochange
    self.use_backup = use_backup
    self.mvdefs = mvdefs
    self.into_paths = into_paths
    self.copy_only = copy_only
    self.classes_only = classes_only

ensure_exists

ensure_exists()

Create the destination file if it doesn't exist, and if this isn't a dry run

Source code in src/mvdef/legacy/transfer.py
def ensure_exists(self):
    "Create the destination file if it doesn't exist, and if this isn't a dry run"
    if not self.is_extant and not self.nochange:
        open(self.path, "w").close()

set_rcv_agenda

set_rcv_agenda()

Merge take/echo lists of info dicts into dict of received names/info

Source code in src/mvdef/legacy/transfer.py
def set_rcv_agenda(self):
    "Merge take/echo lists of info dicts into dict of received names/info"
    self.rcv_agenda = self.get_merged_dicts(["take", "echo"])

set_rm_agenda

set_rm_agenda()

Convert lose list of info dicts into dict of to-be-removed names/info

Source code in src/mvdef/legacy/transfer.py
def set_rm_agenda(self):
    "Convert lose list of info dicts into dict of to-be-removed names/info"
    self.rm_agenda = self.get_merged_dicts(["lose"])
FileLink(mvdefs, into_paths, src_p, dst_p, report, nochange, test_func, use_backup, copy_only, classes_only)
Source code in src/mvdef/legacy/transfer.py
def __init__(
    self,
    mvdefs,
    into_paths,
    src_p,
    dst_p,
    report,
    nochange,
    test_func,
    use_backup,
    copy_only,
    classes_only,
):
    # First set those with no side effects:
    self.mvdefs = mvdefs
    self.into_paths = into_paths
    self.report = report
    self.nochange = nochange
    self.copy_only = copy_only
    self.classes_only = classes_only
    # Now set up the link
    self.set_link(src_p, dst_p, use_backup=use_backup)
    self.use_backup = use_backup  # will create backups (now!) if True
    self.test_func = test_func  # will run the test_func to check it works
    try:
        # print("Running link.src.ast_parse()")
        self.src.ast_parse()  # populate self.src.edits
    except Exception as e:
        self.src.edits = e
        return
    # print("Running link.dst.ensure_exists")
    self.dst.ensure_exists()
    self.set_src_defs_to_move()
    transfers = {
        "take": self.src.edits.get("move"),
        "echo": self.src.edits.get("copy"),
    }
    try:
        # print("Running link.dst.ast_parse(transfers)")
        self.dst.ast_parse(transfers=transfers)  # populate self.dst.edits
    except Exception as e:
        self.dst.edits = e
        return

backup

backup(dry_run)

Run individual backup checks for src and dst

Source code in src/mvdef/legacy/transfer.py
def backup(self, dry_run):
    "Run individual backup checks for src and dst"
    self.src.backup(dry_run=dry_run)
    self.dst.backup(dry_run=dry_run)

parse_transfer

parse_transfer(mvdefs, into_paths, src_p, dst_p, report=True, nochange=True, test_func=None, use_backup=True, copy_only=False, classes_only=False)

Execute the transfer of function definitions and import statements, optionally (if test_func is specified) also calls that afterwards to confirm functionality remains intact.

If test_func is specified, it must only use AssertionError (i.e. you are free to have the test_func call other functions, but it must catch any errors therein and only raise errors from assert statements). This is to simplify this step. My example would be to list one or more failing tests, then assert that this list is None, else raise an AssertionError of these tests' definition names (see example.test.test_demo⠶test_report for an example of such a function).

If nochange is False, files will be changed in place (i.e. setting it to False is equivalent to setting the edit parameter to True). - Note: this was unimplemented...

This parameter is used as a sanity check to prevent wasted computation, as if neither report is True nor nochange is False, there is nothing to do.

Source code in src/mvdef/legacy/transfer.py
def parse_transfer(
    mvdefs,
    into_paths,
    src_p,
    dst_p,
    report=True,
    nochange=True,
    test_func=None,
    use_backup=True,
    copy_only=False,
    classes_only=False,
):
    """
    Execute the transfer of function definitions and import statements, optionally
    (if test_func is specified) also calls that afterwards to confirm functionality
    remains intact.

    If test_func is specified, it must only use AssertionError (i.e. you are free
    to have the test_func call other functions, but it must catch any errors therein
    and only raise errors from assert statements). This is to simplify this step.
    My example would be to list one or more failing tests, then assert that this
    list is None, else raise an AssertionError of these tests' definition names
    (see example.test.test_demo⠶test_report for an example of such a function).

    If nochange is False, files will be changed in place (i.e. setting it
    to False is equivalent to setting the edit parameter to True).
      - Note: this was unimplemented...

    This parameter is used as a sanity check to prevent wasted computation,
    as if neither report is True nor nochange is False, there is nothing to do.
    """
    # Backs up source and target to a hidden file, restorable in case of error,
    # and creating a hidden placeholder if the target doesn't exist yet
    assert True in [report, not nochange], "Nothing to do"
    link = FileLink(
        mvdefs,
        into_paths,
        src_p,
        dst_p,
        report,
        nochange,
        test_func,
        use_backup,
        copy_only,
        classes_only,
    )
    # Raise any error encountered when building the AST
    if isinstance(link.src.edits, Exception):
        global src_err_link
        src_err_link = link
        raise link.src.edits
    elif isinstance(link.dst.edits, Exception):
        global dst_err_link
        dst_err_link = link
        raise link.dst.edits
    # print("Finished checking agenda: no exceptions found")
    if nochange:
        print("DRY RUN: No files have been modified, skipping tests.", file=stderr)
        return link.src.edits, link.dst.edits
    else:
        # Edit the files (no longer pass imports or defs, will recompute AST)
        # print("Transferring mvdefs over the link")
        link.transfer_mvdefs()
    if test_func is None:
        return link.src.edits, link.dst.edits
    else:
        try:
            test_func.__call__()
        except AssertionError as e:
            # TODO: implement backup restore
            print(
                (
                    f"! '{test_func.__name__}' failed, indicating changes made by mvdef broke the"
                    "program (if backups used, mvdefs will now attempt to restore)"
                ),
                file=stderr,
            )
            raise RuntimeError(e)
    return link.src.edits, link.dst.edits