Strings en Python — práctica limpiando una lista de clase, validando DNIs y sacando un acta alineada (y el 7,5 que float() no quiso leer)

Con la lista de clase ya limpia (y aprendida la lección de guardar lo que devuelve strip()), me pasaron las notas de los parciales. Venían de una hoja de cálculo en español, así que cada nota estaba escrita con coma decimal. Las copié a mi programa y fui directo a convertirlas en números:

nota = float("7,5")     # así venía la nota en la hoja de cálculo
print(nota)

Al ejecutarlo:

Traceback (most recent call last):
  File "gancho.py", line 1, in <module>
    nota = float("7,5")     # así venía la nota en la hoja de cálculo
ValueError: could not convert string to float: '7,5'

«¿Cómo que no puede convertir 7,5? Si es un número.» Para mí, sí. Para Python, "7,5" es un string con un 7, una coma y un 5, y float() solo entiende el punto como separador decimal. La solución es de una línea, float(texto.replace(",", ".")), pero la lección es más grande: los datos que escriben las personas nunca llegan limpios. Espacios de más, mayúsculas al azar, comas donde Python espera puntos, fechas con una cifra… Antes de calcular nada, hay que limpiar y comprobar.

Eso es justo lo que vamos a practicar con tres programas completos sobre una lista de clase: limpiar los nombres escritos de cualquier manera y generar el usuario de cada alumno, validar DNIs y fechas de nacimiento explicando qué falla en cada una, y sacar el acta de los parciales con las columnas perfectamente alineadas. Los tres son de nivel medio y usan datos que se parecen a los de verdad, con sus huecos y sus errores. Esta práctica se apoya en la teoría de strings; si algo de los diccionarios te suena raro, repasa también contenedores asociativos, porque aparecen en dos de los programas.

Cómo usar este artículo

Cada programa está completo en un solo archivo. Cópialo, guárdalo con el nombre que se indica y lánzalo desde la terminal con python3 nombre.py. Debajo de cada uno tienes la salida real, un dibujo o una tabla con lo que pasa por dentro y una explicación función a función. Antes de mirar la salida, intenta predecirla: con los strings, saber qué devuelve cada método (y qué no cambia) es exactamente lo que hay que entrenar.

Conviene tener claro, de la teoría:

  • Que los strings son inmutables: cada método devuelve uno nuevo que hay que guardar.
  • La diferencia entre split() y split(" "), y cómo join vuelve a pegar los trozos.
  • Cómo funcionan los índices y los slices (dni[:8], dni[8]).
  • Los modificadores de formato de las f-strings: ancho, alineación y .2f.

Ningún programa usa input(). Los datos van fijos en el código para que tu salida sea idéntica a la que ves aquí. Si quieres probar con tus propios datos, cambia las listas del principio de cada programa.

Programa 1 · Limpiar la lista de clase

La lista llega de un formulario en el que cada alumno escribió «apellidos, nombre» a su manera: espacios delante y detrás, varios espacios seguidos, la coma pegada o separada, mayúsculas por todas partes. Queremos dos cosas para cada alumno: su nombre bien escrito (María José Núñez de la Torre) y un usuario para el campus virtual con la forma nombre.apellido, en minúsculas y sin tildes.

La idea

Antes de programar, piensa en el orden. Primero hay que separar apellidos y nombre (por la coma), después limpiar cada parte (espacios y mayúsculas) y, solo al final, construir el usuario a partir de las partes ya limpias. Cada paso es una función pequeña que recibe un string y devuelve otro: así se pueden probar por separado y combinarlas como piezas.

La coma puede tener espacios a cualquier lado ("torre , MARÍA", "SANTANA,luis"). Si partes por ", " (coma y espacio), la segunda línea no se parte. Mejor parte solo por la coma y limpia los espacios después.

limpiar.py

# Lista tal como llegó del formulario: "apellidos, nombre", escrita por cada uno a su manera
CRUDA = [
    "  gARCÍA pérez, ana ",
    "ROMERO   SANTANA,luis",
    "núñez de la torre , MARÍA JOSÉ",
    "gil, marta   ",
]

PARTICULAS = ["de", "la", "del", "las", "los", "y"]
TILDES = {"á": "a", "é": "e", "í": "i", "ó": "o", "ú": "u", "ü": "u", "ñ": "n"}


def normalizar_espacios(texto):
    """Quita los espacios de los extremos y deja UN solo espacio entre palabras."""
    return " ".join(texto.split())    # split() sin argumento corta por cualquier hueco


def corregir_particulas(texto):
    """title() escribe 'De La'; las partículas van en minúscula (salvo si son la primera palabra)."""
    palabras = texto.split()
    for i in range(1, len(palabras)):          # desde la 1: la primera palabra no se toca
        if palabras[i].lower() in PARTICULAS:
            palabras[i] = palabras[i].lower()  # en una LISTA sí se puede cambiar un elemento
    return " ".join(palabras)


