Skip to content
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
32 changes: 24 additions & 8 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,12 +5,12 @@ This project builds on top of `argparse` by adding type-safety and allowing a mo

## Example usage
```py
from arcparse import arcparser, flag
from arcparse import arcparser, flag, positional
from pathlib import Path

@arcparser
class Args:
path: Path
path: Path = positional()
recurse: bool = flag("-r")
item_limit: int = 100
output_path: Path | None
Expand All @@ -19,6 +19,21 @@ args = Args.parse()
print(f"Scanning {args.path}...")
...
```
<details>
<summary>Help output of this parser</summary>

usage: program.py [-h] [-r] [--item-limit ITEM_LIMIT] [--output-path OUTPUT_PATH] path

positional arguments:
path

options:
-h, --help show this help message and exit
-r, --recurse
--item-limit ITEM_LIMIT
--output-path OUTPUT_PATH

</details>

For more examples see [Examples](examples/).

Expand All @@ -29,13 +44,14 @@ $ pip install arcparse
```

## Features
- Positional, Option and Flag arguments
- Multiple values per argument
- Positional, Option and [Flag](./examples/flag.py) arguments
- Name overriding
- Type conversions
- Mutually exclusive groups
- Subparsers
- Parser inheritance (with overriding)
- [Multiple values per argument](./examples/multiple.py)
- [Type conversions](./examples/conversion.py)
- [Mutually exclusive groups](./examples/mutual_exclusion.py)
- [Subparsers](./examples/subparsers.py)
- [Parser inheritance](./examples/inheritance.py) (with [overriding](./examples/override.py))
- [Presence validation](./examples/presence_validation.py)

## Credits
This project was inspired by [swansonk14/typed-argument-parser](https://github.com/swansonk14/typed-argument-parser).
Expand Down
16 changes: 4 additions & 12 deletions arcparse/_partial_arguments.py
Original file line number Diff line number Diff line change
Expand Up @@ -116,10 +116,6 @@ def resolve_to_kwargs(self, name: str, typehint: type) -> dict[str, Any]:
class PartialPositional[T](BasePartialValueArgument[T, Positional]):
def resolve_with_typehint(self, name: str, typehint: type) -> Positional:
kwargs = self.resolve_to_kwargs(name, typehint)
return Positional(**kwargs)

def resolve_to_kwargs(self, name: str, typehint: type) -> dict[str, Any]:
kwargs = super().resolve_to_kwargs(name, typehint)

type_is_optional = bool(extract_optional_type(typehint))
type_is_collection = bool(extract_collection_type(typehint))
Expand All @@ -135,7 +131,7 @@ def resolve_to_kwargs(self, name: str, typehint: type) -> dict[str, Any]:
kwargs["nargs"] = "+" if self.at_least_one else "*"
kwargs["metavar"] = self.name_override

return kwargs
return Positional(name=name, **kwargs)


@dataclass
Expand All @@ -151,10 +147,6 @@ def resolve_with_typehint(self, name: str, typehint: type) -> Option:
)

kwargs = self.resolve_to_kwargs(name, typehint)
return Option(**kwargs)

def resolve_to_kwargs(self, name: str, typehint: type) -> dict[str, Any]:
kwargs = super().resolve_to_kwargs(name, typehint)
kwargs["short"] = self.short
kwargs["short_only"] = self.short_only

Expand All @@ -170,6 +162,7 @@ def resolve_to_kwargs(self, name: str, typehint: type) -> dict[str, Any]:
if self.choices is None: # choices generate custom `{foo,bar}` metavar in argparse
kwargs["metavar"] = self.name_override.replace("-", "_").upper()
elif self.short_only and self.short is not None:
kwargs["name"] = name
kwargs["dest"] = name
else:
kwargs["name"] = name
Expand All @@ -181,8 +174,7 @@ def resolve_to_kwargs(self, name: str, typehint: type) -> dict[str, Any]:
kwargs["optional"] = True
elif self.mx_group is not None:
raise InvalidArgument("Arguments in mutually exclusive group have to have a default")

return kwargs
return Option(**kwargs)


@dataclass
Expand All @@ -201,7 +193,7 @@ def resolve_with_typehint(self, name: str, typehint: type) -> Flag:
kwargs["short"] = self.short
kwargs["short_only"] = self.short_only
kwargs["no_flag"] = self.no_flag
return Flag(**kwargs)
return Flag(name=name, **kwargs)


class PartialTriFlag(BasePartialArgument[TriFlag]):
Expand Down
89 changes: 89 additions & 0 deletions arcparse/_validations.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
from abc import ABC, abstractmethod
from collections.abc import Callable, Collection, Iterable, Sequence
from dataclasses import dataclass

from arcparse.arguments import BaseArgument, BaseValueArgument, Flag


class ArgumentAccessor:
def __init__(self, arguments: dict[str, BaseArgument]):
self._arguments = arguments

def __getattribute__(self, key: str) -> BaseArgument:
arguments = super().__getattribute__("_arguments")
if key not in arguments:
raise Exception(f'Argument "{key}" doesn\'t exist')
return arguments[key]


class Constraint(ABC):
@abstractmethod
def validate(self, arguments: dict[str, str]) -> bool: ...

@staticmethod
def is_provided(argument: BaseArgument, arguments: dict[str, str]) -> bool:
if (provided_value := arguments.get(argument.name)) is None:
return False

if isinstance(argument, Flag):
defined_default = argument.no_flag
elif isinstance(argument, BaseValueArgument):
defined_default = argument.default
else:
raise Exception(f"is_provided is not defined for {argument.__class__.__name__}")
return provided_value != defined_default


@dataclass
class ImplyConstraint(Constraint):
arg: BaseArgument
required: Collection[BaseArgument]
disallowed: Collection[BaseArgument]

def validate(self, arguments: dict[str, str]) -> bool:
if not self.is_provided(self.arg, arguments):
return False

for arg in self.required:
if not self.is_provided(arg, arguments):
raise Exception(f'Argument "{arg.display_name}" is required when "{self.arg.display_name}" is passed')

for arg in self.disallowed:
if self.is_provided(arg, arguments):
raise Exception(f'Argument "{arg.display_name}" is incompatible with "{self.arg.display_name}"')
return True


@dataclass
class RequireConstraint(Constraint):
args: Collection[BaseArgument]

def validate(self, arguments: dict[str, str]) -> bool:
def and_join(names: Sequence[str]) -> str:
if len(names) == 0:
return ""
elif len(names) == 1:
return names[0]
return f"{', '.join(names[:-1])} and {names[-1]}"

not_provided = [arg for arg in self.args if not self.is_provided(arg, arguments)]
if not_provided:
provided_text = "none" if len(not_provided) == len(self.args) else "only some"
raise Exception(
f"Arguments {and_join([arg.display_name for arg in self.args])} are required together, but {provided_text} were provided"
)
return True


def validate_with(
defined_arguments: dict[str, BaseArgument],
validations_callable: Callable[[ArgumentAccessor], Iterable[Constraint]],
provided_arguments: dict[str, str],
) -> None:
for constraint in validations_callable(ArgumentAccessor(defined_arguments)):
if not isinstance(constraint, Constraint):
raise TypeError("Items returned from __validations__() have to be of type Constrant")

matched = constraint.validate(provided_arguments)
if matched:
break
78 changes: 48 additions & 30 deletions arcparse/arguments.py
Original file line number Diff line number Diff line change
Expand Up @@ -36,17 +36,22 @@ def apply(self, actions_container: _ActionsContainer, name: str) -> None: ...

@dataclass(kw_only=True)
class BaseArgument(ABC, ContainerApplicable):
name: str
help: str | None = None

@property
@abstractmethod
def display_name(self) -> str: ...

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

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

def get_argparse_kwargs(self, name: str) -> dict[str, Any]:
def get_argparse_kwargs(self) -> dict[str, Any]:
kwargs = {}
if self.help is not None:
kwargs["help"] = self.help
Expand All @@ -59,31 +64,38 @@ class Flag(BaseArgument):
short_only: bool = False
no_flag: bool = False

def get_argparse_args(self, name: str) -> list[str]:
@property
def display_name(self) -> str:
if self.no_flag:
args = [f"--no-{name.replace("_", "-")}"]
else:
args = [f"--{name.replace("_", "-")}"]
return f"--no-{self.name.replace("_", "-")}"
return f"--{self.name.replace("_", "-")}"

def get_argparse_args(self) -> list[str]:
if self.short_only:
if self.short is not None:
return [self.short]
else:
return [f"-{name}"]
elif self.short is not None:
args.insert(0, self.short)
return [f"-{self.name}"]

args = [self.display_name]
if self.short is not None:
args.insert(0, self.short)
return args

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

kwargs["dest"] = name
kwargs["dest"] = self.name
return kwargs


class TriFlag(ContainerApplicable):
name: str

@property
def display_name(self) -> str:
return f"--(no-){self.name.replace("_", "-")}"

def apply(self, actions_container: _ActionsContainer, name: str) -> None:
# if actions_container is not an mx group, make it one, argparse
# doesn't support mx group nesting
Expand All @@ -104,8 +116,8 @@ class BaseValueArgument[T](BaseArgument):
optional: bool = False
metavar: str | None = None

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

if self.default is not void:
kwargs["default"] = self.default
Expand All @@ -121,11 +133,15 @@ def get_argparse_kwargs(self, name: str) -> dict[str, Any]:

@dataclass
class Positional[T](BaseValueArgument[T]):
def get_argparse_args(self, name: str) -> list[str]:
return [name]
@property
def display_name(self) -> str:
return self.name

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

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

if self.nargs is None and (self.optional or self.default is not void):
kwargs["nargs"] = "?"
Expand All @@ -135,26 +151,28 @@ def get_argparse_kwargs(self, name: str) -> dict[str, Any]:

@dataclass
class Option[T](BaseValueArgument[T]):
name: str | None = None
dest: str | None = None
short: str | None = None
short_only: bool = False
append: bool = False

def get_argparse_args(self, name: str) -> list[str]:
args = [f"--{(self.name or name).replace("_", "-")}"]
@property
def display_name(self) -> str:
return f"--{(self.name).replace("_", "-")}"

def get_argparse_args(self) -> list[str]:
if self.short_only:
if self.short is not None:
return [self.short]
else:
return [f"-{self.name or name}"]
elif self.short is not None:
args.insert(0, self.short)
return [f"-{self.name}"]

args = [self.display_name]
if self.short is not None:
args.insert(0, self.short)
return args

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

if self.dest is not None:
kwargs["dest"] = self.dest
Expand Down
11 changes: 8 additions & 3 deletions arcparse/parser.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
extract_subparsers_from_typehint,
union_contains_none,
)
from ._validations import validate_with
from .arguments import (
BaseArgument,
BaseValueArgument,
Expand Down Expand Up @@ -106,6 +107,9 @@ def _construct_object_with_parsed(self, parsed: dict[str, Any]) -> T:
sub_parser = subparsers.sub_parsers[chosen_subparser]
parsed[name] = sub_parser._construct_object_with_parsed(parsed)

if (validations := getattr(self.shape, "__presence_validations__", None)) and callable(validations):
validate_with(self.arguments, cast(Any, validations).__func__, parsed)

# apply argument converters
for name, argument in self.all_arguments:
if not isinstance(argument, BaseValueArgument) or argument.converter is None:
Expand Down Expand Up @@ -139,9 +143,10 @@ def __getattribute__(self, name: str):
if name in __dict__:
return __dict__[name]

value = super().__getattribute__(name)
if not isinstance(value, BasePartialArgument):
return value
if hasattr(super(), name):
value = getattr(super(), name)
if not isinstance(value, BasePartialArgument):
return value

raise AttributeError(f"'{cls.__name__}' parser didn't define argument '{name}'", name=name, obj=self)

Expand Down
Loading
Loading