# ---
# jupyter:
#   jupytext:
#     text_representation:
#       extension: .py
#       format_name: light
#       format_version: '1.5'
#       jupytext_version: 1.14.4
#   kernelspec:
#     display_name: Python 3
#     language: python
#     name: python3
# ---

# + [markdown] slideshow={"slide_type": "slide"}
# # Class 2: Virtual Environments and Object-oriented Programming (OOP)
# -

# ## Review

# * Describe the function of the `break` and `continue` statements.
#
# ```{dropdown} Solution
# Within the context of a loop, `continue` is used to invoke an immediate progression to the next step, and `break` is used to terminate the loop before the iterator is exhausted.
# ```
#
# * Briefly explain type hinting in Python and its significance.
#
# ````{dropdown} Solution
# Type hinting is implemented by adding `: <type>` to a variable or function argument's definition. Any type can be used, and Python's built-in `typing` module enables creating fine-grained type hints.
# ````
#
# * How would you recommend implementing a simple file I/O operation?
#
# ````{dropdown} Solution
# The recommended way to implement a simple file I/O operation in Python is by combining the `open()` function with a context manager clause (`with ... as file_object:`). This way we don't have to concern ourselves with closing the file.
#
# For example:
# ```python
# path = "/path/to/file.txt"
# with open(path, "r") as file_object:
#     content = file_object.read()
# ...
# ```
# ````

# + [markdown] slideshow={"slide_type": "slide"}
# ## Virtual Environments

# + [markdown] slideshow={"slide_type": "subslide"}
# The basic idea is that **every Python project you have should be contained within an isolated environment**, which includes the interpreter and any libraries (i.e. external Python packages) that are needed for that specific project.
# -

# ### Why do we need virtual environments?

# Once a project accumulates a number of dependencies (external Python packages used in your code), asserting compatibility between all components become tricky (see [**"dependency hell"**](https://en.wikipedia.org/wiki/Dependency_hell)). Python libraries are updated regularly, and each released version may introduce new bug fixes and features, or remove functionality that was flagged as depracated. Code that was written using one version of some external package will not necessarily run successfully using a different version. Virtual environments allow us to create as many isolated environments as we'd like, each containing only the libraries we need, and in the exact versions that guarantee compatibility.

# + [markdown] slideshow={"slide_type": "subslide"}
# ![Summary of environments and namespaces](envs.png)
# -

# If this concept feels a little vague, don't be too worried. Once you've created a couple of virtual environments you'll get it in no time.

# (create)=
# ### Creating a Virtual Environment

# ```{margin}
# To learn more about other available virtual environment management tools, see the {ref}`resources-venv` section under {ref}`resources <resources>`.
# ```

# + [markdown] slideshow={"slide_type": "subslide"}
# There are numerous tools for creating and managing virtual environments with Python. In this course we will use [`venv`](https://docs.python.org/3/library/venv.html#module-venv), which is one of Python's built-in packages and is therefore a simple option that will always be available to you. 
# -

# Creating a new virtual environment with `venv` is as easy as running:
#
# ````{tabbed} Linux/macOS
#
# ```console
# $ python3 -m venv /path/to/new/virtual/environment/
# ```
#
# ````
#
# ````{tabbed} Windows
#
# ```doscon
# C:\> C:\Python38\python -m venv C:\path\to\new\virtual\environment\
# ```
#
# ````
#
# We are calling `<python binary> -m` to execute the `venv` module as a script and pass it the path in which it should create the new virtual environment. 

# ### The Virtual Environment Directory

# Looking at the newly created virtual environment's directory tree, please note the following files and folders:
#
# ```
# venv
# ├── bin/
# │   ├── python
# │   ├── pip
# │   ├── activate
# │   ├── <Other binaries and CLI tools>
# ├── lib/
# │   ├── python3.8/
# │   │   ├── site-packages/
# │   │   │   ├── <pip-installed packages>
# ├── <...>
# ```
#
# * `venv/bin/python`: The copy of the Python interpreter used when the virtual environment is activated.
# * `venv/bin/pip`: The copy of the `pip` CLI used when the virtual environment is activated.
# * `venv/bin/activate`: A script we will use to activate the virtual environment (making the prior two files the default `python` and `pip` executables).
# * `lib/python3.8/site-packages/`: This directory will contain all external packages installed with `pip install <package-name>` when our virtual environment is activated.

