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-08 16:58:02
++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Comparing /work/SRC/openSUSE:Factory/python-cyclopts (Old)
 and      /work/SRC/openSUSE:Factory/.python-cyclopts.new.1265 (New)
++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++

Package is "python-cyclopts"

Tue Sep  8 16:58:02 2026 rev:16 rq:1376272 version:4.25.1

Changes:
--------
--- /work/SRC/openSUSE:Factory/python-cyclopts/python-cyclopts.changes  
2026-09-07 11:31:45.717112212 +0200
+++ 
/work/SRC/openSUSE:Factory/.python-cyclopts.new.1265/python-cyclopts.changes    
    2026-09-08 17:01:23.924198824 +0200
@@ -1,0 +2,14 @@
+Tue Sep  8 06:24:12 UTC 2026 - Martin Pluskal <[email protected]>
+
+- Update to 4.25.1:
+  * Interactive shell no longer exits on Ctrl-C or malformed
+    input; unbalanced quotes raise TokenizationError instead of
+    a raw ValueError
+  * Sphinx RST: render the root default_command params/usage
+    panel
+  * Keep allow_leading_hyphen=True positional values intact when
+    a later character of a combined short option matches a flag
+  * Honor subcommand-level configuration via the invoked
+    command's app-stack rather than the parent app
+
+-------------------------------------------------------------------

Old:
----
  cyclopts-4.24.0.tar.gz

New:
----
  cyclopts-4.25.1.tar.gz

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

Other differences:
------------------
++++++ python-cyclopts.spec ++++++
--- /var/tmp/diff_new_pack.DDACyl/_old  2026-09-08 17:01:24.731232579 +0200
+++ /var/tmp/diff_new_pack.DDACyl/_new  2026-09-08 17:01:24.732232621 +0200
@@ -18,7 +18,7 @@
 
 %bcond_without libalternatives
 Name:           python-cyclopts
-Version:        4.24.0
+Version:        4.25.1
 Release:        0
 Summary:        Intuitive, easy CLIs based on python type hints
 License:        Apache-2.0

++++++ cyclopts-4.24.0.tar.gz -> cyclopts-4.25.1.tar.gz ++++++
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/cyclopts-4.24.0/PKG-INFO new/cyclopts-4.25.1/PKG-INFO
--- old/cyclopts-4.24.0/PKG-INFO        2020-02-02 01:00:00.000000000 +0100
+++ new/cyclopts-4.25.1/PKG-INFO        2020-02-02 01:00:00.000000000 +0100
@@ -1,6 +1,6 @@
 Metadata-Version: 2.5
 Name: cyclopts
-Version: 4.24.0
+Version: 4.25.1
 Summary: Intuitive, easy CLIs based on type hints.
 Project-URL: Homepage, https://github.com/BrianPugh/cyclopts
 Project-URL: Repository, https://github.com/BrianPugh/cyclopts
@@ -33,6 +33,7 @@
 Requires-Dist: pydantic<3.0.0,>=2.11.2; extra == 'dev'
 Requires-Dist: pytest-cov>=3.0.0; extra == 'dev'
 Requires-Dist: pytest-mock>=3.7.0; extra == 'dev'
+Requires-Dist: pytest-timeout>=2.3.0; extra == 'dev'
 Requires-Dist: pytest>=8.2.0; extra == 'dev'
 Requires-Dist: pyyaml>=6.0.1; extra == 'dev'
 Requires-Dist: syrupy>=4.0.0; extra == 'dev'
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/cyclopts-4.24.0/cyclopts/__init__.py 
new/cyclopts-4.25.1/cyclopts/__init__.py
--- old/cyclopts-4.24.0/cyclopts/__init__.py    2020-02-02 01:00:00.000000000 
+0100
+++ new/cyclopts-4.25.1/cyclopts/__init__.py    2020-02-02 01:00:00.000000000 
+0100
@@ -35,6 +35,7 @@
     "resolve_returncode",
     "UnknownOptionError",
     "UnusedCliTokensError",
+    "TokenizationError",
     "UNSET",
     "ValidationError",
     "config",
@@ -65,6 +66,7 @@
     MixedArgumentError,
     RepeatArgumentError,
     RequiresEqualsError,
+    TokenizationError,
     UnknownCommandError,
     UnknownOptionError,
     UnusedCliTokensError,
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/cyclopts-4.24.0/cyclopts/__init__.pyi 
new/cyclopts-4.25.1/cyclopts/__init__.pyi
--- old/cyclopts-4.24.0/cyclopts/__init__.pyi   2020-02-02 01:00:00.000000000 
+0100
+++ new/cyclopts-4.25.1/cyclopts/__init__.pyi   2020-02-02 01:00:00.000000000 
+0100
@@ -23,6 +23,7 @@
 from cyclopts.exceptions import MixedArgumentError as MixedArgumentError
 from cyclopts.exceptions import RepeatArgumentError as RepeatArgumentError
 from cyclopts.exceptions import RequiresEqualsError as RequiresEqualsError
