<jupyter_start><jupyter_text>PCPP1: Advanced Perspective of Classes and Object-Oriented Programming- Classes, instances, attributes, methods, as well as working with class and instance data;- shallow and deep operations;- abstract classes, method overriding, static and class methods, special methods;- inheritance, polymorphism, subclasses, and encapsulation;- advanced exception handling techniques;the pickle and shelve modules;- metaclasses. - class — an idea, blueprint, or recipe for an instance;- instance — an instantiation of the class; very often used interchangeably with the term 'object';- object — Python's representation of data and methods; objects could be aggregates of instances;- attribute — any object or class trait; could be a variable or method;- method — a function built into a class that is executed on behalf of the class or object; some say that it’s a 'callable attribute';- type — refers to the class that was used to instantiate the object. The following issues will be addressed during this and the next module:- creation and use of decorators;- implementation of core syntax;- class and static methods;- abstract methods;- comparison of inheritance and composition;- attribute encapsulation;- exception chaining;- object persistence;- metaprogramming.<jupyter_code>class Duck:
    def __init__(self, height, weight, sex):
        self.height = height
        self.weight = weight
        self.sex = sex

    def walk(self):
        pass

    def quack(self):
        return print('Quack')
    
canard = Duck(3,2,1)
canard.quack()
# variables: self.height, self.weight, self.sex — containing different values for each object;
# methods: __init__, walk, quack — common to all objects so far.
class Duck:
    def __init__(self, height, weight, sex):
        self.height = height
        self.weight = weight
        self.sex = sex

    def walk(self):
        pass

    def quack(self):
        return print('Quack')

duckling = Duck(sex="male",height=10, weight=3.4)
drake = Duck(height=25, weight=3.7, sex="male")
hen = Duck(height=20, weight=3.4, sex="female")
#my test 
hen.height, duckling.sex, drake.weight
# hen.height=1
hen.height, duckling.sex, drake.weight

drake.quack()
print(duckling.height)

class Duck:
    def __init__(self, height, weight, sex):
        self.height = height
        self.weight = weight
        self.sex = sex

    def walk(self):
        pass

    def quack(self):
        return print('Quack')

duckling = Duck(height=10, weight=3.4, sex="male")
drake = Duck(height=25, weight=3.7, sex="male")
hen = Duck(height=20, weight=3.4, sex="female")
'''Information about an object’s class is contained in __class__.
If you run the code presented in the right pane, you'll get the type details of different objects.'''
print(Duck.__class__)
print(duckling.__class__)
print(duckling.sex.__class__)
print(duckling.quack.__class__)
'''As we predicted:

the Duck class is of the 'type' type;
the duckling object is an instance type built on the basis of the Duck class, and residing in the __main__ scope;
the duckling.sex is an attribute of the 'str' type;
duckling.quack is an attribute of the 'method' type.
'''
#https://edube.org/learn/python-advanced-1/classes-instances-attributes-methods-the-lab
class mobile:
    def __init__(self, number):
        self.__number = number

    def turn_on(self):
        print("mobile phone", self.__number, "is turned on")
        
    def turn_off(self):
        print("mobile phone ", self.__number, "is turned off")
        
    def call(self,number):
        print("calling", number)


phone1 = mobile('01632-960004')
phone2 = mobile('01632-960012')


phone1.turn_on()
phone2.turn_on()
phone1.call('555-34343')
phone1.turn_off()
phone2.turn_off()<jupyter_output>mobile phone 01632-960004 is turned on
mobile phone 01632-960012 is turned on
calling 555-34343
mobile phone  01632-960004 is turned off
mobile phone  01632-960012 is turned off<jupyter_text>Instance variablesThis kind of variable exists when and only when it is explicitly created and added to an object. This can be done during the object's initialization, performed by the __init__ method, or later at any moment of the object's life. Furthermore, any existing property can be removed at any time.<jupyter_code>#Instance variables

class Demo:
    def __init__(self, value):
        self.instance_var = value

d1 = Demo(100)
d2 = Demo(200)

print("d1's instance variable is equal to:", d1.instance_var)
print("d2's instance variable is equal to:", d2.instance_var)

class Demo:
    def __init__(self, value):
        self.instance_var = value

d1 = Demo(100)
d2 = Demo(200)

d1.another_var = 'another variable in the object'

print('contents of d1:', d1.__dict__)
print('contents of d2:', d2.__dict__)<jupyter_output>contents of d1: {'instance_var': 100, 'another_var': 'another variable in the object'}
contents of d2: {'instance_var': 200}<jupyter_text>Class variablesClass variables are defined within the class construction, so these variables are available before any class instance is created. To get access to a class variable, simply access it using the class name, and then provide the variable name.<jupyter_code>#Class variables
class Demo:
    class_var = 'shared variable'

print(Demo.class_var)
print(Demo.__dict__)

class Demo:
    class_var = 'shared variable'

d1 = Demo()
d2 = Demo()

print(Demo.class_var)
print(d1.class_var)
print(d2.class_var)

print('contents of d1:', d1.__dict__)
'''as the class variable is defined outside the object, it is not listed in the object's __dict__.
Conclusion: when you want to read the class variable value, you can use a class or class instance to access it.
'''
class Demo:
    class_var = 'shared variable'

d1 = Demo()
d2 = Demo()

# both instances allow access to the class variable
print(d1.class_var)
print(d2.class_var)
print('.' * 20)

# d1 object has no instance variable
print('contents of d1:', d1.__dict__)
print('.' * 20)

# d1 object receives an instance variable named 'class_var'
d1.class_var = "I'm messing with the class variable"

# d1 object owns the variable named 'class_var' which holds a different value than the class variable named in the same way
print('contents of d1:', d1.__dict__)
print(d1.class_var)
print('.' * 20)

# d2 object variables were not influenced
print('contents of d2:', d2.__dict__)

# d2 object variables were not influenced
print('contents of class variable accessed via d2:', d2.class_var)
'''When you want to set or change a value of the class variable, you should access it via the class, but not the class instance, as you can do for reading.'''
#  class variables can refer to some meta information or common information shared amongst instances of the same class.
'''Both the Duck and Chicken classes own a class variable named species, which holds a value unique to each class. When we iterate over all objects, we can examine the value of this variable to take appropriate action.'''
class Duck:
    counter = 0
    species = 'duck'

    def __init__(self, height, weight, sex):
        self.height = height
        self.weight = weight
        self.sex = sex
        Duck.counter +=1

    def walk(self):
        pass

    def quack(self):
        print('quacks')

class Chicken:
    species = 'chicken'

    def walk(self):
        pass

    def cluck(self):
        print('clucks')

duckling = Duck(height=10, weight=3.4, sex="male")
drake = Duck(height=25, weight=3.7, sex="male")
hen = Duck(height=20, weight=3.4, sex="female")

chicken = Chicken()

print('So many ducks were born:', Duck.counter)

for poultry in duckling, drake, hen, chicken:
    print(poultry.species, end=' ')
    if poultry.species == 'duck':
        poultry.quack()
    elif poultry.species == 'chicken':
        poultry.cluck()

class Phone:
    counter = 0

    def __init__(self, number):
        self.number = number
        Phone.counter += 1

    def call(self, number):
        message = 'Calling {} using own number {}'.format(number, self.number)
        return message


class FixedPhone(Phone):
    last_SN = 0

    def __init__(self, number):
        super().__init__(number)
        FixedPhone.last_SN += 1
        self.SN = 'FP-{}'.format(FixedPhone.last_SN)


class MobilePhone(Phone):
    last_SN = 0

    def __init__(self, number):
        super().__init__(number)
        MobilePhone.last_SN += 1
        self.SN = 'MP-{}'.format(MobilePhone.last_SN)


print('Total number of phone devices created:', Phone.counter)
print('Creating 2 devices')
fphone = FixedPhone('555-2368')
mphone = MobilePhone('01632-960004')

print('Total number of phone devices created:', Phone.counter)
print('Total number of mobile phones created:', MobilePhone.last_SN)

print(fphone.call('01632-960004'))
print('Fixed phone received "{}" serial number'.format(fphone.SN))
print('Mobile phone received "{}" serial number'.format(mphone.SN))

#https://edube.org/learn/python-advanced-1/working-with-class-and-instance-data-the-lab-1

import random

class Apple:  
    number = 0
    total_weight = 0
    
    def __init__(self):
        self.weight = random.uniform(0.2, 0.5)
        Apple.number += 1
        Apple.total_weight += self.weight

while Apple.total_weight <299 and Apple.number <=1000:
    apple = Apple()
#     print("packing...", apple.number, apple.total_weight)
    
print("One package with {} apples and weigh {} is packed!".format(apple.number,apple.total_weight))<jupyter_output>One package with 861 apples and weigh 299.34067420064906 is packed!<jupyter_text>Python core syntaxPython core syntax covers:- operators like '+', '-', '*', '/', '%' and many others;- operators like '==', '', '<=', 'in' and many others;- indexing, slicing, subscripting;- built-in functions like str(), len()- reflexion – isinstance(), issubclass()and a few more elements.<jupyter_code>number = 10
print(number + 20)

# It is in fact translated to:
number = 10
print(number.__add__(20))

class Person:
    def __init__(self, weight, age, salary):
        self.weight = weight
        self.age = age
        self.salary = salary

    def __add__(self, other):
        return self.weight + other.weight


p1 = Person(30, 40, 50)
p2 = Person(35, 45, 55)

print(p1 + p2)

