Lecture 06: Making Your Programs Remember - Working with Files and Structuring Data

Lecture 06: Making Your Programs Remember - Working with Files and Structuring Data

Hello, and welcome to Lecture 06! In our previous lectures, we’ve built command-line applications like the To-Do List. A common question might be: “What happens to your tasks when you close the To-Do List program?” Currently, they disappear! This is because the data was stored in the computer’s temporary memory (RAM). In this lecture, we learn how to make our programs remember information by saving data to files and loading it back. This is called data persistence.

Recap: Organizing Data in Memory

Previously, we’ve used:

  • Variables: To store single pieces of information (e.g., name = "Priya").
  • Lists: To store ordered collections (e.g., tasks = ["Buy milk", "Study Python"]).
  • Dictionaries: To store key-value pairs (e.g., student = {"name": "Ananya", "id": "S101"}).

These structures store data in RAM, which is temporary. Files provide permanent storage.

What is File I/O (Input/Output)?

File I/O refers to how programs interact with files on a computer’s disk.

  • Files: Named locations on disk (hard drive, SSD) for persistent data storage.
  • Input (I): Reading data from a file into your program.
  • Output (O): Writing data from your program to a file.

File I/O allows programs to save state, load previous data, and more.

Working with Text Files in Python

Python provides robust ways to handle files.

Introducing pathlib for Modern Path Management

Before diving into opening files, let’s introduce a modern Python library for handling file paths: pathlib. While you’ll often see file operations done with simple string paths (e.g., "my_folder/my_file.txt"), pathlib offers a more object-oriented and often more readable way to work with paths, especially for complex manipulations or ensuring cross-platform compatibility.

First, you import the Path object:

from pathlib import Path

Then, you can create Path objects:

# Create a path to a file in the current directory
my_file_path = Path("my_notes.txt")

# Create a path to a folder
data_folder = Path("data_files")

# Join paths using the / operator (this is a key feature!)
tasks_file_path = data_folder / "tasks.json"
# On Windows, this might become data_files\tasks.json
# On Linux/macOS, it becomes data_files/tasks.json
# pathlib handles these differences for you!

print(f"Path to file: {my_file_path}")
print(f"Path to data folder: {data_folder}")
print(f"Path to tasks file: {tasks_file_path}")

Benefits of pathlib:

  • Readability: Path manipulations are clearer.
  • Object-Oriented: Paths are objects with useful methods and properties.
  • OS Agnostic: The / operator for joining paths works correctly across Windows, macOS, and Linux.
  • Useful Methods: Easily check if a path exists (.exists()), if it’s a file (.is_file()), get its parent directory (.parent), etc.

We’ll be using pathlib.Path objects in many of our file operation examples.

The open() Function and Path.open() Method

To work with a file’s content, you first need to open it.
The traditional way is Python’s built-in open() function:
file_object = open("filename_as_string.txt", "mode")

When using pathlib, Path objects have their own .open() method:
file_object = path_object.open(mode="mode")

Both achieve the same goal: they return a file object (also called a file handle) that your program uses to interact with the actual file on disk.

The arguments are:

  • filename_as_string or path_object: Specifies the file.
  • mode: A string indicating how you want to use the file.

File Modes

The mode is crucial:

  • 'r' (Read Mode - Default): Opens for reading. Raises FileNotFoundError if the file doesn’t exist.
  • 'w' (Write Mode): Opens for writing. Overwrites the file if it exists! Creates a new file if it doesn’t.
  • 'a' (Append Mode): Opens for appending. New data is added to the end. Creates the file if it doesn’t exist.
  • '+' (e.g., 'r+'): Allows reading and writing. 'r+' requires the file to exist.
  • 'b' (e.g., 'rb', 'wb'): Binary mode for non-text files (images, audio).

The with ... .open(...) as file_variable: Statement (Best Practice!)

It’s vital to close files after use to save changes and free resources. Manually calling file_object.close() can be error-prone if exceptions occur. The with statement handles this automatically:

from pathlib import Path

notes_path = Path("my_diary.txt")

# Using pathlib's .open() method with the 'with' statement
with notes_path.open(mode="w") as diary_file:
    diary_file.write("Today I learned about pathlib!\n")
    diary_file.write("It makes path handling much nicer.\n")
# diary_file is automatically closed here, even if errors happened inside the 'with' block.

