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-06-30 15:13:14
++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Comparing /work/SRC/openSUSE:Factory/python-cyclopts (Old)
 and      /work/SRC/openSUSE:Factory/.python-cyclopts.new.11887 (New)
++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++

Package is "python-cyclopts"

Tue Jun 30 15:13:14 2026 rev:2 rq:1362571 version:4.20.0

Changes:
--------
--- /work/SRC/openSUSE:Factory/python-cyclopts/python-cyclopts.changes  
2026-06-19 17:39:19.955142334 +0200
+++ 
/work/SRC/openSUSE:Factory/.python-cyclopts.new.11887/python-cyclopts.changes   
    2026-06-30 15:13:35.984379260 +0200
@@ -1,0 +2,13 @@
+Tue Jun 30 05:50:23 UTC 2026 - Martin Pluskal <[email protected]>
+
+- Update to 4.20.0:
+  * Add "cyclopts tree" command and App.command_tree() to print
+    the command hierarchy
+  * Add Parameter.short_alias to automatically generate short
+    aliases for parameters
+  * Add ArgumentCollection.filter_by(missing=...) for required or
+    conditionally-required fields
+  * Fix a leading-hyphen parsing regression
+  * Propagate consume_multiple to nested structured-type fields
+
+-------------------------------------------------------------------

Old:
----
  cyclopts-4.18.0.tar.gz

New:
----
  cyclopts-4.20.0.tar.gz

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

Other differences:
------------------
++++++ python-cyclopts.spec ++++++
--- /var/tmp/diff_new_pack.MtLaBn/_old  2026-06-30 15:13:36.496396597 +0200
+++ /var/tmp/diff_new_pack.MtLaBn/_new  2026-06-30 15:13:36.496396597 +0200
@@ -17,7 +17,7 @@
 
 
 Name:           python-cyclopts
-Version:        4.18.0
+Version:        4.20.0
 Release:        0
 Summary:        Intuitive, easy CLIs based on python type hints
 License:        Apache-2.0
@@ -33,17 +33,17 @@
 BuildRequires:  %{python_module wheel}
 BuildRequires:  fdupes
 BuildRequires:  python-rpm-macros
-Requires(post): update-alternatives
-Requires(postun): update-alternatives
 Requires:       python-attrs >= 23.1.0
 Requires:       python-docstring-parser >= 0.15
 Requires:       python-rich >= 13.6.0
 Requires:       python-rich-rst >= 1.3.1
+Requires(post): update-alternatives
+Requires(postun): update-alternatives
+BuildArch:      noarch
 %if %{python_version_nodots} < 311
 Requires:       python-tomli >= 2.0.0
 Requires:       python-typing_extensions >= 4.8.0
 %endif
-BuildArch:      noarch
 %python_subpackages
 
 %description

++++++ cyclopts-4.18.0.tar.gz -> cyclopts-4.20.0.tar.gz ++++++
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/cyclopts-4.18.0/PKG-INFO new/cyclopts-4.20.0/PKG-INFO
--- old/cyclopts-4.18.0/PKG-INFO        2020-02-02 01:00:00.000000000 +0100
+++ new/cyclopts-4.20.0/PKG-INFO        2020-02-02 01:00:00.000000000 +0100
@@ -1,6 +1,6 @@
 Metadata-Version: 2.4
 Name: cyclopts
-Version: 4.18.0
+Version: 4.20.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-4.18.0/cyclopts/_version.py 
new/cyclopts-4.20.0/cyclopts/_version.py
--- old/cyclopts-4.18.0/cyclopts/_version.py    2020-02-02 01:00:00.000000000 
+0100
+++ new/cyclopts-4.20.0/cyclopts/_version.py    2020-02-02 01:00:00.000000000 
+0100
@@ -18,7 +18,7 @@
 commit_id: str | None
 __commit_id__: str | None
 
-__version__ = version = '4.18.0'
-__version_tuple__ = version_tuple = (4, 18, 0)
+__version__ = version = '4.20.0'
+__version_tuple__ = version_tuple = (4, 20, 0)
 
 __commit_id__ = commit_id = None
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/cyclopts-4.18.0/cyclopts/argument/_argument.py 
new/cyclopts-4.20.0/cyclopts/argument/_argument.py
--- old/cyclopts-4.18.0/cyclopts/argument/_argument.py  2020-02-02 
01:00:00.000000000 +0100
+++ new/cyclopts-4.20.0/cyclopts/argument/_argument.py  2020-02-02 
01:00:00.000000000 +0100
@@ -22,6 +22,7 @@
     ITERABLE_TYPES,
     contains_hint,
     get_annotated_discriminator,
+    get_hint_name,
     is_attrs,
     is_dataclass,
     is_enum_flag,
@@ -166,6 +167,15 @@
 
     _enum_flag_type: Any | None = field(default=None, init=False, repr=False)
 
+    _union_branches: "list[tuple[Any, dict[str, FieldInfo]]]" = 
field(factory=list, init=False, repr=False)
+    """Per-branch ``(member_type, field_infos)`` when :attr:`hint` is a 
``Union`` of 2+ keyword-accepting types.
+
+    Empty for non-unions and single-composite optionals (e.g. ``Foo | None``). 
Drives
+    branch-aware required-field detection so that supplying one ``Union`` 
member's fields
+    doesn't demand another member's required fields, and selects the member to 
instantiate.
+    See :meth:`_active_branch_required_keys` and :meth:`_resolve_union_member`.
+    """
+
     def __attrs_post_init__(self):
         from cyclopts.argument._collection import ArgumentCollection
 