dir(Person)
help(Person)
'''
# Comparison methods
Function or operator	Magic method	Implementation meaning or purpose
==	__eq__(self, other)	equality operator
!=	__ne__(self, other)	inequality operator
<	__lt__(self, other)	less-than operator
>	__gt__(self, other)	greater-than operator
<=	__le__(self, other)	less-than-or-equal-to operator
>=	__ge__(self, other)	greater-than-or-equal-to operator

# Numeric methods
Unary operators and functions
Function or operator	Magic method	Implementation meaning or purpose
+	__pos__(self)	unary positive, like a = +b
-	__neg__(self)	unary negative, like a = -b
abs()	__abs__(self)	behavior for abs() function
round(a, b)	__round__(self, b)	behavior for round() function

# Common, binary operators and functions
Function or operator	Magic method	Implementation meaning or purpose
+	__add__(self, other)	addition operator
-	__sub__(self, other)	subtraction operator
*	__mul__(self, other)	multiplication operator
//	__floordiv__(self, other)	integer division operator
/	__div__(self, other)	division operator
%	__mod__(self, other)	modulo operator
**	__pow__(self, other)	exponential (power) operator

# Augumented operators and functions
By augumented assignment we should understand a sequence of unary operators and assignments like a += 20

Function or operator	Magic method	Implementation meaning or purpose
+=	__iadd__(self, other)	addition and assignment operator
-=	__isub__(self, other)	subtraction and assignment operator
*=	__imul__(self, other)	multiplication and assignment operator
//=	__ifloordiv__(self, other)	integer division and assignment operator
/=	__idiv__(self, other)	division and assignment operator
%=	__imod__(self, other)	modulo and assignment operator
**=	__ipow__(self, other)	exponential (power) and assignment operator
'''
'''
Type conversion methods
Python offers a set of methods responsible for the conversion of built-in data types.

Function	Magic method	Implementation meaning or purpose
int()	__int__(self)	conversion to integer type
float()	__float__(self)	conversion to float type
oct()	__oct__(self)	conversion to string, containing an octal representation
hex()	__hex__(self)	conversion to string, containing a hexadecimal representation
Object introspection
Python offers a set of methods responsible for representing object details using ordinary strings.

Function	Magic method	Implementation meaning or purpose
str()	__str__(self)	responsible for handling str() function calls
repr()	__repr__(self)	responsible for handling repr() function calls
format()	__format__(self, formatstr)	called when new-style string formatting is applied to an object
hash()	__hash__(self)	responsible for handling hash() function calls
dir()	__dir__(self)	responsible for handling dir() function calls
bool()	__nonzero__(self)	responsible for handling bool() function calls
Object retrospection
Following the topic of object introspection, there are methods responsible for object reflection.

Function	Magic method	Implementation meaning or purpose
isinstance(object, class)	__instancecheck__(self, object)	responsible for handling isinstance() function calls
issubclass(subclass, class)	__subclasscheck__(self, subclass)	responsible for handling issubclass() function calls

Object attribute access
Access to object attributes can be controlled via the following magic methods

Expression example	Magic method	Implementation meaning or purpose
object.attribute	__getattr__(self, attribute)	responsible for handling access to a non-existing attribute
object.attribute	__getattribute__(self, attribute)	responsible for handling access to an existing attribute
object.attribute = value	__setattr__(self, attribute, value)	responsible for setting an attribute value
del object.attribute	__delattr__(self, attribute)	responsible for deleting an attribute
Methods allowing access to containers
Containers are any object that holds an arbitrary number of other objects; containers provide a way to access the contained objects and to iterate over them. Container examples: list, dictionary, tuple, and set.

Expression example	Magic method	Implementation meaning or purpose
len(container)	__len__(self)	returns the length (number of elements) of the container
container[key]	__getitem__(self, key)	responsible for accessing (fetching) an element identified by the key argument
container[key] = value	__setitem__(self, key, value)	responsible for setting a value to an element identified by the key argument
del container[key]	__delitem__(self, key)	responsible for deleting an element identified by the key argument
for element in container	__iter__(self)	returns an iterator for the container
item in container	__contains__(self, item)	responds to the question: does the container contain the selected item?
The list of special methods built-in in Python contains more entities. For more information, refer to https://docs.python.org/3/reference/datamodel.html#special-method-names.
'''
# https://edube.org/learn/python-advanced-1/lab-1-implementing-core-syntax

#adding time intervals, like: add 21 hours 58 minutes 50 seconds to 1hr 45 minutes 22 seconds;

class time_interval:
    
    def __init__(self, h:int, m:int, s:int):
        try:
            if 0<=s<=60 and 0<=m<=60 and type(h)==int and type(m)==int and type(s)==int:
                 self.h = h
                 self.m = m
                 self.s = s
            else:
                raise TypeError
        except TypeError:
            print("TypeError")
            
    def __str__(self):
        return str(self.h)+':'+str(self.m)+':'+str(self.s)
    
    def __add__(self, other):
        sum=(self.h*60+self.m)*60+self.s + (other.h*60+other.m)*60+other.s
        h= sum//3600%24
        m = sum%3600//60
        s = sum%3600%60
        return str(h)+':'+str(m)+':'+str(s)
        
    def __sub__(self, other):
        diff=( (self.h*60+self.m)*60+self.s )-( (other.h*60+other.m)*60+other.s )
        if diff >= 0:
            h= diff//3600
            m = diff%3600//60
            s = diff%3600%60
            return str(h)+':'+str(m)+':'+str(s)
        else:
            h = -diff//3600
            m = -diff%3600//60
            s = -diff%3600%60
            return '-'+str(h)+':'+str(m)+':'+str(s)
        
    def __mul__(self, number):
        try:
            if type(number)==int:
                mul = ( (self.h*60+self.m)*60+self.s ) * number
                h= mul//3600
                m = mul%3600//60
                s = mul%3600%60
                return str(h)+':'+str(m)+':'+str(s)
            else:
                raise TypeError
        except TypeError:
            print("TypeError")
            
ti1 = time_interval(21,58,50)
ti2 = time_interval(1,45,22)

print(ti1+ti2)
print(ti1-ti2)
print(ti2-ti1)
print(ti1 * 2 )

ti = time_interval(11,45,22)
print(ti * 3)
'''you can use the assert statement to validate if the output of the __str__ method applied to a time interval object equals the expected value.
'''
#https://edube.org/learn/python-advanced-1/python-core-syntax-lab-2

class time_interval:
    
    def __init__(self, h:int, m:int, s:int):
        try:
            if 0<=s<=60 and 0<=m<=60 and type(h)==int and type(m)==int and type(s)==int:
                 self.h = h
                 self.m = m
                 self.s = s
            else:
                raise TypeError
        except TypeError:
            print("TypeError")
            
    def __str__(self):
        return str(self.h)+':'+str(self.m)+':'+str(self.s)
    
    def __add__(self, other):
        if type(other)==int:
            sum=(self.h*60+self.m)*60+self.s + other
            h= sum//3600%24
            m = sum%3600//60
            s = sum%3600%60
            return str(h)+':'+str(m)+':'+str(s)
        else:
            sum=(self.h*60+self.m)*60+self.s + (other.h*60+other.m)*60+other.s
            h= sum//3600%24
            m = sum%3600//60
            s = sum%3600%60
            return str(h)+':'+str(m)+':'+str(s)
   
            
    def __sub__(self, other):
        if type(other)==int:        
            diff=( (self.h*60+self.m)*60+self.s )- other
            if diff >= 0:
                h= diff//3600
                m = diff%3600//60
                s = diff%3600%60
                return str(h)+':'+str(m)+':'+str(s)
            else:
                h = -diff//3600
                m = -diff%3600//60
                s = -diff%3600%60
                return '-'+str(h)+':'+str(m)+':'+str(s)
        else:
            diff=( (self.h*60+self.m)*60+self.s )-( (other.h*60+other.m)*60+other.s )
            if diff >= 0:
                h= diff//3600
                m = diff%3600//60
                s = diff%3600%60
                return str(h)+':'+str(m)+':'+str(s)
            else:
                h = -diff//3600
                m = -diff%3600//60
                s = -diff%3600%60
                return '-'+str(h)+':'+str(m)+':'+str(s)                
              
    def __mul__(self, number):
        try:
            if type(number)==int:
                mul = ( (self.h*60+self.m)*60+self.s ) * number
                h= mul//3600
                m = mul%3600//60
                s = mul%3600%60
                return str(h)+':'+str(m)+':'+str(s)
            else:
                raise TypeError
        except TypeError:
            print("TypeError")


ti1 = time_interval(21,58,50)
ti2 = time_interval(1,45,22)

print(ti1+ti2)
print(ti1-ti2)
print(ti2-ti1)
print(ti1 * 2 )

ti = time_interval(11,45,22)
print(ti * 3)
print(ti1+10)
print(ti1-60)
print(ti2-100)
type(ti2)<jupyter_output><empty_output><jupyter_text>Inheritance and polymorphism — Inheritance is a pillar of OOPSingle inheritance vs. multiple inheritanceMRO — Method Resolution Order (diamond problem, or  deadly diamond of death.)<jupyter_code>class Vehicle:
    pass

class LandVehicle(Vehicle):
    pass

class TrackedVehicle(LandVehicle):
    pass

class A:
    def info(self):
        print('Class A')

class B(A):
    def info(self):
        print('Class B')

class C(A):
    def info(self):
        print('Class C')

class D(B, C):
    pass

D().info()
'''class D does not define the method info(), so Python has to look for it in the class hierarchy;
class D is constructed in this order:
the definition of class B is fetched;
the definition of class C is fetched;
Python finds the requested method in the class B definition and stops searching;
Python executes the method.'''
#Possible pitfalls — MRO inconsistency

class A:
    def info(self):
        print('Class A')

class B(A):
    def info(self):
        print('Class B')

class C(A):
    def info(self):
        print('Class C')

class D(A, C):
    pass

D().info()

class A:
    def info(self):
        print('Class A')

class B(A):
    def info(self):
        print('Class B')

class C(A):
    def info(self):
        print('Class C')

class D(B, C):
    pass

class E(C, B):
    pass

D().info()
E().info()

#https://edube.org/learn/python-advanced-1/multiple-inheritance-the-lab
class Scanner:
    def scan(self):
        print('scan() method from Scanner class')

class Printer:
    def print(self):
        print('print() method from Printer class')

class Fax:
    def send(self):
        print('send() method from Fax class')
        
class MFD_SPF(Scanner,Printer,Fax):
    pass
        
class MFD_SFP(Scanner,Fax,Printer):
    pass


MFD_SPF().scan()  
MFD_SPF().print()    
MFD_SPF().send()    
print()
MFD_SFP().scan()  
MFD_SFP().send()    
MFD_SFP().print()    
print('----------------')
spf = MFD_SPF()
sfp = MFD_SFP()

 
spf.scan()  
spf.print()    
spf.send()    
print()
sfp.scan()  
sfp.send()    
sfp.print()
# demonstrate polymorphism on integers and strings, execute the following code in the Python interpreter:
a = 10
print(a.__add__(20))
b = 'abc'
print(b.__add__('def'))
class Device:
    def turn_on(self):
        print('The device was turned on')

class Radio(Device):
    pass

class PortableRadio(Device):
    def turn_on(self):
        print('PortableRadio type object was turned on')

class TvSet(Device):
    def turn_on(self):
        print('TvSet type object was turned on')

device = Device()
radio = Radio()
portableRadio = PortableRadio()
tvset = TvSet()

for element in (device, radio, portableRadio, tvset):
    element.turn_on()
    
'''In the right pane, there is a code implementing both inheritance and polymorphism:
inheritance: class Radio inherits the turn_on() method from its superclass — that is why we see The device was turned on string twice. Other subclasses override that method and as a result we see different lines being printed;
polymorphism: all class instances allow the calling of the turn_on() method, even when you refer to the objects using the arbitrary variable element.'''
class Wax:
    def melt(self):
        print("Wax can be used to form a tool")

class Cheese:
    def melt(self):
        print("Cheese can be eaten")

class Wood:
    def fire(self):
        print("A fire has been started!")

