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-11-10 14:39:22
++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Comparing /work/SRC/openSUSE:Factory/python-cyclopts (Old)
 and      /work/SRC/openSUSE:Factory/.python-cyclopts.new.3759290 (New)
++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++

Package is "python-cyclopts"

Tue Nov 10 14:39:22 2026 rev:27 rq:1396273 version:5.4.0

Changes:
--------
--- /work/SRC/openSUSE:Factory/python-cyclopts/python-cyclopts.changes  
2026-11-04 19:49:56.696668856 +0100
+++ 
/work/SRC/openSUSE:Factory/.python-cyclopts.new.3759290/python-cyclopts.changes 
    2026-11-10 14:40:01.323565082 +0100
@@ -1,0 +2,16 @@
+Mon Nov  9 08:52:13 UTC 2026 - Martin Pluskal <[email protected]>
+
+- Update to 5.4.0 (PyPI release 2026-11-08):
+  * New App(argfile_prefix="@"): read CLI args from files,
+    one per line; files may reference other files
+  * Meta apps: unknown combined short flag reported whole;
+    meta option in combined token takes all values;
+    combined short flags with = no longer misbind
+  * App run as python -m named after module in help/usage
+  * Parse pydantic URL and other custom-schema types as
+    a single value instead of recursing
+  * CoercionError from custom converters printable
+    instead of AssertionError
+  * Shell completion skips Parameter(parse=False) args
+
+-------------------------------------------------------------------

Old:
----
  cyclopts-5.3.3.tar.gz

New:
----
  cyclopts-5.4.0.tar.gz

++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++

Other differences:
------------------
++++++ python-cyclopts.spec ++++++
--- /var/tmp/diff_new_pack.7K778Q/_old  2026-11-10 14:40:02.278605052 +0100
+++ /var/tmp/diff_new_pack.7K778Q/_new  2026-11-10 14:40:02.279605094 +0100
@@ -18,7 +18,7 @@
 
 %bcond_without libalternatives
 Name:           python-cyclopts
-Version:        5.3.3
+Version:        5.4.0
 Release:        0
 Summary:        Intuitive, easy CLIs based on python type hints
 License:        Apache-2.0

++++++ cyclopts-5.3.3.tar.gz -> cyclopts-5.4.0.tar.gz ++++++
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/cyclopts-5.3.3/PKG-INFO new/cyclopts-5.4.0/PKG-INFO
--- old/cyclopts-5.3.3/PKG-INFO 2020-02-02 01:00:00.000000000 +0100
+++ new/cyclopts-5.4.0/PKG-INFO 2020-02-02 01:00:00.000000000 +0100
@@ -1,6 +1,6 @@
 Metadata-Version: 2.5
 Name: cyclopts
-Version: 5.3.3
+Version: 5.4.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
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/cyclopts-5.3.3/cyclopts/_convert.py 
new/cyclopts-5.4.0/cyclopts/_convert.py
--- old/cyclopts-5.3.3/cyclopts/_convert.py     2020-02-02 01:00:00.000000000 
+0100
+++ new/cyclopts-5.4.0/cyclopts/_convert.py     2020-02-02 01:00:00.000000000 
+0100
@@ -27,6 +27,7 @@
     is_annotated,
     is_enum_flag,
     is_nonetype,
+    is_pydantic_leaf,
     is_union,
     resolve,
     resolve_new_type,
@@ -820,6 +821,16 @@
             try:
                 if token.implicit_value is not UNSET:
                     out = token.implicit_value
+                elif converter is None and is_pydantic_leaf(type_) and 
"pydantic" in sys.modules:
+                    # Validate like pydantic would; the constructor may not 
take the string form.
+                    # Non-pydantic libraries (e.g. ``whenever``) may define 
the hook without pydantic installed.
+                    import pydantic
+
+                    try:
+                        out = 
pydantic.TypeAdapter(type_).validate_python(token.value)
+                    except pydantic.ValidationError as e:
+                        # ``CoercionError`` appends the pydantic reason from 
``__cause__``.
+                        raise CoercionError(token=token, target_type=type_) 
from e
                 elif converter is None:
                     out = _converters.get(type_, type_)(token.value)  # 
