вторник, 31 июля 2012 г.

Определение имени процесса из Python (Перевод)


Поиск имени программы из которой был запущен модуль Python может быть сложнее, чем кажется на первый взгляд и поиск причин этого привёл меня к нескольким интересным результатам.
Несколько недель назад на OpenStack Folsom Summit, Mark McClain показал интересный код, который он обнаружил в исходниках Nova:
nova/utils.py: 339
script_dir = os.path.dirname(inspect.stack()[-1][1])
Этот код - часть логики поиска конфигурационного файла, который находится в папке, указанной относительно места запуска исходной программы. В этом коде мы смотрим на стек в поисках основной программы и оттуда получаем её имя.
Похоже, что этот код взят из ответа на вопрос с StackOverflow, и когда я первый раз его увидел, я подумал, что для решения такой проблемы это слишком проблемное решение. У Марка была похожая реакция и он спросил меня, не знаю ли я более простого способа сделать это.
...для решения такой проблемы это слишком проблемное решение...
Похожие примеры с  inspect.stack() встречаются ещё в четырёх местах исходного кода Nova (по крайней мере на данный момент). Все они используются для того, чтобы построить путь к файлу относительно места запуска главной программы или получить имя этой программы, чтобы построить путь к другому файлу (например, файлу лога или третьей программы). Это хорошая причина внимательно относиться к месту и имени этой программы, однако это не объясняет, почему более очевидные пути тут не подходят. Я предположил, что если уж разработчики OpenStack смотрят на фрейм стека, значит у этого есть своя причина. Эта мысль побудила меня изучить исходный код и потратить немного времени на расшифровку того, что же там происходит, и особенно на поиск случаев, когда этот подход не будет работать так, как должен бы (и в таком случае внести нужные патчи).

Стек

Вызов inspect.stack() получает стек интерпретатора Python для текущего потока. Возвращаемое значение - это список с информацией о вызывающей функции с индексом 0, а верх стека находится в конце списка. Каждый элемент в списке - это кортеж, содержащий:
  • структуру данных фрейма стека
  • имя файла для кода, который выполняется в данный момент
  • номер строки в этом файле
  • значение co_name объекта кода из фрейма, соответствующее имени функции или метода, который выполняется в данный момент
  • исходник этого кода, если доступен
  • индекс в списке строк исходника, определяющий выполняемую в данный момент строку
show_stack.py
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
import inspect


def show_stack():
    stack = inspect.stack()
    for s in stack:
        print 'filename:', s[1]
        print 'line    :', s[2]
        print 'co_name :', s[3]
        print


def inner():
    show_stack()


def outer():
    inner()


if __name__ == '__main__':
    outer()
Эта информация предназначается для создания tracebacks или для инструментов вроде pdb, для отладки приложения (хотя pdb имеет собственную реализацию такой функциональности). Для ответа на вопрос “Какую программу я сейчас запустил?” нас интересует имя файла:
$ python show_stack.py
filename: show_stack.py
line    : 6
co_name : show_stack

filename: show_stack.py
line    : 15
co_name : inner

filename: show_stack.py
line    : 19
co_name : outer

filename: show_stack.py
line    : 23
co_name : <module>
Очевидная проблема с этим - имя файла даётся относительно папки запуска приложения. Это может дать нам не правильный результат, если рабочая директория была изменена между запуском и проверкой стека. Но есть и другой вариант, когда при взгляде на стек мы получим неверный результат.
Простые решения не всегда дают правильный ответ.
$ python -m show_stack
filename: /Users/dhellmann/.../show_stack.py
line    : 6
co_name : show_stack

filename: /Users/dhellmann/.../show_stack.py
line    : 15
co_name : inner

filename: /Users/dhellmann/.../show_stack.py
line    : 19
co_name : outer

filename: /Users/dhellmann/.../show_stack.py
line    : 23
co_name : <module>

filename: /Library/Frameworks/Python.framework/Versions/2.7/lib/python2.7/runpy.py
line    : 72
co_name : _run_code

