~brenns10/subc

a4bfed34bf8184531e116a29f30424afceaacb73 — Stephen Brennan 1 year, 8 months ago fb16a06
Add shortest_prefix option, and tests
5 files changed, 266 insertions(+), 14 deletions(-)

M .gitignore
A requirements-dev.txt
M subc.py
A tests/test_basic.py
A tox.ini
M .gitignore => .gitignore +5 -0
@@ 1,3 1,8 @@
*.pyc
__pycache__
venv
.coverage
.tox
build
dist
htmlcov

A requirements-dev.txt => requirements-dev.txt +3 -0
@@ 0,0 1,3 @@
pytest
pytest-cov
wheel

M subc.py => subc.py +67 -14
@@ 9,6 9,47 @@ from abc import abstractproperty
from abc import abstractmethod


def _first_different(s1, s2):
    """
    Return index of the first different character in s1 or s2. If the strings
    are the same, raises a ValueError.
    """
    for i, (c1, c2) in enumerate(zip(s1, s2)):
        if c1 != c2:
            return i
    if len(s1) == len(s2):
        raise ValueError(f"Duplicate string {s1!r} is not allowed")
    return i + 1


def _unique_prefixes(strings):
    """
    Helper to find a list of unique prefixes for each string in strings.

    Return a dict mapping each string to a list of prefixes which are unique
    among all other strings within the list. Here is an example:

        >>> _unique_prefixes(["commit", "count", "apply", "app", "shape"])
        {'app': [],
         'apply': ['appl'],
         'commit': ['com', 'comm', 'commi'],
         'count': ['cou', 'coun'],
         'launch': ['la', 'lau', 'laun', 'launc'],
         'list': ['li', 'lis'],
         'shape': ['s', 'sh', 'sha', 'shap']}
    """
    strings = sorted(strings)
    diffs = [0] * len(strings)
    for i, (s1, s2) in enumerate(zip(strings, strings[1:])):
        common = _first_different(s1, s2)
        diffs[i] = max(diffs[i], common)
        diffs[i + 1] = max(diffs[i + 1], common)
    return {
        s: [s[:i] for i in range(x + 1, len(s))]
        for (s, x) in zip(strings, diffs)
    }


