LoginSignup
0
0

More than 1 year has passed since last update.

CLIアプリケーションフレームワークcmdkitの紹介

Last updated at Posted at 2021-10-04

はじめに

分散処理をするためのツール調べていたところ、cmdkit というライブラリを使ってCLIアプリケーションを提供しているものがありました。これは、Python 標準ライブラリの argparser を利用するもので、クラス定義でCLIアプリケーションを手軽に定義できる便利なフレームワークです。しかし、ほとんどドキュメントが存在していない状況なので備忘録的にまとめることにしました。

cmdkitについて

cmdkit は、Python のコマンドラインアプリケーションに必要ないくつかの共通パターンを実装したものです。コンソールアプリケーションの開発するために必要な手続きを減らすことを目的に開発されています。cmdkit を使って開発されたアプリケーションは、実装が簡単で、メンテナンスが容易で、理解しやすいものになります

cmdkit はPython 3.7 以降で動作します。

インストール

cmdkit は pip でインストールを行えます。

bash
$ pip install cmdkit

機能概要

cmdkit のApplicationクラスは、優れたエントリーポイントのための定型文を提供します。このApplication クラスを継承してアプリケーションを作成します。

01_demoapp.py
import sys
from cmdkit.app import Application, exit_status
from cmdkit.cli import Interface, ArgumentError
from cmdkit.config import Namespace

APP_NAME = 'demo_simple'
APP_DESCRIPTION = """\
Description for demo application.
"""

APP_USAGE = f"""\
Usage: {APP_NAME} [-h|--help]

{APP_DESCRIPTION}
"""

APP_HELP=f"""\
{APP_USAGE}

Options:
-h, --help        show this message and exit.
"""

class DemoApp(Application):
    interface = Interface(APP_NAME, APP_USAGE, APP_HELP)

    name: str = None
    interface.add_argument('name')

    def run(self):
        print(f'Hello {self.name}')

def main() -> int:
    return DemoApp.main(sys.argv[1:2])

if __name__ == '__main__':
    main()

コマンドラインで与えた引数を表示するだけのスクリプトです。

bash
$ python 01_demoapp.py Python
Hello Python

引数が省略されるか、-h--help オプションが与えられるとヘルプメッセージを表示します。

bash
$ python 01_demoapp.py
Usage: demo_simple [-h|--help] name

Description for demo application.


$ python 01_demoapp.py -h
Usage: demo_simple [-h|--help] name

Description for demo application.



Options:
  -h, --help        show this message and exit.

$ python 01_demoapp.py --help
Usage: demo_simple [-h|--help] name

Description for demo application.



Options:
  -h, --help        show this message and exit.

Interfaceクラスは3つの引数を受け取り、標準ライブラリのargparse.ArgumentParserクラスの動作を変更し、必要に応じていくつかの単純な例外を発生させます。Application クラスはこの例外を捕獲してヘルプメッセージやバージョン情報を表示させます。

これらの例外は、cmdkit/cli.py で次のように定義されています、

class HelpOption(Exception):
    """Raised by :class:`~Interface` when the help option is passed."""


class VersionOption(Exception):
    """Raised by :class:`~Interface` whenever ``action='version'``."""


class ArgumentError(Exception):
    """Raised by :class:`~Interface` on bad arguments."""

ApplicationGroupを使ってコマンドラインアプリケーションを重ねて構築することで、git のサブコマンドのようにCLIを反映したシンプルな構造とモジュールを開発することができます。

Configurationクラスは、読み込みレベルをもたせた複数のファイルから辞書のようなインターフェイスで設定を取り込み、環境変数を階層的に展開してマージすることを、基本的に1行できます。

優れたプリケーションの構成ファイルでは、システムレベル、ユーザーレベル、ローカルの設定が重なり合うような設定になっていることがあります。これらをマージするとき、優先順位の低いソースの同じ値を上書きするようなことを避けるために、Namespaceクラスは、標準的なPython dictの動作を拡張して、updateに深さ優先でマージする実装にしています。

config = Configuration.from_local(env=True, 
                       prefix='MYAPP', default=default, **paths)

Application クラス

すべてのアプリケーションインターフェイスのための抽象ベースクラスです。
使用する場合は次のようにインポートします。

from cmdkit.app import Application

Application クラスを派生したクラスを作成して、独自の処理をrun()メソッドに定義します。クラスメソッドの mai()を呼び出すことでアプリケーションが実行します。

ソースコード cmdkit/app.py から Application クラスを抜粋

class Application(abc.ABC):

    interface: cli.Interface = None
    ALLOW_NOARGS: bool = False

    shared: Namespace = None

    exceptions: Dict[Type[Exception], Callable[[Exception], int]] = dict()
    log_critical: Callable[[str], None] = log.critical
    log_exception: Callable[[str], None] = log.exception

    @classmethod
    def handle_help(cls, message: str) -> None:
        print(message)

    @classmethod
    def handle_version(cls, *args) -> None:
        print(*args)

    @classmethod
    def handle_usage(cls, message: str) -> None:
        print(message)

    def __init__(self, **parameters) -> None:
        """Direct initialization sets `parameters`."""
        for name, value in parameters.items():
            setattr(self, name, value)

    @classmethod
    def from_cmdline(cls, cmdline: List[str] = None) -> Application:
        """Initialize via command line arguments (e.g., `sys.argv`)."""
        return cls.from_namespace(cls.interface.parse_args(cmdline))

    @classmethod
    def from_namespace(cls, namespace: cli.Namespace) -> Application:
        """Initialize via existing namespace/namedtuple."""
        return cls(**vars(namespace))

    @classmethod
    def main(cls, cmdline: List[str] = None) -> int:
        """Entry-point for application."""

        try:
            if not cmdline:
                if hasattr(cls, 'ALLOW_NOARGS') and cls.ALLOW_NOARGS is True:
                    pass
                else:
                    print(cls.interface.usage_text)
                    return exit_status.usage

            with cls.from_cmdline(cmdline) as app:
                app.run()

            return exit_status.success

        except cli.HelpOption as help_opt:
            cls.handle_help(*help_opt.args)
            return exit_status.success

        except cli.VersionOption as version:
            cls.handle_version(*version.args)
            return exit_status.success

        except cli.ArgumentError as error:
            cls.log_critical(error)
            return exit_status.bad_argument

        except KeyboardInterrupt:
            cls.log_critical('keyboard-interrupt: going down now!')
            return exit_status.keyboard_interrupt

        except Exception as error:
            for exc_type, exc_handler in cls.exceptions.items():
                if isinstance(error, exc_type):
                    return exc_handler(error)
            cls.log_exception('uncaught exception occurred!')
            raise

    @abc.abstractmethod
    def run(self) -> None:
        """Business-logic of the application."""
        raise NotImplementedError()

    def __enter__(self) -> Application:
        """Place-holder for context manager."""
        return self

    def __exit__(self, *exc) -> None:
        """Release resources."""
        pass

クラスメソッドmain() は、Applicationクラスのクラスメソッドである from_namespace() と from_cmdline()によって初期化されます。

これらのメソッドは :Interface クラスのインスタンスオブジェクトがもつメソッドを使ってコマンドライン引数を解析します。直接的な初期化はクラス変数の名前を文字列で取り、単にインスタンスに割り当てられます。
これらはアノテーションの付いた既存のクラスレベルの属性でなければなりません。

デフォルトでは、引数を与えずに実行すると使用例(APP_USAGE)が表示されます。
クラス属性として ALLOW_NOARGS=Trueが定義されていると、引数がない場合でもアプリケーションに処理を渡します。

クラスメソッドmain()を実行中にcmdkit が想定している例外が発生すると、クラス変数log_criticallにアサインされている関数を呼び出します。これはデフォルトでは、log.critical()になっています。

