API Tutorial

This is a walkthrough of using the mutatest API. These are the same method calls used by the CLI and provide additional flexibility for customization. The code and notebook to generate this tutorial is located under the docs/api_tutorial folder on GitHub.

# Imports used throughout the tutorial

import ast

from pathlib import Path

from mutatest import run
from mutatest import transformers
from mutatest.api import Genome, GenomeGroup, MutationException
from mutatest.filters import CoverageFilter, CategoryCodeFilter

Tutorial setup

The example/ folder has two Python files, a.py and b.py, with a test_ab.py file that would be automatically detected by pytest.

# This folder and included .py files are in docs/api_tutoral/

src_loc = Path("example")
print(*[i for i in src_loc.iterdir()
        if i.is_file()], sep="\n")
example/a.py
example/test_ab.py
example/b.py

a.py holds two functions: one to add five to an input value, another to return True if the first input value is greater than the second input value. The add operation is represented in the AST as ast.Add, a BinOp operation type, and the greater-than operation is represented by ast.Gt, a CompareOp operation type. If the source code is executed the expected value is to print 10.

def open_print(fn):
    """Open a print file contents."""
    with open(fn) as f:
        print(f.read())

# Contents of a.py example source file
open_print(src_loc / "a.py")
"""Example A.
"""


def add_five(a):
    return a + 5


def greater_than(a, b):
    return a > b


print(add_five(5))

b.py has a single function that returns whether or not the first input is the second input. is is represented by ast.Is and is a CompareIs operation type. The expected value if this source code is executed is True.

# Contents of b.py example source file

open_print(src_loc / "b.py")
"""Example B.
"""


def is_match(a, b):
    return a is b


print(is_match(1, 1))

test_ab.py is the test script for both a.py and b.py. The test_add_five function is intentionally broken to demonstrate later mutations. It will pass if the value is greater than 10 in the test using 6 as an input value, and fail otherwise.

# Contents of test_ab.py example test file

open_print(src_loc / "test_ab.py")
from a import add_five
from b import is_match


def test_add_five():
    assert add_five(6) > 10


def test_is_match():
    assert is_match("one", "one")

Run a clean trial and generate coverage

We can use run to perform a “clean trial” of our test commands based on the source location. This will generate a .coverage file that will be used by the Genome. A .coverage file is not required. This run method is useful for doing clean trials before and after mutation trials as a way to reset the __pycache__.

# The return value of clean_trial is the time to run
# this is used in reporting from the CLI

run.clean_trial(
    src_loc, test_cmds=["pytest", "--cov=example"]
)
datetime.timedelta(microseconds=411150)
Path(".coverage").exists()
True

Genome Basics

Genomes are the basic representation of a source code file in mutatest. They can be initialized by passing in the path to a specific file, or initialized without any arguments and have the source file added later. The basic properties include the Abstract Syntax Tree (AST), the source file, the coverage file, and any category codes for filtering.

# Initialize with the source file location
# By default, the ".coverage" file is set
# for the coverage_file property

genome = Genome(src_loc / "a.py")
genome.source_file
PosixPath('example/a.py')
genome.coverage_file
PosixPath('.coverage')
# By default, no filter codes are set
# These are categories of mutations to filter

genome.filter_codes
set()

Finding mutation targets

The Genome has two additional properties related to finding mutation locations: targets and covered_targets. These are sets of LocIndex objects (defined in transformers) that represent locations in the AST that can be mutated. Covered targets are those that have lines covered by the set coverage_file property.

genome.targets
{LocIndex(ast_class='BinOp', lineno=6, col_offset=11, op_type=<class '_ast.Add'>, end_lineno=6, end_col_offset=16),
 LocIndex(ast_class='Compare', lineno=10, col_offset=11, op_type=<class '_ast.Gt'>, end_lineno=10, end_col_offset=16)}
genome.covered_targets
{LocIndex(ast_class='BinOp', lineno=6, col_offset=11, op_type=<class '_ast.Add'>, end_lineno=6, end_col_offset=16)}
genome.targets - genome.covered_targets
{LocIndex(ast_class='Compare', lineno=10, col_offset=11, op_type=<class '_ast.Gt'>, end_lineno=10, end_col_offset=16)}

Accessing the AST

The ast property is the AST of the source file. You can access the properties directly. This is used to generate the targets and covered targets. The AST parser is defined in transformers but is encapsulted in the Genome.