# You'll also see Python code that uses open() directly with string paths:
# with open("traditional_notes.txt", "w") as traditional_file:
#    traditional_file.write("This also works.\n")
# This is the traditional way and still works perfectly fine. However, pathlib
# is often preferred in modern Python for its clarity and power, especially when
# you need to do more than just open a file (like checking if it exists,
# getting its parent directory, etc.).

Always use the with ... .open(...) as ...: syntax for file operations.

Reading from Files

Let’s assume greet.txt contains:

Hello Priya!
Welcome to Python.
  1. Path.read_text(encoding='utf-8'): Reads Entire Text File (with pathlib)

    • A convenient pathlib method to read the entire file into a single string.
    • encoding='utf-8' is good practice for text files to handle a wide range of characters.
    from pathlib import Path
    
    greet_path = Path("greet.txt") # Assume greet.txt exists with "Hello Priya!\nWelcome to Python."
    # To make this runnable, let's create greet.txt first for the example
    # greet_path.write_text("Hello Priya!\nWelcome to Python.\n", encoding="utf-8")
    try:
        if greet_path.exists(): # Check if file exists using pathlib
            full_content = greet_path.read_text(encoding="utf-8")
            print("--- Reading with path.read_text() ---")
            print(full_content)
        else:
            print(f"Error: {greet_path} was not found!")
    except Exception as e:
        print(f"An error occurred: {e}")
    
  2. Looping Directly Over the File Object (Line-by-Line):

    • Most memory-efficient for large files.
    from pathlib import Path
    
    greet_path = Path("greet.txt") # Assume greet.txt exists
    # To make this runnable, let's ensure greet.txt exists for the example
    # greet_path.write_text("Hello Priya!\nWelcome to Python.\n", encoding="utf-8")
    try:
        with greet_path.open(mode="r", encoding="utf-8") as my_file:
            print("\n--- Looping directly (line by line) ---")
            for current_line in my_file:
                print(f"Line: {current_line.strip()}") # .strip() removes \n
    except FileNotFoundError:
        print(f"Error: {greet_path} not found!")
    

    .strip(): Removes leading/trailing whitespace, especially the newline (\n) character from lines read.

  3. Other file_object methods (can be used with path_object.open()):

    • my_file.read(): Reads entire file (if opened with path_object.open()).
    • my_file.readline(): Reads one line.
    • my_file.readlines(): Reads all lines into a list.

Writing to Files

  1. Path.write_text(text_content, encoding='utf-8'): Writes Entire String (with pathlib)

    • Convenient for writing a whole string (which might contain newlines) to a file.
    • Overwrites the file if it exists, or creates it.
    from pathlib import Path
    
    output_path = Path("my_story.txt")
    story_content = "Once upon a time, in a land of code...\nPython scripts danced freely.\n"
    try:
        output_path.write_text(story_content, encoding="utf-8")
        print(f"Story written to {output_path}")
    except Exception as e:
        print(f"Error writing story: {e}")
    
  2. file_object.write(string_data) (used with path_object.open()):

    • Requires manual \n for new lines. Numbers must be str().
    from pathlib import Path
    
    notes_path = Path("more_notes.txt")
    with notes_path.open(mode="w", encoding="utf-8") as notes_file:
        notes_file.write("Rohan's note.\n")
        notes_file.write(f"Items to buy: {str(5)}\n")
    print(f"More notes written to {notes_path}")
    

Structuring Data in Files (To-Do List Example)

Our To-Do list (tasks = ['Buy groceries', 'Call Priya']) needs to be saved and loaded.

Method 1: Simple Text File - One Task Per Line

Saving:

from pathlib import Path

# tasks_in_memory = ["Buy groceries", "Call Priya"]
# file_path = Path("mytasks_simple.txt")
#
# # Join all tasks with a newline character in between
# text_to_save = "\n".join(tasks_in_memory)
# if tasks_in_memory: # Add a final newline if there are tasks to ensure last line ends with \n
#    text_to_save += "\n"
#
# try:
#     file_path.write_text(text_to_save, encoding="utf-8")
#     print(f"Tasks saved to {file_path}")
# except Exception as e:
#     print(f"Error saving tasks: {e}")

Loading:

from pathlib import Path