# ### Activating the Virtual Environment

# Activating a virtual environment means modifying your working environment to use a particular, isolated installation of Python (created in {ref}`create`). This will normally also "override" some other related CLI tools with those installed within the virtual environment's `bin` directory (such as `pip`), and sometimes also change some OS-related environment variables.

# To activate the virtual environment and use the newly created isolated Python environment, simply run:
#
# ````{tabbed} Linux/MacOS
#
# ```console
# $ source <venv>/bin/activate
# ```
# *\* Replace `<venv>` with your virtual environment's directory path.*
#
# ````
#
# ````{tabbed} Windows
#
# ```doscon
# C:\> <venv>\Scripts\activate
# ```
# *\* Replace `<venv>` with your virtual environment's directory path.*
#
# ````
#
# You should now see your virtual environment's directory name used as a prefix (`(venv)`) in your terminal window.
#
# To validate the activation, run:
#
# ````{tabbed} Linux/MacOS
#
# ```console
# (venv) $ which python
# <venv>/bin/python
# ```
#
# ````
#
# ````{tabbed} Windows
#
# ```doscon
# (venv) C:\> where python
# <venv>\Scripts\python
# ```
#
# ````
#
# The returned path should be the path of the Python interpreter within the virtual environment's directory.
#
# For the purposes of this course one virtual environment will probably be enough. Always remember to activate it before starting work, and be sure to create a new virtual environment for any new project you begin in the future.

# ```{admonition} Project Setup Exercise
#     :class: attention
# 1. Create a new `my_project` directory on your computer.
# 2. Start VSCode and open your new project's directory.
# 3. Initialize Git to add version control to your project.
# 4. Create a virtual environment for your project and activate it (this will also let VSCode know what Python executable is used in this project).
# 5. Create a `.py` file with a function that simply prints your name. 
# 6. Create another `.py` file that `import`s your function and runs it.
# 7. Install the `numpy` package using `pip` and make sure you're able to `import` it.
# 8. Add an [MIT license](https://opensource.org/licenses/MIT) to the repo, as well as a basic `README.md`.
# 9. If the virtual environment's directory is found under the project's directory, create a `.gitignore` file and add its relative path to it. This will tell Git to ignore this directory (and not archive everything in it as part of our code repository, which would be an absolute mess). 
# 10. Create your first commit.
# 10. Publish this project to your GitHub account.
# ```

# + [markdown] slideshow={"slide_type": "slide"}
# ## Object-Oriented Programming: Part 1

# + [markdown] slideshow={"slide_type": "slide"}
# ### Introduction

# + [markdown] slideshow={"slide_type": "subslide"}
# There are three main programming paradigms in use in mainstream programming languages:
# * Procedural
# * Functional
# * Object-oriented
#
# While the _functional_ paradigm is very interesting, we will not be discussing it in this course. You can read about Haskell, OCaml, F# and other functional programming languages wherever you get your information from.
# -

# ### Procedural Programming

# [![Procedural Programming PB&J](http://img.youtube.com/vi/Ct-lOOUqmyY/0.jpg)](https://www.youtube.com/watch?v=Ct-lOOUqmyY "Video Title")

# The _procedural_ paradigm is the most widely used paradigm... in the academia. And it's probably the one you're most familiar with from your work with MATLAB. 

# For example, if we wanted to write a naive script that multiplies the elements in two lists, we could write something like:

# + slideshow={"slide_type": "subslide"}
l1 = [1, 2, 3]
l2 = [4, 5, 6]
result = []
for item1, item2 in zip(l1, l2):
    result.append(item1 * item2)
result
# -

# If we later run into more lists we need to multiply, we'll again write:

# + slideshow={"slide_type": "subslide"}
l3 = [10, 20, 30]
l4 = [40, 50, 60]
result2 = []
for item3, item4 in zip(l3, l4):
    result2.append(item3 * item4)
result2


# + [markdown] slideshow={"slide_type": "subslide"}
# At this point we'll recognize a pattern and immediately be rememinded of the DRY ("Don't Repeat Yourself") principle, leading us to define a function and replace these two parts:

# + slideshow={"slide_type": "subslide"}
def list_multiplier(l1, l2):
    """
    Multiply two lists element-wise.
    
    Parameters
    ----------
    l1 : list
        First list
    l2 : list
        Second list    
    
    Returns
    -------
    list
        Element-wise multiplication result
    """
    result = []
    for item1, item2 in zip(l1, l2):
        result.append(item1 * item2)
    
    return result