genome.ast
<_ast.Module at 0x7f68a4014bb0>
genome.ast.body
[<_ast.Expr at 0x7f68a4014ca0>,
 <_ast.FunctionDef at 0x7f68a4014ac0>,
 <_ast.FunctionDef at 0x7f68a4014eb0>,
 <_ast.Expr at 0x7f68a402c040>]
genome.ast.body[1].__dict__
{'name': 'add_five',
 'args': <_ast.arguments at 0x7f68a4014d30>,
 'body': [<_ast.Return at 0x7f68a4014dc0>],
 'decorator_list': [],
 'returns': None,
 'type_comment': None,
 'lineno': 5,
 'col_offset': 0,
 'end_lineno': 6,
 'end_col_offset': 16}

Filtering mutation targets

You can set filters on a Genome for specific types of targets. For example, setting bn for BinOp will filter both targets and covered targets to only BinOp class operations.

# All available categories are listed
# in transformers.CATEGORIES

print(*[f"Category:{k}, Code: {v}"
        for k,v in transformers.CATEGORIES.items()],
      sep="\n")
Category:AugAssign, Code: aa
Category:BinOp, Code: bn
Category:BinOpBC, Code: bc
Category:BinOpBS, Code: bs
Category:BoolOp, Code: bl
Category:Compare, Code: cp
Category:CompareIn, Code: cn
Category:CompareIs, Code: cs
Category:If, Code: if
Category:Index, Code: ix
Category:NameConstant, Code: nc
Category:SliceUS, Code: su
# If you attempt to set an invalid code a ValueError is raised
# and the valid codes are listed in the message

try:
    genome.filter_codes = ("asdf",)

except ValueError as e:
    print(e)
Invalid category codes: {'asdf'}.
Valid codes: {'AugAssign': 'aa', 'BinOp': 'bn', 'BinOpBC': 'bc', 'BinOpBS': 'bs', 'BoolOp': 'bl', 'Compare': 'cp', 'CompareIn': 'cn', 'CompareIs': 'cs', 'If': 'if', 'Index': 'ix', 'NameConstant': 'nc', 'SliceUS': 'su'}
# Set the filter using an iterable of the two-letter codes

genome.filter_codes = ("bn",)
# Targets and covered targets will only show the filtered value

genome.targets
{LocIndex(ast_class='BinOp', lineno=6, col_offset=11, op_type=<class '_ast.Add'>, end_lineno=6, end_col_offset=16)}
genome.covered_targets
{LocIndex(ast_class='BinOp', lineno=6, col_offset=11, op_type=<class '_ast.Add'>, end_lineno=6, end_col_offset=16)}
# Reset the filter_codes to an empty set
genome.filter_codes = set()
# All target classes are now listed again

genome.targets
{LocIndex(ast_class='BinOp', lineno=6, col_offset=11, op_type=<class '_ast.Add'>, end_lineno=6, end_col_offset=16),
 LocIndex(ast_class='Compare', lineno=10, col_offset=11, op_type=<class '_ast.Gt'>, end_lineno=10, end_col_offset=16)}

Using custom filters

If you need more flexibility, the filters define the two classes of filter used by Genome: the CoverageFilter and the CategoryCodeFilter. These are encapsultated by Genome and GenomeGroup already but can be accessed directly.

Coverage Filter

cov_filter = CoverageFilter(coverage_file=Path(".coverage"))
# Use the filter method to filter targets based on
# a given source file.

cov_filter.filter(
    genome.targets, genome.source_file
)
{LocIndex(ast_class='BinOp', lineno=6, col_offset=11, op_type=<class '_ast.Add'>, end_lineno=6, end_col_offset=16)}
# You can invert the filtering as well

cov_filter.filter(
    genome.targets, genome.source_file,
    invert=True
)
{LocIndex(ast_class='Compare', lineno=10, col_offset=11, op_type=<class '_ast.Gt'>, end_lineno=10, end_col_offset=16)}

Category Code Filter

# Instantiate using a set of codes
# or add them later

catcode_filter = CategoryCodeFilter(codes=("bn",))
# Valid codes provide all potential values

catcode_filter.valid_codes
dict_values(['aa', 'bn', 'bc', 'bs', 'bl', 'cp', 'cn', 'cs', 'if', 'ix', 'nc', 'su'])
# Valid categories are also available