for element in Wax(), Cheese(), Wood():
    try:
        element.melt()
    except AttributeError:
        print('No melt() method')
a_list = list()
b_list = list((10, 20, 43, 54, 23, 23, 34, 23, 2))

print(a_list)
print(b_list)
#Extended function argument syntax

def combiner(a, b, *args, **kwargs):
    print(a, type(a))
    print(b, type(b))
    print(args, type(args))
    print(kwargs, type(kwargs))


combiner(10, '20', 40, 60, 30, argument1=50, argument2='66')
'''*args – refers to a tuple of all additional, not explicitly expected positional arguments, so arguments passed without keywords and passed next after the expected arguments. In other words, *args collects all unmatched positional arguments;
**kwargs (keyword arguments) – refers to a dictionary of all unexpected arguments that were passed in the form of keyword=value pairs. Likewise, **kwargs collects all unmatched keyword arguments.'''

def combiner(a, b, *args, **kwargs):
    super_combiner(*args, **kwargs)

def super_combiner(*my_args, **my_kwargs):
    print('my_args:', my_args)
    print('my_kwargs', my_kwargs)

combiner(10, '20', 40, 60, 30, argument1=50, argument2='66')

'''Extended function argument syntax – forwarding arguments to other functions
When you want to forward arguments received by your very smart and universal function (defined with *args and **kwargs, of course) to another handy and smart function, you should do that in the following way:
'''
#The last example in this section shows how to combine *args, a key word, and **kwargs in one definition:

def combiner(a, b, *args, c=20, **kwargs):
    super_combiner(c, *args, **kwargs)
def super_combiner(my_c, *my_args, **my_kwargs):
    print('my_args:', my_args)
    print('my_c:', my_c)
    print('my_kwargs', my_kwargs)
combiner(1, '1', 1, 1, c=2, argument1=1, argument2='1')<jupyter_output>my_args: (1, 1)
my_c: 2
my_kwargs {'argument1': 1, 'argument2': '1'}<jupyter_text>2.4.1.1 DecoratorsThis is done by passing the original function (i.e., the decorated function) as a parameter to the decorating function so that the decorating function can call the passed function. The decorating function returns a function that can be called later.Decorators are used in:- the validation of arguments;- the modification of arguments;- the modification of returned objects;- the measurement of execution time;- message logging;- thread synchronization;- code refactorization;- caching.<jupyter_code>def simple_hello():
    print("Hello from simple function!")

simple_hello()
def simple_decorator(function):
    print('We are about to call "{}"'.format(function.__name__))
    return function

simple_decorator(simple_hello)

decorated = simple_decorator(simple_hello)
decorated()
def simple_decorator(function):
    print('We are about to call "{}"'.format(function.__name__))
    return function

@simple_decorator
def simple_hello():
    print("Hello from simple function!")

simple_hello()
'''
This means that:

operations are performed on object names;
this is the most important thing to remember: the name of the simple_function object ceases to indicate the object representing our simple_hello() and from that moment on it indicates the object returned by the decorator, the simple_decorator.
The implementation of the decorator pattern introduces this syntax, which appears to be very important and useful to developers. That is why decorators have gained great popularity and are widely used in Python code. It should be mentioned that decorators are very useful for refactoring or debugging the code.

'''
# decorator and closure
def simple_decorator(own_function):

    def internal_wrapper(*args, **kwargs):
        print('"{}" was called with the following arguments'.format(own_function.__name__))
        print('\t{}\n\t{}\n'.format(args, kwargs))
        own_function(*args, **kwargs)
        print('Decorator is still operating')

    return internal_wrapper


@simple_decorator
def combiner(*args, **kwargs):
    print("\tHello from the decorated function; received arguments:", args, kwargs)

combiner('a', 'b', exec='yes')
'''A nested function (internal_wrapper) could reference an object (own_function) in its enclosing scope thanks to the closure.

'''
#Decorators can accept their own attributes

def warehouse_decorator(material):
    def wrapper(our_function):
        def internal_wrapper(*args):
            print('<strong>*</strong> Wrapping items from {} with {}'.format(our_function.__name__, material))
            our_function(*args)
            print()
        return internal_wrapper
    return wrapper


@warehouse_decorator('kraft')
def pack_books(*args):
    print("We'll pack books:", args)


@warehouse_decorator('foil')
def pack_toys(*args):
    print("We'll pack toys:", args)


@warehouse_decorator('cardboard')
def pack_fruits(*args):
    print("We'll pack fruits:", args)


pack_books('Alice in Wonderland', 'Winnie the Pooh')
pack_toys('doll', 'car')
pack_fruits('plum', 'pear')

#Decorator stacking
def big_container(collective_material):
    def wrapper(our_function):
        def internal_wrapper(*args):
            our_function(*args)
            print('<strong>*</strong> The whole order would be packed with', collective_material)
            print()
        return internal_wrapper
    return wrapper

def warehouse_decorator(material):
    def wrapper(our_function):
        def internal_wrapper(*args):
            our_function(*args)
            print('<strong>*</strong> Wrapping items from {} with {}'.format(our_function.__name__, material))
        return internal_wrapper
    return wrapper

@big_container('plain cardboard')
@warehouse_decorator('bubble foil')
def pack_books(*args):
    print("We'll pack books:", args)

@big_container('colourful cardboard')
@warehouse_decorator('foil')
def pack_toys(*args):
    print("We'll pack toys:", args)

@big_container('strong cardboard')
@warehouse_decorator('cardboard')
def pack_fruits(*args):
    print("We'll pack fruits:", args)


pack_books('<NAME> Wonderland', 'Winnie the Pooh')
pack_toys('doll', 'car')
pack_fruits('plum', 'pear')

#https://edube.org/learn/python-advanced-1/lab-timestamping-logger
# import module responsible for time processing
from datetime import datetime

# get current time using now() method
def get_time():
    timestamp = datetime.now()
    # print(timestamp)

    # convert timestamp to human-readable string, following passed pattern:
    string_timestamp = timestamp.strftime('%Y-%m-%d %H:%M:%S')
    
    print(string_timestamp)

@get_time()
def add(num1,num2):
    return num1+num2

@get_time()
def mul(num1,num2):
    return num1*num2

print(add(1,2))
#Decorating functions with classes
 
class SimpleDecorator:
    def __init__(self, own_function):
        self.func = own_function

    def __call__(self, *args, **kwargs):
        print('"{}" was called with the following arguments'.format(self.func.__name__))
        print('\t{}\n\t{}\n'.format(args, kwargs))
        self.func(*args, **kwargs)
        print('Decorator is still operating')


@SimpleDecorator
def combiner(*args, **kwargs):
    print("\tHello from the decorated function; received arguments:", args, kwargs)


combiner('a', 'b', exec='yes')
'''We can define a decorator as a class, and in order to do that, we have to use a __call__ special class method. When a user needs to create an object that acts as a function (i.e., it is callable) then the function decorator needs to return an object that is callable, so the __call__ special method will be very useful.'''
#Decorators with arguments

class WarehouseDecorator:
    def __init__(self, material):
        self.material = material

    def __call__(self, own_function):
        def internal_wrapper(*args, **kwargs):
            print('<strong>*</strong> Wrapping items from {} with {}'.format(own_function.__name__, self.material))
            own_function(*args, **kwargs)
            print()
        return internal_wrapper


@WarehouseDecorator('kraft')
def pack_books(*args):
    print("We'll pack books:", args)


@WarehouseDecorator('foil')
def pack_toys(*args):
    print("We'll pack toys:", args)


@WarehouseDecorator('cardboard')
def pack_fruits(*args):
    print("We'll pack fruits:", args)


pack_books('Alice in Wonderland', 'Winnie the Pooh')
pack_toys('doll', 'car')
pack_fruits('plum', 'pear')
'''When you pass arguments to the decorator, the decorator mechanism behaves quite differently than presented in example of decorator that does not accept arguments (previous slide):

the reference to function to be decorated is passed to __call__ method which is called only once during decoration process,
the decorator arguments are passed to __init__ method
'''<jupyter_output><strong>*</strong> Wrapping items from pack_books with kraft
We'll pack books: ('Alice in Wonderland', 'Winnie the Pooh')

<strong>*</strong> Wrapping items from pack_toys with foil
We'll pack toys: ('doll', 'car')

<strong>*</strong> Wrapping items from pack_fruits with cardboard
We'll pack fruits: ('plum', 'pear')<jupyter_text>Class decorators<jupyter_code>class Car:
    def __init__(self, VIN):
        self.mileage = 0
        self.VIN = VIN

car = Car('ABC123')
print('The mileage is', car.mileage)
print('The VIN is', car.VIN)

def object_counter(class_):
    class_.__getattr__orig = class_.__getattribute__

    def new_getattr(self, name):
        if name == 'mileage':
            print('We noticed that the mileage attribute was read')
        return class_.__getattr__orig(self, name)

    class_.__getattribute__ = new_getattr
    return class_

@object_counter
class Car:
    def __init__(self, VIN):
        self.mileage = 0
        self.VIN = VIN

car = Car('ABC123')
print('The mileage is', car.mileage)
print('The VIN is', car.VIN)<jupyter_output>We noticed that the mileage attribute was read
The mileage is 0
The VIN is ABC123<jupyter_text>Decorators – summaryA decorator is a very powerful and useful tool in Python, because it allows programmers to modify the behavior of a function, method, or class.Decorators allow us to wrap another callable object in order to extend its behavior.Decorators rely heavily on closures and *args and **kwargs.Interesting note:the idea of decorators was described in two documents – PEP 318 and PEP 3129. Don't be discouraged that the first PEP was prepared for Python 2, because what matters here is the idea, not the implementation in a specific Python.  methods- instance methods:     The instance methods, as the first parameter, take the self parameter, which is their hallmark.- class methods:      like class variables, work on the class itself, and not on the class objects that are instantiated.- static methods:<jupyter_code>#instance methods
class Example:
    def __init__(self, value):
        self.__internal = value

    def get_internal(self):
        return self.__internal

example1 = Example(10)
example2 = Example(99)
print(example1.get_internal())
print(example2.get_internal())

#class mehtods: @classmethod, cls
class Example:
    __internal_counter = 0

    def __init__(self, value):
        Example.__internal_counter +=1

    @classmethod
    def get_internal(cls):
        return '# of objects created: {}'.format(cls.__internal_counter)

print(Example.get_internal())

example1 = Example(10)
print(Example.get_internal())