# + [markdown] slideshow={"slide_type": "subslide"}
# This new procedure does one thing, and one thing only. This is what's so powerful about it.
#
# Procedural programming allows us to group and order our code base into small units, called functions or **procedures**, that have a specific, defined task.
#
# It usually contains a "wrapper" script that defines the order of running for these functions:
# ```python
# def run_pipeline(foldername):
#     """Main data pipeline script."""
#     data = get_user_input(foldername)
#     data_without_fieldnames = extract_fieldnames(data)
#     columnar_data = generate_columns(data_without_fieldnames, num_of_columns)
#     # ...
#     # At the end of the file it will contain:
#
# if __name__ == '__main__':
#     foldername = '/path/to/folder'
#     result = run_pipeline(foldername)
#     print(result)
# ```

# + [markdown] slideshow={"slide_type": "subslide"}
# You should decisively eliminate any repeating code. It's perhaps the most common source for errors in scientific computing, and it may bite you any of these ways:
#
# * __Encapsulation__
#
#     ```python
#     # String concatenation
#     first_string = 'abcd'
#     second_string = 'efgh'
#     concat = first_string + second_string[:-1] + 'zzz'  # you suddenly remember that you wish to exclude 
#     # the last character in "second_string" and add the 'zzz' sequence at the end.
#     # Program continues...
#     # ...
#     third_string = 'poiu'
#     fourth_string = 'qwer'
#     concat2 = third_string + fourth_string + 'zzz' # you wish to achieve the same goal in this 
#     # concatenation - but you forgot that you excluded the last character of the second string.
#     ```
#     The moment you realized that you have a recurring operation on strings - you have to encapsulate it in a function. Be _ruthless!_  
#
# * __Parametrization__
#
#     Instead of writing:
#
#     ```python
#     def process_data(data):
#         scaled_data = data * 0.3  #  what is 0.3 exactly? Parametrize it.
#     ```
#     
#     We might do:
#     
#     ```python
#     def process_data(data, na_concentration=0.3):
#         """Multiplies data by the Na concentration."""
#         scaled_data = data * na_concentration
#     ```
#     But this is usually not enough. When calling the `process_data`, parameterize the `na_concentration` variable as well. This will help you avoid a situation such as:
#
#     ```python
#     data = b * c - 1 + a
#     process_data(data, 0.4)
#     # Script continues...
#     process_data(data2, 0.5)  # Perhaps you really did wish to call "process_data" with two different
#     # parameters, but it's more likely that you decided that 0.5 was too high, so you changed it to 0.4
#     # in the first call, but forgot that you had a second call. This parameter should appear somewhere at
#     # the top of your script.
#     ```
#     Instead, we can specify module-wide constants, e.g.:
#     ```python
#     NA_CONCENTRATION = 0.4
#
#     def process_data(data, na_concentration=NA_CONCENTRATION):
#         """Multiplies data by the Na concentration."""
#         scaled_data = data * na_concentration
#
#     data = b * c - 1 + a
#     process_data(data)
#     # If we really do wish to process the data with some other na_concentration:
#     process_data(data2, na_concentration=0.3) 
#     ```    
#     
#
# While procedural programming works great for most simple tasks, it might be considered inferior when writing code that is meant to scale and be collaborated on. 

# + [markdown] slideshow={"slide_type": "slide"}
# ### Classes and Objects

# + [markdown] slideshow={"slide_type": "subslide"}
# Classes are user-defined types. Just like `str`, `dict`, `tuple` and the rest of the standard types, Python allows us to create our own types.
#
# Objects are _instances_ of classes, they're an instance of a type we made. Actually, all instances of all types are objects in Python. It means that every variable and function in Python are, by themselves, an instance of a type. A function you make is an instance of the `function` type, for example. We'll get to this during later stages of the course.
#
# Classes are a type of abstraction we create with our code. A variable is the most simple type of abstraction - it's a _thing_ that is closely tied to a "real value" in a very simple relationship: My variable $x$ represents the value $y$. 
#
# Classes are more abstract - they don't relate to a specific value directly, but rather they try to convey an idea of an object.

# + [markdown] slideshow={"slide_type": "slide"}
# #### Example I: The `Point` Class