これ以外の例外は exceptionsの辞書を検索してヒットした例外があれば、そこに定義されている関数を呼び出します。
それにも該当しない例外は、クラス変数log_exceptionにアサインされている関数を呼び出します。このデフォルトはlog.exceptionになっているためトレースバックが発生します。

ソースコード cmdkit/app.py の Applicationクラスでの exceptions と log_critical、 log_exception の定義部分を抜粋

    exceptions: Dict[Type[Exception], Callable[[Exception], int]] = dict()
    log_critical: Callable[[str], None] = log.critical
    log_exception: Callable[[str], None] = log.exception

ソースコードcmdkit/app.py のロギングの定義部分を抜粋

import logging
# ...
log = logging.getLogger(__name__)

これをみてわかるようにロガーの定義がされているたけなので、実際には必要に応じてロギングを設定する必要が’あります。

ロギング設定の例:

import logging
# ..
# 標準出力(コンソール)にログを出力するハンドラを生成する
log_stderr = logging.StreamHandler(sys.stderr)
log_stderr.setLevel(logging.WARNING)
log_stderr.setLevel(logging.CRITICAL)

# ハンドラをロガーに紐づける
log.addHandler(log_stderr)

アプリケーションの終了コードは次のいずれかが返されます。

ソースコード cmdkit/app.py での終了コードの定義

class ExitStatus(NamedTuple):
    """Collection of exit status values."""
    success:            int = 0
    usage:              int = 1
    bad_argument:       int = 2
    bad_config:         int = 3
    keyboard_interrupt: int = 4
    runtime_error:      int = 5
    uncaught_exception: int = 6

# global shared instance
exit_status = ExitStatus()

Interface クラス

Interface クラスは、sys.exit() を呼び出す代わりに ArgumentError を発生させる argparse.ArgumentParser の派生クラスです。

Interface(program: str, usage_text: str, help_text: str, **kwargs) -> None:
  • program:プログラム名、デフォルトは os.path.basename(sys.argv[0])
  • usage_text:使用方法の文字列
  • help_text: ヘルプメッセージ

usage_texthelp_text` は与えたそのままの内容で使われます。

usage_texthelp_text` を自動生成する click や typer といったライブラリと比較すると、cmdkit は手間がかかるように見えるかもしれません。しかし、cmdkit ではヘルプメッセージを自由に定義できるわけです。

Interfaceインスタンスでは、argparse で定義されているメソッド利用できます。

from cmdkit.cli import Interface

interface = Interface('myapp', 'usage: myapp ...', 'help: ...')
interface.add_argument('--verbose', action='store_true')

Interface クラスでのオプション解析の指示については、後ほど詳しく説明します。

Configurationクラス

ソースコード cmdkit/cli.py には、アプリケーションレベルのパラメータを管理するクラスとインターフェイスのパラメータを管理するクラスが定義されています。

  • Namespaceクラス:深さ優先の更新メソッドを持つ辞書
  • Environクラス: Namespace クラスを派生した環境変数を管理する
  • Configurationクラス:順番に並べられた Namespace 辞書のコレクション

Namespaceクラスと Environクラスを理解しておくと、Configuration クラスを理解が楽になります。

Namesapceクラス

順番に並べられた Namespace 辞書のコレクションです。
Namespaceのアップデート機能を使って、コンフィギュレーションパラメータを重ねて表示することができます。

Namespace(*args: Union[Iterable, Mapping], **kwargs: Any) -> None
In [2]: # %load 02_namespace.py
    ...: from cmdkit.config import Namespace
    ...:
    ...: data = {'a': {'x': 1, 'y': 2}, 'b': 3}
    ...:
    ...: ns = Namespace(data)
    ...:
    ...: v1 = f'{ns}'
    ...: ns.update({'a': {'x': 4, 'z': 5}})
    ...:
    ...: v2 = f'{ns}'
    ...:
    ...: # print(v1)
    ...: # print(v2)
    ...:

 In [3]: print(v1)
 Namespace({'a': {'x': 1, 'y': 2}, 'b': 3})

 In [4]: print(v2)
 Namespace({'a': {'x': 4, 'y': 2, 'z': 5}, 'b': 3})

このクラスはYAML、TOML、JSONのフォーマットで記述された構成ファイルに簡単に読み書きすることができます。

読み込みのためのメソッド

from_dict(cls, other: Dict[str, Any]) -> Namespace:
from_env(cls, prefix: str = '', defaults: dict = None) -> Namespace:
from_local(cls, filepath: str, ignore_if_missing: bool = False, **options) -> Namespace:
from_yaml(cls, path_or_file: Union[str, IO], **options) -> Namespace:
from_toml(cls, path_or_file: Union[str, IO], **options) -> Namespace:
from_json(cls, path_or_file: Union[str, IO], **options) -> Namespace:

書き込みのためのメソッド

to_dict(self) -> Dict[str, Any]:
to_env(self) -> Environ:
to_local(self, filepath: str, **options) -> None:
to_yaml(self, path_or_file: Union[str, IO], encoding: str = 'utf-8', **kwargs) -> None:
to_toml(self, path_or_file: Union[str, IO], encoding: str = 'utf-8', **kwargs) -> None:
to_json(self, path_or_file: Union[str, IO], encoding: str = 'utf-8', indent: int = 4, **kwargs) -> None:

from_env()メソッドは、prefixキーワード引数で与えた文字列で始まる環境変数をフィルタリングしてから処理を行います。

In [1]: import os

In [2]: os.environ['MYAPP_LOGGING_LEVEL']='INFO'

In [3]: %load 03_from_env.py

In [4]: # %load 03_from_env.py
   ...: from cmdkit.config import Namespace
   ...:
   ...: ns  = Namespace.from_env(prefix='MYAPP',
   ...:                    defaults={'MYAPP_LOGGING_LEVEL': 'WARNING', })
   ...: print(ns.items())
   ...:
dict_items([('MYAPP_LOGGING_LEVEL', 'INFO')])

In [5]: os.environ['MYAPP_LOGGING_MSG']='DEBUG' # 追加

In [6]: %run 03_from_env.py
dict_items([('MYAPP_LOGGING_LEVEL', 'INFO'), ('MYAPP_LOGGING_MSG', 'DEBUG')])

prefix が指定されていないと、対象の環境変数をうまく取り込めません。

In [1]: import os

In [2]: os.environ['MYAPP_LOGGING_LEVEL']='INFO'

In [3]: %load 04_from_env_noprefix.py

In [4]: # %load 04_from_env_noprefix.py
   ...: from cmdkit.config import Namespace
   ...:
   ...: ns  = Namespace.from_env(defaults={'MYAPP_LOGGING_LEVEL': 'WARNING', })
   ...: print(ns.items())
   ...:
dict_items([('MYAPP_LOGGING_LEVEL', 'WARNING')])

In [5]: os.environ['MYAPP_LOGGING_MSG']='DEBUG' # 追加

In [6]: %run 04_from_env_noprefix.py
dict_items([('MYAPP_LOGGING_LEVEL', 'WARNING')])

設定ファイルの読み書きも簡単になります。

In [1]: !cat config.yaml
MAIL_SERVER: "smtp.gmail.com"
MAIL_PORT: 587
MAIL_USE_TLS: True
MAIL_USE_SSL: False
MAIL_USERNAME: None
MAIL_PASSWORD: None
MAIL_DEFAULT_SENDER: "admin@example.com"
# for debug
MAIL_DEBUG: False
MAIL_SUPPRESS_SEND: False

In [2]: %load 05_from_yaml.py

