Cuaderno/Proyectos/1 · seqtool
Proyecto 1 de 10 · Fase 1

seqtool

Un programa de línea de comandos que lee archivos FASTA de verdad y calcula lo que Biopython ya sabe calcular. Lo reinventas a propósito: el objetivo no es el resultado, es aprender a separar la lógica del mundo exterior.

concepto · programa = funciones puras + entrada/salida3 sesionestyper · pathlib · pytest · github actions
Construyesseqtool stats, revcomp y find --motif sobre FASTA
AprendesLógica pura testeable + capa delgada de I/O
CostoNinguno. GitHub Actions es gratis para repos así.
Terminas conCI en verde en cada PR y un CLI instalable

Objetivos

Al terminar este proyecto vas a haber hecho:

  • Un paquete con tres módulos y responsabilidades separadas: core.py (cálculo), fasta.py (archivos), cli.py (terminal).
  • Un parser de FASTA escrito por ti, con un generador que no carga el archivo entero en memoria.
  • Un CLI con tres comandos, mensajes de error claros y códigos de salida correctos.
  • Un test que compara tu implementación contra Biopython: tu control externo.
  • Una medición real de cómo escala find_motif cuando la secuencia crece 10×.
  • Integración continua: GitHub Actions corriendo ruff y pytest en cada push y cada PR.
Por qué reinventar lo que Biopython ya hace

Porque el objetivo de este proyecto no es tener un parser de FASTA: es que cuando uses uno ajeno sepas qué hace por dentro, qué puede fallar y cómo se testea. Reinventar para aprender es legítimo; reinventar en producción casi nunca. Esa distinción va a quedar escrita en tu DECISIONES.md.

Temas que vas a usar

Los nuevos de este proyecto, en El libro de Python y en esta guía:

Antes de empezar

Necesitas el Proyecto 0 terminado: mismas herramientas, mismo flujo. Verifica:

uv --version && git --version && gh auth status && docker version --format '{{.Server.Version}}'

Y que puedas explicar, del P0: qué hace uv sync --frozen, por qué el código vive en src/, y la diferencia entre imagen y contenedor. Este proyecto asume las tres.

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 ver
Installed 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.py
import 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.py
from 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))
uv run pytest -q
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.py
from 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 ver
24 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.yml
name: 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.

Dockerfile
FROM 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.

Entiende el código

La idea de este proyecto cabe en un diagrama de tres cajas:

            terminal                    archivos                 cálculo puro
        ┌─────────────┐   llama a   ┌─────────────┐   llama a  ┌─────────────┐
  usuario → │   cli.py    │ ─────────→ │   fasta.py  │ ─────────→ │   core.py   │
        │ typer, Exit │            │ open, yield │            │  str → num  │
        └─────────────┘            └─────────────┘            └─────────────┘
        conoce la terminal         conoce archivos            no conoce nada
        y a los otros dos          y a core                   del mundo exterior
  • core.py no importa nada del proyecto (solo collections). Por eso sus tests son triviales: entra un string, sale un valor. Si mañana quieres una interfaz web en vez de CLI, core.py no cambia ni una línea.
  • fasta.py conoce archivos pero no la terminal. Lanza excepciones (FileNotFoundError, ValueError); no imprime ni decide códigos de salida. Decidir qué mostrarle al usuario es trabajo de la capa de arriba.
  • cli.py es traducción pura: argumentos → llamadas, excepciones → mensajes + exit codes. Fíjate que no tiene ni un solo if de lógica de negocio. Si un día encuentras un cálculo dentro de cli.py, algo se filtró de capa.

El generador, en cámara lenta

iter_fasta tiene yield, así que llamarla no ejecuta nada: devuelve un generador. Cada vez que alguien le pide el siguiente registro (next(), o una vuelta del for), el código avanza hasta el próximo yield y se congela ahí, con sus variables vivas. Consecuencia práctica: un FASTA de 10 GB se procesa con memoria de un registro a la vez. parse_fasta es solo list(iter_fasta(path)): la versión "cárgalo todo" construida sobre la versión "de a uno" — nunca al revés.

Detalles que valen la pena

  • @dataclass(frozen=True): un Record no se puede modificar después de creado. Datos inmutables = una fuente menos de sorpresas, y te regala == para comparar en los tests.
  • linea[1:].split()[0]: de >seq1 fragmento con sitio EcoRI se queda con seq1. El id es hasta el primer espacio; la descripción se descarta (decisión anotable: Biopython la conserva).
  • enumerate(f, start=1): el número de línea viaja con la línea, y el error línea 1: secuencia sin cabecera le dice al usuario exactamente dónde mirar.
  • find_motif con ventana deslizante encuentra motivos solapados (AA en AAAA → 3 veces). str.count() no los cuenta: por eso no lo usamos. Detalle que un test fija para siempre.

Con Claude

El concepto nuevo (separar lógica de I/O) se escribe a mano: core.py, fasta.py y cli.py son tuyos. Claude entra para entender, cuestionar y proponer tests que no se te ocurrieron. Copia el CLAUDE.md de P0 y agrega una regla:

CLAUDE.md (agregar a las reglas de P0)
- core.py y fasta.py no deben importar typer ni usar print. Si un cambio
  tuyo o mío rompe esa separación, señálalo antes de continuar.

