Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Literal typehint support, tri_flag(), dict_*() helpers #7

Merged
merged 21 commits into from
Dec 30, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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