filename: /Library/Frameworks/Python.framework/Versions/2.7/lib/python2.7/runpy.py
line    : 162
co_name : _run_module_as_main
Опция -m интерпретатора запускает модуль runpy, который принимает имя модуля и запускает его как основную программу. В этом случае, вверху стека окажется runpy, так что наша программа находится несколькими уровнями ниже. Простые решения не всегда дают нам правильный ответ.

Почему очевидные решения не работают

Теперь, кода я знаю, что можно получить неправильные результаты глядя на стек, мой следующий вопрос в том - есть ли другие пути найти имя программы, более простые,эффективные и, в особенности, правильные. Самое простое решение - посмотреть на аргументы командной строки - sys.argv.
argv.py
1
2
3
import sys

print sys.argv[0]
Обычно первый элемент в sys.argv это скрипт, который и был запущен как главная программа. Значением этого элемента всегда будет этот файл, хотя путь может быть как абсолютным, так и относительным:
$ python argv.py
argv.py

$ ./argv.py
./argv.py

$ python -m argv
/Users/dhellmann/.../argv.py
Как видно из этого примера, когда скрипт запускается непосредственно или путём передачи его интерпретатору, sys.argv содержит относительный путь к скрипту. Если же мы используем опцию -m мы получаем полный путь, так что просмотреть аргументы командной строки - вполне здоровое решение. Хотя, мы не можем быть уверены в наличии этой опции, мы не всегда можем получить путь к файлу со всеми нужными нам подробностями.

Используем import

Следующей альтернативой, которую я решил проверить, это сам модуль программы. Каждый модуль содержит специальное свойство, __file__, которое содержит путь к файлу из которого и был загружен модуль. Для доступа к модулю главной программы из Python Вам надо импортировать специальный модуль __main__. Чтобы протестировать этот метод я создал специальную программу, которая загружает другой модуль:
import_main_app.py
1
2
3
import import_main_module

import_main_module.main()
And the second module imports __main__ and print the file it was loaded from.
import_main.py
1
2
3
import __main__

print __main__.__file__
Глядя на модуль __main__ мы всегда можем определить запущенную главную программу, но не всегда получим полный путь. На самом деле это понятно, так как имя модуля в стеке берётся как раз отсюда.
$ python import_main.py
import_main.py

$ python -m import_main
/Users/dhellmann/.../import_main.py

Копаем глубже

После того, как я нашёл такой простой способ получения имени программы, я стал думать о причинах, побудивших использовать стек для этих же целей. В итоге мне пришло в голову всего две идеи. Во-первых, вполне возможно, что авторы проекта просто не знали об этом способе. Импортирование __main__ не такая штука, которая требуется Вам каждый день, я сам-то и не помню, откуда я узнал про этот способ (я даже не помню, чтобы я сам его использовал). Это наиболее правдоподобная идея, но есть и ещё один вариант - авторы хотели получить гарантированно правильное значение, не испорченное чем-то случайно. Эта идея побудила меня на дальнейшие изучения, так как мне хотелось понять, какой из способов соответствует этому критерию.
Для того, чтобы понять, что sys.argv можно изменить, мне даже не потребовалось экспериментировать. Так как этот аргумент сохраняется в простом объекте list, его легко можно изменить:
argv_modify.py
1
2
3
4
5
6
7
8
import sys

print 'Type  :', type(sys.argv)
print 'Before:', sys.argv

sys.argv[0] = 'wrong'

print 'After :', sys.argv
Тут поддерживаются все операции списка, так что замена имени программы вполне тривиальная операция. Как и удаление или вырезка элементов из этого списка.
$ python argv_modify.py
Type  : <type 'list'>
Before: ['argv_modify.py']
After : ['wrong']
Атрибут __file__ модуля - это строка, которую саму изменить нельзя, но можно присвоить ей новое значение:
import_modify.py
1
2
3
4
5
6
7
import __main__