class Command(ABC):
    """
    A simple class for implementing sub-commands in your command line


@@ 60,11 101,12 @@ class Command(ABC):
        return self.run()

    @classmethod
    def add_commands(cls, parser, default=None):
    def add_commands(cls, parser, default=None, shortest_prefix=False):
        # type: (argparse.ArgumentParser) -> None
        default_set = False
        subparsers = parser.add_subparsers(title='sub-command')
        subclasses = collections.deque(cls.__subclasses__())
        to_add = []
        while subclasses:
            subcls = subclasses.popleft()
            this_node_subclasses = subcls.__subclasses__()


@@ 73,15 115,22 @@ class Command(ABC):
                # its children to the queue (BFS) but do not instantiate it.
                subclasses.extend(this_node_subclasses)
            else:
                cmd = subcls()
                cmd_parser = subparsers.add_parser(
                    cmd.name, description=cmd.description
                )
                cmd.add_args(cmd_parser)
                cmd_parser.set_defaults(func=cmd.base_run)
                if cmd.name == default:
                    parser.set_defaults(func=cmd.base_run)
                    default_set = True
                to_add.append(subcls())

        if shortest_prefix:
            aliases = _unique_prefixes(c.name for c in to_add)
        else:
            aliases = collections.defaultdict(list)
        for cmd in to_add:
            cmd_parser = subparsers.add_parser(
                cmd.name, description=cmd.description,
                aliases=aliases[cmd.name],
            )
            cmd.add_args(cmd_parser)
            cmd_parser.set_defaults(func=cmd.base_run)
            if cmd.name == default:
                parser.set_defaults(func=cmd.base_run)
                default_set = True

        if not default_set:
            def default(*args, **kwargs):


@@ 90,9 139,13 @@ class Command(ABC):
        return parser

    @classmethod
    def main(cls, description, default=None):
        # type: (str) -> None
    def parse_args(cls, description, default=None, args=None, shortest_prefix=False):
        parser = argparse.ArgumentParser(description=description)
        cls.add_commands(parser, default=default)
        args = parser.parse_args()
        cls.add_commands(parser, default=default, shortest_prefix=shortest_prefix)
        return parser.parse_args(args)

    @classmethod
    def main(cls, description, default=None, args=None, shortest_prefix=False):
        # type: (str) -> None
        args = cls.parse_args(description, default=default, args=args, shortest_prefix=shortest_prefix)
        args.func(args)

A tests/test_basic.py => tests/test_basic.py +175 -0
@@ 0,0 1,175 @@
#!/usr/bin/env python3
import pytest

from subc import Command


def test_basic():
    cmd = None

    class MyBase(Command):
        description = 'foo'

        def run(self):
            nonlocal cmd
            cmd = self

    class FirstCmd(MyBase):
        name = 'first'

        def add_args(self, parser):
            parser.add_argument('required', type=str)
            parser.add_argument('--foobar', action='store_true')

    class SecondCmd(MyBase):
        name = 'second'

    MyBase.main('bar', args=['first', 'blah', '--foobar'])
    assert isinstance(cmd, FirstCmd)
    assert cmd.args.foobar
    assert cmd.args.required == 'blah'
    MyBase.main('bar', args=['second'])
    assert isinstance(cmd, SecondCmd)


def test_shortest_prefix():
    cmd = None

    class MyBase(Command):
        description = 'foo'

        def run(self):
            nonlocal cmd
            cmd = self

    class FirstCmd(MyBase):
        name = 'first'

    class SecondCmd(MyBase):
        name = 'second'

    class FindCmd(MyBase):
        name = 'find'

    class AppCmd(MyBase):
        name = 'app'

    class ApplyCmd(MyBase):
        name = 'apply'

    cases = [
        ('f', None),
        ('fi', None),
        ('fir', FirstCmd),
        ('fin', FindCmd),
        ('first', FirstCmd),
        ('a', None),
        ('ap', None),
        ('app', AppCmd),
        ('appl', ApplyCmd),
        ('apply', ApplyCmd),
        ('s', SecondCmd),
    ]
    for arg, cls in cases:
        try:
            MyBase.main('blah', args=[arg], shortest_prefix=True)
            assert isinstance(cmd, cls)
        except SystemExit:
            assert cls is None


def test_shortest_prefix_disabled():
    class MyBase(Command):
        description = 'foo'

        def run(self):
            pass

    class FirstCmd(MyBase):
        name = 'first'

    class SecondCmd(MyBase):
        name = 'second'

    with pytest.raises(SystemExit):
        MyBase.main('blah', args=['fir'])


def test_shortest_prefix_dup():
    class MyBase(Command):
        description = 'foo'

        def run(self):
            pass

    class FirstCmd(MyBase):
        name = 'first'

    class SecondCmd(MyBase):
        name = 'first'

    with pytest.raises(ValueError):
        MyBase.main('blah', args=['fir'], shortest_prefix=True)


def test_default():
    cmd = None

    class MyBase(Command):
        description = 'foo'

        def run(self):
            nonlocal cmd
            cmd = self

    class FirstCmd(MyBase):
        name = 'first'

    class SecondCmd(MyBase):
        name = 'second'

    MyBase.main('blah', args=[], default='second')
    assert isinstance(cmd, SecondCmd)


def test_nodefault():
    cmd = None

    class MyBase(Command):
        description = 'foo'

        def run(self):
            nonlocal cmd
            cmd = self

    class FirstCmd(MyBase):
        name = 'first'

    class SecondCmd(MyBase):
        name = 'second'

    with pytest.raises(Exception):
        MyBase.main('blah', args=[])


def test_intermediate_not_included():
    cmd = None

    class MyBase(Command):
        description = 'foo'

        def run(self):
            nonlocal cmd
            cmd = self

    class Intermediate(MyBase):
        name = 'intermediate'

        def add_args(self, parser):
            parser.add_argument('--foo', action='store_true')

    class SecondCmd(Intermediate):
        name = 'second'

    with pytest.raises(SystemExit):
        MyBase.main('blah', args=['intermediate'])

A tox.ini => tox.ini +16 -0
@@ 0,0 1,16 @@
[tox]
envlist = py36,py39

[testenv]
deps = -rrequirements-dev.txt
commands =
    pytest tests --cov=subc --cov=tests {posargs} --cov-report html

#[testenv:docs]
#description = invoke sphinx-build to build the HTML docs
#basepython = python3.8
#deps =
#    sphinx
#    sphinx-autorun
#commands = sphinx-build -d "{toxworkdir}/docs_doctree" doc "{toxworkdir}/docs_out" --color -W -bhtml {posargs}
#           python -c 'import pathlib; print("documentation available under file://\{0\}".format(pathlib.Path(r"{toxworkdir}") / "docs_out" / "index.html"))'