def nombre_bonito(linea):
    """'gARCÍA pérez, ana' -> ('Ana', 'García Pérez')."""
    apellidos, coma, nombre = linea.partition(",")   # corta por la PRIMERA coma
    nombre = normalizar_espacios(nombre).title()
    apellidos = corregir_particulas(normalizar_espacios(apellidos).title())
    return nombre, apellidos


def sin_tildes(texto):
    """Cambia cada vocal con tilde (y la ñ) por su versión sin tilde."""
    for con, sin in TILDES.items():
        texto = texto.replace(con, sin)   # replace devuelve un string nuevo: hay que guardarlo
    return texto


def usuario(nombre, apellidos):
    """Primera palabra del nombre + '.' + primer apellido, en minúsculas y sin tildes."""
    primero = nombre.split()[0]
    apellido1 = apellidos.split()[0]
    return sin_tildes((primero + "." + apellido1).lower())   # primero lower: TILDES está en minúsculas


for n, linea in enumerate(CRUDA, start=1):
    nombre, apellidos = nombre_bonito(linea)
    print(f"{n}. {nombre} {apellidos}  ->  {usuario(nombre, apellidos)}@alu.ulpgc.es")

Lánzalo con python3 limpiar.py:

1. Ana García Pérez  ->  ana.garcia@alu.ulpgc.es
2. Luis Romero Santana  ->  luis.romero@alu.ulpgc.es
3. María José Núñez de la Torre  ->  maria.nunez@alu.ulpgc.es
4. Marta Gil  ->  marta.gil@alu.ulpgc.es

Las cuatro líneas, tan distintas al entrar, salen con la misma forma. Fíjate en la tercera: "núñez de la torre , MARÍA JOSÉ" se ha convertido en María José Núñez de la Torre, con «de la» en minúscula, y su usuario es maria.nunez, sin tildes ni eñe. Vamos a ver cómo, función a función.

normalizar_espacios: split y join

def normalizar_espacios(texto):
    """Quita los espacios de los extremos y deja UN solo espacio entre palabras."""
    return " ".join(texto.split())    # split() sin argumento corta por cualquier hueco

Es el truco de la teoría: texto.split() (sin argumento) corta por cualquier hueco, por grande que sea, y descarta los espacios de los extremos. Después, " ".join(…) vuelve a pegar las palabras con un solo espacio. Con "ROMERO SANTANA":

PasoResultado
texto"ROMERO SANTANA"
texto.split()['ROMERO', 'SANTANA']
" ".join(…)"ROMERO SANTANA"

¿Por qué no strip()? Porque strip() solo quita los espacios de los extremos; los tres espacios del medio seguirían ahí.

nombre_bonito: partition y title

def nombre_bonito(linea):
    """'gARCÍA pérez, ana' -> ('Ana', 'García Pérez')."""
    apellidos, coma, nombre = linea.partition(",")   # corta por la PRIMERA coma
    nombre = normalizar_espacios(nombre).title()
    apellidos = corregir_particulas(normalizar_espacios(apellidos).title())
    return nombre, apellidos

Este es el recorrido completo de la tercera línea, la más complicada:

De la línea del formulario al nombre y al usuarioLa línea "núñez de la torre , MARÍA JOSÉ" se parte con partition(",") en apellidos y nombre. Los apellidos pasan por normalizar_espacios, title y corregir_particulas hasta "Núñez de la Torre"; el nombre, por normalizar_espacios y title hasta "María José". El usuario sale de la primera palabra de cada uno: maria.nunez.De la línea del formulario al nombre y al usuario"núñez de la torre , MARÍA JOSÉ"partition(",")apellidosnormalizar_espacios"núñez de la torre"title()"Núñez De La Torre"corregir_particulas"Núñez de la Torre"nombrenormalizar_espacios"MARÍA JOSÉ"title()"María José"usuario"María" + "." + "Núñez"lower()"maría.núñez"sin_tildes"maria.nunez"
Cada paso devuelve un string nuevo que se guarda en una variable; el siguiente paso trabaja sobre ese resultado.
  • partition(",") corta por la primera coma y devuelve tres trozos: lo de antes ("núñez de la torre "), la coma y lo de después (" MARÍA JOSÉ"). La coma no la necesitamos, pero hay que recogerla en una variable (coma) para que el desempaquetado cuadre.
  • ¿Por qué partition y no split(",")? Porque partition siempre devuelve exactamente tres trozos, aunque no haya coma (entonces los dos últimos son ""). Con split(","), una línea sin coma daría un solo trozo y el desempaquetado en dos variables fallaría.
  • Cada parte pasa por normalizar_espacios y por title(). Como los strings son inmutables, cada llamada devuelve uno nuevo, y por eso se reasigna nombre = … y apellidos = ….
  • title() deja «Núñez De La Torre». En los apellidos españoles las partículas van en minúscula, así que los apellidos pasan además por corregir_particulas.

