# Python Fundamentals

* * *

## Part 1: Variables and Memory

### How Python actually stores things

When you write `x = 5`, Python does two things: it creates an integer object `5` somewhere in memory, and it creates a name `x` that points at it. If you then write `y = x`, Python does **not** copy the number — it just makes `y` point at the same object `x` already points at.

```python
a = [1, 2, 3]
b = a              # b now points at the SAME list object as a

print(id(a))       # some memory address, e.g. 140234099283776
print(id(b))       # the exact same address
print(a is b)      # True — they are literally the same object
```

Now watch what happens if you change the list through one name:

```python
b.append(4)
print(a)            # [1, 2, 3, 4]
print(b)            # [1, 2, 3, 4]
```

Both changed, because there was only ever **one list** — `a` and `b` are just two labels on it.

Compare this to making an actual copy:

```python
c = a.copy()         # creates a genuinely NEW list with the same contents
c.append(5)
print(a)              # [1, 2, 3, 4]     — unaffected
print(c)              # [1, 2, 3, 4, 5]
print(a is c)          # False — different objects now
```

`==` and `is` ask different questions:

*   `a == c` → "do these look the same (same contents)?"
    
*   `a is c` → "are these literally the same object in memory?"
    

### Mutable vs Immutable — the most important distinction in the language

**Immutable** (cannot be changed after creation — any "change" makes a brand new object): `int`, `float`, `str`, `bool`, `tuple`

**Mutable** (can be changed in place, without creating a new object): `list`, `dict`, `set`, and custom class instances by default

Strings feel like they should be changeable, but they are not:

```python
s = "hello"
s[0] = "H"          # TypeError: 'str' object does not support item assignment
```

Any string "modification" actually builds a new string and reassigns the name to it:

```python
s = "hello"
s = s.upper()        # a NEW string "HELLO" is created; s now points to it
                       # the original "hello" string still exists in memory
                       # briefly, until nothing points to it and it's cleaned up
```

### Why this matters for function arguments

```python
def try_change_string(text):
    text = text.upper()     # this just repoints the LOCAL name "text"
    return text

original = "hello"
result = try_change_string(original)
print(original)    # "hello"  — the caller's variable never changed
print(result)       # "HELLO" — you have to explicitly return the new value
```

```python
def try_change_list(items):
    items.append("new")     # this MUTATES the actual list object in place

original = ["a", "b"]
try_change_list(original)
print(original)    # ["a", "b", "new"]  — changed, with no return needed!
```

The rule: **reassigning a parameter inside a function never affects the caller's variable. Mutating a mutable object that was passed in does affect the caller, because there's only one object and both the caller and the function are looking at it.**

### The classic trap this causes: mutable default arguments

```python
def add_item(item, my_list=[]):    # DANGEROUS
    my_list.append(item)
    return my_list

print(add_item(1))    # [1]
print(add_item(2))    # [1, 2]   <-- surprising! you'd expect [2]
```

Why: the empty list `[]` in the function signature is created **exactly once**, at the moment the function is *defined* — not each time it's called. Every call that doesn't supply its own list reuses that same original list object, and it keeps growing across calls.

The fix — use `None` as a sentinel, and create a fresh list inside the function body each call:

```python
def add_item(item, my_list=None):
    if my_list is None:
        my_list = []          # fresh list, created fresh on THIS call
    my_list.append(item)
    return my_list
```

### Nested mutability — shallow vs deep copies

```python
matrix = [[0, 0], [0, 0]]
shallow = matrix.copy()
shallow[0][0] = 99
print(matrix)     # [[99, 0], [0, 0]]  <- original changed too!
```

`.copy()` only copies the *outer* list. The inner lists are still the same shared objects. To fully separate everything, nested levels included:

```python
import copy
deep = copy.deepcopy(matrix)
deep[0][0] = 55
print(matrix)      # unaffected this time
```

Dictionaries behave identically:

```python
config = {"debug": True, "settings": {"retries": 3}}
shallow_copy = config.copy()

shallow_copy["debug"] = False
print(config["debug"])                      # True — outer level is independent

shallow_copy["settings"]["retries"] = 99
print(config["settings"]["retries"])        # 99 — inner dict is still SHARED
```

* * *

## Part 2: Functions

### Positional and keyword arguments

