Skip to content

Commit

Permalink
feat: add command and option sections (#136)
Browse files Browse the repository at this point in the history
  • Loading branch information
eonu authored Jan 3, 2024
1 parent 49ec9ae commit f710834
Show file tree
Hide file tree
Showing 15 changed files with 614 additions and 265 deletions.
5 changes: 4 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -360,6 +360,7 @@ but still organized in a sensible way.

import feud
from datetime import date
from typing import Literal

class Blog(feud.Group):
"""Manage and serve a blog."""
Expand Down Expand Up @@ -408,8 +409,10 @@ $ python blog.py --help
╭─ Options ──────────────────────────────────────────────────────────╮
│ --help Show this message and exit. │
╰────────────────────────────────────────────────────────────────────╯
╭─ Commands ─────────────────────────────────────────────────────────╮
╭─ Command groups ───────────────────────────────────────────────────╮
│ post Manage blog posts. │
╰────────────────────────────────────────────────────────────────────╯
╭─ Commands ─────────────────────────────────────────────────────────╮
│ serve Start a local HTTP server. │
╰────────────────────────────────────────────────────────────────────╯
```
Expand Down
2 changes: 1 addition & 1 deletion docs/source/index.rst
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,7 @@ Contents
sections/core/index
sections/typing/index
sections/decorators/index
sections/config/index
sections/config
sections/exceptions

Indices and tables
Expand Down
File renamed without changes.
12 changes: 0 additions & 12 deletions docs/source/sections/core/command.rst
Original file line number Diff line number Diff line change
Expand Up @@ -32,10 +32,6 @@ Understanding function signatures
To understand how Feud converts a function into a :py:class:`click.Command`,
consider the following function.

.. tip::

When called with :py:func:`.run`, a function does not need to be manually decorated with :py:func:`.command`.

.. code:: python
# func.py
Expand Down Expand Up @@ -78,14 +74,6 @@ Similarly, when building a :py:class:`click.Command`, Feud treats:
$ python func.py 1 hello --opt1 2.0 --opt2 3
Note that ``--opt1`` is a required option as it has no default specified, whereas ``--opt2`` is not required.

.. tip::

Feud does **not** support command-line *arguments* with default values.

In such a scenario, it is recommended to configure the parameter as a command-line *option*
(by specifying it as a keyword-only parameter instead of a positional parameter),
since an argument with a default value is optional after all.

API reference
-------------
Expand Down
5 changes: 4 additions & 1 deletion docs/source/sections/core/group.rst
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,9 @@ API reference

.. autoclass:: feud.core.group.Group
:members:
:special-members: __sections__
:exclude-members: from_dict, from_iter, from_module


.. autopydantic_model:: feud.Section
:model-show-json: False
:model-show-config-summary: False
1 change: 1 addition & 0 deletions docs/source/sections/decorators/index.rst
Original file line number Diff line number Diff line change
Expand Up @@ -11,3 +11,4 @@ This module consists of decorators that modify :doc:`../core/command` and their
alias.rst
env.rst
rename.rst
section.rst
26 changes: 26 additions & 0 deletions docs/source/sections/decorators/section.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
Grouping command options
========================

.. contents:: Table of Contents
:class: this-will-duplicate-information-and-it-is-still-useful-here
:local:
:backlinks: none
:depth: 3

In cases when a command has many options, it can be useful to divide these
options into different sections which are displayed on the command help page.
For instance, basic and advanced options.

The :py:func:`.section` decorator can be used to define these sections for a command.

.. seealso::

:py:obj:`.Group.__sections__()` can be used to similarly partition commands
and subgroups displayed on a :py:class:`.Group` help page.

----

API reference
-------------

.. autofunction:: feud.decorators.section
186 changes: 184 additions & 2 deletions feud/_internal/_command.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,13 @@
import inspect
import typing as t

import docstring_parser

import feud.exceptions
from feud import click
from feud._internal import _decorators, _inflect, _types
from feud._internal import _decorators, _docstring, _inflect, _types
from feud.config import Config
from feud.typing import custom

CONTEXT_PARAM = "ctx"

Expand Down Expand Up @@ -40,9 +44,9 @@ class CommandState:
config: Config
click_kwargs: dict[str, t.Any]
is_group: bool
names: dict[str, NameDict] # key: parameter name
aliases: dict[str, str | list[str]] # key: parameter name
envs: dict[str, str] # key: parameter name
names: NameDict
overrides: dict[str, click.Parameter] # key: parameter name
pass_context: bool = False
# below keys are parameter name
Expand Down Expand Up @@ -231,3 +235,181 @@ def sanitize_click_kwargs(
# set help if provided
if help_:
click_kwargs["help"] = help_


def build_command_state( # noqa: PLR0915
state: CommandState, *, func: t.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)

state.description: str | None = _docstring.get_description(doc)

sig: inspect.Signature = inspect.signature(func)

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

# get renamed parameter if @feud.rename used
name: str = state.names["params"].get(param, param)

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

if spec.kind in (spec.POSITIONAL_ONLY, spec.POSITIONAL_OR_KEYWORD):
# function positional arguments correspond to CLI arguments
meta.type = ParameterType.ARGUMENT

# add the argument
meta.args = [name]

# special handling for variable-length collections
is_collection, base_type = _types.click.is_collection_type(
meta.hint
)
if is_collection:
meta.kwargs["nargs"] = -1
meta.hint = base_type

# special handling for feud.typing.custom counting types
if custom.is_counter(meta.hint):
msg = (
"Counting may only be used in conjunction with "
"keyword-only function parameters (command-line "
"options), not positional function parameters "
"(command-line arguments)."
)
raise feud.exceptions.CompilationError(msg)

# handle option default
if spec.default is inspect._empty: # noqa: SLF001
# specify as required option
# (if no default provided in function signature)
meta.kwargs["required"] = True
else:
# convert and show default
# (if default provided in function signature)
meta.kwargs["default"] = _types.defaults.convert_default(
spec.default
)
elif spec.kind == spec.KEYWORD_ONLY:
# function keyword-only arguments correspond to CLI options
meta.type = ParameterType.OPTION

# special handling for variable-length collections
is_collection, base_type = _types.click.is_collection_type(
meta.hint
)
if is_collection:
meta.kwargs["multiple"] = True
meta.hint = base_type

# special handling for feud.typing.custom counting types
if custom.is_counter(meta.hint):
meta.kwargs["count"] = True
meta.kwargs["metavar"] = "COUNT"

# add the option
meta.args = [
get_option(
name, hint=meta.hint, negate_flags=config.negate_flags
)
]

# add aliases - if specified by feud.alias decorator
for alias in state.aliases.get(param, []):
meta.args.append(
get_alias(
alias,
hint=meta.hint,
negate_flags=config.negate_flags,
)
)

# add env var - if specified by feud.env decorator
if env := state.envs.get(param):
meta.kwargs["envvar"] = env
meta.kwargs["show_envvar"] = config.show_help_envvars

# add help - fetch parameter description from docstring
if doc_param := next(
(p for p in doc.params if p.arg_name == param), None
):
meta.kwargs["help"] = doc_param.description

# handle option default
if spec.default is inspect._empty: # noqa: SLF001
# specify as required option
# (if no default provided in function signature)
meta.kwargs["required"] = True
else:
# convert and show default
# (if default provided in function signature)
meta.kwargs["show_default"] = config.show_help_defaults
meta.kwargs["default"] = _types.defaults.convert_default(
spec.default
)
elif spec.kind == spec.VAR_POSITIONAL:
# function positional arguments correspond to CLI arguments
meta.type = ParameterType.ARGUMENT

# add the argument
meta.args = [name]

# special handling for variable-length collections
meta.kwargs["nargs"] = -1

# special handling for feud.typing.custom counting types
if custom.is_counter(meta.hint):
msg = (
"Counting may only be used in conjunction with "
"keyword-only function parameters (command-line "
"options), not positional function parameters "
"(command-line arguments)."
)
raise feud.exceptions.CompilationError(msg)

# add the parameter
if meta.type == ParameterType.ARGUMENT:
state.arguments[param] = meta
elif meta.type == ParameterType.OPTION:
state.options[param] = meta


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

state = CommandState(
config=config,
click_kwargs=click_kwargs,
is_group=False,
aliases=getattr(func, "__feud_aliases__", {}),
envs=getattr(func, "__feud_envs__", {}),
names=getattr(
func, "__feud_names__", NameDict(command=None, params={})
),
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
return command
41 changes: 41 additions & 0 deletions feud/_internal/_group.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
# Copyright (c) 2023-2025 Feud Developers.
# Distributed under the terms of the MIT License (see the LICENSE file).
# SPDX-License-Identifier: MIT
# This source code is part of the Feud project (https://feud.wiki).

from feud import click
from feud._internal import _command


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

state = _command.CommandState(
config=__cls.__feud_config__,
click_kwargs=__cls.__feud_click_kwargs__,
is_group=True,
aliases=getattr(func, "__feud_aliases__", {}),
envs=getattr(func, "__feud_envs__", {}),
names=getattr(
func,
"__feud_names__",
_command.NameDict(command=None, params={}),
),
overrides={
override.name: override
for override in getattr(func, "__click_params__", [])
},
)

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

# generate click.Group and attach original function reference
command = state.decorate(func)
command.__func__ = func
command.__group__ = __cls
return command
Loading

0 comments on commit f710834

Please sign in to comment.