@@ -268,6 +278,18 @@
                 else:
                     self._update_lookup({field_info.name: field_info})
 
+        if self._accepts_keywords and len(hints) > 1:
+            # Genuine multi-branch ``Union`` of keyword-accepting types (not 
``Foo | None``,
+            # whose only composite branch collapses to a single dict). Record 
each branch's
+            # type and fields so requiredness can be evaluated per-branch at 
conversion time.
+            branches = [(member, fis) for member in hints if (fis := 
get_field_infos(member))]
+            if len(branches) > 1:
+                self._union_branches = branches
+                # The generic checker would introspect the ``typing.Union`` 
alias itself
+                # (yielding phantom fields like ``origin``); branch-aware 
gating in
+                # :meth:`_convert` supersedes it.
+                self._missing_keys_checker = None
+
     def _update_lookup(self, field_infos: dict[str, FieldInfo]):
         from typing import Literal
 
@@ -782,11 +804,16 @@
                 if positional_tokens:
                     return safe_converter(self.hint, tuple(positional_tokens))
 
+            supplied_keys = {child.keys[-1] for child in self.children if 
child.has_tokens}
+            active_required = self._active_branch_required_keys(supplied_keys)
             for child in self.children:
                 assert len(child.keys) == (len(self.keys) + 1)
+                # For multi-branch unions, requiredness is decided per active 
branch rather
+                # than by the (cross-branch, over-counting) static 
``child.required``.
+                child_required = child.keys[-1] in active_required if 
active_required is not None else child.required
                 if child.has_tokens:
                     data[child.keys[-1]] = 
child.convert_and_validate(converter=converter)
-                elif child.required:
+                elif child_required:
                     obj = data
                     for k in child.keys:
                         try:
@@ -794,6 +821,13 @@
                         except Exception:
                             raise MissingArgumentError(argument=child) from 
None
                     child._marked = True
+                elif active_required is not None:
+                    # Inactive-branch leaf of a multi-branch union: the parent 
owns the whole
+                    # subtree, so mark it (and descendants) handled to stop 
the collection loop
+                    # from converting it standalone and raising on its static 
``required``.
+                    child._marked = True
+                    for descendant in child.children_recursive:
+                        descendant._marked = True
 
             self._run_missing_keys_checker(data)
 
@@ -802,7 +836,20 @@
                 if not out:
                     out = UNSET
             elif data:
-                out = instantiate_from_dict(self.hint, data)
+                target_hint = self.hint
+                if self._union_branches:
+                    # ``instantiate_from_dict`` cannot build a bare ``Union``; 
pick the branch
+                    # whose fields accept the supplied data.
+                    member = self._resolve_union_member(set(data))
+                    if member is None:
+                        # Supplied fields span multiple branches / match no 
single one.
+                        raise CoercionError(
+                            msg=f"Cannot determine which 
{get_hint_name(self.hint)} variant the supplied fields belong to.",
+                            argument=self,
+                            target_type=self.hint,
+                        )
+                    target_hint = member
+                out = instantiate_from_dict(target_hint, data)
             elif self.required:
                 raise MissingArgumentError(argument=self)  # pragma: no cover
             else:
@@ -1170,17 +1217,112 @@
                 out[keys[0]] = token.value if token.implicit_value is UNSET 
else token.implicit_value
         return out
 
