13. Classes and Methods#

In this notebook, we cover the following subjects:

  • Classes Recap

  • Class Methods


# To enable type hints for lists, dicts, tuples, and sets we need to import the following:
from typing import List, Dict, Tuple, Set

If you’ve been following along, we’ve already dipped our toes into the world of Python classes. So far, we’ve covered what a class is, how to set up our trusty __init__() method, customized our object representations with __str__(), and explored how to create objects and access their attributes.This notebook will begin with a brief recap about these basics, then we will learn a key concept in OOP called methods

13.1. Classes Recap#

As we have already discussed, classes are a core concept in programming, often viewed as blueprints or templates for creating objects. This is important because, in Python, everything is an object—whether it’s an integer, a float, a list, or a string. Classes help you organize your code by bundling related data (attributes) with the functions (methods) that work with that data, making everything more logical and easier to manage.

So, we’ve actually been working with classes all along, probably without you even realizing it. Take the str type, for example—it’s actually a class that handles strings of characters. Remember all those methods you’ve used to manipulate string objects?

# Creating an instance of the str class
my_string: str = "Introduction to Python Programming"  # 'my_string' is an instance of the str class

# Using the split method to break the string into a list of words
output: str = my_string.split(" ")

# Printing the result of the split method
print(f"Split string into words: {output}\n")

# Demonstrating that methods can be used directly on string objects
print("You can also use the methods directly on string objects (i.e., not stored in variables).")
print(f'Split example: {"Python Programming".split(" ")}')
Split string into words: ['Introduction', 'to', 'Python', 'Programming']

You can also use the methods directly on string objects (i.e., not stored in variables).
Split example: ['Python', 'Programming']

The str class is built into Python, so it’s ready to use without any setup. You’ve already used its methods to work with strings, and you didn’t have to think about what’s happening behind the scenes.

13.1.1. Defining a Class#

We learnt to create a class with the class keyword, and we know that everything inside it becomes part of that class.

class Book:
    def __init__(self, title: str, author: str) -> None:
        """
        Sets up a new Book instance.

        :param title: The book's title.
        :param author: The book's author.
        """
        # The __init__ method is the constructor that initializes the object
        self.title: str = title # This is an attribute of the class
        self.author: str = author # This is another attribute of the class


# Creating an instance (object) of Book
my_book = Book("Little Women", "Louisa May Alcott")

Note

A bit unintuitive, as we use snake_case in this course, but the name of a class should be written in CamelCase.

13.1.2. Attributes#

We have seen that attributes are the characteristics or properties of an object. In a class, we define attributes to store data unique to each object created from that class. For example, the following class Book has two attributes, namely title and author. This means that every object we create from this class, representing a book, will have a title and an author.

# ... (class definition)
    # ... (__init__ definition)
        self.title: str = title  # This is an attribute of the class
        self.author: str = author  # This is another attribute of the class

13.1.3. The init Method#

These attributes are set up in a special method called __init__. This method, known as a constructor, is essential in Python classes because it initializes everything when you create a new object. As soon as you make an instance of a class, __init__ runs automatically to set up the attributes and other initial details for that object.

# ... (class definition)
    def __init__(self, title, author):
        # The __init__ method is the constructor that initializes the object
        self.title: str = title # This is an attribute of the class
        self.author: str = author # This is another attribute of the class

13.1.4. The self keyword#

We’ve covered almost all the components of a class, but we’ve somewhat awkwardly avoided discussing self, which appears everywhere: in attribute definitions and as the first parameter of each method.

The self keyword is crucial in classes because it allows an instance of a class to refer to itself. When we create an object from a class, self enables that object to access its own attributes and methods. For example, when you use self.title = title, you’re specifying, “For this particular instance (self), the title attribute should be set to the value provided during object creation.”

13.1.5. Class Instance#

You should know this by hearth by now, but creating a new object is known as instantiation, and the object itself is called an instance of the class. Essentially, we use the blueprint provided by the class to create the object, and you can use this blueprint to create as many instances as you need.

