Python strings — practice cleaning up a class list, validating DNIs and printing an aligned marks report (and the 7,5 that float() refused to read)
With the class list cleaned up (and the lesson learnt about storing what strip() returns), I was given the marks for the mid-term exams. They came from a spreadsheet set up in Spanish, so every mark was written with a decimal comma. I copied them into my program and went straight for converting them into numbers:
grade = float("7,5") # this is how the mark came in the spreadsheet
print(grade)
Python answered:
Traceback (most recent call last):
File "hook.py", line 1, in <module>
grade = float("7,5") # this is how the mark came in the spreadsheet
ValueError: could not convert string to float: '7,5'
“What do you mean it can’t convert 7,5? It’s a number.” For me, yes. For Python, "7,5" is a string with a 7, a comma and a 5, and float() only understands the point as decimal separator. The fix is one line, float(text.replace(",", ".")), but the lesson is bigger: data typed by people never arrives clean. Extra spaces, random capitals, commas where Python expects points, dates with one digit… Before calculating anything, you have to clean and check.
That’s exactly what we’re going to practise with three complete programs about a class list: cleaning up names typed any old way and generating each student’s username, validating DNIs and dates of birth explaining what is wrong with each one, and printing the marks report of the mid-terms with perfectly aligned columns. All three are intermediate level and use data that looks like the real thing, with its gaps and its mistakes. This practice builds on the strings theory; if anything about dictionaries sounds strange, review associative containers too, because they appear in two of the programs.
Table of Contents
How to use this article
Each program is complete in a single file. Copy it, save it with the name shown and run it from the terminal with python3 name.py. Under each one you have the real output, a drawing or a table with what happens inside and an explanation function by function. Before looking at the output, try to predict it: with strings, knowing what each method returns (and what it does not change) is exactly what you need to train.
From the theory, you should be comfortable with:
- Strings being immutable: each method returns a new one that you must store.
- The difference between
split()andsplit(" "), and howjoinsticks the pieces back together. - How indices and slices work (
dni[:8],dni[8]). - The f-string format specifiers: width, alignment and
.2f.
No program uses input(). The data is fixed in the code so that your output is identical to the one you see here. If you want to try your own data, change the lists at the start of each program.
Program 1 · Cleaning up the class list
The list comes from a form where each student typed “surnames, name” their own way: spaces in front and behind, several spaces in a row, the comma stuck to the word or not, capitals everywhere. We want two things for each student: their name properly written (María José Núñez de la Torre) and a username for the virtual campus in the form name.surname, in lowercase and without accents.
The idea
Before programming, think about the order. First you have to separate surnames and name (at the comma), then clean each part (spaces and capitals) and, only at the end, build the username from the parts already cleaned. Each step is a small function that takes a string and returns another one: that way they can be tested separately and combined like building blocks.
The comma may have spaces on either side ("torre , MARÍA", "SANTANA,luis"). If you split at ", " (comma and space), the second line won’t split. Better to split at the comma only and clean the spaces afterwards.
clean_list.py
# List as it came from the form: "surnames, name", each one typed their own way
RAW = [
" gARCÍA pérez, ana ",
"ROMERO SANTANA,luis",
"núñez de la torre , MARÍA JOSÉ",
"gil, marta ",
]
PARTICLES = ["de", "la", "del", "las", "los", "y"]
ACCENTS = {"á": "a", "é": "e", "í": "i", "ó": "o", "ú": "u", "ü": "u", "ñ": "n"}
def normalise_spaces(text):
"""Removes the spaces at both ends and leaves ONE single space between words."""
return " ".join(text.split()) # split() with no argument cuts at any gap
def fix_particles(text):
"""title() writes 'De La'; particles go in lowercase (unless they are the first word)."""
words = text.split()
for i in range(1, len(words)): # from 1: the first word is left alone
if words[i].lower() in PARTICLES:
words[i] = words[i].lower() # in a LIST you can change an element
return " ".join(words)
def pretty_name(line):
"""'gARCÍA pérez, ana' -> ('Ana', 'García Pérez')."""
surnames, comma, name = line.partition(",") # cuts at the FIRST comma
name = normalise_spaces(name).title()
surnames = fix_particles(normalise_spaces(surnames).title())
return name, surnames
def without_accents(text):
"""Replaces each accented vowel (and ñ) with its unaccented version."""
for accented, plain in ACCENTS.items():
text = text.replace(accented, plain) # replace returns a new string: you must store it
return text
def username(name, surnames):
"""First word of the name + '.' + first surname, lowercase and without accents."""
first = name.split()[0]
surname1 = surnames.split()[0]
return without_accents((first + "." + surname1).lower()) # lower first: ACCENTS is in lowercase
for n, line in enumerate(RAW, start=1):
name, surnames = pretty_name(line)
print(f"{n}. {name} {surnames} -> {username(name, surnames)}@alu.ulpgc.es")
Run it with python3 clean_list.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
The four lines, so different on the way in, come out with the same shape. Look at the third one: "núñez de la torre , MARÍA JOSÉ" has become María José Núñez de la Torre, with “de la” in lowercase, and its username is maria.nunez, without accents or ñ. Let’s see how, function by function.
normalise_spaces: split and join
def normalise_spaces(text):
"""Removes the spaces at both ends and leaves ONE single space between words."""
return " ".join(text.split()) # split() with no argument cuts at any gap
It’s the trick from the theory: text.split() (with no argument) cuts at any gap, however big, and drops the spaces at the ends. Then " ".join(…) sticks the words back together with one single space. With "ROMERO SANTANA":
| Step | Result |
|---|---|
text | "ROMERO SANTANA" |
text.split() | ['ROMERO', 'SANTANA'] |
" ".join(…) | "ROMERO SANTANA" |
Why not strip()? Because strip() only removes the spaces at the ends; the three spaces in the middle would still be there.
pretty_name: partition and title
def pretty_name(line):
"""'gARCÍA pérez, ana' -> ('Ana', 'García Pérez')."""
surnames, comma, name = line.partition(",") # cuts at the FIRST comma
name = normalise_spaces(name).title()
surnames = fix_particles(normalise_spaces(surnames).title())
return name, surnames
This is the full journey of the third line, the trickiest one:
partition(",")cuts at the first comma and returns three pieces: what comes before ("núñez de la torre "), the comma and what comes after (" MARÍA JOSÉ"). We don’t need the comma, but it has to be caught in a variable (comma) for the unpacking to work.- Why
partitionand notsplit(",")? Becausepartitionalways returns exactly three pieces, even if there is no comma (then the last two are""). Withsplit(","), a line without a comma would give a single piece and unpacking it into two variables would fail. - Each part goes through
normalise_spacesandtitle(). As strings are immutable, each call returns a new one, which is why we reassignname = …andsurnames = …. title()leaves “Núñez De La Torre”. In Spanish surnames the particles go in lowercase, so the surnames also go throughfix_particles.
Writing normalise_spaces(name).title() on a line of its own, with no name = in front. No error, but name stays as it was: it’s exactly the strip() bug from the start of the topic.
fix_particles: changing a list, not a string
def fix_particles(text):
"""title() writes 'De La'; particles go in lowercase (unless they are the first word)."""
words = text.split()
for i in range(1, len(words)): # from 1: the first word is left alone
if words[i].lower() in PARTICLES:
words[i] = words[i].lower() # in a LIST you can change an element
return " ".join(words)
Here’s where immutability matters. We can’t do text[6] = "d" to change the D of “De”, because a string doesn’t allow assignment by position. But a list does. So we split the text into a list of words, change the words we need to in the list and join it back:
i | words[i] | .lower() | In PARTICLES? | words after |
|---|---|---|---|---|
| (start) | ['Núñez', 'De', 'La', 'Torre'] | |||
| 1 | 'De' | 'de' | Yes | ['Núñez', 'de', 'La', 'Torre'] |
| 2 | 'La' | 'la' | Yes | ['Núñez', 'de', 'la', 'Torre'] |
| 3 | 'Torre' | 'torre' | No | ['Núñez', 'de', 'la', 'Torre'] |
The loop starts at 1, not 0, on purpose: if a surname started with a particle (“De la Rosa”), the first word must stay in capitals.
without_accents and username
def without_accents(text):
"""Replaces each accented vowel (and ñ) with its unaccented version."""
for accented, plain in ACCENTS.items():
text = text.replace(accented, plain) # replace returns a new string: you must store it
return text
def username(name, surnames):
"""First word of the name + '.' + first surname, lowercase and without accents."""
first = name.split()[0]
surname1 = surnames.split()[0]
return without_accents((first + "." + surname1).lower()) # lower first: ACCENTS is in lowercase
username takes the first word of the name ("María José".split()[0] is "María") and the first word of the surnames ("Núñez"), joins them with a dot and lowercases them: "maría.núñez". The order matters: first lower() and then without_accents, because the ACCENTS dictionary only has lowercase letters. If we did it the other way round, a capital Á wouldn’t be changed.
without_accents goes through the dictionary and, for each pair, replaces all the occurrences. With "maría.núñez":
| Pass | accented → plain | text after |
|---|---|---|
| 1 | á → a | maría.núñez (no á) |
| 2 | é → e | maría.núñez |
| 3 | í → i | maria.núñez |
| 4 | ó → o | maria.núñez |
| 5 | ú → u | maria.nuñez |
| 6 | ü → u | maria.nuñez |
| 7 | ñ → n | maria.nunez |
Once you’ve mastered replace, Python has a way of making all the changes at once: text.translate(str.maketrans("áéíóúüñ", "aeiouun")). It does the same as the loop, but it’s worth writing the loop at least once to understand what’s going on.
Try it yourself
- Add the line
"de la rosa, PABLO "toRAW. Predict the name and the username before running it. Does the first word of the surnames come out in capitals? And does the usernamepablo.demake sense? - Write
initials(name, surnames)returning the initials in capitals and without dots (for María José Núñez de la Torre,MJNT: particles don’t count).
Program 2 · Validating DNIs and dates of birth
Each student has typed their DNI and date of birth by hand, separated by ;. We want to check each record and say what is wrong, not just whether it’s right or wrong: a DNI with one digit missing, a letter that doesn’t match, a date not in dd/mm/yyyy format…
The idea
A Spanish DNI has 8 digits and a letter, and the letter is not free: it’s the one at position number % 23 in the string "TRWAGMYFPDXBNJZSQVHLCKE". So validation is a ladder of checks, from the most basic to the finest: first the length, then that the first 8 are digits, and only then compute the letter. If one fails, the reason is returned and nothing else is checked. The date works the same way: format (three pieces of 2, 2 and 4 characters), that they are digits and that the month and day make sense.
The order of the checks is not a whim. If you compute dni[8] before checking that the DNI has length 9, a short DNI will give you an IndexError instead of a message.
validate.py
LETTERS = "TRWAGMYFPDXBNJZSQVHLCKE" # the DNI letter is LETTERS[number % 23]
# Each record: "DNI;date of birth", typed by hand by each student
RECORDS = [
"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 validate_dni(dni):
"""Returns "" if the DNI is correct, or a text with the reason if it is not."""
dni = dni.strip().upper() # tolerates spaces and a lowercase letter
if len(dni) != 9: # length first: that way dni[8] never fails
return f"DNI with {len(dni)} characters (must be 9)"
number, letter = dni[:8], dni[8] # 8 digits and the letter
if not number.isdigit():
return f"'{number}' is not 8 digits"
expected = LETTERS[int(number) % 23] # the remainder (0 to 22) is a position in LETTERS
if letter != expected:
return f"the letter should be {expected}"
return ""
def validate_date(date):
"""Checks the dd/mm/yyyy format and that the day and the month make sense."""
parts = date.strip().split("/")
if len(parts) != 3 or [len(p) for p in parts] != [2, 2, 4]:
return "date not in dd/mm/yyyy format"
if not "".join(parts).isdigit(): # join the pieces and check they are all digits
return "date with characters that are not digits"
day, month = int(parts[0]), int(parts[1])
if not 1 <= month <= 12:
return f"month {month} does not exist"
if not 1 <= day <= 31:
return f"day {day} does not exist"
return ""
correct = 0
print(f"{'DNI':<12}{'Date':<12}Result")
for record in RECORDS:
dni, date = record.split(";")
# only non-empty messages are kept ("" counts as false)
errors = [e for e in (validate_dni(dni), validate_date(date)) if e]
if errors:
result = "; ".join(errors)
else:
result = "OK"
correct += 1
print(f"{dni.strip():<12}{date.strip():<12}{result}")
print(f"Correct: {correct} of {len(RECORDS)} ({correct / len(RECORDS):.0%})")
Run it with python3 validate.py:
DNI Date Result 12345678Z 03/09/2007 OK 1234567Z 15/02/2006 DNI with 8 characters (must be 9) 12345678A 21/11/2006 the letter should be Z x2345678Z 30/06/2007 'X2345678' is not 8 digits 87654321x 7/5/2007 date not in dd/mm/yyyy format 45123456D 31/13/2006 month 13 does not exist Correct: 1 of 6 (17%)
validate_dni: a ladder of checks
def validate_dni(dni):
"""Returns "" if the DNI is correct, or a text with the reason if it is not."""
dni = dni.strip().upper() # tolerates spaces and a lowercase letter
if len(dni) != 9: # length first: that way dni[8] never fails
return f"DNI with {len(dni)} characters (must be 9)"
number, letter = dni[:8], dni[8] # 8 digits and the letter
if not number.isdigit():
return f"'{number}' is not 8 digits"
expected = LETTERS[int(number) % 23] # the remainder (0 to 22) is a position in LETTERS
if letter != expected:
return f"the letter should be {expected}"
return ""
Each if ends the function with a return as soon as it finds a problem; if it reaches the end, it returns "", which means “no errors”. This is how each record looks as it goes down the ladder:
| Record | After strip().upper() | len | number | Digits? | expected | Result |
|---|---|---|---|---|---|---|
12345678Z | 12345678Z | 9 | 12345678 | Yes | Z | OK |
1234567Z | 1234567Z | 8 | — | — | — | DNI with 8 characters |
12345678A | 12345678A | 9 | 12345678 | Yes | Z | the letter should be Z |
x2345678Z | X2345678Z | 9 | X2345678 | No | — | is not 8 digits |
87654321x | 87654321X | 9 | 87654321 | Yes | X | (DNI correct) |
45123456D | 45123456D | 9 | 45123456 | Yes | D | (DNI correct) |
The letter is calculated with an index on a string, which is easiest to understand with a drawing:
dni[:8]are the eight digits (as a string) anddni[8], the ninth character.int(number) % 23gives the remainder of dividing by 23. Let’s check it with the first one: 23 × 536768 = 12345664, and 12345678 − 12345664 = 14. Position 14 ofLETTERSisZ: it matches.- The remainder of dividing by 23 is always between 0 and 22, and
LETTERShas 23 letters. That’s whyLETTERS[remainder]never goes outside the string. strip().upper()at the start makes the function tolerate spaces and lowercase letters: the fifth record,87654321x, is accepted (87654321 % 23 = 10, andLETTERS[10]isX). That’s also why the message for the fourth record says'X2345678'with a capital X: the text had already been uppercased.
Checking the digits with isdigit() before slicing. If you do dni.isdigit() with the whole DNI, the letter makes it always False. First the slice dni[:8], then isdigit().
validate_date: split and check each piece
def validate_date(date):
"""Checks the dd/mm/yyyy format and that the day and the month make sense."""
parts = date.strip().split("/")
if len(parts) != 3 or [len(p) for p in parts] != [2, 2, 4]:
return "date not in dd/mm/yyyy format"
if not "".join(parts).isdigit(): # join the pieces and check they are all digits
return "date with characters that are not digits"
day, month = int(parts[0]), int(parts[1])
if not 1 <= month <= 12:
return f"month {month} does not exist"
if not 1 <= day <= 31:
return f"day {day} does not exist"
return ""
split("/")turns"03/09/2007"into['03', '09', '2007']. The first condition asks for three pieces with lengths 2, 2 and 4. The list[len(p) for p in parts]gives the lengths; with"7/5/2007"it’s[1, 1, 4], which is not[2, 2, 4], so the date is rejected for its format.- The order of the condition matters: if the date had no slashes,
partswould have a single piece andlen(parts) != 3would already be true.ordoesn’t evaluate the second part, so it never tries to look at pieces that don’t exist. "".join(parts)joins the pieces back with no separator ("03092007") to check in one go that everything is a digit.- Only once we know they are digits are they converted with
int. With"31/13/2006",monthis 13 and the condition1 <= month <= 12is false: month 13 does not exist.
This validation is deliberately simple: it accepts 31 February. Checking the days of each month (and leap years) is a good “Try it yourself”, and in topic 8 you’ll see how regular expressions check formats like dd/mm/yyyy in a single line.
The main loop: gathering the errors
correct = 0
print(f"{'DNI':<12}{'Date':<12}Result")
for record in RECORDS:
dni, date = record.split(";")
# only non-empty messages are kept ("" counts as false)
errors = [e for e in (validate_dni(dni), validate_date(date)) if e]
if errors:
result = "; ".join(errors)
else:
result = "OK"
correct += 1
print(f"{dni.strip():<12}{date.strip():<12}{result}")
print(f"Correct: {correct} of {len(RECORDS)} ({correct / len(RECORDS):.0%})")
record.split(";")gives exactly two pieces, DNI and date, which are unpacked.errorsis built with a list comprehension that keeps only the non-empty messages:if eis false for"". So a record with the DNI right and the date wrong has a single error, and one with both wrong would have two, joined by"; ".join(errors).- The output uses
{dni.strip():<12}and{date.strip():<12}: columns of 12 characters on the left, which line up with the header, written with the same widths. - At the end, 1 correct out of 6 is 0.1666…, and the
:.0%format multiplies it by 100 and rounds it with no decimals: 17%.
Try it yourself
- Improve
validate_dateso that it checks the days of each month (with a list[31, 28, 31, …]) and rejects 30 February. - Make
validate_dnialso accept the NIE of foreign residents, which starts withX,YorZ: that letter is replaced by 0, 1 or 2 and the letter is calculated the same way. Try it withX1234567L.
Program 3 · The mid-term marks report, with aligned columns
To finish, the program from the opening: the marks of the three mid-terms arrive as strings with a decimal comma ("7,5; 8,25; 6") in a dictionary student → marks. We want a report like the real ones: a centred title, a header, one row per student with their list number, their three marks, the mean and the final grade, all aligned, and a summary at the end.
The idea
There are two different problems and it’s worth keeping them apart. The first is converting each string of marks into a list of numbers (cut at ;, clean, change the comma for a point, float). The second is presenting: deciding the width of each column and using it the same way in the header, the rows and the summary. If the widths don’t match, the table goes out of line.
Draw the table on paper before programming and count the characters of each column. The width of the name is set by the longest one (“Núñez de la Torre, María José” has length 29), and it’s worth leaving a little margin.
In the report, the Spanish grades are translated as Fail (below 5), Pass (5 to 7), Good (7 to 9) and Excellent (9 or more).
grade_report.py
# Marks of the three mid-terms, as copied from the spreadsheet (with decimal comma)
GRADES = {
"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",
}
WIDTH = 75 # 4 + 31 + 6 + 6 + 6 + 7 + 2 = 62 columns, plus 13 for the final grade
def convert(text):
"""'7,5; 8,25; 6' -> [7.5, 8.25, 6.0]"""
return [float(t.strip().replace(",", ".")) for t in text.split(";")]
def final_grade(average):
if average < 5:
return "Fail"
if average < 7:
return "Pass"
if average < 9:
return "Good"
return "Excellent"
print(" FP1 RESULTS · SUBGROUP 43 ".center(WIDTH, "=")) # title centred between = signs
print(f"{'No.':<4}{'Student':<31}{'P1':>6}{'P2':>6}{'P3':>6}{'Mean':>7} Grade")
print("-" * WIDTH)
averages = []
for n, student in enumerate(sorted(GRADES), start=1): # sorted: by surname
p1, p2, p3 = convert(GRADES[student])
average = (p1 + p2 + p3) / 3
averages.append(average)
# zfill(2) for the number; each mark on the right with 2 decimals
print(f"{str(n).zfill(2):<4}{student:<31}{p1:6.2f}{p2:6.2f}{p3:6.2f}{average:7.2f} {final_grade(average)}")
print("-" * WIDTH)
passed = sum(1 for m in averages if m >= 5)
print(f"{'Class average:':<53}{sum(averages) / len(averages):7.2f}")
print(f"Passed: {passed} of {len(averages)} ({passed / len(averages):.0%})")
Run it with python3 grade_report.py:
======================== FP1 RESULTS · SUBGROUP 43 ======================== No. Student P1 P2 P3 Mean Grade --------------------------------------------------------------------------- 01 García Pérez, Ana 7.50 8.25 6.00 7.25 Good 02 Gil, Marta 4.50 5.00 3.75 4.42 Fail 03 Núñez de la Torre, María José 10.00 9.50 9.75 9.75 Excellent 04 Romero Santana, Luis 5.00 4.25 6.50 5.25 Pass --------------------------------------------------------------------------- Class average: 6.67 Passed: 3 of 4 (75%)
convert: from “7,5; 8,25; 6” to [7.5, 8.25, 6.0]
def convert(text):
"""'7,5; 8,25; 6' -> [7.5, 8.25, 6.0]"""
return [float(t.strip().replace(",", ".")) for t in text.split(";")]
Everything happens in a list comprehension. Read it in parts, from the inside out, with Ana’s marks:
| Step | Result |
|---|---|
text.split(";") | ['7,5', ' 8,25', ' 6'] |
t.strip() (each piece) | '7,5', '8,25', '6' |
.replace(",", ".") | '7.5', '8.25', '6' |
float(…) | 7.5, 8.25, 6.0 |
Notice that strip is needed: split(";") leaves the space that came after each ; stuck to the next piece. Actually float(" 8.25") would tolerate that space, but cleaning first is a good habit: other conversions (and other comparisons) are not so forgiving.
With the three marks, Ana’s mean is (7.5 + 8.25 + 6) / 3 = 21.75 / 3 = 7.25, a Good.
The columns, one by one
print(" FP1 RESULTS · SUBGROUP 43 ".center(WIDTH, "=")) # title centred between = signs
print(f"{'No.':<4}{'Student':<31}{'P1':>6}{'P2':>6}{'P3':>6}{'Mean':>7} Grade")
print("-" * WIDTH)
averages = []
for n, student in enumerate(sorted(GRADES), start=1): # sorted: by surname
p1, p2, p3 = convert(GRADES[student])
average = (p1 + p2 + p3) / 3
averages.append(average)
# zfill(2) for the number; each mark on the right with 2 decimals
print(f"{str(n).zfill(2):<4}{student:<31}{p1:6.2f}{p2:6.2f}{p3:6.2f}{average:7.2f} {final_grade(average)}")
This drawing shows Ana’s row on a character ruler. Each field of the f-string always takes the same width:
{str(n).zfill(2):<4}: the list number with two digits (01) in a field of 4 on the left.zfillis a string method, which is whynis first converted withstr(with an f-string,{n:02d}would also work).{student:<31}: the name on the left in 31 characters. The longest has length 29, so there are always at least 2 spaces before the marks.{p1:6.2f}: each mark with 2 decimals in 6 characters. Being a number, it goes on the right without writing>. That way10.00(5 characters) and7.50(4) end in the same column.{average:7.2f}: the mean in 7, one more than the marks, so it stands a little apart.- The header uses the same widths:
{'Student':<31},{'P1':>6}… Here you do need>, because'P1'is text and would be left-aligned. WIDTHis 75: 4 + 31 + 6 + 6 + 6 + 7 = 60 characters of columns, plus 2 spaces, plus 13 characters of room for the final grade (the longest Spanish grade, “Sobresaliente”, has 13 letters).- The title is centred with
center(WIDTH, "="): ” FP1 RESULTS · SUBGROUP 43 ” has length 27, and the 75 − 27 = 48=signs are shared out, 24 on each side. sorted(GRADES)returns the keys in order. Since the keys start with the surnames, the report comes out in alphabetical order of surname, even though the dictionary was unordered.
The summary
print("-" * WIDTH)
passed = sum(1 for m in averages if m >= 5)
print(f"{'Class average:':<53}{sum(averages) / len(averages):7.2f}")
print(f"Passed: {passed} of {len(averages)} ({passed / len(averages):.0%})")
{'Class average:':<53} pads the text to 53 characters so that the number, with its width of 7, ends at column 53 + 7 = 60, the same as each student’s mean. Check the numbers: (7.25 + 4.4166… + 9.75 + 5.25) / 4 = 26.6666… / 4 = 6.6666…, which with two decimals is 6.67. And 3 passes out of 4 is 75%.
Calculating the mean with the marks still as strings. "7,5" + "8,25" gives no error: it gives "7,58,25", because + concatenates. The error only appears later, when dividing by 3. Always convert to a number before operating.
When a table doesn’t line up, count the widths as on the ruler in the drawing. The sum of the column widths has to be the width of the line of dashes.
Try it yourself
- Add a student with a name longer than 31 characters. What happens to their row? Is the name cut or does the table go out of line? Fix it by cutting the name with a slice (
student[:30]). - Add a column with each student’s highest mark (
max(p1, p2, p3)) between the mean and the final grade, and adjust the header andWIDTH.
Common mistakes in this practice
- Calling a method without storing the result.
line.strip()on its own doesn’t changeline. If the method returns a string, writeline = line.strip(). - **Splitting at
", "instead of",".** As soon as someone types the comma without a space, it doesn’t split. Split at the minimum separator and clean the spaces afterwards. - **Looking at
dni[8]before checking the length.** A short DNI gives anIndexError. Firstlen, then indices. - **Using
isdigit()for decimals.**"7,5".isdigit()and"7.5".isdigit()giveFalse. To convert decimals,replaceandfloat. - Operating on marks that are still strings.
+concatenates instead of adding, and the error shows up later and further away. - Different widths in the header and the rows. The table goes out of line: write the same numbers in both f-strings.
Watch it step by step in Python Tutor
Python Tutor runs the code line by line and shows the value of each variable after every step. Choose Python 3 and paste this minimal version of cleaning one line:
line = " gil, MARTA "
surnames, comma, name = line.partition(",")
surnames = surnames.strip().title()
name = " ".join(name.split()).title()
username = (name + "." + surnames).lower()
print(name, surnames, username)
Expected output:
Marta Gil marta.gil
What to watch, step by step:
- After line 2 three new variables appear:
surnamesis" gil",commais","andnameis" MARTA ". Notice the spaces:partitioncuts, but doesn’t clean. - On line 3,
surnameschanges its value to"Gil". The string hasn’t really changed: Python has created a new one and the variable now points to it. - On line 4,
namegoes from" MARTA "to"Marta":split()swallows the spaces,joinsticks the only word back andtitle()fixes the capitals. - On line 5,
usernameis built by adding three strings and lowercasing the result:"marta.gil". - Go back to line 2 with the Prev button and notice that
linenever changes during the whole program: every transformation creates new strings.
Summary
- Clean before using:
strip," ".join(text.split())andtitle()turn text typed any old way into uniform data. - **
partitionversussplit:**partitionalways gives three pieces, so it can be unpacked safely;splitgives as many as there are cuts. - To change individual words, go through a list: strings don’t allow assignment by position, but a list of words does, and then
joinputs it back together. - Validating is a ladder: length, then type of characters, then the fine rule (the letter, the month). The order avoids errors like
IndexError. - Indices on strings are also useful for looking things up:
LETTERS[number % 23]turns a number into a letter. - Aligned columns = the same widths in the header, rows and summary:
<for text, numbers on the right with.2f, andcenter/zfillfor the details.
Checklist: have you practised it?
- Have you run the three programs and checked the outputs?
- Could you explain why
pretty_nameusespartition(",")and notsplit(", ")? - Can you work out a DNI letter by hand with
% 23and theLETTERStable? - Do you know why
validate_dnichecks the length before anything else? - Could you add a column to the report without it going out of line?
- Have you tried the “Try it yourself” tasks without looking at the theory?
If you answered yes to almost everything, you’re ready for the strings exercises, where I no longer give you the code: university e-mail addresses, signatures and initials, an attendance sheet and three snippets with very common bugs to hunt down. See you there!
Next step: exercisesPython strings — exercises with university e-mail addresses, signatures with initials and an attendance sheet (and the two spaces that broke my initials)Did this article help you? Here is how you can support SergioLearns →

One Comment