```python
def describe(name, age, city="Unknown"):
    print(f"{name}, {age}, from {city}")

describe("Alice", 30)                       # positional
describe(name="Alice", age=30)              # keyword — order irrelevant
describe(age=30, name="Alice")              # same result as above
describe("Alice", city="NYC", age=30)       # mixed — positional args must come first
```

You can force some parameters to only be passable by keyword, using a bare `*`:

```python
def create_user(name, *, email, is_admin=False):
    print(name, email, is_admin)

create_user("Bob", email="bob@example.com")   # fine
create_user("Bob", "bob@example.com")           # TypeError — email must be named
```

This prevents accidental mix-ups when a function has several parameters of the same type.

### Variable-length arguments: `*args` and `**kwargs`

```python
def show_args(*args, **kwargs):
    print("positional extras:", args)
    print("keyword extras:", kwargs)

show_args(1, 2, 3, name="Alice", age=30)
# positional extras: (1, 2, 3)                <- collected into a tuple
# keyword extras: {'name': 'Alice', 'age': 30}  <- collected into a dict
```

The reverse — unpacking a collection into separate arguments:

```python
def add(a, b, c):
    return a + b + c

nums = [1, 2, 3]
print(add(*nums))              # * unpacks the list -> add(1, 2, 3)

kwargs = {"a": 1, "b": 2, "c": 3}
print(add(**kwargs))            # ** unpacks the dict -> add(a=1, b=2, c=3)
```

### Scope — how Python decides which variable you mean

Python looks up names using the order: **Local → Enclosing → Global → Built-in** (often called the LEGB rule).

```python
x = "global"

def outer():
    x = "outer"
    def inner():
        print(x)      # not found locally, found in enclosing scope -> "outer"
    inner()
    print(x)           # "outer"

outer()
print(x)                # "global" — never touched
```

A common trap — if you assign to a name **anywhere** inside a function, Python treats that name as local for the *entire* function body, even on lines before the assignment:

```python
counter = 0

def broken_increment():
    counter = counter + 1
    # Python sees "counter = ..." in this function and decides
    # counter is LOCAL throughout the whole function.
    # So the "counter" being read on the right side is the
    # not-yet-assigned local one -> UnboundLocalError
```

Two ways to fix it:

```python
def fixed_with_global():
    global counter
    counter = counter + 1     # explicitly refers to the global variable

def fixed_with_return():
    return counter + 1         # cleaner — no mutation of outer state at all

counter = fixed_with_return()   # caller decides what to do with the new value
```

In practice, prefer the `return`\-based style. Reaching into outer scope with `global` makes code much harder to reason about as programs grow.

### Closures — functions that remember their environment

```python
def make_counter():
    count = 0
    def increment():
        nonlocal count       # like "global" but for the enclosing (not global) scope
        count += 1
        return count
    return increment

counter1 = make_counter()
counter2 = make_counter()

print(counter1())    # 1
print(counter1())    # 2
print(counter1())    # 3
print(counter2())    # 1  <- completely separate state from counter1
```

Each call to `make_counter()` creates a fresh `count`, and the returned `increment` function "closes over" (remembers) that specific `count` forever — even though `make_counter()` itself already finished running. This is how you get private, persistent state without writing a class.

* * *

## Part 3: Classes

### What `self` really is

```python
class Dog:
    def __init__(self, name):
        self.name = name

    def bark(self):
        return f"{self.name} says woof"

rex = Dog("Rex")
print(rex.bark())         # normal syntax
print(Dog.bark(rex))      # produces the EXACT same result
```

`rex.bark()` is just convenient syntax for `Dog.bark(rex)` — Python automatically passes the object before the dot as the first argument. `self` is simply the name given to that first parameter; there's no hidden magic.

### `__init__` initializes, it doesn't construct

The object already exists by the time `__init__` runs — `__init__` just fills in its starting attribute values.

```python
class Dog:
    species = "Canis familiaris"     # CLASS attribute — one copy, shared by all instances

    def __init__(self, name, age):
        self.name = name              # INSTANCE attribute — unique to each object
        self.age = age
```

Think of the class as a template stored once, and each instance as having its own private dictionary of overrides. Accessing `rex.species` checks `rex`'s own storage first; not finding it there, Python falls back to the class. Accessing `rex.name` finds it directly on the instance.

