greends-ipython

Introduction to Python 2026/2027

Masters in Data Science applied to agricultural and food sciences, environment, and forestry engineering.

Instructor: Manuel Campagnolo (mlc@isa.ulisboa.pt)

Teaching assistant: Hannah Nathanson (hnathanson@edu.ulisboa.pt)


Revised class schedule: class (Friday, room 48) will start at 11:45 and end at 14:45, with a ~45 minute break for lunch.


Online resources for the course

* You need to self-register for the course in Moodle at <https://elearning.ulisboa.pt/user/index.php?id=12863>. The Moodle site allows you to access assignments; submissions; detailed grades and comments * If you don't have one already, create a GitHub account at <https://github.com/signup> * [CS50P: CS50’s Introduction to Programming with Python](https://cs50.harvard.edu/python). Lectures (videos and notes), problems sets, shorts; * [CS50 codespace](https://cs50.dev/). VScode codespace that allows you to use a customized AI bot (CS50's duck debugger, or DDB for short) and test your solutions for CS50P proposed problems (you can access the codespace with your GitHub user). * [Python tutor](https://pythontutor.com/). Python Tutor is a online tool that lets you write code in a web browser and see what happens step-by-step as the computer runs it. * [Fenix webpage for the course](https://fenix.isa.ulisboa.pt/courses/intpy-564938523285845): administrative information and final grades * [Help me](https://1drv.ms/o/c/0bedfe7ffa5a0eb8/IgA-CgpLN8IVTJsDg9FJ9m67AQrx95SYxoXvGRBOMDVRwjI): general help, assignements
Advanced exercises, alternative tutorials and reference materials
  • Codewars is an online educational platform used to practice, improve, and master programming skills through gamified coding challenges in Python and many other programming languages. You can log in with your GitHub account and choose your proficiency level. Codewars will propose exercises for your level.
  • Learn Python with Scrimba, Olof Paulson: interactive lectures (videos), examples and exercises
  • Introduction to Python (VScode): interactive step by step lectures and exercises, real-time quizzes.
  • Basic concepts and features of the Python language and system: The Python Tutorial at python.org.
  • Python Programming course at PP.fi: same features as CS50 but to test your solutions to problems you are required to pass previous tests
  • If you curious about the story of Python, check out this video. In this video, you’ll learn about the output of >>> import this and many other anecdotes about Python.

</blockquote>

</details> ---

Class 1 (September 11, 2026): data types, variables, lists, functions

1. The recommendation for this class is to code using the CS50 codespace. Two steps: 1. log in into your GitHub account; 2. access your code space at <https://cs50.dev/>. This environment allows you to test automatically your scripts for the CS50 problem sets and gives access to a customized AI bot (DDB) 2. Some useful keyworks for the command line interface (CLI): * `code filename.py` to create a new file * `ls` to list files in folder * `cp filename newfilename` to copy a file, e.g. `cp ..\hello.py farewell.py` (`..` represents parent folder) * `mv filename newfilename` to rename or move file, e.g. `my farewell.py goodbye.py` or `mv farewell.py ..` (move one folder up) * `rm filename` to delete (remove) file * `mkdir foldername` to create new folder * `cd foldername` change directory, e.g. `cd ..` * `rmdir foldername` to delete folder * `clear` to clear terminal window 3. Typing `python` gives you access to a REPL (interactive Read-Eval-Print-Loop) environment (<https://realpython.com/interacting-with-python/>). 4. All values in Python have a **type**. The **primitive data types** are: integer, float, string, Boolean, and None (see <https://www.geeksforgeeks.org/python/primitive-data-types-vs-non-primitive-data-types-in-python/>). There are also **Non-Primitive Data Types** like `list`, `tuple`, `set` and `dictionary`. * strings (`str`), variables, print (a function), parameters (e.g. `end=`), input, comments, formatted strings (`f"..."`), `.strip()`, `.title` (methods) * integers (`int`), operations for integers, casting (e.g. `str` to `int`) * floating point values (`float`), round, format floats (e.g. `f"{z:.2f}`) * Boolean: `True`, `False`, that can be combined with operators `and`, `or`, `not` 5. Functions, `def`, `return`. Example: ``` def square(x): return x*x ``` 6. View the step-by-step execution of this code on [Python Tutor](https://pythontutor.com/visualize.html#code=def%20main%28%29%3A%0A%20%20%20%20x%3D4%0A%20%20%20%20y%3Dsquare%28x%29%0A%20%20%20%20print%28f%22%7Bx%7D%20squared%20is%20%7By%7D%22%29%0A%0Adef%20square%28n%29%3A%0A%20%20%20%20s%3Dn*n%0A%20%20%20%20return%28s%29%0A%0Amain%28%29&curInstr=12&mode=display&origin=opt-frontend.js&py=311). Identify the scope of the variables. Try to add `print(s)` in line 4 after calling function `square` inside the `main` function: why do you get a `NameError`? 7. Suggested problems: [CS50 Problem set 0](https://cs50.harvard.edu/python/psets/0/). Find a solution to the problems at your CS50 codespace, and dialog with the AI bot (DDB) for help. The bot won't produce code but will help you by answering your to questions: very precise prompts originate more details in the answers. Resist the temptation of looking for a solution on the web or using a generic AI bot.
---

Class 2 (September 18, 2026): conditionals, lists, dictionaries

