По мере разрастания проекта, в котором я сейчас принимаю активное участие, стал все чаще встречаться с подобными опечатками в именах аргументов у функции, как на картинке справа. Особенно дорого в отладке обходились подобные ошибки в конструкторе класса, когда при длинной цепочке наследования передавался неправильный параметр базового класса, или вообще не передавался. Перекраивание интерфейсов на специальные пользовательские структуры вроде namedtuple вместо **kwargs имело несколько проблем:
- Ухудшало взаимодействие с пользователем. Нужно передавать в функцию специально сконструированный объект. Не понятно что делать с необязательными аргументами.
- Усложняло разработку. При наследовании классов нужно наследовать соответствующие структуры аргументов. С namedtuple-ом не получится это сделать, надо писать собственный хитрый класс. Куча работы по внедрению.
- И главное, все равно полностью не спасало от опечаток в именах.
Решение, к которому я в итоге пришел, не может защитить в 100% всех возможных случаев, однако в тех необходимых 80% (в моем проекте, 100%) прекрасно справляется со своей задачей. Если кратко, оно заключается в анализе исходного (байт)кода функции, построении матрицы расстояний между найденными «настоящими» именами и переданными извне и печати предупреждений по заданным критериям. Исходники.
TDD
Итак, сперва точно поставим задачу. В следующем примере должно печататься 5 «подозрительных» предупреждений:
def foo(arg1, arg2=1, **kwargs):
kwa1 = kwargs["foo"]
kwa2 = kwargs.get("bar", 200)
kwa3 = kwargs.get("baz") or 3000
return arg1 + arg2 + kwa1 + kwa2 + kwa3
res = foo(0, arg3=100, foo=10, fo=2, bard=3, bas=4, last=5)
- Вместо arg2 передали arg3
- Вместо bar или baz передали bas
- Вместо bar передали bard
- Помимо foo передали fo
- last вообще лишний
Аналогично, в примере с классами и наследованием должны быть те же предупреждения плюс еще одно (вместо boo передали bog):
class Foo(object):
def __init__(self, arg1, arg2=1, **kwargs):
self.kwa0 = arg2
self.kwa1 = kwargs["foo"]
self.kwa2 = kwargs.get("bar", 200)
self.kwa3 = kwargs.get("baz") or 3000
class Bar(Foo):
def __init__(self, arg1, arg2=1, **kwargs):
super(Bar, self).__init__(arg1, arg2, **kwargs)
self.kwa4 = kwargs.get("boo")
bar = Bar(0, arg3=100, foo=10, fo=2, bard=3, bas=4, last=5, bog=6)
План решения задачи
- Для первого примера с функцией сделаем умный декоратор, для второго с классами — сделаем метакласс. Они должны разделять всю внутреннюю сложную логику и по сути ничем не различаться. Следовательно, сначала делаем внутренний микро API и на нем уже делаем
userspaceпользовательский API. Декоратор назвал detect_misprints, а метакласс — KeywordArgsMisprintsDetector (тяжелое наследие Java/C#, ага). - Задумка решения была в анализе байткода и нахождении матрицы расстояний. Это независимые друг от друга шаги, так что микро API будет состоять из двух соответствующих функций. Я назвал их get_kwarg_names и check_misprints.
- Для анализа кода применим стандартные inspect и dis, для вычисления расстояний между строками — pyxDamerauLevenshtein. В требованиях проекта стояло совместимость с двойкой и с тройкой, а также PyPy. Как видим, зависимости
малину не портятсовместимы с этими требованиями.
get_kwarg_names (извлечение имен из кода)
Тут должна быть портянка кода, но лучше я дам на нее ссылку. Функция принимает на вход функцию которая принимает на вход функцию которая... и должна возвращать множество найденных именованных аргументов. Я не особо комментариеобилен, так что кратко пройдусь по основным моментам.
Первое, что стоит сделать — узнать, есть ли у функции вообще **kwargs. Если нет — возвращаем пустоту. Дальше уточняем имя «двойной звезды», ведь **kwargs это общепринятое соглашение и не более того. Дальше логика, как это часто бывает в портабельном по версиям коде, раздваивается, но не как обычно на ветки для двойки и для тройки, а на < 3.4 и >=. Дело в том, что вменяемая поддержка дизассемблирования (вместе с тотальным рефакторингом dis) появилась именно в 3.4. До этого, как нb странно, без сторонних модулей можно было лишь печатать питоний байткод в stdout (sic!). Функция dis.get_instructions() возвращает генератор экземпляров всех байткодных инструкций анализируемого объекта. Вообще, насколько я понял, единственным надежным описанием байткода является хидер его опкодов, что, конечно, печально, потому что разворачивание в опкоды конкретных инструкций приходилось определять экспериментально.
Мы будем матчить два паттерна: var = kwargs[«key»] и kwargs.get(«key»[, default]).
>>> from dis import dis
>>> def foo(**kwargs):
return kwargs["key"]
>>> dis(foo)
2 0 LOAD_FAST 0 (kwargs)
3 LOAD_CONST 1 ('key')
6 BINARY_SUBSCR
7 RETURN_VALUE
>>> def foo(**kwargs):
return kwargs.get("key", 0)
>>> dis(foo)
2 0 LOAD_FAST 0 (kwargs)
3 LOAD_ATTR 0 (get)
6 LOAD_CONST 1 ('key')
9 LOAD_CONST 2 (0)
12 CALL_FUNCTION 2 (2 positional, 0 keyword pair)
15 RETURN_VALUE
Как видим, в первом случае это комбинация из LOAD_FAST + LOAD_CONST, во втором LOAD_FAST + LOAD_ATTR + LOAD_CONST. Вместо «kwargs» в аргументе инструкций надо искать найденное в начале имя «двойной звезды». Отсылаю за подробным описанием байткода к сведущим людям, ну а мы будем getting things done, то есть двигаться дальше.
А дальше у нас некрасивый workaround для старых версий Питона на регулярных выражениях. С помощью inspect.getsourcelines() получаем список исходных строк функции, и фигачим по каждой прекомпилированной регуляркой. Этот способ еще хуже чем анализ байткода, например, в текущем виде не определятся выражения, состоящие из нескольких строк или несколько выражений, скленных точкой с запятой. Ну, на то он и workaround чтобы сильно не напрягаться… Впрочем, эту часть можно объективно улучшить, хочу pull request :)
check_misprints (матрица расстояний)
Код. На вход получаем результат предыдущего этапа, переданные именованные аргументы, загадочный tolerance и функцию, которой делать предупреждения. Для каждого переданного аргумента нужно найти editing distance до каждого «настоящего», т.е. которого нашли при анализе байткода. На самом деле, незачем считать тупо всю матрицу целиком, если уже нашли идеальное соответствие, дальше можно не продолжать. Ну и, конечно, матрица симметричная, и, следовательно, можно вычислять только ее половину. Думаю, можно еще как-нибудь соптимизировать, но при типичном количестве kwarg-ов, меньшем 30, сойдет и n2. Расстояние будем вычислять Дамерау-Левенштейна как широко известное, популярное и понятное автору :) На хабре о нем писали, например, здесь. Для него написано несколько пакетов под Питон, я выбрал PyxDamerauLevenshtein за портабельность Cython-а, на котором он написан и оптимальное линейное потребление памяти.
Дальше дело техники: если для аргумента не нашлось ни одного даже отдаленно похожего эталона, заявляем о его категорической бесполезности. Если нашлось несколько соответствий с расстоянием меньше tolerance — заявляем о своих смутных подозрениях.
detect_misprints
Классический декоратор, заранее вычисляем «настоящие» имена именованных аргументов (пардон за тавтологию), и при каждом вызове дергаем check_misprints.
KeywordArgsMisprintsDetector
Наш метакласс будет перехватывать момент создания типа класса (__init__, при котором один раз за все время жизни вычислит «настоящие» имена да-да их самых) и момент создания экземпляра класса (__call__, который дергает check_misprints). Единственный момент — у класса есть mro и базовые классы, в конструкторах которых, возможно, тоже используются **kwargs. Так что в __init__-е мы должны пробежать по всем базовым классам и добавить в общее множество имена аргументов каждого.
Как использовать
Просто добавляем описанные выше декоратор к функции или метакласс к классу.
@detect_misprints
def foo(**kwargs):
...
@six.add_metaclass(KeywordArgsMisprintsDetector)
class Foo(object):
def __init__(self, **kwargs):
...
Резюме
Я рассмотрел один из способов борьбы с опечатками в именах **kwargs, и в моем случае он решил все проблемы и удовлетворил всем требованиям. Сначала мы анализировали байткод функции или просто исходный код на старых версиях Питона, а потом строили матрицу расстояний между именами, которые используются в функции, и переданными пользователем. Расстояние считали по Дамерау-Левенштейну, и в конце писали warning-и по двум случаям ошибок — когда аргумент «совсем левый» и когда он похож на один из «настоящих».
Исходный код из статьи выложен на GitHub. Буду рад исправлениям и улучшениям. Также хочу узнать ваще мнение, стоит ли это творение выкладывать на PyPi.
Автор: markhor