Theory
To run Python, you need the Interpreter. To expand Python's capabilities, you need a Package Manager. pip is the standard, but uv is a modern, high-performance tool written in Rust that is 10-100x faster and manages virtual environments automatically.
Code Snippets
Bash
# 1. Installing a package with pip
pip install requests
# 2. Installing uv (The modern way)
curl -LsSf https://astral.sh/uv/install.sh | sh
# 3. Using uv to create a project and add a library instantly
uv init my_project
uv add requests
uv run main.py
Think About It: If uv is so much faster, why do we still learn pip?
Answer: pip is the "legacy" standard included with every Python installation; you will encounter it in almost every existing server and documentation.
Common Mistake:
Forgetting to check "Add to PATH"
During installation, many users skip the "Add to PATH" checkbox.
Result: Typing python in the terminal returns Command not found.
Fix: Re-run the installer and select "Modify" to check that box.
Edge Case Scenario:
Multiple Python versions installed:
Theory: Users install Python but system still uses older version. python and pip may point to different installations.
Code Snippet:
python --version
pip --version
Output:
Python 3.10
pip 22.x from Python 3.8
Problem: Packages install in wrong Python version.
Fix:
python -m pip install requests
Theory
In Python, variables are labels for memory addresses. Immutable objects cannot be changed once created (integers, strings, tuples). Mutable objects (lists, dicts, sets) can be changed "in-place" without creating a new object.
Code Snippets
Python
# IMMUTABLE: Changing 'a' creates a brand new object
a = "Hello"
print(id(a)) # Memory Address 1
a += " World"
print(id(a)) # Memory Address 2 (New object created)
# MUTABLE: Changing 'b' modifies the same object
b = [1, 2]
print(id(b)) # Memory Address A
b.append(3)
print(id(b)) # Memory Address A (Same object modified)
Think About It: Why does this matter for function arguments?
Answer: If you pass a list to a function and modify it inside, it changes for the caller too. If you pass a string, the original remains safe.
Common Mistake:
The "Mutable Default Argument" Trap:
One of the most famous Python bugs. If you use a list as a default argument, Python creates that list once at definition time, not every time the function is called.
Code Snippets
Python
# ❌ WRONG
def add_item(item, my_list=[]):
my_list.append(item)
return my_list
print(add_item(1)) # [1]
print(add_item(2)) # [1, 2] <- Wait, why is 1 still there?
# ✅ RIGHT
def add_item(item, my_list=None):
if my_list is None:
my_list = []
my_list.append(item)
return my_list
Edge Case Scenario:
The "Tuples are Immutable... or are they?"
Theory: We know that Tuples cannot be changed. However, if a Tuple contains a Mutable object (like a List), the list inside can still be modified. This is a common source of bugs in data processing.
Code Snippet:
Python
# The Tuple itself is immutable, but its contents might not be!
my_tuple = (1, 2, [3, 4])
try:
my_tuple[0] = 99 #This will crash (TypeError)
except TypeError:
print("Can't change the integer!")
my_tuple[2].append(5) #This WORKS!
print(my_tuple) # Output: (1, 2, [3, 4, 5])
Think About It: Did we change the Tuple?
Answer: Technically, no. The Tuple still points to the same memory address for that list. The content of the list changed, but the "pointer" inside the Tuple stayed the same.
Theory
To save memory, Python "recycles" objects. It pre-allocates memory for small integers (-5 to 256) and "interns" short strings so that multiple variables can point to the same memory slot if their values match.
Code Snippets
Python
# Integer Caching Example
x = 10
y = 10
print(x is y) # True (Point to the same pre-cached object)
# Large integers are NOT cached
large_1 = 500
large_2 = 500
print(large_1 is large_2) # False (Two separate objects in memory)
# String Interning
s1 = "python"
s2 = "python"
print(s1 is s2) # True (Python reused the same string object)
Think About It: If Python only caches small integers ($-5$ to $256$) and specific "identifier-like" strings, what is the risk of using is for general value comparisons in your code?
Answer: It creates "flaky" code that works during small-scale testing but fails in production. For example, a loop comparing x is 200 will return True, but x is 300 will return False because $300$ is outside the cache range and exists as two separate objects in memory. Always use == for comparing values to ensure your logic remains consistent regardless of how Python manages memory behind the scenes.
Common Mistake:
String Interning Assumptions:
Developers often assume that because "apple" is "apple" is True, all identical strings will share the same memory address. However, Python typically only interns "clean" strings (letters, numbers, underscores). If a string is created dynamically or contains special characters, is will fail even if the text matches.
Edge Case Scenario:
The "Interning" Limit (Peep-Hole Optimization):
Theory: Python only does this automatically for strings that look like "identifiers" (letters, numbers, underscores). Strings with spaces or special characters are often NOT interned.
Code Snippet:
Python
a = "hello_world"
b = "hello_world"
print(a is b) # True (Automatically interned)
c = "hello world!"
d = "hello world!"
print(c is d) # False (Contains spaces/punctuation, not interned by default)
Theory
== checks for Equality (Do the values look the same?).
is checks for Identity (Are they the exact same physical object in memory?).
Code Snippets
Python
list_1 = [1, 2, 3]
list_2 = [1, 2, 3]
list_3 = list_1
print(list_1 == list_2) # True (Same content)
print(list_1 is list_2) # False (Different containers in memory)
print(list_1 is list_3) # True (list_3 is just an alias for list_1)
Think About It: What is the best way to check if a variable is empty/None?
Answer: Always use if x is None:. It is faster and safer because there is only one None object in Python’s memory.
Common Mistake:
Using is instead of == for value comparison
Developers use is thinking it compares values, but it compares memory location, not value.
Rule of Thumb: Use == for almost everything. Only use is when comparing to None, True, or False.
Python
# Value comes from DB/API
x = int("1000")
# Wrong: compares memory, not value
if x is 1000:
print("Match")
else:
print("No match") # unexpected output
# Correct: compares value
if x == 1000:
print("Match") # correct output
# Correct use of "is"
result = None
if result is None:
print("No result")
Edge Case Scenario:
The "None Comparison" Trap:
Theory: Developers sometimes use == None instead of is None. This can cause unexpected behaviour because == checks value equality and can be overridden, while is safely checks identity.
Code Snippet:
# Custom object that tricks ==
class AlwaysEqual:
def __eq__(self, other):
return True
obj = AlwaysEqual()
# Dangerous comparison
if obj == None:
print("obj is None (WRONG)") # This runs!
# Correct comparison
if obj is None:
print("obj is None (CORRECT)")
else:
print("obj is NOT None") # Correct result
Think About It: == can be overridden by objects, but is always checks true identity. This is why is None is the safe and recommended check.
Theory
Python doesn't enforce types, but Type Hints make code readable and help tools find bugs. mypy is a "static type checker"—it scans your code for type errors without actually running the program.
Code Snippets
Python
# 1. Defining hints for variables and functions
age: int = 25
name: str = "Binary Bridge"
def add_numbers(a: int, b: int) -> int:
return a + b
# 2. To check for errors, run this in your terminal:
# mypy my_script.py
Think About It: Will Python crash if I write name: str = 100?
Answer: No. Python ignores hints at runtime. Only a tool like mypy or an IDE like VS Code will flag this as an error.
Common Mistakes:
Ignoring mypy errors users often treat Type Hints like comments—ignoring the errors mypy generates.
If mypy says there is a type mismatch, it means your logic is likely flawed. Don't just ignore it; fix the underlying type conflict!
Edge Case Scenario:
Type hints do NOT enforce types at runtime:
Theory: Type hints help tools like mypy, but Python itself does not enforce them.
Code Snippet:
def greet(name: str) -> str:
return "Hello " + name
print(greet(123)) # No crash, but wrong usage
Output:
Hello 123
Think About It: Python runs it, but mypy would catch the error. Type hints improve safety, but only if checked.
Theory
argparse turns your script into a professional Command Line Interface (CLI) tool. It allows users to pass data into the script via the terminal using flags (like --name).
Code Snippets
Python
import argparse
# 1. Setup the Parser
parser = argparse.ArgumentParser(description="Classroom Greeter")
# 2. Add Arguments
parser.add_argument("--user", help="Name of the student", default="Student")
parser.add_argument("--age", type=int, help="Age of the student")
# 3. Parse and Use
args = parser.parse_args()
print(f"Hello {args.user}, you are {args.age} years old.")
Think About It: What happens if the user types python script.py --help?
Answer: argparse automatically displays a manual showing all the arguments you defined and their descriptions.
Common Mistakes:
The "String-Only" Argparse Trap:
By default, argparse treats every input as a string. If you are building a tool that performs math or logic on numbers but forget to specify type=int, Python will either throw an error or perform "string math" (concatenation) instead of addition.
Edge Case Scenario:
The "Missing Argument" Crash:
Theory: If a required argument is not provided, the program will crash unless handled properly. Users often forget to mark arguments as required.
Code Snippet:
import argparse
parser = argparse.ArgumentParser()
parser.add_argument("--name", required=True)
args = parser.parse_args()
print("Hello", args.name)
If user runs without argument:
python script.py
Output:
error: the following arguments are required: --name
Think About It: Mark important inputs as required=True to prevent missing data and unexpected program behavior.