my_list = [1, 2, 3, 4, 5]
my_list.insert(2, "new!")
my_list[1, 2, 'new!', 3, 4, 5]
Python Developer 1 in a nutshell
Karsten Naert
October 20, 2026
There are two important data structures you should remember:
Two more data structures are a little less important:
These structures are often called containers because they contain themselves other data. (Not to be confused with Docker containers!) We will assume you are familiar with the basics of how these data structures work. Here are a few commands that may be new:
You may know .append for a list, but there is also .insert to insert a value at a given position.
my_dict["a"], but did you know .get?There is an important difference though! my_dict["a"] will raise an exception when "a" is not a key in the dict. What will .get do?
If you see nothing, it usually means the last value was None. We can make it more explicit by forcing Python to print it.
The default value can be provided as an extra argument to the .get method:
Tuples are also quite interesting. It is in fact the comma that makes the tuple, not the parentheses:
Nevertheless it is recommended to use parentheses anyway. A tuple with one element can be made like this:
The following also creates a tuple with one element, but it is confusing and suggests a typo was made, so don’t use it:
You can do some interesting things with lists for instance: add them to themselves as a member:
Construct two lists that reference each other: list1 = [1, list2] and list2 = [2, list1]. Investigate how Python prints these.
A comprehension is a piece of Python syntax that allows us to very easily create a new container out of another one1. Usually it is used for lists and dicts, but it can also work for tuples or sets2. Some examples:
Control structures direct the flow of code. They can make us repeat a line several times, skip a line, or back to an earlier line. The two most important control structures are for and if.
1 x 7 = 7
2 x 7 = 14
3 x 7 = 21
4 x 7 = 28
5 x 7 = 35
6 x 7 = 42
7 x 7 = 49
8 x 7 = 56
9 x 7 = 63
10 x 7 = 70
Two builtins turn up constantly in for loops. enumerate gives you the index alongside the value, so you never have to write for i in range(len(names)):
0 jan
1 piet
2 joris
It takes a start argument if you want to count from 1:
zip walks over several containers at the same time:
jan is 30
piet is 40
joris is 50
There is a trap here. By default zip stops as soon as the shortest container runs out, silently ignoring the rest:
Poor joris. Almost always this means you have a bug somewhere else in your program. Since Python 3.10 you can ask zip to complain instead, and I recommend you do so whenever the containers are supposed to have the same length:
jan is 30
piet is 40
--------------------------------------------------------------------------- ValueError Traceback (most recent call last) Cell In[17], line 1 ----> 1 for name, age in zip(names, ages, strict=True): 2 print(f"{name} is {age}") ValueError: zip() argument 2 is shorter than argument 1
To test if a container is empty, the officially recommended way is with if my_container:, as follows:
first_list = []
second_list = [1]
if first_list:
print("First list is not empty!")
if second_list:
print("Second list is not empty!")Second list is not empty!
In practice however, it depends. If first_list were accidentally None or 0, the code above would still quietly take the “empty” branch, even though we no longer have a list at all. Writing len(...) instead does not hide that mistake, it raises a TypeError and tells you something is wrong:
if len(first_list):
print("First list is not empty!")
if len(second_list):
print("Second list is not empty!")Second list is not empty!
--------------------------------------------------------------------------- TypeError Traceback (most recent call last) Cell In[20], line 2 1 not_a_list = None ----> 2 if len(not_a_list): 3 print("This line is never reached") TypeError: object of type 'NoneType' has no len()
So if my_container: is shorter and more Pythonic, while if len(my_container): is stricter about what it accepts. Use the first one by default; reach for the second when you would rather find out loudly that you were handed the wrong type.
These two important keywords can help you write your code a bit more efficiently.
Here are some typical use cases for each:
names = ["jan", "piet", "joris", "korneel"]
for name in names:
if 'i' in name:
break
print(f"First name containing an i: {name}")First name containing an i: piet
Be careful with this pattern: if no name matches, we never break and name is simply left holding the last item of the list. Guarding against that is exactly what the for ... else construct below is for.
An alternative to this last example would be to use if 'i' not in name: ... but this has the downside that line print(name), which can in practice be a larger block will be indented one more level.
One should think of this as processing the special cases, exceptions, … in advance to have the general case more prominently featured. Doing it like this has the benefit of communicating to other programmers what is the general case and what is the exception.
Python has a special construct where a for-loop can have an else-clause.
Its intended use is to trigger when the loop finishes normally, i.e. when the iterator is exhausted. It is skipped if we leave the loop through a break. For instance:
for x in range(5):
print(f"We are testing whether {x} = 3")
if x == 3:
print("Got it!")
break
else:
print("Didn't find it :-/ ")We are testing whether 0 = 3
We are testing whether 1 = 3
We are testing whether 2 = 3
We are testing whether 3 = 3
Got it!
for x in range(3):
print(f"We are testing whether {x} = 3")
if x == 3:
print("Got it!")
break
else:
print("Didn't find it :-/ ")We are testing whether 0 = 3
We are testing whether 1 = 3
We are testing whether 2 = 3
Didn't find it :-/
It is rarely used, and a lot of programmers don’t even know it exists. For that reason I recommend trying to avoid it when collaborating with other people. There are often other ways to achieve the same thing, for instance by using a function or initializing a variable beforehand:
A while loop continues to iterate for as long as a condition remains satisfied.
In general if you know an upper bound to the total number of iterations, it is more recommended to use a for loop: this should make the code easier to read and understand.
For instance, by rewriting as follows we are immediately making clear that this code will run for at most 10000 iterations.
An option to simplify complex bits of control structure is to move a piece to a separate function. An early return can then play a role comparable to a continue or break:
def find_in_list(my_list, x):
for item in my_list:
if item == x:
# Don't need a break since we can return
return "Got it!"
return "Didn't find it :-/ "
find_in_list(range(5), 7)"Didn't find it :-/ "
The following Python code was written by Claude 4 Sonnet.
Rewrite the code to make it easier to understand and maintain.
#!/usr/bin/env python3
import subprocess
import json
from http.server import HTTPServer, BaseHTTPRequestHandler
import os
class WebhookHandler(BaseHTTPRequestHandler):
def do_POST(self):
if self.path == '/webhook':
# Read the payload
content_length = int(self.headers['Content-Length'])
payload = self.rfile.read(content_length)
# Basic validation (optional - add GitHub secret validation for security)
try:
data = json.loads(payload)
if data.get('ref') == 'refs/heads/main': # or your default branch
self.rebuild_docs()
self.send_response(200)
self.end_headers()
self.wfile.write(b'OK')
else:
self.send_response(200)
self.end_headers()
except:
self.send_response(400)
self.end_headers()
else:
self.send_response(404)
self.end_headers()
def rebuild_docs(self):
# Change to your project directory
os.chdir('/path/to/your/project')
subprocess.run(['git', 'pull'])
subprocess.run(['quarto', 'render'])
if __name__ == '__main__':
server = HTTPServer(('0.0.0.0', 8080), WebhookHandler)
print("Webhook server running on port 8080")
server.serve_forever()(Note that this code has other problems too: the quarto render may take so long that the web request could time out, and it does os.chdir without going back to the folder.)
Write some code that will make the names in a list of names unique by appending numbers.
names1 = ['jan', 'piet', 'joris', 'jan']
# Result wanted: ['jan', 'piet', 'joris', 'jan 2']
names2 = ['jan', 'piet', 'joris', 'jan', 'piet']
# Result wanted: ['jan', 'piet', 'joris', 'jan 2', 'piet 2']
namen3 = ['jan', 'piet', 'joris', 'jan', 'piet', 'jan']
# Result wanted: ['jan', 'piet', 'joris', 'jan 2', 'piet 2', 'jan 3']On Github you can find a list with data about all Pokémon
Which Pokémon has the longest name? Remove quotes if necessary.
Which Pokémon has the longest name if you remove special characters?
Write a piece of code that will nicely print a table of Pokémon information to the terminal:
┌─────┬────────────┬───────┬────────┬───────┬────┬────────┬─────────┬─────────┬─────────┬───────┬─────────────┐
│ ID │ Name │ Type1 │ Type2 │ Total │ HP │ Attack │ Defense │ Sp. Atk │ Sp. Def │ Speed │ Generation │
├─────┼────────────┼───────┼────────┼───────┼────┼────────┼─────────┼─────────┼─────────┼───────┼─────────────┤
│ 1 │ Bulbasaur │ Grass │ Poison │ 318 │ 45 │ 49 │ 49 │ 65 │ 65 │ 45 │ 1 │
│ 2 │ Ivysaur │ Grass │ Poison │ 405 │ 60 │ 62 │ 63 │ 80 │ 80 │ 60 │ 1 │
│ 3 │ Venusaur │ Grass │ Poison │ 525 │ 80 │ 82 │ 83 │ 100 │ 100 │ 80 │ 1 │
│ 4 │ Charmander │ Fire │ │ 309 │ 39 │ 52 │ 43 │ 60 │ 50 │ 65 │ 1 │
│ 5 │ Charmeleon │ Fire │ │ 405 │ 58 │ 64 │ 58 │ 80 │ 65 │ 80 │ 1 │
│ 6 │ Charizard │ Fire │ Flying │ 534 │ 78 │ 84 │ 78 │ 109 │ 85 │ 100 │ 1 │
│ 7 │ Squirtle │ Water │ │ 314 │ 44 │ 48 │ 65 │ 50 │ 64 │ 43 │ 1 │
│ 8 │ Wartortle │ Water │ │ 405 │ 59 │ 63 │ 80 │ 65 │ 80 │ 58 │ 1 │
│ 9 │ Blastoise │ Water │ │ 530 │ 79 │ 83 │ 100 │ 85 │ 105 │ 78 │ 1 │
└─────┴────────────┴───────┴────────┴───────┴────┴────────┴─────────┴─────────┴─────────┴───────┴─────────────┘
Note that column 2 has disappeared. See wikipedia for information about box-drawing characters.
You have probably encountered exceptions before, since in Python they are usually what gives you headaches.
Often you start with an ordinary function:
def starts_with_b(word: str) -> bool:
return word[0].lower() == 'b'
starts_with_b("Belgium"), starts_with_b("France")(True, False)
And then you call it with an unexpected input, and an exception occurs, Python is basically telling you it could not proceed with the execution.
--------------------------------------------------------------------------- IndexError Traceback (most recent call last) Cell In[30], line 1 ----> 1 starts_with_b("") Cell In[29], line 2, in starts_with_b(word) 1 def starts_with_b(word: str) -> bool: ----> 2 return word[0].lower() == 'b' IndexError: string index out of range
The exception in this case is an IndexError. A reasonable thing to do would be to clean up the code and guarantee that our function starts_with_b would not be triggered on an incorrect input4. But there is another way: which is to just to try to let the function do its thing, and if the IndexError exception occurs, deal with it5.
The syntax is as follows:
try:
result = starts_with_b("")
print(result)
except IndexError:
print("Could not process the input")Could not process the input
The exception has still occurred but it is handled. This means Python no longer suddenly interrupts and can continue to work after handling the exception. Of course, we need to be careful with what we do, for instance the value of result would not be accessible after the try ... except block if an IndexError was handled.
Other errors could still occur though! For instance, the following code generates a TypeError:
try:
result = starts_with_b(0)
print(result)
except IndexError:
print("Could not process the input")--------------------------------------------------------------------------- TypeError Traceback (most recent call last) Cell In[32], line 2 1 try: ----> 2 result = starts_with_b(0) 3 print(result) 4 except IndexError: Cell In[29], line 2, in starts_with_b(word) 1 def starts_with_b(word: str) -> bool: ----> 2 return word[0].lower() == 'b' TypeError: 'int' object is not subscriptable
There are many types of exception that can occur. To handle them all use except Exception:
Could not process the input
There also exists a so called bare except except:. This is considered poor practice, since it will also handle certain exceptions it really shouldn’t, such as KeyboardInterrupt.
try:
result = starts_with_b(0)
print(result)
except: # DONT do this!
print("Could not process the input")Could not process the input
Sometimes people will say they catch an exception. This terminology comes from other languages, such as C++, where the terminology is not that you raise an exception and then handle it, but you throw an exception and catch it. In practice people will often use both pieces of terminology interchangeably.
The exceptions we have seens so far were all triggered by Python itself, or libraries that we have used. With the raise statement we can also manually trigger an exception. This is useful when you want to signal that something has gone wrong in your code. It’s best to think of it as just another form of control flow.
Here’s the basic syntax:
--------------------------------------------------------------------------- ValueError Traceback (most recent call last) Cell In[35], line 6 3 raise ValueError 4 return number ----> 6 check_positive(-5) Cell In[35], line 3, in check_positive(number) 1 def check_positive(number): 2 if number <= 0: ----> 3 raise ValueError 4 return number ValueError:
You can also raise exceptions with a custom message:
def divide_numbers(a, b):
if b == 0:
raise ZeroDivisionError("Cannot divide by zero!")
return a / b
divide_numbers(10, 0)--------------------------------------------------------------------------- ZeroDivisionError Traceback (most recent call last) Cell In[36], line 6 3 raise ZeroDivisionError("Cannot divide by zero!") 4 return a / b ----> 6 divide_numbers(10, 0) Cell In[36], line 3, in divide_numbers(a, b) 1 def divide_numbers(a, b): 2 if b == 0: ----> 3 raise ZeroDivisionError("Cannot divide by zero!") 4 return a / b ZeroDivisionError: Cannot divide by zero!
You can handle multiple different types of exceptions by using multiple except clauses. This is useful when different exceptions require different handling:
def safe_divide(a, b):
try:
result = a / b
return result
except ZeroDivisionError:
print("Error: Cannot divide by zero!")
return None
except TypeError:
print("Error: Both arguments must be numbers!")
return None
safe_divide(10, 2)5.0
You can also handle multiple exceptions in a single except clause by using a tuple:
def process_number(value):
try:
number = int(value)
result = 100 / number
return result
except (ValueError, TypeError):
print("Error: Invalid input - must be a valid number!")
return None
except ZeroDivisionError:
print("Error: Cannot divide by zero!")
return None
process_number("5")20.0
The order of except clauses matters! Python will check them from top to bottom and execute the first one that matches. Always put more specific exceptions before more general ones:
def demonstrate_exception_order(value):
try:
number = int(value)
result = 100 / number
return result
except ZeroDivisionError: # Specific exception first
print("Cannot divide by zero!")
return 0
except ValueError: # More specific than Exception
print("Invalid number format!")
return None
except Exception: # Most general exception last
print("Something unexpected happened!")
return None
demonstrate_exception_order("0")Cannot divide by zero!
0
except Exception as a last resortThe as keyword allows you to capture the exception object itself, which can be useful for accessing error details or logging:
def divide_with_details(a, b):
try:
result = a / b
return result
except ZeroDivisionError as e:
print(f"Error occurred: {e}")
print(f"Error type: {type(e).__name__}")
return None
divide_with_details(10, 0)Error occurred: division by zero
Error type: ZeroDivisionError
This is especially helpful when you need to log the specific error message or when you want to include the original error details in your own error handling:
def process_file_content(filename):
try:
with open(filename, 'r') as file:
content = file.read()
return len(content)
except FileNotFoundError as e:
print(f"Could not find file: {e}")
return 0
except PermissionError as e:
print(f"Permission denied: {e}")
return 0
except Exception as e:
print(f"Unexpected error: {e}")
return 0
process_file_content("nonexistent.txt")Could not find file: [Errno 2] No such file or directory: 'nonexistent.txt'
0
You can also use the exception object to make decisions about how to handle the error:
Python’s try statement can have additional clauses beyond just except: else and finally. Each serves a specific purpose:
The else clause runs only if no exception occurred in the try block:
The finally clause always runs, regardless of whether an exception occurred or not. Even if there is a return during the exception handling, the block will still be executed. This is useful for cleanup operations:
def process_data_with_cleanup(data):
try:
print("Starting processing...")
result = int(data) * 2
print(f"Result: {result}")
return result
except ValueError:
print("Invalid input!")
return None
finally:
print("Cleanup: Processing finished")
process_data_with_cleanup("5")Starting processing...
Result: 10
Cleanup: Processing finished
10
You can combine try, except, else, and finally together:
def complete_example(filename):
file = None
try:
print(f"Trying to open {filename}")
file = open(filename, 'r')
content = file.read()
print("File opened successfully")
except FileNotFoundError:
print("File not found!")
return None
except PermissionError:
print("Permission denied!")
return None
else:
print("File processing completed successfully")
return len(content)
finally:
if file:
file.close()
print("File closed")
else:
print("No file to close")
complete_example("nonexistent.txt")Trying to open nonexistent.txt
File not found!
No file to close
The execution order is:
try block runs firstexcept block runselse block runs (if present)finally block always runs at the endThis pattern is particularly useful for resource management where you need to ensure cleanup happens regardless of success or failure.
The example above is written the long way round on purpose, to show what finally is for. In real code you would never open a file like this: Python has dedicated syntax, with open(...) as f:, which does the closing for you. We pick that up in the next lecture.
There exists other, more specialized ways in which raise can occur. You will not see these too often but it’s good to be aware they exist:
raise ... from ... is used to raise an exception from a specific contextraise ... from None can be used to erase the context in which an exception is raisedraise without exception can be used during exception handling to reraise the last exceptiondef func1(a, b):
return a / b
def func2(a, b, c):
try:
return func1(a, b) + c
except ZeroDivisionError as e:
# We handle the exception
# But first we do something, perhaps we write some logging
print("No idea what happened but it's a big fat problem!")
# We can now re-raise the same exception
raise
func2(3, 0, 10)No idea what happened but it's a big fat problem!
--------------------------------------------------------------------------- ZeroDivisionError Traceback (most recent call last) Cell In[53], line 14 11 # We can now re-raise the same exception 12 raise ---> 14 func2(3, 0, 10) Cell In[53], line 6, in func2(a, b, c) 4 def func2(a, b, c): 5 try: ----> 6 return func1(a, b) + c 7 except ZeroDivisionError as e: 8 # We handle the exception 9 # But first we do something, perhaps we write some logging 10 print("No idea what happened but it's a big fat problem!") Cell In[53], line 2, in func1(a, b) 1 def func1(a, b): ----> 2 return a / b ZeroDivisionError: division by zero
Here we handle the error and raise a different error of our own. Python will show this by saying “During the handling of this exception, a different exception occurred”:
def func1(a, b):
return a / b
def func2(a, b, c):
try:
return func1(a, b) + c
except ZeroDivisionError:
raise ValueError("Some problem occurred during the calculation.")
func2(3, 0, 10)--------------------------------------------------------------------------- ZeroDivisionError Traceback (most recent call last) Cell In[54], line 6, in func2(a, b, c) 5 try: ----> 6 return func1(a, b) + c 7 except ZeroDivisionError: Cell In[54], line 2, in func1(a, b) 1 def func1(a, b): ----> 2 return a / b ZeroDivisionError: division by zero During handling of the above exception, another exception occurred: ValueError Traceback (most recent call last) Cell In[54], line 10 7 except ZeroDivisionError: 8 raise ValueError("Some problem occurred during the calculation.") ---> 10 func2(3, 0, 10) Cell In[54], line 8, in func2(a, b, c) 6 return func1(a, b) + c 7 except ZeroDivisionError: ----> 8 raise ValueError("Some problem occurred during the calculation.") ValueError: Some problem occurred during the calculation.
Python noticed the new exception was raised while handling the old one and chained them together for us. If you want to make that link explicit, use raise ... from .... Note that we now need the as e to have a name for the original exception. The traceback then reads “The above exception was the direct cause of the following exception”, which is a stronger statement: we are saying the ZeroDivisionError is the reason for our ValueError, not something that merely happened at the same time.
def func1(a, b):
return a / b
def func2(a, b, c):
try:
return func1(a, b) + c
except ZeroDivisionError as e:
raise ValueError("Some problem occurred during the calculation.") from e
func2(3, 0, 10)--------------------------------------------------------------------------- ZeroDivisionError Traceback (most recent call last) Cell In[55], line 6, in func2(a, b, c) 5 try: ----> 6 return func1(a, b) + c 7 except ZeroDivisionError as e: Cell In[55], line 2, in func1(a, b) 1 def func1(a, b): ----> 2 return a / b ZeroDivisionError: division by zero The above exception was the direct cause of the following exception: ValueError Traceback (most recent call last) Cell In[55], line 10 7 except ZeroDivisionError as e: 8 raise ValueError("Some problem occurred during the calculation.") from e ---> 10 func2(3, 0, 10) Cell In[55], line 8, in func2(a, b, c) 6 return func1(a, b) + c 7 except ZeroDivisionError as e: ----> 8 raise ValueError("Some problem occurred during the calculation.") from e ValueError: Some problem occurred during the calculation.
If we raise from None there is no mention of the original ZeroDivisionError anymore:
def func1(a, b):
return a / b
def func2(a, b, c):
try:
return func1(a, b) + c
except ZeroDivisionError as e:
# Here we handle the error and raise a new exception of our own
# But we remove the context, i.e. the original error is no longer
# in the traceback
raise ValueError("Some problem occurred during the calculation.") from None
func2(3, 0, 10)--------------------------------------------------------------------------- ValueError Traceback (most recent call last) Cell In[56], line 13 7 except ZeroDivisionError as e: 8 # Here we handle the error and raise a new exception of our own 9 # But we remove the context, i.e. the original error is no longer 10 # in the traceback 11 raise ValueError("Some problem occurred during the calculation.") from None ---> 13 func2(3, 0, 10) Cell In[56], line 11, in func2(a, b, c) 6 return func1(a, b) + c 7 except ZeroDivisionError as e: 8 # Here we handle the error and raise a new exception of our own 9 # But we remove the context, i.e. the original error is no longer 10 # in the traceback ---> 11 raise ValueError("Some problem occurred during the calculation.") from None ValueError: Some problem occurred during the calculation.
Since Python 3.11 you can attach extra information to an exception without replacing it, using .add_note. This is handy when the code that catches the exception knows something useful that the code which raised it could not have known:
def load_config(filename):
try:
with open(filename) as f:
return f.read()
except FileNotFoundError as e:
e.add_note("Looked for the config while starting up the application.")
e.add_note("Hint: run `myapp --init` to create a default config file.")
raise
load_config("settings.ini")--------------------------------------------------------------------------- FileNotFoundError Traceback (most recent call last) Cell In[57], line 10 7 e.add_note("Hint: run `myapp --init` to create a default config file.") 8 raise ---> 10 load_config("settings.ini") Cell In[57], line 3, in load_config(filename) 1 def load_config(filename): 2 try: ----> 3 with open(filename) as f: 4 return f.read() 5 except FileNotFoundError as e: File /var/www/pydev2/.venv/lib/python3.13/site-packages/IPython/core/interactiveshell.py:343, in _modified_open(file, *args, **kwargs) 336 if file in {0, 1, 2}: 337 raise ValueError( 338 f"IPython won't let you open fd={file} by default " 339 "as it is likely to crash IPython. If you know what you are doing, " 340 "you can use builtins' open." 341 ) --> 343 return io_open(file, *args, **kwargs) FileNotFoundError: [Errno 2] No such file or directory: 'settings.ini' Looked for the config while starting up the application. Hint: run `myapp --init` to create a default config file.
The notes are printed at the bottom of the traceback, after the error message.
Tracebacks have quietly become much more helpful in recent Python versions. Since 3.11 they underline the exact sub-expression that failed with ~~~^^^~~~ markers, which is a big help in a line like a["x"] / b["y"] where several things could have gone wrong. Since 3.13 they are also coloured by default in the terminal. If you are still reading tracebacks from an older Python, this alone is a good reason to upgrade.
Finally, it is possible to define your own exceptions apart from the existing IndexError, KeyError, … We will learn more about this in the lectures on Object Oriented Programming.
Technically out of an iterator.↩︎
Strictly speaking there is no such thing as a “tuple comprehension”: the last line below is a generator expression that we then hand to tuple(...). For now you can safely read it as a comprehension; we come back to generators in the lectures on Architecture.↩︎
There is another way using next.↩︎
This is sometimes called asking permission↩︎
Sometimes called asking forgiveness↩︎
---
title: "Data and control structures"
subtitle: "Python Developer 1 in a nutshell"
author: "Karsten Naert"
date: today
toc: true
execute:
echo: true
output: true
---
# Data Structures
## Important methods
There are two important data structures you should remember:
- lists
- dicts
Two more data structures are a little less important:
- tuples
- sets
These structures are often called _containers_ because they contain themselves other data. (Not to be confused with Docker containers!) We will assume you are familiar with the basics of how these data structures work. Here are a few commands that may be new:
You may know `.append` for a list, but there is also `.insert` to insert a value at a given position.
```{python}
my_list = [1, 2, 3, 4, 5]
my_list.insert(2, "new!")
my_list
```
- You may also know `my_dict["a"]`, but did you know `.get`?
```{python}
my_dict = {"a": 100, "b": 200}
my_dict.get("a")
```
There is an important difference though! `my_dict["a"]` will raise an exception when `"a"` is not a key in the dict. What will `.get` do?
```{python}
d = {"a": 10}
d.get("b")
```
If you see nothing, it usually means the last value was `None`. We can make it more explicit by forcing Python to print it.
```{python}
d = {"a": 10}
print(d.get("b"))
```
The default value can be provided as an extra argument to the `.get` method:
```{python}
d.get("b", "default_value")
```
Tuples are also quite interesting. It is in fact the comma that makes the tuple, not the parentheses:
```{python}
x = (5, 6) # ok, recommended
x = 5, 6 # also ok, not recommended
```
Nevertheless it is recommended to use parentheses anyway. A tuple with one element can be made like this:
```{python}
x = (3,) # tuple with one element
```
The following also creates a tuple with one element, but it is confusing and suggests a typo was made, so don't use it:
```{python}
x = 3,
```
You can do some interesting things with lists for instance: add them to themselves as a member:
```{python}
my_list = []
my_list.append(my_list)
my_list
```
::: {.callout-note icon=false collapse="true"}
## Exercise
Construct two lists that reference each other: `list1 = [1, list2]` and `list2 = [2, list1]`. Investigate how Python prints these.
:::
## Comprehensions
A _comprehension_ is a piece of Python syntax that allows us to very easily create a new container out of another one^[Technically out of an iterator.]. Usually it is used for lists and dicts, but it can also work for tuples or sets^[Strictly speaking there is no such thing as a "tuple comprehension": the last line below is a _generator expression_ that we then hand to `tuple(...)`. For now you can safely read it as a comprehension; we come back to generators in the lectures on Architecture.]. Some examples:
```{python}
#| output: false
[x**2 for x in range(5)] # list comprehension
{x**2: x for x in range(5)} # dict comprehension
{x for x in range(5)} # set comprehension
tuple(x for x in range(5)) # "tuple comprehension"
```
# Control Structures
## for and if
Control structures direct the flow of code. They can make us repeat a line several times, skip a line, or back to an earlier line. The two most important control structures are _for_ and _if_.
```{python}
x = 10
if x < 5:
print("x is small")
else:
print("x is large")
```
```{python}
for i in range(1, 11):
print(f"{i} x 7 = {7 * i}")
```
### Looping with enumerate and zip
Two builtins turn up constantly in `for` loops. `enumerate` gives you the index alongside
the value, so you never have to write `for i in range(len(names))`:
```{python}
names = ["jan", "piet", "joris"]
for i, name in enumerate(names):
print(i, name)
```
It takes a `start` argument if you want to count from 1:
```{python}
for i, name in enumerate(names, start=1):
print(f"{i}. {name}")
```
`zip` walks over several containers at the same time:
```{python}
ages = [30, 40, 50]
for name, age in zip(names, ages):
print(f"{name} is {age}")
```
There is a trap here. By default `zip` stops as soon as the *shortest* container runs out,
silently ignoring the rest:
```{python}
ages = [30, 40]
for name, age in zip(names, ages):
print(f"{name} is {age}")
```
Poor joris. Almost always this means you have a bug somewhere else in your program. Since
Python 3.10 you can ask `zip` to complain instead, and I recommend you do so whenever the
containers are supposed to have the same length:
```{python}
#| error: true
for name, age in zip(names, ages, strict=True):
print(f"{name} is {age}")
```
### Testing whether a container is empty
To test if a container is empty, the [officially recommended way](https://peps.python.org/pep-0008/#programming-recommendations) is with `if my_container:`, as follows:
```{python}
first_list = []
second_list = [1]
if first_list:
print("First list is not empty!")
if second_list:
print("Second list is not empty!")
```
In practice however, it depends. If `first_list` were accidentally `None` or `0`, the
code above would still quietly take the "empty" branch, even though we no longer have a
list at all. Writing `len(...)` instead does not hide that mistake, it *raises* a
`TypeError` and tells you something is wrong:
```{python}
if len(first_list):
print("First list is not empty!")
if len(second_list):
print("Second list is not empty!")
```
```{python}
#| error: true
not_a_list = None
if len(not_a_list):
print("This line is never reached")
```
So `if my_container:` is shorter and more Pythonic, while `if len(my_container):` is
stricter about what it accepts. Use the first one by default; reach for the second when
you would rather find out loudly that you were handed the wrong type.
## break and continue
These two important keywords can help you write your code a bit more efficiently.
- break stops the current for loop and breaks out of it to the next block of code
- continue stops the current iteration of the loop and immediately goes to the next
Here are some typical use cases for each:
- break: can help you find an object as we are iterating through a container. For instance, this lets us find the first name with an i in it ^[There is another way using `next`.]
```{python}
names = ["jan", "piet", "joris", "korneel"]
for name in names:
if 'i' in name:
break
print(f"First name containing an i: {name}")
```
Be careful with this pattern: if *no* name matches, we never `break` and `name` is
simply left holding the last item of the list. Guarding against that is exactly what
the `for ... else` construct below is for.
- continue: can act as a filter as we are iterating. For instance, say we want to print only the names that do not have a letter i in them:
```{python}
for name in names:
if 'i' in name:
continue
print(name)
```
An alternative to this last example would be to use `if 'i' not in name: ...` but this has the downside that line `print(name)`, which can in practice be a larger block will be indented one more level.
```{python}
#| eval: false
for name in names:
if "i" in name:
raise ValueError
function1()
function2()
...
function100()
```
```{python}
#| eval: false
for name in names:
if "i" not in name:
function1()
function2()
...
function100()
else:
raise ValueError
```
One should think of this as processing the special cases, exceptions, ... in advance to have the _general case_ more prominently featured. Doing it like this has the benefit of communicating to other programmers what is the general case and what is the exception.
## for ... else
Python has a special construct where a `for`-loop can have an `else`-clause.
Its intended use is to trigger when the loop finishes normally, i.e. when the iterator is exhausted. It is *skipped* if we leave the loop through a `break`. For instance:
```{python}
for x in range(5):
print(f"We are testing whether {x} = 3")
if x == 3:
print("Got it!")
break
else:
print("Didn't find it :-/ ")
```
```{python}
for x in range(3):
print(f"We are testing whether {x} = 3")
if x == 3:
print("Got it!")
break
else:
print("Didn't find it :-/ ")
```
It is rarely used, and a lot of programmers don't even know it exists. For that reason I recommend trying to avoid it when collaborating with other people. There are often other ways to achieve the same thing, for instance by using a function or initializing a variable beforehand:
```{python}
result = "Didn't find it :-/ "
for x in range(3):
if x == 3:
result = "Got it!"
break
print(result)
```
## while
A while loop continues to iterate for as long as a condition remains satisfied.
```{python}
total = 0
i = 0
while total < 10_000:
i += 1
total += i
print(i)
```
In general if you know an upper bound to the total number of iterations, it is more recommended to use a for loop: this should make the code easier to read and understand.
For instance, by rewriting as follows we are immediately making clear that this code will run for at most 10000 iterations.
```{python}
total = 0
for i in range(10_000):
total += i
if total > 10_000:
break
print(i)
```
## functions
An option to simplify complex bits of control structure is to move a piece to a separate function. An early `return` can then play a role comparable to a continue or break:
```{python}
def find_in_list(my_list, x):
for item in my_list:
if item == x:
# Don't need a break since we can return
return "Got it!"
return "Didn't find it :-/ "
find_in_list(range(5), 7)
```
::: {.callout-note icon=false collapse="true"}
## Exercise
The following Python code was written by Claude 4 Sonnet.
Rewrite the code to make it easier to understand and maintain.
```{python}
#| eval: false
#!/usr/bin/env python3
import subprocess
import json
from http.server import HTTPServer, BaseHTTPRequestHandler
import os
class WebhookHandler(BaseHTTPRequestHandler):
def do_POST(self):
if self.path == '/webhook':
# Read the payload
content_length = int(self.headers['Content-Length'])
payload = self.rfile.read(content_length)
# Basic validation (optional - add GitHub secret validation for security)
try:
data = json.loads(payload)
if data.get('ref') == 'refs/heads/main': # or your default branch
self.rebuild_docs()
self.send_response(200)
self.end_headers()
self.wfile.write(b'OK')
else:
self.send_response(200)
self.end_headers()
except:
self.send_response(400)
self.end_headers()
else:
self.send_response(404)
self.end_headers()
def rebuild_docs(self):
# Change to your project directory
os.chdir('/path/to/your/project')
subprocess.run(['git', 'pull'])
subprocess.run(['quarto', 'render'])
if __name__ == '__main__':
server = HTTPServer(('0.0.0.0', 8080), WebhookHandler)
print("Webhook server running on port 8080")
server.serve_forever()
```
(Note that this code has other problems too: the `quarto render` may take so long that the web request could time out, and it does `os.chdir` without going back to the folder.)
:::
::: {.callout-note icon=false collapse="true"}
## Exercise
Write some code that will make the names in a list of names unique by appending numbers.
```{python}
#| eval: false
names1 = ['jan', 'piet', 'joris', 'jan']
# Result wanted: ['jan', 'piet', 'joris', 'jan 2']
names2 = ['jan', 'piet', 'joris', 'jan', 'piet']
# Result wanted: ['jan', 'piet', 'joris', 'jan 2', 'piet 2']
namen3 = ['jan', 'piet', 'joris', 'jan', 'piet', 'jan']
# Result wanted: ['jan', 'piet', 'joris', 'jan 2', 'piet 2', 'jan 3']
```
:::
::: {.callout-note icon=false collapse="true"}
## Exercise
On Github you can find [a list](https://raw.githubusercontent.com/lgreski/pokemonData/master/Pokemon.csv) with data about all Pokémon
Which Pokémon has the longest name? Remove quotes if necessary.
Which Pokémon has the longest name if you remove special characters?
:::
::: {.callout-note icon=false collapse="true"}
## Exercise
Write a piece of code that will nicely print a table of Pokémon information to the terminal:
```
┌─────┬────────────┬───────┬────────┬───────┬────┬────────┬─────────┬─────────┬─────────┬───────┬─────────────┐
│ ID │ Name │ Type1 │ Type2 │ Total │ HP │ Attack │ Defense │ Sp. Atk │ Sp. Def │ Speed │ Generation │
├─────┼────────────┼───────┼────────┼───────┼────┼────────┼─────────┼─────────┼─────────┼───────┼─────────────┤
│ 1 │ Bulbasaur │ Grass │ Poison │ 318 │ 45 │ 49 │ 49 │ 65 │ 65 │ 45 │ 1 │
│ 2 │ Ivysaur │ Grass │ Poison │ 405 │ 60 │ 62 │ 63 │ 80 │ 80 │ 60 │ 1 │
│ 3 │ Venusaur │ Grass │ Poison │ 525 │ 80 │ 82 │ 83 │ 100 │ 100 │ 80 │ 1 │
│ 4 │ Charmander │ Fire │ │ 309 │ 39 │ 52 │ 43 │ 60 │ 50 │ 65 │ 1 │
│ 5 │ Charmeleon │ Fire │ │ 405 │ 58 │ 64 │ 58 │ 80 │ 65 │ 80 │ 1 │
│ 6 │ Charizard │ Fire │ Flying │ 534 │ 78 │ 84 │ 78 │ 109 │ 85 │ 100 │ 1 │
│ 7 │ Squirtle │ Water │ │ 314 │ 44 │ 48 │ 65 │ 50 │ 64 │ 43 │ 1 │
│ 8 │ Wartortle │ Water │ │ 405 │ 59 │ 63 │ 80 │ 65 │ 80 │ 58 │ 1 │
│ 9 │ Blastoise │ Water │ │ 530 │ 79 │ 83 │ 100 │ 85 │ 105 │ 78 │ 1 │
└─────┴────────────┴───────┴────────┴───────┴────┴────────┴─────────┴─────────┴─────────┴───────┴─────────────┘
```
Note that column 2 has disappeared. See [wikipedia](https://en.wikipedia.org/wiki/Box-drawing_character) for information about box-drawing characters.
:::
# Exceptions
## try ... except
You have probably encountered exceptions before, since in Python they are usually what gives you headaches.
Often you start with an ordinary function:
```{python}
def starts_with_b(word: str) -> bool:
return word[0].lower() == 'b'
starts_with_b("Belgium"), starts_with_b("France")
```
And then you call it with an unexpected input, and an _exception_ occurs, Python is basically telling you it could not proceed with the execution.
```{python}
#| error: true
starts_with_b("")
```
The exception in this case is an `IndexError`. A reasonable thing to do would be to clean up the code and guarantee that our function `starts_with_b` would not be triggered on an incorrect input^[This is sometimes called _asking permission_]. But there is another way: which is to just to `try` to let the function do its thing, and if the `IndexError` exception occurs, deal with it^[Sometimes called _asking forgiveness_].
The syntax is as follows:
```{python}
try:
result = starts_with_b("")
print(result)
except IndexError:
print("Could not process the input")
```
The exception has still occurred but it is _handled_. This means Python no longer suddenly interrupts and can continue to work after handling the exception. Of course, we need to be careful with what we do, for instance the value of `result` would not be accessible after the `try ... except` block if an `IndexError` was handled.
Other errors could still occur though! For instance, the following code generates a `TypeError`:
```{python}
#| error: true
try:
result = starts_with_b(0)
print(result)
except IndexError:
print("Could not process the input")
```
There are many types of exception that can occur. To handle them all use `except Exception`:
```{python}
try:
result = starts_with_b(0)
print(result)
except Exception:
print("Could not process the input")
```
There also exists a so called _bare except_ `except:`. This is considered poor practice, since it will also handle certain exceptions it really shouldn't, such as `KeyboardInterrupt`.
```{python}
try:
result = starts_with_b(0)
print(result)
except: # DONT do this!
print("Could not process the input")
```
Sometimes people will say they _catch_ an exception. This terminology comes from other languages, such as C++, where the terminology is not that you _raise_ an exception and then _handle_ it, but you _throw_ an exception and _catch_ it. In practice people will often use both pieces of terminology interchangeably.
## raise
The exceptions we have seens so far were all triggered by Python itself, or libraries that we have used. With the `raise` statement we can also manually trigger an exception. This is useful when you want to signal that something has gone wrong in your code. It's best to think of it as just another form of control flow.
Here's the basic syntax:
```{python}
#| error: true
def check_positive(number):
if number <= 0:
raise ValueError
return number
check_positive(-5)
```
You can also raise exceptions with a custom message:
```{python}
#| error: true
def divide_numbers(a, b):
if b == 0:
raise ZeroDivisionError("Cannot divide by zero!")
return a / b
divide_numbers(10, 0)
```
## try ... except ... except
You can handle multiple different types of exceptions by using multiple `except` clauses. This is useful when different exceptions require different handling:
```{python}
def safe_divide(a, b):
try:
result = a / b
return result
except ZeroDivisionError:
print("Error: Cannot divide by zero!")
return None
except TypeError:
print("Error: Both arguments must be numbers!")
return None
safe_divide(10, 2)
```
```{python}
safe_divide(10, 0)
```
```{python}
safe_divide(10, "hello")
```
You can also handle multiple exceptions in a single `except` clause by using a tuple:
```{python}
def process_number(value):
try:
number = int(value)
result = 100 / number
return result
except (ValueError, TypeError):
print("Error: Invalid input - must be a valid number!")
return None
except ZeroDivisionError:
print("Error: Cannot divide by zero!")
return None
process_number("5")
```
```{python}
process_number("hello")
```
```{python}
process_number("0")
```
The order of `except` clauses matters! Python will check them from top to bottom and execute the first one that matches. Always put more specific exceptions before more general ones:
```{python}
def demonstrate_exception_order(value):
try:
number = int(value)
result = 100 / number
return result
except ZeroDivisionError: # Specific exception first
print("Cannot divide by zero!")
return 0
except ValueError: # More specific than Exception
print("Invalid number format!")
return None
except Exception: # Most general exception last
print("Something unexpected happened!")
return None
demonstrate_exception_order("0")
```
## Best practices:
- Use specific exceptions whenever you can
- You can use `except Exception` as a last resort
- Avoid bare except unless you have a very good reason
## try ... except as
The `as` keyword allows you to capture the exception object itself, which can be useful for accessing error details or logging:
```{python}
def divide_with_details(a, b):
try:
result = a / b
return result
except ZeroDivisionError as e:
print(f"Error occurred: {e}")
print(f"Error type: {type(e).__name__}")
return None
divide_with_details(10, 0)
```
This is especially helpful when you need to log the specific error message or when you want to include the original error details in your own error handling:
```{python}
def process_file_content(filename):
try:
with open(filename, 'r') as file:
content = file.read()
return len(content)
except FileNotFoundError as e:
print(f"Could not find file: {e}")
return 0
except PermissionError as e:
print(f"Permission denied: {e}")
return 0
except Exception as e:
print(f"Unexpected error: {e}")
return 0
process_file_content("nonexistent.txt")
```
You can also use the exception object to make decisions about how to handle the error:
```{python}
def safe_convert_to_int(value):
try:
return int(value)
except ValueError as e:
if "invalid literal" in str(e):
print(f"'{value}' is not a valid number format")
else:
print(f"Value error: {e}")
return None
safe_convert_to_int("hello")
```
```{python}
safe_convert_to_int("123")
```
## try ... except ... else ... finally
Python's `try` statement can have additional clauses beyond just `except`: `else` and `finally`. Each serves a specific purpose:
### else clause
The `else` clause runs only if no exception occurred in the `try` block:
```{python}
def divide_with_else(a, b):
try:
result = a / b
except ZeroDivisionError:
print("Cannot divide by zero!")
return None
else:
print("Division successful!")
return result
divide_with_else(10, 2)
```
```{python}
divide_with_else(10, 0)
```
### finally clause
The `finally` clause always runs, regardless of whether an exception occurred or not. Even if there is a `return` during the exception handling, the block will still be executed. This is useful for cleanup operations:
```{python}
def process_data_with_cleanup(data):
try:
print("Starting processing...")
result = int(data) * 2
print(f"Result: {result}")
return result
except ValueError:
print("Invalid input!")
return None
finally:
print("Cleanup: Processing finished")
process_data_with_cleanup("5")
```
```{python}
process_data_with_cleanup("hello")
```
### Combining all clauses
You can combine `try`, `except`, `else`, and `finally` together:
```{python}
def complete_example(filename):
file = None
try:
print(f"Trying to open {filename}")
file = open(filename, 'r')
content = file.read()
print("File opened successfully")
except FileNotFoundError:
print("File not found!")
return None
except PermissionError:
print("Permission denied!")
return None
else:
print("File processing completed successfully")
return len(content)
finally:
if file:
file.close()
print("File closed")
else:
print("No file to close")
complete_example("nonexistent.txt")
```
The execution order is:
1. `try` block runs first
2. If an exception occurs, the appropriate `except` block runs
3. If no exception occurs, the `else` block runs (if present)
4. The `finally` block always runs at the end
This pattern is particularly useful for resource management where you need to ensure cleanup happens regardless of success or failure.
::: {.callout-note}
The example above is written the long way round on purpose, to show what `finally` is for.
In real code you would never open a file like this: Python has dedicated syntax,
`with open(...) as f:`, which does the closing for you. We pick that up in the
[next lecture](02-bytes-files.qmd).
:::
## raise from
There exists other, more specialized ways in which `raise` can occur. You will not see these too often but it's good to be aware they exist:
- `raise ... from ...` is used to raise an exception from a specific context
- `raise ... from None` can be used to erase the context in which an exception is raised
- `raise` without exception can be used during exception handling to reraise the last exception
```{python}
#| error: true
def func1(a, b):
return a / b
def func2(a, b, c):
try:
return func1(a, b) + c
except ZeroDivisionError as e:
# We handle the exception
# But first we do something, perhaps we write some logging
print("No idea what happened but it's a big fat problem!")
# We can now re-raise the same exception
raise
func2(3, 0, 10)
```
Here we handle the error and raise a different error of our own. Python will show this by saying "During the handling of this exception, a different exception occurred":
```{python}
#| error: true
def func1(a, b):
return a / b
def func2(a, b, c):
try:
return func1(a, b) + c
except ZeroDivisionError:
raise ValueError("Some problem occurred during the calculation.")
func2(3, 0, 10)
```
Python noticed the new exception was raised *while* handling the old one and chained them
together for us. If you want to make that link explicit, use `raise ... from ...`. Note
that we now need the `as e` to have a name for the original exception. The traceback then
reads "The above exception was the direct cause of the following exception", which is a
stronger statement: we are saying the `ZeroDivisionError` is *the reason* for our
`ValueError`, not something that merely happened at the same time.
```{python}
#| error: true
def func1(a, b):
return a / b
def func2(a, b, c):
try:
return func1(a, b) + c
except ZeroDivisionError as e:
raise ValueError("Some problem occurred during the calculation.") from e
func2(3, 0, 10)
```
If we `raise from None` there is no mention of the original `ZeroDivisionError` anymore:
```{python}
#| error: true
def func1(a, b):
return a / b
def func2(a, b, c):
try:
return func1(a, b) + c
except ZeroDivisionError as e:
# Here we handle the error and raise a new exception of our own
# But we remove the context, i.e. the original error is no longer
# in the traceback
raise ValueError("Some problem occurred during the calculation.") from None
func2(3, 0, 10)
```
## Adding notes to an exception
Since Python 3.11 you can attach extra information to an exception without replacing it,
using `.add_note`. This is handy when the code that *catches* the exception knows
something useful that the code which *raised* it could not have known:
```{python}
#| error: true
def load_config(filename):
try:
with open(filename) as f:
return f.read()
except FileNotFoundError as e:
e.add_note("Looked for the config while starting up the application.")
e.add_note("Hint: run `myapp --init` to create a default config file.")
raise
load_config("settings.ini")
```
The notes are printed at the bottom of the traceback, after the error message.
::: {.callout-tip}
Tracebacks have quietly become much more helpful in recent Python versions. Since 3.11 they
underline the *exact* sub-expression that failed with `~~~^^^~~~` markers, which is a big
help in a line like `a["x"] / b["y"]` where several things could have gone wrong. Since 3.13
they are also coloured by default in the terminal. If you are still reading tracebacks from
an older Python, this alone is a good reason to upgrade.
:::
## Creating your own exceptions
Finally, it is possible to define your own exceptions apart from the existing `IndexError`, `KeyError`, ... We will learn more about this in the lectures on Object Oriented Programming.
---
**Next**: [Lesson 2: Bytes and files](02-bytes-files.qmd)