명령과 그룹

클릭의 가장 중요한 기능은 명령행 도구들로 마음대로 계층 구조를 만들 수 있다는 것이다. CommandGroup (MultiCommand)를 통해 구현한다.

콜백 호출

일반 명령에서는 명령이 실행될 때마다 콜백이 실행된다. 스크립트에 명령이 유일하면 매번 호출된다. (매개변수 콜백에서 막는 경우는 예외다. 예를 들어 스크립트에 --help를 주는 경우가 그렇다.)

그룹 및 다중 명령에서는 상황이 달라진다. 그 경우에는 (동작 방식을 바꾸지 않았다면) 하위 명령이 불릴 때마다 콜백이 불린다. 이게 무슨 뜻이냐면 내부 명령이 실행될 때 외부 명령도 실행된다는 것이다.

@click.group()
@click.option('--debug/--no-debug', default=False)
def cli(debug):
    click.echo('Debug mode is %s' % ('on' if debug else 'off'))

@cli.command()
def sync():
    click.echo('Syncing')

다음처럼 된다.

$ tool.py
Usage: tool.py [OPTIONS] COMMAND [ARGS]...

Options:
  --debug / --no-debug
  --help                Show this message and exit.

Commands:
  sync

$ tool.py --debug sync
Debug mode is on
Syncing

매개변수 전달

클릭에서는 명령과 하위 명령 간에 매개변수를 엄격하게 구분한다. 이게 무슨 뜻이냐면 어떤 명령에 대한 옵션과 인자는 그 명령 이름 뒤에, 그리고 다음 명령이 있다면 그 명령 이름 앞에 지정해야 한다는 것이다.

이미 정의돼 있는 --help 옵션에서도 이 동작을 볼 수 있다. 가령 tool.py라는 프로그램이 있고 거기에 sub라는 하위 명령이 있다고 하자.

  • tool.py --help라고 하면 프로그램 전체의 (하위 명령들을 나열하는) 도움말이 나온다.

  • tool.py sub --help라고 하면 하위 명령 sub의 도움말이 나온다.

  • 하지만 tool.py --help sub라고 하면 --help를 주 프로그램의 인자로 취급한다. 그럼 클릭에서 --help의 콜백을 호출하고, 그러면 도움말을 찍고서 프로그램 실행을 중단한다. 그래서 하위 명령은 처리하지 못한다.

계층 처리와 문맥

앞선 예에서 볼 수 있듯 기본 명령 그룹은 콜백으로 전달되는 debug 인자를 받지만 sync 명령은 받지 못한다. sync 명령은 자체 인자만 받는다.

덕분에 도구들이 서로 완전히 독립적으로 동작할 수 있다. 하지만 어떤 명령에서 하위 명령으로 뭔가를 전달하려면 어떡해야 할까? 답은 Context다.

명령이 호출될 때마다 새 문맥이 생성돼서 부모 문맥에 연결된다. 보통은 그 문맥들을 볼 수 없지만 분명 그렇게 존재한다. 문맥은 매개변수 콜백에 자동으로 값과 함께 전달된다. 그리고 명령에서도 pass_context() 데코레이터로 표시를 해서 문맥을 전달받을 수 있다. 그 경우 문맥이 첫 번째 인자로 전달된다.

프로그램 자체 용도를 위해 프로그램에서 지정한 객체를 문맥이 가지고 있을 수 있다. 즉 다음처럼 스크립트를 만들 수 있다.

@click.group()
@click.option('--debug/--no-debug', default=False)
@click.pass_context
def cli(ctx, debug):
    # ctx.obj가 존재하는지, 그리고 dict인지 (아래의 `if` 블록
    # 아닌 경로로 `cli()`가 호출되는 경우 대비) 확인한다
    ctx.ensure_object(dict)

    ctx.obj['DEBUG'] = debug

@cli.command()
@click.pass_context
def sync(ctx):
    click.echo('Debug is %s' % (ctx.obj['DEBUG'] and 'on' or 'off'))

