Funciones, paquetes y APIs

Funciones

Sin darte cuenta, llevas todo el curso usando funciones: print(), len(), type(), int(), sum()… Una función es un bloque de código con nombre que hace una tarea concreta. Para usarla (llamarla) escribes su nombre seguido de paréntesis, y dentro le pasas los datos con los que tiene que trabajar: sus argumentos. La función hace su trabajo y devuelve un resultado, que puedes guardar en una variable.

Argumentos posicionales y con nombre

Algunas funciones aceptan varios argumentos. Los posicionales se identifican por su orden; los argumentos con nombre se escriben como nombre=valor y suelen ser opcionales, porque tienen un valor por defecto:

Devolver no es lo mismo que imprimir

print() muestra un valor en pantalla, pero no lo devuelve: si guardas su resultado en una variable, obtienes None. En cambio, len() devuelve un valor con el que puedes seguir trabajando.

Definir tus propias funciones

Cuando repites el mismo cálculo varias veces, conviene convertirlo en una función. Se definen con def, el nombre de la función, sus parámetros entre paréntesis y dos puntos. El cuerpo de la función va indentado (igual que en un if o un for), y return indica qué valor devuelve:

Fíjate en que hemos escrito el cálculo una sola vez y lo podemos reutilizar con cualquier secuencia. El texto entre triples comillas es la docstring: una descripción de la función que aparece al usar help(contenido_gc).

Los parámetros también pueden tener un valor por defecto, que se usa si al llamar a la función no se indica otro:

Advertencia¡Atención!

Las variables que creas dentro de una función (como num_gc) solo existen dentro de ella. Si intentas usarlas fuera, Python dará un error NameError. Lo que quieras sacar de la función, devuélvelo con return.

TipCuestión

Mejora la función contenido_gc para que funcione aunque la secuencia tenga minúsculas y espacios en blanco. Añade un parámetro decimales (por defecto, 2) para redondear el resultado.

Recuerda los métodos de cadenas .upper() y .replace(), y la función round().

TipCuestión

Escribe una función media_sin_faltantes(valores, faltante=99) que calcule la media de una lista ignorando los valores faltantes.

Módulos y paquetes

Python trae de serie unas pocas funciones básicas (print, len, sum…), pero la mayor parte de sus herramientas están organizadas en módulos: archivos de Python que agrupan funciones relacionadas. Un paquete es simplemente un conjunto de módulos. Hay dos tipos:

  • La biblioteca estándar: módulos que vienen instalados con Python, como math, random, statistics, csv o json.
  • Paquetes externos: los escribe la comunidad y hay que instalarlos, como requests (peticiones web), pandas (tablas de datos) o biopython (bioinformática).

Importar

Para usar un módulo primero hay que importarlo con import. Después, sus funciones se llaman con la sintaxis modulo.funcion():

Hay otras formas de importar:

Igual que con las cadenas, puedes explorar un módulo con dir() y consultar su documentación con help():

TipCuestión

Un cultivo de E. coli se duplica cada 20 minutos. Usa el módulo math para calcular cuántos minutos tarda en pasar de 1000 a 1 000 000 de células, sin usar bucles.

El número de duplicaciones necesarias es el logaritmo en base 2 de final / inicial.

Instalar paquetes externos

Los paquetes externos se instalan una sola vez desde la terminal con pip, el gestor de paquetes de Python:

~$ pip install requests

Una vez instalado, se importa como cualquier otro módulo. Si intentas importar un paquete que no está instalado, obtendrás un error ModuleNotFoundError.

Un ejemplo: requests

requests es un paquete externo para hacer peticiones a páginas web desde Python. Su función más importante es requests.get(): recibe como argumento una dirección web (URL) y devuelve la respuesta del servidor.

import requests

respuesta = requests.get("https://rest.ensembl.org/info/ping?content-type=application/json")
print(respuesta.status_code)
print(respuesta.text)
200
{"ping":1}

Lo que devuelve requests.get() no es un número ni una cadena, sino un objeto de tipo Response que agrupa toda la información de la respuesta. Como con las cadenas y las listas, accedes a ella con un punto: .status_code es un valor (el código de estado, 200 si todo ha ido bien), .text es el contenido de la respuesta como cadena, y .json() es un método que convierte ese contenido en un diccionario. Puedes ver todo lo que ofrece con dir(respuesta).

Y como requests.get() es una función más, puedes combinarla con tus propias funciones:

import requests

