Skip to content

Commit

Permalink
Merge pull request #1 from Kuba314/dev
Browse files Browse the repository at this point in the history
Add subparser support, add tests and CI
  • Loading branch information
Kuba314 authored Nov 15, 2023
2 parents 502b5c1 + 8e4937c commit 23570a8
Show file tree
Hide file tree
Showing 9 changed files with 424 additions and 99 deletions.
29 changes: 29 additions & 0 deletions .github/workflows/python.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
name: Python application

on:
push:
branches: [ "main" ]
pull_request:
branches: [ "main" ]

permissions:
contents: read

jobs:
test:
runs-on: ubuntu-latest

steps:
- uses: actions/checkout@v3
- name: Set up Python 3.12
uses: actions/setup-python@v3
with:
python-version: "3.12"
- name: Install poetry
uses: abatilo/actions-poetry@v2
- name: Install poetry project
run: poetry install
- name: Run pyright
run: poetry run pyright
- name: Run pytest
run: poetry run pytest
48 changes: 40 additions & 8 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
# Arcparse
Declare program arguments declaratively and type-safely. Optionally set argument defaults dynamically (see [Dynamic argument defaults](#dynamic-argument-defaults))
Declare program arguments declaratively and type-safely. Optionally set argument defaults dynamically (see [Dynamic argument defaults](#dynamic-argument-defaults)).

This project provides a wrapper around `argparse`. It adds type-safety and allows for more expressive argument parser definitions.

Disclaimer: This library is young and probably highly unstable. Use at your own risk. Pull requests are welcome.

Expand Down Expand Up @@ -35,7 +37,7 @@ $ poetry install
### Required and optional arguments
Arguments without explicitly assigned argument class are implicitly options (prefixed with `--`). A non-optional typehint results in `required=True` for options. Defaults can be set by directly assigning them. You can use `option()` to further customize the argument.
```py
class Required(ArcParser):
class Args(ArcParser):
required: str
optional: str | None
default: str = "foo"
Expand All @@ -45,15 +47,15 @@ class Required(ArcParser):
### Positional arguments
Positional arguments use `positional()`. Type-hinting the argument as `list[...]` uses `nargs="*"` in the background for positional arguments.
```py
class Positional(ArcParser):
class Args(ArcParser):
single: str = positional()
multiple: list[str] = positional()
```

### 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-...` 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. 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`.
```py
class FlagArgs(ArcParser):
class Args(ArcParser):
sync: bool
recurse: bool = no_flag(help="Do not recurse")

Expand All @@ -62,9 +64,9 @@ class FlagArgs(ArcParser):
```

### 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`.
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()`.
```py
class TypeArgs(ArcParser):
class Args(ArcParser):
class Result(StrEnum):
PASS = "pass"
FAIL = "fail"
Expand All @@ -82,10 +84,40 @@ class TypeArgs(ArcParser):
### 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.
```py
class NameOverrideArgs(ArcParser):
class Args(ArcParser):
values: list[str] = option(name_override="value")
```

### Subparsers
Type-hinting an argument as a union of ArcParser subclasses creates subparsers in the background. Assigning from `subparsers()` gives them names as they will be entered from the command-line. Subparsers are required by default. Adding `None` to the union makes the subparsers optional.
```py
class FooArgs(ArcParser):
arg1: str

class BarArgs(ArcParser):
arg2: int = positional()

class Args(ArcParser):
action: FooArgs | BarArgs = subparsers("foo", "bar")

class OptionalSubparsersArgs(ArcParser):
action: FooArgs | BarArgs | None = subparsers("foo", "bar")
```

Once the arguments are parsed, the different subparsers can be triggered and distinguished like so:
```shell
python3 script.py foo --arg1 baz
python3 script.py bar --arg2 123
```
```py
args = Args.parse()
if isinstance(foo := args.action, FooArgs):
print(f"foo {foo.arg1}")
elif isinstance(bar := args.action, BarArgs):
print(f"bar {bar.arg2}")
```
Be aware that even though the `isinstance()` check passes, the instantiated subparser objects are never actual instances of their class because a dynamically created `dataclass` is used instead. The `isinstance()` relation is faked using a metaclass overriding `__instancecheck__()`.

## Dynamic argument defaults
The `parse()` classmethod supports an optional dictionary of defaults, which replace the statically defined defaults before parsing arguments. This might be useful for saving some arguments in a config file allowing the user to provide only the ones that are not present in the config.

Expand Down
2 changes: 2 additions & 0 deletions arcparse/__init__.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
from .parser import ArcParser
from .argument import positional, option, flag, no_flag
from .subparser import subparsers

__all__ = [
"ArcParser",
"positional",
"option",
"flag",
"no_flag",
"subparsers",
]
74 changes: 33 additions & 41 deletions arcparse/argument.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,37 +11,36 @@ class Void:


@dataclass(kw_only=True)
class Argument(ABC):
name: str | None = None
class _BaseArgument(ABC):
help: str | None = None

def register(self, parser: ArgumentParser) -> None:
args = self.get_argparse_args()
kwargs = self.get_argparse_kwargs()
def apply(self, parser: ArgumentParser, name: str) -> None:
args = self.get_argparse_args(name)
kwargs = self.get_argparse_kwargs(name)
parser.add_argument(*args, **kwargs)

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

def get_argparse_kwargs(self) -> dict[str, Any]:
def get_argparse_kwargs(self, name: str) -> dict[str, Any]:
kwargs = {}
if self.help is not None:
kwargs["help"] = self.help
return kwargs


@dataclass(kw_only=True)
class _BaseValueArgument[T](Argument):
class _BaseValueArgument[T](_BaseArgument):
default: T | Void = void
choices: list[T] | None = None
converter: Callable[[str], T] | None = None
name_override: str | None = None
multiple: bool = False
required: bool = False

def get_argparse_kwargs(self) -> dict[str, Any]:
kwargs = super().get_argparse_kwargs()
def get_argparse_kwargs(self, name: str) -> dict[str, Any]:
kwargs = super().get_argparse_kwargs(name)
if self.converter is not None:
kwargs["type"] = self.converter
if self.default is not void:
Expand All @@ -57,18 +56,16 @@ def get_argparse_kwargs(self) -> dict[str, Any]:

@dataclass
class _Positional[T](_BaseValueArgument[T]):
def get_argparse_args(self) -> list[str]:
assert self.name is not None, "name should be known at this point"
def get_argparse_args(self, name: str) -> list[str]:
if self.name_override is not None:
return [self.name_override]
return [self.name]
return [name]

def get_argparse_kwargs(self) -> dict[str, Any]:
assert self.name is not None, "name should be known at this point"
kwargs = super().get_argparse_kwargs()
def get_argparse_kwargs(self, name: str) -> dict[str, Any]:
kwargs = super().get_argparse_kwargs(name)
if self.multiple:
kwargs["nargs"] = "*"
kwargs["metavar"] = self.name.upper()
kwargs["metavar"] = name.upper()
elif not self.required:
kwargs["nargs"] = "?"
return kwargs
Expand All @@ -79,10 +76,9 @@ class _Option[T](_BaseValueArgument[T]):
short: str | None = None
short_only: bool = False

def get_argparse_args(self) -> list[str]:
assert self.name is not None, "name should be known at this point"
def get_argparse_args(self, name: str) -> list[str]:

name = self.name_override if self.name_override is not None else self.name.replace("_", "-")
name = self.name_override if self.name_override is not None else name.replace("_", "-")
args = [f"--{name}"]
if self.short_only:
assert self.short is not None
Expand All @@ -92,34 +88,33 @@ def get_argparse_args(self) -> list[str]:

return args

def get_argparse_kwargs(self) -> dict[str, Any]:
assert self.name is not None, "name should be known at this point"
def get_argparse_kwargs(self, name: str) -> dict[str, Any]:

kwargs = super().get_argparse_kwargs()
kwargs = super().get_argparse_kwargs(name)
if self.multiple:
kwargs["action"] = "append"

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

if self.required:
kwargs["required"] = True

return kwargs


@dataclass
class _Flag(Argument):
class _Flag(_BaseArgument):
short: str | None = None
short_only: bool = False
default: bool = False

def get_argparse_args(self) -> list[str]:
assert self.name is not None, "name should be known at this point"
def get_argparse_args(self, name: str) -> list[str]:

args = [f"--{self.name.replace("_", "-")}"]
args = [f"--{name.replace("_", "-")}"]
if self.short_only:
assert self.short is not None
return [self.short]
Expand All @@ -128,28 +123,25 @@ def get_argparse_args(self) -> list[str]:

return args

def get_argparse_kwargs(self) -> dict[str, Any]:
kwargs = super().get_argparse_kwargs()
def get_argparse_kwargs(self, name: str) -> dict[str, Any]:
kwargs = super().get_argparse_kwargs(name)
kwargs["action"] = "store_false" if self.default else "store_true"

assert self.name is not None, "name should be known at this point"
if self.short_only:
kwargs["dest"] = self.name
kwargs["dest"] = name
return kwargs


@dataclass
class _NoFlag(Argument):
def get_argparse_args(self) -> list[str]:
assert self.name is not None, "name should be known at this point"
return [f"--no-{self.name.replace("_", "-")}"]
class _NoFlag(_BaseArgument):
def get_argparse_args(self, name: str) -> list[str]:
return [f"--no-{name.replace("_", "-")}"]

def get_argparse_kwargs(self) -> dict[str, Any]:
kwargs = super().get_argparse_kwargs()
def get_argparse_kwargs(self, name: str) -> dict[str, Any]:
kwargs = super().get_argparse_kwargs(name)
kwargs["action"] = "store_false"

assert self.name is not None, "name should be known at this point"
kwargs["dest"] = self.name
kwargs["dest"] = name
return kwargs


Expand Down
Loading

0 comments on commit 23570a8

Please sign in to comment.