<jupyter_start><jupyter_text>Class 3: More OOP and Exception Handling  Object Oriented Programming: Part 2  Inheritance ![inheritance](inheritance.jpg) One of the most interesting aspects or features of object-oriented programming is _inheritance_. Inheritance is a specific type of relation between the objects we create that signifies that a "child" object _inherits_ from its "parent". In other words, the child object __"is a"__ parent object. Our examples so far were of "real-life" objects, and so to continue this line we'd perhaps wish to create a class of a `Person`, which would be the parent class, and a child class of a `Student`. In this examples it's clear why a `Student` __is a__ `Person`, so the inheritance holds. All `Person` instances have a name, age, gender, etc., but only `Student` instances have an associated school and a final grade. This means that the `Student` will have all attributes associated with all `Person`s (name, age, gender), and they'll also have a school and a grade, the is referred to as "inheritance".Say that we have some computational pipeline, that reads some data, preprocesses it (denoising, filtering, changing its dimensions), then it computes some measurement of that dataset (number of cells in the image, mean calcium activity, min-max of electrical activity) and finally it writes it to disk. Let's focus on the first step of this pipeline - reading in the data.![Computational pipeline](pipeline_oop_inheritance.png)Our approach will be the following: we will build a parent class called `DataReader`, and its child classes will know how to read in specific data formats only. In the graph above we'll have two child implementations: one that can read *.tif* images, and one for *.png*. This will allow us to "plug in" different classes into our pipeline, depending on the input, and the downstream functions won't see a difference.<jupyter_code>from pathlib import Path


class DataReader:
    """
    Base class for DataReader objects.
    
    This class provides an "interface" for subclasses to implement which
    conforms with the larger data processing pipeline of this project.
    """
    def __init__(self, path: Path):
        """
        Initialize a new instance.
        
        Parameters
        ----------
        path : Path
            Input data path
        """
        self.path = Path(path)
        self.data = None
    
    def read(self):
        """Reads data into disk and returns it. Should populate self.data."""
        raise NotImplementedError
    
    def summarize(self):
        """Returns an informative summary of the data."""
        raise NotImplementedError<jupyter_output><empty_output><jupyter_text>This parent class could be initialized with:<jupyter_code>path = Path("/some/path.tiff")
data_reader = DataReader(path)<jupyter_output><empty_output><jupyter_text>and no harm will be done, but in practice there's little one can achieve with this class. Any class that inherits from this base class has all methods the parent class has, but currently all they do is simply raise an error (an exception) called `NotImplementedError`.<jupyter_code>path = 'a.png'
data_reader = DataReader(path)
data_reader.read()<jupyter_output><empty_output><jupyter_text>This `NotImplementedError` tells the users of this class that they'll have to implement this method when they derive the child class if they wish to have anything done with this interface. Let's see how we write a child class:<jupyter_code>from pathlib import Path

import tifffile


class TifDataReader(DataReader):
    """
    A DataReader subclass for reading *.tif* files.
    """

    def read(self):
        """
        Reads a *.tif* image to self.data and returns it as well.
        """
        self.data = tifffile.imread(file)
        return self.data

    def summarize(self) -> float:
        """
        Returns the mean of the data.

        Returns
        -------
        float
            Data mean
        """
        return self.data.mean()

    def calculate_tif_thing(self, data):
        """Calcualtes some measurement from a *.tif* file."""
        pass<jupyter_output><empty_output><jupyter_text>Inheritance happens when you pass some class using the braces of the `class` definition. This tells Python to transfer attributes and methods from the parent to this child.Note how on one hand we didn't have to rewrite the `__init__()` method to define the instance initialization parameters, and on the other hand we had no problem creating a new `calculate_tif_thing()` method to provide some *.tif*-specific functionality. In between we have the `read()` and `summarize()` methods, which where implemented by overriding the base class's.Let's consider how one could use this class:<jupyter_code>path = 'a.tif'
