Обзор полезных инструментов для интроспекции объектов Python

Python позволяет разными способами задавать вопросы о коде. В распоряжении программистов находятся соответствующие инструменты, помогающие найти необходимые ответы. Среди них базовая функция help(), встроенные функции вроде dir() и усложненные методы модуля inspect

Из материала статьи вы узнаете, на какие вопросы о коде может ответить Python и как он помогает во время сеансов отладки, работы с аннотациями типов, валидации входных данных и т.д. 

Встроенные функции

Python предлагает несколько категорий инструментов/функций для интроспекции. Начнем с рассмотрения наиболее базовых из них, а именно со встроенных функций. 

Python включает базовый набор встроенных функций, большинство из которых хорошо знакомы программистам: len(), range() и print(). Однако есть ряд малоизвестных функций, которые помогают отвечать на вопросы о коде: 

if "some_var" in locals():
... # some_var существует

if "some_var" in globals():
... # some_var существует

if hasattr(instance, "some_class_attr"):
... # instance.some_class_attr существует

locals(), globals() и hasattr() позволяют определить наличие локальной/глобальной переменной и экземпляра класса. 

Кроме того, с помощью встроенных функций можно проверить, является ли переменная функцией: 

print(callable(some_func))
# True

# В случае с версией, предшествующей Python 3.2
print(hasattr(some_func, "__call__"))
# True

# Внимание!
print(inspect.isfunction(sum))
# False

Такую проверку можно осуществить двумя способами. Лучшего всего воспользоваться callable(). Однако если вы работаете с Python 3.1, то наличие атрибута __call__ устанавливается с помощью функции hasattr. Второй способ подразумевает привлечение isfunction() из модуля inspect. Однако будьте внимательны при работе с данной функцией. Дело в том, что она возвращает False для встроенных функций, таких как sum, len и range, по причине их реализации на языке C, поэтому они не являются функциями Python. 

Помимо предложенных вариантов, можно также установить, является ли переменная списком (последовательностью) или скалярной величиной: 

import collections.abc

# Работает со встроенными типами, но включает строки. Не подходит для массивов NumPy:
print(isinstance([1, 2, 3], collections.abc.Sequence)) # True
print(isinstance(np.array([1, 2, 3]), collections.abc.Sequence)) # False - wrong
print(isinstance("1234", collections.abc.Sequence)) # True - wrong

# Работает также для массивов NumPy, но возвращает True для словарей
print(hasattr([1, 2, 3], "__len__")) # True
print(hasattr(np.array([1, 2, 3]), "__len__")) # True
print(hasattr({"a": 1}, "__len__")) # True - wrong (?)

print(isinstance([1, 2, 3], (collections.abc.Sequence, np.ndarray))) # True
print(isinstance(np.array([1, 2, 3]), (collections.abc.Sequence, np.ndarray))) # True
print(isinstance("1234", (collections.abc.Sequence, np.ndarray))) # True - wrong

Напрашивается очевидное решение в виде функции isinstance, позволяющей проверить, является ли переменная экземпляром абстрактного класса Sequence. Однако этот вариант не работает в случае с невстроенными типами, такими как массивы NumPy. К тому же, данный подход рассматривает строки как последовательность, что приемлемо, но нежелательно. Альтернативное решение  —  функция hasattr, которая устанавливает наличие у переменной атрибута __len__. Этот подход работает для массивов NumPy, но также возвращает True для словарей. Третий способ заключается в передаче кортежа типов в isinstance для настройки требуемого поведения. 

Вы уже знаете, как работает globals() для проверки наличия переменной. Помимо этого, она используется для вызова функции по строке: 

def some_func():
print("Call me!")

func_name = "some_func"

globals()[func_name]()
# Call me!

Аналогичная стратегия действует и для атрибутов объекта (класса) с помощью getattr(instance, "func_name")().

Последний пример со встроенными функциями иллюстрирует применение locals(). Допустим, у нас есть функция, принимающая множество аргументов, все из которых необходимо передать в другую функцию. При этом вы не расположены прописывать их все. В таком случае на помощь приходит locals() с оператором деструктуризации **:

def func(a, b, c, d, e):
other_func(**locals())

def other_func(a, b, c, d, e):
print(a, b, c, d, e)