if __name__ == '__main__':
    cli(obj={})

객체가 제공되면 각 문맥에서 그 객체를 자식들로 계속 전달한다. 단 어느 단계에서든 문맥의 객체를 바꿀 수 있다. 부모 문맥에 접근하려면 context.parent를 이용하면 된다.

추가로, 객체를 내려 주는 방식 대신 응용에서 전역 상태를 변경하는 것도 얼마든 가능하다. 예를 들어 그냥 전역의 DEBUG 변수를 바꾸는 식으로 할 수도 있다.

명령 데코레이터

앞선 예에서 본 것처럼 데코레이터를 써서 명령이 호출되는 방식을 바꿀 수 있다. 배후에서 실제 일어나는 동작은 콜백은 항상 Context.invoke() 메소드를 통해 호출되고 그 메소드에서 자동으로 명령을 올바르게 (문맥을 전달하며, 또는 하지 않으며) 호출하는 것이다.

이게 유용한 건 새로운 데코레이터를 작성하고 싶을 때다. 예를 들어 흔한 패턴으로 상태를 나타내는 객체를 구성해서 문맥에 저장해 둔 다음 새로운 데코레이터를 사용해 그런 최근 객체를 첫 번째 인자로 전달해 주는 방식이 있다.

예를 들어 pass_obj() 데코레이터를 다음처럼 구현할 수 있다.

from functools import update_wrapper

def pass_obj(f):
    @click.pass_context
    def new_func(ctx, *args, **kwargs):
        return ctx.invoke(f, ctx.obj, *args, **kwargs)
    return update_wrapper(new_func, f)

Context.invoke()에서 함수를 올바른 방식으로 호출해 준다. 즉 pass_context()로 꾸며 줬는지 여부에 따라 함수가 f(ctx, obj)f(obj) 중 하나로 호출된다.

이 강력한 개념을 이용하면 아주 복잡한 중첩 응용을 만들 수 있다. 자세한 내용은 복잡한 응용 참고.

명령 없이 그룹 호출하기

기본적으로 그룹 내지 다중 명령은 하위 명령을 주지 않는 한 호출되지 않는다. 실제로 명령을 주지 않으면 기본적으로 --help가 자동으로 들어간다. 이 동작 방식을 바꾸려면 그룹에 invoke_without_command=True를 주면 된다. 그러면 도움말 페이지를 보이는 대신 항상 콜백을 호출한다. 그리고 문맥 객체에는 호출이 하위 명령으로 가게 되는지 여부에 대한 정보가 있다.

예:

@click.group(invoke_without_command=True)
@click.pass_context
def cli(ctx):
    if ctx.invoked_subcommand is None:
        click.echo('I was invoked without subcommand')
    else:
        click.echo('I am about to invoke %s' % ctx.invoked_subcommand)

@cli.command()
def sync():
    click.echo('The subcommand')

그러면 실제로 다음처럼 된다.

$ tool
I was invoked without subcommand
$ tool sync
I am about to invoke sync
The subcommand

새로운 다중 명령

click.group()을 쓰는 대신 자체적으로 새로운 다중 명령을 만들 수도 있다. 필요할 때 플러그인의 명령들을 적재하는 걸 지원하려 할 때 유용하다.

새로운 다중 명령에는 나열 메소드와 적재 메소드만 구현해 주면 된다.

import click
import os

plugin_folder = os.path.join(os.path.dirname(__file__), 'commands')

class MyCLI(click.MultiCommand):

    def list_commands(self, ctx):
        rv = []
        for filename in os.listdir(plugin_folder):
            if filename.endswith('.py'):
                rv.append(filename[:-3])
        rv.sort()
        return rv

    def get_command(self, ctx, name):
        ns = {}
        fn = os.path.join(plugin_folder, name + '.py')
        with open(fn) as f:
            code = compile(f.read(), fn, 'exec')
            eval(code, ns, ns)
        return ns['cli']