catcode_filter.valid_categories
{'AugAssign': 'aa',
 'BinOp': 'bn',
 'BinOpBC': 'bc',
 'BinOpBS': 'bs',
 'BoolOp': 'bl',
 'Compare': 'cp',
 'CompareIn': 'cn',
 'CompareIs': 'cs',
 'If': 'if',
 'Index': 'ix',
 'NameConstant': 'nc',
 'SliceUS': 'su'}
# add more codes

catcode_filter.add_code("aa")
catcode_filter.codes
{'aa', 'bn'}
# see all validation mutations
# based on the set codes

catcode_filter.valid_mutations
{_ast.Add,
 _ast.Div,
 _ast.FloorDiv,
 _ast.Mod,
 _ast.Mult,
 _ast.Pow,
 _ast.Sub,
 'AugAssign_Add',
 'AugAssign_Div',
 'AugAssign_Mult',
 'AugAssign_Sub'}
# discard codes

catcode_filter.discard_code("aa")
catcode_filter.codes
{'bn'}
catcode_filter.valid_mutations
{_ast.Add, _ast.Div, _ast.FloorDiv, _ast.Mod, _ast.Mult, _ast.Pow, _ast.Sub}
# Filter a set of targets based on codes

catcode_filter.filter(genome.targets)
{LocIndex(ast_class='BinOp', lineno=6, col_offset=11, op_type=<class '_ast.Add'>, end_lineno=6, end_col_offset=16)}
# Optionally, invert the filter

catcode_filter.filter(
    genome.targets, invert=True
)
{LocIndex(ast_class='Compare', lineno=10, col_offset=11, op_type=<class '_ast.Gt'>, end_lineno=10, end_col_offset=16)}

Changing the source file in a Genome

If you change the source file property of the Genome all core properties except the coverage file and filters are reset - this includes targets, covered targets, and AST.

genome.source_file = src_loc / "b.py"
genome.targets
{LocIndex(ast_class='CompareIs', lineno=6, col_offset=11, op_type=<class '_ast.Is'>, end_lineno=6, end_col_offset=17)}
genome.covered_targets
{LocIndex(ast_class='BinOp', lineno=6, col_offset=11, op_type=<class '_ast.Add'>, end_lineno=6, end_col_offset=16)}

Creating Mutations

Mutations are applied to specific LocIndex targets in a Genome. You must speicfy a valid operation e.g., “add” can be mutated to “divide” or “subtract”, but not “is”. The Genome itself is not modified, a returned Mutant object holds the information required to create a mutated version of the __pycache__ for that source file. For this example, we’ll change a.py to use a multiplication operation instead of an addition operation for the add_five function. The original expected result of the code was 10 from 5 + 5 if executed, with the mutation it will be 25 since the mutation creates 5 * 5.

# Set the Genome back to example a
# filter to only the BinOp targets

genome.source_file = src_loc / "a.py"
genome.filter_codes = ("bn",)

# there is only one Binop target

mutation_target = list(genome.targets)[0]
mutation_target
LocIndex(ast_class='BinOp', lineno=6, col_offset=11, op_type=<class '_ast.Add'>, end_lineno=6, end_col_offset=16)
# The mutate() method applies a mutation operation
# and returns a mutant

mutant = genome.mutate(mutation_target, ast.Mult)
# applying an invalid mutation
# raises a MutationException

try:
    genome.mutate(mutation_target, ast.IsNot)

except MutationException as e:
    print(e)
<class '_ast.IsNot'> is not a member of mutation category bn.
Valid mutations for bn: {<class '_ast.Mult'>, <class '_ast.Sub'>, <class '_ast.Add'>, <class '_ast.Pow'>, <class '_ast.FloorDiv'>, <class '_ast.Mod'>, <class '_ast.Div'>}.
# mutants have all of the properties
# needed to write mutated __pycache__

mutant
Mutant(mutant_code=<code object <module> at 0x7f68a4040b30, file "example/a.py", line 1>, src_file=PosixPath('example/a.py'), cfile=PosixPath('example/__pycache__/a.cpython-38.pyc'), loader=<_frozen_importlib_external.SourceFileLoader object at 0x7f689cfbd310>, source_stats={'mtime': 1571346690.5703905, 'size': 118}, mode=33188, src_idx=LocIndex(ast_class='BinOp', lineno=6, col_offset=11, op_type=<class '_ast.Add'>, end_lineno=6, end_col_offset=16), mutation=<class '_ast.Mult'>)
# You can directly execute the mutant_code
# This result is with the mutated target being
# applied as Mult instead of Add in a.py

exec(mutant.mutant_code)
25
# Mutants have a write_cache() method to apply
# the change to __pycache__