example2 = Example(99)
print(Example.get_internal())<jupyter_output># of objects created: 0
# of objects created: 1
# of objects created: 2<jupyter_text>1. we control access to class variables, e.g., to a class variable containing information about the number of created instances or the serial number given to the last produced object, or we modify the state of the class variables; 2. we need to create a class instance in an alternative way, so the class method can be implemented by an alternative constructor. Convention'''To be able to distinguish a class method from an instance method, the programmer signals it with the @classmethod decorator preceding the class method definition.Additionally, the first parameter of the class method is cls, which is used to refer to the class methods and class attributes.As with self, cls was chosen arbitrarily (i.e., you can use a different name, but you must do it consistently).'''The get_internal() method is a class method. This has been signaled to the Python interpreter by using an appropriate decorator. Additionally, the method uses the cls parameter to access the class variable appropriate for the Example class.Of course, you can use the reference to “Example.__internal_counter”, but this will be inconsistent with the convention and the code loses its effectiveness in communicating its own meaning.An exception is the __init__() method, which by definition is an instance method, so it can’t use “cls”, and as a result it references the class variable by the “Example” prefix.<jupyter_code># how to use the class method as an alternative constructor
class Car:
    def __init__(self, vin):
        print('Ordinary __init__ was called for', vin)
        self.vin = vin
        self.brand = ''

    @classmethod
    def including_brand(cls, vin, brand):
        print('Class method was called')
        _car = cls(vin) # the creation of a class object (i.e., calling the __init__ method, among other things) is done using cls(vin).
        _car.brand = brand
        return _car

car1 = Car('ABCD1234')
car2 = Car.including_brand('DEF567', 'NewBrand')

print(car1.vin, car1.brand)
print(car2.vin, car2.brand)

# static methods 
class Bank_Account:
    def __init__(self, iban):
        print('__init__ called')
        self.iban = iban
            
    @staticmethod
    def validate(iban):
        if len(iban) == 20:
            return True
        else:
            return False

#Static methods

class Bank_Account:
    def __init__(self, iban):
        print('__init__ called')
        self.iban = iban
            
    @staticmethod
    def validate(iban):
        if len(iban) == 20:
            return True
        else:
            return False


account_numbers = ['8' * 20, '7' * 4, '2222']

for element in account_numbers:
    if Bank_Account.validate(element):
        print('We can use', element, ' to create a bank account')
    else:
        print('The account number', element, 'is invalid')<jupyter_output><empty_output><jupyter_text>Using static and class methods - comparisonThe time has come to compare the use of class and static methods:- class method requires 'cls' as the first parameter and a static method does not;- class method has the ability to access the state or methods of the class, and a static method does not;- class method is decorated by '@classmethod' and a static method by '@staticmethod';-class method can be used as an alternative way to create objects, and a static method is only a utility method.<jupyter_code>#https://edube.org/learn/python-advanced-1/lab

#class mehtods: @classmethod, cls
class Watch:
    __watches_created = 0

    def __init__(self):
        self.engraving_text = ''
        Watch.__watches_created +=1

    @classmethod
    def get_number_of_watches_created(cls):
        print('Class method get_number_of_watches_created() was called')        
        return '# of watch created: {}'.format(cls.__watches_created)

    @staticmethod
    def validate(engraving_text):
        print('Class method validate() was called')
        if len(engraving_text.strip()) == len(engraving_text) and \
           len(engraving_text) <= 40 and \
               engraving_text.isalnum():
            return True
        else:
            return False
        
    @classmethod
    def engraving(cls, engraving_text):
        print('Class method engraving() was called')
        _watch = cls() # the creation of a class object (i.e., calling the __init__ method, among other things) is done using cls(vin).
        _watch.engraving_text = engraving_text
        return _watch

watch_1 = Watch()
print(watch_1.get_number_of_watches_created())
watch_2 = Watch()
print(watch_1.get_number_of_watches_created())

engraving_texts = ["Iloveyou", "I love you"]

for text in engraving_texts:
    if Watch.validate(text):
        print('Watch', text, ' is created')
        text = Watch.engraving(text)
        print(text.get_number_of_watches_created())
    else:
        print('We cannot use', text, 'to engrave a watch')<jupyter_output>Class method get_number_of_watches_created() was called
# of watch created: 1
Class method get_number_of_watches_created() was called
# of watch created: 2
Class method validate() was called
Watch Iloveyou  is created
Class method engraving() was called
Class method get_number_of_watches_created() was called
# of watch created: 3
Class method validate() was called
We cannot use I love you to engrave a watch<jupyter_text>Abstract classes- the class designer sets requirements regarding methods that must be implemented by just declaring them, but not defining them in detail. Such methods are called abstract methods.- The programmer has to deliver all method definitions and the completeness would be validated by another, dedicated module. The programmer delivers the method definitions by overriding the method declarations received from the class designer.- Remember that it isn’t possible to instantiate an abstract class, and it needs subclasses to provide implementations for those abstract methods which are declared in the abstract classes. This behavior is a test performed by a dedicated Python module to validate if the developer has implemented a subclass that overrides all abstract methods.<jupyter_code># a typical class that can be instantiated:
class BluePrint:
    def hello(self):
        print('Nothing is blue unless you need it')


bp = BluePrint()
bp.hello()

#Python has come up with a module which provides the helper class for defining Abstract Base Classes (ABC) and that module name is abc.

#The ABC allows you to mark classes as abstract ones and distinguish which methods of the base abstract class are abstract. A method becomes abstract by being decorated with an @abstractmethod decorator.
import abc

class BluePrint(abc.ABC):
    @abc.abstractmethod
    def hello(self):
        pass

class GreenField(BluePrint):
    def hello(self):
        print('Welcome to Green Field!')


gf = GreenField()
gf.hello()

#bp = BluePrint()

import abc


class BluePrint(abc.ABC):
    @abc.abstractmethod
    def hello(self):
        pass


class GreenField(BluePrint):
    def hello(self):
        print('Welcome to Green Field!')


class RedField(BluePrint):
    def yellow(self):
        pass


gf = GreenField()
gf.hello()

rf = RedField()<jupyter_output>Welcome to Green Field!<jupyter_text>Multiple inheritanceWhen you plan to implement a multiple inheritance from abstract classes, remember that an effective subclass should override all abstract methods inherited from its super classes. Summary:- Abstract Base Class (ABC) is a class that cannot be instantiated. Such a class is a base class for concrete classes;- ABC can only be inherited from;- we are forced to override all abstract methods by delivering concrete method implementations. A note:It’s tempting to call a module “abc” and then try to import it, but by doing so Python imports the module containing the ABC class instead of your local file. This could cause some confusion – why does such a common name as “abc” conflict with my simple module “abc”?Run your own experiment to become familiar with the error messages you would encounter in such a situation.<jupyter_code># https://edube.org/learn/python-advanced-1/lab-1
import abc

class Scanner(abc.ABC):
    @abc.abstractmethod
    def scan_document(self):
        pass
    @abc.abstractmethod
    def get_scanner_status(self):
        pass
    
class Printer(abc.ABC):
    @abc.abstractmethod
    def print_document(self):
        pass
    @abc.abstractmethod
    def get_printer_status(self):
        pass

class MFD1(Scanner, Printer):
    def scan_document(self):
        print("MFD1: the documents are scanned.")
    def print_document(self):
        print("MFD1: the documents are printed.")
    def get_scanner_status(self):
        pass
        #print("the scanner status is printed.")
    def get_printer_status(self):
        pass
        #print("the scanner status is printed.")
        
class MFD2(Scanner, Printer):
    def scan_document(self):
        print("MFD2: the documents are scanned.")
    def print_document(self):
        print("MFD2: the documents are printed.")
    def get_scanner_status(self):
        pass
        #print("the scanner status is printed.")
    def get_printer_status(self):
        print("MFD2: the printer status is printed.")
        
class MFD3(Scanner, Printer):
    def scan_document(self):
        print("MFD3: the documents are scanned.")
    def print_document(self):
        print("MFD3: the documents are printed.")
    def get_scanner_status(self):
        print("MFD3: the scanner status is printed.")
    def get_printer_status(self):
        print("MFD3: the scanner status is printed.")


mfd1 = MFD1()
mfd1.scan_document()
mfd1.print_document()

mfd2 = MFD2()
mfd2.scan_document()
mfd2.print_document()
mfd2.get_printer_status()

mfd3 = MFD3()
mfd3.scan_document()
mfd3.print_document()
mfd3.get_printer_status()
mfd3.get_scanner_status()<jupyter_output>MFD1: the documents are scanned.
MFD1: the documents are printed.
MFD2: the documents are scanned.
MFD2: the documents are printed.
MFD2: the printer status is printed.
MFD3: the documents are scanned.
MFD3: the documents are printed.
MFD3: the scanner status is printed.
MFD3: the scanner status is printed.<jupyter_text>Attribute encapsulationPython allows you to control access to attributes with the built-in property() function and corresponding decorator @property.<jupyter_code>class TankError(Exception):
    pass

class Tank:
    def __init__(self, capacity):
        self.capacity = capacity
        self.__level = 0

    @property
    def level(self):
        return self.__level

    @level.setter
    def level(self, amount):
        if amount > 0:
            # fueling
            if amount <= self.capacity:
                self.__level = amount
            else:
                raise TankError('Too much liquid in the tank')
        elif amount < 0:
            raise TankError('Not possible to set negative liquid level')

    @level.deleter
    def level(self):
        if self.__level > 0:
            print('It is good to remember to sanitize the remains from the tank!')
        self.__level = None
        
'''@tank.setter() – designates the method called for setting the encapsulated attribute value;
@tank.deleter() – designates the method called when other code wants to delete the encapsulated attribute.

The getter method is decorated with '@property'. It designates the name of the attribute to be used by the external code;
the setter method is decorated with '@name.setter'. The method name should be the attribute name;
the deleter method is decorated with '@name.deleter'. The method name should should be the attribute name.
'''
class TankError(Exception):
    pass

class Tank:
    def __init__(self, capacity):
        self.capacity = capacity
        self.__level = 0

    @property
    def level(self):
        return self.__level

    @level.setter
    def level(self, amount):
        if amount > 0:
            # fueling
            if amount <= self.capacity:
                self.__level = amount
            else:
                raise TankError('Too much liquid in the tank')
        elif amount < 0:
            raise TankError('Not possible to set negative liquid level')

    @level.deleter
    def level(self):
        if self.__level > 0:
            print('It is good to remember to sanitize the remains from the tank!')
        self.__level = None

# our_tank object has a capacity of 20 units
our_tank = Tank(20)

# our_tank's current liquid level is set to 10 units
our_tank.level = 10
print('Current liquid level:', our_tank.level)

# adding additional 3 units (setting liquid level to 13)
our_tank.level += 3
print('Current liquid level:', our_tank.level)

# let's try to set the current level to 21 units
# this should be rejected as the tank's capacity is 20 units
try:
    our_tank.level = 21
except TankError as e:
    print('Trying to set liquid level to 21 units, result:', e)

# similar example - let's try to add an additional 15 units
# this should be rejected as the total capacity is 20 units
try:
    our_tank.level += 15
except TankError as e:
    print('Trying to add an additional 15 units, result:', e)

