Exercise section 1

class JazzRecord:
    
    def __init__(self, player, album, year):
        self.player = player
        self.album = album
        self.year = year
        
    def as_string(self):
        return "{} (by {}, {})".format(self.album, self.player, self.year)
    
record = JazzRecord("John Coltrane", "Giant Steps", 1957)

print(record.as_string())
Giant Steps (by John Coltrane, 1957)

Exercise section 2

import math

# we have to repeat some class declarations here 
# so that our solution works in this notebook:

class Point2D:
    
    def __init__(self, x0, y0):
        self.x = x0     # set attribute x
        self.y = y0     # set attribute y
        
    def distance_to_origin(self):
        """method which computes length of Vector2D"""
        return math.hypot(self.x, self.y)
    
    def as_string(self):
        return "<Point2D x={}, y={}>".format(self.x, self.y)
    

class Vector2D(Point2D):
        
    def add(self, other):
        """ is called when you execute self + other """
        return Vector2D(self.x + other.x, self.y + other.y)
    
    def length(self):
        return self.distance_to_origin()
    
    def as_string(self):
        return "<Vector2D x={}, y={}>".format(self.x, self.y)


class FancyVector2D(Vector2D):
    
    def __init__(self, x, y, name):
        
        # call method of same name in base class
        super().__init__(x, y)
        self.name = name

    def __len__(self):
        # implements len(..) for a Point2D Object
        return 2
    
    def __str__(self):
        # to string conversion, when str(.) is called
        # or implicitly within print:
        return "<FancyVector2D x={}, y={} name='{}'>".format(self.x, self.y, self.name)
    
    def __add__(self, other):
        # implements "self + other"
        assert isinstance(other, FancyVector2D)
        new_name = "{} + {}".format(self.name, other.name)
        return FancyVector2D(self.x + other.x, self.y + other.y, new_name)
    
    def __getitem__(self, index):
        # square bracket access
        if 0 <= index < 2:
            return (self.x, self.y)[index]
        raise IndexError()
        
    def __eq__(self, other):
        # implements "self == other"
        if type(self) != type(other):
            return False
        return self.x == other.x and self.y == other.y
    
    
    # SOLUTIONS FROM THE EXERCISE:
    
    def scale(self, factor):
        self.x *= factor
        self.y *= factor
        
    def __mul__(self, other):
        assert isinstance(other, Vector2D)  # can also be vector2d here !
        return self.x * other.x + self.y * other.y
        
        
f = FancyVector2D(2, 1, "v1")
f.scale(2)
print(f)

v = Vector2D(1, 2)
print(f * v)
<FancyVector2D x=4, y=2 name='v1'>
8

This solution implements a bit more than the exercise asked for:

class ComplexNumber(Vector2D):
    
    def __str__(self):
        if self.y == 0:
            return str(self.x)
        elif self.x == 0:
            return "{} i".format(self.y)
        elif self.y > 0:
            return "{} + {} i".format(self.x, self.y)
        else:
            return "{} - {} i".format(self.x, -self.y)
        
    def __mul__(self, other):
        re = self.x * other.x - self.y * other.y
        im = self.x * other.y + self.y * other.x
        return ComplexNumber(re, im)
    
    # EVERYTHING WHAT FOLLOWS NOW WAS NOT PART OF THE
    # EXERCISE !
    
    # used to print internals using repr() function    
    # we use the same implementation as for __str__:
    __repr__ = __str__
            
    
    def __neg__(self):
        "implements unary minus"
        return ComplexNumber(-self.x, -self.y)
    
    def __abs__(self):
        "called by abs(..) function"
        return self.length()
    
    def __iadd__(self, other):
        "implementx self += other"
        self.x += other.x
        self.y += other.y
        return self
        
    def __float__(self):
        "implements type conversion to float."
        assert self.y == 0
        return float(self.x)  # could be int
 
    def __hash__(self):
        """computes hash value, required if you want to use complex
        numbers as keys of a dictionary, you als must implent
        __eq__ to complete this"""
        return hash((self.x, self.y))  # use builtin hash function
    
    def __eq__(self, other):
        if type(self) != type(other):
            return False
        return self.x == other.x and self.y == other.y
    
    def __getattr__(self, name):
        "is called when the user uses unknown attributes"
        if name == "real":
            return self.x
        if name == "imag":
            return self.y
        raise AttributeError("instances of ComplexNumber have no attribute '{}'".format(name))
        
    @property
    def conjugate(self):
        """properties are computed attributes, so the client writes
        c.conjugate and not c.conjugate()
        
        we also could have used properites for implementin additional
        real and imag attributes as we did it in __getattr__.
        """
        return ComplexNumber(self.x, -self.y)

        
c1 = ComplexNumber(1, 1)
c2 = ComplexNumber(1, -1)
c3 = ComplexNumber(2, 0)

print("check to string conversion")
print("c1 is", c1)
print("c2 is", c2)
print("c3 is", c3)

print()
print("check multiplication")

print("c1 * c1 is", c1 * c1)
print("c1 * c2 is", c1 * c2)
print("c1 * c3 is", c1 * c3)

print()

print("-c1 is", -c1)
print("abs(c1) is", abs(c1))

print()
c1 += c2
print("c1 += c2 updated c1 to", c1)

print("c3 has no imag value, so conversion to float is", float(c3))

print()
print("dictionary with complex numbers as keys:")
dd = {c3: 1, c2: 2}
print(dd)

# this will also call __repr__ for the keys:
print("dd[c3] is", dd[c3])

print()
print("check if attribute lookup for real and imag works:")
print("c3.real is", c3.real)
print("c3.imag is", c3.imag)

print()
print("c1.conjugate is", c1.conjugate)
check to string conversion
c1 is 1 + 1 i
c2 is 1 - 1 i
c3 is 2

check multiplication
c1 * c1 is 2 i
c1 * c2 is 2
c1 * c3 is 2 + 2 i

-c1 is -1 - 1 i
abs(c1) is 1.4142135623730951

c1 += c2 updated c1 to 2
c3 has no imag value, so conversion to float is 2.0

dictionary with complex numbers as keys:
{2: 1, 1 - 1 i: 2}
dd[c3] is 1

check if attribute lookup for real and imag works:
c3.real is 2
c3.imag is 0

c1.conjugate is 2