print 'Before:', __main__.__file__

__main__.__file__ = 'wrong'

print 'After :', __main__.__file__
Это, конечно, менее вероятно, что произойдёт случайно, так что способ достаточно безопасный. Однако, само по себе это простая операция.
$ python import_modify.py
Before: import_modify.py
After : wrong
Так что остаётся только стек.

Ещё глубже

Как мы уже говорили выше, inspect.stack() возвращает список кортежей. Список вычисляется каждый раз, когда вызывается эта функция, так что это единственное место, где его можно случайно изменить. Ключевое слово - случайно, но даже и злонамеренные программы столкнутся с проблемами, пытаясь это сделать.
stack_modify1.py
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
import inspect


def faux_stack():
    full_stack = orig_stack()
    top = full_stack[-1]
    top = (top[0], 'wrong') + top[2:]
    full_stack[-1] = top
    return full_stack
orig_stack = inspect.stack
inspect.stack = faux_stack


stack_data = inspect.stack()
script_name = stack_data[-1][1]
print 'From stack:', script_name
print 'From frame:', stack_data[-1][0].f_code.co_filename
Имя файла присутствует в двух местах возвращаемых данных. Во-первых - это кортеж, который является частью стека, а во-вторых - это объект кода в этом же кортеже (frame.f_code.co_filename).
$ python stack_modify1.py
From stack: wrong
From frame: stack_modify1.py
Изменить объект кода будет сложно.
Заменить имя файла в кортеже относительно просто и этого вполне достаточно для кода, который доверяет стеку, возвращаемому inspect.stack(). Но изменить объект кода будет сложно. В C Python, класс code реализован на C как часть набора объектов, используемых интерпретатором.
Objects/codeobject.c
static PyMemberDef code_memberlist[] = {
    {"co_argcount",    T_INT,    OFF(co_argcount),    READONLY},
    {"co_nlocals",     T_INT,    OFF(co_nlocals),     READONLY},
    {"co_stacksize",   T_INT,    OFF(co_stacksize),   READONLY},
    {"co_flags",       T_INT,    OFF(co_flags),       READONLY},
    {"co_code",        T_OBJECT, OFF(co_code),        READONLY},
    {"co_consts",      T_OBJECT, OFF(co_consts),      READONLY},
    {"co_names",       T_OBJECT, OFF(co_names),       READONLY},
    {"co_varnames",    T_OBJECT, OFF(co_varnames),    READONLY},
    {"co_freevars",    T_OBJECT, OFF(co_freevars),    READONLY},
    {"co_cellvars",    T_OBJECT, OFF(co_cellvars),    READONLY},
    {"co_filename",    T_OBJECT, OFF(co_filename),    READONLY},
    {"co_name",        T_OBJECT, OFF(co_name),        READONLY},
    {"co_firstlineno", T_INT,    OFF(co_firstlineno), READONLY},
    {"co_lnotab",      T_OBJECT, OFF(co_lnotab),      READONLY},
    {NULL}      /* Sentinel */
};
Все данные объекта кода определены как READONLY, что значит, что Вы не можете изменить их из Python напрямую.
code_modify_fail.py
1
2
3
4
5
6
7
import inspect

stack_data = inspect.stack()
frame = stack_data[0][0]
code = frame.f_code

code.co_filename = 'wrong'
Попытка их изменения приводит к TypeError.
$ python code_modify_fail.py
Traceback (most recent call last):
  File "code_modify_fail.py", line 8, in <module>
    code.co_filename = 'wrong'
TypeError: readonly attribute
Вместо изменения объекта кода можно попробовать заменить его другим объектом. Так как ссылка на объект кода содержится в объекте фрейма, мне потребуется изменить и фрейм. Объект фрейма неизменяемый, но, можно сделать "левый" фрейм и заменить им оригинальный. К сожалению, не возможно создать экземпляры code или frame из Python, так что будем их имитировать.
stack_modify2.py

import collections
import inspect
 