# let's try to set the liquid level to a negative amount
# this should be rejected as it is senseless
try:
    our_tank.level = -3
except TankError as e:
    print('Trying to set liquid level to -3 units, result:', e)

print('Current liquid level:', our_tank.level)

del our_tank.level

print(our_tank.level)
# https://edube.org/learn/python-advanced-1/lab-2

class BankError(Exception):
    pass

class Bank:
    def __init__(self, account_number):
        self.__account_number = account_number
        self.__balance = 0

    @property
    def account_number(self):
        return self.__account_number
    
    @account_number.setter
    def account_number(self, number):
        raise BankError("Not possible to change account number to:", number )    
    
    @account_number.deleter
    def account_number(self):
        if self.__balance > 0:
            print('It is not possible to delete an account as long as the balance is not zero')
        else:
            self.__account_number = None    
    
    
    @property
    def balance(self):
        return self.__balance

    @balance.setter
    def balance(self, amount):
        if amount >= 0:
            self.__balance = amount
            if self.__balance > 100:
                print("You account balance: {}".format(self.__balance))
        else:
             raise BankError('Not possible to set negative balance')

    @balance.deleter
    def balance(self):
        if self.__balance > 0:
            print('It is not possible to delete an account as long as the balance is not zero')
        else:
            self.__balance = None

# our_bank object has a account_number 9999
our_bank = Bank(9999)
print(our_bank.account_number)
try:
    our_bank.account_number=8888
except BankError as e:
    print('Trying to set account number, result:', e)
print('==============================================')

# our_bank's current balance is set to 1000 units
our_bank.balance = 1000
print('Current balance:', our_tank.balance)

# trying to deposit 1,000,000; 
our_bank.balance += 1000000
print('Current account balance:', our_bank.balance)

# trying to deposit
try:
    our_bank.balance = -200
except BankError as e:
    print('Trying to set bank balance to -200, result:', e)

# trying to deposit
try:
    our_bank.balance += 15
except BankError as e:
    print('Trying to add an additional 15 $, result:', e)


try:
    our_bank.balance = -3
except BankError as e:
    print('TTrying to set bank balance to  -3, result:', e)

print('Current account balance:', our_bank.balance)

del our_bank.balance
del our_bank.account_number
class Person:
    def __init__(self, name):
        self._name = name

    @property
    def name(self):
        print('Getting name')
        return self._name

    @name.setter
    def name(self, value):
        print('Setting name to ' + value)
        self._name = value

    @name.deleter
    def name(self):
        print('Deleting name')
        del self._name

p = Person('Adam')
print('The name is:', p.name)
p.name = 'John'
del p.name
class Person:
    def __init__(self, name):
        self._name = name

    def get_name(self):
        print('Getting name')
        return self._name

    def set_name(self, value):
        print('Setting name to ' + value)
        self._name = value

    def del_name(self):
        print('Deleting name')
        del self._name

    # Set property to use get_name, set_name
    # and del_name methods
    name = property(get_name, set_name, del_name, 'Name property')

p = Person('Adam')
print(p.name)
p.name = 'John'
#del p.name<jupyter_output>Getting name
Adam
Setting name to John<jupyter_text>composition.- inheritance extends a class's capabilities by adding new components and modifying existing ones; in other words, the complete recipe is contained inside the class itself and all its ancestors; the object takes all the class's belongings and makes use of them;- composition projects a class as a container (called a composite) able to store and use other objects (derived from other classes) where each of the objects implements a part of a desired class's behavior. It’s worth mentioning that blocks are loosely coupled with the composite, and those blocks could be exchanged any time, even during program runtime.<jupyter_code>class Car:
    def __init__(self, engine):
        self.engine = engine

class GasEngine:
    def __init__(self, horse_power):
        self.hp = horse_power

    def start(self):
        print('Starting {}hp gas engine'.format(self.hp))

class DieselEngine:
    def __init__(self, horse_power):
        self.hp = horse_power

    def start(self):
        print('Starting {}hp diesel engine'.format(self.hp))


my_car = Car(GasEngine(4))
my_car.engine.start()
my_car.engine = DieselEngine(2)
my_car.engine.start()

class Base_Computer:
    def __init__(self, serial_number):
        self.serial_number = serial_number

class Personal_Computer(Base_Computer):
    def __init__(self, sn, connection):
        super().__init__(sn)
        self.connection = connection
        print('The computer costs $1000')

class Connection:
    def __init__(self, speed):
        self.speed = speed

    def download(self):
        print('Downloading at {}'.format(self.speed))

class DialUp(Connection):
    def __init__(self):
        super().__init__('9600bit/s')

    def download(self):
        print('Dialling the access number ... '.ljust(40), end='')
        super().download()

class ADSL(Connection):
    def __init__(self):
        super().__init__('2Mbit/s')

    def download(self):
        print('Waking up modem  ... '.ljust(40), end='')
        super().download()

class Ethernet(Connection):
    def __init__(self):
        super().__init__('10Mbit/s')

    def download(self):
        print('Constantly connected... '.ljust(40), end='')
        super().download()

# I started my IT adventure with an old-school dial up connection
my_computer = Personal_Computer('1995', DialUp())
my_computer.connection.download()

# then it came year 1999 with ADSL
my_computer.connection = ADSL()
my_computer.connection.download()

# finally I upgraded to Ethernet
my_computer.connection = Ethernet()
my_computer.connection.download()
'''* When we start with our personal computer, we set the serial number to 1995 and equip it with a dialup connection. This an example of composition.

It is possible to download some data using a slow dialup connection;
later, we equip our personal computer with a more advanced connection device. There is no need to recreate the computer object – we just arm it with a new component;
the last steps are about arming our old computer with a fast connection and downloading some data.'''
# https://edube.org/learn/python-advanced-1/lab-3
class Tires:
    def __init__(self, size):
        self.size = size
    def get_pressure(self):
        pass
    def pump(self):
        pass    
    
class Engine:
    def __init__(self, fuel_type):
        self.fuel_type = fuel_type
        print("I burn {} to run".format(self.fuel_type))
    def start(self):
        print("I don't want to start, it's too cold")
    def stop():
        print("I don't want to stop")
    def get_state():
        print("je vais bien...")
    
class Vehicle:
    def __init__(self, VIN, engine, tires):
        self.VIN = VIN
        self.engine = engine
        self.tires = tires
        print("Je m'appelle {} ...".format(self.VIN))
        
        
city_tires = Tires(15)
off_road_tires = Tires(18)

electric_engine = Engine('electric')
petrol_engine = Engine('petrol')

city_car = Vehicle('BelleVille', electric_engine, city_tires)
all_terrain_car = Vehicle('WildWest', petrol_engine, off_road_tires)<jupyter_output>I burn electric to run
I burn petrol to run
Je m'appelle BelleVille ...
Je m'appelle WildWest ...<jupyter_text>Inheriting properties from built-in classes<jupyter_code>#https://edube.org/learn/python-advanced-1/inheriting-properties-from-built-in-classes-1
class IntegerList(list):

    @staticmethod
    def check_value_type(value):
        if type(value) is not int:
            raise ValueError('Not an integer type')

    def __setitem__(self, index, value):
        IntegerList.check_value_type(value)
        list.__setitem__(self, index, value)

    def append(self, value):
        IntegerList.check_value_type(value)
        list.append(self, value)

    def extend(self, iterable):
        for element in iterable:
            IntegerList.check_value_type(element)

        list.extend(self, iterable)
        
    def insert(self, value):
        IntegerList.check_value_type(value) 
        pass
    
    def __add__(self, value):
        IntegerList.check_value_type(value)
        pass  
            
            
            
int_list = IntegerList()

int_list.append(66)
int_list.append(22)
print('Appending int elements succeed:', int_list)

int_list[0] = 49
print('Updating int element succeed:', int_list)

int_list.extend([2, 3])
print('Extending with int elements succeed:', int_list)

try:
    int_list.append('8-10')
except ValueError:
    print('Appending string failed')

try:
    int_list[0] = '10/11'
except ValueError:
    print('Inserting string failed')

try:
    int_list.extend([997, '10/11'])
except ValueError:
    print('Extending with ineligible element failed')

print('Final result:', int_list)

# https://edube.org/learn/python-advanced-1/inheriting-properties-from-built-in-classes-2
from datetime import datetime

class MonitoredDict(dict):
    def __init__(self, *args, **kwargs):
        super().__init__(*args, **kwargs)
        self.log = list()
        self.log_timestamp('MonitoredDict created')

    def __getitem__(self, key):
        val = super().__getitem__(key)
        self.log_timestamp('value for key [{}] retrieved'.format(key))
        return val

    def __setitem__(self, key, val):
        super().__setitem__(key, val)
        self.log_timestamp('value for key [{}] set'.format(key))

    def log_timestamp(self, message):
        timestampStr = datetime.now().strftime("%Y-%m-%d (%H:%M:%S.%f)")
        self.log.append('{} {}'.format(timestampStr, message))


kk = MonitoredDict()
kk[10] = 15
kk[20] = 5

print('Element kk[10]:', kk[10])
print('Whole dictionary:', kk)
print('Our log book:\n')
print('\n'.join(kk.log))

#https://edube.org/learn/python-advanced-1/inheriting-properties-from-built-in-classes-3
# IBAN Validator

iban = input("Enter IBAN, please: ")
iban = iban.replace(' ','')
if not iban.isalnum():
    print("You have entered invalid characters.")
elif len(iban) < 15:
    print("IBAN entered is too short.")
elif len(iban) > 31:
    print("IBAN entered is too long.")
else:
    iban = (iban[4:] + iban[0:4]).upper()
    iban2 = ''
    for ch in iban:
        if ch.isdigit():
            iban2 += ch
        else:
            iban2 += str(10 + ord(ch) - ord('A'))
    ibann = int(iban2)
    if ibann % 97 == 1:
        print("IBAN entered is valid.")
    else:
        print("IBAN entered is invalid.")
        
'''British: GB72 HBZU 7006 7212 1253 00
French: FR76 30003 03620 00020216907 50
German: DE02100100100152517108'''
# https://edube.org/learn/python-advanced-1/inheriting-properties-from-built-in-classes-4

class IBANValidationError(Exception):
    pass

def validateIBAN(iban):
    iban = iban.replace(' ', '')

    if not iban.isalnum():
        raise IBANValidationError("You have entered invalid characters.")

    elif len(iban) < 15:
        raise IBANValidationError("IBAN entered is too short.")

    elif len(iban) > 31:
        raise IBANValidationError("IBAN entered is too long.")

    else:
        iban = (iban[4:] + iban[0:4]).upper()
        iban2 = ''
        for ch in iban:
            if ch.isdigit():
                iban2 += ch
            else:
                iban2 += str(10 + ord(ch) - ord('A'))
        ibann = int(iban2)

        if ibann % 97 != 1:
            raise IBANValidationError("IBAN entered is invalid.")

        return True