Escribir normalizar_espacios(nombre).title() en una línea suelta, sin nombre = delante. No da error, pero nombre se queda como estaba: es exactamente el fallo del strip() del principio del tema.

corregir_particulas: cambiar una lista, no un string

def corregir_particulas(texto):
    """title() escribe 'De La'; las partículas van en minúscula (salvo si son la primera palabra)."""
    palabras = texto.split()
    for i in range(1, len(palabras)):          # desde la 1: la primera palabra no se toca
        if palabras[i].lower() in PARTICULAS:
            palabras[i] = palabras[i].lower()  # en una LISTA sí se puede cambiar un elemento
    return " ".join(palabras)

Aquí está la gracia de la inmutabilidad. No podemos hacer texto[6] = "d" para cambiar la D de «De», porque un string no admite asignación por posición. Pero una lista sí. Así que partimos el texto en una lista de palabras, cambiamos las palabras que haga falta en la lista y la volvemos a juntar:

ipalabras[i].lower()¿Está en PARTICULAS?palabras después
(inicio)['Núñez', 'De', 'La', 'Torre']
1'De''de'Sí['Núñez', 'de', 'La', 'Torre']
2'La''la'Sí['Núñez', 'de', 'la', 'Torre']
3'Torre''torre'No['Núñez', 'de', 'la', 'Torre']

El bucle empieza en 1, no en 0, a propósito: si un apellido empezara por una partícula («De la Rosa»), la primera palabra debe seguir en mayúscula.

sin_tildes y usuario

def sin_tildes(texto):
    """Cambia cada vocal con tilde (y la ñ) por su versión sin tilde."""
    for con, sin in TILDES.items():
        texto = texto.replace(con, sin)   # replace devuelve un string nuevo: hay que guardarlo
    return texto


def usuario(nombre, apellidos):
    """Primera palabra del nombre + '.' + primer apellido, en minúsculas y sin tildes."""
    primero = nombre.split()[0]
    apellido1 = apellidos.split()[0]
    return sin_tildes((primero + "." + apellido1).lower())   # primero lower: TILDES está en minúsculas

usuario toma la primera palabra del nombre ("María José".split()[0] es "María") y la primera del apellido ("Núñez"), los une con un punto y los pasa a minúsculas: "maría.núñez". El orden importa: primero lower() y después sin_tildes, porque el diccionario TILDES solo tiene las letras minúsculas. Si lo hiciéramos al revés, una Á mayúscula no se cambiaría.

sin_tildes recorre el diccionario y, para cada pareja, sustituye todas las apariciones. Con "maría.núñez":

Vueltacon → sintexto después
1á → amaría.núñez (no hay á)
2é → emaría.núñez
3í → imaria.núñez
4ó → omaria.núñez
5ú → umaria.nuñez
6ü → umaria.nuñez
7ñ → nmaria.nunez

Cuando ya domines replace, Python tiene una forma de hacer todos los cambios de una vez: texto.translate(str.maketrans("áéíóúüñ", "aeiouun")). Hace lo mismo que el bucle, pero merece la pena escribir el bucle al menos una vez para entender qué pasa.

Pruébalo tú

  • Añade a CRUDA la línea "de la rosa, PABLO ". Predice el nombre y el usuario antes de ejecutar. ¿La primera palabra de los apellidos sale en mayúscula? ¿Y el usuario, tiene sentido que sea pablo.de?
  • Escribe iniciales(nombre, apellidos) que devuelva las iniciales en mayúscula y sin puntos (para María José Núñez de la Torre, MJNT: las partículas no cuentan).

Programa 2 · Validar DNIs y fechas de nacimiento

Cada alumno ha copiado a mano su DNI y su fecha de nacimiento, separados por ;. Queremos revisar cada registro y decir qué falla, no solo si está bien o mal: un DNI con una cifra de menos, una letra que no corresponde, una fecha sin el formato dd/mm/aaaa…

La idea

Un DNI español tiene 8 cifras y una letra, y la letra no es libre: es la que ocupa la posición número % 23 en el string "TRWAGMYFPDXBNJZSQVHLCKE". Así que la validación es una escalera de comprobaciones, de la más básica a la más fina: primero la longitud, luego que las 8 primeras sean cifras, y solo entonces calcular la letra. Si una falla, se devuelve el motivo y no se sigue. Con la fecha, lo mismo: formato (tres trozos de 2, 2 y 4 caracteres), que sean cifras y que el mes y el día tengan sentido.