+from cyclopts.exceptions import TokenizationError as TokenizationError
 from cyclopts.exceptions import UnknownCommandError as UnknownCommandError
 from cyclopts.exceptions import UnknownOptionError as UnknownOptionError
 from cyclopts.exceptions import UnusedCliTokensError as UnusedCliTokensError
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/cyclopts-4.24.0/cyclopts/_convert.py 
new/cyclopts-4.25.1/cyclopts/_convert.py
--- old/cyclopts-4.24.0/cyclopts/_convert.py    2020-02-02 01:00:00.000000000 
+0100
+++ new/cyclopts-4.25.1/cyclopts/_convert.py    2020-02-02 01:00:00.000000000 
+0100
@@ -402,16 +402,16 @@
     """
     from cyclopts.token import Token
 
-    # Validate no extra keys in JSON data
     _validate_json_extra_keys(data, type_)
 
     converted_data = {}
     for field_name, field_info in field_infos.items():
         if field_name in data:
             value = data[field_name]
-            # Convert the value to the proper type
+            # None and str-typed fields pass through unchanged; every other 
field is
+            # round-tripped through convert() via a Token, re-serializing 
dict/list back
+            # to JSON so the recursive call receives parseable JSON (scalars 
are stringified).
             if value is not None and not 
is_class_and_subclass(field_info.hint, str):
-                # Create a token for the value and convert it
                 token = Token(value=json.dumps(value) if isinstance(value, 
dict | list) else str(value))
                 # Always attempt conversion, let errors propagate for 
consistency
                 converted_value = convert(field_info.hint, [token], converter, 
name_transform)
@@ -419,7 +419,6 @@
                 converted_value = value
             converted_data[field_name] = converted_value
 
-    # Create the dataclass with converted values
     return type_(**converted_data)
 
 
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/cyclopts-4.24.0/cyclopts/_version.py 
new/cyclopts-4.25.1/cyclopts/_version.py
--- old/cyclopts-4.24.0/cyclopts/_version.py    2020-02-02 01:00:00.000000000 
+0100
+++ new/cyclopts-4.25.1/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.24.0'
-__version_tuple__ = version_tuple = (4, 24, 0)
+__version__ = version = '4.25.1'
+__version_tuple__ = version_tuple = (4, 25, 1)
 
 __commit_id__ = commit_id = None
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/cyclopts-4.24.0/cyclopts/app_stack.py 
new/cyclopts-4.25.1/cyclopts/app_stack.py
--- old/cyclopts-4.24.0/cyclopts/app_stack.py   2020-02-02 01:00:00.000000000 
+0100
+++ new/cyclopts-4.25.1/cyclopts/app_stack.py   2020-02-02 01:00:00.000000000 
+0100
@@ -74,6 +74,12 @@
                 meta_app.app_stack.stack.append(meta_subapps)
                 # Also push the overrides onto the meta app's stack
                 meta_app.app_stack.overrides_stack.append(overrides or {})
+        # Give the entry app (this AppStack's owner) visibility into the full 
invoked command
+        # chain, so command-scoped settings resolve to the deepest invoked 
command instead of
+        # only the entry app (#933). Without this, 
``self.app_stack.resolve(...)`` at an entry
+        # point sees just ``[entry_app]`` and silently ignores 
subcommand-level settings.
+        if resolved_apps and resolved_apps[0] is self.stack[0][0]:
+            self.stack[-1] = so_far.copy()
         try:
             yield
         finally:
@@ -102,6 +108,11 @@
     @property
     def default_parameter(self) -> Parameter:
         """default_parameter has special resolution since it needs to include 
the command groups in the derivation."""
+        # Unlike ``resolve`` (which scans only ``current_frame``), this must 
chain *all* frames:
+        # it combines every contribution rather than taking the closest, and 
it skips meta-parents
+        # with no ``_meta_parent`` walk -- so a root default_parameter reaches 
a nested-meta command
+        # only by being present in an earlier frame. Narrowing this to 
``current_frame`` breaks
+        # ``test_nested_meta_app_inheriting_root_default_parameter``. See #933.
         cparams = []
         for child_app in chain.from_iterable(self.stack):
             if child_app._meta_parent:
@@ -142,8 +153,11 @@
                 if value is not None:
                     return value
 
+        # Only the current (innermost) frame is consulted; it already holds 
the full invoked
+        # command chain (root -> ... -> command), so a re-entrant invocation 
of the same entry
+        # app can't leak an outer sibling's settings sideways through a 
leftover frame (#933).
         # `reversed` so that "closer" apps have higher priority.
-        for app in reversed(list(chain.from_iterable(self.stack))):
+        for app in reversed(self.current_frame):
             result = getattr(app, attribute)
             if result is not None:
                 return result
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/cyclopts-4.24.0/cyclopts/bind.py 
new/cyclopts-4.25.1/cyclopts/bind.py
--- old/cyclopts-4.24.0/cyclopts/bind.py        2020-02-02 01:00:00.000000000 
+0100
+++ new/cyclopts-4.25.1/cyclopts/bind.py        2020-02-02 01:00:00.000000000 
+0100
@@ -19,6 +19,7 @@
     CycloptsError,
     MissingArgumentError,
     RequiresEqualsError,
+    TokenizationError,
     UnknownOptionError,
     ValidationError,
 )
@@ -58,7 +59,10 @@
     if tokens is None:
         tokens = sys.argv[1:]  # Remove the executable
     elif isinstance(tokens, str):
-        tokens = shlex.split(tokens)
+        try:
+            tokens = shlex.split(tokens)
+        except ValueError as e:
+            raise TokenizationError(msg=f"{e}: {tokens!r}") from e
     else:
         tokens = list(tokens)
     return tokens
@@ -198,6 +202,16 @@
                         if stop_at_first_unknown:
                             unused_tokens.extend(tokens[i:])
                             return unused_tokens, None
+                        if position == 0:
+                            # The first character isn't a known short option, 
so this
+                            # isn't a combined-short-option token: GNU-style 
groups are
+                            # parsed strictly left-to-right and must begin 
with a known
+                            # option. Bail out (leaving ``matches`` empty) so 
the token is
+                            # kept intact rather than scanning deeper for a 
later match --
+                            # e.g. a leading-hyphen positional value like 
``-ojson`` must
+                            # not be exploded just because its trailing ``n`` 
matches
+                            # ``-n``. See issue #932.
+                            break
                         unmatched_flags.append(test_flag)
                         position += 1
 
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/cyclopts-4.24.0/cyclopts/cli/__init__.py 
new/cyclopts-4.25.1/cyclopts/cli/__init__.py
--- old/cyclopts-4.24.0/cyclopts/cli/__init__.py        2020-02-02 
01:00:00.000000000 +0100
+++ new/cyclopts-4.25.1/cyclopts/cli/__init__.py        2020-02-02 
01:00:00.000000000 +0100
@@ -2,7 +2,6 @@
 
 import cyclopts
 
-# Create the main CLI app
 app = cyclopts.App(name="cyclopts")
 app.register_install_completion_command(
     help="""\
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/cyclopts-4.24.0/cyclopts/command_spec.py 
new/cyclopts-4.25.1/cyclopts/command_spec.py
--- old/cyclopts-4.24.0/cyclopts/command_spec.py        2020-02-02 
01:00:00.000000000 +0100
+++ new/cyclopts-4.25.1/cyclopts/command_spec.py        2020-02-02 
01:00:00.000000000 +0100
@@ -82,14 +82,12 @@
         if self._resolved is not None:
             return self._resolved
 