# ... (class definition)

# Creating an instance (object) of Book
my_book = Book("Little Women", "Louisa May Alcott")

This code snippet, Book("Little Women", "Louisa May Alcott"), calls the Book class constructor (the __init__ method) with two arguments: "Little Women" and "Louisa May Alcott". A new object is created using these arguments and assigned to the variable my_book.

Note

Since every object is an instance of some class, “object” and “instance” are interchangeable terms.

13.1.6. Accessing an Attribute#

Lastly, let’s recap how we can access an attribute of an object. As we have seen, objects have stored attributes that are unique to each instance. To access these attributes, we use the following syntax (dot notation .):

instance.attribute

In practise, this looks as follows:

# Accessing attributes
print(f'Book title: {my_book.title}')
print(f'Book author: €{my_book.author}')
Book title: Little Women
Book author: €Louisa May Alcott

There is one last thing that we need to touch upon before we can start talking about class methods and that thing is called default values.

13.1.7. Default Values#

Default values are like the secret ingredient that can make your functions and classes much more user-friendly and flexible. Imagine having functions or methods that can handle different situations without requiring you to provide a value for every single parameter—sounds convenient, right?

With default values, you can specify a “fallback” that Python will use if no argument is provided for a parameter. It’s a great way to keep your code simple and reduce the chance of errors!

Let’s explore how you can use default values, not just in regular functions but also in classes.

def greet(name: str ="there") -> None:
    print(f"Hello, {name}!")

greet()
greet("Alice")
Hello, there!
Hello, Alice!

Here, the name parameter has a default value of "there", so if we call greet() without an argument, it uses that default. But if we pass a name, it uses that instead without any problems.

Default values are super useful in classes too! One of the most common places to use them is in the __init__() method when initializing objects. Let’s take a look at an example:

class Dog:
    """
    A class to represent a dog.
    
    Attributes:
        name (str): The name of the dog.
        breed (str): The breed of the dog. Defaults to 'Mixed Breed'.
        age (int): The age of the dog. Defaults to 1.
    """
    def __init__(self, name: str, breed: str = "Mixed Breed", age: int = 1) -> None:
        """
        Initializes a new instance of the Dog class with optional breed and age parameters.

        Parameters:
            name (str): The name of the dog.
            breed (str): The breed of the dog, with a default value if not specified.
            age (int): The age of the dog, with a default value of 1 if not specified.
        """
        self.name = name
        self.breed = breed
        self.age = age

    def __str__(self) -> str:
        """
        Returns a string representation of the Dog instance.

        Returns:
            str: A description of the dog including its name, breed, and age.
        """
        return (f"My name is {self.name}, I'm a {self.breed} and I'm {self.age} years old!")

# Creating instances with and without specifying all arguments
dog1 = Dog("Buddy")  # Defaults to Mixed Breed and age 1
dog2 = Dog("Luna", "Golden Retriever", 3)

print(dog1)
print('-'*60)
print(dog2)
My name is Buddy, I'm a Mixed Breed and I'm 1 years old!
------------------------------------------------------------
My name is Luna, I'm a Golden Retriever and I'm 3 years old!

In this example:

  • If you don’t specify a breed, it defaults to “Mixed Breed”.

  • If you don’t specify an age, it defaults to 1.

By using default values in the __init__() method, we make it easier to create Dog objects without needing to provide every detail.

Note

When using default values, remember: parameters with default values must come after parameters without default values. This is because Python needs to know which values are required and which are optional.

For example, this works:

def example(a: int, b: int=10) -> int:
    return a + b

But this will cause an error:

def example(a: int=10, b: int):
    return a + b
# ❌ SyntaxError: non-default argument follows default argument

Now that we covered this as well, let’s dive into class methods.

13.2. Class methods#

You might be wondering, “Aren’t we already using methods inside our classes?” And you’re absolutely right! However, so far, the methods we’ve been using are tied to instances of a class. That means they work with individual objects we create, using their attributes and properties.

