Source code for viper.project

"""Extending the Command-line Interface (using ``viperfile.py``)
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~

Why and How
^^^^^^^^^^^

The viper CLI can easily be extended to include custom subcommands using
the :py:mod:`viper.project` module.

To do this, you have to create a file named ``viperfile.py`` in the root
of your workspace. This file will contain the definition(s) of one or multiple
projects. A project works like a namespace for all the custom subcommands under it.


Example: Defining a Project
^^^^^^^^^^^^^^^^^^^^^^^^^^^

This is how a project can be defined in ``viperfile.py``:

.. code-block:: python

    from viper.project import Project, arg

    foo = Project("foo")


The :py:func:`viper.project.arg` function helps defining the command-line
arguments a.k.a options or switches that the subcommand expects.

Let's define a subcommand ``@foo:group1`` that expects optional arguments
``--login_name`` and ``--identity_file`` with some default values
and returns the text representation of a :py:class:`viper.Hosts` object.


Example: Defining a subcommand for host group
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^

.. code-block:: python

    from viper import Host, Hosts, meta

    @foo.hostgroup(
        args=[
            arg("-l", "--login_name", default="root"),
            arg("-i", "--identity_file", default="/root/.ssh/id_rsa.pub"),
        ]
    )
    def group1(args):
        return Hosts.from_items(
            Host(
                ip="192.168.0.11",
                hostname="host11"
                login_name="root",
                identity_file=args.identity_file,
                meta=meta(provider="aws"),
            ),
            Host(
                ip="192.168.0.12",
                hostname="host12",
                login_name="root",
                identity_file=args.identity_file,
                meta=meta(provider="aws"),
            )
        )

Now running ``viper -h`` in that workspace will show us ``@foo:group1  [Hosts]``,
and running ``viper @foo:group1 --help`` will list the arguments it's expecting
and their default values.

The subcommand can now be executed as below:

.. code-block:: bash

    # Use the default values
    viper @foo:group1

    # Specify the login name and identity file
    viper @foo:group1 -l user1 -i ~user1/.ssh/id_rsa.pub


.. note::

    All the custom subcommands are prefixed with ``@`` to separate them from the
    core viper subcommands. And the string following ``@`` acts like a namespace
    that separates the subcommands belonging from different projects in the same
    viperfile.
"""

from __future__ import annotations
from argparse import ArgumentParser
from argparse import Namespace
from collections.abc import Iterable
from dataclasses import dataclass
from dataclasses import field
from viper.cli_base import SubCommand
from viper.collections import Collection as ViperCollection
from viper.collections import Hosts
from viper.collections import Results

import typing as t

T = t.TypeVar("T")
C = t.TypeVar("C")
ArgType = t.Tuple[t.Tuple[str, ...], t.Dict[str, t.Any]]
HostsFuncType = t.Callable[[Namespace], Hosts]
HandlerFuncType = t.Callable[[T, Namespace], C]
JobFuncType = t.Callable[[Hosts, Namespace], Results]
ActionFuncType = t.Callable[[Namespace], T]