pyright: ignore[reportOptionalCall]
                 elif converter_needs_token:
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/cyclopts-5.3.3/cyclopts/_version.py 
new/cyclopts-5.4.0/cyclopts/_version.py
--- old/cyclopts-5.3.3/cyclopts/_version.py     2020-02-02 01:00:00.000000000 
+0100
+++ new/cyclopts-5.4.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 = '5.3.3'
-__version_tuple__ = version_tuple = (5, 3, 3)
+__version__ = version = '5.4.0'
+__version_tuple__ = version_tuple = (5, 4, 0)
 
 __commit_id__ = commit_id = None
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/cyclopts-5.3.3/cyclopts/annotations.py 
new/cyclopts-5.4.0/cyclopts/annotations.py
--- old/cyclopts-5.3.3/cyclopts/annotations.py  2020-02-02 01:00:00.000000000 
+0100
+++ new/cyclopts-5.4.0/cyclopts/annotations.py  2020-02-02 01:00:00.000000000 
+0100
@@ -77,6 +77,14 @@
     return hasattr(hint, "__pydantic_core_schema__")
 
 
+def is_pydantic_leaf(hint) -> bool:
+    """Check if a type is a non-model Pydantic custom-schema type (``AnyUrl``, 
``ByteSize``, etc.).
+
+    Pydantic validates these from a single value, so their ``__init__`` isn't 
a set of fields.
+    """
+    return hasattr(hint, "__get_pydantic_core_schema__") and not 
is_pydantic(hint)
+
+
 def is_pydantic_secret(hint) -> bool:
     """Check if a type is a Pydantic secret type (SecretStr, SecretBytes, 
Secret, etc.)."""
     return (
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/cyclopts-5.3.3/cyclopts/bind.py 
new/cyclopts-5.4.0/cyclopts/bind.py
--- old/cyclopts-5.3.3/cyclopts/bind.py 2020-02-02 01:00:00.000000000 +0100
+++ new/cyclopts-5.4.0/cyclopts/bind.py 2020-02-02 01:00:00.000000000 +0100
@@ -6,6 +6,7 @@
 from collections.abc import Callable, Iterable, Iterator, Sequence
 from contextlib import suppress
 from functools import partial
+from pathlib import Path
 from typing import TYPE_CHECKING, Any, NamedTuple, get_args
 
 from cyclopts._convert import _bool, _none, create_empty_instance
@@ -63,6 +64,42 @@
     return tokens
 
 
+def expand_argfiles(tokens: list[str], prefix: str | None, 
end_of_options_delimiter: str) -> list[str]:
+    """Replace each ``<prefix><path>`` token with the lines of the file at 
``path``.
+
+    Mirrors :mod:`argparse`'s ``fromfile_prefix_chars``: one argument per 
line, paths
+    relative to the current directory, and argument files may reference other 
argument files.
+    Tokens after ``end_of_options_delimiter`` are not expanded.
+    """
+    if not prefix:
+        return tokens
+
+    expanded: list[str] = []
+    end_of_options = False
+
+    def expand(tokens: Iterable[str], parents: tuple[Path, ...]) -> None:
+        nonlocal end_of_options
+        for token in tokens:
+            if end_of_options or not token.startswith(prefix) or token == 
prefix:
+                if end_of_options_delimiter and token == 
end_of_options_delimiter:
+                    end_of_options = True
+                expanded.append(token)
+                continue
+
+            path = Path(token[len(prefix) :])
+            resolved = path.resolve()
+            if resolved in parents:
+                raise TokenizationError(msg=f"Argument file {str(path)!r} is 
referenced recursively.")
+            try:
+                lines = path.read_text(encoding="utf-8").splitlines()
+            except OSError as e:
+                raise TokenizationError(msg=f"Cannot read argument file 
{str(path)!r}: {e.strerror}.") from e
+            expand(lines, (*parents, resolved))
+
+    expand(tokens, ())
+    return expanded
+
+
 class _ProbeResult(NamedTuple):
     """Result of :func:`_probe_unclaimed`."""
 
@@ -148,9 +185,10 @@
     meta_part: str | None
     """Reconstructed token for the meta's keyword stream, or ``None``."""
 
-    meta_awaits_value: bool
-    """A meta value-taking option ended the token with no attached value; the
-    next stream token is its value."""
+    meta_value_option: str | None
+    """The meta value-taking option that ended the token, with its attached
+    value (e.g. ``-uroot``), or ``None``. Bare (``-u``) means its values come
+    from the following stream tokens."""
 
 
 def _is_combined_short_candidate(
@@ -160,21 +198,20 @@
     """Whether ``token`` should be resolved by the merged combined-short scan.
 
     Mirrors the gate in :func:`_parse_kw_and_flags`'s combined-short branch:
-    single-dash, multi-character, non-numeric, no ``=``, and not an exact match
-    of any collection (exact matches are ordinary options, handled by the
-    normal probing paths).
+    single-dash, multi-character, non-numeric, and neither the token nor its
+    ``=``-prefix an exact match of any collection (exact matches are ordinary
+    options, handled by the normal probing paths).
     """
-    if "=" in token:
-        return False
     if not (token.startswith("-") and not token.startswith("--") and 
len(token) > 2):
         return False
     if not is_option_like(token, allow_numbers=False):
         return False
+    option = token.split("=", 1)[0]
     for collection in collections:
         if collection is None:
             continue
         try:
-            collection.match(token)
+            collection.match(option)
         except ValueError:
             continue
         return False
@@ -185,17 +222,22 @@
     token: str,
     child_collection: "ArgumentCollection | None",
     meta_collection: ArgumentCollection,
+    intermediate_collections: Sequence[ArgumentCollection] = (),
 ) -> _MergedScanResult:
     """Resolve a combined short-option token against the MERGED flag namespace.
 
     Fallthrough mode presents the user with one flat flag namespace; this scan
     makes that literal: a single GNU left-to-right pass where each character is
     matched against the child first (child wins name collisions after its
-    command), then the meta. Standard rules apply to the merged table:
+    command), then the meta, then any ``intermediate_collections`` (meta levels
+    between this meta and the child, which receive their characters via the
+    child's stream). The meta outranks intermediates, as it does for
+    standalone options. Standard rules apply to the merged table:
 
     - a flag/count match consumes its character;
     - the first value-taking match absorbs the rest of the token as an attached
-      value (or, with no remainder, the next stream token) and ends the scan;
+      value and ends the scan (any further values come from the following
+      stream tokens, claimed by the caller);
     - an unknown character is routed to the child's stream, which reports it.
 
     Characters are routed to per-owner reconstructed tokens in scan order, so
@@ -204,12 +246,16 @@
     chars = token.lstrip("-")
     child_chars: list[str] = []
     meta_chars: list[str] = []
-    meta_awaits_value = False
+    meta_value_option: str | None = None
     position = 0
     while position < len(chars):
         char = chars[position]
         matched = None
-        for owner_chars, collection in ((child_chars, child_collection), 
(meta_chars, meta_collection)):
+        for owner_chars, collection in (
+            (child_chars, child_collection),
+            (meta_chars, meta_collection),
+            *((child_chars, collection) for collection in 
intermediate_collections),
+        ):
             if collection is None:
                 continue
             try:
@@ -219,6 +265,10 @@
             matched = (owner_chars, argument, implicit)
             break
         if matched is None:
+            if position == 0:
+                # Not a combined-short group (GNU groups must begin with a 
known
+                # option); the child keeps the token intact. See issue #932.
+                return _MergedScanResult(token, None, None)
             child_chars.append(char)  # Unknown flag; the child's parse 
reports it.
             position += 1
             continue
@@ -229,15 +279,14 @@
             continue
         # Value-taking option: the rest of the token is its attached value.
         remainder = chars[position + 1 :]
-        if remainder:
-            owner_chars.extend(remainder)
-        elif owner_chars is meta_chars:
-            meta_awaits_value = True
+        owner_chars.extend(remainder)
+        if owner_chars is meta_chars:
+            meta_value_option = f"-{char}{remainder}"
         break
     return _MergedScanResult(
         "-" + "".join(child_chars) if child_chars else None,
         "-" + "".join(meta_chars) if meta_chars else None,
-        meta_awaits_value,
+        meta_value_option,
     )
 
 
@@ -254,6 +303,7 @@
     *,
     parse_mode: str,
     end_of_options_delimiter: str = "--",
+    intermediate_collections: Sequence[ArgumentCollection] = (),
 ) -> tuple[list[str], list[str], int | None]:
     """Scope tokens between a meta-app level and the subcommand it forwards to.
 
@@ -284,6 +334,10 @@
         ``"fallthrough"`` or ``"strict"``.
     end_of_options_delimiter: str
         Token that marks the end of options.
+    intermediate_collections: Sequence[ArgumentCollection]
+        Collections of the meta levels between this meta and the child (a meta
+        of a meta). Their short flags count as known in the combined-short
+        scan; their characters stay in the child's stream.
 
     Returns
     -------
@@ -408,10 +462,10 @@
             synthetics = child_unclaimed_by_position[position]
             partially_claimed = not (len(synthetics) == 1 and synthetics[0] == 
original)
             if not partially_claimed and not _is_combined_short_candidate(
-                original, (child_collection, meta_collection)
+                original, (child_collection, *intermediate_collections, 
meta_collection)
             ):
                 continue
-            scan = _merged_combined_short_scan(original, child_collection, 
meta_collection)
+            scan = _merged_combined_short_scan(original, child_collection, 
meta_collection, intermediate_collections)
             if scan.meta_part is None:
                 if partially_claimed:
                     # Nothing for the meta; keep the child's own reading and
@@ -421,22 +475,45 @@
             scanned_positions.add(position)
             newly_replaced[position] = scan.child_part
             bubbled.append(scan.meta_part)
-            if scan.meta_awaits_value:
-                # The meta's value-taking option ended the token; the NEXT
-                # stream token is its value — if the child didn't claim it.
-                value_position = position + 1
-                value_synthetics = 
child_unclaimed_by_position.get(value_position)
-                if (
-                    value_position < delimiter_pos
-                    and value_synthetics is not None
-                    and len(value_synthetics) == 1
-                    and value_synthetics[0] == stream_tokens[value_position]
-                ):
-                    scanned_positions.add(value_position)
-                    newly_replaced[value_position] = None
-                    bubbled.append(stream_tokens[value_position])
-                # Otherwise the meta's real parse raises the proper
-                # missing-value error.
+            if scan.meta_value_option is None:
+                continue
+            # The meta's value-taking option may consume following stream
+            # tokens the child left wholly unclaimed (as many as its token
+            # count, or more with ``consume_multiple``). Probe that option
+            # alone, so no other meta option claims a follower the child's
+            # flags should win.
+            follower_positions: list[int] = []
+            follower_position = position + 1
+            while follower_position < delimiter_pos and 
child_unclaimed_by_position.get(follower_position) == [
+                stream_tokens[follower_position]
+            ]:
+                follower_positions.append(follower_position)
+                follower_position += 1
+            if not follower_positions:
+                continue
+            value_argument = 
meta_collection.match(scan.meta_value_option[:2])[0]
+            follower_probe = _probe_unclaimed(
+                ArgumentCollection([value_argument]),
+                [scan.meta_value_option, *(stream_tokens[p] for p in 
follower_positions)],
+                end_of_options_delimiter=end_of_options_delimiter,
+            )
+            if follower_probe is None:
+                # Malformed for the meta (e.g. too few values); hand it the
+                # next token so its real parse raises the proper error.
+                claimed_followers = follower_positions[:1] if 
len(scan.meta_value_option) == 2 else []
+            else:
+                claimed_followers = []
+                for follower_position in follower_positions:
+                    if follower_probe.kw_claims.get(follower_position - 
position) != (
+                        stream_tokens[follower_position],
+                        None,
+                    ):
+                        break
+                    claimed_followers.append(follower_position)
+            for follower_position in claimed_followers:
+                scanned_positions.add(follower_position)
+                newly_replaced[follower_position] = None
+                bubbled.append(stream_tokens[follower_position])
 
         candidates = [
             (position, token)
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/cyclopts-5.3.3/cyclopts/completion/_base.py 
new/cyclopts-5.4.0/cyclopts/completion/_base.py
--- old/cyclopts-5.3.3/cyclopts/completion/_base.py     2020-02-02 
01:00:00.000000000 +0100
+++ new/cyclopts-5.4.0/cyclopts/completion/_base.py     2020-02-02 
01:00:00.000000000 +0100
@@ -145,8 +145,13 @@
                 # represent it; its ``--[KEYWORD]`` placeholder name is 
invalid shell
                 # syntax (zsh's ``_arguments`` aborts on it, breaking 
completion for the
                 # entire command).
+                # Unparsed arguments (``Parameter(parse=False)``) are never 
read from the
+                # command line, so they are never completed; inspecting their 
type hint
+                # (e.g. via ``is_flag()``) may also fail for values injected 
at runtime.
                 app_arguments = ArgumentCollection(
-                    argument for argument in app_arguments if 
argument.field_info.kind is not VAR_KEYWORD
+                    argument
+                    for argument in app_arguments
+                    if argument.field_info.kind is not VAR_KEYWORD and 
argument.parse
                 )
                 arguments.extend(app_arguments)
                 if not any(subapp is a for a in current):
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/cyclopts-5.3.3/cyclopts/core.py 
new/cyclopts-5.4.0/cyclopts/core.py
--- old/cyclopts-5.3.3/cyclopts/core.py 2020-02-02 01:00:00.000000000 +0100
+++ new/cyclopts-5.4.0/cyclopts/core.py 2020-02-02 01:00:00.000000000 +0100
@@ -34,6 +34,7 @@
     _parse_kw_and_flags,
     _scope_tokens_for_meta,
     create_bound_arguments,
+    expand_argfiles,
     is_option_like,
     normalize_tokens,
 )
@@ -130,6 +131,28 @@
     raise _CannotDeriveCallingModuleNameError  # pragma: no cover
 
 
+def _default_app_name() -> str:
+    """Name of the running program, for an :class:`App` without an explicit 
``name``.
+
+    Like :mod:`argparse`'s default ``prog``: ``python -m <module>`` when the 
program
+    was started with ``python -m``, otherwise the script's file name.
+    """
+    try:
+        spec = sys.modules["__main__"].__spec__
+    except (KeyError, AttributeError):
+        spec = None
+    # Wrappers like ``python -m cProfile script.py`` point ``sys.argv[0]`` at 
the script.
+    if spec is not None and spec.name != "__main__" and sys.argv[0] == 
spec.origin:
+        # Started with ``python -m``; a package's ``__main__`` is run as the 
package.
+        module_name = spec.name.removesuffix(".__main__")
+        return f"{Path(sys.executable).name} -m {module_name}"
+
+    name = Path(sys.argv[0]).name
+    if name == "__main__.py":
+        name = _get_root_module_name()
+    return name
+
+
 def _validate_default_command(x: Callable[..., Any] | None) -> Callable[..., 
Any] | None:
     if isinstance(x, App):
         raise TypeError("Cannot register a sub-App to default.")
@@ -506,6 +529,8 @@
 
     end_of_options_delimiter: str | None = field(default=None, kw_only=True)
 
+    argfile_prefix: str | None = field(default=None, kw_only=True)
+
     parse_mode: Literal["fallthrough", "strict"] | None = field(
         default=None,
         kw_only=True,
@@ -656,10 +681,7 @@
         if self._name:
             return self._name + self.alias  # pyright: ignore
         elif self.default_command is None:
-            name = Path(sys.argv[0]).name
-            if name == "__main__.py":
-                name = _get_root_module_name()
-            return (name,) + self.alias  # pyright: ignore
+            return (_default_app_name(),) + self.alias  # pyright: ignore
         else:
             try:
                 func_name = self.default_command.__name__
@@ -1713,7 +1735,11 @@
             "_error_console": error_console,
             "end_of_options_delimiter": end_of_options_delimiter,
         }
+        if tokens is None:
+            _log_framework_warning(_detect_test_framework())
+
         with self.app_stack([], overrides=overrides):
+            tokens = self._expand_argfiles(normalize_tokens(tokens))
             command, bound, unused_tokens, ignored, _ = 
self._parse_known_args(tokens)
 
         return command, bound, unused_tokens, ignored
@@ -1912,6 +1938,14 @@
                                 # forwarded tokens reach the child's own parse.
                                 pass
                             else:
+                                intermediate_collections: 
list[ArgumentCollection] = []
+                                intermediate = command_app._meta_parent
+                                while intermediate is not None and 
intermediate._meta_parent is not None:
+                                    if intermediate.default_command and (
+                                        collection := 
_safe_assemble_argument_collection(intermediate)
+                                    ):
+                                        
intermediate_collections.append(collection)
+                                    intermediate = intermediate._meta_parent
                                 scoped = _scope_tokens_for_meta(
                                     argument_collection,
                                     child_argument_collection,
@@ -1919,6 +1953,7 @@
                                     hier_command_indices,
                                     parse_mode=parse_mode,
                                     
end_of_options_delimiter=end_of_options_delimiter,
+                                    
intermediate_collections=intermediate_collections,
                                 )
 
                         # A meta level's captured tokens are forwarded to
@@ -2103,10 +2138,22 @@
         Must be called inside an :attr:`app_stack` context so error settings 
resolve.
         """
         try:
-            return normalize_tokens(tokens)
+            return self._expand_argfiles(normalize_tokens(tokens))
         except CycloptsError as e:
             self._handle_parse_error(e, [])
 
+    def _expand_argfiles(self, tokens: list[str]) -> list[str]:
+        """Expand :attr:`argfile_prefix` tokens. Must be called inside an 
:attr:`app_stack` context."""
+        if tokens and tokens[0] == _COMPLETE_COMMAND:
+            # The shell's words may hold a half-typed ``@path``; reading it 
would abort completion.
+            return tokens
+        # Files are expanded before any command is resolved, so resolve from 
the entry app alone,
+        # even when an enclosing ``app_stack`` already holds the invoked 
command chain.
+        with self.app_stack([self]):
+            prefix = self.app_stack.resolve("argfile_prefix")
+            delimiter = self.app_stack.resolve("end_of_options_delimiter", 
fallback="--")
+        return expand_argfiles(tokens, prefix, delimiter)
+
     def _handle_parse_error(self, e: CycloptsError, tokens: list[str]) -> 
NoReturn:
         """Print ``e`` according to the resolved error settings, then exit or 
re-raise it."""
         print_error = self.app_stack.resolve("print_error")
@@ -2210,6 +2257,7 @@
                 "verbose": verbose,
                 "backend": backend,
                 "result_action": _result_action_converter(result_action),
+                "end_of_options_delimiter": end_of_options_delimiter,
                 "error_formatter": error_formatter,
             }.items()
             if v is not None
@@ -2340,6 +2388,7 @@
                 "verbose": verbose,
                 "backend": backend,
                 "result_action": _result_action_converter(result_action),
+                "end_of_options_delimiter": end_of_options_delimiter,
                 "error_formatter": error_formatter,
             }.items()
             if v is not None
@@ -3126,6 +3175,8 @@
             overrides["_console"] = console
         if error_console is not None:
             overrides["_error_console"] = error_console
+        if kwargs.get("end_of_options_delimiter") is not None:
+            overrides["end_of_options_delimiter"] = 
kwargs["end_of_options_delimiter"]
         overrides["exit_on_error"] = exit_on_error
         overrides["remap_flags"] = remap_flags
 
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/cyclopts-5.3.3/cyclopts/exceptions.py 
new/cyclopts-5.4.0/cyclopts/exceptions.py
--- old/cyclopts-5.3.3/cyclopts/exceptions.py   2020-02-02 01:00:00.000000000 
+0100
+++ new/cyclopts-5.4.0/cyclopts/exceptions.py   2020-02-02 01:00:00.000000000 
+0100
@@ -1,4 +1,5 @@
 import inspect
+import sys
 from collections.abc import Callable, Iterator, Sequence
 from enum import Enum
 from itertools import chain
@@ -383,14 +384,17 @@
                 yield f"Invalid value for {self.token.keyword}: 
{verbosified}", ""
             return
 
-        assert self.argument is not None
         assert self.target_type is not None
 
         yield from super()._segments()
 
+        # ``argument`` is filled in only once the error reaches the binding
+        # layer. Raised by ``convert`` directly, e.g. inside a custom
+        # converter, it is still None, so the message must not depend on it.
+        argument = self.argument
         choice_strs: list[str] | None = None
         plain_choices: list[str] | None = None
-        nt = self.argument.parameter.name_transform
+        nt = argument.parameter.name_transform if argument is not None else 
cyclopts.utils.default_name_transform
         if get_origin(self.target_type) is Literal:
             args = [nt(x.name) if isinstance(x, Enum) and x.name else x for x 
in get_args(self.target_type)]
             choice_strs = [f'"{x}"' if isinstance(x, str) else repr(x) for x 
in args]
@@ -402,11 +406,19 @@
 
         # Branch 3: Literal/Enum with a token -- "Choose from" + suggestion.
         if choice_strs is not None and self.token is not None:
-            name = self.token.keyword if self.token.keyword else 
self.argument.name.lstrip("-").upper()
+            if self.token.keyword:
+                name = self.token.keyword
+            elif argument is not None:
+                name = argument.name.lstrip("-").upper()
+            else:
+                name = None
             yield 'Invalid value "', ""
             yield self.token.value, STYLE_OFFENDING_VALUE
-            yield '" for ', ""
-            yield name, STYLE_NAME
+            if name is None:
+                yield '"', ""
+            else:
+                yield '" for ', ""
+                yield name, STYLE_NAME
             if self.token.source not in ("", "cli"):
                 yield f" from {self.token.source}", STYLE_SOURCE
             yield ". Choose from: ", ""
@@ -431,23 +443,47 @@
         )
 
         if not self.token:
+            if argument is None:
+                yield f"Unable to convert value to {target_type_name}.", ""
+                return
             yield "Invalid value for ", ""
-            yield self.argument.name, STYLE_NAME
+            yield argument.name, STYLE_NAME
             yield f": unable to convert value to {target_type_name}.", ""
             return
 
-        if self.token.keyword is None:
-            display_name = self.argument.name.lstrip("-").upper()
-        else:
+        if self.token.keyword is not None:
             display_name = self.token.keyword
+        elif argument is not None:
+            display_name = argument.name.lstrip("-").upper()
+        else:
+            display_name = None
 
-        yield "Invalid value for ", ""
-        yield display_name, STYLE_NAME
-        if self.token.source not in ("", "cli"):
-            yield f" from {self.token.source}", STYLE_SOURCE
-        yield ': unable to convert "', ""
-        yield self.token.value, STYLE_OFFENDING_VALUE
-        yield f'" into {target_type_name}.', ""
+        if display_name is None:
+            yield 'Unable to convert "', ""
+            yield self.token.value, STYLE_OFFENDING_VALUE
+            yield '"', ""
+            if self.token.source not in ("", "cli"):
+                yield f" from {self.token.source}", STYLE_SOURCE
+            yield f" into {target_type_name}.", ""
+        else:
+            yield "Invalid value for ", ""
+            yield display_name, STYLE_NAME
+            if self.token.source not in ("", "cli"):
+                yield f" from {self.token.source}", STYLE_SOURCE
+            yield ': unable to convert "', ""
+            yield self.token.value, STYLE_OFFENDING_VALUE
+            yield f'" into {target_type_name}.', ""
+
+        if "pydantic" in sys.modules:
+            import pydantic
+
+            if isinstance(self.__cause__, pydantic.ValidationError):
+                error = self.__cause__.errors()[0]
+                reason = error["msg"]
+                if error["type"] == "value_error":
+                    reason = reason.removeprefix("Value error, ")
+                reason = reason.rstrip(".")
+                yield f" {reason[:1].upper()}{reason[1:]}.", ""
 
 
 class UnknownCommandError(CycloptsError):
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/cyclopts-5.3.3/cyclopts/field_info.py 
new/cyclopts-5.4.0/cyclopts/field_info.py
--- old/cyclopts-5.3.3/cyclopts/field_info.py   2020-02-02 01:00:00.000000000 
+0100
+++ new/cyclopts-5.4.0/cyclopts/field_info.py   2020-02-02 01:00:00.000000000 
+0100
@@ -24,6 +24,7 @@
     is_enum_flag,
     is_namedtuple,
     is_pydantic,
+    is_pydantic_leaf,
     is_pydantic_secret,
     is_typeddict,
     is_union,
@@ -384,6 +385,9 @@
         # Plain (non-Flag) Enums are leaf choice-sets converted by member name;
         # a user-defined ``__init__`` must not cause decomposition into 
children.
         return {}
+    elif is_pydantic_leaf(hint):
+        # ``AnyUrl``'s ``__init__`` references itself; decomposing it would 
recurse forever.
+        return {}
     else:
         return _generic_class_field_infos(hint)
 
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/cyclopts-5.3.3/cyclopts/help/help.py 
new/cyclopts-5.4.0/cyclopts/help/help.py
--- old/cyclopts-5.3.3/cyclopts/help/help.py    2020-02-02 01:00:00.000000000 
+0100
+++ new/cyclopts-5.4.0/cyclopts/help/help.py    2020-02-02 01:00:00.000000000 
+0100
@@ -1,9 +1,7 @@
 import inspect
-import sys
 from collections.abc import Iterable, Sequence
 from enum import Enum
 from functools import lru_cache, partial
-from pathlib import Path
 from typing import (
     TYPE_CHECKING,
     Any,
@@ -27,7 +25,7 @@
     resolve_optional,
 )
 from cyclopts.argument.utils import is_short_flag
-from cyclopts.core import _get_root_module_name, 
_iter_resolution_argument_collections
+from cyclopts.core import _default_app_name, 
_iter_resolution_argument_collections
 from cyclopts.field_info import get_field_infos
 from cyclopts.group import Group
 from cyclopts.help.inline_text import InlineText
@@ -351,13 +349,10 @@
     usage = []
 
     # If we're at the root level (no command chain), the app has a 
default_command,
-    # and no explicit name was set, derive a better name from sys.argv[0]
+    # and no explicit name was set, derive the name of the running program
     if not command_chain and app.default_command and not app._name:
         # Use the same logic as in App.name property for apps without 
default_command
-        name = Path(sys.argv[0]).name
-        if name == "__main__.py":
-            name = _get_root_module_name()
-        app_name = name
+        app_name = _default_app_name()
     else:
         app_name = app.name[0]
 

Reply via email to