+    def _resolve_missing_keys(self, data) -> "list[tuple[tuple[str, ...], 
Argument | None]]":
+        """Map each required-but-absent key reported by the checker to its 
child ``Argument``.
+
+        Non-raising core shared by :meth:`_run_missing_keys_checker` (the 
conversion-time
+        error path) and :meth:`_missing_children` (the read-only query path). 
The checker
+        only inspects ``set(data)`` — the keys — so the values in ``data`` are 
irrelevant.
+
+        Returns a list of ``(full_keys, argument)`` pairs in checker order; 
``argument`` is
+        ``None`` for a required key that maps to no Cyclopts-accessible child.
+        """
+        if not self._missing_keys_checker:
+            return []
+        out = []
+        for key in self._missing_keys_checker(self, data):
+            keys = self.keys + (key,)
+            matched = self.children.filter_by(keys_prefix=keys)
+            out.append((keys, matched[0] if matched else None))
+        return out
+
+    def _union_candidate_branches(self, supplied_keys: "set[str]") -> 
"list[tuple[Any, set[str]]]":
+        """Branches that can fully account for ``supplied_keys`` (i.e. 
``supplied ⊆ branch``).
+
+        These are the ``Union`` members the user could be completing; a branch 
that doesn't
+        contain every supplied field can't be the intended one. Yields 
``(member, required_keys)``
+        — the only branch information either caller needs. Returns ``[]`` when 
the supplied
+        fields span multiple branches (over-supplied / ambiguous input) or 
when this is not a
+        multi-branch ``Union``. In declaration order.
+        """
+        return [
+            (member, {k for k, v in fis.items() if v.required})
+            for member, fis in self._union_branches
+            if supplied_keys <= set(fis)
+        ]
+
+    def _active_branch_required_keys(self, supplied_keys: "set[str]") -> 
"set[str] | None":
+        """Required child keys still owed by the chosen branch of a 
multi-branch ``Union``.
+
+        Picks among the candidate branches (see 
:meth:`_union_candidate_branches`): if any is
+        already fully satisfied, nothing is owed (``set()``); otherwise the 
candidate closest
+        to completion (fewest missing required fields, ties broken by 
declaration order) guides
+        which fields are still required. This convergent guidance means the 
prompt loop fills
+        the same branch that :meth:`_resolve_union_member` later instantiates. 
Supplying one
+        ``Union`` member's fields never demands a sibling member's
+        required fields. Returns ``None`` when :attr:`hint` is not a 
multi-branch ``Union``
+        (caller falls back to the static :attr:`Argument.required` per child).
+        """
+        if not self._union_branches:
+            return None
+        if not supplied_keys:
+            # Nothing supplied: the user hasn't committed to a branch, so 
don't demand any
+            # particular one. Total omission is handled by the composite-level 
required check.
+            return set()
+        candidates = self._union_candidate_branches(supplied_keys)
+        if not candidates:  # ambiguous/over-supplied: owe nothing, let 
instantiation error cleanly
+            return set()
+        required_per_candidate = [required for _, required in candidates]
+        if any(required <= supplied_keys for required in 
required_per_candidate):
+            return set()  # a complete branch exists
+        return min(required_per_candidate, key=lambda required: len(required - 
supplied_keys))
+
+    def _resolve_union_member(self, data_keys: "set[str]"):
+        """The ``Union`` member to instantiate given the converted 
``data_keys``.
+
+        Picks the first candidate branch (``data ⊆ branch``) whose required 
fields are all
+        present, since :func:`instantiate_from_dict` cannot instantiate a bare 
``Union``.
+        Returns ``None`` if no branch is satisfied (caller raises a clean 
error).
+        """
+        for member, required in self._union_candidate_branches(data_keys):
+            if required <= data_keys:
+                return member
+        return None
+
+    def _missing_children(self) -> "list[Argument]":
+        """Direct child arguments the checker reports as required-and-absent 
(non-raising).
+
+        Builds the provided-key set from token presence rather than converted 
values, so it
+        can be called before/without conversion. When no child currently has a 
token (a
+        fully-omitted composite) the checker returns every required child. For 
multi-branch
+        ``Union`` composites, requiredness is branch-aware (see
+        :meth:`_active_branch_required_keys`).
+
+        Special case: a *required* multi-branch ``Union`` with no supplied 
fields hasn't
+        committed to a branch, so :meth:`_active_branch_required_keys` owes 
nothing — which
+        would leave an interactive prompt loop blind to a value it must 
collect. Default to
+        the first branch's required fields so the loop converges on (and 
instantiates) it,
+        matching the declaration-order preference of 
:meth:`_resolve_union_member`. This
+        read-only query path only; conversion still errors on the composite 
itself.
+        """
+        supplied_keys = {child.keys[-1] for child in self.children if 
child.has_tokens}
+        if self._union_branches and self.required and not supplied_keys:
+            first_branch_required = {k for k, v in 
self._union_branches[0][1].items() if v.required}
+            return [
+                child for child in self.children if not child.has_tokens and 
child.keys[-1] in first_branch_required
+            ]
+        active_required = self._active_branch_required_keys(supplied_keys)
+        if active_required is not None:
+            return [child for child in self.children if not child.has_tokens 
and child.keys[-1] in active_required]
+        data = dict.fromkeys(supplied_keys)
+        return [argument for _, argument in self._resolve_missing_keys(data) 
if argument is not None]
+
     def _run_missing_keys_checker(self, data):
         if not self._missing_keys_checker or (not self.required and not data):
             return
-        if not (missing_keys := self._missing_keys_checker(self, data)):
-            return
-        missing_key = missing_keys[0]
-        keys = self.keys + (missing_key,)
-        missing_arguments = self.children.filter_by(keys_prefix=keys)
-        if missing_arguments:
-            raise MissingArgumentError(argument=missing_arguments[0])
-        else:
+        for keys, argument in self._resolve_missing_keys(data):
+            if argument is not None:
+                raise MissingArgumentError(argument=argument)
             missing_description = self.field_info.names[0] + "->" + 
"->".join(keys)
             raise ValueError(
                 f'Required field "{missing_description}" is not accessible by 
Cyclopts; possibly due to conflicting POSITIONAL/KEYWORD requirements.'
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/cyclopts-4.18.0/cyclopts/argument/_collection.py 
new/cyclopts-4.20.0/cyclopts/argument/_collection.py
--- old/cyclopts-4.18.0/cyclopts/argument/_collection.py        2020-02-02 
01:00:00.000000000 +0100
+++ new/cyclopts-4.20.0/cyclopts/argument/_collection.py        2020-02-02 
01:00:00.000000000 +0100
@@ -26,6 +26,9 @@
     KIND_PARENT_CHILD_REASSIGNMENT,
     PARAMETER_SUBKEY_BLOCKER,
     extract_docstring_help,
+    generate_short_alias,
+    is_short_alias_eligible,
+    reserve_explicit_shorts,
     resolve_parameter_name,
     to_cli_option_name,
     walk_leaves,
@@ -240,6 +243,8 @@
         parse_docstring: bool = True,
         docstring_lookup: dict[tuple[str, ...], Parameter] | None = None,
         positional_index: int | None = None,
+        used_short_aliases: set[str] | None = None,
+        pending_short_aliases: list["Argument"] | None = None,
         _resolve_groups: bool = True,
     ):
         from cyclopts.parameter import get_parameters
@@ -365,6 +370,19 @@
 
         argument = Argument(field_info=field_info, parameter=cparam, 
keys=keys, hint=hint)
 
