Package for wrapping applications inside a tailored interactive shell.
Project description
riposte
Riposte allows you to easily wrap your application inside a tailored interactive shell. Common chores regarding building REPLs was factored out and being taken care of so you can really focus on specific domain logic of your application.
The motivation for building Riposte coming from many sleepless nights of handling numerous tricky cases regarding REPLs during routersploit development. Like every other project it began very innocently but after a while, when the project got some real traction and code base was rapidly growing, shell logic started to intertwine with domain logic making things less and less readable and contributor friendly.
Moreover, to our surprise, people started to fork routersploit not because they were interested in the security of embedded devices but simply because they want to leverage our interactive shell logic and build their own tools using similar concept. All these years they must have said: "There must be a better way!" and they were completely right, the better way is called Riposte.
Table of contents
Getting Started
Installing
The package is available on PyPI so please use pip to install it:
pip install riposte
Riposte supports Python 3.6 and newer.
Example usage
from riposte import Riposte
calculator = Riposte(prompt="calc:~$ ")
MEMORY = []
@calculator.command("add")
def add(x: int, y: int):
result = f"{x} + {y} = {x + y}"
MEMORY.append(result)
calculator.success(result)
@calculator.command("multiply")
def multiply(x: int, y: int):
result = f"{x} * {y} = {x * y}"
MEMORY.append(result)
calculator.success(result)
@calculator.command("memory")
def memory():
for entry in MEMORY:
calculator.print(entry)
calculator.run()
calc:~$ add 2 2
[+] 2 + 2 = 4
calc:~$ multiply 3 3
[+] 3 * 3 = 9
calc:~$ memory
2 + 2 = 4
3 * 3 = 9
calc:~$
Manual
Command
First and foremost you want to register some commands to make your REPL
actionable. Adding command and bounding it with handling function is possible
through Riposte.command
decorator.
from riposte import Riposte
repl = Riposte()
@repl.command("hello")
def hello():
repl.success("Is it me you're looking for?")
repl.run()
riposte:~ $ hello
[+] Is it me you're looking for?
Additionally Riposte.command
accepts few optional parameters:
description
few words describing command which you can later use to build meaningful helpguides
definition of how to interpret passed arguments
Completer
Riposte
comes with support for tab-completion for commands. You can register
completer function in a similar way you registering commands, just use Riposte.complete
decorator and point it to a specific command.
from riposte import Riposte
repl = Riposte()
START_SUBCOMMANDS = ["foo", "bar"]
@repl.command("start")
def start(subcommand: str):
if subcommand in START_SUBCOMMANDS:
repl.status(f"{subcommand} started")
else:
repl.error("Unknown subcommand.")
@repl.complete("start")
def start_completer(text, line, start_index, end_index):
return [
subcommand
for subcommand in START_SUBCOMMANDS
if subcommand.startswith(text)
]
repl.run()
Completer function is triggered by the TAB key. Every completer function should return list of valid options and should accept the following parameters:
text
last word in the lineline
content of the whole linestart_index
starting index of the last word in the lineend_index
ending index of the last word in the line
So in the case of our example:
riposte:~ $ start ba<TAB>
text -> "ba"
line -> "start ba"
start_index -> 6
end_index -> 8
Equipped with this information you can build your custom completer functions for every command.
Guides
Guides is a way of saying how command should interpret arguments
passed by the user via prompt. Riposte
rely on
type-hints in order to do that.
from riposte import Riposte
repl = Riposte()
@repl.command("guideme")
def guideme(x: int, y: str):
repl.print("x:", x, type(x))
repl.print("y:", y, type(y))
repl.run()
riposte:~ $ guideme 1 1
x: 1 <class 'int'>
y: 1 <class 'str'>
In both cases we've passed value 1 as x
and y
. Based on
parameter's type-hint passed arguments was interpreted as int
in case of x
and as str
in case of y
. You can also use this technique for different types.
from riposte import Riposte
repl = Riposte()
@repl.command("guideme")
def guideme(x: dict, y: list):
x["foo"] = "bar"
repl.print("x:", x, type(x))
y.append("foobar")
repl.print("y:", y, type(y))
repl.run()
riposte:~ $ guideme "{'bar': 'baz'}" "['barbaz']"
x: {'bar': 'baz', 'foo': 'bar'} <class 'dict'>
y: ['barbaz', 'foobar'] <class 'list'>
Another more powerful way of defining guides for handling function parameters
is defining it straight fromRiposte.command
decorator. In this case guide
defined this way take precedence over the type hints.
from riposte import Riposte
repl = Riposte()
@repl.command("guideme", guides={"x": [int]})
def guideme(x):
repl.print("x:", x, type(x))
repl.run()
riposte:~ $ guideme 1
x: 1 <class 'int'>
Why it is more powerful? Because this way you can chain different guides, where output of one guide is input for another, creating validation or cast input into more complex types.
from collections import namedtuple
from riposte import Riposte
from riposte.exceptions import RiposteException
from riposte.guides import literal
repl = Riposte()
def non_negative(value: int):
if value < 0:
raise RiposteException("Value can't be negative")
return value
Point = namedtuple("Point", ("x", "y"))
def get_point(value: dict):
return Point(**value)
@repl.command("guideme",
guides={"x": [int, non_negative], "y": [literal, get_point]})
def guideme(x, y):
repl.print("x:", x, type(x))
repl.print("y:", y, type(y))
repl.run()
riposte:~ $ guideme -1 '{"x": 1, "y": 2}'
[-] Value can't be negative
riposte:~ $ guideme 1 '{"x": 1, "y": 2}'
x: 1 <class 'int'>
y: Point(x=1, y=2) <class '__main__.Point'>
riposte:~ $
Under the hood, it is a simple function call where the input string is passed to first guide function in the chain. In this case, the call looks like this:
non_negative(int("-1")) # guide chain for parameter `x`
get_point(literal('{"x": 1, "y": 2}')) # guide chain for parameter `y`
Printing
Riposte comes with built-in thread safe printing methods:
print
info
error
status
success
Every method follows the signature of Python's built-in
print()
function.
Besides print
all of them provide informative coloring corresponding to its name.
We strongly encourage to stick to our thread safe printing API but if you are
feeling frisky, know what you are doing and you are 100% sure, that threaded
execution is something that will never come up at some point in the lifecycle of
you application feel free to use Python's built-in
print()
function.
Extending PrinterMixin
If you want to change the styling of existing methods or add custom one, please
extend PrinterMixin
class.
from riposte import Riposte
from riposte.printer.mixins import PrinterMixin
class ExtendedPrinterMixin(PrinterMixin):
def success(self, *args, **kwargs): # overwriting existing method
self.print(*args, **kwargs)
def shout(self, *args, **kwargs): # adding new one
self.print((*args, "!!!"), **kwargs)
class CustomRiposte(Riposte, ExtendedPrinterMixin):
pass
repl = CustomRiposte()
@repl.command("foobar")
def foobar(message: str):
repl.shout(message)
Customizing PrinterMixin
Not happy about existing printing API? No problem, you can also build your own
from scratch using PrinterBaseMixin
and its thread safe _print
method.
from riposte import Riposte
from riposte.printer.mixins import PrinterBaseMixin
class CustomPrinterMixin(PrinterBaseMixin):
def ask(self, *args, **kwargs): # adding new one
self._print((*args, "???"), **kwargs)
def shout(self, *args, **kwargs): # adding new one
self._print((*args, "!!!"), **kwargs)
class CustomRiposte(Riposte, CustomPrinterMixin):
pass
repl = CustomRiposte()
@repl.command("foobar")
def foobar(message: str):
repl.shout(message)
repl.ask(message)
repl.success(message) # It'll raise exception as it's no longer available
Coloring output with Pallete
If you feel like adding a few colors to the output you can always use Pallete
.
from riposte import Riposte
from riposte.printer import Palette
repl = Riposte()
@repl.command("foo")
def foo(msg: str):
repl.print(Palette.GREEN.format(msg)) # It will be green
Pallete
goes with the following output formattings:
GREY
RED
GREEN
YELLOW
BLUE
MAGENTA
CYAN
WHITE
BOLD
History
Command history is stored in your HOME directory in .riposte
file.
The default length is 100 lines. Both settings can be changed using
history_file
and history_length
parameters.
from pathlib import Path
from riposte import Riposte
repl = Riposte(
history_file=Path.home() / ".custom_history_file",
history_length=500,
)
Prompt
The default prompt is riposte:~ $
but you can easily customize it:
from riposte import Riposte
repl = Riposte(prompt="custom-prompt >>> ")
repl.run()
You can also dynamically resolve prompt layout based on the state of some object
simply by overwriting Riposte.prompt
property. In the following example, we'll
determine prompt based on MODULE
value:
from riposte import Riposte
class Application:
def __init__(self):
self.module = None
class CustomRiposte(Riposte):
@property
def prompt(self):
if app.module:
return f"foo:{app.module} > "
else:
return self._prompt # reference to `prompt` parameter.
app = Application()
repl = CustomRiposte(prompt="foo > ")
@repl.command("set")
def set_module(module_name: str):
app.module = module_name
repl.success("Module has been set.")
@repl.command("unset")
def unset_module():
app.module = None
repl.success("Module has been unset.")
repl.run()
foo > set bar
[+] Module has been set.
foo:bar > unset
[+] Module has been unset.
foo >
Banner
# banner.py
from riposte import Riposte
BANNER = """ _ _ _ _ _ _ _ _ _
| | | | | | | | | | | | | | | |
| |_| | ___| | | ___ | | | | ___ _ __| | __| | |
| _ |/ _ \ | |/ _ \ | |/\| |/ _ \| '__| |/ _` | |
| | | | __/ | | (_) | \ /\ / (_) | | | | (_| |_|
\_| |_/\___|_|_|\___/ \/ \/ \___/|_| |_|\__,_(_)
Welcome User Hello World v1.2.3
"""
repl = Riposte(banner=BANNER)
@repl.command("hello")
def hello():
repl.print("Hello World!")
repl.run()
$ python banner.py
_ _ _ _ _ _ _ _ _
| | | | | | | | | | | | | | | |
| |_| | ___| | | ___ | | | | ___ _ __| | __| | |
| _ |/ _ \ | |/ _ \ | |/\| |/ _ \| '__| |/ _` | |
| | | | __/ | | (_) | \ /\ / (_) | | | | (_| |_|
\_| |_/\___|_|_|\___/ \/ \/ \___/|_| |_|\__,_(_)
Welcome User Hello World v1.2.3
riposte:~ $
If for some reason you don't want to display banner (Maybe you have custom
input stream?) you can set Riposte.print_banner
attribute
to False
.
Inline command execution
Similarly to the bash
if you delimit commands with semicolon you can trigger
execution of multiple commands in one line.
riposte:~ $ hello; hello; hello
[+] Is it me you're looking for?
[+] Is it me you're looking for?
[+] Is it me you're looking for?
Riposte
also exposes CLI for your applications which gives you the ability to
pass commands using -c
switch:
$ python app.py -c "hello; hello; hello;"
[+] Is it me you're looking for?
[+] Is it me you're looking for?
[+] Is it me you're looking for?
$
Given all of this, you can also start to treat your application as something that could be turned into automated scripts.
CLI
If you application needs custom CLI arguments Riposte gives you way to
implement it by overwriting Riposte.setup_cli()
method. Let's say you want to
introduce --verbose
flag into your application:
# custom_cli_args.py
from riposte import Riposte
class CustomArgsRiposte(Riposte):
def setup_cli(self):
super().setup_cli() # preserve default Riposte CLI
self.parser.add_argument(
"-v", "--verbose", action="store_true", help="Verbose mode"
)
repl = CustomArgsRiposte()
@repl.command("foo")
def foo(bar: str):
repl.success("Foobar executed.")
if repl.arguments.verbose:
repl.success("Argument passed as bar: ", bar)
repl.run()
$ python custom_cli_args.py -v
riposte:~ $ foo 123
[+] Foobar executed.
[+] Argument passed as bar: 123
riposte:~ $
Riposte.parser
is an instance of Python's builtin argparse.ArgumentParser
so for all further instructions regarding adding CLI arguments please follow
argparse
documentation.
Passed arguments are being parsed in Riposte.run()
and stored in
Riposte.arguments
so you can access it within your application. If you need
to access them before entering the main evaluation loop you can overwrite
Riposte.parse_cli_arguments()
from riposte import Riposte
class CustomArgsRiposte(Riposte):
def setup_cli(self):
super().setup_cli() # preserve default Riposte CLI
self.parser.add_argument(
"-v", "--verbose", action="store_true", help="Verbose mode"
)
def parse_cli_arguments(self):
super().parse_cli_arguments() # preserve default Riposte CLI
if self.arguments.verbose:
do_something_specific()
Input streams
The input stream is an abstraction telling how you feed Riposte with commands. Right now you can use following ones out of the box.
Prompt
Default one which allows you input commands using the traditional prompt.
CLI
Riposte
also exposes CLI for your applications which gives you the ability
to pass commands using -c
switch:
$ python app.py -c "hello; hello; hello;"
[+] Is it me you're looking for?
[+] Is it me you're looking for?
[+] Is it me you're looking for?
File
You can also pass text file containing commands as an argument to your application:
# demo.py
from riposte import Riposte
repl = Riposte()
@repl.command("hello")
def hello():
repl.print("Is it me you're looking for?")
repl.run()
commands.rpst
text file containing commands to be executed:
hello
hello
hello
$ python demo.py commands.rpst
[+] Is it me you're looking for?
[+] Is it me you're looking for?
[+] Is it me you're looking for?
Adding custom input stream
If for some reason you need a custom way of feeding Riposte with commands
you can always add your custom input stream. The input stream is a generator
that yields function which after calling it returns a string (the command)
Generator[Callable[[], str], None, None]
. Let's say you are an evil genius
and want to make your interactive shell application less interactive by
feeding it with some kind of messaging system.
import itertools
from typing import Callable, Generator
from riposte import Riposte
from some.messaging.system import Subscriber
def some_messaging_system_input_stream(
subscriber: Subscriber # you can parametrize your input streams
) -> Generator[Callable, None, None]:
# itertools.repeat() make sure that your input stream runs forever
yield from itertools.repeat(subscriber.poll) # calling poll() will return command
class CustomInputStreamRiposte(Riposte):
def setup_cli(self):
super().setup_cli() # preserve default Riposte CLI
self.parser.add_argument(
"-h", "--host", help="Some messaging system address"
)
def parse_cli_arguments(self) -> None:
super().parse_cli_arguments() # preserve default Riposte CLI
if self.arguments.host:
subscriber = Subscriber(self.arguments.host)
self.input_stream = some_messaging_system_input_stream(subscriber)
self.print_banner = False # I guess you don't want to print banner
Project status
Riposte is under development. It might be considered to be in beta phase. There might be some breaking changes in the future although a lot of concepts present here was already battle-tested during routersploit development.
Contributing
Please read CONTRIBUTING.md for details on our code of conduct, and the process for submitting pull requests to us.
Versioning
Project uses SemVer versioning. For the versions available, see the releases.
License
Riposte is licensed under the MIT License - see the LICENSE file for details
Acknowledgments
Project details
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
File details
Details for the file riposte-0.5.1.tar.gz
.
File metadata
- Download URL: riposte-0.5.1.tar.gz
- Upload date:
- Size: 25.9 kB
- Tags: Source
- Uploaded using Trusted Publishing? No
- Uploaded via: twine/5.0.0 CPython/3.12.1
File hashes
Algorithm | Hash digest | |
---|---|---|
SHA256 | 3fdd92596c27c4ee71ad20fbe1cbc2b09e174616abac4f9df8369e6537d61fa5 |
|
MD5 | 3d5e4a04be1e6b365d6cd0ccd8c324e3 |
|
BLAKE2b-256 | 411114d77b1ea897a79aaa270daaf6653b05b84ebc2a20a6ad46322f45eb89d7 |
File details
Details for the file riposte-0.5.1-py3-none-any.whl
.
File metadata
- Download URL: riposte-0.5.1-py3-none-any.whl
- Upload date:
- Size: 16.4 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? No
- Uploaded via: twine/5.0.0 CPython/3.12.1
File hashes
Algorithm | Hash digest | |
---|---|---|
SHA256 | 3976037ac6ac6186b861a4747b1b938c6d307f24f4ed65715813d3a560385e43 |
|
MD5 | 9288700128d7a547482ea7a311b2a7ab |
|
BLAKE2b-256 | 9eaa1be94639cfd418777b2adf1c10434e405dd44ea229f19b797491425a918f |