diff --git a/CHANGELOG.md b/CHANGELOG.md
index a2db07c..f190d54 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -2,6 +2,24 @@
All notable changes to this project will be documented in this file.
+## [v0.3.0](https://github.com/eonu/feud/releases/tag/v0.3.0) - 2024-01-03
+
+### Bug Fixes
+
+- check `__main__` first for module discovery ([#131](https://github.com/eonu/feud/issues/131))
+- fix `click` & `pydantic` min. versions + fix `feud.typing` versions ([#133](https://github.com/eonu/feud/issues/133))
+- define `__all__` for `feud.typing` module ([#134](https://github.com/eonu/feud/issues/134))
+
+### Documentation
+
+- remove click admonition from `README.md` ([#129](https://github.com/eonu/feud/issues/129))
+- remove headings from projects table ([#137](https://github.com/eonu/feud/issues/137))
+
+### Features
+
+- define `feud.click.is_rich` for checking `rich-click` install ([#132](https://github.com/eonu/feud/issues/132))
+- add command and option sections ([#136](https://github.com/eonu/feud/issues/136))
+
## [v0.2.0](https://github.com/eonu/feud/releases/tag/v0.2.0) - 2023-12-27
### Features
diff --git a/README.md b/README.md
index cfbc62e..8877f1e 100644
--- a/README.md
+++ b/README.md
@@ -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."""
@@ -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. │
╰────────────────────────────────────────────────────────────────────╯
```
@@ -656,9 +659,6 @@ on the important part – implementing your commands._
### Highly configurable and extensible
-> [!IMPORTANT]
-> _Feud is **not** the new Click_ - it is an extension of Click and directly depends it.
-
While designed to be simpler than Click, this comes with the trade-off that
Feud is also more opinionated than Click and only directly implements a subset
of its functionality.
@@ -855,7 +855,7 @@ maintainers and the work they have done that Feud has built upon.
-##### [Click](https://github.com/pallets/click)
+[**Click**](https://github.com/pallets/click)
@@ -875,7 +875,7 @@ generated CLI.
-##### [Rich Click](https://github.com/ewels/rich-click)
+[**Rich Click**](https://github.com/ewels/rich-click)
@@ -894,7 +894,7 @@ A shim around Click that renders help output nicely using
-##### [Pydantic](https://github.com/pydantic/pydantic)
+[**Pydantic**](https://github.com/pydantic/pydantic)
@@ -916,7 +916,7 @@ types which can also be used as type hints in Feud commands for input validation
-##### [Typer](https://github.com/tiangolo/typer)
+[**Typer**](https://github.com/tiangolo/typer)
@@ -939,7 +939,7 @@ lacks support for more complex types such as those offered by Pydantic.
-##### [Thor](https://github.com/rails/thor)
+[**Thor**](https://github.com/rails/thor)
diff --git a/docs/source/conf.py b/docs/source/conf.py
index eb0393d..22d28d0 100644
--- a/docs/source/conf.py
+++ b/docs/source/conf.py
@@ -20,7 +20,7 @@
project = "feud"
copyright = "2023-2025, Feud Developers" # noqa: A001
author = "Edwin Onuonga (eonu)"
-release = "0.2.0"
+release = "0.3.0"
# -- General configuration ---------------------------------------------------
# https://www.sphinx-doc.org/en/master/usage/configuration.html#general-configuration
diff --git a/docs/source/index.rst b/docs/source/index.rst
index 0b8c17d..68f058a 100644
--- a/docs/source/index.rst
+++ b/docs/source/index.rst
@@ -56,7 +56,7 @@ Contents
sections/core/index
sections/typing/index
sections/decorators/index
- sections/config/index
+ sections/config
sections/exceptions
Indices and tables
diff --git a/docs/source/sections/config/index.rst b/docs/source/sections/config.rst
similarity index 100%
rename from docs/source/sections/config/index.rst
rename to docs/source/sections/config.rst
diff --git a/docs/source/sections/core/command.rst b/docs/source/sections/core/command.rst
index 3086754..aa91a0f 100644
--- a/docs/source/sections/core/command.rst
+++ b/docs/source/sections/core/command.rst
@@ -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
@@ -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
-------------
diff --git a/docs/source/sections/core/group.rst b/docs/source/sections/core/group.rst
index 17fa96d..8066139 100644
--- a/docs/source/sections/core/group.rst
+++ b/docs/source/sections/core/group.rst
@@ -31,6 +31,9 @@ API reference
.. autoclass:: feud.core.group.Group
:members:
+ :special-members: __sections__
:exclude-members: from_dict, from_iter, from_module
-
\ No newline at end of file
+.. autopydantic_model:: feud.Section
+ :model-show-json: False
+ :model-show-config-summary: False
diff --git a/docs/source/sections/decorators/index.rst b/docs/source/sections/decorators/index.rst
index e92a9ce..037c877 100644
--- a/docs/source/sections/decorators/index.rst
+++ b/docs/source/sections/decorators/index.rst
@@ -11,3 +11,4 @@ This module consists of decorators that modify :doc:`../core/command` and their
alias.rst
env.rst
rename.rst
+ section.rst
diff --git a/docs/source/sections/decorators/section.rst b/docs/source/sections/decorators/section.rst
new file mode 100644
index 0000000..767ebca
--- /dev/null
+++ b/docs/source/sections/decorators/section.rst
@@ -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
diff --git a/docs/source/sections/typing/pydantic.rst b/docs/source/sections/typing/pydantic.rst
index ec0952c..4cb4dfb 100644
--- a/docs/source/sections/typing/pydantic.rst
+++ b/docs/source/sections/typing/pydantic.rst
@@ -37,72 +37,76 @@ The following commonly used Pydantic types can be used as type hints for Feud co
----
+The version number indicates the minimum ``pydantic`` version required to use the type.
+
+If this version requirement is not met, the type is not imported by Feud.
+
String types
------------
-- :py:obj:`pydantic.types.ImportString`
-- :py:obj:`pydantic.types.SecretStr`
-- :py:obj:`pydantic.types.StrictStr`
-- :py:obj:`pydantic.types.constr`
+- :py:obj:`pydantic.types.ImportString` (``>= 2.0.3``)
+- :py:obj:`pydantic.types.SecretStr` (``>= 2.0.3``)
+- :py:obj:`pydantic.types.StrictStr` (``>= 2.0.3``)
+- :py:obj:`pydantic.types.constr` (``>= 2.0.3``)
Integer types
-------------
-- :py:obj:`pydantic.types.NegativeInt`
-- :py:obj:`pydantic.types.NonNegativeInt`
-- :py:obj:`pydantic.types.NonPositiveInt`
-- :py:obj:`pydantic.types.PositiveInt`
-- :py:obj:`pydantic.types.StrictInt`
-- :py:obj:`pydantic.types.conint`
+- :py:obj:`pydantic.types.NegativeInt` (``>= 2.0.3``)
+- :py:obj:`pydantic.types.NonNegativeInt` (``>= 2.0.3``)
+- :py:obj:`pydantic.types.NonPositiveInt` (``>= 2.0.3``)
+- :py:obj:`pydantic.types.PositiveInt` (``>= 2.0.3``)
+- :py:obj:`pydantic.types.StrictInt` (``>= 2.0.3``)
+- :py:obj:`pydantic.types.conint` (``>= 2.0.3``)
Float types
-----------
-- :py:obj:`pydantic.types.FiniteFloat`
-- :py:obj:`pydantic.types.NegativeFloat`
-- :py:obj:`pydantic.types.NonNegativeFloat`
-- :py:obj:`pydantic.types.NonPositiveFloat`
-- :py:obj:`pydantic.types.PositiveFloat`
-- :py:obj:`pydantic.types.StrictFloat`
-- :py:obj:`pydantic.types.confloat`
+- :py:obj:`pydantic.types.FiniteFloat` (``>= 2.0.3``)
+- :py:obj:`pydantic.types.NegativeFloat` (``>= 2.0.3``)
+- :py:obj:`pydantic.types.NonNegativeFloat` (``>= 2.0.3``)
+- :py:obj:`pydantic.types.NonPositiveFloat` (``>= 2.0.3``)
+- :py:obj:`pydantic.types.PositiveFloat` (``>= 2.0.3``)
+- :py:obj:`pydantic.types.StrictFloat` (``>= 2.0.3``)
+- :py:obj:`pydantic.types.confloat` (``>= 2.0.3``)
Sequence types
--------------
-- :py:obj:`pydantic.types.confrozenset`
-- :py:obj:`pydantic.types.conlist`
-- :py:obj:`pydantic.types.conset`
+- :py:obj:`pydantic.types.confrozenset` (``>= 2.0.3``)
+- :py:obj:`pydantic.types.conlist` (``>= 2.0.3``)
+- :py:obj:`pydantic.types.conset` (``>= 2.0.3``)
Datetime types
--------------
-- :py:obj:`pydantic.types.AwareDatetime`
-- :py:obj:`pydantic.types.FutureDate`
-- :py:obj:`pydantic.types.FutureDatetime`
-- :py:obj:`pydantic.types.NaiveDatetime`
-- :py:obj:`pydantic.types.PastDate`
-- :py:obj:`pydantic.types.PastDatetime`
-- :py:obj:`pydantic.types.condate`
+- :py:obj:`pydantic.types.AwareDatetime` (``>= 2.0.3``)
+- :py:obj:`pydantic.types.FutureDate` (``>= 2.0.3``)
+- :py:obj:`pydantic.types.FutureDatetime` (``>= 2.0.3``)
+- :py:obj:`pydantic.types.NaiveDatetime` (``>= 2.0.3``)
+- :py:obj:`pydantic.types.PastDate` (``>= 2.0.3``)
+- :py:obj:`pydantic.types.PastDatetime` (``>= 2.0.3``)
+- :py:obj:`pydantic.types.condate` (``>= 2.0.3``)
Path types
----------
-- :py:obj:`pydantic.types.DirectoryPath`
-- :py:obj:`pydantic.types.FilePath`
-- :py:obj:`pydantic.types.NewPath`
+- :py:obj:`pydantic.types.DirectoryPath` (``>= 2.0.3``)
+- :py:obj:`pydantic.types.FilePath` (``>= 2.0.3``)
+- :py:obj:`pydantic.types.NewPath` (``>= 2.0.3``)
Decimal type
------------
-- :py:obj:`pydantic.types.condecimal`
+- :py:obj:`pydantic.types.condecimal` (``>= 2.0.3``)
URL types
---------
-- :py:obj:`pydantic.networks.AnyHttpUrl`
-- :py:obj:`pydantic.networks.AnyUrl`
-- :py:obj:`pydantic.networks.FileUrl`
-- :py:obj:`pydantic.networks.HttpUrl`
+- :py:obj:`pydantic.networks.AnyHttpUrl` (``>= 2.0.3``)
+- :py:obj:`pydantic.networks.AnyUrl` (``>= 2.0.3``)
+- :py:obj:`pydantic.networks.FileUrl` (``>= 2.0.3``)
+- :py:obj:`pydantic.networks.HttpUrl` (``>= 2.0.3``)
Email types
-----------
@@ -116,61 +120,64 @@ Email types
$ pip install feud[email]
-- :py:obj:`pydantic.networks.EmailStr`
-- :py:obj:`pydantic.networks.NameEmail`
+- :py:obj:`pydantic.networks.EmailStr` (``>= 2.0.3``)
+- :py:obj:`pydantic.networks.NameEmail` (``>= 2.0.3``)
Base-64 types
-------------
-- :py:obj:`pydantic.types.Base64Bytes`
-- :py:obj:`pydantic.types.Base64Str`
+- :py:obj:`pydantic.types.Base64Bytes` (``>= 2.0.3``)
+- :py:obj:`pydantic.types.Base64Str` (``>= 2.0.3``)
+- :py:obj:`pydantic.types.Base64UrlBytes` (``>= 2.4.0``)
+- :py:obj:`pydantic.types.Base64UrlStr` (``>= 2.4.0``)
Byte types
----------
-- :py:obj:`pydantic.types.ByteSize`
-- :py:obj:`pydantic.types.SecretBytes`
-- :py:obj:`pydantic.types.StrictBytes`
-- :py:obj:`pydantic.types.conbytes`
+- :py:obj:`pydantic.types.ByteSize` (``>= 2.0.3``)
+- :py:obj:`pydantic.types.SecretBytes` (``>= 2.0.3``)
+- :py:obj:`pydantic.types.StrictBytes` (``>= 2.0.3``)
+- :py:obj:`pydantic.types.conbytes` (``>= 2.0.3``)
JSON type
---------
-- :py:obj:`pydantic.types.Json`
+- :py:obj:`pydantic.types.Json` (``>= 2.0.3``)
+- :py:obj:`pydantic.types.JsonValue` (``>= 2.5.0``)
IP address types
----------------
-- :py:obj:`pydantic.networks.IPvAnyAddress`
-- :py:obj:`pydantic.networks.IPvAnyInterface`
-- :py:obj:`pydantic.networks.IPvAnyNetwork`
+- :py:obj:`pydantic.networks.IPvAnyAddress` (``>= 2.0.3``)
+- :py:obj:`pydantic.networks.IPvAnyInterface` (``>= 2.0.3``)
+- :py:obj:`pydantic.networks.IPvAnyNetwork` (``>= 2.0.3``)
Database connection types
-------------------------
-- :py:obj:`pydantic.networks.AmqpDsn`
-- :py:obj:`pydantic.networks.CockroachDsn`
-- :py:obj:`pydantic.networks.KafkaDsn`
-- :py:obj:`pydantic.networks.MariaDBDsn`
-- :py:obj:`pydantic.networks.MongoDsn`
-- :py:obj:`pydantic.networks.MySQLDsn`
-- :py:obj:`pydantic.networks.PostgresDsn`
-- :py:obj:`pydantic.networks.RedisDsn`
+- :py:obj:`pydantic.networks.AmqpDsn` (``>= 2.0.3``)
+- :py:obj:`pydantic.networks.CockroachDsn` (``>= 2.0.3``)
+- :py:obj:`pydantic.networks.KafkaDsn` (``>= 2.0.3``)
+- :py:obj:`pydantic.networks.MariaDBDsn` (``>= 2.0.3``)
+- :py:obj:`pydantic.networks.MongoDsn` (``>= 2.0.3``)
+- :py:obj:`pydantic.networks.MySQLDsn` (``>= 2.0.3``)
+- :py:obj:`pydantic.networks.PostgresDsn` (``>= 2.0.3``)
+- :py:obj:`pydantic.networks.RedisDsn` (``>= 2.0.3``)
UUID types
----------
-- :py:obj:`pydantic.types.UUID1`
-- :py:obj:`pydantic.types.UUID3`
-- :py:obj:`pydantic.types.UUID4`
-- :py:obj:`pydantic.types.UUID5`
+- :py:obj:`pydantic.types.UUID1` (``>= 2.0.3``)
+- :py:obj:`pydantic.types.UUID3` (``>= 2.0.3``)
+- :py:obj:`pydantic.types.UUID4` (``>= 2.0.3``)
+- :py:obj:`pydantic.types.UUID5` (``>= 2.0.3``)
Boolean type
------------
-- :py:obj:`pydantic.types.StrictBool`
+- :py:obj:`pydantic.types.StrictBool` (``>= 2.0.3``)
Other types
-----------
-- :py:obj:`pydantic.functional_validators.SkipValidation`
+- :py:obj:`pydantic.functional_validators.SkipValidation` (``>= 2.0.3``)
diff --git a/docs/source/sections/typing/pydantic_extra_types.rst b/docs/source/sections/typing/pydantic_extra_types.rst
index 0e01157..ae92d5e 100644
--- a/docs/source/sections/typing/pydantic_extra_types.rst
+++ b/docs/source/sections/typing/pydantic_extra_types.rst
@@ -37,44 +37,53 @@ The following types can be used as type hints for Feud commands.
----
+The version number indicates the minimum ``pydantic-extra-types`` version required to use the type.
+
+If this version requirement is not met, the type is not imported by Feud.
+
Color type
----------
-- :py:obj:`pydantic_extra_types.color.Color`
+- :py:obj:`pydantic_extra_types.color.Color` (``>= 2.1.0``)
Coordinate types
----------------
-- :py:obj:`pydantic_extra_types.coordinate.Coordinate`
-- :py:obj:`pydantic_extra_types.coordinate.Latitude`
-- :py:obj:`pydantic_extra_types.coordinate.Longitude`
+- :py:obj:`pydantic_extra_types.coordinate.Coordinate` (``>= 2.1.0``)
+- :py:obj:`pydantic_extra_types.coordinate.Latitude` (``>= 2.1.0``)
+- :py:obj:`pydantic_extra_types.coordinate.Longitude` (``>= 2.1.0``)
Country types
-------------
-- :py:obj:`pydantic_extra_types.country.CountryAlpha2`
-- :py:obj:`pydantic_extra_types.country.CountryAlpha3`
-- :py:obj:`pydantic_extra_types.country.CountryNumericCode`
-- :py:obj:`pydantic_extra_types.country.CountryOfficialName`
-- :py:obj:`pydantic_extra_types.country.CountryShortName`
+- :py:obj:`pydantic_extra_types.country.CountryAlpha2` (``>= 2.1.0``)
+- :py:obj:`pydantic_extra_types.country.CountryAlpha3` (``>= 2.1.0``)
+- :py:obj:`pydantic_extra_types.country.CountryNumericCode` (``>= 2.1.0``)
+- :py:obj:`pydantic_extra_types.country.CountryOfficialName` (``>= 2.1.0``)
+- :py:obj:`pydantic_extra_types.country.CountryShortName` (``>= 2.1.0``)
Phone number type
-----------------
-- :py:obj:`pydantic_extra_types.phone_numbers.PhoneNumber`
+- :py:obj:`pydantic_extra_types.phone_numbers.PhoneNumber` (``>= 2.1.0``)
Payment types
-------------
-- :py:obj:`pydantic_extra_types.payment.PaymentCardBrand`
-- :py:obj:`pydantic_extra_types.payment.PaymentCardNumber`
+- :py:obj:`pydantic_extra_types.payment.PaymentCardBrand` (``>= 2.1.0``)
+- :py:obj:`pydantic_extra_types.payment.PaymentCardNumber` (``>= 2.1.0``)
MAC address type
----------------
-- :py:obj:`pydantic_extra_types.mac_address.MacAddress`
+- :py:obj:`pydantic_extra_types.mac_address.MacAddress` (``>= 2.1.0``)
Routing number type
-------------------
-- :py:obj:`pydantic_extra_types.routing_number.ABARoutingNumber`
+- :py:obj:`pydantic_extra_types.routing_number.ABARoutingNumber` (``>= 2.1.0``)
+
+ULID type
+---------
+
+- :py:obj:`pydantic_extra_types.ulid.ULID` (``>= 2.2.0``)
diff --git a/feud/_internal/_command.py b/feud/_internal/_command.py
index da53dc1..29717f9 100644
--- a/feud/_internal/_command.py
+++ b/feud/_internal/_command.py
@@ -10,17 +10,13 @@
import inspect
import typing as t
-try:
- import rich_click as click
+import docstring_parser
- RICH = True
-except ImportError:
- import click
-
- RICH = False
-
-from feud._internal import _decorators, _inflect, _types
+import feud.exceptions
+from feud import click
+from feud._internal import _decorators, _docstring, _inflect, _types
from feud.config import Config
+from feud.typing import custom
CONTEXT_PARAM = "ctx"
@@ -48,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
@@ -153,7 +149,7 @@ def decorate( # noqa: PLR0915
if self.pass_context:
command = click.pass_context(command)
- if RICH:
+ if click.is_rich:
# apply rich-click styling
command = click.rich_config(
help_config=click.RichHelpConfiguration(
@@ -239,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
diff --git a/feud/_internal/_group.py b/feud/_internal/_group.py
new file mode 100644
index 0000000..a9f9006
--- /dev/null
+++ b/feud/_internal/_group.py
@@ -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
diff --git a/feud/_internal/_sections.py b/feud/_internal/_sections.py
new file mode 100644
index 0000000..8ef1b08
--- /dev/null
+++ b/feud/_internal/_sections.py
@@ -0,0 +1,104 @@
+# 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 __future__ import annotations
+
+import dataclasses
+from collections import defaultdict
+from typing import TypedDict
+
+from feud import click
+
+
+class CommandGroup(TypedDict):
+ name: str
+ commands: list[str]
+
+
+class OptionGroup(TypedDict):
+ name: str
+ options: list[str]
+
+
+def add_command_sections(
+ group: click.Group, context: list[str]
+) -> click.Group:
+ if feud_group := getattr(group, "__group__", None):
+ command_groups: dict[str, list[CommandGroup]] = {
+ " ".join(context): [
+ CommandGroup(
+ name=section.name,
+ commands=[
+ item if isinstance(item, str) else item.name()
+ for item in section.items
+ ],
+ )
+ for section in feud_group.__sections__()
+ ]
+ }
+
+ for sub in group.commands.values():
+ if isinstance(sub, click.Group):
+ add_command_sections(sub, context=[*context, sub.name])
+
+ settings = group.context_settings
+ if help_config := settings.get("rich_help_config"):
+ settings["rich_help_config"] = dataclasses.replace(
+ help_config, command_groups=command_groups
+ )
+ else:
+ settings["rich_help_config"] = click.RichHelpConfiguration(
+ command_groups=command_groups
+ )
+
+
+def add_option_sections(
+ obj: click.Command | click.Group, context: list[str]
+) -> click.Command | click.Group:
+ if isinstance(obj, click.Group):
+ update_command(obj, context=context)
+ for sub in obj.commands.values():
+ if isinstance(sub, click.Group):
+ add_option_sections(sub, context=[*context, sub.name])
+ else:
+ update_command(sub, context=[*context, sub.name])
+ else:
+ update_command(obj, context=context)
+
+
+def get_opts(option: str, *, command: click.Command) -> list[str]:
+ name_map = lambda name: name # noqa: E731
+ if names := getattr(command.__func__, "__feud_names__", None):
+ name_map = lambda name: names["params"].get(name, name) # noqa: E731
+ return next(
+ param.opts
+ for param in command.params
+ if param.name == name_map(option)
+ )
+
+
+def update_command(command: click.Command, context: list[str]) -> None:
+ if func := getattr(command, "__func__", None): # noqa: SIM102
+ if options := getattr(func, "__feud_sections__", None):
+ sections = defaultdict(list)
+ for option, section_name in options.items():
+ opts: list[str] = get_opts(option, command=command)
+ sections[section_name].append(opts[0])
+ option_groups: dict[str, list[OptionGroup]] = {
+ " ".join(context): [
+ OptionGroup(name=name, options=options)
+ for name, options in sections.items()
+ ]
+ }
+
+ settings = command.context_settings
+ if help_config := settings.get("rich_help_config"):
+ settings["rich_help_config"] = dataclasses.replace(
+ help_config, option_groups=option_groups
+ )
+ else:
+ settings["rich_help_config"] = click.RichHelpConfiguration(
+ option_groups=option_groups
+ )
diff --git a/feud/click/__init__.py b/feud/click/__init__.py
index 43aebdc..4db6a2e 100644
--- a/feud/click/__init__.py
+++ b/feud/click/__init__.py
@@ -5,9 +5,16 @@
"""Overrides for ``click``."""
+#: Whether ``rich_click`` is installed or not.
+is_rich: bool
+
try:
from rich_click import *
+
+ is_rich = True
except ImportError:
from click import *
-from feud.click.context import *
+ is_rich = False
+
+from feud.click.context import * # noqa: E402
diff --git a/feud/config.py b/feud/config.py
index 0ef5954..066cfec 100644
--- a/feud/config.py
+++ b/feud/config.py
@@ -114,7 +114,8 @@ def config(
Returns
-------
- The reusable :py:class:`.Config`.
+ Config
+ The reusable configuration.
Examples
--------
diff --git a/feud/core/__init__.py b/feud/core/__init__.py
index 77625e1..c3ec471 100644
--- a/feud/core/__init__.py
+++ b/feud/core/__init__.py
@@ -15,11 +15,12 @@
import feud.exceptions
from feud import click
+from feud._internal import _sections
from feud.config import Config
from feud.core.command import *
from feud.core.group import *
-__all__ = ["Group", "build", "command", "run"]
+__all__ = ["Group", "Section", "build", "command", "run"]
Runner = t.Union[
click.Command,
@@ -103,7 +104,8 @@ def run(
Returns
-------
- Output of the called object.
+ typing.Any
+ Output of the called object.
Examples
--------
@@ -195,6 +197,13 @@ def run(
args = obj
obj = None
+ # retrieve program name
+ prog_name: str | None = click_kwargs.get("prog_name")
+ if prog_name is None:
+ from click.utils import _detect_program_name
+
+ prog_name = _detect_program_name()
+
# get runner
runner: click.Command | click.Group = build(
obj,
@@ -205,6 +214,12 @@ def run(
warn=warn,
)
+ # add command and option sections
+ if click.is_rich:
+ _sections.add_option_sections(runner, context=[prog_name])
+ if isinstance(runner, click.Group):
+ _sections.add_command_sections(runner, context=[prog_name])
+
return runner(args, **click_kwargs)
@@ -371,11 +386,12 @@ def build(
Returns
-------
- :py:class:`click.Command`, :py:class:`click.Group` or :py:class:`.Group`
+ click.Command | click.Group | Group
+ The runnable object.
Raises
------
- feud.exceptions.CompilationError
+ CompilationError
If no runnable object or current module can be determined.
Examples
@@ -393,7 +409,7 @@ def build(
# use current module if no runner provided
if obj is None:
frame = inspect.stack()[1]
- obj = inspect.getmodule(frame[0]) or sys.modules.get("__main__")
+ obj = sys.modules.get("__main__", inspect.getmodule(frame[0]))
if obj is None:
msg = (
diff --git a/feud/core/command.py b/feud/core/command.py
index 3b2e165..62e1d88 100644
--- a/feud/core/command.py
+++ b/feud/core/command.py
@@ -11,21 +11,13 @@
from __future__ import annotations
-import inspect
import typing
-import docstring_parser
import pydantic as pyd
-try:
- import rich_click as click
-except ImportError:
- import click
-
-import feud.exceptions
-from feud._internal import _command, _docstring, _types
+from feud import click
+from feud._internal import _command
from feud.config import Config
-from feud.typing import custom
__all__ = ["command"]
@@ -89,7 +81,8 @@ def command(
Returns
-------
- The generated :py:class:`click.Command`.
+ click.Command
+ The generated command.
Examples
--------
@@ -123,184 +116,8 @@ def decorate(__func: typing.Callable, /) -> typing.Callable:
rich_click_kwargs=rich_click_kwargs,
)
# decorate function
- return get_command(__func, config=cfg, click_kwargs=click_kwargs)
+ return _command.get_command(
+ __func, config=cfg, click_kwargs=click_kwargs
+ )
return decorate(func) if func else decorate
-
-
-def build_command_state( # noqa: PLR0915
- 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)
-
- state.description: str | None = _docstring.get_description(doc)
-
- sig: inspect.Signature = inspect.signature(func)
-
- for param, spec in sig.parameters.items():
- meta = _command.ParameterSpec()
- meta.hint: type = spec.annotation
-
- # get renamed parameter if @feud.rename used
- name: str = state.names["params"].get(param, param)
-
- if _command.pass_context(sig) and param == _command.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 = _command.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 = _command.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 = [
- _command.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(
- _command.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 = _command.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 == _command.ParameterType.ARGUMENT:
- state.arguments[param] = meta
- 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__", {}),
- 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
- 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
diff --git a/feud/core/group.py b/feud/core/group.py
index e6b709a..ecd40b9 100644
--- a/feud/core/group.py
+++ b/feud/core/group.py
@@ -18,13 +18,35 @@
from collections import OrderedDict
from itertools import chain
+import pydantic as pyd
+
import feud.exceptions
from feud import click
-from feud._internal import _command, _metaclass
+from feud._internal import _group, _metaclass
from feud.config import Config
-from feud.core.command import build_command_state
-__all__ = ["Group"]
+__all__ = ["Group", "Section"]
+
+
+class Section(pyd.BaseModel, extra="forbid"):
+ """Commands or subgroups to display in a separate section on the help page
+ of a :py:class:`.Group`.
+ """
+
+ #: Name of the command section.
+ name: str
+
+ #: Description of the command section.
+ #:
+ #: .. deprecated:: 0.3.0
+ #: Not yet supported by ``rich-click``.
+ description: str | None = None
+
+ #: Names of commands or subgroups to include in the section.
+ #:
+ #: If :py:func:`.rename` was used to rename a command, the new command
+ #: name should be used.
+ items: list[str] = []
class Group(metaclass=_metaclass.GroupBase):
@@ -57,13 +79,15 @@ class Group(metaclass=_metaclass.GroupBase):
:py:func:`.command`. In the above example, ``func`` is automatically
wrapped with ``@feud.command(show_help_defaults=False)``.
- .. warning::
+ .. caution::
The following function names should **NOT** be used in a group:
+ - :py:func:`~commands`
- :py:func:`~compile`
- :py:func:`~deregister`
- :py:func:`~descendants`
+ - :py:func:`~name`
- :py:func:`~register`
- :py:func:`~subgroups`
@@ -82,6 +106,10 @@ def __new__(
) -> t.Any:
"""Compile and run the group.
+ .. warning::
+ This function should be considered internal. The preferred way to
+ run a group is to use the :py:func:`.run` function.
+
Parameters
----------
cls:
@@ -97,7 +125,8 @@ def __new__(
Returns
-------
- Output of the called :py:class:`click.Command`.
+ typing.Any
+ Output of the called :py:class:`click.Command`.
Examples
--------
@@ -117,7 +146,9 @@ def __new__(
@classmethod
def __compile__(
- cls: type[Group], *, parent: click.Group | None = None
+ cls: type[Group],
+ *,
+ parent: click.Group | None = None,
) -> click.Group:
"""Compile the group into a :py:class:`click.Group`.
@@ -134,7 +165,8 @@ def __compile__(
Returns
-------
- The generated :py:class:`click.Group`.
+ click.Group
+ The generated group.
Examples
--------
@@ -149,11 +181,12 @@ def __compile__(
cls._check_descendants()
# create the group
- click_group: click.Group = get_group(cls)
+ click_group: click.Group = _group.get_group(cls)
# add commands to the group
for name in cls.__feud_commands__:
- click_group.add_command(getattr(cls, name))
+ command: click.Command = getattr(cls, name)
+ click_group.add_command(command)
# compile all subgroups
for subgroup in cls.__feud_subgroups__:
@@ -165,13 +198,79 @@ def __compile__(
return click_group
+ @staticmethod
+ def __main__() -> None: # noqa: D105
+ pass
+
+ @classmethod
+ def __sections__(cls: type[Group]) -> list[feud.Section]:
+ """Sections to partition commands and subgroups into.
+
+ These sections are displayed on the group help page if ``rich-click``
+ is installed.
+
+ Returns
+ -------
+ list[Section]
+ Command sections.
+
+ Examples
+ --------
+ >>> import feud
+ >>> class Test(feud.Group):
+ ... def one():
+ ... pass
+ ... def two():
+ ... pass
+ ... def three():
+ ... pass
+ ... def __sections__() -> list[feud.Section]:
+ ... return [
+ ... feud.Section(
+ ... name="Odd commands", items=["one", "three"]
+ ... ),
+ ... feud.Section(name="Even commands", items=["two"]),
+ ... feud.Section(name="Groups", items=["subgroup"]),
+ ... ]
+ >>> class Subgroup(feud.Group):
+ ... pass
+ >>> Test.register(Subgroup)
+
+ """
+ return [
+ feud.Section(
+ name="Command groups",
+ items=cls.subgroups(name=True),
+ )
+ ]
+
+ @classmethod
+ def name(cls: type[Group]) -> str:
+ """Return the name of the group.
+
+ Returns
+ -------
+ str
+ The group name.
+
+ Examples
+ --------
+ >>> import feud
+ >>> class A(feud.Group):
+ ... pass
+ >>> A.name()
+ 'a'
+ """
+ return cls.__feud_click_kwargs__["name"]
+
@classmethod
def compile(cls: type[Group]) -> click.Group: # noqa: A003
"""Compile the group into a :py:class:`click.Group`.
Returns
-------
- The generated :py:class:`click.Group`.
+ click.Group
+ The generated group.
Examples
--------
@@ -185,12 +284,54 @@ def compile(cls: type[Group]) -> click.Group: # noqa: A003
return cls.__compile__()
@classmethod
- def subgroups(cls: type[Group]) -> list[type[Group]]:
+ def commands(
+ cls: type[Group], *, name: bool = False
+ ) -> list[click.Command] | list[str]:
+ """Commands defined in the group.
+
+ Parameters
+ ----------
+ name:
+ Whether or not to return the command names.
+
+ Returns
+ -------
+ list[click.Command] | list[str]
+ Group commands.
+
+ Examples
+ --------
+ >>> import feud
+ >>> class Test(feud.Group):
+ ... def func_a():
+ ... pass
+ ... def func_b():
+ ... pass
+ >>> Test.commands()
+ [, ]
+ """
+ commands: list[click.Command] = [
+ getattr(cls, cmd) for cmd in cls.__feud_commands__
+ ]
+ if name:
+ return [command.name for command in commands]
+ return commands
+
+ @classmethod
+ def subgroups(
+ cls: type[Group], *, name: bool = False
+ ) -> list[type[Group]] | list[str]:
"""Registered subgroups.
+ Parameters
+ ----------
+ name:
+ Whether or not to return the subgroup names.
+
Returns
-------
- Registered subgroups.
+ list[type[Group]] | list[str]
+ Registered subgroups.
Examples
--------
@@ -210,6 +351,8 @@ def subgroups(cls: type[Group]) -> list[type[Group]]:
descendants:
Directed acyclic graph of subgroup descendants.
""" # noqa: D401
+ if name:
+ return [sub.name() for sub in cls.__feud_subgroups__]
return list(cls.__feud_subgroups__)
@classmethod
@@ -218,7 +361,8 @@ def descendants(cls: type[Group]) -> OrderedDict[type[Group], OrderedDict]:
Returns
-------
- Subgroup descendants.
+ collections.OrderedDict[type[Group], collections.OrderedDict]
+ Subgroup descendants.
Examples
--------
@@ -443,9 +587,6 @@ def deregister(
# deregister all subgroups
cls.__feud_subgroups__ = []
- def __main__() -> None: # noqa: D105
- pass
-
@classmethod
def from_dict(
cls: type[Group],
@@ -470,7 +611,8 @@ def from_dict(
Returns
-------
- The generated :py:class:`.Group`.
+ Group
+ The generated group.
"""
# split commands and subgroups
commands: dict[str, click.Command | t.Callable] = obj.copy()
@@ -483,14 +625,14 @@ def from_dict(
# rename commands (if necessary)
funcs: list[str] = []
for name, command in commands.copy().items():
- if isinstance(command, click.Command) and name != command.name:
- # copy command
- commands[name] = copy.copy(command)
- commands[name].name = name
- elif isinstance(command, t.Callable):
+ if isinstance(command, click.Command):
+ if name != command.name:
+ # copy command
+ commands[name] = copy.copy(command)
+ commands[name].name = name
+ elif isinstance(command, t.Callable) and name != command.__name__:
# note commands generated by functions to be renamed later
- if name != command.__name__:
- funcs.append(name)
+ funcs.append(name)
# rename groups
for name, subgroup in subgroups.copy().items():
@@ -551,7 +693,8 @@ def from_iter(
Returns
-------
- The generated :py:class:`.Group`.
+ Group
+ The generated group.
"""
# convert to list
obj: list[click.Command | type[Group] | t.Callable] = list(obj)
@@ -612,7 +755,8 @@ def from_module(
Returns
-------
- The generated :py:class:`.Group`.
+ Group
+ The generated group.
"""
def is_command(item: t.Any) -> bool:
@@ -666,32 +810,3 @@ def get_name(o: click.Command | t.Callable) -> str:
group.register(subgroups)
return group
-
-
-def get_group(__cls: type[Group], /) -> click.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
- 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
- return command
diff --git a/feud/decorators.py b/feud/decorators.py
index 2dd280d..12187e6 100644
--- a/feud/decorators.py
+++ b/feud/decorators.py
@@ -16,7 +16,7 @@
from feud._internal import _command
from feud.exceptions import CompilationError
-__all__ = ["alias", "env", "rename"]
+__all__ = ["alias", "env", "rename", "section"]
@pyd.validate_call
@@ -27,8 +27,6 @@ def alias(**aliases: str | list[str]) -> t.Callable:
to be used at compile time to alias :py:class:`click.Option` objects.
Aliases may only be defined for command-line options, not arguments.
- This translates to keyword-only parameters, i.e. those
- positioned after the ``*`` operator in a function signature.
Parameters
----------
@@ -261,3 +259,53 @@ def decorator(f: t.Callable) -> t.Callable:
return f
return decorator
+
+
+def section(**options: str) -> t.Callable:
+ """Partition command options into sections.
+
+ These sections are displayed on the group help page if ``rich-click``
+ is installed.
+
+ Parameters
+ ----------
+ **options:
+ Mapping of option names to section names.
+ Option names must be keyword-only parameters in the decorated
+ function signature.
+
+ Returns
+ -------
+ Function decorated with section metadata.
+
+ Examples
+ --------
+ >>> import feud
+ >>> @feud.section(
+ ... opt1="Basic options",
+ ... opt2="Advanced options",
+ ... opt3="Basic options",
+ ... )
+ ... def my_func(arg1: int, *, opt1: str, opt2: bool, opt3: float):
+ ... pass
+ """
+
+ def decorator(f: t.Callable) -> t.Callable:
+ # check provided names and parameters match
+ sig = inspect.signature(f)
+ specified = set(options.keys())
+ received = {
+ p.name for p in sig.parameters.values() if p.kind == p.KEYWORD_ONLY
+ }
+ if len(specified - received) > 0:
+ msg = (
+ f"Arguments provided to 'section' decorator must "
+ f"also be keyword parameters for function {f.__name__!r}. "
+ f"Received extra arguments: {specified - received!r}."
+ )
+ raise CompilationError(msg)
+
+ f.__feud_sections__ = options.copy()
+ return f
+
+ return decorator
diff --git a/feud/typing/__init__.py b/feud/typing/__init__.py
index 390cd75..43744d4 100644
--- a/feud/typing/__init__.py
+++ b/feud/typing/__init__.py
@@ -14,3 +14,5 @@
from feud.typing.pydantic_extra_types import *
from feud.typing.stdlib import *
from feud.typing.typing import *
+
+__all__ = [name for name in dir() if not name.startswith("__")]
diff --git a/feud/typing/pydantic.py b/feud/typing/pydantic.py
index fc9a04d..ec78e38 100644
--- a/feud/typing/pydantic.py
+++ b/feud/typing/pydantic.py
@@ -5,66 +5,92 @@
"""Officially supported types from the ``pydantic`` package."""
-from pydantic import (
- UUID1,
- UUID3,
- UUID4,
- UUID5,
- AmqpDsn,
- AnyHttpUrl,
- AnyUrl,
- AwareDatetime,
- Base64Bytes,
- Base64Str,
- ByteSize,
- CockroachDsn,
- DirectoryPath,
- EmailStr,
- FilePath,
- FileUrl,
- FiniteFloat,
- FutureDate,
- FutureDatetime,
- HttpUrl,
- ImportString,
- IPvAnyAddress,
- IPvAnyInterface,
- IPvAnyNetwork,
- Json,
- KafkaDsn,
- MariaDBDsn,
- MongoDsn,
- MySQLDsn,
- NaiveDatetime,
- NameEmail,
- NegativeFloat,
- NegativeInt,
- NewPath,
- NonNegativeFloat,
- NonNegativeInt,
- NonPositiveFloat,
- NonPositiveInt,
- PastDate,
- PastDatetime,
- PositiveFloat,
- PositiveInt,
- PostgresDsn,
- RedisDsn,
- SecretBytes,
- SecretStr,
- SkipValidation,
- StrictBool,
- StrictBytes,
- StrictFloat,
- StrictInt,
- StrictStr,
- conbytes,
- condate,
- condecimal,
- confloat,
- confrozenset,
- conint,
- conlist,
- conset,
- constr,
+from __future__ import annotations
+
+__all__ = []
+
+import packaging.version
+import pydantic
+
+version: packaging.version.Version = packaging.version.parse(
+ pydantic.__version__,
)
+
+if version >= packaging.version.parse("2.0.3"):
+ __all__.extend(
+ [
+ "UUID1",
+ "UUID3",
+ "UUID4",
+ "UUID5",
+ "AmqpDsn",
+ "AnyHttpUrl",
+ "AnyUrl",
+ "AwareDatetime",
+ "Base64Bytes",
+ "Base64Str",
+ "ByteSize",
+ "CockroachDsn",
+ "DirectoryPath",
+ "EmailStr",
+ "FilePath",
+ "FileUrl",
+ "FiniteFloat",
+ "FutureDate",
+ "FutureDatetime",
+ "HttpUrl",
+ "ImportString",
+ "IPvAnyAddress",
+ "IPvAnyInterface",
+ "IPvAnyNetwork",
+ "Json",
+ "KafkaDsn",
+ "MariaDBDsn",
+ "MongoDsn",
+ "MySQLDsn",
+ "NaiveDatetime",
+ "NameEmail",
+ "NegativeFloat",
+ "NegativeInt",
+ "NewPath",
+ "NonNegativeFloat",
+ "NonNegativeInt",
+ "NonPositiveFloat",
+ "NonPositiveInt",
+ "PastDate",
+ "PastDatetime",
+ "PositiveFloat",
+ "PositiveInt",
+ "PostgresDsn",
+ "RedisDsn",
+ "SecretBytes",
+ "SecretStr",
+ "SkipValidation",
+ "StrictBool",
+ "StrictBytes",
+ "StrictFloat",
+ "StrictInt",
+ "StrictStr",
+ "conbytes",
+ "condate",
+ "condecimal",
+ "confloat",
+ "confrozenset",
+ "conint",
+ "conlist",
+ "conset",
+ "constr",
+ ]
+ )
+
+if version >= packaging.version.parse("2.4.0"):
+ types: list[str] = ["Base64UrlBytes", "Base64UrlStr"]
+
+ __all__.extend(types)
+
+if version >= packaging.version.parse("2.5.0"):
+ types: list[str] = ["JsonValue"]
+
+ __all__.extend(types)
+
+globals().update({attr: getattr(pydantic, attr) for attr in __all__})
diff --git a/feud/typing/pydantic_extra_types.py b/feud/typing/pydantic_extra_types.py
index 3b5ee43..1093984 100644
--- a/feud/typing/pydantic_extra_types.py
+++ b/feud/typing/pydantic_extra_types.py
@@ -5,26 +5,64 @@
"""Officially supported types from the ``pydantic-extra-types`` package."""
+from __future__ import annotations
+
+__all__ = []
+
+from operator import attrgetter
+
+import packaging.version
+
+
+def split(string: str) -> str:
+ return string.split(".")[-1]
+
+
try:
- from pydantic_extra_types.color import Color
- from pydantic_extra_types.coordinate import (
- Coordinate,
- Latitude,
- Longitude,
- )
- from pydantic_extra_types.country import (
- CountryAlpha2,
- CountryAlpha3,
- CountryNumericCode,
- CountryOfficialName,
- CountryShortName,
- )
- from pydantic_extra_types.mac_address import MacAddress
- from pydantic_extra_types.payment import (
- PaymentCardBrand,
- PaymentCardNumber,
+ import pydantic_extra_types
+
+ version: packaging.version.Version = packaging.version.parse(
+ pydantic_extra_types.__version__,
)
- from pydantic_extra_types.phone_numbers import PhoneNumber
- from pydantic_extra_types.routing_number import ABARoutingNumber
+
+ if version >= packaging.version.parse("2.1.0"):
+ import pydantic_extra_types.color
+ import pydantic_extra_types.coordinate
+ import pydantic_extra_types.country
+ import pydantic_extra_types.mac_address
+ import pydantic_extra_types.payment
+ import pydantic_extra_types.phone_numbers
+ import pydantic_extra_types.routing_number
+
+ types: list[str] = [
+ "color.Color",
+ "coordinate.Coordinate",
+ "coordinate.Latitude",
+ "coordinate.Longitude",
+ "country.CountryAlpha2",
+ "country.CountryAlpha3",
+ "country.CountryNumericCode",
+ "country.CountryOfficialName",
+ "country.CountryShortName",
+ "mac_address.MacAddress",
+ "payment.PaymentCardBrand",
+ "payment.PaymentCardNumber",
+ "phone_numbers.PhoneNumber",
+ "routing_number.ABARoutingNumber",
+ ]
+
+ globals().update(
+ {
+ split(attr): attrgetter(attr)(pydantic_extra_types)
+ for attr in types
+ }
+ )
+
+ __all__.extend(map(split, types))
+
+ if version >= packaging.version.parse("2.2.0"):
+ from pydantic_extra_types.ulid import ULID
+
+ __all__.extend(["ULID"])
except ImportError:
pass
diff --git a/feud/typing/stdlib.py b/feud/typing/stdlib.py
index b8330f4..57a417e 100644
--- a/feud/typing/stdlib.py
+++ b/feud/typing/stdlib.py
@@ -13,9 +13,32 @@
- ``uuid``
"""
-from collections import deque
-from datetime import date, datetime, time, timedelta
-from decimal import Decimal
-from enum import Enum, IntEnum, StrEnum
-from pathlib import Path
-from uuid import UUID
+from __future__ import annotations
+
+import collections
+import datetime
+import decimal
+import enum
+import pathlib
+import uuid
+from itertools import chain
+from types import ModuleType
+
+types: dict[ModuleType, list[str]] = {
+ collections: ["deque"],
+ datetime: ["date", "datetime", "time", "timedelta"],
+ decimal: ["Decimal"],
+ enum: ["Enum", "IntEnum", "StrEnum"],
+ pathlib: ["Path"],
+ uuid: ["UUID"],
+}
+
+globals().update(
+ {
+ attr: getattr(module, attr)
+ for module, attrs in types.items()
+ for attr in attrs
+ }
+)
+
+__all__ = list(chain.from_iterable(types.values()))
diff --git a/feud/typing/typing.py b/feud/typing/typing.py
index ace0451..55c5144 100644
--- a/feud/typing/typing.py
+++ b/feud/typing/typing.py
@@ -5,18 +5,26 @@
"""Officially supported types from the ``typing`` package."""
-from typing import (
- Annotated,
- Any,
- Deque,
- FrozenSet,
- List,
- Literal,
- NamedTuple,
- Optional,
- Pattern,
- Set,
- Text,
- Tuple,
- Union,
-)
+from __future__ import annotations
+
+import typing
+
+types: list[str] = [
+ "Annotated",
+ "Any",
+ "Deque",
+ "FrozenSet",
+ "List",
+ "Literal",
+ "NamedTuple",
+ "Optional",
+ "Pattern",
+ "Set",
+ "Text",
+ "Tuple",
+ "Union",
+]
+
+globals().update({attr: getattr(typing, attr) for attr in types})
+
+__all__ = list(types)
diff --git a/feud/version.py b/feud/version.py
index fc687f8..25b0fca 100644
--- a/feud/version.py
+++ b/feud/version.py
@@ -33,7 +33,7 @@
__all__ = ["VERSION", "version_info"]
-VERSION = "0.2.0"
+VERSION = "0.3.0"
def version_info() -> str:
diff --git a/pyproject.toml b/pyproject.toml
index 2123de2..be20560 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -1,6 +1,6 @@
[tool.poetry]
name = "feud"
-version = "0.2.0"
+version = "0.3.0"
license = "MIT"
authors = ["Edwin Onuonga "]
maintainers = ["Edwin Onuonga "]
@@ -61,10 +61,10 @@ build-backend = 'poetry.core.masonry.api'
[tool.poetry.dependencies]
python = "^3.11"
-pydantic = "^2.0.0"
-click = "^8.1.7"
+pydantic = "^2.0.3"
+click = "^8.1.0"
docstring-parser = "^0.15"
-Pydantic = { version = "^2.0.0", optional = true, extras = ["email"] }
+Pydantic = { version = "^2.0.3", optional = true, extras = ["email"] }
rich-click = { version = "^1.6.1", optional = true }
pydantic-extra-types = { version = "^2.1.0", optional = true, extras = ["all"] }
| | | | |