Skip to main content

A wrapper around the standard argparse module that allows you to describe argument parsers declaratively

Project description

argclass

Coverage Actions Latest Version Python Versions License

A wrapper around the standard argparse module that allows you to describe argument parsers declaratively.

By default, the argparse module suggests creating parsers imperatively, which is not convenient for type checking and attribute access. Additionally, IDE autocompletion and type hints are not applicable in this case.

This module allows you to declare command-line parsers using classes.

Quick Start

import argclass

class CopyParser(argclass.Parser):
    recursive: bool
    preserve_attributes: bool

parser = CopyParser()
parser.parse_args(["--recursive", "--preserve-attributes"])
assert parser.recursive
assert parser.preserve_attributes

As you can see, this example shows basic module usage. When you want to specify argument defaults and other options, you have to use argclass.Argument.

Subparsers

The following example shows how to use subparsers:

import argclass

class SubCommand(argclass.Parser):
    comment: str

    def __call__(self) -> int:
        endpoint: str = self.__parent__.endpoint
        print("Subcommand called", self, "endpoint", endpoint)
        return 0

class Parser(argclass.Parser):
    endpoint: str
    subcommand = SubCommand()

if __name__ == '__main__':
    parser = Parser()
    parser.parse_args()
    exit(parser())

The __call__ method will be called when the subparser is used. Otherwise, help will be printed.

Value Conversion

If an argument has a generic or composite type, you must explicitly describe it using argclass.Argument, specifying a converter function with the type or converter argument to transform the value after parsing.

The main differences between type and converter are:

  • type will be directly passed to the argparse.ArgumentParser.add_argument method.
  • The converter function will be called after parsing the argument.
import uuid
import argclass

def string_uid(value: str) -> uuid.UUID:
    return uuid.uuid5(uuid.NAMESPACE_OID, value)

class Parser(argclass.Parser):
    strid1: uuid.UUID = argclass.Argument(converter=string_uid)
    strid2: uuid.UUID = argclass.Argument(type=string_uid)

parser = Parser()
parser.parse_args(["--strid1=hello", "--strid2=world"])
assert parser.strid1 == uuid.uuid5(uuid.NAMESPACE_OID, 'hello')
assert parser.strid2 == uuid.uuid5(uuid.NAMESPACE_OID, 'world')

As you can see, the string_uid function is called in both cases, but converter is applied after parsing the argument.

The following example shows how type is applied to each item in a list when using nargs:

import argclass

class Parser(argclass.Parser):
    numbers = argclass.Argument(nargs=argclass.Nargs.ONE_OR_MORE, type=int)

parser = Parser()
parser.parse_args(["--numbers", "1", "2", "3"])
assert parser.numbers == [1, 2, 3]

type will be applied to each item in the list of arguments.

If you want to convert a list of strings to a list of integers and then to a frozenset, you can use the following example:

import argclass

class Parser(argclass.Parser):
    numbers = argclass.Argument(
        nargs=argclass.Nargs.ONE_OR_MORE, type=int, converter=frozenset
    )

parser = Parser()
parser.parse_args(["--numbers", "1", "2", "3"])
assert parser.numbers == frozenset([1, 2, 3])

Configuration Files

Parser objects can get default values from environment variables or from specified configuration files.

import logging
from pathlib import Path
from tempfile import TemporaryDirectory
import argclass

class Parser(argclass.Parser):
    log_level: int = argclass.LogLevel
    address: str
    port: int

with TemporaryDirectory() as tmpdir:
    tmp = Path(tmpdir)
    with open(tmp / "config.ini", "w") as fp:
        fp.write(
            "[DEFAULT]\n"
            "log_level=info\n"
            "address=localhost\n"
            "port=8080\n"
        )

    parser = Parser(config_files=[tmp / "config.ini"])
    parser.parse_args([])
    assert parser.log_level == logging.INFO
    assert parser.address == "localhost"
    assert parser.port == 8080

When using configuration files, argclass uses Python's ast.literal_eval for parsing arguments with nargs and complex types. This means that in your INI configuration files, you should write values in a syntax that literal_eval can parse for these specific arguments.

For regular arguments (simple types like strings, integers, booleans), you can write the values as-is.

Argument Groups

The following example uses argclass.Argument and argument groups:

from typing import FrozenSet
import logging
import argclass