# + [markdown] slideshow={"slide_type": "subslide"}
# To show what we mean by "our own type", we'll define the `Point` type.
#
# So, what is a point? 
# * In a 2D space it's a pair of values, $(x, y)$, specifying a location on a grid.
# * $x$ and $y$ are the coordinates of the point.
# * Points have special relations to other points and to the space they reside in.
#
# From these three simple observations, we expect our `Point` type to include both data about its coordinates, and functions, or **methods**, used to interact with the grid and\or other points. 
#
# An object usually bundles together data (attributes) and methods we wish to express as some abstract template in our code. It might seem like a lot to write at first, but it pays off tremendously in no time.

# + slideshow={"slide_type": "subslide"}
# Introducing the class keyword:
class Point:
    """Represents a point in a 2D space."""
    pass

Point
# A new type is born in __main__
# -

# The name `Point` is now a factory to create new `Point` instances. To make one, we have to call it like we do with a function:

# + slideshow={"slide_type": "subslide"}
blank = Point()
blank
# -

# We call this *instantiation* (and `blank` is now an *instance* of `Point`).

# + slideshow={"slide_type": "subslide"}
# Assign the point's data in the form of coordinates
blank.x = 1.0
blank.y = 0.0

# x and y are now attributes of our class:
blank.x

# + [markdown] slideshow={"slide_type": "subslide"}
# The `.` means `x` is an attribute or method (callable) of `blank` (and of course there's no conflict between a variable named `x` and `blank.x`).

# + slideshow={"slide_type": "subslide"}
print(1 + blank.x)

# + slideshow={"slide_type": "subslide"}
f"A case of a pointy Point at {(blank.x, blank.y)}"


# + slideshow={"slide_type": "subslide"}
def print_point(p: Point) -> str:
    """Print a Point object.
    
    Parameters
    ----------
    p : Point
        The point instance to print
    
    Returns
    -------
    str
        Point coordinates
    """
    print(f"{p.x, p.y}")

print_point(blank)


# -

# `````{admonition} Exercise: calculate_distance()
#     :class: attention
# Write the `calculate_distance()` function that takes two points (`p1` and `p2`) and returns the Cartesian distance between them.    
# ````{dropdown} Solution
# ```python
# import math
#
# def calculate_distance(p1: Point, p2: Point) -> float:
#     """
#     Returns the Cartesian distance between two points.
#
#     Parameters
#     ----------
#     p1 : Point
#         One point
#     p2 : Point
#         Second point
#
#     Returns
#     -------
#     float
#         Cartesian distance
#     """
#     dx = p1.x - p2.x
#     dy = p1.y - p2.y
#     return math.sqrt(dx ** 2 + dy ** 2)
# ```
# ````
# `````

# #### Example II: Rectangles

# + [markdown] slideshow={"slide_type": "subslide"}
# Take a minute to think how you would implement a `Rectangle` class. 
#
# Here are a couple of options:
#
# * We can decide to define it with a point (corner or center) and two sides.
# * We can also use two opposing points.
#
# We'll go with the first option, with the point being the corner.

# + slideshow={"slide_type": "subslide"}
class Rectangle:
    """
    Rectangle model.
    
    Attributes
    ----------
    corner : Point
        Bottom left corner
    height : float
        Length of vertical side
    width : float
        Length of horizontal size
    """
    pass

rect = Rectangle()
rect.width = 100.0
rect.height = 200.0
corner = Point()
corner.x = 0.0
corner.y = 0.0
rect.corner = corner

rect


# -

# We can return instances of classes (just like we do with instances of dictionaries):

# + slideshow={"slide_type": "subslide"}
def find_center(rect: Rectangle) -> Point:
    """ 
    Return a Point instance with coordinates pointing to the center of the Rectangle.
    
    Parameters
    ----------
    rect : Rectangle
        Rectangle instance to calculate the center of
    
    Returns
    -------
    Point
        Rectangle center
    """
    p = Point()
    p.x = rect.corner.x + rect.width / 2
    p.y = rect.corner.y + rect.height / 2
    return p

center = find_center(rect)
print_point(center)


# -

# Also, objects are mutable:

# + slideshow={"slide_type": "subslide"}
def grow_rectangle(rect: Rectangle, dwidth: float, dheight: float) -> None:
    """
    Take a Rectangle instance and grow it by (*dwidth*, *dheight*).

    Parameters
    ----------
    rect : Rectangle
        Rectangle instance to grow
    dwidth : float
        Width delta
    dheight : float
        Height delta
    """
    rect.width += dwidth
    rect.height += dheight