# loaded_tasks_list = []
# file_path = Path("mytasks_simple.txt")
#
# try:
#     if file_path.exists():
#         text_content = file_path.read_text(encoding="utf-8")
#         # .strip() removes any leading/trailing blank lines from the whole content
#         # .splitlines() splits the string into a list at newline characters
#         # and handles the last line not having a newline correctly.
#         loaded_tasks_list = text_content.strip().splitlines()
#     else:
#         print(f"Task file '{file_path}' not found. Starting empty.")
# except Exception as e:
#     print(f"Error loading tasks: {e}")
#
# print(f"Loaded tasks: {loaded_tasks_list}")
  • Pros: Simple, human-readable.
  • Cons: Difficult for complex data (e.g., tasks with due dates, status). \n in task descriptions would break it.

Method 2: Using JSON for Structured and Robust Storage

JSON (JavaScript Object Notation) is a text format for storing structured data, mapping well to Python lists and dictionaries.

Python’s json Module:
import json

  • Saving with json.dump(python_object, file_object, indent=optional):
    Writes a Python list/dict to a file in JSON. indent=4 for readability.
    # import json
    # from pathlib import Path
    # tasks_to_save = ["Buy milk", "Learn JSON"]
    # json_file_path = Path("mytasks.json")
    #
    # with json_file_path.open(mode="w", encoding="utf-8") as f_out:
    #     json.dump(tasks_to_save, f_out, indent=4)
    
  • Loading with python_object = json.load(file_object):
    Reads JSON from a file into a Python list/dict.
    # import json
    # from pathlib import Path
    # json_file_path = Path("mytasks.json")
    # loaded_tasks = []
    # if json_file_path.exists():
    #     with json_file_path.open(mode="r", encoding="utf-8") as f_in:
    #         loaded_tasks = json.load(f_in)
    

Advantages: Handles complex data and special characters well. More robust.

Project: Enhancing the To-Do List App with File Persistence (using JSON and pathlib)

We will now enhance our To-Do list application to save tasks to a .json file using pathlib.

Planning the Persistence Feature

  • Filename: todolist_data.json (managed by a Path object).
  • Functions:
    • load_tasks_from_file(filepath: Path)
    • save_tasks_to_file(tasks_list: list, filepath: Path)
  • Integration: Load on start, save on any change (add/remove).

Building the load_tasks_from_file Function

import json
from pathlib import Path

def load_tasks_from_file(filepath: Path):
    """Loads tasks from a JSON file using pathlib.Path.
    Returns an empty list if the file doesn't exist or is invalid.
    """
    try:
        if filepath.exists() and filepath.is_file():
            with filepath.open(mode='r', encoding='utf-8') as f:
                tasks = json.load(f)
                if not isinstance(tasks, list): # Validate structure
                    print(f"Warning: Data in {filepath} is not a list. Starting fresh.")
                    return []
                return tasks
        else:
            # No error message needed if file simply doesn't exist (first run)
            return []
    except json.JSONDecodeError:
        print(f"Error: Could not decode JSON from '{filepath}'. File might be corrupted.")
        return []
    except Exception as e:
        print(f"An unexpected error occurred loading tasks: {e}")
        return []

Building the save_tasks_to_file Function

# import json # Already imported
# from pathlib import Path # Already imported

def save_tasks_to_file(tasks_list: list, filepath: Path):
    """Saves the current list of tasks to a JSON file using pathlib.Path."""
    try:
        # Ensure parent directory exists (optional, good for complex paths)
        # filepath.parent.mkdir(parents=True, exist_ok=True)
        with filepath.open(mode='w', encoding='utf-8') as f:
            json.dump(tasks_list, f, indent=4)
    except Exception as e:
        print(f"An error occurred while saving tasks to '{filepath}': {e}")

Modifying the Main Application Logic

The main run_todo_app function from Lecture 04 needs to be adapted.
(The following shows conceptual changes integrated into a full application structure below).

Complete Code: todo_list_app_persistent.py

Here is the complete code for the enhanced To-Do List application, now using pathlib and JSON for persistence.

# examples/todo_list/todo_list_app_persistent.py
import json
from pathlib import Path

TASKS_FILE_PATH = Path("todolist_data.json") # Using pathlib

