Pages

Sunday, June 28, 2020

GRE TUNNEL CONFIG on LINUX

Case 1 : source gre and destination gre ip’s are in different network

Peer 1:
-------
1. Create gre tunnel interface
      ip tunnel add gre1 mode gre remote local ttl 255
      ex: ip tunnel add gre1 mode gre remote 172.52.1.2 local 172.51.1.2 ttl 255

2. Bringup created gre tunnel interface 
      ip link set gre1 up

3. Assign ip address to gre interface
      ip addr add dev gre1
      ex: ip addr add 2.2.2.2/24 dev gre1

4. add route to reach remote gre interface network
      ex: ip route add 3.3.3.0/24 via 2.2.2.2 dev gre1
  

Peer 2:
-------
1. Create gre tunnel interface
      ip tunnel add gre1 mode gre remote local ttl 255
      ex:ip tunnel add gre1 mode gre remote 172.51.1.2 local 172.52.1.2 ttl 255

2. Bringup created gre tunnel interface 
      ip link set gre1 up

3. Assign ip address to gre interface
      ip addr add dev gre1
      ex: ip addr add 3.3.3.3/24 dev gre1

4. add route to reach remote gre interface network
      ex: route ip route add 2.2.2.0/24 via 3.3.3.3 dev gre1


* To enable gre functionality in linux execute following command
     modprobe ip_gre   (* by default gre functionality is enabled in linux)

* To Delete gre interface
    ip tunnel del gre1

* To down/up gre interface
     ifconfig gre1 down
     ifconfig gre1 up


** Send traffic using gre tunnel address.

ping 3.3.3.3

> show conn 
2 in use, 4 most used
Inspect Snort:
preserve-connection: 1 enabled, 0 in effect, 1 most enabled, 0 most in effect

ICMP outside 3.3.3.3:0 inside  2.2.2.2:4053, idle 0:00:00, bytes 112, flags pN1
GRE outside 172.52.1.2:0 inside  172.51.1.2:0, idle 0:00:00, bytes 0, flags  LN


wget http://3.3.3.3/5mb.out -O /dev/null --limit-rate=10K

> show conn detail all
2 in use, 4 most used

TCP outside: 3.3.3.3/80 inside: 2.2.2.2/41314,
    flags UIOpN1, idle 0s, uptime 12s, timeout 1h0m, bytes 177954
  Initiator: 2.2.2.2, Responder: 3.3.3.3

GRE outside: 172.52.1.2/0 inside: 172.51.1.2/0,
    flags  LN, idle 0s, uptime 12s, timeout 2m0s, bytes 0

> 
> 


wget https://3.3.3.3/5mb.out -O /dev/null --limit-rate=10K --no-check-certificate


> show conn all
2 in use, 4 most used
Inspect Snort:
preserve-connection: 1 enabled, 0 in effect, 1 most enabled, 0 most in effect

TCP outside  3.3.3.3:443 inside  2.2.2.2:45483, idle 0:00:00, bytes 493269, flags UIOpN1
GRE outside 172.52.1.2:0 inside  172.51.1.2:0, idle 0:00:00, bytes 0, flags  LN
> 

> 
> show conn all detail
2 in use, 4 most used
TCP outside: 3.3.3.3/443 inside: 2.2.2.2/45483,
    flags UIOpN1, idle 0s, uptime 27s, timeout 1h0m, bytes 341213
  Initiator: 2.2.2.2, Responder: 3.3.3.3

GRE outside: 172.52.1.2/0 inside: 172.51.1.2/0,
    flags  LN, idle 0s, uptime 27s, timeout 2m0s, bytes 0



> show capture in

143 packets captured

   1: 08:15:20.342923       802.1Q vlan#911 P7 arp who-has 172.51.1.1 (0:f6:63:e4:a3:6e) tell 0.0.0.0 
   2: 08:15:20.342954       802.1Q vlan#911 P7 arp reply 172.51.1.1 is-at 0:f6:63:e4:a3:6e 
   3: 08:15:24.438225       802.1Q vlan#911 P0 172.51.1.2 > 172.52.1.2: gre: 2.2.2.2.45484 > 3.3.3.3.443: S 228517354:228517354(0) win 14360  
   4: 08:15:24.490773       802.1Q vlan#911 P0 172.52.1.2 > 172.51.1.2: gre: 3.3.3.3.443 > 2.2.2.2.45484: S 2973806477:2973806477(0) ack 228517355 win 28480  
   5: 08:15:24.490971       802.1Q vlan#911 P0 172.51.1.2 > 172.52.1.2: gre: 2.2.2.2.45484 > 3.3.3.3.443: . ack 2973806478 win 898  
   6: 08:15:24.491231       802.1Q vlan#911 P0 172.51.1.2 > 172.52.1.2: gre: 2.2.2.2.45484 > 3.3.3.3.443: P 228517355:228517650(295) ack 2973806478 win 898  
   7: 08:15:24.590942       802.1Q vlan#911 P0 172.52.1.2 > 172.51.1.2: gre: 3.3.3.3.443 > 2.2.2.2.45484: . ack 228517650 win 231  
   8: 08:15:24.605696       802.1Q vlan#911 P0 172.52.1.2 > 172.51.1.2: gre: 3.3.3.3.443 > 2.2.2.2.45484: . 2973806478:2973807846(1368) ack 228517650 win 231  
   9: 08:15:24.605712       802.1Q vlan#911 P0 172.52.1.2 > 172.51.1.2: gre: 3.3.3.3.443 > 2.2.2.2.45484: P 2973807846:2973807901(55) ack 228517650 win 231  
  10: 08:15:24.606093       802.1Q vlan#911 P0 172.51.1.2 > 172.52.1.2: gre: 2.2.2.2.45484 > 3.3.3.3.443: . ack 2973807846 win 1076  
  11: 08:15:24.607192       802.1Q vlan#911 P0 172.51.1.2 > 172.52.1.2: gre: 2.2.2.2.45484 > 3.3.3.3.443: . ack 2973807901 win 1076  
  12: 08:15:24.607207       802.1Q vlan#911 P0 172.51.1.2 > 172.52.1.2: gre: 2.2.2.2.45484 > 3.3.3.3.443: P 228517650:228517776(126) ack 2973807901 win 1076  
  13: 08:15:24.691492       802.1Q vlan#911 P0 172.52.1.2 > 172.51.1.2: gre: 3.3.3.3.443 > 2.2.2.2.45484: P 2973807901:2973808159(258) ack 228517776 win 231



> show capture out

