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"] }