test_keys = ['<KEY>', 'FR76 30003 03620 00020216907 50', 'DE02100100100152517108' ]

for key in test_keys:
    try:
        print('Status of "{}" validation: '.format(key))
        validateIBAN(key)
    except IBANValidationError as e:
        print("\t{}".format(e))
    else:
        print("\tcorrect")

#https://edube.org/learn/python-advanced-1/inheriting-properties-from-built-in-classes-5
import random


class IBANValidationError(Exception):
    pass


class IBANDict(dict):
    def __setitem__(self, _key, _val):
        if validateIBAN(_key):
            super().__setitem__(_key, _val)

    def update(self, *args, **kwargs):
        for _key, _val in dict(*args, **kwargs).items():
            self.__setitem__(_key, _val)


def validateIBAN(iban):
    iban = iban.replace(' ', '')

    if not iban.isalnum():
        raise IBANValidationError("You have entered invalid characters.")

    elif len(iban) < 15:
        raise IBANValidationError("IBAN entered is too short.")

    elif len(iban) > 31:
        raise IBANValidationError("IBAN entered is too long.")

    else:
        iban = (iban[4:] + iban[0:4]).upper()
        iban2 = ''
        for ch in iban:
            if ch.isdigit():
                iban2 += ch
            else:
                iban2 += str(10 + ord(ch) - ord('A'))
        ibann = int(iban2)

        if ibann % 97 != 1:
            raise IBANValidationError("IBAN entered is invalid.")

        return True


my_dict = IBANDict()
keys = ['<KEY>', 'FR76 30003 03620 00020216907 50', 'DE02100100100152517108']

for key in keys:
    my_dict[key] = random.randint(0, 1000)

print('The my_dict dictionary contains:')
for key, value in my_dict.items():
    print("\t{} -> {}".format(key, value))

try:
    my_dict.update({'dummy_account': 100})
except IBANValidationError:
    print('IBANDict has protected your dictionary against incorrect data insertion')<jupyter_output>The my_dict dictionary contains:
	GB72 HBZU 7006 7212 1253 00 -> 951
	FR76 30003 03620 00020216907 50 -> 359
	DE02100100100152517108 -> 921
IBANDict has protected your dictionary against incorrect data insertion<jupyter_text>Summary:- Python allows you to subclass any built-in class such as a list, tuple, dictionary, and many others;- by subclassing the built-ins, you can easily adapt generics to provide more sophisticated features;- by subclassing the built-ins, you can modify only the parts (methods, attributes) that you intend to modify, while all remaining parts will behave as good old built-ins.  Advanced Exceptions<jupyter_code>try:
    print(int('a'))
except ValueError:
    print('You tried to do a nasty thing...')

try:
    print(int('a'))
except ValueError as e_variable:
    print(e_variable.args)
try:
    import abcdefghijk

except ImportError as e:
    print(e.args)
    print(e.name)
    print(e.path)

try:
    b'\x80'.decode("utf-8")
except UnicodeError as e:
    print(e)
    print(e.encoding)
    print(e.reason)
    print(e.object)
    print(e.start)
    print(e.end)<jupyter_output>'utf-8' codec can't decode byte 0x80 in position 0: invalid start byte
utf-8
invalid start byte
b'\x80'
0
1<jupyter_text>chained exceptionsThis chaining concept introduces two attributes of exception instances:- the __context__ attribute, which is inherent for implicitly chained exceptions;- the __cause__ attribute, which is inherent for explicitly chained exceptions.<jupyter_code>a_list = ['First error', 'Second error']

try:
    print(a_list[3])
except Exception as e:
    print(0 / 0)

a_list = ['First error', 'Second error']

try:
    print(a_list[3])
except Exception as e:
    try:
        # the following line is a developer mistake - they wanted to print progress as 1/10	but wrote 1/0
        print(1 / 0)
    except ZeroDivisionError as f:
        print('Inner exception (f):', f)
        print('Outer exception (e):', e)
        print('Outer exception referenced:', f.__context__)
        print('Is it the same object:', f.__context__ is e)<jupyter_output>Inner exception (f): division by zero
Outer exception (e): list index out of range
Outer exception referenced: list index out of range
Is it the same object: True<jupyter_text>explicitly chained exceptions<jupyter_code>class RocketNotReadyError(Exception):
    pass

def personnel_check():
    try:
        print("\tThe captain's name is", crew[0])
        print("\tThe pilot's name is", crew[1])
        print("\tThe mechanic's name is", crew[2])
        print("\tThe navigator's name is", crew[3])
    except IndexError as e:
        raise RocketNotReadyError('Crew is incomplete') from e

crew = ['John', 'Mary', 'Mike']
print('Final check procedure')

personnel_check()


class RocketNotReadyError(Exception):
    pass

def personnel_check():
    try:
        print("\tThe captain's name is", crew[0])
        print("\tThe pilot's name is", crew[1])
        print("\tThe mechanic's name is", crew[2])
        print("\tThe navigator's name is", crew[3])
    except IndexError as e:
        raise RocketNotReadyError('Crew is incomplete') from e

crew = ['John', 'Mary', 'Mike']
print('Final check procedure')

try:
    personnel_check()
except RocketNotReadyError as f:
    print('General exception: "{}", caused by "{}"'.format(f, f.__cause__))
    
'''To catch the cause of the RocketNotReadyError exception, you should access the __cause__ attribute of the RocketNotReadyError object.'''
class RocketNotReadyError(Exception):
    pass


def personnel_check():
    try:
        print("\tThe captain's name is", crew[0])
        print("\tThe pilot's name is", crew[1])
        print("\tThe mechanic's name is", crew[2])
        print("\tThe navigator's name is", crew[3])
    except IndexError as e:
        raise RocketNotReadyError('Crew is incomplete') from e


def fuel_check():
    try:
        print('Fuel tank is full in {}%'.format(100 / 0))
    except ZeroDivisionError as e:
        raise RocketNotReadyError('Problem with fuel gauge') from e


crew = ['John', 'Mary', 'Mike']
fuel = 100
check_list = [personnel_check, fuel_check]

print('Final check procedure')

for check in check_list:
    try:
        check()
    except RocketNotReadyError as f:
        print('RocketNotReady exception: "{}", caused by "{}"'.format(f, f.__cause__))

# https://edube.org/learn/python-advanced-1/advanced-exceptions-the-lab

class RocketNotReadyError(Exception):
    pass


def personnel_check():
    try:
        print("\tThe captain's name is", crew[0])
        print("\tThe pilot's name is", crew[1])
        print("\tThe mechanic's name is", crew[2])
        print("\tThe navigator's name is", crew[3])
    except IndexError as e:
        raise RocketNotReadyError('Crew is incomplete') from e


def fuel_check():
    try:
        print('Fuel tank is full in {}%'.format(100/0))
    except ZeroDivisionError as e:
        raise RocketNotReadyError('Problem with fuel gauge') from e

def batteries_check():
    # add your own implentation
    #pass
    try:
        print('Batteries are full in {}%'.format(100/0))
    except ZeroDivisionError as e:
        raise RocketNotReadyError('Problem with fuel gauge') from e

def circuits_check():
    # add your own implentation
    #pass
    try:
        print('Circuits are function in {}%'.format(100/0))
    except ZeroDivisionError as e:
        raise RocketNotReadyError('Problem with fuel gauge') from e


crew = ['John', 'Mary', 'Mike']
fuel = 100
check_list = [personnel_check, fuel_check, batteries_check, circuits_check]

print('Final check procedure')

for check in check_list:
    try:
        check()
    except RocketNotReadyError as f:
        print('RocketNotReady exception: "{}", caused by "{}"'.format(f, f.__cause__))


#Advanced exceptions - the traceback attribute

class RocketNotReadyError(Exception):
    pass

def personnel_check():
    try:
        print("\tThe captain's name is", crew[0])
        print("\tThe pilot's name is", crew[1])
        print("\tThe mechanic's name is", crew[2])
        print("\tThe navigator's name is", crew[3])
    except IndexError as e:
        raise RocketNotReadyError('Crew is incomplete') from e

crew = ['John', 'Mary', 'Mike']
print('Final check procedure')

try:
    personnel_check()
except RocketNotReadyError as f:
    print(f.__traceback__)
    print(type(f.__traceback__))

import traceback

class RocketNotReadyError(Exception):
    pass


def personnel_check():
    try:
        print("\tThe captain's name is", crew[0])
        print("\tThe pilot's name is", crew[1])
        print("\tThe mechanic's name is", crew[2])
        print("\tThe navigator's name is", crew[3])
    except IndexError as e:
        raise RocketNotReadyError('Crew is incomplete') from e


crew = ['John', 'Mary', 'Mike']

print('Final check procedure')

try:
    personnel_check()
except RocketNotReadyError as f:
    print(f.__traceback__)
    print(type(f.__traceback__))
    print('\nTraceback details')
    details = traceback.format_tb(f.__traceback__)
    print("\n".join(details))

print('Final check is over')

'''For more information about chained exceptions and traceback attributes, look at the PEP 3134 document.'''<jupyter_output>Final check procedure
	The captain's name is John
	The pilot's name is Mary
	The mechanic's name is Mike
<traceback object at 0x00000252BEB0F6C0>
<class 'traceback'>

Traceback details
  File "C:\Users\fan.z\AppData\Local\Temp/ipykernel_22724/4268288308.py", line 22, in <module>
    personnel_check()

  File "C:\Users\fan.z\AppData\Local\Temp/ipykernel_22724/4268288308.py", line 14, in personnel_check
    raise RocketNotReadyError('Crew is incomplete') from e

Final check is over<jupyter_text>Copying objects using shallow and deep operations- object: label vs. identity vs. value;- the id() function and the is operand;- shallow and deep copies of the objects.<jupyter_code>a_string = ['10', 'days', 'to', 'departure']
b_string = a_string

print('a_string identity:', id(a_string))
print('b_string identity:', id(b_string))
print('The result of the value comparison:', a_string == b_string)
print('The result of the identity comparison:', a_string is b_string)

print()

a_string = ['10', 'days', 'to', 'departure']
b_string = ['10', 'days', 'to', 'departure']

print('a_string identity:', id(a_string))
print('b_string identity:', id(b_string))
print('The result of the value comparison:', a_string == b_string)
print('The result of the identity comparison:', a_string is b_string)

#shallow copy 
print("Part 1")
print("Let's make a copy")
a_list = [10, "banana", [997, 123]]
b_list = a_list[:]
print("a_list contents:", a_list)
print("b_list contents:", b_list)
print("Is it the same object?", a_list is b_list)

