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

feat: add Group.__main__() support #90

Merged
merged 2 commits into from
Dec 9, 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
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