+        # Auto-generate a short alias (e.g. ``-e`` for ``--env``), appended as 
a standalone
+        # flag. Gated to root-namespace input-binding parameters so that 
promoted containers
+        # and dotted nested fields don't silently claim letters. Phase 1 
reserves explicit
+        # shorts from *every* argument (including nested fields, whose 
explicit shorts are
+        # global) and collects eligible arguments; the actual short is 
generated in phase 2
+        # (see ``_from_callable``) once every explicit short is known, so an 
earlier
+        # parameter's auto short can't shadow a later parameter's explicit one.
+        if used_short_aliases is not None:
+            reserve_explicit_shorts(argument, used_short_aliases)
+            if is_short_alias_eligible(argument, immediate_parameter):
+                assert pending_short_aliases is not None
+                pending_short_aliases.append(argument)
+
         if positional_index is not None:
             if not argument._accepts_keywords or argument._enum_flag_type:
                 argument.index = positional_index
@@ -412,6 +430,8 @@
                     parse_docstring=parse_docstring,
                     docstring_lookup=subkey_docstring_lookup,
                     positional_index=positional_index,
+                    used_short_aliases=used_short_aliases,
+                    pending_short_aliases=pending_short_aliases,
                     _resolve_groups=_resolve_groups,
                 )
                 if subkey_argument_collection:
@@ -435,6 +455,7 @@
         group_parameters: Group | None = None,
         parse_docstring: bool = True,
         _resolve_groups: bool = True,
+        reserved: Iterable[str] | None = None,
     ):
         out = cls()
 
@@ -458,6 +479,8 @@
 
         docstring_lookup = extract_docstring_help(func) if parse_docstring 
else {}
         positional_index = 0
