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(

Reply via email to