Skip to content

Commit

Permalink
feat: add Group.__main__() support (#90)
Browse files Browse the repository at this point in the history
  • Loading branch information
eonu authored Dec 9, 2023
1 parent cf901d0 commit fb36108
Show file tree
Hide file tree
Showing 10 changed files with 547 additions and 140 deletions.
81 changes: 43 additions & 38 deletions feud/_internal/_command.py
Original file line number Diff line number Diff line change
Expand Up @@ -38,8 +38,8 @@ class ParameterSpec:
class CommandState:
config: Config
click_kwargs: dict[str, t.Any]
context: bool
is_group: bool
pass_context: bool = False
# below keys are parameter name
arguments: dict[str, ParameterSpec] = dataclasses.field(
default_factory=dict
Expand All @@ -55,45 +55,47 @@ def decorate(self: CommandState, func: t.Callable) -> click.Command:
sensitive_vars: dict[str, bool] = {}
params: list[click.Parameter] = []

if self.is_group:
for param in self.overrides.values():
params.append(param) # noqa: PERF402

command = func
else:
for i, param_name in enumerate(inspect.signature(func).parameters):
sensitive: bool = False
if self.context and i == 0:
continue
if param_name in self.overrides:
param: click.Parameter = self.overrides[param_name]
sensitive = param.hide_input
elif param_name in self.arguments:
spec = self.arguments[param_name]
spec.kwargs["type"] = _types.click.get_click_type(
spec.hint, config=self.config
)
param = click.Argument(spec.args, **spec.kwargs)
elif param_name in self.options:
spec = self.options[param_name]
spec.kwargs["type"] = _types.click.get_click_type(
spec.hint, config=self.config
)
param = click.Option(spec.args, **spec.kwargs)
meta_vars[param_name] = self.get_meta_var(param)
sensitive_vars[param_name] = sensitive
sig: inspect.signature = inspect.signature(func)

for i, param_name in enumerate(sig.parameters):
sensitive: bool = False
if self.pass_context and i == 0:
continue
if param_name in self.overrides:
param: click.Parameter = self.overrides[param_name]
sensitive = param.hide_input
elif param_name in self.arguments:
spec = self.arguments[param_name]
spec.kwargs["type"] = _types.click.get_click_type(
spec.hint, config=self.config
)
param = click.Argument(spec.args, **spec.kwargs)
elif param_name in self.options:
spec = self.options[param_name]
spec.kwargs["type"] = _types.click.get_click_type(
spec.hint, config=self.config
)
param = click.Option(spec.args, **spec.kwargs)
meta_vars[param_name] = self.get_meta_var(param)
sensitive_vars[param_name] = sensitive
params.append(param)

# add any overrides that don't appear in function signature
# e.g. version_option or anything else
for param_name, param in self.overrides.items():
if param_name not in sig.parameters:
params.append(param)

command = _decorators.validate_call(
func,
name=self.click_kwargs["name"],
meta_vars=meta_vars,
sensitive_vars=sensitive_vars,
pydantic_kwargs=self.config.pydantic_kwargs,
)
command = _decorators.validate_call(
func,
name=self.click_kwargs["name"],
meta_vars=meta_vars,
sensitive_vars=sensitive_vars,
pydantic_kwargs=self.config.pydantic_kwargs,
)

if self.context:
command = click.pass_context(command)
if self.pass_context:
command = click.pass_context(command)

constructor = click.group if self.is_group else click.command
command = constructor(**self.click_kwargs)(command)
Expand Down Expand Up @@ -159,7 +161,7 @@ def get_alias(alias: str, *, hint: type, negate_flags: bool) -> str:


def sanitize_click_kwargs(
click_kwargs: dict[str, t.Any], *, name: str
click_kwargs: dict[str, t.Any], *, name: str, help_: str | None = None
) -> None:
"""Sanitize click command/group arguments.
Expand All @@ -170,3 +172,6 @@ def sanitize_click_kwargs(
# sanitize the provided name
# (only necessary for auto-naming a Group by class name)
click_kwargs["name"] = click_kwargs.get("name", _inflect.sanitize(name))
# set help if provided
if help_:
click_kwargs["help"] = help_
25 changes: 21 additions & 4 deletions feud/_internal/_metaclass.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@

class GroupBase(abc.ABCMeta):
def __new__(
cls: type[GroupBase],
__cls: type[GroupBase], # noqa: N804
cls_name: str,
bases: tuple[type, ...],
namespace: dict[str, t.Any],
Expand Down Expand Up @@ -57,7 +57,8 @@ def __new__(
subgroups: list[type] = [] # type[Group], but circular import
commands: list[str] = []

# extend/inherit from parent group if subclassed
# extend/inherit information from parent group if subclassed
help_: str | None = None
for base in bases:
if config := getattr(base, "__feud_config__", None):
# NOTE: may want **dict(config) depending on behaviour
Expand All @@ -79,6 +80,7 @@ def __new__(
for cmd in base.__feud_commands__
if cmd not in commands
]
help_ = base.__feud_click_kwargs__.get("help")

# deconstruct base config, override config kwargs and click kwargs
config_kwargs: dict[str, t.Any] = {}
Expand All @@ -97,7 +99,9 @@ def __new__(
d[k] = v

# sanitize click kwargs
_command.sanitize_click_kwargs(click_kwargs, name=cls_name)
_command.sanitize_click_kwargs(
click_kwargs, name=cls_name, help_=help_
)

# members to consider as commands
funcs = {
Expand All @@ -124,4 +128,17 @@ def __new__(
func, config=namespace["__feud_config__"]
)

return super().__new__(cls, cls_name, bases, namespace)
group = super().__new__(__cls, cls_name, bases, namespace)

if bases:
# use class-level docstring as help if provided
if doc := group.__doc__:
click_kwargs["help"] = doc
# use __main__ function-level docstring as help if provided
if doc := group.__main__.__doc__:
click_kwargs["help"] = doc
# use class-level click kwargs help if provided
if doc := kwargs.get("help"):
click_kwargs["help"] = doc

return group
60 changes: 35 additions & 25 deletions feud/core/command.py
Original file line number Diff line number Diff line change
Expand Up @@ -114,39 +114,24 @@ def decorate(__func: typing.Callable, /) -> typing.Callable:
return decorate(func) if func else decorate


def get_command(
func: typing.Callable,
/,
*,
config: Config,
click_kwargs: dict[str, typing.Any],
) -> click.Command:
if isinstance(func, staticmethod):
func = func.__func__
def build_command_state(
state: _command.CommandState, *, func: callable, config: Config
) -> None:
doc: docstring_parser.Docstring
if state.is_group:
doc = docstring_parser.parse(state.click_kwargs.get("help", ""))
else:
doc = docstring_parser.parse_from_object(func)

doc: docstring_parser.Docstring = docstring_parser.parse_from_object(func)
sig: inspect.Signature = inspect.signature(func)
pass_context: bool = _command.pass_context(sig)

state = _command.CommandState(
config=config,
click_kwargs=click_kwargs,
context=pass_context,
is_group=False,
aliases=getattr(func, "__feud_aliases__", {}),
overrides={
override.name: override
for override in getattr(func, "__click_params__", [])
},
)

for param, spec in sig.parameters.items():
meta = _command.ParameterSpec()
meta.hint: type = spec.annotation

if pass_context and param == _command.CONTEXT_PARAM:
if _command.pass_context(sig) and param == _command.CONTEXT_PARAM:
# skip handling for click.Context argument
continue
state.pass_context = True

if spec.kind in (spec.POSITIONAL_ONLY, spec.POSITIONAL_OR_KEYWORD):
# function positional arguments correspond to CLI arguments
Expand Down Expand Up @@ -231,6 +216,31 @@ def get_command(
elif meta.type == _command.ParameterType.OPTION:
state.options[param] = meta


def get_command(
func: typing.Callable,
/,
*,
config: Config,
click_kwargs: dict[str, typing.Any],
) -> click.Command:
if isinstance(func, staticmethod):
func = func.__func__

state = _command.CommandState(
config=config,
click_kwargs=click_kwargs,
is_group=False,
aliases=getattr(func, "__feud_aliases__", {}),
overrides={
override.name: override
for override in getattr(func, "__click_params__", [])
},
)

# construct command state from signature
build_command_state(state, func=func, config=config)

# generate click.Command and attach original function reference
command = state.decorate(func)
command.__func__ = func
Expand Down
28 changes: 15 additions & 13 deletions feud/core/group.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,14 +16,11 @@

import pydantic as pyd

try:
import rich_click as click
except ImportError:
import click

import feud.exceptions
from feud import click
from feud._internal import _command, _metaclass
from feud.config import Config
from feud.core.command import build_command_state

__all__ = ["Group", "compile"]

Expand Down Expand Up @@ -421,6 +418,9 @@ def deregister(
# deregister all subgroups
cls.__feud_subgroups__ = []

def __main__() -> None: # noqa: D105
pass


@pyd.validate_call(config=pyd.ConfigDict(arbitrary_types_allowed=True))
def compile(group: type[Group], /) -> click.Group: # noqa: A001
Expand Down Expand Up @@ -448,21 +448,23 @@ def compile(group: type[Group], /) -> click.Group: # noqa: A001


def get_group(__cls: type[Group], /) -> click.Group:
func: callable = __cls.__main__

state = _command.CommandState(
config=__cls.__feud_config__,
click_kwargs=__cls.__feud_click_kwargs__,
context=False,
is_group=True,
aliases=getattr(__cls, "__feud_aliases__", {}),
aliases=getattr(func, "__feud_aliases__", {}),
overrides={
override.name: override
for override in getattr(__cls, "__click_params__", [])
for override in getattr(func, "__click_params__", [])
},
)

def wrapper() -> None:
pass

wrapper.__doc__ = __cls.__doc__
# construct command state from signature
build_command_state(state, func=func, config=__cls.__feud_config__)

return state.decorate(wrapper)
# generate click.Group and attach original function reference
command = state.decorate(func)
command.__func__ = func
return command
2 changes: 1 addition & 1 deletion feud/decorators.py
Original file line number Diff line number Diff line change
Expand Up @@ -99,7 +99,7 @@ def decorator(
received = {
p.name for p in sig.parameters.values() if p.kind == p.KEYWORD_ONLY
}
if specified > received:
if len(specified - received) > 0:
msg = (
f"Arguments provided to 'alias' decorator must "
f"also be keyword parameters for function {f.__name__!r}. "
Expand Down
4 changes: 2 additions & 2 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -169,11 +169,11 @@ allow-star-arg-any = true
"__init__.py" = ["PLC0414", "F403", "F401", "F405"]
"feud/typing/*.py" = ["PLC0414", "F403", "F401"]
"tests/**/*.py" = ["D100", "D100", "D101", "D102", "D103", "D104"] # temporary
"tests/**/test_*.py" = ["ARG001", "S101"]
"tests/**/test_*.py" = ["ARG001", "S101", "D", "FA100", "FA102"]

[tool.pydoclint]
style = "numpy"
exclude = ".git|.tox|feud/_internal" # temporary
exclude = ".git|.tox|feud/_internal|tests" # temporary
check-return-types = false
arg-type-hints-in-docstring = false
quiet = true
Expand Down
6 changes: 3 additions & 3 deletions tests/unit/test_core/test_command.py
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,7 @@ def f(arg1: int, *, arg2: bool) -> None:
arg2:
Changes something.
""" # noqa: D301, D400
"""

with pytest.raises(SystemExit):
f(["--help"])
Expand Down Expand Up @@ -196,15 +196,15 @@ def f(ctx: click.Context, *, arg1: bool) -> bool:


def test_run_undecorated() -> None:
def f(arg1: int, *, arg2: bool) -> tuple[int, bool]: # noqa: FA102
def f(arg1: int, *, arg2: bool) -> tuple[int, bool]:
return arg1, arg2

assert feud.run(f, ["1", "--no-arg2"], standalone_mode=False) == (1, False)


def test_run_decorated() -> None:
@feud.command
def f(arg1: int, *, arg2: bool) -> tuple[int, bool]: # noqa: FA102
def f(arg1: int, *, arg2: bool) -> tuple[int, bool]:
return arg1, arg2

assert feud.run(f, ["1", "--no-arg2"], standalone_mode=False) == (1, False)
Expand Down
Loading

0 comments on commit fb36108

Please sign in to comment.