(Python) prompt_toolkit 사용법

prompt_toolkit은 xNIX 계열의 쉘에서 사용되는 readline 라이브러리를 순수 파이썬으로 구현한 것으로 명령줄 도구를 사용할 때 히스토리 탐색이나 자동 추천, 자동 완성등의 기능을 쓸 수 있게 해주는 라이브러리이다. readline 자체가 제공하는 기능이 워낙 다양하고 유용하기 때문에 대화형 쉘과 같은 프로그램을 매우 쉽게 만들 수 있게 해준다. prompt toolkit의 제작자는 이 라이브러리의 기능을 활용하여 ptpython이라는 개선된 파이썬 대화형 쉘을 제작하였으며, vim의 기능을 흉내낸 pyvim 프로젝트도 개발하고 있다. (공식문서보기)

prompt_toolkit은 입력 프롬프트 상에서 표시되는 내용과 입력에 대한 검증을 실시간으로 가능하게 하기 때문에, 입력을 특정한 규격에 맞춰야 하는 프로그램에서 아주 유용하다. 또한 텍스트에 컬러를 지정하는 것도 가능하며, 입력 중인 내용에 대해 구문 강조를 적용하는 것도 가능하기 때문에 예쁜 CLI 프로그램을 만드는데 도움을 준다.

오늘은 prompt_toolkit에 대해 좀 알아보도록 하자.

설치

기본적으로 prompt_toolkit은 pip로 설치가 가능하다. ipython에서 이미 이 패키지를 사용하고 있기 때문에 ipython을 쓰고 있다면 별다른 설치없이 바로 사용할 수 있다.

λ pip show prompt_toolkit
Name: prompt-toolkit
Version: 3.0.5
Summary: Library for building powerful interactive command lines in Python
Home-page: https://github.com/prompt-toolkit/python-prompt-toolkit
Author: Jonathan Slenders
Author-email: None
License: UNKNOWN
Location: g:\python37\lib\site-packages
Requires: wcwidth
Required-by: ptpython, jupyter-console, ipython

서식을 갖춘 출력

prompt toolkit을 사용하는 가장 기본적인 이유는 키보드 입력에 있어서의 사용자 경험을 향상하는 것이다. 하지만 입력 만큼이나 중요한 것은 출력인데, prompt toolkit은 출력을 위한 여러 기능들을 갖추고 있다. 단순히 print()를 쓰는 것 외에 prompt toolkit을 사용하면 색상을 지정하거나 볼드체를 사용하는 등, 향상된 출력을 만들어 낼 수 있다. 먼저 서식화된 출력을 생성하고 사용하는 방법을 먼저 살펴보자.

prompt toolkit의 출력 함수는 print_formatted_text() 이다. 여기에 일반 문자열이나 FormattedText값을 넘겨주면 그에 맞게 출력해준다.

>>> from prompt_toolkit import print_formatted_text
>>> print_formatted_text('hello world')
hello world

포맷이 있는 텍스트는 HTML 클래스를 통해서 생성할 수 있으며, [(스타일, 텍스트)]의 리스트로 만들 수 있다. 스타일은 미리 약속된 포맷을 가진 문자열이며 그 형식은 다음과 같다.

"#{전경색상코드} [bg:#{배경색상코드}] [bold|italic|underline]"
from prompt_toolkit.formatted_text import FormattedText
from prompt_toolkit import print_formatted_text

line = FormattedText([
  ('#ff0000 bg:#ffff00', 'hello'),
  ('', '')  # 서식이 없는 빈 문자열을 사용해서 서식을 초기화
  ('#ffff00 underline', 'world')
])
print_formatted_text(line)
promprt_toolkit 컬러와 서식을 적용한 텍스트 출력 예시
위 예제코드를 출력해본 결과

HTML을 사용하는 방법

prompt_toolkit.formated_text.HTML은 HTML과 유사한 태그 기반 문법을 사용해서 FormattedText를 생성해주는 장치이다. 실제로 HTML을 렌더링하는 것은 아니고 텍스트 구역에 따른 포맷방식을 HTML처럼 하는 것이다. <b>Bold</b>, <u>UnderLined</u>, <i>Italic</i> 등의 태그를 사용할 수 있고, <ansired>Red</ansired>, <ansigreen>Green</ansigreen>과 같은 식으로 색상을 줄 수 있다. 실제 HTML이 아니므로, 멀티라인 문자열을 사용했다면 줄을 바꿨을 때 실제로 개행이 발생한다.

from prompt_toolkit import print_formatted_text
from prompt_toolkit.formatted_text import HTML, FormattedText

