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.

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() and split(" "), and how join sticks 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":

StepResult
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:

From the form line to the name and the usernameThe line "núñez de la torre , MARÍA JOSÉ" is split with partition(",") into surnames and name. The surnames go through normalise_spaces, title and fix_particles until "Núñez de la Torre"; the name, through normalise_spaces and title until "María José". The username comes from the first word of each: maria.nunez.From the form line to the name and the username"núñez de la torre , MARÍA JOSÉ"partition(",")surnamesnormalise_spaces"núñez de la torre"title()"Núñez De La Torre"fix_particles"Núñez de la Torre"namenormalise_spaces"MARÍA JOSÉ"title()"María José"username"María" + "." + "Núñez"lower()"maría.núñez"without_accents"maria.nunez"
Each step returns a new string that is stored in a variable; the next step works on that result.
  • 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 partition and not split(",")? Because partition always returns exactly three pieces, even if there is no comma (then the last two are ""). With split(","), a line without a comma would give a single piece and unpacking it into two variables would fail.
  • Each part goes through normalise_spaces and title(). As strings are immutable, each call returns a new one, which is why we reassign name = … and surnames = ….
  • title() leaves “Núñez De La Torre”. In Spanish surnames the particles go in lowercase, so the surnames also go through fix_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:

iwords[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":

Passaccented → plaintext after
1á → amaría.núñez (no á)
2é → emaría.núñez
3í → imaria.núñez
4ó → omaria.núñez
5ú → umaria.nuñez
6ü → umaria.nuñez
7ñ → nmaria.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 " to RAW. Predict the name and the username before running it. Does the first word of the surnames come out in capitals? And does the username pablo.de make 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:

RecordAfter strip().upper()lennumberDigits?expectedResult
12345678Z12345678Z912345678YesZOK
1234567Z1234567Z8———DNI with 8 characters
12345678A12345678A912345678YesZthe letter should be Z
x2345678ZX2345678Z9X2345678No—is not 8 digits
87654321x87654321X987654321YesX(DNI correct)
45123456D 45123456D945123456YesD(DNI correct)

The letter is calculated with an index on a string, which is easiest to understand with a drawing:

How the DNI letter is checkedThe DNI 12345678Z is split into the eight digits, dni[:8], and the letter, dni[8]. 12345678 divided by 23 leaves a remainder of 14, and position 14 of the string LETTERS is Z: it matches the written letter.How the DNI letter is checked12345678Zdni[:8] → numberdni[8] → letter12345678 % 23 = 14(12345678 = 23 × 536768 + 14)LETTERS = "TRWAGMYFPDXBNJZSQVHLCKE"T0R1W2A3G4M5Y6F7P8D9X10B11N12J13Z14S15Q16V17H18L19C20K21E22it matches → correct letter
The remainder of dividing by 23 is always between 0 and 22: exactly the positions of the string LETTERS.
  • dni[:8] are the eight digits (as a string) and dni[8], the ninth character.
  • int(number) % 23 gives the remainder of dividing by 23. Let’s check it with the first one: 23 × 536768 = 12345664, and 12345678 − 12345664 = 14. Position 14 of LETTERS is Z: it matches.
  • The remainder of dividing by 23 is always between 0 and 22, and LETTERS has 23 letters. That’s why LETTERS[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, and LETTERS[10] is X). 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, parts would have a single piece and len(parts) != 3 would already be true. or doesn’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", month is 13 and the condition 1 <= month <= 12 is 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.
  • errors is built with a list comprehension that keeps only the non-empty messages: if e is 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_date so that it checks the days of each month (with a list [31, 28, 31, …]) and rejects 30 February.
  • Make validate_dni also accept the NIE of foreign residents, which starts with X, Y or Z: that letter is replaced by 0, 1 or 2 and the letter is calculated the same way. Try it with X1234567L.

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:

StepResult
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:

Each column of the record has a fixed widthOne row of the record drawn on a 75-character ruler: the number in 4 with zfill(2), the student left-aligned in 31, the three marks right-aligned in 6 with 2 decimals, the average in 7, two spaces and the final grade.Each column of the record has a fixed width01{n:<4}4García Pérez, Ana{student:<31}317.50{p1:6.2f}68.25{p2:6.2f}66.00{p3:6.2f}67.25{average:7.2f}72Good1304354147536275
Since every field always takes the same room, the columns line up even though the names have different lengths.
  • {str(n).zfill(2):<4}: the list number with two digits (01) in a field of 4 on the left. zfill is a string method, which is why n is first converted with str (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 way 10.00 (5 characters) and 7.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.
  • WIDTH is 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 and WIDTH.

Common mistakes in this practice

  • Calling a method without storing the result. line.strip() on its own doesn’t change line. If the method returns a string, write line = 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 an IndexError. First len, then indices.
  • **Using isdigit() for decimals.** "7,5".isdigit() and "7.5".isdigit() give False. To convert decimals, replace and float.
  • 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:

  1. After line 2 three new variables appear: surnames is " gil", comma is "," and name is " MARTA ". Notice the spaces: partition cuts, but doesn’t clean.
  2. On line 3, surnames changes its value to "Gil". The string hasn’t really changed: Python has created a new one and the variable now points to it.
  3. On line 4, name goes from " MARTA " to "Marta": split() swallows the spaces, join sticks the only word back and title() fixes the capitals.
  4. On line 5, username is built by adding three strings and lowercasing the result: "marta.gil".
  5. Go back to line 2 with the Prev button and notice that line never changes during the whole program: every transformation creates new strings.

Summary

  • Clean before using: strip, " ".join(text.split()) and title() turn text typed any old way into uniform data.
  • **partition versus split:** partition always gives three pieces, so it can be unpacked safely; split gives 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 join puts 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, and center/zfill for the details.

Checklist: have you practised it?

  • Have you run the three programs and checked the outputs?
  • Could you explain why pretty_name uses partition(",") and not split(", ")?
  • Can you work out a DNI letter by hand with % 23 and the LETTERS table?
  • Do you know why validate_dni checks 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 →

Sigue aprendiendo

One Comment

Leave a Reply

Your email address will not be published. Required fields are marked *