But what if we want to do something that’s related to the class as a whole rather than any specific object? This is where class methods come in handy. Instead of focusing on a single instance, these methods can work at the class level, letting us access and modify the class itself.

Before we dive deeper into class methods, let’s take a step back and think about how we’ve been using functions in Python. Functions are great because they allow us to encapsulate logic, reuse code, and make our programs more modular. But what happens when we use functions inside a class?

13.2.1. Functions vs. Methods#

When we define a function inside a class, it’s called a method. However, it’s still essentially a function — the difference is that it now belongs to the class and operates on the data (attributes) of the objects created from that class.

Let’s see an example to refresh our understanding:

import math

def calculate_hypotenuse(a: float, b: float) -> float:
    """
    Calculate the length of the hypotenuse of a right triangle given the lengths 
    of the other two sides using the Pythagorean theorem.

    Args:
        a (float): The length of one side of the triangle.
        b (float): The length of the other side of the triangle.

    Returns:
        float: The length of the hypotenuse.
    """
    return math.sqrt(a ** 2 + b ** 2)

side1: float = 3.0
side2: float = 4.0
hypotenuse: float = calculate_hypotenuse(side1, side2)
print(f"The length of the hypotenuse is: {hypotenuse}")
The length of the hypotenuse is: 5.0

The function calculate_hypotenuse() takes two arguments, a and b, representing the lengths of the two shorter sides of a right triangle. It uses the Pythagorean theorem (a^2 + b^2 = c^2) to calculate the hypotenuse (c) and returns its value. Easy enough, right?

Now, let’s take this concept into the context of a class. First things first, let’s define a Cube class:

class Cube:
    """
    A class to represent a cube with additional attributes.

    Attributes:
        side_length (float): The length of a side of the cube.
        color (str): The color of the cube.
        material (str): The material of which the cube is made.
        is_solid (bool): Indicates whether the cube is solid or hollow.
    """
    def __init__(self, side_length: float, color: str, material: str, is_solid: bool) -> None:
        """
        Initializes a new instance of the Cube class.

        Parameters:
            side_length (float): The length of a side of the cube.
            color (str): The color of the cube.
            material (str): The material of which the cube is made.
            is_solid (bool): Indicates whether the cube is solid or hollow.
        """
        self.side_length = side_length
        self.color = color
        self.material = material
        self.is_solid = is_solid

    def __str__(self) -> str:
        """
        Returns a string representation of the Cube instance.

        Returns:
            str: A description of the cube including its side length, color, material, and solid status.
        """
        return (f"Cube with side length: {self.side_length}, color: {self.color}, "
                f"material: {self.material}, solid: {self.is_solid}")

# Creating an instance of Cube
my_cube = Cube(4, "blue", "plastic", True)

print(my_cube)
Cube with side length: 4, color: blue, material: plastic, solid: True

So far nothing new. Now, let’s add two methods to our class:

  • The first method will calculate the volume of the cube

  • While the second method will calculate_surface_area of the cube

these will help us explore more of the capabilities of classes.

class Cube:
    """
    A class to represent a cube with additional attributes.

    Attributes:
        side_length (float): The length of a side of the cube.
        color (str): The color of the cube.
        material (str): The material of which the cube is made.
        is_solid (bool): Indicates whether the cube is solid or hollow.
    """
    def __init__(self, side_length: float, color: str, material: str, is_solid: bool):
        """
        Initializes a new instance of the Cube class.

        Parameters:
            side_length (float): The length of a side of the cube.
            color (str): The color of the cube.
            material (str): The material of which the cube is made.
            is_solid (bool): Indicates whether the cube is solid or hollow.
        """
        self.side_length = side_length
        self.color = color
        self.material = material
        self.is_solid = is_solid

    def __str__(self) -> str:
        """
        Returns a string representation of the Cube instance.

        Returns:
            str: A description of the cube including its side length, color, material, and solid status.
        """
        return (f"Cube with side length: {self.side_length}, color: {self.color}, "
                f"material: {self.material}, solid: {self.is_solid}")

    def calculate_volume(self) -> float:
        """
        Calculates the volume of the cube based on its side length.

        Returns:
            float: The volume of the cube.
        """
        return self.side_length ** 3

    def calculate_surface_area(self) -> float:
        """
        Calculates the surface area of the cube based on its side length.

        Returns:
            float: The surface area of the cube.
        """
        return 6 * (self.side_length ** 2)

