Decorators for Bioinformatics
@decorator above def f(): is exactly f = decorator(f) at definition time.
When to Use
- Adding timing/logging around a pipeline step (e.g.
@timeron a parsing or alignment function) without editing its body. - Validating that a function's input is a legal DNA/RNA/protein alphabet before running expensive logic.
- Caching pure functions with repeated inputs — codon-to-amino-acid lookups, recursive alignment scoring, k-mer counting.
- Building reusable "specialized function" closures, e.g. a motif counter factory (
make_motif_counter("CG")). - Explaining or debugging why a decorated function lost its
__name__/docstring, or why decorator stacking order changed behavior.
Version Compatibility
Pure standard library — functools and closures work unchanged on Python ≥3.8 (examples use f-strings and functools.wraps, both stable since 3.6+). No third-party dependency required.
Prerequisites
- Comfort with Python functions as first-class objects (functions passed as arguments, returned from functions).
*args, **kwargssyntax for variadic wrappers.- Related:
python-bio-functions(higher-order functions, closures basics),python-bio-error-handling(raising/catching inside wrappers),python-bio-context-managers(the sibling resource-management pattern).
Core Pattern: Basic Decorator
Goal: measure and log how long a bioinformatics function takes to run, without modifying its body.
Approach: wrap the function in a closure that times the call and forwards *args, **kwargs; use functools.wraps to preserve identity.
import functools
import time
def timer(func):
"""Decorator: measure and print execution time of a function."""
@functools.wraps(func) # preserves __name__, __doc__
def wrapper(*args, **kwargs):
start = time.perf_counter()
result = func(*args, **kwargs)
elapsed = time.perf_counter() - start
print(f"[timer] {func.__name__}: {elapsed:.4f}s")
return result
return wrapper
@timer
def gc_content(seq: str) -> float:
"""Calculate GC content of a DNA sequence."""
seq = seq.upper()
return (seq.count('G') + seq.count('C')) / len(seq) * 100
gc_content("ATGCGATCGATCGTAGC") # prints "[timer] gc_content: 0.0000s", returns 47.05...
Decorator Factory (with arguments)
Goal: validate that a sequence only contains legal characters for its type (DNA vs. protein) before the wrapped function runs.
Approach: a decorator that itself takes arguments needs three nested levels — factory(args) → decorator(func) → wrapper(*args); the factory closes over valid_chars.
def validate_sequence(valid_chars: str, seq_type: str = "DNA"):
"""Decorator factory: validate that the first argument is a valid sequence."""
valid_set = set(valid_chars.upper())
def decorator(func):
@functools.wraps(func)
def wrapper(seq, *args, **kwargs):
invalid = set(seq.upper()) - valid_set
if invalid:
raise ValueError(
f"Invalid {seq_type} characters {invalid} in input to {func.__name__}()"
)
return func(seq, *args, **kwargs)
return wrapper
return decorator
@validate_sequence('ATGC', seq_type='DNA')
def complement(seq: str) -> str:
"""Return the DNA complement."""
comp_map = {'A': 'T', 'T': 'A', 'G': 'C', 'C': 'G'}
return ''.join(comp_map[b] for b in seq.upper())
complement("ATGCGATC") # "TACGCTAG"
complement("ATGXYZ") # raises ValueError: Invalid DNA characters {'X','Y','Z'} ...
Memoization
Goal: avoid recomputing expensive pure functions (codon lookup, recursive alignment) for repeated inputs.
Approach: prefer the stdlib functools.lru_cache; write a manual cache only when you need custom eviction or a .clear() hook exposed on the function.
from functools import lru_cache
@lru_cache(maxsize=None)
def translate_codon(codon: str) -> str:
"""Translate a single codon to its amino acid (cached)."""
table = {
'TTT':'F','TTC':'F','TTA':'L','TTG':'L','TCT':'S','TCC':'S','TCA':'S','TCG':'S',
'TAT':'Y','TAC':'Y','TAA':'*','TAG':'*','TGT':'C','TGC':'C','TGA':'*','TGG':'W',
'CTT':'L','CTC':'L','CTA':'L','CTG':'L','CCT':'P','CCC':'P','CCA':'P','CCG':'P',
'CAT':'H','CAC':'H','CAA':'Q','CAG':'Q','CGT':'R','CGC':'R','CGA':'R','CGG':'R',
'ATT':'I','ATC':'I','ATA':'I','ATG':'M','ACT':'T','ACC':'T','ACA':'T','ACG':'T',
'AAT':'N','AAC':'N','AAA':'K','AAG':'K','AGT':'S','AGC':'S','AGA':'R','AGG':'R',
'GTT':'V','GTC':'V','GTA':'V','GTG':'V','GCT':'A','GCC':'A','GCA':'A','GCG':'A',
'GAT':'D','GAC':'D','GAA':'E','GAG':'E','GGT':'G','GGC':'G','GGA':'G','GGG':'G',
}
return table.get(codon.upper(), 'X')
def translate_sequence(dna: str) -> str:
"""Translate a DNA sequence codon-by-codon using the cached lookup."""
protein = []
for i in range(0, len(dna) - 2, 3):
aa = translate_codon(dna[i:i + 3])
if aa == '*':
break
protein.append(aa)
return ''.join(protein)
translate_sequence("ATGGCTGCTTAG") # "MAA"
translate_codon.cache_info() # CacheInfo(hits=..., misses=..., maxsize=None, currsize=...)
For recursive functions (e.g. a naive Needleman-Wunsch scorer), a manual dict-backed memoizer works the same way but exposes .cache and .clear_cache directly on the wrapper — useful when args aren't hashable via lru_cache alone or you need to inspect hit count mid-run.
Stacking Decorators
Applied bottom-up (closest to the function runs first): @A @B def f is A(B(f)).
@timer # applied second — measures total time, including validation
@validate_sequence('ATGC') # applied first — runs before the body, and before timing starts
def analyze(seq: str) -> dict:
"""Full analysis of a DNA sequence."""
seq = seq.upper()
return {'length': len(seq), 'gc': (seq.count('G') + seq.count('C')) / len(seq) * 100}
analyze("ATGCGATCGATCGATCGATCG")
analyze("ATGXYZ") # raises before the timer ever prints
Closure Pattern
Goal: build a family of specialized functions (e.g. one motif counter per motif) without repeating code. Approach: an inner function defined inside an outer one keeps a live reference to the outer function's local variables even after the outer call returns.
def make_motif_counter(motif: str):
"""Return a function that counts a specific motif in any sequence."""
motif = motif.upper()
def counter(sequence: str) -> int:
return sequence.upper().count(motif)
return counter
count_cpg = make_motif_counter("CG") # counter "remembers" motif via closure
count_cpg("GCGCGCATCG") # 3
Pitfalls
- Always use
functools.wraps: without it,func.__name__becomes'wrapper', breaking logging,help(), and stack traces. - Decorator factories need 3 levels:
@validate_sequence('ATGC')requires factory → decorator → wrapper; a 2-level decorator receives the argument as the function, causing a confusingTypeError. - Stacking order matters:
@A @B def f=A(B(f)); validation should be inner (runs first, fails fast), timing outer (measures total including validation). lru_cacherequires hashable arguments: lists, dicts, and numpy arrays cannot be cached; convert totupleorbytesbefore passing.lru_cacheholds strong references: cached results are never GC'd until the cache is cleared; set a boundedmaxsizeand call.cache_clear()on long-running processes handling many distinct sequences.
See Also
python-bio-functions— higher-order functions and closures without the decorator syntax sugar.python-bio-error-handling— designing the exceptions raised inside a validation wrapper.python-bio-context-managers— thewith-statement analog for setup/teardown around code.python-bio-classes— combining decorators (@property,@staticmethod) with class-based sequence objects.