파이썬 매크로 만들기

mmoho·2022년 7월 10일
1
post-thumbnail

지인 분이 필요하실만한 매크로 프로그램을 만들어보기로 했다. 최근 파이썬을 공부하고 있는데, 문법과 표준 모듈 몇 개만 공부하고 그치기엔 아까워서 익숙한 언어로 만들기보다 새롭게 배우는 언어로 해보는 게 좋겠다 싶었다.

만들고자 하는 매크로의 기능은 간단하다. 우선 지인분은 개인 러스트 서버를 운영하고 있는데 매달 초기화를 하고 나서 서버 유저들의 설정을 복원하기 위해 유저마다 어드민 명령을 입력한다.
/명령어 유저아이디 설정값 이런 명령을 반복적으로 입력하는데, 엔터 -> 명령어 입력 -> 엔터 이 과정이 반복되다 보니 간단하게 매크로로 만들어볼 수 있을 것 같았고, 아래와 같은 조건을 만족시켜보기로 했다.

  1. GUI 기반이어야 한다.
  2. 백그라운드에서 매크로 실행/정지를 위해 키 입력을 캡처할 수 있어야 한다.
  3. 텍스트 파일로부터 명령어를 불러와 자동으로 입력시킬 수 있어야 한다.
  4. 딜레이를 조절할 수 있어야 한다.
  5. 간편하게 실행할 수 있어야 한다.

키 매크로와 같이 잘 만들어진 매크로들이 많지만, 3번의 텍스트 파일을 읽어와 처리를 하기 위한 매크로는 찾기 어려웠다. 있더라도 매크로 스크립트를 작성해야 하는 것들이라서 그 보다는 직접 파이썬으로 작성하는 것이 좋겠다는 판단으로 필요한 라이브러리들을 찾아보기 시작했다.

GUI는 Tkinter, PyQt5가 대표적이었는데, PyQt5는 GUI 기반의 디자인 툴을 제공해서 PyQt5로 선택, 키보드 캡처/입력은 keyboard와 pynput을 비교하였고 keyboard가 조금 더 직관적이고 사용하기 편해서 keyboard로 결정.
이 프로그램을 사용할 지인분은 개발자가 아니기 때문에 파이썬을 설치하고, 스크립트를 실행하는 등 사용하기 어려워서는 안된다는 조건을 정했다. 파이썬 라이브러리 중 pyinstaller는 코드를 패키징하여 실행가능한 파일을 만들어준다. (그런데 이렇게 하더라도 파이썬 런타임이 필요할지, 아닐지는 모르겠다. 아예 파이썬이 없는 환경에서 실행해보지는 못했다.)

구현 순서상 GUI 내용부터 시작하고 있고 조금 깁니다. 매크로 기능을 찾다가 글을 읽게 되었다면 스크롤을 내려 "매크로 기능 구현하기"를 읽어주세요.


GUI 구성하기

기능이 복잡하지 않아 GUI의 구성은 간단하다.

  1. 불러오기 버튼을 클릭하면 파일 선택 다이얼로그가 표시되고, txt 확장자를 갖는 텍스트 파일만 선택할 수 있다.
  2. 텍스트 파일을 불러오면, 기존이 리스트에 채워져 있던 내용은 지우고, 텍스트 파일의 각 라인이 표시된다. 그냥 멀티라인 텍스트 필드 같은 컴포넌트를 사용해도 되지만 리스트를 써보고 싶었다.
    현재 차례의 명령을 표시한다던가 하는 기능은 구현하지 않는다.
  3. "엔터, 명령어 입력, 엔터" 동작 사이의 딜레이를 초단위로 설정한다. 러스트에서 엔터키를 치면 채팅 창이 활성화되는데, 엔터키와 명령어 입력 사이에 딜레이가 없으면 채팅 창이 뜨기 전에 명령어가 입력되어 제대로 동작하지 않는 경우가 있다. 컴퓨터 사양에 따라 차이가 있을 수 있어 조정할 수 있도록 구성했다. 정수로 초단위를 설정하는 경우 차이가 너무 크므로 미세하게 설정할 수 있도록 0.1 스텝으로 소수점 값을 입력할 수 있도록 했다.
  4. 상태는 매크로가 실행되는 동안에는 러스트 창을 보고있어 어차피 볼 수 없지만, 허전해서 넣어봤다(...)

