Поиск имени программы из которой был запущен модуль Python может быть сложнее, чем кажется на первый взгляд и поиск причин этого привёл меня к нескольким интересным результатам.
Несколько недель назад на OpenStack Folsom Summit, Mark McClain показал интересный код, который он обнаружил в исходниках Nova:
nova/utils.py: 339
Этот код - часть логики поиска конфигурационного файла, который находится в папке, указанной относительно места запуска исходной программы. В этом коде мы смотрим на стек в поисках основной программы и оттуда получаем её имя.
Похоже, что этот код взят из ответа на вопрос с StackOverflow, и когда я первый раз его увидел, я подумал, что для решения такой проблемы это слишком проблемное решение. У Марка была похожая реакция и он спросил меня, не знаю ли я более простого способа сделать это.
...для решения такой проблемы это слишком проблемное решение...
Похожие примеры с
inspect.stack() встречаются ещё в четырёх местах исходного кода Nova (по крайней мере на данный момент). Все они используются для того, чтобы построить путь к файлу относительно места запуска главной программы или получить имя этой программы, чтобы построить путь к другому файлу (например, файлу лога или третьей программы). Это хорошая причина внимательно относиться к месту и имени этой программы, однако это не объясняет, почему более очевидные пути тут не подходят. Я предположил, что если уж разработчики OpenStack смотрят на фрейм стека, значит у этого есть своя причина. Эта мысль побудила меня изучить исходный код и потратить немного времени на расшифровку того, что же там происходит, и особенно на поиск случаев, когда этот подход не будет работать так, как должен бы (и в таком случае внести нужные патчи).
Стек
Вызов inspect.stack() получает стек интерпретатора Python для текущего потока. Возвращаемое значение - это список с информацией о вызывающей функции с индексом 0, а верх стека находится в конце списка. Каждый элемент в списке - это кортеж, содержащий:
- структуру данных фрейма стека
- имя файла для кода, который выполняется в данный момент
- номер строки в этом файле
- значение co_name объекта кода из фрейма, соответствующее имени функции или метода, который выполняется в данный момент
- исходник этого кода, если доступен
- индекс в списке строк исходника, определяющий выполняемую в данный момент строку
show_stack.py
Эта информация предназначается для создания tracebacks или для инструментов вроде pdb, для отладки приложения (хотя pdb имеет собственную реализацию такой функциональности). Для ответа на вопрос “Какую программу я сейчас запустил?” нас интересует имя файла:
Очевидная проблема с этим - имя файла даётся относительно папки запуска приложения. Это может дать нам не правильный результат, если рабочая директория была изменена между запуском и проверкой стека. Но есть и другой вариант, когда при взгляде на стек мы получим неверный результат.
Простые решения не всегда дают правильный ответ.
Опция -m интерпретатора запускает модуль runpy, который принимает имя модуля и запускает его как основную программу. В этом случае, вверху стека окажется runpy, так что наша программа находится несколькими уровнями ниже. Простые решения не всегда дают нам правильный ответ.
Почему очевидные решения не работают
Теперь, кода я знаю, что можно получить неправильные результаты глядя на стек, мой следующий вопрос в том - есть ли другие пути найти имя программы, более простые,эффективные и, в особенности, правильные. Самое простое решение - посмотреть на аргументы командной строки - sys.argv.
argv.py
Обычно первый элемент в sys.argv это скрипт, который и был запущен как главная программа. Значением этого элемента всегда будет этот файл, хотя путь может быть как абсолютным, так и относительным:
Как видно из этого примера, когда скрипт запускается непосредственно или путём передачи его интерпретатору, sys.argv содержит относительный путь к скрипту. Если же мы используем опцию -m мы получаем полный путь, так что просмотреть аргументы командной строки - вполне здоровое решение. Хотя, мы не можем быть уверены в наличии этой опции, мы не всегда можем получить путь к файлу со всеми нужными нам подробностями.
Используем import
Следующей альтернативой, которую я решил проверить, это сам модуль программы. Каждый модуль содержит специальное свойство, __file__, которое содержит путь к файлу из которого и был загружен модуль. Для доступа к модулю главной программы из Python Вам надо импортировать специальный модуль __main__. Чтобы протестировать этот метод я создал специальную программу, которая загружает другой модуль:
import_main_app.py
And the second module imports __main__ and print the file it was loaded from.
import_main.py
Глядя на модуль __main__ мы всегда можем определить запущенную главную программу, но не всегда получим полный путь. На самом деле это понятно, так как имя модуля в стеке берётся как раз отсюда.
Копаем глубже
После того, как я нашёл такой простой способ получения имени программы, я стал думать о причинах, побудивших использовать стек для этих же целей. В итоге мне пришло в голову всего две идеи. Во-первых, вполне возможно, что авторы проекта просто не знали об этом способе. Импортирование __main__ не такая штука, которая требуется Вам каждый день, я сам-то и не помню, откуда я узнал про этот способ (я даже не помню, чтобы я сам его использовал). Это наиболее правдоподобная идея, но есть и ещё один вариант - авторы хотели получить гарантированно правильное значение, не испорченное чем-то случайно. Эта идея побудила меня на дальнейшие изучения, так как мне хотелось понять, какой из способов соответствует этому критерию.
Для того, чтобы понять, что sys.argv можно изменить, мне даже не потребовалось экспериментировать. Так как этот аргумент сохраняется в простом объекте list, его легко можно изменить:
argv_modify.py
Тут поддерживаются все операции списка, так что замена имени программы вполне тривиальная операция. Как и удаление или вырезка элементов из этого списка.
Атрибут __file__ модуля - это строка, которую саму изменить нельзя, но можно присвоить ей новое значение:
import_modify.py
Это, конечно, менее вероятно, что произойдёт случайно, так что способ достаточно безопасный. Однако, само по себе это простая операция.
Так что остаётся только стек.
Ещё глубже
Как мы уже говорили выше, inspect.stack() возвращает список кортежей. Список вычисляется каждый раз, когда вызывается эта функция, так что это единственное место, где его можно случайно изменить. Ключевое слово - случайно, но даже и злонамеренные программы столкнутся с проблемами, пытаясь это сделать.
stack_modify1.py
Имя файла присутствует в двух местах возвращаемых данных. Во-первых - это кортеж, который является частью стека, а во-вторых - это объект кода в этом же кортеже (frame.f_code.co_filename).
Изменить объект кода будет сложно.
Заменить имя файла в кортеже относительно просто и этого вполне достаточно для кода, который доверяет стеку, возвращаемому inspect.stack(). Но изменить объект кода будет сложно. В C Python, класс code реализован на C как часть набора объектов, используемых интерпретатором.
Objects/codeobject.c
Все данные объекта кода определены как READONLY, что значит, что Вы не можете изменить их из Python напрямую.
code_modify_fail.py
Попытка их изменения приводит к TypeError.
Вместо изменения объекта кода можно попробовать заменить его другим объектом. Так как ссылка на объект кода содержится в объекте фрейма, мне потребуется изменить и фрейм. Объект фрейма неизменяемый, но, можно сделать "левый" фрейм и заменить им оригинальный. К сожалению, не возможно создать экземпляры code или frame из Python, так что будем их имитировать.
stack_modify2.py
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.
Замена объектов фрема и кода будет работать хорошо, если мы обратимся к объекту кода напрямую, но мы получим ошибку, если попробуем использовать inspect.getframeinfo() так как в начале функции getframeinfo() (см линию 16) есть явная проверка типа, возбуждающая TypeError.
http://hg.python.org/cpython/file/35ef949e85d7/Lib/inspect.py#l987
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() (на самом деле - мою функцию, которая её замещает) и увидеть изменённое имя файла, которое она вернёт.
После ещё одного взгляда на inspect.py в попытке понять, надо ли мне изменить что-то ещё, я нашёл лучшее решение. Реализация inspect.stack() простая, так как вызывается функция inspect.getouterframes() для построения списка фреймов. Фрейм, передаваемый в getouterframes() берётся из sys._getframe().
Оставшаяся часть стека получается из первого фрейма, возвращаемого _getframe() с использованием атрибута f_back для связи между фреймами.
http://hg.python.org/cpython/file/35ef949e85d7/Lib/inspect.py#l1025
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() всё ещё нужны для того, чтобы избежать исключений при проверке типа.
Хватит на сегодня
На этот момент я убедился, что хотя маловероятно, что кто-то будет делать все эти телодвижения в реальной программе (и уж тем более не будет делать это случайно), тем не менее вполне возможно предоставить программе ложную информацию о ней самой. Эта реализация не будет работать с pdb, так как он не использует inspect. У pdb есть собственная реализация функции построения стека, которую тоже можно заменить тем же самым способом, что мы с Вами рассмотрели.
Эти исследования привели меня к нескольким выводам. Во-первых, я так и не понял, почему в оригинальном коде имя программы получают из стека. Стоило бы спросить в рассылке OpenStack, но мне не жалко потраченного времени на это исследование. Во-вторых, получение этой информации из __main__.__file__ настолько же надёжно и правильно, как и поиск в стеке во всех ситуациях, а лучше всего при использовании флага -m. И, наконец, перед использованием monkey-patching - подумайте об этом.
Комментариев нет:
Отправить комментарий