```python
rex = Dog("Rex", 3)
buddy = Dog("Buddy", 5)

Dog.species = "Canis lupus familiaris"    # change at the class level
print(rex.species)     # updated — both instances see the change, since neither has its own override

rex.species = "Special"                    # this creates an INSTANCE-level override on rex only
print(rex.species)      # "Special"
print(buddy.species)     # unaffected — still the class-level value
```

### The same mutable-default trap, in class form

```python
class ShoppingCart:
    items = []          # DANGER: this is a class attribute, shared by every instance

    def add(self, item):
        self.items.append(item)

cart1 = ShoppingCart()
cart2 = ShoppingCart()
cart1.add("apple")
print(cart2.items)      # ["apple"]  <- leaked in from cart1!
```

`self.items.append(...)` doesn't create a new instance attribute — it mutates the one shared list, because no instance-level `items` was ever separately created. Fix:

```python
class ShoppingCart:
    def __init__(self):
        self.items = []        # a fresh, independent list per instance

    def add(self, item):
        self.items.append(item)
```

### Dunder methods — how your objects plug into Python's built-in syntax

None of Python's operators are hardcoded to work on custom objects. `+`, `==`, `<`, `len()`, printing, truthiness in `if` — all of these work because your class defines the matching "dunder" (double underscore) method.

```python
class Money:
    def __init__(self, amount):
        self.amount = amount

    def __add__(self, other):        # enables: money1 + money2
        return Money(self.amount + other.amount)

    def __eq__(self, other):          # enables: money1 == money2
        return self.amount == other.amount

    def __lt__(self, other):          # enables: money1 < money2
        return self.amount < other.amount

    def __repr__(self):               # controls what print(money) shows
        return f"Money(${self.amount})"

    def __bool__(self):               # controls behavior in "if money:"
        return self.amount != 0

m1 = Money(10)
m2 = Money(20)
print(m1 + m2)            # Money($30)
print(m1 < m2)             # True
print(m1 == Money(10))     # True
if not Money(0):
    print("no money")       # this prints, since __bool__ returns False for Money(0)
```

### Three decorators you'll see constantly

```python
class Circle:
    def __init__(self, radius):
        self.radius = radius

    @property
    def area(self):                     # accessed like a plain attribute, no parentheses
        return 3.14159 * self.radius ** 2

    @classmethod
    def from_diameter(cls, diameter):   # alternate way to construct a Circle
        return cls(diameter / 2)

    @staticmethod
    def is_valid_radius(r):             # doesn't need self or cls — just grouped here logically
        return r > 0

c = Circle(5)
print(c.area)                       # 78.53975   -- notice: no parentheses used
c2 = Circle.from_diameter(10)
print(c2.radius)                     # 5.0
print(Circle.is_valid_radius(-1))    # False
```

*   `@property` — call a method without `()`, as if it were a plain attribute (useful for computed values)
    
*   `@classmethod` — receives the class itself (`cls`) instead of an instance; typically used for alternate constructors
    
*   `@staticmethod` — receives neither `self` nor `cls`; it's a plain function grouped inside the class for organization
    

### Inheritance and `super()`

```python
class Puppy(Dog):
    def __init__(self, name):
        super().__init__(name, age=0)   # runs Dog's __init__ first
        self.is_puppy = True
```

`super()` gives you a handle to the parent class, letting you build on top of its behavior instead of rewriting it from scratch.

* * *

## Part 4: Type Hints

Type hints look like they should enforce types, but by default, **plain Python ignores them completely at runtime**:

```python
def add(a: int, b: int) -> int:
    return a + b

print(add("hello", "world"))    # runs fine, prints "helloworld" — no error!
```

So what are they for?

*   Editor/IDE autocomplete and inline warnings
    
*   Static analysis tools (like `mypy`) that check your code *before* running it
    
*   Documentation that stays accurate, since it lives in the code itself
    

```python
from typing import Optional, List, Dict, Union

def get_name() -> Optional[str]:     # means: returns a str, OR returns None
    return None

def get_scores() -> List[int]:        # a list of ints
    return [90, 85, 77]

def get_user() -> Dict[str, str]:     # a dict with string keys and string values
    return {"name": "Alice"}

def process(value: Union[int, str]) -> str:   # accepts either type
    return str(value)

# Python 3.10+ shorthand:
def get_name_new() -> str | None:
    return None
```