# -

print(rect.width, rect.height)

grow_rectangle(rect, 100, 100)
print(rect.width, rect.height)


# #### Methods

# + [markdown] slideshow={"slide_type": "slide"}
# We really haven't done object-oriented programming yet. Our objects currently contain only data (as attributes), and we wrote independent functions to manipulate them as required. *Methods* are functions bound to objects, describing actions they can do, or that can be done to them. 
#
# For example, a real-world car can drive. So a `Car` object should have a `drive()` method. It should also have a `park()` method, and a couple of attributes, like `number_of_wheels`, `manufacturer` and `model`.
#
# As we'll see in a second, the only difference between methods and functions is that methods are a part of an object, and they only make sense the context of that object or an instance of it. A `park()` method has no meaning when we try to run it on a `Rectangle`.
#
# We've already met many methods and used them successfully. For example, we used the `append()` method of a list instance. In this case it's clear why a method is always bound to a specific class - it's irrelevant to "append" an item to an object which is not a list.
#
# Let's add a method to our Point object:

# +
class Point:
    """A 2D point."""
    
    def transpose(self): # The first argument to a method is always the calling instance itself (self)
        """Trasnposes by flipping x and y"""
        self.x, self.y = self.y, self.x

p = Point()
p.x, p.y = 10, 20
# -

print(f"transpose is now of type: {type(p.transpose)}")

print(f"Before:\tp.x: {p.x}, p.y: {p.y}")
p.transpose()
print(f"After:\tp.x: {p.x}, p.y: {p.y}")

# The conceptual change here is the following: The active agents here are the *objects*, not the functions. Instead of `transpose(point)` we have the point transpose itself with `p.transpose()`.
#
# In general, most functions that take an instance of some object as one of their parameters should be a candidate for becoming a method, bound to that object, since you might need it later on for other instances as well.
#
# :::{note} 
# Even though methods have `self` as their first argument, when we call them we don't need to pass that first parameter. `self` acts as a reference to the instance, or object, that we're currently handling. This is what makes methods "special" - they work with the data "inside" the object they're a part of, and can modify this data if needed. All methods must be defined with the `self` parameter as their first parameter (`self` isn't actually a special keyword, rather it's just the convention for the first argument in the method definition).
# :::

# This doesn't work, look at the number of arguments:
p.transpose(p)


# Two arguments were given? We gave only one. The second was the `self` argument that is implicitly passed.

# One thing is still missing though, each time we create the point we have a three step process:
# 1. Create the instance: `p = Point()`
# 2. Add the `x` attribute: `p.x = 2`
# 3. Add the `y` attribute: `p.y = 3`
#
# First, it would be nice if we could make this process shorter. Second, the Point instance is really unusable unless it has both attributes `(x, y)` set, so we want to make sure that we don't have a Point without both `x` and `y`. This is accomplished by the `__init__` method. 

# #### The `__init__` Method

# ```{margin}
# `__init__()` is one example of a *dunder* method. *dunder* is short for *double underscore* and generally implies an internal (structural) part of the class itself.
# ```

# + [markdown] slideshow={"slide_type": "slide"}
# Classes have several special methods attached to them. While most are out of the scope of this course, the `__init__()` method is regularly used and we should definitely familiarize ourselves with it. 
#
# The `__init__()` methods allows us to define our class attributes inside the class definition:
# -

class Point:
    """A 2D point."""

    def __init__(self, x: float, y: float) -> None:
        """
        Initialize a new Point instance.

        Parameters
        ----------
        x : float
            X-axis coordinate
        y : float
            Y-axis coordinate
        """
        self.x = x
        self.y = y

    def transpose(self) -> None:
        """
        Trasnposes by flipping *x* and *y*.
        """
        self.x, self.y = self.y, self.x



# Now, in order to create a `Point` instance, we have to pass in the two arguments that the `__init__()` method requires:

p = Point(10, 20)
print(f"p.x: {p.x}, p.y: {p.y}")
p.transpose()
print(f"p.x: {p.x}, p.y: {p.y}")

p2 = Point()


# As we said, this is better because we enforce our `Point` user to initialize all attributes, which eases the use of the other methods the `Point` has. Most chances are that the first method you'll write for a newly defined class is the `__init__()` method. 
#
# Let's look at a broader example using the Rectange we defined earlier and the two other functions we also had.

