Deep Dive on Python - Introduction

October 13th 2025
All there is to know about python

1. Objects

  • type(obj) - check the type of the object
  • isinstance(my_obj, MyClass) - check if the object is an instance of the class

1.1. Creating Classes

  • class MyClass:
    • Python creates and object
      • called MyClass
      • of type type
      • with certain attributes automatically assigned
class Person:
	pass

type(Person) # type
type(type) # type
## Person.name # 'Person'

p = Person()
type(p) # __main__.Person

p.__class__ # __main__.Person

type(p) is p.__class__ # True

isinstance(p, Person) # True

1.2 Class Attributes

  • MyClass is a class -> it is an object of type type
  • We are talking about the object MyClass itself not the instance of MyClass

1.2.1 Retrieving Attribute Values from Objects

  • getattr(object_symbol: type, attr_name: str, default_return_val) can be used to retrieve attributes
class MyClass:
	language = 'Python'
	verison = '3.6'

getattr(MyClass, 'language') 		# 'Python'
getattr(MyClass, 'width', 'N/A') 	# 'N/A default value'

try:
	getattr(MyClass, 'name') 			# AttributeError exception
except AttributeError:
	print("attribute 'name' does't exist in class MyClass")

## dot notation
MyClass.language 	# 'Python'

try:
	MyClass.x 			# AttributeError
except AttributeError:
	print("attribute 'x' does't exist in class MyClass")

1.2.2 Setting Attributes Values

  • setattr(object_symbol: type, attribute_name: str, attribute_value) - can be used to set values
class MyClass:
	language = 'Python'
	version = '3.6'

setattr(MyClass, 'language', 'Java')
MyClass.version
getattr(MyClass, 'language') # Java

1.2.3 Where if the state of a class stored

class MyClass:
	language = 'Python'
	verison = '3.6'

print(MyClass.__dict__) # a read only hashmap, called class namespace
Max
Max
Peter

1.2.4 Setting Attribute Value to a Callable

class MyClass:
	def hello(): # a callable attribute value
		pass


'''
{
	...
	'hello': <function MyClass.hello at 0x1032b3920>,
	...
}
'''
MyClass.__dict__

1.2.5 Data Attributes, Classes & Instances

  • MyClass.__dict__ - returns the attributes of the MyClass object type

  • my_obj.__dict__ - returns the attributes of the my_obj instance of the MyClass

  • MyClass.language

    • Python looks for language attribute in MyClass namespace
  • my_obj.language

    • Python looks for language attribute in my_obj namespace first
    • if not found, it looks for language in type(class) of my_obj i.e MyClass

class MyClass:
	language = "Python"

my_obj = MyClass()

print(
	MyClass.__dict__, # {'language': 'Python'}
	my_obj.__dict__, # {}

	MyClass.language, # Python
	my_obj.language, # Python

	sep="\n"
)
## Now this is called an instance attr not a class attr
## This only changes the instance attr, not class attr
my_obj.language = 'java'

print(
	my_obj.__dict__,
	MyClass.__dict__,
	sep="\n"
)
{'__module__': '__main__', '__firstlineno__': 80, 'language': 'Python', '__static_attributes__': (), '__dict__': <attribute '__dict__' of 'MyClass' objects>, '__weakref__': <attribute '__weakref__' of 'MyClass' objects>, '__doc__': None}
{}
Python
Python

1.2.6 Function Attribute of a class

  • When attributes are function they behave different then regular attributes
class MyClass:
    def say_hello():
        print("Hello World!")

my_obj = MyClass()

print(
    MyClass.say_hello,      # <function MyClass.say_hello at 0x1006f7880>
    my_obj.say_hello,       # <bound method MyClass.say_hello of <__main__.MyClass object at 0x10059ba10>>

    sep="\n"
)

MyClass.say_hello()

try:
    my_obj.say_hello()
except TypeError as e:
    print("Error:", e)
<function MyClass.say_hello at 0x1028bf880>
<bound method MyClass.say_hello of <__main__.MyClass object at 0x1026b3a10>>
Hello World!
Error: MyClass.say_hello() takes 0 positional arguments but 1 was given

1.2.6 Methods

  • In my_object.say_hello()

    • say_hello is an object of method type
    • it is bound to the object my_obj
    • when my_obj.say_hello() is called, the bound obj my_obj is injected as the first argument by default
    • Now say_hello() has a handle on the namespace of my_obj - it can access the attributes defined in the namespace
  • Functions ==defined in the Class== - becomes a ==method== when called from an instance of that class

  • Function ==defined in the instance== at runtime, remains a ==regular function==.

class MyClass:
    language = 'Python'

    def say_hello(obj, name):
        return f"Hello {name}! I am {obj.language}."

python = MyClass()
print(python.say_hello('John'))

java = MyClass()
java.language = 'Java'
print(java.say_hello('John'))

