Seminar 19 Context, settings, and exceptions
Today, we will reuse our lovely Guitar Hero game and see how we simplify our life via having settings in a separate file. Plus we will be using context to isolate repetitive initialization / clean-up code and exceptions to control the flow. Thus, create a new folder with all the classes and the final exercise from Guitar Hero, which you will serve as a foundation for exercise 1.
19.1 Context manager
On the one hand, context management is a frequently used feature in Python, particularly for file operations. On the other hand, its full power is rarely is less known even though it can be very useful whenever you the context of your program is the same or very similar, as in case of the PsychoPy games that we programmed and typical PsychoPy experiments. In both cases, there is a fairly fixed structure of the program:
- Initialization
- define experimental settings by either reading them from an external file, as you will learn below, or creating constants (less flexible but what we have been doing so far)
- create PsychoPy window, logger for experimental results, mouse (if required), initialize special devices such as response box, eye tracker, etc.
- Actual experiment
- Saving and cleaning up
- save data logs
- if required, close connection to special devices such as response boxes, eye tracker, etc.
- close PsychoPy window
If you look at your code, you will realized that steps 1 and 3 remain fairly the same throughout all the games that we programmed. Thus, we will create a context manager class, which you can always reuse, that will hide away the boilerplate code.
Here is an example of how use of a context manager helps when working with files (something, we will need to use later for our settings files). First, how it works without a context manager: you open a file and assign the object to variable, you work with it, you close it. The latter is important to ensure that information was fully written into it and that you do not lock for file.
file = open("somefile.txt", "r")
# ... do something with the file, such as reading the entire file into a single variable
= file.read()
data file) close(
A better way is to use a context manager that file class implements via with ... as ...
statement.
with open("somefile.txt", "r") as file:
file.read()
Note that now the file.read()
is inside of the with
block and there is no file.close()
call. The latter is evoked automatically, once you run all the code inside the with
block and exit it. Although for this example the difference is minimal — a different way to assign a value to a variable and explicit vs. implicit file closing — the second variant takes care of cleaning up, ensures that you do not forget about it, and allows you to concentrate on the important bits.
Here is how it works behind the scenes. A context manager is a class that implements two special methods __enter__
and __exit__
(so, this is a classic Python duck typing). The former creates and returns a context, which is whatever attribute or value you require, wheres the former performs cleaning up that is necessary before exiting the context. Here is how we would implement a limited file context manager by ourselves:
class FileManager():
def __init__(self, filename, mode):
"""
Stores the settings for use in __enter__
Parameters
----------
filename : str
mode : str
"""
self.file = None
self.filename = filename
self.mode = mode
def __enter__(self):
"""
What we need to do to create context:
* Open the file and returns the object.
Returns
----------
File object
"""
self.file = open(self.filename, self.mode)
return self.file
def __exit__(self, exc_type, exc_value, traceback):
"""
What we need to do before destroying the context:
* Close the file before we exit the context.
"""
self.file)
close(
# and now we use it!
with FileManager("somefile.txt", "r") as file:
file.read()
Note that __exit__
method has extra parameters exc_type
, exc_value
, and traceback
. They will be relevant for exception handling later on but for now, you can ignore them.
Now is your turn! Create a WindowContext
class (in a separate file, of course) that will create a PsychoPy Window object of a given size, which is passed to the constructor, and closes when the code exists the context. For now, you will need one attribute to store information about the requested size and one attribute for PsychoPy Window itself (hint, use win
as an attribute name). There will be a small but important difference relative to FileManager
class in the example: There will be other objects that are context-relevant (settings, logging class) that we will add later on, so instead of returning just the window object, the enter it should return the reference to the object itself (reminder, reference to the current object is always in the self
parameter of a method). Then, you can use it to create a 640-by-480 window (use whatever window size you need of course).
with WindowContext((640, 480)) as ctx:
# your usual code inside but
# PsychoPy window is ctx.win
ctx.win.flip()
Create WindowContext
class and use it in exercise01.py.
19.2 Settings file formats
So far, we either hard-coded specific values or defined them as constants (a better of these two approaches). However, this means that if you want to run your game with different settings, you need to modify the program itself. And if you want to have two versions of the game (two experimental conditions), you would need to have two programs with all the problems of maintaining virtually identical code in several places at once.
A better approach is to have separate files with settings, so you can keep the code constant and alter specific parameters by specifying which settings file the program should use. This is helpful even you plan to have a single set of setting as it separates code from constants, puts the latter all in one place and makes easier to edit and check them. There are multiple formats for settings files: XML, INI, JSON, YALM, etc.
19.2.1 XML
XML — an Extensible Markup Language — looks similar to HTML (HyperText Markup Language). Experiments designed using PsychoPy Builder interface are stored using XML files but with .psyexp extension. A settings file for Guitar Hero in XML could look like this
<Target>
<width>0.4</width>
<height>0.1</height>
<locations>
<location>-0.5</location>
<location>0</location>
<location>0.5</location>
</locations>
</Target>
The advantage of XML is that it is very flexible yet structured and you can use native Python interface to work with them. However, XML is not easy for humans to read, it is overpowered for our purposes of having a simple set of unique constants and its power means that using it is fairly cumbersome.
from xml.dom import minidom
= minidom.parse('test.xml')
settings "target")[0].getElementsByTagName("width")[0].firstChild.data # this will give you string "0.4" settings.getElementsByTagName(
19.2.2 INI
This is a format with a structure similar to that found in MS Windows INI files.
[Target]
width = 0.4
height = 0.1
locations = -0.5, 0, 0.5
As you can see it is easier to read and Python has a special configparser library to work with them. The object you get is, effectively, a dictionary with additional methods and attributes. Note, however, that ConfigParser
does not try to guess the type of data, so all values are stored as strings and it is your job to convert them to whatever type you need, e.g. integer, list, etc.
import configparser
= configparser.ConfigParser()
settings 'settings.ini')
settings.read("Target"]["width"] # this will give you a string '0.4' settings[
19.2.3 JSON
JSON (JavaScript Object Notation) is a popular format for web applications that use it to exchange data between server and client.
{
"Target": {
"width": 0.4,
"height": 0.1,
"locations": [-0.5, 0, 0.5]
}
}
You can parse any string with JSON format into a dictionary in Python using json module. Its advantage over INI files is that JSON explicitly specifies data type, so it converts it automatically. Note that unlike configparse, json module does not work with files direction, so you need to open it manually.
import json
with open('settings.json') as json_file:
= json.load(json_file)
settings
"Target"]["width"] # this will give a float number 0.4 settings[
19.2.4 YAML
YAML (YAML Ain’t Markup Language, rhymes with camel) is very similar to JSON but its config files are more human-readable. It has fewer special symbols and brackets but, as in Python, you must watch the indentations as they determine the hierarchy.
Target:
width : 0.4
height : 0.1
locations : [-0.5, 0, 0.5]
You will need to install a third-party library pyyaml to work with YAML files. Otherwise, you get the same dictionary as for the JSON
import yaml
with open("settings.yaml") as yaml_stream:
= yaml.load(yaml_stream)
settings
"Target"]["width"] # this will give a float number 0.4 settings[
19.3 Settings file for Guitar Hero
My personal preference is for yaml. However, it requires a separate library, so you could use json file instead. Create a settings file and call it settings.json (or use your imagination and come up with a better name). You will add more settings later on but for now just define a window size as
{
"window": {
"size" : [640, 480]
}
}
In exercise02.py simply read the settings into a dictionary and print it out the window size. Remember that you will get a dictionary of dictionaries and think about keys that you need to access that size entry. Check that there are no errors and that dictionary has the correct structure. There should be no further code in exercise02.py.
Implement reading and printing settings in exercise02.py.
19.4 Context based on settings
Now, we will settings when we create the context. Create a new context manager class SettingsContext
that will take a string with settings file name as the only input. In the __enter__
, you should read settings into settings
attribute (reuse your code from exercise 2) and then use window size from that settings to create PsychoPy window and store it in a win
attribute. Again, you return the reference to an object itself. For now, the only thing you need to do in the __exit__
is to close the window. Use it the same way as before but for the file name instead of the size.
with SettingsContext('settings.json') as ctx:
# your usual code inside but
# PsychoPy window is ctx.win
ctx.win.flip()
Create SettingsContext
class and use it in exercise03.py (based on exercise01.py, what do you need to change?).
19.5 Using settings
The next exercise is simple: Look through your code, determine which values, either explicit constants or hard-coded ones, are effectively settings, define them in the settings.json file (create a nice hierarchical structure for them), and use them in the code. Hint, if you can get all settings for a window via ctx.settings["Window"]
. Think how you can use this to simplify passing multiple settings to Target
and TimedResponseTask
.
Update Target
and TimedResponseTask
classes, put updated main script into exercise04.py.
19.6 Logging data
In the real experiment, we would want to log the responses, e.g., when participants pressed a button and what was the score. PsychoPy provides a convenience class ExperimentHandler for such purposes that we already met when programming the Memory Game.
Reuse the code of SettingsContext
class for a GameContext
class. In addition to the original functionality, it should create an ExperimentHandler
object as a data
attribute, so you could use it as ctx.data
in the main script. For the context __exit__
, you should save the results as wide text. I suggest that the log file name should be one of the settings. In the main code, log the pressed key, time of press (relative to the game start), and the score of that press.
Create GameContext
class, use in an updated main script in exercise05.py.
19.7 Exceptions
When you are running an actual experiment, one of the worries that you have is “what happens to the data I have already logged, program crashes with an error”? Not collecting a full measurement is bad but not keeping at least partial log is even worse, as you can still use it for analysis or as a guidance for future adjustments. Python, as other languages, has special mechanisms to handle exceptions that arise during the code execution.
Whenever an error occurs at a run time, it raises an exception: it creates an object of a special class that contains information describe the problem. For example, a ZeroDivisionError is raised whenever you try to divide by zero, e.g., 1 / 0
(you can try this in a Jupyter notebook). A KeyError is raised, if you using a dictionary with a wrong key, the code below will raise it:
= {"a_key" : 1}
a_dict "b_key"] a_dict[
Similarly, an IndexError is raise, if you try to use an invalid index for a list, a NameError, if you are trying to access variable that does not exist, AttributeError when an object does not have an attribute you are trying to use, etc.
In Python, you can use try: ... except:...finally:
operators:
try:
# some code that might generate a runtime error
except:
# code that is executed if something bad happens
finally:
# code that is executed both with and without exception
# code that is executed ONLY if there were no exceptions or if an exception was handled
In the simplest case, you need just the first two operators: try
and except
. Create a Jupyter notebook (that you will submit as part of the assignment) and write the code that generates a division-by-zero error but is handled via try...except...
. In the except
simply print out a message, so that you know that it was executed. Create another cell, copy the code and now check that the exception handling code is not executed, if the error is not generated (i.e., divide by some non-zero number).
Using except:
catches all exceptions. However, you can be more specific and handle exceptions based on their class.
try:
# some code that might generate a runtime error
except KeyError as key_error:
# code that is executed only if KeyError exception was raised
except ZeroDivisionError as zero_division_error:
# code that is executed only if ZeroDivisionError exception was raised
except:
# code that is executed if any OTHER exception is raised.
Implement handling for KeyError
and ZeroDivisionError
, they should print out different messages to check that it works. Test it generating these runtime errors with your code. What happens if you have the first two specific exception handlers but no general except:
?
So far, you generated exception but cause the errors in the code but you can raise these exceptions yourself via raise
operator. For example, instead of dividing by zero, you can raise ZeroDivisionError()
(note that you are create an object of the class, hence the round brackets!). Use it with you previous code, instead of an actual division by zero. Try raising other exception and see how your code handles them.
Put exception handling code is cell of a Jupyter notebook.
So far I have talked about exceptions as a way to report runtime errors. However, they can be used in a more general way to control the execution flow. You already did it when implementing class-as-an-iterator approach. Once you ran out of items to return, your nextmethod raised (StopIteration)[https://docs.python.org/3/library/exceptions.html#StopIteration] exception that informed the
for` loop that it has no more items to loop through. We will use that side of exception in the next section when dealing with context.
19.8 Exception within context
try..except...
operators provide a general mechanism for exceptions handling but what happens if an exception is raised inside a context? You can, of course, put a try...except...
in the code itself, something you should do, if you are planning to handling specific exceptions. However, if an exception occur in the code inside the context, Python will first exit the context, i.e., the __exit__
method. Moreover, it will put the exception information into the parameters exc_type
(a class of the exception) and exc_value
(an object of that class). This way, you can perform a proper clean-up (save data, close window, etc.) and then handle an exception or leave it alone, so that it propagates further and can be handled by other pieces of your code or stop the execution.
Here, we will use this mechanism not only for safe clean-up but also to make aborting an experiment (or a game) easy. In previous game with many rounds, you had nested loops that made aborting a game via escape key press awkward. You had to check if in the inner loop, differentiate between a normal end-of-round and abort outside, etc. We can make out life much easier via a combination of a context manager and a custom exception. First, create a custom GameAbort
class, which is a descendant of the Exception
class. You do not need any code in it, even a constructor does not need to be redefined, so use pass
statement for its body (you do need to have at least one line of code in the class, so pass
will create a line but with no actual executable code). Next, you raise GameAbort()
, if the player pressed escape key (do not forget to import GameAbort
class, so you can use it in the main script). Finally, in the exit method of the GameContext
manager, you should check whether exc_type is GameAbort
(exc_type
will be None
, if no exception occurred) and, important(!), return True
in that case:
def GameContext:
...def __exit__(self, exc_type, exc_value, traceback):
...if exc_type is GameAbort:
return True
That last bit return True
informs Python that you handled the exception and all is good (not need to propagate it further). Now, you can safely abort your experiment from any code location, inside nested loops, functions, etc. In all cases, the exception will be propagated until the __exit__
method, doing away with awkward extra checks.
Create GameAbort
exception class, update GameContext
class to handle it, use this in an updated main script in exercise06.py.