class Rectangle:
    """
    Representation of a rectangle in Cartesian space based on the
    bottom left corner and the sizes of its sides.
    """

    def __init__(self, corner: Point, height: float = 10, width: float = 10):
        """
        Initialize a Rectangle instance.

        Parameters
        ----------
        corner : Point
            Bottom left corner
        height : float
            Length of vertical side
        width : float
            Length of horizontal side
        """
        self.corner = corner
        self.height = height
        self.width = width

    def find_center(self) -> Point:
        """
        Return a Point to the center of the Rectangle box.

        Returns
        -------
        Point
            This rectangle instance's center
        """
        x = self.corner.x + self.width / 2
        y = self.corner.y + self.height / 2
        return Point(x, y)

    def grow(self, dwidth: float, dheight: float) -> None:
        """
        Change this instance's size by (*dwidth*, *dheight*).

        Parameters
        ----------
        dwidth, dheight : float
            Change the first and second axes by +dwidth\dheight
        """
        self.width += dwidth
        self.height += dheight

    def move_to_origin(self) -> None:
        """Moves the center of the rectangle to (0, 0)."""
        center = self.find_center()
        centered = center.x == 0 and center.y == 0
        if not centered:
            self.corner = Point(-self.width / 2, -self.height / 2)



# :::{note}
# * Class names are written in CamelCase.
# * The docstring of the entire class describes its general purpose.
# * The `__init__` method takes in three arguments, but two of them are optional.
# * We added the two functions we defined earlier to the class as methods, since they only operate on rectangles in the first place.
# :::

# + slideshow={"slide_type": "subslide"}
rect = Rectangle(p)
print(f"rect.width: {rect.width}, rect.height: {rect.height}")
# -

# If we now wish to create a new `Rectangle` instance and find its center, we can:

# + slideshow={"slide_type": "subslide"}
corner = Point(10, 10)
rect = Rectangle(corner, 4, 4)
center = rect.find_center()
print(f"The center of the rectange is {(center.x, center.y)}")
# -

# Move it to origin:

rect.move_to_origin()
new_center = rect.find_center()
print(f"The center of the moved rectange is {(new_center.x, new_center.y)}")


# Note how the object modifies itself and acts upon itself using its methods. We're not modifying the internal parts of the instance ourselves, we let the methods do it for us.

# #### The `__str__` Method

# + [markdown] slideshow={"slide_type": "subslide"}
# Another interesting _dunder_ (_"double underscore"_) method is the `__str__()` method, which defines what an instance of the class will show when invoked with the `print(class_instance)` command. For example:

# + slideshow={"slide_type": "subslide"}
class ShoppingList:
    def __init__(self, vegetables=10, fruit=5, bread=1):
        self.vegetables = vegetables
        self.fruit = fruit
        self.bread = bread
    
    def __str__(self):
        n_items = self.vegetables + self.fruit + self.bread
        return f"""
        Shopping List:
            Vegetabels: {self.vegetables}
            Fruits: {self.fruit}
            Bread: {self.bread}
            Total items: {n_items}
        """


# -

shopping_list = ShoppingList()
print(shopping_list)

# :::{note}
# We can change the order of parameters when using keyword arguments, e.g.:
#
# ```python
# shopping_list_2 = ShoppingList(fruit=5, bread=1, vegetables=3)
# ```
# :::

# `````{admonition} The _str_ Dunder Method
#     :class: attention
# Implement a `__str__()` method for the `Point` class from earlier.
#
# ````{dropdown} Solution
# ```python
# class Point:
#     """A 2D point."""
#
#     def __init__(self, x: float, y: float) -> None:
#         """
#         Initialize a new Point instance.
#
#         Parameters
#         ----------
#         x : float
#             X-axis coordinate
#         y : float
#             Y-axis coordinate
#         """
#         self.x = x
#         self.y = y
#     
#     def __str__(self) -> str:
#         """
#         Returns the string representation of this instance.
#         
#         Returns
#         -------
#         str
#             String represetation
#         """
#         return f"({self.x}, {self.y})"
#
#     def transpose(self) -> None:
#         """
#         Trasnposes by flipping *x* and *y*.
#         """
#         self.x, self.y = self.y, self.x
#
# ```
# ````
# `````

# + [markdown] slideshow={"slide_type": "slide"}
# #### Operator Overloading

