Script 'mail_helper' called by obssrc
Hello community,
here is the log from the commit of package python-cyclopts for openSUSE:Factory
checked in at 2026-09-07 11:30:06
++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Comparing /work/SRC/openSUSE:Factory/python-cyclopts (Old)
and /work/SRC/openSUSE:Factory/.python-cyclopts.new.1265 (New)
++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Package is "python-cyclopts"
Mon Sep 7 11:30:06 2026 rev:15 rq:1375749 version:4.24.0
Changes:
--------
--- /work/SRC/openSUSE:Factory/python-cyclopts/python-cyclopts.changes
2026-08-27 18:55:53.219062188 +0200
+++
/work/SRC/openSUSE:Factory/.python-cyclopts.new.1265/python-cyclopts.changes
2026-09-07 11:31:45.717112212 +0200
@@ -1,0 +2,19 @@
+Fri Sep 4 08:00:17 UTC 2026 - Martin Pluskal <[email protected]>
+
+- Update to 4.24.0:
+ * Add Parameter.choices to override the choices shown in help
+ and accepted from the command line
+ * Allow a per-source encoding on file-based Config classes
+ * Add the "sys_exit_if_non_zero_else_return" result action,
+ which calls sys.exit only for a non-zero return code
+ * Sphinx directive: add :anchor-prefix: and :anchor-suffix: so
+ several apps documented in one project stop colliding on ref
+ labels
+ * Register a pydantic field-name option only when the model
+ actually accepts it (honor validate_by_name/validate_by_alias
+ rather than only the deprecated populate_by_name)
+ * Derive the root command name in generated docs from the
+ instantiating module instead of argv[0], and drop the
+ duplicate root anchor in RST output
+
+-------------------------------------------------------------------
Old:
----
cyclopts-4.23.3.tar.gz
New:
----
cyclopts-4.24.0.tar.gz
++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Other differences:
------------------
++++++ python-cyclopts.spec ++++++
--- /var/tmp/diff_new_pack.dq6twt/_old 2026-09-07 11:31:46.332133772 +0200
+++ /var/tmp/diff_new_pack.dq6twt/_new 2026-09-07 11:31:46.334133842 +0200
@@ -18,7 +18,7 @@
%bcond_without libalternatives
Name: python-cyclopts
-Version: 4.23.3
+Version: 4.24.0
Release: 0
Summary: Intuitive, easy CLIs based on python type hints
License: Apache-2.0
++++++ cyclopts-4.23.3.tar.gz -> cyclopts-4.24.0.tar.gz ++++++
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn'
'--exclude=.svnignore' old/cyclopts-4.23.3/PKG-INFO new/cyclopts-4.24.0/PKG-INFO
--- old/cyclopts-4.23.3/PKG-INFO 2020-02-02 01:00:00.000000000 +0100
+++ new/cyclopts-4.24.0/PKG-INFO 2020-02-02 01:00:00.000000000 +0100
@@ -1,6 +1,6 @@
Metadata-Version: 2.5
Name: cyclopts
-Version: 4.23.3
+Version: 4.24.0
Summary: Intuitive, easy CLIs based on type hints.
Project-URL: Homepage, https://github.com/BrianPugh/cyclopts
Project-URL: Repository, https://github.com/BrianPugh/cyclopts
@@ -160,7 +160,7 @@
# Compared to Typer
Cyclopts is what you thought Typer was.
-Cyclopts's includes information from docstrings, support more complex types
(even Unions and Literals!), and include proper validation support.
+Cyclopts includes information from docstrings, supports more complex types
(even Unions!), and includes proper validation support.
See [the documentation for a complete Typer
comparison](https://cyclopts.readthedocs.io/en/latest/vs_typer/README.html).
Consider the following short 29-line Cyclopts application:
@@ -227,21 +227,15 @@
0.0.0
```
-In its current state, this application would be impossible to implement in
Typer.
-However, lets see how close we can get with Typer (47-lines):
+In its current state, this application would be impossible to implement in
Typer; Typer does not support the union type-hint of `replicas`.
+However, let's see how close we can get with Typer (41-lines):
```python
import typer
from typing import Annotated, Literal
-from enum import Enum
app = typer.Typer()
-class Environment(str, Enum):
- dev = "dev"
- staging = "staging"
- prod = "prod"
-
def replica_parser(value: str):
if value == "default":
return 10
@@ -265,7 +259,7 @@
@app.command(help="Deploy code to an environment.")
def deploy(
- env: Annotated[Environment, typer.Argument(help="Environment to deploy
to.")],
+ env: Annotated[Literal["dev", "staging", "prod"],
typer.Argument(help="Environment to deploy to.")],
replicas: Annotated[
int,
typer.Argument(
@@ -274,7 +268,7 @@
),
] = replica_parser("default"),
):
- print(f"Deploying to {env.name} with {replicas} replicas.")
+ print(f"Deploying to {env} with {replicas} replicas.")
if __name__ == "__main__":
app()
@@ -283,13 +277,13 @@
```console
$ my-script deploy --help
-Usage: my-script deploy [OPTIONS] ENV:{dev|staging|prod} [REPLICAS]
+ Usage: my-script deploy [OPTIONS] {env}:<dev|staging|prod> [replicas]
Deploy code to an environment.
╭─ Arguments
─────────────────────────────────────────────────────────────────────────────────────╮
-│ * env ENV:{dev|staging|prod} Environment to deploy to.
[default: None] [required] │
-│ replicas [REPLICAS] Number of workers to spin up.
[default: 10] │
+│ * env <dev|staging|prod> Environment to deploy to. [required]
│
+│ replicas <replica_parser> Number of workers to spin up.
[default: 10] │
╰─────────────────────────────────────────────────────────────────────────────────────────────────╯
╭─ Options
───────────────────────────────────────────────────────────────────────────────────────╮
│ --help Show this message and exit.
│
@@ -305,19 +299,19 @@
Deploying to staging with 20 replicas.
$ my-script deploy nonexistent-env
-Usage: my-script.py deploy [OPTIONS] ENV:{dev|staging|prod} [REPLICAS]
-Try 'my-script.py deploy --help' for help.
+Usage: my-script deploy [OPTIONS] {env}:<dev|staging|prod> [replicas]
+Try 'my-script deploy --help' for help.
╭─ Error
─────────────────────────────────────────────────────────────────────────────────────────╮
-│ Invalid value for '[REPLICAS]': nonexistent-env
│
+│ Invalid value for 'env': 'nonexistent-env' is not one of 'dev', 'staging',
'prod'. │
╰─────────────────────────────────────────────────────────────────────────────────────────────────╯
$ my-script --version
0.0.0
```
-The Typer implementation is 47 lines long, while the Cyclopts implementation
is just 29 (38% shorter!).
+The Typer implementation is 41 lines long, while the Cyclopts implementation
is just 29 (29% shorter!).
Not only is the Cyclopts implementation significantly shorter, but the code is
easier to read.
-Since Typer does not support Unions, the choices for ``replica`` could not be
displayed on the help page.
+Since Typer does not support Unions, the choices for ``replicas`` could not be
displayed on the help page.
Cyclopts is much more terse, much more readable, and much more intuitive to
use.
# Contributing
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn'
'--exclude=.svnignore' old/cyclopts-4.23.3/README.md
new/cyclopts-4.24.0/README.md
--- old/cyclopts-4.23.3/README.md 2020-02-02 01:00:00.000000000 +0100
+++ new/cyclopts-4.24.0/README.md 2020-02-02 01:00:00.000000000 +0100
@@ -100,7 +100,7 @@
# Compared to Typer
Cyclopts is what you thought Typer was.
-Cyclopts's includes information from docstrings, support more complex types
(even Unions and Literals!), and include proper validation support.
+Cyclopts includes information from docstrings, supports more complex types
(even Unions!), and includes proper validation support.
See [the documentation for a complete Typer
comparison](https://cyclopts.readthedocs.io/en/latest/vs_typer/README.html).
Consider the following short 29-line Cyclopts application:
@@ -167,21 +167,15 @@
0.0.0
```
-In its current state, this application would be impossible to implement in
Typer.
-However, lets see how close we can get with Typer (47-lines):
+In its current state, this application would be impossible to implement in
Typer; Typer does not support the union type-hint of `replicas`.
+However, let's see how close we can get with Typer (41-lines):
```python
import typer
from typing import Annotated, Literal
-from enum import Enum
app = typer.Typer()
-class Environment(str, Enum):
- dev = "dev"
- staging = "staging"
- prod = "prod"
-
def replica_parser(value: str):
if value == "default":
return 10
@@ -205,7 +199,7 @@
@app.command(help="Deploy code to an environment.")
def deploy(
- env: Annotated[Environment, typer.Argument(help="Environment to deploy
to.")],
+ env: Annotated[Literal["dev", "staging", "prod"],
typer.Argument(help="Environment to deploy to.")],
replicas: Annotated[
int,
typer.Argument(
@@ -214,7 +208,7 @@
),
] = replica_parser("default"),
):
- print(f"Deploying to {env.name} with {replicas} replicas.")
+ print(f"Deploying to {env} with {replicas} replicas.")
if __name__ == "__main__":
app()
@@ -223,13 +217,13 @@
```console
$ my-script deploy --help
-Usage: my-script deploy [OPTIONS] ENV:{dev|staging|prod} [REPLICAS]
+ Usage: my-script deploy [OPTIONS] {env}:<dev|staging|prod> [replicas]
Deploy code to an environment.
╭─ Arguments
─────────────────────────────────────────────────────────────────────────────────────╮
-│ * env ENV:{dev|staging|prod} Environment to deploy to.
[default: None] [required] │
-│ replicas [REPLICAS] Number of workers to spin up.
[default: 10] │
+│ * env <dev|staging|prod> Environment to deploy to. [required]
│
+│ replicas <replica_parser> Number of workers to spin up.
[default: 10] │
╰─────────────────────────────────────────────────────────────────────────────────────────────────╯
╭─ Options
───────────────────────────────────────────────────────────────────────────────────────╮
│ --help Show this message and exit.
│
@@ -245,19 +239,19 @@
Deploying to staging with 20 replicas.
$ my-script deploy nonexistent-env
-Usage: my-script.py deploy [OPTIONS] ENV:{dev|staging|prod} [REPLICAS]
-Try 'my-script.py deploy --help' for help.
+Usage: my-script deploy [OPTIONS] {env}:<dev|staging|prod> [replicas]
+Try 'my-script deploy --help' for help.
╭─ Error
─────────────────────────────────────────────────────────────────────────────────────────╮
-│ Invalid value for '[REPLICAS]': nonexistent-env
│
+│ Invalid value for 'env': 'nonexistent-env' is not one of 'dev', 'staging',
'prod'. │
╰─────────────────────────────────────────────────────────────────────────────────────────────────╯
$ my-script --version
0.0.0
```
-The Typer implementation is 47 lines long, while the Cyclopts implementation
is just 29 (38% shorter!).
+The Typer implementation is 41 lines long, while the Cyclopts implementation
is just 29 (29% shorter!).
Not only is the Cyclopts implementation significantly shorter, but the code is
easier to read.
-Since Typer does not support Unions, the choices for ``replica`` could not be
displayed on the help page.
+Since Typer does not support Unions, the choices for ``replicas`` could not be
displayed on the help page.
Cyclopts is much more terse, much more readable, and much more intuitive to
use.
# Contributing
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn'
'--exclude=.svnignore' old/cyclopts-4.23.3/cyclopts/_result_action.py
new/cyclopts-4.24.0/cyclopts/_result_action.py
--- old/cyclopts-4.23.3/cyclopts/_result_action.py 2020-02-02
01:00:00.000000000 +0100
+++ new/cyclopts-4.24.0/cyclopts/_result_action.py 2020-02-02
01:00:00.000000000 +0100
@@ -16,6 +16,7 @@
"return_int_as_exit_code_else_zero",
"print_non_int_sys_exit",
"sys_exit",
+ "sys_exit_if_non_zero_else_return",
"return_none",
"return_zero",
"print_return_zero",
@@ -116,6 +117,16 @@
sys.exit(result)
else:
sys.exit(resolve_returncode(result))
+ case "sys_exit_if_non_zero_else_return":
+ if isinstance(result, bool):
+ returncode = 0 if result else 1
+ elif isinstance(result, int):
+ returncode = result
+ else:
+ returncode = resolve_returncode(result)
+ if returncode == 0:
+ return returncode
+ sys.exit(returncode)
case "print_non_int_return_int_as_exit_code":
if isinstance(result, bool):
return 0 if result else 1
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn'
'--exclude=.svnignore' old/cyclopts-4.23.3/cyclopts/_version.py
new/cyclopts-4.24.0/cyclopts/_version.py
--- old/cyclopts-4.23.3/cyclopts/_version.py 2020-02-02 01:00:00.000000000
+0100
+++ new/cyclopts-4.24.0/cyclopts/_version.py 2020-02-02 01:00:00.000000000
+0100
@@ -18,7 +18,7 @@
commit_id: str | None
__commit_id__: str | None
-__version__ = version = '4.23.3'
-__version_tuple__ = version_tuple = (4, 23, 3)
+__version__ = version = '4.24.0'
+__version_tuple__ = version_tuple = (4, 24, 0)
__commit_id__ = commit_id = None
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn'
'--exclude=.svnignore' old/cyclopts-4.23.3/cyclopts/annotations.py
new/cyclopts-4.24.0/cyclopts/annotations.py
--- old/cyclopts-4.23.3/cyclopts/annotations.py 2020-02-02 01:00:00.000000000
+0100
+++ new/cyclopts-4.24.0/cyclopts/annotations.py 2020-02-02 01:00:00.000000000
+0100
@@ -1,10 +1,11 @@
import inspect
import sys
import typing
-from collections.abc import Iterable, Sequence
+from collections.abc import Callable, Iterable, Sequence
from enum import Enum, Flag
+from functools import partial
from types import UnionType
-from typing import Annotated, Any, Union, get_args, get_origin
+from typing import Annotated, Any, Literal, Union, get_args, get_origin
import attrs
@@ -260,3 +261,49 @@
if getattr(hint, "_name", None) is not None:
return hint._name
return str(hint)
+
+
+def contains_enum(hint) -> bool:
+ """Whether an ``Enum`` appears anywhere in ``hint`` (through
``Annotated``, unions, iterables, aliases)."""
+ hint = resolve_type_alias(hint)
+ return is_enum(hint) or any(contains_enum(arg) for arg in get_args(hint)
if arg is not Ellipsis)
+
+
+def get_choices_from_hint(type_: Any, name_transform: Callable[[str], str]) ->
list[str]:
+ """Extract completion choices from a type hint.
+
+ Recursively extracts choices from Literal types, Enum types, and Union
types.
+
+ Parameters
+ ----------
+ type_ : Any
+ Type annotation to extract choices from.
+ name_transform : Callable[[str], str]
+ Function to transform choice names (e.g., for case conversion).
+
+ Returns
+ -------
+ list[str]
+ List of choice strings extracted from the type hint.
+ """
+ get_choices = partial(get_choices_from_hint, name_transform=name_transform)
+ choices = []
+ _origin = get_origin(type_)
+ if is_enum(type_):
+ choices.extend(name_transform(x) for x in type_.__members__)
+ elif is_union(_origin):
+ inner_choices = [get_choices(inner) for inner in get_args(type_)]
+ for x in inner_choices:
+ if x:
+ choices.extend(x)
+ elif _origin is Literal:
+ choices.extend(str(x) for x in get_args(type_))
+ elif _origin in ITERABLE_TYPES:
+ args = get_args(type_)
+ if len(args) == 1 or (_origin is tuple and len(args) == 2 and args[1]
is Ellipsis):
+ choices.extend(get_choices(args[0]))
+ elif _origin is Annotated:
+ choices.extend(get_choices(resolve_annotated(type_)))
+ elif TypeAliasType is not None and isinstance(type_, TypeAliasType):
+ choices.extend(get_choices(type_.__value__))
+ return choices
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn'
'--exclude=.svnignore' old/cyclopts-4.23.3/cyclopts/argument/__init__.py
new/cyclopts-4.24.0/cyclopts/argument/__init__.py
--- old/cyclopts-4.23.3/cyclopts/argument/__init__.py 2020-02-02
01:00:00.000000000 +0100
+++ new/cyclopts-4.24.0/cyclopts/argument/__init__.py 2020-02-02
01:00:00.000000000 +0100
@@ -1,5 +1,6 @@
"""Argument and ArgumentCollection classes for CLI parsing."""
+from cyclopts.annotations import get_choices_from_hint
from cyclopts.token import Token
from ._argument import Argument
@@ -8,7 +9,7 @@
_resolve_groups_from_callable,
update_argument_collection,
)
-from .utils import get_choices_from_hint, resolve_parameter_name
+from .utils import resolve_parameter_name
__all__ = [
"Argument",
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn'
'--exclude=.svnignore' old/cyclopts-4.23.3/cyclopts/argument/_argument.py
new/cyclopts-4.24.0/cyclopts/argument/_argument.py
--- old/cyclopts-4.23.3/cyclopts/argument/_argument.py 2020-02-02
01:00:00.000000000 +0100
+++ new/cyclopts-4.24.0/cyclopts/argument/_argument.py 2020-02-02
01:00:00.000000000 +0100
@@ -8,7 +8,7 @@
from collections.abc import Callable, Sequence
from contextlib import suppress
from functools import partial, reduce
-from typing import TYPE_CHECKING, Any, get_args, get_origin
+from typing import TYPE_CHECKING, Any, Literal, get_args, get_origin
from attrs import define, field
@@ -21,8 +21,10 @@
)
from cyclopts.annotations import (
ITERABLE_TYPES,
+ contains_enum,
contains_hint,
get_annotated_discriminator,
+ get_choices_from_hint,
get_hint_name,
is_attrs,
is_dataclass,
@@ -59,7 +61,6 @@
from .utils import (
enum_flag_from_dict,
- get_choices_from_hint,
missing_keys_factory,
startswith,
)
@@ -67,6 +68,11 @@
if TYPE_CHECKING:
from cyclopts.argument._collection import ArgumentCollection
+_CHOICES_UNSUPPORTED = (
+ "Parameter(choices=...) only supports single-token value types (not flags,
multi-token tuples, "
+ "or keyword-accepting classes); got {hint}. Annotate the individual fields
instead."
+)
+
@define(kw_only=True)
class Argument:
@@ -215,6 +221,9 @@
)
return
+ if self.parameter.choices and self.token_count()[0] != 1:
+ raise ValueError(_CHOICES_UNSUPPORTED.format(hint=self.hint))
+
if self.parameter.accepts_keys is False:
return
@@ -294,9 +303,10 @@
# :meth:`_convert` supersedes it.
self._missing_keys_checker = None
- def _update_lookup(self, field_infos: dict[str, FieldInfo]):
- from typing import Literal
+ if self.parameter.choices and self._accepts_keywords and not any(dict
in {h, get_origin(h)} for h in hints):
+ raise ValueError(_CHOICES_UNSUPPORTED.format(hint=self.hint))
+ def _update_lookup(self, field_infos: dict[str, FieldInfo]):
discriminator = get_annotated_discriminator(self.field_info.annotation)
for key, field_info in field_infos.items():
@@ -672,6 +682,11 @@
if self.has_tokens:
import pydantic
+ for child in self.children_recursive:
+ if child.parameter.choices and child.has_tokens:
+ # Persist the canonicalized tokens so ``_json`` (and
pydantic) see the
+ # listed spelling rather than the raw input (e.g. Enum
``RED`` -> ``red``).
+ child.tokens =
child._validate_choices(child._expand_json_list_tokens(child.tokens))
unstructured_data = self._json()
try:
return
pydantic.TypeAdapter(self.field_info.annotation).validate_python(unstructured_data)
@@ -723,31 +738,9 @@
positional: list[Token] = []
keyword = {}
- def expand_tokens(tokens):
- for token in tokens:
- if self._should_attempt_json_list(token):
- try:
- parsed_json = json.loads(token.value)
- except json.JSONDecodeError as e:
- raise CoercionError(token=token,
target_type=self.hint) from e
-
- if not isinstance(parsed_json, list):
- raise CoercionError(token=token,
target_type=self.hint)
-
- if not parsed_json:
- yield token.evolve(value="", implicit_value=[])
- else:
- for element in parsed_json:
- if element is None:
- yield token.evolve(value="",
implicit_value=element)
- elif isinstance(element, dict):
- yield
token.evolve(value=json.dumps(element))
- else:
- yield token.evolve(value=str(element))
- else:
- yield token
-
- expanded_tokens = list(expand_tokens(self.tokens))
+ expanded_tokens = self._expand_json_list_tokens(self.tokens)
+ if self.parameter.choices:
+ expanded_tokens = self._validate_choices(expanded_tokens)
for token in expanded_tokens:
resolved_hint = resolve_optional(self.hint)
if token.implicit_value is not UNSET and isinstance(
@@ -1171,8 +1164,71 @@
"""
return self.token_count() == (0, False)
+ def _explicit_choices(self) -> tuple[str, ...]:
+ """Resolve ``Parameter.choices`` to strings; empty when not set."""
+ choices = self.parameter.choices
+ if not choices:
+ return ()
+ if isinstance(choices, tuple):
+ return choices
+ return tuple(get_choices_from_hint(choices,
self.parameter.name_transform)) # pyright: ignore[reportArgumentType]
+
+ def _expand_json_list_tokens(self, tokens: Sequence[Token]) -> list[Token]:
+ """Split JSON-list tokens into one token per element."""
+ out = []
+ for token in tokens:
+ if not self._should_attempt_json_list(token):
+ out.append(token)
+ continue
+ try:
+ parsed_json = json.loads(token.value)
+ except json.JSONDecodeError as e:
+ raise CoercionError(token=token, target_type=self.hint) from e
+ if not isinstance(parsed_json, list):
+ raise CoercionError(token=token, target_type=self.hint)
+ if not parsed_json:
+ out.append(token.evolve(value="", implicit_value=[]))
+ for element in parsed_json:
+ if element is None:
+ out.append(token.evolve(value="", implicit_value=element))
+ elif isinstance(element, dict):
+ out.append(token.evolve(value=json.dumps(element)))
+ else:
+ out.append(token.evolve(value=str(element)))
+ return out
+
+ def _validate_choices(self, tokens: Sequence[Token]) -> list[Token]:
+ """Reject tokens outside ``Parameter.choices``; rewrite matches to the
listed spelling.
+
+ Runs before the converter so it only ever sees a listed choice. When
an ``Enum`` is
+ involved (in ``choices`` or the type hint) matching follows
:func:`get_enum_member`:
+ ``name_transform`` is applied to both sides.
+ """
+ choices = self._explicit_choices()
+ explicit = self.parameter.choices
+ normalize = (
+ self.parameter.name_transform
+ if (not isinstance(explicit, tuple) and contains_enum(explicit))
or contains_enum(self.hint)
+ else str
+ )
+ lookup = {normalize(choice): choice for choice in choices}
+ out = []
+ for token in tokens:
+ # A non-keyed token on a dict-like leaf is not a single value; let
the converter report it.
+ if token.implicit_value is not UNSET or (self._accepts_keywords
and not token.keys):
+ out.append(token)
+ continue
+ try:
+ canonical = lookup[normalize(token.value)]
+ except KeyError:
+ raise CoercionError(token=token, argument=self,
target_type=Literal[choices]) from None # pyright: ignore
+ out.append(token if canonical == token.value else
token.evolve(value=canonical))
+ return out
+
def get_choices(self, force: bool = False) -> tuple[str, ...] | None:
- """Extract completion choices from type hint.
+ """Choices for the help page and shell completion.
+
+ ``Parameter.choices`` takes precedence; otherwise derived from the
type hint.
Extracts choices from Literal types, Enum types, and Union types
containing them.
Respects the Parameter.show_choices setting unless force=True.
@@ -1200,6 +1256,8 @@
"""
if not force and not self.parameter.show_choices:
return None
+ if explicit := self._explicit_choices():
+ return explicit
choices = get_choices_from_hint(self.hint,
self.parameter.name_transform)
return tuple(choices) if choices else None
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn'
'--exclude=.svnignore' old/cyclopts-4.23.3/cyclopts/argument/utils.py
new/cyclopts-4.24.0/cyclopts/argument/utils.py
--- old/cyclopts-4.23.3/cyclopts/argument/utils.py 2020-02-02
01:00:00.000000000 +0100
+++ new/cyclopts-4.24.0/cyclopts/argument/utils.py 2020-02-02
01:00:00.000000000 +0100
@@ -3,9 +3,8 @@
import sys
from collections.abc import Callable, Iterable, Iterator
from contextlib import suppress
-from enum import Enum, Flag
-from functools import partial
-from typing import TYPE_CHECKING, Annotated, Any, Literal, TypeVar, get_args,
get_origin
+from enum import Flag
+from typing import TYPE_CHECKING, Any, TypeVar
if TYPE_CHECKING:
from cyclopts.argument._argument import Argument
@@ -13,12 +12,6 @@
F = TypeVar("F", bound=Flag)
from cyclopts._convert import convert_enum_flag
-from cyclopts.annotations import (
- ITERABLE_TYPES,
- is_class_and_subclass,
- is_union,
- resolve_annotated,
-)
from cyclopts.field_info import (
KEYWORD_ONLY,
POSITIONAL_ONLY,
@@ -41,6 +34,7 @@
validator=None,
accepts_keys=None,
env_var=None,
+ choices=None,
)
KIND_PARENT_CHILD_REASSIGNMENT = {
@@ -72,46 +66,6 @@
}
-def get_choices_from_hint(type_: type, name_transform: Callable[[str], str])
-> list[str]:
- """Extract completion choices from a type hint.
-
- Recursively extracts choices from Literal types, Enum types, and Union
types.
-
- Parameters
- ----------
- type_ : type
- Type annotation to extract choices from.
- name_transform : Callable[[str], str]
- Function to transform choice names (e.g., for case conversion).
-
- Returns
- -------
- list[str]
- List of choice strings extracted from the type hint.
- """
- get_choices = partial(get_choices_from_hint, name_transform=name_transform)
- choices = []
- _origin = get_origin(type_)
- if isinstance(type_, type) and is_class_and_subclass(type_, Enum):
- choices.extend(name_transform(x) for x in type_.__members__)
- elif is_union(_origin):
- inner_choices = [get_choices(inner) for inner in get_args(type_)]
- for x in inner_choices:
- if x:
- choices.extend(x)
- elif _origin is Literal:
- choices.extend(str(x) for x in get_args(type_))
- elif _origin in ITERABLE_TYPES:
- args = get_args(type_)
- if len(args) == 1 or (_origin is tuple and len(args) == 2 and args[1]
is Ellipsis):
- choices.extend(get_choices(args[0]))
- elif _origin is Annotated:
- choices.extend(get_choices(resolve_annotated(type_)))
- elif TypeAliasType is not None and isinstance(type_, TypeAliasType):
- choices.extend(get_choices(type_.__value__))
- return choices
-
-
def startswith(string, prefix):
def normalize(s):
return s.replace("_", "-")
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn'
'--exclude=.svnignore' old/cyclopts-4.23.3/cyclopts/config/_common.py
new/cyclopts-4.24.0/cyclopts/config/_common.py
--- old/cyclopts-4.23.3/cyclopts/config/_common.py 2020-02-02
01:00:00.000000000 +0100
+++ new/cyclopts-4.24.0/cyclopts/config/_common.py 2020-02-02
01:00:00.000000000 +0100
@@ -134,6 +134,7 @@
path: str | Path = field(converter=Path)
must_exist: bool = field(default=False, kw_only=True)
search_parents: bool = field(default=False, kw_only=True)
+ encoding: str | None = field(default=None, kw_only=True)
_config: dict[str, Any] | None = field(default=None, init=False,
repr=False)
"Loaded configuration structure (to be loaded by subclassed
``_load_config`` method)."
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn'
'--exclude=.svnignore' old/cyclopts-4.23.3/cyclopts/config/_json.py
new/cyclopts-4.24.0/cyclopts/config/_json.py
--- old/cyclopts-4.23.3/cyclopts/config/_json.py 2020-02-02
01:00:00.000000000 +0100
+++ new/cyclopts-4.24.0/cyclopts/config/_json.py 2020-02-02
01:00:00.000000000 +0100
@@ -8,7 +8,7 @@
class Json(ConfigFromFile):
def _load_config(self, path: Path) -> dict[str, Any]:
- with path.open() as f:
+ with path.open(encoding=self.encoding) as f:
try:
return json.load(f)
except json.JSONDecodeError as e:
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn'
'--exclude=.svnignore' old/cyclopts-4.23.3/cyclopts/config/_yaml.py
new/cyclopts-4.24.0/cyclopts/config/_yaml.py
--- old/cyclopts-4.23.3/cyclopts/config/_yaml.py 2020-02-02
01:00:00.000000000 +0100
+++ new/cyclopts-4.24.0/cyclopts/config/_yaml.py 2020-02-02
01:00:00.000000000 +0100
@@ -8,5 +8,5 @@
def _load_config(self, path: Path) -> dict[str, Any]:
from yaml import safe_load # pyright: ignore[reportMissingImports]
- with path.open() as f:
+ with path.open(encoding=self.encoding) as f:
return safe_load(f)
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn'
'--exclude=.svnignore' old/cyclopts-4.23.3/cyclopts/docs/base.py
new/cyclopts-4.24.0/cyclopts/docs/base.py
--- old/cyclopts-4.23.3/cyclopts/docs/base.py 2020-02-02 01:00:00.000000000
+0100
+++ new/cyclopts-4.24.0/cyclopts/docs/base.py 2020-02-02 01:00:00.000000000
+0100
@@ -238,6 +238,45 @@
return sub_commands_filter, sub_exclude_commands
+def resolve_doc_root_name(app: "App") -> str:
+ """Resolve the root command name to use in generated documentation.
+
+ When an app has no explicit ``name`` and no ``default_command``,
``App.name``
+ falls back to ``Path(sys.argv[0]).name``. That is correct for the *runtime*
+ help screen (``argv[0]`` is the invoked command), but wrong during
+ documentation generation: when a Sphinx build or a generator script imports
+ the app, ``argv[0]`` is the *generator's* name (``sphinx-build``,
``python``,
+ ...), not the CLI's. In that specific case, prefer the root package of the
+ module where the App was instantiated (captured as
+ ``_instantiating_module_name``), which is a much better default than the
+ generator's name -- though for a ``package.module`` layout it may still
+ differ from the actual console-script name (users wanting an exact name
+ should pass ``name=``). Explicit names and the ``default_command``
+ function-name fallback are left untouched.
+
+ Parameters
+ ----------
+ app : App
+ The cyclopts App instance.
+
+ Returns
+ -------
+ str
+ The resolved root command name.
+ """
+ # Only intervene when App.name would fall back to Path(sys.argv[0]).name.
+ if app._name or app.default_command is not None:
+ return app.name[0]
+
+ module_name = app._instantiating_module_name
+ if module_name:
+ root_package = module_name.split(".")[0]
+ if root_package and root_package != "__main__":
+ return root_package
+
+ return app.name[0]
+
+
def get_app_info(app: "App", command_chain: list[str] | None = None) ->
tuple[str, str, str]:
"""Get app name, full command path, and title.
@@ -254,7 +293,7 @@
(app_name, full_command, title)
"""
if not command_chain:
- app_name = app.name[0]
+ app_name = resolve_doc_root_name(app)
full_command = app_name
title = app_name
else:
@@ -288,31 +327,43 @@
return [app_name, command_name]
-def apply_usage_name(command_chain: list[str], usage_name: str | None) ->
list[str]:
- """Return a display command chain with the root replaced by ``usage_name``.
+def usage_display_chain(command_chain: list[str], usage_name: str | None,
root_name: str | None = None) -> list[str]:
+ """Return the display command chain for a ``Usage:`` line.
+
+ Applies, in order of precedence:
- When ``usage_name`` is ``None``, returns ``command_chain`` unchanged so
callers
- can use this helper unconditionally. When ``usage_name`` is ``""``, the
root
- token is dropped rather than substituted, so downstream formatters never
see
- an empty element (which would render as stray leading/internal whitespace).
- When the chain is empty and ``usage_name`` is a non-empty string, returns a
- single-element list containing ``usage_name``.
+ - an explicit ``usage_name`` override (replaces the root token; an empty
+ string drops it entirely so downstream formatters never see an empty
+ element, which would render as stray whitespace);
+ - otherwise, at the root (empty ``command_chain``) with no override,
+ ``root_name`` substituted for the app-name token that ``format_usage``
+ embedded. That token comes from ``App.name``, which may be the
+ ``Path(sys.argv[0]).name`` fallback (e.g. ``sphinx-build`` during a docs
+ build); pass the :func:`resolve_doc_root_name` result so the Usage line
+ matches the title and anchors. See #910.
Parameters
----------
command_chain : list[str]
The logical command chain (root app name first).
usage_name : str | None
- Replacement for the chain's root element used in Usage: lines only.
+ Explicit Usage: line root override, or ``None`` for the default.
An empty string drops the root token entirely.
+ root_name : str | None
+ The resolved documentation name to substitute at the root when there is
+ no ``usage_name`` override. Defaults to ``None``, which leaves the root
+ usage text untouched -- required for a user-provided custom
``app.usage``
+ string (whose first token is not necessarily the app name and must not
be
+ rewritten) and for subcommand chains (which have no root token to fix
up).
Returns
-------
list[str]
- A new list with the root replaced/dropped, or the original chain when
- ``usage_name`` is ``None``.
+ The display chain to feed to the Usage: line formatter.
"""
if usage_name is None:
+ if not command_chain and root_name is not None:
+ return [root_name]
return command_chain
if usage_name == "":
return command_chain[1:]
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn'
'--exclude=.svnignore' old/cyclopts-4.23.3/cyclopts/docs/html.py
new/cyclopts-4.24.0/cyclopts/docs/html.py
--- old/cyclopts-4.23.3/cyclopts/docs/html.py 2020-02-02 01:00:00.000000000
+0100
+++ new/cyclopts-4.24.0/cyclopts/docs/html.py 2020-02-02 01:00:00.000000000
+0100
@@ -4,7 +4,6 @@
from cyclopts._markup import escape_html, extract_text
from cyclopts.docs.base import (
- apply_usage_name,
build_command_chain,
extract_description,
extract_usage,
@@ -12,6 +11,8 @@
format_usage_line,
generate_anchor,
iterate_commands,
+ resolve_doc_root_name,
+ usage_display_chain,
)
if TYPE_CHECKING:
@@ -396,8 +397,10 @@
# Determine the app name and full command path
if not command_chain:
- # Root level - use app name or derive from sys.argv
- app_name = app.name[0]
+ # Root level - use the app name, or (for an unnamed app) the module it
was
+ # defined in rather than the sys.argv[0] fallback, which is the doc
+ # generator's name (e.g. "sphinx-build") in a build context. See #910.
+ app_name = resolve_doc_root_name(app)
full_command = app_name
title = app_name
# Add title for all levels
@@ -442,7 +445,9 @@
usage_text = usage
else:
usage_text = extract_text(usage, None)
- display_chain = apply_usage_name(command_chain, usage_name)
+ # Root Usage-line name substitution; None leaves a custom app.usage
intact. See #910.
+ root_name = app_name if app.usage is None else None
+ display_chain = usage_display_chain(command_chain, usage_name,
root_name)
usage_text = format_usage_line(usage_text, display_chain, prefix="$")
lines.append(f'<pre class="usage">{escape_html(usage_text)}</pre>')
lines.append("</div>")
@@ -523,7 +528,7 @@
sub_usage_text = sub_usage
else:
sub_usage_text = extract_text(sub_usage, None)
- sub_display_chain = apply_usage_name(sub_command_chain,
usage_name)
+ sub_display_chain = usage_display_chain(sub_command_chain,
usage_name)
sub_usage_text = format_usage_line(sub_usage_text,
sub_display_chain, prefix="$")
lines.append(f'<pre
class="usage">{escape_html(sub_usage_text)}</pre>')
lines.append("</div>")
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn'
'--exclude=.svnignore' old/cyclopts-4.23.3/cyclopts/docs/markdown.py
new/cyclopts-4.24.0/cyclopts/docs/markdown.py
--- old/cyclopts-4.23.3/cyclopts/docs/markdown.py 2020-02-02
01:00:00.000000000 +0100
+++ new/cyclopts-4.24.0/cyclopts/docs/markdown.py 2020-02-02
01:00:00.000000000 +0100
@@ -6,7 +6,6 @@
from cyclopts.core import DEFAULT_FORMAT
from cyclopts.docs.base import (
adjust_filters_for_subcommand,
- apply_usage_name,
build_command_chain,
extract_description,
extract_usage,
@@ -16,9 +15,11 @@
is_all_builtin_flags,
iterate_commands,
normalize_command_filters,
+ resolve_doc_root_name,
should_include_command,
should_show_commands_list,
should_show_usage,
+ usage_display_chain,
)
if TYPE_CHECKING:
@@ -196,7 +197,9 @@
usage_text = usage
else:
usage_text = extract_text(usage, None, preserve_markup=False)
- display_chain = apply_usage_name(command_chain, usage_name)
+ # Root Usage-line name substitution; None leaves a custom
app.usage intact. See #910.
+ root_name = resolve_doc_root_name(app) if app.usage is None else
None
+ display_chain = usage_display_chain(command_chain, usage_name,
root_name)
usage_line = format_usage_line(usage_text, display_chain)
lines.append(usage_line)
lines.append("```")
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn'
'--exclude=.svnignore' old/cyclopts-4.23.3/cyclopts/docs/rst.py
new/cyclopts-4.24.0/cyclopts/docs/rst.py
--- old/cyclopts-4.23.3/cyclopts/docs/rst.py 2020-02-02 01:00:00.000000000
+0100
+++ new/cyclopts-4.24.0/cyclopts/docs/rst.py 2020-02-02 01:00:00.000000000
+0100
@@ -5,7 +5,6 @@
from cyclopts._markup import extract_text
from cyclopts.docs.base import (
adjust_filters_for_subcommand,
- apply_usage_name,
extract_description,
extract_usage,
generate_anchor,
@@ -15,6 +14,7 @@
normalize_command_filters,
should_include_command,
should_show_usage,
+ usage_display_chain,
)
if TYPE_CHECKING:
@@ -171,6 +171,8 @@
code_block_title: bool = False,
skip_preamble: bool = False,
usage_name: str | None = None,
+ anchor_prefix: str = "",
+ anchor_suffix: str = "",
) -> str:
"""Generate reStructuredText documentation for a CLI application.
@@ -221,6 +223,17 @@
Optional replacement for the root app name used in ``Usage:`` lines
only. Section headings, anchors, and TOC continue to use
``app.name[0]``.
Default is None.
+ anchor_prefix : str
+ Optional token prepended to every generated anchor/label (outside the
+ ``cyclopts-`` namespace, e.g. ``<prefix>-cyclopts-<app>-<command>``).
+ Use it to namespace a whole directive by page or section so the same
+ command documented on multiple pages keeps distinct, referenceable
+ ``:ref:`` targets. Default is ``""`` (no prefix).
+ anchor_suffix : str
+ Optional token appended to every generated anchor/label (e.g.
+ ``cyclopts-<app>-<command>-<suffix>``). Use it to distinguish multiple
+ renderings of the same command on a single page. Default is ``""`` (no
+ suffix).
Returns
-------
@@ -249,17 +262,37 @@
# Root app: use base title
title = base_title
- # Always generate RST anchor/label with improved namespacing
- # RST uses a "cyclopts-" prefix for namespacing
- anchor_parts = ["cyclopts"]
- if command_chain:
- anchor_parts.extend(command_chain)
- else:
- anchor_parts.append(app_name)
- # Use shared anchor generation logic, then add RST-specific slash
replacement
- anchor_name = generate_anchor(" ".join(anchor_parts)).replace("/", "-")
- lines.append(f".. _{anchor_name}:")
- lines.append("")
+ # Generate RST anchor/label with improved namespacing (RST uses a
+ # "cyclopts-" prefix for namespacing). Skip the anchor for an *unqualified*
+ # title-less root (e.g. the Sphinx ``.. cyclopts::`` directive, which
always
+ # sets ``no_root_title``): a title-less root has nothing to reference, and
its
+ # bare ``cyclopts-<app>`` label is identical across every page documenting
+ # the same app, producing "duplicate label" warnings that make the sections
+ # unreferenceable. An ``anchor_prefix``/``anchor_suffix`` disambiguates
that
+ # label, so it opts the root anchor back in. Subcommand anchors (which have
+ # command_chain) are unique per command and are always emitted.
+ unqualified_root = no_root_title and not command_chain and not
anchor_prefix and not anchor_suffix
+ if not unqualified_root:
+ # ``anchor_prefix`` sits outside the ``cyclopts-`` namespace (a
page/section
+ # namespace); ``anchor_suffix`` trails the command path (a per-page
variant
+ # qualifier). Both let the same command be documented by multiple
directives
+ # while keeping distinct, referenceable ``:ref:`` targets.
+ anchor_parts = []
+ if anchor_prefix:
+ anchor_parts.append(anchor_prefix)
+ anchor_parts.append("cyclopts")
+ if command_chain:
+ anchor_parts.extend(command_chain)
+ else:
+ anchor_parts.append(app_name)
+ if anchor_suffix:
+ anchor_parts.append(anchor_suffix)
+ # generate_anchor() slugifies a space-joined command path into hyphens
+ # (the same contract used for markdown/HTML anchors); the join spaces
never
+ # survive into the anchor. Then apply RST-specific slash replacement.
+ anchor_name = generate_anchor(" ".join(anchor_parts)).replace("/", "-")
+ lines.append(f".. _{anchor_name}:")
+ lines.append("")
# Determine effective heading level for this command
if no_root_title and not command_chain:
@@ -301,8 +334,9 @@
else:
usage_text = extract_text(usage, None,
preserve_markup=False)
- # Apply usage_name override to the display chain (only for the
Usage: line)
- display_chain = apply_usage_name(command_chain, usage_name)
+ # Root Usage-line name substitution; None leaves a custom
app.usage intact. See #910.
+ root_name = app_name if app.usage is None else None
+ display_chain = usage_display_chain(command_chain, usage_name,
root_name)
# Format usage with the display chain when one is present
if display_chain:
@@ -471,6 +505,8 @@
code_block_title=code_block_title,
skip_preamble=is_single_target or is_intermediate_path, #
Skip preamble for target or intermediate
usage_name=usage_name,
+ anchor_prefix=anchor_prefix,
+ anchor_suffix=anchor_suffix,
)
lines.append(subdocs)
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn'
'--exclude=.svnignore' old/cyclopts-4.23.3/cyclopts/ext/sphinx.py
new/cyclopts-4.24.0/cyclopts/ext/sphinx.py
--- old/cyclopts-4.23.3/cyclopts/ext/sphinx.py 2020-02-02 01:00:00.000000000
+0100
+++ new/cyclopts-4.24.0/cyclopts/ext/sphinx.py 2020-02-02 01:00:00.000000000
+0100
@@ -27,6 +27,8 @@
commands: list[str] | None = None
exclude_commands: list[str] | None = None
usage_name: str | None = None
+ anchor_prefix: str = ""
+ anchor_suffix: str = ""
# All booleans must have ``False`` default.
no_recursive: bool = False
@@ -360,6 +362,8 @@
code_block_title=opts.code_block_title,
skip_preamble=opts.skip_preamble,
usage_name=opts.usage_name,
+ anchor_prefix=opts.anchor_prefix,
+ anchor_suffix=opts.anchor_suffix,
)
def _create_nodes(self, rst_content: str, opts: DirectiveOptions) ->
list["nodes.Node"]:
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn'
'--exclude=.svnignore' old/cyclopts-4.23.3/cyclopts/field_info.py
new/cyclopts-4.24.0/cyclopts/field_info.py
--- old/cyclopts-4.23.3/cyclopts/field_info.py 2020-02-02 01:00:00.000000000
+0100
+++ new/cyclopts-4.24.0/cyclopts/field_info.py 2020-02-02 01:00:00.000000000
+0100
@@ -171,17 +171,31 @@
for python_name, pydantic_field in model.model_fields.items():
names = []
if pydantic_field.alias:
- if model.model_config.get("populate_by_name", False):
+ # Register a CLI name only if the model would actually accept it
during
+ # validation (cyclopts builds the model via
TypeAdapter.validate_python).
+ # * validate_by_name -> field names are accepted.
+ # * validate_by_alias -> aliases are accepted (defaults to True).
+ # populate_by_name is the pre-2.11 spelling of validate_by_name;
it is not
+ # recommended in pydantic 2.11+ and is slated for removal in
pydantic v3, so
+ # we honor both across the supported pydantic range. pydantic
forbids both
+ # validate_by_* being False, so at least one name is always
registered.
+ validate_by_name = model.model_config.get("validate_by_name",
False) or model.model_config.get(
+ "populate_by_name", False
+ )
+ validate_by_alias = model.model_config.get("validate_by_alias",
True)
+
+ if validate_by_name:
names.append(python_name)
- names.append(pydantic_field.alias)
+ if validate_by_alias:
+ names.append(pydantic_field.alias)
- # Add legacy-compatible CLI form if not already present.
- # This allows both "user-name" (new) and "username" (legacy) to
work as CLI options.
- # Old transform behavior: alias.lower() (no pascal_to_snake)
- # New transform behavior: _pascal_to_snake(alias).lower()
- legacy_form = pydantic_field.alias.lower()
- if legacy_form not in names:
- names.append(legacy_form)
+ # Add legacy-compatible CLI form if not already present.
+ # This allows both "user-name" (new) and "username" (legacy)
to work as CLI options.
+ # Old transform behavior: alias.lower() (no pascal_to_snake)
+ # New transform behavior: _pascal_to_snake(alias).lower()
+ legacy_form = pydantic_field.alias.lower()
+ if legacy_form not in names:
+ names.append(legacy_form)
else:
names.append(python_name)
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn'
'--exclude=.svnignore' old/cyclopts-4.23.3/cyclopts/parameter.py
new/cyclopts-4.24.0/cyclopts/parameter.py
--- old/cyclopts-4.23.3/cyclopts/parameter.py 2020-02-02 01:00:00.000000000
+0100
+++ new/cyclopts-4.24.0/cyclopts/parameter.py 2020-02-02 01:00:00.000000000
+0100
@@ -26,6 +26,7 @@
from cyclopts.annotations import (
ITERABLE_TYPES,
NoneType,
+ get_choices_from_hint,
is_annotated,
is_nonetype,
is_union,
@@ -88,6 +89,28 @@
return cast(tuple[str, ...], to_tuple_converter(value))
+def _choices_converter(value: Any) -> tuple[str, ...] | type | None:
+ """Normalize ``Parameter.choices``.
+
+ An iterable of strings becomes a tuple. A type hint (``Literal``, ``Enum``,
+ unions thereof, ``TypeAliasType``) is stored as-is so
:meth:`Argument.get_choices`
+ can resolve it with the parameter's ``name_transform``.
+ """
+ if value is None:
+ return None
+ if not isinstance(value, str):
+ if get_choices_from_hint(value, default_name_transform):
+ return value
+ if isinstance(value, type):
+ raise TypeError(f"Parameter.choices type hint {value!r} yields no
choices.")
+ out = _str_tuple_converter(value)
+ if not all(isinstance(x, str) for x in out):
+ raise TypeError("Parameter.choices must be an iterable of strings or a
Literal/Enum type hint.")
+ if not out:
+ raise TypeError("Parameter.choices cannot be an empty iterable.")
+ return out
+
+
def _validator_tuple_converter(
value: Callable[..., Any] | str | Iterable[Callable[..., Any] | str] |
None,
) -> tuple[Callable[..., Any] | str, ...]:
@@ -292,6 +315,13 @@
kw_only=True,
)
+ # Either a Tuple[str, ...] or an unresolved type hint (resolved in
Argument.get_choices).
+ choices: None | str | Iterable[str] | type = field(
+ default=None,
+ converter=_choices_converter,
+ kw_only=True,
+ )
+
help: str | None = field(default=None, kw_only=True)
show_env_var: bool = field(