Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add a custom Context with align_{} params and a BaseCommand that uses it #16

Merged
merged 1 commit into from
Apr 21, 2021
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions CHANGELOG.rst
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,26 @@ v0.8.0 (in development)

- Cloup license changed from MIT to 3-clause BSD, the one used by Click.

** Compatible changes

- Added a custom `Context` class, having the following additional parameters:

* ``align_option_groups = True``,
* ``align_sections = True``.

The corresponding arguments in ``OptionGroupMixin`` and ``SectionMixin`` were
set to ``None``. By setting them, you can override the context value.

- Changed the class hierarchy:

* added a ``BaseCommand``, extending ``click.Command`` and using the custom
``Context`` by default. This class also "backports" the Click 8.0 class
attribute ``context_class``.

* ``cloup.Command`` and `cloup.MultiCommand` extends ``cloup.BaseCommand``

* ``cloup.Group`` now extends ``cloup.MultiCommand``.


v0.7.0 (2021-03-24)
===================
Expand Down
1 change: 1 addition & 0 deletions cloup/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
__version_tuple__ = _version.version_tuple

# flake8: noqa F401
from ._context import Context
from ._option_groups import (
GroupedOption, OptionGroup, OptionGroupMixin, option, option_group
)
Expand Down
41 changes: 35 additions & 6 deletions cloup/_commands.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,20 +3,42 @@

import click

from ._context import Context
from ._option_groups import OptionGroupMixin
from ._sections import Section, SectionMixin
from .constraints import ConstraintMixin, BoundConstraintSpec
from .constraints import BoundConstraintSpec, ConstraintMixin


class Command(ConstraintMixin, OptionGroupMixin, click.Command):
class BaseCommand(click.Command):
"""Base class for cloup commands.

* It back-ports a feature from Click v8.0, i.e. the ``context_class``
class attribute, which is set to ``cloup.Context``.
"""
context_class: Type[Context] = Context

def make_context(self, info_name, args, parent=None, **extra) -> Context:
for key, value in self.context_settings.items():
if key not in extra:
extra[key] = value

ctx = self.context_class(self, info_name=info_name, parent=parent, **extra)

with ctx.scope(cleanup=False):
self.parse_args(ctx, args)
return ctx


class Command(ConstraintMixin, OptionGroupMixin, BaseCommand):
"""
A ``click.Command`` supporting option groups and constraints.
"""