-        # Parse import path
         module_path, _, attr_name = self.import_path.rpartition(":")
         if not module_path or not attr_name:
             raise ValueError(
                 f"Invalid import path: {self.import_path!r}. Expected format: 
'module.path:attribute_name'"
             )
 
-        # Import the module and get the attribute
         try:
             module = importlib.import_module(module_path)
         except ImportError as e:
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/cyclopts-4.24.0/cyclopts/core.py 
new/cyclopts-4.25.1/cyclopts/core.py
--- old/cyclopts-4.24.0/cyclopts/core.py        2020-02-02 01:00:00.000000000 
+0100
+++ new/cyclopts-4.25.1/cyclopts/core.py        2020-02-02 01:00:00.000000000 
+0100
@@ -16,6 +16,7 @@
     Annotated,
     Any,
     Literal,
+    NoReturn,
     Optional,
     TypeVar,
     Union,
@@ -57,15 +58,12 @@
     to_tuple_converter,
 )
 
-if sys.version_info < (3, 11):  # pragma: no cover
-    pass
-else:  # pragma: no cover
-    pass
-
-with suppress(ImportError):
+try:
     # By importing, makes things like the arrow-keys work.
+    import readline
+except ImportError:  # pragma: no cover
     # Not available on windows
-    import readline  # noqa: F401
+    readline = None
 
 if TYPE_CHECKING:
     from rich.console import Console
@@ -1146,7 +1144,6 @@
             else:
                 app = app_or_spec
 
-            # Found a command - add it to the chain
             add_parent_metas(app)
             apps.append(app)
             command_mapping = _combined_meta_command_mapping(app, 
recurse_parent_meta=include_parent_meta)
@@ -1812,8 +1809,6 @@
         if tokens is None:
             _log_framework_warning(_detect_test_framework())
 
-        tokens = normalize_tokens(tokens)
-
         # Store overrides for nested calls
         overrides = {
             k: v
@@ -1830,36 +1825,56 @@
             if v is not None
         }
 
-        # overrides isn't being propagated to subcommands because they aren't 
provided to the context manager here.
         with self.app_stack([], overrides=overrides):
+            tokens = self._normalize_tokens(tokens)
+        # Keep the invoked-command chain live across parsing *and* error 
handling, so
+        # ``_handle_parse_error`` resolves error-reporting settings from the 
invoked subcommand
+        # rather than the entry app (#933). Normalization runs first, in the 
entry-app scope, so a
+        # tokenization failure is reported before any command is resolved.
+        with self.app_stack(tokens, overrides=overrides):
             try:
                 command, bound, _, ignored, _ = self._parse_known_args(
                     tokens,
                     raise_on_unused_tokens=True,
                 )
             except CycloptsError as e:
-                print_error = self.app_stack.resolve("print_error")
-                exit_on_error = self.app_stack.resolve("exit_on_error")
-                help_on_error = self.app_stack.resolve("help_on_error")
-                verbose = self.app_stack.resolve("verbose")
-
-                e.verbose = verbose if verbose is not None else False
-                e.root_input_tokens = tokens
-                assert e.console is not None
-                if help_on_error if help_on_error is not None else False:
-                    self.help_print(tokens, console=e.console)
-                if print_error if print_error is not None else True:
-                    resolved_error_formatter = 
self.app_stack.resolve("error_formatter")
-                    if resolved_error_formatter is not None:
-                        e.console.print(resolved_error_formatter(e))
-                    else:
-                        e.console.print(CycloptsPanel(e))
-                if exit_on_error if exit_on_error is not None else True:
-                    sys.exit(1)
-                raise
+                self._handle_parse_error(e, tokens)
 
         return command, bound, ignored
 
