Skip to content

Commit

Permalink
Merge pull request #7 from Kuba314/dev
Browse files Browse the repository at this point in the history
Literal typehint support, tri_flag(), dict_*() helpers
  • Loading branch information
Kuba314 authored Dec 30, 2023
2 parents c01e755 + 95f7d58 commit 370359f
Show file tree
Hide file tree
Showing 20 changed files with 1,032 additions and 492 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: Check unused imports
run: poetry run ruff --select F401 .
- name: Sort imports
run: poetry run isort --check --diff .
- name: Run pyright
Expand Down
35 changes: 27 additions & 8 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,12 +12,12 @@ from arcparse import arcparser, positional
@arcparser
class Args:
name: str = positional()
age: int = positional()
age: int
hobbies: list[str] = positional()
happy: bool


args = Args.parse("Thomas 25 news coffee running --happy".split())
args = Args.parse("--age 25 Thomas news coffee running --happy".split())
print(f"Hi, my name is {args.name}!")
```

Expand All @@ -27,9 +27,6 @@ For a complete overview of features see [Features](#features).
```shell
# Using pip
$ pip install arcparse

# locally using poetry
$ poetry install
```

## Features
Expand All @@ -55,12 +52,17 @@ class Args:
```

### Flags
All arguments type-hinted as `bool` are flags, they use `action="store_true"` in the background. Use `no_flag()` to easily create a `--no-...` flag with `action="store_false"`. Flags as well as options can also define short forms for each argument. They can also disable the long form with `short_only=True`.
All arguments type-hinted as `bool` are flags, they use `action="store_true"` in the background. Flags (as well as options) can also define short forms for each argument. They can also disable the long form with `short_only=True`.

Use `no_flag()` to easily create a `--no-...` flag with `action="store_false"`.

Use `tri_flag()` (or type-hint argument as `bool | None`) to create a "true" flag and a "false" flag (e.g. `--clone` and `--no-clone`). Passing `--clone` will store `True`, passing `--no-clone` will store `False` and not passing anything will store `None`. Passing both is an error ensured by an implicit mutually exclusive group.
```py
@arcparser
class Args:
sync: bool
recurse: bool = no_flag(help="Do not recurse")
clone: bool | None

debug: bool = flag("-d") # both -d and --debug
verbose: bool = flag("-v", short_only=True) # only -v
Expand Down Expand Up @@ -91,13 +93,14 @@ class Args:
```

### 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` 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).
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 `Literal` also populates choices but does not set converter unlike `StrEnum`. 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 sv, csv, sv_dict, itemwise
from enum import StrEnum
from re import Pattern
from typing import Literal

@arcparser
class Args:
Expand All @@ -112,6 +115,7 @@ class Args:

number: int
result: Result
literal: Literal["yes", "no"]
pattern: Pattern
custom: Result = option(converter=Result.from_int)
ints: list[int] = option(converter=csv(int))
Expand All @@ -120,12 +124,27 @@ class Args:
results: list[Result] = option(converter=itemwise(Result.from_int))
```

### dict helpers
Sometimes creating an argument able to choose a value from a dict by its key is desired. `dict_option` and `dict_positional` do exactly that. In the following example passing `--foo yes` will result in `.foo` being `True`.
```py
from arcparse import dict_option

values = {
"yes": True,
"no": False,
}

@arcparser
class Args:
foo: bool = dict_option(values)
```

### 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
@arcparser
class Args:
group = MxGroup() # alternatively use `(group := MxGroup())` on the next line
group = mx_group() # alternatively use `(group := mx_group())` on the next line
flag: bool = flag(mx_group=group)
option: str | None = option(mx_group=group)
```
Expand Down
23 changes: 20 additions & 3 deletions arcparse/__init__.py
Original file line number Diff line number Diff line change
@@ -1,14 +1,31 @@
from ._arguments import MxGroup, flag, no_flag, option, positional
from ._parser import arcparser, subparsers
from ._argument_helpers import (
dict_option,
dict_positional,
flag,
mx_group,
no_flag,
option,
positional,
subparsers,
tri_flag,
)
from ._parser import InvalidArgument, InvalidParser, InvalidTypehint, arcparser
from .converters import itemwise