El orden de las comprobaciones no es un capricho. Si calculas dni[8] antes de comprobar que el DNI mide 9, un DNI corto te dará un IndexError en lugar de un mensaje.

validar.py

LETRAS = "TRWAGMYFPDXBNJZSQVHLCKE"    # la letra del DNI es LETRAS[número % 23]

# Cada registro: "DNI;fecha de nacimiento", copiado a mano por cada alumno
REGISTROS = [
    "12345678Z;03/09/2007",
    "1234567Z;15/02/2006",
    "12345678A;21/11/2006",
    "x2345678Z;30/06/2007",
    "87654321x;7/5/2007",
    "  45123456D ;31/13/2006 ",
]


def validar_dni(dni):
    """Devuelve "" si el DNI es correcto, o un texto con el motivo si no lo es."""
    dni = dni.strip().upper()             # tolera espacios y la letra en minúscula
    if len(dni) != 9:                     # lo primero, la longitud: así dni[8] nunca falla
        return f"DNI con {len(dni)} caracteres (deben ser 9)"
    numero, letra = dni[:8], dni[8]       # 8 cifras y la letra
    if not numero.isdigit():
        return f"'{numero}' no son 8 cifras"
    esperada = LETRAS[int(numero) % 23]   # el resto (de 0 a 22) es una posición de LETRAS
    if letra != esperada:
        return f"la letra debería ser {esperada}"
    return ""


def validar_fecha(fecha):
    """Comprueba el formato dd/mm/aaaa y que el día y el mes tengan sentido."""
    partes = fecha.strip().split("/")
    if len(partes) != 3 or [len(p) for p in partes] != [2, 2, 4]:
        return "fecha sin el formato dd/mm/aaaa"
    if not "".join(partes).isdigit():     # juntar los trozos y mirar que todo sean cifras
        return "fecha con caracteres que no son cifras"
    dia, mes = int(partes[0]), int(partes[1])
    if not 1 <= mes <= 12:
        return f"el mes {mes} no existe"
    if not 1 <= dia <= 31:
        return f"el día {dia} no existe"
    return ""


correctos = 0
print(f"{'DNI':<12}{'Fecha':<12}Resultado")
for registro in REGISTROS:
    dni, fecha = registro.split(";")
    # se guardan solo los mensajes no vacíos ("" cuenta como falso)
    errores = [e for e in (validar_dni(dni), validar_fecha(fecha)) if e]
    if errores:
        resultado = "; ".join(errores)
    else:
        resultado = "OK"
        correctos += 1
    print(f"{dni.strip():<12}{fecha.strip():<12}{resultado}")

print(f"Correctos: {correctos} de {len(REGISTROS)} ({correctos / len(REGISTROS):.0%})")

Lánzalo con python3 validar.py:

DNI         Fecha       Resultado
12345678Z   03/09/2007  OK
1234567Z    15/02/2006  DNI con 8 caracteres (deben ser 9)
12345678A   21/11/2006  la letra debería ser Z
x2345678Z   30/06/2007  'X2345678' no son 8 cifras
87654321x   7/5/2007    fecha sin el formato dd/mm/aaaa
45123456D   31/13/2006  el mes 13 no existe
Correctos: 1 de 6 (17%)

validar_dni: una escalera de comprobaciones

def validar_dni(dni):
    """Devuelve "" si el DNI es correcto, o un texto con el motivo si no lo es."""
    dni = dni.strip().upper()             # tolera espacios y la letra en minúscula
    if len(dni) != 9:                     # lo primero, la longitud: así dni[8] nunca falla
        return f"DNI con {len(dni)} caracteres (deben ser 9)"
    numero, letra = dni[:8], dni[8]       # 8 cifras y la letra
    if not numero.isdigit():
        return f"'{numero}' no son 8 cifras"
    esperada = LETRAS[int(numero) % 23]   # el resto (de 0 a 22) es una posición de LETRAS
    if letra != esperada:
        return f"la letra debería ser {esperada}"
    return ""

Cada if corta la función con un return en cuanto encuentra un problema; si se llega al final, se devuelve "", que significa «sin errores». Así se ve cada registro al pasar por la escalera:

RegistroTras strip().upper()lennumero¿Cifras?esperadaResultado
12345678Z12345678Z912345678SíZOK
1234567Z1234567Z8———DNI con 8 caracteres
12345678A12345678A912345678SíZla letra debería ser Z
x2345678ZX2345678Z9X2345678No—no son 8 cifras
87654321x87654321X987654321SíX(DNI correcto)
45123456D 45123456D945123456SíD(DNI correcto)

