Notes compiled and expanded by FALAMLIH
- Unicode & Encoding
- What is Python?
- From Code to Result: The Interpreter Pipeline
- Keywords & Comments
- Control Flow
- Data Types: The Collections
- Other Built-in Data Types
- Useful Built-in Functions & Variable Basics
- Operators
- Errors & Exception Handling
- Modules & Packages
- Generators & Comprehensions
- File I/O & Context Managers
- Object-Oriented Programming (OOP)
- Scope & Namespaces
- Polymorphism, Abstraction & Protocols
- Virtual Environments,
.env& Poetry - Pydantic: Data Validation
- Lambda Functions
- Functions in Depth
- Types of Methods & Special (Dunder) Methods
- Advanced Python
- Further Reading
Unicode is a universal character-encoding standard that assigns a unique numeric value — a code point — to virtually every character, symbol, and emoji across all the world's writing systems.
- A code point is usually written as
U+XXXX(e.g.U+0041isA). - UTF-8 is the default source-code encoding in Python 3 — it's a variable-width encoding (1–4 bytes per character) that is backward-compatible with ASCII.
print(ord("A")) # 65 -> character to code point
print(chr(65)) # 'A' -> code point to character
print("café".encode("utf-8")) # b'caf\xc3\xa9'#!/usr/bin/python3Python is a general-purpose, interpreted, interactive, object-oriented, high-level programming language. It is:
- Dynamically typed — variable types are checked at runtime, not compile time.
- Garbage-collected — memory is managed automatically.
- Object-oriented — everything in Python (numbers, functions, classes, modules) is an object.
Python source code is not compiled to machine code. Instead, it runs through an interpreter at runtime, so there's no separate build step before execution.
| Component | Role |
|---|---|
| Parser | Reads source code and checks its syntax, building a parse tree |
| Compiler | Translates the parse tree into bytecode |
| Bytecode Generator | Produces the .pyc-style bytecode instructions |
| Python Virtual Machine (PVM) | Executes the bytecode instruction by instruction |
| Garbage Collector | Reclaims memory occupied by unreachable objects |
| Memory Manager | Allocates and tracks memory for Python objects |
| Standard Library | Ships with the interpreter; provides built-in modules (os, sys, json...) |
Interpreter vs. PVM: The interpreter (
python/python3) is the whole program you install and run. The PVM is the component inside the interpreter that actually executes bytecode. The CPU never runs Python bytecode directly — it executes the interpreter's own compiled machine code, which in turn carries out what the bytecode says.
- Web development (Django, Flask, FastAPI)
- Data analysis & machine learning (pandas, NumPy, scikit-learn, PyTorch)
- Automation & scripting (system administration, CI/CD)
- Software testing (pytest, unittest)
- Desktop apps, game prototyping, embedded scripting, and more
Virtual environments (preview — see §17 for details)
A virtual environment is a self-contained, isolated directory tree holding its own Python installation and its own set of installed packages, so a project's dependencies never collide with another project's or the system-wide Python.
Running a Python program goes through three broad stages:
code.py— the file containing your Python source code.- Compilation to bytecode — the interpreter translates the source into bytecode, a lower-level, platform-independent instruction set. (Bytecode is not machine code — machine code is executed directly by the CPU; bytecode is executed by a virtual machine.)
- Execution by the PVM — the Python Virtual Machine runs an interpreter loop that reads each bytecode instruction and carries it out on the host machine.
Along the way, the PVM also:
- Maintains a Python object model representing every data type (ints, strings, lists, custom objects, ...) and manages their creation, mutation, and destruction.
- Handles memory allocation and garbage collection dynamically.
- Uses reference counting plus a cyclic garbage collector — the latter specifically detects and cleans up circular references (objects that reference each other and would otherwise never hit a reference count of zero), preventing memory leaks.
code.py ──compile──▶ bytecode ──PVM interprets──▶ result
and as assert async await break class
continue def del elif else except False
finally for from global if import in
is lambda None nonlocal not or pass
raise return True try while with yield
Python 3.10 also introduced the soft keywords
match,case, and_, which only act as keywords inside amatchstatement — they remain valid identifiers elsewhere.
# This is a single-line comment
"""
This is a multi-line string,
commonly used as a comment block
or as a docstring when placed at the
top of a module/function/class.
"""| Statement | Purpose |
|---|---|
if / elif / else |
Branch based on a condition |
for ... else |
Loop over a sequence; the else runs only if the loop completes without hitting break |
while ... else |
Loop while a condition holds; same else rule as above |
break |
Terminates the loop entirely |
continue |
Skips the rest of the current iteration and jumps to the next one |
pass |
A null operation — does nothing, used as a placeholder |
return |
Exits the current function, optionally sending back a value |
for num in numbers:
total += num
else:
print("Loop finished without break")Similar to switch / case in C, C++, or Java, but far more powerful — it can match on structure, not just values.
match variable_name:
case "pattern 1":
statement_1()
case "pattern 2":
statement_2()
case [x, y]: # matches a 2-element sequence, unpacks it
print(x, y)
case {"key": value}: # matches a dict containing "key"
print(value)
case _: # wildcard, like `default`
print("no match")- Immutable objects (their internal state can't change): numbers, strings, tuples, frozensets. You can't add or remove elements — any "modification" actually creates a new object.
- Mutable objects (state can change in place): lists, sets, dictionaries.
Python collections
│
┌───────────────┴───────────────┐
│ │
Sequences Non-sequences
│ │
┌───────┼───────┐ ┌───────┴───────┐
↓ ↓ ↓ ↓ ↓
list tuple str dict set
│ │ │ │ │
ordered ordered ordered ordered by unordered,
indexed indexed indexed insertion, membership-
mutable immutable immutable keyed, mutable based, mutable
| Type | Ordered | Mutable | Duplicates | Indexing/Slicing | Literal |
|---|---|---|---|---|---|
list |
✅ | ✅ | ✅ | ✅ | [1, 2, 3] |
tuple |
✅ | ❌ | ✅ | ✅ | (1, 2, 3) |
set |
❌ | ✅ | ❌ | ❌ | {1, 2, 3} |
dict |
✅ (insertion order, 3.7+) | ✅ | ❌ (keys) | ❌ (by key, not position) | {"a": 1} |
An ordered, mutable collection that can hold mixed data types.
fruits = [1, 2, 3, 4, 5]
fruits.append(6) # add to the end — you don't insert by index
fruits.insert(0, 0) # insert at a specific position
fruits.remove(3) # remove the first occurrence of a value- Allows duplicates.
- Supports indexing and slicing:
seq[start:stop:step]selects elements fromstartup to (but excluding)stop, movingstepat a time.
nums = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9]
print(nums[2:7:2]) # [2, 4, 6]
print(nums[::-1]) # reversed listAn unordered collection of unique elements — duplicates are silently dropped. Sets do not support indexing or slicing (they're not a sequence).
a = {1, 2, 3}
b = {2, 3, 4}
a.intersection(b) # {2, 3} — elements common to both
a.union(b) # {1, 2, 3, 4} — all elements from both
a.difference(b) # {1} — elements only in a
a.symmetric_difference(b) # {1, 4} — in either, but not bothAn ordered, immutable collection. Once created, elements cannot be modified. Allows duplicates and supports indexing/slicing.
point = (1, 2, 3, 4, 5)- Tuples are hashable (as long as every element inside is also hashable) and can therefore be used as dictionary keys — lists cannot, because they're mutable.
Hashable = something Python can "lock in a box" and trust it will never change, so it can safely compute a fixed hash value for it.
- Because they're immutable, tuples help protect the structure of your data — great for fixed records like coordinates or RGB values.
Unpacking: assigning a collection's elements to multiple variables in one line.
x, y, z = (1, 2, 3)
first, *rest = [1, 2, 3, 4] # first = 1, rest = [2, 3, 4]A non-homogeneous structure storing key–value pairs. Keys must be unique and hashable; dictionaries are ordered (by insertion, since Python 3.7) but not a sequence — you can't index by position.
bag = {1: "a", 2: "b", 3: "c"}
bag.keys() # dict_keys([1, 2, 3])
bag.values() # dict_values(['a', 'b', 'c'])
bag.items() # dict_items([(1, 'a'), (2, 'b'), (3, 'c')])
for item, quantity in bag.items():
print(item, quantity)get(key, default): safely reads a value without crashing."If the key exists → give me its value. If it doesn't → give me this default."
bag.get("apple", 0) # returns 0 if "apple" isn't a key
update(): adds or overwrites multiple key–value pairs at once.bag.update({"apple": 5, "pear": 2})
- Nested dictionaries: use
.items()at each level to drill down.print(players.items()) print(players["alice"].items()) print(players["bob"]["items"].items())
| Type | Description | Example |
|---|---|---|
int |
Signed integers, arbitrary precision | 42 |
float |
Floating-point real numbers | 3.14 |
complex |
Complex numbers | 5 + 6j |
str |
Sequence of Unicode characters (non-numeric) | "hello" |
bool |
True / False (a subtype of int) |
True |
bytes |
Immutable sequence of bytes | b"abc" |
bytearray |
Mutable sequence of bytes | bytearray(b"abc") |
memoryview |
A view into the memory of another binary object, without copying it | memoryview(b"abc") |
range |
An immutable, memory-efficient sequence of numbers | range(start, stop, step) |
NoneType |
The null type — represents the absence of a value | None |
- An empty string
""is notNone— it is a valid, "empty" string, unlike some languages where an uninitialized string might be null. Noneis not0, not0.0, and notFalse— they are different objects of different types.- Comparing
None == anythingreturnsFalse, exceptNone == None, which isTrue. (Preferis None/is not Noneover==for this check.)
A raw string is created by prefixing the literal with r or R. It tells Python to treat backslashes as literal characters rather than escape sequences — very useful for regex patterns and Windows file paths.
raw_string = r"This is a newline:\n"
print(raw_string) # This is a newline:\n (no actual newline is produced)b = bytes([104, 105]) # b'hi' — immutable
ba = bytearray(b"hi") # mutable copy — ba[0] = 72 is allowed
mv = memoryview(b"hello") # zero-copy view into the buffer# Using curly braces
my_dict = {"name": "Jake", "age": 22, "city": "London"}
# Using the dict() constructor
another_dict = dict(brand="Ford", model="Mustang", year=1964)
# Initializing a dict with default values from a list of keys
keys = ["A", "B", "C", "D"]
graph = dict.fromkeys(keys, [])
⚠️ Gotcha:dict.fromkeys(keys, [])makes every key point to the same list object. Mutating one entry's list will affect all of them. If you need independent lists, build the dict with a comprehension instead:{k: [] for k in keys}.
| Function / Concept | Purpose |
|---|---|
input() |
Reads from standard input |
print() |
Writes to standard output |
id(obj) |
Returns the memory address ("identity") of an object |
del name |
Deletes a variable (or removes an item from a collection) |
type(obj) |
Returns the type of an object |
a, b, c = 1, 2, "text" |
Declares multiple variables in a single line |
a = 3
a = str(a) # a is now '3'Casting converts a value from one data type to another so it becomes compatible with the operation you need. One common trick: cast a string to a list, mutate it with insert(), append(), or remove(), then join it back into a string.
letters = list("hello")
letters.append("!")
"".join(letters) # 'hello!'- A variable name must start with a letter or an underscore (
_), and can contain letters, digits, and underscores after that (but not start with a digit). - Local variables are defined inside a function and only exist while that function runs.
- Python has no formally defined
constkeyword — "constants" are a naming convention (e.g.MAX_SIZE = 100), not enforced by the language. - A Python variable is a name that refers to an object, not a memory location itself — think of variables as labels/tags attached to objects, not boxes that hold values.
- An object is stored in memory only once; multiple variables can reference the same object (
id(a) == id(b)). - Private variables: prefixing an attribute with a double underscore (
__name) triggers name mangling, making it awkward to access from outside the class without going through a method (see §14).
def function_name(parameters):
...
# lambda: any number of arguments, exactly one expression
total = lambda arg1, arg2: arg1 + arg2
print("Value of total:", total(10, 20))Iterates over an iterable while automatically tracking the index, returning (index, element) pairs — no manual counter needed.
for index, value in enumerate(["a", "b", "c"]):
print(index, value)
# 0 a
# 1 b
# 2 c
for index, value in enumerate(["a", "b", "c"], start=1):
print(index, value) # start counting from 1📚 More on function arguments (positional, arbitrary, keyword, default): tutorialspoint.com/python/python_functions.htm
| Category | Operators |
|---|---|
| Arithmetic | + - * / % (modulus) ** (exponent) // (floor division, e.g. 9 // 2 = 4) |
| Comparison | == != > < >= <= |
| Logical | and or not |
| Identity | is is not |
| Membership | in not in |
| Assignment | = += -= *= /= //= **= %= := (walrus, 3.8+) |
if (n := len(my_list)) > 10: # walrus operator: assign & test in one expression
print(f"List is too long ({n} elements)")📚 Full reference: w3schools.com/python/python_operators.asp
raise says: "This situation is invalid — stop normal execution."
- It stops execution immediately.
- Python looks for the nearest matching
exceptblock, walking up the call stack. - If no matching
exceptis found anywhere, the program crashes and prints a traceback.
if age < 0:
raise ValueError("Age cannot be negative")Exception is the base class for almost all built-in runtime errors (ValueError, TypeError, KeyError, IndexError, ZeroDivisionError, etc.). You defend your program by wrapping risky code in a try: block, followed by one or more except: clauses that handle the problem gracefully.
try:
result = 10 / 0
except ZeroDivisionError as e:
print(f"Can't divide by zero: {e}")
except (TypeError, ValueError) as e:
print(f"Bad input: {e}")
else:
print("Ran only if no exception was raised")
finally:
print("Always runs — success, failure, or return")An exception class is a specialized class — usually subclassing Exception — that serves as a blueprint for creating exception objects representing a specific kind of runtime error.
class InsufficientFundsError(Exception):
"""Raised when an account balance can't cover a withdrawal."""
def __init__(self, balance, amount):
super().__init__(f"Balance {balance} is less than requested {amount}")
self.balance = balance
self.amount = amountCode in finally always executes — whether an exception was raised, handled, or never occurred at all (even if the try block returns). Its purpose is to guarantee cleanup actions run in every circumstance (closing files, releasing locks, disconnecting from a database).
A module is simply a file containing definitions — functions, classes, variables, constants, or any other Python object — that you can reuse via the import keyword.
import sys
from sys import argv
import numpy as np
from . import sibling_module # relative import within a packageProvides access to variables and functions used or maintained by the interpreter, and interacts closely with the runtime and OS.
sys.argv— a list of command-line arguments passed to the script (sys.argv[0]is always the script's own name).len(sys.argv)— the number of command-line arguments (including the script name).sysgives your program direct access to the Python runtime and operating-system interface (e.g.sys.exit(),sys.path,sys.platform).
A package is a directory of related modules. The __init__.py file inside it serves two purposes:
- It marks the directory as a regular Python package (so
import package.moduleworks). - It provides a place for package-level initialization code that runs the first time the package is imported.
Since Python 3.3, "namespace packages" can technically work without
__init__.py, but including it remains the clear, explicit convention for regular packages.
A generator is a function that behaves like an iterator, producing a sequence of values one at a time, lazily — only when requested. They're memory-efficient because they never hold the entire sequence in memory; instead, execution pauses at each yield and resumes right there when the next value is requested.
def countdown(n):
while n > 0:
yield n
n -= 1
for value in countdown(3):
print(value) # 3, 2, 1- Calling a generator function doesn't run its body immediately — it returns a generator object.
- Each call to
next()(implicitly done by aforloop) resumes execution until the nextyield. - Once the function returns (falls off the end, or hits a bare
return), the generator raisesStopIteration.
Generator expressions are a compact, lazy alternative to list comprehensions:
squares = (x**2 for x in range(1_000_000)) # nothing computed yet
next(squares) # 0A comprehension is a concise, single-line way to build a new collection by transforming and/or filtering an existing iterable — generally faster and more readable than an equivalent for loop with .append().
# List comprehension
squares = [x**2 for x in range(10)]
# with a condition (filter)
evens = [x for x in range(20) if x % 2 == 0]
# with if/else (transform, not filter)
labels = ["even" if x % 2 == 0 else "odd" for x in range(5)]
# Set comprehension
unique_lengths = {len(word) for word in ["hi", "bye", "ok"]}
# Dict comprehension
squares_map = {x: x**2 for x in range(5)}
# Nested comprehension (flatten a 2D list)
matrix = [[1, 2], [3, 4]]
flat = [num for row in matrix for num in row] # [1, 2, 3, 4]List comprehension vs. generator expression:
[x for x in ...]builds the entire list in memory immediately.(x for x in ...)builds a lazy generator that produces values on demand — use it when you don't need the whole collection at once (e.g. streaming through a huge file).
Python's built-in open() function opens a file in a given mode.
file = open("filename", "mode")| Mode | Meaning |
|---|---|
'r' |
Read only (default). File pointer starts at the beginning; errors if the file doesn't exist. |
'w' |
Write only. Overwrites the file if it exists; creates it if it doesn't. |
'a' |
Append. Writes are added to the end of the file; creates it if it doesn't exist. |
'x' |
Exclusive creation. Fails if the file already exists. |
'b' |
Binary mode modifier (combine, e.g. 'rb', 'wb'). |
'+' |
Update modifier — allows both reading and writing (e.g. 'r+'). |
A context manager is an object that defines two things: a setup action run when a block is entered, and a cleanup action run when the block is exited — even if an error occurs inside it. The with statement is the syntax that invokes a context manager.
with open("data.txt", "r") as f:
contents = f.read()
# f.close() is called automatically here, even if an exception was raised abovewith replaces verbose try / except / finally boilerplate with clean, guaranteed cleanup. If a file isn't closed properly, you risk:
- Resource leaks (too many open file handles)
- Corrupted or incomplete data (buffered writes never flushed to disk)
- Locked files (other processes can't access them)
- Crashes in long-running programs (running out of file descriptors)
Under the hood, any object used with with must implement two dunder methods:
class ManagedResource:
def __enter__(self):
print("Setting up")
return self # this becomes the `as` variable
def __exit__(self, exc_type, exc_value, traceback):
print("Cleaning up")
return False # False = don't suppress exceptions
with ManagedResource() as r:
print("Using the resource")You can also write context managers with the contextlib module, which is often shorter:
from contextlib import contextmanager
@contextmanager
def managed_resource():
print("Setting up")
yield "the resource"
print("Cleaning up")
with managed_resource() as r:
print("Using", r)RAII (Resource Acquisition Is Initialization) is a programming idiom, originally from C++, where a resource's lifetime is tightly bound to an object's lifetime: acquiring the resource happens in the constructor, and releasing it happens automatically when the object is destroyed. Python doesn't have deterministic destructors the way C++ does (garbage collection timing isn't guaranteed), so Python's idiomatic equivalent of RAII is precisely the context manager / with statement pattern above — deterministic setup and teardown, without relying on the garbage collector's timing.
OOP is a programming model built around objects — units that bundle together state (data) and behavior (functions) to represent real-world entities.
| Concept | Meaning |
|---|---|
| Class | The blueprint — defines what attributes and methods its objects will have |
| Object / Instance | A concrete "real thing" built from a class |
__init__ |
The setup function (constructor), run automatically when an object is created |
self |
A reference to the object itself, passed automatically to instance methods |
class Plant:
def __init__(self, name, height, age):
self.name = name
self.height = height
self.age = age
p = Plant("Rose", 10, 3)
# Python automatically calls: Plant.__init__(p, "Rose", 10, 3)Why __init__ matters:
- Gives the object its starting values.
- Makes sure the object is ready to use as soon as it's created.
- Avoids ending up with "empty" objects that have no meaningful data.
Every Python module has a built-in __name__ variable.
- If you run the file directly, Python sets
__name__to"__main__". - If the file is imported into another module,
__name__is set to that file's module name instead.
if __name__ == "__main__":
main() # only runs when this file is executed directly, not when imported- A class attribute belongs to the class itself, and its value is shared across every instance — unless a specific instance overrides it.
- An instance attribute belongs to one specific object and is typically defined inside
__init__().
class Dog:
species = "Canis familiaris" # class attribute — shared by every Dog
def __init__(self, name):
self.name = name # instance attribute — unique per Dog| Attribute | Meaning |
|---|---|
__dict__ |
Dictionary containing the class's (or instance's) namespace |
__doc__ |
The class's docstring, or None if undefined |
__name__ |
The class's name |
__module__ |
The name of the module the class was defined in ("__main__" in interactive mode) |
__bases__ |
A (possibly empty) tuple of the class's base classes, in declaration order |
Encapsulation means keeping data and the code that operates on it bundled together, and protecting that data from misuse — typically by making attributes private and controlling access through getters/setters or @property.
| Access level | Convention | Accessible from |
|---|---|---|
| Public | name |
Anywhere, inside or outside the class |
| Protected | _name (single underscore) |
The class and its subclasses (a convention, not enforced) |
| Private | __name (double underscore) |
Only within the defining class |
Analogy: a public variable is a public place anyone can enter; a protected variable is like a family home accessible to relatives (subclasses) but not outsiders; a private variable is a vault only its owner (the class itself) can open.
You can still technically reach a "private" attribute from outside via name mangling.
Python automatically renames any class attribute that starts with a double underscore (__name) to include the class name — __name becomes _ClassName__name. This avoids accidental name collisions, especially in subclasses.
class Account:
def __init__(self, balance):
self.__balance = balance # actually stored as _Account__balance
a = Account(100)
print(a._Account__balance) # 100 — works, but defeats the purpose of privacyAn OOP mechanism where a class (the subclass) derives from another class (the superclass), reusing its behavior and state, while guaranteeing the subclass can be used anywhere the base class is expected (the Liskov substitution principle) without breaking correctness.
class Animal:
def __init__(self, name):
self.name = name
def speak(self):
return f"{self.name} makes a sound"
class Dog(Animal):
def speak(self):
base = super().speak() # call the parent's implementation
return f"{base}: Woof!"super() gives you access to the parent class's methods and attributes from inside a child class — most commonly to extend, rather than fully replace, the parent's behavior.
A do-nothing statement. It tells Python: "There must be a statement here, but I intentionally want nothing to happen." Commonly used as a placeholder for a class, function, or loop body you haven't implemented yet.
class NotImplementedYet:
passThe scope of a variable is the region of code where that variable can be accessed. Python stores objects and their name bindings inside the namespace of the relevant scope.
| Scope | Where it's accessible |
|---|---|
| Built-in | Everywhere — names like print, len, Exception provided by Python itself |
| Global (module) | Anywhere within the module it's defined in |
| Enclosing | Inside nested functions, referring to the enclosing function's scope |
| Local | Only inside the function it's defined in |
This is Python's LEGB rule: when resolving a name, Python looks in Local → Enclosing → Global → Built-in scope, in that order, stopping at the first match — Python scopes are nested.
x = "global"
def outer():
x = "enclosing"
def inner():
x = "local"
print(x) # "local"
inner()
print(x) # "enclosing"
outer()
print(x) # "global"Use the global keyword when you want to modify a global variable from inside a function (reading it doesn't require this — only reassignment does):
counter = 0
def increment():
global counter
counter += 1Literally "many forms." In programming, it means a single interface (e.g. a method name) can operate on objects of many different types, each providing its own specific behavior.
class Circle:
def area(self):
return "π × r²"
class Square:
def area(self):
return "side²"
for shape in [Circle(), Square()]:
print(shape.area()) # same call, different behavior per typeA class that cannot be instantiated directly — it exists purely as a template that defines a required interface for its subclasses.
- It forces every concrete subclass to implement certain methods.
- In Python, this is built with the
abcmodule.
from abc import ABC, abstractmethod
class Shape(ABC):
@abstractmethod
def area(self):
...
class Circle(Shape):
def __init__(self, radius):
self.radius = radius
def area(self):
return 3.14159 * self.radius ** 2
# Shape() -> TypeError: Can't instantiate abstract class
circle = Circle(5) # OK — Circle implements area()📚 More on polymorphism: cs.sjsu.edu — polymorphism
A protocol defines an interface based purely on what methods/attributes an object has, not on what class it inherits from — this is Python's flavor of "duck typing," formalized with the typing.Protocol class (introduced in Python 3.8).
from typing import Protocol
class Speaker(Protocol):
def speak(self) -> str: ...
def announce(entity: Speaker):
print(entity.speak())
class Dog:
def speak(self):
return "Woof!"
announce(Dog()) # works — Dog satisfies the Speaker protocol
# without ever inheriting from it explicitlyProtocol vs. Abstract Class: an abstract class requires explicit inheritance (
class Circle(Shape)); a Protocol only requires the object to have the right methods — no inheritance relationship needed at all. This is "if it walks like a duck and quacks like a duck, it's a duck."
A virtual environment is a separate, self-contained Python workspace with its own interpreter and its own installed packages, isolated from your system Python and other projects.
# Create a virtual environment
python -m venv matrix_env
# Activate it (Linux/macOS)
source matrix_env/bin/activate
# Activate it (Windows)
matrix_env\Scripts\activateOnce activated, you can check whether you're actually inside the venv:
import sys
if sys.base_prefix != sys.prefix:
print("You are in a virtual environment")sys.base_prefix points to the "real" system Python installation, while sys.prefix points to wherever the currently running interpreter lives — inside a venv, these differ.
Sensitive data (API keys, database passwords, secrets) should never be hard-coded directly into your source. Instead, store them in a .env file (excluded from version control) and load them at runtime.
from dotenv import load_dotenv
import os
load_dotenv() # reads .env into the process environment
api_key = os.getenv("API_KEY") # safely read the variable
if os.path.exists(".env"):
print(".env file found").toml (Tom's Obvious, Minimal Language) is a configuration-file format built specifically to store structured data in a human-readable way. Python projects commonly use pyproject.toml as the standard place to declare project metadata, dependencies, and build settings.
Poetry is a dependency-management and packaging tool that reads and drives everything from pyproject.toml. It:
- Creates a virtual environment for your project automatically.
- Installs and locks dependencies (via
poetry.lock, for reproducible installs). - Manages the whole project lifecycle: building, publishing, versioning.
poetry install # create venv + install dependencies
poetry add requests # add a new dependency
poetry env info # show info about the active environmentBy default, Poetry creates its virtual environments in:
~/.cache/pypoetry/virtualenvs/
Pydantic is a library for data validation using Python type hints — extremely popular in APIs (especially with FastAPI), config loading, and any place where you need to guarantee incoming data has the right shape.
Declares that data flowing into your model must conform to a defined schema — Pydantic validates and coerces types automatically at object-creation time, raising a clear ValidationError if the data doesn't fit.
from pydantic import BaseModel, Field
class User(BaseModel):
name: str = Field(min_length=2, max_length=50)
age: int = Field(gt=0, le=120)
email: strA function used to define validation rules, default values, and metadata for a BaseModel attribute.
| Applies to | Common constraints |
|---|---|
str |
min_length, max_length, pattern, strip_whitespace, to_lower, to_upper |
int, float |
gt (>), ge (>=), lt (<), le (<=), multiple_of |
bool |
default, description, alias |
list |
min_length, max_length |
A model validator is custom logic that checks or transforms the entire model's data (not just one field) to ensure it's consistent as a whole (e.g. "end_date must be after start_date").
from pydantic import BaseModel, model_validator
class DateRange(BaseModel):
start: str
end: str
@model_validator(mode="before") # runs before field validation, raw dict input
def check_raw(cls, data):
return data
@model_validator(mode="after") # runs after field validation, on the final instance
def check_order(self):
if self.end < self.start:
raise ValueError("end must be after start")
return self| Mode | Runs | Receives |
|---|---|---|
"before" |
Before field-level validation | Raw input (usually a dict) |
"after" |
After field-level validation | The final, validated model instance |
Pydantic validates Python data against a defined schema using BaseModel.
Define fields with field: type.
model_validate(data) validates a Python dictionary/object.
model_validate_json(json) validates JSON text directly.
Missing required fields → ValidationError.
Nested models validate nested structures such as list[Level].
extra="forbid" rejects unknown fields.
Catch errors with except ValidationError as e.
e.errors() gives structured errors: loc → where the error is msg → what went wrong type → error type input → received value
Build clean messages with loc + msg.
Lets you define a variable that can only take one of a fixed, predefined set of values.
from enum import Enum
class Status(Enum):
ACTIVE = "active"
INACTIVE = "inactive"
BANNED = "banned"
class User(BaseModel):
status: StatusA lambda is a small, anonymous, single-expression function — used for short operations where defining a full def would be overkill.
add = lambda x, y: x + y
add(3, 4) # 7
(lambda x: x + 1)(5) # 6, called immediately
list(map(lambda x: x * 2, [1, 2, 3])) # [2, 4, 6]- A lambda can take any number of arguments, but its body must be a single expression — no statements, no multiple lines, and it implicitly returns that expression's value.
- Common partners:
map(),filter(),sorted(..., key=...).
words = ["banana", "kiwi", "apple"]
sorted(words, key=lambda w: len(w)) # ['kiwi', 'apple', 'banana']A callable is anything in Python you can invoke with parentheses () — this includes ordinary functions, lambdas, classes (calling one constructs an instance), and any object implementing __call__.
class Multiplier:
def __init__(self, factor):
self.factor = factor
def __call__(self, x):
return x * self.factor
double = Multiplier(2)
double(5) # 10 — the instance itself is callabledef demo(*args, **kwargs):
print(args) # tuple of all extra positional arguments
print(kwargs) # dict of all extra keyword arguments
demo(1, 2, 3, name="Alice", age=30)
# (1, 2, 3)
# {'name': 'Alice', 'age': 30}*argscollects any number of extra positional arguments into a tuple.**kwargscollects any number of extra keyword arguments into a dict.
Lets a nested function modify a variable that lives in its enclosing (outer) function's scope — not global, specifically the immediately enclosing local scope.
def outer():
count = 0
def inner():
nonlocal count
count += 1
inner()
inner()
return count
outer() # 2Rules:
nonlocalonly works if the variable already exists in an outer (enclosing) function — it can't create a new one.- It does not work for truly global variables (use
globalfor that instead). - It must be declared before the variable is used/assigned inside the nested function.
A first-class citizen (or first-class object) is an entity treated like any other value in the language: it can be stored in a variable, passed as an argument, returned from a function, and stored in data structures — without restriction.
Most languages treat basic types (integers, strings) as first-class, but Python goes further: functions and classes are first-class citizens too.
def greet():
return "hello"
def call_it(fn): # a function passed as an argument
return fn()
call_it(greet) # 'hello' — functions can be passed around like any value
functions_list = [greet, str.upper, len] # functions stored in a listA decorator is a function that modifies or extends the behavior of another function without changing its source code. Applied with the @ symbol.
from functools import wraps
def logger(func):
@wraps(func) # preserves func.__name__ and func.__doc__
def wrapper(*args, **kwargs):
print(f"Calling {func.__name__}")
result = func(*args, **kwargs)
print(f"{func.__name__} returned {result}")
return result
return wrapper
@logger
def add(a, b):
return a + b
add(2, 3)
# Calling add
# add returned 5functools.wraps is essential — without it, wrapper would shadow the original function's __name__ and __doc__, breaking introspection and documentation tools.
| Method type | Decorator | First parameter | Operates on |
|---|---|---|---|
| Instance method | (none) | self |
A specific object's state |
| Class method | @classmethod |
cls |
The class itself (e.g. alternate constructors) |
| Static method | @staticmethod |
(none) | Nothing — a plain utility function namespaced inside the class |
class Pizza:
def __init__(self, toppings):
self.toppings = toppings # instance state
def describe(self): # instance method
return f"Pizza with {self.toppings}"
@classmethod
def margherita(cls): # class method — alternate constructor
return cls(["tomato", "mozzarella"])
@staticmethod
def is_valid_topping(topping): # static method — independent utility
return topping in {"tomato", "mozzarella", "basil", "pepperoni"}Named __something__, these let your objects hook into Python's built-in syntax and behavior.
| Method | Triggered by |
|---|---|
__init__ |
Object creation |
__str__ |
str(obj) / print(obj) — a human-readable representation |
__repr__ |
repr(obj) — an unambiguous, developer-facing representation |
__len__ |
len(obj) |
__add__ |
obj + other |
__eq__ |
obj == other |
__lt__ |
obj < other |
__call__ |
obj(...) — makes an instance callable |
__getitem__ |
obj[key] |
__iter__ / __next__ |
Iterating with for or next() |
class Vector:
def __init__(self, x, y):
self.x, self.y = x, y
def __add__(self, other):
return Vector(self.x + other.x, self.y + other.y)
def __eq__(self, other):
return (self.x, self.y) == (other.x, other.y)
def __repr__(self):
return f"Vector({self.x}, {self.y})"
Vector(1, 2) + Vector(3, 4) # Vector(4, 6)Turns a method into something accessed like a plain attribute — enabling controlled, validated reads/writes without changing the calling syntax.
class Circle:
def __init__(self, radius):
self._radius = radius
@property
def radius(self): # getter — accessed as obj.radius
return self._radius
@radius.setter
def radius(self, value): # setter — obj.radius = value
if value <= 0:
raise ValueError("Radius must be positive")
self._radius = value
@radius.deleter
def radius(self): # deleter — del obj.radius
del self._radiusDeclared with @abstractmethod inside a class deriving from ABC (see §16) — they define an interface/requirement that every concrete subclass must implement, or Python raises a TypeError at instantiation time.
A decorator that auto-generates the boring boilerplate of a class — __init__, __repr__, __eq__ — just from type-annotated attributes.
from dataclasses import dataclass, field
@dataclass
class Point:
x: int
y: int
tags: list = field(default_factory=list) # safe way to default a mutable value
p1 = Point(1, 2)
p2 = Point(1, 2)
p1 == p2 # True — __eq__ is generated automatically
print(p1) # Point(x=1, y=2, tags=[])field(default_factory=list)avoids the classic "mutable default argument" bug — each instance gets its own fresh list.- Add
frozen=Trueto make instances immutable, like a tuple with names.
Type hints don't change runtime behavior — Python doesn't enforce them — but they power editor autocomplete, static checkers (mypy), and self-documenting code.
from typing import Optional, Union, Literal
def find_user(user_id: int) -> Optional[str]: # str or None
...
def parse(value: Union[int, str]) -> int: # int or str in, int out
...
def set_mode(mode: Literal["read", "write"]) -> None: # only these exact values
...
# 3.10+ shorthand — no need to import Optional/Union anymore
def find_user2(user_id: int) -> str | None:
...Generics let you write a class or function that works with any type, while still being type-checkable:
from typing import TypeVar, Generic
T = TypeVar("T")
class Box(Generic[T]):
def __init__(self, item: T):
self.item = item
int_box: Box[int] = Box(5)A class defines how objects behave. A metaclass defines how classes themselves behave — it's "a class whose instances are classes." By default, every class's metaclass is type.
This introduction to python 3.10+ for beginners has been created by FALAMLIH
the sys module provides a variable called sys.argv. It's main purpose are:
It is a list of command-line arguments.
len(sys.argv) provides the number of command-line arguments.
sys is a built-in Python module that gives your program direct access to the Python runtime and the operating system interface
The __init.py file in Python serves two primary purposes: it marks a directory as a regular Python package, and it provides a place for initialization code when that package is importe
generator is a function that acts as an iterator to produce a sequence of values one at a time, and only when requested. They are memory efficient because they don't store the entire sequence in memory at once; instead, they pause their execution with the yield keyword and resume when the next value is needed.
comprehension:
open:Python's built-in open() function is used to open files in various modes, such as reading, writing, and appending
file = open("filename", "mode")
Where, filename is the name of the file to open and mode is the mode in which the file is opened (e.g., 'r' for reading, 'w' for writing, 'a' for appending)
r:
Opens a file for reading only. The file pointer is placed at the beginning of the file. This is the default mode of open
w:
Opens a file for writing only. Overwrites the file if the file exists. If the file does not exist, creates a new file for writing
CONTEXT MANAGERS
WITH STATEMENT
RAII
is defined as a programming model that uses the concept of objects which refers to real-world entities with state and behavior
Class → blueprint
Object → real thing
init → setup function
self → the object itself
main (if name == "main") : Every Python file has a built-in variable called name. If you run the file directly, Python sets name to "main". If the file is imported into another file, name is set to the filename instead.
Class attributes are those variables that belong to a class and whose value is shared among all the instances of that class
Built-In Class Attributes:
dict − Dictionary containing the class's namespace.
doc − Class documentation string or none, if undefined.
name − Class name.
module − Module name in which the class is defined. This attribute is "main" in interactive mode.
bases − A possibly empty tuple containing the base classes, in the order of their occurrence in the base class list.
instance attribute in Python is a variable that is specific to an individual object of a class. It is defined inside the init() method.
init is a special function inside a class (called constructor) p = Plant("Rose", 10, 3) Python automatically calls: Plant.init(p, "Rose", 10, 3)
We need init to:
Give the object starting values
Make sure the object is ready to use
Avoid creating empty objects with no data
ft_command_quest.py
Encapsulation = keeping data and the code that uses it together, and protecting it from misuse, it hide internal data & control how it is accessed or modified by using private attributes, setters and gtters
Public Variables: Public variables are accessible from anywhere, both inside and outside the class. In the image, a public place is considered as an analogy for public variables as they can be accessed by anyone.
class Meta(type):
def __new__(mcs, name, bases, namespace):
print(f"Creating class {name}")
return super().__new__(mcs, name, bases, namespace)
class MyClass(metaclass=Meta):
pass
# prints: Creating class MyClassMetaclasses are rare in everyday code — they're what libraries like Django (models) and Pydantic use internally to auto-register fields when a class is defined, not when it's instantiated. __new__ builds the object before __init__ initializes it; you almost never need to override __new__ outside of metaclasses or immutable-type subclassing.
The mechanism that actually powers @property, methods, and __slots__ under the hood. A descriptor is any object implementing __get__, __set__, or __delete__, placed as a class attribute.
class PositiveNumber:
def __set_name__(self, owner, name):
self.name = "_" + name
def __get__(self, obj, objtype=None):
return getattr(obj, self.name)
def __set__(self, obj, value):
if value <= 0:
raise ValueError(f"{self.name} must be positive")
setattr(obj, self.name, value)
class Product:
price = PositiveNumber() # descriptor — validates every assignment
p = Product()
p.price = 10 # OK
p.price = -5 # raises ValueError@property is really just a convenient, built-in descriptor.
By default, every instance gets a __dict__ to hold its attributes, which is flexible but memory-hungry when you have millions of small objects. __slots__ declares a fixed set of attributes up front, skipping the per-instance __dict__ entirely.
class Point:
__slots__ = ("x", "y") # no __dict__, no arbitrary new attributes
def __init__(self, x, y):
self.x = x
self.y = y
p = Point(1, 2)
p.z = 3 # AttributeError — 'z' isn't a declared slot| Tool | What it does |
|---|---|
@lru_cache / @cache |
Memoizes a function's return values — repeated calls with the same arguments skip recomputation |
partial |
"Pre-fills" some arguments of a function, returning a new callable |
reduce |
Cumulatively applies a function across an iterable, reducing it to one value |
@singledispatch |
Lets a function behave differently depending on the type of its first argument |
from functools import lru_cache, partial, reduce, singledispatch
@lru_cache(maxsize=None)
def fib(n):
return n if n < 2 else fib(n - 1) + fib(n - 2)
double = partial(lambda x, y: x * y, 2) # y=2 pre-filled
double(5) # 10
reduce(lambda acc, x: acc + x, [1, 2, 3, 4]) # 10
@singledispatch
def describe(value):
return f"a value: {value}"
@describe.register
def _(value: int):
return f"an integer: {value}"
describe(5) # 'an integer: 5'
describe("hi") # 'a value: hi'| Tool | What it does |
|---|---|
chain(a, b) |
Iterates over several iterables as if they were one |
groupby(iterable, key) |
Groups consecutive items sharing a key (input must already be sorted by that key) |
product(a, b) |
Cartesian product — every combination of elements |
islice(iterable, n) |
Lazily slices an iterator without loading it all into memory |
count(), cycle(), repeat() |
Infinite iterators — pair with islice or break to stop them |
from itertools import chain, product, islice
list(chain([1, 2], [3, 4])) # [1, 2, 3, 4]
list(product([1, 2], ["a"])) # [(1, 'a'), (2, 'a')]
list(islice(range(1_000_000), 5)) # [0, 1, 2, 3, 4] — never builds the full rangeConcurrency for I/O-bound work (network calls, file access, waiting on other systems) — a single thread cooperatively switches between tasks whenever one is waiting, instead of sitting idle.
import asyncio
async def fetch(name, delay):
await asyncio.sleep(delay) # simulates an I/O wait, doesn't block the whole program
return f"{name} done"
async def main():
results = await asyncio.gather(
fetch("A", 2),
fetch("B", 1),
)
print(results)
asyncio.run(main()) # both run "at once" — total time ≈ 2s, not 3sasync defdefines a coroutine function — calling it returns a coroutine object, it doesn't run yet.awaitpauses the current coroutine until the awaited thing finishes, letting other coroutines run meanwhile.asyncio.gather()runs several coroutines concurrently and waits for all of them.
The GIL (Global Interpreter Lock) allows only one thread to execute Python bytecode at a time, even on a multi-core machine.
| Approach | Good for | Why |
|---|---|---|
threading |
I/O-bound work | Threads release the GIL while waiting on I/O, so they still overlap usefully |
multiprocessing |
CPU-bound work | Each process gets its own interpreter and GIL, giving true parallelism across cores |
asyncio |
I/O-bound work, many concurrent tasks | Single thread, no GIL contention at all, very lightweight |
from multiprocessing import Pool
def square(x):
return x * x
with Pool(4) as pool:
print(pool.map(square, [1, 2, 3, 4])) # runs across 4 real processesRule of thumb: I/O-bound →
asyncioorthreading. CPU-bound →multiprocessing. Threading won't speed up pure number-crunching because the GIL still serializes bytecode execution.
A weak reference points to an object without increasing its reference count — so the object can still be garbage-collected even while the weak reference exists. Useful for caches or observer patterns where you don't want to keep something alive just by referencing it.
import weakref
class Big:
pass
obj = Big()
ref = weakref.ref(obj)
print(ref()) # <Big object> — still alive
del obj
print(ref()) # None — it's been garbage-collectedraise ... from ... preserves the original exception as context when you raise a new one — crucial for honest debugging instead of hiding the root cause.
try:
1 / 0
except ZeroDivisionError as e:
raise RuntimeError("Calculation failed") from e
# Traceback shows both: the ZeroDivisionError *and* the RuntimeErrorException groups (except*, Python 3.11+) let you raise and handle multiple unrelated exceptions at once — common in concurrent code where several tasks can fail independently.
try:
raise ExceptionGroup("multiple failures", [ValueError("bad value"), TypeError("bad type")])
except* ValueError as eg:
print("Handled ValueErrors:", eg.exceptions)
except* TypeError as eg:
print("Handled TypeErrors:", eg.exceptions)When a class inherits from more than one parent, Python needs a deterministic order to search for methods — the MRO (Method Resolution Order), computed with the C3 linearization algorithm.
class A:
def greet(self): return "A"
class B(A):
def greet(self): return "B"
class C(A):
def greet(self): return "C"
class D(B, C):
pass
D().greet() # 'B' — B comes before C in the MRO
print(D.__mro__) # (D, B, C, A, object) — the exact search orderThis is how Python resolves the classic diamond problem (D inheriting from both B and C, which both inherit from A) predictably, instead of ambiguously.
- Function arguments in depth — tutorialspoint.com/python/python_functions.htm
- Operators reference — w3schools.com/python/python_operators.asp
- Polymorphism — cs.sjsu.edu — polymorphism
- Advanced Python topics — pythontutorial.net/advanced-python
- Official Python docs — docs.python.org/3