mutant.write_cache()
# Alternatively, use run to do a single trial
# and return the result

mutant_trial_result = run.create_mutation_run_trial(
    genome, mutation_target, ast.Mult, ["pytest"], max_runtime=5
)
# In this case the mutation would survive
# The test passes if the value is
# greater than 10.

mutant_trial_result.status
'SURVIVED'
# Using a different operation, such as Div
# will be a detected mutation
# since the test will fail.

mutant_trial_result = run.create_mutation_run_trial(
    genome, mutation_target, ast.Div, ["pytest"], max_runtime=5
)

mutant_trial_result.status
'DETECTED'

GenomeGroups

The GenomeGroup is a way to interact with multiple Genomes. You can create a GenomeGroup from a folder of files, add new Genomes, and access shared properties across the Genomes. It is a MutableMapping and behaves accordingly, though it only accepts Path keys and Genome values. You can use the GenomeGroup to assign common filters, common coverage files, and to get all targets across an entire collection of Genomes.

ggrp = GenomeGroup(src_loc)
# key-value pairs in the GenomeGroup are
# the path to the source file
# and the Genome object for that file

for k,v in ggrp.items():
    print(k, v)
example/a.py <mutatest.api.Genome object at 0x7f689cfc8c10>
example/b.py <mutatest.api.Genome object at 0x7f689cfc8f70>
# targets, and covered_targets produce
# GenomeGroupTarget objects that have
# attributes for the source path and
# LocIdx for the target

for t in ggrp.targets:
    print(
        t.source_path, t.loc_idx
    )
example/b.py LocIndex(ast_class='CompareIs', lineno=6, col_offset=11, op_type=<class '_ast.Is'>, end_lineno=6, end_col_offset=17)
example/a.py LocIndex(ast_class='Compare', lineno=10, col_offset=11, op_type=<class '_ast.Gt'>, end_lineno=10, end_col_offset=16)
example/a.py LocIndex(ast_class='BinOp', lineno=6, col_offset=11, op_type=<class '_ast.Add'>, end_lineno=6, end_col_offset=16)
# You can set a filter or
# coverage file for the entire set
# of genomes

ggrp.set_coverage = Path(".coverage")

for t in ggrp.covered_targets:
    print(
        t.source_path, t.loc_idx
    )
example/b.py LocIndex(ast_class='CompareIs', lineno=6, col_offset=11, op_type=<class '_ast.Is'>, end_lineno=6, end_col_offset=17)
example/a.py LocIndex(ast_class='BinOp', lineno=6, col_offset=11, op_type=<class '_ast.Add'>, end_lineno=6, end_col_offset=16)
# Setting filter codes on all Genomes
# in the group

ggrp.set_filter(("cs",))
ggrp.targets
{GenomeGroupTarget(source_path=PosixPath('example/b.py'), loc_idx=LocIndex(ast_class='CompareIs', lineno=6, col_offset=11, op_type=<class '_ast.Is'>, end_lineno=6, end_col_offset=17))}
for k, v in ggrp.items():
    print(k, v.filter_codes)
example/a.py {'cs'}
example/b.py {'cs'}
# MutableMapping operations are
# available as well

ggrp.values()
dict_values([<mutatest.api.Genome object at 0x7f689cfc8c10>, <mutatest.api.Genome object at 0x7f689cfc8f70>])
ggrp.keys()
dict_keys([PosixPath('example/a.py'), PosixPath('example/b.py')])
# pop a Genome out of the Group

genome_a = ggrp.pop(Path("example/a.py"))
ggrp
{PosixPath('example/b.py'): <mutatest.api.Genome object at 0x7f689cfc8f70>}
# add a Genome to the group

ggrp.add_genome(genome_a)
ggrp
{PosixPath('example/b.py'): <mutatest.api.Genome object at 0x7f689cfc8f70>, PosixPath('example/a.py'): <mutatest.api.Genome object at 0x7f689cfc8c10>}
# the add_folder options provides
# more flexibility e.g., to include
# the test_ files.

ggrp_with_tests = GenomeGroup()
ggrp_with_tests.add_folder(
    src_loc, ignore_test_files=False
)

for k, v in ggrp_with_tests.items():
    print(k, v)
example/a.py <mutatest.api.Genome object at 0x7f68a4044700>
example/test_ab.py <mutatest.api.Genome object at 0x7f689cfd7340>
example/b.py <mutatest.api.Genome object at 0x7f689cfd74f0>