+        used_short_aliases: set[str] = set(reserved or ())
+        pending_short_aliases: list[Argument] = []
         for field_info in signature_parameters(func).values():
             if parse_docstring:
                 subkey_docstring_lookup = {
@@ -474,6 +497,8 @@
                 group_arguments=group_arguments,
                 group_parameters=group_parameters,
                 positional_index=positional_index,
+                used_short_aliases=used_short_aliases,
+                pending_short_aliases=pending_short_aliases,
                 parse_docstring=parse_docstring,
                 docstring_lookup=subkey_docstring_lookup,
                 _resolve_groups=_resolve_groups,
@@ -484,6 +509,17 @@
                     positional_index += 1
             out.extend(iparam_argument_collection)
 
+        # Phase 2: now that every explicit short flag has been reserved, 
generate auto
+        # shorts in tree order (first-wins). Deferring to here ensures an 
explicit alias
+        # on a later parameter is never shadowed by an earlier parameter's 
auto short.
+        for argument in pending_short_aliases:
+            shorts = generate_short_alias(argument, used_short_aliases)
+            if shorts:
+                assert isinstance(argument.parameter.name, tuple)
+                argument.parameter = Parameter.combine(
+                    argument.parameter, Parameter(name=argument.parameter.name 
+ shorts)
+                )
+
         return out
 
     @property
@@ -506,6 +542,51 @@
     def _max_index(self) -> int | None:
         return max((x.index for x in self if x.index is not None), 
default=None)
 
+    def _missing(self) -> "ArgumentCollection":
+        """Leaf arguments still needing a value, given what's been parsed.
+
+        Tree-aware counterpart to the static :attr:`Argument.required`. 
Includes
+        conditionally-required fields of composites that have been *partially* 
supplied
+        (e.g. ``--end`` when only ``--start`` was given) by delegating to each 
composite's
+        own missing-keys checker — so it is correct for dataclasses, pydantic, 
attrs and
+        TypedDicts alike. A *required* composite that received no tokens 
contributes all of
+        its required leaves; an *optional* one contributes nothing.
+
+        Returns leaf arguments in declaration order. Read-only: nothing is 
converted.
+        """
+        cls = type(self)
+        out = cls()
+
+        def expand_required(argument: Argument) -> None:
+            # ``argument`` is known to be required and currently has no 
tokens. Append it
+            # (if it's a parseable leaf) or recurse into each of its required 
children.
+            # Requiredness is established by the caller/checker, *not* 
re-derived from the
+            # static ``argument.required`` — conditionally-required leaves 
have it ``False``.
+            if not argument.children:
+                if argument.parse:
+                    out.append(argument)
+                return
+            for child in argument._missing_children():  # empty data -> every 
required child
+                expand_required(child)
+
+        def walk(argument: Argument) -> None:
+            if argument.has_tokens:
+                if not argument.children:  # leaf already satisfied
+                    return
+                missing_ids = {id(a) for a in argument._missing_children()}  # 
activated composite
+                for child in argument.children:
+                    if child.has_tokens:
+                        walk(child)  # recurse into (possibly nested) partial 
fills
+                    elif id(child) in missing_ids:
+                        expand_required(child)
+            elif argument.required:  # fully-omitted required leaf or composite
+                expand_required(argument)
+            # optional, no tokens: contributes nothing
+
+        for argument in self._root_arguments:
+            walk(argument)
+        return out
+
     def filter_by(
         self,
         *,
@@ -514,6 +595,7 @@
         has_tree_tokens: bool | None = None,
         keys_prefix: tuple[str, ...] | None = None,
         kind: inspect._ParameterKind | None = None,
+        missing: bool | None = None,
         parse: bool | None = None,
         show: bool | None = None,
         value_set: bool | None = None,
@@ -532,6 +614,10 @@
             :class:`Argument` and/or it's children have parsed tokens.
         kind: inspect._ParameterKind | None
             The :attr:`~inspect.Parameter.kind` of the argument.
+        missing: bool | None
+            The leaf :class:`Argument` still needs a value given what's been 
parsed,
+            accounting for conditionally-required fields of partially-supplied 
composites.
+            See :meth:`_missing`. ``False`` selects the complement.
         parse: bool | None
             If the argument is intended to be parsed or not.
         show: bool | None
@@ -542,6 +628,9 @@
         ac = self.copy()
         cls = type(self)
 
+        if missing is not None:
+            missing_ids = {id(x) for x in self._missing()}
+            ac = cls(x for x in ac if not ((id(x) in missing_ids) ^ 
bool(missing)))
         if group is not None:
             ac = cls(x for x in ac if group in x.parameter.group)  # pyright: 
ignore
         if kind is not None:
@@ -549,7 +638,7 @@
         if has_tokens is not None:
             ac = cls(x for x in ac if not (bool(x.tokens) ^ bool(has_tokens)))
         if has_tree_tokens is not None:
-            ac = cls(x for x in ac if not (bool(x.tokens) ^ 
bool(has_tree_tokens)))
+            ac = cls(x for x in ac if not (x.has_tokens ^ 
bool(has_tree_tokens)))
         if keys_prefix is not None:
             ac = cls(x for x in ac if x.keys[: len(keys_prefix)] == 
keys_prefix)
         if show is not None:
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/cyclopts-4.18.0/cyclopts/argument/utils.py 
new/cyclopts-4.20.0/cyclopts/argument/utils.py
--- old/cyclopts-4.18.0/cyclopts/argument/utils.py      2020-02-02 
01:00:00.000000000 +0100
+++ new/cyclopts-4.20.0/cyclopts/argument/utils.py      2020-02-02 
01:00:00.000000000 +0100
@@ -1,7 +1,7 @@
 """Shared helper functions and constants for the argument package."""
 
 import sys
-from collections.abc import Callable, Iterator
+from collections.abc import Callable, Iterable, Iterator
 from contextlib import suppress
 from enum import Enum, Flag
 from functools import partial
@@ -40,7 +40,6 @@
     converter=None,  # pyright: ignore
     validator=None,
     accepts_keys=None,
-    consume_multiple=None,
     env_var=None,
 )
 
@@ -153,6 +152,146 @@
     return convert_enum_flag(enum_type, (k for k, v in data.items() if v), 
name_transform)
 
 
+def is_short_flag(flag: str) -> bool:
+    """Return :obj:`True` for a single-letter flag like ``-e`` (not ``--env`` 
or ``-`` alone)."""
+    return len(flag) == 2 and flag[0] == "-" and flag[1] != "-"
+
+
+def _is_root_namespace(names: str | Iterable[str] | None) -> bool:
+    """Return :obj:`True` if the parameter surfaces at the root CLI namespace.
+
+    A root-namespace parameter has an **undotted** long flag (e.g. ``--env``). 
This
+    covers genuine top-level command parameters as well as fields promoted to 
the root
+    via ``Parameter(name="*")`` or PEP 692 unpacking (``--name``), while 
excluding dotted
+    nested fields such as ``--user.name``.
+    """
+    if isinstance(names, str):  # Defensive: resolved names are always a tuple 
at this point.
+        names = (names,)
+    return any(n.startswith("--") and "." not in n for n in (names or ()))
+
+
+def reserve_explicit_shorts(argument: "Argument", used_short_aliases: 
set[str]) -> None:
+    """Phase 1a: reserve every explicitly-provided short flag into 
``used_short_aliases``.
+
+    Runs for *every* argument as the tree is built (independent of 
eligibility), so that
+    auto-generation (phase 2, deferred until the whole tree is known) avoids 
user-supplied
+    shorts regardless of parameter ordering. At this point no auto short has 
been appended
+    yet, so any single-letter flag present is necessarily user-provided.
+    """
+    cparam = argument.parameter
+    for flag in (*(cparam.name or ()), *(cparam.alias or ())):
+        if is_short_flag(flag):
+            used_short_aliases.add(flag)
+
+
+def is_short_alias_eligible(argument: "Argument", immediate_parameter: 
Parameter) -> bool:
+    """Phase 1b: pure predicate for whether this argument should receive an 
auto-generated short flag.
+
+    Auto shorts apply to opted-in (``short_alias``), **input-binding** 
parameters only
+    (scalars, dicts, enum flags) — never to promoted containers whose fields 
become child
+    options. They apply only to **root-namespace** parameters (an undotted 
long flag). A
+    field that stays namespaced (e.g. ``--user.name``) never gets one — 
``short_alias`` on
+    such a field is inert; flatten it to the root namespace via ``name="*"`` 
to expose it.
+    """
+    cparam = argument.parameter
+
+    if not cparam.short_alias:
+        return False
+
+    # An explicitly-provided alias or name suppresses auto-generation: the 
user has taken
+    # manual control of this parameter's flags, so Cyclopts only uses what 
they supplied.
+    # The checks deliberately differ:
+    #   - ``name`` uses ``immediate_parameter._provided_args`` (the user's own 
annotation),
+    #     because Cyclopts always re-injects a resolved ``name`` into 
``cparam`` — so
+    #     ``"name" in cparam._provided_args`` is always True and useless here.
+    #   - ``alias`` uses ``cparam.alias`` *truthiness* rather than 
``_provided_args``:
+    #     internal subkey combining (``PARAMETER_SUBKEY_BLOCKER``) injects 
``alias=None``,
+    #     which pollutes ``cparam._provided_args`` with a spurious 
``"alias"``. Truthiness
+    #     ignores that ``None`` while still catching a real alias from a global
+    #     ``App(default_parameter=Parameter(alias=...))``.
+    # (``name="*"`` flattening sets ``name`` on a container, excluded below 
anyway; its
+    # promoted children carry no explicit ``name`` and remain eligible.)
+    if "name" in immediate_parameter._provided_args or "alias" in 
immediate_parameter._provided_args or cparam.alias:
+        return False
+
+    # Root-namespace only. A field that stays namespaced (e.g. 
``--user.name``) never gets
+    # a short, even with ``short_alias=True`` set directly on it; flatten it 
via ``name="*"``.
+    if not _is_root_namespace(cparam.name):
+        return False
+
+    # Only parameters that bind CLI input directly get a short; containers do 
not.
+    if argument._accepts_keywords and not argument._enum_flag_type:
+        return False
+
+    return True
+
+
+def generate_short_alias(
+    argument: "Argument",
+    used_short_aliases: set[str],
+) -> tuple[str, ...] | None:
+    """Phase 2: generate the auto short flag(s) for an argument deemed 
eligible by phase 1.
+
+    Deferred until every parameter's explicit short has been reserved, so an 
earlier
+    parameter's auto short can never shadow a later parameter's explicit 
``alias``.
+
+    Returns the generated short name(s) to append to the argument's name as 
standalone
+    flags (so they surface globally, e.g. ``-e``, never dotted like 
``-u.name``), or
+    :obj:`None`. Mutates ``used_short_aliases`` to reserve claimed letters.
+    """
+    cparam = argument.parameter
+    field_info = argument.field_info
+
+    short = None
+    short_alias = cparam.short_alias
+    if callable(short_alias):
+        # Hand the callable a read-only snapshot so it cannot mutate the 
internal
+        # collision-tracking set and corrupt assignment for later parameters.
+        short = short_alias(field_info, frozenset(used_short_aliases))
+    elif short_alias and field_info.kind not in (POSITIONAL_ONLY, 
VAR_POSITIONAL):
+        # A boolean that already defaults to True would only get a no-op 
positive short
+        # (the meaningful off-switch ``--no-flag`` is long-only), so skip 
auto-generation
+        # and leave the letter free for a parameter that can actually use it.
+        if argument.hint is bool and field_info.default is True:
+            return None
+        # Derive the letter from the transformed CLI name (not the raw python 
identifier)
+        # so it stays consistent with the long flag (``--my-flag`` -> ``-m``, 
``_foo`` -> ``-f``).
+        transformed = cparam.name_transform(field_info.names[0])
+        if transformed:
+            letter = transformed[0].lower()
+            for candidate in (f"-{letter}", f"-{letter.upper()}"):
+                if candidate not in used_short_aliases:
+                    short = candidate
+                    break
+
+    if not short:
+        return None
+    if isinstance(short, str):
+        shorts = (short,)
+    else:
+        try:
+            shorts = tuple(short)
+        except TypeError:
+            raise TypeError(
+                f"Parameter.short_alias callable must return a str, an 
iterable of str, or None; got {short!r}."
+            ) from None
+    # The callable is custom logic, but it still must produce single-letter 
short flags
+    # (e.g. ``-e``) — anything else would be silently appended as a long flag 
or, worse, a
+    # positional name. Fail loudly instead, mirroring the field-level str 
rejection.
+    for s in shorts:
+        if not isinstance(s, str) or not is_short_flag(s):
+            raise ValueError(
+                f"Parameter.short_alias callable must return single-letter 
short flags like '-e'; got {s!r}."
+            )
+    # Drop any short already claimed by an earlier parameter (first-wins), so a
+    # callable that ignores ``used_short_aliases`` can't create duplicate 
flags.
+    shorts = tuple(s for s in shorts if s not in used_short_aliases)
+    if not shorts:
+        return None
+    used_short_aliases.update(shorts)
+    return shorts
+
+
 def extract_docstring_help(f: Callable) -> dict[tuple[str, ...], Parameter]:
     from docstring_parser import parse_from_object
 
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/cyclopts-4.18.0/cyclopts/bind.py 
new/cyclopts-4.20.0/cyclopts/bind.py
--- old/cyclopts-4.18.0/cyclopts/bind.py        2020-02-02 01:00:00.000000000 
+0100
+++ new/cyclopts-4.20.0/cyclopts/bind.py        2020-02-02 01:00:00.000000000 
+0100
@@ -168,6 +168,7 @@
                 # Once we hit an option that takes a value, the rest is the 