print()
print("Part 2")
print("Let's modify b_list[2]")
b_list[2][0] = 112
print("a_list contents:", a_list)
print("b_list contents:", b_list)
print("Is it the same object?", a_list is b_list)
'''Using [:], which is an array slice syntax, we get a fresh copy of the a_list object;
So, despite the fact that b_list is a copy of a_list, modifying b_list results in a modification of the a_list object.

'''<jupyter_output>Part 1
Let's make a copy
a_list contents: [10, 'banana', [997, 123]]
b_list contents: [10, 'banana', [997, 123]]
Is it the same object? False

Part 2
Let's modify b_list[2]
a_list contents: [10, 'banana', [112, 123]]
b_list contents: [10, 'banana', [112, 123]]
Is it the same object? False<jupyter_text>The explanation of the behavior presented on the previous page is:- the 'a_list' object is a compound object;- we’ve run a  shallow copy  that constructs a new compound object, b_list in our example, and then populated it with references to the objects found in the original;- as you can see, a shallow copy is only one level deep. The copying process does not recurse and therefore does not create copies of the child objects, but instead populates b_list with references to the already existing objects.<jupyter_code># deep copy
import copy

print("Let's make a deep copy")
a_list = [10, "banana", [997, 123]]
b_list = copy.deepcopy(a_list)
print("a_list contents:", a_list)
print("b_list contents:", b_list)
print("Is it the same object?", a_list is b_list)

print()
print("Let's modify b_list[2]")
b_list[2][0] = 112
print("a_list contents:", a_list)
print("b_list contents:", b_list)
print("Is it the same object?", a_list is b_list)
'''The 'copy' module contains a function for shallow copying: copy(). Of course, you could say that for copying lists there is already the [:] notation, or a_list=list(b_list), and for dictionaries you could use a_dict = dict(b_dict).

But think about making use of polymorphism when you need a universal function to copy any type object, so that in that case using a copy() function is the smart way to accomplish the task.

'''
import copy
import time

a_list = [(1,2,3) for x in range(1_000_000)]

print('Single reference copy')
time_start = time.time()
b_list = a_list
print('Execution time:', round(time.time() - time_start, 3))
print('Memory chunks:', id(a_list), id(b_list))
print('Same memory chunk?', a_list is b_list)

print()

print('Shallow copy')
time_start = time.time()
b_list = a_list[:]
print('Execution time:', round(time.time() - time_start, 3))
print('Memory chunks:', id(a_list), id(b_list))
print('Same memory chunk?', a_list is b_list)

print()

print('Deep copy')
time_start = time.time()
b_list = copy.deepcopy(a_list)
print('Execution time:', round(time.time() - time_start, 3))
print('Memory chunks:', id(a_list), id(b_list))
print('Same memory chunk?', a_list is b_list)

import copy

a_dict = {
    'first name': 'James',
    'last name': 'Bond',
    'movies': ['Goldfinger (1964)', 'You Only Live Twice']
    }
b_dict = copy.deepcopy(a_dict)
print('Memory chunks:', id(a_dict), id(b_dict))
print('Same memory chunk?', a_dict is b_dict)
print("Let's modify the movies list")
a_dict['movies'].append('Diamonds Are Forever (1971)')
print('a_dict movies:', a_dict['movies'])
print('b_dict movies:', b_dict['movies'])


import copy

class Example:
    def __init__(self):
        self.properties = ["112", "997"]
        print("Hello from __init__()")

a_example = Example()
b_example = copy.deepcopy(a_example)
print("Memory chunks:", id(a_example), id(b_example))
print("Same memory chunk?", a_example is b_example)
print()
print("Let's modify the movies list")
b_example.properties.append("911")
print('a_example.properties:', a_example.properties)
print('b_example.properties:', b_example.properties)

# https://edube.org/learn/python-advanced-1/lab-1-1
import copy

warehouse = list()
warehouse.append({'name': 'Lolly Pop', 'price': 0.4, 'weight': 133})
warehouse.append({'name': 'Licorice', 'price': 0.1, 'weight': 251})
warehouse.append({'name': 'Chocolate', 'price': 1, 'weight': 601})
warehouse.append({'name': 'Sours', 'price': 0.01, 'weight': 513})
warehouse.append({'name': 'Hard candies', 'price': 0.3, 'weight': 433})

print('Source list of candies')
for item in warehouse:
    print(item)

proposal = copy.deepcopy(warehouse)
print('Price proposal')
for item in proposal:
    if item['weight']>300:
        item['price'] = item['price'] * .8
    print(item)

# https://edube.org/learn/python-advanced-1/lab-2-1
import copy

class Delicacy:
    def __init__(self, name, price, weight):
        self.name = name
        self.price = price
        self.weight = weight
        
    def __str__(self):
        return 'name: {}, price: {}, weight: {}'.format(self.name,self.price,self.weight)

warehouse = list()
warehouse.append(Delicacy('Lolly Pop', 0.4, 133))
warehouse.append(Delicacy('Licorice', 0.1, 251))
warehouse.append(Delicacy('Chocolate', 1,  601))
warehouse.append(Delicacy('Sours', 0.01, 513))
warehouse.append(Delicacy('Hard candies', 0.3, 433))

print('Source list of candies')
for item in warehouse:
    print(item)
print()
proposal = copy.deepcopy(warehouse)
for item in proposal:
    if item.weight>300:
        item.price = item.price * .8
    print(item)<jupyter_output>Source list of candies
name: Lolly Pop, price: 0.4, weight: 133
name: Licorice, price: 0.1, weight: 251
name: Chocolate, price: 1, weight: 601
name: Sours, price: 0.01, weight: 513
name: Hard candies, price: 0.3, weight: 433

name: Lolly Pop, price: 0.4, weight: 133
name: Licorice, price: 0.1, weight: 251
name: Chocolate, price: 0.8, weight: 601
name: Sours, price: 0.008, weight: 513
name: Hard candies, price: 0.24, weight: 433<jupyter_text>Section summaryImportant things to remember:the deepcopy() method creates and persists new instances of source objects, whereas any shallow copy operation only stores references to the original memory address;a deep copy operation takes significantly more time than any shallow copy operation;the deepcopy() method copies the whole object, including all nested objects; it’s an example of practical recursion taking place;deep copy might cause problems when there are cyclic references in the structure to be copied.  Serialization of Python objects using the pickle moduleThe following types can be pickled:- None, booleans;- integers, floating-point numbers, complex numbers;- strings, bytes, bytearrays;- tuples, lists, sets, and dictionaries containing pickleable objects;- objects, including objects with references to other objects (remember to avoid cycles!)- references to functions and classes, but not their definitions.<jupyter_code>import pickle

a_dict = dict()
a_dict['EUR'] = {'code':'Euro', 'symbol': '€'}
a_dict['GBP'] = {'code':'Pounds sterling', 'symbol': '£'}
a_dict['USD'] = {'code':'US dollar', 'symbol': '$'}
a_dict['JPY'] = {'code':'Japanese yen', 'symbol': '¥'}

a_list = ['a', 123, [10, 100, 1000]]

with open('multidata.pckl', 'wb') as file_out:
    pickle.dump(a_dict, file_out)
    pickle.dump(a_list, file_out)

import pickle

with open('multidata.pckl', 'rb') as file_in:
    data1 = pickle.load(file_in)
    data2 = pickle.load(file_in)

print(type(data1))
print(data1)
print(type(data2))
print(data2)

import pickle

a_list = ['a', 123, [10, 100, 1000]]
bytes = pickle.dumps(a_list)
print('Intermediate object type, used to preserve data:', type(bytes))

# now pass 'bytes' to appropriate driver

# therefore when you receive a bytes object from an appropriate driver you can deserialize it
b_list = pickle.loads(bytes)
print('A type of deserialized object:', type(b_list))
print('Contents:', b_list)<jupyter_output>Intermediate object type, used to preserve data: <class 'bytes'>
A type of deserialized object: <class 'list'>
Contents: ['a', 123, [10, 100, 1000]]<jupyter_text>Remember that attempts to pickle non-pickleable objects will raise the PicklingError exception.Trying to pickle a highly recursive data structure (mind the cycles) may exceed the maximum recursion depth, and a RecursionError exception will be raised in such cases.Note that functions (both built-in and user-defined) are pickled by their name reference, not by any value. This means that only the function name is pickled; neither the function’s code, nor any of its function attributes, are pickled.Similarly, classes are pickled by named reference, so the same restrictions in the unpickling environment apply. Note that none of the class’s code or data are pickled.This is done on purpose, so you can fix bugs in a class or add methods to the class, and still load objects that were created with an earlier version of the class.Hence, your role is to ensure that the environment where the class or function is unpickled is able to import the class or function definition. In other words, the function or class must be available in the namespace of your code reading the pickle file.Otherwise, an AtrributeError exception will be raised.<jupyter_code>import pickle

def f1():
    print('Hello from the jar!')

with open('function.pckl', 'wb') as file_out:
    pickle.dump(f1, file_out)


import pickle

with open('function.pckl', 'rb') as file_in:
    data = pickle.load(file_in)

print(type(data))
print(data)
data()

import pickle

class Cucumber:
    def __init__(self):
        self.size = 'small'

    def get_size(self):
        return self.size

cucu = Cucumber()

with open('cucumber.pckl', 'wb') as file_out:
    pickle.dump(cucu, file_out)

import pickle

with open('cucumber.pckl', 'rb') as file_in:
    data = pickle.load(file_in)

print(type(data))
print(data)
print(data.size)
print(data.get_size())<jupyter_output><class '__main__.Cucumber'>
<__main__.Cucumber object at 0x00000252C2F9C460>
small
small<jupyter_text>Serialization of Python objects using the shelve moduleYou should treat a shelve object as a Python dictionary, with a few additional notes:- the keys must be strings;- Python puts the changes in a buffer which is periodically flushed to the disk. To enforce an immediate flush, call the sync() method on your shelve object;- when you call the close() method on an shelve object, it also flushes the buffers.When you treat a shelve object like a Python dictionary, you can make use of the dictionary utilities:- the len() function;- the in operator;- the keys() anditems() methods;- the update operation, which works the same as when applied to a Python dictionary;- the del instruction, used to delete a key-value pair.<jupyter_code>import shelve

shelve_name = 'first_shelve.shlv'

my_shelve = shelve.open(shelve_name, flag='c')
my_shelve['EUR'] = {'code':'Euro', 'symbol': '€'}
my_shelve['GBP'] = {'code':'Pounds sterling', 'symbol': '£'}
my_shelve['USD'] = {'code':'US dollar', 'symbol': '$'}
my_shelve['JPY'] = {'code':'Japanese yen', 'symbol': '¥'}
my_shelve.close()

new_shelve = shelve.open(shelve_name)
print(new_shelve['USD'])
new_shelve.close()

