diff --git a/README.md b/README.md index b368b7e..1d8f681 100644 --- a/README.md +++ b/README.md @@ -178,6 +178,53 @@ response = client({ response = client.vectorize(description=description, image=image) ``` +## CLI like [cURL](https://curl.se/docs/manpage.html) + +Aside from the Python `Client`, we provide an easy-to-use Command Line Interface, which comes with `pip install ujrpc`. +It allow you to call a remote server, upload files, with direct support for images and NumPy arrays. +Translating previous example into a Bash script, to call the server on the same machine: + +```sh +ujrpc vectorize description='Product description' -i image=./local/path.png +``` + +To address a remote server: + +```sh +ujrpc vectorize description='Product description' -i image=./local/path.png --uri 0.0.0.0 -p 8545 +``` + +To print the docs, use `ujrpc -h`: + +```txt +usage: ujrpc [-h] [--uri URI] [--port PORT] [-f [FILE ...]] [-i [IMAGE ...]] [--positional [POSITIONAL ...]] method [kwargs ...] + +UJRPC Client CLI + +positional arguments: + method method name + kwargs method arguments + +options: + -h, --help show this help message and exit + --uri URI server uri + --port PORT server port + -f [FILE ...], --file [FILE ...] + method positional arguments + -i [IMAGE ...], --image [IMAGE ...] + method positional arguments + --positional [POSITIONAL ...] + method positional arguments +``` + +You can also explicitly annotate types, to distinguish integers, floats, and strings, to avoid ambiguity. + +``` +ujrpc auth id=256 +ujrpc auth id:int=256 +ujrpc auth id:str=256 +``` + ## Free Tier Throughput We will leave bandwidth measurements to enthusiasts, but will share some more numbers. diff --git a/setup.py b/setup.py index 9b73306..8211f59 100644 --- a/setup.py +++ b/setup.py @@ -122,6 +122,11 @@ def run(self): cmdclass={ 'build_ext': CMakeBuild, }, + entry_points={ + 'console_scripts': [ + 'ujrpc=ujrpc.cli:cli' + ] + }, install_requires=[ 'numpy>=1.16', 'pillow' diff --git a/src/ujrpc/cli.py b/src/ujrpc/cli.py new file mode 100644 index 0000000..e26e976 --- /dev/null +++ b/src/ujrpc/cli.py @@ -0,0 +1,115 @@ +from pydoc import locate +from typing import Optional +import json +import argparse + +from PIL import Image + +from ujrpc.client import Client + + +def get_kwargs(buffer): + kwargs = {} + if buffer is not None: + for arg in buffer: + sp = None + if '=' in arg: + sp = arg.split('=') + else: + raise KeyError('Missing key in kwarg argument') + kwargs[sp[0]] = sp[1] + return kwargs + + +def cast(value: str, type_name: Optional[str]): + """Casts a single argument value to the expected `type_name` or guesses it.""" + if type_name is None: + if value.isdigit(): + return int(value) + if value.replace('.', '', 1).isdigit(): + return float(value) + if value in ['True', 'False']: + return bool(value) + return value + + type_name = type_name.lower() + if type_name == 'image': + return Image.open(value) + if type_name == 'binary': + return open(value, 'rb').read() + + return locate(type_name)(value) + + +def fix_types(args, kwargs): + """Casts `args` and `kwargs` to expected types.""" + for i in range(len(args)): + if ':' in args[i]: + val, tp = args[i].split(':') + args[i] = cast(val, tp) + else: + args[i] = cast(args[i], None) + keys = list(kwargs.keys()) + for k in keys: + if ':' in k: + key, tp = k.split(':') + val = kwargs.pop(k) + kwargs[key] = cast(val, tp) + else: + kwargs[k] = cast(kwargs[k], None) + + +def add_specials(kwargs: dict, special: Optional[list[str]], type_name: str): + if special is None: + return + for x in special: + if not '=' in x: + raise KeyError(f'Missing key in {type_name} argument') + k, v = x.split('=') + kwargs[k + ':' + type_name] = v + + +def cli(): + parsed = get_parser().parse_args() + kwargs = get_kwargs(parsed.kwargs) + args = parsed.positional if parsed.positional else [] + + add_specials(kwargs, parsed.file, 'binary') + add_specials(kwargs, parsed.image, 'image') + + fix_types(args, kwargs) + client = Client(uri=parsed.uri, port=parsed.port, use_http=True) + res = getattr(client, parsed.method)(*args, **kwargs) + + if parsed.format == 'raw': + print(json.dumps(res.data, indent=4)) + else: + try: + print(getattr(res, parsed.format)) + except Exception as err: + print('Error:', err) + + +def get_parser(): + parser = argparse.ArgumentParser(description='UJRPC Client CLI') + parser.add_argument('method', type=str, help='method name') + + parser.add_argument('--uri', type=str, + help='Server uri', default='localhost') + parser.add_argument('-p', '--port', type=int, + help='Server port', default=8545) + + parser.add_argument('kwargs', nargs='*', help='KEY[:TYPE]=VALUE arguments') + parser.add_argument('-f', '--file', nargs='*', help='Binary Files') + parser.add_argument('-i', '--image', nargs='*', help='Image Files') + + parser.add_argument('--positional', nargs='*', + help='Switch to positional arguments VALUE[:TYPE]') + + parser.add_argument('--format', type=str, choices=[ + 'json', 'bytes', 'numpy', 'image', 'raw'], help='How to parse response', default='raw') + return parser + + +if __name__ == '__main__': + cli() diff --git a/src/ujrpc/client.py b/src/ujrpc/client.py index 20d4999..a2e6cfe 100644 --- a/src/ujrpc/client.py +++ b/src/ujrpc/client.py @@ -106,6 +106,9 @@ def _pack_numpy(self, array): buf.seek(0) return base64.b64encode(buf.getvalue()).decode() + def _pack_bytes(self, buffer): + return base64.b64encode(buffer).decode() + def _pack_pillow(self, image): buf = BytesIO() if not image.format: @@ -128,6 +131,9 @@ def pack(self, req): elif isinstance(req['params'][k], Image.Image): req['params'][k] = self._pack_pillow(req['params'][k]) + elif isinstance(req['params'][k], bytes): + req['params'][k] = self._pack_bytes(req['params'][k]) + return req