tif_data_reader = TifDataReader(path)
tif_data_reader.path<jupyter_output><empty_output><jupyter_text>`tif_data_reader` has the `.read()` and `.summarize()` method ready to use. We won't read an actual `.tif` file but you can see that nothing is really special about this class and instance. Inheritance in this case helped us give a tag to this class, signaling to its users that it's "special" and should be used in some specific contexts. ````{note}Sometimes we wish to call the parent class's method, particularly when overriding that same method in the subclass and wanting to continue any base class logic, or when modifying the instance initialization logic.This is easily done by using the built-in `super()` function, e.g.:```pythonclass ChildClass(ParentClass):    def overridden_method(self, arg_1, arg_2):        base_class_result = super().overridden_method(arg_1, arg_2)        return self.another_calculation(base_class_result)```````  Inheritance - Why and When Inheritance can facilitate code re-use and simpler, clearer mental models of the problem at hand. In the example above, we generated very clear and concise classes that do one thing, and do it well. People reading this code, including us in three months, will understand everything about it without any hassle.A possible issue with inheritance is readability - finding the methods that are associated with the base class can be cumbersome when we start working with tens of attributes and multiple methods. This is why usually people try to avoid more than a single layer of inheritance. `````{admonition} Exercise: The Person Base Class    :class: attentionYou have decided to write a program that will manage the entirety of your lab's data and you're considering creating a `Person` class that will be the base class for the `Researcher` and the `Participant` classes. Note that:* The shared attributes are: `first_name`, `last_name`, `id_number`, `sex`, and `date_of_birth`.* Researchers also have `title` and `position` attributes.* Participants also have `weight` and `dominant_hand` attributes.* Both classes need a `get_full_name()` method to return the full name, but the `Researcher` class should include the `title` as a prefix if it is specified.* The `Participant` class should also have a `read_text()` method that reads a file that is expected to be found under *data/.txt* and returns its content.````{dropdown} Solution```pythonfrom datetime import datetimefrom pathlib import Pathclass Person:    """    Base class representing people.    """    def __init__(self, **kwargs):        """        Extracts person attributes from the provided keyword arguments.        """        self.first_name: str = kwargs.get("first_name", "")        self.last_name: str = kwargs.get("last_name", "")        self.id_number: str = kwargs.get("id_number", "")        self.sex: str = kwargs.get("sex", "")        self.date_of_birth: datetime.date = kwargs.get("date_of_birth")    def get_full_name(self) -> str:        """        Returns the full name of the person in the format of "first last".        Returns        -------        str            Full name        """        return f"{self.first_name} {self.last_name}"class Researcher(Person):    """    Represents a researcher in the lab.    """    def __init__(self, **kwargs):        """        Extract researcher attributes from the provided keyword arguments.        """        super().__init__(**kwargs)        self.title: str = kwargs.get("title", "")        self.position: str = kwargs.get("position", "")    def get_full_name(self) -> str:        """        Returns the full name of the researcher, including their title.        Returns        -------        str            Researcher name and title        """        full_name = super().get_full_name()         Introducting Python's ternary expressions:        return f"{self.title} {full_name}" if self.title else full_namedef Participant(Person):    """    Represents a participant in some experiment.    """    TEXT_PATH_TEMPLATE: str = "data/{id_number}.txt"    def __init__(self, **kwargs):        super().__init__(**kwargs)        self.weight: float = kwargs.get("weight")        self.dominant_hand: str = kwargs.get("dominant_hand", "")    def read_text(self) -> str:        """        Reads the participant's textual data from a predefined location.        Returns        -------        str            Participants textual data        """        path = self.TEXT_PATH_TEMPLATE.format(id_number=self.id_number)        with open(path, "r") as text_file:            return text_file.read()```````````` Object-oriented design requires you to think about the code you're about to write - how to model each object, how to deal with the interfaces between them, how to verify the types of each input, etc.Classes are tremendously powerful, but require a great deal of experience in order to be used wisely. Until we can envision complete entity structures in our mind and account for all the details of their implementation and interactions, **we do our best, we learn from it, and then we do better**. Rewriting large parts of an application you designed is expected, it is a natural and important part of software design (and a luxury other engineers rarely have).The term used to describe rewriting parts of some code in a way that preserves its original function but somehow improves it (performance, readability, documentation) is _refactoring_, and it is absolutely crucial.  Errors and Exceptions A very debated feature of Python (and other scripting languages) is its fear of failing. Python tries to coerce unknown commands into something familiar that it can work with. For example, addition of `bool`s and other types is fully supported, since `bool` types are treated as 0 (for `False`) and 1 (for `True`).<jupyter_code>True - 1.0
False + 10<jupyter_output><empty_output><jupyter_text>However, many other statements will result in an error, or _Exception_ in Python's terms:<jupyter_code>'3' + 3  # TypeError
a = [0, 1, 2]
a[3]  # IndexError
camel  # NameError<jupyter_output><empty_output><jupyter_text>There are many built-in exceptions in Python, and most modules you'll use created their designated exceptions. Modules and packages do this because the exception is _meaningful_ - each exception conveys information about what went wrong during the runtime. Since it's not a simple error, we can use this information by predetermining the course of action when an excpetion occurs. This is called _catching_ an exception. The keywords involved are: `try`, `except`, `else` and `finally`. An example might consist of interacting with the file system:```pythontry:     Do something that might fail    file.write()except PermissionError:     If we don't have permission to do the operation (e.g. write to protected disk), do the following     ...except IsADirectoryError:     Trying to do a file operation on a directory - so do the following     ...except (NameError, TypeError):     If we encouter either a non-existent variable or operation on variables, do the following     ...except Exception:     General error, not caught by previous exceptions     ...else:     If the operation under "try" succeeded, do the following     ...finally:     Regardless of the result - success or failure - do this.     ...``` Let's break it down:<jupyter_code># Simplest form of exception handling:
a = 2
try:
    b = a + 1