198 packets captured

   1: 08:15:24.438759       802.1Q vlan#912 P0 172.51.1.2 > 172.52.1.2: gre: 2.2.2.2.45484 > 3.3.3.3.443: S 1561470146:1561470146(0) win 14360  
   2: 08:15:24.490712       802.1Q vlan#912 P0 172.52.1.2 > 172.51.1.2: gre: 3.3.3.3.443 > 2.2.2.2.45484: S 1291382108:1291382108(0) ack 1561470147 win 28480  
   3: 08:15:24.491155       802.1Q vlan#912 P0 172.51.1.2 > 172.52.1.2: gre: 2.2.2.2.45484 > 3.3.3.3.443: . ack 1291382109 win 898  
   4: 08:15:24.491414       802.1Q vlan#912 P0 172.51.1.2 > 172.52.1.2: gre: 2.2.2.2.45484 > 3.3.3.3.443: P 1561470147:1561470442(295) ack 1291382109 win 898  
   5: 08:15:24.590896       802.1Q vlan#912 P0 172.52.1.2 > 172.51.1.2: gre: 3.3.3.3.443 > 2.2.2.2.45484: . ack 1561470442 win 231  
   6: 08:15:24.605467       802.1Q vlan#912 P0 172.52.1.2 > 172.51.1.2: gre: 3.3.3.3.443 > 2.2.2.2.45484: . 1291382109:1291383477(1368) ack 1561470442 win 231  
   7: 08:15:24.605483       802.1Q vlan#912 P0 172.52.1.2 > 172.51.1.2: gre: 3.3.3.3.443 > 2.2.2.2.45484: P 1291383477:1291383532(55) ack 1561470442 win 231  
   8: 08:15:24.606124       802.1Q vlan#912 P0 172.51.1.2 > 172.52.1.2: gre: 2.2.2.2.45484 > 3.3.3.3.443: . ack 1291383477 win 1076  
   9: 08:15:24.607222       802.1Q vlan#912 P0 172.51.1.2 > 172.52.1.2: gre: 2.2.2.2.45484 > 3.3.3.3.443: . ack 1291383532 win 1076  
  10: 08:15:24.607237       802.1Q vlan#912 P0 172.51.1.2 > 172.52.1.2: gre: 2.2.2.2.45484 > 3.3.3.3.443: P 1561470442:1561470568(126) ack 1291383532 win 1076  
  11: 08:15:24.691446       802.1Q vlan#912 P0 172.52.1.2 > 172.51.1.2: gre: 3.3.3.3.443 > 2.2.2.2.45484: P 1291383532:1291383790(258) ack 1561470568 win 231  
  12: 08:15:24.692255       802.1Q vlan#912 P0 172.51.1.2 > 172.52.1.2: gre: 2.2.2.2.45484 > 3.3.3.3.443: P 1561470568:1561470711(143) ack 1291383790 win 1247  

Friday, February 16, 2018

How DNS works

Step 1. The browser sends a query to the local DNS server in your network.

Step 2. If the local network has no internal DNS server or the DNS server has no information about the site you want to visit, a query is sent to the recursive DNS server of your Internet service provider (ISP). If the recursive server has information about the IP address in its cache, your browser receives that information. No additional queries are performed.

Step 3. However, if the recursive server does not know the IP address, the recursive server sends the query to one of the 13 sets of root name servers located worldwide. A root server knows the DNS information about a top-level domain (TLD).

Step 4. The DNS server of a TLD sends information about the second-level domain and its authoritative name server. An authoritative name server knows all the addressing information for a particular domain.

Step 5. The authoritative name server responds to a query by returning the Address Record (A record) to your ISP. The ISP’s recursive server stores the record on its cache for a specific amount of time and sends the IP address to your browser.


DNS (Domain Name System) is like the phonebook of the internet.

It translates human-readable domain names (like www.google.com) into machine-readable IP addresses (like 142.250.190.78) so your computer knows where to connect.


 Step-by-Step Process

1. User Request

  • You type www.google.com into your browser.

2. Browser/OS Check

  • Your browser first checks:

    • Browser cache → Have I recently visited this site?

    • Operating system cache → Is it already stored in my device’s memory?

    • Hosts file (local overrides, if any).

3. Recursive Resolver (DNS Resolver)

  • If not cached, the request goes to your DNS resolver (usually provided by your ISP, or public ones like Google DNS 8.8.8.8, Cloudflare 1.1.1.1).

  • This resolver’s job is to find the IP address on your behalf.

4. Root Name Server

  • If the resolver doesn’t know, it asks a Root DNS server (there are 13 root server clusters worldwide).

  • The root server doesn’t know the IP of www.google.com, but it says:

    • "This is a .com domain → Ask the .com TLD server."

5. TLD Name Server

  • The resolver queries the TLD (Top-Level Domain) server for .com.

  • It replies:

    • "I don’t know the IP, but I know the authoritative name server for google.com."

6. Authoritative Name Server

  • The resolver queries the authoritative server for google.com.

  • This server responds with the actual IP address of www.google.com.

7. Response Back

  • The resolver returns the IP address to your computer.

  • Your browser then uses this IP to connect to Google’s web server.

8. Caching

  • The IP is stored in:

    • Your device cache,

    • Resolver’s cache,

    • For a set TTL (Time to Live) to speed up future lookups.


 Example

  • You type www.openai.com

  • DNS process resolves it to → 104.18.12.123

  • Browser connects directly to that IP.


 Key Points

  • DNS = Internet phonebook

  • Works in a hierarchy: Root → TLD → Authoritative

  • Resolvers do the heavy lifting

  • Caching makes it fast


How DNS Works: A Simple Explanation

The Domain Name System (DNS) is often called the "phonebook of the internet." When you type a website address like www.google.com into your browser, DNS is the system that translates that human-friendly name into a machine-readable IP address, such as 142.250.72.206. Without DNS, you would have to remember a string of numbers for every website you want to visit.

The Four Key Players

The DNS resolution process involves four main servers that work together to find the correct IP address.

  1. DNS Recursor (DNS Resolver): This is the first stop for a DNS query. It acts as a middleman between your computer and the other DNS servers. Your internet service provider (ISP) or a third-party service like Google's Public DNS (8.8.8.8) provides this service. Its job is to find the IP address you're looking for.

  2. Root Name Server: At the top of the DNS hierarchy, there are 13 logical root servers worldwide, managed by various organizations. They don't know the IP address for every website, but they do know the addresses of the next level of servers, the TLD servers. .

  3. TLD (Top-Level Domain) Name Server: A TLD server manages the top-level part of a domain name, such as .com, .org, or .net. When the root server directs a query to a TLD server, that server knows which authoritative name server to contact next.

  4. Authoritative Name Server: This is the final and most important server in the process. It holds the definitive record for a domain name and its corresponding IP address. When it receives a query, it provides the final answer—the IP address that your computer needs to connect to the website.