# Создаём именованый кортеж с атрибутами стека
frame_attrs = ['f_back',
               'f_code',
               'f_builtins',
               'f_globals',
               'f_lasti',
               'f_lineno',
               ]
frame=collections.namedtuple('frame',' '.join(frame_attrs))
 
# Создаём именованный кортеж с атрибутами кода
code_attrs = ['co_argcount',
              'co_nlocals',
              'co_stacksize',
              'co_flags',
              'co_code',
              'co_consts',
              'co_names',
              'co_varnames',
              'co_freevars',
              'co_cellvars',
              'co_filename',
              'co_name',
              'co_firstlineno',
              'co_lnotab',
              ]
code=collections.namedtuple('code',' '.join(code_attrs))
 
 
def _make_fake_frame(original, filename):
    """Возвращаем "левый" объект стека."""
    new_c_attrs = dict((a, getattr(original.f_code, a))
                          for a in code_attrs)
    new_c_attrs['co_filename'] = filename
    new_c = code(**new_c_attrs)
 
    new_f_attrs = dict((a, getattr(original, a))
                           for a in frame_attrs)
    new_f_attrs['f_code'] = new_c
    new_f = frame(**new_f_attrs)
    return new_f
 
 
def faux_stack():
    full_stack = orig_stack()
    top = full_stack[-1]
 
    new_frame = _make_fake_frame(top[0], 'wrong')
 
    # подменяем вершину стека
    top = (new_frame, 'wrong') + top[2:]
    full_stack[-1] = top
 
    return full_stack
orig_stack = inspect.stack
inspect.stack = faux_stack
 
 
def show_app_name():
    stack_data = inspect.stack()
    script_name = stack_data[-1][1]
    print 'From stack:', script_name
    print 'From frame:',stack_data[-1][0].f_code.co_filename
 
 
if __name__ == '__main__':
    show_app_name()


Я украл идею использовать namedtuple для имитации класса без методов из inspect, который использует его для определение класса Traceback.
$ python stack_modify2.py
From stack: wrong
From frame: wrong
Замена объектов фрема и кода будет работать хорошо, если мы обратимся к объекту кода напрямую, но мы получим ошибку, если попробуем использовать inspect.getframeinfo() так как в начале функции getframeinfo() (см линию 16) есть явная проверка типа, возбуждающая TypeError.
http://hg.python.org/cpython/file/35ef949e85d7/Lib/inspect.py#l987

def getframeinfo(frame, context=1):
    """Получаем информацию об объекте фрейма или трассировки.
 
    Возвращается кортеж из пяти элементов: имя файла, номер
    текущей строки, имя функции, список строк исходного кода и
    индекс текущей строки в этом списке. Опциональный второй
    аргумент определяет количество строк контекста, которые будут
    возвращены вокруг текущей строки."""
    if istraceback(frame):
        lineno = frame.tb_lineno
        frame = frame.tb_frame
    else:
        lineno = frame.f_lineno
    if not isframe(frame):
        raise TypeError('{!r} is not a frame or traceback object'.format(frame))
 
    filename = getsourcefile(frame) or getfile(frame)
    if context > 0:
        start = lineno - 1 - context//2
        try:
            lines, lnum = findsource(frame)
        except IOError:
            lines = index = None
        else:
            start = max(start, 1)
            start = max(0, min(start, len(lines) - context))
            lines = lines[start:start+context]
            index = lineno - 1 - start
    else:
        lines = index = None
 
    return Traceback(filename, lineno, frame.f_code.co_name, lines, index)
Решением этого будет заменить getframeinfo() версией, где эта проверка отключена. К сожалению, getframeinfo() использует getfile(), который предпринимает похожую проверку, так что его тоже надо заменить.
stack_modify3.py
import inspect
 