'''Value	Meaning
'r'	Open existing database for reading only
'w'	Open existing database for reading and writing
'c'	Open database for reading and writing, creating it if it doesn’t exist (this is a default value)
'n'	Always create a new, empty database, open for reading and writing'''<jupyter_output>{'code': 'US dollar', 'symbol': '$'}<jupyter_text>Introduction to metaclasses<NAME>, the Python guru who authored the Zen of Python, expressed his feelings about metaclasses in the comp.lang.python newsgroup on 12/22/2002:The typical use cases for metaclasses:- logging;- registering classes at creation time;- interface checking;- automatically adding new methods;- automatically adding new variables..These observations lead us to the following conclusions:- metaclasses are used to create classes;- classes are used to create objects;- the type of the metaclass type is type – no, that is not a typo.<jupyter_code>class Dog:
    pass


age = 10
codes = [33, 92]
dog = Dog()

print(type(age))
print(type(codes))
print(type(dog))
print(type(Dog))

for t in (int, list, type):
    print(type(t))
    
'''To extend the above observations, it’s important to add:
type is a class that generates classes defined by a programmer;
metaclasses are subclasses of the type class.'''
class Dog:
    pass

dog = Dog()
print('"dog" is an object of class named:', Dog.__name__)
print()
print('class "Dog" is an instance of:', Dog.__class__)
print('instance "dog" is an instance of:', dog.__class__)
print()
print('class "Dog" is  ', Dog.__bases__)
print()
print('class "Dog" attributes:', Dog.__dict__)
print('object "dog" attributes:', dog.__dict__)
print(
'''
We should get familiar with some special attributes:
__name__ – inherent for classes; contains the name of the class;
__class__ – inherent for both classes and instances; contains information about the class to which a class instance belongs;
__bases__ – inherent for classes; it’s a tuple and contains information about the base classes of a class;
__dict__ – inherent for both classes and instances; contains a dictionary (or other type mapping object) of the object's attributes.
The output of the code presented in the right pane:''')
for element in (1, 'a', True):
    print(element, 'is', element.__class__, type(element))

#For the invocation of type(, , ):
#    the argument specifies the class name; this value becomes the __name__ attribute of the class;
#    the argument specifies a tuple of the base classes from which the newly created class is inherited; this argument becomes the __bases__ attribute of the class;
#    the argument specifies a dictionary containing method definitions and variables for the class body; the elements of this argument become the __dict__ attribute of the class and state the class namespace.
#A very simple example, when both bases and dictionary are empty, is presented in the right pane.

Dog = type('Dog', (), {})

print('The class name is:', Dog.__name__)
print('The class is an instance of:', Dog.__class__)
print('The class is based on:', Dog.__bases__)
print('The class attributes are:', Dog.__dict__)

def bark(self):
    print('Woof, woof')

class Animal:
    def feed(self):
        print('It is feeding time!')

Dog = type('Dog', (Animal, ), {'age':0, 'bark':bark})

print('The class name is:', Dog.__name__)
print('The class is an instance of:', Dog.__class__)
print('The class is based on:', Dog.__bases__)
print('The class attributes are:', Dog.__dict__)

doggy = Dog()
doggy.feed()
doggy.bark()
'''
This way of creating classes, using the type function, is substantial for Python's way of creating classes using the class instruction:

after the class instruction has been identified and the class body has been executed, the class = type(, , ) code is executed;
the type is responsible for calling the __call__ method upon class instance creation; this method calls two other methods:
__new__(), responsible for creating the class instance in the computer memory; this method is run before __init__();
__init__(), responsible for object initialization.
Metaclasses usually implement these two methods (__init__, __new__), taking control of the procedure of creating and initializing a new class instance. Classes receive a new layer of logic.'''
### 1st metaclass
#the class My_Meta is derived from type. This makes our class a metaclass;
class My_Meta(type):
    #our own __new__ method has been defined. Its role is to call the __new__ method of the parent class to create a new class;
    def __new__(mcs, name, bases, dictionary):
        #__new__ uses 'mcs' to refer to the class – it’s just a convention;
        obj = super().__new__(mcs, name, bases, dictionary)
        #a class attribute is created additionally;
        obj.custom_attribute = 'Added by My_Meta'
        #the class is returned.
        return obj
#a new class has been defined in a way where a custom metaclass is listed in the class definition as a metaclass. This is a way to tell Python to use My_Meta as a metaclass, not as an ordinary superclass;
class My_Object(metaclass=My_Meta):
    pass
#we are printing the contents of the class __dict__ attribute to check if the custom attribute is present.
print(My_Object.__dict__)
#Indeed, the class attribute has been created.
#Metaprogramming – another metaclass
def greetings(self):
    print('Just a greeting function, but it could be something more serious like a check sum')

class My_Meta(type):
    def __new__(mcs, name, bases, dictionary):
        if 'greetings' not in dictionary:
            dictionary['greetings'] = greetings
        obj = super().__new__(mcs, name, bases, dictionary)
        return obj

class My_Class1(metaclass=My_Meta):
    pass

class My_Class2(metaclass=My_Meta):
    def greetings(self):
        print('We are ready to greet you!')

myobj1 = My_Class1()
myobj1.greetings()
myobj2 = My_Class2()
myobj2.greetings()

# https://edube.org/learn/python-advanced-1/metaprogramming-lab-metaclasses
from datetime import datetime

def get_instantiation_time(self):
    timestamp = datetime.now()
    # convert timestamp to human-readable string, following passed pattern:
    string_timestamp = timestamp.strftime('%Y-%m-%d %H:%M:%S')
    return string_timestamp

class My_Meta(type):
    def __new__(mcs, name, bases, dictionary):
        if 'instantiation_time' not in dictionary:
            dictionary['instantiation_time'] = get_instantiation_time
        obj = super().__new__(mcs, name, bases, dictionary)
        return obj

class My_Class1(metaclass=My_Meta):
    pass

myobj1 = My_Class1()
myobj1.instantiation_time()<jupyter_output><empty_output><jupyter_text>https://edube.org/quiz/python-advanced-1/advanced-classes-and-oop-final-quiz    Which sentence describing an abstract class is true?it provides a means for APITake a look at the snippet and choose one of the follwing statments which is trueit is enooneous as these is no abc.abstractclass decorator<jupyter_code>import abc

@abc.abstractclass
class BluePrint(abc.ABC):
    @abc.abstractmethod
    def hello(self):
        pass
    
class WhitePool(BluePrinte):
    def hello(self):
        print|('Welcome to the White Pool!')

wp = WhitePool()
wp.hello()<jupyter_output><empty_output><jupyter_text>The chaining concept introduces the following atribute on exception instances:traceback, which is inherent only for explicitly/implicitly chained exceptions*context, which is inherent only for implicitly chained exceptionscause, which is inherent only for explicitly chained exceptionshttps://www.python.org/dev/peps/pep-3134/implicit-exception-chaining__cause__ is the cause of the exception - due to the given exception, the current exception was raised. This is a direct link - X threw this exception, therefore Y has to throw this exception.__context__ on the other hand means that the current exception was raised while trying to handle another exception, and defines the exception that was being handled at the time this one was raised. This is so that you don't lose the fact that the other exceptions happened (and hence were at this code to throw the exception) - the context. X threw this exception, while handling it, Y was also thrown.__traceback__ shows you the stack - the various levels of functions that have been followed to get to the current line of code. This allows you to pinpoint what caused the exception. It is likely to be used (potentially in tandem with __context__) to find what caused a given bug.<jupyter_code># the following snippet is an example of what kind of exception chaining?
class OwnMath(Exception):
    pass
def calculate_value(numerator, denominator):
    try:
        value =  numerator / denominator
    except ZeroDivisionError as e:
        raise OwnMath from e
    return value
calculate_value(4,0)
'''explicitly chained exceptions'''<jupyter_output><empty_output><jupyter_text>@property is used to decorator:an 'access controller' type methods  XXXany proxying methodwhat should be the order of decorators, placed in your code, controlling access to one specific attribute?first @property then @attibute.setter or @attribute.deleter<jupyter_code>#look at the following code and name its elemets
class A:
    def __init_():
        self.name = Non
        
    def function(self, value):
        self.b= value
        
a=A()
# A is a class, fuction is an object, a is an object
what is the difference between inheritance and composition?
inheritance models and 'is a' relation whereas composition models a 'has a' relation

the id() function returns:
the identity of the object, a unique value amongst other objects.

selcet the true statement about class methods:
class methods are methods that work on the class itself.

what are class methods:
class methods are tool methods available to all class instances.

True statement:
*args refers to a tuple of all not explicitly expected arguments; **kwargs refers to a dictionary of all not explicitly expected keyword arguments

What is a decorator? select the best answer.
it can be a function that returns a function that can be called later

which function can be used to get a list of methods inherence to an object?
__dict()__ **?
dict()
__help()__
help()


a=dict()
help(a)<jupyter_output>Help on dict object:

class dict(object)
 |  dict() -> new empty dictionary
 |  dict(mapping) -> new dictionary initialized from a mapping object's
 |      (key, value) pairs
 |  dict(iterable) -> new dictionary initialized as if via:
 |      d = {}
 |      for k, v in iterable:
 |          d[k] = v
 |  dict(**kwargs) -> new dictionary initialized with the name=value pairs
 |      in the keyword argument list.  For example:  dict(one=1, two=2)
 |  
 |  Built-in subclasses:
 |      StgDict
 |  
 |  Methods defined here:
 |  
 |  __contains__(self, key, /)
 |      True if the dictionary has the specified key, else False.
 |  
 |  __delitem__(self, key, /)
 |      Delete self[key].
 |  
 |  __eq__(self, value, /)
 |      Return self==value.
 |  
 |  __ge__(self, value, /)
 |      Return self>=value.
 |  
 |  __getattribute__(self, name, /)
 |      Return getattr(self, name).
 |  
 |  __getitem__(...)
 |      x.__getitem__(y) <==> x[y]
 |  
 |  __gt__(self, value, /)
 |      Return self>va[...]
{'fraction_non_alphanumeric': 0.10558992797987324, 'fraction_numerical': 0.018517918650729378, 'mean_word_length': 3.684335643113651, 'pattern_counts': {'":': 0, '<': 134, '<?xml version=': 0, '>': 141, 'https://': 22, 'lorem ipsum': 0, 'www.': 1, 'xml': 0}, 'pii_count': 1, 'substrings_counts': 0, 'word_list_counts': {'cursed_substrings.json': 14, 'profanity_word_list.json': 0, 'sexual_word_list.json': 0, 'zh_pornsignals.json': 0}}
{'chain_length': '27', 'dir': 'jupyter-structured-clean-dedup', 'id': '274299', 'license': 'Unlicense', 'n_tokens_mistral': 28995, 'n_tokens_neox': 27166, 'n_words': 9975, 'path': 'edube.org.pcpp1-advanced-perspective-of-classes-and-oop.ipynb', 'repo_name': 'fanzhefu/edube', 'size': '92663'}