The DNS Resolution Process

Here is a step-by-step breakdown of what happens when you type a website address into your browser.

  1. Initial Query: You type www.example.com into your browser. Your computer first checks its own local cache and then your router's cache to see if it already knows the IP address for this website. If it's not found, it sends a DNS query to your DNS Recursor.

  2. Contacting the Root Server: The DNS Recursor receives the query for www.example.com. Since it doesn't know the answer, it asks a Root Name Server, "Where can I find the IP address for example.com?"

  3. The Root Server's Response: The Root Name Server doesn't know the full address, but it knows where all the .com domains are located. It responds by saying, "I don't know the IP address for example.com, but I can give you the address of the .com TLD Name Server."

  4. Contacting the TLD Server: The DNS Recursor then sends the same query, "Where can I find www.example.com?" to the .com TLD Name Server.

  5. The TLD Server's Response: The .com TLD Server doesn't know the full address either, but it does know which Authoritative Name Server is responsible for all domains ending in example.com. It responds by saying, "I can give you the address of the Authoritative Name Server for example.com."

  6. Contacting the Authoritative Server: Finally, the DNS Recursor sends the query to the Authoritative Name Server. This server holds the actual record for www.example.com.

  7. The Final Response: The Authoritative Name Server responds with the correct IP address for www.example.com.

  8. Caching and Connection: The DNS Recursor caches this IP address for a certain amount of time to speed up future requests. It then sends the IP address back to your computer. Your computer now has the IP address and can establish a connection with the server hosting www.example.com.

This entire process happens in milliseconds, allowing you to seamlessly navigate the web without ever thinking about the complex system working in the background. 

Friday, December 15, 2017

Python OOP Concepts

Variable concept:
a = 10
a is a variable that refers to object 10.
same is for function definition
def test
a test is a variable which refers to object function test
variable is reference or label to object not placeholder
All attributes are called by "." operator
Anything defined inside class is class attributes
Anything defined inside function is variable
only variable has scope
every class should be instantiated

while True is a substitution of do-while in python

# only variable have scope but attribute doesn’t have
## variable defined under class are class attributes. variable defined inside and outside functions are local and global variables
## list and dictionary don’t store data. They refer to data
## variables are not object in python. variables are reference to object
## all definition happens in run time
## any object ref count is 0 is freed from memory i.e. garbage collection
## you can’t have two scope under a function at a same time
## import this (for python zen)
## python is compiled interpreter language not line by line interpreter language
## i ++ is not supported in python
## to check object is callable or not
>>> v = 10
>>> callable(v)
False
>>> def foo(): print("hello world")
...
>>> callable(foo)
True
## class is a instance of type

## tuple is ", separator operator

>>> t = 1,2,3,4,5,6
>>>
>>> t
(1, 2, 3, 4, 5, 6)

or

>>> t = (1,2,3,4,5)
>>> t
(1, 2, 3, 4, 5)

## we can’t have one element tuple
>>> t = (1)  ## this is basically integer
>>> t
1
>>> type(t)

>>>

If you want to have one element tuple use
>>> t = (1,)
>>> t
(1,)
>>> type(t)


dict:
>>> d = { 'one': 1, 'two': 2, 'three': 3}
>>> d
{'one': 1, 'two': 2, 'three': 3}
>>>
>>>
>>> d = dict(one = 1, two = 2, three = 3)
>>> d
{'one': 1, 'two': 2, 'three': 3}
>>>
>>> type(d)

>>>
>>> x = dict(four = 4, five =5, six = 6)
>>> x
{'four': 4, 'five': 5, 'six': 6}
>>>
>>> d = dict(one = 1, two = 2, three =3, **x)
>>> d
{'one': 1, 'two': 2, 'three': 3, 'four': 4, 'five': 5, 'six': 6}
>>>
>>>
>>> 'four' in x
True
>>> 'three' in x
False
>>>
>>>
>>> for k in d: print(k)
...
one
two
three
four
five
six
>>>
>>>
>>> for k, v in d.items():print(k, v)
...
one 1
two 2
three 3
four 4
five 5
six 6

>>> d['three']
3

>>> x['three']
Traceback (most recent call last):
  File "", line 1, in
KeyError: 'three'
>>>
>>>
>>> x.get('three')
>>> x
{'four': 4, 'five': 5, 'six': 6}
>>>
>>>
>>> d.get('three')
3

>>> x.get('three', 'not found')
'not found'
>>>
>>> x
{'four': 4, 'five': 5, 'six': 6}

>>> del x['four']
>>> x
{'five': 5, 'six': 6}
>>>
>>>
>>> x.pop('five')
5

>>> x
{'six': 6}
>>>

## iter function is a function that returns iterator object
## Note: yield turns function into generator. Each time yield is run it returns
## the value and next time the function is called, execution starts right after yield and this turns function into generator
## and what it generates is iterator object which can be used like any iterator object in python.

# define a list
my_list = [4, 7, 0, 3]

# get an iterator using iter()
my_iter = iter(my_list)

## iterate through it using next()

#prints 4
print(next(my_iter))

#prints 7
print(next(my_iter))

## next(obj) is same as obj.__next__()

#prints 0
print(my_iter.__next__())

#prints 3
print(my_iter.__next__())

## This will raise error, no items left
next(my_iter)

two ways to create dict in python:
>>> d = {'one': 1, 'two': 2, 'three': 3, 'four': 4, 'five': 5}
>>> for k in d:
...     print(k, d[k])
...
one 1
two 2
three 3
four 4
five 5
>>>
>>>
>>> for k in sorted(d.keys()):
...     print(k,d[k])
...
five 5
four 4
one 1
three 3
two 2
>>>
>>> d = dict(
...     one =1, two = 2, three = 3, four =4, five = 'five'
... )
>>>
>>> for k in sorted(d.keys()):
...     print(k,d[k])
...
five five
four 4
one 1
three 3
two 2
>>>
>>>
>>> d['seven'] = 7
>>> for k in sorted(d.keys()):
...     print(k, d[k])
...
five five
four 4
one 1
seven 7
three 3
two 2

>>> d = { 'one': 1, 'two': 2, 'three': 3}
>>> d
{'one': 1, 'two': 2, 'three': 3}
>>>
>>>
>>> d = dict(one = 1, two = 2, three = 3)
>>> d
{'one': 1, 'two': 2, 'three': 3}
>>>
>>> type(d)

>>>
>>> x = dict(four = 4, five =5, six = 6)
>>> x
{'four': 4, 'five': 5, 'six': 6}