value
                 chars = cli_option.lstrip("-")
                 position = 0
+                unmatched_flags: list[str] = []
 
                 while position < len(chars):
                     char = chars[position]
@@ -197,13 +198,18 @@
                         if stop_at_first_unknown:
                             unused_tokens.extend(tokens[i:])
                             return unused_tokens, None
-                        unused_tokens.append(test_flag)
-                        unused_token_original_indices.append(i)
+                        unmatched_flags.append(test_flag)
                         position += 1
 
                 if not matches:
-                    # No valid matches found at all
+                    # No character matched a known short option, so this 
wasn't a
+                    # combined-short-option token after all; keep the original 
token intact.
+                    unused_tokens.append(token)
+                    unused_token_original_indices.append(i)
                     continue
+                for unmatched_flag in unmatched_flags:
+                    unused_tokens.append(unmatched_flag)
+                    unused_token_original_indices.append(i)
             else:
                 if stop_at_first_unknown:
                     # Unknown option, stop parsing and return all remaining 
tokens
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/cyclopts-4.18.0/cyclopts/cli/__init__.py 
new/cyclopts-4.20.0/cyclopts/cli/__init__.py
--- old/cyclopts-4.18.0/cyclopts/cli/__init__.py        2020-02-02 
01:00:00.000000000 +0100
+++ new/cyclopts-4.20.0/cyclopts/cli/__init__.py        2020-02-02 
01:00:00.000000000 +0100
@@ -11,11 +11,9 @@
 )
 
 