구성요소와 레이아웃의 설계를 잡았으면 이제 PyQt5의 디자이너에서 작업할 차례다. (사실 위의 그림은 글을 쓰기 위해 만든거고, 실제로는 저 작업 자체를 디자이너에서 작업했다.)

PyQt5 디자이너를 사용하기 위해서는 먼저 pyqt5-tools를 설치해야 한다. 소스코드 내에서는 pyqt5 모듈을 사용해야 하므로 함께 설치해준다.

PS> pip install pyqt5
PS> pip install pyqt5-tools

pyqt5-tools를 설치하면 파이썬이 설치된 폴더의 Lib/site-packages/qt5_applications/Qt 경로 안에 designer.exe 파일이 있는 것을 볼 수 있다.
파이썬이 설치된 폴더는 설치하는 환경에 따라 위치가 다를 수 있을 것 같은데, 내 경우는 PyCharm에서 virtualenv로 설치하고 직접 설정한 디렉토리에 파이썬 패키지 파일이 위치해 있어서 해당 경로에서 찾을 수 있었고, 만약 파이썬 공식 사이트에서 윈도우즈의 설치형으로 다운로드 받아 설치했다면 사용자 홈 디렉토리(보통 C:\Users\<사용자명> 아래)에서 AppData/Local/Program/Python/<파이썬버전> 폴더에서 Lib 폴더를 찾을 수 있을 것이다.

designer.exe 파일을 실행하면 GUI 환경에서 애플리케이션의 PyQt5 레이아웃과 컴포넌트 등을 쉽게 구성할 수 있다.

내 경우는 HorizontalLayout과 같은 레이아웃 컴포넌트를 사용하지 않고 절대 위치로 컴포넌트들의 위치를 잡았다. (캡처에 보이는 오른쪽 빨간 색 테두리는 FormLayout이긴하다.) 대개의 경우 레이아웃 컴포넌트를 활용하는 게 컴포넌트들의 위치를 잡고 컨테이너 단위로 묶어서 관리할 수 있게 되기 때문에 더 좋을 것이다. 안드로이드, C#, AWT/Swing 등의 GUI 경험으로 봤을 때 코드 상에서도 더 구조적으로 컴포넌트를 관리할 수 있다.

Qt Designer에서 GUI를 구성하고 저장하면 XML로 기술된 .ui 확장자를 갖는 파일로 저장되고, pyuic5 명령어를 통해 파이썬 코드로 변환할 수 있다.

auto_command.ui로 저장한 파일을 auto_command.py로 변환하기 위한 명령은 아래와 같다.

PS> pyuic5 -x -o auto_command.py auto_command.ui

위 명령을 실행하면 auto_command.py 파일이 생긴 것을 확인할 수 있다.

# -*- coding: utf-8 -*-

# Form implementation generated from reading ui file 'auto_command.ui'
#
# Created by: PyQt5 UI code generator 5.15.7
#
# WARNING: Any manual changes made to this file will be lost when pyuic5 is
# run again.  Do not edit this file unless you know what you are doing.


from PyQt5 import QtCore, QtGui, QtWidgets


class Ui_MainWindow(object):
    def setupUi(self, MainWindow):
        MainWindow.setObjectName("MainWindow")
        MainWindow.resize(672, 279)
        self.centralwidget = QtWidgets.QWidget(MainWindow)
        self.centralwidget.setObjectName("centralwidget")
        ... 생략


if __name__ == "__main__":
    import sys
    app = QtWidgets.QApplication(sys.argv)
    MainWindow = QtWidgets.QMainWindow()
    ui = Ui_MainWindow()
    ui.setupUi(MainWindow)
    MainWindow.show()
    sys.exit(app.exec_())