class AddressPortGroup(argclass.Group):
    address: str = argclass.Argument(default="127.0.0.1")
    port: int

class Parser(argclass.Parser):
    log_level: int = argclass.LogLevel
    http = AddressPortGroup(title="HTTP options", defaults=dict(port=8080))
    rpc = AddressPortGroup(title="RPC options", defaults=dict(port=9090))
    user_id: FrozenSet[int] = argclass.Argument(
        nargs="*", type=int, converter=frozenset
    )

parser = Parser(
    config_files=[".example.ini", "~/.example.ini", "/etc/example.ini"],
    auto_env_var_prefix="EXAMPLE_"
)
parser.parse_args([])

# Remove all used environment variables from os.environ
parser.sanitize_env()

logging.basicConfig(level=parser.log_level)
logging.info('Listening http://%s:%d', parser.http.address, parser.http.port)
logging.info('Listening rpc://%s:%d', parser.rpc.address, parser.rpc.port)

assert parser.http.address == '127.0.0.1'
assert parser.rpc.address == '127.0.0.1'

assert parser.http.port == 8080
assert parser.rpc.port == 9090

Argument groups are sections in the parser configuration. For example, in this case, the configuration file might be:

[DEFAULT]
log_level=info
user_id=[1, 2, 3]

[http]
port=9001

[rpc]
port=9002

Run this script:

$ python example.py
INFO:root:Listening http://127.0.0.1:8080
INFO:root:Listening rpc://127.0.0.1:9090

Example of --help output:

$ python example.py --help
usage: example.py [-h] [--log-level {debug,info,warning,error,critical}]
                  [--http-address HTTP_ADDRESS] [--http-port HTTP_PORT]
                  [--rpc-address RPC_ADDRESS] [--rpc-port RPC_PORT]

optional arguments:
  -h, --help            show this help message and exit
  --log-level {debug,info,warning,error,critical}
                        (default: info) [ENV: EXAMPLE_LOG_LEVEL]

HTTP options:
  --http-address HTTP_ADDRESS
                        (default: 127.0.0.1) [ENV: EXAMPLE_HTTP_ADDRESS]
  --http-port HTTP_PORT
                        (default: 8080) [ENV: EXAMPLE_HTTP_PORT]

RPC options:
  --rpc-address RPC_ADDRESS
                        (default: 127.0.0.1) [ENV: EXAMPLE_RPC_ADDRESS]
  --rpc-port RPC_PORT   (default: 9090) [ENV: EXAMPLE_RPC_PORT]

Default values will be based on the following configuration files ['example.ini',
'~/.example.ini', '/etc/example.ini']. Now 1 file has been applied
['example.ini']. The configuration files are INI-formatted files where
configuration groups are INI sections.
See more https://pypi.org/project/argclass/#configs

Secrets

Arguments that contain sensitive data, such as tokens, encryption keys, or URLs with passwords, when passed through environment variables or a configuration file, can be printed in the output of --help. To hide defaults, add the secret=True parameter, or use the special default constructor argclass.Secret instead of argclass.Argument.

import argclass

class HttpAuthentication(argclass.Group):
    username: str = argclass.Argument()
    password: str = argclass.Secret()

class HttpBearerAuthentication(argclass.Group):
    token: str = argclass.Argument(secret=True)

class Parser(argclass.Parser):
    http_basic = HttpAuthentication()
    http_bearer = HttpBearerAuthentication()

parser = Parser()
parser.print_help()

Preventing Secrets from Being Logged

A secret is not actually a string, but a special class inherited from str. All attempts to cast this type to a str (using the __str__ method) will return the original value, unless the __str__ method is called from the logging module.

import logging
from argclass import SecretString

logging.basicConfig(level=logging.INFO)
s = SecretString("my-secret-password")
logging.info(s)          # __str__ will be called from logging
logging.info(f"s=%s", s) # __str__ will be called from logging too
logging.info(f"{s!r}")   # repr is safe
logging.info(f"{s}")     # the password will be compromised

Of course, this is not absolute sensitive data protection, but it helps prevent accidental logging of these values.

The repr for this will always give a placeholder, so it is better to always add !r to any f-string, for example f'{value!r}'.

Enum Argument

The library provides a special argument type for working with enumerations. For enum arguments, the choices parameter will be generated automatically from the enum names. After parsing the argument, the value will be converted to the enum member.

import enum
import logging
import argclass

