Skip to content

Commit

Permalink
Merge pull request #4 from Kuba314/dev
Browse files Browse the repository at this point in the history
Support nargs="+", mutually exclusive groups and other other stuff
  • Loading branch information
Kuba314 authored Dec 8, 2023
2 parents 6817a8d + 98e1401 commit 3fe2f74
Show file tree
Hide file tree
Showing 22 changed files with 694 additions and 144 deletions.
2 changes: 2 additions & 0 deletions .github/workflows/python.yml
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,8 @@ jobs:
uses: abatilo/actions-poetry@v2
- name: Install poetry project
run: poetry install
- name: Sort imports
run: poetry run isort --check --diff .
- name: Run pyright
run: poetry run pyright
- name: Run pytest
Expand Down
34 changes: 27 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -64,42 +64,62 @@ class Args(ArcParser):
```

### Multiple values per argument
By type-hinting the argument as `list[...]`, the argument will use `nargs="*"` in the background. Passing `append=True` to `option()` uses `action="append"` instead (this is available only for `option()`).
By type-hinting the argument as `list[...]`, the argument will use `nargs="*"` in the background. Passing `at_least_one=True` uses `nargs="+"` instead. Passing `append=True` to `option()` uses `action="append"` instead (this is available only for `option()` and incompatible with `at_least_one`).
```py
class Args(ArcParser):
option_nargs: list[str]
positional_nargs: list[str] = positional()
append_option: list[str] = option(append=True)
nargs_plus_option: list[str] = option(at_least_one=True)
nargs_plus_positional: list[str] = positional(at_least_one=True)
```

Note that `option(at_least_one=True)` will cause the option to be required. If this is not intended, provide a default value.

### Name overriding
Passing `name_override=...` will cause the provided string to be used instead of the variable name for the argument name. The string will undergo a replacement of `_` with `-` and will contain a `--` prefix if used in `option()`.

This is useful in combination with accepting multiple values with `append=True`, because the user will use `--value foo --value bar`, while the code will use `args.values`.
```py
class Args(ArcParser):
values: list[str] = option(name_override="value", append=True)
```

### Type conversions
Automatic type conversions are supported. The type-hint is used in `type=...` in the background (unless it's `str`, which does no conversion). Using a `StrEnum` instance as a type-hint automatically populates `choices`. A custom type-converter can be used by passing `converter=...` to either `option()` or `positional()`. Come common utility converters are defined in [converters.py](arcparse/converters.py).
Automatic type conversions are supported. The type-hint is used in `type=...` in the background (unless it's `str`, which does no conversion). Using a `StrEnum` subclass as a type-hint automatically populates `choices`. Using a `re.Pattern` typehint automatically uses `re.compile` as a converter. A custom type-converter can be used by passing `converter=...` to either `option()` or `positional()`. Come common utility converters are defined in [converters.py](arcparse/converters.py).

Custom converters may be used in combination with multiple values per argument. These converters are called `itemwise` and need to be wrapped in `itemwise()`. This wrapper is used automatically if an argument is typed as `list[...]` and no converter is set.
```py
from arcparse.converters import csv, itemwise
from arcparse.converters import sv, csv, sv_dict, itemwise
from re import Pattern

class Args(ArcParser):
class Result(StrEnum):
PASS = "pass"
FAIL = "fail"

@classmethod
def from_int(cls, arg: str) -> Result:
def from_int(cls, arg: str) -> "Result":
number = int(arg)
return cls.PASS if number == 1 else cls.FAIL

