Archivos de texto en Python — ejercicios con contadores de líneas, filtros de carreras y un diario que hay que normalizar (y la línea vacía que paró el bucle a la mitad)

El último día de octubre quise ver todas mis sesiones del mes con un programa que copié del esquema de lectura: linea = f.readline().rstrip() y un while linea != ''. Me salieron dos sesiones. Dos. Pensé que se me había olvidado apuntar casi todo el mes, hasta que abrí el archivo y vi las otras cuatro ahí, tan tranquilas, después de una línea en blanco. El programa no tenía ningún error de sintaxis: simplemente se paraba en la primera línea vacía. Este es el fragmento 3 de «¿Qué falla aquí?», y te lo explico con calma.

Estos ejercicios cierran el tema de archivos de texto y, con él, el temario de FP1. Antes, la teoría y la práctica. Ahora te toca escribir el código a ti.

Cómo usar este artículo

  1. Lee el enunciado y la salida esperada de cada nivel. Crea los archivos de datos copiándolos tal cual (las líneas vacías también cuentan).
  2. Intenta resolverlo sin mirar la solución. Si te atascas más de diez minutos, abre la pista 🔵.
  3. Haz los tres fragmentos de «¿Qué falla aquí?»: predice qué sale antes de leer la explicación.
  4. Compara tu solución con la mía y lee el «por qué está hecho así». Al final tienes el chuletario para repasar.

Los niveles 1 y 2 usan este octubre.txt:

2026-10-01;carrera;30
2026-10-02;natación;45

2026-10-04;carrera;25
2026-10-05;bici;60
2026-10-07;carrera;40

2026-10-09;yoga;20

Nivel 1 · Básico: contar el diario

Escribe una función contar(nombre) que lea el archivo y devuelva cuatro números: las líneas totales, las líneas vacías, las sesiones y los minutos totales. El programa principal los imprime con su etiqueta. Una línea es vacía si, tras quitarle el salto y los espacios, no queda nada.

Salida esperada:

Líneas: 8
Vacías: 2
Sesiones: 6
Minutos: 220

Recorre con for linea in f, suma 1 a lineas en todas las vueltas y usa continue para que las vacías no lleguen a la parte que suma minutos. Los minutos son el tercer campo: split(';')[2].

Nivel 2 · Intermedio: solo las carreras largas

Escribe filtrar_carreras(origen, destino, minimo), que escriba en destino las sesiones de carrera de al menos minimo minutos, numeradas (1. fecha (minutos min)), y una última línea con el total. Devuelve cuántas ha copiado. Llámala con octubre.txt, carreras.txt y 30, y después muestra carreras.txt con el esquema de readline().

Salida esperada:

Carreras copiadas: 2
1. 2026-10-01 (30 min)
2. 2026-10-07 (40 min)
Total: 2 sesiones, 70 min

Abre los dos archivos en el mismo with, uno en 'r' y otro en 'w'. El bucle recorre el origen. La línea vacía, tras strip() y split(';'), es ['']: tiene un solo campo.

Nivel 3 · Desafío: normalizar un diario escrito a mano

Mi compañero de piso apunta sus entrenos como le da la gana: a veces separa con ;, otras con , o con | , con o sin espacios, y a veces escribe 45min o 25 min. Este es su sucio.txt:

2026-10-01;carrera;30
2026-10-02, natación, 45min

2026-10-04 | carrera | 25 min
sin datos hoy
2026-10-05;bici;60
2026-10-07,carrera,40

Escribe normalizar(origen, destino). Para cada línea que sea una sesión, escribe en destino la versión normalizada fecha;deporte;minutos, seguida de <- y la línea original. Las líneas que no sean sesiones se ignoran. Al final, una línea con diez > y otra con Normalizadas: y el número de líneas que han cambiado, y , Total: y el número de sesiones, los dos números en un campo de 3 caracteres. La función devuelve la tupla (cambiadas, total). Imprime esa tupla y el archivo resultante.

Salida esperada:

