Solved Python exercise: read a grade log from STDIN, validate each line with regular expressions and checksum logic, and summarise the valid records.
Updated on
Exam Code Python Programming fundamentals Exam level Tested code

A university registrar exports a text file every night with the marks of one exam session. Each line should have the form ID;Surname, Name;COURSE;mark, but the file is edited by hand and contains mistakes. Write a program that reads all lines from standard input, validates each one and prints a summary.
A line is valid if it satisfies everything below. (1) The ID is 8 digits followed by an uppercase letter, and the letter is the correct one: it is the character at position number % 23 in the string TRWAGMYFPDXBNJZSQVHLCKE. (2) The name has the form Surname, Name, a comma followed by exactly one space, and contains only letters (accents and ñ included), spaces and hyphens, each part starting with a letter. (3) The course is a code made of three uppercase letters, a hyphen and three digits (for example PRG-101). (4) The mark is a number between 0 and 10 with at most two decimals, using a dot as decimal separator.
Leading and trailing whitespace on each line is ignored, and blank lines count neither as valid nor as rejected. Rejected lines are numbered starting at 1 over all the lines of the input (blank lines also occupy a number).
Output, in exactly this order: the line VALID: n; the line REJECTED: m; and, if there are rejected lines, one line REJECTED LINE k for each, with its line number in increasing order. Then, for every course with at least one valid line, sorted alphabetically, a line COURSE: students=a mean=x.xx with the arithmetic mean of its valid marks to two decimals. Finally the line BEST: ID First Last mark with the student with the highest mark among the valid lines (on a tie, the first one in the input), where the name is written in reverse order with respect to the file (given name first, surname second) and the mark has two decimals. If there is no valid line, neither the course section nor the BEST line is printed.
Sample input
12345678Z;García López, María;PRG-101;8.5
87654321X;Pérez Ruiz, Juan;PRG-101;6.25
12345678A;Gómez Díaz, Luis;MAT-200;7
00000000T;Martín Vega, Elena;MAT-200;10
11111111H;Ruiz,Ana;PRG-101;5
23456789D;Soler Mas, Pau;PRG-101;11Expected output
VALID: 3
REJECTED: 3
REJECTED LINE 4
REJECTED LINE 6
REJECTED LINE 7
MAT-200: students=1 mean=10.00
PRG-101: students=2 mean=7.38
BEST: 00000000T Elena Martín Vega 10.00- Correct regular expression for the line format (named groups, full match, ranges)
- 1.0
- Validation of the rules a regex cannot express (ID check letter, grade range)
- 0.75
- Reading STDIN line by line, handling blank lines and stray whitespace
- 0.5
- Aggregation and exact output format (ordering, rounding, list of rejected lines)
- 0.75
Hints
Hint 1 · Which part of the problem belongs to the regex and which does not?
A regular expression describes shapes, not calculations. Compile a single regex with re.compile and named groups ((?P<num>...)) for the structure of the line, and check separately, in ordinary code, that the ID letter matches int(number) % 23.
Hint 2 · How do you anchor the pattern and handle accented characters?
Use re.fullmatch (or the anchors ^ and $) so a partial match is not enough. For accented letters, the class [A-Za-zÁÉÍÓÚÜÑáéíóúüñ] is explicit and predictable. For the mark, think in alternatives: 10 with optional zero decimals, or one digit from 0 to 9 with optional decimals.
Hint 3 · Which data structures do you need for the summary?
A dictionary from course to list of marks gives you the count and the mean. For BEST, keep a tuple with the best mark seen so far and compare with a strict >, so a tie never replaces the first student. Remember that the line number is counted with enumerate(..., 1) before blank lines are discarded.
Solution
Explained solution
The solution separates two responsibilities. The function parse_line receives an already stripped line and returns a tuple with the fields if it is valid, or None if it is not. The main program walks through the input, accumulates results and formats the output.
The regex captures named groups and is compiled once at module level. The check letter is verified outside the regex, because it depends on an arithmetic computation on the number.
import re
import sys
LETTERS = "TRWAGMYFPDXBNJZSQVHLCKE"
NAME_CHARS = "A-Za-zÁÉÍÓÚÜÑáéíóúüñ"
PATTERN = re.compile(
r"(?P<num>\d{8})(?P<letter>[A-Z]);"
r"(?P<surname>[" + NAME_CHARS + r"][" + NAME_CHARS + r" -]*), "
r"(?P<name>[" + NAME_CHARS + r"][" + NAME_CHARS + r" -]*);"
r"(?P<course>[A-Z]{3}-\d{3});"
r"(?P<mark>10(?:\.0{1,2})?|\d(?:\.\d{1,2})?)"
)
def parse_line(line):
m = PATTERN.fullmatch(line)
if m is None:
return None
if LETTERS[int(m.group("num")) % 23] != m.group("letter"):
return None
student_id = m.group("num") + m.group("letter")
return student_id, m.group("surname"), m.group("name"), m.group("course"), float(m.group("mark"))
def main():
marks = {}
rejected = []
valid = 0
best = None
for number, raw in enumerate(sys.stdin.read().splitlines(), 1):
line = raw.strip()
if not line:
continue
data = parse_line(line)
if data is None:
rejected.append(number)
continue
valid += 1
student_id, surname, name, course, mark = data
marks.setdefault(course, []).append(mark)
if best is None or mark > best[3]:
best = (student_id, name, surname, mark)
print(f"VALID: {valid}")
print(f"REJECTED: {len(rejected)}")
for k in rejected:
print(f"REJECTED LINE {k}")
for course in sorted(marks):
values = marks[course]
mean = sum(values) / len(values)
print(f"{course}: students={len(values)} mean={mean:.2f}")
if best is not None:
student_id, name, surname, mark = best
print(f"BEST: {student_id} {name} {surname} {mark:.2f}")
main()The constant PATTERN is built by concatenating fragments so that it stays readable. The groups num and letter separate the eight digits from the letter, so that parse_line can compute int(m.group("num")) % 23 and compare it with LETTERS. The groups surname and name must start with a letter and allow spaces and hyphens; the comma and the space between them are literal parts of the pattern, which is why Ruiz,Ana is rejected.
The group mark uses two alternatives: 10(?:\.0{1,2})? for ten (with optional zero decimals) and \d(?:\.\d{1,2})? for a digit from 0 to 9 with up to two decimals. That rules out 10.5, 7.123 and 07. Because we use fullmatch, no anchors are needed and the classic error of accepting a partial match disappears.
In main, enumerate(..., 1) numbers every line before the blank ones are filtered, as the statement requires. Valid lines feed the dictionary marks (course to list of marks) and the variable best, which is only updated with mark > best[3]: the strict comparison guarantees that on a tie the first student in the input is kept. The output walks sorted(marks) for alphabetical order and formats the mean with :.2f.
In the statement's example test there are six useful lines: three valid and three rejected (wrong ID letter, mark 11, and a missing space after the comma); line 3 is blank and just takes up a number. In the test with only blank lines the program prints just the two counters at zero, and in the tie test it checks that BEST keeps the first student.
Test cases
| Case | Input | Expected output | Actual output | Result |
|---|---|---|---|---|
| Statement example | 12345678Z;García López, María;PRG-101;8.5
87654321X;Pérez Ruiz, Juan;PRG-101;6.25
12345678A;Gómez Díaz, Luis;MAT-200;7
00000000T;Martín Vega, Elena;MAT-200;10
11111111H;Ruiz,Ana;PRG-101;5
23456789D;Soler Mas, Pau;PRG-101;11 | VALID: 3
REJECTED: 3
REJECTED LINE 4
REJECTED LINE 6
REJECTED LINE 7
MAT-200: students=1 mean=10.00
PRG-101: students=2 mean=7.38
BEST: 00000000T Elena Martín Vega 10.00 | VALID: 3
REJECTED: 3
REJECTED LINE 4
REJECTED LINE 6
REJECTED LINE 7
MAT-200: students=1 mean=10.00
PRG-101: students=2 mean=7.38
BEST: 00000000T Elena Martín Vega 10.00 | OK |
| Empty input | (empty) | VALID: 0
REJECTED: 0 | VALID: 0
REJECTED: 0 | OK |
| Only blank lines |
| VALID: 0
REJECTED: 0 | VALID: 0
REJECTED: 0 | OK |
| All rejected | text without format
12345678;No Letter, Ana;PRG-101;5 | VALID: 0
REJECTED: 2
REJECTED LINE 1
REJECTED LINE 2 | VALID: 0
REJECTED: 2
REJECTED LINE 1
REJECTED LINE 2 | OK |
| Tie for best mark and limits | 12345678Z;Núñez Gil, Íñigo;BIO-300;9.99
87654321X;Vidal Roca, Marta-Luz;BIO-300;9.99
00000000T;Ortega Sanz, Lola;BIO-300;0 | VALID: 3
REJECTED: 0
BIO-300: students=3 mean=6.66
BEST: 12345678Z Íñigo Núñez Gil 9.99 | VALID: 3
REJECTED: 0
BIO-300: students=3 mean=6.66
BEST: 12345678Z Íñigo Núñez Gil 9.99 | OK |
Actual outputs: code compiled with Python 3.13.16 and run in an isolated container on 3 November 2026.
Complexity
Time is linear in the total size of the input: each line is processed once, and the regex, having no nested quantifiers or ambiguous alternatives, is evaluated in linear time over the length of the line. The final sort of courses costs O(k log k) for k distinct courses, negligible compared with reading.
Memory is O(n) in the worst case, because all lines are read with sys.stdin.read() and the valid marks are stored per course. If the file were huge, it would be enough to iterate over sys.stdin and keep only the sum and count per course, giving O(k) memory.
Common mistakes
- Using
re.matchorre.searchwithout anchors: a line with trailing garbage (for example...;7.5;xx) is accepted because the partial match succeeds. Withfullmatchthis cannot happen. - Trying to validate the ID letter inside the regex, or not validating it at all: the regex only checks that there are 8 digits and an uppercase letter, not that the letter is the right one.
- Writing the letter class as
\wor[A-z]:\wadmits digits and underscore, and[A-z]includes symbols such as brackets and the caret between Z and a. - Numbering the rejected lines after discarding the blank ones, so the numbers no longer match the real position in the file.
- Updating BEST with
>=, which makes a tie replace the first student instead of keeping them. - Forgetting to escape the decimal point (an unescaped
.matches any character) or not limiting the decimals, letting7.123or10.5through.
Variants
Accept a comma or a dot as decimal separator
Change the decimal part of the group mark to [.,] and, before converting, replace the comma with a dot using .replace(',', '.'). You must decide whether the file may mix both formats; the regex does not prevent it, so the statement has to say so.
Read from a file given as an argument instead of STDIN
Replace sys.stdin.read() with open(sys.argv[1], encoding='utf-8') inside a with block and walk the file with enumerate(f, 1). Iterating the file object reads one line at a time without loading it all into memory, and with guarantees the file is closed even if an exception occurs.
Also reject duplicate IDs
Keep a set seen with the IDs already accepted. After validating a line, if its ID is in seen treat it as rejected; otherwise add it. Ask yourself whether a duplicate should be detected by ID alone or by the pair ID and course, which are different rules.
On the syllabus
Reading and processing files is part of the syllabus of several introductory programming courses. The Fundamentos de Programación course at the Instituto Politécnico Nacional (Mexico) lists control statements, functions, pointers and files among its contents: course programme.
At the Universidad de Chile, the course CC3301 Programación de Software de Sistemas covers the Linux API for handling files and directories: course programme. This exercise works on the same idea, reading and processing text files, from Python, with regular expressions as the validation tool.
Article generated with AI.larebelion