class LogLevelEnum(enum.IntEnum):
    debug = logging.DEBUG
    info = logging.INFO
    warning = logging.WARNING
    error = logging.ERROR
    critical = logging.CRITICAL

class Parser(argclass.Parser):
    """Log level with default"""
    log_level = argclass.EnumArgument(LogLevelEnum, default="info")

class ParserLogLevelIsRequired(argclass.Parser):
    log_level: LogLevelEnum

parser = Parser()
parser.parse_args([])
assert parser.log_level == logging.INFO

parser = Parser()
parser.parse_args(["--log-level=error"])
assert parser.log_level == logging.ERROR

parser = ParserLogLevelIsRequired()
parser.parse_args(["--log-level=warning"])
assert parser.log_level == logging.WARNING

Config Action

This library provides a base class for writing custom configuration parsers.

argclass.Config is a special argument type for parsing configuration files. The optional parameter config_class is used to specify the custom configuration parser. By default, it is an INI parser.

YAML Parser

To parse YAML files, you need to install the PyYAML package. Follow code is an implementation of a YAML config parser.

from typing import Mapping, Any
from pathlib import Path
import argclass
import yaml

class YAMLConfigAction(argclass.ConfigAction):
    def parse_file(self, file: Path) -> Mapping[str, Any]:
        with file.open("r") as fp:
            return yaml.load(fp, Loader=yaml.FullLoader)

class YAMLConfigArgument(argclass.ConfigArgument):
    action = YAMLConfigAction

class Parser(argclass.Parser):
    config = argclass.Config(
        required=True,
        config_class=YAMLConfigArgument,
    )

TOML Parser

To parse TOML files, you need to install the tomli package. Follow code is an implementation of a TOML config parser.

import tomli
import argclass
from pathlib import Path
from typing import Mapping, Any

class TOMLConfigAction(argclass.ConfigAction):
    def parse_file(self, file: Path) -> Mapping[str, Any]:
        with file.open("rb") as fp:
            return tomli.load(fp)

class TOMLConfigArgument(argclass.ConfigArgument):
    action = TOMLConfigAction

class Parser(argclass.Parser):
    config = argclass.Config(
        required=True,
        config_class=TOMLConfigArgument,
    )

Subparsers Advanced Usage

There are two ways to work with subparsers: either by calling the parser as a regular function, in which case the subparser must implement the __call__ method (otherwise help will be printed and the program will exit with an error), or by directly inspecting the .current_subparser attribute in the parser. The second method can be simplified using functools.singledispatch.

Using __call__

Just implement the __call__ method for subparsers and call the main parser.

from typing import Optional
import argclass

class AddressPortGroup(argclass.Group):
    address: str = "127.0.0.1"
    port: int = 8080

class CommitCommand(argclass.Parser):
    comment: str = argclass.Argument()

    def __call__(self) -> int:
        endpoint: AddressPortGroup = self.__parent__.endpoint
        print(
            "Commit command called", self,
            "endpoint", endpoint.address, "port", endpoint.port
        )
        return 0

class PushCommand(argclass.Parser):
    comment: str = argclass.Argument()

    def __call__(self) -> int:
        endpoint: AddressPortGroup = self.__parent__.endpoint
        print(
            "Push command called", self,
            "endpoint", endpoint.address, "port", endpoint.port
        )
        return 0

class Parser(argclass.Parser):
    log_level: int = argclass.LogLevel
    endpoint = AddressPortGroup(title="Endpoint options")
    commit: Optional[CommitCommand] = CommitCommand()
    push: Optional[PushCommand] = PushCommand()

if __name__ == '__main__':
    parser = Parser(
        config_files=["example.ini", "~/.example.ini", "/etc/example.ini"],
        auto_env_var_prefix="EXAMPLE_"
    )
    parser.parse_args()
    exit(parser())

Using singledispatch

You can use the current_subparser attribute to get the current subparser and then call it. This does not require implementing the __call__ method.

from functools import singledispatch
from typing import Optional, Any
import argclass

class AddressPortGroup(argclass.Group):
    address: str = argclass.Argument(default="127.0.0.1")
    port: int

class CommitCommand(argclass.Parser):
    comment: str = argclass.Argument()

class PushCommand(argclass.Parser):
    comment: str = argclass.Argument()