(3, 5)
2026-10-01;carrera;30 <- 2026-10-01;carrera;30
2026-10-02;natación;45 <- 2026-10-02, natación, 45min
2026-10-04;carrera;25 <- 2026-10-04 | carrera | 25 min
2026-10-05;bici;60 <- 2026-10-05;bici;60
2026-10-07;carrera;40 <- 2026-10-07,carrera,40
>>>>>>>>>>
Normalizadas:  3, Total:  5

Es el momento de las expresiones regulares: un patrón con tres grupos (fecha, deporte, minutos), un separador [;,|] rodeado de \s* y un (?:min)? opcional al final. Con fullmatch la línea entera tiene que encajar. Para el ancho de 3, f'{n:3d}'.

¿Qué falla aquí?

Tres programas cortos que parecen correctos. Lee cada uno y predice qué sale antes de seguir leyendo.

Fragmento 1

Queremos copiar tres.txt (tres sesiones) en copia.txt:

# Copiar un archivo línea a línea
origen = open('tres.txt', 'r', encoding='utf-8')
destino = open('copia.txt', 'w', encoding='utf-8')
for linea in origen:
    print(linea, file=destino)
origen.close()
destino.close()

f = open('copia.txt', 'r', encoding='utf-8')
print(repr(f.read()))
f.close()

¿Qué contendrá la copia? Esto es lo que sale:

'2026-10-05;carrera;35\n\n2026-10-06;bici;50\n\n2026-10-08;natación;40\n\n'

Hay un '\n' doble detrás de cada sesión: la copia tiene líneas en blanco que el original no tenía. Cada linea ya trae su '\n', y print() añade otro al escribir en el archivo, igual que cuando imprime en pantalla.

Copiar con print(linea, file=destino). El salto de la línea más el de print() dan dos saltos.

El arreglo es escribir la línea tal cual, con write():

# Copiar un archivo línea a línea (arreglado)
origen = open('tres.txt', 'r', encoding='utf-8')
destino = open('copia.txt', 'w', encoding='utf-8')
for linea in origen:
    destino.write(linea)          # la línea ya trae su '\n': se escribe tal cual
origen.close()
destino.close()

f = open('copia.txt', 'r', encoding='utf-8')
print(repr(f.read()))
f.close()

Ahora la copia es idéntica al original:

'2026-10-05;carrera;35\n2026-10-06;bici;50\n2026-10-08;natación;40\n'

print(linea, end='', file=destino) también vale. Quédate con la idea: lo que lees ya lleva su salto, así que no añadas otro.

Fragmento 2

Apuntamos la sesión de hoy en hoy.txt:

# Apuntar la sesión de hoy
fecha, deporte, minutos = '2026-10-20', 'bici', 55
with open('hoy.txt', 'w', encoding='utf-8') as f:
    f.write(fecha + ';' + deporte + ';')
    f.write(minutos)

¿Qué crees que pasa? Esto:

Traceback (most recent call last):
  File "apuntar.py", line 5, in <module>
    f.write(minutos)
    ~~~~~~~^^^^^^^^^
TypeError: write() argument must be str, not int

write() solo acepta strings, y minutos es el entero 55. El mensaje lo dice con todas las letras: el argumento tiene que ser str y es int. Fíjate en los ^^^ del traceback: señalan justo el argumento minutos. Y hay un segundo fallo escondido, que no llega a verse porque el programa revienta antes: aunque fuera una string, no hay '\n' al final.

Pasarle a write() un número, o una línea sin su salto. print() hace las dos cosas por ti; write(), ninguna.

Arreglado, con la conversión y el salto, y leyendo después lo que ha quedado:

# Apuntar la sesión de hoy (arreglado)
fecha, deporte, minutos = '2026-10-20', 'bici', 55
with open('hoy.txt', 'w', encoding='utf-8') as f:
    f.write(fecha + ';' + deporte + ';')
    f.write(str(minutos) + '\n')     # write solo acepta str, y el '\n' no lo pone él
with open('hoy.txt', 'r', encoding='utf-8') as f:
    print(repr(f.read()))

El archivo queda con una línea completa:

'2026-10-20;bici;55\n'

Con una f-string no hay que acordarse de str(): f.write(f'{fecha};{deporte};{minutos}\n').

Fragmento 3

El de mi anécdota. Queremos mostrar octubre.txt sin los saltos de línea:

# Mostrar el diario sin los saltos de línea


def mostrar(nombre):
    f = open(nombre, 'r', encoding='utf-8')
    linea = f.readline().rstrip()
    while linea != '':
        print(linea)
        linea = f.readline().rstrip()
    f.close()


mostrar('octubre.txt')

¿Cuántas sesiones salen? Solo dos:

2026-10-01;carrera;30
2026-10-02;natación;45

El rstrip() va pegado al readline(), así que la condición del while ya no mira lo que se leyó, sino lo que queda después de limpiarlo. La tercera línea del archivo es una línea vacía, '\n', y '\n'.rstrip() es '': exactamente el valor que significa «fin del archivo». El bucle se para y las cuatro sesiones siguientes nunca se leen.

La trampa del rstrip Tres lecturas de octubre.txt: las dos primeras se imprimen y la tercera, una línea vacía, detiene el bucle antes de tiempo. readline().rstrip(): la línea vacía se confunde con el final readline() lee .rstrip() deja linea != » ‘2026-10-01;carrera;30\n’ ‘2026-10-01;carrera;30’ True imprime ‘2026-10-02;natación;45\n’ ‘2026-10-02;natación;45’ True imprime ‘\n’ » False el bucle se para Nunca se leen: las sesiones de 25, 60, 40 y 20 minutos
Con rstrip() pegado a readline(), la línea vacía ‘\n’ se convierte en » y el while cree que el archivo se ha acabado.

Limpiar la línea en la misma instrucción que la lee. Con archivos sin líneas vacías funciona, y por eso engaña tanto: el día que aparece una línea en blanco, el programa deja de leer sin dar ningún error.

El arreglo: la condición mira la línea original y se limpia una copia dentro del bucle.

# Mostrar el diario sin los saltos de línea (arreglado)


def mostrar(nombre):
    f = open(nombre, 'r', encoding='utf-8')
    linea = f.readline()
    while linea != '':                # la condición mira la línea CON su '\n'
        limpia = linea.rstrip()       # limpio una copia para imprimirla
        if limpia != '':
            print(limpia)
        linea = f.readline()
    f.close()


mostrar('octubre.txt')

Ahora salen las seis sesiones, y la línea vacía no se imprime:

2026-10-01;carrera;30
2026-10-02;natación;45
2026-10-04;carrera;25
2026-10-05;bici;60
2026-10-07;carrera;40
2026-10-09;yoga;20

Con for linea in f este problema no existe: el bucle termina cuando se acaba el archivo, no cuando una línea parece vacía. Si no necesitas parar a mitad, usa for.

Soluciones

Mira las soluciones solo después de intentarlo. Cada una lleva los comentarios dentro del código y una explicación de por qué está hecha así.

Solución del Nivel 1

# Nivel 1 · Contar lo que hay en el diario


def contar(nombre):
    lineas = vacias = sesiones = minutos = 0
    with open(nombre, 'r', encoding='utf-8') as f:
        for linea in f:
            lineas += 1                      # toda línea cuenta, también las vacías
            limpia = linea.strip()
            if limpia == '':                 # '\n' a secas (o solo espacios)
                vacias += 1
                continue
            sesiones += 1
            minutos += int(limpia.split(';')[2])   # el tercer campo son los minutos
    return lineas, vacias, sesiones, minutos


lineas, vacias, sesiones, minutos = contar('octubre.txt')
print('Líneas:', lineas)
print('Vacías:', vacias)
print('Sesiones:', sesiones)
print('Minutos:', minutos)

Da exactamente la salida del enunciado. Por qué está hecho así:

  • lineas += 1 va antes del if, para contar también las vacías. Si lo pusieras después del continue, las vacías no contarían como líneas y saldría 6.
  • limpia = linea.strip() y no rstrip('\n'): una línea con solo espacios también es vacía para nosotros.
  • lineas = vacias = sesiones = minutos = 0 pone las cuatro a cero en una línea.
  • Devolver una tupla y desempaquetarla fuera separa el cálculo de la impresión.