간편하게 PyQt5 기반의 GUI 코드를 생성했다. 리팩토링이 필요해 보이지만 무시하기로 했다. 컨벤션도 마음에 들지 않지만 무시하기로 했다. 어차피 PyQt5의 함수나 필드가 파이썬 표준 컨벤션과는 달라 바꿀 수 있는 부분을 바꾸더라도 PyQt5에 종속된 코드는 바꿀 수가 없다. (코드가 오염된 느낌이다.)
어차피 이 코드는 나만 볼 수 있을 것이고 사용하는 지인분은 GUI만 보게 될 것이므로 pyuic5가 만들어준 코드 위에 기능들을 붙여나가기로 했다. 혹시 코드를 보는데 불편함이 느껴지신다면 적당히 양해를 부탁드린다.


GUI 동작 구현하기

GUI 컴포넌트의 상호 작용은 QT Designer에서 구현할 수 없기 때문에 관련된 코드를 직접 작성한다.

먼저 파일 다이얼로그. 파일 다이얼로그 클래스는 PyQt5 모듈에서 QtWidgets에 위치해 있다. "파일 열기" 버튼의 클릭 이벤트에 핸들러를 설정하고, 해당 핸들러에서 아래와 같이 작성한다.

...
from PyQt5 import QtWidgets

def setupui(self, MainWindow):
   ... 생략
   # 버튼 클릭 이벤트에 핸들러 함수를 연결한다.
   self.openFileButton.clicked.connect(self.handle_open_file_button)
   ... 생략

def handle_open_file_button(self):
   fileNames = QtWidgets.QFileDialog.getOpenFileName(self.centralwidget, '파일 열기', filter='*.txt')
   # fileNames 리스트에서 파일 이름을 얻을 수 있다.

파일 다이얼로그를 여는 getOpenFileName 함수의 첫 번째 인자는 다이얼로그의 부모 컨테이너(메인 윈도우를 참조할 수 없어서 기본적으로 생성된 centralwidget으로 설정했다.), 두 번째 인자는 창 타이틀, 세 번째 인자는 필터 옵션이다.
텍스트 파일로만 설정하기 위해 확장자가 txt인 것으로 필터를 설정했다.

파일 다이얼로그에서 파일을 선택하면 getOpenFileName 함수의 리턴값으로 선택된 파일의 절대 경로를 얻을 수 있다. 이 때 리턴값은 파일 다이얼로그에서 여러 파일을 선택할 수 있으므로 리스트 타입이다.

파일을 선택하지 않고 닫을 수 있으므로 선택된 파일이 있는지 확인을 해야하고, 나는 첫 번째 파일만 불러올 것이므로 리스트의 첫 번째 요소만 확인하여 사용한다. 또한 파일 다이얼로그에서 파일을 선택함과 함께 파일의 내용을 리스트에 설정해주도록 했다. 전체 handle_open_file_button 함수는 아래와 같다.

def setupUi(self, MainWindow):
    ...
    # 최초에 아이템 모델(QStandardItemModel) 인스턴스를 설정해주어야 한다.
	self.commandList.setModel(QtGui.QStandardItemModel())
    ...

def handle_open_file_button(self):
    try:
        frame = QFileDialog.getOpenFileName(self.centralwidget, '파일 열기', filter='*.txt')
        if frame[0]:
            print('파일을 불러옵니다. {}'.format(frame[0]))
            with open(frame[0], 'r', encoding='UTF-8') as file:
                # 처음에 설정한 모델 인스턴스를 사용하거나
                # 혹은 다시 생성하면서 setModel을 해줘도 무방할 듯하다.
                model = self.commandList.model()
                
                # 기존의 아이템을 비운다.
                model.clear()

                for line in file:
                    line = line.strip()
                    # 텍스트에 내용이 있을 때만 아이템을 추가한다.
                    if line:
                        model.appendRow(QtGui.QStandardItem(line))
    except Exception as e:
        print(e)

(뎁스가 깊어 보이는 건 기분 탓이다. 어떤 짤이 생각나는 것도.)