number: int
result: Result
pattern: Pattern
custom: Result = option(converter=Result.from_int)
ints: list[int] = option(converter=csv(int))
ip_parts: list[int] = option(converter=sv(".", int), name_override="ip")
int_overrides: dict[str, int] = option(converter=sv_dict(",", "=", value_type=int)) # accepts x=1,y=2
results: list[Result] = option(converter=itemwise(Result.from_int))
```

### Name overriding
Type-hinting an option as `list[...]` uses `action="append"` in the background. Use this in combination with `name_override=...` to get rid of the `...s` suffixes.
### Mutually exclusive groups
Use `mx_group` to group multiple arguments together in a mutually exclusive group. Each argument has to have a default defined either implicitly through the type (being `bool` or a union with `None`) or explicitly with `default`.
```py
class Args(ArcParser):
values: list[str] = option(name_override="value")
group = MxGroup() # alternatively use `(group := MxGroup())` on the next line
flag: bool = flag(mx_group=group)
option: str | None = option(mx_group=group)
```

### Subparsers
Expand Down
4 changes: 2 additions & 2 deletions arcparse/__init__.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
from .arguments import MxGroup, flag, no_flag, option, positional
from .converters import itemwise
from .parser import ArcParser
from .arguments import positional, option, flag, no_flag
from .subparser import subparsers
from .converters import itemwise

__all__ = [
"ArcParser",
Expand Down
169 changes: 141 additions & 28 deletions arcparse/arguments.py
Original file line number Diff line number Diff line change
@@ -1,27 +1,47 @@
from abc import ABC, abstractmethod
from argparse import ArgumentParser
from dataclasses import dataclass, field
from argparse import ArgumentParser, _ActionsContainer
from collections.abc import Callable
from typing import Any
from dataclasses import dataclass, field
from typing import Any, Literal, overload

from .converters import itemwise
from .typehints import extract_collection_type, extract_optional_type, extract_type_from_typehint
from .typehints import (
extract_collection_type,
extract_optional_type,
extract_type_from_typehint,
)


class Void:
pass

void = Void()


class MxGroup:
def __init__(self, *, required: bool = False):
self.required = required

def apply(self, parser: ArgumentParser, arguments_by_name: list[tuple[str, "_BaseArgument"]]):
group = parser.add_mutually_exclusive_group(required=self.required)
for name, argument in arguments_by_name:
argument.apply(group, name)


@dataclass(kw_only=True)
class _BaseArgument(ABC):
mx_group: MxGroup | None = None
help: str | None = None
typehint: type = field(init=False, default=Void)

def apply(self, parser: ArgumentParser, name: str) -> None:
def apply(self, actions_container: _ActionsContainer, name: str) -> None:
# value is overriden, do not add argument
if isinstance(self, _ValueOverride) and self.value_override is not void:
return

args = self.get_argparse_args(name)
kwargs = self.get_argparse_kwargs(name)
parser.add_argument(*args, **kwargs)
actions_container.add_argument(*args, **kwargs)

@abstractmethod
def get_argparse_args(self, name: str) -> list[str]:
Expand All @@ -44,7 +64,8 @@ class _BaseValueArgument[T](_BaseArgument):
converter: Callable[[str], T] | None = None
name_override: str | None = None
multiple: bool = False
required: bool = False
at_least_one: bool = False
type_requires_value: bool = False

def get_argparse_kwargs(self, name: str) -> dict[str, Any]:
kwargs = super().get_argparse_kwargs(name)
Expand All @@ -54,7 +75,7 @@ def get_argparse_kwargs(self, name: str) -> dict[str, Any]:
kwargs["default"] = self.default
if self.choices is not None:
kwargs["choices"] = self.choices
if self.multiple:
if self.multiple and not self.at_least_one:
if self.default is void:
kwargs["default"] = []

Expand Down Expand Up @@ -89,18 +110,18 @@ def get_argparse_args(self, name: str) -> list[str]:
def get_argparse_kwargs(self, name: str) -> dict[str, Any]:
kwargs = super().get_argparse_kwargs(name)
if self.multiple:
kwargs["nargs"] = "*"
kwargs["nargs"] = "+" if self.at_least_one else "*"
kwargs["metavar"] = name.upper()
elif not self.required:
elif not self.type_requires_value or self.default is not void:
kwargs["nargs"] = "?"
return kwargs

def resolve_with_typehint(self, typehint: type) -> None:
super().resolve_with_typehint(typehint)
is_optional = bool(extract_optional_type(typehint))
is_collection = bool(extract_collection_type(typehint))
if is_optional or is_collection or self.default is not void:
self.required = False
if is_optional or is_collection:
self.type_requires_value = False


@dataclass
Expand All @@ -110,7 +131,6 @@ class _Option[T](_BaseValueArgument[T]):
append: bool = False

def get_argparse_args(self, name: str) -> list[str]:

name = self.name_override if self.name_override is not None else name.replace("_", "-")
args = [f"--{name}"]
if self.short_only:
Expand All @@ -127,15 +147,15 @@ def get_argparse_kwargs(self, name: str) -> dict[str, Any]:
if self.append:
kwargs["action"] = "append"
else:
kwargs["nargs"] = "*"
kwargs["nargs"] = "+" if self.at_least_one else "*"

if self.name_override is not None:
kwargs["dest"] = name
kwargs["metavar"] = self.name_override.replace("-", "_").upper()
elif self.short_only:
kwargs["dest"] = name

if self.required:
if self.default is void and (self.type_requires_value or self.at_least_one):
kwargs["required"] = True

return kwargs
Expand All @@ -144,12 +164,24 @@ def resolve_with_typehint(self, typehint: type) -> None:
super().resolve_with_typehint(typehint)
is_optional = bool(extract_optional_type(typehint))
is_collection = bool(extract_collection_type(typehint))
if not is_optional and not is_collection and self.default is void:
self.required = True
if not is_optional and not is_collection:
self.type_requires_value = True


@dataclass(kw_only=True)
class _ValueOverride[T]:
"""Value override for arguments
Utilized in flags and no_flags when providing dynamic defaults for them.
Setting a non-void `value_override` causes the argument to not be included
into ArgumentParser and the value will be always contained in the return
arguments.
"""
value_override: T | Void = void


@dataclass
class _Flag(_BaseArgument):
class _Flag(_ValueOverride[bool], _BaseArgument):
short: str | None = None
short_only: bool = False

Expand All @@ -173,7 +205,7 @@ def get_argparse_kwargs(self, name: str) -> dict[str, Any]:


@dataclass
class _NoFlag(_BaseArgument):
class _NoFlag(_ValueOverride[bool], _BaseArgument):
def get_argparse_args(self, name: str) -> list[str]:
return [f"--no-{name.replace("_", "-")}"]

Expand All @@ -185,23 +217,53 @@ def get_argparse_kwargs(self, name: str) -> dict[str, Any]:
return kwargs


@overload
def positional[T](
*,
default: T | Void = void,
choices: list[T] | None = None,
converter: Callable[[str], T] | None = None,
name_override: str | None = None,
at_least_one: Literal[False] = False,
mx_group: MxGroup | None = None,
help: str | None = None,
) -> T:
) -> T: ...

@overload
def positional[T](
*,
default: list[T] | Void = void,
choices: list[T] | None = None,
converter: Callable[[str], list[T]] | None = None,
name_override: str | None = None,
at_least_one: Literal[True] = True,
mx_group: MxGroup | None = None,
help: str | None = None,
) -> list[T]: ...

def positional( # type: ignore
*,
default=void,
choices=None,
converter=None,
name_override=None,
at_least_one=False,
mx_group=None,
help=None,
):
return _Positional(
default=default,
choices=choices,
converter=converter,
name_override=name_override,
required=True,
type_requires_value=True,
at_least_one=at_least_one,
mx_group=mx_group,
help=help,
) # type: ignore
)


@overload
def option[T](
short: str | None = None,
*,
Expand All @@ -210,9 +272,56 @@ def option[T](
choices: list[T] | None = None,
converter: Callable[[str], T] | None = None,
name_override: str | None = None,
append: bool = False,
append: Literal[False] = False,
at_least_one: Literal[False] = False,
mx_group: MxGroup | None = None,
help: str | None = None,
) -> T:
) -> T: ...


@overload
def option[T](
short: str | None = None,
*,
short_only: bool = False,
default: list[T] | Void = void,
choices: list[T] | None = None,
converter: Callable[[str], list[T]] | None = None,
name_override: str | None = None,
append: Literal[True] = True,
at_least_one: Literal[False] = False,
mx_group: MxGroup | None = None,
help: str | None = None,
) -> list[T]: ...

@overload
def option[T](
short: str | None = None,
*,
short_only: bool = False,
default: list[T] | Void = void,
choices: list[T] | None = None,
converter: Callable[[str], list[T]] | None = None,
name_override: str | None = None,
append: Literal[False] = False,
at_least_one: Literal[True] = True,
mx_group: MxGroup | None = None,
help: str | None = None,
) -> list[T]: ...

def option( # type: ignore
short=None,
*,
short_only=False,
default=void,
choices=None,
converter=None,
name_override=None,
append=False,
at_least_one=False,
mx_group=None,
help=None,
):
if short_only and short is None:
raise Exception("`short_only` cannot be True if `short` is not provided")
return _Option(
Expand All @@ -222,16 +331,19 @@ def option[T](
choices=choices,
converter=converter,
name_override=name_override,
required=False,
type_requires_value=False,
append=append,
at_least_one=at_least_one,
mx_group=mx_group,
help=help,
) # type: ignore
)


def flag(
short: str | None = None,
*,
short_only: bool = False,
mx_group: MxGroup | None = None,
help: str | None = None,
) -> bool:
if short_only and short is None:
Expand All @@ -240,8 +352,9 @@ def flag(
short=short,
short_only=short_only,
help=help,
mx_group=mx_group,
) # type: ignore


def no_flag(*, help: str | None = None) -> bool:
return _NoFlag(help=help) # type: ignore
def no_flag(*, mx_group: MxGroup | None = None, help: str | None = None) -> bool:
return _NoFlag(mx_group=mx_group, help=help) # type: ignore
Loading

0 comments on commit 3fe2f74

Please sign in to comment.