__all__ = [
"arcparser",
"positional",
"option",
"flag",
"no_flag",
"MxGroup",
"tri_flag",
"dict_positional",
"dict_option",
"mx_group",
"subparsers",
"itemwise",
"InvalidParser",
"InvalidArgument",
"InvalidTypehint",
]
159 changes: 159 additions & 0 deletions arcparse/_argument_helpers.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,159 @@
from collections.abc import Callable, Collection
from typing import Any

from arcparse.errors import InvalidArgument

from ._arguments import Void, void
from ._partial_arguments import (
PartialFlag,
PartialMxGroup,
PartialNoFlag,
PartialOption,
PartialPositional,
PartialSubparsers,
PartialTriFlag,
)


def positional[T](
*,
default: T | str | Void = void,
choices: Collection[str] | None = None,
converter: Callable[[str], T] | None = None,
name_override: str | None = None,
at_least_one: bool = False,
mx_group: PartialMxGroup | None = None,
help: str | None = None,
) -> T:
return PartialPositional(
default=default,
choices=choices,
converter=converter,
name_override=name_override,
at_least_one=at_least_one,
mx_group=mx_group,
help=help,
) # type: ignore


def option[T](
short: str | None = None,
*,
short_only: bool = False,
default: T | str | Void = void,
choices: Collection[str] | None = None,
converter: Callable[[str], T] | None = None,
name_override: str | None = None,
append: bool = False,
at_least_one: bool = False,
mx_group: PartialMxGroup | None = None,
help: str | None = None,
) -> T:
if short_only and short is None:
raise ValueError("`short_only` cannot be True if `short` is not provided")

if append and at_least_one:
raise ValueError("`append` is incompatible with `at_least_one`")

return PartialOption(
short=short,
short_only=short_only,
default=default,
choices=choices,
converter=converter,
name_override=name_override,
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: PartialMxGroup | None = None,
help: str | None = None,
) -> bool:
if short_only and short is None:
raise ValueError("`short_only` cannot be True if `short` is not provided")
return PartialFlag(
short=short,
short_only=short_only,
help=help,
mx_group=mx_group,
) # type: ignore


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


def tri_flag(mx_group: PartialMxGroup | None = None) -> bool | None:
return PartialTriFlag(mx_group=mx_group) # type: ignore


def mx_group(*, required: bool = False) -> PartialMxGroup:
return PartialMxGroup(required=required)


def subparsers(*args: str) -> Any:
return PartialSubparsers(names=list(args))


def dict_positional[T](
dict_: dict[str, T],
*,
default: T | Void = void,
name_override: str | None = None,
at_least_one: bool = False,
mx_group: PartialMxGroup | None = None,
help: str | None = None,
) -> T:
"""Creates positional() from dict by pre-filling choices and converter"""
if default is not void and default not in dict_.values():
raise InvalidArgument("dict_positional default must be a value in dict")
return positional(
default=default,
choices=list(dict_.keys()),
converter=dict_.__getitem__,
name_override=name_override,
at_least_one=at_least_one,
mx_group=mx_group,
help=help,
)
def dict_option[T](
dict_: dict[str, T],
*,
short: str | None = None,
short_only: bool = False,
default: T | Void = void,
name_override: str | None = None,
append: bool = False,
at_least_one: bool = False,
mx_group: PartialMxGroup | None = None,
help: str | None = None,
) -> T:
"""Creates option() from dict by pre-filling choices and converter"""

if default is not void and default not in dict_.values():
raise InvalidArgument("dict_positional default must be a value in dict")

return option(
short=short,
short_only=short_only,
default=default,
choices=list(dict_.keys()),
converter=dict_.__getitem__,
name_override=name_override,
append=append,
at_least_one=at_least_one,
mx_group=mx_group,
help=help,
)
Loading

0 comments on commit 370359f

Please sign in to comment.