def descargar_json(url):
    """Descarga una URL y devuelve su contenido como diccionario, o None si falla."""
    respuesta = requests.get(url)
    if respuesta.status_code == 200:
        return respuesta.json()
    return None

datos = descargar_json("https://rest.ensembl.org/info/ping?content-type=application/json")
print(datos)
{'ping': 1}

La dirección que le hemos pasado a requests.get() no es una página web normal, sino un servicio pensado para que lo consulten programas: una API. En el resto del tema veremos qué son y cómo sacarles partido.

¿Qué es una API?

Hasta ahora has trabajado con archivos que ya tenías en tu ordenador. Pero gran parte de los datos biológicos que necesitarás — secuencias de referencia, anotaciones de genes, registros de biodiversidad — viven en bases de datos públicas enormes, accesibles por internet. Podrías abrir un navegador, buscar a mano y copiar/pegar, pero eso no escala: si necesitas los datos de 500 genes, no vas a hacerlo 500 veces a mano.

Una API (interfaz de programación de aplicaciones) es una puerta de entrada pensada para programas, no para personas: le envías una petición a una dirección concreta (un endpoint) y te responde con datos en un formato que un programa puede procesar directamente, normalmente JSON. Es como pedir un plato por su nombre en vez de entrar a la cocina a buscarlo tú mismo.

El formato JSON

JSON representa datos con la misma forma que ya conoces de Python: objetos como diccionarios ({clave: valor}) y listas ([...]). Esta respuesta de ejemplo:

{
  "id": "ENSG00000139618",
  "display_name": "BRCA2",
  "species": "homo_sapiens",
  "start": 32315086,
  "end": 32400268
}

… se convierte en Python, prácticamente sin cambios, en el diccionario {"id": "ENSG00000139618", "display_name": "BRCA2", "species": "homo_sapiens", "start": 32315086, "end": 32400268}. Por eso, en cuanto tengas una respuesta JSON en Python, la tratarás exactamente como los diccionarios del módulo 8: respuesta["display_name"], respuesta.get(...), for clave, valor in respuesta.items():.

Anatomía de una petición: descargar un FASTA de NCBI

Las APIs no siempre devuelven JSON. El servicio E-utilities de NCBI, por ejemplo, puede devolver directamente una secuencia en formato FASTA. Fíjate en la URL que usaríamos para pedir los primeros 200 pares de bases del genoma de E. coli:

https://eutils.ncbi.nlm.nih.gov/entrez/eutils/efetch.fcgi?db=nuccore&id=NC_000913.3&rettype=fasta&retmode=text&seq_stop=200

La URL tiene dos partes: la dirección base del endpoint (.../efetch.fcgi) y, tras el ?, los parámetros separados por &: qué base de datos (db=nuccore), qué accession (id=...), en qué formato quieres la respuesta (rettype=fasta&retmode=text) y seq_stop=200, que limita la descarga a los primeros 200 pares de bases para no traer un genoma completo en un ejercicio de clase.

Escribir todos esos parámetros a mano es incómodo y fácil de equivocar. requests permite pasarlos como un diccionario con el argumento params, y se encarga de construir la URL por ti:

import requests

url = "https://eutils.ncbi.nlm.nih.gov/entrez/eutils/efetch.fcgi"
parametros = {
    "db": "nuccore",
    "id": "NC_000913.3",
    "rettype": "fasta",
    "retmode": "text",
    "seq_stop": 200,
}
respuesta = requests.get(url, params=parametros)

print(respuesta.url)    # la URL completa que ha construido requests
print(respuesta.text)
https://eutils.ncbi.nlm.nih.gov/entrez/eutils/efetch.fcgi?db=nuccore&id=NC_000913.3&rettype=fasta&retmode=text&seq_stop=200
>NC_000913.3:1-200 Escherichia coli str. K-12 substr. MG1655, complete genome
AGCTTTTCATTCTGACTGCAACGGGCAATATGTCTCTGTGTGGATTAAAAAAAGAGTGTCTGATAGCAGC
TTCTGAACTGGTTACCTGCCGTGAGTAAATTAAAATTTTATTGACTTAGGTCACTAAATACTTTAACCAA
TATAGGCATAGCGCACAGACAGATAAAAATTACAGAGTACACAACATCCATGAAACGCAT

Como la respuesta es texto (no JSON), usamos .text en lugar de .json(). Y como .text es una cadena normal, puedes guardarla en un archivo tal como viste en el módulo 10:

