Construcción paso a paso
Igual que en P0: escribe los archivos tú misma, corre cada comando, compara con "Deberías ver". La novedad es que ahora son tres módulos y el orden importa: primero la lógica, después los archivos, al final la terminal. Nunca al revés.
Paso 1 Crear el proyecto
mkdir seqtool
cd seqtool
git init
uv python pin 3.12
mkdir -p src/seqtool tests/data
pyproject.toml[project]
name = "seqtool"
version = "0.1.0"
description = "Proyecto 1: CLI de secuencias de ADN, hecho a mano a propósito"
requires-python = ">=3.12"
dependencies = []
[project.scripts]
seqtool = "seqtool.cli:app"
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
[tool.hatch.build.targets.wheel]
packages = ["src/seqtool"]
[tool.pytest.ini_options]
testpaths = ["tests"]
[tool.ruff]
line-length = 100
La sección nueva respecto a P0 es [project.scripts]: declara que al instalar este paquete debe existir un comando seqtool que ejecuta el objeto app del módulo seqtool.cli. Todavía no existe ese módulo; no importa, lo declaramos primero.
Copia el .gitignore del P0 tal cual, crea el paquete y agrega las dependencias. Typer es la única de producción; Biopython entra como dependencia de desarrollo porque solo la usan los tests:
touch src/seqtool/__init__.py
uv add typer
uv add --dev pytest ruff biopython
Deberías verInstalled 8 packages in …
+ typer==0.27.1
…
+ biopython==1.88
+ pytest==9.1.1
+ ruff==0.16.3
Producción vs desarrollo. dependencies es lo que el programa necesita para funcionar (typer). --dev es lo que tú necesitas para desarrollarlo (pytest, ruff, biopython de control). La distinción paga en el paso 8: la imagen Docker instala solo producción y pesa menos.
git add . && git commit -m "Estructura del proyecto seqtool"
Paso 2 La lógica: core.py, tests primero
Ya conoces gc_content y reverse_complement del P0. Aquí las mejoras (las secuencias reales traen N: base indeterminada) y agregas dos funciones. Los tests definen el comportamiento antes de escribirlo:
tests/test_core.pyimport pytest
from seqtool.core import base_counts, find_motif, gc_content, reverse_complement
def test_gc_content_mitad():
assert gc_content("ATGC") == 0.5
def test_gc_content_ignora_minusculas():
assert gc_content("atgc") == 0.5
def test_gc_content_secuencia_vacia_lanza_error():
with pytest.raises(ValueError):
gc_content("")
def test_gc_content_ignora_n_en_el_denominador():
# ATGNNNGCC: 9 bases, 3 son N; de las 6 válidas, 4 son G/C
assert gc_content("ATGNNNGCC") == pytest.approx(4 / 6)
def test_reverse_complement_basico():
assert reverse_complement("ATGC") == "GCAT"
def test_reverse_complement_conserva_n():
assert reverse_complement("ATGN") == "NCAT"
def test_reverse_complement_base_invalida_lanza_error():
with pytest.raises(ValueError):
reverse_complement("ATXG")
def test_find_motif_devuelve_posiciones_0_based():
assert find_motif("ATGGAATTCGGCC", "GAATTC") == [3]
def test_find_motif_varias_veces_y_solapadas():
assert find_motif("AAAA", "AA") == [0, 1, 2]
def test_find_motif_sin_coincidencia_devuelve_lista_vacia():
assert find_motif("ATGC", "GGG") == []
def test_find_motif_ignora_minusculas():
assert find_motif("atggaattc", "GAATTC") == [3]
def test_find_motif_motivo_vacio_lanza_error():
with pytest.raises(ValueError):
find_motif("ATGC", "")
def test_base_counts():
assert base_counts("ATGCNa") == {"A": 2, "C": 1, "G": 1, "T": 1, "N": 1}
Cuenta las posiciones con el dedo antes de escribir el número. ¿Dónde empieza GAATTC en ATGGAATTCGGCC? A=0, T=1, G=2, G=3… la G de GAATTC está en el índice 3. El primer número que escribí yo fue 4, y el test me corrigió. Para eso son: también atrapan tus errores de esperado, no solo los del código.
Corre uv run pytest: rojo (ModuleNotFoundError). Ahora la implementación:
src/seqtool/core.py"""Lógica sobre secuencias de ADN. Funciones puras: entra texto, sale un valor.
Aquí no se leen archivos, no se imprime nada y no se sabe qué es una terminal.
"""
from collections import Counter
COMPLEMENTO = {"A": "T", "T": "A", "G": "C", "C": "G", "N": "N"}
def gc_content(seq: str) -> float:
"""Fracción de G y C entre las bases válidas (A, C, G, T). Las N no cuentan.
Lanza ValueError si la secuencia está vacía o no tiene bases válidas.
"""
if not seq:
raise ValueError("la secuencia está vacía")
seq = seq.upper()
validas = sum(seq.count(b) for b in "ACGT")
if validas == 0:
raise ValueError("la secuencia no tiene bases A/C/G/T")
return (seq.count("G") + seq.count("C")) / validas
def reverse_complement(seq: str) -> str:
"""Complemento reverso (5'→3'). Acepta N. Lanza ValueError ante otras bases."""
seq = seq.upper()
invalidas = set(seq) - set(COMPLEMENTO)
if invalidas:
raise ValueError(f"bases inválidas: {sorted(invalidas)}")
return "".join(COMPLEMENTO[b] for b in reversed(seq))
def find_motif(seq: str, motif: str) -> list[int]:
"""Posiciones (0-based) donde empieza el motivo, incluyendo solapadas."""
if not motif:
raise ValueError("el motivo está vacío")
seq, motif = seq.upper(), motif.upper()
posiciones = []
for i in range(len(seq) - len(motif) + 1):
if seq[i : i + len(motif)] == motif:
posiciones.append(i)
return posiciones
def base_counts(seq: str) -> dict[str, int]:
"""Cuántas veces aparece cada base, en mayúsculas."""
return dict(Counter(seq.upper()))
uv run pytest -q tests/test_core.py
Deberías ver............. [100%]
13 passed in 0.01s
git add . && git commit -m "core: gc_content con soporte de N, revcomp, find_motif, base_counts"
Paso 3 Leer FASTA a mano
Primero el archivo de prueba. Tiene los tres casos raros que un FASTA real trae: descripción después del id, secuencia partida en varias líneas y en minúsculas, y bases N:
tests/data/ejemplo.fasta>seq1 fragmento con sitio EcoRI
ATGGAATTCGGCC
>seq2 en minusculas y partida en dos lineas
atgcatgc
gaattc
>seq3 con bases indeterminadas
ATGNNNGCC
tests/test_fasta.pyfrom pathlib import Path
import pytest
from seqtool.fasta import Record, iter_fasta, parse_fasta
DATA = Path(__file__).parent / "data"
def test_parse_fasta_lee_tres_registros():
registros = parse_fasta(DATA / "ejemplo.fasta")
assert len(registros) == 3
assert registros[0] == Record(id="seq1", seq="ATGGAATTCGGCC")
def test_parse_fasta_une_lineas_y_pasa_a_mayusculas():
registros = parse_fasta(DATA / "ejemplo.fasta")
assert registros[1].id == "seq2"
assert registros[1].seq == "ATGCATGCGAATTC"
def test_iter_fasta_es_un_generador():
gen = iter_fasta(DATA / "ejemplo.fasta")
primero = next(gen)
assert primero.id == "seq1"
def test_parse_fasta_archivo_inexistente_lanza_error():
with pytest.raises(FileNotFoundError):
parse_fasta(DATA / "no_existe.fasta")
def test_parse_fasta_sin_cabecera_lanza_error(tmp_path):
malo = tmp_path / "malo.fasta"
malo.write_text("ATGC\nGGCC\n")
with pytest.raises(ValueError, match="cabecera"):
parse_fasta(malo)
def test_parse_fasta_vacio_devuelve_lista_vacia(tmp_path):
vacio = tmp_path / "vacio.fasta"
vacio.write_text("")
assert parse_fasta(vacio) == []
tmp_path es tu primera fixture. pytest te regala una carpeta temporal limpia por test: ahí fabricas archivos malformados sin ensuciar tests/data/. Los datos buenos van en data/; los rotos se fabrican al vuelo.
src/seqtool/fasta.py"""Leer archivos FASTA. Aquí sí hay archivos, pero todavía nada de terminal."""
from collections.abc import Iterator
from dataclasses import dataclass
from pathlib import Path
@dataclass(frozen=True)
class Record:
id: str
seq: str
def iter_fasta(path: Path) -> Iterator[Record]:
"""Recorre un FASTA registro por registro sin cargar el archivo entero en memoria."""
id_actual: str | None = None
partes: list[str] = []
with open(path, encoding="utf-8") as f:
for numero, linea in enumerate(f, start=1):
linea = linea.strip()
if not linea:
continue
if linea.startswith(">"):
if id_actual is not None:
yield Record(id_actual, "".join(partes))
id_actual = linea[1:].split()[0]
partes = []
elif id_actual is None:
raise ValueError(f"línea {numero}: secuencia sin cabecera '>' antes")
else:
partes.append(linea.upper())
if id_actual is not None:
yield Record(id_actual, "".join(partes))
def parse_fasta(path: Path) -> list[Record]:
"""Lee un FASTA completo a una lista. Cómodo para archivos chicos."""
return list(iter_fasta(path))
Deberías ver................... [100%]
19 passed in 0.01s
git add . && git commit -m "fasta: parser propio con iter_fasta generador y parse_fasta"
Paso 4 El CLI con typer
Recién ahora la terminal. Fíjate en el patrón: cli.py no calcula nada; traduce argumentos → llamadas a core/fasta → texto en pantalla, y errores → mensajes + código de salida.
src/seqtool/cli.py"""Entrada/salida: argumentos, archivos, pantalla. La capa delgada y aburrida."""
from pathlib import Path
import typer
from seqtool import core, fasta
app = typer.Typer(
help="Herramientas simples para secuencias de ADN en FASTA.", no_args_is_help=True
)
def _leer(archivo: Path) -> list[fasta.Record]:
"""Lee el FASTA o termina el programa con un mensaje claro y código de salida 1."""
try:
return fasta.parse_fasta(archivo)
except FileNotFoundError:
typer.echo(f"error: no existe el archivo {archivo}", err=True)
raise typer.Exit(code=1)
except ValueError as e:
typer.echo(f"error: FASTA malformado ({e})", err=True)
raise typer.Exit(code=1)
@app.command()
def stats(archivo: Path) -> None:
"""Longitud, %GC y conteo por base de cada secuencia."""
for r in _leer(archivo):
try:
gc = f"{core.gc_content(r.seq):.1%}"
except ValueError:
gc = "n/a"
conteo = " ".join(f"{b}={n}" for b, n in sorted(core.base_counts(r.seq).items()))
typer.echo(f"{r.id}\tlen={len(r.seq)}\tGC={gc}\t{conteo}")
@app.command()
def revcomp(archivo: Path) -> None:
"""Complemento reverso de cada secuencia, en formato FASTA."""
for r in _leer(archivo):
try:
typer.echo(f">{r.id}_rc\n{core.reverse_complement(r.seq)}")
except ValueError as e:
typer.echo(f"error: {r.id}: {e}", err=True)
raise typer.Exit(code=1)
@app.command()
def find(
archivo: Path,
motif: str = typer.Option(..., "--motif", "-m", help="Motivo a buscar, ej. GAATTC"),
) -> None:
"""Posiciones (0-based) donde aparece el motivo en cada secuencia."""
if not motif:
typer.echo("error: el motivo no puede estar vacío", err=True)
raise typer.Exit(code=2)
for r in _leer(archivo):
pos = core.find_motif(r.seq, motif)
typer.echo(f"{r.id}\t{len(pos)}\t{','.join(map(str, pos)) or '-'}")
Y sus tests, con el CliRunner que trae typer (corre el CLI en memoria, sin abrir procesos):
tests/test_cli.pyfrom pathlib import Path
from typer.testing import CliRunner
from seqtool.cli import app
DATA = Path(__file__).parent / "data"
runner = CliRunner()
def test_stats_imprime_una_linea_por_secuencia():
resultado = runner.invoke(app, ["stats", str(DATA / "ejemplo.fasta")])
assert resultado.exit_code == 0
lineas = resultado.stdout.strip().splitlines()
assert len(lineas) == 3
assert lineas[0].startswith("seq1\tlen=13\tGC=53.8%")
def test_find_encuentra_ecori():
resultado = runner.invoke(app, ["find", str(DATA / "ejemplo.fasta"), "--motif", "GAATTC"])
assert resultado.exit_code == 0
assert "seq1\t1\t3" in resultado.stdout
assert "seq3\t0\t-" in resultado.stdout
def test_revcomp_devuelve_fasta():
resultado = runner.invoke(app, ["revcomp", str(DATA / "ejemplo.fasta")])
assert resultado.exit_code == 0
assert ">seq1_rc\nGGCCGAATTCCAT" in resultado.stdout
def test_archivo_inexistente_sale_con_codigo_1():
resultado = runner.invoke(app, ["stats", "no_existe.fasta"])
assert resultado.exit_code == 1
assert "no existe el archivo" in resultado.output
def test_motivo_vacio_sale_con_codigo_2():
resultado = runner.invoke(app, ["find", str(DATA / "ejemplo.fasta"), "--motif", ""])
assert resultado.exit_code == 2
Pruébalo de verdad. El comando seqtool existe porque lo declaraste en [project.scripts]:
uv run pytest -q
uv run seqtool stats tests/data/ejemplo.fasta
uv run seqtool find tests/data/ejemplo.fasta --motif GAATTC
uv run seqtool stats no_existe.fasta; echo "exit=$?"
Deberías ver24 passed in 0.04s
seq1 len=13 GC=53.8% A=3 C=3 G=4 T=3
seq2 len=14 GC=42.9% A=4 C=3 G=3 T=4
seq3 len=9 GC=66.7% A=1 C=2 G=2 N=3 T=1
seq1 1 3
seq2 1 8
seq3 0 -
error: no existe el archivo no_existe.fasta
exit=1
Códigos de salida. 0 = todo bien; cualquier otro número = error. Los scripts y los pipelines (y el CI del paso 7) deciden con ese número, no leyendo el mensaje. Un CLI que falla con exit 0 es un CLI que miente. Y los errores van a stderr (err=True), no a stdout: así puedes redirigir la salida útil a un archivo sin que se mezclen los errores.
git add . && git commit -m "cli: stats, revcomp y find con errores claros y exit codes"
Paso 5 Control externo: Biopython
En el laboratorio validas un método nuevo contra el método de referencia. Aquí igual: tu gc_content contra Bio.SeqUtils.gc_fraction, tu reverse_complement contra Seq.reverse_complement().
tests/test_contra_biopython.py"""Control externo: nuestra implementación contra una librería madura."""
from Bio.Seq import Seq
from Bio.SeqUtils import gc_fraction
from seqtool.core import gc_content, reverse_complement
def test_gc_content_coincide_con_biopython():
for seq in ["ATGC", "GGCC", "ATATAT", "atggaattcggcc"]:
assert gc_content(seq) == gc_fraction(seq)
def test_reverse_complement_coincide_con_biopython():
for seq in ["ATGC", "GGAATTCC", "atgn"]:
assert reverse_complement(seq) == str(Seq(seq).reverse_complement()).upper()
uv run pytest -q tests/test_contra_biopython.py
Deberías ver.. [100%]
2 passed in 0.01s
Pregunta antes de mirar: ¿qué hace gc_fraction con las N? ¿Las cuenta en el denominador o no? Escribe tu hipótesis, luego pruébala con uv run python -c "from Bio.SeqUtils import gc_fraction; print(gc_fraction('ATGNNNGCC'))". Si difiere de tu implementación, tienes una decisión documentable: ¿quién tiene razón para tu caso de uso? No hay una respuesta única; hay una decisión anotada.
git add . && git commit -m "tests: control externo contra Biopython"
Paso 6 Medir la complejidad, no adivinarla
find_motif recorre la secuencia una vez: es O(n). La teoría dice que 10× más secuencia ≈ 10× más tiempo. Compruébalo:
scripts/medir_find_motif.py"""¿Cuánto crece el tiempo de find_motif si la secuencia crece 10x? Medirlo, no adivinarlo."""
import random
import time
from seqtool.core import find_motif
random.seed(1)
for n in (100_000, 1_000_000, 10_000_000):
seq = "".join(random.choice("ACGT") for _ in range(n))
inicio = time.perf_counter()
hallazgos = find_motif(seq, "GAATTC")
segundos = time.perf_counter() - inicio
print(f"n={n:>10,} hallazgos={len(hallazgos):>6,} tiempo={segundos:.3f}s")
uv run python scripts/medir_find_motif.py
Deberías ver (números parecidos, no idénticos)n= 100,000 hallazgos= 40 tiempo=0.004s
n= 1,000,000 hallazgos= 243 tiempo=0.038s
n=10,000,000 hallazgos= 2,475 tiempo=0.386s
Lee la columna de tiempo: 0.004 → 0.038 → 0.386. Cada salto de 10× en n multiplica el tiempo por ~10. Eso es O(n), medido en tu máquina. Dos observaciones más: random.seed(1) hace la medición reproducible (mismos datos cada corrida), y ~2.475 hallazgos en 10 millones de bases cuadra con la teoría (un motivo de 6 bases aparece por azar 1 vez cada 4⁶ = 4.096). Cuando en el P9 un índice vectorial te prometa "más rápido que comparar contra todo", vas a saber exactamente qué te está vendiendo.
git add . && git commit -m "scripts: medición de escalado de find_motif (O(n) verificado)"
Paso 7 CI: tus tests corren solos en cada PR
Hasta ahora los tests corren cuando tú te acuerdas. GitHub Actions los corre siempre: en cada push y en cada PR, en una máquina limpia de GitHub. Un archivo YAML lo configura:
.github/workflows/ci.ymlname: CI
on:
push:
branches: [main]
pull_request:
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: astral-sh/setup-uv@v6
with:
python-version: "3.12"
- run: uv sync
- run: uv run ruff check .
- run: uv run ruff format --check .
- run: uv run pytest
Léelo como un protocolo: en un Ubuntu recién creado (runs-on), clona el repo (checkout), instala uv y Python 3.12 (setup-uv), instala dependencias, y corre exactamente los tres comandos que tú corres a mano. Si cualquiera falla, el PR se marca en rojo.
git add . && git commit -m "ci: ruff y pytest en cada push y PR"
gh repo create seqtool --private --source . --push
gh run watch
Deberías ver (tras ~1 minuto)✓ main CI · …
✓ test
✓ Run CI (…) completed with 'success'
Por qué importa aunque trabajes sola. El CI corre en una máquina que no es la tuya: si pasa ahí, tu uv.lock y tu código son autosuficientes de verdad. Y desde hoy, "los tests pasan" deja de ser una afirmación tuya y pasa a ser un check verde que Rodrigo ve en el PR sin preguntarte.
Paso 8 Docker: el CLI sobre archivos montados
En P0 el contenedor corría tests. Aquí corre el programa, y aparece el problema nuevo: el contenedor no ve tus archivos. Se los prestas con -v (un volumen). Nota también --no-dev: la imagen no lleva pytest, ruff ni biopython.
DockerfileFROM python:3.12-slim
COPY --from=ghcr.io/astral-sh/uv:latest /uv /usr/local/bin/uv
WORKDIR /app
COPY pyproject.toml uv.lock ./
COPY src ./src
# Solo lo que necesita el programa (sin pytest, ruff ni biopython)
RUN uv sync --frozen --no-dev
# El script `seqtool` quedó instalado en el entorno virtual de la imagen
ENV PATH="/app/.venv/bin:$PATH"
# Carpeta donde el usuario monta sus archivos con -v
WORKDIR /data
ENTRYPOINT ["seqtool"]
CMD ["--help"]
Copia el .dockerignore de P0. Construye y prueba las tres variantes:
docker build -t seqtool .
docker run --rm seqtool
docker run --rm -v "$PWD/tests/data:/data" seqtool stats ejemplo.fasta
docker run --rm seqtool stats ejemplo.fasta; echo "exit=$?"
Deberías ver# sin argumentos: la ayuda
Usage: seqtool [OPTIONS] COMMAND [ARGS]...
# con -v: tus archivos aparecen en /data dentro del contenedor
seq1 len=13 GC=53.8% A=3 C=3 G=4 T=3
seq2 len=14 GC=42.9% A=4 C=3 G=3 T=4
seq3 len=9 GC=66.7% A=1 C=2 G=2 N=3 T=1
# sin -v: el contenedor NO ve tu disco — esta es la lección
error: no existe el archivo ejemplo.fasta
exit=1
ENTRYPOINT vs CMD. ENTRYPOINT es fijo (siempre corre seqtool); CMD es el argumento por defecto (--help) que se reemplaza por lo que escribas después del nombre de la imagen. Por eso docker run seqtool stats ejemplo.fasta ejecuta seqtool stats ejemplo.fasta. Y el error final no es un fallo: es la demostración de que el contenedor está aislado. Tu disco entra solo si tú lo montas.
git add . && git commit -m "docker: imagen del CLI con datos montados por volumen"
Paso 9 README, decisiones y el PR final
Escribe el README con el mismo esqueleto de P0 (propósito, cómo correr, cómo testear, estructura, decisiones) agregando los ejemplos de uso del CLI y la tabla del paso 6. En DECISIONES.md, la entrada importante de este proyecto:
DECISIONES.md (fragmento)## 2026-08-xx · Parser FASTA propio, Biopython solo como control
Reinventé el parser para aprender qué hay dentro (cabeceras, líneas
partidas, mayúsculas, errores de formato). En un proyecto real usaría
Bio.SeqIO: está mantenido, cubre variantes que el mío no (FASTQ,
alineamientos, archivos comprimidos) y lo conocen más personas.
Regla que me llevo: reinventar para aprender, sí; en producción, la
librería madura. Los tests contra gc_fraction se quedan como control.
Todo este proyecto debió avanzar en ramas con PR (como aprendiste en P0, paso 8). Si commiteaste directo a main, no pasa nada: la próxima vez, rama primero. Cierra con un PR real — por ejemplo, agrega el comando seqtool stats --json en una rama feat/stats-json, con test primero, y pídele revisión a Rodrigo:
git switch -c feat/stats-json
# … test primero, luego el cambio en cli.py …
git push -u origin feat/stats-json
gh pr create --title "stats --json" --reviewer rotorrest
Mira el check de CI aparecer en el PR. Esa es la diferencia con P0: ahora la máquina revisa antes que Rodrigo.