-# Explicitly import command modules
-from cyclopts.cli import (
-    _complete,  # noqa: F401
-    docs,  # noqa: F401
-    run,  # noqa: F401
-)
+from cyclopts.cli import _complete as _complete
+from cyclopts.cli import docs as docs
+from cyclopts.cli import run as run
+from cyclopts.cli import tree as tree
 
 __all__ = ["app"]
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/cyclopts-4.18.0/cyclopts/cli/tree.py 
new/cyclopts-4.20.0/cyclopts/cli/tree.py
--- old/cyclopts-4.18.0/cyclopts/cli/tree.py    1970-01-01 01:00:00.000000000 
+0100
+++ new/cyclopts-4.20.0/cyclopts/cli/tree.py    2020-02-02 01:00:00.000000000 
+0100
@@ -0,0 +1,34 @@
+"""Display a tree of a Cyclopts application's commands."""
+
+from typing import Annotated
+
+from rich.console import Console
+
+from cyclopts.cli import app
+from cyclopts.loader import load_app_from_script
+from cyclopts.parameter import Parameter
+
+
[email protected]
+def tree(
+    script: str,
+    /,
+    *,
+    description: Annotated[bool, Parameter(alias="-d")] = True,
+    max_depth: Annotated[int | None, Parameter(alias="-m")] = None,
+):
+    """Display a tree of a Cyclopts application's commands.
+
+    Parameters
+    ----------
+    script : str
+        Python script path, optionally with ``':app_object'`` notation to 
specify
+        the App object. If not specified, will search for App objects in the
+        script's global namespace.
+    description : bool
+        Show each command's short description next to its name.
+    max_depth : Optional[int]
+        Maximum subcommand depth to display. ``None`` (default) shows all.
+    """
+    app_obj, _ = load_app_from_script(script)
+    Console().print(app_obj.command_tree(description=description, 
max_depth=max_depth))
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/cyclopts-4.18.0/cyclopts/core.py 
new/cyclopts-4.20.0/cyclopts/core.py
--- old/cyclopts-4.18.0/cyclopts/core.py        2020-02-02 01:00:00.000000000 
+0100
+++ new/cyclopts-4.20.0/cyclopts/core.py        2020-02-02 01:00:00.000000000 
+0100
@@ -28,6 +28,7 @@
 from cyclopts.annotations import resolve_annotated
 from cyclopts.app_stack import AppStack
 from cyclopts.argument import ArgumentCollection
+from cyclopts.argument.utils import is_short_flag
 from cyclopts.bind import create_bound_arguments, is_option_like, 
normalize_tokens
 from cyclopts.command_spec import CommandSpec
 from cyclopts.config._env import Env
@@ -68,6 +69,7 @@
 
 if TYPE_CHECKING:
     from rich.console import Console
+    from rich.tree import Tree
 
     from cyclopts.docs.types import DocFormat
     from cyclopts.help import HelpPanel
@@ -1553,6 +1555,7 @@
             group_arguments=self._group_arguments,  # pyright: ignore
             group_parameters=self._group_parameters,  # pyright: ignore
             parse_docstring=parse_docstring,