So what did we do here?

The calculate_volume() method calculates the volume of the cube. The volume of a cube is the amount of space it occupies, measured in cubic units. The formula to calculate the volume of a cube is: volume = side_length^3

In this method, self.side_length refers to the side_length attribute of the specific cube on which the method is called.

  • If we call cube1.calculate_volume(), self refers to cube1, so it will use cube1’s side_length.

  • If we call cube2.calculate_volume(), self will refer to cube2 and use cube2’s side_length.

Let’s see it in action:

cube1: Cube = Cube(4, "red", "wood", True)
cube2: Cube = Cube(3, "blue", "plastic", False)

print(f' The side length of cube1 is {cube1.side_length}')
print(f' The side length of cube1 is {cube2.side_length}')
print('--'*20)
print(f' The surface area of cube1 is {cube1.calculate_surface_area()}')
print(f' The surface area of cube1 is {cube2.calculate_surface_area()}')
 The side length of cube1 is 4
 The side length of cube1 is 3
----------------------------------------
 The surface area of cube1 is 96
 The surface area of cube1 is 54

Let's Think!

if cube1.calculate_surface_area() > cube2.calculate_surface_area():
    print('Cube 1 has a larger surface area')
else:
    print(':(')
Cube 1 has a larger surface area

Let’s look at one last example…

class Penguin:
    """
    A class to represent a penguin.

    Attributes:
        name (str): The name of the penguin.
        age (int): The age of the penguin in years.
        species (str): The species of the penguin.
        weight (float): The weight of the penguin in kilograms.
        is_hungry (bool): Indicates if the penguin is hungry, defaults to True.
    """
    def __init__(self, name: str, age: int, species: str, weight: float, is_hungry: bool = True) -> None:
        """
        Initializes a new instance of the Penguin class with all essential attributes.

        Parameters:
            name (str): The name of the penguin.
            age (int): The age of the penguin.
            species (str): The species of the penguin.
            weight (float): The weight of the penguin.
            is_hungry (bool): Indicates if the penguin is currently hungry, defaults to True.
        """
        self.name = name
        self.age = age
        self.species = species
        self.weight = weight
        self.is_hungry = is_hungry

    def __str__(self) -> str:
        """
        Returns a string representation of the Penguin instance.

        Returns:
            str: A description of the penguin including its name, species, age, weight, and hunger status.
        """
        return (f"Penguin {self.name} (Species: {self.species}) - Age: {self.age} years, "
                f"Weight: {self.weight} kg, Hungry: {self.is_hungry}")

    def swim(self) -> str:
        """
        Simulates the penguin swimming.

        Returns:
            str: A description of the penguin swimming.
        """
        return f"{self.name} is happily swimming in the cold water! 🏊‍♂️❄️"

    def eat(self, food: str) -> str:
        """
        Feeds the penguin, affecting its hunger status.

        Parameters:
            food (str): The type of food the penguin eats.

        Returns:
            str: A message indicating that the penguin has eaten or that it is not hungry.
        """
        if self.is_hungry:
            self.is_hungry = False
            return f"{self.name} ate the {food} and is now full. 🍽️"
        else:
            return f"{self.name} is not hungry right now."

    def grow_older(self) -> str:
        """
        Increments the age of the penguin by one year.

        Returns:
            str: A message celebrating the penguin's birthday.
        """
        self.age += 1
        return f"{self.name} has turned {self.age} years old! 🎂"