>>> d = dict(one = 1, two = 2, three =3, **x)
>>> d
{'one': 1, 'two': 2, 'three': 3, 'four': 4, 'five': 5, 'six': 6}

>>> 'four' in x
True
>>> 'three' in x
False
>>>
>>>
>>> for k in d: print(k)
...
one
two
three
four
five
six

>>> for k, v in d.items():print(k, v)
...
one 1
two 2
three 3
four 4
five 5
six 6

>>> d['three']
3

>>> x['three']
Traceback (most recent call last):
  File "", line 1, in
KeyError: 'three'

>>> x.get('three')
>>> x
{'four': 4, 'five': 5, 'six': 6}
>>>
>>>
>>> d.get('three')
3
>>>
>>>
>>> x.get('three', 'not found')
'not found'
>>>
>>> x
{'four': 4, 'five': 5, 'six': 6}

>>> del x['four']
>>> x
{'five': 5, 'six': 6}

>>> x.pop('five')
5

>>> x
{'six': 6}
>>>

In python NOT has first precedence, then AND then OR

# define a list
my_list = [4, 7, 0, 3]

# get an iterator using iter()
my_iter = iter(my_list)

## iterate through it using next()

#prints 4
print(next(my_iter))

#prints 7
print(next(my_iter))

## next(obj) is same as obj.__next__()

#prints 0
print(my_iter.__next__())

#prints 3
print(my_iter.__next__())

## This will raise error, no items left
next(my_iter)

### for is used for deterministic loop
nawlekha@NAWLEKHA-M-Q1GZ:/users/nawlekha/Desktop/pyATS/Python_OOP$ python3 -v findall_exercise.py
## for verbose print

## Instance of base class is type and inheritance of base class is object

Iterator in Python is simply an object that can be iterated upon. An object which will return data, one element at a time.
Technically speaking, Python iterator object must implement two special methods, __iter__() and __next__(), collectively called the iterator protocol.
An object is called iterable if we can get an iterator from it. Most of the built-in containers in Python like: list, tuple, string etc. are iterable.
The iter() function (which in turn calls the __iter__() method) returns an iterator from them.

The match() function only checks if the RE matches at the beginning of the string while search() will scan forward through the string for a match. It’s important to keep this distinction in mind. Remember, match() will only report a successful match which will start at 0; if the match wouldn’t start at zero, match() will not report it.
>>>
>>> print(re.match('super', 'superstition').span())
(0, 5)
>>> print(re.match('super', 'insuperable'))
None
On the other hand, search() will scan forward through the string, reporting the first match it finds.
>>>
>>> print(re.search('super', 'superstition').span())
(0, 5)
>>> print(re.search('super', 'insuperable').span())
(2, 7)

>>> a = dict(one=1, two=2, three=3)
>>> b = {'one': 1, 'two': 2, 'three': 3}
>>> c = dict(zip(['one', 'two', 'three'], [1, 2, 3]))
>>> d = dict([('two', 2), ('one', 1), ('three', 3)])
>>> e = dict({'three': 3, 'one': 1, 'two': 2})
>>> a == b == c == d == e
True

To print python builtins:
>>> dir(__builtins__)

>>> list(range(5,3))
[]
>>>

>>> 'ab c\n\nde fg\rkl\r\n'.splitlines()
['ab c', '', 'de fg', 'kl']
>>>
>>>
>>>
>>> 'ab c\n\nde fg\rkl\r\n'.split()
['ab', 'c', 'de', 'fg', 'kl']
>>>

>>> "One line\n".splitlines()
['One line']
>>>
>>>
>>> 'Two lines\n'.split('\n')
['Two lines', '']
>>>

## default inheritance of all class is from object
that’s in python3

python2
class Employee(object): pass

python3
class Employee(): pass

## class attributes are commonly shared among all instances and automatically inherited to their instances. Instances can overwrite them

##if we make a callable method as class attribute, they are automatically wired up as an instance method
## for instance method to work, the first argument should be always referenced to the instance

## all functions defined under __init__ are local variables
if we want them globally we can create instance attribute
all instance attributes are visible globally

## this will not work
>>> class Car:
...     def __init__(self):
...             print("car object created...")
...     def drive():
...             print("driving a car....")
...
>>> c = Car()
car object created...
>>> c.drive()
Traceback (most recent call last):
  File "", line 1, in
TypeError: drive() takes 0 positional arguments but 1 was given
>>>

c.drive() is equivalent to Car.drive(c)

but Car.drive() will work in python3.
>>> Car.drive()
driving a car....

To make it work in both pyhon2 and python3, we can use staticmethod

calling drive either from object or from class should work. But this is not at all good practice to follow
>>> class Car:
...     def __init__(self):
...             print("car object created...")
...     @staticmethod
...     def drive():
...             print("driving a car....")
...
>>> c = Car()
car object created...
>>> c.drive()
driving a car....
>>> Car.drive()
driving a car....
>>>

Other decorator is @classmethod

## without classmethod decorator

>>> class Car:
...     def __init__(self):
...             print("car object created...")
...     @staticmethod
...     def drive():
...             print("driving a car....")
...     def sell(c):
...             print("c =", c)
...

>>> c = Car()
car object created...
>>> c.drive()
driving a car....
>>> c.sell()
c = <__main__ .car="" 0x108c30d30="" at="" object="">
## sell takes objet instance

Now let’s use @classmethod

>>> class Car:
...     def __init__(self):
...             print("car object created...")
...     @staticmethod
...     def drive():
...             print("driving a car....")
...     @classmethod
...     def sell(c):
...             print("c =", c)
...
>>> c = Car()
car object created...
>>> c.drive()
driving a car....
>>> Car.drive()
driving a car....
>>> c.sell()
c =
## it will always pass class as an argument to this particular instance
c.sell() or Car.sell() means the same
>>> Car.sell()
c =

staticmethod will pass nothing it will call function as it is. a static method cannot access neither class or instance attributes.

## trying to find value in dictionary with unknown key will raise a error

>>> a = {"name": "john", "role": "admin"}
>>> a
{'name': 'john', 'role': 'admin'}
>>>
>>>
>>> a["name"]
'john'
>>>
>>> a["city"]
Traceback (most recent call last):
  File "", line 1, in
KeyError: 'city'

## if we don’t want to raise an error. we can use get function. it will return default value None for unknown key

>>> a.get("name")
'john'
>>>
>>> a.get("city")
>>>
>>> v = a.get("city")
>>> print(v)
None
>>>

## instead of default value None we can also decide what value to print

>>> v = a.get("city", "not found")
>>> v
'not found'
>>>

but

>>> a.get("role", "not found")
'admin'
>>>