with open("genoma_ecoli.fasta", "w") as f:
    f.write(respuesta.text)

Consultar una API JSON: Ensembl

La mayoría de APIs modernas responden en JSON. Ensembl, por ejemplo, devuelve la información de un gen a partir de su identificador:

import requests

url = "https://rest.ensembl.org/lookup/id/ENSG00000139618"
respuesta = requests.get(url, params={"content-type": "application/json"})

print(respuesta.status_code)
datos = respuesta.json()
print(datos["display_name"])
200
BRCA2

requests.get() hace la petición. .status_code es el código de estado HTTP: 200 significa “todo correcto”; los códigos que empiezan por 4 indican un problema con tu petición, como 404 (“no encontrado”) o 400 (“petición incorrecta”, que es lo que devuelve Ensembl si el ID no existe). Los que empiezan por 5, como 500, indican un problema del servidor: no es culpa tuya, y suele bastar con volver a intentarlo más tarde. .json() convierte el texto de la respuesta directamente en un diccionario de Python, listo para usar como cualquier otro.

Comprueba siempre el status_code

Antes de asumir que .json() va a funcionar, comprueba que la petición salió bien — el mismo hábito de if/else del módulo 9, aplicado a peticiones de red:

if respuesta.status_code == 200:
    datos = respuesta.json()
    print(datos["display_name"])
else:
    print(f"Error {respuesta.status_code}: no se pudo obtener el gen")

Si la petición falla, la respuesta no contiene los datos que esperas: intentar acceder a datos["display_name"] provocaría un error confuso. Comprobar status_code primero te da un mensaje claro sobre qué ha ido mal. Prueba, por ejemplo, con el ID inexistente ENSG00000000000:

Error 400: no se pudo obtener el gen

Guardar la respuesta en un archivo

Ya sabes escribir archivos (módulo 10). Para JSON, en vez de escribir el texto crudo, usa el módulo json de la librería estándar, que se encarga del formato correctamente:

import json

with open(f"gene_{datos['id']}.json", "w") as f:
    json.dump(datos, f, indent=2)

json.dump(datos, f, indent=2) escribe el diccionario datos en el archivo f, con una indentación de 2 espacios para que sea legible si lo abres con gedit.

Otro ejemplo: ocurrencias de una especie en GBIF

GBIF (Global Biodiversity Information Facility) da acceso a millones de registros de observaciones de especies. Su respuesta trae los resultados dentro de una lista, bajo la clave "results", junto con el número total de coincidencias en "count":

import requests

url = "https://api.gbif.org/v1/occurrence/search"
parametros = {"scientificName": "Quercus robur", "country": "ES", "limit": 5}
respuesta = requests.get(url, params=parametros)

if respuesta.status_code == 200:
    datos = respuesta.json()
    print(f"Registros totales encontrados: {datos['count']}")
    for registro in datos["results"]:
        print(registro.get("stateProvince"), registro.get("eventDate"))

Como no todos los registros tienen todos los campos, usamos registro.get("stateProvince") en vez de registro["stateProvince"]: si la clave no existe, .get() devuelve None en lugar de dar un error.

Ejercicios

Para estos ejercicios necesitas conexión a internet y ejecutarlos en tu ordenador (no en el navegador).

  1. Haz una petición a https://rest.ensembl.org/info/ping con el parámetro content-type igual a application/json. Imprime el status_code y el contenido de la respuesta como texto.
  2. Descarga con requests los primeros 200 pares de bases del genoma de E. coli (NC_000913.3) desde NCBI, pasando los parámetros con params. Guárdalos en genoma_ecoli.fasta e imprime solo la primera línea (la cabecera).
  3. Modifica el ejercicio anterior para descargar los primeros 500 pares de bases. Sin guardar el archivo, calcula cuántas bases tiene la secuencia descargada (sin contar la cabecera ni los saltos de línea) y su contenido en GC.
  4. Consulta Ensembl para el gen ENSG00000139618, comprueba el status_code y muestra su display_name, su species y su longitud en pares de bases (end - start + 1).
  5. Amplía el ejercicio anterior para que guarde la respuesta completa en gene_ENSG00000139618.json usando el módulo json (con indentación).
  6. Escribe una función nombre_gen(id_gen) que consulte Ensembl y devuelva el display_name del gen, o None si la petición falla. Úsala en un bucle sobre la lista ["ENSG00000139618", "ENSG00000141510", "ENSG00000000000"] e imprime, para cada ID, su nombre o un mensaje de error.
  7. Consulta GBIF con scientificName="Pinus sylvestris" y country="ES", y muestra cuántos registros totales hay (datos["count"]) y la comunidad autónoma ("stateProvince") de los 5 primeros resultados.
  8. (Reto) Para cada especie de la lista ["Pinus sylvestris", "Quercus ilex", "Lynx pardinus", "Aquila adalberti"], consulta en GBIF cuántos registros hay en España y guarda el resultado en un diccionario {especie: número_de_registros}. Después, imprime la especie con más registros. Pista: con "limit": 0 GBIF devuelve el count sin descargar ningún registro.