# + [markdown] slideshow={"slide_type": "subslide"}
# One of the most interesting properties of Python (although it's not unique to it) is [operator overloading](https://en.wikipedia.org/wiki/Operator_overloading). It means that we can force our self-declared types (i.e. classes) to behave in a certain way with the standard mathematical operations.
#
# We'll use the ShoppingList class as an example. Say we want to __add__ two different shopping lists. Naively, we might just try the following:

# + slideshow={"slide_type": "subslide"}
shopping_list_a = ShoppingList()
shopping_list_b = ShoppingList()
print(shopping_list_a + shopping_list_b)


# + [markdown] slideshow={"slide_type": "subslide"}
# To us, this expression seems completely fine - adding two shopping lists should just concatenate the items one after the other. The fact that it's a very readable line of code makes it a _good_ line of code, since you have to remember that we write code for humans to read, not computers.
#
# Unfortunately, Python can't add two shopping lists because it was never taught how to do that. Luckily, we can override the behavior of the addition operator, by defining the `__add__()` method in the class definition:

# + slideshow={"slide_type": "subslide"}
class ShoppingList:
    """Represents a shopping list."""

    def __init__(self, vegetables: int = 10, fruit: int = 5, bread: int = 1):
        """
        Initialize a ShoppingList instance.

        Parameters
        ----------
        vegetables : int
            Number of vegetable items
        fruit : int
            Number of fruit items
        bread : int
            Number of bread items
        """
        self.vegetables = vegetables
        self.fruit = fruit
        self.bread = bread

    def __str__(self) -> str:
        """
        Return a string representation of this instance.

        Returns
        -------
        str
            String representation
        """
        n_items = self.vegetables + self.fruit + self.bread
        return f"""
        Shopping List:
            Vegetabels: {self.vegetables}
            Fruits: {self.fruit}
            Bread: {self.bread}
            Total items: {n_items}
        """

    # ----- New method below: ------
    def __add__(self, other: ShoppingList) -> ShoppingList:
        """
        Combines two shopping lists and returns the result.

        Notes
        -----
        This method returns a new shopping list, meaning it doesn't modify
        any of the existing instances it was given.

        Parameters
        ----------
        other : ShoppingList
            Another shopping list

        Returns
        -------
        ShoppingList
            Combined shopping list
        """
        return ShoppingList(
            vegetables=self.vegetables + other.vegetables,
            fruit=self.fruit + other.fruit,
            bread=self.bread + other.bread,
        )



# -

# Now we can safely add two `ShoppingList` instances together:

# + slideshow={"slide_type": "subslide"}
shopping_list_a = ShoppingList()
shopping_list_b = ShoppingList()

shopping_list_c = shopping_list_a + shopping_list_b
print(shopping_list_c)
# -

# Addition of something other than a `ShoppingList` instance will result in an `AttributeError`.

# + slideshow={"slide_type": "subslide"}
shopping_list_a + 1
# -

# `````{admonition} Exercise: _Dunder_ Methods
#     :class: attention    
# Modify the `__add__()` method so that if *other* is an integer, it will simply add that number to all items in the shopping list.
#
# ````{dropdown} Solution
# ```python
# from typing import Union
#
#
# class ShoppingList:
#     """Represents a shopping list."""
#     
#     ...
#     
#     def __add__(self, other: Union[ShoppingList, int]) -> ShoppingList:
#         """
#         Combines two shopping lists or adds *other* to all item categories
#         (if an *int* is provided).
#
#         Notes
#         -----
#         This method returns a new shopping list, meaning it doesn't modify
#         any of the existing instances it was given.
#
#         Parameters
#         ----------
#         other : Union[ShoppingList, int]
#             Another shopping list or number to increase all categories by
#
#         Returns
#         -------
#         ShoppingList
#             New shopping list
#         """
#         if isinstance(other, ShoppingList):
#             return ShoppingList(
#                 vegetables=self.vegetables + other.vegetables,
#                 fruit=self.fruit + other.fruit,
#                 bread=self.bread + other.bread
#             )
#         elif isinstance(other, int):
#             return ShoppingList(
#                 vegetables=self.vegetables + other,
#                 fruit=self.fruit + other,
#                 bread=self.bread + other                
#             )
#         else:
#             raise TypeError(
#                 f"other must by either a ShoppingList instance or an integer, got: {type(other)}"
#             )
#
# ```
# ````
# `````

