Python Notes - Part 3: Going
Deeper
More Real-World Python, Still in Plain Words
Covers core syntax, data types, control flow, functions, object-oriented
programming, file handling and two hands-on mini projects -- with runnable
code and sample output for every topic.
1
Python Notes - Part 3: Going Deeper: A Practical Guide
Table of Contents
1. Getting Information From the Person Using Your Program
2. A Few More Operators Worth Knowing
3. Why Variables Sometimes 'Disappear': Scope and Closures
4. Sending a Flexible Number of Ingredients Into a Function
5. Looping Smarter: enumerate() and zip()
6. Repeating Yourself Without a Loop: Recursion
7. Custom Context Managers: Your Own 'with' Blocks
8. Reading and Writing Spreadsheet-Style Data (CSV Files)
9. Talking to the Computer's Filing Cabinet: os, pathlib and sys
10. Keeping a Record of What Your Program Did (Logging)
11. Teaching Your Own Objects to Behave Like Built-ins (Dunder Methods)
12. Three Special Kinds of Methods Inside a Class
13. Less Typing for Simple Data-Holding Classes: dataclasses
14. Leaving Hints About What Kind of Data You Expect (Type Hints)
15. A Field Guide to Common Errors
16. Checking Your Own Work: A Gentle Intro to Testing
17. Doing Several Things 'At Once': A Gentle Intro to Threads
18. Practical Project: Contact Book That Remembers Between Runs
19. Practical Project: Text-Based Quiz Using Classes
20. Tidying Up Your Code: A Few Style Habits
2
Python Notes - Part 3: Going Deeper: A Practical Guide
1. Getting Information From the Person Using Your
Program
Everything so far has used values already typed into the code. In real life, a program usually needs to
ask the person using it a question and wait for an answer. The input() function pauses your program
and waits for someone to type something and press Enter.
name = input("What is your name? ")
print(f"Nice to meet you, {name}!")
Output:
What is your name? Rohan
Nice to meet you, Rohan!
Note: input() always hands you back text (a string), even if the person types numbers. If you need to do
maths with it, wrap it in int() or float() first.
age_text = input("How old are you? ")
age = int(age_text)
print(f"Next year you will be {age + 1}")
Output:
How old are you? 30
Next year you will be 31
3
Python Notes - Part 3: Going Deeper: A Practical Guide
2. A Few More Operators Worth Knowing
Beyond the basic maths and comparison symbols, Python has a few extra operators that come in
handy in specific situations.
The Walrus Operator (:=) -- Assign and Use in One Step
Normally you assign a value on one line and use it on the next. The walrus operator lets you do both at
once, which is handy inside loops and conditions.
numbers = [3, 7, 2, 9, 4]
# without walrus
total = sum(numbers)
if total > 20:
print(f"Total is {total}, that is a lot!")
# with walrus - assign "total" while checking it
if (total := sum(numbers)) > 20:
print(f"Total is {total}, that is a lot!")
Output:
Total is 25, that is a lot!
Total is 25, that is a lot!
Bitwise Operators -- Working at the Level of 1s and 0s
Every number is stored as binary (1s and 0s) under the hood. Bitwise operators let you manipulate
those individual bits directly -- useful in networking, graphics, and permission systems (like file
read/write flags).
a = 6 # binary: 110
b = 3 # binary: 011
print(a & b) # AND -> 010 -> 2
print(a | b) # OR -> 111 -> 7
print(a ^ b) # XOR -> 101 -> 5
print(a << 1) # shift left -> 1100 -> 12
print(a >> 1) # shift right -> 011 -> 3
Output:
2
7
5
12
3
4
Python Notes - Part 3: Going Deeper: A Practical Guide
3. Why Variables Sometimes 'Disappear': Scope and
Closures
Picture a function as a room with a closed door. Variables created inside that room can't be seen from
the hallway (outside the function) -- but the room can see out into the hallway and use variables that
already existed there.
hallway_variable = "I am outside"
def room():
room_variable = "I am inside"
print(hallway_variable) # can see out
print(room_variable)
room()
# print(room_variable) -> this would crash, room_variable does not exist out here
Output:
I am outside
I am inside
A Function That Remembers: Closures
A closure is a function built inside another function, which 'remembers' the values that existed when it
was created -- like a note that gets sealed inside an envelope and carried around.
def make_multiplier(factor):
def multiply(number):
return number * factor # remembers "factor" forever
return multiply
double = make_multiplier(2)
triple = make_multiplier(3)
print(double(5))
print(triple(5))
Output:
10
15
5
Python Notes - Part 3: Going Deeper: A Practical Guide
4. Sending a Flexible Number of Ingredients Into a
Function
Sometimes you don't know in advance how many pieces of information a function will need to accept.
Python lets a function accept a flexible 'bundle' of extra items, and also lets you 'unpack' a list or
dictionary straight into a function call.
def make_pizza(*toppings):
print(f"Pizza with: {', '.join(toppings)}")
make_pizza("cheese")
make_pizza("cheese", "mushroom", "olives")
Output:
Pizza with: cheese
Pizza with: cheese, mushroom, olives
Unpacking a List Straight Into a Function Call
def describe_trip(destination, days, budget):
print(f"{days} days in {destination} with a budget of {budget}")
trip_details = ["Goa", 4, 15000]
describe_trip(*trip_details) # the * spreads the list into 3 separate arguments
Output:
4 days in Goa with a budget of 15000
6
Python Notes - Part 3: Going Deeper: A Practical Guide
5. Looping Smarter: enumerate() and zip()
Two small tools that make loops much easier to write once you know them.
enumerate() -- Get the Position AND the Item Together
runners = ["Asha", "Ben", "Chen"]
for position, name in enumerate(runners, start=1):
print(f"{position}. {name}")
Output:
1. Asha
2. Ben
3. Chen
zip() -- Walk Through Two Lists Side by Side
names = ["Asha", "Ben", "Chen"]
scores = [88, 92, 79]
for name, score in zip(names, scores):
print(f"{name} scored {score}")
Output:
Asha scored 88
Ben scored 92
Chen scored 79
7
Python Notes - Part 3: Going Deeper: A Practical Guide
6. Repeating Yourself Without a Loop: Recursion
Recursion is when a function solves a big problem by calling a smaller copy of itself, like a set of
Russian nesting dolls -- you keep opening smaller dolls until you reach the tiniest one, then work your
way back out.
def countdown(n):
if n == 0:
print("Liftoff!")
return
print(n)
countdown(n - 1) # the function calls itself, with a smaller number
countdown(3)
Output:
3
2
1
Liftoff!
A Classic Example: Factorial
def factorial(n):
if n <= 1:
return 1
return n * factorial(n - 1)
print(factorial(5)) # 5 x 4 x 3 x 2 x 1
Output:
120
Note: Every recursive function needs a 'stopping point' (called the base case). Forgetting it causes the
function to call itself forever until the program crashes.
8
Python Notes - Part 3: Going Deeper: A Practical Guide
7. Custom Context Managers: Your Own 'with' Blocks
You have already used with open(...) to handle files safely. You can build your own with-blocks for
anything that needs a clean 'setup' and 'teardown' step, like turning a light on before a task and
switching it off afterwards, no matter what happens in between.
class Timer:
def __enter__(self):
print("Timer started")
return self
def __exit__(self, exc_type, exc_value, traceback):
print("Timer stopped")
with Timer():
print("Doing some work...")
Output:
Timer started
Doing some work...
Timer stopped
Note: __enter__ runs when the with block begins, and __exit__ always runs when it ends -- even if an
error happened in between. That guarantee is exactly why with-blocks are considered 'safe'.
9
Python Notes - Part 3: Going Deeper: A Practical Guide
8. Reading and Writing Spreadsheet-Style Data (CSV
Files)
CSV stands for 'Comma-Separated Values' -- it's simply a plain text way of storing table-like data (rows
and columns), and it's what you get when you 'save as' from Excel or Google Sheets in the simplest
format.
import csv
rows = [
["name", "score"],
["Asha", 88],
["Ben", 92],
]
with open("[Link]", "w", newline="") as f:
writer = [Link](f)
[Link](rows)
with open("[Link]", "r") as f:
reader = [Link](f)
for row in reader:
print(row)
Output:
['name', 'score']
['Asha', '88']
['Ben', '92']
Reading CSV Rows as Dictionaries
with open("[Link]", "r") as f:
reader = [Link](f)
for row in reader:
print(row["name"], "->", row["score"])
Output:
Asha -> 88
Ben -> 92
10
Python Notes - Part 3: Going Deeper: A Practical Guide
9. Talking to the Computer's Filing Cabinet: os, pathlib
and sys
These three built-in toolboxes let your program look around the computer's folders, find out things
about the system it's running on, and read information passed in from outside.
from pathlib import Path
current_folder = Path(".")
for item in current_folder.iterdir():
print(item)
Checking If a File Exists Before Opening It
from pathlib import Path
file_path = Path("[Link]")
if file_path.exists():
print("Found it, size in bytes:", file_path.stat().st_size)
else:
print("File not found")
Reading Values Passed In From the Terminal
# save as [Link], then run: python3 [Link] Alice
import sys
name = [Link][1] if len([Link]) > 1 else "stranger"
print(f"Hello, {name}!")
Output:
Hello, Alice!
11
Python Notes - Part 3: Going Deeper: A Practical Guide
10. Keeping a Record of What Your Program Did
(Logging)
print() is fine for quick checks, but for a real program you usually want a proper record of events -- with
timestamps and severity levels -- that you can turn on or off without deleting code. That's what the
logging module is for.
import logging
[Link](level=[Link], format="%(levelname)s: %(message)s")
[Link]("Program started")
[Link]("Disk space is getting low")
[Link]("Could not save the file")
Output:
INFO: Program started
WARNING: Disk space is getting low
ERROR: Could not save the file
Note: The four common levels, from least to most serious, are DEBUG, INFO, WARNING and
ERROR. You can tell Python to only show WARNING and above, hiding routine INFO messages once
your program is finished and running smoothly.
12
Python Notes - Part 3: Going Deeper: A Practical Guide
11. Teaching Your Own Objects to Behave Like Built-ins
(Dunder Methods)
'Dunder' just means 'double underscore'. These are special method names like __init__ that Python
calls automatically in certain situations. You can add a few more to make your own classes print nicely,
compare correctly, or work with len().
class Money:
def __init__(self, amount):
[Link] = amount
def __str__(self):
return f"Rs. {[Link]:.2f}"
def __eq__(self, other):
return [Link] == [Link]
def __add__(self, other):
return Money([Link] + [Link])
wallet1 = Money(500)
wallet2 = Money(500)
print(wallet1) # uses __str__
print(wallet1 == wallet2) # uses __eq__
print(wallet1 + wallet2) # uses __add__
Output:
Rs. 500.00
True
Rs. 1000.00
13
Python Notes - Part 3: Going Deeper: A Practical Guide
12. Three Special Kinds of Methods Inside a Class
A Regular Method Needs 'self'
By default, every method in a class automatically receives the specific object it belongs to (self), so it
can look at or change that object's own information.
A staticmethod Doesn't Care About Any Object
class TemperatureTools:
@staticmethod
def celsius_to_fahrenheit(c):
return (c * 9/5) + 32
print(TemperatureTools.celsius_to_fahrenheit(30))
Output:
86.0
A classmethod Works on the Whole Class, Not One Object
class Employee:
raise_percentage = 5
@classmethod
def set_raise_percentage(cls, new_value):
cls.raise_percentage = new_value
Employee.set_raise_percentage(10)
print(Employee.raise_percentage)
Output:
10
A property Lets a Method Be Used Like a Plain Value
class Rectangle:
def __init__(self, width, height):
[Link] = width
[Link] = height
@property
def area(self):
return [Link] * [Link]
box = Rectangle(4, 5)
print([Link]) # notice: no parentheses, looks like a normal attribute
Output:
20
14
Python Notes - Part 3: Going Deeper: A Practical Guide
13. Less Typing for Simple Data-Holding Classes:
dataclasses
Many classes exist purely to hold a bundle of related values (like a record in a spreadsheet) with very
little behaviour. Writing __init__ and __repr__ by hand for these gets repetitive, so Python provides a
shortcut called a dataclass.
from dataclasses import dataclass
@dataclass
class Book:
title: str
author: str
pages: int
book1 = Book("Atomic Habits", "James Clear", 320)
print(book1)
print([Link])
Output:
Book(title='Atomic Habits', author='James Clear', pages=320)
320
Note: Notice the class already knows how to print itself nicely and compare equal to another Book with
the same values -- both would normally need to be written by hand.
15
Python Notes - Part 3: Going Deeper: A Practical Guide
14. Leaving Hints About What Kind of Data You Expect
(Type Hints)
Python never forces you to say what type a variable or function argument should be, but you can leave
'hints' for other people reading your code (and for tools that catch mistakes before you even run the
program).
def calculate_total(price: float, quantity: int) -> float:
return price * quantity
print(calculate_total(49.99, 3))
Output:
149.97
Note: Type hints are not enforced while the program runs -- passing the 'wrong' type will not stop your
code. They exist purely to help humans and editors, and to be checked by optional tools such as mypy.
16
Python Notes - Part 3: Going Deeper: A Practical Guide
15. A Field Guide to Common Errors
Recognising an error by its name saves a lot of guesswork. Here are the ones you will run into most
often, in plain words.
● SyntaxError - the code is not written in valid Python at all, often a missing colon or bracket.
● NameError - you used a variable that was never created (or you made a typo in its name).
● TypeError - you tried to combine or use two things whose types don't work together, like text + a
number.
● ValueError - the type is right, but the actual value doesn't make sense, like int("hello").
● IndexError - you asked for a position in a list that doesn't exist.
● KeyError - you asked a dictionary for a key that isn't there.
● ZeroDivisionError - you tried to divide a number by zero.
● FileNotFoundError - you tried to open a file that doesn't exist at that location.
● AttributeError - you tried to use a method or property that the object doesn't have.
try:
numbers = [1, 2, 3]
print(numbers[10])
except IndexError as e:
print("Oops:", e)
Output:
Oops: list index out of range
17
Python Notes - Part 3: Going Deeper: A Practical Guide
16. Checking Your Own Work: A Gentle Intro to Testing
Instead of manually re-running a program every time you change it to check nothing broke, you can
write small automatic checks. The simplest form is the assert keyword, which raises an error if a
condition turns out to be false.
def add(a, b):
return a + b
assert add(2, 3) == 5
assert add(-1, 1) == 0
print("All checks passed")
Output:
All checks passed
A More Serious Tool: unittest
import unittest
def add(a, b):
return a + b
class TestAdd([Link]):
def test_positive_numbers(self):
[Link](add(2, 3), 5)
def test_negative_numbers(self):
[Link](add(-2, -3), -5)
# normally run from the terminal with: python3 -m unittest test_file.py
Note: Writing tests feels slower at first, but it saves enormous time later -- especially once a program
grows beyond a few dozen lines.
18
Python Notes - Part 3: Going Deeper: A Practical Guide
17. Doing Several Things 'At Once': A Gentle Intro to
Threads
Normally Python runs one instruction after another, in a single queue. Threading lets you start extra
'workers' that run alongside your main program -- useful when a task involves a lot of waiting, such as
downloading several files at the same time.
import threading
import time
def download(name):
print(f"Starting download: {name}")
[Link](1)
print(f"Finished download: {name}")
t1 = [Link](target=download, args=("[Link]",))
t2 = [Link](target=download, args=("[Link]",))
[Link]()
[Link]()
[Link]()
[Link]()
print("Both downloads complete")
Note: Threading is a deep topic with real pitfalls once programs get complex. For most beginner
projects, running tasks one after another is simpler and safer -- reach for threading only once you
specifically need to avoid waiting around.
19
Python Notes - Part 3: Going Deeper: A Practical Guide
18. Practical Project: Contact Book That Remembers
Between Runs
This project saves contacts to a JSON file, so the information is still there the next time you run the
program -- unlike a plain list, which disappears the moment the program ends.
import json
from pathlib import Path
CONTACTS_FILE = Path("[Link]")
def load_contacts():
if CONTACTS_FILE.exists():
with open(CONTACTS_FILE, "r") as f:
return [Link](f)
return {}
def save_contacts(contacts):
with open(CONTACTS_FILE, "w") as f:
[Link](contacts, f, indent=2)
def add_contact(contacts, name, phone):
contacts[name] = phone
save_contacts(contacts)
contacts = load_contacts()
add_contact(contacts, "Neha", "9123456780")
add_contact(contacts, "Vikram", "9988776655")
for name, phone in [Link]():
print(f"{name}: {phone}")
Output:
Neha: 9123456780
Vikram: 9988776655
20
Python Notes - Part 3: Going Deeper: A Practical Guide
19. Practical Project: Text-Based Quiz Using Classes
This project brings together classes, loops, dictionaries and simple scorekeeping into a small, complete
quiz program.
class Quiz:
def __init__(self, questions):
[Link] = questions
[Link] = 0
def run(self, answers_given):
for (question, correct_answer), given in zip([Link], answers_given):
print(question)
if [Link]().lower() == correct_answer.lower():
[Link] += 1
print(f"Final score: {[Link]}/{len([Link])}")
questions = [
("What is the capital of France?", "Paris"),
("What is 7 x 6?", "42"),
]
# pretend a user typed these two answers
user_answers = ["Paris", "42"]
quiz = Quiz(questions)
[Link](user_answers)
Output:
What is the capital of France?
What is 7 x 6?
Final score: 2/2
21
Python Notes - Part 3: Going Deeper: A Practical Guide
20. Tidying Up Your Code: A Few Style Habits
● Follow PEP 8, Python's official style guide -- 4 spaces per indent, lowercase_with_underscores for
variable and function names, CapitalisedWords for class names.
● Keep each function focused on doing just one clear job.
● Add a short triple-quoted docstring under a function or class to explain what it does, especially
once other people (or future-you) will read it.
● Avoid deeply nested if-inside-if-inside-if blocks -- consider returning early instead.
● Group related code into separate files (modules) once a single file grows past a few hundred
lines.
def calculate_discount(price, percentage):
"""Return the price after applying a percentage discount."""
return price - (price * percentage / 100)
print(calculate_discount(1000, 20))
Output:
800.0
Note: None of this changes whether the code runs -- it changes how quickly you (or someone else)
can understand it six months from now, which matters just as much in real projects.
22