<jupyter_start><jupyter_text>A very hurried course in Python Derived from [SOCS2018-PYTHON-HANDSON](https://github.com/calde12/SOSC2018-PYTHON-HANDSON) by <NAME>    - [Classes definition](Classes)    - [Class structure](Structure-of-a-class)    - [Iterators and Iterables](Iterators,-Iterables)    - [Generators](Generators)- [Working with dates](Working-with-dates) You remember this, right?<jupyter_code>l = [1, 2, 3]                   # a list
d = {'z': 0, 'u': 1}            # a dictionary
s = {8, 9, 2, 5, 8, 9, 2, 5}    # a set

print(type(l), type(d), type(s))<jupyter_output><empty_output><jupyter_text>Classes **Classes** are objects that provide a means of bundling **data and functionality** together. Control flows, `with` statements, exception handling ect. is what *procedural programming* is made of.Classes are the foundation of what is called *Object Oriented Programming (OOP)*. This is a programming paradigm based on the concept of **"objects"**, which may contain **data, in the form of** fields, often known as **attributes**; and **code, in the form of** procedures, often known as **methods**.  A class defines how the objects should be: their status and the actions that they can perform to create their status. To create a particular specimen of a certain class is said creating an **instance** of that class.  Structure of a class Creating a new type of object```pythonclass ClassName():    def __init__(initial_arguments):        ...            def method_1(arguments):        ...```     Creating an instance:`instance_name = ClassName(initial_arguments)`  Let's see  together how to create a class<jupyter_code>class Student():                      # creating the Student class 
    
    # the __init__ method is 'special'. 
    # In it we define the initial status that
    # new instances of this class will have.
    def __init__(self, name, age):
        # self is a protected keyword referring to the to-be-created instance.
        self.age = age                
        self.name = name
        # age and name are ATTRIBUTES
student = Student('<NAME>', 33)    # student is an instance of Student

print(student.name, '---', student.age)
# Adding new methods

class Student():                    
    
    # initial arguments can have DEFAULT values, as functions!
    def __init__(self, name, age=33):
                                     
        self.age = age                
        self.name = name
        
    # by passing self, we give access to ALL the attributes of the instance!
    def upper_name(self):
        up_name = self.name.upper()
        return up_name
    
    
student = Student("<NAME>")
big_name = student.upper_name()
print(big_name)<jupyter_output><empty_output><jupyter_text>The `self` keyword is a placeholder for the class instance yet-to-be-created.  That's why we add it in our methods, that will apply on an instance. When you create a class instance, and call the method with the syntax> `instance_name.method_name()`  the class object fills the place required by the `self` argument. That's why in the example above, `upper_name()` was defined with a `self` argument but when we invoked it on *student*, we passed no arguments: *student* filled the `self`!<jupyter_code># Methods that can CHANGE ATTRIBUTES

class Student():                    
    
    def __init__(self, name, age=33, passed_exams=0):    
                                     
        self.age = age                
        self.name = name
        self.passed_exams = passed_exams
        
    def upper_name(self):                
        up_name = self.name.upper()
        return up_name
    
    
    def increase_exams(self, n):
        
        # this means we're adding the number n to the passed_exams attribute
        self.passed_exams += n
        print('Yeeeeah!')
st = Student("<NAME>")
print(st.passed_exams)

# note that we don't pass the self argument: 
# it is given by the instance itself!
st.increase_exams(2)
print(st.passed_exams)
# TO DO: create a class Dog that accept this initial arguments: 
# name (string) and bark_length (int) with default value 1.
# Add a method .barking that prints the string 'Wof!' with as many 'o' as the 
# number in bark_length. Add a method .increase_barking that accepts an int 
# and MODIFIES the bark_length.
# Create an instance of dog and try your methods.

# YOUR CODE HERE<jupyter_output><empty_output><jupyter_text>Iterators, Iterables An **Iterator** is an object that represents a stream of data. More precisely, an objects that has the `__next__` method which returns the next item from the iterator or raises `StopIteration` exception if there are no further item. When you use a for loop, list comprehension or anything else that iterates over an object, in the background the `__next__` method is being called on an iterator.An **Iterable** is anything that is able to iterate. In practice, an object that has the `__iter__` method, which returns an iterator. To clarify, strings, lists, files, and dictionaries are all examples of iteables that return an iterator on themselves.Here an awesome article that explain the differences https://hackaday.com/2018/09/19/learn-to-loop-the-python-way-iterators-and-generators-explained/<jupyter_code>from math import gcd

# define the iterator object
class Multiple():
    
    # as any class it supports the __init__ method
    def __init__(self, number, maximum=1000):
        self.number = number
        self.maximum = maximum
        self.counter = 0
    
    # __iter__ method makes the class an iterable
    def __iter__(self):
        return self
        
    # __next__ method makes the class an iterator
    def __next__(self):
        self.counter += 1
        value = self.number * self.counter
        if value > self.maximum:
            raise StopIteration
        return value
# use it in a for loop
for number in Multiple(79):
    print(number)<jupyter_output><empty_output><jupyter_text>**Why it is good to be able to write your own iterator?**Many programs have a need to iterate over a large list of generated data and iterators can work on the principle of **lazy evaluation**: as you loop over an iterator, values are generated as required. In many situations, the simple choice to use an iterator can markedly improve performance.<jupyter_code># TO DO: create a class Dice whose __next__ method returns a random number
# that ranges from 1 to 6. Then, create two instances of Dice and iter over 
# them until they draw the same number.
# HINT: use randint (from the random library) to draw a random integer and
# use zip to iter over the two iterables in a single for

# YOUR CODE HERE<jupyter_output><empty_output><jupyter_text>Generators You may have noticed that there is a fair amount of boilerplate code in the example above. **Generators** make it far easier to build your own iterators. There is no fussing aroung `__iter__` and `__next__`, and we don't have to keep track of an internal state or worry about raising exceptions.<jupyter_code># generators are defined as a function
def multiple_gen(number, maximum=1000):
    counter = 1
    value = number * counter
    while value <= maximum:
        # yield is the keyword that makes the function a generator
        yield value
        counter += 1
        value = number * counter
# use it in a for loop
for number in multiple_gen(79):
    print(number)<jupyter_output><empty_output><jupyter_text>`yield` keyword is similar to `return`, but instead of terminating the function, it simply pauses execution until another values is required.<jupyter_code># TO DO: create a generator dice_gen that, in a for loop, has the same 
# behaviour of the Dice class.

# YOUR CODE HERE<jupyter_output><empty_output>
StarcoderdataJupyterStructuredCleanDedup