COMP 202 Foundations of Programming • McGill University, Montreal
Corrected exercises: files, exceptions and real data, COMP 202 at McGill
This is the corrected exercise set on files and exceptions for COMP 202, Foundations of Programming, the first Python course taken at McGill University. It is the chapter where the assignments stop being self contained: the data comes from a file, the file may be missing, the line may be malformed, and the user may type anything. Part A covers the mechanics: the modes of open, reading line by line, writing, the four clauses of try, and the exceptions themselves. Part B works at midterm level: a robust reading function, a full CSV pipeline, five statements to correct, a write and read round trip, and the question of which layer catches.
The thread running through the set: the world outside the program is text, and it is never clean. Everything read is a string carrying whatever the writer left in it, everything written must be turned into characters by hand, and every contact with the outside can fail. The two decisions that follow are the ones the whole set is built on, namely what to convert and when, and where to catch what.
The traps named explicitly in the solutions: mode 'w' truncating at the open rather than at the first write, an open placed inside the loop it should contain, a handle read twice giving nothing the second time, write refusing an int and adding no newline, a bare except swallowing the interrupt key, a try block holding more than the risky line, a test for the file's existence that the disk may invalidate a line later, an empty field converted to zero, and a separator that occurs inside the data.
10 corrected exercises • 100 points
• 150 minutes
Course recap
•open returns a file OBJECT, not the text, and the handle carries a position.
•Mode 'r' reads, 'w' EMPTIES the file at the moment it is opened, 'a' appends, 'x' refuses to overwrite.
•with open(name) as f: closes the file on the way out, exception included. The open itself can still raise.
•f.read() gives one string, f.readlines() gives a list, for line in f: gives them one at a time and is the form to prefer.
•A line read still carries its newline: strip it once, at the top of the loop.
•A file read to its end gives nothing on a second loop. Read into a list, or seek(0).
•f.write takes a str and adds NOTHING: write the newline yourself, or use print(x, file=f).
•try holds the risky line and nothing else; else holds what must run when nothing was raised; finally runs in every case.
•Never write a bare except: it catches KeyboardInterrupt and SystemExit, and hides the typos of the handler.
•Catch what the world does to you, let a bug of your own reach the top with its traceback.
•Raise rather than return a special value: raise ValueError with a message naming the rule and the offending value.
Part A: the mechanics (/50)
Exercise 1: Opening a file: the mode decides everything
open does not give back the text of a file: it gives back a file OBJECT, a handle positioned at one place in the file, through which characters can be read or written. The second argument, the mode, decides what that handle allows and, more importantly, what opening the file does to it before anything is read or written.
The line to read twice in the table is the second one. Opening a file for writing EMPTIES it immediately, before a single character is written, which is how an assignment destroys the data it was meant to process.
a) A program opens a file with mode 'w' to check something in it, then closes it without writing. What is left in the file?
b) Give the three ways of reading a whole file, and say what each one returns.
c) Write the with statement that reads a file, and say what it does that a plain open does not.
d) A file is opened with 'r' and does not exist. What is raised, and what should the program do about it?
e) Two parts of the same program open the same file for writing at the same time. Explain what happens to the data.
Show the solution
a) Nothing at all. Mode 'w' truncates the file to zero length at the moment it is opened, whether or not anything is ever written, so the check finds an empty file and the original content is gone. The mode to open a file for looking at it is 'r', and the mode to add without destroying is 'a'. This is the single most expensive typo of a first course, and it is worth the habit of never writing 'w' next to a filename that holds real data.
b) infile.read() returns the whole content as ONE string, newlines included. infile.readlines() returns a LIST of strings, one per line, each still carrying its newline. Iterating with for line in infile: hands the lines over one at a time, which is the form to prefer because it never holds more than one line in memory and reads naturally. read is right for a small file that has to be searched as a whole; readlines is rarely the best of the three.
c) with open('marks.txt', 'r') as infile: then the reading inside the block. It closes the file automatically at the end of the block, INCLUDING when an exception leaves the block early, which a plain open followed by close does not do unless a finally is written by hand. In COMP 202 a file left open costs style marks; in a long running program it costs file handles, and on the writing side it costs data, since what has not been flushed is lost.
d) FileNotFoundError, naming the path it tried. What to do depends on whose fault it is: if the name came from the user, catch it and ask again, since a typo is an expected event and not a bug; if the name is fixed in the program, let it propagate, because the message names the file and the line, which is exactly what is needed to fix the real problem. Catching an exception only to print a vaguer message than the one Python already gave is worse than not catching it.
e) Both truncate the file on opening and both write from position zero, so the result depends on the order in which the two handles happen to flush, and it is usually a mixture of the two outputs or the shorter one padded with the tail of the longer. Nothing raises. The rule is one writer at a time, and inside a single program that means one function owns the writing. It is also why a program that reads a file and rewrites it in place should write to a temporary file and rename it at the end.
Exercise 2: One line at a time, and what a line carries
A file on disk is one long run of characters. What a program calls a line is a piece of that run ending with a newline character, and that character is part of the string handed over. There is no structure in the file itself, only characters, which is why reading is always the same three steps: get the line, remove the newline, cut it into fields.
The figure shows a two line file exactly as it sits on disk.
a) A loop reads the file of the figure. Give the two strings it produces, written with their escapes.
b) The file is read once by a first loop, then a second loop is written over the same handle. How many lines does the second one see, and why?
c) Give the one line that reads a file into a list of clean lines, with no empty ones.
d) The last line of a file has no newline. Which of the two loops, over the handle or over readlines, notices, and does it matter?
e) A file written on Windows is read on another machine and every line ends with a stray character. Name it, and give the two ways of getting rid of it.
Show the solution
a) The strings 'Ana,8\n' and 'Ben,5\n', six characters each, the newline included and counted. That is what makes int(parts[1]) work and parts[1] == '8' fail, as the strings set showed: int tolerates surrounding whitespace, an equality test does not. Everything downstream is cleaner if the newline is removed once, at the top of the loop, with line = line.strip().
b) Zero. A file handle carries a POSITION, and reading to the end leaves it there; a second loop starts from where the first stopped, which is the end, and finds nothing. Nothing is raised, so the symptom is an empty result rather than an error. The two repairs: read the file into a list once, and walk that list as often as needed, which is the usual answer; or move the position back with infile.seek(0), which is rarely worth it.
c) lines = [line.strip() for line in infile if line.strip() != ''], inside the with block. It reads, cleans and filters in one pass, and it produces exactly what the rest of the program wants, a list of non empty strings. Note that the comprehension calls strip twice; a version that avoids it needs two lines, and at this size the readable one wins.
d) Neither notices, and it does not matter, because both hand over the last line as a string without a trailing newline and the strip that follows would have removed it anyway. It matters on the WRITING side: a program that appends to a file whose last line has no newline will glue its first line to that one. When appending, ending every line written with a newline is what keeps the file well formed.
e) The carriage return, written as backslash r, because Windows ends its lines with two characters, a carriage return and a newline. The two ways: strip, which removes every kind of whitespace at both ends, including that one, and is the reason strip is preferred to a hand written removal of the last character; or open the file in text mode with newline handling left to Python, which is the default and normally converts the pair for you. The symptom to recognise is a comparison that fails while the printed value looks right, since a carriage return does not show.
Exercise 3: Writing: what write does not do for you
Writing is the mirror of reading and it is stricter. The call takes a string and only a string, it adds nothing to it, and it does not decide when the characters actually reach the disk. Everything print does silently has to be done by hand, or print itself has to be used with its file argument.
The three lines below are the three ways of writing one line to an open file.
a) Two of the three lines produce the same file. Which two, and what does the third produce when it runs three times in a loop?
b) outfile.write(8) is called. Give the error, and the two correct versions.
c) A report is written with mode 'w' inside a loop over the students. What does the finished file contain, and where is the mistake?
d) Give the difference between mode 'w' and mode 'a' for a program run twice, and say which one a log file needs.
e) The program writes and then, without closing, opens the same file to read it back. What is likely to be missing, and what are the two fixes?
Show the solution
a) The second and the third produce the same file, since print adds the newline that write does not. The first, run three times in a loop, produces one single line reading Ana,8Ana,8Ana,8, because nothing separates the writes: write puts exactly the characters it is given and moves the position on. The habit that avoids it is to end every written line with a newline, or to use print with file, which also converts numbers on the way.
b) TypeError, write() argument must be str, not int. The two correct versions: outfile.write(str(8) + '\n'), which converts explicitly, or print(8, file=outfile), which converts on its own and adds the newline. This strictness is deliberate: a file is a stream of characters, and how a number should look in it is a decision the program must make, not the library.
c) It contains ONE line, the last student's, because opening with 'w' truncates and the open call is inside the loop, so every pass wipes what the previous one wrote. The mistake is the position of the open, not the mode: it belongs outside the loop, with open('report.txt', 'w') as outfile: and the loop inside the block. Mode 'a' inside the loop would also work and is the wrong fix, since it appends to whatever the previous RUN of the program left behind.
d) With 'w' the second run starts from an empty file, so the file always holds the results of the latest run only. With 'a' the second run adds to the first, so the file grows across runs. A log file needs 'a', which is exactly what a log is for; a report that describes the current state of the data needs 'w', so that it is never a mixture of two runs.
e) The last lines, or everything. Characters written are held in a buffer and reach the disk when the buffer fills, when flush is called, or when the file is closed; a reader opening the file before that sees only what has already gone through. The two fixes: close the file, which a with block does at the end of its block, or call outfile.flush() when the file must be readable while the program is still running. Writing and reading the same file at the same time is best avoided altogether.
Exercise 4: try, except, else, finally: the four clauses
An exception is not a crash: it is a value that travels up the chain of calls until something catches it, and it stops the program only when nothing does. Catching one is a decision about WHERE a problem can be dealt with, and the answer is rarely the line that raised it.
The figure gives the four clauses and when each one runs. Two of them are used constantly and two are worth knowing so that the code stays honest.
a) A conversion int(text) may fail. Write the try that catches only that, and say what must NOT be inside it.
b) except: on its own catches everything. Give two things it catches that no program should catch, and the correct form.
c) What is the else clause for, given that its lines could simply go at the end of the try?
d) A file is opened, read, and closed in a finally. Rewrite it with a with, and say what the with does not cover.
e) The except prints a message and continues. When is that right, and when is it the worst thing to do?
Show the solution
a) try: value = int(text), except ValueError: then handle it. What must not be inside the try is everything that follows and depends on value: putting the whole rest of the program inside means a ValueError raised much later, by a completely different line, is caught by this handler and reported as a bad conversion. A try block holds the risky lines and nothing else, which is the same discipline as a guard clause holding only its guard.
b) A bare except catches KeyboardInterrupt, so the user can no longer stop the program with the interrupt key, and SystemExit, so a program that asked to end does not end. It also catches the NameError caused by a typo in the handler itself, which is how a bare except hides the very bug it was meant to survive. The correct form names the exception, except ValueError:, or at worst except Exception:, which is the base of the ordinary errors and does not catch the two above.
c) The else runs only when the try completed with no exception, and its purpose is to keep the try SHORT. Lines that cannot fail, or that could raise the same exception for a different reason, go in the else so that the handler cannot catch them by accident. It is the answer to the trap of question a), written as a clause instead of as a discipline.
d) with open('marks.txt', 'r') as infile: then the reading inside. The close is guaranteed on the way out, exception or not, which is what the finally was doing. What the with does NOT cover is the opening itself: a FileNotFoundError is raised by the open, before the block is entered, so it still needs a try around the with when the name is not trusted.
e) It is right when the exception is an EXPECTED event of the outside world that the program knows how to continue past: a line of the file that does not parse, a value the user mistyped, a file that is not there yet. It is the worst thing to do when the exception means the program's own assumptions are broken, because the program then carries on with a state it does not understand and produces wrong output instead of stopping. The test to apply: if the message would be I could not do this, so I skipped it, catching is right; if it would be something impossible happened, let it propagate.
Exercise 5: Which exception, and whether to look before leaping
Reading the NAME of an exception is half the debugging. Each one says which contract was broken, and most of them point at a fix so standard that the message alone is enough once the table is known.
The other half of the chapter is a choice of style. Look before you leap tests first, if the key is there, if the list is long enough; it is easier to ask forgiveness tries and catches. Python leans to the second, and there is one case where the first is simply wrong.
a) Name the exception raised by each: float('n/a'), values[10] on a list of three, marks['Dan'] on a dictionary without Dan, open('nope.txt'), 'total: ' + 12.
b) A program tests if os.path.exists(name): then opens the file. Explain the case where that test is wrong, and give the version that is not.
c) Give the shape that catches a missing key with a default value, without any try.
d) A function is handed a mark of 250. Should it return an error value, or raise? Write the raise, with its message.
e) An except catches ValueError but the program still stops with a ValueError. Give two explanations.
Show the solution
a) float('n/a') raises ValueError, since the characters do not spell a number. values[10] raises IndexError, list index out of range. marks['Dan'] raises KeyError, with the key as the message. open('nope.txt') raises FileNotFoundError. And 'total: ' + 12 raises TypeError, can only concatenate str to str, which is the types chapter again. Naming the exception before writing the handler is what stops a handler from catching more than it understands.
b) It is wrong because the file can disappear, or its permissions change, between the test and the open: the two lines are not one operation, and something else may act in between. It is also two trips to the disk instead of one. The version that is not wrong asks forgiveness: try: with open(name) as f: ..., except FileNotFoundError: then handle it. The test has its uses for a message before starting a long job, but it is never a guarantee.
c) marks.get('Dan', 0), which returns the default when the key is absent and never raises. The same idea exists for a list only in the form of a length test, which is why the dictionary is the structure that makes look before you leap comfortable. Note that a default of 0 says the mark is zero, which may be false; a default of None says there is no mark, which is usually what is meant.
d) It should raise. Returning a special value, -1 or None, means every caller has to remember to test it, and the day one forgets, the wrong value travels on silently, which is exactly the failure of find returning -1 from the strings set. The raise is one line: raise ValueError('mark must be between 0 and 100, got ' + str(mark)). The message names the rule AND the offending value, which is what makes it useful in a traceback.
e) Either the raise happened outside the try, for instance in a line placed after the block or inside the except clause itself, in which case the handler cannot see it; or the exception is a ValueError raised again by the handler, since a handler that calls int on the same bad text raises the same error. A third and rarer one: the exception is a subclass caught elsewhere, or the try is inside a function whose caller re raises. In all three the fix starts by reading the traceback bottom up, since the LAST line names the place where it was raised.
Part B: problems and reasoning (/50)
Exercise 6: A reading function that survives its user
Asking the user for a number is three problems in one: the text may not be a number at all, the number may be outside the allowed range, and the user may keep getting it wrong. A function that solves all three is short, it is asked for in most assignments, and its shape is worth memorising.
The specification: read a mark between 0 and 100, keep asking until one arrives, and return it as an int.
a) Write the function. Give the loop, the try and the range test, in the right order.
b) Why must the range test be OUTSIDE the try, and what would go wrong if the whole body were inside it?
c) The function is written with except: instead of except ValueError:. Give the two consequences.
d) The user types 7.5 when an integer is expected. What happens, and is the message the function prints good enough?
e) The same function is now needed for a temperature, where any number is valid. Give the change, and say what should NOT be copied.
Show the solution
a) def read_mark(): then while True: text = input('Mark: '), then try: value = int(text), except ValueError: print('That is not a whole number.'), continue, then if 0 <= value <= 100: return value, then print('The mark must be between 0 and 100.'). The order is the one that matters: read, convert with the conversion alone in the try, then test the range on a value that is now certainly an int, then return. The while True with a return is the honest form, since the loop ends exactly when a good value arrives.
b) Because the try must hold the risky line and nothing else. With the whole body inside, a ValueError raised by anything else, including a later change to the function, would be reported as that is not a whole number, and the real cause would be hidden. There is also a subtler reason: the range test cannot raise ValueError at all, so putting it inside says something false about it to whoever reads the code.
c) First, the user can no longer interrupt the program, because the interrupt key raises KeyboardInterrupt and the bare except swallows it inside an infinite loop: the program has to be killed. Second, any typo inside the loop, a misspelled variable raising NameError for instance, is caught too and reported as an invalid number, so the function loops for ever telling the user their input is wrong when the bug is in the code.
d) int('7.5') raises ValueError, so the function prints that it is not a whole number and asks again. The message is accurate, and it is not good enough on its own if the specification meant to accept 7.5: then the conversion is float and the message must change with it. A good message names what is expected rather than what was refused, for example please type a whole number between 0 and 100.
e) Change int to float and drop the range test, or replace it with whatever range makes sense. What must NOT be copied is the whole function under a new name: the two versions differ by a conversion and a test, so the version to write takes them as parameters, def read_number(prompt, lo, hi, convert=float), and the two uses become two calls. Copying a function to change two lines is how a program acquires two behaviours where it meant to have one, as the functions set said of the average computed twice.
Exercise 7: From a CSV file to a report
The whole pipeline, from the file on disk to a printed table: open, skip the header, split, convert, build a structure, compute, format. The figure shows the file and the structure it must become.
The temperatures are the monthly averages in Montreal, in degrees Celsius. Nothing here is new; what is new is that every step of the previous exercises appears once, in order.
a) Write the function read_temps(filename) returning the list of records. Where exactly is the header skipped?
b) Compute the mean of the highs and the mean of the lows over the four months, and give both numbers.
c) Give the month with the largest gap between high and low, with the gaps you computed.
d) A fifth line reads May,,7 with an empty field. What is raised, where, and what are the two defensible answers?
e) Print the report as one aligned line per month with the gap. Give the f-string, and say why the file must be closed before the report is printed.
Show the solution
a) def read_temps(filename): records = [], then with open(filename, 'r') as infile: then a first call, infile.readline(), to consume the header, then for line in infile: with the usual strip, skip of empty lines, split on the comma and conversion, appending [name, int(high), int(low)] to records; then return records. The header is skipped by that single readline BEFORE the loop, which is the cleanest place: skipping it inside the loop needs a counter or a test on every line, for one line out of many.
b) The highs are -6, -4, 2 and 11, whose sum is 3, so the mean is 3/4=0,75 degree. The lows are -14, -13, -6 and 2, whose sum is -31, so the mean is −31/4=−7,75 degrees. Note that both are computed with a division that returns a float, and that printing them with two decimals is a formatting decision, not a rounding of the data.
c) The gaps are 8, 9, 8 and 9 degrees for January, February, March and April, so the largest gap is 9 and it is reached TWICE, in February and in April. The question says the month, which is exactly the trap of the strings set: a maximum found by a running best returns the first of the two, and the honest answer lists both, [r for r in records if r[1] - r[2] == best].
d) int('') raises ValueError, on the conversion line, while the split itself succeeds and produces three fields of which one is empty. The two defensible answers: skip the record, with a try around the conversion and a continue, which is right when a missing value means no measurement; or record it as missing, storing None instead of a number, which is right when the month must still appear in the report. What is not defensible is converting it to 0, since a missing temperature is not a temperature of zero and it would drag every average down.
e) print(f'{name:<10}{high:>5}{low:>6}{high - low:>6}') inside the loop over the records, which lines the four columns up whatever the length of the month. The file must be closed first because the report is built from the STRUCTURE, not from the file: read once, close, then compute and print. Keeping the file open while printing works but ties the length of the report to the lifetime of the handle, and it is the habit that leads to reading a file twice and finding it empty the second time.
Exercise 8: Five statements to correct
Each of the five statements below is plausible, has been written by a student in an examination, and is false. For each one, say what is wrong, give the counterexample that settles it, and write the correct version.
The marks go to the counterexample, which here means a file state or an exact exception.
a) 'Opening a file with mode w is safe as long as you do not write anything.'
b) 'A line read from a file is a list of its fields.'
c) 'except: on its own is the safest handler, since it catches everything.'
d) 'outfile.write(line) writes the line, so the file will have one line per call.'
e) 'Once a file has been read with a for loop, a second loop reads it again from the start.'
Show the solution
a) False. The truncation happens at the OPEN, not at the first write, so a file opened with 'w' is empty from that instant whatever follows. The counterexample: a file of a thousand lines, opened with 'w' and closed immediately, has zero lines. Correct version: use 'r' to look, 'a' to add, and reserve 'w' for a file whose previous content is genuinely to be discarded.
b) False. It is a STRING, ending with a newline, and it becomes a list only when something splits it. The counterexample: for the line Ana,8, line[0] is 'A', the first character, and not 'Ana', which is what a program written on this belief silently uses as a name. Correct version: line.strip().split(',') produces the list of fields, all of them strings.
c) False, it is the least safe. It catches KeyboardInterrupt, so the program cannot be stopped, and SystemExit, so it cannot end; and it catches the programming errors of the handler itself. The counterexample: a loop asking for input, with a bare except inside, cannot be interrupted and has to be killed. Correct version: name the exception expected, except ValueError:, and let everything else through.
d) False. write adds nothing at all, so three calls with no newline produce ONE line. The counterexample: three calls with 'Ana,8' give the file Ana,8Ana,8Ana,8. Correct version: write the newline yourself, outfile.write(line + '\n'), or use print(line, file=outfile), which adds it and converts non strings on the way.
e) False. The handle keeps its position and the first loop left it at the end, so the second loop sees nothing and, worse, raises nothing. The counterexample: two identical loops counting lines print 3 then 0. Correct version: read into a list once and walk that list as many times as needed, or move the position back with infile.seek(0) when re reading is really wanted.
Exercise 9: Writing a file and reading it back
A program that writes a file should be able to read its own output. That round trip is the cheapest test there is, and it catches the two mistakes of the writing side at once: the missing newline and the number that was never converted.
The task: write one line per student, name and average separated by a semicolon, then read the file back and compute the class average from it.
a) Write the loop that produces the file from a list of records. Give the exact string written for Ana with an average of 7.5.
b) Write the reading loop that rebuilds the records and computes the class average, and give that average for Ana at 7.5, Ben at 6.0 and Chen at 8.0.
c) The average is written with an f-string rounded to two decimals. What is then lost, and when does it matter?
d) The program is run twice with mode 'a'. What does the file hold, and what does the reading loop compute?
e) A name contains a semicolon. Describe the failure on the way back, and give two ways of preventing it.
Show the solution
a) with open('report.txt', 'w') as outfile: then for name, avg in records: outfile.write(name + ';' + str(avg) + '\n'). For Ana the string written is 'Ana;7.5\n', seven visible characters and the newline. Two conversions happen on that line and both are compulsory: str around the number, since write refuses anything else, and the newline, since write adds nothing.
b) with open('report.txt', 'r') as infile: then total = 0 and count = 0, then for line in infile: line = line.strip(), if line == '': continue, then name, text = line.split(';'), then total = total + float(text), then count = count + 1; the average is total / count. With 7.5, 6.0 and 8.0 the sum is 21.5 and the class average is 21,5/3≈7,167. The unpacking of the split into two names is the compact form, and it raises ValueError if a line has the wrong number of fields, which is a fair way to notice a corrupt file.
c) The digits beyond the second are lost, permanently, because the file becomes the only remaining copy: 7.166666 is written as 7.17 and comes back as 7.17. It matters as soon as the values are used for further arithmetic, since the rounding of each value accumulates in the total, exactly as in the floats exercise of the first set. The rule: round for the reader, keep full precision for the program, and if the file is data rather than a report, write the unrounded value.
d) It holds every student twice, once per run, and the reading loop computes the average of six lines, which happens to equal the average of three when the same data is written twice, and does not when the data has changed between the runs. The deeper problem is that nothing in the file says which run a line came from. A report file that describes the current state is opened with 'w'; if history is wanted, each line must carry a date.
e) The line splits into three fields instead of two, so the unpacking raises ValueError, not enough values to unpack, or a plain split gives a wrong name and a text that float refuses. Two preventions: choose a separator that cannot appear in the data, a tab or a character the names cannot contain, and validate it when writing; or use the csv module, which quotes any field containing the separator and unquotes it on the way back. The general rule of the strings set applies unchanged: a separator must be impossible in the data, or the data must be escaped.
Exercise 10: Where to catch: a program that must not crash
A marking program reads a directory of files, parses each one, computes the averages and writes a report. Any of those steps can fail, and the interesting design question is not how to catch an exception but WHERE, that is in which function.
The rule that answers it: catch where you can still do something sensible. Everywhere else, let it travel, because an exception that reaches the top with its traceback is far more useful than one swallowed at the bottom.
a) parse_line raises ValueError on a malformed line. Should it catch it itself? Justify.
b) read_file loops over the lines and calls parse_line. Give the handler it should have, and what it should do with a bad line.
c) main loops over the files. One file is missing. Where is that caught, and what does the program do next?
d) The program must end with a summary of what it could not read. Give the structure that collects it, and the place it is filled in.
e) A bug in the averaging code raises ZeroDivisionError. Explain why no handler in the program should catch it.
Show the solution
a) No. parse_line knows that the line is malformed and knows nothing else: it cannot ask for a better line, cannot skip anything, and cannot decide whether one bad line among a thousand is acceptable. Its job is to state the problem as precisely as possible, which is what raising does, ideally with a message naming the offending text. A function that catches an error it cannot resolve turns information into silence.
b) try: record = parse_line(line), except ValueError as err: then count the bad line, record it with its number, and continue with the next one. read_file is the first caller that has a sensible answer, namely one line out of many is unusable and the rest of the file is still worth reading. Catching there and continuing is exactly the catch where you can do something sensible rule, and keeping the line NUMBER is what makes the summary usable.
c) In main, around the call to read_file, with except FileNotFoundError: then a message naming the file and a continue to the next one. main is the level that knows there are other files, so it is the level that can decide to carry on without this one. Note that the with statement inside read_file does not help here: the open is what raises, before any block is entered.
d) A list, or a dictionary keyed by filename whose values are the lists of problems: problems = {} in main, filled in by the two handlers of b) and c), and printed at the end. It is filled in exactly where the exceptions are caught, which is the argument for catching at a level that has somewhere to put the information. A summary that says 3 files unreadable, 12 lines skipped, with the names, is what turns a program that must not crash into a program that can be trusted.
e) Because it is a BUG, not an event of the outside world. Catching it would hide a division whose denominator is a count that should never have been zero, and the program would carry on producing averages computed from nothing. The correct response is to let it reach the top, read the traceback, find the empty list and fix the code that produced it. The line to hold on to: catch what the world does to you, never what you do to yourself.