# --- File Operations ---
def load_tasks_from_file(filepath: Path):
    """Loads tasks from a JSON file using pathlib.Path."""
    try:
        if filepath.exists() and filepath.is_file():
            with filepath.open(mode='r', encoding='utf-8') as f:
                tasks_data = json.load(f)
                if not isinstance(tasks_data, list):
                    print(f"Warning: Data in {filepath} is not a list. Starting fresh.")
                    return []
                print(f"Tasks loaded from {filepath}")
                return tasks_data
        else:
            return [] # No file found, start with empty list
    except json.JSONDecodeError:
        print(f"Error: Corrupted data in {filepath}. Starting fresh.")
        return []
    except Exception as e:
        print(f"An unexpected error occurred loading tasks: {e}")
        return []

def save_tasks_to_file(tasks_list: list, filepath: Path):
    """Saves the current list of tasks to a JSON file using pathlib.Path."""
    try:
        with filepath.open(mode='w', encoding='utf-8') as f:
            json.dump(tasks_list, f, indent=4)
        # print(f"Tasks saved to {filepath}") # Optional confirmation
    except Exception as e:
        print(f"An error occurred while saving tasks: {e}")

# --- Core To-Do Functions ---
def display_menu():
    print("\n===== Persistent To-Do List Menu =====")
    print("1. Add a new task")
    print("2. View all tasks")
    print("3. Remove a task (by number)")
    print("4. Quit application")
    print("====================================")

def add_task_action(tasks_list: list):
    task_description = input("Enter the description of the new task: ")
    cleaned_description = task_description.strip()
    if cleaned_description:
        tasks_list.append(cleaned_description)
        print(f"Task '{cleaned_description}' added.")
        return True # Indicate success for saving
    else:
        print("Task description cannot be empty.")
        return False

def view_tasks_action(tasks_list: list):
    print("\n--- Your Tasks ---")
    if not tasks_list:
        print("Your to-do list is currently empty.")
    else:
        for index, task in enumerate(tasks_list, start=1):
            print(f"{index}. {task}")
    print("------------------")

def remove_task_action(tasks_list: list):
    view_tasks_action(tasks_list)
    if not tasks_list:
        return False

    try:
        task_number_str = input("Enter the number of the task to remove: ")
        task_number_to_remove = int(task_number_str)

        if 1 <= task_number_to_remove <= len(tasks_list):
            task_index_to_remove = task_number_to_remove - 1
            removed_task = tasks_list.pop(task_index_to_remove)
            print(f"Task '{removed_task}' removed.")
            return True # Indicate success for saving
        else:
            print(f"Invalid task number. Please enter a number between 1 and {len(tasks_list)}.")
            return False
    except ValueError:
        print("Invalid input. Please enter a number.")
        return False
    except Exception as e:
        print(f"An unexpected error occurred during removal: {e}")
        return False

# --- Main Application Logic ---
def run_persistent_todo_app():
    current_tasks = load_tasks_from_file(TASKS_FILE_PATH)

    while True:
        display_menu()
        choice = input("Enter your choice (1-4): ")

        if choice == '1':
            if add_task_action(current_tasks):
                save_tasks_to_file(current_tasks, TASKS_FILE_PATH)
        elif choice == '2':
            view_tasks_action(current_tasks)
        elif choice == '3':
            if remove_task_action(current_tasks):
                save_tasks_to_file(current_tasks, TASKS_FILE_PATH)
        elif choice == '4':
            print("Exiting To-Do List. Your tasks are saved. Goodbye!")
            break
        else:
            print("Invalid choice. Please enter a number between 1 and 4.")

        input("\nPress Enter to continue...")

if __name__ == "__main__":
    run_persistent_todo_app()

Understanding the Persistent To-Do List App: A Code Walkthrough