# :::{note}
# To learn more about other Python operators, see [this overview](https://www.python-course.eu/python3_magic_methods.php).
# :::

# + [markdown] slideshow={"slide_type": "subslide"}
# ### Summary
#
# OOP is the most important programming paradigm for you to master on your Python journey. Some problems fit this paradigm hand in glove, however, it's not the "ultimate" answer to any design difficulty you have. Some problems _can_ be solved by using intricate objects and multiple inheritance, but in reality they're much simpler when solved using a procedural design. Remember to write code that humans, and especially your future self, can read and understand.
#
# With that being said, throughout this course I prefer you write too many objects over writing too few. Whether you'll be writing new objects every day or not, you will certainly be using them any time you write Python code, and creating classes will nurture your confidence when doing so.

# + [markdown] slideshow={"slide_type": "subslide"}
# `````{admonition} Exercise: The Vector Class
#     :class: attention
#
# 1. Create a `Vector` class that simulates a _1D_ vector array. Assume the inputs to the class are valid. The `Vector` instance should be initialized with at least two attributes.
# 2. The `Vector` class should enable adding either an integer or a different vector to a vector.
# 3. The `Vector` class should enable checking which of two vectors is bigger, element-wise. The output is another vector with the corresponding `True` and `False` values.
#
# ````{dropdown} Solution
# ```python
# from typing import Iterable
#
#
# class Vector:
#     """
#     Models a 1D vector array, assuming the inputs are valid.
#
#     Notes
#     -------
#     Integers and other vectors can be added. Element-wise comparison with other vectors
#     is also supported.
#     """
#
#     def __init__(self, data: Iterable):
#         """
#         Initialize a new `Vector` instance.
#
#         Attributes
#         ----------
#         data : Iterable
#             The actual data in a list
#         """
#         self.data = list(data)
#         self.length = len(self.data)
#
#     def __len__(self) -> int:
#         """
#         Allows us to call len() on the vector.
#
#         Returns
#         -------
#         int
#             Vector size
#         """
#         return self.length
#
#     def __str__(self) -> str:
#         """
#         Returns a string representation of the instance.
#
#         Returns
#         -------
#         str
#             String representation
#         """
#         return f"{self.data} with {self.length} elements"
#
#     def __add__(self, other: Union[Vector, int]) -> Vector:
#         """
#         Left-add a Vector to an integer or another vector.
#
#         Parameters
#         ----------
#         other : Union[Vector, int]
#             New `Vector` instance if added with a `Vector`, this instance
#             otherwise
#
#         Returns
#         -------
#         Vector
#             New vector or `self`
#
#         Raises
#         ------
#         AssertionError
#             If length of the supplied vectors doesn't match
#         TypeError
#             If other isn't a vector or an integer
#         """
#         # Handle modifying this instance when provided an integer.
#         if isinstance(other, int):
#             self.data = [datum + other for datum in self.data]
#             return self
#         # Handle creating a new `Vector` instance when provided another vector.
#         elif isinstance(other, Vector):
#             assert len(self) == len(other)
#             new_vec = [
#                 elem0 + elem1 for elem0, elem1 in zip(self.data, other.data)
#             ]
#             return Vector(new_vec)
#         # Otherwise, raise exception.
#         else:
#             raise TypeError(
#                 f"Other object was {type(other)}, expected int or Vector."
#             )
#
#     def __gt__(self, other: Vector) -> bool:
#         """
#         Enables using `>` between two vectors.
#
#         Parameters
#         ----------
#         other : Vector
#             Vector to compare size with
#
#         Returns
#         -------
#         bool
#             Left is greater than right or not
#
#         Raises
#         ------
#         AssertionError
#             If other has a different length
#         TypeError
#             If other isn't a vector
#         """
#         if not isinstance(other, Vector):
#             raise TypeError(
#                 f"Other object was {type(other)}, expected Vector."
#             )
#         assert len(self) == len(other)
#         ans = [elem0 > elem1 for elem0, elem1 in zip(self.data, other.data)]
#         return Vector(ans)
#
#         # The list concatenation does the same thing as:
#         # ans = []
#         # for elem0, elem1 in zip(self.data, other.data):
#         #     ans.append(elem0 > elem1)
#         # but better :)
# ```
# ````
# `````

StarcoderdataJupyterScriptsDedupFiltered