cli = MyCLI(help='This tool\'s subcommands are loaded from a '
            'plugin folder dynamically.')

if __name__ == '__main__':
    cli()

이 새 클래스를 데코레이터에 쓸 수도 있다.

@click.command(cls=MyCLI)
def cli():
    pass

다중 명령 병합

새로운 다중 명령을 구현하는 것에 못지 않게 여러 스크립트를 하나로 합치는 것도 눈여겨볼 만하다. 일반적으로는 한쪽을 다른 쪽 아래 두는 방식을 더 권장하지만 어떤 경우에는 합치는 방식을 써서 더 편한 셸 사용 경험을 제공할 수 있다.

그런 병합 방식의 기본 구현체가 CommandCollection 클래스다. 다른 다중 명령들의 목록을 받아서 그 명령들을 같은 단계에서 사용할 수 있게 만들어 준다.

사용례:

import click

@click.group()
def cli1():
    pass

@cli1.command()
def cmd1():
    """Command on cli1"""

@click.group()
def cli2():
    pass

@cli2.command()
def cmd2():
    """Command on cli2"""

cli = click.CommandCollection(sources=[cli1, cli2])

if __name__ == '__main__':
    cli()

그러면 다음처럼 된다.

$ cli --help
Usage: cli [OPTIONS] COMMAND [ARGS]...

Options:
  --help  Show this message and exit.

Commands:
  cmd1  Command on cli1
  cmd2  Command on cli2

한 명령이 여러 곳에 존재하는 경우에는 처음 나오는 곳의 명령을 쓴다.

다중 명령 연속 지정

New in version 3.0.

한 번에 여러 하위 명령을 호출하는 게 가능하면 좋을 때가 있다. 예를 들어 이전에 setuptools 패키지를 설치해 본 적이 있다면 setup.py sdist bdist_wheel upload라는 연속 명령에 익숙할 것이다. 이 명령은 sdist 다음에 bdist_wheel을, 그리고 upload를 차례로 호출한다. 클릭 3.0부터는 이걸 아주 간편하게 구현할 수 있다. 다중 명령에 chain=True를 주기만 하면 된다.

@click.group(chain=True)
def cli():
    pass


@cli.command('sdist')
def sdist():
    click.echo('sdist called')


@cli.command('bdist_wheel')
def bdist_wheel():
    click.echo('bdist_wheel called')

그러면 다음처럼 호출할 수 있다.

$ setup.py sdist bdist_wheel
sdist called
bdist_wheel called

연속 다중 명령 방식을 쓸 때는 (마지막의) 한 명령에서만 인자에 nargs=-1을 쓸 수 있다. 또한 연속 다중 명령 아래에 다른 다중 명령을 넣는 게 불가능하다. 그 외에는 동작 방식에 어떤 제약도 없다. 다른 경우들처럼 옵션과 인자를 받을 수 있다.

추가 참고 사항: 다중 명령에서 여러 명령을 호출할 때는 Context.invoked_subcommand 속성에 '*' 값이 들어가므로 별 쓸모가 없다. 이는 하위 명령 처리가 하나씩 차례로 이뤼지기 때문에 콜백 발화 때는 정확히 어떤 하위 명령들이 처리될지 알 수 없기 때문이다.

Note

현재는 연속 명령들에 하위 명령을 넣는 게 불가능하다. 클릭 향후 버전에서 고쳐질 예정이다.

다중 명령 파이프라인

New in version 3.0.

연속 다중 명령을 사용하는 아주 흔한 경우는 한 명령이 앞 명령의 결과를 처리하게 하는 것이다. 이를 가능하게 해 주는 방법이 여러 가지 있다. 쉽게 떠오르는 걸로는 문맥 객체에 값을 저장해서 함수를 넘나들며 그 값을 처리하는 방법이 있다. 함수를 pass_context()로 꾸며 주면 문맥 객체가 제공되므로 하위 명령에서 거기에 데이터를 저장할 수 있게 된다.