La letra se calcula con un índice sobre un string, que es lo que mejor se entiende con un dibujo:

Cómo se comprueba la letra del DNIEl DNI 12345678Z se parte en las ocho cifras, dni[:8], y la letra, dni[8]. 12345678 entre 23 da resto 14, y la posición 14 del string LETRAS es la Z: coincide con la letra escrita.Cómo se comprueba la letra del DNI12345678Zdni[:8] → númerodni[8] → letra12345678 % 23 = 14(12345678 = 23 × 536768 + 14)LETRAS = "TRWAGMYFPDXBNJZSQVHLCKE"T0R1W2A3G4M5Y6F7P8D9X10B11N12J13Z14S15Q16V17H18L19C20K21E22coincide → letra correcta
El resto de dividir entre 23 siempre está entre 0 y 22: justo las posiciones del string LETRAS.
  • dni[:8] son las ocho cifras (como string) y dni[8], el noveno carácter.
  • int(numero) % 23 da el resto de dividir entre 23. Comprobémoslo con el primero: 23 × 536768 = 12345664, y 12345678 − 12345664 = 14. La posición 14 de LETRAS es la Z: coincide.
  • El resto de dividir entre 23 siempre está entre 0 y 22, y LETRAS tiene 23 letras. Por eso LETRAS[resto] nunca se sale del string.
  • strip().upper() al principio hace que la función tolere espacios y letras en minúscula: el quinto registro, 87654321x, se da por bueno (87654321 % 23 = 10, y LETRAS[10] es la X). Por eso en el cuarto registro el mensaje dice 'X2345678' con la equis mayúscula: el texto ya se había pasado a mayúsculas.

Comprobar las cifras con numero.isdigit() antes de recortar. Si haces dni.isdigit() con el DNI entero, la letra hace que salga siempre False. Primero el slice dni[:8], luego isdigit().

validar_fecha: partir y comprobar cada trozo

def validar_fecha(fecha):
    """Comprueba el formato dd/mm/aaaa y que el día y el mes tengan sentido."""
    partes = fecha.strip().split("/")
    if len(partes) != 3 or [len(p) for p in partes] != [2, 2, 4]:
        return "fecha sin el formato dd/mm/aaaa"
    if not "".join(partes).isdigit():     # juntar los trozos y mirar que todo sean cifras
        return "fecha con caracteres que no son cifras"
    dia, mes = int(partes[0]), int(partes[1])
    if not 1 <= mes <= 12:
        return f"el mes {mes} no existe"
    if not 1 <= dia <= 31:
        return f"el día {dia} no existe"
    return ""
  • split("/") convierte "03/09/2007" en ['03', '09', '2007']. La primera condición pide tres trozos y que midan 2, 2 y 4. La lista [len(p) for p in partes] da las longitudes; con "7/5/2007" sale [1, 1, 4], que no es [2, 2, 4], y la fecha se rechaza por el formato.
  • El orden de la condición importa: si la fecha no tuviera barras, partes tendría un solo trozo y len(partes) != 3 ya sería verdad. El or no evalúa la segunda parte, así que nunca se intenta mirar trozos que no existen.
  • "".join(partes) vuelve a juntar los trozos sin separador ("03092007") para comprobar de una vez que todo son cifras.
  • Solo cuando sabemos que son cifras se convierten con int. Con "31/13/2006", mes vale 13 y la condición 1 <= mes <= 12 es falsa: el mes 13 no existe.

Esta validación es deliberadamente sencilla: acepta el 31 de febrero. Comprobar los días de cada mes (y los años bisiestos) es un buen «Pruébalo tú», y en el tema 8 verás cómo las expresiones regulares comprueban formatos como dd/mm/aaaa en una sola línea.

El bucle principal: juntar los errores

correctos = 0
print(f"{'DNI':<12}{'Fecha':<12}Resultado")
for registro in REGISTROS:
    dni, fecha = registro.split(";")
    # se guardan solo los mensajes no vacíos ("" cuenta como falso)
    errores = [e for e in (validar_dni(dni), validar_fecha(fecha)) if e]
    if errores:
        resultado = "; ".join(errores)
    else:
        resultado = "OK"
        correctos += 1
    print(f"{dni.strip():<12}{fecha.strip():<12}{resultado}")

print(f"Correctos: {correctos} de {len(REGISTROS)} ({correctos / len(REGISTROS):.0%})")
  • registro.split(";") da exactamente dos trozos, DNI y fecha, que se desempaquetan.
  • errores se construye con una list comprehension que se queda solo con los mensajes no vacíos: if e es falso para "". Así, un registro con el DNI bien y la fecha mal tiene un solo error, y uno con los dos mal tendría dos, unidos por "; ".join(errores).
  • La salida usa {dni.strip():<12} y {fecha.strip():<12}: columnas de 12 caracteres a la izquierda, que cuadran con la cabecera, escrita con los mismos anchos.
  • Al final, 1 correcto de 6 es 0,1666…, y el formato :.0% lo multiplica por 100 y lo redondea sin decimales: 17%.