La traza de las ocho líneas:

Línealimpiavaciassesionesminutos
1'2026-10-01;carrera;30'0130
2'2026-10-02;natación;45'0275
3''1275
4'2026-10-04;carrera;25'13100
5'2026-10-05;bici;60'14160
6'2026-10-07;carrera;40'15200
7''25200
8'2026-10-09;yoga;20'26220

Cuentas: 30 + 45 + 25 + 60 + 40 + 20 = 220, y 6 sesiones + 2 vacías = 8 líneas.

Solución del Nivel 2

# Nivel 2 · Un archivo solo con las carreras largas


def filtrar_carreras(origen, destino, minimo):
    """Escribe en destino las sesiones de carrera de al menos 'minimo' minutos. Devuelve cuántas."""
    n = total = 0
    with open(origen, 'r', encoding='utf-8') as fin, open(destino, 'w', encoding='utf-8') as fout:
        for linea in fin:                    # el bucle recorre el ORIGEN; el destino solo recibe
            partes = linea.strip().split(';')
            if len(partes) != 3:             # línea vacía: [''] tiene un solo campo
                continue
            fecha, deporte, minutos = partes
            if deporte == 'carrera' and int(minutos) >= minimo:   # >=: 30 minutos justos también valen
                n += 1
                total += int(minutos)
                fout.write(f'{n}. {fecha} ({minutos} min)\n')
        fout.write(f'Total: {n} sesiones, {total} min\n')
    return n


cuantas = filtrar_carreras('octubre.txt', 'carreras.txt', 30)
print('Carreras copiadas:', cuantas)
f = open('carreras.txt', 'r', encoding='utf-8')
linea = f.readline()
while linea != '':
    print(linea, end='')
    linea = f.readline()
f.close()

Por qué está hecho así:

  • Los dos archivos en un solo with: se cierran los dos al salir, y la línea final de totales se escribe dentro del bloque, cuando fout aún está abierto. Si la sacaras fuera del with, daría ValueError: I/O operation on closed file.
  • len(partes) != 3 descarta las líneas vacías, que tras split(';') son [''].
  • >= minimo y no >: la sesión del día 1 dura justo 30 minutos y el enunciado dice «al menos 30».
  • int(minutos) se usa para comparar y sumar, pero en el archivo escribo minutos tal cual (ya es texto).
  • write() con el '\n' explícito en cada línea, para no depender de print().

Las carreras del archivo son tres: 30 (entra), 25 (no llega) y 40 (entra). Por eso sale Total: 2 sesiones, 70 min, con 30 + 40 = 70.

Solución del Nivel 3

# Nivel 3 · Normalizar un diario escrito "a mano"
import re

# fecha, separador (; , o |) con espacios opcionales, deporte, separador, minutos y 'min' opcional
PATRON = re.compile(r'(\d{4}-\d{2}-\d{2})\s*[;,|]\s*(\w+)\s*[;,|]\s*(\d+)\s*(?:min)?')


def normalizar(origen, destino):
    cambiadas = total = 0
    with open(origen, 'r', encoding='utf-8') as fin, open(destino, 'w', encoding='utf-8') as fout:
        for linea in fin:
            original = linea.strip()
            m = PATRON.fullmatch(original)        # fullmatch: la línea ENTERA tiene que encajar
            if m is None:                         # vacías y frases sueltas no son sesiones
                continue
            limpia = ';'.join(m.groups())         # fecha;deporte;minutos
            total += 1
            if limpia != original:
                cambiadas += 1
            fout.write(f'{limpia} <- {original}\n')
        fout.write('>' * 10 + '\n')
        fout.write(f'Normalizadas:{cambiadas:3d}, Total:{total:3d}\n')   # campos de 3 caracteres
    return cambiadas, total