run script in debugger mode + python prompt
Python_OOP$ python3 -m pdb scope_1.py
> /Users/nawlekha/Desktop/pyATS/Python_OOP/scope_1.py(10)()
-> """
(Pdb) help

Documented commands (type help ):
========================================
EOF    c          d        h         list      q        rv       undisplay
a      cl         debug    help      ll        quit     s        unt
alias  clear      disable  ignore    longlist  r        source   until
args   commands   display  interact  n         restart  step     up
b      condition  down     j         next      return   tbreak   w
break  cont       enable   jump      p         retval   u        whatis
bt     continue   exit     l         pp        run      unalias  where

Miscellaneous help topics:
==========================
exec  pdb

(Pdb) n
>Python_OOP/scope_1.py(113)()
-> """
(Pdb) s

l == list a program
n = next will step over a function
s = will step into a function
globals = to list all global variables currently defined


function prototype:
==================

>>> def greet():
...     print("Hello world")
...
>>> def greet(name):
...     print("Welcome", name)
...
>>> greet("John")
Welcome John
>>> greet()
Traceback (most recent call last):
  File "", line 1, in
TypeError: greet() missing 1 required positional argument: 'name'
>>>

Rem: the last definition overwrites the previous definition
similar to
a = 10
a = “hello world”

Both greet variable points to different functions and last overwrites the previous

So how to do polymorphism
=============
a function with an ability to work differently based on different arguments.

>>> def greet(name=None):
...     if name is None:
...             print("Hello world")
...     else:
...             print("Welcome", name)
...
>>> greet("John")
Welcome John
>>> greet()
Hello world

or

>>> def greet(name="Guest"):
...     print("Welcome", name)
...
>>> greet("John")
Welcome John
>>> greet()
Welcome Guest


## we should always pass non-default argument followed by default argument
>>> def greet(name="Guest", city):
...     print("Hello", name, "Welcome to", city)
...
  File "", line 1
SyntaxError: non-default argument follows default argument
>>>


>>> def greet(name, city="bangalore"):
...     print("Hello", name, "Welcome to", city)
...
>>> greet("John", "Mumbai")
Hello John Welcome to Mumbai
>>>
>>> greet("John")
Hello John Welcome to bangalore
>>>

>>> def greet(name="Guest", city="bangalore"):
...     print("hello", name, "Welcome to", city)
...
>>> greet()
hello Guest Welcome to bangalore
>>>

## print takes care of adding space after “,” and add new line after last argument

>>> def greet(name="Guest", city="bangalore"):
...     print("hello", name, "Welcome to", city)
...
>>> greet("John")
hello John Welcome to bangalore

>>> greet("Mumbai")
hello Mumbai Welcome to bangalore
## python doesn’t know Mumbai is name of city. It just replace first argument with Mumbai

>>> greet(city="Mumbai")
hello Guest Welcome to Mumbai
## this is what we call as passing key-word argument

## solution is explicitly mention keyword arguments
>>> greet(city="Pune", name="Smith")
hello Smith Welcome to Pune

## all python programs can be run as the script or loaded as a module.
## if you run a program as a script, name of a file is __main__ and everything in the file will execute including the main function.
## if you load program as a module, codes outside of “if” statement will only execute i.e. program main function will not run.

nawlekha@NAWLEKHA-M-Q1GZ:~/Desktop$ vi test.py
def greet(): print("Hello world")
name = "John"

print("__name__=", __name__)

if __name__ == "__main__":
        greet()
        print("name=", name)

nawlekha@NAWLEKHA-M-Q1GZ:~/Desktop$ python3 test.py
__name__= __main__
Hello world
name= John

nawlekha@NAWLEKHA-M-Q1GZ:~/Desktop$
nawlekha@NAWLEKHA-M-Q1GZ:~/Desktop$ python3
Python 3.6.2 |Anaconda, Inc.| (default, Sep 21 2017, 18:29:43)
[GCC 4.2.1 Compatible Clang 4.0.1 (tags/RELEASE_401/final)] on darwin
Type "help", "copyright", "credits" or "license" for more information.
>>> import test
__name__= test
>>> test.greet()
Hello world
>>> test.name
'John'
>>>

## string is a sequence and immutable. Other sequences in python are list and tuples.
All sequence should be indicible

## to have mutable string we can use bytearray

>>> a = "Hello world"
>>> a
'Hello world'
>>> a[0]
'H'
>>> a[0] = "A"
Traceback (most recent call last):
  File "", line 1, in
TypeError: 'str' object does not support item assignment
>>>
>>> a = bytearray(b"Hello world")
>>> a
bytearray(b'Hello world')
>>>
>>> a[0]
72 ## this is ascii value of H
>>> a[0] = 65
>>> a
bytearray(b'Aello world') ## this mutates existing string
>>>

## Two kinds of immutable strings. Unicode and byte string
Strings by default represent sequence of unicode characters
>>> a = "hello world"
>>> type(a)

>>>
In unicode, each character can be of different bytes may be 1, 2 or 3 byte size based on encoding.This supports different language characters and it is UTF compliance.This is useful to read textual data.

but to read binary data i.e. packet, binary buffer, element must be byte.Each element must be a byte.
to create byte string

>>> b = b"Hello world"
>>> b
b'Hello world'
>>> type(b)

>>> print(b)
b'Hello world'
## this is a sequence of bytes

## byte array can only be created from byte string.

## Variable argument function. Function that can take one or more arguments
## arbitrary argument list a.k.a varargs

>>> def greet(*args):
...     pass

or

>>> def greet(*users):
...     pass

## args is not a keyword. Non of the function parameters are keywords. self is also not a reserved keyword. args and kwargs are just a convention.

* followed by name indicates collect all arguments in the form of tuple

>>> def greet(*users):
...     print("users=", users)
...
>>> greet("John", "smith", "Sam", "Joe")
users= ('John', 'smith', 'Sam', 'Joe') ## this is tuple

>>> def greet(name, city="Pune", *visited):
...     print(name)
...     print(city)
...     print(visited)
...
>>>
>>> greet()
Traceback (most recent call last):
  File "", line 1, in
TypeError: greet() missing 1 required positional argument: 'name'
>>> greet("John")
John
Pune
()
>>>
>>> greet("John", "bengaluru", "Kolkatta", "Chennai", "Mumbai")
John
bengaluru
('Kolkatta', 'Chennai', 'Mumbai')

## so rule is no-defualt, followed by default followed by varargs. This is for python2
but here limitation is we can’t print default value.if we use default argument with varargs, the default argument is no longer a default

but in python3 we can have non-default followed by varargs and default