func(1, 2, 3, 4, 5)
# Prints: 1 2 3 4 5

В данном разделе мы рассмотрели возможности встроенных функций, однако спектр их действия не ограничивается представленными примерами . Для полной картины рекомендую самостоятельно изучить еще несколько функций, например dir() и vars(), опираясь на документацию.

Атрибуты объектов

Если вышеперечисленные встроенные функции не дают всех требуемых ответов, продолжим изучение и познакомимся с другими способами. Каждый объект в Python обладает довольно обширным набором атрибутов, сообщающих более подробные сведения о конкретных объектах.  

По сути, речь идет о значениях, применяемых для создания вывода help(object). Все, что в нем содержится, извлекается из атрибута object. Так, вывод help(object) может включать строки документации объекта (.__doc__), исходный код функции (.__code__) и информацию о трассировке/стеке, например .tb_frame.

Однако вариант с атрибутами не самый лучший. Рассмотрим более интересный способ в виде модуля inspect.

Модуль Inspect 

Модуль inspect задействует все вышеуказанные атрибуты (и даже больше), позволяя более эффективно осуществлять интроспекцию кода. 

Возможно, вам уже доводилось работать со встроенным методом dir() для получения всех атрибутов объекта. Модуль inspect предоставляет аналогичный вариант с именем getmembers():

import inspect

print(inspect.getmembers(SomeClass))

# [('__class__', <class 'type'>),
# ('__delattr__', <slot wrapper '__delattr__' of 'object' objects>),
# ('__dict__', mappingproxy({...})),
# ...,
# ('__init__', <slot wrapper '__init__' of 'object' objects>), ('__repr__', <slot wrapper '__repr__' of 'object' objects>),
# ('__weakref__', ..., ('some_func', <function SomeClass.some_func at 0x7f3fdf62e5e0>)]

methods = inspect.getmembers(SomeClass, lambda attr: not(inspect.ismethod(attr))) # Нет атрибутов
methods_filtered = [m for m in methods if not(m[0].startswith("__") and m[0].endswith("__"))]
# [('some_func', <function SomeClass.some_func at 0x7fc823f77430>), ...]

attributes = inspect.getmembers(SomeClass, lambda attr: not(inspect.isroutine(attr))) # Нет функций
attrs_filtered = [a for a in attributes if not(a[0].startswith("__") and a[0].endswith("__"))]
# [('some_var', 'value'), ...]

Преимущество метода inspect.getmembers() состоит в том, что он предоставляет второй аргумент, который можно использовать для фильтрации атрибутов. В данном примере он соответственно фильтрует атрибуты переменных и функции. 

Модуль inspect также отлично подходит для отладки. Например, он задействуется для отладки состояния генератора: 

import inspect

def some_gen():
yield 1

print(inspect.isgeneratorfunction(some_gen))
# True

gen = some_gen()
print(inspect.getgeneratorstate(gen))
# GEN_CREATED

next(gen)
print(inspect.getgeneratorstate(gen))
# GEN_SUSPENDED

try:
next(gen)
except StopIteration:
pass
print(inspect.getgeneratorstate(gen))
# GEN_CLOSED

Здесь мы определяем фиктивный генератор, помещая yield в тело функции. Затем с помощью isgeneratorfunction() убеждаемся, действительно ли это генератор. Кроме того, проверяем его состояние посредством isgeneratorfunction(), которая возвращает GEN_CREATED (в процессе выполнения), GEN_RUNNING, GEN_SUSPENDED (ожидание в инструкции yield) или GEN_CLOSED (завершено). 

inspect.signature помогает в отладке элементов, связанных с сигнатурой функции, таких как изменяемые аргументы по умолчанию: 

def some_func(var_with_default=[]):
var_with_default.append("value")

print(inspect.signature(some_func))
# (var_with_default=[])

some_func()
some_func()
some_func()

print(inspect.signature(some_func))
# (var_with_default=['value', 'value', 'value'])

Как известно, не следует использовать изменяемые типы для аргументов по умолчанию, такие как list, поскольку они будут изменяться при каждом выполнении функции. Проверка сигнатуры функции с помощью inspect.signature() проясняет все, что нужно узнать.  

В продолжении темы аргументов по умолчанию отметим возможность применения signature() для их чтения: 