+    def _normalize_tokens(self, tokens: None | str | Iterable[str]) -> 
list[str]:
+        """Tokenize ``tokens``, reporting a failure like any other parse error.
+
+        Must be called inside an :attr:`app_stack` context so error settings 
resolve.
+        """
+        try:
+            return normalize_tokens(tokens)
+        except CycloptsError as e:
+            self._handle_parse_error(e, [])
+
+    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")
+        exit_on_error = self.app_stack.resolve("exit_on_error")
+        help_on_error = self.app_stack.resolve("help_on_error")
+        verbose = self.app_stack.resolve("verbose")
+
+        e.verbose = verbose if verbose is not None else False
+        e.root_input_tokens = tokens
+        if e.console is None:
+            e.console = self.error_console
+        if help_on_error if help_on_error is not None else False:
+            self.help_print(tokens, console=e.console)
+        if print_error if print_error is not None else True:
+            resolved_error_formatter = 
self.app_stack.resolve("error_formatter")
+            if resolved_error_formatter is not None:
+                e.console.print(resolved_error_formatter(e))
+            else:
+                e.console.print(CycloptsPanel(e))
+        if exit_on_error if exit_on_error is not None else True:
+            sys.exit(1)
+        raise
+
     def _is_nested_call(self) -> bool:
         """Check if this is a nested call (meta app pattern or same-app 
recursion)."""
         return len(self.app_stack.overrides_stack) > 1 or (
@@ -1929,8 +1944,6 @@
         if tokens is None:
             _log_framework_warning(_detect_test_framework())
 
-        tokens = normalize_tokens(tokens)
-
         overrides = {
             k: v
             for k, v in {
@@ -1950,6 +1963,9 @@
         if self._is_nested_call():
             overrides.setdefault("result_action", "return_value")
 
+        with self.app_stack([], overrides):
+            tokens = self._normalize_tokens(tokens)
+
         with self.app_stack(tokens, overrides):
             command, bound, _ = self.parse_args(
                 tokens,
@@ -2058,8 +2074,6 @@
         if tokens is None:
             _log_framework_warning(_detect_test_framework())
 
-        tokens = normalize_tokens(tokens)
-
         overrides = {
             k: v
             for k, v in {
@@ -2079,6 +2093,9 @@
         if self._is_nested_call():
             overrides.setdefault("result_action", "return_value")
 
+        with self.app_stack([], overrides):
+            tokens = self._normalize_tokens(tokens)
+
         with self.app_stack(tokens, overrides):
             command, bound, _ = self.parse_args(
                 tokens,
@@ -2659,11 +2676,15 @@
         console: "Console | None" = None,
         exit_on_error: bool = False,
         result_action: ResultAction | None = None,
+        error_console: "Console | None" = None,
         **kwargs,
     ) -> None:
         """Create a blocking, interactive shell.
 
         All registered commands can be executed in the shell.
+        Ctrl-C clears a partially typed line; on an empty line it exits the 
shell.
+        Ctrl-C during a command returns to the prompt unless
+        :attr:`App.suppress_keyboard_interrupt` is :obj:`False`.
 
         Parameters
         ----------
@@ -2694,6 +2715,8 @@
             Defaults to ``"print_non_int_return_int_as_exit_code"`` which 
prints non-int results
             and returns int/bool as exit codes without calling sys.exit.
             If :obj:`None`, inherits from :attr:`App.result_action`.
+        error_console: Console | None
+            Rich Console to use for error messages and tracebacks. If 
:obj:`None`, uses :attr:`App.error_console`.
         `**kwargs`
             Get passed along to :meth:`parse_args`.
         """
@@ -2721,31 +2744,59 @@
             overrides["result_action"] = result_action
         if console is not None:
             overrides["_console"] = console
+        if error_console is not None:
+            overrides["_error_console"] = error_console
+        overrides["exit_on_error"] = exit_on_error
+
+        # libedit (macOS) keeps reporting the previous line from 
``get_line_buffer`` until
+        # new text is typed, so an interrupted buffer equal to the last seen 
line means the
+        # line was actually empty. GNU readline reports "" directly.
+        previous_line = ""
+        with self.app_stack([], overrides):
+            while True:
+                try:
+                    user_input = input(prompt)
+                except EOFError:  # pragma: no cover
+                    break
+                except KeyboardInterrupt:
+                    print()
+                    # typeshed guards ``get_line_buffer`` behind 
``sys.platform != "win32"``;
+                    # ``readline`` is ``None`` on Windows anyway, so this 
branch never runs there.
+                    line_buffer = readline.get_line_buffer() if readline else 
""  # pyright: ignore[reportAttributeAccessIssue]
+                    if line_buffer in ("", previous_line):
+                        break
+                    previous_line = line_buffer
+                    continue
+                previous_line = user_input + "\n"
 
-        while True:
-            try:
-                user_input = input(prompt)
-            except EOFError:  # pragma: no cover
-                break
-
-            tokens = normalize_tokens(user_input)
-            if not tokens:
-                continue
-            if tokens[0] in quit:
-                break
+                try:
+                    tokens = self._normalize_tokens(user_input)
+                except CycloptsError:
+                    # ``_normalize_tokens`` already reported the error 
(respecting
+                    # ``exit_on_error``); keep the shell running.
+                    continue
+                if not tokens:
+                    continue
+                if tokens[0] in quit:
+                    break
 
-            try:
-                with self.app_stack(tokens, overrides):
-                    command, bound, ignored = self.parse_args(
-                        tokens, console=console, exit_on_error=exit_on_error, 
**kwargs
-                    )
-                    result = dispatcher(command, bound, ignored)
-                    self._handle_result_action(result, 
fallback="print_non_int_return_int_as_exit_code")
-            except CycloptsError:
-                # Upstream ``parse_args`` already printed the error
-                pass
-            except Exception:
-                print(traceback.format_exc())
+                # Keep the exception handlers inside the token ``app_stack`` 
context so that
+                # context-sensitive settings (e.g. ``error_console``) resolve 
from the invoked
+                # subcommand rather than the root app.
+                with self.app_stack(tokens):
+                    try:
+                        command, bound, ignored = self.parse_args(tokens, 
**kwargs)
+                        result = dispatcher(command, bound, ignored)
+                        self._handle_result_action(result, 
fallback="print_non_int_return_int_as_exit_code")
+                    except CycloptsError:
+                        # Upstream ``parse_args`` already printed the error
+                        pass
+                    except KeyboardInterrupt:
+                        if not self.suppress_keyboard_interrupt:
+                            raise
+                        print()
+                    except Exception:
+                        self.error_console.print(traceback.format_exc(), 
markup=False, highlight=False, soft_wrap=True)
 
     def _handle_result_action(self, result: Any, fallback: ResultAction = 
"print_non_int_sys_exit") -> Any:
         """Handle command result based on result_action.
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/cyclopts-4.24.0/cyclopts/docs/html.py 
new/cyclopts-4.25.1/cyclopts/docs/html.py
--- old/cyclopts-4.24.0/cyclopts/docs/html.py   2020-02-02 01:00:00.000000000 
+0100
+++ new/cyclopts-4.25.1/cyclopts/docs/html.py   2020-02-02 01:00:00.000000000 
+0100
@@ -388,7 +388,6 @@
     if command_chain is None:
         command_chain = []
 
-    # Build the main documentation
     lines = []
 
     # Only add the outer div for standalone documents or root level
@@ -603,7 +602,6 @@
     if standalone or not command_chain:
         lines.append("</div>")  # Close cli-documentation div
 
-    # Join all lines into body content
     body_content = "\n".join(lines)
 
     # If standalone, wrap in complete HTML document
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/cyclopts-4.24.0/cyclopts/docs/markdown.py 
new/cyclopts-4.25.1/cyclopts/docs/markdown.py
--- old/cyclopts-4.24.0/cyclopts/docs/markdown.py       2020-02-02 
01:00:00.000000000 +0100
+++ new/cyclopts-4.25.1/cyclopts/docs/markdown.py       2020-02-02 
01:00:00.000000000 +0100
@@ -391,7 +391,6 @@
     """
     from cyclopts.help.formatters.markdown import MarkdownFormatter
 
-    # Build the main documentation
     lines = []
 
     if command_chain is None:
@@ -451,7 +450,6 @@
         # Build a mapping of command names to App objects for filtering
         command_map = _build_command_map(app, include_hidden=True)
 
-        # Create formatter
         formatter = MarkdownFormatter(
             heading_level=heading_level + 1,
             include_hidden=include_hidden,
@@ -771,7 +769,6 @@
                         lines.append(nested_docs)
                         lines.append("")
 
-    # Join all lines into final document
     doc = "\n".join(lines).rstrip() + "\n"
 
     # Normalize multiple consecutive blank lines to a single blank line
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/cyclopts-4.24.0/cyclopts/docs/rst.py 
new/cyclopts-4.25.1/cyclopts/docs/rst.py
--- old/cyclopts-4.24.0/cyclopts/docs/rst.py    2020-02-02 01:00:00.000000000 
+0100
+++ new/cyclopts-4.25.1/cyclopts/docs/rst.py    2020-02-02 01:00:00.000000000 
+0100
@@ -322,10 +322,13 @@
     if not skip_preamble and should_show_usage(app):
         # Generate usage line - only if we're documenting a specific command.
         # When no_root_title is set at the root (e.g., in Sphinx contexts), the
-        # root Usage: line is intentionally suppressed; usage_name still 
applies
-        # to every subcommand usage block below.
-        if not (no_root_title and not command_chain):
-            # Extract usage from app
+        # root Usage: line is normally suppressed; usage_name still applies to
+        # every subcommand usage block below. Exception: a title-less root that
+        # has its own ``default_command`` still needs its Usage: line, since 
the
+        # default command's invocation (e.g. ``myapp [ARGS]``) is documented
+        # nowhere else. See #923.
+        suppressed_root = no_root_title and not command_chain
+        if not suppressed_root or app.default_command is not None:
             usage = extract_usage(app)
             usage_text = None
             if usage:
@@ -385,7 +388,6 @@
     # Build a mapping of command names to App objects for filtering
     command_map = _build_command_map(app, include_hidden=True)
 
-    # Create formatter for help panels
     formatter = RstFormatter(heading_level=heading_level + 1, 
include_hidden=include_hidden)
 
     # Render panels as-is without categorization
@@ -394,8 +396,15 @@
         if not include_hidden and group and not group.show:
             continue
 
-        # Skip if no_root_title and we're at root
-        if no_root_title and not command_chain:
+        # At a title-less root (e.g. the Sphinx ``.. cyclopts::`` directive), 
the
+        # command list is emitted as its own recursive sections below, so skip 
the
+        # root command panel here. The root *parameter* panel, however, holds 
the
+        # root ``default_command``'s own arguments -- which are documented 
nowhere
+        # else -- so it must still render. Skipping it is what made a
+        # ``@app.default`` app's parameters vanish from Sphinx output. See 
#923.
+        # ``skip_preamble`` documents a filtered subcommand on its own, so the 
root
+        # parameter panel is skipped along with the root usage/description.
+        if no_root_title and not command_chain and (panel.format == "command" 
or skip_preamble):
             continue
 
         # Render command panels as grouped command lists
@@ -430,6 +439,19 @@
 
         # Render parameter panels as-is
         elif panel.format == "parameter":
+            # A "parameter" panel is not guaranteed to hold only parameter
+            # entries: ``_assemble_help_panels`` merges command entries into a
+            # same-named parameter panel (upgrading its format to "parameter").
+            # At the title-less root those command entries would be rendered as
+            # bogus parameters, duplicated by the recursive sections below, and
+            # would bypass ``commands_filter``/``exclude_commands``. The 
commands
+            # are documented by the recursive sections, so drop them here. See 
#924.
+            if no_root_title and not command_chain:
+                param_entries = [e for e in panel.entries if not any(name in 
command_map for name in e.names)]
+                if not param_entries:
+                    continue  # Nothing left once commands are stripped
+                panel = panel.copy(entries=param_entries)
+
             # Render content first to check if there's anything
             formatter.reset()
             panel_copy = panel.copy(title="")
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/cyclopts-4.24.0/cyclopts/exceptions.py 
new/cyclopts-4.25.1/cyclopts/exceptions.py
--- old/cyclopts-4.24.0/cyclopts/exceptions.py  2020-02-02 01:00:00.000000000 
+0100
+++ new/cyclopts-4.25.1/cyclopts/exceptions.py  2020-02-02 01:00:00.000000000 
+0100
@@ -47,6 +47,7 @@
     "RequiresEqualsError",
     "UnknownOptionError",
     "UnusedCliTokensError",
+    "TokenizationError",
     "ValidationError",
     "CombinedShortOptionError",
     "STYLE_OFFENDING_VALUE",
@@ -299,6 +300,15 @@
             yield ".", ""
 
         if keyword := self.token.keyword or self.token.value:
+            # A short cluster reaches here whole only when its first char is 
unknown
+            # (a typo'd ``-avb`` -> ``-xvb``, #932): name that char instead of 
a fuzzy
+            # ``Did you mean`` on a later flag.
+            if is_option_like(keyword) and not keyword.startswith("--") and 
len(keyword) > 2:
+                yield " ", ""
+                yield keyword[:2], STYLE_OFFENDING_VALUE
+                yield " is not a recognized option.", ""
+                return
+
             import difflib
 
             candidates = list(chain.from_iterable(x.names for x in 
self.argument_collection if x.parse))
@@ -510,6 +520,11 @@
 
 
 @define(kw_only=True)
+class TokenizationError(CycloptsError):
+    """A string of CLI tokens could not be split (e.g. unbalanced quotes)."""
+
+
+@define(kw_only=True)
 class UnusedCliTokensError(CycloptsError):
     """Not all CLI tokens were used as expected."""
 
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/cyclopts-4.24.0/cyclopts/ext/mkdocs.py 
new/cyclopts-4.25.1/cyclopts/ext/mkdocs.py
--- old/cyclopts-4.24.0/cyclopts/ext/mkdocs.py  2020-02-02 01:00:00.000000000 
+0100
+++ new/cyclopts-4.25.1/cyclopts/ext/mkdocs.py  2020-02-02 01:00:00.000000000 
+0100
@@ -215,7 +215,6 @@
         except Exception as e:
             raise PluginError(f"Error processing ::: cyclopts directive: {e}") 
from e
 
-    # Replace all directives in the markdown
     processed = DIRECTIVE_PATTERN.sub(replace_directive, markdown)
     return processed
 
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/cyclopts-4.24.0/cyclopts/ext/sphinx.py 
new/cyclopts-4.25.1/cyclopts/ext/sphinx.py
--- old/cyclopts-4.24.0/cyclopts/ext/sphinx.py  2020-02-02 01:00:00.000000000 
+0100
+++ new/cyclopts-4.25.1/cyclopts/ext/sphinx.py  2020-02-02 01:00:00.000000000 
+0100
@@ -298,7 +298,6 @@
                 if lines[i].strip() == "::":
                     break
 
-                # Check if this is a blank line
                 if not lines[i].strip():
                     # Include the blank line and continue to see if there's 
more content
                     content_lines.append(lines[i])
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/cyclopts-4.24.0/cyclopts/help/formatters/default.py 
new/cyclopts-4.25.1/cyclopts/help/formatters/default.py
--- old/cyclopts-4.24.0/cyclopts/help/formatters/default.py     2020-02-02 
01:00:00.000000000 +0100
+++ new/cyclopts-4.25.1/cyclopts/help/formatters/default.py     2020-02-02 
01:00:00.000000000 +0100
@@ -218,7 +218,6 @@
             # It's a column builder
             columns = columns(console, options, help_panel.entries)
 
-        # Build table with columns and entries
         table = table_spec.build(columns, help_panel.entries)
 
         # Build the panel
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/cyclopts-4.24.0/cyclopts/help/formatters/html.py 
new/cyclopts-4.25.1/cyclopts/help/formatters/html.py
--- old/cyclopts-4.24.0/cyclopts/help/formatters/html.py        2020-02-02 
01:00:00.000000000 +0100
+++ new/cyclopts-4.25.1/cyclopts/help/formatters/html.py        2020-02-02 
01:00:00.000000000 +0100
@@ -74,21 +74,17 @@
         if not panel.entries:
             return
 
-        # Write panel as a section
         self._output.write('<section class="help-panel">\n')
 
-        # Write panel title as heading
         if panel.title:
             title_text = escape_html(extract_text(panel.title, console))
             self._output.write(f'<h{self.heading_level} 
class="panel-title">{title_text}</h{self.heading_level}>\n')
 
-        # Write panel description if present
         if panel.description:
             desc_text = escape_html(extract_text(panel.description, console))
             if desc_text:
                 self._output.write(f'<div 
class="panel-description">{desc_text}</div>\n')
 
-        # Format entries based on panel type
         if panel.format == "command":
             self._format_command_panel(panel.entries, console)
         elif panel.format == "parameter":
@@ -171,7 +167,6 @@
             # Start list item (no type display)
             self._output.write(f"<li><strong>{name_html}</strong>")
 
-            # Add description
             desc = extract_text(entry.description, console)
             if desc:
                 self._output.write(f": {escape_html(desc)}")
@@ -179,7 +174,6 @@
             # Add metadata as styled badges
             metadata_items = []
 
-            # Add required marker
             if entry.required:
                 metadata_items.append('<span class="metadata-item 
metadata-required">Required</span>')
 
@@ -204,7 +198,6 @@
                     f'<span class="metadata-item metadata-env"><span 
class="metadata-label">env:</span> {env_html}</span>'
                 )
 
-            # Write metadata
             if metadata_items:
                 self._output.write(f'<span 
class="parameter-metadata">{"".join(metadata_items)}</span>')
 
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/cyclopts-4.24.0/cyclopts/help/formatters/markdown.py 
new/cyclopts-4.25.1/cyclopts/help/formatters/markdown.py
--- old/cyclopts-4.24.0/cyclopts/help/formatters/markdown.py    2020-02-02 
01:00:00.000000000 +0100
+++ new/cyclopts-4.25.1/cyclopts/help/formatters/markdown.py    2020-02-02 
01:00:00.000000000 +0100
@@ -70,19 +70,16 @@
         if not panel.entries:
             return
 
-        # Write panel title as heading
         if panel.title:
             title_text = extract_text(panel.title, console)
             heading = "#" * self.heading_level
             self._output.write(f"{heading} {title_text}\n\n")
 
-        # Write panel description if present
         if panel.description:
             desc_text = extract_text(panel.description, console)
             if desc_text:
                 self._output.write(f"{desc_text}\n\n")
 
-        # Format entries based on panel type
         if panel.format == "command":
             self._format_command_panel(panel.entries, console)
         elif panel.format == "parameter":
@@ -175,7 +172,7 @@
                     in_numbered_list = False
 
                     for line in lines[1:]:
-                        if not line.strip():  # Blank line
+                        if not line.strip():
                             self._output.write("\n")
                         else:
                             stripped = line.lstrip()
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/cyclopts-4.24.0/cyclopts/help/formatters/plain.py 
new/cyclopts-4.25.1/cyclopts/help/formatters/plain.py
--- old/cyclopts-4.24.0/cyclopts/help/formatters/plain.py       2020-02-02 
01:00:00.000000000 +0100
+++ new/cyclopts-4.25.1/cyclopts/help/formatters/plain.py       2020-02-02 
01:00:00.000000000 +0100
@@ -107,7 +107,6 @@
         if panel.title:
             self._print_plain(console, f"{panel.title}:")
 
-        # Print each entry in the panel
         for entry in panel.entries:
             desc = _to_plain_text(entry.description, console)
 
@@ -264,7 +263,6 @@
                     # Additional names on separate lines
                     self._print_plain(console, textwrap.indent(name, 
self.indent))
         elif shorts:
-            # Only short names
             shorts_str = " ".join(shorts)
             if desc:
                 text = f"{shorts_str}: {desc}"
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/cyclopts-4.24.0/cyclopts/help/formatters/rst.py 
new/cyclopts-4.25.1/cyclopts/help/formatters/rst.py
--- old/cyclopts-4.24.0/cyclopts/help/formatters/rst.py 2020-02-02 
01:00:00.000000000 +0100
+++ new/cyclopts-4.25.1/cyclopts/help/formatters/rst.py 2020-02-02 
01:00:00.000000000 +0100
@@ -66,19 +66,16 @@
         if not panel.entries:
             return
 
-        # Write panel title as heading
         if panel.title:
             title_text = extract_text(panel.title, console)
             header = "\n".join(make_rst_section_header(title_text, 
self.heading_level))
             self._output.write(f"{header}\n\n")
 
-        # Write panel description if present
         if panel.description:
             desc_text = extract_text(panel.description, console)
             if desc_text:
                 self._output.write(f"{desc_text}\n\n")
 
-        # Format entries based on panel type
         if panel.format == "command":
             self._format_command_panel(panel.entries, console)
         elif panel.format == "parameter":
@@ -150,7 +147,6 @@
                 # Build description with metadata
                 desc_parts = []
 
-                # Add main description
                 # Check if the description has RST markup to preserve
                 preserve_rst_markup = (
                     hasattr(entry.description, "primary_renderable")
@@ -161,7 +157,6 @@
                 if desc:
                     desc_parts.append(desc)
 
-                # Add metadata
                 metadata = []
 
                 if is_positional and entry.required:
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/cyclopts-4.24.0/cyclopts/help/specs.py 
new/cyclopts-4.25.1/cyclopts/help/specs.py
--- old/cyclopts-4.24.0/cyclopts/help/specs.py  2020-02-02 01:00:00.000000000 
+0100
+++ new/cyclopts-4.25.1/cyclopts/help/specs.py  2020-02-02 01:00:00.000000000 
+0100
@@ -175,17 +175,14 @@
         if description is None:
             description = InlineText(Text())
         elif not isinstance(description, InlineText):
-            # Convert to InlineText if it isn't already
             if hasattr(entry.description, "__rich_console__"):
                 # It's already a Rich renderable, wrap it
                 description = InlineText(description)
             else:
-                # Convert to Text first, then wrap in InlineText
                 from rich.text import Text
 
                 description = InlineText(Text(str(description)))
 
-        # Collect metadata items
         metadata_items = []
 
         if entry.choices:
@@ -208,18 +205,14 @@
             from rich.console import Group as RichGroup
             from rich.text import Text
 
-            # Create a list of renderables to group
             renderables = []
 
-            # Add the original description first
             if description.primary_renderable:
                 renderables.append(description.primary_renderable)
 
-            # Add each metadata item without indentation
             for item in metadata_items:
                 renderables.append(item)
 
-            # Return a Rich Group that stacks these vertically
             return RichGroup(*renderables) if renderables else Text()
         else:
             # Original inline behavior
@@ -676,7 +669,6 @@
 
         table = Table(**opts)
 
-        # Add columns
         for column in columns:
             col_opts = {
                 "header": column.header,
@@ -697,7 +689,6 @@
                 col_opts["highlight"] = column.highlight
             table.add_column(**col_opts)
 
-        # Add entries
         for e in entries:
             cells = [col._render_cell(e) for col in columns]
             table.add_row(*cells)
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/cyclopts-4.24.0/cyclopts/loader.py 
new/cyclopts-4.25.1/cyclopts/loader.py
--- old/cyclopts-4.24.0/cyclopts/loader.py      2020-02-02 01:00:00.000000000 
+0100
+++ new/cyclopts-4.25.1/cyclopts/loader.py      2020-02-02 01:00:00.000000000 
+0100
@@ -62,7 +62,6 @@
     app_name = None
     script_str = str(script)
     if ":" in script_str:
-        # Split on the last colon
         script_path_str, potential_app_name = script_str.rsplit(":", 1)
         # Only treat it as an app name if it looks like a Python identifier
         # (no path separators), otherwise it may be part of a Windows path 
like C:\path\to\file.py
@@ -113,7 +112,7 @@
         # Heuristic: find App objects in the module's global namespace
         app_objects = []
         for name in dir(module):
-            if not name.startswith("_"):  # Skip private/protected names
+            if not name.startswith("_"):
                 obj = getattr(module, name)
                 if isinstance(obj, App):
                     app_objects.append((name, obj))
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/cyclopts-4.24.0/cyclopts/types.py 
new/cyclopts-4.25.1/cyclopts/types.py
--- old/cyclopts-4.24.0/cyclopts/types.py       2020-02-02 01:00:00.000000000 
+0100
+++ new/cyclopts-4.25.1/cyclopts/types.py       2020-02-02 01:00:00.000000000 
+0100
@@ -206,7 +206,6 @@
 ##########
 # Number #
 ##########
-# foo
 PositiveFloat = Annotated[float, Parameter(validator=validators.Number(gt=0))]
 "A float that **must** be ``>0``."
 NonNegativeFloat = Annotated[float, 
Parameter(validator=validators.Number(gte=0))]
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/cyclopts-4.24.0/pyproject.toml 
new/cyclopts-4.25.1/pyproject.toml
--- old/cyclopts-4.24.0/pyproject.toml  2020-02-02 01:00:00.000000000 +0100
+++ new/cyclopts-4.25.1/pyproject.toml  2020-02-02 01:00:00.000000000 +0100
@@ -53,6 +53,7 @@
     "pytest>=8.2.0",
     "pytest-cov>=3.0.0",
     "pytest-mock>=3.7.0",
+    "pytest-timeout>=2.3.0",
     "pydantic>=2.11.2,<3.0.0",
     "syrupy>=4.0.0",
     "toml>=0.10.2,<1.0.0",
@@ -225,6 +226,7 @@
     "slow: marks tests as slow (deselected by default, use --run-slow to 
include)",
 ]
 addopts = "-m 'not slow'"
+timeout = 60
 
 [tool.codespell]
 skip = 'uv.lock,tests/test_help.py'

Reply via email to