Some tools **do** enforce type hints at runtime — most notably Pydantic, which reads them and validates incoming data against them:

```python
from pydantic import BaseModel
from typing import List, Optional

class Address(BaseModel):
    street: str
    city: str

class User(BaseModel):
    name: str
    age: int
    tags: List[str] = []
    address: Optional[Address] = None
```

Constructing a `User(age="not a number")` here would immediately raise a validation error — unlike plain Python functions, which silently let mismatched types through.

* * *

## Part 5: async/await

The core idea: **async is about not sitting idle while waiting, not about doing multiple things at the literal same instant.** Only one line of Python code ever executes at a time — there's no multi-core magic here. The win comes purely from not blocking during waits (like network requests or database calls).

### Synchronous version — everything waits in line

```python
import time

def fetch_user():
    time.sleep(2)          # simulates a 2-second network call — freezes the whole program
    return "user data"

def fetch_orders():
    time.sleep(2)
    return "orders data"

start = time.time()
fetch_user()
fetch_orders()
print(f"Took {time.time() - start:.1f}s")    # Took 4.0s
```

### Async version — waits overlap

```python
import asyncio
import time

async def fetch_user():
    await asyncio.sleep(2)
    return "user data"

async def fetch_orders():
    await asyncio.sleep(2)
    return "orders data"

async def main():
    start = time.time()
    results = await asyncio.gather(fetch_user(), fetch_orders())
    print(f"Took {time.time() - start:.1f}s")    # Took 2.0s
    print(results)

asyncio.run(main())
```

Step by step:

1.  `fetch_user()` runs until it hits `await asyncio.sleep(2)`, then **pauses**, handing control back to the event loop (a scheduler managing all this).
    
2.  The event loop, now free, starts `fetch_orders()`, which also pauses at its own `await`.
    
3.  Both are now "parked" waiting on their timers simultaneously.
    
4.  After 2 seconds (not 4), both resume, return their values, and `gather` collects them into a list.
    

### The most common async bug: forgetting `await`

```python
async def get_data():
    await asyncio.sleep(1)
    return {"status": "ok"}

async def main():
    result = get_data()          # forgot await!
    print(result)                  # <coroutine object get_data at 0x7f...>
    print(result["status"])        # TypeError — this isn't the actual data yet

    result = await get_data()     # correct — actually runs and waits for the result
    print(result["status"])         # "ok"
```

Calling an `async def` function without `await` doesn't run it — it just hands you back an unexecuted coroutine object.

### `async for` and `async with`

```python
async def list_users(db):
    async with db.client.start_session() as session:   # async context manager
        cursor = db.users.find({})
        async for user in cursor:                        # async iteration
            print(user)
```

Same shapes as regular `with` and `for`, but built for operations where each step might need to wait on I/O — such as fetching the next result from a database query over the network.

* * *

## Part 6: Virtual Environments

By default, Python installs packages into one **global** location shared by everything on your machine. This becomes a problem the moment two different projects need different versions of the same library.

A virtual environment ("venv") is a self-contained, isolated copy of Python's package directory for a single project.

```bash
mkdir my_project && cd my_project

python -m venv venv
# Creates a venv/ folder: its own python, pip, and package storage

which python
# /usr/bin/python3        <- still the SYSTEM python, venv isn't active yet

source venv/bin/activate
# Shell prompt now shows (venv)

which python
# /Users/you/my_project/venv/bin/python     <- now rewired to the venv's copy

pip install fastapi uvicorn
# Installs ONLY inside venv/ — completely invisible to other projects

pip freeze > requirements.txt
# Records exact installed versions:
#   fastapi==0.115.0
#   uvicorn==0.30.1

deactivate
# Prompt returns to normal; "python" points back to the system version
```

On another machine (or a deployment server), nobody copies the `venv/` folder itself — it gets rebuilt from the requirements file:

```bash
python -m venv venv
source venv/bin/activate
pip install -r requirements.txt    # reproduces the exact same set of packages
```

`requirements.txt` is the one artifact that travels with your project (usually committed to git); `venv/` itself is added to `.gitignore`, since it's just a large, easily-regeneratable, machine-specific folder.