>>> def greet(name, *visited, city="Pune"):
...     print(name)
...     print(city)
...     print(visited)
...
>>> greet("John")
John
Pune
()
>>> greet("John", "Mumbai", "Kolkatta", 'Hyderabad') ## default argument always remain default

John
Pune
('Mumbai', 'Kolkatta', 'Hyderabad')
>>>
>>>
>>> greet("John", "Mumbai", "Kolkatta", 'Hyderabad', city= "Bengaluru") ## now overwrite default value
John
Bengaluru
('Mumbai', 'Kolkatta', 'Hyderabad')

## so in python3.4 and above non-default should be the first argument always and varargs and default arguments are interchangeable

## How to pass variable number of arguments

>>> def store_record(name, role, dept, city):
...     print("Storing name = {}, role= {}, dept = {}, city= {}".format(name, role, dept,city))
...
>>> rec = "John", "Support", "IT", "Bengaluru"
>>>
>>> store_record(*rec)
Storing name = John, role= Support, dept = IT, city= Bengaluru

## numbers of arguments in function and numbers of values in tuple used while calling should be same

>>> rec = "John", "Support", "IT"
>>>
>>> store_record(*rec)
Traceback (most recent call last):
  File "", line 1, in
TypeError: store_record() missing 1 required positional argument: 'city'
>>>

>>> rec = "John", "Support", "IT", "Mumbai", "Bengaluru" ## this can be either list or tuple
>>>
>>> store_record(*rec)
Traceback (most recent call last):
  File "", line 1, in
TypeError: store_record() takes 4 positional arguments but 5 were given


## tuple is extension of list

## Advantage of format
>>> a = "Hello %s, How are you %s, What will you do today %s"
>>> a
'Hello %s, How are you %s, What will you do today %s'
>>> print(a % ("John", "John", "John"))
Hello John, How are you John, What will you do today John
>>>
>>>
>>> b = "Hello {0}, How are you {0}, What will do you today {0}"
>>> b.format("John")
'Hello John, How are you John, What will do you today John'
>>>

>>> a = "Decimal: {0:d}, Hex: {0:x}, Oct: {0:o}, Bin: {0:b}"
>>> a
'Decimal: {0:d}, Hex: {0:x}, Oct: {0:o}, Bin: {0:b}'
>>>
>>> a.format(34)
'Decimal: 34, Hex: 22, Oct: 42, Bin: 100010'
>>>

{0:d} == %d
{0:x} == %x

>>> def store_data(*n): ## collects arguments as tuple
...     print(n)
...
>>> store_data()
() ## empty tuple
>>>

arbitrary keyword arguments
>>> def store_data(**n): ## collects arguments as dictionary
...     print(n)
...
>>> store_data()
{} ## empty dictionary

>>> store_data(name="John", a=100, test="Helloworld", info=(10,20,30))
{'name': 'John', 'a': 100, 'test': 'Helloworld', 'info': (10, 20, 30)}

>>>
>>> def store_data(a, b=20, *c, **d):
...     print("a=", a)
...     print("b=", b)
...     print("c=", c)
...     print("d=", d)
...
>>> store_data(10)
a= 10
b= 20
c= ()
d= {}
>>>

>>> store_data(10,20,30,40,50,60,70)
a= 10
b= 20
c= (30, 40, 50, 60, 70)
d= {}
>>>

## all positional arguments got collected in tuple

>>> store_data(10,20,30,40,50,60,70, x=10, name= "Sam", role= "Admin")
a= 10
b= 20
c= (30, 40, 50, 60, 70)
d= {'x': 10, 'name': 'Sam', 'role': 'Admin'}

## all keyword arguments got collected in dictionary

>>> def store_record(name, role, dept, city):
...     print("Storing name = {}, role = {}, dept = {}, city = {}".format(name, role, dept, city))
...
>>> rec = {"name": "John", "dept": "IT", "city": "Bengaluru", "role": "Support"}

>>> store_record(**rec)
Storing name = John, role = Support, dept = IT, city = Bengaluru

## when using ** whatever variables you are passing there should be a mapping


## for default value
>>> def store_record(name, role, dept, city="Pune"):
...     print("Storing name = {}, role = {}, dept = {}, city = {}".format(name, role, dept, city))
...
>>> rec = {"name": "John", "dept": "IT", "role": "Support"}
>>>
>>> store_record(**rec)
Storing name = John, role = Support, dept = IT, city = Pune
>>>


## let’ say we have hundreds and function and all are returning some string
Once function is called we want to convert all return strings into upper case

## one way is we can change return string under all functions. other is while calling we have to do call.upper() but this will be tedious since we need to do it across all the function

Solution is:

define one more function which can do it.
This is called a decorator. It takes once function and return another function

def greet():
    return "Hello world"

def welcome():
    return "Welcome to Python"

def convert_to_upper(fn):
    def wrapper():
        return fn().upper()
    return wrapper


print("greet =", greet) ## this will print a reference since we are not calling function
print(greet())  ## calling original greet function
greet = convert_to_upper(greet)
print("greet =", greet) ## reference to convert_to_upper function
print(greet()) ## returns wrapper


runfile('/Users/nawlekha/Desktop/pyATS/decorator-working.py', wdir='/Users/nawlekha/Desktop/pyATS')
greet =
Hello world
greet = .wrapper at 0x1811e5f0d0>
HELLO WORLD

## This is called as implementing cross-cutting function

print("greet =", greet)
print(greet())
print(welcome)
print(welcome())
greet = convert_to_upper(greet)
welcome = convert_to_upper(welcome)
print("greet =", greet)
print(greet())
print(welcome())

runfile('/Users/nawlekha/Desktop/pyATS/decorator-working.py', wdir='/Users/nawlekha/Desktop/pyATS')
greet =
Hello world

Welcome to Python
greet = .wrapper at 0x1811e5f378>
HELLO WORLD
WELCOME TO PYTHON

## this syntax is simplified by python

def convert_to_upper(fn):
    def wrapper():
        return fn().upper()
    return wrapper

@convert_to_upper
def greet():
    return "Hello world"

@convert_to_upper ## decorator function, @ is called annotation. annotation can be placed before fun or class
def welcome():
    return "Welcome to Python"

print("greet =", greet)
print("welcome =", welcome)

print(greet())
print(welcome())

runfile('/Users/nawlekha/Desktop/pyATS/decorator-working.py', wdir='/Users/nawlekha/Desktop/pyATS')
greet = .wrapper at 0x1811e5fae8> 
welcome = .wrapper at 0x1811e5f378>
HELLO WORLD
WELCOME TO PYTHON

Here is how it works.
decorator will take greet() function as an argument and whatever it will return is assigned to greet variable.

