Object-Oriented Programming

Inventory Class

Write an Inventory class, as defined below, that handles the management of inventory for a company. All instances of this class should be initialized by passing an integer value named max_capacity that indicates the maximum number of items that can be stored in inventory. Your Inventory class will need to store items that are represented by a name, price and quantity.

Your class should implement the following methods.

  • add_item(name, price, quantity): This method should add an item to inventory and return True if it was successfully added. If adding an item results in the inventory being over capacity your method should return False and omit adding this item to the inventory. Additionally, if an item with the passed name already exists in inventory this method should return False to indicate the item could not be added.
  • delete_item(name): This method should delete an item from inventory and return True if the item was successfully deleted. If there is no item with the passed name this method should return False.
  • get_most_stocked_item(): This method should return the name of the item that has the highest quantity in the inventory, and return None if there are no items in the inventory. You may assume there will always be exactly one item with the largest quantity, except for the case where the inventory is empty.
  • get_items_in_price_range(min_price, max_price): This method should return a list of the names of items that have a price within the specified range (inclusively).

Note: you may assume all input/arguments to your class will be valid and the correct types. For example, the max_capacity will always be greater than or equal to 0 and a valid integer.

See below for an example of how the Inventory class should behave.

>>> i = Inventory(4)
>>> i.add_item('Chocolate', 4.99, 1)
True
>>> i.add_item('Cereal', 6.99, 1)
True
>>> i.add_item('Milk', 3.99, 2)
True
>>> i.add_item('Butter', 2.99, 1)
False
>>> i.delete_item('Bread')
False
>>> i.delete_item('Cereal')
True        
>>> i.get_items_in_price_range(4.50, 6.50)
['Chocolate']

Solution

class Inventory:
    def __init__(self, max_capacity):
        self.max_capacity = max_capacity
        self.items = {}
        self.item_count = 0

    def add_item(self, name, price, quantity):
        if name in self.items:
            return False

        if self.item_count + quantity > self.max_capacity:
            return False

        self.items[name] = {"name": name, "price": price, "quantity": quantity}
        self.item_count += quantity
        return True

    def delete_item(self, name):
        if name not in self.items:
            return False

        self.item_count -= self.items[name]["quantity"]
        del self.items[name]
        return True

    def get_items_in_price_range(self, min_price, max_price):
        item_names = []

        for item in self.items.values():
            name = item["name"]
            price = item["price"]

            if min_price <= price <= max_price:
                item_names.append(name)

        return item_names

    def get_most_stocked_item(self):
        most_stocked_item_name = None
        largest_quantity = 0

        for item in self.items.values():
            name = item["name"]
            quantity = item["quantity"]

            if quantity > largest_quantity:
                most_stocked_item_name = name
                largest_quantity = quantity

        return most_stocked_item_name

Student Class

Write a Student class, as defined below, that keeps track of all created students.

Your class should implement the following methods, class variables and properties:

  • An instance attribute called name.
  • A property called grade that returns the grade of that student. Trying to set the grade should raise a ValueError if the new grade is not a number between 0 and 100.
  • A static method called calculate_average_grade(students) that accepts a list of Student objects and returns the average grade for those students. If there are no students in the list, it should return -1.
  • A class variable called all_students that stores all of the student objects that have been created in a list.
  • A class method named get_average_grade() which returns the average grade of all created students.
  • A class method named get_best_student() which returns the student object with the best grade out of all the currently created students. If there are no students created this method should return None. You may assume there will always be one student with the best grade, except in the case where there are no students created.

See below for an example of the behavior of the Student class.

>>> Student.get_average_grade()
-1
>>> student1 = Student("Antoine", 75)
>>> student1.name
"Antoine"
>>> student1.grade
75
>>> student1.grade = 150
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
ValueError: New grade not in the accepted range of [0-100].
>>> student1 == Student.get_best_student()
True

Solution

class Student:
    all_students = []

    def __init__(self, name, grade):
        self.name = name
        self._grade = grade
        Student.all_students.append(self)

    @property
    def grade(self):
        return self._grade

    @grade.setter
    def grade(self, new_grade):
        if new_grade < 0 or new_grade > 100:
            raise ValueError("New grade not in the accepted range of [0-100].")
        self._grade = new_grade

    @classmethod
    def get_best_student(cls):
        best_student = None
        for student in cls.all_students:
            if best_student == None or best_student.grade < student.grade:
                best_student = student
        return best_student

    @classmethod
    def get_average_grade(cls):
        return cls.calculate_average_grade(cls.all_students)

    @staticmethod
    def calculate_average_grade(students):
        if len(students) == 0:
            return -1

        total = 0
        for student in students:
            total += student.grade
        return total / len(students)

