company_banner

Старый новый pywinauto: автоматизация Windows GUI на Python на примере install/uninstall

    image
    Однажды, в процессе поиска инструмента для автоматизации GUI тестирования, мне попался интересный питоновский пакет pywinauto. И хотя он поддерживает только нативные контролы и частично Windows Forms, для наших задач он вполне подошёл.
    История pywinauto берёт своё начало где-то в районе 1998 года, когда Mark McMahon написал для своих нужд GUI Automation утилиту на языке C (на это потребовалось года два), а затем, уже в 2005-м, переписал её на Python за три месяца. Мощь питона проявила себя во всей красе: интерфейс pywinauto получился простым и выразительным. Инструмент активно развивался с 2006 по 2010. В годы затишья, в 2011-2012 добрый человек moden-py написал GUI helper для просмотра иерархии окон и генерации pywinauto кода под названием SWAPY (бинарники здесь).
    Тем временем мир менялся. Наша команда перешла на 64-битные бинарники, и клон pywinauto заработал на 64-битном Python. В основной ветке проект не развивался четыре года и порядком устарел. В 2015 году с согласия Марка удалось вдохнуть в проект новую жизнь. Теперь pywinauto официально живёт на гитхабе, а во многом благодаря камраду airelil модульные тесты бегают на CI сервере AppVeyor.

    На данный момент мы выпустили 3 новых релиза линейки 0.5.x (последний — 0.5.2). Главные улучшения по сравнению с 0.4.2:

    • Поддержка 64-битных приложений и x64 питона (правда, нужен 32-битный Python для 32-битных бинарников).
    • Поддержка Python 3.
    • Решены проблемы с PyPI пакетом.
    • Удалось подружить pywinauto c py2exe и ему подобными.
    • Улучшена поддержка ряда контролов, особенно тулбара, tree view и list view.
    • Можно включить запись большинства действий в лог через pywinauto.actionlogger.enable().
    • Ещё целый ряд мелких улучшений и баг фиксов.


    Последние четыре года наша команда успешно использует pywinauto для тестирования внутреннего софта, включающего сложные графические custom контролы. Для них есть собственные wrapper'ы, использующие метод HwndWrapper.SendMessage и класс RemoteMemoryBlock (тоже, кстати, улучшенный по ходу дела). Но это тема для отдельного разбора, т.к. открытых примеров custom контролов для pywinauto я не встречал.

    Пока рассмотрим некоторые возможности pywinauto на конкретном примере.

    Пример автоматизации install / uninstall


    Часто встречается задача автоматизировать установку/удаление софта на 100500 тестовых машинах. Покажем, как это можно сделать на примере 7zip (пример демонстрационный!). Предполагается, что 64-битный installer заранее скачан с www.7-zip.org и лежит, например, в одной папке с нашими скриптами. На тестовых машинах User Account Control (UAC) отключен до нулевого уровня (обычно это изолированная подсеть, что не вредит безопасности).

    Установка


    Так выглядит установочный скрипт install_7zip.py (по ссылке — обновляемая версия):

    from __future__ import print_function # for py2/py3 compaibility
    import sys, os
    
    # assume the installer is placed in the same folder as the script
    os.chdir(os.path.join(os.getcwd(), os.path.dirname(sys.argv[0])))
    import pywinauto
    
    app = pywinauto.Application().Start(r'msiexec.exe /i 7z920-x64.msi')
    
    Wizard = app['7-Zip 9.20 (x64 edition) Setup']
    Wizard.NextButton.Click()
    
    Wizard['I &accept the terms in the License Agreement'].Wait('enabled').CheckByClick()
    Wizard.NextButton.Click()
    
    Wizard['Custom Setup'].Wait('enabled')
    Wizard.NextButton.Click()
    
    Wizard.Install.Click()
    
    Wizard.Finish.Wait('enabled', timeout=30)
    Wizard.Finish.Click()
    Wizard.WaitNot('visible')
    
    if os.path.exists(r"C:\Program Files\7-Zip\7zFM.exe"):
        print('OK')
    else:
        print('FAIL')
    


    С установкой все довольно просто, но есть пара неочевидных моментов. Чтобы включить check box для согласия с лицензией, мы используем метод CheckByClick(), появившийся в pywinauto 0.5.0, потому что многие чек боксы обрабатывают не сообщение WM_CHECK, а реагируют только на настоящий клик.

    Длинное ожидание самого процесса установки обеспечивает метод Wait() с явным параметром timeout=30 (в секундах). То есть сам объект Wizard.Finish — это лишь описание кнопки, и оно не связано с реальной кнопкой, пока не вызван метод Wait() либо любой другой метод. Строго говоря, вызов Wizard.Finish.Click() эквивалентен более длинному вызову Wizard.Finish.WrapperObject().Click() (обычно он происходит неявно) и почти эквивалентен Wizard.Finish.Wait('enabled').Click(). Можно было написать в одну строчку, но иногда стоит подчеркнуть важность метода Wait().

    Удаление


    Скрипт для удаления uninstall_7zip.py устроен несколько сложнее, потому что приходится лезть в панель управления, в раздел «удаление программ». При желании, используя explorer.exe, можно автоматизировать и другие задачи.

    from __future__ import print_function
    import pywinauto
    
    pywinauto.Application().Start(r'explorer.exe')
    explorer = pywinauto.Application().Connect(path='explorer.exe')
    
    # Go to "Control Panel -> Programs and Features"
    NewWindow = explorer.Window_(top_level_only=True, active_only=True, class_name='CabinetWClass')
    try:
        NewWindow.AddressBandRoot.ClickInput()
        NewWindow.TypeKeys(r'Control Panel\Programs\Programs and Features{ENTER}', with_spaces=True, set_foreground=False)
        ProgramsAndFeatures = explorer.Window_(top_level_only=True, active_only=True, title='Programs and Features', class_name='CabinetWClass')
    
        # wait while list of programs is loading
        explorer.WaitCPUUsageLower(threshold=5)
    
        item_7z = ProgramsAndFeatures.FolderView.GetItem('7-Zip 9.20 (x64 edition)')
        item_7z.EnsureVisible()
        item_7z.ClickInput(button='right', where='icon')
        explorer.PopupMenu.MenuItem('Uninstall').Click()
    
        Confirmation = explorer.Window_(title='Programs and Features', class_name='#32770', active_only=True)
        if Confirmation.Exists():
            Confirmation.Yes.ClickInput()
            Confirmation.WaitNot('visible')
    
        WindowsInstaller = explorer.Window_(title='Windows Installer', class_name='#32770', active_only=True)
        if WindowsInstaller.Exists():
            WindowsInstaller.WaitNot('visible', timeout=20)
    
        SevenZipInstaller = explorer.Window_(title='7-Zip 9.20 (x64 edition)', class_name='#32770', active_only=True)
        if SevenZipInstaller.Exists():
            SevenZipInstaller.WaitNot('visible', timeout=20)
    
        if '7-Zip 9.20 (x64 edition)' not in ProgramsAndFeatures.FolderView.Texts():
            print('OK')
    finally:
        NewWindow.Close()
    


    Здесь есть несколько ключевых моментов.

    При запуске explorer.exe кратковременно создаётся запускающий процесс (launcher), который проверяет, что explorer.exe (worker) уже запущен. Такая связка «launcher->worker» иногда встречается. Поэтому отдельно подсоединяемся к рабочему процессу explorer.exe методом connect().

    После клика на адресную строку (AddressBandRoot) появляется так называемый in-place edit box (только на время ввода). При вызове метода TypeKeys() обязательно указываем параметр set_foreground=False (появился в 0.5.0), иначе in-place edit box исчезнет с радаров. Для всех in-place контролов рекомендуется ставить этот параметр в False.

    Далее, список программ инициализируется долго, однако сам ListView контрол доступен и простой вызов ProgramsAndFeatures.FolderView.Wait('enabled') не гарантирует, что он уже инициализирован полностью. Ленивая (lazy) инициализация идёт в отдельном потоке, поэтому необходимо отслеживать активность CPU всего процесса explorer.exe. Для этого в pywinauto 0.5.2 реализовано два метода: CPUUsage(), возвращающий загрузку CPU в процентах, и WaitCPUUsageLower(), ждущий, пока загрузка CPU не упадёт ниже порога (2.5% по умолчанию). Идею реализации этих методов подсказала статья камрада JOHN_16: «Отслеживаем завершение процессом загрузки CPU».

    Кстати, вызов item_7z.EnsureVisible() волшебным образом прокручивает список так, чтобы искомый item стал виден. Никакой специальной работы со скролл баром не нужно.

    Несколько вызовов Wait и WaitNot означают, что нужно подождать открытия или закрытия определенного окна относительно долго (дольше, чем по умолчанию). Впрочем, некоторые вызовы WaitNot вставлены просто для контроля. Это хорошая практика.

    «Ведь жизнь, она и проще, и сложнее...»


    Конечно, это был всего лишь пример. В случае с 7zip всё решается гораздо проще. Запускаем cmd.exe as Administrator и выполняем простую строку (работает при любом уровне UAC):
        wmic product where name="7-Zip 9.20 (x64 edition)" call uninstall
    

    Разумеется, зоопарк инсталляторов не ограничивается .msi пакетами, а спектр задач автоматизации очень широк.

    О чем чаще всего спрашивают


    Если раньше главный вопрос был про Python 3 и 64 бита, то сейчас на повестке дня стоит поддержка WPF и ряда других не нативных приложений, поддерживающих UI Automation API. Наработки в этом направлении есть. Любую помощь в адаптации различных back-end'ов под интерфейс pywinauto мы приветствуем.
    Метки:
    Intel 145,59
    Компания
    Поделиться публикацией
    Реклама помогает поддерживать и развивать наши сервисы

    Подробнее
    Реклама
    Комментарии 15
    • 0
      Вот если было бы еще нормальное решение для GPO, а то обычно это танцы с бубном что то поменять:)
      • 0
        А можно поподробнее про GPO?
        Для пополнения collection of use cases. :)
      • 0
        >>> Часто встречается задача автоматизировать установку/удаление софта на 100500 тестовых машинах. Покажем, как это можно сделать на примере 7zip.

        cinst 7zip
        • 0
          Ну да, пример иллюстративный. Ниже об этом написано. Сверху тоже пометил, чтоб было понятнее.
          • 0
            да, я понял это просто хотелось бы напомнить о клевой возможности скачать и установить софт под виндой одной командой. Кстати, придумать жизненный и короткий пример применения — особый вид искусства. Я например автоматил некую нерасширямую оболочку на AutoHotKey, и присамтривался в те времена к pywinauto, но что-то не сложилось
            • 0
              По мне, так StackOverflow — хороший источник примеров (на будущее коллекционирую). Конкретно этот, про uninstall, мне подсказали коллеги из других команд.
              • 0
                имеется ввиду именно демонстрационный пример. Одним из его достоинств должно быть то, что он ужу подобран автором статьи :)
        • 0
          Интересно, а сможет ли он работать с WTL?
          • 0
            Насколько я понял из описания WTL, должен смочь.
            • 0
              Смотрю на github и вижу, что нужно первым делом ставить pywin32. И у меня возникает вопрос, а почему это не автоматизировано? Может не все верно понял и 'pip install pywinauto' все заменя поставит?! К примеру, как это делает flask таща за собой werkzeug, itsdangerous и др. ;)
              • 0
                «pip install pywinauto» поставит pyWin32 сам. Зависимость прописана на PyPI. Правда, там он лежит под именем pypiwin32, но это не столь существенно.
          • 0
            С явой оно работать сумеет? Хотелось бы банк автоматизировать.
            • 0
              Это можно быстро проверить в SWAPY, видит ли он контролы и какие. Но, вообще, Java — навряд ли. Если только координатным методом прокликивать по всему окну, но это не гуд, конечно.
            • 0
              Такой вопрос:
              Есть Delphi XE8 desktop application, проблема заключается в том, что Меню представляет собой TActionManager. Хочется получить доступ, например, к Программа — > Закрыть. Контролы:
              Control Identifiers:
              TActionToolBar - 'b'''   (L0, T51, R1861, B78)
              
              'b'''
              'b'0''
              'b'1''
              'b'TActionToolBar''
              
              TActionMainMenuBar - 'b'\\u041c\\u0435\\u043d\\u044e''   (L0, T22, R1861, B51)
              
              'b'TActionMainMenuBar''
              'b'\\u041c\\u0435\\u043d\\u044e''
              'b'\\u041c\\u0435\\u043d\\u044eTActionMainMenuBar''
              
              TStatusBar - 'b'''   (L0, T1095, R1861, B1114)
              
              'b'2''
              'b'StatusBar''
              
              MDIClient - 'b'''   (L0, T78, R1861, B1095)
              
              'b'3''
              'b'MDIClient''

              Что уже пробовалось:
              app.MainForm.MenuSelect(«item») — меню недоступно
              app.MainForm.ActionManager.Item1.Click(), app.MainForm.ActionManager.Client1.Click(), app.MainForm.ActionManager.MainMenu.Click()- нет таких атрибутов
              __getitem__, __getattr__ — тоже ничего не дали
              Как правильно обратиться к меню?
              Заранее, спасибо.
              • 0
                Если есть бинарник с аналогичным меню (сэмпл какой-нибудь), киньте, пожалуйста, в личку.
                Или, если есть trial версия Delphi, подкиньте ссылку. Последний раз имел дело с Delphi лет 14 назад. :)
                Но кое-что, возможно, получится сделать.

              Только полноправные пользователи могут оставлять комментарии. Войдите, пожалуйста.

              Самое читаемое