Skip to content

Commit

Permalink
Merge pull request #258 from BrianPugh/app-sort-key
Browse files Browse the repository at this point in the history
New attribute `App.sort_key` that controls command-order in the help page.
  • Loading branch information
BrianPugh authored Nov 23, 2024
2 parents dcf29e3 + 057df8d commit abfb1ef
Show file tree
Hide file tree
Showing 7 changed files with 199 additions and 67 deletions.
12 changes: 12 additions & 0 deletions cyclopts/core.py
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@
from cyclopts.protocols import Dispatcher
from cyclopts.token import Token
from cyclopts.utils import (
UNSET,
default_name_transform,
optional_to_tuple_converter,
to_list_converter,
Expand Down Expand Up @@ -292,6 +293,13 @@ class App:
kw_only=True,
)

_sort_key: Any = field(
default=None,
alias="sort_key",
converter=lambda x: UNSET if x is None else x,
kw_only=True,
)

######################
# Private Attributes #
######################
Expand Down Expand Up @@ -447,6 +455,10 @@ def name_transform(self):
def name_transform(self, value):
self._name_transform = value

@property
def sort_key(self):
return None if self._sort_key is UNSET else self._sort_key

def version_print(
self,
console: Optional["Console"] = None,
Expand Down
76 changes: 16 additions & 60 deletions cyclopts/group.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@

from attrs import field, frozen

from cyclopts.utils import Sentinel, is_iterable, to_tuple_converter
from cyclopts.utils import UNSET, SortHelper, is_iterable, resolve_callables, to_tuple_converter

if TYPE_CHECKING:
from cyclopts.argument import ArgumentCollection
Expand All @@ -30,10 +30,6 @@ def _group_default_parameter_must_be_none(instance, attribute, value: Optional["
_sort_key_counter = itertools.count()


class NO_USER_SORT_KEY(Sentinel): # noqa: N801
pass


@frozen
class Group:
name: str = ""
Expand All @@ -46,7 +42,7 @@ class Group:
_sort_key: Any = field(
default=None,
alias="sort_key",
converter=lambda x: NO_USER_SORT_KEY if x is None else x,
converter=lambda x: UNSET if x is None else x,
kw_only=True,
)

Expand All @@ -71,7 +67,7 @@ def show(self):

@property
def sort_key(self):
return None if self._sort_key is NO_USER_SORT_KEY else self._sort_key
return None if self._sort_key is UNSET else self._sort_key

@classmethod
def create_default_arguments(cls):
Expand Down Expand Up @@ -113,7 +109,7 @@ def create_ordered(cls, name="", help="", *, show=None, sort_key=None, validator
"""
count = next(_sort_key_counter)
if sort_key is None:
sort_key = (NO_USER_SORT_KEY, count)
sort_key = (UNSET, count)
elif is_iterable(sort_key):
sort_key = (tuple(sort_key), count)
else:
Expand All @@ -129,60 +125,20 @@ def create_ordered(cls, name="", help="", *, show=None, sort_key=None, validator


def sort_groups(groups: list[Group], attributes: list[Any]) -> tuple[list[Group], list[Any]]:
"""Sort groups for the help-page."""
"""Sort groups for the help-page.
Note, much logic is similar to here and ``HelpPanel.sort``, so any changes here should probably be reflected over there as well.
"""
assert len(groups) == len(attributes)

if not groups:
return groups, attributes

# Resolve callable ``sort_key``
sort_key__group_attributes = []
for group, attribute in zip(groups, attributes):
value = (group, attribute)
if callable(group._sort_key) or is_iterable(group._sort_key):
sort_key__group_attributes.append((resolve_callables(group._sort_key, group), value))
else:
sort_key__group_attributes.append((group._sort_key, value))

sort_key_panels: list[tuple[tuple, tuple[Group, Any]]] = []
ordered_no_user_sort_key_panels: list[tuple[tuple, tuple[Group, Any]]] = []
no_user_sort_key_panels: list[tuple[tuple, tuple[Group, Any]]] = []

for sort_key, (group, attribute) in sort_key__group_attributes:
value = (group, attribute)
if sort_key in (NO_USER_SORT_KEY, None):
no_user_sort_key_panels.append(((group.name,), value))
elif is_iterable(sort_key) and sort_key[0] in (NO_USER_SORT_KEY, None):
ordered_no_user_sort_key_panels.append((sort_key[1:] + (group.name,), value))
else:
sort_key_panels.append(((sort_key, group.name), value))

sort_key_panels.sort()
ordered_no_user_sort_key_panels.sort()
no_user_sort_key_panels.sort()

combined = sort_key_panels + ordered_no_user_sort_key_panels + no_user_sort_key_panels

out_groups, out_attributes = zip(*[x[1] for x in combined])

sorted_entries = SortHelper.sort(
[
SortHelper(resolve_callables(group._sort_key, group), group.name, (group, attribute))
for group, attribute in zip(groups, attributes)
]
)
out_groups, out_attributes = zip(*[x.value for x in sorted_entries])
return list(out_groups), list(out_attributes)


def resolve_callables(t, *args, **kwargs):
"""Recursively resolves callable elements in a tuple."""
if isinstance(t, type(Sentinel)):
return t

if callable(t):
return t(*args, **kwargs)

resolved = []
for element in t:
if isinstance(element, type(Sentinel)):
resolved.append(element)
elif callable(element):
resolved.append(element(*args, **kwargs))
elif is_iterable(element):
resolved.append(resolve_callables(element, *args, **kwargs))
else:
resolved.append(element)
return tuple(resolved)
19 changes: 18 additions & 1 deletion cyclopts/help.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
from math import ceil
from typing import (
TYPE_CHECKING,
Any,
Callable,
Literal,
Union,
Expand All @@ -19,6 +20,7 @@
import cyclopts.utils
from cyclopts.annotations import is_union
from cyclopts.group import Group
from cyclopts.utils import SortHelper, resolve_callables

if TYPE_CHECKING:
from rich.console import Console, ConsoleOptions, RenderableType, RenderResult
Expand Down Expand Up @@ -55,6 +57,7 @@ class HelpEntry:
short: str
description: "RenderableType"
required: bool = False
sort_key: Any = None


def _text_factory():
Expand All @@ -80,7 +83,20 @@ def remove_duplicates(self):
self.entries = out

def sort(self):
self.entries.sort(key=lambda x: (x.name.startswith("-"), x.name))
"""Sort entries in-place.
Callable sort_keys are provided with no argument?
"""
if not self.entries:
return

if self.format == "command":
sorted_sort_helper = SortHelper.sort(
[SortHelper(entry.sort_key, (entry.name.startswith("-"), entry.name), entry) for entry in self.entries]
)
self.entries = [x.value for x in sorted_sort_helper]
else:
raise NotImplementedError

def __rich_console__(self, console: "Console", options: "ConsoleOptions") -> "RenderResult":
if not self.entries:
Expand Down Expand Up @@ -403,6 +419,7 @@ def format_command_entries(apps: Iterable["App"], format: str) -> list:
name="\n".join(long_names),
short=" ".join(short_names),
description=format_str(docstring_parse(app.help).short_description or "", format=format),
sort_key=resolve_callables(app.sort_key, app),
)
if entry not in entries:
entries.append(entry)
Expand Down
61 changes: 61 additions & 0 deletions cyclopts/utils.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,15 @@
"""To prevent circular dependencies, this module should never import anything else from Cyclopts."""

import functools
import inspect
import sys
from collections.abc import Iterable, Iterator, MutableMapping
from contextlib import suppress
from operator import itemgetter
from typing import Any, Literal, Optional, Sequence, Tuple, Union

from attrs import field, frozen

# fmt: off
if sys.version_info >= (3, 10): # pragma: no cover
def signature(f: Any) -> inspect.Signature:
Expand Down Expand Up @@ -431,3 +436,59 @@ def is_option_like(token: str) -> bool:

def is_builtin(obj: Any) -> bool:
return getattr(obj, "__module__", "").split(".")[0] in stdlib_module_names


def resolve_callables(t, *args, **kwargs):
"""Recursively resolves callable elements in a tuple.
Returns an object that "looks like" the input, but with all callable's invoked
and replaced with their return values. Positional and keyword elements will be
passed along to each invocation.
"""
if isinstance(t, type(Sentinel)):
return t

if callable(t):
return t(*args, **kwargs)
elif is_iterable(t):
resolved = []
for element in t:
if isinstance(element, type(Sentinel)):
resolved.append(element)
elif callable(element):
resolved.append(element(*args, **kwargs))
elif is_iterable(element):
resolved.append(resolve_callables(element, *args, **kwargs))
else:
resolved.append(element)
return tuple(resolved)
else:
return t


@frozen
class SortHelper:
key: Any
fallback_key: Any = field(converter=to_tuple_converter)
value: Any

@staticmethod
def sort(entries: Sequence["SortHelper"]) -> list["SortHelper"]:
user_sort_key = []
ordered_no_user_sort_key = []
no_user_sort_key = []

for entry in entries:
if entry.key in (UNSET, None):
no_user_sort_key.append((entry.fallback_key, entry))
elif is_iterable(entry.key) and entry.key[0] in (UNSET, None):
ordered_no_user_sort_key.append((entry.key[1:] + entry.fallback_key, entry))
else:
user_sort_key.append(((entry.key, entry.fallback_key), entry))

user_sort_key.sort(key=itemgetter(0))
ordered_no_user_sort_key.sort(key=itemgetter(0))
no_user_sort_key.sort(key=itemgetter(0))

combined = user_sort_key + ordered_no_user_sort_key + no_user_sort_key
return [x[1] for x in combined]
52 changes: 51 additions & 1 deletion docs/source/api.rst
Original file line number Diff line number Diff line change
Expand Up @@ -139,6 +139,56 @@ API
โ”‚ --version Display application version. โ”‚
โ•ฐโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ•ฏ
.. attribute:: sort_key
:type: Any
:value: None

Modifies command display order on the help-page.

1. If :attr:`sort_key`, or any of it's contents, are ``Callable``, then invoke it ``sort_key(app)`` and apply the returned value to (2) if :obj:`None`, (3) otherwise.

2. For all commands with ``sort_key==None`` (default value), sort them alphabetically.
These sorted commands will be displayed **after** ``sort_key != None`` list (see 3).

3. For all commands with ``sort_key!=None``, sort them by ``(sort_key, app.name)``.
It is the user's responsibility that ``sort_key`` s are comparable.

Example usage:

.. code-block:: python
from cyclopts import App
app = App()
@app.command # sort_key not specified; will be sorted AFTER bob/charlie.
def alice():
"""Alice help description."""
@app.command(sort_key=2)
def bob():
"""Bob help description."""
@app.command(sort_key=1)
def charlie():
"""Charlie help description."""
app()
Resulting help-page:

.. code-block:: text
Usage: demo.py COMMAND
โ•ญโ”€ Commands โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ•ฎ
โ”‚ charlie Charlie help description. โ”‚
โ”‚ bob Bob help description. โ”‚
โ”‚ alice Alice help description. โ”‚
โ”‚ --help -h Display this message and exit. โ”‚
โ”‚ --version Display application version. โ”‚
โ•ฐโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ•ฏ
.. attribute:: version
:type: Union[None, str, Callable]
:value: None
Expand Down Expand Up @@ -749,7 +799,7 @@ API
Resulting help-page:

.. code-block:: bash
.. code-block:: text
Usage: app COMMAND
Expand Down
6 changes: 3 additions & 3 deletions tests/test_group.py
Original file line number Diff line number Diff line change
Expand Up @@ -125,7 +125,7 @@ def foo(

def test_group_sort_key_property():
assert Group().sort_key is None
assert Group()._sort_key is cyclopts.group.NO_USER_SORT_KEY
assert Group()._sort_key is cyclopts.group.UNSET

g = Group(sort_key=1)
assert g.sort_key == 1
Expand All @@ -144,8 +144,8 @@ def test_group_sorted_classmethod_basic(mock_sort_key_counter):
g2 = Group.create_ordered("bar")
g3 = Group.create_ordered("baz", sort_key="non-int value")

assert g1.sort_key == (cyclopts.group.NO_USER_SORT_KEY, 0)
assert g2.sort_key == (cyclopts.group.NO_USER_SORT_KEY, 1)
assert g1.sort_key == (cyclopts.group.UNSET, 0)
assert g2.sort_key == (cyclopts.group.UNSET, 1)
assert g3.sort_key == ("non-int value", 2)
assert g4.sort_key is None

Expand Down
Loading

0 comments on commit abfb1ef

Please sign in to comment.