Geometry Inheritance

Create 4 classes: Polygon, Triangle, Rectangle and Square. The Triangle and Rectangle class should be subclasses of Polygon, and Square should be a subclass of Rectangle.

Your Polygon class should raise a NotImplementedError when the get_area() and get_sides() methods are called. However, it should correctly return the perimeter of the polygon when get_perimeter() is called. Treat the Polygon class as an abstract class.

Your Triangle class should have a constructor that takes in 3 arguments, which will be the lengths of the 3 sides of the triangle. You may assume the sides passed to the constructor will always form a valid triangle.

Your Rectangle class should have a constructor that takes in 2 arguments, which will be the width and height of the Rectangle.

Your Square class should have a constructor that takes in 1 argument, which will be the length of each side of the Square.

Your Triangle and Rectangle classes should both implement the following methods:

  • get_sides(): This method returns a list containing the lengths of the sides of the shape.
  • get_area(): This method returns the area of the polygon.

Your Square class should only have an implementation for its constructor, and rely on the Rectangle superclass for implementations of get_sides() and get_area().

Note: To calculate the area of a triangle given three side lengths (x, y and z) you can use the following formula. First calculate the semi perimeter s using: s = (x + y + z) / 2. Then calculate the area A using: A = math.sqrt(s * (s - x) * (s - y) * (s - z)).

See below for an example of how these classes should behave.

>>> triangle = Triangle(2, 5, 6)
>>> triangle.get_area()
4.68
>>> Square(4).get_perimeter()
16
>>> Rectangle(3, 5).get_sides()
[3, 5, 3, 5]

Solution

import math


class Polygon:
    def get_sides(self):
        raise NotImplementedError

    def get_area(self):
        raise NotImplementedError

    def get_perimeter(self):
        return sum(self.get_sides())


class Triangle(Polygon):
    def __init__(self, side1, side2, side3):
        self.sides = [side1, side2, side3]

    def get_sides(self):
        return self.sides

    def get_area(self):
        side1, side2, side3 = self.sides
        return get_triangle_area(side1, side2, side3)


class Rectangle(Polygon):
    def __init__(self, width, height):
        self.width = width
        self.height = height

    def get_sides(self):
        return [self.width, self.height, self.width, self.height]

    def get_area(self):
        return get_rectangle_area(self.width, self.height)


class Square(Rectangle):
    def __init__(self, side_length):
        super().__init__(side_length, side_length)


def get_triangle_area(side1, side2, side3):
    semi_perimeter = (side1 + side2 + side3) / 2
    return math.sqrt(
        semi_perimeter * 
        (semi_perimeter - side1) * 
        (semi_perimeter - side2) * 
        (semi_perimeter - side3)
    )


def get_rectangle_area(width, height):
    return width * height

Deck Class

Create a Deck class that represents a deck of 52 playing cards. The Deck should maintain which cards are currently in the deck and never contain duplicated cards. Cards should be represented by a string containing their value (2 - 10, J, Q, K, A) followed by their suit (D, H, C, S). For example, the jack of clubs would be represented by “JC” and the three of hearts would be represented by “3H”.

Your Deck class should implement the following methods:

  • shuffle(): This method shuffles the cards randomly, in place. You may use the random.shuffle() method to help you do this.
  • deal(n): This method removes and returns the last n cards from the deck in a list. If the deck does not contain enough cards it returns all the cards in the deck.
  • sort_by_suit(): This method sorts the cards by suit, placing all the hearts first, diamonds second, clubs third and spades last. The order within each suit (i.e. the card values) does not matter. This method should sort the cards in place, it does not return anything.
  • contains(card): This method returns True if the given card exists in the deck and False otherwise.
  • copy(): This method returns a new Deck object that is a copy of the current deck. Any modifications made to the new Deck object should not affect the Deck object that was copied.
  • get_cards(): This method returns all the cards in the deck in a list. Any modifications to the returned list should not change the Deck object.
  • len(): This method returns the number of the cards in the Deck.

