Text Files and User Input
Data structures (list, set, and dict)
We will have lectures dedicated to lists and sets later on, but here is the basic syntax to create them:
nums: list[int] = [1, 2, 3]
words: set[str] = {'hi', 'hello', 'howdy'}
We're assuming you have used lists before. Sets may be new to some.
A set is very similar to a list: it is a collection of items.
Differences between a set and a list:
- A set is unordered
- A set can only hold each item (at most) once -- no duplicates
None
None works like a value that represents the absence of a value.
bodyguard_name: str = None # doesn't have a value -- I don't have a bodyguard
It's different from "" or 0 (see Null Island)
Can store None in a list
grades: list[int] = [5, None, 0]
Cannot add None to a number or string
None + "hi"does not worklen(None)does not work
Optional
To specify that a type might be None, we use Optional. For example:
from typing import Optional
def get_number_or_None(hopefully_a_number: str) -> Optional[int]:
try:
return int(hopefully_a_number)
except ValueError:
return None
Reading and writing text files
Just as we can read user input using input(prompt), we can also read input from a file:
with open('story.txt', 'r', encoding="utf-8") as file:
for line in file.readlines():
print(line)
The readlines() function returns a list of strings: each line in the file is a string in the list.
If, instead of reading from the file, we want to write to the file, then we must use a different option than 'r'.
open('story.txt', 'r'): read the fileopen('story.txt', 'w'): write the file (overwrite it if it already exists)open('story.txt', 'a'): append to the end of the file (and create the file if it doesn't exist)
We can then write to the file using file.write("Line to write to file").
Testing functions that print or take user input
We've been using the input('prompt') function which returns the user's response to the provided 'prompt'. We've also been print()ing things.
To make testing practical, we can't rely on the user to type in input(), and we don't want to rely on them to check the printed output to verify things that were print()ed.
So, instead, we "mock" the user. The unittest module is great for this -- it can imitate a user typing things, and it can read the output that would have been printed to the console.
Source: https://en.meming.world/wiki/Mocking_SpongeBob
Tests that mock user input
To make a test function "mock" a user typing in inputs, we use @patch('builtins.input', side_effect=user_inputs), replacing user_inputs with an array of things that the mock user should type.
Here is an example testing a function that takes three inputs from the user and returns them, concatenated with commas:
import pytest
from unittest.mock import patch, Mock
def concat_three_inputs() -> str:
"""Reads three inputs from the user and concatenates them into a single string separated by spaces."""
inputs = []
for _ in range(3):
user_input = input("Enter something: ")
inputs.append(user_input)
return ', '.join(inputs)
@patch('builtins.input', side_effect=['first thing typed by user', 'second thing', 'third thing'])
def test_concat_three_inputs(_: Mock) -> None:
"""Test that concat_three_inputs correctly concatenates three user inputs."""
result = concat_three_inputs()
assert result == 'first thing typed by user, second thing, third thing'
Notice the _: Mock argument to the test function.
This example replaces the built-in input() function with a different input() function that returns the three strings listed as "side effects" instead of real user input.
Note: If there are not enough inputs specified in the side_effect array, the call to input() will wait forever (until it times out).
Tests that mock console output
We use @patch('builtins.print') to mock things being printed to the console, and then we make assertions on that printed output inside the test function.
import pytest
from unittest.mock import patch, Mock
def repeat_three_inputs() -> str:
"""Reads three inputs from the user and prints them."""
for _ in range(3):
user_input = input("Enter something: ")
print(user_input)
@patch('builtins.input', side_effect=['first thing typed by user', 'second thing', 'third thing'])
@patch('builtins.print')
def test_repeat_three_inputs(mock_print: Mock, _: Mock) -> None:
"""Test that repeat_three_inputs correctly reads and prints three inputs."""
repeat_three_inputs()
expected_calls = [
unittest.mock.call("first thing typed by user"),
unittest.mock.call("second thing"),
unittest.mock.call("third thing"),
]
mock_print.assert_has_calls(expected_calls)
Notice the order of the two Mock arguments to the test function: @patch decorators "stack" such that the first decorator is the last argument, and vice versa.
Since we don't use the mock input's argument inside the test function, we name it using _.
Error handing
There is a control structure that we have not introduced until now: try / except
a: int = 4
b: int = 0
try:
result = a / b
print(result)
except ZeroDivisionError:
print("Cannot divide by zero")
It allows us to try to run risky code, and if an error is raised during that risky code, then it jumps immediately to the corresponding except block.
It is acceptable to use try / except blocks while testing whether a function raises an error: it is an alternative to using pytest.raises().
Otherwise, we try to minimize the use of try / except, and only use it when absolutely necessary. We don't want to simply avoid fixing legitimate bugs by wrapping our code in a try / except.
Places where try / except is commonly used:
- Converting values
def get_user_age() -> int:
"""Get a numerical age from the user"""
user_input: str = input("Enter your age: ")
try:
age: int = int(user_input)
return age
except ValueError:
print("Please enter a valid number")
return -1
- Operations that rely on external things like network requests or database operations
- Reading from files (though using a
withblock, as we have been doing, is recommended instead)
Keywords in a try / except block:
- Each error that can be raised should get its own
exceptblock. It is okay to have multipleexceptblocks for the sametryblock. - One
exceptblock can handle multiple errors, if they require the same process:except (ValueError, TypeError) as e: - Inside an
exceptblock, we may choose toraisea different error. - If there is a
finallyblock at the end of a try / except block, then it is run in all cases (whether thetrywas fully executed, or it jumped to theexcept. - If there is an
elseblock at the end of a try / except block, then it is run only if thetrywas fully executed (and it never jumped to anexceptblock
Best practices:
- Only use try / except for the few legitimate reasons, not for control flow of the program
- Make the errors handled in
exceptblocks as specific as possible. It is okay to list multiple specific errors in the sameexceptblock.
Poll: What is output?
def noodle(hopefully_a_number: str) -> None:
try:
num: int = int(hopefully_a_number)
print('Cats rule')
except AssertionError as e:
print(f'{hopefully_a_number} is not a number')
noodle('hello')
- Cats rule
- hello is not a number
- Cats rule hello is not a number
- No output - it raises the error
Poll: What is output?
def noodle(hopefully_a_number: str) -> None:
try:
num: int = int(hopefully_a_number)
print('Cats rule')
except ValueError as e:
print(f'{hopefully_a_number} is not a number')
noodle('hello')
- Cats rule
- hello is not a number
- Cats rule hello is not a number
- No output - it raises the error