MyClass.do_something = lambda self: print(f"Doing something at {self}")
python.write_something = lambda self: print(f"Writing something at {self}")

print(
    python.do_something,        # function defined in the class, becomes method
    python.write_something,     # function defined in the instance, stays a regular function
sep="\n"
)
Hello John! I am Python.
Hello John! I am Java.
<bound method <lambda> of <__main__.MyClass object at 0x102812ba0>>
<function <lambda> at 0x1029c3c40>

1.3 Initializing a class instance

  • When MyClass('3.13') is called
    1. Python create a new instance object obj with ==empty namespace==
    2. If __init__ is defined,
      1. it calls obj.__init__('3.13') - which is MyClass.__init__(obj, '3.13)
      2. the function then add version to the empty namespace of obj and assigns it to 3.13
      3. version is an instance attr of obj
        • obj.__dict__ -> {'version': '3.13'}
class MyClass:
	language = 'Python'

	def __init__(obj, version):
		obj.version = version

1.3.1 Difference between __new__ and __init__

  • By the time __init__ is called
    • new instance obj has already been created with an empty namespace
    • then __init__ is called as a bound method to that obj
  • __new__ can be used to override the default object creation behavior - before the initialization

1.4 Creating Attributes at Runtime

  • We saw in [[OOP - Introduction#1.2.6.i Methods]]
    • functions attributes created on an instance at runtime is not a method, they are regular functions
    • but we can define them as such to make them a method

1.4.1 Creating and Binding a method to an instance at runtime

from types import MethodType # MethodType(function , object)

class MyClass:
	language = 'Python'

	def __init__(obj, version):
		obj.version = version

obj = MyClass('3.13')
obj.do_something = lambda self: print("hello world!")
print(obj.do_something)

try:
    obj.do_something_meth = MethodType(lambda self: print("hello world!"), obj)
    print(obj.do_something_meth)
except:
    print("this doesnot work in a notebook for some reason!")
<function <lambda> at 0x10442ca40>
<bound method <lambda> of <__main__.MyClass object at 0x104206a50>>

1.5 Class and Instance Properties

1.5.1 Defining properties using “property” type

  • class has getter , setter, deleter
  • property is an immutable object
class MyClass:
    def __init__(self, val):
        self._prop = val

    def get_prop(self):
        print("getting prop")
        return  self._prop
    def set_prop(self, val):
        print("setting prop")
        self._prop = val

    prop = property(get_prop)       # defining getter
    prop = prop.setter(set_prop)    # defining setter

obj = MyClass(10)
obj.prop
obj.prop = 20
getting prop
setting prop

1.5.2 Using the “property” type as a decorator

class MyClass:
    def __init__(self, val):
        self._prop = val

    '''
    What is happening here?

    1. prop method gets defined - "prop" holds the reference to the method object
    2. prop is passed into property() as an arg and the resulting value is assigned to the "prop" var

        prop <property type> = property(prop <method prop(self)>)
    '''
    @property
    def prop(self):
        return self._prop

    '''
    What is happening here?

    prop <property type> = prop.setter(prop <method prop(self, val)>)
    '''
    @prop.setter
    def prop(self, val):
        self._prop = val


obj = MyClass(10)
obj.prop
obj.prop = 20

1.5.3 Immutability of “Property” object

def getter():
	pass
def setter():
	pass

p = property()
p = p.getter(getter)
print("p getter:", id(p))
p = p.setter(setter)
print("p setter:", id(p)) # property object's internal state is not changed - but rather a new property object is created and bound to "p"
p getter: 4339486768
p setter: 4339486528

1.5.4 Read Only and Computed Property

1.5.4.i Read-only Property

  • not true read only, since _prop is still accessible
  • just a suggestion
  • just define a getter and not the setter

1.5.4.ii Computed Property

import math

class Circle:
    def __init__(self, radius):
        self._radius = radius
        self._area = None

    def area(self):
        print("regular method")
        return math.pi * (self._radius ** 2)

    @property
    def area_prop(self):
        print("property")
        return math.pi * (self._radius ** 2)

    @property
    def area_cached(self):
        if self._area is None:
            print("calc in lazy")
            self._area = math.pi * (self._radius ** 2)
            return self._area
        else:
            print("pulled from cache")
            return self._area

circle = Circle(10)

circle.area()
circle.area_prop
circle.area_cached
circle.area_cached
regular method
property
calc in lazy
pulled from cache
314.1592653589793

1.5.4.iii Example webpage loader

from urllib import request
from time import perf_counter

class WebPage():

    def __init__(self, url):
        self.url = url
        self._page = None
        self._page_size = None
        self._page_load_time_sec = None

    @property
    def url(self):
        return self._url

    @url.setter
    def url(self, value):
        self._url = value

    @property
    def page(self):
        if self._page is None:
            self._download_page()
        return self._page

    @property
    def page_size(self):
        if self._page is None:
            self._download_page()
        return self._page_size

    @property
    def time_elapsed(self):
        if self._page is None:
            self._download_page()
        return self._page_load_time_sec

    def _download_page(self):
        "downlad the page & and set all the instance props related to that page in that function"

        start_time = perf_counter()
        with request.urlopen(self.url) as f:
            self._page = f.read()
        end_time = perf_counter()
        self._page_size = len(self._page)
        self._page_load_time_sec = end_time - start_time

urls = [
    # "https://www.python.org",
    # "https://www.github.com",
    # "https://www.w3schools.com",
    # "https://docs.python.org/3.13/"
]

for url in urls:
    web_page = WebPage(url)
    print( f"{url}\tsize={web_page.page_size}\telapsed={web_page.time_elapsed:.2f} secs")
https://www.python.org	size=50110	elapsed=0.21secs
https://www.github.com	size=560652	elapsed=0.99secs
https://www.w3schools.com	size=423163	elapsed=0.53secs
https://docs.python.org/3.13/	size=17840	elapsed=0.14secs

1.5.4 Deleting Properties (from instance)

class MyClass:
    def __init__(self, language="Python"):
        self.language = language

    @property
    def language(self):
        return self._language

    @language.setter
    def language(self, val):
        self._language = val

    @language.deleter
    def language(self):
        del self._language

my_class = MyClass()

print(my_class.language)

'''
    OR
    delattr(my_class, "language")
'''
del my_class.language # this is just modifying instance not the class

try:
    print(my_class.language)
except AttributeError as e:
    print(e)
Python
'MyClass' object has no attribute '_language'

1.5.5 Class and Static Methods

  • A function defined inside a class will alter its behavior based on how it’s called
class MyClass:
	def hello():
		print("Hello")

MyClass.hello()

try:
	m = MyClass()
	m.hello() # Not allowed in m.hello() - hello() is bound to the class
except TypeError as e:
	print(e)
Hello
MyClass.hello() takes 0 positional arguments but 1 was given
  • but how to create a function that is always bound to the class

1.5.5.i Class Methods - a function that is always bound to the class

  • It can be done using @classmethod
class MyClass:
	def hello():
		print("hello..")

	def hello_inst(self):
		print(f"hello from self: {self}, {id(self)}")

	@classmethod
	def hello_cls(cls):
		'''
		- Before hello_cls() is passed into the decorator, it is a regular function
		- After the hello_cls() is passed into the decorator, it becomes a method for the MyClass obj, and not the instance
		'''
		print(f"hello from {cls}, {id(cls)}")

MyClass.hello()
c = MyClass()
c.hello_inst()

MyClass.hello_cls()
c.hello_cls()
hello..
hello from self: <__main__.MyClass object at 0x100aa5fd0>
hello from <class '__main__.MyClass'>
hello from <class '__main__.MyClass'>
functionMyClassInstance
helloregular functionmethod bound to instance - will fail
hello_instregular functionmethod bound to instance
hello_clsmethod bound to classmethod bound to class

1.5.5.ii Static Methods - a function that is never bound to any object

  • it can be done using @staticmethod
class MyClass:

	@staticmethod
	def hello():
		print("hello..")

MyClass.hello()

c = MyClass()
c.hello()

## both are of the same type
print(MyClass.hello)
print(c.hello)
hello..
hello..
<function MyClass.hello at 0x1036e2480>
<function MyClass.hello at 0x1036e2480>
functionMyClassInstance
helloregular functionregular function

1.6 Builtin types & Standard types in Python - import types

  • Some types defined in python as a part of built-in
    • int, str, list, tuple ,…
    • When you do type([1,2,3]) - <class 'list'>
    • But not all do, so we use import types

1.7 Class Body Scope and Class function/method scopes

  • ==Module scope== contains - Python, p

  • ==Classbody scope== contains - kingdom, pythlum, family, __init__, say_hello

  • __init__ & say_hello symbols are in the class body namespace.

  • But the scope of functions referenced by __ini__ & say_hello are different then regular nested scopes.

    • __ini__ & say_hello functions are not nested inside the ==classbody scope==
    • they are nested inside the ==module scope==
    • When Python looks for a symbol in a function inside a class
    • It will not use the ==Classbody scope==!
first_name = "Peter"

class Person:
    first_name = "Max"
    last_name = "Evans"

    '''
    this works because current scope has both first_name & last_name
    '''
    full_name = first_name + last_name

    def first_name_inst(self):
        return self.first_name

    @classmethod
    def first_name_cls(cls):
        return cls.first_name

    # it will return 'Peter' because it is not nested inside the classbody scope
    # and pulls the value from the module scope
    def first_name_direct():
        return first_name


p = Person()

print(
    p.first_name_inst(),
    Person.first_name_cls(),
    Person.first_name_direct(),
    sep="\n"
)
Max
Max
Peter