또 다른 방법은 처리 함수를 반환하게 해서 파이프라인을 구성하는 것이다. 말하자면, 하위 명령을 호출하면 거기선 매개변수들을 모두 처리하고서 어떻게 처리를 수행할지 계획을 세운다. 그러고 나면 그 처리 함수를 반환하며 돌아오는 것이다.

그럼 반환된 그 함수들은 어디로 가는 걸까? 연속 다중 명령에서는 MultiCommand.resultcallback()으로 콜백을 등록할 수 있는데 거기서 그 함수들을 모두 훑으며 호출한다.

좀 더 구체적으로 보자면 다음 예를 생각해 보자.

@click.group(chain=True, invoke_without_command=True)
@click.option('-i', '--input', type=click.File('r'))
def cli(input):
    pass

@cli.resultcallback()
def process_pipeline(processors, input):
    iterator = (x.rstrip('\r\n') for x in input)
    for processor in processors:
        iterator = processor(iterator)
    for item in iterator:
        click.echo(item)

@cli.command('uppercase')
def make_uppercase():
    def processor(iterator):
        for line in iterator:
            yield line.upper()
    return processor

@cli.command('lowercase')
def make_lowercase():
    def processor(iterator):
        for line in iterator:
            yield line.lower()
    return processor

@cli.command('strip')
def make_strip():
    def processor(iterator):
        for line in iterator:
            yield line.strip()
    return processor

설명할 게 좀 많다. 하나씩 살펴보자.

  1. 첫 번째로 할 일은 명령 연속 지정이 가능한 group()을 만드는 것이다. 그리고 하위 명령이 지정되지 않더라도 클릭에서 호출하도록 한다. 이렇게 하지 않으면 빈 파이프라인 호출 시에 결과 콜백을 실행하는 대신 도움말 페이지를 내놓게 된다.

  2. 다음으로 할 일은 그룹에 결과 콜백을 등록하는 것이다. 그러면 모든 하위 명령의 반환 값들, 그리고 그룹 자체가 받은 것과 같은 키워드 매개변수들을 인자로 해서 그 콜백이 호출된다. 즉 문맥 객체를 쓰지 않아도 거기서 입력 파일에 쉽게 접근할 수 있다.

  3. 그 결과 콜백 내에서는 입력 파일의 모든 행으로 이터레이터를 만들고 하위 명령에서 반환한 콜백 모두를 이터레이터가 거치게 하고서 모든 행을 stdout으로 찍는다.

이렇게 한 다음에는 원하는 대로 하위 명령들을 등록할 수 있으며 각 하위 명령에서는 행 스트림을 변경하는 처리 함수를 반환하면 된다.

주의할 점 하나는 각 콜백이 실행된 후에 클릭에서 문맥을 없앤다는 것이다. 그래서 예를 들면 파일 타입을 processor 함수 안에서 접근할 수 없다. 거기선 파일이 이미 닫혀 있기 때문이다. 이런 제약은 자원 관리를 훨씬 단순하게 해 주기 때문에 아마 바뀌지 않을 것이다. 따라서 파일 타입을 쓰는 대신 직접 open_file()을 통해 파일을 열기를 권한다.

파이프라인 처리 방식도 개선한 더 복잡한 예를 클릭 저장소의 imagepipe 연속 다중 명령 예시에서 볼 수 있다. 파이프라인 기반으로 이미지 편집 툴을 구현한 것인데 내부 구조가 파이프라인에 잘 맞게 돼 있다.

기본값 바꾸기

기본적으로 매개변수의 기본값은 그 매개변수를 정의할 때 준 default 플래그에서 가져오지만 거기서만 가져오는 건 아니다. 문맥의 Context.default_map(딕셔너리)에서도 기본값을 가져온다. 이를 이용하면 설정 파일에서 기본값을 읽어 들여서 원래 기본값을 교체할 수 있다.

다른 패키지에서 어떤 명령들을 플러그인 형태로 가져오려는데 기본값은 마음에 들지 않을 때 유용하다.