## callable objects: functions, classes, and method underclasses are callable objects

## understand the flow
def convert_to_upper(fn):
    print("convert_to_upper called: fn =", fn)
    def wrapper():
        return fn().upper()
    return wrapper

@convert_to_upper ##while defining itself greet is called
def greet():
    return "Hello world"

print(greet())
print(greet())

## Here decorator is called only once. The number of time annotation is used, decorator is called.

runfile('/Users/nawlekha/Desktop/pyATS/decorator-working.py', wdir='/Users/nawlekha/Desktop/pyATS')
convert_to_upper called: fn =
HELLO WORLD
HELLO WORLD


def to_upper(fn):
    def wrapper(n):
        return fn(n).upper()
    return wrapper

@to_upper
def greet(name):
    return "Hello " + name

print(greet("John"))

runfile('/Users/nawlekha/Desktop/pyATS/decorator-working.py', wdir='/Users/nawlekha/Desktop/pyATS')
HELLO JOHN

Generalization
==============

def to_upper(fn):
    def wrapper(*args, **kwargs):
        return fn(*args, **kwargs).upper()
    return wrapper

@to_upper
def greet(name):
    return "Hello " + name


@to_upper
def welcome(name,city):
    return "Hello {}. Welcome to {}".format(name, city)

print(greet("John"))

print(welcome("Smith", "Bengaluru"))


HELLO JOHN
HELLO SMITH. WELCOME TO BENGALURU


Partials:

==========

def store_record(name, role, dept, city):
    print("Storing name = {}, role = {}, dept = {}, city = {}".format(name, role, dept, city))


store_record("John", "admin", "IT", "Bengaluru")
store_record("John", "admin", "IT", "Pune")
store_record("John", "admin", "IT", "Mumbai")
store_record("John", "admin", "IT", "Kolkatta")

Storing name = John, role = admin, dept = IT, city = Bengaluru
Storing name = John, role = admin, dept = IT, city = Pune
Storing name = John, role = admin, dept = IT, city = Mumbai
Storing name = John, role = admin, dept = IT, city = Kolkatta

## passing first three common arguments multiple times is cumbersome here

Solution is hardcode first three arguments

def partial_deco(fn):
    def wrapper(city):
        return fn("John", "admin", "IT", city)
    return wrapper

@partial_deco
def store_record(name, role, dept, city):
    print("Storing name = {}, role = {}, dept = {}, city = {}".format(name, role, dept, city))


#store_record("John", "admin", "IT", "Bengaluru")
#store_record("John", "admin", "IT", "Pune")
#store_record("John", "admin", "IT", "Mumbai")
#store_record("John", "admin", "IT", "Kolkatta")

store_record("Bengaluru")

Storing name = John, role = admin, dept = IT, city = Bengaluru

## other variation

def partial_deco(fn):
    def wrapper(**kwargs):
        defaults = {"name": "John", "role": "admin", "dept": "IT"}
        if "city" not in kwargs:
            raise TypeError("city is required...")
        defaults.update(kwargs)
        return fn(**defaults)
    return wrapper

@partial_deco
def store_record(name, role, dept, city):
    print("Storing name = {}, role = {}, dept = {}, city = {}".format(name, role, dept, city))


#store_record("John", "admin", "IT", "Bengaluru")
#store_record("John", "admin", "IT", "Pune")
#store_record("John", "admin", "IT", "Mumbai")
#store_record("John", "admin", "IT", "Kolkatta")

store_record(city="Bengaluru")
store_record(name = "Smith", city="Bengaluru")
store_record(name = "Smith")

Storing name = John, role = admin, dept = IT, city = Bengaluru
Storing name = Smith, role = admin, dept = IT, city = Bengaluru
TypeError: city is required...

## if you want to initialize instance attribute the moment object is created, put them under __init__ method.

NameError: looking for global variable which is not defined
AttributeError: trying to access attribute which is not defined in an object

test(100)

NameError: name 'test' is not defined

os.getcwd()

NameError: name 'os' is not defined

sys.owner

AttributeError: module 'sys' has no attribute 'owner'

=========
>>> def greet():
...     a = 100
...     val = 500
...     name = "Smith"
...     print(name, "says 'Hello'")
...
>>>
>>> greet

>>>
>>>
>>> a = greet

>>> a

>>>
>>> del greet ## deleting greet will not delete greet function. There is still reference to greet function i.e. a
>>>
>>> greet
Traceback (most recent call last):
  File "", line 1, in
NameError: name 'greet' is not defined
>>>
>>>
>>> a

>>>
>>>

>>> a()
Smith says 'Hello'
>>>
>>>
>>> a

>>>
>>> dir(a)
['__annotations__', '__call__', '__class__', '__closure__', '__code__', '__defaults__', '__delattr__', '__dict__', '__dir__', '__doc__', '__eq__', '__format__', '__ge__', '__get__', '__getattribute__', '__globals__', '__gt__', '__hash__', '__init__', '__init_subclass__', '__kwdefaults__', '__le__', '__lt__', '__module__', '__name__', '__ne__', '__new__', '__qualname__', '__reduce__', '__reduce_ex__', '__repr__', '__setattr__', '__sizeof__', '__str__', '__subclasshook__']
>>>

>>> a.__qualname__ ## name used during function definition
'greet'

we can change it to some different name

>>> a.__qualname__ = "new_function"
>>>
>>> a

>>>


>>> a.__code__
", line 1>

## to find local variables defined in this function

>>> a.__code__.co_varnames
('a', 'val', 'name')
>>>

## to find byte code in binary format
>>> a.__code__.co_code
b'd\x01}\x00d\x02}\x01d\x03}\x02t\x00|\x02d\x04\x83\x02\x01\x00d\x00S\x00'

facade pattern
==============

class Car:
    def __init__(self, name,price):
        self.name, self.price = name, price
 
    def drive(self):
        print("Driving", self.name)


c = Car("honda", 10000)

c
Out[13]: <__main__ .car="" 0x108bbf3c8="" at="">

dir(c)
Out[14]:
['__class__',
 '__delattr__',
 '__dict__',
 '__dir__',
 '__doc__',
 '__eq__',
 '__format__',
 '__ge__',
 '__getattribute__',
 '__gt__',
 '__hash__',
 '__init__',
 '__init_subclass__',
 '__le__',
 '__lt__',
 '__module__',
 '__ne__',
 '__new__',
 '__reduce__',
 '__reduce_ex__',
 '__repr__',
 '__setattr__',
 '__sizeof__',
 '__str__',
 '__subclasshook__',
 '__weakref__',
 'drive',
 'name',
 'price']