Your deck should always start with exactly 52 cards that are distributed across 4 suits and 13 values where there are no duplicate cards.

See below for an example of how the Deck class should behave.

>>> d = Deck()
>>> d.shuffle()
>>> d.deal(3)
["AS", "2H", "4D"]
>>> d.contains("4D")
False
>>> d.sort_by_suit()
>>> d.deal(3)
['2S', '5S', 'JS']
>>> len(d)
46

Solution

import random


class Deck:
    suits = ["H", "D", "C", "S"]
    values = [str(i) for i in range(2, 11)] + ["J", "Q", "K", "A"]

    def __init__(self):
        self.cards = []
        self.fill_deck()

    def fill_deck(self):
        for suit in Deck.suits:
            for value in Deck.values:
                card = value + suit
                self.cards.append(card)

    def shuffle(self):
        random.shuffle(self.cards)

    def deal(self, n):
        dealt_cards = []

        for i in range(n):
            if len(self.cards) == 0:
                break

            card = self.cards.pop()
            dealt_cards.append(card)

        return dealt_cards

    def sort_by_suit(self):
        cards_by_suit = {"H": [], "D": [], "C": [], "S": []}

        for card in self.cards:
            suit = card[-1]
            cards_by_suit[suit].append(card)

        self.cards = (
            cards_by_suit["H"] +
            cards_by_suit["D"] +
            cards_by_suit["C"] +
            cards_by_suit["S"]
        )

    def contains(self, card):
        return card in self.cards

    def copy(self):
        new_deck = Deck()
        new_deck.cards = self.cards[:]
        return new_deck

    def get_cards(self):
        return self.cards[:]

    def __len__(self):
        return len(self.cards)

FileSystem Implementation

In this question, you need to implement a very simplistic FileSystem class that mimics the way that your own computer’s FileSystem works. A FileSystem starts empty with only a root node which will always be a directory.

A FileSystem is a tree-like structure composed of nodes, each of which is either a File or Directory.

Files are simplest and only have name and contents as attributes; which correspond to the name of the file and its contents, respectively. Files also have a write method, which sets the contents of that file to the argument passed in. Additionally, files override the len dunder method which returns the number of characters in the contents of that file.

Directories have a name and a children attribute. children is a dictionary that stores the name of its children nodes as keys, and the nodes themselves as the values of that dictionary. Directories also have the add and delete methods which are used to add or delete nodes from its children dictionary.

For your convenience, the str methods of each class have been overridden so that you may debug your FileSystem more easily.

Your task is to implement the following methods on the FileSystem class:

  • create_directory(path): This method should create a Directory inside the FileSystem at the location specified. For instance, create_directory(“/dir1”) should create a directory as a child of the root of the filesystem called “dir1”. Running create_directory(“/dir1/dir2”) should create another directory, dir2, inside the one that was just created. If the path is malformed or the operation is impossible, it should raise a ValueError.
  • create_file(path, contents): This method should create a new file at the desired path, with the contents passed in. If the operation is impossible, it should raise a ValueError.
  • read_file(path): This method should return the contents of the file at the path parameter. If no such file exists, it should raise a ValueError.
  • delete_directory_or_file(path): This method should delete the node located at path. It should work on files and directories alike, and should raise a ValueError if that file does not exist.
  • size(): This method should return the number of characters across all files in your filesystem.
  • _find_bottom_node(node_names): This is a private helper method of the FileSystem class that takes in a list of node names and should traverse the filesystem downwards until the last node in the list. For instance, calling this with [“a”, “b”, “c”] should first look for a node a inside the root node of the filesystem, then for a node b inside node a, and then return the node c which should be a child of node b.

Note: for all methods that accept a path parameter you will need to first validate that path and then parse it. The path will be a string, and from that string you’ll need to do one of the following:

  • Obtain the directory object used to create a new directory or file inside of.
  • Obtain the directory object used to delete a directory or file.
  • Obtain the file object to read the contents of.

This is non-trivial because you may need to distinguish between the name of the new node create and the path where this node should be created.

See below for an example of how these classes should behave.

>>> fs = FileSystem()
>>> fs.create_directory("/dir1")
>>> fs.create_file("/file1.txt", "Hello World!")
>>> print(fs)
*** FileSystem ***
/ (Directory)
  dir1 (Directory)
  file1.txt (File) | 12 characters