Pruébalo tú

  • Mejora validar_fecha para que compruebe los días de cada mes (con una lista [31, 28, 31, …]) y rechace el 30 de febrero.
  • Haz que validar_dni acepte también NIE de extranjeros, que empiezan por X, Y o Z: se sustituye esa letra por 0, 1 o 2 y se calcula igual. Prueba con X1234567L.

Programa 3 · El acta de los parciales, con columnas alineadas

Para terminar, el programa del gancho: las notas de los tres parciales llegan como strings con coma decimal ("7,5; 8,25; 6") en un diccionario alumno → notas. Queremos un acta como las de verdad: un título centrado, una cabecera, una fila por alumno con su número de lista, sus tres notas, la media y la calificación, todo alineado, y un resumen al final.

La idea

Hay dos problemas distintos y conviene separarlos. El primero es convertir cada string de notas en una lista de números (cortar por ;, limpiar, cambiar la coma por un punto, float). El segundo es presentar: decidir el ancho de cada columna y usarlo igual en la cabecera, en las filas y en el resumen. Si los anchos no coinciden, la tabla se descoloca.

Dibuja la tabla en papel antes de programar y cuenta los caracteres de cada columna. El ancho del nombre lo marca el más largo («Núñez de la Torre, María José» mide 29), y conviene dejar algún espacio de margen.

acta.py

# Notas de los tres parciales, tal como se copiaron de la hoja de cálculo (con coma decimal)
NOTAS = {
    "Romero Santana, Luis": "5; 4,25; 6,5",
    "García Pérez, Ana": "7,5; 8,25; 6",
    "Núñez de la Torre, María José": "10; 9,5; 9,75",
    "Gil, Marta": "4,5; 5; 3,75",
}
ANCHO = 75      # 4 + 31 + 6 + 6 + 6 + 7 + 2 = 62 columnas, más 13 de "Sobresaliente"


def convertir(texto):
    """'7,5; 8,25; 6' -> [7.5, 8.25, 6.0]"""
    return [float(t.strip().replace(",", ".")) for t in texto.split(";")]


def calificacion(media):
    if media < 5:
        return "Suspenso"
    if media < 7:
        return "Aprobado"
    if media < 9:
        return "Notable"
    return "Sobresaliente"


print(" ACTA DE FP1 · SUBGRUPO 43 ".center(ANCHO, "="))   # título centrado entre signos =
print(f"{'N.º':<4}{'Alumno':<31}{'P1':>6}{'P2':>6}{'P3':>6}{'Media':>7}  Calificación")
print("-" * ANCHO)

medias = []
for n, alumno in enumerate(sorted(NOTAS), start=1):     # sorted: por apellidos
    p1, p2, p3 = convertir(NOTAS[alumno])
    media = (p1 + p2 + p3) / 3
    medias.append(media)
    # zfill(2) para el número; cada nota a la derecha con 2 decimales
    print(f"{str(n).zfill(2):<4}{alumno:<31}{p1:6.2f}{p2:6.2f}{p3:6.2f}{media:7.2f}  {calificacion(media)}")

print("-" * ANCHO)
aprobados = sum(1 for m in medias if m >= 5)
print(f"{'Media de la clase:':<53}{sum(medias) / len(medias):7.2f}")
print(f"Aprobados: {aprobados} de {len(medias)} ({aprobados / len(medias):.0%})")

Lánzalo con python3 acta.py:

======================== ACTA DE FP1 · SUBGRUPO 43 ========================
N.º Alumno                             P1    P2    P3  Media  Calificación
---------------------------------------------------------------------------
01  García Pérez, Ana                7.50  8.25  6.00   7.25  Notable
02  Gil, Marta                       4.50  5.00  3.75   4.42  Suspenso
03  Núñez de la Torre, María José   10.00  9.50  9.75   9.75  Sobresaliente
04  Romero Santana, Luis             5.00  4.25  6.50   5.25  Aprobado
---------------------------------------------------------------------------
Media de la clase:                                      6.67
Aprobados: 3 de 4 (75%)

convertir: de «7,5; 8,25; 6» a [7.5, 8.25, 6.0]

def convertir(texto):
    """'7,5; 8,25; 6' -> [7.5, 8.25, 6.0]"""
    return [float(t.strip().replace(",", ".")) for t in texto.split(";")]

Todo ocurre en una list comprehension. Léela por partes, de dentro afuera, con las notas de Ana:

PasoResultado
texto.split(";")['7,5', ' 8,25', ' 6']
t.strip() (cada trozo)'7,5', '8,25', '6'
.replace(",", ".")'7.5', '8.25', '6'
float(…)7.5, 8.25, 6.0

Fíjate en que strip hace falta: split(";") deja el espacio que había después de cada ; pegado al trozo siguiente. En realidad float(" 8.25") toleraría ese espacio, pero limpiar antes es una buena costumbre: otras conversiones (y otras comparaciones) no son tan permisivas.

Con las tres notas, la media de Ana es (7.5 + 8.25 + 6) / 3 = 21.75 / 3 = 7.25, un Notable.

Las columnas, una a una

print(" ACTA DE FP1 · SUBGRUPO 43 ".center(ANCHO, "="))   # título centrado entre signos =
print(f"{'N.º':<4}{'Alumno':<31}{'P1':>6}{'P2':>6}{'P3':>6}{'Media':>7}  Calificación")
print("-" * ANCHO)

medias = []
for n, alumno in enumerate(sorted(NOTAS), start=1):     # sorted: por apellidos
    p1, p2, p3 = convertir(NOTAS[alumno])
    media = (p1 + p2 + p3) / 3
    medias.append(media)
    # zfill(2) para el número; cada nota a la derecha con 2 decimales
    print(f"{str(n).zfill(2):<4}{alumno:<31}{p1:6.2f}{p2:6.2f}{p3:6.2f}{media:7.2f}  {calificacion(media)}")

Este dibujo muestra la fila de Ana sobre una regla de caracteres. Cada campo de la f-string ocupa siempre el mismo ancho:

Cada columna del acta tiene un ancho fijoUna fila del acta dibujada sobre una regla de 75 caracteres: el número en 4 con zfill(2), el alumno a la izquierda en 31, las tres notas a la derecha en 6 con 2 decimales, la media en 7, dos espacios y la calificación.Cada columna del acta tiene un ancho fijo01{n:<4}4García Pérez, Ana{alumno:<31}317.50{p1:6.2f}68.25{p2:6.2f}66.00{p3:6.2f}67.25{media:7.2f}72Notable1304354147536275
Como cada campo ocupa siempre lo mismo, las columnas quedan alineadas aunque los nombres midan distinto.
  • {str(n).zfill(2):<4}: el número de lista con dos cifras (01) en un campo de 4 a la izquierda. zfill es de los strings, por eso primero se convierte n con str (con una f-string también valdría {n:02d}).
  • {alumno:<31}: el nombre a la izquierda en 31 caracteres. El más largo mide 29, así que siempre quedan al menos 2 espacios antes de las notas.
  • {p1:6.2f}: cada nota con 2 decimales en 6 caracteres. Al ser un número, va a la derecha sin tener que escribir >. Así 10.00 (5 caracteres) y 7.50 (4) terminan en la misma columna.
  • {media:7.2f}: la media en 7, uno más que las notas, para que quede un poco separada.
  • La cabecera usa los mismos anchos: {'Alumno':<31}, {'P1':>6}… Aquí sí hay que escribir >, porque 'P1' es un texto y se alinearía a la izquierda.
  • ANCHO es 75: 4 + 31 + 6 + 6 + 6 + 7 = 60 caracteres de columnas, más 2 espacios, más los 13 de «Sobresaliente», la calificación más larga.
  • El título se centra con center(ANCHO, "="): » ACTA DE FP1 · SUBGRUPO 43 » mide 27, y los 75 − 27 = 48 signos = se reparten, 24 a cada lado.
  • sorted(NOTAS) devuelve las claves ordenadas. Como las claves empiezan por los apellidos, el acta sale en orden alfabético de apellidos, aunque el diccionario estuviera desordenado.

El resumen

print("-" * ANCHO)
aprobados = sum(1 for m in medias if m >= 5)
print(f"{'Media de la clase:':<53}{sum(medias) / len(medias):7.2f}")
print(f"Aprobados: {aprobados} de {len(medias)} ({aprobados / len(medias):.0%})")

{'Media de la clase:':<53} rellena el texto hasta 53 caracteres para que el número, con su ancho de 7, termine en la columna 53 + 7 = 60, la misma que la media de cada alumno. Comprueba la cuenta: (7.25 + 4.4166… + 9.75 + 5.25) / 4 = 26.6666… / 4 = 6.6666…, que con dos decimales es 6.67. Y 3 aprobados de 4 es el 75%.

Calcular la media con las notas todavía como strings. "7,5" + "8,25" no da error: da "7,58,25", porque + concatena. El error solo aparece después, al dividir entre 3. Convierte siempre a número antes de operar.