# HTML을 사용하여
print_formatted_text(HTML('''
Normal
<b>Bold</b>
<ansired>Red</ansired>
<ansigreen>Green</ansigreen>
'''))
# 브라우저에서와는 달리, 각각의 단어는 개행하여 출력된다.

문법 강조를 적용하여 출력하기

FormattedText는 스타일과 텍스트의 튜플의 리스트를 인자로 받아 포맷된 문자열을 생성한다. 특정한 문자열이 소스 코드라면 구문 분석을 통해서 예쁜 포맷 텍스트를 만들어주는 라이브러리인 Pygments를 사용하여 문법 요소별로 쪼개고 객 요소별 스타일을 적용하여 토큰으로 파싱할 수 있는데, prompt_toolkit은 이러한 Pygments 토큰을 FormattedText로 만들 수 있다. 이 때 PygmentsTokens를 사용한다.

pygment 라이브러리는 구문 분석을 수행하는 lex라는 함수를 가지고 있다. 이 함수는 분석할 문자열과 분석을 수행할 렉서(Lexer) 인스턴스를 인자로 요구한다. 다행히 우리는 그러한 렉서를 구현하지 않고 pygments가 제공하는 렉서를 사용하면 되겠다.

"""synhigh.py: syntax highlight"""
import pygments
from pygments.lexers.python import PythonLexer
from prompt_toolkit.formatted_text import PygmentsTokens
from prompt_toolkit import print_formatted_text


def synhi(line: str):
  tokens = pygments.lex(line, lexer=PythonLexer())
  print_formatted_text(PygmentsTokens(tokens), end="")
  # print

def getlines():
  try:
    while True:
      yield input()
  except EOFError:
    pass

if __name__ == '__main__':
  for _ in map(synhi, getlines()):
    pass

위 예제에서는 주어진 문자열을 파이썬 코드로 취급하여 문법강조를 적용하여 출력하는 함수 synhi()를 만들고 표준입력으로 들어오는 값에 이를 적용하여 출력하도록 하고 있다.

참고로 print_formatted_text()는 (윈도 환경에서만 그런건지는 모르겠는데) 출력한 후에 개행을 두 번씩 한다. 따라서 출력이 제대로 보이게 하려면 end='' 옵션을 함께 주도록하자.


입력 받기

이제 prompt_toolkit의 꽃이라 할 수 있는 입력 처리를 살펴보자.

입력은 기본적으로 prompt() 함수를 사용할 수 있다. 이 함수는 기본적으로는 input() 함수와 비슷하게 동작한다. 하지만 다양한 인자를 사용해서 입력받을 때의 프롬프트 상에서의 동작을 제어할 수 있다.

```
from prompt_toolkit import prompt

text = prompt('>>> ')
print(f'You entered {text}')
```

prompt() 함수의 인자는 기본적으로 프롬프트로 표시할 문구를 지정한다. 처음 한 번 만 지정해두면, prompt_toolkit 내부에 이 값이 저장되므로, 이후에는 prompt()로만 호출해도 원래의 프롬프트를 계속 유지하고 사용할 수 있다.

기본적으로 prompt() 는 일회성으로 사용될만한 함수이다. 만약 대화형 쉘처럼 계속해서 입력을 받는 경우라면 세션을 사용하는 것이 좋다. 세션을 사용하면 기본적으로 입력한 모든 라인들이 히스토리로 기억된다. 히스토리에 저장된 내용들은 입력 시점에 위아래 화살표 키를 눌러서 찾을 수 있고, BASH처럼 Ctrl-R을 눌러서 검색할 수도 있다! 세션은 PromptSession 클래스의 인스턴스를 생성하면 되며, 해당 세션도 prompt()라는 메소드를 가지고 있다.

from prompt_toolkit import PromptSession, print_formatted_text
session = PromptSession()
session.prompt()
# ...
session.prompt()  # 입력 시에 아래/위 화살표키나 Ctrl-R을 눌러보자.

히스토리를 파일에 저장하기

입력한 히스토리를 별도의 파일로 저장하여 보관할 수 있다. 이렇게하여 프로그램을 실행했을 때, 이전 실행의 입력 내역을 그대로 참조할 수 있게 된다. 그렇게하면 UP 키로 이전 히스토리를 탐색하거나 Ctrl+R로 검색할 때, 이전에 실행했을 때의 입력 내용까지도 조회할 수 있다는 장점이 있다.

이 때 주의해야 할 점이 두 가지 있는데, 우선 히스토리 파일은 이미 존재하는 파일의 경로를 주어야 한다는 것과, 세션을 생성하는 시점에 history 속성을 제공해야 한다는 것이다.

from pathlib import Path
from prompt_toolkit.history import FileHistory
# ....