from stack_modify2 import show_app_name
 
 
def getframeinfo(frame, context=1):
    """Получаем информацию об объекте фрейма или трассировки.
 
    Возвращается кортеж из пяти элементов: имя файла, номер
    текущей строки, имя функции, список строк исходного кода и
    индекс текущей строки в этом списке. Опциональный второй
    аргумент определяет количество строк контекста, которые будут
    возвращены вокруг текущей строки."""
    if inspect.istraceback(frame):
        lineno = frame.tb_lineno
        frame = frame.tb_frame
    else:
        lineno = frame.f_lineno
    # if not isframe(frame):
    #     raise TypeError('{!r} is not a frame or traceback object'.format(frame))
 
    filename = inspect.getsourcefile(frame) or inspect.getfile(frame)
    if context > 0:
        start = lineno - 1 - context//2
        try:
            lines, lnum = inspect.findsource(frame)
        except IOError:
            lines = index = None
        else:
            start = max(start, 1)
            start = max(0, min(start, len(lines) - context))
            lines = lines[start:start+context]
            index = lineno - 1 - start
    else:
        lines = index = None
 
    return inspect.Traceback(filename, lineno, frame.f_code.co_name, lines, index)
inspect.getframeinfo = getframeinfo
 
 
def getfile(object):
    """Решается, где был определён объект."""
    if hasattr(object, 'f_code'):
        return object.f_code.co_filename
    return orig_getfile(object)
orig_getfile = inspect.getfile
inspect.getfile = getfile
 
 
if __name__ == '__main__':
    show_app_name()
    s = inspect.stack()
    print inspect.getframeinfo(s[-1][0])

Теперь можно использовать inspect.getframeinfo() (на самом деле - мою функцию, которая её замещает) и увидеть изменённое имя файла, которое она вернёт.
$ python stack_modify3.py
From stack: wrong
From frame: wrong
Traceback(filename='wrong', lineno=51, function='<module>',
code_context=None, index=None)
После ещё одного взгляда на inspect.py в попытке понять, надо ли мне изменить что-то ещё, я нашёл лучшее решение. Реализация inspect.stack() простая, так как вызывается функция inspect.getouterframes() для построения списка фреймов. Фрейм, передаваемый в getouterframes() берётся из sys._getframe().
def stack(context=1):
    """Возвращает списко записей для стека под фреймом вызывающей функции."""
    return getouterframes(sys._getframe(1), context)
Оставшаяся часть стека получается из первого фрейма, возвращаемого _getframe() с использованием атрибута f_back для связи между фреймами.
http://hg.python.org/cpython/file/35ef949e85d7/Lib/inspect.py#l1025

def getouterframes(frame, context=1):
    """Получаем список записей для фрейма и всех вышележащих фреймов.
 
    Каждая запись содержит объект фрейма, имя файла, номер
    строки, именем функции, списком строк исходного кода и индексом
    строки в этом списке"""
    framelist = []
    while frame:
        framelist.append((frame,) + getframeinfo(frame, context))
        frame = frame.f_back
    return framelist
Если я изменю getouterframes() вместо inspect.stack(), тогда я могу быть уверенным, что моя подложная информация будет добавлена в начало стека и все остальные функции модуля inspect будут этой информации доверять.
stack_modify4.py
import collections
import inspect
 
# Define a namedtuple with the attributes of a stack frame
frame_attrs = ['f_back',
               'f_code',
               'f_builtins',
               'f_globals',
               'f_lasti',
               'f_lineno',
               ]
frame = collections.namedtuple('frame', ' '.join(frame_attrs))
 
# Define a namedtuple with the attributes of a code object
code_attrs = ['co_argcount',
              'co_nlocals',
              'co_stacksize',
              'co_flags',
              'co_code',
              'co_consts',
              'co_names',
              'co_varnames',
              'co_freevars',
              'co_cellvars',
              'co_filename',
              'co_name',
              'co_firstlineno',
              'co_lnotab',
              ]