[docs]def arg(*args: str, **kwargs: t.Any) -> ArgType: """Argumenst to be passed to argparse""" return args, kwargs
[docs]@dataclass class Project: """A project (namespace) for the viper sub commands :param str prefix: Prefix for the related sub commands. When we define a project, we basically define a namespace (a prefix) for the commands. """ prefix: str action_commands: t.List[t.Type[SubCommand]] = field(default_factory=lambda: []) hostgroup_commands: t.List[t.Type[SubCommand]] = field(default_factory=lambda: []) filter_commands: t.List[t.Type[SubCommand]] = field(default_factory=lambda: []) handler_commands: t.List[t.Type[SubCommand]] = field(default_factory=lambda: []) job_commands: t.List[t.Type[SubCommand]] = field(default_factory=lambda: [])
[docs] def all_commands(self) -> t.List[t.Type[SubCommand]]: """Return all sub commands""" return ( self.action_commands + self.hostgroup_commands + self.filter_commands + self.handler_commands + self.job_commands )
[docs] def hostgroup( self, args: t.Optional[t.Sequence[ArgType]] = None ) -> t.Callable[[HostsFuncType], HostsFuncType]: """Use this decorator to define host groups :param list args (optional): Arguments to be parsed by py:class:`argparse.ArgumentParser` """ def wrapper(func: HostsFuncType) -> HostsFuncType: doc = func.__doc__.splitlines()[0] if func.__doc__ else "" class HostGroupCommand(SubCommand): __doc__ = f"[Hosts] {doc}" name = f"@{self.prefix}:{func.__name__}" def add_arguments(self, parser: ArgumentParser) -> None: if args: for arg in args: parser.add_argument(*arg[0], **arg[1]) parser.add_argument("-i", "--indent", type=int, default=None) def __call__(self, args: Namespace) -> int: print(func(args).to_json(indent=args.indent)) return 0 self.hostgroup_commands.append(HostGroupCommand) return func return wrapper
[docs] def handler( self, fromtype: type, totype: type, args: t.Optional[t.Sequence[ArgType]] = None, ) -> t.Callable[[HandlerFuncType[T, C]], HandlerFuncType[T, C]]: """Use this decorator to define handlers :param type fromtype: The type of object this handler is expecting. :param type totype: The type of object this handler returns. :param list args (optional): List of arguments for :py:class:`argparse.ArgumentParser`. """ if not issubclass(fromtype, ViperCollection): raise ValueError(f"{fromtype} does not have pipe option") def wrapper(func: HandlerFuncType[T, C]) -> HandlerFuncType[T, C]: doc = func.__doc__.splitlines()[0] if func.__doc__ else "" class HandlerCommand(SubCommand): __doc__ = f"[{fromtype.__name__} -> {totype.__name__}] {doc}" name = f"@{self.prefix}:{func.__name__}" def add_arguments(self, parser: ArgumentParser) -> None: if args: for arg in args: parser.add_argument(*arg[0], **arg[1]) parser.add_argument("-i", "--indent", type=int, default=None) def __call__(self, args: Namespace) -> int: if not issubclass(fromtype, ViperCollection): raise ValueError(f"{fromtype}: invalid fromtype") obj: C = fromtype.from_json(input()).pipe( lambda obj: func(obj, args) ) if not isinstance(obj, totype): raise ValueError( f"invalid totype, expected {totype} but got {type(obj)}" ) if isinstance(obj, ViperCollection): print(obj.to_json(indent=args.indent)) else: print(obj) return 0 self.handler_commands.append(HandlerCommand) return func return wrapper
[docs] def job( self, args: t.Optional[t.Sequence[ArgType]] = None, ) -> t.Callable[[JobFuncType], JobFuncType]: """Use this decorator to define a job. :param list args (optional): List of arguments for :py:class:`argparse.ArgumentParser`. """ def wrapper(func: JobFuncType) -> JobFuncType: doc = func.__doc__.splitlines()[0] if func.__doc__ else "" class JobCommand(SubCommand): __doc__ = f"[Hosts -> Results] {doc}" name = f"@{self.prefix}:{func.__name__}" def add_arguments(self, parser: ArgumentParser) -> None: if args: for arg in args: parser.add_argument(*arg[0], **arg[1]) parser.add_argument("-i", "--indent", type=int, default=None) def __call__(self, args: Namespace) -> int: results = func(Hosts.from_json(input()), args) if not isinstance(results, Results): raise ValueError( f"a job must return {Results} object but got {type(results)}" ) print(results.to_json(indent=args.indent)) return 0 self.job_commands.append(JobCommand) return func return wrapper
[docs] def action( self, args: t.Optional[t.Sequence[ArgType]] = None, totype: t.Optional[type] = None, ) -> t.Callable[[ActionFuncType[T]], ActionFuncType[T]]: """Use this decorator to define an action. :param list args (optional): List of arguments for :py:class:`argparse.ArgumentParser`. """ def wrapper(func: ActionFuncType[T]) -> ActionFuncType[T]: doc = func.__doc__.splitlines()[0] if func.__doc__ else "" class ActionCommand(SubCommand): __doc__ = f"[{totype.__name__}] {doc}" if totype else doc name = f"@{self.prefix}:{func.__name__}" def add_arguments(self, parser: ArgumentParser) -> None: if args: for arg in args: parser.add_argument(*arg[0], **arg[1]) def __call__(self, args: Namespace) -> int: res = func(args) if res is None: if totype is None: return 0 else: raise ValueError(f"expected an object of type {totype}") if totype is None: raise ValueError( f"not expecting any result, but got a result of type {type(res)}" ) if not isinstance(res, totype): raise ValueError( f"expected result of type {totype} but got result of type {type(res)}" ) if isinstance(res, ViperCollection): print(res.to_json()) elif isinstance(res, Iterable): print("\n".join(map(str, res))) else: print(res) return 0 self.action_commands.append(ActionCommand) return func return wrapper