except NameError:  # a or b isn't defined
    a = 1
    b = 2

# We could catch other exceptions
a = '3'
try:
    b = a + 1
except TypeError:  # a isn't a float\int
    a = int(a)
    b = a + 1

# With the else clause
current_key = 'Mike'
default_val = 'Cohen'
dict_1 = {'John': 'Doe', 'Jane': 'Doe'}
try:
    johns = dict_1.pop(current_key)
except KeyError:  # Non-existent key
    dict_1[current_key] = default_val
    print(f"{len(dict_1)} remaining key(s) in the dictionary")
else:
    print(f"{len(dict_1)} remaining key(s) in the dictionary")
print(dict_1)
# Another else example
tup = (1,)
try:
    a, b = tup[0], tup[1]
except IndexError as e:
    print("IndexError")
    print(f"Exception: {e}; tup: {tup}")
    raise
else:
    # process_data(a, b)
    print(a, b)<jupyter_output>IndexError
Exception: tuple index out of range; tup: (1,)<jupyter_text>We use the `else` clause because we wish to catch a specific `IndexError` during the tuple unpacking (`a, b = tup[0], tup[1]`). The `process_data(a, b)` can raise other `IndexError`s which we'll deal with inside the function. But the relevant `IndexError` to catch is the tuple destructuring one.<jupyter_code># With the finally clause
def divisor(a, b):
    """
    Divides two numbers.
    a, b - numbers (int, float)
    returns a tuple of the result and a possible error.
    """
    try:
        ans = a / b
    except ZeroDivisionError as e:
        ans = None
        err = e
    except TypeError as e:
        ans = None
        err = e
    else:
        err = None
    # finally:
    return ans, err

# Should work:
ans, err = divisor(1, 2)
print(ans, " ----", err)

# ZeroDivisionError:
ans, err = divisor(1, 0)
print(ans, "----", err)

# TypeError
ans, err = divisor(1, 'a')
print(ans, "----", err)<jupyter_output>0.5  ---- None
None ---- division by zero
None ---- unsupported operand type(s) for /: 'int' and 'str'<jupyter_text>Exception handling is used almost everywhere in the Python world. We always expect our operations to fail, and catch the errors as our backup plan. This is considered more Pythonic than other options. Here's a "real-world" example:<jupyter_code># Integer conversion. We check before doing it to make sure it won't raise errors
def int_conversion(s):
    """ Convert a string to int. """
    if not isinstance(s, str) or not s.isdigit():
        return None
    elif len(s) > 10:    # too many digits for int conversion
        return None
    else:
        return int(s)
# Same purpose - more Pythonic
def pythonic_int_conversion(s):
    """ Convert a string to int. """
    try:
        return int(s)
    except (TypeError, ValueError, OverflowError):
        return None
# This is also sometimes phrased as "easier to ask for forgiveness than permission"<jupyter_output><empty_output><jupyter_text>`pathlib`: A Short Introduction For file I/O and other disk operations, some of which are required in this exercise, Pythonistas use `pathlib`, a module in the Python standard library designated to work with files and folders. It's a basic premise is that files and folders are objects themselves, and certain operations are allowed between these objects.<jupyter_code>from pathlib import Path
p_win = Path(r'C:/Users/Zvi/Documents\Classes\python-course-for-students')  # notice the "raw" string r'',
# it forces Python to not duplicate backslashes
p1 = Path('/home/groot/Projects/textbook-public')
p1
p1.parent
list(p1.parents)
p1.exists()  # is it actually a folder\file?
p1.parts
p1.name
for file in p1.iterdir():
    print(file)
# Traversing the file system
p2 = Path('C:/Users/Zvi/Documents')
p2 / 'Classes' / 'python-course-for-students'
# Operator overloading<jupyter_output><empty_output>
{'fraction_non_alphanumeric': 0.0878505848320966, 'fraction_numerical': 0.003521569613885046, 'mean_word_length': 3.6432116788321167, 'pattern_counts': {'":': 0, '<': 43, '<?xml version=': 0, '>': 48, 'https://': 0, 'lorem ipsum': 0, 'www.': 0, 'xml': 0}, 'pii_count': 0, 'substrings_counts': 0, 'word_list_counts': {'cursed_substrings.json': 0, 'profanity_word_list.json': 0, 'sexual_word_list.json': 0, 'zh_pornsignals.json': 0}}
{'chain_length': '11', 'dir': 'jupyter-structured-clean-dedup', 'id': '348266', 'license': 'CC-BY-4.0', 'n_tokens_mistral': 4395, 'n_tokens_neox': 4211, 'n_words': 1959, 'path': 'source/classes/class_3/class_3.ipynb', 'repo_name': 'sagol-python-for-neuroscientists/textbook-public', 'size': '15902'}