def some_func(some_arg=42):
...

signature = inspect.signature(some_func)
print({
k: v.default
for k, v in signature.parameters.items()
if v.default is not inspect.Parameter.empty
})
# {'some_arg': 42}

# Если вы знаете имя аргумента:
print(inspect.signature(some_func).parameters["some_arg"].default)
# 42

Вы можете воспользоваться signature() при намерении передать значения по умолчанию одной функции другой. Как видно из предыдущего примера, вы либо перебираете все аргументы и выбираете те, у которых непустое значение по умолчанию, либо, зная имя аргумента, запрашиваете его напрямую. 

Усложненные случаи с функцией signature() включают внедрение дополнительных аргументов в функцию с помощью декоратора: 

from functools import wraps
import inspect

def optional_debug(fn):
if "debug" in inspect.signature(fn).parameters:
raise TypeError('"debug" argument already defined')

@wraps(fn)
def wrapper(*args, debug=False, **kwargs):
if debug:
print("Calling", fn.__name__)
return fn(*args, **kwargs)

sig = inspect.signature(fn)
params = list(sig.parameters.values())
params.append(inspect.Parameter("debug",
inspect.Parameter.KEYWORD_ONLY,
default=False))
wrapper.__signature__ = sig.replace(parameters=params)
return wrapper

@optional_debug
def func(x):
return x

print(inspect.signature(func))
# (x, *, debug=False)

func(42, debug=True)
# Prints: Calling func

Данный фрагмент кода определяет декоратор с именем optional_debug, который внедряет аргумент debug при применении к функции. Для этого он сначала просматривает сигнатуру функции и проверяет наличие аргумента debug. В случае его отсутствия он заменяет исходную сигнатуру функции на новую с добавлением аргумента, содержащего только ключевое слово. 

Допустим, у вас есть функция, которая объявляет только *args и **kwargs для аргументов. Указанная особенность делает ее “универсальной”, но сильно усложняет проверку параметров. Эта проблема решается с помощью Signature и классов Parameter из модуля inspect:

from inspect import Signature, Parameter

params = [Parameter("first", Parameter.POSITIONAL_ONLY),
Parameter("second", Parameter.POSITIONAL_OR_KEYWORD),
Parameter("third", Parameter.KEYWORD_ONLY, default="default_value")]

# Options:
# POSITIONAL_ONLY (ones before "/")
# POSITIONAL_OR_KEYWORD
# VAR_POSITIONAL ("*args")
# KEYWORD_ONLY (ones after a "*" or "*args")
# VAR_KEYWORD ("**kwargs")

sig = Signature(params)
print(sig)
# (first, /, second, *, third=None)

def func(*args, **kwargs):
bound_values = sig.bind(*args, **kwargs)
for name, value in bound_values.arguments.items():
print(name, value)

func(10, "value")
# first 10
# second value

# func(second="value", third="another")
# TypeError: missing a required argument: 'first'

Сначала мы определяем ожидаемые параметры (params) посредством класса Parameter, указывая их тип и значения по умолчанию, после чего создаем из них сигнатуру. Внутри “универсальной” функции применяем метод сигнатуры bind для привязки предоставленных *args и **kwargs к подготовленной сигнатуре. Если параметры не соответствуют сигнатуре, получаем исключение. Если все в порядке, то получаем доступ к привязанному параметру, используя возвращаемое значение метода bind

Данное решение подходит для валидации параметров внутри базовой функции. А что если нужно проверить, получает ли генератор все необходимые параметры из декорированной функции? 

import functools
import inspect

def login_required(fn):

@functools.wraps(fn)
def wrapper(*args, **kwargs):
func_args = inspect.Signature.from_callable(fn).parameters
if "username" not in func_args:
raise Exception("Missing username argument")

# ... Выполнение логики аутентификации

return fn(*args, **kwargs)
return wrapper

@login_required
def handler(username):
...

handler() # Аргумент не предоставлен...
# TypeError: handler() missing 1 required positional argument: 'username'

Этот фрагмент кода показывает реализацию декоратора аутентификации, который предполагает передачу параметра username в декорированную функцию. Для проверки наличия параметра задействуется метод from_callable() класса Signature, который извлекает предоставленные параметры из функции. Затем перед выполнением какой-либо фактической логики мы смотрим, находится ли username в возвращаемом кортеже. 