Cuando una tabla no cuadra, cuenta los anchos como en la regla del dibujo. La suma de los anchos de las columnas tiene que ser el ancho de la línea de guiones.

Pruébalo tú

  • Añade un alumno con un nombre más largo de 31 caracteres. ¿Qué pasa con su fila? ¿Se recorta el nombre o se descoloca la tabla? Arréglalo recortando el nombre con un slice (alumno[:30]).
  • Añade una columna con la nota máxima de cada alumno (max(p1, p2, p3)) entre la media y la calificación, y ajusta la cabecera y ANCHO.

Errores típicos en esta práctica

  • Llamar a un método sin guardar el resultado. linea.strip() suelto no cambia linea. Si el método devuelve un string, escribe linea = linea.strip().
  • **Partir por ", " en lugar de por ",".** En cuanto alguien escriba la coma sin espacio, no se parte. Parte por el separador mínimo y limpia los espacios después.
  • **Mirar dni[8] antes de comprobar la longitud.** Un DNI corto da IndexError. Primero len, luego índices.
  • **Usar isdigit() para decimales.** "7,5".isdigit() y "7.5".isdigit() dan False. Para convertir decimales, replace y float.
  • Operar con notas que siguen siendo strings. + concatena en lugar de sumar, y el error aparece más tarde y más lejos.
  • Anchos distintos en la cabecera y en las filas. La tabla se descoloca: escribe los mismos números en las dos f-strings.

Míralo paso a paso en Python Tutor

Python Tutor ejecuta el código línea a línea y muestra el valor de cada variable después de cada paso. Elige Python 3 y pega esta versión mínima de la limpieza de una línea:

linea = " gil,  MARTA "
apellidos, coma, nombre = linea.partition(",")
apellidos = apellidos.strip().title()
nombre = " ".join(nombre.split()).title()
usuario = (nombre + "." + apellidos).lower()
print(nombre, apellidos, usuario)

Salida esperada:

Marta Gil marta.gil

Qué observar, paso a paso:

  1. Tras la línea 2 aparecen tres variables nuevas: apellidos vale " gil", coma vale "," y nombre vale " MARTA ". Fíjate en los espacios: partition corta, pero no limpia.
  2. En la línea 3, apellidos cambia de valor a "Gil". En realidad no ha cambiado el string: Python ha creado uno nuevo y la variable apunta ahora a él.
  3. En la línea 4, nombre pasa de " MARTA " a "Marta": split() se come los espacios, join pega la única palabra y title() arregla las mayúsculas.
  4. En la línea 5, usuario se construye sumando tres strings y pasando el resultado a minúsculas: "marta.gil".
  5. Vuelve a la línea 2 con el botón Prev y observa que linea nunca cambia durante todo el programa: todas las transformaciones crean strings nuevos.

Resumen

  • Limpiar antes de usar: strip, " ".join(texto.split()) y title() convierten texto escrito de cualquier manera en datos uniformes.
  • **partition frente a split:** partition siempre da tres trozos, así que se puede desempaquetar sin miedo; split da tantos como cortes haya.
  • Para cambiar palabras sueltas, pasa a lista: los strings no admiten asignación por posición, pero una lista de palabras sí, y luego join la vuelve a juntar.
  • Validar es una escalera: longitud, después tipo de caracteres, después la regla fina (la letra, el mes). El orden evita errores como IndexError.
  • Los índices sobre strings también sirven para buscar: LETRAS[numero % 23] convierte un número en una letra.
  • Columnas alineadas = mismos anchos en cabecera, filas y resumen: < para textos, números a la derecha con .2f, y center/zfill para los detalles.

Checklist: ¿lo has practicado?

  • ¿Has ejecutado los tres programas y comprobado las salidas?
  • ¿Sabrías explicar por qué nombre_bonito usa partition(",") y no split(", ")?
  • ¿Puedes calcular a mano la letra de un DNI con % 23 y la tabla LETRAS?
  • ¿Sabes por qué validar_dni comprueba la longitud antes que nada?
  • ¿Podrías añadir una columna al acta sin que se descoloque?
  • ¿Has intentado los «Pruébalo tú» sin mirar la teoría?

Si has respondido que sí a casi todo, estás listo para los ejercicios de strings, donde ya no te doy el código hecho: correos de la universidad, firmas e iniciales, un parte de asistencia y tres fragmentos con fallos muy típicos para cazar. ¡Nos vemos allí!

Siguiente paso: ejerciciosStrings en Python — ejercicios con correos de la universidad, firmas con iniciales y un parte de asistencia (y los dos espacios que rompieron mis iniciales)

¿Te ha servido este artículo? Así puedes apoyar SergioLearns →

Sigue aprendiendo

Deja una respuesta

Tu dirección de correo electrónico no será publicada. Los campos obligatorios están marcados con *