print(normalizar('sucio.txt', 'limpio.txt'))
with open('limpio.txt', 'r', encoding='utf-8') as f:
    print(f.read(), end='')

Por qué está hecho así:

  • El patrón se compila una vez, fuera de la función, como viste en el tema de regex.
  • \s*[;,|]\s*: el separador es uno de los tres caracteres, con espacios opcionales a los lados. Así encajan ;, , y | .
  • (\w+) atrapa el deporte; \w incluye las letras con tilde, así que natación encaja.
  • \s*(?:min)? acepta 45min, 25 min o nada. El (?:…) agrupa sin crear un cuarto grupo, para que m.groups() siga dando tres.
  • fullmatch y no search: con search, una línea con basura delante o detrás también se daría por buena.
  • ';'.join(m.groups()) monta la línea normalizada con los tres grupos.
  • Una línea «ha cambiado» si la versión limpia es distinta de la original sin el salto. Por eso se compara con original = linea.strip().
  • f'{cambiadas:3d}' reserva 3 caracteres: el 3 sale como 3, con dos espacios delante, igual que pide el formato.

El resultado, línea a línea:

El Nivel 3 dibujado Las siete líneas del archivo sucio, cuáles se normalizan, cuáles cambian y cuáles se ignoran, y las dos líneas finales. Normalizar: un solo formato para todas las sesiones sucio.txt limpio.txt 2026-10-01;carrera;30 2026-10-01;carrera;30 2026-10-02, natación, 45min 2026-10-02;natación;45 ⏎ ignorada (vacía) 2026-10-04 | carrera | 25 min 2026-10-04;carrera;25 sin datos hoy ignorada (no encaja) 2026-10-05;bici;60 2026-10-05;bici;60 2026-10-07,carrera,40 2026-10-07;carrera;40 >>>>>>>>>> Normalizadas: 3, Total: 5 coral = línea que ha cambiado
Cinco líneas encajan con el patrón y se reescriben como fecha;deporte;minutos; tres cambian y las otras dos ya estaban bien.

Cuentas: 7 líneas en sucio.txt; 2 no son sesiones (la vacía y «sin datos hoy»), así que el total es 7 − 2 = 5. De esas 5, las de los días 2, 4 y 7 cambian: 3 normalizadas.

Este ejercicio tiene la misma forma que los exámenes de FP1 que juntan archivos, regex y formato: leer un archivo, buscar con un patrón, escribir otro archivo y terminar con líneas de resumen de ancho fijo.

Míralo paso a paso en Python Tutor

En Python Tutor (elige Python 3) vamos a ver en directo el fallo del fragmento 3. Python Tutor no abre archivos de tu disco, así que el archivo es una string y next(f, '') hace de readline().

# El fragmento 3 con el "archivo" simulado: mira cuándo se para el while
texto = '2026-10-01;carrera;30\n2026-10-02;natación;45\n\n2026-10-04;carrera;25\n'
f = iter(texto.splitlines(keepends=True))   # next(f, '') hace de readline()
linea = next(f, '').rstrip()
while linea != '':
    print(linea)
    linea = next(f, '').rstrip()     # la línea vacía '\n' se convierte en '' y el bucle termina
print('Fin del bucle')

Sale lo mismo que en el fragmento:

2026-10-01;carrera;30
2026-10-02;natación;45
Fin del bucle

Qué observar:

  1. Mira la lista que crea iter(...): la tercera línea es '\n', una línea vacía, pero no una string vacía.
  2. En las dos primeras vueltas, linea ya llega sin '\n' porque el rstrip() va pegado a next().
  3. En la tercera lectura, next() devuelve '\n' y el rstrip() lo convierte en ''. Fíjate en el valor de linea justo antes de evaluar la condición.
  4. La condición es False, el bucle termina y se imprime Fin del bucle, aunque en el iterador aún quedaba una línea con la carrera de 25 minutos.

Chuletario: Archivos de texto