Let’s break down how the todo_list_app_persistent.py script works, focusing on the new persistence features.

  1. Overall Structure:

    • Imports: We start by importing json to work with JSON data and Path from the pathlib module for easier file path management.
    • TASKS_FILE_PATH = Path("todolist_data.json"): A constant is defined using Path to represent the file where tasks will be stored. Using Path makes the code cleaner and more adaptable to different operating systems.
    • The tasks list, which used to be initialized as empty at the start of the main function, will now be populated by load_tasks_from_file. (In the provided full code, current_tasks in run_persistent_todo_app serves this role).
  2. Function display_menu():

    • This is a simple helper function. Its only job is to print the menu options to the user, making the main loop cleaner. It doesn’t take any arguments or return anything.
  3. Function load_tasks_from_file(filepath: Path):

    • Purpose: This crucial function is responsible for loading the tasks from our JSON file when the application starts.
    • filepath: Path Argument: It expects a Path object representing the location of the data file.
    • File Existence Check: if filepath.exists() and filepath.is_file(): It first checks if the path exists and if it’s actually a file (not a directory) before trying to open it. This is a good practice with pathlib.
    • Opening and Reading: with filepath.open(mode='r', encoding='utf-8') as f: opens the file in read mode ('r'). encoding='utf-8' is specified for broad character compatibility.
    • json.load(f): This is where the magic happens. json.load() reads the text content from the file object f, parses the JSON structure, and converts it into a Python data structure. Since we saved our tasks as a JSON array, this will return a Python list.
    • Validation: if not isinstance(tasks_data, list): is a safety check. If the JSON file was somehow corrupted or manually changed to not contain a list at its top level, this prevents errors later by returning an empty list.
    • FileNotFoundError Handling: The try...except FileNotFoundError block (implicitly handled by filepath.exists() in this version, but good to remember open() can raise it) ensures that if the todolist_data.json file doesn’t exist (e.g., when the user runs the application for the very first time), the program doesn’t crash. Instead, it returns an empty list [], allowing the application to start with a fresh to-do list.
    • json.JSONDecodeError Handling: If the file exists but contains text that isn’t valid JSON (e.g., it’s corrupted or was manually edited incorrectly), json.load(f) would raise a json.JSONDecodeError. The except block catches this, prints a warning, and returns an empty list, preventing a crash.
    • Generic Exception: Catches any other unexpected I/O errors during loading.
  4. Function save_tasks_to_file(tasks_list: list, filepath: Path):

    • Purpose: This function takes the current list of tasks (tasks_list) from the application’s memory and writes it to the JSON file specified by filepath.
    • filepath.open(mode='w', encoding='utf-8'): The file is opened in write mode ('w'). This means if the file already exists, its old content will be completely overwritten with the new data. This is exactly what we want to save the current state of the to-do list. If the file doesn’t exist, it will be created.
    • json.dump(tasks_list, f, indent=4): This function does the work of converting the Python tasks_list into JSON format and writing it to the file object f.
      • indent=4: This argument is very helpful for us humans! It tells json.dump to “pretty-print” the JSON data with an indentation of 4 spaces for each level of nesting. This makes the todolist_data.json file easy to open in a text editor and inspect. Without it, the entire JSON array would often be written on a single, long line.
    • Generic Exception: Catches potential errors during saving (e.g., disk full, file permissions).
  5. Function add_task_action(tasks_list: list): (Assuming it modifies the passed list)

    • It prompts the user for a new task description.
    • It uses .strip() to remove any accidental leading or trailing spaces from the user’s input.
    • It checks if the cleaned_description is not empty before appending.
    • tasks_list.append(cleaned_description): The new task is added to the list that was passed into the function.
    • It returns True if a task was added, False otherwise, so the main loop knows whether to save.
  6. Function view_tasks_action(tasks_list: list):

    • Its purpose is to display all the tasks currently stored in the tasks_list.
    • It first checks if the tasks_list is empty and prints an appropriate message if it is.
    • If there are tasks, it uses enumerate(tasks_list, start=1) to loop through the tasks. enumerate provides both the index (we start it at 1 for user-friendliness) and the task itself.
    • Each task is then printed with its number.
  7. Function remove_task_action(tasks_list: list):

    • First, it calls view_tasks_action(tasks_list) so the user can see the numbers of the tasks they might want to remove.
    • It checks if the list is empty; if so, it returns False.
    • It prompts the user to enter the number of the task they wish to remove.
    • Input Validation:
      • task_number_to_remove = int(task_number_str): It tries to convert the user’s input string into an integer. If this fails (e.g., the user types “abc”), a ValueError is caught, an error message is printed, and it returns False.
      • if 1 <= task_number_to_remove <= len(tasks_list): It checks if the entered number is within the valid range of the displayed task numbers.
    • Index Conversion: task_index_to_remove = task_number_to_remove - 1 converts the 1-based number from the user into a 0-based index suitable for Python lists.
    • removed_task = tasks_list.pop(task_index_to_remove): The task at the calculated index is removed from the list. .pop() also returns the removed item, which is used in the confirmation message.
    • It returns True if a task was successfully removed.
  8. Function run_persistent_todo_app() (Main Application Logic):

    • Initial Load: current_tasks = load_tasks_from_file(TASKS_FILE_PATH) is the very first significant action. This attempts to load any tasks saved from a previous session into the current_tasks list. If no file is found or it’s invalid, current_tasks will be an empty list.
    • Main Loop (while True): This loop keeps the application running and presenting the menu until the user chooses to quit.
    • display_menu() is called in each iteration to show the user their options.
    • The user’s choice is read.
    • if/elif/else for Choices:
      • If ‘1’ (Add): add_task_action(current_tasks) is called. If it returns True (meaning a task was actually added), save_tasks_to_file(current_tasks, TASKS_FILE_PATH) is immediately called to make the change persistent.
      • If ‘2’ (View): view_tasks_action(current_tasks) is called.
      • If ‘3’ (Remove): remove_task_action(current_tasks) is called. If it returns True (a task was removed), save_tasks_to_file(current_tasks, TASKS_FILE_PATH) is called to persist the removal.
      • If ‘4’ (Quit): A goodbye message is printed, and break is executed to terminate the while loop, thus ending the program.
      • Else (Invalid choice): An error message is shown.
    • input("\nPress Enter to continue..."): This simple line pauses the program after each action, waiting for the user to press Enter. This prevents the menu from instantly reappearing and gives the user time to read messages (like “Task added successfully!”).
  9. The if __name__ == "__main__": block:

    • This is a standard Python idiom. It ensures that the run_persistent_todo_app() function (which starts the whole application) is called only when the script (todo_list_app_persistent.py) is executed directly by the Python interpreter (e.g., python todo_list_app_persistent.py).
    • If this script were to be imported as a module into another Python file, the code inside this if block would not automatically run, which is generally the desired behavior for reusable modules.