Let’s break down each method one by one:

  • The swim() method simulates the penguin swimming. This method simply returns a message indicating that the penguin is happily swimming.

  • The eat() method feeds the penguin if it’s hungry. This method checks if the is_hungry attribute is True.

    • If the penguin is hungry, it changes is_hungry to False and returns a message that the penguin ate the given food.

    • If the penguin is not hungry, it returns a different message.

  • The grow_older() method increases the age of the penguin by one year. This method adds 1 to the age attribute of the penguin. It also returns a message to indicate the penguin’s new age.

Let’s see some example code:

penguin_1 = Penguin('Liv', 2, 'Emperor penguin', 30.21)
print(penguin_1)
print('---'*20)
print(penguin_1.swim())
print('---'*20)
print(penguin_1.eat('Antarctic silverfish'))
print('---'*20)
Penguin Liv (Species: Emperor penguin) - Age: 2 years, Weight: 30.21 kg, Hungry: True
------------------------------------------------------------
Liv is happily swimming in the cold water! 🏊‍♂️❄️
------------------------------------------------------------
Liv ate the Antarctic silverfish and is now full. 🍽️
------------------------------------------------------------

Notice, that now the is_hungry attribute is set to False:

print(penguin_1.is_hungry)
False

Lastly, let’s increase our penguin’s age.

current_age = penguin_1.age
print(current_age)
2
print(penguin_1.grow_older())
Liv has turned 3 years old! 🎂

Throughout this notebook, we used self to refer to the current instance of the class. It allowed us to access and modify the attributes and call methods specific to each object we created. For example, in our Penguin class, self was used to store each penguin’s name, age, and species, making each penguin object unique.

Note

Remember: self is what connects an object’s data with its behavior, allowing each instance of a class to maintain its own state while sharing the same methods.

13.3. Exercises#

Let’s practice! Mind that each exercise is designed with multiple levels to help you progressively build your skills. Level 1 is the foundational level, designed to be straightforward so that everyone can successfully complete it. In Level 2, we step it up a notch, expecting you to use more complex concepts or combine them in new ways. Finally, in Level 3, we get closest to exam level questions, but we may use some concepts that are not covered in this notebook. However, in programming, you often encounter situations where you’re unsure how to proceed. Fortunately, you can often solve these problems by starting to work on them and figuring things out as you go. Practicing this skill is extremely helpful, so we highly recommend completing these exercises.

For each of the exercises, make sure to add a docstring and type hints, and do not import any libraries unless specified otherwise.

13.3.1. Exercise 1#

First, let’s practice using default values in functions and classes! In this exercise, you will work with both functions and classes to get comfortable using default parameters.

Level 1: Define a function describe_pet() that takes a name (a string) and an optional species (a string) with a default value of "Dog". The function should return a description of the pet in the format: "This is [name], the [species]."

Example input:

describe_pet("Buddy")
describe_pet("Whiskers", "Cat")

Example output:

"This is Buddy, the Dog."
"This is Whiskers, the Cat."
# TODO.

Level 2: Create a class Pet with an __init__() method that takes a name (string) and an optional species (default value “Dog”). Implement a __str__() method to provide a string representation of the pet in the format: "Pet(name=[name], species=[species])".

Additionally, add a method change_species() that allows updating the species but restricts it to a list of allowed species (“Dog”, “Cat”, “Rabbit”, “Parrot”).
If the user tries to set an unsupported species, raise a ValueError. (e.g. Invalid species: Tiger)

Example input:

pet1: Pet = Pet("Buddy")
print(pet1)

pet1.change_species("Cat")
print(pet1)

try:
    pet1.change_species("Tiger")  
except ValueError as e:
    print(e)

Example output:

"Pet(name=Buddy, species=Dog)"
"Pet(name=Buddy, species=Cat)"
Invalid species: Tiger
# TODO