Lo esencial en 30 segundos

  • Abre siempre con with open(nombre, modo, encoding='utf-8') as f:. Se cierra solo.
  • 'r' lee (el archivo debe existir), 'w' vacía y escribe, 'a' añade al final.
  • Cada línea leída trae su '\n'. Una línea vacía es '\n'; '' significa fin del archivo.
  • print(..., file=f) convierte y añade el salto; f.write(s) solo admite strings y no añade nada.
  • Un manejador se recorre una vez: para volver a leer, vuelve a abrir.

Leer

Quiero…CódigoResultado
recorrer línea a líneafor linea in f:cada línea con su '\n'
numerar las líneasfor n, linea in enumerate(f, start=1):1, 2, 3…
quitar el salto y espacioslinea.strip()'2026-10-01;carrera;30'
solo el saltolinea.rstrip('\n')conserva los espacios
leer una líneaf.readline()'' al final del archivo
todo en una stringf.read()'...\n...\n'
n caracteresf.read(4)'2026'
lista de líneas sin saltof.read().splitlines()['…', '…']
ver espacios y saltosprint(repr(linea))'… ;30 \t\n'

Escribir

Quiero…CódigoResultado
una línea con printprint(a, b, c, sep=';', file=f)a;b;c + '\n'
una línea con writef.write(f'{a};{b};{c}\n')lo mismo
un númerof.write(str(55) + '\n')'55\n'
copiar una línea leídafout.write(linea)sin salto extra
añadir sin borraropen(nombre, 'a', encoding='utf-8')escribe al final

Módulo os

Quiero…Código
¿existe?os.path.exists(ruta)
crear carpetasos.makedirs('a/b', exist_ok=True)
unir una rutaos.path.join('a', 'b', 'x.txt')
listar una carpetaos.listdir(carpeta)
¿es carpeta?os.path.isdir(ruta)
borrar un archivoos.remove(ruta)
carpeta de trabajoos.getcwd()

Plantillas de los ejercicios

Contar y saltar líneas vacías (Nivel 1):

with open(nombre, 'r', encoding='utf-8') as f:
    for linea in f:
        limpia = linea.strip()
        if limpia == '':
            continue
        campos = limpia.split(';')

Filtrar de un archivo a otro (Nivel 2):

with open(origen, 'r', encoding='utf-8') as fin, open(destino, 'w', encoding='utf-8') as fout:
    for linea in fin:
        if cumple(linea):
            fout.write(linea)
    fout.write('línea final\n')   # dentro del with

Normalizar con regex y cerrar con un resumen de ancho fijo (Nivel 3):

m = PATRON.fullmatch(linea.strip())
if m is not None:
    fout.write(';'.join(m.groups()) + '\n')
fout.write('>' * 10 + '\n')
fout.write(f'Normalizadas:{cambiadas:3d}, Total:{total:3d}\n')

Errores que más se repiten

SíntomaCausa probable
El archivo ha quedado vacío o con una sola línealo abriste con 'w' en lugar de 'a'
FileNotFoundErrornombre mal escrito o carpeta de trabajo distinta
Líneas en blanco entre las líneasprint(linea) con la línea que ya trae '\n'
Todo sale en una sola líneawrite() sin '\n'
TypeError: write() argument must be strle pasaste un número a write()
El bucle se para antes de tiemporeadline().rstrip() en la misma instrucción
La segunda lectura no da nadarecorriste el mismo manejador dos veces
Gáldar y caracteres raroscodificación distinta; usa encoding='utf-8'
ValueError: I/O operation on closed fileescribiste fuera del with

Antes de entregar, pregúntate

  1. ¿He elegido el modo pensando en lo que ya hay en el archivo?
  2. ¿Quito el '\n' donde hace falta y no añado otro al escribir?
  3. ¿Mi bucle sobrevive a líneas vacías, comentarios y campos de más o de menos?
  4. ¿Todo se escribe dentro del with y con encoding='utf-8'?

Con esto terminas los ejercicios de archivos de texto y todo el temario de FP1 en SergioLearns. Si te ha servido, vuelve a la teoría cuando lo necesites: el chuletario está aquí para eso.

Sigue aprendiendoTodos los artículos de Python

¿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 *