class Parser(argclass.Parser):
    log_level: int = argclass.LogLevel
    endpoint = AddressPortGroup(
        title="Endpoint options",
        defaults=dict(port=8080)
    )
    commit: Optional[CommitCommand] = CommitCommand()
    push: Optional[PushCommand] = PushCommand()

@singledispatch
def handle_subparser(subparser: Any) -> None:
    raise NotImplementedError(
        f"Unexpected subparser type {subparser.__class__!r}"
    )

@handle_subparser.register(type(None))
def handle_none(_: None) -> None:
    Parser().print_help()
    exit(2)

@handle_subparser.register(CommitCommand)
def handle_commit(subparser: CommitCommand) -> None:
    print("Commit command called", subparser)

@handle_subparser.register(PushCommand)
def handle_push(subparser: PushCommand) -> None:
    print("Push command called", subparser)

if __name__ == '__main__':
    parser = Parser(
        config_files=["example.ini", "~/.example.ini", "/etc/example.ini"],
        auto_env_var_prefix="EXAMPLE_"
    )
    parser.parse_args()
    handle_subparser(parser.current_subparser)

Value Conversion with Optional and Union Types

If an argument has a generic or composite type, you must explicitly describe it using argclass.Argument, specifying the converter function with type or converter to transform the value after parsing. The exception to this rule is Optional with a single type. In this case, an argument without a default value will not be required, and its value can be None.

import argclass
from typing import Optional, Union

def converter(value: str) -> Optional[Union[int, str, bool]]:
    if value.lower() == "none":
        return None
    if value.isdigit():
        return int(value)
    if value.lower() in ("yes", "true", "enabled", "enable", "on"):
        return True
    return False

class Parser(argclass.Parser):
    gizmo: Optional[Union[int, str, bool]] = argclass.Argument(
        converter=converter
    )
    optional: Optional[int]

parser = Parser()

parser.parse_args(["--gizmo=65535"])
assert parser.gizmo == 65535

parser.parse_args(["--gizmo=None"])
assert parser.gizmo is None

parser.parse_args(["--gizmo=on"])
assert parser.gizmo is True
assert parser.optional is None

parser.parse_args(["--gizmo=off", "--optional=10"])
assert parser.gizmo is False
assert parser.optional == 10

Project details


Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distribution

argclass-1.1.0.tar.gz (21.5 kB view details)

Uploaded Source

Built Distribution

argclass-1.1.0-py3-none-any.whl (18.3 kB view details)

Uploaded Python 3

File details

Details for the file argclass-1.1.0.tar.gz.

File metadata

  • Download URL: argclass-1.1.0.tar.gz
  • Upload date:
  • Size: 21.5 kB
  • Tags: Source
  • Uploaded using Trusted Publishing? No
  • Uploaded via: poetry/1.8.3 CPython/3.11.7 Darwin/24.0.0

File hashes

Hashes for argclass-1.1.0.tar.gz
Algorithm Hash digest
SHA256 655964e13f1fc1b6a8837cdd69abe6345230afbd3316bc5f78ab0fcb5623238e
MD5 ec7dccb0c7b906560e8aedac25bf530e
BLAKE2b-256 0c40463b9ccbffaab7793f9e894a0bece2cd7e99323b8ee17a8c46d918efcd9e

See more details on using hashes here.

File details

Details for the file argclass-1.1.0-py3-none-any.whl.

File metadata

  • Download URL: argclass-1.1.0-py3-none-any.whl
  • Upload date:
  • Size: 18.3 kB
  • Tags: Python 3
  • Uploaded using Trusted Publishing? No
  • Uploaded via: poetry/1.8.3 CPython/3.11.7 Darwin/24.0.0

File hashes

Hashes for argclass-1.1.0-py3-none-any.whl
Algorithm Hash digest
SHA256 6169f275670125851edec25387d87a23504ba55fc0576d65de343cbd7a75b67b
MD5 55b76e9d64c48df6516c44ebba9e0c7a
BLAKE2b-256 430813cd5d3e75dddd6a6869cbbf0ecd1e5ff9aada4bb3ccfe670d6c6e8825ea

See more details on using hashes here.

Supported by

AWS AWS Cloud computing and Security Sponsor Datadog Datadog Monitoring Fastly Fastly CDN Google Google Download Analytics Microsoft Microsoft PSF Sponsor Pingdom Pingdom Monitoring Sentry Sentry Error logging StatusPage StatusPage Status page