This walkthrough should clarify how each part of the persistent To-Do List app functions, with a special focus on how file operations are integrated to load and save tasks.

How to Run & Test

  1. Save: Save the code as todo_list_app_persistent.py in a directory like examples/todo_list/.
  2. Run: Open your terminal, navigate to that directory (cd examples/todo_list), and run python todo_list_app_persistent.py.
  3. Test: Add tasks, view them, remove some, then quit. Run the app again. Your tasks should be loaded from todolist_data.json (which will be created in the same directory). Inspect this file to see the JSON structure.

Concepts Learned Recap

This lecture covered essential skills for making your programs more robust and useful:

  • File I/O: Reading from and writing to files for data persistence.
  • pathlib.Path: Using Path objects for modern, object-oriented path management (e.g., Path("file.txt"), data_dir / "file.json", my_path.exists(), my_path.read_text(), my_path.write_text()).
  • File Modes: Understanding 'r', 'w', 'a'.
  • with my_path.open(...) as f:: The standard way to ensure files are properly closed.
  • Reading Methods: Using f.read(), f.readline(), f.readlines(), and looping directly over f. Path.read_text().
  • Writing Methods: Using f.write() (and adding \n), f.writelines(). Path.write_text().
  • .strip(): Removing whitespace (especially newlines) from read lines.
  • JSON for Structured Data: Using the json module (import json) with json.dump() (to save Python lists/dicts to a JSON file) and json.load() (to load JSON from a file into Python lists/dicts). Using indent for readable JSON.
  • Error Handling: try-except FileNotFoundError and try-except json.JSONDecodeError for robust file operations.

Homework Challenges

  1. Simple Personal Journal App:

    • Create an app to write and view journal entries.
    • Entries are strings, saved to journal.txt (perhaps use pathlib and Path.write_text in append mode, or read all, append, then rewrite).
    • Consider adding a timestamp to each entry (import datetime; datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S")).
  2. High Score System (JSON):

    • Modify “Guess the Number” to save high scores (name and attempts) to high_scores.json.
    • Store as a list of dictionaries: [{"name": "Rohan", "attempts": 5}].
    • Load, update (sort by attempts, keep top N), and save scores. Display them.
  3. CSV Data for To-Do List:

    • Modify the persistent To-Do list to use CSV format instead of JSON. Each line in tasks.csv could be task_description,status (e.g., “Buy milk,pending”).
    • Use string .split(',') for reading and f-strings or ','.join() for writing.
    • (Consider how to handle commas within task descriptions – this shows a limitation of simple CSV vs. JSON).