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
November 15, 2025
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 sets. 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
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. For instance, if first_list were None or 0 the code will still behave identically despite it not even being a list. To guard against that it occasionally make sense to do it as follows:
These two important keywords can help you write your code a bit more efficiently.
Here are some typical use cases for each:
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 if we break out of the loop in the normal way, but it is skipped by 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[24], line 1 ----> 1 starts_with_b("") Cell In[23], 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 input3. But there is another way: which is to just to try to let the function do its thing, and if the NameError exception occurs, deal with it4.
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[26], line 2 1 try: ----> 2 result = starts_with_b(0) 3 print(result) 4 except IndexError: Cell In[23], 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[29], line 6 3 raise ValueError 4 return number ----> 6 check_positive(-5) Cell In[29], 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[30], line 6 3 raise ZeroDivisionError("Cannot divide by zero!") 4 return a / b ----> 6 divide_numbers(10, 0) Cell In[30], 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.
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[47], line 14 11 # We can now re-raise the same exception 12 raise ---> 14 func2(3, 0, 10) Cell In[47], 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[47], 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 as e:
raise ValueError("Some problem occurred during the calculation.")
func2(3, 0, 10)--------------------------------------------------------------------------- ZeroDivisionError Traceback (most recent call last) Cell In[48], line 6, in func2(a, b, c) 5 try: ----> 6 return func1(a, b) + c 7 except ZeroDivisionError as e: Cell In[48], 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[48], line 10 7 except ZeroDivisionError as e: 8 raise ValueError("Some problem occurred during the calculation.") ---> 10 func2(3, 0, 10) Cell In[48], 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.") 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[49], 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[49], 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.
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.
---
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. 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}")
```
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. For instance, if `first_list` were `None` or `0` the code will still behave identically despite it not even being a list. To guard against that it occasionally make sense to do it as follows:
```{python}
if len(first_list):
print("First list is not empty!")
if len(second_list):
print("Second list is not empty!")
```
## 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"]
name = ""
for name in names:
if 'i' in name:
break
```
- 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 if we break out of the loop in the normal way, but it is skipped by 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 collaps="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 `NameError` 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.
## 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 as e:
raise ValueError("Some problem occurred during the calculation.")
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)
```
## 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)