def __init__(
self, *click_args,
constraints: Sequence[BoundConstraintSpec] = (),
show_constraints: bool = False,
align_option_groups: bool = True,
align_option_groups: Optional[bool] = None,
**click_kwargs,
):
super().__init__(
Expand All @@ -27,7 +49,12 @@ def __init__(
**click_kwargs)


class MultiCommand(SectionMixin, click.MultiCommand, metaclass=abc.ABCMeta):
class MultiCommand(
SectionMixin,
BaseCommand,
click.MultiCommand,
metaclass=abc.ABCMeta
):
"""
A ``click.MultiCommand`` that allows to organize its subcommands in
multiple help sections and and whose subcommands are, by default, of type
Expand All @@ -40,7 +67,7 @@ class MultiCommand(SectionMixin, click.MultiCommand, metaclass=abc.ABCMeta):
pass


class Group(SectionMixin, click.Group):
class Group(MultiCommand, click.Group):
"""
A ``click.Group`` that allows to organize its subcommands in multiple help
sections and and whose subcommands are, by default, of type
Expand All @@ -52,11 +79,13 @@ class Group(SectionMixin, click.Group):

See the docstring of the two superclasses for more details.
"""
pass

def __init__(self, name: Optional[str] = None,
commands: Optional[Dict[str, click.Command]] = None,
sections: Iterable[Section] = (),
align_sections: bool = True, **attrs):
align_sections: Optional[bool] = None,
**attrs):
"""
:param name: name of the command
:param commands:
Expand Down
15 changes: 15 additions & 0 deletions cloup/_context.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import click


class Context(click.Context):
"""A Context that adds a ``formatter_factory`` instance argument."""

def __init__(
self, *args,
align_option_groups: bool = True,
align_sections: bool = True,
**ctx_kwargs,
):
super().__init__(*args, **ctx_kwargs)
self.align_option_groups = align_option_groups
self.align_sections = align_sections
17 changes: 14 additions & 3 deletions cloup/_option_groups.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
import click
from click import Option, Parameter

from cloup._util import make_repr
from cloup._util import coalesce, make_repr
from cloup.constraints import Constraint, ConstraintMixin

C = TypeVar('C', bound=Callable)
Expand Down Expand Up @@ -79,7 +79,9 @@ class OptionGroupMixin:
a command must inherits from :class:`cloup.ConstraintMixin` too!
"""

def __init__(self, *args, align_option_groups: bool = True, **kwargs):
def __init__(
self, *args, align_option_groups: Optional[bool] = None, **kwargs
):
self.align_option_groups = align_option_groups
self.option_groups, self.ungrouped_options = \
self._option_groups_from_params(kwargs['params'])
Expand Down Expand Up @@ -135,6 +137,15 @@ def format_option_group(self, ctx: click.Context,
formatter.write_text(opt_group.help)
formatter.write_dl(help_records)

def must_align_option_groups(
self, ctx: Optional[click.Context], default=True
) -> bool:
return coalesce(
self.align_option_groups,
getattr(ctx, 'align_option_groups', None),
default,
) # type: ignore

def format_options(self, ctx: click.Context,
formatter: click.HelpFormatter,
max_option_width: int = 30):
Expand All @@ -148,7 +159,7 @@ def format_options(self, ctx: click.Context,
default_group.options = ungrouped_options
records_by_group[default_group] = default_group.get_help_records(ctx)

if self.align_option_groups and len(records_by_group) > 1:
if self.must_align_option_groups(ctx) and len(records_by_group) > 1:
option_name_width = min(
max_option_width,
max(len(rec[0])
Expand Down
15 changes: 13 additions & 2 deletions cloup/_sections.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@

import click

from cloup._util import coalesce

CommandType = TypeVar('CommandType', bound=Type[click.Command])
Subcommands = Union[Iterable[click.Command], Dict[str, click.Command]]

Expand Down Expand Up @@ -92,7 +94,7 @@ def __init__(
self, *args,
commands: Optional[Dict[str, click.Command]] = None,
sections: Iterable[Section] = (),
align_sections: bool = True,
align_sections: Optional[bool] = None,
**kwargs,
):
"""
Expand Down Expand Up @@ -155,10 +157,19 @@ def list_sections(self, ctx: click.Context,
section_list.append(default_section)
return section_list

def must_align_sections(
self, ctx: Optional[click.Context], default: bool = True
) -> bool:
return coalesce(
self.align_sections,
getattr(ctx, 'align_sections', None),
default,
) # type: ignore

def format_commands(self, ctx: click.Context, formatter: click.HelpFormatter):
section_list = self.list_sections(ctx)
command_name_col_width = None
if self.align_sections:
if self.must_align_sections(ctx):
command_name_col_width = max(len(name)
for section in section_list
for name in section.commands)
Expand Down
14 changes: 13 additions & 1 deletion cloup/_util.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
"""Generic utilities."""
from typing import Iterable, List
from typing import Iterable, List, Optional, TypeVar


T = TypeVar('T')


def class_name(obj):
Expand Down Expand Up @@ -61,3 +64,12 @@ def pluralize(
if count == 1 and one:
return one
return many.format(count=count)


def coalesce(*values: Optional[T], default=None) -> Optional[T]:
"""Returns the first value that is not None (or ``default`` if no such value exists).
Inspired by the homonym SQL function."""
for val in values:
if val is not None:
return val
return default
6 changes: 5 additions & 1 deletion tests/conftest.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
from functools import partial

from click.testing import CliRunner
from pytest import fixture

Expand All @@ -7,7 +9,9 @@

@fixture()
def runner():
return CliRunner()
runner = CliRunner()
runner.invoke = partial(runner.invoke, catch_exceptions=True)
return runner


@fixture(scope='session')
Expand Down
47 changes: 47 additions & 0 deletions tests/test_option_groups.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
"""Tests for the "option groups" feature/module."""
from textwrap import dedent

import pytest

import cloup
from cloup import option
from tests.util import noop


Expand Down Expand Up @@ -44,3 +46,48 @@ def test_option_group_decorator_raises_if_group_is_passed_to_contained_option():
def test_option_group_decorator_raises_for_no_options():
with pytest.raises(ValueError):
cloup.option_group('grp')


@pytest.mark.parametrize(['ctx_value', 'cmd_value', 'should_align'], [
pytest.param(True, None, True, id='ctx-yes'),
pytest.param(False, None, False, id='ctx-no'),
pytest.param(False, True, True, id='none'),
pytest.param(True, False, False, id='ctx-yes_cmd-no'),
pytest.param(False, True, True, id='ctx-no_cmd-yes'),
])
def test_align_option_groups_context_setting(runner, ctx_value, cmd_value, should_align):
@cloup.command(
context_settings=dict(align_option_groups=ctx_value),
align_option_groups=cmd_value,
)
@cloup.option_group('First group', option('--opt', help='first option'))
@cloup.option_group('Second group', option('--much-longer-opt', help='second option'))
def cmd(one, much_longer_opt):
pass

result = runner.invoke(cmd, args=('--help',))
start = result.output.find('First')
if should_align:
expected = """
First group:
--opt TEXT first option

Second group:
--much-longer-opt TEXT second option

Other options:
--help Show this message and exit."""
else:
expected = """
First group:
--opt TEXT first option

Second group:
--much-longer-opt TEXT second option

Other options:
--help Show this message and exit."""

expected = dedent(expected).strip()
end = start + len(expected)
assert result.output[start:end] == expected
49 changes: 49 additions & 0 deletions tests/test_sections.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
"""Tests for the "subcommand sections" feature/module."""
from textwrap import dedent

import click
import pytest

Expand Down Expand Up @@ -53,3 +55,50 @@ def test_Group_subcommand_decorator(subcommand_cls, assign_to_section):
assert section.commands[subcommand_name] is subcommand
else:
assert grp._default_section.commands[subcommand_name] is subcommand


@pytest.mark.parametrize(['ctx_value', 'cmd_value', 'should_align'], [
pytest.param(True, None, True, id='ctx-yes'),
pytest.param(False, None, False, id='ctx-no'),
pytest.param(False, True, True, id='none'),
pytest.param(True, False, False, id='ctx-yes_cmd-no'),
pytest.param(False, True, True, id='ctx-no_cmd-yes'),
])
def test_align_sections_context_setting(runner, ctx_value, cmd_value, should_align):
@cloup.group(
context_settings=dict(align_sections=ctx_value),
align_sections=cmd_value,
)
def cmd(one, much_longer_opt):
pass

cmd.section(
"First section",
cloup.command('cmd', help='First command help')(noop),
)

cmd.section(
"Second section",
cloup.command('longer-cmd', help='Second command help')(noop),
)

result = runner.invoke(cmd, args=('--help',))
start = result.output.find('First section')
if should_align:
expected = """
First section:
cmd First command help

Second section:
longer-cmd Second command help"""
else:
expected = """
First section:
cmd First command help

Second section:
longer-cmd Second command help"""

expected = dedent(expected).strip()
end = start + len(expected)
assert result.output[start:end] == expected