Кросс-вмный (CLR/JVM) код на Python

в 14:13, , рубрики: .net, clr, ironpython, java, jython, python, кроссплатформенная разработка, метки: , , , , ,

Это узкоспециализированная короткая заметка про то, как я запинывал write once, run everywhere тесты для библиотеки, портированной с C# на Java, при помощи Python.

Смысл в следующем: есть большая, толстая и красивая библиотека, которая была по коммерческим соображениям портирована с C# на Java. API осталось почти одинаковым, naming conventions естественно сменились при переходе на другой язык. Нам нужно было написать толстую пачку тестов, проверяющих, что клон библиотеки работает идентично оригиналу (тесты на регрессии, иными словами). Для этого сравнивались результаты работы кода библиотек (некие бинарники и xml-метаданные). Тесты были нетривиальные, их было много, и что самое неприятное — они постоянно дописывались с одного конца командой из четырех человек. Некоторое время я старательно портировал их на Java, затем плюнул и предложил команде писать тесты на языке, который сразу можно было бы выполнять на CLR (со старой библиотекой) и на JVM (с клоном). Оказалось, они и сами уже некоторое время думали про Python, и вот как это получилось.

1. Basics

Ну, о примитивном. На CLR я код запускаю IronPython-ом, на JVM — Jython-ом соответственно. Из приятного — Jython сам предоставляет загружаемым Java-классам вместо геттеров-сеттеров механизм пропертей. Почему это приятно? Потому что при портировании на Java шарповые проперти естественно были заменены геттерами-сеттерами.

2. Где я?

Полезно знать, поверх какого рантайма мы сейчас выполняемся. Тут все просто.

isDotNet = sys.version.find('IronPython') > -1
3. Подключение нужных библиотек

В IronPython и Jython это делается по разному (с помощью библиотечки clr в IronPython, и просто добавлением в path в Jython), да и конвенция именования модулей разная, так что унифицируем:

def cpUseLibrary(lib_path, library):
    if isDotNet:
        sys.path.append(lib_path)
        import clr
        clr.AddReference('OurProduct20.' + library)
    else:
        sys.path.append(lib_path + '\java_libs\ourproduct20.' + library.lower() + '.jar')

Используется это в рабочем коде следующим тупым образом:

cpUseLibrary(path_to_bins, 'Core')
cpUseLibrary(path_to_bins, 'Formats.Common')
4. Загрузка нужных классов.

Вот тут небольшая проблема. Вообще и тот, и другой интерпретаторы позволяют тупо делать import из нужных namespace-ов / пакаджей, но нам-то нужна динамическая конструкция, учитывая, опять же, разные конвенции именования пакетов.

def cpImport(module, clazz, globs = globals()):
    # load class
    mname = '';
    if isDotNet:
        mname = 'OurProduct.' + module
    else:
        mname = 'com.ourcompany.ourproduct.' + module.lower()
    pckg = __import__(mname, globals(), locals(), [clazz], -1)
    aliased_class = getattr(pckg, clazz)
    globs[clazz] = aliased_class

Смысл в использовании __import__(), как питоновского внутреннего механизма импорта из модулей. Собственно, запись «import from откуда-то что-то» в __import__() в конечном итоге и разворачивается. Текущая проблема, которую не дошли пока руки решить — это необходимость передавать globals() каждый раз в такой импорт (из-за того, что все кроссплатформенные методы вынесены в отдельный модуль). Выглядит это следующим образом:

cpImport('Core', 'OurProductLicense', globals())
cpImport('Formats', 'OurProductCommonFormats', globals())

Не очень красиво, но опять же, работает унифицированно на обоих рантаймах.

5. Решение проблемы конвенции именования

Разница в именовании методов очень простая: в C# они с заглавной начинаются, в Java — со строчной. Вот чем удобны современные скриптовые языки, так это возможностью метапрограммирования. Не лисп, наверное, но все равно. Руби тут был бы еще сподручнее, но и так неплохо. Дописываем cpImport():

    aliased_class = getattr(pckg, clazz)
    # add uppercased aliases for its lowercased methods (unless there's already an uppercased method with the same name)
    original_methods = aliased_class.__dict__.copy()
    for name, method in original_methods.iteritems():
        if name[0:1] in string.uppercase:
            continue
        newname = name[0:1].upper() + name[1:]
        if hasattr(aliased_class, newname):
            continue
        setattr(aliased_class, newname, method)
    globs[clazz] = aliased_class

Смысл тут в использовании setattr() для прописывания алиаса на метод. Фактически, всем методам, начинающимся со строчной буквы, мы прямо в классе создаем алиас, начинающийся с заглавной.

Как это выглядит в целом. Файлик test.py:

from cptest import isDotNet, cpUseLibrary, cpImport

cpUseLibrary('Core')
cpUseLibrary('Formats.Common')
cpImport('Core', 'OurProductLicense', globals())
cpImport('Formats', 'OurProductCommonFormats', globals())

OurProductLicense.SetProductID(".NET Product ID" if isDotNet else "JavaProductID")
OurProductCommonFormats.Initialize();

Выполняется простым ipy test.py / jython test.py из одного скрипта без танцев с бубном. Что приятно.

P.S. Возможные проблемы

Ну, они довольно очевидны. Нам повезло, наши разработчики в процессе портирования достаточно скурпулезно придерживались одной конвенции именования и портирования имен. Поэтому библиотеки, методы и прочее, действительно просто вызывать так, как описано выше. Иначе пришлось бы составлять некую табличку исключений, и учитывать ее в методе cpImport(). Но в целом описанный подход мне очень понравился и выглядит живым решением такой вот редко, наверное, встречающейся проблемы.

Автор: EaE

* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js