***
>>> fs.delete("/file2.txt")
ValueError: file2.txt does not exist.

Solution

class FileSystem:
    def __init__(self):
        self.root = Directory("/")

    def create_directory(self, path):
        FileSystem._validate_path(path)

        path_node_names = path[1:].split("/")
        middle_node_names = path_node_names[:-1]
        new_directory_name = path_node_names[-1]

        before_last_node = self._find_bottom_node(middle_node_names)
        
        if not isinstance(before_last_node, Directory):
            raise ValueError(f"{before_last_node.name} isn't a directory.")
        
        new_directory = Directory(new_directory_name)

        before_last_node.add_node(new_directory)

    def create_file(self, path, contents):
        FileSystem._validate_path(path)

        path_node_names = path[1:].split("/")
        middle_node_names = path_node_names[:-1]
        new_file_name = path_node_names[-1]

        before_last_node = self._find_bottom_node(middle_node_names)
        
        if not isinstance(before_last_node, Directory):
            raise ValueError(f"{before_last_node.name} isn't a directory.")
        
        new_file = File(new_file_name)
        new_file.write_contents(contents)

        before_last_node.add_node(new_file)

    def read_file(self, path):
        FileSystem._validate_path(path)

        path_node_names = path[1:].split("/")
        middle_node_names = path_node_names[:-1]
        file_name = path_node_names[-1]

        before_last_node = self._find_bottom_node(middle_node_names)
        
        if not isinstance(before_last_node, Directory):
            raise ValueError(f"{before_last_node.name} isn't a directory.")

        if file_name not in before_last_node.children:
            raise ValueError(f"File not found: {file_name}.")
            
        return before_last_node.children[file_name].contents

    def delete_directory_or_file(self, path):
        FileSystem._validate_path(path)

        path_node_names = path[1:].split("/")
        middle_node_names = path_node_names[:-1]
        node_to_delete_name = path_node_names[-1]

        before_last_node = self._find_bottom_node(middle_node_names)
        
        if not isinstance(before_last_node, Directory):
            raise ValueError(f"{before_last_node.name} isn't a directory.")

        if node_to_delete_name not in before_last_node.children:
            raise ValueError(f"Node not found: {node_to_delete_name}.")
            
        before_last_node.delete_node(node_to_delete_name)

    def size(self):
        size = 0
        nodes = [self.root]
        while len(nodes) > 0:
            current_node = nodes.pop()
            if isinstance(current_node, Directory):
                children = list(current_node.children.values())
                nodes.extend(children)
                continue

            if isinstance(current_node, File):
                size += len(current_node)

        return size

    def __str__(self):
        return f"*** FileSystem ***\n" + self.root.__str__() + "\n***"
    
    @staticmethod
    def _validate_path(path):
        if not path.startswith("/"):
            raise ValueError("Path should start with `/`.")


    def _find_bottom_node(self, node_names):
        current_node = self.root
        for node_name in node_names:
            if not isinstance(current_node, Directory):
                raise ValueError(f"{current_node.name} isn't a directory.")

            if node_name not in current_node.children:
                raise ValueError(f"Node not found: {node_name}.")

            current_node = current_node.children[node_name]
            
        return current_node


class Node:
    def __init__(self, name):
        self.name = name

    def __str__(self):
        return f"{self.name} ({type(self).__name__})"


class Directory(Node):
    def __init__(self, name):
        super().__init__(name)
        self.children = {}

    def add_node(self, node):
        self.children[node.name] = node

    def delete_node(self, name):
        del self.children[name]

    def __str__(self):
        string = super().__str__()

        children_strings = []
        for child in list(self.children.values()):
            child_string = child.__str__().rstrip()
            children_strings.append(child_string)

        children_combined_string = indent("\n".join(children_strings), 2)
        string += "\n" + children_combined_string.rstrip()
        return string


class File(Node):
    def __init__(self, name):
        super().__init__(name)
        self.contents = ""

    def write_contents(self, contents):
        self.contents = contents

    def __len__(self):
        return len(self.contents)

    def __str__(self):
        return super().__str__() + f" | {len(self)} characters"


def indent(string, number_of_spaces):
    spaces = " " * number_of_spaces
    lines = string.split("\n")
    indented_lines = [spaces + line for line in lines]
    return "\n".join(indented_lines)