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'