От рассмотрения inspect.signature переходим к изучению других возможностей модуля inspect, а именно к интроспекции файлов исходного кода: 

from pathlib import Path

import datetime

print(Path(inspect.getfile(some_func)).resolve())
# /home/martin/.../examples.py

print(Path(inspect.getfile(datetime.date)).resolve())
# /usr/lib/python3.8/datetime.py

Здесь мы просто ищем местоположение (файл) функции some_func с помощью getfile. Этот же прием работает и для встроенных функций/объектов, как показано выше на примере datetime.date.

Модуль inspect также включает вспомогательные функции для работы с трассировкой. Например, его можно задействовать для поиска исходного файла и строки, выполняемой в текущий момент: 

from inspect import currentframe, getframeinfo, stack

frame_info = getframeinfo(currentframe())
frame_info = getframeinfo(stack()[0][0]) # Same as above

print(f"{frame_info.filename}: {frame_info.lineno}")
# examples.py: 235

def some_func():
# Эквивалент "getframeinfo(stack()[1][0]).lineno"
frame = currentframe()
print(f"Line: {frame.f_back.f_lineno}") # <- Line 243

some_func() # <- Line 245
# Prints: "Line: 245"

Эта задача решается двумя способами, самый простой из которых предполагает применение getframeinfo(currentframe()). Однако доступ к стековым фреймам можно получить и через функцию stack(). В этом случае потребуется проиндексировать стек для поиска нужного фрейма. 

Согласно второму способу вы напрямую используете currentframe() и обращаетесь к f_back и f_lineno, чтобы найти соответственно нужный фрейм и строку. 

Вспомогательные функции трассировки также помогают получить доступ к вызывающему объекту: 

class SomeClass:

def some_func(self):
some_other_class = SomeOtherClass()
some_other_class.some_other_func()

class SomeOtherClass:

def some_other_func(self):
import inspect
print(inspect.currentframe().f_back.f_locals["self"])

some_class = SomeClass()
some_class.some_func()
# <__main__.SomeClass object at 0x7f341fe173d0>

С этой целью мы привлекаем к работе функцию inspect.currentframe().f_back.f_locals['self'], которая обеспечивает доступ к “родительскому” объекту. Такое решение работает только для отладки. Если требуется доступ к вызывающему объекту, то мы не перемещаемся по стеку, а передаем его в функцию в качестве аргумента.

В завершение статьи обратим внимание еще на один немаловажный момент. Если вы задействуете подсказки типов в коде, то наверняка знакомы с функцией typing.get_type_hints, которая помогает проверять подсказки типов. Однако эта функция часто выбрасывает ошибку NameError (особенно при вызове для класса). Вот почему при работе с Python 3.10 следует переключиться на функцию inspect.get_annotations, которая обрабатывает разные пограничные случаи: 

import pytest

help(pytest.exit)
# Справка по выходу из функции в модуле _pytest.outcomes:
# exit(reason: str = '', returncode: Optional[int] = None, *, msg: Optional[str] = None) -> 'NoReturn'
# Тестирование выхода.
# ...

inspect.get_annotations(pytest.exit)
# {'reason': <class 'str'>, 'returncode': typing.Optional[int], 'msg': typing.Optional[str], 'return': 'NoReturn'}

Более подробная информация о доступе к аннотациям в Python 3.1 и последующих версиях предоставлена в документации

Заключение 

Как видим, под рукой программистов предостаточно инструментов для интроспекции и анализа кода Python. Некоторые из них сложны для понимания и, возможно, так и останутся невостребованными. Однако не помешает знать об их существовании, особенно о тех, которые могут пригодится для отладки. 

Если перечисленных в статье вариантов недостаточно или нужна более углубленная информация по данной теме, то поизучайте модуль ast, предназначенный для обхода и проверки синтаксического дерева, и модуль dis для дизассемблирования байт-кода Python. 

Читайте также:

Читайте нас в TelegramVK и Дзен


Перевод статьи Martin Heinz: All the Ways To Introspect Python Objects at Runtime

Предыдущая статья3 ошибки на собеседованиях по программированию, из-за которых можно получить отказ
Следующая статья7 советов по очистке кода React