hist_file = Path('~/.sample_history').expanduser()
if not hist_file.exists():
  hist_file.touch()

session = PromptSession(history = FileHistory(str(hist_file)))

이전 입력을 자동으로 제안하기

세션을 사용할 때의 강점은 입력시 풍부한 보조 기능을 제공하기 좋기 때문이다. 세션은 히스토리를 기억하며, 이 내역을 파일로 저장해서 이후 실행시까지 활용할 수 있다. 뿐만 아니라 세션 객체에는 auto_suggest 속성이 있는데, 이 속성에 적당한 공급자 객체를 달아주면, 사용자가 키 입력을 시작할 때, 지정된 제시어 객체가 제공하는 값에 따라 앞부분이 일치하는 제시어를 미리 보여준다. 이 때 오른쪽 화살표키를 사용해서 해당 히스토리를 완성할 수 있다. 이 때 만들어지는 제시어는 별도의 객체가 생성해서 제공해야 하는데, prompt toolkit 에서는 기본적으로 입력 이력을 기반으로 완성된 문장을 제시할 수 있다.

이 기능을 사용하려면 AutoSuggestFromHistory 객체를 사용할 수 있다. 이 속성은 session.auto_suggest를 지정하는 것으로 변경할 수 있으며, 혹은 세션 생성시에 auto_suggest= 키워드 인자에 적절한 객체를 넣어주면 된다. auto_suggest=는 1회용으로 prompt() 의 키워드 인자로도 정의되어 있기에, 여기서도 지정하여 사용할 수 있다.

참고로 제시어 뿐만 아니라 이후 설명하는 거의 모든 기능이 이런식으로 세션의 속성으로 지정되거나, prompt()의 인자로 들어가서 오버라이드가 될 수 있다.

다음 예제는 AutoSuggestFromHistory를 사용해서, 입력을 시작할 때 같은 세션의 이전 입력 히스토리에서 앞부분이 같은 내용으로 어두운 색상으로 표시되는 것을 볼 수 있다.

from prompt_toolkit.auto_suggest import AutoSuggestFromHistory
from prompt_toolkit import PromptSession

session = PromptSession()
session.auto_suggest = AutoSuggestFromHistory()

session.prompt()
promprt_toolkit, auto suggest demo
prompt_toolkit 세션에서는 히스토리에서 문장을 제안하면 앞부분만 입력하여 문장을 다시 입력할 수 있다. 세션을 사용한다면 UP/DOWN 키로도 입력 내역을 다시 입력할 수 있다.

자동 완성 지원

prompt toolkit 에서 가장 멋진 부분이라 할 수 있는 것은 바로 자동완성이다. 어지간한 쉘은 파일이름이나 디렉토리 이름들을 탭 키로 자동완성할 수 있는데, prompt toolkit도 이와 비슷한 자동완성을 지원한다. bash나 cmd.exe와 비슷하게 현재 경로의 하위 경로를 자동완성할 수도 있으며, 미리 지정한 단어 중에서 자동완성 할 수도 있다. 세션의 completer 속성은 자동완성 기능을 지원하는 부분이다. 어떤 컴플리터를 사용하느냐에 따라서 자동완성할 수 있는 내용이 달라진다. 기본적으로 제공되는 컴플리터에는 다음과 같은 것들이 있다.

여기 외의 별도의 커스텀 컴플리터는 Completer 베이스 클래스를 상속하여 생성하고, 사용하면 된다. 커스텀 컴플리터를 작성하는 부분에 대해서는 다음 기회에 알아보도록 하자.

  • WordCompleter : 미리 정한 단어의 목록에서 자동완성한다. (키워드 기반)
  • PathCompleter : 현재 경로의 하위 경로에서 자동완성한다.
  • ExecutableCompleter: 현재 경로에서 실행할 수 있는 명령으로 자동완성한다.
  • FuzzyCompleter : 다른 컴플리터를 인자로 받아서, 퍼지 완성을 적용한다. (단어의 처음이 아닌 중간 부분에도 매치한다.) 이 컴플리터는 다른 컴플리터에 대한 wrapper로 생성한다.
  • NestedCompleter : 주로 명령과 서브명령 처럼 계층으로 나뉘어진 구조에서 자동완성하게 한다.
  • ThreadedCompleter : 역시 다른 컴플리터를 인자로 받아서 별도 스레드에서 돌아가게 한다. 자동완성 후보를 수집, 생성하는데 시간이 오래 걸린다면 고려해볼만한 대상이다.

아래 코드는 몇 가지 SQL의 키워드를 사용하여 SQL 쿼리를 입력받는 동안, 자동완성 기능이 작동하게 한 것이다.