+            reserved=(f for f in (*self.help_flags, *self.version_flags) if 
is_short_flag(f)),
         )
 
     def parse_known_args(
@@ -2380,6 +2383,76 @@
 
         return doc
 
+    def command_tree(
+        self,
+        *,
+        description: bool = True,
+        include_hidden: bool = False,
+        max_depth: int | None = None,
+    ) -> "Tree":
+        """Build a :class:`rich.tree.Tree` of this application's command 
hierarchy.
+
+        The returned tree is a Rich renderable; print it with
+        ``console.print(app.command_tree())`` or embed it within other Rich
+        output. Hidden commands and built-in help/version flags are excluded by
+        default.
+
+        Parameters
+        ----------
+        description : bool
+            Show each command's short description (from its docstring) next to
+            its name. Default is True.
+        include_hidden : bool
+            Include hidden commands (``show=False``). Default is False.
+        max_depth : int | None
+            Maximum subcommand depth to display. ``None`` (default) shows all
+            levels; ``1`` shows only top-level commands.
+
+        Returns
+        -------
+        rich.tree.Tree
+            A renderable tree of the command hierarchy.
+
+        Examples
+        --------
+        >>> from rich.console import Console
+        >>> app = App(name="myapp")
+        >>> Console().print(app.command_tree())  # doctest: +SKIP
+        """
+        from rich.text import Text
+        from rich.tree import Tree
+
+        from cyclopts.docs.base import iterate_commands
+        from cyclopts.help.help import docstring_parse
+
+        def short_description(subapp: "App") -> str:
+            try:
+                return docstring_parse(subapp.help, 
"restructuredtext").short_description or ""
+            except Exception:
+                return ""
+
+        def node_label(name: str, subapp: "App") -> Text:
+            label = Text(name)
+            if description:
+                short = short_description(subapp)
+                if short:
+                    label.append("  ")
+                    label.append(short, style="dim")
+            return label
+
+        def build(node, subapp: "App", depth: int) -> None:
+            if max_depth is not None and depth > max_depth:
+                return
+            # resolve_lazy=True so the tree shows the complete hierarchy; lazy
+            # commands left unresolved would be silently omitted entirely.
+            for name, child in iterate_commands(subapp, 
include_hidden=include_hidden, resolve_lazy=True):
+                branch = node.add(node_label(name, child))
+                build(branch, child, depth + 1)
+
+        tree = Tree(node_label(self.name[0], self))
+        build(tree, self, 1)
+        return tree
+
     def generate_completion(
         self,
         *,
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/cyclopts-4.18.0/cyclopts/help/help.py 
new/cyclopts-4.20.0/cyclopts/help/help.py
--- old/cyclopts-4.18.0/cyclopts/help/help.py   2020-02-02 01:00:00.000000000 
+0100
+++ new/cyclopts-4.20.0/cyclopts/help/help.py   2020-02-02 01:00:00.000000000 
+0100
@@ -14,6 +14,7 @@
 from attrs import define, evolve, field
 
 from cyclopts.annotations import resolve_annotated
+from cyclopts.argument.utils import is_short_flag
 from cyclopts.core import _get_root_module_name, 
_iter_resolution_argument_collections
 from cyclopts.field_info import get_field_infos
 from cyclopts.group import Group
@@ -185,10 +186,6 @@
             raise NotImplementedError
 
 
-def _is_short(s):
-    return not s.startswith("--") and s.startswith("-")
-
-
 def _categorize_keyword_arguments(argument_collection: "ArgumentCollection") 
-> tuple[list, list]:
     """Categorize keyword arguments by requirement status for usage string 
formatting.
 
@@ -417,7 +414,7 @@
     value_type = argument._default
 
     negatives = set(argument.negatives)
-    outer_long_names = tuple(o for o in argument.names if o not in negatives 
and not _is_short(o))
+    outer_long_names = tuple(o for o in argument.names if o not in negatives 
and not is_short_flag(o))
 
     is_unresolvable = isinstance(value_type, (str, ForwardRef))
     is_cycle = id(value_type) in seen
@@ -479,10 +476,10 @@
             options = [arg_name, *options]
 
     negatives = set(argument.negatives)
-    positive_names = [o for o in options if o not in negatives and not 
_is_short(o)]
-    positive_shorts = [o for o in options if o not in negatives and 
_is_short(o)]
-    negative_names = [o for o in options if o in negatives and not 
_is_short(o)]
-    negative_shorts = [o for o in options if o in negatives and _is_short(o)]
+    positive_names = [o for o in options if o not in negatives and not 
is_short_flag(o)]
+    positive_shorts = [o for o in options if o not in negatives and 
is_short_flag(o)]
+    negative_names = [o for o in options if o in negatives and not 
is_short_flag(o)]
+    negative_shorts = [o for o in options if o in negatives and 
is_short_flag(o)]
 
     help_description = InlineText.from_format(argument.parameter.help, 
format=format)
 
@@ -598,7 +595,7 @@
         # Commands don't have negative variants, so all names are "positive"
         short_names, long_names = [], []
         for name in names:
-            short_names.append(name) if _is_short(name) else 
long_names.append(name)
+            short_names.append(name) if is_short_flag(name) else 
long_names.append(name)
 
         sort_key = resolve_callables(app.sort_key, app)
 
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/cyclopts-4.18.0/cyclopts/parameter.py 
new/cyclopts-4.20.0/cyclopts/parameter.py
--- old/cyclopts-4.18.0/cyclopts/parameter.py   2020-02-02 01:00:00.000000000 
+0100
+++ new/cyclopts-4.20.0/cyclopts/parameter.py   2020-02-02 01:00:00.000000000 
+0100
@@ -33,7 +33,7 @@
     resolve_new_type,
     resolve_optional,
 )
-from cyclopts.field_info import get_field_infos, signature_parameters
+from cyclopts.field_info import FieldInfo, get_field_infos, 
signature_parameters
 from cyclopts.group import Group
 from cyclopts.utils import (
     default_name_transform,
@@ -93,6 +93,23 @@
     return value if value is not None else False
 
 
+def _short_alias_converter(
+    value: bool | Callable[[FieldInfo, frozenset[str]], str | Iterable[str] | 
None] | None,
+) -> bool | Callable[[FieldInfo, frozenset[str]], str | Iterable[str] | None]:
+    return False if value is None else value
+
+
+def _short_alias_validator(instance, attribute, value):
+    # A str is also an Iterable[str], so an explicit "-z" would silently 
expand into
+    # individual letters. Reject it so the developer reaches for alias/name 
instead.
+    if isinstance(value, str):
+        raise TypeError(
+            "Parameter.short_alias does not accept a string. Pass a bool to 
auto-generate a "
+            'short flag, or a callable for custom logic. To set an explicit 
flag like "-z", use '
+            "Parameter.alias or Parameter.name instead."
+        )
+
+
 def _negative_converter(default: tuple[str, ...]):
     def converter(value: str | Iterable[str] | None) -> tuple[str, ...]:
         if value is None:
@@ -337,6 +354,13 @@
         kw_only=True,
     )
 
+    short_alias: bool | Callable[[FieldInfo, frozenset[str]], str | 
Iterable[str] | None] = field(
+        default=None,
+        converter=_short_alias_converter,
+        validator=_short_alias_validator,
+        kw_only=True,
+    )
+
     allow_repeating: bool | None = field(
         default=None,
         kw_only=True,
@@ -372,7 +396,7 @@
             # Sort union members by priority: non-None types first, then 
None/NoneType
             # This ensures that if bool | None both produce the same custom 
negative,
             # we only include it once from the higher-priority type (bool).
-            sorted_args = sorted(union_args, key=lambda x: (is_nonetype(x) or 
x is None))
+            sorted_args = sorted(union_args, key=lambda x: is_nonetype(x) or x 
is None)
             out: list[str] = []
             for x in sorted_args:
                 for neg in self.get_negatives(x):

Reply via email to