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):