In [3]: # %load 05_from_yaml.py
   ...: from cmdkit.config import Namespace
   ...: from pprint import pprint
   ...:
   ...: ns = Namespace.from_yaml('config.yaml')
   ...:
   ...: pprint(ns.items())
   ...: ns.MAIL_DEBUG = True
   ...:
   ...: ns.to_yaml('config.yaml')
   ...:
   ...: #!cat config.yaml
   ...:
dict_items([('MAIL_SERVER', 'smtp.gmail.com'), ('MAIL_PORT', 587), ('MAIL_USE_TLS', True), ('MAIL_USE_SSL', False), ('MAIL_USERNAME', 'None'), ('MAIL_PASSWORD', 'None'), ('MAIL_DEFAULT_SENDER', 'admin@example.com'), ('MAIL_DEBUG', False), ('MAIL_SUPPRESS_SEND', False)])

In [4]: !cat config.yaml
MAIL_DEBUG: true
MAIL_DEFAULT_SENDER: admin@example.com
MAIL_PASSWORD: None
MAIL_PORT: 587
MAIL_SERVER: smtp.gmail.com
MAIL_SUPPRESS_SEND: false
MAIL_USERNAME: None
MAIL_USE_SSL: false
MAIL_USE_TLS: true

In []: %run 05_from_yaml.py
dict_items([('MAIL_DEBUG', True), ('MAIL_DEFAULT_SENDER', 'admin@example.com'), ('MAIL_PASSWORD', 'None'), ('MAIL_PORT', 587), ('MAIL_SERVER', 'smtp.gmail.com'), ('MAIL_SUPPRESS_SEND', False), ('MAIL_USERNAME', 'None'), ('MAIL_USE_SSL', False), ('MAIL_USE_TLS', True)])

from_local()は、システム設定、ユーザ設定、ローカル設定といったレベルで構成ファイルを読み込むことができます。

In [2]: # %load 06_from_local.py
   ...: import os
   ...: from cmdkit.config import Configuration
   ...: from pprint import pprint
   ...:
   ...: HOME, CWD = os.getenv('HOME'), os.getcwd()
   ...:
   ...: cfg = Configuration.from_local(
   ...:             default=None, env=True, prefix='MYAPP',
   ...:             system='/etc/myapp.yml',
   ...:             user=f'{HOME}/.myapp.yml',
   ...:             local=f'{CWD}/myapp.yml')
   ...:
   ...: # pprint(cfg)
   ...: # print(cfg)
   ...:

In [3]: pprint(cfg)
{'MAIL_DEBUG': True,
 'MAIL_DEFAULT_SENDER': 'admin@example.com',
 'MAIL_PASSWORD': 'None',
 'MAIL_PORT': 587,
 'MAIL_SERVER': 'smtp.gmail.com',
 'MAIL_SUPPRESS_SEND': False,
 'MAIL_USERNAME': 'None',
 'MAIL_USE_SSL': False,
 'MAIL_USE_TLS': True}

In [4]: print(cfg)
Configuration(default=Namespace({}), system=Namespace({}), user=Namespace({}), local=Namespace({'MAIL_DEBUG': True, 'MAIL_DEFAULT_SENDER': 'admin@example.com', 'MAIL_PASSWORD': 'None', 'MAIL_PORT': 587, 'MAIL_SERVER': 'smtp.gmail.com', 'MAIL_SUPPRESS_SEND': False, 'MAIL_USERNAME': 'None', 'MAIL_USE_SSL': False, 'MAIL_USE_TLS': True}), env=Namespace({}))

この例の場合では、はじめに /etc/myapp.yml を読み込み、次にユーザホームディレクトリの $HOME/.myapp.yml を読み込んで、実行時のカレントディレクトリの myapp.yml を読み込みます。それぞれのファイルは存在していなくてもOKです。
ファイル名の拡張子は重要で、ファイルフォーマットを判別するために使用されています。

whereis

whereis で value でフィルタリングされた leaf へのパスを検索できます、

whereis(self, leaf: str, value: Union[Callable[[T], bool], T] = lambda _: True) 
    -> List[Tuple[str, ...]]:
In [2]: # %load 07_whereis.py
   ...: from cmdkit.config import Namespace
   ...:
   ...: data = {'a': {'x': 1, 'y': 2},
   ...:         'b': {'x': 3, 'z': 4} }
   ...:
   ...: ns = Namespace(data)
   ...:
   ...: v1 = f'{ns}'
   ...: v2 = ns.whereis('x')
   ...: v3 = ns.whereis('x', 1)
   ...: v4 = ns.whereis('x', lambda v: v % 3 == 0)
   ...:
   ...: # print(v1)
   ...: # ...
   ...: # print(v4)
   ...:

In [3]: print(v1)
Namespace({'a': {'x': 1, 'y': 2}, 'b': {'x': 3, 'z': 4}})

In [4]: print(v2)
[('a',), ('b',)]

In [5]: print(v3)
[('a',)]

In [6]: print(v4)
[('b',)]

Environクラス