매크로 기능 구현하기

이 프로그램의 핵심 기능인 매크로를 구현할 차례이다.

키보드 캡처와 입력은 keyboard 모듈을 사용한다. PyQt5 역시 일반적인 GUI 프로그래밍과 마찬가지로 키보드 이벤트를 받아 처리할 수 있지만, 특정 컨테이너나 컴포넌트에서 발생하는 이벤트를 잡아낸다. 여기서는 이 프로그램의 바깥, 그러니까 다른 창에서 입력한 키를 캡처해야하므로 별도의 모듈을 활용하는 것이다. 예전의 살짝 겉핥기를 해봤을 때는 시스템 프로그래밍의 영역이거나 프로그래밍 언어의 표준 모듈에서 지원하더라도 추상 수준이 굉장히 낮았는데, 몇 줄의 코드로도 해결할 수 있어서 간단하게 구현할 수 있었다.

keyboard 모듈은 파이썬에 내장된 표준 라이브러리가 아니므로 pip로 설치한다.

PS> pip install keyboard

매크로는 단축키 F3 키를 눌러 매크로를 시작하거나 중지하도록 할 것이다. 딜레이를 설정한 값과 레이블에 매크로 실행 여부를 업데이트하기 위해 컴포넌트에 접근이 필요하므로 PyQt5 디자이너를 통해 자동생성된 UiMainWindow 클래스 내에 키보드에 초기화를 위한 함수를 추가해주고, setupUi 함수에서 호출해 준다.

...
import keyboard

def setupUi(self):
    ...
    self.init_keyboard()

def init_keyboard(self):
    # 매크로 실행 여부를 False로 초기화한다.
    self.is_playing = False
    
    # F3 키에 이벤트 핸들러를 설정한다.
    keyboard.on_press_key('f3', self.on_started)
    
def on_started(self, __):
    # 이미 실행 중일 때 다시 F3키를 누른 경우
    if self.is_playing:
        # 레이블 업데이트
        self.is_playing_label.setText('중지 됨')
        
        # 실행 상태 False로 변경
        self.is_playing = False
        return

    # 레이블 업데이트
    self.is_playing_label.setText('실행 중')
    
    # 실행 상태 True로 변경
    self.is_playing = True

    # 매크로 실제 동작을 수행하는 스레드 생성 및 시작
    WorkerThread('macro', self).start()

특별히 봐야할 부분은 keyboard.on_press_key 함수를 호출하여 F3 키에 on_started 함수를 바인딩 하는 부분이다. 끝이다 저 한 줄로. 저렇게 작성하면 F3키를 눌렀을 때 on_started 함수가 호출된다.
여기서 키보드 이벤트를 다뤄본 분이라면 왜 "on_press_key"인지 의아할 수도 있을 것 같다. 마우스나 키보드 이벤트는 눌렀을 때(pressed), 그리고 뗄 때(released)로 나뉘어진다. 상황에 따라 어떤 이벤트를 잡을 지 차이가 있겠지만 이 경우는 눌리는 순간에 바로 매크로가 실행되도록 하기 위한 것이 첫 번째 이유이고, 두 번째 이유로는 단축키를 처음에는 F3이 아닌 Ctrl+Alt+S와 같이 조합된 키로 설정하려고 했었다. on_press_key가 아닌 add_hotkey를 이용해 바인딩 했었는데 add_hotkey는 키가 릴리즈되는 시점에 호출이 되고, 세 키 조합을 다 누르는데 소요되는 시간이 하나의 키를 입력하는 것보다는 조금 더 걸리다보니 매크로가 동작할 때 명령어를 입력 시키는 것과 충돌이 생겨서 원하는 대로 동작하지 않았다. 그래서 조합된 단축키 대신 하나의 키로만 실행되도록 하고 add_hotkey 함수 대신 on_press_key 함수로 바꾸었다.