code = collections.namedtuple('code', ' '.join(code_attrs))
 
 
def _make_fake_frame(original, filename):
    """Return a new fake frame object with the wrong filename."""
    new_c_attrs = dict((a, getattr(original.f_code, a))
                          for a in code_attrs)
    new_c_attrs['co_filename'] = filename
    new_c = code(**new_c_attrs)
 
    new_f_attrs = dict((a, getattr(original, a))
                           for a in frame_attrs)
    new_f_attrs['f_code'] = new_c
    new_f = frame(**new_f_attrs)
    return new_f
 
 
def getouterframes(frame, context=1):
    """Get a list of records for a frame and all higher (calling) frames.
 
    Each record contains a frame object, filename, line number, function
    name, a list of lines of context, and index within the context."""
    framelist = []
    while frame:
        if not frame.f_back:
            # Replace the top of the stack with a fake entry
            frame = _make_fake_frame(frame, 'wrong')
        framelist.append((frame,) + getframeinfo(frame, context))
        frame = frame.f_back
    return framelist
inspect.getouterframes = getouterframes
 
 
def getframeinfo(frame, context=1):
    """Get information about a frame or traceback object.
 
    A tuple of five things is returned: the filename, the line number of
    the current line, the function name, a list of lines of context from
    the source code, and the index of the current line within that list.
    The optional second argument specifies the number of lines of context
    to return, which are centered around the current line."""
    if inspect.istraceback(frame):
        lineno = frame.tb_lineno
        frame = frame.tb_frame
    else:
        lineno = frame.f_lineno
    # if not isframe(frame):
    #     raise TypeError('{!r} is not a frame or traceback object'.format(frame))
 
    filename = inspect.getsourcefile(frame) or inspect.getfile(frame)
    if context > 0:
        start = lineno - 1 - context // 2
        try:
            lines, lnum = inspect.findsource(frame)
        except IOError:
            lines = index = None
        else:
            start = max(start, 1)
            start = max(0, min(start, len(lines) - context))
            lines = lines[start:start + context]
            index = lineno - 1 - start
    else:
        lines = index = None
 
    return inspect.Traceback(filename, lineno, frame.f_code.co_name, lines, index)
inspect.getframeinfo = getframeinfo
 
 
def getfile(object):
    """Work out which source or compiled file an object was defined in."""
    if isinstance(object, frame):
        return object.f_code.co_filename
    return orig_getfile(object)
orig_getfile = inspect.getfile
inspect.getfile = getfile
 
 
def show_app_name():
    stack_data = inspect.stack()
    #print [(s[1], s[0].__class__, s[0].f_code.co_name) for s in stack_data]
    print 'From stack        :', stack_data[-1][1]
    print 'From code in frame:', stack_data[-1][0].f_code.co_filename
    print 'From frame info   :', inspect.getframeinfo(stack_data[-1][0]).filename
 
 
if __name__ == '__main__':
    show_app_name()
Однако переделанные версии getframeinfo() и getfile() всё ещё нужны для того, чтобы избежать исключений при проверке типа.
$ python stack_modify4.py
From stack        : wrong
From code in frame: wrong
From frame info   : wrong

Хватит на сегодня

На этот момент я убедился, что хотя маловероятно, что кто-то будет делать все эти телодвижения в реальной программе (и уж тем более не будет делать это случайно), тем не менее вполне возможно предоставить программе ложную информацию о ней самой. Эта реализация не будет работать с pdb, так как он не использует inspect. У pdb есть собственная реализация функции построения стека, которую тоже можно заменить тем же самым способом, что мы с Вами рассмотрели.
Эти исследования привели меня к нескольким выводам. Во-первых, я так и не понял, почему в оригинальном коде имя программы получают из стека. Стоило бы спросить в рассылке OpenStack, но мне не жалко потраченного времени на это исследование. Во-вторых, получение этой информации из __main__.__file__ настолько же надёжно и правильно, как и поиск в стеке во всех ситуациях, а лучше всего при использовании флага -m. И, наконец, перед использованием monkey-patching - подумайте об этом.

Комментариев нет:

Отправить комментарий