Namespaceクラスを継承したクラスで、Namespace.from_env() で初期化されます。
特別なメソッド reduce() は環境変数をアンダースコアで分割することでデータの構造を再現します。つまり、環境変数名をアンダースコア(_)で区切ると、環境変数をネストすることができます。
例えば、Python での辞書型のデータ{'run: {'echo': True}} は、PREFIX_RUN_ECHO=1と定義することができます。

In [2]: # %load 10_environ.py
   ...: import os
   ...: from cmdkit.config import Environ
   ...:
   ...: os.environ['MYAPP_A_X'] = '1'
   ...: os.environ['MYAPP_A_Y'] = '2'
   ...: os.environ['MYAPP_B'] = '3'
   ...:
   ...: # env = Environ(prefix='MYAPP')
   ...: env = Environ('MYAPP')
   ...: v1 = env.copy()
   ...:
   ...: v2 = env.reduce()
   ...:
   ...: # print(v1)
   ...: # print(v2)
   ...:

In [3]: print(v1)
{'MYAPP_A_X': '1', 'MYAPP_A_Y': '2', 'MYAPP_B': '3'}

In [4]: print(v2)
Environ({'a': {'x': 1, 'y': 2}, 'b': 3})

Configuration クラス

Configurationクラスは、Namespaceクラスオブジェクトを保持します。

In [2]: # %load 20_configuration.py
   ...: from cmdkit.config import Namespace, Configuration
   ...:
   ...: cfg = Configuration(A=Namespace({'x': 1, 'y': 2}),
   ...:                     B=Namespace({'x': 3, 'z': 4}))
   ...:
   ...: v1 = cfg['x'], cfg['y'], cfg['z']
   ...: v2 = cfg.namespaces['A']['x']
   ...:
   ...: # print(cfg)
   ...: # print(v1)
   ...: # print(v2)
   ...:

In [3]: print(cfg)
Configuration(A=Namespace({'x': 1, 'y': 2}), B=Namespace({'x': 3, 'z': 4}))

In [4]: print(v1)
(3, 2, 4)

In [5]: print(v2)
1

オプション解析

Interfaceクラスは argparse.ArgumentParserクラスを継承しています。そのため、add_argument()などのメソッドを使用してオプションや引数の定義を行うことができます。

コマンドラインの位置引数

引数の型を指定 (type=)

float型とint型の2つの位置引数を受け取るようにしてみましょう。

30_args_type.py
import sys
from cmdkit.app import Application, ApplicationGroup, exit_status
from cmdkit.cli import Interface, ArgumentError
from cmdkit.config import Namespace

APP_NAME = 'demo_simple'
APP_DESCRIPTION = """\
Description for demo application.
"""

APP_USAGE = f"""\
Usage: {APP_NAME} [-h|--help] price lots

{APP_DESCRIPTION}
"""

APP_HELP=f"""\
{APP_USAGE}

Options:
  -h, --help        show this message and exit.
"""

class DemoApp(Application):
    interface = Interface(APP_NAME, APP_USAGE, APP_HELP)

    price: float = 0.0
    lots: int = 0

    interface.add_argument('price', type=float)
    interface.add_argument('lots', type=int)

    def run(self):
        print(f'{self.price} x {self.lots}')
        print(f'price: {type(self.price)}')
        print(f'lots: {type(self.lots)}')

def main() -> int:
    return DemoApp.main(sys.argv[1:])

if __name__ == '__main__':
    main()
$ python 30_args_type.py
Usage: demo_simple [-h|--help] price lots

Description for demo application.


$ python 30_args_type.py 109.58 3
109.58 x 3
price: <class 'float'>
lots: <class 'int'>

add_argument()メソッドに type=キーワード引数で明示的に型を指定しています。

上記のコードの次の部分です。

    price: float = 0.0
    lots: int = 0

    interface.add_argument('price', type=float)
    interface.add_argument('lots', type=int)

クラス変数ではタイプヒントを指定していて冗長に見えるかもしれませんが、add_argument()メソッドは、デフォルトでは引数は文字列(str型)として処理してしまうことに留意してください。

位置引数の省略

デフォルトでは、引数を省略すると使用例(USAGE_TEXT)が表示されます。これを、引数されたときはデフォルト値で処理するようにしてみましょう。

31_nargs_default.py
from cmdkit.app import Application, ApplicationGroup, exit_status
from cmdkit.cli import Interface, ArgumentError
from cmdkit.config import Namespace

APP_NAME = 'demo_simple'
APP_DESCRIPTION = """\
Description for demo application.
"""

APP_USAGE = f"""\
Usage: {APP_NAME} [-h|--help] [name]

{APP_DESCRIPTION}
"""

APP_HELP=f"""\
{APP_USAGE}

Options:
  -h, --help        show this message and exit.
"""

class DemoApp(Application):
    ALLOW_NOARGS = True
    interface = Interface(APP_NAME, APP_USAGE, APP_HELP)

    name: str = 'Python'
    debug: bool = False
    verbose: int = 0

    interface.add_argument('name', nargs='?', default=name)

    def run(self):
        print(f'Hello {self.name}')

def main() -> int:
    return DemoApp.main(sys.argv[1:])

if __name__ == '__main__':
    main()
$ python 31_noags_default.py
Hello Python

$ python 31_noags_default.py Osaka
Hello Osaka

はじめに cmkkit に位置引数がないことを許すように設定します。これには、Application クラスを継承して定義したDemoAppクラスにクラス変数 ALLOW_NOARGS = Trueを設定します。

次に、add_argument() メソッドに与える引数で制御します。

上記のコードの次の部分です。

     name: str = None
     interface.add_argument('name', nargs='?', default='Python')

受け取る引数を格納する変数 name には、タイプヒントを使った型を明示しておき、その変数名を文字列でadd_argment()メソッドに与えます。

ひとつの位置引数が受け入れる個数 (nargs=)

nargs= キーワード引数に与える文字で、位置引数の個数の制御ができます。

  • ?:ゼロ(0)もしくはひとつの位置引数を受け付ける
  • +:ひとつ以上の位置引数を受け付ける
  • *:ゼロ(0)もしくはひとつ以上の位置引数を受け付ける
  • 数値:数値で指定した数だけ位置引数を受け付ける

位置引数のデフォルト値を設定 (default=)

default=キーワード引数に、その位置引数のデフォルト値を与えます。

オプション/フラグ

add_argument() に与える変数名の指定(つまり第1引数)がダッシュ記号(-)で始まっていると、オプション文字列として解析されます。

これまでのコードを--debugオプションと--verboseオプションを受け取るようにしてみましょう。

32_debug_verbose.py
import sys
from cmdkit.app import Application, ApplicationGroup, exit_status
from cmdkit.cli import Interface, ArgumentError
from cmdkit.config import Namespace

APP_NAME = 'demo_simple'
APP_DESCRIPTION = """\
Description for demo application.
"""

APP_USAGE = f"""\
Usage: {APP_NAME} [-h|--help] [name]

{APP_DESCRIPTION}
"""

APP_HELP=f"""\
{APP_USAGE}

Options:
  -D, --debug       debug mode
  -v, --verbose     print message verbosly
  -h, --help        show this message and exit.
"""

class DemoApp(Application):
    ALLOW_NOARGS = True
    interface = Interface(APP_NAME, APP_USAGE, APP_HELP)

    name: str = 'Python'
    debug: bool = False
    verbose: int = 0

    interface.add_argument('name', nargs='?', default=name)
    interface.add_argument('-D', '--debug',
                           default=debug, action='store_true')
    interface.add_argument('-v', '--verbose',
                           default=verbose, action='count')

    def run(self):
        print(f'DEBUG: {self.debug}')
        print(f'VERBSE: {self.verbose}')
        print(f'Hello {self.name}')

def main() -> int:
    return DemoApp.main(sys.argv[1:])

if __name__ == '__main__':
    main()
$ python 32_debug_verbose.py
DEBUG: False
VERBSE: 0
Hello Python

$ python 32_debug_verbose.py --debug
DEBUG: True
VERBSE: 0
Hello Python

$ python 32_debug_verbose.py --verbose
DEBUG: False
VERBSE: 1
Hello Python

$ python 32_debug_verbose.py --verbose Osaka
DEBUG: False
VERBSE: 1
Hello Osaka

$ python 32_debug_verbose.py -vvv Osaka
DEBUG: False
VERBSE: 3
Hello Osaka

add_arguent()メソッドの第1引数がダッシュ記号(-)で始まっていると、オプションとして処理されます。オプションは文字通り省略可能で、指示されたときに何らかの機能をさせるようなときに使用します。
通常、2つのダッシュ記号(--)で始まるオプション(--debug--verbose)はロングオプションと呼ばれます。また、1つのダッシュ記号(-)で始まるオプションはショートオプション(-D, -v) と呼ばれます。
add_arguent()は2つの種類のオプションを定義することができます。

オプシンをフラグとして扱う

上記のコードのように --debug オプションが指示されたときは、クラス変数 debugTrue にセットされるようにするためには、add_arguent()action='store_true'を与えます。

    debug: bool = False
    interface.add_argument('-D', '--debug',
                           default=debug, action='store_true')
  • store_true:指示されたときに True をセット
  • store_false:指示されたときに False をセット

オプションが指示された回数をカウント

上記のコードのように--verboseオプションが指示された回数をカウントしたいときは、add_arguent()action='count'を与えます。

    verbose: int = 0
    interface.add_argument('-v', '--verbose',
                           default=verbose, action='count')

SSHコマンドの-vのように、数が多いほどメッセージが詳細になっていくような処理などで使用されます。

オプション引数

SSHコマンドの-l USERNEME のように引数をとるオプションを実装してみましょう。

33_option_args.py
import os
import sys
from cmdkit.app import Application, ApplicationGroup, exit_status
from cmdkit.cli import Interface, ArgumentError
from cmdkit.config import Namespace

APP_NAME = 'demo_simple'
APP_DESCRIPTION = """\
Description for demo application.
"""

APP_USAGE = f"""\
Usage: {APP_NAME} [-h|--help] [-u|--user username]  name

{APP_DESCRIPTION}
"""

APP_HELP=f"""\
{APP_USAGE}

Options:
  -U, --username    Set Username
  -h, --help        show this message and exit.
"""

class DemoApp(Application):
    interface = Interface(APP_NAME, APP_USAGE, APP_HELP)

    name: str = 'Python'
    username: str = os.getlogin()

    interface.add_argument('name', nargs='?', default=name)
    interface.add_argument('-U', '--username', default=username)

    def run(self):
        print(f'USER: {self.username}')
        print(f'Hello {self.name}')

def main() -> int:
    return DemoApp.main(sys.argv[1:])

if __name__ == '__main__':
    main()
$ python 33_option_args.py
Usage: demo_simple [-h|--help] [-U|--user username]  name

Description for demo application.

$ python 33_option_args.py Python
USER: goichiiisaka
Hello Python

$ python 33_option_args.py -U guido
USER: guido
Hello Python

$ python 33_option_args.py -U guido Osaka
USER: guido
Hello Osaka

この場合は、オプション引数で最小限の定義をするだけです。

    name: str = 'Python'
    username: str = os.getlogin()

    interface.add_argument('name', nargs='?', default=name)
    interface.add_argument('-U', '--username', default=username)

相互排他のオプション

こんどは、--enable--disable のように相互排他となるオプションを設定してみましょう。
これにはいくつかの実装方法があります。まず、--enable--disable とで、同じクラス変数 mode にブール値を格納する方法です。

34_mutually_exclusive_manual.py
import sys
from cmdkit.app import Application, ApplicationGroup, exit_status
from cmdkit.cli import Interface, ArgumentError
from cmdkit.config import Namespace

APP_NAME = 'demo_simple'
APP_DESCRIPTION = """\
Description for demo application.
"""

APP_USAGE = f"""\
Usage: {APP_NAME} [-h|--help] [name]

{APP_DESCRIPTION}
"""

APP_HELP=f"""\
{APP_USAGE}

Options:
      --enable      Set enable. not allowed with argument --disable
      --diable      Set disable. not allowed with argument --enable
  -D, --debug       debug mode
  -v, --verbose     print message verbosly
  -h, --help        show this message and exit.
"""

class DemoApp(Application):
    interface = Interface(APP_NAME, APP_USAGE, APP_HELP)

    debug: bool = False
    verbose: int = 0
    mode: bool = True

    interface.add_argument('-D', '--debug',
                           default=debug, action='store_true')
    interface.add_argument('-v', '--verbose',
                           default=verbose, action='count')

    interface.add_argument('--enable', dest='mode',
                       default=mode, action='store_true')
    interface.add_argument('--disable', dest='mode',
                       default=mode, action='store_false')
    def run(self):
        print(f'DEBUG: {self.debug}')
        print(f'VERBSE: {self.verbose}')
        print(f'MODE: {self.mode}')

def main() -> int:
    return DemoApp.main(sys.argv[1:])

if __name__ == '__main__':
    main()
$ python 34_mutually_exclusive_manual.py --debug
DEBUG: True
VERBSE: 0
MODE: True

$ python 34_mutually_exclusive_manual.py --enable
DEBUG: False
VERBSE: 0
MODE: True

$ python 34_mutually_exclusive_manual.py --disable
DEBUG: False
VERBSE: 0
MODE: False

$ python 34_mutually_exclusive_manual.py --disable --enable
DEBUG: False
VERBSE: 0
MODE: True

この場合、--enable--disable はコマンドラインにいくつあってもエラーにはならずに、最後に指定されたものが保持されます。

通常は、argparse ではこうした場合、add_mutually_exclusive_group()メソッドを用いて、オプションをグループ化します。

35_mutually_exclusive.py
import sys
import functools
from typing import Callable
from cmdkit.app import Application, exit_status, log
from cmdkit.cli import Interface, ArgumentError
from cmdkit.config import Namespace

APP_NAME = 'demo_simple'
APP_DESCRIPTION = """\
Description for demo application.
"""

APP_USAGE = f"""\
Usage: {APP_NAME} [-h|--help] [name]

{APP_DESCRIPTION}
"""

APP_HELP=f"""\
{APP_USAGE}

Options:
      --enable      Set enable. not allowed with argument --disable
      --disable      Set disable. not allowed with argument --enable
  -D, --debug       debug mode
  -v, --verbose     print message verbosly
  -h, --help        show this message and exit.
"""

def print_and_exit(exc: Exception, logger: Callable[[str], None], status: int) -> int:
    """Log the exception argument and exit with `status`."""
    logger(*exc.args)
    return status

class DemoApp(Application):
    interface = Interface(APP_NAME, APP_USAGE, APP_HELP)

    debug: bool = False
    verbose: int = 0

    interface.add_argument('-D', '--debug',
                           default=debug, action='store_true')

    interface.add_argument('-v', '--verbose',
                           default=verbose, action='count')

    enable: bool = False
    disable: bool = False
    group = interface.add_mutually_exclusive_group()
    group.add_argument('--enable', action='store_true')
    group.add_argument('--disable', action='store_true')

    def run(self):
        print(f'DEBUG: {self.debug}')
        print(f'VERBSE: {self.verbose}')
        print(f'enable: {self.enable}')
        print(f'disable: {self.disable}')


def main() -> int:
    return DemoApp.main(sys.argv[1:])

if __name__ == '__main__':
    main()
$ python 35_mutually_exclusive.py --debug
DEBUG: True
VERBSE: 0
enable: False
disable: False

$ python 35_mutually_exclusive.py --enable
DEBUG: False
VERBSE: 0
enable: True
disable: False

$ python 35_mutually_exclusive.py --disable
DEBUG: False
VERBSE: 0
enable: False
disable: True

add_mutually_exclusive_group()メソッドで作成したグループオブジェクトにもadd_argument()にメソッドがあり、ここでdest='変数名' と与えるとオプションの結果を格納する変数を指定することができます。

add_mutually_exclusive_group()メソッドでグループを作るときに、required=Trueを与えると、グループのうちどれかひとつは必須となります。つまり、この場合は、--enable / --disable のいずれかを与えられることを要求します。

動作的には問題ないのですが、このままでは相互排他のオプションを同時に与えたときは、
次のように何も出力されないため’ユーザに何が起きたのかを知らせることができません。

 In [6]: %run 35_mutually_exclusive.py --enable --disable

 In [7]:
In [7]: DemoApp.interface.parse_args(['--enable', '--disable'])
---------------------------------------------------------------------------
ArgumentError                             Traceback (most recent call last)
~/anaconda3/envs/tutorials/lib/python3.9/argparse.py in parse_known_args(self, args, namespace)
   1850             try:
-> 1851                 namespace, args = self._parse_known_args(args, namespace)
   1852             except ArgumentError:
(中略)
 ArgumentError: argument --disable: not allowed with argument --enable

 During handling of the above exception, another exception occurred:

 ArgumentError                             Traceback (most recent call last)
 <ipython-input-8-d62b152479db> in <module>
 ----> 1 DemoApp.interface.parse_args(['--enable', '--disable'])
 (中略)
 ~/anaconda3/envs/tutorials/lib/python3.9/site-packages/cmdkit/cli.py in error(self, message)
      98     # simple raise, no printing
      99     def error(self, message: str) -> None:
 --> 100         raise ArgumentError(message)

 ArgumentError: argument --disable: not allowed with argument --enable

これを cmdkit が捕獲して log.critical() でメッセージを出力しているのですが、ロギングの設定がされていないと何も出力されないわけです。

import logging

# 標準出力(コンソール)にログを出力するハンドラを生成する
log_stderr = logging.StreamHandler(sys.stderr)
log_stderr.setLevel(logging.WARNING)
log_stderr.setLevel(logging.CRITICAL)

# ハンドラをロガーに紐づける
log.addHandler(log_stderr)

ロギングハンドラーについては Python 公式ドキュメントの logging.handlers --- ロギングハンドラ を参照してください。

このコードを追加したものが次のサンプルです。

36_mutually_exclusive_with_logging.py
import sys
import functools
from typing import Callable
from cmdkit.app import Application, exit_status, log
from cmdkit.cli import Interface, ArgumentError
from cmdkit.config import Namespace
import logging

# 標準出力(コンソール)にログを出力するハンドラを生成する
log_stderr = logging.StreamHandler(sys.stderr)
log_stderr.setLevel(logging.WARNING)
log_stderr.setLevel(logging.CRITICAL)

# ハンドラをロガーに紐づける
log.addHandler(log_stderr)


APP_NAME = 'demo_simple'
APP_DESCRIPTION = """\
Description for demo application.
"""

APP_USAGE = f"""\
Usage: {APP_NAME} [-h|--help] [name]

{APP_DESCRIPTION}
"""

APP_HELP=f"""\
{APP_USAGE}

Options:
      --enable      Set enable. not allowed with argument --disable
      --disable      Set disable. not allowed with argument --enable
  -D, --debug       debug mode
  -v, --verbose     print message verbosly
  -h, --help        show this message and exit.
"""

def print_and_exit(exc: Exception, logger: Callable[[str], None], status: int) -> int:
    """Log the exception argument and exit with `status`."""
    logger(*exc.args)
    return status

class DemoApp(Application):
    interface = Interface(APP_NAME, APP_USAGE, APP_HELP)

    debug: bool = False
    verbose: int = 0

    interface.add_argument('-D', '--debug',
                           default=debug, action='store_true')

    interface.add_argument('-v', '--verbose',
                           default=verbose, action='count')

    enable: bool = False
    disable: bool = False
    group = interface.add_mutually_exclusive_group()
    group.add_argument('--enable', action='store_true')
    group.add_argument('--disable', action='store_true')

    def run(self):
        print(f'DEBUG: {self.debug}')
        print(f'VERBSE: {self.verbose}')
        print(f'enable: {self.enable}')
        print(f'disable: {self.disable}')


def main() -> int:
    return DemoApp.main(sys.argv[1:])

if __name__ == '__main__':
    main()
$ python 36_mutually_exclusive_with_logging.py --enable
DEBUG: False
VERBSE: 0
enable: True
disable: False

$ python 36_mutually_exclusive_with_logging.py --disable
DEBUG: False
VERBSE: 0
enable: False
disable: True

$ python 36_mutually_exclusive_with_logging.py --disable --enable
argument --enable: not allowed with argument --disable

Applicationクラスのクラス変数 log_criticalが、デフォルトでは’ log.critical()に設定されていることを思い出してみましょう。もっとシンプルにprint()関数を呼び出すようにすることもできます。
print()関数に file=sys.stderr を与えると標準エラー出力に書き出すようになります。この引数をfunctools.partial()でまとめることができます。

functools_partial_demo.py
import sys
import functools

log_critical = functools.partial(print, file=sys.stderr)

log_critical('Hello World.')
# print('Hello World.', file=sys.stderr)
37_mutually_exclusive_with_stderr.py
import sys
import functools
from typing import Callable
from cmdkit.app import Application, exit_status, log
from cmdkit.cli import Interface, ArgumentError
from cmdkit.config import Namespace

APP_NAME = 'demo_simple'
APP_DESCRIPTION = """\
Description for demo application.
"""

APP_USAGE = f"""\
Usage: {APP_NAME} [-h|--help] [name]

{APP_DESCRIPTION}
"""

APP_HELP=f"""\
{APP_USAGE}

Options:
      --enable      Set enable. not allowed with argument --disable
      --disable      Set disable. not allowed with argument --enable
  -D, --debug       debug mode
  -v, --verbose     print message verbosly
  -h, --help        show this message and exit.
"""

class DemoApp(Application):
    interface = Interface(APP_NAME, APP_USAGE, APP_HELP)

    debug: bool = False
    verbose: int = 0
    log_critical = functools.partial(print, file=sys.stderr)
    log_exception = functools.partial(print, file=sys.stderr)

    interface.add_argument('-D', '--debug',
                           default=debug, action='store_true')

    interface.add_argument('-v', '--verbose',
                           default=verbose, action='count')

    enable: bool = False
    disable: bool = False
    group = interface.add_mutually_exclusive_group()
    group.add_argument('--enable', action='store_true')
    group.add_argument('--disable', action='store_true')

    def run(self):
        print(f'DEBUG: {self.debug}')
        print(f'VERBSE: {self.verbose}')
        print(f'enable: {self.enable}')
        print(f'disable: {self.disable}')


def main() -> int:
    return DemoApp.main(sys.argv[1:])

if __name__ == '__main__':
    main()
$ python 37_mutually_exclusive_with_stderr.py --disable --enable
argument --enable: not allowed with argument --disable

$ python 37_mutually_exclusive_with_stderr.py --disable --enable 2>/dev/null

選択肢を制限する

位置引数やオプション引数でいくつかの選択肢の中から選ばせたい場合があります。
通常であれば、こうした場合 argparse では、 add_argument() メソッドに choices キーワード引数を渡します。

38_choices.py
import sys
from cmdkit.app import Application, ApplicationGroup, exit_status
from cmdkit.cli import Interface, ArgumentError
from cmdkit.config import Namespace

APP_NAME = 'demo_simple'
APP_DESCRIPTION = """\
Description for demo application.
"""

APP_USAGE = f"""\
Usage: {APP_NAME} [-h|--help] [-c|--color <green|yellow|red>]  name

{APP_DESCRIPTION}
"""

APP_HELP=f"""\
{APP_USAGE}

Options:
  -h, --help        show this message and exit.
"""

class DemoApp(Application):
    interface = Interface(APP_NAME, APP_USAGE, APP_HELP)

    name: str = 'Python'
    color: str = 'green'

    interface.add_argument('name', nargs='?', default=name)
    interface.add_argument('-c', '--color', default=color,
                           choices=['green', 'yellow', 'red'])

    def run(self):
        print(f'COLOR: {self.color}')
        print(f'Hello {self.name}')

def main() -> int:
    return DemoApp.main(sys.argv[1:])

if __name__ == '__main__':
    main()
$ python 38_choices.py
Usage: demo_simple [-h|--help] [-c|--color <green|yellow|red>]  name

Description for demo application.


$ python 38_choices.py --color yellow
COLOR: yellow
Hello Python

$ python 38_choices.py --color red
COLOR: red
Hello Python

一見するとうまくいっているように見えますが、これも相互排他オプションと同じで、argparse が発生させる例外が cmdkit によって捕獲され log.ceritical()で出力されるため、ロギング設定がされていないとメッセージが隠されてしまいます。

In [5]: DemoApp.interface.parse_args(['--color'])
---------------------------------------------------------------------------
ArgumentError                             Traceback (most recent call last)
(中略)
ArgumentError: argument -c/--color: expected one argument

During handling of the above exception, another exception occurred:

ArgumentError                             Traceback (most recent call last)
<ipython-input-5-3e71c07867aa> in <module>
----> 1 DemoApp.interface.parse_args(['--color'])
(中略)
ArgumentError: argument -c/--color: expected one argument

In [6]: DemoApp.interface.parse_args(['--color', 'black'])
---------------------------------------------------------------------------
ArgumentError                             Traceback (most recent call last)
(中略)
ArgumentError: argument -c/--color: invalid choice: 'black' (choose from 'green', 'yellow', 'red')

During handling of the above exception, another exception occurred:

ArgumentError                             Traceback (most recent call last)
(中略)
ArgumentError: argument -c/--color: invalid choice: 'black' (choose from 'green', 'yellow', 'red')

In [7]: %run 38_choices.py --color

In [8]:

In [8]: %run 38_choices.py --color balck

In [9]:

ロギング設定を追加して再度実行してみます。

39_choices_with_logging.py
import sys
from cmdkit.app import Application, ApplicationGroup, exit_status, log
from cmdkit.cli import Interface, ArgumentError
from cmdkit.config import Namespace
import logging

# 標準出力(コンソール)にログを出力するハンドラを生成する
log_stderr = logging.StreamHandler(sys.stderr)
log_stderr.setLevel(logging.WARNING)
log_stderr.setLevel(logging.CRITICAL)

# ハンドラをロガーに紐づける
log.addHandler(log_stderr)

APP_NAME = 'demo_simple'
APP_DESCRIPTION = """\
Description for demo application.
"""

APP_USAGE = f"""\
Usage: {APP_NAME} [-h|--help] [-c|--color <green|yellow|red>]  name

{APP_DESCRIPTION}
"""

APP_HELP=f"""\
{APP_USAGE}

Options:
  -h, --help        show this message and exit.
"""

class DemoApp(Application):
    interface = Interface(APP_NAME, APP_USAGE, APP_HELP)

    name: str = 'Python'
    color: str = 'green'

    interface.add_argument('name', nargs='?', default=name)
    interface.add_argument('-c', '--color', default=color,
                           choices=['green', 'yellow', 'red'])

    def run(self):
        print(f'COLOR: {self.color}')
        print(f'Hello {self.name}')

def main() -> int:
    return DemoApp.main(sys.argv[1:])

if __name__ == '__main__':
    main()
$ python 39_choices_with_logging.py -c green
COLOR: green
Hello Python

$ python 39_choices_with_logging.py --color yellow
COLOR: yellow
Hello Python

$ python 39_choices_with_logging.py --color yellow red
COLOR: yellow
Hello red

$ python 39_choices_with_logging.py --color black
argument -c/--color: invalid choice: 'black' (choose from 'green', 'yellow', 'red')

この場合も、DemoAppクラスのクラス変数log_criticalprint()を設定することで、ロギング設定をしなくても標準エラー出力に書き出すようにできます。

40_choices_with_stderr.py
import sys
import functools
from cmdkit.app import Application, ApplicationGroup, exit_status, log
from cmdkit.cli import Interface, ArgumentError
from cmdkit.config import Namespace

APP_NAME = 'demo_simple'
APP_DESCRIPTION = """\
Description for demo application.
"""

APP_USAGE = f"""\
Usage: {APP_NAME} [-h|--help] [-c|--color <green|yellow|red>]  name

{APP_DESCRIPTION}
"""

APP_HELP=f"""\
{APP_USAGE}

Options:
  -h, --help        show this message and exit.
"""

class DemoApp(Application):
    interface = Interface(APP_NAME, APP_USAGE, APP_HELP)

    name: str = 'Python'
    color: str = 'green'
    log_critical = functools.partial(print, file=sys.stderr)
    log_exception = functools.partial(print, file=sys.stderr)

    interface.add_argument('name', nargs='?', default=name)
    interface.add_argument('-c', '--color', default=color,
                           choices=['green', 'yellow', 'red'])

    def run(self):
        print(f'COLOR: {self.color}')
        print(f'Hello {self.name}')

def main() -> int:
    return DemoApp.main(sys.argv[1:])

if __name__ == '__main__':
    main()

もうひとつ別の方法でも実装することができます。この場合は、add_argument(()action=キーワード引数にカスタマイズアクションを与えることで擬似的に処理することができます。

まず、カスタマイズアクションを作成します。
--colorがあるのに色していがないパターンと、許容する色ではないものが指定された場合はUSAGE_TEXTを表示させて終了しています。

class ChoiceAction(argparse.Action):
    ACCEPTABLE_CHOICES=['green', 'yellow', 'red']
    def __call__(self, parser, namespace, values=None, options_string=None):
        if values is not None and values in ACCEPTABLE_CHOICES:
            setattr(namespace, self.dest, values)
        else:
            print(f"invalid {values} in {self.ACCEPTABLE_CHOICES}")
            print(APP_USAGE)
            sys.exit(exit_status.bad_argument)

このアクションを add_argument(()action=キーワード引数に与えます。

    color: str = 'green'
    interface.add_argument('-c', '--color', nargs='?', default=color,
                           action=ChoiceAction)
47_choices_manual.py
import sys
from cmdkit.app import Application, ApplicationGroup, exit_status
from cmdkit.cli import Interface, ArgumentError
from cmdkit.config import Namespace
import argparse

APP_NAME = 'demo_simple'
APP_DESCRIPTION = """\
Description for demo application.
"""

APP_USAGE = f"""\
Usage: {APP_NAME} [-h|--help] [-c|--color <green|yellow|red>]  name

{APP_DESCRIPTION}
"""

APP_HELP=f"""\
{APP_USAGE}

Options:
  -h, --help        show this message and exit.
"""

class ChoiceAction(argparse.Action):
    ACCEPTABLE_CHOICES=['green', 'yellow', 'red']
    def __call__(self, parser, namespace, values=None, options_string=None):
        if values is not None and values in self.ACCEPTABLE_CHOICES:
            setattr(namespace, self.dest, values)
        else:
            print(f"invalid {values} in {self.ACCEPTABLE_CHOICES}")
            print(APP_USAGE)
            sys.exit(exit_status.bad_argument)

class DemoApp(Application):
    interface = Interface(APP_NAME, APP_USAGE, APP_HELP)

    name: str = 'Python'
    color: str = 'green'

    interface.add_argument('name', nargs='?', default=name)
    interface.add_argument('-c', '--color', nargs='?', default=color,
                           action=ChoiceAction)

    def run(self):
        print(f'COLOR: {self.color}')
        print(f'Hello {self.name}')

def main() -> int:
    return DemoApp.main(sys.argv[1:])

if __name__ == '__main__':
    main()
$ python 41_choices_manualy.py
Usage: demo_simple [-h|--help] [-c|--color <green|yellow|red>]  name

Description for demo application.


$ python 41_choices_manualy.py --color
invalid None in ['green', 'yellow', 'red']
Usage: demo_simple [-h|--help] [-c|--color <green|yellow|red>]  name

Description for demo application.


$ python 41_choices_manualy.py --color black
invalid black in ['green', 'yellow', 'red']
Usage: demo_simple [-h|--help] [-c|--color <green|yellow|red>]  name

Description for demo application.


$ python 41_choices_manualy.py --color red Python
COLOR: red
Hello Python

FileType オブジェクト

argparse.FileTypeクラスは add_argument() メソッドの type 引数に渡すことができるオブジェクトを生成します。
type が FileType オブジェクトである引数はコマンドライン引数を、指定されたモード、バッファーサイズ、エンコーディング、エラー処理でファイルをオープンします。

42_filetype.py
from cmdkit.app import Application, ApplicationGroup, exit_status
from cmdkit.cli import Interface, ArgumentError
from cmdkit.config import Namespace
import argparse
from typing import List, Union

APP_NAME = 'demo_simple'
APP_DESCRIPTION = """\
Description for demo application.
"""

APP_USAGE = f"""\
Usage: {APP_NAME} [-h|--help] [--infie path] [--outfile path]

{APP_DESCRIPTION}
"""

APP_HELP=f"""\
{APP_USAGE}

Options:
  -h, --help        show this message and exit.
"""

class DemoApp(Application):
    interface = Interface(APP_NAME, APP_USAGE, APP_HELP)

    infile: argparse.FileType = None
    outfile: argparse.FileType = None

    interface.add_argument('--infile', type=argparse.FileType('r'))
    interface.add_argument('--outfile',
                           type=argparse.FileType('w', encoding='UTF-8'))

    def run(self):
        print(f'infile: {self.infile}')
        print(f'outile: {self.outfile}')

def main() -> int:
    return DemoApp.main(sys.argv[1:])

if __name__ == '__main__':
    main()
$ touch a b

$ python 42_filetype.py --infile a --outfile b
infile: <_io.TextIOWrapper name='a' mode='r' encoding='UTF-8'>
outile: <_io.TextIOWrapper name='b' mode='w' encoding='UTF-8'>

サブコマンドを実装

git のようにサブコマンドをもコンソールアプリケーションを作ってみましょう。
まず、ディレクトリ dbmanager を作成します。cmdkitとしてはディレクトリ名何でも構いません。

$ mkdir dbmanager

次に、ロギング設定のモジュール logging.py を作成しておきます。

dbmanager/dblogging.py
import sys
import logging
from cmdkit.app import log

log_stderr = logging.StreamHandler(sys.stderr)
log_stderr.setLevel(logging.WARNING)
log_stderr.setLevel(logging.CRITICAL)

log.addHandler(log_stderr)

ここで、initialize.pydump.py の2つのコマンドを作成します。

dbmanager/initialize.py
"""Initialize database """

import sys
from cmdkit.app import Application, exit_status
from cmdkit.cli import Interface
from dblogging import log

NAME = 'initialize'
PROGRAM = 'dbmanager initialize'
PADDING = ' ' * len(PROGRAM)

USAGE = f"""\
       {PROGRAM} FILE
       {PADDING} [--verbose] [--debug]
       {PADDING} [--help]

"""

HELP = f"""\
{USAGE}

arguments:
FILE                 Path to file for database

options:
-v, --verbose        Show info messages.
-d, --debug          Show debug messages.
-h, --help           Show this message and exit.
"""

class DBInit(Application):

    interface = Interface(PROGRAM, USAGE, HELP)

    dbfile: str = ''
    interface.add_argument('dbfile', nargs=1, default=dbfile)

    debug: bool = False
    interface.add_argument('-d', '--debug', action='store_true')

    verbose: bool = False
    interface.add_argument('-v', '--verbose', action='store_true')

    def run(self) -> int:
        print(f'DEBUG: {self.debug}')
        print(f'VERBOSE: {self.verbose}')
        print(f'DB initialize DB: {self.dbfile}')

DBInit.__doc__ = __doc__

if __name__ == '__main__':
    DBInit.main(sys.argv[1:])
dbmanager/dump.py
"""dump database"""

import ys
from cmdkit.app import Application, exit_status
from cmdkit.cli import Interface
from dblogging import log

NAME = 'dump'
PROGRAM = 'dbmanager dump'
PADDING = ' ' * len(PROGRAM)

USAGE = f"""\
       {PROGRAM} DBNAME
       {PADDING} [--verbose] [--debug]
       {PADDING} [--help]

"""

HELP = f"""\
{USAGE}

arguments:
DBNAME               Name of Database
options:
-v, --verbose        Show info messages.
-d, --debug          Show debug messages.
-h, --help           Show this message and exit.
"""

class DBDump(Application):

    interface = Interface(PROGRAM, USAGE, HELP)

    dbname: str = ''
    interface.add_argument('dbname', nargs=1, default=dbname)

    debug: bool = False
    interface.add_argument('-d', '--debug', action='store_true')

    verbose: bool = False
    interface.add_argument('-v', '--verbose', action='store_true')

    def run(self) -> int:
        print(f'DEBUG: {self.debug}')
        print(f'VERBOSE: {self.verbose}')
        print(f'Dumo DB: {self.dbname}')

DBDump.__doc__ = __doc__

if __name__ == '__main__':
    DBDump.main(sys.argv[1:])

この2つのスクリプトは独立していて、それぞれ単独に引数を与えて実行することができます。

$ python dbmanager/dump.py
       dbmanager dump DBNAME
                      [--verbose] [--debug]
                      [--help]


$ python dbmanager/dump.py --help
       dbmanager dump DBNAME
                      [--verbose] [--debug]
                      [--help]



arguments:
DBNAME               Name of Database

options:
-v, --verbose        Show info messages.
-d, --debug          Show debug messages.
-h, --help           Show this message and exit.

$ python dbmanager/dump.py sample
DEBUG: False
VERBOSE: False
Dumo DB: ['sample']

次に、dbmanager/cli.py を作成して、これらのスクリプトをサブコマンドとして呼び出せるようにしてみます。

ここでのポイントは、ApplicationクラスではなくApplicationGroupクラスを使うことです。

dbmanager/cli.py
import sys
from cmdkit.app import ApplicationGroup
from cmdkit.cli import Interface, ArgumentError

# commands
from .initialize import DBInit
from .dump import DBDump
from .dblogging import log

COMMANDS = {
    'initialize': DBInit,
    'dump': DBDump,
}

PROGRAM = 'dbmanager'

USAGE = f"""\
usage: {PROGRAM} <command> [<args>...]
       {PROGRAM} [--help]

database manager.
"""

HELP = f"""\
{USAGE}\

commands:
initialize             {DBInit.__doc__}
dump                   {DBDump.__doc__}

options:
-h, --help             Show this message and exit.

Use the -h/--help flag with the above commands to
learn more about their usage.

"""

class CompletedCommand(Exception):
    pass

class DBManager(ApplicationGroup):
    interface = Interface(PROGRAM, USAGE, HELP)

    command: str = None
    interface.add_argument('command')

    exceptions = {
        CompletedCommand: (lambda exc: int(exc.args[0])),
    }


    def run(self) -> None:
        try:
            status = COMMANDS[self.command].main(sys.argv[2:])
            raise CompletedCommand(status)

        except KeyError as error:
            cmd, = error.args
            raise ArgumentError(f'"{cmd}" is not an available command.')

def main() -> int:
    return DBManager.main(sys.argv[1:])


if __name__ == '__main__':
    import sys
    sys.exit(main())

ディレクトリ dbmanager はこんな具合です。。

$ tree -I __pycache__ dbmanager
dbmanager
├── __init__.py
├── cli.py
├── dblogging.py
├── dump.py
└── initialize.py

モジュール dbmaager.climain()を呼び出すスクリプトを dbmanager_app.py とします。

dbmanager_app
#!/usr/bin/env python


import re
import sys
from dbmanager.cli import main
if __name__ == '__main__':
    sys.argv[0] = re.sub(r'(-script\.pyw|\.exe)?$', '', sys.argv[0])
    sys.exit(main())

dbmanager のディレクトリが PYTHONPATH に、dbmanager_app に実行権を持たせて が PATH で検索できる状態であれば、次のように実行できます。

% dbmanager_app --help
usage: dbmanager <command> [<args>...]
       dbmanager [--help]

database manager.

commands:
initialize             Initialize database
dump                   dump database

options:
-h, --help             Show this message and exit.

Use the -h/--help flag with the above commands to
learn more about their usage.


% dbmanager_app initialize --help
       dbmanager initialize FILE
                            [--verbose] [--debug]
                            [--help]



arguments:
FILE                 Path to file for database

options:
-v, --verbose        Show info messages.
-d, --debug          Show debug messages.
-h, --help           Show this message and exit.

% dbmanager_app dump  --help
       dbmanager dump DBNAME
                      [--verbose] [--debug]
                      [--help]



arguments:
DBNAME               Name of Database

options:
-v, --verbose        Show info messages.
-d, --debug          Show debug messages.
-h, --help           Show this message and exit.

まとめ

cmdkit は argparse をうまくラッピングしてクラス定義の中で使用できるようにしているため、小規模なスクリプトから、複雑なサブコマンドをもつアプリケーションまで一貫性を保ちながら開発することができます。

参考

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0