이는 상황에 맞게 사용하면 될 것 같다. 내 경우는 매크로 시작과 정지가 토글되어야 하기도 했고, GUI 기반에서 이 같은 처리가 적절했기 때문에 위와 같이 한 것이므로 만약 단순히 키 이벤트를 받아 동작을 처리하기만 하면 된다면 add_hotkey가 적절할 것이다.

다음은 매크로 동작을 별도의 스레드에서 처리하는 부분이다.
별도 스레드로 따로 뺀 이유는 매크로 실행 후 정지가 되지 않았기 때문이다.

# 매크로 동작 루프
for command in commands:
    키 입력('enter')
    딜레이(설정값)
    if not is_playing:
        break
    
    커맨드 쓰기(command)
    딜레이(설정값)
    if not is_playing:
        break
    
    키 입력('enter')
    딜레이(설정값)
    if not is_playing:
        break

F3 키를 눌러 매크로를 시작하면 위와 같은 루프가 실행되는데, 다시 F3 키를 눌러 정지시키려고 해도 앞 서 실행된 루프가 종료되어야 그 다음 F3 키 입력 이벤트에 반응한다. 루프가 끝나면 매크로 실행 플래그(is_playing) 값을 다시 False로 변경하게 되므로 루프가 돌던 중에 누른 F3 키는 결국 정지가 아니라 다시 루프를 수행하게 된다.

루프 사이에 is_playing 값을 체크하여 break 하도록 했더라도 정지를 위해 누른 F3 키 입력을 통해 is_playing = False로 업데이트 하는 것은 저 루프가 끝나야 한다는 얘기다.

이는 싱글 스레드에서 키입력에 대한 동작을 비동기적으로 수행할 수 없는 문제 때문이라고 생각하여 매크로 동작을 수행하는 부분을 스레드로 분리하여, F3 키 입력 시 메인 스레드와 별개의 흐름을 갖도록 했다.

매크로 동작을 수행하는 스레드는 아래와 같이 구현했다.

class Worker(threading.Thread):
    def __init__(self, name, window):
        super().__init__()
        self.name = name
        # 공유 객체를 별도로 사용하지 않고
        # window의 인스턴스 변수(is_playing, 컴포넌트 등)을 그대로 참조한다.
        self.window = window

    def run(self):
        # GUI 초기화 시 리스트에 설정한 모델을 가져와 row를 순한한다(입력할 커맨드를 읽어 순차적으로 처리)
        model = self.window.commandList.model()
        count = model.rowCount()

        for index in range(count):
            item = model.item(index)

            # 엔터키 입력을 발생시킨다.
            # (자동으로 엔터키를 입력하도록 한다)
            keyboard.send('enter')
            # is_playing 값을 체크하고 딜레이를 발생시킨다.
            if self.window.check_stopped_and_delay():
                break

            # 커맨드를 쓴다.
            keyboard.write(item.text())
            if self.window.check_stopped_and_delay():
                break

			# 다시 엔터키 입력을 발생시킨다.
            keyboard.send('enter')
            if self.window.check_stopped_and_delay():
                break
                
        #루프를 빠져나와 is_playing을 False로 바꾼다.
        self.window.is_playing = False

다행히 안드로이드나 자바 GUI처럼 다른 스레드에서의 UI 스레드의 컴포넌트를 참조하는 것을 막지 않아서 스레드간 통신은 크게 고려하지 않고 window 객체에서 직접 참조하였다.

최종적으로 완성된 모습은 아래와 같다.

실행가능한 파일로 번들링

이제 코드는 모두 작성이 되었다. 이 파이썬 코드를 그대로 주고 "실행하시면 쓰실 수 있어요"라고 할 수는 없으니 "5. 간편하게 실행할 수 있어야 한다"라는 조건을 만족시킬 수 있도록 exe 파일을 만들어 볼 차례다.

exe 파일을 만들기 위해서는 pyinstaller 모듈을 활용한다. 역시 pip를 이용해 설치한다.

PS> pip installer pyinstaller

