"""Command line options for extension commands."""
import dataclasses
import enum
from typing import Optional, Any, Callable, Mapping, List, Tuple
class _ArgumentType(enum.Enum):
OPTION = "option"
POSITIONAL = "positional"
MUTEX_GROUP = "mutually_exclusive_group"
FLAG = "flag"
# for one reason or another, this argument should be ignored when adding
# arguments to the parser. This is typically used by mutex groups to add
# their arguments to the top level of the class
IGNORE = "ignore"
class _NotSet:
"""A marker to indicate that a value is not set."""
def __repr__(self):
return "_NotSet()"
def __str__(self):
return "The value related to this argument has not yet been set"
NOTSET = _NotSet()
@dataclasses.dataclass
class _Option:
short_name: Optional[str] = None
long_name: Optional[str] = None
configurable: Optional[bool] = None
help: Optional[str] = None
converter: Optional[Callable[[str], Any]] = None
required: Optional[bool] = None
default: Optional[Any] = None
argument_type: _ArgumentType = _ArgumentType.OPTION
argparse_kwargs: Optional[Mapping[str, Any]] = None
# Value_attr_name should be set by the __set_name__ function. Attempting to
# use this default will cause a crash as it isn't a valid Python identifier
value_attr_name: str = "invalid attribute name"
def __set_name__(self, owner, name) -> None:
if self.long_name is None:
self.long_name = f"--{name.replace('_', '-')}"
self.value_attr_name = f"_parsed_value_{name}"
def __set__(self, obj, value) -> None:
setattr(obj, self.value_attr_name, value)
def __get__(self, obj, type=None) -> Any:
return getattr(obj, self.value_attr_name, NOTSET)
@dataclasses.dataclass(frozen=True)
class _MutuallyExclusiveGroup:
options: List[Tuple[str, _Option]]
required: bool = False
def __post_init__(self):
for name, opt in self.options:
self._check_arg_type(name, opt)
# __set_name__ must be called explicitly as it is not called when
# assigning values to keyword arguments, as is done in mutex groups
opt.__set_name__(self, name)
def _check_arg_type(self, name: str, opt: _Option):
allowed_types = (_ArgumentType.OPTION, _ArgumentType.FLAG)
if opt.argument_type not in allowed_types:
raise ValueError(
f"{opt.argument_type.value} not allowed in mutex group"
)
def __set__(self, obj, value) -> None:
self._add_options_to_obj(obj, self.options)
@staticmethod
def _add_options_to_obj(
obj: object, options: List[Tuple[str, _Option]]
) -> None:
for name, opt in options:
setattr(
obj,
name,
dataclasses.replace(opt, argument_type=_ArgumentType.IGNORE),
)
[docs]def is_cli_arg(obj: Any) -> bool:
"""Determine if an object is a CLI argument.
Args:
obj: An object.
Returns:
True if the object is an instance of a CLI argument class.
"""
return isinstance(obj, (_Option, _MutuallyExclusiveGroup))
[docs]def option(
short_name: Optional[str] = None,
long_name: Optional[str] = None,
help: str = "",
required: bool = False,
default: Optional[Any] = None,
configurable: bool = False,
converter: Optional[Callable[[str], Any]] = None,
argparse_kwargs: Optional[Mapping[str, Any]] = None,
) -> _Option:
"""Create an option for a :py:class:`Command` or a
:py:class:`CommandExtension`.
Example usage:
.. code-block:: python
:caption: ext.py
import repobee_plug as plug
class Hello(plug.Plugin, plug.cli.Command):
name = plug.cli.option(help="Your name.")
age = plug.cli.option(converter=int, help="Your age.")
def command(self):
print(
f"Hello, my name is {self.name} "
f"and I am {self.age} years old"
)
This command can then be called like so:
.. code-block:: bash
$ repobee -p ext.py hello --name Alice --age 22
Hello, my name is Alice and I am 22 years old
.. danger::
This function returns an `_Option`, which is an internal structure. You
should not handle this value directly, it should only ever be assigned
as an attribute to a command class.
Args:
short_name: The short name of this option. Must start with ``-``.
long_name: The long name of this option. Must start with `--`.
help: A description of this option that is used in the CLI help
section.
required: Whether or not this option is required.
default: A default value for this option.
configurable: Whether or not this option is configurable. If an option
is both configurable and required, having a value for the option
in the configuration file makes the option non-required.
converter: A converter function that takes a string and returns
the argument in its proper state. Should also perform input
validation and raise an error if the input is malformed.
argparse_kwargs: Keyword arguments that are passed directly to
:py:meth:`argparse.ArgumentParser.add_argument`
Returns:
A CLI argument wrapper used internally by RepoBee to create command
line arguments.
"""
return _Option(
short_name=short_name,
long_name=long_name,
configurable=configurable,
help=help,
converter=converter,
required=required,
default=default,
argument_type=_ArgumentType.OPTION,
argparse_kwargs=argparse_kwargs or {},
)
[docs]def positional(
help: str = "",
converter: Optional[Callable[[str], Any]] = None,
argparse_kwargs: Optional[Mapping[str, Any]] = None,
) -> _Option:
"""Create a positional argument for a :py:class:`Command` or a
:py:class:`CommandExtension`.
Example usage:
.. code-block:: python
:caption: ext.py
import repobee_plug as plug
class Hello(plug.Plugin, plug.cli.Command):
name = plug.cli.positional(help="Your name.")
age = plug.cli.positional(converter=int, help="Your age.")
def command(self):
print(
f"Hello, my name is {self.name} "
f"and I am {self.age} years old"
)
This command can then be called like so:
.. code-block:: bash
$ repobee -p ext.py hello Alice 22
Hello, my name is Alice and I am 22 years old
.. danger::
This function returns an `_Option`, which is an internal structure. You
should not handle this value directly, it should only ever be assigned
as an attribute to a command class.
Args:
help: The help section for the positional argument.
converter: A converter function that takes a string and returns
the argument in its proper state. Should also perform input
validation and raise an error if the input is malformed.
argparse_kwargs: Keyword arguments that are passed directly to
:py:meth:`argparse.ArgumentParser.add_argument`
Returns:
A CLI argument wrapper used internally by RepoBee to create command
line argument.
"""
return _Option(
help=help,
converter=converter,
argparse_kwargs=argparse_kwargs or {},
argument_type=_ArgumentType.POSITIONAL,
)
[docs]def flag(
short_name: Optional[str] = None,
long_name: Optional[str] = None,
help: str = "",
const: Any = True,
default: Optional[Any] = None,
) -> _Option:
"""Create a command line flag for a :py:class:`Command` or a
:py:class`CommandExtension`. This is simply a convenience wrapper around
:py:func:`option`.
A flag is specified on the command line as ``--flag``, and causes a
constant to be stored. If the flag is omitted, a default value is used
instead. The default behavior is that specifying ``--flag``
stores the constant ``True``, and omitting it causes it to default to
``False``. It can also be used to store any other form of constant by
specifying the ``const`` argument. If so, then omitting the flag will cause
it to default to ``None`` instead of ``False``. Finally, the default value
can also be overridden by specifying the ``default`` argument.
Example:
.. code-block:: python
:caption: ext.py
import repobee_plug as plug
class Flags(plug.Plugin, plug.cli.Command):
# a normal flag, which toggles between True and False
is_great = plug.cli.flag()
# a "reverse" flag which defaults to True instead of False
not_great = plug.cli.flag(const=False, default=True)
# a flag that stores a constant and defaults to None
meaning = plug.cli.flag(const=42)
# a flag that stores a constant and defaults to another constant
approve = plug.cli.flag(const="yes", default="no")
def command(self):
print("is_great", self.is_great)
print("not_great", self.not_great)
print("meaning", self.meaning)
print("approve", self.approve)
We can then call this command (for example) like so:
.. code-block:: bash
$ repobee -p ext.py flags --meaning --not-great
is_great False
not_great False
meaning 42
approve no
.. danger::
This function returns an `_Option`, which is an internal structure. You
should not handle this value directly, it should only ever be assigned
as an attribute to a command class.
Args:
short_name: The short name of this option. Must start with ``-``.
long_name: The long name of this option. Must start with `--`.
help: A description of this option that is used in the CLI help
section.
const: The constant to store.
default: The value to default to if the flag is omitted.
Returns:
A CLI argument wrapper used internally by RepoBee to create command
line argument.
"""
resolved_default = (
not const if default is None and isinstance(const, bool) else default
)
return _Option(
short_name=short_name,
long_name=long_name,
help=help,
argparse_kwargs=dict(
action="store_const", const=const, default=resolved_default
),
argument_type=_ArgumentType.FLAG,
)
[docs]def mutually_exclusive_group(*, __required__: bool = False, **kwargs):
"""Create a mutually exclusive group of arguments in a command.
.. danger::
This function returns a `_MutuallyExclusiveGroup`, which is an internal
structure. You should not handle this value directly, it should only
ever be assigned as an attribute to a command class.
Args:
__required__: Whether or not this mutex group is required.
kwargs: Keyword arguments on the form ``name=plug.cli.option()``.
"""
options = [(key, value) for key, value in kwargs.items()]
return _MutuallyExclusiveGroup(required=__required__, options=options)
[docs]@dataclasses.dataclass(frozen=True)
class ConfigurableArguments:
"""A container for holding a plugin's configurable arguments."""
config_section_name: str
argnames: List[str]