c.name
Out[15]: 'honda'

c.price
Out[16]: 10000

c.drive()
Driving honda

c.color
Traceback (most recent call last):

  File "", line 1, in
    c.color

AttributeError: 'Car' object has no attribute 'color'

## we can handle this attribute error

class Car:
    def __init__(self, name,price):
        self.name, self.price = name, price
 
    def drive(self):
        print("Driving", self.name)
 
    def __getattr__(self, attr):
        print("Get-attr called!")
        return 100


Now if any attributes is not found and if we have defined __getattr__ function it will call this function

c = Car("Honda", 1000000000)

c.name
Out[31]: 'Honda'

c.price
Out[32]: 1000000000

c.color
Get-attr called!
Out[33]: 100

c.owner
Get-attr called!
Out[34]: 100

## all attributes are stored as dictionary in instance

Instead of that we want to store it somewhere else, in outside dict

attrs = {}

class Car:
    def __init__(self, name, price):
        attrs[id(self)] = {}
        attrs[id(self)]['name'] = name
        attrs[id(self)]['price'] = price
 
    def drive(self):
        print("Driving", self.name)
 
    def __getattr__(self, attr):
        return attrs[id(self)][attr]

    def __setattr__(self, attr, value):
        attrs[id(self)][attr]] = value


attrs
Out[36]: {}

c = Car("Honda", 100000)

attrs
Out[38]: {4441491888: {'name': 'Honda', 'price': 100000}}

c2 = Car("Maruti", 50000)

attrs
Out[42]:
{4441491888: {'name': 'Honda', 'price': 100000},
 4441567752: {'name': 'Maruti', 'price': 50000}}

c.name ## getting value but attribute is not there in dir(c). It's getting from outside dict
Out[45]: 'Honda'

dir(c)
Out[46]:
['__class__',
 '__delattr__',
 '__dict__',
 '__dir__',
 '__doc__',
 '__eq__',
 '__format__',
 '__ge__',
 '__getattr__',
 '__getattribute__',
 '__gt__',
 '__hash__',
 '__init__',
 '__init_subclass__',
 '__le__',
 '__lt__',
 '__module__',
 '__ne__',
 '__new__',
 '__reduce__',
 '__reduce_ex__',
 '__repr__',
 '__setattr__',
 '__sizeof__',
 '__str__',
 '__subclasshook__',
 '__weakref__',
 'drive']

=========
## private methods. Not accessible from outside world

class Car:
    def __sell(self):
        print("Car sold...")
    def drive(self):
        self.__sell()

c = Car()

c.drive()
Car sold...

c.__sell()
Traceback (most recent call last):

  File "", line 1, in
    c.__sell()

AttributeError: 'Car' object has no attribute '__sell'

Note : if you define anything with __word then it is not accessible to outside world.

There is a different way to access it.

c._Car__sell
Out[59]: >

c._Car__sell()
Car sold...

Unicode vs byte array in Python

========================
Suppose you create a string using Python 3 in the usual way:
stringobject = 'ant'
stringobject would be a unicode string.
A unicode string is made up of unicode characters. In string object above, the unicode characters are the individual letters, e.g. a, n, t
Each unicode character is assigned a code point, which can be expressed as a sequence of hex digits (a hex digit can take on 16 values, ranging from 0-9 and A-F). For instance, the letter 'a' is equivalent to '\u0091', and 'ant' is equivalent to '\u0061\u006E\u0074'.
So you will find that if you type in,
stringobject = '\u0061\u006E\u0074'
stringobject
You will also get the output 'ant'.
Now, unicode is converted to bytes, in a process known as encoding. The reverse process of converting bytes to unicode is known as decoding.
How is this done? Since each hex digit can take on 16 different values, it can be reflected in a 4-bit binary sequence (e.g. the hex digit 0 can be expressed in binary as 0000, the hex digit 1 can be expressed as 0001 and so forth). If a unicode character has a code point consisting of four hex digits, it would need a 16-bit binary sequence to encode it.
Different encoding systems specify different rules for converting unicode to bits. Most importantly, encodings differ in the number of bits they use to express each unicode character.
For instance, the ASCII encoding system uses only 8 bits (1 byte) per character. Thus it can only encode unicode characters with code points up to two hex digits long (i.e. 256 different unicode characters). The UTF-8 encoding system uses 8 to 32 bits (1 to 4 bytes) per character, so it can encode unicode characters with code points up to 8 hex digits long, i.e. everything.
Running the following code:
byteobject = stringobject.encode('utf-8')
byteobject, type(byteobject)
converts a unicode string into a byte string using the utf-8 encoding system, and returns b'ant', bytes'.
Note that if you used 'ASCII' as the encoding system, you wouldn't run into any problems since all code points in 'ant' can be expressed with 1 byte. But if you had a unicode string containing characters with code points longer than two hex digits, you would get a UnicodeEncodeError.
Similarly,
stringobject = byteobject.decode('utf-8')
stringobject, type(stringobject)
gives you 'ant', str.

The only thing that a computer can store is bytes.
To store anything in a computer, you must first encode it, i.e. convert it to bytes. For example:
If you want to store music, you must first encode it using MP3, WAV, etc.
If you want to store a picture, you must first encode it using PNG, JPEG, etc.
If you want to store text, you must first encode it using ASCII, UTF-8, etc.
MP3, WAV, PNG, JPEG, ASCII and UTF-8 are examples of encodings. An encoding is a format to represent audio, images, text, etc in bytes.
In Python, a byte string is just that: a sequence of bytes. It isn't human-readable. Under the hood, everything must be converted to a byte string before it can be stored in a computer.
On the other hand, a character string, often just called a "string", is a sequence of characters. It is human-readable. A character string can't be directly stored in a computer, it has to be encoded first (converted into a byte string). There are multiple encodings through which a character string can be converted into a byte string, such as ASCII and UTF-8.
'I am a string'.encode('ASCII')
The above Python code will encode the string 'I am a string' using the encoding ASCII. The result of the above code will be a byte string. If you print it, Python will represent it as b'I am a string'. Remember, however, that byte strings aren't human-readable, it's just that Python decodes them from ASCII when you print them. In Python, a byte string is represented by a b, followed by the byte string's ASCII representation.
A byte string can be decoded back into a character string if you know the encoding that was used to encode it.
b'I am a string'.decode('ASCII')
The above code will return the original string 'I am a string'.
Encoding and decoding are inverse operations. Everything must be encoded before it can be written to disk, and it must be decoded before it can be read by a human.

If you are running file directly then file name is __main__
if you are running file by importing first file then it will print first file name

Generator doesn’t hold entire result in memory.
Generator is better with performance