1. See: - [notes on CS50P lecture 1](https://cs50.harvard.edu/python/notes/1/): conditionals `if`, `match`, boolean expressions, etc - [notes on CS50P lecture 2](https://cs50.harvard.edu/python/weeks/2/): `for` and `while` loops, etc 3. Conditionals: - `if`, `elif`, `else`: ```Python if score >= 70: print("Grade: C to A") elif score >= 60: print("Grade: D") else: print("Grade: F") ``` - `match`: ```Python match species: case 'versicolor': label=0 case 'virginica': label=1 case _: label=2 ``` 3. Pythonic coding: `def main()`, define other functions, call `main()`. The code must be modular. 4. While loops, for loops, `break`, `break` and `return` ``` s='intro to python' for i in range(15): print(s[i]) ``` ``` # require user to provide a positive number while True: n = int(input("What's n? ")) if n > 0: break ``` 6. Data type *list* `[]`: methods `append`, `extend` 7. Data type *dictionary* `{}`, `items()`, keys `.key()` and values `.values()` ```Python knights = {'gallahad': 'the pure', 'robin': 'the brave'} for k, v in knights.items(): print(k, v) if 'gallahad' in knights: print('Go Gallahad') ``` 8. Suggested problems: [CS50 Problem set 1](https://cs50.harvard.edu/python/2022/psets/): Deep Thought, Home Federal Savings Bank, Math Interpreter, Meal Time 9. Assignment #1 available on Moodle: problems [File extensions (set 1)](https://cs50.harvard.edu/python/2022/psets/1/extensions/), [Coke machine (set 2)](https://cs50.harvard.edu/python/2022/psets/2/coke/), [Plates (set 2)](https://cs50.harvard.edu/python/2022/psets/2/plates/)
---

Class 3 (September 25, 2026): list and dictionary comprehensions, collaborative exercise

1. See lecture on [List and dictionary comprehensions](https://cs50.harvard.edu/python/shorts/list_dictionary_comprehensions/) 2. [Exercises on list comprehension (with some solutions)](/greends-ipython/docs/exercises_list_comprehension.html) 2. [Exercises on dictionary comprehension (with some solutions)](/greends-ipython/docs/exercises_dict_comprehension.html) 3. Collaborative project: each student or small group of students should define each necessary function to complete the script below. The side effect of `main()` is a simple histogram printed in the terminal. ```Python from read_values import read_values from determine_min_max import determine_min_max from number_of_classes_sturges import number_of_classes_sturges from amplitude import amplitude from determine_frequency import determine_frequency from print_frequency import print_frequency def main(): # read values x=read_values() # x is a list of numbers, either integers or floats n=len(x) # integer; number of values # determine min value and max value xmin,xmax=determine_min_max(x) # integers or floats # determine number of classes m=number_of_classes_sturges(n) # m is a positive integer such that 2**(m-1) <= n <= 2**m # determine class amplitude delta=amplitude(xmin,xmax,m) # positive float, the range of values divided by the number of classes # Compute frequency for each class and plot histogram row by row for i in range(m): left=xmin+i*delta if i < m-1: right=left+delta else: right=left+delta+1 # either 1 or any positive value freq=determine_frequency(x,left,right) # integer; note that each value must belong to one and only one class print_frequency(freq) # the output must be '****' where each * represents one observation # execute main main() ``` --- To share code, students should submit a *pull request* on GitHub. Test how to perform a pull request with a trivial `run()` function for each team: ```text 1. Fork the course repository `https://github.com/isa-ulisboa/greends-ipython` on your GitHub account 2. In your fork, go to `collaborative_proj/submissions/`. 3. Create a file called `team_name.py` with your own team name, say `team_01.py` 4. Add a `run()` function to the file with contents like `print('team 1')` 5. Commit the file. 6. Click Contribute → Open pull request. 7. Make sure it goes from your fork to the teacher’s original repository `https://github.com/isa-ulisboa/greends-ipython` 8. Click `Create pull request`. ``` If the teacher's original repository changes after you forked it (for instance, the README is updated or other teams' pull requests are merged), your fork does not update automatically. Before working on a new submission, bring your fork up to date: ```text 1. Open your fork on GitHub (https://github.com//greends-ipython). 2. If it says "This branch is X commits behind isa-ulisboa/greends-ipython:main", click Sync fork → Update branch. 3. If you also have a local clone of your fork, run `git pull` in it to get the updates. ``` Alternatively, from a local clone of your fork, using the command line: ```bash git remote add upstream https://github.com/isa-ulisboa/greends-ipython.git # only the first time git fetch upstream git merge upstream/main git push origin main ``` </blockquote> </details> ---

Class 4 (October 2, 2026): handling exceptions in Python: catching and raising exceptions

  1. See CS50P lecture 3, in particular the short on handling exceptions: try, except, ValueError, KeyError.

  2. A few examples of code that can be helpful to solve problems in CS50 Problem set 3.

Example of basic use of `try-except` to catch a `ValueError`: ``` try: x = int(input("What's x?")) except ValueError: print("x is not an integer") else: print(f"x is {x}") ``` Function for requesting an integer from the user until no exceptions are caught: ``` def get_int(): while True: try: x = int(input("What's x?")) except ValueError: print("x is not an integer") else: break return x ``` For a list of Python Built-in Exceptions, besides `ValueError`, you can check https://www.w3schools.com/python/python_ref_exceptions.asp
  1. The fuel gauge problem (https://cs50.harvard.edu/python/2022/psets/3/fuel/)
To solve this problem, try to organize your code as follows. As suggested in *hints*, you should catch `ValueError` and `ZeroDivisionError` exceptions in your code. In the code below, the user is being asked for correct values for `x,y` until they satisfy the requirements: `x,y` must be inputted as a string `x/y`, `x` has to be less or equal to `y`, and `y` cannot be zero. The function `get_string_of_integers_X_less_than_Y` in the code below should take care of that. ``` def main(): # asks user for input until the input is as expected x,y=get_string_of_integers_X_less_than_Y() # compute percentage from two integers p=compute_percentage(x,y) # print output print_gauge(p) ```
  1. Example from https://cs50.harvard.edu/python/2022/shorts/handling_exceptions/
Exercise: adapt the code proposed in the short to be more modular, where the main function is something like the one below: ``` def main(): spacecraft = input("Enter a spacecraft: ") au=get_au(spacecraft) m = convert(au) print(f"{m} m") ```
  1. Other useful applications of try-except
- We may want to exit the execution of our script if some exception is caught. This can be done with `sys.exit()`, which can also be used to print a message. ``` import sys # import module try: x = int(input("What's x?")) except ValueError: sys.exit("x is not an integer") ``` - Example of code that catches `CRTL-C` or `CRTL-D`: ``` while True: try: x=int(input()) except ValueError: print('x is not integer') except KeyboardInterrupt: #CTRL-C (in Linux, interrupt execution) print('\n KeyboardInterrupt') break except EOFError: #CTRL-D (in Linux, log out terminal/end-of-file) print('\n EOFError') break else: print(x) ```
  1. Exercise (Asking for an haphazard list of numbers): Create a program that asks the user to provide haphazardly a series of numbers that you want to store in a list. The user is asked for a number at the time. Only inputs that are numbers are stored in the list. When the user wants to stop, it should type CTRL-D. Then, the program should print the list of numbers.
---

Class 5 (October 9, 2026): modules, packages, APIs

  1. See lecture https://cs50.harvard.edu/python/weeks/4/

  2. Modules and packages

Suggestion: watch <https://cs50.harvard.edu/python/shorts/creating_modules_packages/> Modules are just python scripts (files like `module_name.py`) which can be imported into your main code and that can be shared between multiple applications. You can import everything that belongs to the module, or just some given function(s) or other objects.
Create and import your own module

Exercise: Create file named utils.py and file main.py in http://cs50.dev. Organize the files in the following folders:

|--- class_5 # or whatever folder name you wish
     |--- common # General reusable code used across the project
          |--- __init__.py # optional: marks the folder as a package
          |--- utils.py
     |--- main.py

The contents of utils.py are typically functions or constants that you can re-use in different contexts. Let’s suppose that utils.py has the following contents.

utils.py
import sys

Constants={
    'e'   : 2.718281828459045, # Euler's constant
    'pi'  : 3.141592653589793, # Archimedes' constant
    'phi' : 1.618033988749895  # Golden ratio
}

def get_integer() -> int:
    #get integer from user
    while True:
        try:
            return(int(input('Type a number:  ')))
        except ValueError:
            print('Not an integer number: try again')
        except KeyboardInterrupt: #CTRL-C
            print('\n If you want to exit type CTRL-D')
        except EOFError: # CTRL-D
            sys.exit('\n Exit as requested')

and main.py is the following file:

main.py (1st version)
import common.utils

def main():
    x=common.utils.get_integer()
    print(x)

main()

Run main.py from inside the class_5 folder (cd class_5, then python main.py), so that Python can find the common folder.

If you prefer, you can explicitly import some given functions from the module as in the following example.

main.py (2nd version)
from  common.utils import get_integer

def main():
    x=get_integer()
    print(x)

main()

You can also import everything from the module with from common.utils import * instead of the more specific (and recommended) from common.utils import get_integer.

The examples above follow the directory tree that was suggested. If you change the module’s location, you need to adapt the code accordingly. In alternative, you can add the path to the directories where your modules lies to sys.path as in the following example.

import sys
sys.path.append(r'path-to-folder') # folder that contains utils.py (e.g. `/workspaces/8834091/class_5/common`)
import utils
As explained on the recommended video, a python **package** is just a folder with modules and a special file named `__init__.py`
Pip install

Some modules, like sys, math or random, are part of the Python standard library: they come with Python and can be imported without installing anything. For instance, random provides a series of functions for sampling, shuffling, and extracting random numbers from a variety of probability distributions.

Many other packages are available at the Python Package Index, https://pypi.org/. They need to be installed before they can be imported, which is done in the terminal with pip. For instance, the package requests (used below to access APIs) is installed with

$ pip install requests

and then imported in your script with import requests. If you want to know the folder where a module is located, you can get that information with the attribute __file__, as in the following script. Compare the two folders: the standard library and the installed packages (site-packages) are in different places.

import random
import requests
print(random.__file__)
print(requests.__file__)

Suggestion: write a script to estimate the value of $\pi$ with a Monte Carlo algorithm that makes calls to random.uniform(-1, 1). One possible solution: https://www.geeksforgeeks.org/dsa/estimating-value-pi-using-monte-carlo/

  1. sys.argv
Previously, we used module `sys`, in particular functions `sys.exit()` and `sys.path`. Another useful function is `sys.argv`, that allows you to have access to what the user typed in at the command line `$` as in the following script. ```Python import sys print(len(sys.argv)) # returns the number of words in the command line after $python print(sys.argv[1]) # returns the 2nd word, i.e., the first word after $python myscript.py ``` For instance, the following script named `sum.py` prints the sum of two numbers that were specified in the command line.
sum.py
# sum.py
import sys
try:
    x,y = float(sys.argv[1]), float(sys.argv[2])
    print('the sum is',x+y)
except IndexError:
    print('missing argument')
except ValueError:
    print('The arguments are not numbers')
To run it, you can for instance execute the command `$python sum.py 1.2 4.3` in the terminal.
  1. APIs
Suggestion: watch https://cs50.harvard.edu/python/shorts/api_calls/ (13') *Application program interfaces* allow you to communicate with a remote server. For instance, `requests` is a package that allows your program to behave as a web browser would. Consider the following script `myrequest.py` that allows you to explore the *itunes* database (https://performance-partners.apple.com/search-api):
Example: iTunes
import requests
import sys
try:
    response = requests.get("https://itunes.apple.com/search?entity=song&limit=2&term=" + sys.argv[1])
    print(response.json())
except IndexError:
    sys.exit('Missing argument')
except requests.RequestException:
   sys.exit('Request failed')

You can then call the API from your terminal with $python myrequest.py 'name of my favorite band'. What you see printed on the terminal is a dictionary. How can you edit your script such that the output is just a list of track names (key trackName)? Notice that &limit=2 in the search sets the number of songs that are returned by the API.

Example: GBIF

You can easily adapt that code to access a different database. For instance if you want to explore the GBIF database https://data-blog.gbif.org/post/gbif-api-beginners-guide/, you can just replace the main line of code in myrequest.py with

response=requests.get('https://api.gbif.org/v1/species/match?name='+ sys.argv[1])

and execute it with, say, $python myrequest.py Tracheophyta in the terminal.

Example: open-meteo

Another example of a useful API for weather data is https://open-meteo.com/en/docs#api_documentation. You can find a customized requests package for open-meteo at https://pypi.org/project/openmeteo-requests/.

import openmeteo_requests

openmeteo = openmeteo_requests.Client()

# Make sure all required weather variables are listed here
# The order of variables in hourly or daily is important to assign them correctly below
url = "https://api.open-meteo.com/v1/forecast"
params = {
	"latitude": [38.74,41.15], # Lisbon, Porto
	"longitude": [-9.14,-8.6] ,
	"current": ["temperature_2m", "relative_humidity_2m","wind_speed_10m"],
}
responses = openmeteo.weather_api(url, params=params)

# Process first location. Add a for-loop for multiple locations or weather models
for i in range(len(responses)):
    response = responses[i]
    print(f"Coordinates: {response.Latitude()}°N {response.Longitude()}°E")
    print(f"Elevation: {response.Elevation()} m")

    # Process current data. The order of variables needs to be the same as requested.
    current = response.Current()
    print(f"Current temperature_2m: {current.Variables(0).Value()}")
    print(f"Current relative_humidity_2m: {current.Variables(1).Value()}")
    print(f"Current wind_spreed_10m: {current.Variables(2).Value()}")
    print()
  1. Suggested problem
Solve problem *Bitcoin price index* from CS50P [Problem_set_4](https://cs50.harvard.edu/python/2022/psets/4/). In particular, organize your code so the main function is the following: ``` def main(): x=read_command_line_input() price=get_bitcoin_price() print(f"${x*price:,.4f}") ```
---

Class 6 (October 16, 2026): virtual environments; file I/O with images

In this class we build a small project, image_project, that downloads and processes images of leaves. First we prepare a virtual environment with the packages the project needs (requests and Pillow), then we use it to read and write text and image files. At the end of the class, the project folder should look like this:

image_project/            <- all commands are run from here
|--- .venv/               <- the virtual environment (created by python -m venv .venv; not shared)
|--- .gitignore           <- tells Git to ignore .venv/
|--- requirements.txt     <- list of packages and versions (shared)
|--- images/              <- input images
|--- output/              <- files created by our scripts
|--- notes.py
|--- download_image.py
|--- explore_image.py
|--- process_images.py
    1. Why virtual environments?

    Suppose your script was written and tested with version 12.0.0 of Pillow, but a colleague has an older version installed that behaves differently, or another project of yours needs a different version. Installing every package in the same place (the base environment) quickly leads to conflicts. A virtual environment solves this problem. It is:

    • A folder that contains a Python interpreter and a set of packages, with given versions, for one project. It is isolated from other virtual environments and from the packages installed in the base environment.
    • Conventionally named .venv and placed at the root of the project folder. Tools such as VS Code detect it automatically.
    • Disposable: it should be simple to delete and re-create it from scratch. You never place your scripts or data inside .venv.
    • Not shared (e.g. not checked into Git) and not copied to another computer. Instead, one shares the list of packages (requirements.txt) and each person re-creates the environment.

    Let’s first look at the base environment. The following commands list the installed packages and show information about the package requests (version, location, dependencies):

    $ pip list
    $ pip show requests
    

    The commands below work in the CS50 codespace, which runs Linux. When a command is different in Windows, the Windows version is given in a comment.

    2. Create and activate a virtual environment
    1. Create the project folder and move into it. From now on, all commands are executed from image_project:
      $ mkdir image_project
      $ cd image_project
      
    2. Create the virtual environment. This creates the folder .venv inside image_project:
      image_project/ $ python -m venv .venv
      

      Since the name starts with a dot, .venv is a hidden folder: use ls -a to see it.

    3. Activate the environment:
      image_project/ $ source .venv/bin/activate      # Windows: .venv\Scripts\activate
      

      The prompt now starts with (.venv), which shows that the environment is active: the commands python and pip now refer to the Python and packages in .venv. To de-activate it, the command is deactivate.

    4. Check which Python is running and which packages are installed. A new environment has (almost) no packages:
      (.venv) image_project/ $ python -c "import sys; print(sys.prefix)"
      (.venv) image_project/ $ pip list
      

      sys.prefix is the folder of the Python installation in use: it should be the .venv folder of your project.

    3. Install packages and create requirements.txt
    1. With the environment active, install the packages for the project, specifying the versions:
      (.venv) image_project/ $ pip install pillow==12.0.0 requests==2.32.5
      

      Packages that these depend on (e.g. urllib3 for requests) are installed automatically. Compare the result of pip list and pip show requests with what you got in the base environment: the version and the location are different.

    2. Check where Python looks for packages:
      (.venv) image_project/ $ python -c "import sys; print(sys.path)"
      

      The site-packages folder inside .venv is listed, but the folder where the base environment packages are stored is not.

    3. Save the list of installed packages and versions to requirements.txt, at the root of the project:
      (.venv) image_project/ $ pip freeze > requirements.txt
      

      Open requirements.txt to see its contents: one line per package, such as pillow==12.0.0.

    4. Tell Git to ignore the .venv folder, by creating a file .gitignore with the line .venv/:
      (.venv) image_project/ $ echo ".venv/" > .gitignore
      

      If you share the project (e.g. on GitHub), you share the scripts, requirements.txt and .gitignore, but not .venv.

    4. Delete and re-create the environment

    Since .venv is disposable, we can delete it and re-create it from requirements.txt. This is also what a colleague does after downloading your project.

    (.venv) image_project/ $ deactivate
    image_project/ $ rm -rf .venv                     # Windows (PowerShell): Remove-Item -Recurse -Force .venv
    image_project/ $ python -m venv .venv
    image_project/ $ source .venv/bin/activate        # Windows: .venv\Scripts\activate
    (.venv) image_project/ $ pip install -r requirements.txt
    (.venv) image_project/ $ pip list
    

    Exercise: install one more package in your environment (say, emoji), update requirements.txt, and send the file to a colleague. Your colleague should create a new project folder with a .venv environment from your requirements.txt and check with pip list that both environments have the same packages and versions.

    5. Reading and writing text files

    First, create the folders for the input and output files:

    (.venv) image_project/ $ mkdir images output
    

    In this step we write a script notes.py, in the image_project folder, that writes some notes about the leaf to a text file and then reads them back. Add each piece of code below at the end of notes.py, and run the script after each addition with

    (.venv) image_project/ $ python notes.py
    

    Then check the contents of output/notes.txt (open it in the editor, or use cat output/notes.txt in the terminal).

    open is a function built into Python that allows you to open a file to read from it or to write to it. The second argument is the mode: "w" (write), "a" (append) or "r" (read, the default). In the code below, the file is opened in writing mode: if the file already exists, its previous contents are deleted. This is why running notes.py several times does not accumulate notes: each run starts with an empty file.

    # notes.py
    file = open("output/notes.txt", "w")
    file.write("olive leaf, upper surface dark green\n")
    file.close()
    

    Notice that write does not add a new line: we need to include "\n" at the end of the string. To add new contents at the end of an existing file, open it in mode "a" (append) instead.

    Instead of explicitly opening and closing a file, it’s simpler and safer to use a context manager, with the keyword with, which automatically closes the file at the end of the block, even if an error occurs. Add the following lines to notes.py, which append a second note to the file:

    with open("output/notes.txt", "a") as f:
        f.write("olive leaf, lower surface silvery\n")
    

    To read the file, open it in mode "r". The method readlines returns a list where each element is a line of the file (including the "\n" at the end). Add these lines to notes.py, so the script prints what it wrote:

    with open("output/notes.txt", "r") as f:
        lines = f.readlines()
    print(lines)
    

    It is also possible to read the file one line at a time, which is better for large files. Add this last block to notes.py and compare its output with the previous one:

    with open("output/notes.txt", "r") as f:
        for line in f:
            print(line.rstrip()) # rstrip() removes the "\n" at the end of the line
    
    6. Binary files: downloading and inspecting an image

    Files can be text files (human-readable, like notes.txt or a .py script) or binary files, like images. Binary files are opened with the modes "rb" and "wb", and their contents are bytes instead of str.

    The following script download_image.py downloads a photo of an olive leaf (upper and lower surface) from Wikimedia Commons, using the package requests (see class 5). The image is in response.content, which is a bytes object, so the file is opened in mode "wb". Wikimedia Commons rejects requests without a descriptive User-Agent (the name of the program that makes the request), so we add one in headers.

    # download_image.py
    import requests
    
    url = "https://upload.wikimedia.org/wikipedia/commons/b/b2/Olive-tree-leaf-0.jpg"
    headers = {"User-Agent": "greends-ipython-course/1.0"}
    response = requests.get(url, headers=headers)
    response.raise_for_status() # raises an exception if the download failed
    print(response.headers["Content-Type"], len(response.content), "bytes")
    
    with open("images/olive_leaf.jpg", "wb") as f:
        f.write(response.content)
    

    If the download fails, you can copy the same image from datasets/olive_leaf.jpg in this repository. (Photo: Rnbc at en.wikipedia, CC BY-SA 3.0, via Wikimedia Commons.)

    The first bytes of a file usually identify its format. Let’s read the first 4 bytes of the image:

    with open("images/olive_leaf.jpg", "rb") as f:
        first_bytes = f.read(4)
    print(first_bytes) # b'\xff\xd8\xff\xe0'
    

    All JPEG files start with b'\xff\xd8', and all PNG files start with b'\x89PNG'. Try to open the image in text mode ("r") and see what happens.

    7. Images with Pillow

    Reading the bytes of an image is not enough to work with its pixels: we need to decode the file format. The package Pillow (imported as PIL) does that for many formats. The following script explore_image.py opens the image, prints its properties and saves three new images in output.

    # explore_image.py
    from PIL import Image
    
    with Image.open("images/olive_leaf.jpg") as img:
        print(img.format, img.size, img.mode) # JPEG (1024, 768) RGB
    
        # each pixel of an RGB image is a tuple (red, green, blue) with values between 0 and 255
        print("upper surface:", img.getpixel((450, 220))) # (x, y) = (column, row), from the top-left corner
        print("lower surface:", img.getpixel((500, 580)))
    
        gray = img.convert("L") # "L" means grayscale: one value between 0 (black) and 255 (white) per pixel
        gray.save("output/olive_leaf_gray.png") # the format is given by the extension
    
        thumb = img.copy()
        thumb.thumbnail((256, 256)) # resizes in place, keeping the proportions
        thumb.save("output/olive_leaf_thumb.jpg")
    
        img.rotate(90, expand=True).save("output/olive_leaf_rotated.jpg")
    

    Questions:

    • Which surface of the leaf is brighter? Compare the values of gray.getpixel(...) at the same two points.
    • What is the size of the thumbnail? Why is it not (256, 256)?
    • Check the first bytes of output/olive_leaf_gray.png as in step 6: is it a PNG file?
    8. Exercise: process a folder of images

    Write a script process_images.py that creates a grayscale thumbnail of each image in a folder, and keeps a log of the processed files. The input and output folders are given in the command line (see sys.argv in class 5):

    (.venv) image_project/ $ python process_images.py images output
    

    For each image name.jpg (or .jpeg, .png) in images, the script saves output/name_thumb.png and appends a line name.jpg -> output/name_thumb.png to the file output/log.txt. Other files in the folder are ignored. Add a few more leaf images to images to test it.

    Use the template below. The module os (standard library) provides os.listdir(folder), which returns the list of file names in a folder, os.path.join(folder, name), which builds a path, and os.path.splitext(name), which splits a name such as "olive_leaf.jpg" into ("olive_leaf", ".jpg").

    import os
    import sys
    from PIL import Image
    
    def main():
        input_folder, output_folder = read_command_line()
        for file_name in os.listdir(input_folder):
            if is_image(file_name):
                input_path = os.path.join(input_folder, file_name)
                output_path = make_thumbnail(input_path, output_folder)
                write_log(output_folder, file_name, output_path)
    
    def read_command_line():
        '''returns the input and output folders; exits with a usage message if they are missing'''
        ...
    
    def is_image(file_name):
        '''returns True if the extension of file_name is .jpg, .jpeg or .png (in lower or upper case)'''
        ...
    
    def make_thumbnail(input_path, output_folder, size=(256, 256)):
        '''saves a grayscale thumbnail of the image as a png file in output_folder and returns its path'''
        name = os.path.splitext(os.path.basename(input_path))[0]
        output_path = os.path.join(output_folder, name + "_thumb.png")
        ...
        return output_path
    
    def write_log(output_folder, file_name, output_path):
        '''appends the line "file_name -> output_path" to output_folder/log.txt'''
        ...
    
    main()
    

    Suggested problem: CS50 P-Shirt from CS50P Problem set 6.

---

Class 7 (October 23, 2026): Jupyter notebooks; tabular data with pandas

The goal of this class is not to memorise pandas syntax, which is well documented, but to understand how pandas thinks about data, because that is what makes it a tool for data science. All the code is in the notebook class7/class7_pandas.ipynb:

  • Part 1 (expository): typed columns and vectorisation, boolean masks, the index.
  • Part 2 (guided): groupby, merge, tidy vs wide tables.

The exercises on rural fires in Portugal (INE), which were Part 3 of this class, are now in Class 8.

    Setup: a Jupyter notebook in the CS50 codespace

    A Jupyter notebook (.ipynb) mixes text cells (Markdown) and code cells. Code cells are run one at a time with Shift+Enter, and their results (tables, numbers, plots) appear right below. Variables stay in memory between cells, which makes notebooks convenient to explore data step by step.

    In the terminal of the CS50 codespace:

    1. Create a folder for the class and move into it:
      $ mkdir class7
      $ cd class7
      
    2. Install pandas, and ipykernel, which allows VS Code to run notebooks. Here we install them in the default Python of the codespace, without a virtual environment: the notebook’s kernel picker in cs50.dev does not easily find a .venv created inside a subfolder. If a virtual environment is active (its name appears in the prompt), run deactivate first.
      $ pip install pandas ipykernel
      
    3. Download the notebook and open it:
      $ wget https://raw.githubusercontent.com/isa-ulisboa/greends-ipython/main/class7/class7_pandas.ipynb
      $ code class7_pandas.ipynb
      
    4. Click Select Kernel (top right of the notebook) → Python Environments → choose the default Python 3 (not a .venv). If VS Code asks to install the Jupyter extension, accept.
    5. Run the first cell. If it shows the pandas version, you are ready.

    The datasets are read directly from the course repository, so there is no need to download them.

    If the notebook cannot be set up in the codespace, open it in Google Colab (File → Open notebook → GitHub, then paste the notebook’s URL).

    Part 1 — How pandas thinks

    1.1 From a list of rows to a table of columns. Until now, we stored records as lists of dictionaries, one per observation. A DataFrame stores them as columns, and each column (a Series) has a single data type (df.dtypes). A DataFrame can be thought of as a dictionary of columns that share the same row labels.

    1.2 Vectorisation: describe what, not how. Because a column has one type, an operation can be applied to the whole column at once, instead of looping over its elements:

    # the "Python lists" way
    temp_f = []
    for t in fires['temp']:
        temp_f.append(t * 9 / 5 + 32)
    # the pandas way
    fires['temp_f'] = fires['temp'] * 9 / 5 + 32
    

    The second version is shorter, and also much faster on large tables since the loop runs in compiled code.

    1.3 The forest fires dataset. The dataset datasets/forestfires.csv describes 517 fires in the Montesinho natural park, in Portugal (source). The variables are:

    • X, Y: cell in a 9×9 grid over the park map
    • month, day: month (jan to dec) and weekday (mon to sun); note that there is no year
    • FFMC, DMC, DC, ISI: indices of the Canadian Fire Weather Index (FWI) system
    • temp: temperature (°C); RH: relative humidity (%); wind: wind speed (km/h); rain: rain (mm/m²)
    • area: burned area (ha), from 0 to 1090.84

    The file is read with pd.read_csv and explored with .head(), .shape, .info() and .describe(). Look before you compute: the mean and the median of area are very different, because most fires burned (almost) nothing.

    1.4 Boolean masks: asking questions to the data. fires['temp'] > 25 is a column of True/False (a mask), and fires[mask] keeps the rows where the mask is True. Masks are combined with & (and), | (or), ~ (not), with each condition between parentheses, and .isin() checks membership in a list. Since True counts as 1, mask.sum() counts and mask.mean() gives a proportion.

    • Exercise 1.4: How many fires occurred on weekends? Is it more than expected if fires were spread evenly over the week?

    1.5 The index: labels, not positions. Every row of a DataFrame has a label (the index, shown on the left) and a position (0, 1, 2, …). Columns also have labels (the column names) and positions. There are two ways to select rows and columns:

      .loc[rows, columns] .iloc[rows, columns]
    selects by label position
    slice a:b includes b excludes b, like Python lists
    slice example fires.loc[0:4, 'temp':'wind']
    5 rows (labels 0 to 4), columns temp, RH, wind
    fires.iloc[0:4, 8:10]
    4 rows (positions 0 to 3), columns temp, RH (positions 8, 9)
    typical use fires.loc[fires['month'] == 'aug', ['temp', 'RH']] fires.iloc[:20, 4:8]

    When a CSV file is read, the index is 0, 1, 2, ..., so labels and positions coincide and loc and iloc seem to do the same thing. The difference appears as soon as rows are filtered, because the original labels are kept:

    hot = fires[fires['temp'] > 25]
    hot.iloc[0]      # first row of hot (by position)
    hot.loc[0]       # KeyError: there is no longer a row with label 0
    

    Labels do not need to be integers. set_index turns a column into the index:

    months = pd.DataFrame({'mth': ['jan', 'feb', 'mar'], 'name': ['January', 'February', 'March']})
    months = months.set_index('mth')
    months.loc['feb', 'name']    # 'February'
    

    Why does the index exist? Alignment. When two Series are combined, pandas matches their values by label, not by position. Labels that exist in only one of them give NaN (missing value), instead of a silent mismatch:

    a = pd.Series([1, 2, 3], index=['jan', 'feb', 'mar'])
    b = pd.Series([10, 20, 30], index=['mar', 'jan', 'feb'])
    a + b            # jan 21, feb 32, mar 13
    

    For instance, fires.loc[fires['area'] > 10, 'month'].value_counts() / fires['month'].value_counts() gives the proportion of large fires for each month, although the two counts have months in different orders.

    If needed, labels and positions can be converted into each other: fires.columns.get_indexer(['FFMC', 'DMC']) gives the positions of the columns, to use with iloc; fires.index[0:20] gives the labels of the first 20 rows, to use with loc.

    • Exercise 1.5: Use loc to select the FWI variables for the fires in August and September. Then use iloc to take the first 5 rows of the result. Why does .loc[0:4] not give the same result?
    Part 2 — Split, apply, combine; tidy and wide data

    2.1 groupby. fires.groupby('month')['area'].sum() splits the rows by month, applies sum to each group and combines the results in a new table. This replaces the “loop and accumulate in a dictionary” pattern that we used in Classes 2 and 3. Several summaries can be computed at once with .agg(...):

    by_month = fires.groupby('month').agg(n_fires=('area', 'size'), total_area=('area', 'sum'))
    

    After groupby, the group key becomes the index of the result, sorted alphabetically (apr, aug, dec, …). .reindex(labels) builds a new Series (or DataFrame) with exactly the given labels, in the given order: existing labels keep their values, new labels get NaN, and labels not in the list are dropped. The table months of section 1.5 (with all 12 months, in the notebook) has the months in calendar order as its index, so months.index is exactly the list of labels needed:

    by_month = by_month.reindex(months.index)   # rows from jan to dec
    

    .reset_index() turns the index back into an ordinary column (useful before exporting or merging).

    • Exercise 2.1: Group by day instead. On which weekday is the median burned area largest? And the number of fires?

    2.2 Joining tables: merge. merge combines two tables through a common key, like a database join. In the notebook, a table months with the full name and the season of each month is merged with fires, to compare the burned area by season.

    2.3 Tidy vs wide data. A table is tidy when (1) each variable is a column, (2) each observation is a row, and (3) each type of observational unit is a table (Wickham, 2014). The forest fires table is tidy: one row per fire, one column per variable.

    Two methods convert between the two shapes, and they are (roughly) inverse operations: pivot_table goes from tidy to wide (the values of one column become the row labels, index, the values of another column become the column names, columns, and each cell summarises a third column, values, with a function, aggfunc), and melt goes from wide to tidy (the column names become the values of a new column). “Roughly” because pivot_table aggregates: after melt we get back the totals per month and weekday, not the individual fires.

    A wide table spreads a variable across several columns. For instance, the total burned area by month and weekday (fill_value=0 fills the combinations without any fire):

    wide = fires.pivot_table(index='month', columns='day', values='area', aggfunc='sum', fill_value=0)
    

    Here, the weekday is a variable, but each weekday became a column. Wide tables are good for people (easy to read and compare, common in reports and in official statistics). Tidy tables are good for computing: filtering, groupby, merge and plotting all expect one variable per column. melt converts a wide table into a tidy (long) one:

    long = wide.reset_index().melt(id_vars='month', var_name='day', value_name='area')
    

    Rule of thumb: keep data tidy while working, and make it wide at the end, for a report.

    Reference: exporting to file

    Exporting is done with methods named .to_..., and reading with functions named pd.read_..., as listed in https://pandas.pydata.org/docs/user_guide/io.html. For instance:

    1. Export a dataframe as a CSV file with df.to_csv('filename.csv', index=False). Use index=False unless the index contains information (for instance, after groupby); otherwise, the labels 0, 1, 2, ... are written as an extra column.
    2. Export as an Excel spreadsheet with df.to_excel('filename.xlsx', sheet_name='fires', index=False) (requires pip install openpyxl).
    3. Read an Excel spreadsheet with pd.read_excel('filename.xlsx', sheet_name='fires').
---

Class 8 (November 6, 2026): pandas, continued: select, sort, group, create

This class continues Class 7, and focuses on the four operations that are needed in assignment #3:

question pandas
Which rows? masks: comparisons, & \| ~, .between, .isin, string methods .str...
In which order? sort_values (one or more columns), nlargest
What is the summary for each group? groupby + agg
How do I create a new variable? arithmetic on columns; apply with a function or a lambda

All the code is in the notebook class8/class8_pandas.ipynb:

  • Part 1 (guided): the four operations, on the forest fires dataset of Class 7.
  • Part 2 (on your own): cleaning and analysing official statistics on rural fires in Portugal (INE). This was Part 3 of Class 7: if you have already done some of the exercises, continue from where you stopped.

The notebook is set up as in Class 7 (see Setup in Class 7), in a folder class8:

$ mkdir class8
$ cd class8
$ wget https://raw.githubusercontent.com/isa-ulisboa/greends-ipython/main/class8/class8_pandas.ipynb
$ code class8_pandas.ipynb
    Part 1 — Select, sort, group, create

    1.1 Selecting rows. A condition on a column gives a mask (a column of True/False), and fires[mask] keeps the rows where the mask is True. Conditions are combined with & (and), | (or), ~ (not), and each condition needs its own parentheses. The Python words and and or do not work on whole columns (ValueError: The truth value of a Series is ambiguous).

    fires[(fires['temp'] > 25) & (fires['RH'] < 30)]
    fires[fires['temp'].between(20, 22)]                  # 20 <= temp <= 22
    fires[fires['month'].isin(['jun', 'jul', 'aug', 'sep'])]
    fires[~fires['month'].isin(['jun', 'jul', 'aug', 'sep'])]
    

    The methods of Python strings are applied to a whole text column through .str, for instance .str.upper(), .str.startswith('j'), .str.contains('Vila'), .str.replace('&', ''), .str.len() or .str[:2] (first two characters). fires.loc[mask, ['month', 'area']] selects rows and columns at the same time, and mask.sum() counts the rows.

    • Exercise 1.1: How many fires occurred in summer with wind above 5 km/h and no rain?

    1.2 Sorting. sort_values returns a new, sorted table. With a list of columns, rows are sorted by the first column, then by the second one for rows with the same value in the first column, and so on; ascending can be a list too:

    fires.sort_values('area', ascending=False)
    fires.sort_values(['month', 'area'], ascending=[True, False])   # within each month, largest fires first
    fires.nlargest(5, 'area')
    
    • Exercise 1.2: Sort the fires by day and then by temp (highest first), and show the 3 hottest fires of each weekday with .groupby('day').head(3).

    1.3 Group and aggregate. The pattern is table.groupby(KEY)[COLUMN].FUNCTION(). .agg computes several summaries at once, and named aggregation (new_name=(column, function)) chooses the names of the result. Operations can be chained: select, then group, then sort.

    fires.groupby('month')['area'].agg(['size', 'sum', 'mean', 'max'])
    fires.groupby('month').agg(n_fires=('area', 'size'), mean_temp=('temp', 'mean'), total_area=('area', 'sum'))
    fires[fires['area'] > 0].groupby('month')['area'].median().sort_values(ascending=False)
    fires.groupby(['month', 'day']).size().reset_index(name='n_fires')            # two keys
    
    • Exercise 1.3: For each weekday, compute the number of fires, the mean temperature and the total burned area, sorted by total burned area.

    1.4 New variables: apply and lambda. When the new variable is a formula, use arithmetic on columns (fires['area'] / 100). When the rule needs an if/else, or a function of your own, .apply(f) calls the function f on each value of the column. A lambda is a function without a name, written in one line: lambda a: expression is the same as def f(a): return expression. With axis=1, the function is applied to each row, and can use several columns.

    def size_class(a):
        if a == 0:
            return 'none'
        elif a < 10:
            return 'small'
        else:
            return 'large'
    
    fires['size_class'] = fires['area'].apply(size_class)                          # a function, without ()
    fires['burned'] = fires['area'].apply(lambda a: 'yes' if a > 0 else 'no')      # a lambda
    fires['season'] = fires['month'].apply(lambda m: season[m])                    # season is a dictionary
    fires['hot_dry'] = fires.apply(lambda row: row['temp'] > 25 and row['RH'] < 30, axis=1)   # each row
    fires.groupby('size_class')['temp'].mean()                                     # group by the new variable
    

    Rule of thumb: use arithmetic and masks when possible (they are much faster), and apply when you need a function of your own.

    • Exercise 1.4: Create a column wind_class (calm, moderate, strong) with apply, and compute the mean burned area for each class. Create a column weekend with apply and a lambda, and compare the proportion of fires that burned something on weekends and on weekdays.
    Part 2 — On your own: rural fires in Portugal (INE)

    The file datasets/rural_fires.csv was exported from INE, the Portuguese national statistics institute, and contains the number of rural fires in 2023 by geographic unit and by cause of fire. The geographic units follow the NUTS classification (version 2024), with hierarchical codes:

    code level examples
    PT country Portugal
    1 character NUTS I 1 Continente, 2 Açores, 3 Madeira
    2 characters NUTS II 11 Norte, 19 Centro, 1A Grande Lisboa, 1B Península de Setúbal, 1C Alentejo, 1D Oeste e Vale do Tejo, 15 Algarve, 20, 30
    3 characters NUTS III 111 Alto Minho, 112 Cávado, …
    7 characters municipality 1111601 Arcos de Valdevez

    The code of a municipality starts with the codes of its NUTS II (2 characters) and NUTS III (3 characters) regions. As with most official statistics, the file was prepared for people and not for computing:

    • the first 11 lines contain titles, and there are notes at the end of the file;
    • the separator is ;;
    • each number is followed by ` &, so pandas reads numbers as text (dtype is object, or str` in pandas 3);
    • the first column combines a code and a name (1111601: Arcos de Valdevez);
    • the rows mix different units (country, NUTS I, II, III regions, and municipalities);
    • the table is wide: the cause of the fire is spread over six columns.

    The notebook first shows the raw text of the file, and then reads only the data rows into a dataframe rf with pd.read_csv(..., sep=';', skiprows=11, nrows=347, header=None, usecols=range(8), names=[...]). The exercises follow a typical workflow: clean, then select, sort, create and group (Part 1), and finally report.

    Cleaning

    • 2.1 Convert the numeric columns to integers, either with string methods on whole columns (.str.replace, .str.strip, .astype(int)), or with apply and a lambda that converts one value.
    • 2.2 Split the first column into code and name with .str.split(': ', n=1, expand=True).

    Selecting rows

    • 2.3 Keep the municipalities (codes with 7 characters) in a dataframe mun, and add columns nuts2 and nuts3 with the first characters of the code.
    • 2.4 With masks: the number of fires in a list of cities (.isin), in the municipalities whose name starts with Vila, the municipalities without fires, and the municipalities of the Alentejo with more than 20 fires.

    Sorting

    • 2.5 The 10 municipalities with the most fires, and the 3 municipalities with the most fires in each NUTS II region (sort by two columns).

    Validating and creating new variables

    • 2.6 Validate the data: does the sum over municipalities equal the total for Portugal? Is the total of each municipality equal to the sum of the six causes?
    • 2.7 The percentage of fires due to negligence in each municipality.
    • 2.8 With apply: a class of municipality by number of fires (none, few, some, many), and the main cause of fire in each municipality (apply on rows with axis=1).

    Grouping

    • 2.9 For each NUTS II region: number of municipalities, total, mean and maximum number of fires; the region names with merge; and the number of municipalities by region and main cause (two keys).

    Challenge

    • 2.10 Convert the table into a tidy table with columns code, name, nuts2, cause, n_fires with .melt().
    • 2.11 Compute the percentage of each cause in each region, and present the result as a wide table (regions × causes).
    • 2.12 Export the tidy table with .to_csv('rural_fires_2023_tidy.csv', index=False) (see Reference: exporting to file in Class 7).
---

Class 9 (November 13, 2026): objects and classes; attributes and methods

  • CS50P, Lecture 8: Object-Oriented Programming. For this class, the first part of the lecture (classes, attributes, methods, raise, __str__) is enough.
  • CS50P. In particular, check the shorts on classes, methods and variables, in this order: Classes, Instance Variables, Instance Methods, and Class Methods and Class Variables.

The goal of this class is to understand what an object is, and to write your first classes. As an example, we model a plant nursery: plants grow in trays, all plants of a tray are of the same species, and plants can be added to or removed from a tray. We want to keep track of the trays and of the number of plants of each species.

  • Part 1 (expository): you have been using objects for weeks.
  • Part 2 (guided): from a dictionary to a class: __init__, self, attributes.
  • Part 3 (guided): methods with rules, __str__, objects that contain objects.
  • Part 4 (on your own): a nursery inventory.

Create a folder class9 in the CS50 codespace and write the code of Parts 2 and 3 in a script, for instance nursery.py, and run it with python nursery.py. Short tests can also be done in the Python REPL (type python in the terminal).

    Part 1 — You have been using objects

    1.1 Data and actions. Every value in Python is an object, and every object belongs to a class (its type). An object bundles data (its attributes) and actions on that data (its methods):

    s = "Quercus suber"
    type(s)          # <class 'str'>: s is an object (an instance) of class str
    s.upper()        # method: an action, with parentheses
    s.split()        # another method of class str
    
    values = [12, 3, 7]
    type(values)     # <class 'list'>
    values.append(5) # method: changes the list
    values.sort()
    

    In Class 7 we used both kinds with pandas: fires.shape and fires.columns are attributes (data, no parentheses), while fires.head() and fires.groupby('month') are methods (actions, with parentheses). The same happened with Pillow in Class 6 (img.size and img.rotate(90)) and with requests in Class 5 (response.status_code and response.json()).

    The function dir(s) lists all attributes and methods of an object.

    • Exercise 1.1: Create two strings a = "olive" and b = "cork oak". Do a.upper() and b.upper() run the same code? Do they return the same result? Why?

    The answer to exercise 1.1 is the main idea of this class: all objects of a class share the same methods (the class is the blueprint), but each object has its own data. Today we write our own classes.

    Part 2 — From a dictionary to a class

    2.1 A tray as a dictionary. With what we know from Classes 2 and 3, a tray could be a dictionary, and actions on trays could be functions that receive the dictionary as their first argument:

    def new_tray(species, n_plants):
        return {"species": species, "n_plants": n_plants}
    
    def add_plants(tray, n):
        tray["n_plants"] += n
    
    tray1 = new_tray("Pansy", 26)
    add_plants(tray1, 10)
    print(tray1["n_plants"])
    

    This works, but the data and the functions are separate: nothing says that add_plants is meant for trays, and any dictionary (or anything else) can be passed to it.

    2.2 The same tray as a class. A class puts the data and the functions together:

    class Tray:
        def __init__(self, species, n_plants):
            self.species = species
            self.n_plants = n_plants
    
        def add_plants(self, n):
            self.n_plants += n
    
    tray1 = Tray("Pansy", 26)
    tray1.add_plants(10)
    print(tray1.n_plants)
    

    Compare the two versions line by line:

    dictionary + functions class
    def new_tray(species, n_plants): def __init__(self, species, n_plants):
    tray1 = new_tray("Pansy", 26) tray1 = Tray("Pansy", 26)
    tray1["n_plants"] tray1.n_plants (an attribute)
    def add_plants(tray, n): def add_plants(self, n): (a method)
    add_plants(tray1, 10) tray1.add_plants(10)
    • Tray("Pansy", 26) creates a new, empty object of class Tray, calls __init__ to fill in its attributes, and returns the object. The object is called an instance of the class.
    • self is the object that the method works on, exactly like the argument tray in the dictionary version. When you write tray1.add_plants(10), Python runs Tray.add_plants(tray1, 10): try both and check that they do the same thing.
    • By convention, class names use CamelCase (Tray, PlantNursery), while variables, functions and methods use snake_case (n_plants, add_plants).

    2.3 Each instance has its own data.

    tray1 = Tray("Pansy", 26)
    tray2 = Tray("Petunia", 28)
    tray2.add_plants(5)
    print(tray1.n_plants, tray2.n_plants)
    
    • Exercise 2.3: Before running the code, predict what it prints. Then change __init__ so that n_plants is optional, with default value 0 (as for any function), and create a tray with Tray("Begonia").

    2.4 The most common errors. Each line marked # bug below is wrong. Run the code, read the error message, and fix it:

    class Tray:
        def __init__(self, species, n_plants):
            self.species = species
            self.n_plants = n_plants
    
        def add_plants(n):            # bug 1
            self.n_plants += n
    
        def remove_plants(self, n):
            n_plants -= n             # bug 2
    
    tray1 = Tray("Pansy", 26)
    tray1.add_plants(10)
    tray1.remove_plants(5)
    tray2 = Tray                      # bug 3
    tray2.remove_plants(5)
    
    Part 3 — Methods with rules, __str__, objects that contain objects

    3.1 Methods keep the object valid. A tray has room for at most 50 plants, and one cannot remove more plants than there are in the tray. If all changes to a tray go through its methods, the methods can enforce these rules, and raise an exception (Class 4) when a rule is broken:

    class Tray:
        MAX_PLANTS = 50   # class attribute: shared by all trays
    
        def __init__(self, species, n_plants=0):
            if n_plants < 0 or n_plants > Tray.MAX_PLANTS:
                raise ValueError(f"n_plants must be between 0 and {Tray.MAX_PLANTS}")
            self.species = species
            self.n_plants = n_plants
    
        def add_plants(self, n):
            if self.n_plants + n > Tray.MAX_PLANTS:
                raise ValueError(f"there is only room for {Tray.MAX_PLANTS - self.n_plants} more plants")
            self.n_plants += n
    
        def remove_plants(self, n):
            if n > self.n_plants:
                raise ValueError(f"the tray only has {self.n_plants} plants")
            self.n_plants -= n
    try:
        tray = Tray('Pansy', n_plants=60)
    except ValueError as e:
        print(e)
    else:
        print(tray.n_plants)   # runs only if the tray was created
    
    

    MAX_PLANTS is a class attribute: it is defined once, in the class, and is the same for all trays (Tray.MAX_PLANTS). self.species and self.n_plants are instance attributes: each tray has its own values.

    • Exercise 3.1: Create a tray with 40 plants and try to add 20 plants. Then catch the error with try/except ValueError and print the message of the exception.

    Note that Python does not prevent tray1.n_plants = -5: methods only protect the object if we use them. We will see in Class 10 how to signal that an attribute should not be changed directly (encapsulation).

    3.2 Printing an object: __str__. Try print(tray1): the result looks like <__main__.Tray object at 0x7f...>, which is not very informative. When an object is printed, or converted with str(), or used in an f-string, Python calls its method __str__, which should return a string. Add this method to class Tray:

        def __str__(self):
            return f"{self.n_plants} {self.species} plants"
    

    Methods whose names start and end with double underscores, like __init__ and __str__, are called special methods: we do not call them directly, Python calls them for us (Tray(...) calls __init__, print(...) calls __str__).

    • Exercise 3.2: Change __str__ so that it also shows how many plants can still be added to the tray.

    3.3 Objects that contain objects. The nursery is also an object: it has a name and contains trays. Its attribute trays is a dictionary whose values are Tray objects, with the tray names as keys:

    class Inventory:
        def __init__(self, name):
            self.name = name
            self.trays = {}   # tray name -> Tray object
    
        def add_tray(self, tray_name, tray):
            self.trays[tray_name] = tray
    
        def get_tray(self, tray_name):
            return self.trays[tray_name]
    
        def __str__(self):
            return f"{self.name}: {len(self.trays)} trays"
    
    nursery = Inventory("ISA nursery")
    nursery.add_tray("A1", Tray("Pansy", 25))
    nursery.add_tray("A2", Tray("Petunia", 10))
    nursery.get_tray("A2").add_plants(10)
    print(nursery)
    print(nursery.get_tray("A2"))
    

    get_tray returns the tray object itself, not a copy, so nursery.get_tray("A2").add_plants(10) changes the tray that is in the inventory.

    3.4 Puzzle: a shared dictionary. In this version, the trays can be given when the inventory is created, with an empty dictionary as the default value:

    class Inventory:
        def __init__(self, name, trays={}):
            self.name = name
            self.trays = trays
    
        def add_tray(self, tray_name, tray):
            self.trays[tray_name] = tray
    
    north = Inventory("North")
    south = Inventory("South")
    north.add_tray("A1", Tray("Pansy", 25))
    print(len(south.trays))
    
    • Exercise 3.4: Predict what is printed, and run the code. Why?

    The default value {} is created only once, when def is executed, and not each time the function is called: all inventories created without the argument trays share the same dictionary. Rule: never use a list or a dictionary as a default value. Create it inside __init__ (as in 3.3), or use trays=None and then if trays is None: trays = {}.

    3.5 Looking ahead. A class can be built from another class: for instance, class MultipleUseTray(Tray): would inherit all attributes and methods of Tray, and only add or change what is different. This is called inheritance, and it is one of the main concepts of OOP that we will see in Class 10.

    Part 4 — On your own: a nursery inventory

    Start from class Tray (3.1, with __str__ from 3.2) and class Inventory (3.3) in the script nursery.py. Add the following methods to class Inventory:

    • 4.1 remove_tray(tray_name): removes a tray, and raises ValueError if there is no tray with that name.
    • 4.2 list_all_trays(): prints one line per tray, for instance A1: 25 Pansy plants.
    • 4.3 n_plants(): returns the total number of plants in the nursery.
    • 4.4 plants_by_species(): returns a dictionary with the number of plants of each species, for instance {"Pansy": 57, "Petunia": 10}.
    • 4.5 empty_trays(): returns a list with the names of the empty trays.
    • 4.6 total_value(prices): returns the value of all plants in the nursery, where prices is a dictionary with the price of each species.


    Some more challenging exercises:

    • 4.7 sell(species, n): removes n plants of the given species, taking them from the trays in order (first empty the first tray of that species, then the next one, and so on). If there are not enough plants, raise ValueError without changing any tray.
    • 4.8 load_csv(path): adds trays read from a CSV file with columns tray,species,n_plants (create a small file yourself; see Class 6 for reading files).
    • 4.9 to_dataframe(): returns a pandas DataFrame with one row per tray and columns tray, species, n_plants (see Class 7). Use it to sort the trays by number of plants.