exe 파일을 패키징하는 방법은 간단하다. 터미널에서 pyinstaller 명령을 사용한다. 이 때 몇 가지 옵션을 추가하였다.

  • -w, --windowed, --noconsole: 콘솔을 표시하지 않도록 하는 옵션이다. 사용자 입장에서는 콘솔이 뜨면 까만 건 창이요, 하얀 건 글씨니 굳이 필요하지 않을 것이다. (물론 오류가 났을 때 디버깅 할 때 필요한 메시지가 표시되겠지만 디버깅이 복잡하진 않을 것이므로 생략하기로 했다.)
  • -F, --onefile: 프로그램 실행에 필요한 여러 파일을 exe 파일 안에 묶어서 하나의 파일로 생성하는 옵션이다. 굳이 난잡하게 여러 파일이 보여지지 않기 위해 사용했다.

위 두 가지의 옵션과 함께 pyinstaller를 사용하는 명령은 아래와 같다. 대상 파일명은 auto_command.py이다.

PS> pyinstaller -wF auto_command.py

위 명령을 실행하면 빌드하는 과정에서 표시되는 로그가 출력된다.

PS C:\Users\...> pyinstaller -wF .\auto_command.py
151 INFO: PyInstaller: 5.2
151 INFO: Python: 3.10.5
... 생략
23790 INFO: Fixing EXE headers
24474 INFO: Building EXE from EXE-00.toc completed successfully.

그리고 두 개의 폴더가 만들어진 것을 확인할 수 있는데, build 폴더는 빌드의 결과물로 생성된 파일들이 위치해 있고, dist 폴더에 우리가 원하는 exe 파일이 생성된 것을 확인할 수 있다.

마치며

  • 문득 웹에서 구조(HTML), 디자인(CSS), 동작(JavaScript)을 각각 분리하여 작성할 수 있다는 게 얼마나 좋은 구조인지 새삼 실감하게 되었다. 사실 웹을 제외하고는 UI가 있는 대부분 구조, 디자인, 동작이 분리된 경우는 아직 경험하지 못했다. 어딘가 있을지 모르지만 내가 경험한 안드로이드(요즘의 안드로이드는 모르겠다), C#(WPF), Java AWT/Swing 등에서는 대부분 파이썬의 GUI와 유사하거나 레이아웃이나 코드 둘 중 어딘가에 디자인 요소가 포함되어 있다.
    구조는 당연히 상호작용을 위해 동작과 관련된 코드에서 참조하게 되지만, 디자인은 렌더링되는 결과 외에 구조와 동작에는 영향을 주지 않는다. 그런데 이런 역할을 하는 코드가 구조와 동작에 같이 포함되면 가독성이 떨어지고 코드를 관리하는데 어려움이 따른다. 물론 디자이너의 관점에서는 달리 볼 수도 있겠다.
    저 길지 않은 코드에서조차 가독성을 해치는데 대형 애플리케이션에서야 오죽하랴.
  • 괜히 1, 2등을 다투는 언어가 아니다, 생태계와 커뮤니티가 잘 구성되어 있어 라이브러리와 자료를 쉽게 찾을 수 있었다.
  • 파이썬 기본서의 내용만 본 상태여서 중간 중간 막히는 부분을 다시 찾아봐야 하기도 했지만, 파이썬을 어떻게 사용하면 좋을 지 조금이나마 감을 잡을 수 있었고 또 어떤 부분이 부족한지 확인할 수 있었다. 내 스스로 부채감과 죄책감이 남지 않게 이 프로젝트를 리팩토링 하는 시간도 가져보면 좋을 것 같다.
  • 생성된 실행 파일을 지인분께 보내 한 번 써보시라고 하며, 혹시 실행이 안되면 뭘 설치해야할 수도 있다고 했는데, 다행히 파이썬을 따로 설치하지 않고도 실행이 되는 것 같다. 물론 모종의(?) 이유로 파이썬을 설치하셨을 수도 있어서 파이썬이 설치되지 않은 환경에서 테스트는 해보지 못했다.
profile
애매모호한 사람. Programmable한 세계를 좋아합니다.

0개의 댓글