Level 3: Extend the Pet class to include an optional age parameter in the __init__() method with a default value of 1. Update the __str__() method so that it returns a full description of the pet including its age in the format: “This is [name], a [species] who is [age] years old.”.

Additionally, add a method birthday() that increases the pet’s age by 1 each time it’s called. However, if the pet’s age exceeds 20, the method should raise an exception: "Age limit reached".

Example input:

pet1: Pet = Pet("Buddy")
print(pet1)

pet1.birthday()
print(pet1)

try:
    for _ in range(20):
        pet1.birthday()
except Exception as e:
    print(e)

Example output:

"This is Buddy, a Dog who is 1 years old."
"This is Buddy, a Dog who is 2 years old."
"Age limit reached"

13.3.2. Exercise 2#

Let’s take it up a notch! In this exercise, we’ll create a more complex class and combine multiple methods to manage data effectively.

Level 1: Write a function calculate_price() that takes base_price (float) and an optional tax_rate (float) with a default value of 0.05. The function should return the total price including tax.

Example input:

print(calculate_price(100.0))
print(calculate_price(200.0, 0.1))

Example output:

105.0
220.0
# TODO

Level 2: Create a class Product with an __init__() method that takes a name (string), price (float), and an optional discount (float, default is 0.1). Implement a __str__() method for a string representation.

Add methods:

  • final_price(): Calculates the price after applying the discount.

  • bulk_discount(quantity: int): If quantity is more than 10, apply an additional 5% discount. If more than 50, apply additional 15% discount (not chainable).

Example input:

product1: Product = Product("Laptop", 1000.0)
print(product1.final_price())

print(product1.bulk_discount(12))
print(product1.bulk_discount(60))

Example output:

"The product's final price is $900.0"
"The product's price after the bulk discount is $855.0"
"The product's price after the bulk discount is $765.0"
# TODO

Level 3: Extend the Product class by adding a method apply_tax() that takes an optional tax_rate (float, default is 0.05) and returns the final price after applying both the discount and tax.

Add a method adjust_price() which:

  • takes one parameter: percentage (a float), which can be positive (for a price increase) or negative (for a price decrease).

  • the method should update the product’s price based on this percentage:

    • If the percentage is positive, it increases the price.

    • If the percentage is negative, it decreases the price.

  • Moreover, ensure that the price never drops below a certain minimum threshold, such as 50.0:

    • If an adjustment would cause the price to go below this minimum, raise a ValueError with a message ("Price cannot go below 50.0").

Example input:

product2: Product = Product("Phone", 500.0, 0.2)
print(product2.apply_tax())

product2.adjust_price(-0.1)
print(product2)

Example output:

"The product's final price after tax is $420.0"
"The product's price after price adjustion is $450.0"
# TODO

13.3.3. Exercise 3#

In this exercise, you’ll create a system to manage a shopping cart filled with different LEGO pieces, each with limited stock.

Level 1: Create a class LegoBrick to represent a LEGO piece. This class should include:

  • an __init__() method that takes:

    • name (str): The name of the LEGO piece (e.g., “2x4 Brick”, “Round Plate”).

    • color (str): The color of the LEGO piece (e.g., “Red”, “Blue”).

    • price (float): The price of a single piece.

    • An optional quantity (int) with a default value of 1.

  • A __str__() method that returns a string representation of the LEGO piece in the format:

    • "LEGOBrick(name=[name], color=[color], price=[price], quantity=[quantity])"

  • A method total_price() that returns the total cost of the piece based on its quantity.

Example input:

brick1: LegoBrick = LegoBrick("2x4 Brick", "Red", 0.30, 10)
print(brick1
print(brick1.total_price())

Example output:

"This Lego Brick is called 2x4 Brick, has color Red, costs $0.3/piece and has a total quantity of 10 pieces"
"The total price of the Red 2x4 Brick is $3.0"
# TODO.

Level 2: Create a class LegoInventory to manage the stock of different LEGO parts. Make:

  • an __init__() method initializes an empty dictionary inventory to store LegoBrick objects.

Add a method add_brick() that takes a LegoBrick object and adds it to the inventory.

  • If the brick already exists (same name and color), increase its quantity.

Add a method check_stock() that returns the current stock of a specific part (by name and color).

Add a method remove_brick() that decrease the quantity of a specific brick when an order is placed.

  • If the stock is insufficient, raise a ValueError. (e.g. Insufficient stock for 1x1 Blue Round Plate)

Example input:

inventory: LegoInventory = LegoInventory()

brick1: LegoBrick = LegoBrick("2x4 Brick", "Red", 0.30, 20)
brick2: LegoBrick = LegoBrick("1x1 Round Plate", "Blue", 0.15, 50)

inventory.add_brick(brick1)
inventory.add_brick(brick2)

print(inventory.check_stock("2x4 Brick", "Red"))

inventory.remove_brick("2x4 Brick", "Red", 5)
print(inventory.check_stock("2x4 Brick", "Red"))

try:
    inventory.remove_brick("1x1 Round Plate", "Blue", 60)
except ValueError as e:
    print(e)

Example output:

"Currently in our stock, we have 20 Red 2x4 Brick"
"Currently in our stock, we have 15 Red 2x4 Brick"
Insufficient stock for 1x1 Blue Round Plate
# TODO

Level 3: Create a class ShoppingCart that allows customers to add LEGO parts to their cart and manage orders:

  • make an __init__() method initializes an empty dictionary cart where:

    • keys are tuples of (name, color)

    • and values are dictionaries with quantity and price.

Add a method add_to_cart() that adds a specified quantity of a LEGO part to the cart. Ensure the quantity does not exceed the stock in the inventory. If the quantity would exceed the inventory, raise a Valuerror. (e.g. Cannot add 100 pieces of Red 2x4 Brick to the cart. Only 50 available in stock.)

Add a method calculate_total() that calculates the total cost of all items in the cart. Add a method apply_discount() that takes an optional discount (default is 0.1) if the total is above 100.0. Add a method checkout() that finalizes the purchase, updates the inventory, and clears the cart.

Example input:

inventory: LegoInventory = LegoInventory()
cart: ShoppingCart = ShoppingCart(inventory)

# Add items to the inventory
inventory.add_brick(LegoBrick("2x4 Brick", "Red", 0.30, 50))
inventory.add_brick(LegoBrick("1x1 Round Plate", "Blue", 0.15, 100))

# Add items to the cart (within stock limits)
cart.add_to_cart("2x4 Brick", "Red", 10)
cart.add_to_cart("1x1 Round Plate", "Blue", 20)

print(cart.calculate_total())

# Apply a discount if applicable
print(cart.apply_discount())   # (no discount applied)

# Attempt to add more than available stock (will raise ValueError)
try:
    cart.add_to_cart("2x4 Brick", "Red", 100)  # Exceeds stock
except ValueError as e:
    print(f"Error: {e}")

# Checkout and update the inventory
cart.checkout()
print(inventory.check_stock("2x4 Brick", "Red"))
print(inventory.check_stock("1x1 Round Plate", "Blue"))

Example output:

"The total cost of the items in the shopping cart is $6.0 at the moment."
"The total cost of the items in the shopping cart is $6.0 after discount.": Cannot add 100 pieces of Red 2x4 Brick to the cart. Only 50 available in stock.
"The current stock of the Red 2x4 Brick is 40 pieces."
"The current stock of the Blue 1x1 Round Plate is 80 pieces."

# TODO.

Material for the VU Amsterdam course “Introduction to Python Programming” for BSc Artificial Intelligence students. These notebooks are created using the following sources:

  1. Learning Python by Doing: This book, developed by teachers of TU/e Eindhoven and VU Amsterdam, is the main source for the course materials. Code snippets or text explanations from the book may be used in the notebooks, sometimes with slight adjustments.

  2. Think Python

  3. GeekForGeeks