Pedidos que sí valen la pena en este proyecto

Lee src/seqtool/fasta.py. ¿Qué archivos FASTA reales lo romperían? Dame 5 casos borde que mis tests no cubren, ordenados por probabilidad. No escribas los tests: descríbelos. Explícame qué pasa exactamente cuando hago next() sobre iter_fasta, línea por línea, con el archivo de ejemplo. ¿Dónde queda "congelada" la función entre llamadas? ¿Por qué typer.Exit y no sys.exit? ¿Qué diferencia hay para quien testea con CliRunner? Mi find_motif es O(n·m) en el peor caso, no O(n). ¿Con qué entrada se nota la diferencia? ¿Existe un algoritmo mejor y cuándo valdría la pena? Compara mi parse_fasta con Bio.SeqIO.parse: ¿qué decisiones tomó Biopython distinto (ids, descripciones, mayúsculas, errores) y por qué crees que las tomaron?

Cierre del proyecto: pídele "revisa el repo como revisor de PR exigente: tres cosas que un profesional haría distinto y por qué", y lleva sus respuestas (con tu opinión sobre cada una) a la sesión con Rodrigo.

Si algo falla

Antes de buscar aquí: traceback de abajo hacia arriba, hipótesis, y recién entonces la lista.

seqtool: command not found o typer no encuentra app

El comando nace de [project.scripts]seqtool = "seqtool.cli:app". Tres causas típicas: (1) corriste seqtool a secas en vez de uv run seqtool; (2) escribiste la sección después de crear el entorno — corre uv sync para que se reinstale el paquete y aparezca el script; (3) el objeto en cli.py no se llama app. El formato es modulo:objeto.

El test de stats falla: GC=53.8% esperado pero calculaste otro número

Cuenta a mano: ATGGAATTCGGCC tiene 13 bases, de las cuales G+C = 7 → 7/13 = 53.8%. Si te da 61.5% u otro valor, revisa si estás contando las N en el denominador (seq1 no tiene, pero seq3 sí) o si tu base_counts cuenta antes de pasar a mayúsculas. Este tipo de "número esperado mal calculado por mí" es el error más común al escribir tests: el test te obliga a resolver el desacuerdo.

ruff format --check falla en CI pero local "se ve bien"

Local corriste ruff format . (que arregla) pero commiteaste antes de formatear, o nunca lo corriste. El CI usa --check: no arregla, solo falla. Corre uv run ruff format ., mira el diff con git diff, commitea. Truco: corre siempre ruff check . && ruff format . antes de cada commit, en ese orden.

En el contenedor: error: no existe el archivo ejemplo.fasta

Es el comportamiento esperado si no montaste el volumen. El contenedor tiene su propio sistema de archivos; tu disco no existe adentro. Montaje: -v "$PWD/tests/data:/data" significa "mi carpeta tests/data aparece como /data dentro". Como el WORKDIR final es /data, ahí busca seqtool los archivos. Ruta local siempre absoluta (por eso $PWD).

ValueError: línea 1: secuencia sin cabecera '>' antes con un FASTA que "se ve bien"

Casi siempre es un BOM (tres bytes invisibles que Excel y algunos editores ponen al inicio): la primera línea ya no empieza con > aunque lo parezca. Compruébalo con head -c 10 archivo.fasta | xxd — si ves efbb bf, es eso. Solución: abre con encoding="utf-8-sig" (y anota la decisión), o guarda el archivo como UTF-8 sin BOM.

El CI falla en uv sync: error: Failed to parse uv.lock o lock desactualizado

El uv.lock commiteado no coincide con pyproject.toml (agregaste una dependencia y no commiteaste el lock nuevo, o lo editaste a mano — nunca se edita a mano). Corre uv lock local, commitea el resultado y push. El lock es parte del código: viaja junto.

gh run watch se queda esperando o no muestra nada

El workflow solo corre si el YAML está en .github/workflows/ (con puntito y en plural) y ese commit llegó a GitHub. Verifica con git push y gh run list. Si el YAML tiene un error de sintaxis, GitHub lo marca en la pestaña Actions del repo con el detalle exacto.

Listo cuando

Las mismas casillas que en el índice; marcarlas aquí las marca allá.

La pregunta de Rodrigo en la sesión

"Te pido que stats también acepte leer de la entrada estándar (un pipe, sin archivo). ¿Qué archivos tocas y cuáles no deberían cambiar ni una línea?" Si tu respuesta es "solo cli.py, y quizá una función nueva en fasta.py que reciba un objeto de texto en vez de una ruta", entendiste la separación.

Siguiente

En el Proyecto 2 · Reporte de placa dejas los datos de juguete: un export real de tu trabajo, con celdas vacías, unidades mezcladas y filas de comentarios. El concepto nuevo es validar en el borde y fallar temprano — y la separación que acabas de aprender (load → transform → report) es la misma de aquí, con pandas en el medio.

Extensión opcional de este proyecto: uv build genera un wheel en dist/; instálalo como herramienta global con uv tool install dist/seqtool-0.1.0-py3-none-any.whl y tendrás seqtool disponible en cualquier carpeta, sin uv run. Con eso entiendes qué es exactamente "un paquete": un zip con tu código más metadatos que dicen cómo instalarlo.