Soluciones

  1. import requests
    
    respuesta = requests.get("https://rest.ensembl.org/info/ping",
                             params={"content-type": "application/json"})
    print(respuesta.status_code)
    print(respuesta.text)
  2. import requests
    
    url = "https://eutils.ncbi.nlm.nih.gov/entrez/eutils/efetch.fcgi"
    parametros = {"db": "nuccore", "id": "NC_000913.3",
                  "rettype": "fasta", "retmode": "text", "seq_stop": 200}
    respuesta = requests.get(url, params=parametros)
    
    if respuesta.status_code == 200:
        with open("genoma_ecoli.fasta", "w") as f:
            f.write(respuesta.text)
        print(respuesta.text.split("\n")[0])
    else:
        print(f"Error {respuesta.status_code}")
  3. import requests
    
    url = "https://eutils.ncbi.nlm.nih.gov/entrez/eutils/efetch.fcgi"
    parametros = {"db": "nuccore", "id": "NC_000913.3",
                  "rettype": "fasta", "retmode": "text", "seq_stop": 500}
    respuesta = requests.get(url, params=parametros)
    
    secuencia = ""
    for linea in respuesta.text.split("\n"):
        if not linea.startswith(">"):
            secuencia += linea.strip()
    
    num_gc = secuencia.count("G") + secuencia.count("C")
    print(f"Longitud: {len(secuencia)} pb")
    print(f"Contenido GC: {num_gc / len(secuencia) * 100:.2f} %")
  4. import requests
    
    url = "https://rest.ensembl.org/lookup/id/ENSG00000139618"
    respuesta = requests.get(url, params={"content-type": "application/json"})
    
    if respuesta.status_code == 200:
        datos = respuesta.json()
        longitud = datos["end"] - datos["start"] + 1
        print(datos["display_name"], datos["species"], f"{longitud} pb")
    else:
        print(f"Error {respuesta.status_code}")
  5. import json
    
    if respuesta.status_code == 200:
        with open(f"gene_{datos['id']}.json", "w") as f:
            json.dump(datos, f, indent=2)
  6. import requests
    
    def nombre_gen(id_gen):
        """Devuelve el display_name de un gen de Ensembl, o None si falla."""
        url = f"https://rest.ensembl.org/lookup/id/{id_gen}"
        respuesta = requests.get(url, params={"content-type": "application/json"})
        if respuesta.status_code == 200:
            return respuesta.json()["display_name"]
        return None
    
    for id_gen in ["ENSG00000139618", "ENSG00000141510", "ENSG00000000000"]:
        nombre = nombre_gen(id_gen)
        if nombre is None:
            print(f"{id_gen}: no encontrado")
        else:
            print(f"{id_gen}: {nombre}")
  7. import requests
    
    url = "https://api.gbif.org/v1/occurrence/search"
    parametros = {"scientificName": "Pinus sylvestris", "country": "ES", "limit": 5}
    respuesta = requests.get(url, params=parametros)
    
    if respuesta.status_code == 200:
        datos = respuesta.json()
        print(f"Registros totales encontrados: {datos['count']}")
        for registro in datos["results"]:
            print(registro.get("stateProvince"))
  8. import requests
    
    url = "https://api.gbif.org/v1/occurrence/search"
    especies = ["Pinus sylvestris", "Quercus ilex", "Lynx pardinus", "Aquila adalberti"]
    
    registros = {}
    for especie in especies:
        parametros = {"scientificName": especie, "country": "ES", "limit": 0}
        respuesta = requests.get(url, params=parametros)
        if respuesta.status_code == 200:
            registros[especie] = respuesta.json()["count"]
    
    print(registros)
    
    especie_max = None
    for especie, n in registros.items():
        if especie_max is None or n > registros[especie_max]:
            especie_max = especie
    print(f"Especie con más registros: {especie_max} ({registros[especie_max]})")