from prompt_toolkit.completion import WordCompleter

# ...
sql_completer = WordCompleter(['select', 'insert', 'from', 'into', 
    # ... 그 외에 sql에서 사용되는 키워드들을 정의한다.
    ], ignore_case=True)
session.completer = sql_completer
promprt_toolkit, auto completion demo
자동완성 기능을 사용하여 sql 구문을 쉽게 입력하도록 한다. lexer를 추가하여 입력시 문법 강조가 적용되도록 했다.

비밀번호 입력

prompt(is_password=True)로 호출하면 입력한 키의 문자를 에코하지 않고 * 로 표시하여 숨겨준다.

promprt_toolkit, 비밀번호 입력모드
is_password를 사용해서 비밀번호가 노출되지 않도록 한다. 우측의 [password]는 rprompt 기능을 사용했다.

입력 중 구문 강조 적용하기

바로 위 예제는 입력 중에 입력하던 내용에 구문 강조가 적용된다. 이는 프롬프트의 lexer= 값을 지정했을 때이다. Pygments의 렉서를 사용하면 되는데, prompt_toolkit.lexers 모듈 내의 PygmentsLexer에 해당 사용할 렉서 클래스를 넘겨서 전달하면 된다.

from prompt_toolkit.lexers import PygmentsLexer
from pygments.lexers.sql import SqlLexer

prompt(lexer=PygmentsLexer(SqlLexer))

유효성 검사

한 번에 한 줄씩 입력받는 readline 툴의 특성상, 유효성 검사를 통과한 값만 입력되게 할 수 있다. 즉 엔터키를 누르기 전에 매 키가 입력될 때마다 현재 라인을 검사해서, 유효하지 않으면 아예 엔터키로 입력을 받지 않게 막을 수 있다.

validation.Validator 클래스를 상속하여 검사기를 만들거나, Validate.from_callable()을 통해 함수로부터 값을 체크할 수 있다. 다음은 숫자만 입력 받는 경우의 유효성 검사 예제이다.

from prompt_toolkit.validation import Validator
from prompt_toolkit import prompt

def is_number(s: str) -> bool:
  return s.is_decimal()

vd = Validator.from_callable(is_number)
xs = [int(prompt('> ' validator=vd)) for _ in range(3)
print(sum(xs))
promprt_toolkit, validator를 사용했을 때 올바르지 않은 입력을 미리 차단한다.
validator를 사용하여 원치 않는 입력을 사전에 차단할 수 있다.

단순히 함수로만 유효성 검사를 하면 예쁜 피드백을 받을 수 없다. (메시지가 “Invalid Input”으로 고정) 좀 더 친절한 안내를 보여주고 싶다면 커스텀 검사기를 작성한다. 커스텀 검사기는 Validator를 상속하여 만드는데, validate(document) 라는 메소드만 구현한다. 이 메소드에서는 유효성 검사를 통과하지 못할 때 ValidationError 예외를 일으켜서 메시지를 표현할 수 있다.

from prompt_toolkit.validation import Validator, ValidationError

class NumberValidator(Validator):
    def validate(self, document):
        for (i, c) in enumerate(document.text):
            if not c.isdigit():
                raise ValidationError(
                    message="Not a number!",
                    cursor_position=i)

number = int(prompt(": ", Validator=NumberValidator()))

유효성 검사는 우측 프롬프트를 표시하는 기능을 활용하면, 조금 도움이 될지도 모르겠다. prompt()의 rprompt= 인자로 일반텍스트나 포맷된 텍스트를 넘겨서 입력줄의 오른쪽에 표시를 추가할 수 있다. 앞서 비밀번호 입력 모드의 스샷에 이 기능을 적용했었다.

from prompt_toolkit.formatted_text import FormattedText

rprompt = FormattedText([('#ffffff bg:#6666ff', 'password')])
line = prompt('pwd: ', is_password=True, rprompt=rprmpt)

이 글에서 다 소개할 수 없지만, 그외에 몇가지 추가적으로 커스텀 키 바인딩을 적용하는 기능도 포함하고 있다. 물론 기본적으로 vi 모드나 emac 모드로 작동할 때에는 해당 애플리케이션의 키 바인딩을 기본적으로 제공해준다. (vi 모드를 사용해서 복사나 붙여넣기도 자유롭게 할 수 있다. 이 라이브러리를 기반으로 pyvim을 작성했다는 점을 잊지 말자.) 그외에도 이 라이브러리가 커버하는 영역은 상당해서 단순한 대화줄 입력을 제어하는 것외에도 TUI 애플리케이션을 작성하는데 사용할 수 있을 수준이다.