Python notes

decorators

Decorators deep dive


Prerequisite concept: "top level"

“Top-level” doesn’t mean it’s near the top of the file.

It means: “outside of any other block,” and executed immediately when the module is loaded.

It doesn’t mean “at the beginning of the file.” It means outside of any function/class definitions.


🧪 Example: What's Top-Level vs Not

print("✅ Top-level 1")  # ← top-level

def foo():
    print("❌ NOT top-level - inside function")

print("✅ Top-level 2")  # ← still top-level

class MyClass:
    print("❌ NOT top-level - inside class body")

if __name__ == "__main__":
    print("✅ Still top-level - in the global scope")

🔎 Why It Matters

Top-level (runs at module load):
├── Imports
├── Variable assignments
├── Function/class definitions
├── print() calls

Not top-level (runs later):
└── Inside def, class, if, for, while, try...

Top-level code is executed immediately when:

  • the module is imported
  • or run as a script

Code inside functions, classes, conditionals, etc. isn’t executed until the interpreter enters those blocks during execution.

What does "definitions are executed, but not their body" really means

This is one of those deceptively deep Python ideas that shows how "code is data" in Python. When you write a function or class in Python:

def foo():
    print("Hello")

class Bar:
    print("Inside class")
    def method(self):
        print("Inside method")

Python executes the def or class statement during module loading — but not the code inside the function or method body.

Function/Class Definitions Are Statements

def foo(): ...
class Bar: ...

These lines are executed like regular statements — they:

  • Create a function object or class object
  • Bind that object to a name in the current scope

✅ That’s what we mean when we say the definition is executed.


Function or Method Bodies Are Not Run Immediately

The body of the function (i.e., the code inside the def) is:

  • not executed right now
  • just stored inside a code object in the function/class

It only runs when the function or method is called later.


print("💡 Module loading")

def foo():
    print("🚀 Inside foo")

print("✅ foo is defined")

foo()  # <- now the body runs
💡 Module loading
✅ foo is defined
🚀 Inside foo

Notice:

  • def foo(): got executed — a function object was created and bound to the name foo
  • The print("🚀 Inside foo") did not run until foo() was called

🎯 For Classes, It's Slightly Different

When Python hits a class statement:

class MyClass:
    print("🔧 Inside class body")

    def method(self):
        print("🚀 Inside method")
  • The class body is run immediately, just like module-level code
  • The method() function object is created and stored in the class’s namespace
  • But the method body is not executed until called

class MyClass:
    print("🔧 Class body running")

    def method(self):
        print("🚀 Method body running")

print("✅ Class defined")

Output:

🔧 Class body running
✅ Class defined

Calling MyClass().method() would then trigger:

🚀 Method body running

Concept Happens at Definition Happens at Call
def foo(): ... ✅ Function is created ❌ Body not run
foo() ❌ Definition ignored ✅ Body runs
class Bar: ... ✅ Class body runs ❌ Methods not run
Bar().method() ❌ Class already defined ✅ Method body runs

Prerequisite concept: 🧭 Timeline in Python Execution

When you run or import a Python module, here’s what happens in order:

Start
├── Module Loading Time
│   ├── Top-level code execution
│   ├── Function/class definitions (definition time)
│   │   └── Decorators take effect here
│   └── All code outside functions/classes is executed
└── Runtime
    └── Functions and classes get called or used

📦 Module Loading Time

This happens:

  • When a script is executed directly (python my_script.py)
  • Or imported as a module

What happens:

  • Python parses and executes the top-level code line by line.
  • This includes defining functions, classes, variables, and applying decorators.

At this point, function bodies aren't executed yet — only the definitions are registered in memory.


🏗️ Definition Time

This happens during module loading for each function/class definition.

What happens:

  • The def statement creates a function object.
  • Then, decorator syntax (the @decorator) is applied immediately.
@my_decorator
def foo():
    ...

This is really:

def foo():
    ...
foo = my_decorator(foo)

So the @ sugar runs right after the function object is created. This is definition time, not runtime.


🚀 Runtime

This is when the user (or program) calls the function or class.

What happens:

  • The function/class is already decorated.
  • Whatever the decorator returned (often a wrapped function) is now what's being called.

📌 When Do Decorators "Take Effect"?

Decorators take effect at definition time, which happens during module loading.

🎯 Summary Table