각 하위 명령에 맞춰 필요한 대로 기본값 맵의 계층을 만들어서 스크립트 호출 시에 제공할 수 있다. 아니면 명령 어느 지점에서든 기본값을 교체할 수도 있다. 예를 들어 최상위 명령에서 설정 파일에 있는 기본값을 읽어 들일 수 있을 것이다.

사용례:

import click

@click.group()
def cli():
    pass

@cli.command()
@click.option('--port', default=8000)
def runserver(port):
    click.echo('Serving on http://127.0.0.1:%d/' % port)

if __name__ == '__main__':
    cli(default_map={
        'runserver': {
            'port': 5000
        }
    })

동작시켜 보면:

$ cli runserver
Serving on http://127.0.0.1:5000/

문맥 기본값

New in version 2.0.

클릭 2.0부터는 스크립트를 호출할 때만이 아니라 명령을 선언하는 데코레이터에서도 문맥의 기본값을 교체할 수 있다. 예를 들어 따로 default_map을 정의하는 앞선 예가 있을 때 그걸 데코레이터에서도 할 수 있다.

다음 예는 앞의 예와 동일하게 동작한다.

import click

CONTEXT_SETTINGS = dict(
    default_map={'runserver': {'port': 5000}}
)

@click.group(context_settings=CONTEXT_SETTINGS)
def cli():
    pass

@cli.command()
@click.option('--port', default=8000)
def runserver(port):
    click.echo('Serving on http://127.0.0.1:%d/' % port)

if __name__ == '__main__':
    cli()

마찬가지로 동작시켜 보면:

$ cli runserver
Serving on http://127.0.0.1:5000/

명령 반환 값

New in version 3.0.

클릭 3.0에서 새로 도입된 것 중 하나는 명령 콜백 반환값을 제대로 지원하는 것이다. 이를 이용하면 이전에는 구현하기 힘들었던 여러 기능들이 가능해진다.

기본적으로 이제 어떤 명령 콜백에서도 값을 반환할 수 있다. 그 반환 값은 특정 수신자에게로 흘러간다. 이를 이용하는 사례를 다중 명령 연속 지정의 예에서 이미 보았다. 거기서 본 것처럼 반환 값들을 연속 다중 명령의 콜백에서 처리할 수 있다.

클릭에서 명령 반환 값을 다룰 때 다음 사항들에 유념해야 한다.

  • 일반적으로 BaseCommand.invoke() 메소드에서는 명령 콜백의 반환 값이 반환된다. 이 규칙에 대한 예외는 Group과 관련돼 있다.

    • 그룹에서 반환 값은 일반적으로 호출된 하위 명령의 반환 값이다. 이 규칙의 유일한 예외로 인자 없이 호출됐고 invoke_without_command가 켜져 있으면 그룹 콜백의 반환 값이 반환 값이다.

    • 그룹이 연속 호출 설정이 돼 있으면 모든 하위 명령 결과들의 리스트가 반환 값이다.

    • 그룹의 반환 값을 MultiCommand.result_callback을 통해 처리할 수 있다. 연속 모드에서는 모든 반환 값들의 리스트로, 아닌 경우에는 반환 값 하나로 호출된다.

  • Context.invoke()Context.forward() 메소드에서 반환 값이 솟아 나온다. 내부적으로 다른 명령을 호출하고 싶은 경우에 유용하다.

  • 클릭에선 반환 값에 대해 어떤 뚜렷한 요구 조건도 없으며 반환 값을 자체적으로 사용하지 않는다. 그래서 반환 값을 (다중 명령 연속 지정 예시에서와 같은) 자체 데코레이터나 처리 흐름에서 이용하는 게 가능하다.

  • 클릭 스크립트가 (BaseCommand.main()을 통해) 명령행 응용으로 호출될 때는 그 반환 값이 무시된다. 단 standalone_mode가 꺼져 있는 경우에는 전달된다.