I would like a function (let's call it detect_undefined) which detects variables used in a function, which are not defined inside the function without running it.
Examples:
import numpy as np

def add(a):
    return np.sum(a)

print(detect_undefined(add))

Output: ["np"]
def add(a):
    import numpy as np
    return np.sum(a)

print(detect_undefined(add))

Output: []
b = 3


def add(a):
    return a + b

print(detect_undefined(add))

Output: ["b"]
def add(a, b=3):
    return a + b

print(detect_undefined(add))

Output: []
It is crucial that the algorithm works without running the function to be examined, i.e. I cannot do something like try ... except. ChatGPT suggested to use inspect and ast, but its suggestion didn't quite work.

How to detect variables defined outside the function (or undefined) without running the function in python
I'll provide two solutions, the first one doesn't work exactly, but I'll leave the information in case someone sees this post a few years later.
Solution1
inspect.getclosurevars or func.__code__.co_names is exactly what it is for. And it should be preferred over the 2nd solution.
b = 3


def add(a):
    return a + b


print(inspect.getclosurevars(add))
# ClosureVars(nonlocals={}, globals={'b': 3}, builtins={}, unbound=set())
print(add.__code__.co_names)
# ('b',)

Unfortunately, there is a bug in it, so it won't work.
import numpy as np


def add(a):
    return np.sum(a)


print(inspect.getclosurevars(add))
# ClosureVars(nonlocals={}, globals={'np': <module 'numpy' from ...>}, builtins={'sum': <built-in function sum>}, unbound=set())
print(add.__code__.co_names)
# ('np', 'sum')
# Note: sum is incorrectly detected as a built-in function.

Solution2
Use the dis module to analyze the function in bytecode. Below is an example of the information that dis provides.
b = 3


def add(a):
    return np.sum([a, b])


print(dis.dis(add))

Result:
 10           0 LOAD_GLOBAL              0 (np)
              2 LOAD_METHOD              1 (sum)
              4 LOAD_FAST                0 (a)
              6 LOAD_GLOBAL              2 (b)
              8 BUILD_LIST               2
             10 CALL_METHOD              1
             12 RETURN_VALUE

As you can see, LOAD_GLOBAL lines are what you are looking for. Now we can parse the lines and extract the names.
To parse the above result, dis.get_instructions is more useful.
def detect_undefined(func):
    for ins in dis.get_instructions(func):
        if inspect.iscode(ins.argval):  # inner function
            yield from detect_undefined(ins.argval)
        elif ins.opname == "LOAD_GLOBAL":  # global variable
            yield ins.argval

Here is the complete code with some tests.
import dis
import inspect
import numpy as np


def detect_undefined(func):
    for ins in dis.get_instructions(func):
        if inspect.iscode(ins.argval):  # inner function
            yield from detect_undefined(ins.argval)
        elif ins.opname == "LOAD_GLOBAL":  # global variable
            yield ins.argval


def add1(a):
    return np.sum(a)

print(list(detect_undefined(add1)))
# ['np']


def add2(a):
    import numpy as np
    return np.sum(a)

print(list(detect_undefined(add2)))
# []


b = 3

def add3(a):
    return a + b

print(list(detect_undefined(add3)))
# ['b']


def add4(a, b=3):
    return a + b

print(list(detect_undefined(add4)))
# []


x = 0
y = 1
z = 2

def complex_func(*args, **kwargs):
    p = 0
    print(f"{x}")
    def inner1(t):
        q = y
        return lambda s: x + add3(z)
    return inner1(2)

print(list(detect_undefined(complex_func)))
# ['print', 'x', 'y', 'x', 'add3', 'z']
# Note that x is used twice, so it is detected twice.

One last thing to mention is that it also detects global functions. This may not be the OP's desired result, but excluding functions would be quite a challenge. Keep in mind that in Python we can assign functions to variables (b = add) or redefine functions as variables (add = 0). Or even worse, what are we supposed to do with a tuple that contains both variables and functions.