Phase What Happens When @decorator applies
Parsing Python parses your .py file (doesn't run anything) ❌
Module Load Top-level code is executed ✅
Definition Time def foo() creates a function object ✅
Decorator Time foo = my_decorator(foo) ✅
Runtime You call foo() (already decorated!) ❌

🧠 Are There Other "Times"?

Some other relevant phases:

  • Import time: When a module is imported, it's essentially the same as module loading time.
  • Compilation time: Python compiles .py to .pyc (bytecode) – decorators don’t run here.
  • Initialization time: Sometimes refers to when classes or modules initialize variables – still under "module loading."

So the core phases for decorators are really:

  • Module loading
  • Definition time (inside module loading)

Elaboration with code

print("📦 Module loading starts")

def simple_decorator(func):
    print("🔧 Decorator is being applied to:", func.__name__)
    def wrapper(*args, **kwargs):
        print("🚀 Wrapped function is called!")
        return func(*args, **kwargs)
    return wrapper

@simple_decorator
def greet():
    print("👋 Hello, world!")

print("📦 Module loading ends")

print("⏱ Now we call greet() at runtime:")
greet()
📦 Module loading starts
🔧 Decorator is being applied to: greet
📦 Module loading ends
⏱ Now we call greet() at runtime:
🚀 Wrapped function is called!
👋 Hello, world!

🧪 Illustrative Separation

print("📦 Module loading starts")  # Module loading

def foo():
    print("⏱ This is runtime — inside foo")  # Runtime

print("📦 Module loading ends")    # Still module loading

print("⏱ Now we call foo():")     # Still module loading
foo()                              # Triggers runtime

Technically all print() calls are executed at runtime, because Python is an interpreted language — there's no separate compile phase like in C++.

But when we talk about module loading time vs runtime, we're making a conceptual distinction in when code is executed relative to function calls.

✅ Module Loading Time

  • When the Python interpreter executes top-level code in a module.
  • This includes:
    • Imports
    • Function and class definitions
    • Decorator application
    • Top-level print() calls

→ This happens once, when the module is imported or run directly.

print("📦 Module loading starts")  # <-- Executed immediately as the script starts

✅ Runtime (Function Execution Time)

  • Happens after the module has been loaded.
  • Involves code inside functions or methods — i.e., the stuff that only runs when the function is called.
def greet():
    print("👋 Hello!")  # <-- This runs only when greet() is called
Code location When it runs What it's called
Top-level print() When the module is loaded Module loading time
@decorator When function is defined Definition time (during loading)
Code inside def When the function is called Function runtime

🔍 What Exactly Happens at Definition Time?


🧱 1. @simple_decorator is syntax sugar

This:

@simple_decorator
def greet():
    ...

Is equivalent to:

def greet():
    ...
greet = simple_decorator(greet)

So Python:

  1. Defines greet as a function object (like normal).
  2. Immediately calls simple_decorator(greet).
  3. Assigns the result of that call (which will be wrapper) back to greet.

🧩 Inside simple_decorator(func) — Definition Time

Here’s what happens step-by-step:

print("🔧 Decorator is being applied to:", func.__name__)  # ✅ Executed

✅ This line runs immediately when the decorator is applied.
That’s why you see the print when the module loads.


def wrapper(*args, **kwargs):  # ✅ This is executed (definition of function object)
    print("🚀 Wrapped function is called!")
    return func(*args, **kwargs)

✅ The wrapper function is defined (i.e., Python creates a function object named wrapper), but its body is not executed yet. It’s just created as a function object in memory.


return wrapper  # ✅ Executed — returns the function object

✅ This line is executed immediately. It returns the wrapper function object to the caller.

So now:

greet = wrapper

🚨 Important Clarification

greet = wrapper(greet()) ← ❌ Not what's happening!

What’s really happening is:

greet = simple_decorator(greet)  # greet is the undecorated function object

# Inside the decorator:
return wrapper  # ← returns a function, NOT the result of calling it

E.g. greet becomes a reference to a new function, wrapper, which closes over the original greet.

def simple_decorator(func):               # func == original greet function
    print("🔧 Decorator is being applied to:", func.__name__)

    def wrapper(*args, **kwargs):         # ← This function closes over `func`
        print("🚀 Wrapped function is called!")
        return func(*args, **kwargs)      # ← uses the original function!

    return wrapper                        # ← returns the new function

So at @simple_decorator, Python does:

  1. Define greet() (the original function)
  2. Pass it into simple_decorator → func = greet
  3. Define wrapper(...), which remembers func via closure
  4. Return wrapper
  5. Assign: greet = wrapper

🔐 Closure Magic

The function wrapper(...) remembers the func that was passed into simple_decorator.

That’s how wrapper knows what to call later:

return func(*args, **kwargs)  # this func is the original greet

✅ func is bound to the original undecorated greet()
✅ This binding happens once during decorator execution


✅ Summary with Objects

Think of it this way:

Name Points to Notes
greet wrapper function after decorator application
wrapper uses func in closure func == original greet()

So when you do:

greet()

You're really doing:

wrapper()  # calls original greet() from the closure

✅ Closure in Python vs Shared Pointers in C++

🌐 Python Closure: What It Is

In Python, a closure happens when:

  • A nested function (wrapper) references a variable from an enclosing scope (func)
  • That enclosing scope is no longer active, but its referenced variables stay alive because they’re captured.

So when simple_decorator() returns, func (original greet) would normally go out of scope…

BUT since wrapper still closes over it, Python keeps func alive.


🤝 Analogy: Shared Pointer in C++

In C++, you could imagine something like:

std::shared_ptr<void()> greet = std::make_shared<...>();
std::function<void()> wrapper = [greet]() {
    (*greet)();  // call the original greet
};

This maps very well to the Python model:

  • greet is captured by value (shared_ptr copy) into the lambda
  • Even if the outer greet goes out of scope, the lambda (wrapper) holds a reference
  • greet is only destroyed when no one holds a ref → just like in Python!

So we can say this is accurate:

“func will be destructed (ref count -1), but wrapper's body still holding the ref count, hence it lives longer”


🧠 How Python Does It Under the Hood

Python’s version is slightly lower-level than shared_ptr but similar in spirit:

  • The variables wrapper uses from the enclosing scope (like func) are stored in __closure__
  • These are actually cell objects (similar to a shared_ptr<T> — just not named that way)
  • Python’s garbage collector keeps track of these cell references

You can inspect them:

greet.__closure__[0].cell_contents

✅ TL;DR: Summary Table

Concept Python C++ Analogy
Closure Nested function holds enclosing var Lambda captures by shared_ptr
Lifetime extension Via __closure__ cell references Via shared_ptr ref count
Execution context Enclosing scope gone, but values stay Stack frame gone, but heap shared
Memory mgmt Reference counting (plus GC) Reference counting (shared_ptr)

So yes: treat closures like shared_ptr to the captured values, and it’s a fantastic mental model — especially coming from C++.