Personal study notes by Kalpit Rathod covering Python from fundamentals to OOP. Each section explains the why + the how, with code straight from the source files.
stdPython/
├── Start_Python/ # Core language fundamentals (numbered 1–18)
├── BasicPythonStructures/ # File I/O, CSV, Regex, PIL, Unit Testing
├── ExtraPython/ # Functional Python: comprehensions, generators, sets, scope
└── NewPython/ # Object-Oriented Programming (4 progressive files)
- A variable is a named container that stores a value.
- A string (
str) is a sequence of characters. Python treats everything frominput()as a string. - f-strings (
f"...") let you embed variable values directly inside a string using{}. - Strings have built-in methods — functions that live on the string object itself.
# input() always returns a str
name = input("Enter your name: ")
# Three ways to print with a variable
print("Hello, " + name) # concatenation (only works str + str)
print("Hello,", name) # comma adds automatic space
print(f"Hello, {name}") # f-string — cleanest approach
# String methods — chained together
name = input("Name: ").strip().title()
# .strip() → removes leading/trailing whitespace
# .capitalize() → capitalizes first letter only
# .title() → capitalizes first letter of EACH word
# .upper() → all uppercase
# .lower() → all lowercase
# Splitting a string
first, last = name.split(" ") # split by space, unpack into two vars
print("First:", first, "Last:", last)print(*objects, sep=' ', end='\n', file=sys.stdout, flush=False)
sep → what to put between multiple arguments (default: space)
end → what to add after the last item (default: newline \n)
input()always returns a string. You must cast tointorfloatexplicitly.int— whole numbers.float— decimal numbers.round(number, ndigits)— rounds to N decimal places.- Format specifiers inside f-strings control how numbers are displayed.
# Type conversion (casting)
x = int(input("What's x? ")) # str → int
y = float(input("What's y? ")) # str → float
# Arithmetic operators
# + addition
# - subtraction
# * multiplication
# / division (always returns float)
# // integer (floor) division
# % modulo (remainder)
# ** exponentiation
# Round a float result
z = round(3.14159, 2) # → 3.14
z = round(x / y) # round to nearest integer
# Format specifiers in f-strings
pi = 3.14159
print(f"{pi:.2f}") # → 3.14 (2 decimal places)
print(f"{1000000:,}") # → 1,000,000 (comma separator)- A function is a reusable named block of code. Defined with
def. - Parameters = variables listed in the function definition.
- Arguments = values you pass when calling the function.
- Default parameters = fallback values if caller doesn't provide one.
- Scope = where a variable is visible. Variables inside a function only exist inside it.
- Return = send a value back to whoever called the function.
- The
main()pattern ensures functions are defined before you use them.
# Basic function
def hello():
print("Hello")
hello() # call it
# ---
# Function with a parameter
def hello(to):
print("Hello,", to)
hello("Kalpit")
# ---
# Default parameter (used when no argument is passed)
def hello(to="World"):
print("Hello,", to)
hello() # → Hello, World
hello("Kalpit") # → Hello, Kalpit
# ---
# The main() pattern — safely call functions defined later
def main():
name = input("Name? ")
hello(name)
def hello(to="World"):
print("Hello,", to)
main() # Python reads all defs first, then runs this
# ---
# Returning a value from a function
def main():
x = int(input("x? "))
print("x squared is", square(x))
def square(num):
return num * num # sends value back to the caller
main()Scope rule: a variable created inside
square()does not exist inmain()and vice versa.
- Python evaluates boolean expressions to
TrueorFalse. if / elif / else— only one branch runs.- Chain comparisons Pythonically:
90 <= score <= 100. - Logical operators:
and,or,not. - Ternary (inline if):
value_if_true if condition else value_if_false.
x = int(input("x? "))
y = int(input("y? "))
# Full if/elif/else
if x < y:
print("x is less than y")
elif x > y:
print("x is greater than y")
else:
print("x equals y")
# Logical operators
if x < y or x > y:
print("not equal")
if x >= 0 and x <= 100:
print("in range")
# Pythonic range check
if 0 <= x <= 100: # works! Python supports chained comparisons
print("in range")Grade example (chained elif):
score = int(input("Score: "))
if score >= 90:
print("A")
elif score >= 80:
print("B")
elif score >= 70:
print("C")
elif score >= 60:
print("D")
else:
print("F")Ternary expression:
def is_even(n):
return n % 2 == 0 # returns True or False directly
# ternary: one-liner conditional
print("Even") if is_even(x) else print("Odd")match (Python 3.10+) is like a cleaner switch statement. Use | to match multiple values in one case. case _: is the default/catch-all.
name = input("Name? ")
# Verbose version (lots of elif)
if name == "Harry" or name == "Hermione" or name == "Ron":
print("Gryffindor")
elif name == "Draco":
print("Slytherin")
else:
print("Who?")
# Clean version with match
match name:
case "Harry" | "Hermione" | "Ron":
print("Gryffindor")
case "Draco":
print("Slytherin")
case _: # default case (like else)
print("Who?")whileloop — keep going as long as a condition is True. Risk of infinite loop.forloop — iterate over a sequence a known number of times.range(n)produces integers 0, 1, … n-1.- Use
_as the loop variable when you don't need the value (throwaway). break— exit the loop immediately.continue— skip to next iteration.- Nested loops = loop inside a loop (for grids/2D printing).
# while loop — count down
i = 3
while i != 0:
print("meow")
i -= 1
# while loop — count up
i = 0
while i < 3:
print("meow")
i += 1
# for loop with range
for i in range(3): # i = 0, 1, 2
print("meow")
for _ in range(3): # _ means "I don't need the index"
print("meow")
# String repetition shortcut
print("meow\n" * 3, end="")
# Validate input, then loop
while True:
n = int(input("n? "))
if n > 0:
break # exits the while True loop
for _ in range(n):
print("meow")
# Refactored into functions
def main():
meow(get_number())
def get_number():
while True:
n = int(input("n? "))
if n > 0:
return n # return also exits the loop
def meow(n):
for _ in range(n):
print("meow")
main()Nested loops (print a square):
def print_square(size):
for i in range(size): # each row
for j in range(size): # each column
print("#", end="") # no newline between columns
print() # newline after each row
# Shorter:
for i in range(size):
print("#" * size)- A list is an ordered, mutable (changeable) collection. Uses
[]. - Elements can be any type: strings, ints, dicts, even other lists.
- Access by index (0-based). Negative index counts from the end:
list[-1]= last element. - Common methods:
.append(),.remove(),.pop(),.sort(),.reverse(). sorted(list)returns a new sorted list;list.sort()sorts in place.
students = ["Hermione", "Harry", "Ron"]
print(students) # ['Hermione', 'Harry', 'Ron']
print(students[0]) # Hermione
print(students[-1]) # Ron (last)
# Iterate
for student in students:
print(student)
# Iterate with index
for i in range(len(students)):
print(i, students[i])
# Better: use enumerate
for i, student in enumerate(students):
print(i + 1, student)
# Build a list from input
names = []
for _ in range(3):
names.append(input("Name? "))
for name in sorted(names): # sorted doesn't mutate original
print(f"Hello, {name}")- A dict is an unordered mapping of key → value pairs. Uses
{}. - Keys must be unique and immutable (strings, ints, tuples).
- Access by key:
d["key"]. KeyError if key doesn't exist. - Use
.get("key", default)to avoid KeyError. - Lists of dicts = the standard way to store tabular data in Python.
# Simple dict: name → house
students = {
"Hermione": "Gryffindor",
"Harry": "Gryffindor",
"Ron": "Gryffindor",
"Draco": "Slytherin",
}
print(students["Hermione"]) # Gryffindor
for student in students: # iterates over KEYS
print(student, students[student], sep=", ")
# ---
# List of dicts — best for structured records
students = [
{"name": "Hermione", "house": "Gryffindor", "patronus": "Otter"},
{"name": "Harry", "house": "Gryffindor", "patronus": "Stag"},
{"name": "Ron", "house": "Gryffindor", "patronus": "Jack Russell terrier"},
{"name": "Draco", "house": "Slytherin", "patronus": None},
]
for student in students:
print(student["name"], student["house"], student["patronus"], sep=", ")
# Sort list of dicts by a key
sorted_students = sorted(students, key=lambda s: s["name"])- Exceptions = runtime errors (things that go wrong while the program runs).
try / exceptlets you catch errors and handle them gracefully instead of crashing.elseblock runs only if no exception occurred intry.pass— silently do nothing (absorb the exception and retry).raise— manually throw an exception with a message.- Always catch the specific exception type, not bare
except:.
# Basic try/except
try:
x = int(input("x? "))
except ValueError:
print("x is not an integer")
# try / except / else
try:
x = int(input("x? "))
except ValueError:
print("Not a number")
else:
print(f"x is {x}") # only runs if try succeeded
# Loop until valid input
while True:
try:
x = int(input("x? "))
except ValueError:
print("Try again")
else:
break
# Refactored as a function
def get_int(prompt):
while True:
try:
return int(input(prompt)) # return exits the loop too
except ValueError:
pass # silently retry
x = get_int("What's x? ")
print(f"x is {x}")
# raise — throw your own exception
def set_age(age):
if age < 0:
raise ValueError("Age cannot be negative")
return age- A module is a
.pyfile you import to reuse code. - Python's standard library ships with hundreds of modules (
random,sys,os,math,csv,re,json, etc.). import module— imports the whole module; prefix everything withmodule..from module import name— imports one thing; use it directly without prefix.- A package is a folder of modules with an
__init__.py.
import random
coin = random.choice(["heads", "tails"]) # random.choice
number = random.randint(1, 10) # random.randint
cards = ["jack", "queen", "king"]
random.shuffle(cards) # shuffles in-place
for card in cards:
print(card)
# Selective import (no prefix needed)
from random import choice, shuffle
coin = choice(["heads", "tails"])sys.argvis a list of strings representing what the user typed in the terminal.sys.argv[0]= the script name itself.sys.argv[1]= first user argument,sys.argv[2]= second, etc.sys.exit(message)prints the message to stderr and exits with error code 1.argparseis the professional way to handle complex CLI flags.
import sys
# Basic: access argument
print("Hello,", sys.argv[1]) # IndexError if nothing passed!
# Safe version with length check
if len(sys.argv) < 2:
sys.exit("Too few arguments")
elif len(sys.argv) > 2:
sys.exit("Too many arguments")
print("Hello,", sys.argv[1])
# Handle multiple arguments (slice from index 1)
if len(sys.argv) < 2:
sys.exit("Too few arguments")
for name in sys.argv[1:]: # [1:] skips the script name
print("Hello,", name)With argparse (much cleaner for real tools):
import argparse
parser = argparse.ArgumentParser(description="Meow like a cat")
parser.add_argument("-n", default=1, help="Number of times to meow", type=int)
args = parser.parse_args()
for _ in range(args.n):
print("meow")
# Usage: python meow.py -n 5
# Also auto-generates: python meow.py --help- An API (Application Programming Interface) lets you request data from a web service.
requests.get(url)sends an HTTP GET request and returns a Response object..json()on the response parses the JSON body into a Python dict/list.json.dumps(data, indent=2)pretty-prints any Python object as formatted JSON.
import json, requests, sys
if len(sys.argv) != 2:
sys.exit("Usage: APIS.py <search_term>")
response = requests.get(
"https://itunes.apple.com/search?entity=song&limit=50&term=" + sys.argv[1]
)
# See the raw JSON (useful for exploring)
print(json.dumps(response.json(), indent=2))
# Extract just track names
data = response.json()
for result in data["results"]:
print(result["trackName"])open(filename, mode)opens a file. Always usewith— it auto-closes the file even on error.- Modes:
"r"read,"w"write (overwrites!),"a"append (adds to end). .read()— entire file as one string..readlines()— list of lines (each includes\n).- Iterating directly over the file object is the most memory-efficient way.
.rstrip()strips trailing whitespace/newline from each line.
# Write: append to file
name = input("Name? ")
with open("names.txt", "a") as file:
file.write(f"{name}\n")
# Read: iterate line by line (memory efficient)
with open("names.txt", "r") as file:
for line in file:
print("Hello,", line.rstrip()) # rstrip removes \n
# Read: collect, then sort, then print
names = []
with open("names.txt", "r") as file:
for line in file:
names.append(line.rstrip())
for name in sorted(names):
print(f"Hello, {name}")
# Shortcut: sort while reading
with open("names.txt", "r") as file:
for line in sorted(file):
print("Hello,", line.rstrip())Why
with? It uses the context manager protocol — guaranteesfile.close()is called even if an exception happens inside the block.
- CSV = Comma-Separated Values. A plain text table format.
- Python's
csvmodule handles quoting, commas inside fields, and line endings automatically. csv.reader— iterates rows as lists.csv.DictReader— iterates rows as dicts (first row = header = keys). Preferred.csv.writer/csv.DictWriterfor writing.lambdais an anonymous one-liner function, often used as a sort key.
import csv
# Manual parsing (fragile — breaks if field contains comma)
with open("names.csv") as file:
for line in file:
name, house = line.rstrip().split(",")
print(f"{name} is in {house}")
# Using csv.reader
with open("names.csv") as file:
for name, house in csv.reader(file):
print(f"{name} is in {house}")
# Using csv.DictReader (best — header row becomes keys)
students = []
with open("names.csv") as file:
for row in csv.DictReader(file): # row is a dict
students.append({"name": row["name"], "house": row["house"]})
# Sort by name using lambda
for s in sorted(students, key=lambda s: s["name"]):
print(f"{s['name']} is in {s['house']}")
# Writing with csv.writer (order matters)
with open("students.csv", "a") as file:
writer = csv.writer(file)
writer.writerow([name, house])
# Writing with csv.DictWriter (order doesn't matter)
with open("students.csv", "a", newline="") as file:
writer = csv.DictWriter(file, fieldnames=["name", "home"])
writer.writerow({"name": name, "home": home})
newline=""in write mode prevents double line endings on Windows.
- A regex is a pattern for matching text. Python's
remodule provides this. re.search(pattern, string, flags)— finds first match anywhere in string. Returns a Match object orNone.re.fullmatch(pattern, string)— entire string must match.- Patterns use special characters (metacharacters):
| Pattern | Meaning |
|---|---|
. |
Any character except newline |
* |
0 or more of preceding |
+ |
1 or more of preceding |
? |
0 or 1 of preceding |
{m} |
Exactly m repetitions |
{m,n} |
Between m and n repetitions |
^ |
Start of string |
$ |
End of string |
[abc] |
Any of a, b, or c |
[^abc] |
Anything except a, b, or c |
\w |
Word char: [a-zA-Z0-9_] |
\d |
Digit: [0-9] |
\s |
Whitespace (space, tab, newline) |
\W \D \S |
NOT word/digit/whitespace |
A|B |
A or B |
(...) |
Capturing group |
(?:...) |
Non-capturing group |
Flags: re.IGNORECASE (case-insensitive), re.MULTILINE, re.DOTALL
import re
email = input("Email? ").strip()
# Simple: must have exactly one @
if "@" in email:
print("Valid (basic check)")
# Better: use regex
# \w+ → one or more word chars (username)
# @ → literal @
# \w+ → domain name
# \. → literal dot (. needs escaping)
# (edu|com|gov|net) → one of these TLDs
if re.search(r"^\w+@\w+\.(edu|com|gov|net)$", email):
print("Valid")
else:
print("Invalid")
# Even better: handle dots in username, case-insensitive
if re.search(r"^(\w)+(\.)?@(\w+\.)?\w+\.(edu|com|gov|net)$", email, re.IGNORECASE):
print("Valid")
else:
print("Invalid")
# Extracting matched groups
match = re.search(r"^(\w+)@(\w+)\.(\w+)$", email)
if match:
print("Username:", match.group(1))
print("Domain:", match.group(2))
print("TLD:", match.group(3))- Unit testing = writing small tests to verify individual functions work correctly.
pytestdiscovers and runs all files namedtest_*.pyor*_test.py.- A test function must start with
test_. - Use
assert expected == actual— if False, pytest marks the test as FAILED. - Test edge cases: zero, negative, boundary values, type errors.
# calculator.py
def add(a, b):
return a + b
def subtract(a, b):
return a - b# test_cal.py
import calculator
def test_add_positive():
assert calculator.add(2, 3) == 5
def test_add_negative():
assert calculator.add(-1, -1) == -2
def test_add_zero():
assert calculator.add(0, 5) == 5
def test_subtract():
assert calculator.subtract(10, 4) == 6# Run all tests
pytest test_cal.py
# Verbose output
pytest -v test_cal.pyA compact one-line way to build a list. Replaces a for loop + .append() pattern. Can include an optional if filter.
Syntax: [expression for item in iterable if condition]
# Loop version (verbose)
words = ["This", "is", "CS50"]
uppercased = []
for word in words:
uppercased.append(word.upper())
# List comprehension (concise)
uppercased = [word.upper() for word in words]
print(*uppercased) # unpack list into print (space-separated)
# With filter
students = [
{"name": "Hermione", "house": "Gryffindor"},
{"name": "Harry", "house": "Gryffindor"},
{"name": "Ron", "house": "Gryffindor"},
{"name": "Draco", "house": "Slytherin"},
]
# Get names of Gryffindor students only
gryffindors = [s["name"] for s in students if s["house"] == "Gryffindor"]
for name in sorted(gryffindors):
print(name)
# Using filter() + lambda (same result, lazy evaluation)
gryffindors = filter(lambda s: s["house"] == "Gryffindor", students)
for s in gryffindors:
print(s["name"])Same idea as list comprehensions but builds a dict: {key: value for item in iterable}
students = ["Hermione", "Harry", "Ron"]
# Loop version
gryffindor = []
for student in students:
gryffindor.append({"name": student, "house": "Gryffindor"})
# List comprehension (list of dicts)
gryffindors = [{"name": s, "house": "Gryffindor"} for s in students]
# Dict comprehension (name → house mapping)
gryffindors = {student: "Gryffindor" for student in students}
# → {"Hermione": "Gryffindor", "Harry": "Gryffindor", "Ron": "Gryffindor"}
# enumerate — get both index AND value
for i, student in enumerate(students):
print(i + 1, student)
# → 1 Hermione
# → 2 Harry
# → 3 Ronmap(function, iterable) applies a function to every element of an iterable. Returns a lazy map object (evaluated only when iterated). Avoids writing a for loop.
words = ["This", "is", "CS50"]
# Loop version
uppercased = []
for word in words:
uppercased.append(word.upper())
# map version — more functional, no side effects
uppercased = map(str.upper, words) # str.upper is a reference to the method
print(*uppercased) # unpack to print all
# With *args (accept any number of positional arguments)
def yell(*words): # *words collects all args into a tuple
print(*map(str.upper, words))
yell("This", "is", "CS50") # → THIS IS CS50A generator uses yield instead of return. It produces values one at a time (on demand), which is memory-efficient for large or infinite sequences. Contrast with a function that builds and returns an entire list.
return→ function ends, returns one value.yield→ function pauses, hands back one value, resumes from the same spot next call.
# Regular function — builds the whole list in memory
def sheep(n):
flock = []
for i in range(1, n + 1):
flock.append("|Sheep|" * i)
return flock # if n = 1,000,000 this fills RAM
# Generator — produces values lazily
def sheep(n):
for i in range(1, n + 1):
yield "|Sheep|" * i # produces one value, then pauses
# Usage is identical
for s in sheep(5):
print(s)
# |Sheep|
# |Sheep||Sheep|
# |Sheep||Sheep||Sheep|
# ...Rule of thumb: if you only need to loop through the results once and there could be many, use a generator. If you need to access elements by index, use a list.
Python lets you "unpack" a list or dict into individual variables or function arguments.
*unpacks a list/tuple into positional arguments.**unpacks a dict into keyword arguments.*argsin a function signature collects extra positional args into a tuple.**kwargscollects extra keyword args into a dict.
# Unpack into variables
first, last = input("Full name? ").split(" ")
print(f"Hello, {first}")
# ---
def total(galleons, sickles, knuts):
return (galleons * 17 + sickles) * 29 + knuts
# Pass list elements as positional args using *
coins = [100, 50, 25]
print(total(*coins)) # same as total(100, 50, 25)
# Pass dict as keyword args using **
coins = {"galleons": 100, "sickles": 50, "knuts": 25}
print(total(**coins)) # same as total(galleons=100, sickles=50, knuts=25)
# ---
# *args in definition: collect all positional args
def f(*args, **kwargs):
print("Positional:", args) # tuple
print("Named:", kwargs) # dict
f(100, 50, 25, galleons=50)
# Positional: (100, 50, 25)
# Named: {'galleons': 50}A set is an unordered collection of unique items. Automatically removes duplicates. Fast for membership checks (in). Created with set() or {a, b, c}.
students = [
{"name": "Hermione", "house": "Gryffindor"},
{"name": "Harry", "house": "Gryffindor"},
{"name": "Ron", "house": "Gryffindor"},
{"name": "Draco", "house": "Slytherin"},
{"name": "Padma", "house": "Ravenclaw"},
]
# List approach: must manually check duplicates
houses = []
for student in students:
if student["house"] not in houses:
houses.append(student["house"]) # O(n) per check
# Set approach: unique by nature, O(1) membership check
houses = set()
for student in students:
houses.add(student["house"]) # duplicates ignored automatically
for house in sorted(houses):
print(house)
# Gryffindor
# Ravenclaw
# Slytherin
# Set operations
a = {1, 2, 3}
b = {3, 4, 5}
print(a | b) # union: {1, 2, 3, 4, 5}
print(a & b) # intersection: {3}
print(a - b) # difference: {1, 2}The global keyword lets a function modify a variable defined at the module level. Avoid this pattern — it makes code hard to debug. The right fix is using a class.
# BAD: global variable approach
balance = 0
def deposit(n):
global balance # without this, Python assumes local variable
balance += n
def withdraw(n):
global balance
balance -= n
deposit(100)
withdraw(50)
print(balance) # 50
# ---
# BETTER: encapsulate state in a class
class Account:
def __init__(self):
self._balance = 0 # private by convention
@property
def balance(self):
return self._balance
def deposit(self, n):
self._balance += n # self replaces global — no global needed
def withdraw(self, n):
self._balance -= n
account = Account()
account.deposit(100)
account.withdraw(50)
print(account.balance) # 50# Syntax: param: type, return -> type
def greet(name: str) -> str:
return f"Hello, {name}"
def add(a: int, b: int) -> int:
return a + b
def process(items: list[str]) -> None:
for item in items:
print(item)Type hints are not enforced at runtime — they're documentation for humans and tools like
mypy.
def square(n: int) -> int:
"""
Return the square of n.
Args:
n (int): The number to square.
Returns:
int: The square of n.
Example:
>>> square(4)
16
"""
return n * n
# Access via help() or .__doc__
help(square)
print(square.__doc__)OOP organizes code around objects — things that have:
- Attributes (data / state): name, house, balance
- Methods (behavior / actions): speak, sort, deposit
A class is the blueprint; an object (instance) is what you build from it.
Step 1 — Just functions with separate variables (too loose)
name = input("Name: ")
house = input("House: ")
print(f"{name} from {house}")Step 2 — Tuple (can't change values, no labels)
# Tuples use () — immutable; lists use [] — mutable
student = ("Harry", "Gryffindor")
print(student[0]) # positional — unclear what [0] meansStep 3 — Dict (labeled, mutable, but no validation)
student = {"name": "Harry", "house": "Gryffindor"}
print(student["name"])Step 4 — Class (labeled, mutable, validation, behavior)
class Student:
def __init__(self, name, house):
# __init__ = constructor. Called automatically when you do Student(...)
# self = the specific instance being created
self.name = name
self.house = house
def __str__(self):
# Called automatically by print(student) or str(student)
return f"{self.name} from {self.house}"
student = Student("Harry", "Gryffindor")
print(student) # triggers __str__ → "Harry from Gryffindor"
print(student.name) # attribute accessclass Student:
def __init__(self, name, house):
if not name:
raise ValueError("Missing name")
if house not in ["Gryffindor", "Hufflepuff", "Ravenclaw", "Slytherin"]:
raise ValueError("Invalid house")
self.name = name
self.house = houseMethods are just functions inside a class that take self as first arg.
class Student:
def __init__(self, name, house, patronus):
self.name = name
self.house = house
self.patronus = patronus
def __str__(self):
return f"{self.name} from {self.house}"
def charm(self):
match self.patronus:
case "Stag": return ":)"
case "Otter": return ";)"
case "Jack Russell": return ":>"
case _: return ":/"
s = Student("Harry", "Gryffindor", "Stag")
print(s.charm()) # → :)Why? You want to validate every time an attribute is set — even after construction.
class Student:
def __init__(self, name, house):
self.name = name # ← goes through @name.setter
self.house = house # ← goes through @house.setter
def __str__(self):
return f"{self.name} from {self.house}"
@property
def name(self): # GETTER — called when you READ self.name
return self._name
@name.setter
def name(self, name): # SETTER — called when you WRITE self.name = ...
if not name:
raise ValueError("Missing name")
self._name = name # store in _name (private by convention)
@property
def house(self):
return self._house
@house.setter
def house(self, house):
if house not in ["Gryffindor", "Hufflepuff", "Ravenclaw", "Slytherin"]:
raise ValueError("Invalid house")
self._house = houseNaming convention:
_var= "private by convention" (Python doesn't truly enforce it).__var= name-mangled (harder to access externally).
| Feature | Instance Variable | Class Variable |
|---|---|---|
| Defined in | __init__ with self. |
Directly in the class body |
| Belongs to | Each instance | The class itself (shared) |
| Accessed via | self.attr |
cls.attr or ClassName.attr |
import random
class Hat:
houses = ["Gryffindor", "Hufflepuff", "Ravenclaw", "Slytherin"]
# ↑ class variable: shared across all Hat instances
@classmethod
def sort(cls, name): # cls = the class itself (not an instance)
print(name, "is in", random.choice(cls.houses))
Hat.sort("Harry") # called on the class directly — no instance neededFactory class method — alternative constructor:
class Student:
def __init__(self, name, house):
self.name = name
self.house = house
def __str__(self):
return f"{self.name} from {self.house}"
@classmethod
def get(cls): # factory: handles input and creates instance
name = input("Name: ")
house = input("House: ")
return cls(name, house) # cls(...) = Student(...)
def main():
student = Student.get() # call factory directly on class
print(student)
if __name__ == "__main__":
main()Inheritance lets a child class reuse and extend the code from a parent class.
class Child(Parent)— Child inherits everything from Parent.super().__init__(...)— calls Parent's__init__to set shared attributes.- Child can override parent methods (polymorphism).
class Wizard:
def __init__(self, name):
if not name:
raise ValueError("Missing name")
self.name = name
...
class Student(Wizard): # Student IS-A Wizard
def __init__(self, name, house):
super().__init__(name) # delegate name validation to Wizard
self.house = house # add Student-specific attribute
def __str__(self):
return f"{self.name} from {self.house}"
...
class Professor(Wizard): # Professor IS-A Wizard too
def __init__(self, name, subject):
super().__init__(name)
self.subject = subject
...
# All three work independently
wizard = Wizard("Albus")
student = Student("Harry", "Gryffindor")
professor = Professor("Severus", "Defense Against the Dark Arts")IS-A vs HAS-A: Prefer inheritance for "IS-A" relationships. Use a separate object (composition) for "HAS-A".
Python lets you define what +, -, *, ==, <, etc. mean for your custom class, by defining dunder methods.
class Vault:
def __init__(self, galleons=0, sickles=0, knuts=0):
self.galleons = galleons
self.sickles = sickles
self.knuts = knuts
def __str__(self):
return f"{self.galleons} Galleons, {self.sickles} Sickles, {self.knuts} Knuts"
def __add__(self, other): # enables: vault1 + vault2
return Vault(
self.galleons + other.galleons,
self.sickles + other.sickles,
self.knuts + other.knuts
)
potter = Vault(100, 50, 25)
weasley = Vault(25, 50, 100)
total = potter + weasley # calls __add__
print(total) # → 125 Galleons, 100 Sickles, 125 KnutsCommon dunder methods:
| Dunder | Operator / Usage |
|---|---|
__init__ |
ClassName() construction |
__str__ |
print(obj), str(obj) |
__repr__ |
Developer-facing representation |
__add__ |
obj1 + obj2 |
__sub__ |
obj1 - obj2 |
__mul__ |
obj1 * obj2 |
__eq__ |
obj1 == obj2 |
__lt__ |
obj1 < obj2 |
__len__ |
len(obj) |
__getitem__ |
obj[key] |
BaseException
├── KeyboardInterrupt ← Ctrl+C
└── Exception
├── ArithmeticError
│ └── ZeroDivisionError
├── AssertionError
├── AttributeError ← obj has no such attribute
├── EOFError
├── ImportError
│ └── ModuleNotFoundError
├── LookupError
│ ├── KeyError ← dict key not found
│ └── IndexError ← list index out of range
├── NameError ← variable not defined
├── OSError
│ └── FileNotFoundError
├── SyntaxError
│ └── IndentationError
├── TypeError ← wrong type passed
└── ValueError ← right type, wrong value
| Type | Example | Mutable? | Ordered? | Unique? |
|---|---|---|---|---|
str |
"hello" |
✗ | ✓ | ✗ |
int |
42 |
✗ | — | — |
float |
3.14 |
✗ | — | — |
list |
[1,2,3] |
✓ | ✓ | ✗ |
tuple |
(1,2,3) |
✗ | ✓ | ✗ |
dict |
{"a":1} |
✓ | ✓ (3.7+) | keys ✓ |
set |
{1,2,3} |
✓ | ✗ | ✓ |
# List
[expr for item in iterable]
[expr for item in iterable if condition]
# Dict
{key: val for item in iterable}
# Set
{expr for item in iterable}
# Generator (lazy — use inside functions)
(expr for item in iterable)Class Blueprint:
__init__(self, ...) → constructor
__str__(self) → string representation
@property → getter
@attr.setter → setter (validates writes)
@classmethod → belongs to class, not instance (cls)
@staticmethod → no self or cls needed
Object creation:
obj = MyClass(args) → calls __init__
print(obj) → calls __str__
obj.attr → getter
obj.attr = val → setter
# Run any file
python3 "Start_Python/3. function.py"
# Run with CLI argument
python3 "Start_Python/17. APIS.py" taylor
# Run tests
cd BasicPythonStructures && pytest test_cal.py -v
# Run argparse tool
python3 ExtraPython/arg_parse.py -n 5Personal Python reference — Kalpit Rathod