From 23edb2d1acda8b060b7a46a6079ce5f505be1b5c Mon Sep 17 00:00:00 2001 From: Reid Orsten Date: Wed, 14 May 2025 08:53:37 -0600 Subject: [PATCH 1/7] doc-updates --- README.md | 9 ++- doc/callable_serialization.md | 98 +++++++++++++++++++++++ doc/index.md | 0 doc/transformers.md | 0 src/cli_wrapper/__init__.py | 6 +- src/cli_wrapper/cli_wrapper.py | 63 ++++++++++----- src/cli_wrapper/transformers.py | 1 - src/cli_wrapper/util/callable_registry.py | 10 +-- tests/pre_packaged/test_kubectl.py | 10 +++ 9 files changed, 166 insertions(+), 31 deletions(-) create mode 100644 doc/callable_serialization.md create mode 100644 doc/index.md create mode 100644 doc/transformers.md diff --git a/README.md b/README.md index f77fac1..22b5f7c 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,8 @@ # CLI Wrapper -![Codecov](https://img.shields.io/codecov/c/github/orstensemantics/cli_wrapper) +[![Codecov](https://img.shields.io/codecov/c/github/orstensemantics/cli_wrapper)](https://app.codecov.io/gh/orstensemantics/cli_wrapper) ![PyPI - License](https://img.shields.io/pypi/l/cli_wrapper) -![PyPI - Version](https://img.shields.io/pypi/v/cli_wrapper) +[![PyPI - Version](https://img.shields.io/pypi/v/cli_wrapper)](https://pypi.org/project/cli-wrapper) CLI Wrapper uses subprocess to wrap external CLI tools and present an interface that looks more like a python class. CLI @@ -104,5 +104,6 @@ pip install dotted_dict # for dotted_dict support shown above - [ ] Configuration dictionaries for common tools - [x] kubectl - [x] helm - - [ ] docker - - [x] cilium \ No newline at end of file + - [x] docker + - [x] cilium + - [ ] ... \ No newline at end of file diff --git a/doc/callable_serialization.md b/doc/callable_serialization.md new file mode 100644 index 0000000..44a7020 --- /dev/null +++ b/doc/callable_serialization.md @@ -0,0 +1,98 @@ +# Callable serialization + +Argument validation and parser configuration are not straightforward to serialize. To get around this, CLI Wrapper uses +`CallableRegistry` and `CallableChain`. These make it somewhat more straightforward to create more serializable wrapper +configurations. + +### TL;DR +- Functions that perform validation, argument transformation, or output parsing are registered with a name in a + `CallableRegistry` +- `CallableChain` resolves a serializable structure to a sequence of calls to those functions + - a string refers to a function, which will be called directly + - a dict is expected to have one key (the function name), with a value that provides additional configuration: + - a string as a single positional arg + - a list of positional args + - a dict of kwargs (the key "args" will be popped and used as positional args if present) + - a list of the previous two + + +- A list of validators is treated as a set of conditions which must be true +- A list of parsers will be piped together in sequence +- Transformers receive an arg name and value, and return another arg and value. They are not chained. + +Here's how these work: + +## `CallableRegistry` + +Callable registries form the basis of serializing callables by mapping strings to functions. If you are doing custom +parsers and validators and you want these to be serializable, you will use their respective callable registries to +associate the code with the serializable name. + +### `CallableRegistry.register(name: str, callable_: callable, group="core")` + +- `name`: the string to associate with the callable, or `group.name`. If you specify a group in the name and in the + kwarg, it will raise a `KeyError`. +- `callable_`: the callable itself +- `group`: the group to add the callable to. The default, "core", contains all of the prepackaged callables. + +Once the callable is registered, it can be retrieved with `get`. + +### `CallableRegistry.register_group(name: str, callables: dict = None)` + +If you already have a dictionary of things you want to register, this is a shorthand. + +### `CallableRegistry.get(self, name: str | Callable, args=None, kwargs=None)` + +This function will return a lambda that takes `*nargs` and calls the registered callable `name` (or `name` itself if +it's callable) with `*nargs, *args, **kwargs`. This is probably best explained by example: + +```python + +def greater_than(a, b): + return a > b + + +registry = CallableRegistry( + { + "core" = {} + } +) +registry.register("gt", greater_than) + +x = registry.get("gt", [2]) + +assert(not x(1)) +assert(x(3)) +``` + +## `CallableChain` + +A callable chain is a serializable structure that gets converted to a sequence of calls to things in a +`CallableRegistry`. It is an abstract base class, and so shouldn't be created directly; subclasses are expected to +implement `__call__`. We'll use the `Validator` class as an example. `validators` is a `CallableRegistry` with all of +the base validators (`is_dict`, `is_list`, `is_str`, `startswith`...) + +```python +# Say we have these validators that we want to run: +def every_letter_is(v, l): + return all((x == l.lower()) or (x == l.upper()) for x in v) + +validators.register("every_letter_is", every_letter_is) + +my_validation = ["is_str", {"every_letter_is": "a"}] + +straight_as = Validator(my_validation) +assert(straight_as("aaaaAAaa")) +assert(not straight_as("aaaababa")) +``` + +`Validator.__call__` just checks that every validation returns true. Elsewhere, `Parser` pipes inputs in sequence: + +```yaml +parser: + - yaml + - extract: result +``` + +This would first parse the output as yaml and then extract the "result" key from the dictionary returned by the yaml +step. \ No newline at end of file diff --git a/doc/index.md b/doc/index.md new file mode 100644 index 0000000..e69de29 diff --git a/doc/transformers.md b/doc/transformers.md new file mode 100644 index 0000000..e69de29 diff --git a/src/cli_wrapper/__init__.py b/src/cli_wrapper/__init__.py index d032bbc..42a272f 100644 --- a/src/cli_wrapper/__init__.py +++ b/src/cli_wrapper/__init__.py @@ -1,5 +1,9 @@ +""" +Wraps CLI tools and presents a python object-like interface. +""" from .cli_wrapper import CLIWrapper from .transformers import transformers from .parsers import parsers +from .validators import validators -__all__ = ["CLIWrapper", "transformers", "parsers"] +__all__ = ["CLIWrapper", "transformers", "parsers", "validators"] diff --git a/src/cli_wrapper/cli_wrapper.py b/src/cli_wrapper/cli_wrapper.py index d8c03fd..c3eda53 100644 --- a/src/cli_wrapper/cli_wrapper.py +++ b/src/cli_wrapper/cli_wrapper.py @@ -1,7 +1,7 @@ """ CLIWrapper represents calls to CLI tools as an object with native python function calls. For example: -``` python +`` from json import loads # or any other parser from cli_wrapper import CLIWrapper kubectl = CLIWrapper('kubectl') @@ -14,10 +14,10 @@ kubectl._update_command("get", default_flags={"output": "json"}, parse=loads) result = await kubectl.get("pods", namespace="kube-system") # same thing but async print(result) -``` +`` You can also override argument names and provide input validators: -``` python +`` from json import loads from cli_wrapper import CLIWrapper kubectl = CLIWrapper('kubectl') @@ -33,7 +33,7 @@ def validate_pod_name(name): ) kubectl._update_command("get", validators={1: validate_pod_name}) result = kubectl.get("pod", "my-pod!!") # raises ValueError -``` +`` Attributes: trusting: if false, only run defined commands, and validate any arguments that have validation. If true, run @@ -44,6 +44,8 @@ def validate_pod_name(name): that you don't want to bother defining. arg_separator: what to put between a flag and its value. default is '=', so `command(arg=val)` would translate to `command --arg=val`. If you want to use spaces instead, set this to ' ' + + """ import asyncio.subprocess @@ -52,6 +54,7 @@ def validate_pod_name(name): import subprocess from copy import copy from itertools import chain +from typing import Callable from attrs import define, field @@ -71,7 +74,7 @@ class Argument: literal_name: str | None = None default: str = None validator: Validator | str | dict | list[str | dict] = field(converter=Validator, default=None) - transformer: str = "snake2kebab" + transformer: Callable | str | dict | list[str | dict] = "snake2kebab" @classmethod def from_dict(cls, arg_dict): @@ -220,7 +223,7 @@ def build_args(self, *args, **kwargs): positional = copy(self.cli_command) if self.cli_command is not None else [] params = [] for arg, value in chain( - enumerate(args), kwargs.items(), [(k, v) for k, v in self.default_flags.items() if k not in kwargs] + enumerate(args), kwargs.items(), [(k, v) for k, v in self.default_flags.items() if k not in kwargs] ): logger.debug(f"arg: {arg}, value: {value}") if arg in self.args: @@ -247,13 +250,31 @@ def build_args(self, *args, **kwargs): @define class CLIWrapper: # pylint: disable=too-many-instance-attributes + """ + :param path: The path to the CLI tool. This will be passed to subprocess directly, and does not require a full path + unless the tool is not in the system path. + :param env: A dict of environment variables to be set in the subprocess environment, in addition to and overriding + those in os.environ. + :param trusting: If True, the wrapper will accept any command and pass them to the cli with default configuration. + Otherwise, it will only allow commands that have been defined with update_command_ + :param raise_exc: If True, the wrapper will raise an exception if a command returns a non-zero exit code. + :param async_: If true, the wrapper will return coroutines that must be awaited. + :param default_transformer: The transformer configuration to apply to all arguments. The default of snake2kebab will + convert pythonic_snake_case_kwargs to kebab-case-arguments + :param short_prefix: The string prefix for single-letter arguments + :param long_prefix: The string prefix for arguments longer than 1 letter + :param arg_separator: The character that separates argument values from names. Defaults to '=', so wrapper.command(arg=value) + would become "wrapper command --arg=value" + """ path: str env: dict[str, str] = None commands: dict[str, Command] = {} + """ If true, wrapper will take any commands and pass them to the CLI without further validation""" trusting: bool = True raise_exc: bool = False async_: bool = False + """ This is the transformer configuration that will be applied to all commands (unless those commands specify their own) """ default_transformer: str = "snake2kebab" short_prefix: str = "-" long_prefix: str = "--" @@ -279,18 +300,19 @@ def _get_command(self, command: str): return self.commands[command] def update_command_( # pylint: disable=too-many-arguments - self, - command: str, - *, - cli_command: str | list[str] = None, - args: dict[str | int, any] = None, - default_flags: dict = None, - parse=None, + self, + command: str, + *, + cli_command: str | list[str] = None, + args: dict[str | int, any] = None, + default_flags: dict = None, + parse=None, ): """ update the command to be run with the cli_wrapper :param command: the command name for the wrapper :param cli_command: the command to be run, if different from the command name + :param args: the arguments passed to the command :param default_flags: default flags to be used with the command :param parse: function to parse the output of the command :return: @@ -307,13 +329,6 @@ def update_command_( # pylint: disable=too-many-arguments ) def _run(self, command: str, *args, **kwargs): - """ - run the command with the cli_wrapper - :param command: the subcommand for the cli tool - :param args: arguments to be passed to the command - :param kwargs: flags to be passed to the command - :return: - """ command_obj = self._get_command(command) command_obj.validate_args(*args, **kwargs) command_args = [self.path] + command_obj.build_args(*args, **kwargs) @@ -354,6 +369,14 @@ def __getattr__(self, item, *args, **kwargs): return lambda *args, **kwargs: self._run(item, *args, **kwargs) def __call__(self, *args, **kwargs): + """ + Invokes the wrapper with no extra arguments. e.g., for the kubectl wrapper, calls bare kubectl. + `kubectl(help=True)` will be interpreted as "kubectl --help". + :param args: positional arguments to be passed to the command + :param kwargs: kwargs will be treated as `--options`. Boolean values will be bare flags, others will be + passed as `--kwarg=value` (where `=` is the wrapper's arg_separator) + :return: + """ return (self.__getattr__(None))(*args, **kwargs) @classmethod diff --git a/src/cli_wrapper/transformers.py b/src/cli_wrapper/transformers.py index a8c6752..76c5a2b 100644 --- a/src/cli_wrapper/transformers.py +++ b/src/cli_wrapper/transformers.py @@ -1,6 +1,5 @@ from .util.callable_registry import CallableRegistry - def snake2kebab(arg: str, value: any) -> tuple[str, any]: """ snake.gravity == 0 diff --git a/src/cli_wrapper/util/callable_registry.py b/src/cli_wrapper/util/callable_registry.py index 96612ac..6a08959 100644 --- a/src/cli_wrapper/util/callable_registry.py +++ b/src/cli_wrapper/util/callable_registry.py @@ -57,24 +57,24 @@ def register(self, name: str, callable_: callable, group="core"): raise KeyError(f"{self.callable_name} '{name}' already registered.") self._all[group][name] = callable_ - def register_group(self, name: str, parsers: dict = None): + def register_group(self, name: str, callables: dict = None): """ Registers a new parser group with the specified name. :param name: The name to associate with the parser group. - :param parsers: A dictionary of parsers to register in the group. + :param callables: A dictionary of parsers to register in the group. """ if name in self._all: raise KeyError(f"{self.callable_name} group '{name}' already registered.") if "." in name: raise KeyError(f"{self.callable_name} group name '{name}' is not valid.") - parsers = {} if parsers is None else parsers - bad_parser_names = [x for x in parsers.keys() if "." in x] + callables = {} if callables is None else callables + bad_parser_names = [x for x in callables.keys() if "." in x] if bad_parser_names: raise KeyError( f"{self.callable_name} group '{name}' contains invalid parser names: {', '.join(bad_parser_names)}" ) - self._all[name] = parsers + self._all[name] = callables def _parse_name(self, name: str) -> tuple[str, str]: """ diff --git a/tests/pre_packaged/test_kubectl.py b/tests/pre_packaged/test_kubectl.py index 1f31f9e..41fd578 100644 --- a/tests/pre_packaged/test_kubectl.py +++ b/tests/pre_packaged/test_kubectl.py @@ -1,3 +1,4 @@ +from pathlib import Path from shutil import which import pytest @@ -11,3 +12,12 @@ def test_kubectl_wrapper(): result = kubectl.config_get_contexts() assert result is not None + + +@pytest.mark.skipif(which("kubectl") is not None, reason="skipping fake kubectl test because real kubectl exists") +def test_kubectl_wrapper_fake(): + kubectl = get_wrapper("kubectl") + kubectl.path = (Path(__file__).parent.parent / "data" / "fake_kubectl").as_posix() + + result = kubectl.get_pods() + assert result is not None From 14fd4a0d3fcdab1e0f42ce2a7caa5a47980a4ecb Mon Sep 17 00:00:00 2001 From: Reid Orsten Date: Wed, 14 May 2025 09:05:57 -0600 Subject: [PATCH 2/7] pylint/black --- src/cli_wrapper/__init__.py | 1 + src/cli_wrapper/cli_wrapper.py | 25 ++++++++++++++----------- src/cli_wrapper/transformers.py | 1 + 3 files changed, 16 insertions(+), 11 deletions(-) diff --git a/src/cli_wrapper/__init__.py b/src/cli_wrapper/__init__.py index 42a272f..5f6bce1 100644 --- a/src/cli_wrapper/__init__.py +++ b/src/cli_wrapper/__init__.py @@ -1,6 +1,7 @@ """ Wraps CLI tools and presents a python object-like interface. """ + from .cli_wrapper import CLIWrapper from .transformers import transformers from .parsers import parsers diff --git a/src/cli_wrapper/cli_wrapper.py b/src/cli_wrapper/cli_wrapper.py index c3eda53..bbbd54b 100644 --- a/src/cli_wrapper/cli_wrapper.py +++ b/src/cli_wrapper/cli_wrapper.py @@ -223,7 +223,7 @@ def build_args(self, *args, **kwargs): positional = copy(self.cli_command) if self.cli_command is not None else [] params = [] for arg, value in chain( - enumerate(args), kwargs.items(), [(k, v) for k, v in self.default_flags.items() if k not in kwargs] + enumerate(args), kwargs.items(), [(k, v) for k, v in self.default_flags.items() if k not in kwargs] ): logger.debug(f"arg: {arg}, value: {value}") if arg in self.args: @@ -263,9 +263,10 @@ class CLIWrapper: # pylint: disable=too-many-instance-attributes convert pythonic_snake_case_kwargs to kebab-case-arguments :param short_prefix: The string prefix for single-letter arguments :param long_prefix: The string prefix for arguments longer than 1 letter - :param arg_separator: The character that separates argument values from names. Defaults to '=', so wrapper.command(arg=value) - would become "wrapper command --arg=value" + :param arg_separator: The character that separates argument values from names. Defaults to '=', so + wrapper.command(arg=value) would become "wrapper command --arg=value" """ + path: str env: dict[str, str] = None commands: dict[str, Command] = {} @@ -274,7 +275,9 @@ class CLIWrapper: # pylint: disable=too-many-instance-attributes trusting: bool = True raise_exc: bool = False async_: bool = False - """ This is the transformer configuration that will be applied to all commands (unless those commands specify their own) """ + """ + This is the transformer configuration that will be applied to all commands (unless those commands specify their own) + """ default_transformer: str = "snake2kebab" short_prefix: str = "-" long_prefix: str = "--" @@ -300,13 +303,13 @@ def _get_command(self, command: str): return self.commands[command] def update_command_( # pylint: disable=too-many-arguments - self, - command: str, - *, - cli_command: str | list[str] = None, - args: dict[str | int, any] = None, - default_flags: dict = None, - parse=None, + self, + command: str, + *, + cli_command: str | list[str] = None, + args: dict[str | int, any] = None, + default_flags: dict = None, + parse=None, ): """ update the command to be run with the cli_wrapper diff --git a/src/cli_wrapper/transformers.py b/src/cli_wrapper/transformers.py index 76c5a2b..a8c6752 100644 --- a/src/cli_wrapper/transformers.py +++ b/src/cli_wrapper/transformers.py @@ -1,5 +1,6 @@ from .util.callable_registry import CallableRegistry + def snake2kebab(arg: str, value: any) -> tuple[str, any]: """ snake.gravity == 0 From 6d9c5b854ab60bec98b9636bc1a74f7ccb9f1c60 Mon Sep 17 00:00:00 2001 From: Reid Orsten Date: Wed, 14 May 2025 09:16:09 -0600 Subject: [PATCH 3/7] stop validators after one fails --- src/cli_wrapper/validators.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/cli_wrapper/validators.py b/src/cli_wrapper/validators.py index 61e6ae3..513ee10 100644 --- a/src/cli_wrapper/validators.py +++ b/src/cli_wrapper/validators.py @@ -47,6 +47,10 @@ def __call__(self, value): validator_result = x(value) logger.debug(f"Validator {c} result: {validator_result}") result = result and validator_result + if not result: + # don't bother doing other validations once one has failed + logger.debug("...failed") + break return result def to_dict(self): From 8f15986fcaab4c0491b6d5227218888df654be51 Mon Sep 17 00:00:00 2001 From: Reid Orsten Date: Tue, 20 May 2025 10:28:30 -0600 Subject: [PATCH 4/7] initial transformer docs --- doc/transformers.md | 58 +++++++++++++++++++++++++++++++++++++++ tests/test_cli_wrapper.py | 10 +++++++ 2 files changed, 68 insertions(+) diff --git a/doc/transformers.md b/doc/transformers.md index e69de29..8766abc 100644 --- a/doc/transformers.md +++ b/doc/transformers.md @@ -0,0 +1,58 @@ +# Transformers + +Argument transformers receive an argument (either a numbered positional argument or a string keywork argument/flag) and +a value. They return a tuple of argument and value that replace the original. + +The main transformer used by cli-wrapper is `snake2kebab`, which converts a `an_argument_like_this` to +`an-argument-like-this` and returns the value unchanged. This is the default transformer for all keyword arguments. + +Transformers are added to a callable registry, so they can be refernced as a string after they're registered. +Transformers are not currently chained. + +## Other possibilities for transformers + +### 1. Write dictionaries to files and return a flag referencing a file + +Consider a command like `kubectl create`: the primary argument is a filename or list of files. Say you have your +manifest to create as a dictionary: + +```python +from pathlib import Path +from ruamel.yaml import YAML +from cli_wrapper import transformers, CLIWrapper + +manifest_count = 0 +base_filename = "my_manifest" +base_dir = Path() +y = YAML() +def write_manifest(manifest: dict | list[dict]): + global manifest_count + manifest_count += 1 + file = base_dir / f"{base_filename}_{manifest_count}.yaml" + with file.open("w") as f: + if isinstance(manifest, list): + y.dump_all(manifest, f) + else: + y.dump(manifest, f) + return file.as_posix() + +def manifest_transformer(arg, value, writer=write_manifest): + return "filename", writer(value) + +transformers.register("manifest", manifest_transformer) + +# If you had different writer functions (e.g., different base name), you could register those as partials: +from functools import partial +transformers.register("other_manifest", partial(manifest_transformer, writer=my_other_writer)) + +kubectl = CLIWrapper('kubectl') +kubectl.update_command_("create", args={"data": {"transformer": "manifest"}}) + +# will write the manifest to "my_manifest_1.yaml" and execute `kubectl create -f my_manifest_1.yaml` +kubectl.create(data=my_kubernetes_manifest) +``` + +## Possible future changes + +- it might make sense to make transformers a [`CallableChain`](callable_serialization.md#callablechain) similar to parser so a sequence of things can be done on an arg +- it might also make sense to support transformers that break individual args into multiple args with separate values diff --git a/tests/test_cli_wrapper.py b/tests/test_cli_wrapper.py index af127ae..8ae6165 100644 --- a/tests/test_cli_wrapper.py +++ b/tests/test_cli_wrapper.py @@ -21,6 +21,16 @@ def test_argument(self): with pytest.raises(KeyError): Argument("test", validator="not callable") + def is_invalid(value): + return value == "invalid" + validators.register("is_invalid", is_invalid) + + arg = Argument("test", default="default", validator=is_invalid) + assert arg.is_valid("valid") is False + assert arg.is_valid("invalid") is True + + validators._all["core"].pop("is_invalid") + def test_argument_from_dict(self): arg = Argument.from_dict({"literal_name": "test", "default": "default", "validator": lambda x: x == "valid"}) From 6c9d9d497b476ccc21acc64f8eff00188d390052 Mon Sep 17 00:00:00 2001 From: Reid Orsten Date: Wed, 21 May 2025 09:22:09 -0600 Subject: [PATCH 5/7] refactors for pdoc mostly --- doc/callable_serialization.md | 30 +- doc/index.md | 0 doc/parsers.md | 45 + doc/pre_packaged.md | 90 ++ doc/transformers.md | 2 +- doc/validators.md | 61 + docs/cli_wrapper.html | 633 ++++++++ docs/cli_wrapper/cli_wrapper.html | 1698 +++++++++++++++++++++ docs/cli_wrapper/parsers.html | 561 +++++++ docs/cli_wrapper/pre_packaged.html | 322 ++++ docs/cli_wrapper/transformers.html | 328 ++++ docs/cli_wrapper/util.html | 743 +++++++++ docs/cli_wrapper/validators.html | 475 ++++++ docs/index.html | 7 + docs/search.js | 46 + src/cli_wrapper/__init__.py | 60 +- src/cli_wrapper/cli_wrapper.py | 124 +- src/cli_wrapper/parsers.py | 24 +- src/cli_wrapper/pre_packaged/__init__.py | 8 +- src/cli_wrapper/transformers.py | 12 +- src/cli_wrapper/util/__init__.py | 4 + src/cli_wrapper/util/callable_chain.py | 12 +- src/cli_wrapper/util/callable_registry.py | 43 +- src/cli_wrapper/validators.py | 23 +- src/help_parser/__main__.py | 16 +- tests/test_cli_wrapper.py | 17 +- 26 files changed, 5227 insertions(+), 157 deletions(-) delete mode 100644 doc/index.md create mode 100644 doc/parsers.md create mode 100644 doc/pre_packaged.md create mode 100644 doc/validators.md create mode 100644 docs/cli_wrapper.html create mode 100644 docs/cli_wrapper/cli_wrapper.html create mode 100644 docs/cli_wrapper/parsers.html create mode 100644 docs/cli_wrapper/pre_packaged.html create mode 100644 docs/cli_wrapper/transformers.html create mode 100644 docs/cli_wrapper/util.html create mode 100644 docs/cli_wrapper/validators.html create mode 100644 docs/index.html create mode 100644 docs/search.js diff --git a/doc/callable_serialization.md b/doc/callable_serialization.md index 44a7020..814c315 100644 --- a/doc/callable_serialization.md +++ b/doc/callable_serialization.md @@ -4,7 +4,7 @@ Argument validation and parser configuration are not straightforward to serializ `CallableRegistry` and `CallableChain`. These make it somewhat more straightforward to create more serializable wrapper configurations. -### TL;DR +## TL;DR - Functions that perform validation, argument transformation, or output parsing are registered with a name in a `CallableRegistry` - `CallableChain` resolves a serializable structure to a sequence of calls to those functions @@ -20,32 +20,16 @@ configurations. - A list of parsers will be piped together in sequence - Transformers receive an arg name and value, and return another arg and value. They are not chained. +## Implementation + Here's how these work: -## `CallableRegistry` +### `CallableRegistry` Callable registries form the basis of serializing callables by mapping strings to functions. If you are doing custom parsers and validators and you want these to be serializable, you will use their respective callable registries to associate the code with the serializable name. -### `CallableRegistry.register(name: str, callable_: callable, group="core")` - -- `name`: the string to associate with the callable, or `group.name`. If you specify a group in the name and in the - kwarg, it will raise a `KeyError`. -- `callable_`: the callable itself -- `group`: the group to add the callable to. The default, "core", contains all of the prepackaged callables. - -Once the callable is registered, it can be retrieved with `get`. - -### `CallableRegistry.register_group(name: str, callables: dict = None)` - -If you already have a dictionary of things you want to register, this is a shorthand. - -### `CallableRegistry.get(self, name: str | Callable, args=None, kwargs=None)` - -This function will return a lambda that takes `*nargs` and calls the registered callable `name` (or `name` itself if -it's callable) with `*nargs, *args, **kwargs`. This is probably best explained by example: - ```python def greater_than(a, b): @@ -65,11 +49,11 @@ assert(not x(1)) assert(x(3)) ``` -## `CallableChain` +### `CallableChain` A callable chain is a serializable structure that gets converted to a sequence of calls to things in a -`CallableRegistry`. It is an abstract base class, and so shouldn't be created directly; subclasses are expected to -implement `__call__`. We'll use the `Validator` class as an example. `validators` is a `CallableRegistry` with all of +`cli_wrapper.util.callable_registry.CallableRegistry`. It is an abstract base class, and so shouldn't be created directly; subclasses are expected to +implement `__call__`. We'll use the `.validators.Validator` class as an example. `validators` is a `CallableRegistry` with all of the base validators (`is_dict`, `is_list`, `is_str`, `startswith`...) ```python diff --git a/doc/index.md b/doc/index.md deleted file mode 100644 index e69de29..0000000 diff --git a/doc/parsers.md b/doc/parsers.md new file mode 100644 index 0000000..df05d1e --- /dev/null +++ b/doc/parsers.md @@ -0,0 +1,45 @@ +# Parsers + +Parsers provide a mechanism to convert the output of a CLI tool into a usable structure. They make use of +`cli_wrapper.util.callable_chain.CallableChain` to be serializable-ish. + +## Default Parsers + +1. `json`: uses `json.loads` to parse stdout +2. `extract`: extracts data from the raw output, using the args as a list of nested keys. +3. `yaml`: if `ruamel.yaml` is installed, uses `YAML().load_all` to read stdout. If `load_all` only returns one + document, it returns that document. Otherwise, it returns a list of documents. `pyyaml` is also supported. +4. `dotted_dict`: if `dotted_dict` is installed, converts an input dict or list to a `PreserveKeysDottedDict` or + a list of them. This lets you refer to most dictionary keys as `a.b.c` instead of `a["b"]["c"]`. + +These can be combined in a list in the `parse` argument to `cli_wrapper.cli_wrapper.CLIWrapper.update_command_`, +allowing the result of the call to be immediately usable. + +You can also register your own parsers in `cli_wrapper.parsers.parsers`, which is a +`cli_wrapper.util.callable_registry.CallableRegistry`. + +## Example + +```python +from cli_wrapper import CLIWrapper + +def skip_lists(result): + if result["kind"] == "List": + return result["items"] + return result + +kubectl = CLIWrapper("kubectl") +# you can use the parser directly, but you won't be able to serialize the +# wrapper to json +kubectl.update_command_( + "get", + parse=["json", skip_lists, "dotted_dict"], + default_flags=["--output", "json"] +) + +a = kubectl.get("pods", namespace="kube-system") +assert isinstance(a, list) +b = kubectl.get("pods", a[0].metadata.name, namespace="kube-system") +assert isinstance(b, dict) +assert b.metadata.name == a[0].metadata.name +``` diff --git a/doc/pre_packaged.md b/doc/pre_packaged.md new file mode 100644 index 0000000..7f8d5d5 --- /dev/null +++ b/doc/pre_packaged.md @@ -0,0 +1,90 @@ +# Pre-packaged CLIWrappers + +As a convenience, some pre-configured wrappers for common tools are included in the package. At the moment, none of +them are well tested, but they are complete in terms of commands and flags. They are generated by the help_parser tool +in this repo. + +## Usage + +```python +from cli_wrapper.pre_packaged import get_wrapper + +kubectl = get_wrapper("kubectl") +helm = get_wrapper("helm") +``` + +## Available Wrappers + +- `kubectl` +- `helm` +- `cilium` +- `docker` +- `terraform` + +## Help Parser and Why These Aren't Well Tested + +The help parser works by calling the tool with --help, parsing the output, and then calling the tool with `command --help` +help. The help parser takes some semi-obscure command line arguments, like default parsers: + +```shell +PYTHONPATH=src python -m help_parser helm --parser-default-pairs json:output=json +``` + +This will add `--output json` as a default flag and the json parser to every command that seems to support the +`--output` flag. And this usually works, except for fun cases like this one: + +``` +> kubectl config get-contexts --help +Display one or many contexts from the kubeconfig file. + +Examples: + # List all the contexts in your kubeconfig file + kubectl config get-contexts + + # Describe one context in your kubeconfig file + kubectl config get-contexts my-context + +Options: + --no-headers=false: + When using the default or custom-column output format, don't print headers (default print headers). + + -o, --output='': + Output format. One of: (name). + +Usage: + kubectl config get-contexts [(-o|--output=)name)] [options] + +Use "kubectl options" for a list of global command-line options (applies to all commands). +``` + +If you want to use the help parser yourself: + +``` +PYTHONPATH=src python -m help_parser --help +usage: __main__.py [-h] [--help-flag HELP_FLAG] [--style {golang,argparse}] [--default-flags DEFAULT_FLAGS [DEFAULT_FLAGS ...]] [--parser-default-pairs PARSER_DEFAULT_PAIRS [PARSER_DEFAULT_PAIRS ...]] [--default-separator DEFAULT_SEPARATOR] [--long-prefix LONG_PREFIX] [--output OUTPUT] command + +Parse CLI command help. + +positional arguments: + command The CLI command to parse. + +options: + -h, --help show this help message and exit + --help-flag HELP_FLAG + The flag to use for getting help (default: 'help'). + --style {golang,argparse} + The style of cli help output (default: 'golang'). + --default-flags DEFAULT_FLAGS [DEFAULT_FLAGS ...] + Default flags to add to the command, key=value pairs. + --parser-default-pairs PARSER_DEFAULT_PAIRS [PARSER_DEFAULT_PAIRS ...] + parser:key=value,... to configure default parsers. + --default-separator DEFAULT_SEPARATOR + Default separator to use for command arguments. + --long-prefix LONG_PREFIX + Default prefix for long flags. + --output OUTPUT, -o OUTPUT + Output file to save the parsed command. + +``` + +For the moment, argparse style isn't actually implemented, since no significant \ No newline at end of file diff --git a/doc/transformers.md b/doc/transformers.md index 8766abc..796f536 100644 --- a/doc/transformers.md +++ b/doc/transformers.md @@ -3,7 +3,7 @@ Argument transformers receive an argument (either a numbered positional argument or a string keywork argument/flag) and a value. They return a tuple of argument and value that replace the original. -The main transformer used by cli-wrapper is `snake2kebab`, which converts a `an_argument_like_this` to +The main transformer used by cli-wrapper is `cli_wrapper.transformers.snake2kebab`, which converts a `an_argument_like_this` to `an-argument-like-this` and returns the value unchanged. This is the default transformer for all keyword arguments. Transformers are added to a callable registry, so they can be refernced as a string after they're registered. diff --git a/doc/validators.md b/doc/validators.md new file mode 100644 index 0000000..dae6747 --- /dev/null +++ b/doc/validators.md @@ -0,0 +1,61 @@ +from curses import wrapper + +# Validators + +Validators are used to validate argument values. They are implemented as a +`cli_wrapper.util.callable_chain.CallableChain` for serialization. Callables in the chain are called with the value +sequentially, stopping at the first callable that returns False. + +## Default Validators + +The default validators are: + +- `is_dict` +- `is_list` +- `is_str` +- `is_str_or_list` +- `is_int` +- `is_float` +- `is_bool` +- `is_path` - is a `pathlib.Path` +- `is_alnum` - is alphanumeric +- `is_alpha` - is alphabetic +- `starts_alpha` - first digit is a letter +- `startswith` - checks if the string starts with a given prefix + +## Custom Validators + +You can register your own validators in `cli_wrapper.validators.validators`: + +1. Takes at most one positional argument +2. When configuring the validator, additional arguments can be supplied using a dictionary: + +```python +wrapper.update_command_("cmd", validators={"arg":["is_str", {"startswith": {"prefix": "prefix"}}]}) +# or +wrapper.update_command_("cmd", validators={"arg": ["is_str", {"startswith": "prefix"}]}) +``` +## Example + +```python +from cli_wrapper import CLIWrapper +from cli_wrapper.validators import validators + +def is_alnum_or_dash(value): + return all(c.isalnum() or c == "-" for c in value) +validators.register("is_alnum_or_dash", is_alnum_or_dash) + +kubectl = CLIWrapper("kubectl") +# 1 refers to the first positional argument, so in `kubectl.get("pods", "my-pod")` it would refer to `"my-pod"` +kubectl.update_command_("get", validators={ + 1: ["is_str", "is_alnum_or_dash", "starts_alpha"], +}) + +assert kubectl.get("pods", "my-pod") +threw = False +try: + kubectl.get("pods", "level-9000-pod!!") +except ValueError: + threw = True +assert threw +``` \ No newline at end of file diff --git a/docs/cli_wrapper.html b/docs/cli_wrapper.html new file mode 100644 index 0000000..d0a9729 --- /dev/null +++ b/docs/cli_wrapper.html @@ -0,0 +1,633 @@ + + + + + + + cli_wrapper API documentation + + + + + + + + + +
+
+

+cli_wrapper

+ +

CLIWrapper represents calls to CLI tools as an object with native python function calls.

+ +

Examples

+ +
from json import loads  # or any other parser
+from cli_wrapper import CLIWrapper
+kubectl = CLIWrapper('kubectl')
+kubectl._update_command("get", default_flags={"output": "json"}, parse=loads)
+# this will run `kubectl get pods --namespace kube-system --output json`
+result = kubectl.get("pods", namespace="kube-system")
+print(result)
+
+kubectl = CLIWrapper('kubectl', async_=True)
+kubectl._update_command("get", default_flags={"output": "json"}, parse=loads)
+result = await kubectl.get("pods", namespace="kube-system")  # same thing but async
+print(result)
+
+ +

You can also override argument names and provide input validators:

+ +
from json import loads
+from cli_wrapper import CLIWrapper
+kubectl = CLIWrapper('kubectl')
+kubectl._update_command("get_all", cli_command="get", default_flags={"output": "json", "A": None}, parse=loads)
+result = kubectl.get_all("pods")  # this will run `kubectl get pods -A --output json`
+print(result)
+
+def validate_pod_name(name):
+    return all(
+        len(name) < 253,
+        name[0].isalnum() and name[-1].isalnum(),
+        all(c.isalnum() or c in ['-', '.'] for c in name[1:-1])
+    )
+kubectl._update_command("get", validators={1: validate_pod_name})
+result = kubectl.get("pod", "my-pod!!")  # raises ValueError
+
+ +

Callable serialization

+ +

Argument validation and parser configuration are not straightforward to serialize. To get around this, CLI Wrapper uses +CallableRegistry and CallableChain. These make it somewhat more straightforward to create more serializable wrapper +configurations.

+ +

TL;DR

+ +
    +
  • Functions that perform validation, argument transformation, or output parsing are registered with a name in a +CallableRegistry
  • +
  • CallableChain resolves a serializable structure to a sequence of calls to those functions

    + +
      +
    • a string refers to a function, which will be called directly
    • +
    • a dict is expected to have one key (the function name), with a value that provides additional configuration: +
        +
      • a string as a single positional arg
      • +
      • a list of positional args
      • +
      • a dict of kwargs (the key "args" will be popped and used as positional args if present)
      • +
    • +
    • a list of the previous two
    • +
  • +
  • A list of validators is treated as a set of conditions which must be true

  • +
  • A list of parsers will be piped together in sequence
  • +
  • Transformers receive an arg name and value, and return another arg and value. They are not chained.
  • +
+ +

Implementation

+ +

Here's how these work:

+ +

CallableRegistry

+ +

Callable registries form the basis of serializing callables by mapping strings to functions. If you are doing custom +parsers and validators and you want these to be serializable, you will use their respective callable registries to +associate the code with the serializable name.

+ +
+
def greater_than(a, b):
+  return a > b
+
+
+registry = CallableRegistry(
+  {
+    "core" = {}
+  }
+)
+registry.register("gt", greater_than)
+
+x = registry.get("gt", [2])
+
+assert(not x(1))
+assert(x(3))
+
+
+ +

CallableChain

+ +

A callable chain is a serializable structure that gets converted to a sequence of calls to things in a +cli_wrapper.util.callable_registry.CallableRegistry. It is an abstract base class, and so shouldn't be created directly; subclasses are expected to +implement __call__. We'll use the .validators.Validator class as an example. validators is a CallableRegistry with all of +the base validators (is_dict, is_list, is_str, startswith...)

+ +
+
# Say we have these validators that we want to run:
+def every_letter_is(v, l):
+    return all((x == l.lower()) or (x == l.upper()) for x in v)
+
+validators.register("every_letter_is", every_letter_is)
+
+my_validation = ["is_str", {"every_letter_is": "a"}]
+
+straight_as = Validator(my_validation)
+assert(straight_as("aaaaAAaa"))
+assert(not straight_as("aaaababa"))
+
+
+ +

Validator.__call__ just checks that every validation returns true. Elsewhere, Parser pipes inputs in sequence:

+ +
+
parser:
+  - yaml
+  - extract: result 
+
+
+ +

This would first parse the output as yaml and then extract the "result" key from the dictionary returned by the yaml +step.

+ +

from curses import wrapper

+ +

Validators

+ +

Validators are used to validate argument values. They are implemented as a +cli_wrapper.util.callable_chain.CallableChain for serialization. Callables in the chain are called with the value +sequentially, stopping at the first callable that returns False.

+ +

Default Validators

+ +

The default validators are:

+ +
    +
  • is_dict
  • +
  • is_list
  • +
  • is_str
  • +
  • is_str_or_list
  • +
  • is_int
  • +
  • is_float
  • +
  • is_bool
  • +
  • is_path - is a pathlib.Path
  • +
  • is_alnum - is alphanumeric
  • +
  • is_alpha - is alphabetic
  • +
  • starts_alpha - first digit is a letter
  • +
  • startswith - checks if the string starts with a given prefix
  • +
+ +

Custom Validators

+ +

You can register your own validators in cli_wrapper.validators.validators:

+ +
    +
  1. Takes at most one positional argument
  2. +
  3. When configuring the validator, additional arguments can be supplied using a dictionary:
  4. +
+ +
+
wrapper.update_command_("cmd", validators={"arg":["is_str", {"startswith": {"prefix": "prefix"}}]})
+# or
+wrapper.update_command_("cmd", validators={"arg": ["is_str", {"startswith": "prefix"}]})
+
+
+ +

Example

+ +
+
from cli_wrapper import CLIWrapper
+from cli_wrapper.validators import validators
+
+def is_alnum_or_dash(value):
+    return all(c.isalnum() or c == "-" for c in value)
+validators.register("is_alnum_or_dash", is_alnum_or_dash)
+
+kubectl = CLIWrapper("kubectl")
+# 1 refers to the first positional argument, so in `kubectl.get("pods", "my-pod")` it would refer to `"my-pod"`
+kubectl.update_command_("get", validators={
+ 1: ["is_str", "is_alnum_or_dash", "starts_alpha"],
+})
+
+assert kubectl.get("pods", "my-pod")
+threw = False
+try:
+    kubectl.get("pods", "level-9000-pod!!")
+except ValueError:
+    threw = True
+assert threw
+
+
+ +

Parsers

+ +

Parsers provide a mechanism to convert the output of a CLI tool into a usable structure. They make use of +cli_wrapper.util.callable_chain.CallableChain to be serializable-ish.

+ +

Default Parsers

+ +
    +
  1. json: uses json.loads to parse stdout
  2. +
  3. extract: extracts data from the raw output, using the args as a list of nested keys.
  4. +
  5. yaml: if ruamel.yaml is installed, uses YAML().load_all to read stdout. If load_all only returns one +document, it returns that document. Otherwise, it returns a list of documents. pyyaml is also supported.
  6. +
  7. dotted_dict: if dotted_dict is installed, converts an input dict or list to a PreserveKeysDottedDict or +a list of them. This lets you refer to most dictionary keys as a.b.c instead of a["b"]["c"].
  8. +
+ +

These can be combined in a list in the parse argument to cli_wrapper.cli_wrapper.CLIWrapper.update_command_, +allowing the result of the call to be immediately usable.

+ +

You can also register your own parsers in cli_wrapper.parsers.parsers, which is a +cli_wrapper.util.callable_registry.CallableRegistry.

+ +

Examples

+ +
+
from cli_wrapper import CLIWrapper
+
+def skip_lists(result): 
+    if result["kind"] == "List":
+        return result["items"]
+    return result
+
+kubectl = CLIWrapper("kubectl")
+# you can use the parser directly, but you won't be able to serialize the
+# wrapper to json
+kubectl.update_command_(
+   "get",
+   parse=["json", skip_lists, "dotted_dict"],
+   default_flags=["--output", "json"]
+)
+
+a = kubectl.get("pods", namespace="kube-system")
+assert isinstance(a, list)
+b = kubectl.get("pods", a[0].metadata.name, namespace="kube-system")
+assert isinstance(b, dict)
+assert b.metadata.name == a[0].metadata.name
+
+
+ +

Transformers

+ +

Argument transformers receive an argument (either a numbered positional argument or a string keywork argument/flag) and +a value. They return a tuple of argument and value that replace the original.

+ +

The main transformer used by cli-wrapper is cli_wrapper.transformers.snake2kebab, which converts a an_argument_like_this to +an-argument-like-this and returns the value unchanged. This is the default transformer for all keyword arguments.

+ +

Transformers are added to a callable registry, so they can be refernced as a string after they're registered. +Transformers are not currently chained.

+ +

Other possibilities for transformers

+ +

1. Write dictionaries to files and return a flag referencing a file

+ +

Consider a command like kubectl create: the primary argument is a filename or list of files. Say you have your +manifest to create as a dictionary:

+ +
+
from pathlib import Path
+from ruamel.yaml import YAML
+from cli_wrapper import transformers, CLIWrapper
+
+manifest_count = 0
+base_filename = "my_manifest"
+base_dir = Path()
+y = YAML()
+def write_manifest(manifest: dict | list[dict]):
+    global manifest_count
+    manifest_count += 1
+    file = base_dir / f"{base_filename}_{manifest_count}.yaml"
+    with file.open("w") as f:
+        if isinstance(manifest, list):
+            y.dump_all(manifest, f)
+        else:
+            y.dump(manifest, f)
+    return file.as_posix()
+
+def manifest_transformer(arg, value, writer=write_manifest):
+    return "filename", writer(value)
+
+transformers.register("manifest", manifest_transformer)
+
+# If you had different writer functions (e.g., different base name), you could register those as partials:
+from functools import partial
+transformers.register("other_manifest", partial(manifest_transformer, writer=my_other_writer))
+
+kubectl = CLIWrapper('kubectl')
+kubectl.update_command_("create", args={"data": {"transformer": "manifest"}})
+
+# will write the manifest to "my_manifest_1.yaml" and execute `kubectl create -f my_manifest_1.yaml`
+kubectl.create(data=my_kubernetes_manifest)
+
+
+ +

Possible future changes

+ +
    +
  • it might make sense to make transformers a CallableChain similar to parser so a sequence of things can be done on an arg
  • +
  • it might also make sense to support transformers that break individual args into multiple args with separate values
  • +
+
+ + + + + +
 1"""
+ 2CLIWrapper represents calls to CLI tools as an object with native python function calls.
+ 3
+ 4# Examples
+ 5
+ 6```
+ 7from json import loads  # or any other parser
+ 8from cli_wrapper import CLIWrapper
+ 9kubectl = CLIWrapper('kubectl')
+10kubectl._update_command("get", default_flags={"output": "json"}, parse=loads)
+11# this will run `kubectl get pods --namespace kube-system --output json`
+12result = kubectl.get("pods", namespace="kube-system")
+13print(result)
+14
+15kubectl = CLIWrapper('kubectl', async_=True)
+16kubectl._update_command("get", default_flags={"output": "json"}, parse=loads)
+17result = await kubectl.get("pods", namespace="kube-system")  # same thing but async
+18print(result)
+19```
+20
+21You can also override argument names and provide input validators:
+22```
+23from json import loads
+24from cli_wrapper import CLIWrapper
+25kubectl = CLIWrapper('kubectl')
+26kubectl._update_command("get_all", cli_command="get", default_flags={"output": "json", "A": None}, parse=loads)
+27result = kubectl.get_all("pods")  # this will run `kubectl get pods -A --output json`
+28print(result)
+29
+30def validate_pod_name(name):
+31    return all(
+32        len(name) < 253,
+33        name[0].isalnum() and name[-1].isalnum(),
+34        all(c.isalnum() or c in ['-', '.'] for c in name[1:-1])
+35    )
+36kubectl._update_command("get", validators={1: validate_pod_name})
+37result = kubectl.get("pod", "my-pod!!")  # raises ValueError
+38```
+39.. include:: ../../doc/callable_serialization.md
+40
+41.. include:: ../../doc/validators.md
+42.. include:: ../../doc/parsers.md
+43.. include:: ../../doc/transformers.md
+44
+45"""
+46
+47# from .cli_wrapper import CLIWrapper
+48# from .transformers import transformers
+49# from .parsers import parsers
+50# from .validators import validators
+51# from .util import callable_chain, callable_registry
+52# from .pre_packaged import get_wrapper
+53
+54# __all__ = ["CLIWrapper", "get_wrapper", "transformers", "parsers", "validators"]
+55
+56"""
+57.. include:: ./util
+58"""
+
+ + +
+
+ + \ No newline at end of file diff --git a/docs/cli_wrapper/cli_wrapper.html b/docs/cli_wrapper/cli_wrapper.html new file mode 100644 index 0000000..b94c6dd --- /dev/null +++ b/docs/cli_wrapper/cli_wrapper.html @@ -0,0 +1,1698 @@ + + + + + + + cli_wrapper.cli_wrapper API documentation + + + + + + + + + +
+
+

+cli_wrapper.cli_wrapper

+ + + + + + +
  1import asyncio.subprocess
+  2import logging
+  3import os
+  4import subprocess
+  5from copy import copy
+  6from itertools import chain
+  7from typing import Callable
+  8
+  9from attrs import define, field
+ 10
+ 11from .parsers import Parser
+ 12from .transformers import transformers
+ 13from .validators import validators, Validator
+ 14
+ 15_logger = logging.getLogger(__name__)
+ 16
+ 17
+ 18@define
+ 19class Argument:
+ 20    """
+ 21    Argument represents a command line argument to be passed to the cli_wrapper
+ 22    """
+ 23
+ 24    literal_name: str | None = None
+ 25    """ @private """
+ 26    default: str = None
+ 27    """ @private """
+ 28    validator: Validator | str | dict | list[str | dict] = field(converter=Validator, default=None)
+ 29    """ @private """
+ 30    transformer: Callable | str | dict | list[str | dict] = "snake2kebab"
+ 31    """ @private """
+ 32
+ 33    @classmethod
+ 34    def from_dict(cls, arg_dict):
+ 35        """
+ 36        Create an Argument from a dictionary
+ 37        :param arg_dict: the dictionary to be converted
+ 38        :return: Argument object
+ 39        """
+ 40        return Argument(
+ 41            literal_name=arg_dict.get("literal_name", None),
+ 42            default=arg_dict.get("default", None),
+ 43            validator=arg_dict.get("validator", None),
+ 44            transformer=arg_dict.get("transformer", None),
+ 45        )
+ 46
+ 47    def to_dict(self):
+ 48        """
+ 49        Convert the Argument to a dictionary
+ 50        :return: the dictionary representation of the Argument
+ 51        """
+ 52        _logger.debug(f"Converting argument {self.literal_name} to dict")
+ 53        return {
+ 54            "literal_name": self.literal_name,
+ 55            "default": self.default,
+ 56            "validator": self.validator.to_dict() if self.validator is not None else None,
+ 57        }
+ 58
+ 59    def is_valid(self, value):
+ 60        """
+ 61        Validate the value of the argument
+ 62        :param value: the value to be validated
+ 63        :return: True if valid, False otherwise
+ 64        """
+ 65        _logger.debug(f"Validating {self.literal_name} with value {value}")
+ 66        return validators.get(self.validator)(value) if self.validator is not None else True
+ 67
+ 68    def transform(self, name, value, **kwargs):
+ 69        """
+ 70        Transform the name and value of the argument
+ 71        :param name: the name of the argument
+ 72        :param value: the value to be transformed
+ 73        :return: the transformed value
+ 74        """
+ 75        return (
+ 76            transformers.get(self.transformer)(name, value, **kwargs) if self.transformer is not None else (name, value)
+ 77        )
+ 78
+ 79
+ 80def _cli_command_converter(value: str | list[str]):
+ 81    if value is None:
+ 82        return []
+ 83    if isinstance(value, str):
+ 84        return [value]
+ 85    return value
+ 86
+ 87
+ 88def _arg_converter(value: dict):
+ 89    """
+ 90    Convert the value of the argument to a string
+ 91    :param value: the value to be converted
+ 92    :return: the converted value
+ 93    """
+ 94    value = value.copy()
+ 95    for k, v in value.items():
+ 96        if isinstance(v, str):
+ 97            v = {"validator": v}
+ 98        if isinstance(v, dict):
+ 99            if "literal_name" not in v:
+100                v["literal_name"] = k
+101            value[k] = Argument.from_dict(v)
+102        if isinstance(v, Argument):
+103            if v.literal_name is None:
+104                v.literal_name = k
+105    return value
+106
+107
+108@define
+109class Command:  # pylint: disable=too-many-instance-attributes
+110    """
+111    Command represents a command to be run with the cli_wrapper
+112    """
+113
+114    cli_command: list[str] | str = field(converter=_cli_command_converter)
+115    """ @private """
+116    default_flags: dict = {}
+117    """ @private """
+118    args: dict[str | int, any] = field(factory=dict, converter=_arg_converter)
+119    """ @private """
+120    parse: Parser = field(converter=Parser, default=None)
+121    """ @private """
+122    default_transformer: str = "snake2kebab"
+123    """ @private """
+124    short_prefix: str = field(repr=False, default="-")
+125    """ @private """
+126    long_prefix: str = field(repr=False, default="--")
+127    """ @private """
+128    arg_separator: str = field(repr=False, default="=")
+129    """ @private """
+130
+131    @classmethod
+132    def from_dict(cls, command_dict, **kwargs):
+133        """
+134        Create a Command from a dictionary
+135        :param command_dict: the dictionary to be converted
+136        :return: Command object
+137        """
+138        command_dict = command_dict.copy()
+139        if "args" in command_dict:
+140            for k, v in command_dict["args"].items():
+141                if isinstance(v, dict):
+142                    if "literal_name" not in v:
+143                        v["literal_name"] = k
+144                if isinstance(v, Argument):
+145                    if v.literal_name is None:
+146                        v.literal_name = k
+147        if "cli_command" not in command_dict:
+148            command_dict["cli_command"] = kwargs.pop("cli_command", None)
+149        return Command(
+150            **command_dict,
+151            **kwargs,
+152        )
+153
+154    def to_dict(self):
+155        """
+156        Convert the Command to a dictionary.
+157        Excludes prefixes/separators, because they are set in the CLIWrapper
+158        :return: the dictionary representation of the Command
+159        """
+160        _logger.debug(f"Converting command {self.cli_command} to dict")
+161        return {
+162            "cli_command": self.cli_command,
+163            "default_flags": self.default_flags,
+164            "args": {k: v.to_dict() for k, v in self.args.items()},
+165            "parse": self.parse.to_dict() if self.parse is not None else None,
+166        }
+167
+168    def validate_args(self, *args, **kwargs):
+169        # TODO: validate everything and raise comprehensive exception instead of just the first one
+170        for name, arg in chain(enumerate(args), kwargs.items()):
+171            _logger.debug(f"Validating arg {name} with value {arg}")
+172            if name in self.args:
+173                _logger.debug("Argument found in args")
+174                v = self.args[name].is_valid(arg)
+175                if isinstance(name, int):
+176                    name += 1  # let's call positional arg 0, "Argument 1"
+177                if isinstance(v, str):
+178                    raise ValueError(
+179                        f"Value '{arg}' is invalid for command {' '.join(self.cli_command)} arg {name}: {v}"
+180                    )
+181                if not v:
+182                    raise ValueError(f"Value '{arg}' is invalid for command {' '.join(self.cli_command)} arg {name}")
+183
+184    def build_args(self, *args, **kwargs):
+185        positional = copy(self.cli_command) if self.cli_command is not None else []
+186        params = []
+187        for arg, value in chain(
+188            enumerate(args), kwargs.items(), [(k, v) for k, v in self.default_flags.items() if k not in kwargs]
+189        ):
+190            _logger.debug(f"arg: {arg}, value: {value}")
+191            if arg in self.args:
+192                literal_arg = self.args[arg].literal_name if self.args[arg].literal_name is not None else arg
+193                arg, value = self.args[arg].transform(literal_arg, value)
+194            else:
+195                arg, value = transformers.get(self.default_transformer)(arg, value)
+196            _logger.debug(f"after: arg: {arg}, value: {value}")
+197            if isinstance(arg, str):
+198                prefix = self.long_prefix if len(arg) > 1 else self.short_prefix
+199                if value is not None and not isinstance(value, bool):
+200                    if self.arg_separator != " ":
+201                        params.append(f"{prefix}{arg}{self.arg_separator}{value}")
+202                    else:
+203                        params.extend([f"{prefix}{arg}", value])
+204                else:
+205                    params.append(f"{prefix}{arg}")
+206            else:
+207                positional.append(value)
+208        result = positional + params
+209        _logger.debug(result)
+210        return result
+211
+212
+213@define
+214class CLIWrapper:  # pylint: disable=too-many-instance-attributes
+215    """
+216    :param path: The path to the CLI tool. This will be passed to subprocess directly, and does not require a full path
+217      unless the tool is not in the system path.
+218    :param env: A dict of environment variables to be set in the subprocess environment, in addition to and overriding
+219      those in os.environ.
+220    :param trusting: If True, the wrapper will accept any command and pass them to the cli with default configuration.
+221      Otherwise, it will only allow commands that have been defined with `update_command_`
+222    :param raise_exc: If True, the wrapper will raise an exception if a command returns a non-zero exit code.
+223    :param async_: If true, the wrapper will return coroutines that must be awaited.
+224    :param default_transformer: The transformer configuration to apply to all arguments. The default of snake2kebab will
+225      convert pythonic_snake_case_kwargs to kebab-case-arguments
+226    :param short_prefix: The string prefix for single-letter arguments
+227    :param long_prefix: The string prefix for arguments longer than 1 letter
+228    :param arg_separator: The character that separates argument values from names. Defaults to '=', so
+229      wrapper.command(arg=value) would become "wrapper command --arg=value"
+230    """
+231
+232    path: str
+233    """ @private """
+234    env: dict[str, str] = None
+235    """ @private """
+236    _commands: dict[str, Command] = {}
+237    """ @private """
+238
+239    trusting: bool = True
+240    """ @private """
+241    raise_exc: bool = False
+242    """ @private """
+243    async_: bool = False
+244    """ @private """
+245    default_transformer: str = "snake2kebab"
+246    """ @private """
+247    short_prefix: str = "-"
+248    """ @private """
+249    long_prefix: str = "--"
+250    """ @private """
+251    arg_separator: str = "="
+252    """ @private """
+253
+254    def _get_command(self, command: str):
+255        """
+256        get the command from the cli_wrapper
+257        :param command: the command to be run
+258        :return:
+259        """
+260        if command not in self._commands:
+261            if not self.trusting:
+262                raise ValueError(f"Command {command} not found in {self.path}")
+263            c = Command(
+264                cli_command=command,
+265                default_transformer=self.default_transformer,
+266                short_prefix=self.short_prefix,
+267                long_prefix=self.long_prefix,
+268                arg_separator=self.arg_separator,
+269            )
+270            return c
+271        return self._commands[command]
+272
+273    def update_command_(  # pylint: disable=too-many-arguments
+274        self,
+275        command: str,
+276        *,
+277        cli_command: str | list[str] = None,
+278        args: dict[str | int, any] = None,
+279        default_flags: dict = None,
+280        parse=None,
+281    ):
+282        """
+283        update the command to be run with the cli_wrapper
+284        :param command: the command name for the wrapper
+285        :param cli_command: the command to be run, if different from the command name
+286        :param args: the arguments passed to the command
+287        :param default_flags: default flags to be used with the command
+288        :param parse: function to parse the output of the command
+289        :return:
+290        """
+291        self._commands[command] = Command(
+292            cli_command=command if cli_command is None else cli_command,
+293            args=args if args is not None else {},
+294            default_flags=default_flags if default_flags is not None else {},
+295            parse=parse,
+296            default_transformer=self.default_transformer,
+297            short_prefix=self.short_prefix,
+298            long_prefix=self.long_prefix,
+299            arg_separator=self.arg_separator,
+300        )
+301
+302    def _run(self, command: str, *args, **kwargs):
+303        command_obj = self._get_command(command)
+304        command_obj.validate_args(*args, **kwargs)
+305        command_args = [self.path] + command_obj.build_args(*args, **kwargs)
+306        env = os.environ.copy().update(self.env if self.env is not None else {})
+307        _logger.debug(f"Running command: {' '.join(command_args)}")
+308        # run the command
+309        result = subprocess.run(command_args, capture_output=True, text=True, env=env, check=self.raise_exc)
+310        if result.returncode != 0:
+311            raise RuntimeError(f"Command {command} failed with error: {result.stderr}")
+312        return command_obj.parse(result.stdout)
+313
+314    async def _run_async(self, command: str, *args, **kwargs):
+315        command_obj = self._get_command(command)
+316        command_obj.validate_args(*args, **kwargs)
+317        command_args = [self.path] + list(command_obj.build_args(*args, **kwargs))
+318        env = os.environ.copy().update(self.env if self.env is not None else {})
+319        _logger.debug(f"Running command: {', '.join(command_args)}")
+320        proc = await asyncio.subprocess.create_subprocess_exec(  # pylint: disable=no-member
+321            *command_args,
+322            stdout=asyncio.subprocess.PIPE,
+323            stderr=asyncio.subprocess.PIPE,
+324            env=env,
+325        )
+326
+327        stdout, stderr = await proc.communicate()
+328        if proc.returncode != 0:
+329            raise RuntimeError(f"Command {command} failed with error: {stderr.decode()}")
+330        return command_obj.parse(stdout.decode())
+331
+332    def __getattr__(self, item, *args, **kwargs):
+333        """
+334        get the command from the cli_wrapper
+335        :param item: the command to be run
+336        :return:
+337        """
+338        if self.async_:
+339            return lambda *args, **kwargs: self._run_async(item, *args, **kwargs)
+340        return lambda *args, **kwargs: self._run(item, *args, **kwargs)
+341
+342    def __call__(self, *args, **kwargs):
+343        """
+344        Invokes the wrapper with no extra arguments. e.g., for the kubectl wrapper, calls bare kubectl.
+345        `kubectl(help=True)` will be interpreted as "kubectl --help".
+346        :param args: positional arguments to be passed to the command
+347        :param kwargs: kwargs will be treated as `--options`. Boolean values will be bare flags, others will be
+348          passed as `--kwarg=value` (where `=` is the wrapper's arg_separator)
+349        :return:
+350        """
+351        return (self.__getattr__(None))(*args, **kwargs)
+352
+353    @classmethod
+354    def from_dict(cls, cliwrapper_dict):
+355        """
+356        Create a CLIWrapper from a dictionary
+357        :param cliwrapper_dict: the dictionary to be converted
+358        :return: CLIWrapper object
+359        """
+360        cliwrapper_dict = cliwrapper_dict.copy()
+361        commands = {}
+362        command_config = {
+363            "arg_separator": cliwrapper_dict.get("arg_separator", "="),
+364            "default_transformer": cliwrapper_dict.get("default_transformer", "snake2kebab"),
+365            "short_prefix": cliwrapper_dict.get("short_prefix", "-"),
+366            "long_prefix": cliwrapper_dict.get("long_prefix", "--"),
+367        }
+368        for command, config in cliwrapper_dict.pop("commands", {}).items():
+369            if isinstance(config, str):
+370                config = {"cli_command": config}
+371            else:
+372                if "cli_command" not in config:
+373                    config["cli_command"] = command
+374                config = command_config | config
+375            commands[command] = Command.from_dict(config)
+376
+377        return CLIWrapper(
+378            _commands=commands,
+379            **cliwrapper_dict,
+380        )
+381
+382    def to_dict(self):
+383        """
+384        Convert the CLIWrapper to a dictionary
+385        :return: a dictionary that can be used to recreate the wrapper using `from_dict`
+386        """
+387        return {
+388            "path": self.path,
+389            "env": self.env,
+390            "commands": {k: v.to_dict() for k, v in self._commands.items()},
+391            "trusting": self.trusting,
+392            "async_": self.async_,
+393            "default_transformer": self.default_transformer,
+394            "short_prefix": self.short_prefix,
+395            "long_prefix": self.long_prefix,
+396            "arg_separator": self.arg_separator,
+397        }
+
+ + +
+
+ +
+
@define
+ + class + Argument: + + + +
+ +
19@define
+20class Argument:
+21    """
+22    Argument represents a command line argument to be passed to the cli_wrapper
+23    """
+24
+25    literal_name: str | None = None
+26    """ @private """
+27    default: str = None
+28    """ @private """
+29    validator: Validator | str | dict | list[str | dict] = field(converter=Validator, default=None)
+30    """ @private """
+31    transformer: Callable | str | dict | list[str | dict] = "snake2kebab"
+32    """ @private """
+33
+34    @classmethod
+35    def from_dict(cls, arg_dict):
+36        """
+37        Create an Argument from a dictionary
+38        :param arg_dict: the dictionary to be converted
+39        :return: Argument object
+40        """
+41        return Argument(
+42            literal_name=arg_dict.get("literal_name", None),
+43            default=arg_dict.get("default", None),
+44            validator=arg_dict.get("validator", None),
+45            transformer=arg_dict.get("transformer", None),
+46        )
+47
+48    def to_dict(self):
+49        """
+50        Convert the Argument to a dictionary
+51        :return: the dictionary representation of the Argument
+52        """
+53        _logger.debug(f"Converting argument {self.literal_name} to dict")
+54        return {
+55            "literal_name": self.literal_name,
+56            "default": self.default,
+57            "validator": self.validator.to_dict() if self.validator is not None else None,
+58        }
+59
+60    def is_valid(self, value):
+61        """
+62        Validate the value of the argument
+63        :param value: the value to be validated
+64        :return: True if valid, False otherwise
+65        """
+66        _logger.debug(f"Validating {self.literal_name} with value {value}")
+67        return validators.get(self.validator)(value) if self.validator is not None else True
+68
+69    def transform(self, name, value, **kwargs):
+70        """
+71        Transform the name and value of the argument
+72        :param name: the name of the argument
+73        :param value: the value to be transformed
+74        :return: the transformed value
+75        """
+76        return (
+77            transformers.get(self.transformer)(name, value, **kwargs) if self.transformer is not None else (name, value)
+78        )
+
+ + +

Argument represents a command line argument to be passed to the cli_wrapper

+
+ + +
+ +
+ + Argument( literal_name: str | None = None, default: str = None, validator=None, transformer: Union[Callable, str, dict, list[str | dict]] = 'snake2kebab') + + + +
+ +
26def __init__(self, literal_name=attr_dict['literal_name'].default, default=attr_dict['default'].default, validator=attr_dict['validator'].default, transformer=attr_dict['transformer'].default):
+27    _setattr = _cached_setattr_get(self)
+28    _setattr('literal_name', literal_name)
+29    _setattr('default', default)
+30    _setattr('validator', __attr_converter_validator(validator))
+31    _setattr('transformer', transformer)
+
+ + +

Method generated by attrs for class Argument.

+
+ + +
+
+ +
+
@classmethod
+ + def + from_dict(cls, arg_dict): + + + +
+ +
34    @classmethod
+35    def from_dict(cls, arg_dict):
+36        """
+37        Create an Argument from a dictionary
+38        :param arg_dict: the dictionary to be converted
+39        :return: Argument object
+40        """
+41        return Argument(
+42            literal_name=arg_dict.get("literal_name", None),
+43            default=arg_dict.get("default", None),
+44            validator=arg_dict.get("validator", None),
+45            transformer=arg_dict.get("transformer", None),
+46        )
+
+ + +

Create an Argument from a dictionary

+ +
Parameters
+ +
    +
  • arg_dict: the dictionary to be converted
  • +
+ +
Returns
+ +
+

Argument object

+
+
+ + +
+
+ +
+ + def + to_dict(self): + + + +
+ +
48    def to_dict(self):
+49        """
+50        Convert the Argument to a dictionary
+51        :return: the dictionary representation of the Argument
+52        """
+53        _logger.debug(f"Converting argument {self.literal_name} to dict")
+54        return {
+55            "literal_name": self.literal_name,
+56            "default": self.default,
+57            "validator": self.validator.to_dict() if self.validator is not None else None,
+58        }
+
+ + +

Convert the Argument to a dictionary

+ +
Returns
+ +
+

the dictionary representation of the Argument

+
+
+ + +
+
+ +
+ + def + is_valid(self, value): + + + +
+ +
60    def is_valid(self, value):
+61        """
+62        Validate the value of the argument
+63        :param value: the value to be validated
+64        :return: True if valid, False otherwise
+65        """
+66        _logger.debug(f"Validating {self.literal_name} with value {value}")
+67        return validators.get(self.validator)(value) if self.validator is not None else True
+
+ + +

Validate the value of the argument

+ +
Parameters
+ +
    +
  • value: the value to be validated
  • +
+ +
Returns
+ +
+

True if valid, False otherwise

+
+
+ + +
+
+ +
+ + def + transform(self, name, value, **kwargs): + + + +
+ +
69    def transform(self, name, value, **kwargs):
+70        """
+71        Transform the name and value of the argument
+72        :param name: the name of the argument
+73        :param value: the value to be transformed
+74        :return: the transformed value
+75        """
+76        return (
+77            transformers.get(self.transformer)(name, value, **kwargs) if self.transformer is not None else (name, value)
+78        )
+
+ + +

Transform the name and value of the argument

+ +
Parameters
+ +
    +
  • name: the name of the argument
  • +
  • value: the value to be transformed
  • +
+ +
Returns
+ +
+

the transformed value

+
+
+ + +
+
+
+ +
+
@define
+ + class + Command: + + + +
+ +
109@define
+110class Command:  # pylint: disable=too-many-instance-attributes
+111    """
+112    Command represents a command to be run with the cli_wrapper
+113    """
+114
+115    cli_command: list[str] | str = field(converter=_cli_command_converter)
+116    """ @private """
+117    default_flags: dict = {}
+118    """ @private """
+119    args: dict[str | int, any] = field(factory=dict, converter=_arg_converter)
+120    """ @private """
+121    parse: Parser = field(converter=Parser, default=None)
+122    """ @private """
+123    default_transformer: str = "snake2kebab"
+124    """ @private """
+125    short_prefix: str = field(repr=False, default="-")
+126    """ @private """
+127    long_prefix: str = field(repr=False, default="--")
+128    """ @private """
+129    arg_separator: str = field(repr=False, default="=")
+130    """ @private """
+131
+132    @classmethod
+133    def from_dict(cls, command_dict, **kwargs):
+134        """
+135        Create a Command from a dictionary
+136        :param command_dict: the dictionary to be converted
+137        :return: Command object
+138        """
+139        command_dict = command_dict.copy()
+140        if "args" in command_dict:
+141            for k, v in command_dict["args"].items():
+142                if isinstance(v, dict):
+143                    if "literal_name" not in v:
+144                        v["literal_name"] = k
+145                if isinstance(v, Argument):
+146                    if v.literal_name is None:
+147                        v.literal_name = k
+148        if "cli_command" not in command_dict:
+149            command_dict["cli_command"] = kwargs.pop("cli_command", None)
+150        return Command(
+151            **command_dict,
+152            **kwargs,
+153        )
+154
+155    def to_dict(self):
+156        """
+157        Convert the Command to a dictionary.
+158        Excludes prefixes/separators, because they are set in the CLIWrapper
+159        :return: the dictionary representation of the Command
+160        """
+161        _logger.debug(f"Converting command {self.cli_command} to dict")
+162        return {
+163            "cli_command": self.cli_command,
+164            "default_flags": self.default_flags,
+165            "args": {k: v.to_dict() for k, v in self.args.items()},
+166            "parse": self.parse.to_dict() if self.parse is not None else None,
+167        }
+168
+169    def validate_args(self, *args, **kwargs):
+170        # TODO: validate everything and raise comprehensive exception instead of just the first one
+171        for name, arg in chain(enumerate(args), kwargs.items()):
+172            _logger.debug(f"Validating arg {name} with value {arg}")
+173            if name in self.args:
+174                _logger.debug("Argument found in args")
+175                v = self.args[name].is_valid(arg)
+176                if isinstance(name, int):
+177                    name += 1  # let's call positional arg 0, "Argument 1"
+178                if isinstance(v, str):
+179                    raise ValueError(
+180                        f"Value '{arg}' is invalid for command {' '.join(self.cli_command)} arg {name}: {v}"
+181                    )
+182                if not v:
+183                    raise ValueError(f"Value '{arg}' is invalid for command {' '.join(self.cli_command)} arg {name}")
+184
+185    def build_args(self, *args, **kwargs):
+186        positional = copy(self.cli_command) if self.cli_command is not None else []
+187        params = []
+188        for arg, value in chain(
+189            enumerate(args), kwargs.items(), [(k, v) for k, v in self.default_flags.items() if k not in kwargs]
+190        ):
+191            _logger.debug(f"arg: {arg}, value: {value}")
+192            if arg in self.args:
+193                literal_arg = self.args[arg].literal_name if self.args[arg].literal_name is not None else arg
+194                arg, value = self.args[arg].transform(literal_arg, value)
+195            else:
+196                arg, value = transformers.get(self.default_transformer)(arg, value)
+197            _logger.debug(f"after: arg: {arg}, value: {value}")
+198            if isinstance(arg, str):
+199                prefix = self.long_prefix if len(arg) > 1 else self.short_prefix
+200                if value is not None and not isinstance(value, bool):
+201                    if self.arg_separator != " ":
+202                        params.append(f"{prefix}{arg}{self.arg_separator}{value}")
+203                    else:
+204                        params.extend([f"{prefix}{arg}", value])
+205                else:
+206                    params.append(f"{prefix}{arg}")
+207            else:
+208                positional.append(value)
+209        result = positional + params
+210        _logger.debug(result)
+211        return result
+
+ + +

Command represents a command to be run with the cli_wrapper

+
+ + +
+ +
+ + Command( cli_command: str | list[str], default_flags: dict = {}, args: dict = NOTHING, parse=None, default_transformer: str = 'snake2kebab', short_prefix: str = '-', long_prefix: str = '--', arg_separator: str = '=') + + + +
+ +
30def __init__(self, cli_command, default_flags=attr_dict['default_flags'].default, args=NOTHING, parse=attr_dict['parse'].default, default_transformer=attr_dict['default_transformer'].default, short_prefix=attr_dict['short_prefix'].default, long_prefix=attr_dict['long_prefix'].default, arg_separator=attr_dict['arg_separator'].default):
+31    _setattr = _cached_setattr_get(self)
+32    _setattr('cli_command', __attr_converter_cli_command(cli_command))
+33    _setattr('default_flags', default_flags)
+34    if args is not NOTHING:
+35        _setattr('args', __attr_converter_args(args))
+36    else:
+37        _setattr('args', __attr_converter_args(__attr_factory_args()))
+38    _setattr('parse', __attr_converter_parse(parse))
+39    _setattr('default_transformer', default_transformer)
+40    _setattr('short_prefix', short_prefix)
+41    _setattr('long_prefix', long_prefix)
+42    _setattr('arg_separator', arg_separator)
+
+ + +

Method generated by attrs for class Command.

+
+ + +
+
+ +
+
@classmethod
+ + def + from_dict(cls, command_dict, **kwargs): + + + +
+ +
132    @classmethod
+133    def from_dict(cls, command_dict, **kwargs):
+134        """
+135        Create a Command from a dictionary
+136        :param command_dict: the dictionary to be converted
+137        :return: Command object
+138        """
+139        command_dict = command_dict.copy()
+140        if "args" in command_dict:
+141            for k, v in command_dict["args"].items():
+142                if isinstance(v, dict):
+143                    if "literal_name" not in v:
+144                        v["literal_name"] = k
+145                if isinstance(v, Argument):
+146                    if v.literal_name is None:
+147                        v.literal_name = k
+148        if "cli_command" not in command_dict:
+149            command_dict["cli_command"] = kwargs.pop("cli_command", None)
+150        return Command(
+151            **command_dict,
+152            **kwargs,
+153        )
+
+ + +

Create a Command from a dictionary

+ +
Parameters
+ +
    +
  • command_dict: the dictionary to be converted
  • +
+ +
Returns
+ +
+

Command object

+
+
+ + +
+
+ +
+ + def + to_dict(self): + + + +
+ +
155    def to_dict(self):
+156        """
+157        Convert the Command to a dictionary.
+158        Excludes prefixes/separators, because they are set in the CLIWrapper
+159        :return: the dictionary representation of the Command
+160        """
+161        _logger.debug(f"Converting command {self.cli_command} to dict")
+162        return {
+163            "cli_command": self.cli_command,
+164            "default_flags": self.default_flags,
+165            "args": {k: v.to_dict() for k, v in self.args.items()},
+166            "parse": self.parse.to_dict() if self.parse is not None else None,
+167        }
+
+ + +

Convert the Command to a dictionary. +Excludes prefixes/separators, because they are set in the CLIWrapper

+ +
Returns
+ +
+

the dictionary representation of the Command

+
+
+ + +
+
+ +
+ + def + validate_args(self, *args, **kwargs): + + + +
+ +
169    def validate_args(self, *args, **kwargs):
+170        # TODO: validate everything and raise comprehensive exception instead of just the first one
+171        for name, arg in chain(enumerate(args), kwargs.items()):
+172            _logger.debug(f"Validating arg {name} with value {arg}")
+173            if name in self.args:
+174                _logger.debug("Argument found in args")
+175                v = self.args[name].is_valid(arg)
+176                if isinstance(name, int):
+177                    name += 1  # let's call positional arg 0, "Argument 1"
+178                if isinstance(v, str):
+179                    raise ValueError(
+180                        f"Value '{arg}' is invalid for command {' '.join(self.cli_command)} arg {name}: {v}"
+181                    )
+182                if not v:
+183                    raise ValueError(f"Value '{arg}' is invalid for command {' '.join(self.cli_command)} arg {name}")
+
+ + + + +
+
+ +
+ + def + build_args(self, *args, **kwargs): + + + +
+ +
185    def build_args(self, *args, **kwargs):
+186        positional = copy(self.cli_command) if self.cli_command is not None else []
+187        params = []
+188        for arg, value in chain(
+189            enumerate(args), kwargs.items(), [(k, v) for k, v in self.default_flags.items() if k not in kwargs]
+190        ):
+191            _logger.debug(f"arg: {arg}, value: {value}")
+192            if arg in self.args:
+193                literal_arg = self.args[arg].literal_name if self.args[arg].literal_name is not None else arg
+194                arg, value = self.args[arg].transform(literal_arg, value)
+195            else:
+196                arg, value = transformers.get(self.default_transformer)(arg, value)
+197            _logger.debug(f"after: arg: {arg}, value: {value}")
+198            if isinstance(arg, str):
+199                prefix = self.long_prefix if len(arg) > 1 else self.short_prefix
+200                if value is not None and not isinstance(value, bool):
+201                    if self.arg_separator != " ":
+202                        params.append(f"{prefix}{arg}{self.arg_separator}{value}")
+203                    else:
+204                        params.extend([f"{prefix}{arg}", value])
+205                else:
+206                    params.append(f"{prefix}{arg}")
+207            else:
+208                positional.append(value)
+209        result = positional + params
+210        _logger.debug(result)
+211        return result
+
+ + + + +
+
+
+ +
+
@define
+ + class + CLIWrapper: + + + +
+ +
214@define
+215class CLIWrapper:  # pylint: disable=too-many-instance-attributes
+216    """
+217    :param path: The path to the CLI tool. This will be passed to subprocess directly, and does not require a full path
+218      unless the tool is not in the system path.
+219    :param env: A dict of environment variables to be set in the subprocess environment, in addition to and overriding
+220      those in os.environ.
+221    :param trusting: If True, the wrapper will accept any command and pass them to the cli with default configuration.
+222      Otherwise, it will only allow commands that have been defined with `update_command_`
+223    :param raise_exc: If True, the wrapper will raise an exception if a command returns a non-zero exit code.
+224    :param async_: If true, the wrapper will return coroutines that must be awaited.
+225    :param default_transformer: The transformer configuration to apply to all arguments. The default of snake2kebab will
+226      convert pythonic_snake_case_kwargs to kebab-case-arguments
+227    :param short_prefix: The string prefix for single-letter arguments
+228    :param long_prefix: The string prefix for arguments longer than 1 letter
+229    :param arg_separator: The character that separates argument values from names. Defaults to '=', so
+230      wrapper.command(arg=value) would become "wrapper command --arg=value"
+231    """
+232
+233    path: str
+234    """ @private """
+235    env: dict[str, str] = None
+236    """ @private """
+237    _commands: dict[str, Command] = {}
+238    """ @private """
+239
+240    trusting: bool = True
+241    """ @private """
+242    raise_exc: bool = False
+243    """ @private """
+244    async_: bool = False
+245    """ @private """
+246    default_transformer: str = "snake2kebab"
+247    """ @private """
+248    short_prefix: str = "-"
+249    """ @private """
+250    long_prefix: str = "--"
+251    """ @private """
+252    arg_separator: str = "="
+253    """ @private """
+254
+255    def _get_command(self, command: str):
+256        """
+257        get the command from the cli_wrapper
+258        :param command: the command to be run
+259        :return:
+260        """
+261        if command not in self._commands:
+262            if not self.trusting:
+263                raise ValueError(f"Command {command} not found in {self.path}")
+264            c = Command(
+265                cli_command=command,
+266                default_transformer=self.default_transformer,
+267                short_prefix=self.short_prefix,
+268                long_prefix=self.long_prefix,
+269                arg_separator=self.arg_separator,
+270            )
+271            return c
+272        return self._commands[command]
+273
+274    def update_command_(  # pylint: disable=too-many-arguments
+275        self,
+276        command: str,
+277        *,
+278        cli_command: str | list[str] = None,
+279        args: dict[str | int, any] = None,
+280        default_flags: dict = None,
+281        parse=None,
+282    ):
+283        """
+284        update the command to be run with the cli_wrapper
+285        :param command: the command name for the wrapper
+286        :param cli_command: the command to be run, if different from the command name
+287        :param args: the arguments passed to the command
+288        :param default_flags: default flags to be used with the command
+289        :param parse: function to parse the output of the command
+290        :return:
+291        """
+292        self._commands[command] = Command(
+293            cli_command=command if cli_command is None else cli_command,
+294            args=args if args is not None else {},
+295            default_flags=default_flags if default_flags is not None else {},
+296            parse=parse,
+297            default_transformer=self.default_transformer,
+298            short_prefix=self.short_prefix,
+299            long_prefix=self.long_prefix,
+300            arg_separator=self.arg_separator,
+301        )
+302
+303    def _run(self, command: str, *args, **kwargs):
+304        command_obj = self._get_command(command)
+305        command_obj.validate_args(*args, **kwargs)
+306        command_args = [self.path] + command_obj.build_args(*args, **kwargs)
+307        env = os.environ.copy().update(self.env if self.env is not None else {})
+308        _logger.debug(f"Running command: {' '.join(command_args)}")
+309        # run the command
+310        result = subprocess.run(command_args, capture_output=True, text=True, env=env, check=self.raise_exc)
+311        if result.returncode != 0:
+312            raise RuntimeError(f"Command {command} failed with error: {result.stderr}")
+313        return command_obj.parse(result.stdout)
+314
+315    async def _run_async(self, command: str, *args, **kwargs):
+316        command_obj = self._get_command(command)
+317        command_obj.validate_args(*args, **kwargs)
+318        command_args = [self.path] + list(command_obj.build_args(*args, **kwargs))
+319        env = os.environ.copy().update(self.env if self.env is not None else {})
+320        _logger.debug(f"Running command: {', '.join(command_args)}")
+321        proc = await asyncio.subprocess.create_subprocess_exec(  # pylint: disable=no-member
+322            *command_args,
+323            stdout=asyncio.subprocess.PIPE,
+324            stderr=asyncio.subprocess.PIPE,
+325            env=env,
+326        )
+327
+328        stdout, stderr = await proc.communicate()
+329        if proc.returncode != 0:
+330            raise RuntimeError(f"Command {command} failed with error: {stderr.decode()}")
+331        return command_obj.parse(stdout.decode())
+332
+333    def __getattr__(self, item, *args, **kwargs):
+334        """
+335        get the command from the cli_wrapper
+336        :param item: the command to be run
+337        :return:
+338        """
+339        if self.async_:
+340            return lambda *args, **kwargs: self._run_async(item, *args, **kwargs)
+341        return lambda *args, **kwargs: self._run(item, *args, **kwargs)
+342
+343    def __call__(self, *args, **kwargs):
+344        """
+345        Invokes the wrapper with no extra arguments. e.g., for the kubectl wrapper, calls bare kubectl.
+346        `kubectl(help=True)` will be interpreted as "kubectl --help".
+347        :param args: positional arguments to be passed to the command
+348        :param kwargs: kwargs will be treated as `--options`. Boolean values will be bare flags, others will be
+349          passed as `--kwarg=value` (where `=` is the wrapper's arg_separator)
+350        :return:
+351        """
+352        return (self.__getattr__(None))(*args, **kwargs)
+353
+354    @classmethod
+355    def from_dict(cls, cliwrapper_dict):
+356        """
+357        Create a CLIWrapper from a dictionary
+358        :param cliwrapper_dict: the dictionary to be converted
+359        :return: CLIWrapper object
+360        """
+361        cliwrapper_dict = cliwrapper_dict.copy()
+362        commands = {}
+363        command_config = {
+364            "arg_separator": cliwrapper_dict.get("arg_separator", "="),
+365            "default_transformer": cliwrapper_dict.get("default_transformer", "snake2kebab"),
+366            "short_prefix": cliwrapper_dict.get("short_prefix", "-"),
+367            "long_prefix": cliwrapper_dict.get("long_prefix", "--"),
+368        }
+369        for command, config in cliwrapper_dict.pop("commands", {}).items():
+370            if isinstance(config, str):
+371                config = {"cli_command": config}
+372            else:
+373                if "cli_command" not in config:
+374                    config["cli_command"] = command
+375                config = command_config | config
+376            commands[command] = Command.from_dict(config)
+377
+378        return CLIWrapper(
+379            _commands=commands,
+380            **cliwrapper_dict,
+381        )
+382
+383    def to_dict(self):
+384        """
+385        Convert the CLIWrapper to a dictionary
+386        :return: a dictionary that can be used to recreate the wrapper using `from_dict`
+387        """
+388        return {
+389            "path": self.path,
+390            "env": self.env,
+391            "commands": {k: v.to_dict() for k, v in self._commands.items()},
+392            "trusting": self.trusting,
+393            "async_": self.async_,
+394            "default_transformer": self.default_transformer,
+395            "short_prefix": self.short_prefix,
+396            "long_prefix": self.long_prefix,
+397            "arg_separator": self.arg_separator,
+398        }
+
+ + +
Parameters
+ +
    +
  • path: The path to the CLI tool. This will be passed to subprocess directly, and does not require a full path +unless the tool is not in the system path.
  • +
  • env: A dict of environment variables to be set in the subprocess environment, in addition to and overriding +those in os.environ.
  • +
  • trusting: If True, the wrapper will accept any command and pass them to the cli with default configuration. +Otherwise, it will only allow commands that have been defined with update_command_
  • +
  • raise_exc: If True, the wrapper will raise an exception if a command returns a non-zero exit code.
  • +
  • async_: If true, the wrapper will return coroutines that must be awaited.
  • +
  • default_transformer: The transformer configuration to apply to all arguments. The default of snake2kebab will +convert pythonic_snake_case_kwargs to kebab-case-arguments
  • +
  • short_prefix: The string prefix for single-letter arguments
  • +
  • long_prefix: The string prefix for arguments longer than 1 letter
  • +
  • arg_separator: The character that separates argument values from names. Defaults to '=', so +wrapper.command(arg=value) would become "wrapper command --arg=value"
  • +
+
+ + +
+ +
+ + CLIWrapper( path: str, env: dict[str, str] = None, commands: dict[str, Command] = {}, trusting: bool = True, raise_exc: bool = False, async_: bool = False, default_transformer: str = 'snake2kebab', short_prefix: str = '-', long_prefix: str = '--', arg_separator: str = '=') + + + +
+ +
32def __init__(self, path, env=attr_dict['env'].default, commands=attr_dict['_commands'].default, trusting=attr_dict['trusting'].default, raise_exc=attr_dict['raise_exc'].default, async_=attr_dict['async_'].default, default_transformer=attr_dict['default_transformer'].default, short_prefix=attr_dict['short_prefix'].default, long_prefix=attr_dict['long_prefix'].default, arg_separator=attr_dict['arg_separator'].default):
+33    self.path = path
+34    self.env = env
+35    self._commands = commands
+36    self.trusting = trusting
+37    self.raise_exc = raise_exc
+38    self.async_ = async_
+39    self.default_transformer = default_transformer
+40    self.short_prefix = short_prefix
+41    self.long_prefix = long_prefix
+42    self.arg_separator = arg_separator
+
+ + +

Method generated by attrs for class CLIWrapper.

+
+ + +
+
+ +
+ + def + update_command_( self, command: str, *, cli_command: str | list[str] = None, args: dict[str | int, any] = None, default_flags: dict = None, parse=None): + + + +
+ +
274    def update_command_(  # pylint: disable=too-many-arguments
+275        self,
+276        command: str,
+277        *,
+278        cli_command: str | list[str] = None,
+279        args: dict[str | int, any] = None,
+280        default_flags: dict = None,
+281        parse=None,
+282    ):
+283        """
+284        update the command to be run with the cli_wrapper
+285        :param command: the command name for the wrapper
+286        :param cli_command: the command to be run, if different from the command name
+287        :param args: the arguments passed to the command
+288        :param default_flags: default flags to be used with the command
+289        :param parse: function to parse the output of the command
+290        :return:
+291        """
+292        self._commands[command] = Command(
+293            cli_command=command if cli_command is None else cli_command,
+294            args=args if args is not None else {},
+295            default_flags=default_flags if default_flags is not None else {},
+296            parse=parse,
+297            default_transformer=self.default_transformer,
+298            short_prefix=self.short_prefix,
+299            long_prefix=self.long_prefix,
+300            arg_separator=self.arg_separator,
+301        )
+
+ + +

update the command to be run with the cli_wrapper

+ +
Parameters
+ +
    +
  • command: the command name for the wrapper
  • +
  • cli_command: the command to be run, if different from the command name
  • +
  • args: the arguments passed to the command
  • +
  • default_flags: default flags to be used with the command
  • +
  • parse: function to parse the output of the command
  • +
+ +
Returns
+
+ + +
+
+ +
+
@classmethod
+ + def + from_dict(cls, cliwrapper_dict): + + + +
+ +
354    @classmethod
+355    def from_dict(cls, cliwrapper_dict):
+356        """
+357        Create a CLIWrapper from a dictionary
+358        :param cliwrapper_dict: the dictionary to be converted
+359        :return: CLIWrapper object
+360        """
+361        cliwrapper_dict = cliwrapper_dict.copy()
+362        commands = {}
+363        command_config = {
+364            "arg_separator": cliwrapper_dict.get("arg_separator", "="),
+365            "default_transformer": cliwrapper_dict.get("default_transformer", "snake2kebab"),
+366            "short_prefix": cliwrapper_dict.get("short_prefix", "-"),
+367            "long_prefix": cliwrapper_dict.get("long_prefix", "--"),
+368        }
+369        for command, config in cliwrapper_dict.pop("commands", {}).items():
+370            if isinstance(config, str):
+371                config = {"cli_command": config}
+372            else:
+373                if "cli_command" not in config:
+374                    config["cli_command"] = command
+375                config = command_config | config
+376            commands[command] = Command.from_dict(config)
+377
+378        return CLIWrapper(
+379            _commands=commands,
+380            **cliwrapper_dict,
+381        )
+
+ + +

Create a CLIWrapper from a dictionary

+ +
Parameters
+ +
    +
  • cliwrapper_dict: the dictionary to be converted
  • +
+ +
Returns
+ +
+

CLIWrapper object

+
+
+ + +
+
+ +
+ + def + to_dict(self): + + + +
+ +
383    def to_dict(self):
+384        """
+385        Convert the CLIWrapper to a dictionary
+386        :return: a dictionary that can be used to recreate the wrapper using `from_dict`
+387        """
+388        return {
+389            "path": self.path,
+390            "env": self.env,
+391            "commands": {k: v.to_dict() for k, v in self._commands.items()},
+392            "trusting": self.trusting,
+393            "async_": self.async_,
+394            "default_transformer": self.default_transformer,
+395            "short_prefix": self.short_prefix,
+396            "long_prefix": self.long_prefix,
+397            "arg_separator": self.arg_separator,
+398        }
+
+ + +

Convert the CLIWrapper to a dictionary

+ +
Returns
+ +
+

a dictionary that can be used to recreate the wrapper using from_dict

+
+
+ + +
+
+
+ + \ No newline at end of file diff --git a/docs/cli_wrapper/parsers.html b/docs/cli_wrapper/parsers.html new file mode 100644 index 0000000..cb02dc6 --- /dev/null +++ b/docs/cli_wrapper/parsers.html @@ -0,0 +1,561 @@ + + + + + + + cli_wrapper.parsers API documentation + + + + + + + + + +
+
+

+cli_wrapper.parsers

+ + + + + + +
  1import logging
+  2
+  3from .util.callable_chain import CallableChain
+  4from .util.callable_registry import CallableRegistry
+  5
+  6_logger = logging.getLogger(__name__)
+  7
+  8
+  9def extract(src: dict, *args) -> dict:
+ 10    """
+ 11    Extracts a sub-dictionary from a source dictionary based on a given path.
+ 12    TODO: this
+ 13
+ 14    :param src: The source dictionary to extract from.
+ 15    :param path: A list of keys representing the path to the sub-dictionary.
+ 16    :return: The extracted sub-dictionary.
+ 17    """
+ 18    for key in args:
+ 19        src = src[key]
+ 20    return src
+ 21
+ 22
+ 23core_parsers = {
+ 24    "extract": extract,
+ 25}
+ 26
+ 27try:
+ 28    from json import loads
+ 29
+ 30    core_parsers["json"] = loads
+ 31except ImportError:  # pragma: no cover
+ 32    pass
+ 33try:
+ 34    # prefer ruamel.yaml over PyYAML
+ 35    from ruamel.yaml import YAML
+ 36
+ 37    def yaml_loads(src: str) -> dict:  # pragma: no cover
+ 38        #  pylint: disable=missing-function-docstring
+ 39        yaml = YAML(typ="safe")
+ 40        result = list(yaml.load_all(src))
+ 41        if len(result) == 1:
+ 42            return result[0]
+ 43        return result
+ 44
+ 45    core_parsers["yaml"] = yaml_loads
+ 46except ImportError:  # pragma: no cover
+ 47    pass
+ 48
+ 49if "yaml" not in core_parsers:
+ 50    try:  # pragma: no cover
+ 51        from yaml import safe_load as yaml_loads
+ 52
+ 53        core_parsers["yaml"] = yaml_loads
+ 54    except ImportError:  # pragma: no cover
+ 55        pass
+ 56
+ 57try:
+ 58    # https://github.com/josh-paul/dotted_dict -> lets us use dotted notation to access dict keys while preserving
+ 59    # the original key names. Syntactic sugar that makes nested dictionaries more palatable.
+ 60    from dotted_dict import PreserveKeysDottedDict
+ 61
+ 62    def dotted_dictify(src, *args, **kwargs):
+ 63        if isinstance(src, list):
+ 64            return [dotted_dictify(x, *args, **kwargs) for x in src]
+ 65        if isinstance(src, dict):
+ 66            return PreserveKeysDottedDict(src)
+ 67        return src
+ 68
+ 69    core_parsers["dotted_dict"] = dotted_dictify
+ 70except ImportError:  # pragma: no cover
+ 71    pass
+ 72
+ 73parsers = CallableRegistry({"core": core_parsers}, callable_name="Parser")
+ 74"""
+ 75A `CallableRegistry` of parsers. These can be chained in sequence to perform 
+ 76operations on input.
+ 77
+ 78Defaults:
+ 79core parsers:
+ 80 - json - parses the input as json, returns the result
+ 81 - extract - extracts the specified sub-dictionary from the source dictionary
+ 82 - yaml - parses the input as yaml, returns the result (requires ruamel.yaml or pyyaml)
+ 83 - dotted_dict - converts an input dictionary to a dotted_dict (requires dotted_dict)
+ 84"""
+ 85
+ 86
+ 87class Parser(CallableChain):
+ 88    """
+ 89    @public
+ 90    Parser class that allows for the chaining of multiple parsers. Callables in the configuration are run as a
+ 91    pipeline, with the output of one parser being passed as input to the next.
+ 92    """
+ 93
+ 94    def __init__(self, config):
+ 95        super().__init__(config, parsers)
+ 96
+ 97    def __call__(self, src):
+ 98        # For now, parser expects to be called with one input.
+ 99        result = src
+100        for parser in self.chain:
+101            _logger.debug(result)
+102            result = parser(result)
+103        return result
+
+ + +
+
+ +
+ + def + extract(src: dict, *args) -> dict: + + + +
+ +
10def extract(src: dict, *args) -> dict:
+11    """
+12    Extracts a sub-dictionary from a source dictionary based on a given path.
+13    TODO: this
+14
+15    :param src: The source dictionary to extract from.
+16    :param path: A list of keys representing the path to the sub-dictionary.
+17    :return: The extracted sub-dictionary.
+18    """
+19    for key in args:
+20        src = src[key]
+21    return src
+
+ + +

Extracts a sub-dictionary from a source dictionary based on a given path. +TODO: this

+ +
Parameters
+ +
    +
  • src: The source dictionary to extract from.
  • +
  • path: A list of keys representing the path to the sub-dictionary.
  • +
+ +
Returns
+ +
+

The extracted sub-dictionary.

+
+
+ + +
+
+
+ core_parsers = + + {'extract': <function extract>, 'json': <function loads>, 'yaml': <function yaml_loads>, 'dotted_dict': <function dotted_dictify>} + + +
+ + + + +
+
+
+ parsers = + + CallableRegistry(_all={'core': {'extract': <function extract>, 'json': <function loads>, 'yaml': <function yaml_loads>, 'dotted_dict': <function dotted_dictify>}}, callable_name='Parser') + + +
+ + +

A CallableRegistry of parsers. These can be chained in sequence to perform +operations on input.

+ +

Defaults: +core parsers:

+ +
    +
  • json - parses the input as json, returns the result
  • +
  • extract - extracts the specified sub-dictionary from the source dictionary
  • +
  • yaml - parses the input as yaml, returns the result (requires ruamel.yaml or pyyaml)
  • +
  • dotted_dict - converts an input dictionary to a dotted_dict (requires dotted_dict)
  • +
+
+ + +
+
+ +
+ + class + Parser(cli_wrapper.util.callable_chain.CallableChain): + + + +
+ +
 88class Parser(CallableChain):
+ 89    """
+ 90    @public
+ 91    Parser class that allows for the chaining of multiple parsers. Callables in the configuration are run as a
+ 92    pipeline, with the output of one parser being passed as input to the next.
+ 93    """
+ 94
+ 95    def __init__(self, config):
+ 96        super().__init__(config, parsers)
+ 97
+ 98    def __call__(self, src):
+ 99        # For now, parser expects to be called with one input.
+100        result = src
+101        for parser in self.chain:
+102            _logger.debug(result)
+103            result = parser(result)
+104        return result
+
+ + +

Parser class that allows for the chaining of multiple parsers. Callables in the configuration are run as a +pipeline, with the output of one parser being passed as input to the next.

+
+ + +
+ +
+ + Parser(config) + + + +
+ +
95    def __init__(self, config):
+96        super().__init__(config, parsers)
+
+ + +
Parameters
+ +
    +
  • config: a callable, a string, a dictionary with one key and config, or a list of the previous
  • +
  • source: a CallableRegistry to get callables from
  • +
+
+ + +
+
+
+ +
+ + def + yaml_loads(src: str) -> dict: + + + +
+ +
38    def yaml_loads(src: str) -> dict:  # pragma: no cover
+39        #  pylint: disable=missing-function-docstring
+40        yaml = YAML(typ="safe")
+41        result = list(yaml.load_all(src))
+42        if len(result) == 1:
+43            return result[0]
+44        return result
+
+ + + + +
+
+ +
+ + def + dotted_dictify(src, *args, **kwargs): + + + +
+ +
63    def dotted_dictify(src, *args, **kwargs):
+64        if isinstance(src, list):
+65            return [dotted_dictify(x, *args, **kwargs) for x in src]
+66        if isinstance(src, dict):
+67            return PreserveKeysDottedDict(src)
+68        return src
+
+ + + + +
+
+ + \ No newline at end of file diff --git a/docs/cli_wrapper/pre_packaged.html b/docs/cli_wrapper/pre_packaged.html new file mode 100644 index 0000000..436a36b --- /dev/null +++ b/docs/cli_wrapper/pre_packaged.html @@ -0,0 +1,322 @@ + + + + + + + cli_wrapper.pre_packaged API documentation + + + + + + + + + +
+
+

+cli_wrapper.pre_packaged

+ + + + + + +
 1from json import loads
+ 2from pathlib import Path
+ 3
+ 4from ..cli_wrapper import CLIWrapper
+ 5
+ 6
+ 7def get_wrapper(name, status=None):
+ 8    """
+ 9    Gets a wrapper defined in the beta/stable folders as json.
+10    :param name: the name of the wrapper to retrieve
+11    :param status: stable/beta/None. None will search stable and beta
+12    :return: the requested wrapper
+13    """
+14    if status is None:
+15        status = ["stable", "beta"]
+16    if isinstance(status, str):
+17        status = [status]
+18    wrapper_config = None
+19    for d in status:
+20        path = Path(__file__).parent / d / f"{name}.json"
+21        if path.exists():
+22            with open(path, "r", encoding="utf-8") as f:
+23                wrapper_config = loads(f.read())
+24    if wrapper_config is None:
+25        raise ValueError(f"Wrapper {name} not found")
+26    return CLIWrapper.from_dict(wrapper_config)
+
+ + +
+
+ +
+ + def + get_wrapper(name, status=None): + + + +
+ +
 8def get_wrapper(name, status=None):
+ 9    """
+10    Gets a wrapper defined in the beta/stable folders as json.
+11    :param name: the name of the wrapper to retrieve
+12    :param status: stable/beta/None. None will search stable and beta
+13    :return: the requested wrapper
+14    """
+15    if status is None:
+16        status = ["stable", "beta"]
+17    if isinstance(status, str):
+18        status = [status]
+19    wrapper_config = None
+20    for d in status:
+21        path = Path(__file__).parent / d / f"{name}.json"
+22        if path.exists():
+23            with open(path, "r", encoding="utf-8") as f:
+24                wrapper_config = loads(f.read())
+25    if wrapper_config is None:
+26        raise ValueError(f"Wrapper {name} not found")
+27    return CLIWrapper.from_dict(wrapper_config)
+
+ + +

Gets a wrapper defined in the beta/stable folders as json.

+ +
Parameters
+ +
    +
  • name: the name of the wrapper to retrieve
  • +
  • status: stable/beta/None. None will search stable and beta
  • +
+ +
Returns
+ +
+

the requested wrapper

+
+
+ + +
+
+ + \ No newline at end of file diff --git a/docs/cli_wrapper/transformers.html b/docs/cli_wrapper/transformers.html new file mode 100644 index 0000000..ab20e0d --- /dev/null +++ b/docs/cli_wrapper/transformers.html @@ -0,0 +1,328 @@ + + + + + + + cli_wrapper.transformers API documentation + + + + + + + + + +
+
+

+cli_wrapper.transformers

+ + + + + + +
 1from .util.callable_registry import CallableRegistry
+ 2
+ 3
+ 4def snake2kebab(arg: str, value: any) -> tuple[str, any]:
+ 5    """
+ 6    `snake.gravity = 0`
+ 7
+ 8    converts a snake_case argument to a kebab-case one
+ 9    """
+10    if isinstance(arg, str):
+11        return arg.replace("_", "-"), value
+12    # don't do anything if the arg is positional
+13    return arg, value
+14
+15
+16core_transformers = {
+17    "snake2kebab": snake2kebab,
+18}
+19""" @private """
+20
+21transformers = CallableRegistry({"core": core_transformers})
+22"""
+23A callable registry of transformers.
+24
+25Defaults:
+26core group:
+27 - snake2kebab
+28"""
+
+ + +
+
+ +
+ + def + snake2kebab(arg: str, value: <built-in function any>) -> tuple[str, any]: + + + +
+ +
 5def snake2kebab(arg: str, value: any) -> tuple[str, any]:
+ 6    """
+ 7    `snake.gravity = 0`
+ 8
+ 9    converts a snake_case argument to a kebab-case one
+10    """
+11    if isinstance(arg, str):
+12        return arg.replace("_", "-"), value
+13    # don't do anything if the arg is positional
+14    return arg, value
+
+ + +

snake.gravity = 0

+ +

converts a snake_case argument to a kebab-case one

+
+ + +
+
+
+ transformers = + + CallableRegistry(_all={'core': {'snake2kebab': <function snake2kebab>}}, callable_name='Callable thing') + + +
+ + +

A callable registry of transformers.

+ +

Defaults: +core group:

+ +
    +
  • snake2kebab
  • +
+
+ + +
+
+ + \ No newline at end of file diff --git a/docs/cli_wrapper/util.html b/docs/cli_wrapper/util.html new file mode 100644 index 0000000..070cef7 --- /dev/null +++ b/docs/cli_wrapper/util.html @@ -0,0 +1,743 @@ + + + + + + + cli_wrapper.util API documentation + + + + + + + + + +
+
+

+cli_wrapper.util

+ + + + + + +
1from cli_wrapper.util.callable_chain import CallableChain
+2from cli_wrapper.util.callable_registry import CallableRegistry
+3
+4__all__ = [CallableRegistry.__name__, CallableChain.__name__]
+
+ + +
+
+ +
+
@define
+ + class + CallableRegistry: + + + +
+ +
  7@define
+  8class CallableRegistry:
+  9    """
+ 10    Stores collections of callables. @public
+ 11    - callables are registered by name
+ 12    - they are retrieved by name with args and kwargs
+ 13    - calling the callable with positional arguments will call the callable
+ 14      with the args in the call, plus any args and kwargs passed to get()
+ 15    """
+ 16
+ 17    _all: dict[str, dict[str, Callable]]
+ 18    callable_name: str = "Callable thing"
+ 19    """ a name of the things in the registry to use in error messages """
+ 20
+ 21    def get(self, name: str | Callable, args=None, kwargs=None) -> Callable:
+ 22        """
+ 23        Retrieves a callable function based on the specified parser name.
+ 24
+ 25        :param name: The name of the callable to retrieve.
+ 26        :return: The corresponding callable function.
+ 27        :raises KeyError: If the specified callable name is not found.
+ 28        """
+ 29        if args is None:
+ 30            args = []
+ 31        if kwargs is None:
+ 32            kwargs = {}
+ 33        if callable(name):
+ 34            return lambda *fargs: name(*fargs, *args, **kwargs)
+ 35        callable_ = None
+ 36        group, name = self._parse_name(name)
+ 37        if group is not None:
+ 38            if group not in self._all:
+ 39                raise KeyError(f"{self.callable_name} group '{group}' not found.")
+ 40            callable_group = self._all[group]
+ 41            if name not in callable_group:
+ 42                raise KeyError(f"{self.callable_name} '{name}' not found.")
+ 43            callable_ = callable_group[name]
+ 44        else:
+ 45            for _, v in self._all.items():
+ 46                if name in v:
+ 47                    callable_ = v[name]
+ 48                    break
+ 49        if callable_ is None:
+ 50            raise KeyError(f"{self.callable_name} '{name}' not found.")
+ 51        return lambda *fargs: callable_(*fargs, *args, **kwargs)
+ 52
+ 53    def register(self, name: str, callable_: callable, group="core"):
+ 54        """
+ 55        Registers a new callable function with the specified name.
+ 56
+ 57        :param name: The name to associate with the callable.
+ 58        :param callable_: The callable function to register.
+ 59        """
+ 60        ngroup, name = self._parse_name(name)
+ 61        if ngroup is not None:
+ 62            if group != "core":
+ 63                # approximately, raise an exception if a group is specified in the name and the group arg
+ 64                raise KeyError(f"'{callable_}' already specifies a group.")
+ 65            group = ngroup
+ 66        if name in self._all[group]:
+ 67            raise KeyError(f"{self.callable_name} '{name}' already registered.")
+ 68        self._all[group][name] = callable_
+ 69
+ 70    def register_group(self, name: str, callables: dict = None):
+ 71        """
+ 72        Registers a new callable group with the specified name.
+ 73
+ 74        :param name: The name to associate with the callable group.
+ 75        :param callables: A dictionary of callables to register in the group.
+ 76        """
+ 77        if name in self._all:
+ 78            raise KeyError(f"{self.callable_name} group '{name}' already registered.")
+ 79        if "." in name:
+ 80            raise KeyError(f"{self.callable_name} group name '{name}' is not valid.")
+ 81        callables = {} if callables is None else callables
+ 82        bad_callable_names = [x for x in callables.keys() if "." in x]
+ 83        if bad_callable_names:
+ 84            raise KeyError(
+ 85                f"{self.callable_name} group '{name}' contains invalid callable names: {', '.join(bad_callable_names)}"
+ 86            )
+ 87        self._all[name] = callables
+ 88
+ 89    def _parse_name(self, name: str) -> tuple[str, str]:
+ 90        """
+ 91        Parses a name into a group and callable name.
+ 92
+ 93        :param name: The name to parse.
+ 94        :return: A tuple containing the group and callable name.
+ 95        """
+ 96        if "." not in name:
+ 97            return None, name
+ 98        try:
+ 99            group, name = name.split(".")
+100        except ValueError as err:
+101            raise KeyError(f"{self.callable_name} name '{name}' is not valid.") from err
+102        return group, name
+
+ + +

Stores collections of callables.

+ +
    +
  • callables are registered by name
  • +
  • they are retrieved by name with args and kwargs
  • +
  • calling the callable with positional arguments will call the callable +with the args in the call, plus any args and kwargs passed to get()
  • +
+
+ + +
+ +
+ + CallableRegistry( all: dict[str, dict[str, typing.Callable]], callable_name: str = 'Callable thing') + + + +
+ +
24def __init__(self, all, callable_name=attr_dict['callable_name'].default):
+25    self._all = all
+26    self.callable_name = callable_name
+
+ + +

Method generated by attrs for class CallableRegistry.

+
+ + +
+
+
+ callable_name: str + + +
+ + +

a name of the things in the registry to use in error messages

+
+ + +
+
+ +
+ + def + get(self, name: Union[str, Callable], args=None, kwargs=None) -> Callable: + + + +
+ +
21    def get(self, name: str | Callable, args=None, kwargs=None) -> Callable:
+22        """
+23        Retrieves a callable function based on the specified parser name.
+24
+25        :param name: The name of the callable to retrieve.
+26        :return: The corresponding callable function.
+27        :raises KeyError: If the specified callable name is not found.
+28        """
+29        if args is None:
+30            args = []
+31        if kwargs is None:
+32            kwargs = {}
+33        if callable(name):
+34            return lambda *fargs: name(*fargs, *args, **kwargs)
+35        callable_ = None
+36        group, name = self._parse_name(name)
+37        if group is not None:
+38            if group not in self._all:
+39                raise KeyError(f"{self.callable_name} group '{group}' not found.")
+40            callable_group = self._all[group]
+41            if name not in callable_group:
+42                raise KeyError(f"{self.callable_name} '{name}' not found.")
+43            callable_ = callable_group[name]
+44        else:
+45            for _, v in self._all.items():
+46                if name in v:
+47                    callable_ = v[name]
+48                    break
+49        if callable_ is None:
+50            raise KeyError(f"{self.callable_name} '{name}' not found.")
+51        return lambda *fargs: callable_(*fargs, *args, **kwargs)
+
+ + +

Retrieves a callable function based on the specified parser name.

+ +
Parameters
+ +
    +
  • name: The name of the callable to retrieve.
  • +
+ +
Returns
+ +
+

The corresponding callable function.

+
+ +
Raises
+ +
    +
  • KeyError: If the specified callable name is not found.
  • +
+
+ + +
+
+ +
+ + def + register( self, name: str, callable_: <built-in function callable>, group='core'): + + + +
+ +
53    def register(self, name: str, callable_: callable, group="core"):
+54        """
+55        Registers a new callable function with the specified name.
+56
+57        :param name: The name to associate with the callable.
+58        :param callable_: The callable function to register.
+59        """
+60        ngroup, name = self._parse_name(name)
+61        if ngroup is not None:
+62            if group != "core":
+63                # approximately, raise an exception if a group is specified in the name and the group arg
+64                raise KeyError(f"'{callable_}' already specifies a group.")
+65            group = ngroup
+66        if name in self._all[group]:
+67            raise KeyError(f"{self.callable_name} '{name}' already registered.")
+68        self._all[group][name] = callable_
+
+ + +

Registers a new callable function with the specified name.

+ +
Parameters
+ +
    +
  • name: The name to associate with the callable.
  • +
  • callable_: The callable function to register.
  • +
+
+ + +
+
+ +
+ + def + register_group(self, name: str, callables: dict = None): + + + +
+ +
70    def register_group(self, name: str, callables: dict = None):
+71        """
+72        Registers a new callable group with the specified name.
+73
+74        :param name: The name to associate with the callable group.
+75        :param callables: A dictionary of callables to register in the group.
+76        """
+77        if name in self._all:
+78            raise KeyError(f"{self.callable_name} group '{name}' already registered.")
+79        if "." in name:
+80            raise KeyError(f"{self.callable_name} group name '{name}' is not valid.")
+81        callables = {} if callables is None else callables
+82        bad_callable_names = [x for x in callables.keys() if "." in x]
+83        if bad_callable_names:
+84            raise KeyError(
+85                f"{self.callable_name} group '{name}' contains invalid callable names: {', '.join(bad_callable_names)}"
+86            )
+87        self._all[name] = callables
+
+ + +

Registers a new callable group with the specified name.

+ +
Parameters
+ +
    +
  • name: The name to associate with the callable group.
  • +
  • callables: A dictionary of callables to register in the group.
  • +
+
+ + +
+
+
+ +
+ + class + CallableChain(abc.ABC): + + + +
+ +
 5class CallableChain(ABC):
+ 6    """
+ 7    A callable object representing a collection of callables.
+ 8    """
+ 9
+10    chain: list[callable]
+11    config: list
+12
+13    def __init__(self, config, source):
+14        """
+15        @public
+16        :param config: a callable, a string, a dictionary with one key and config, or a list of the previous
+17        :param source: a `CallableRegistry` to get callables from
+18        """
+19        self.chain = []
+20        self.config = config
+21        if callable(config):
+22            self.chain = [config]
+23        if isinstance(config, str):
+24            self.chain = [source.get(config)]
+25        if isinstance(config, list):
+26            self.chain = []
+27            for x in config:
+28                if callable(x):
+29                    self.chain.append(x)
+30                else:
+31                    name, args, kwargs = params_from_kwargs(x)
+32                    self.chain.append(source.get(name, args, kwargs))
+33        if isinstance(config, dict):
+34            name, args, kwargs = params_from_kwargs(config)
+35            self.chain = [source.get(name, args, kwargs)]
+36
+37    def to_dict(self):
+38        return self.config
+39
+40    @abstractmethod
+41    def __call__(self, value):
+42        """
+43        This function should be overridden by subclasses to determine how the
+44        callable chain is handled.
+45        """
+46        raise NotImplementedError()
+
+ + +

A callable object representing a collection of callables.

+
+ + +
+ +
+ + CallableChain(config, source) + + + +
+ +
13    def __init__(self, config, source):
+14        """
+15        @public
+16        :param config: a callable, a string, a dictionary with one key and config, or a list of the previous
+17        :param source: a `CallableRegistry` to get callables from
+18        """
+19        self.chain = []
+20        self.config = config
+21        if callable(config):
+22            self.chain = [config]
+23        if isinstance(config, str):
+24            self.chain = [source.get(config)]
+25        if isinstance(config, list):
+26            self.chain = []
+27            for x in config:
+28                if callable(x):
+29                    self.chain.append(x)
+30                else:
+31                    name, args, kwargs = params_from_kwargs(x)
+32                    self.chain.append(source.get(name, args, kwargs))
+33        if isinstance(config, dict):
+34            name, args, kwargs = params_from_kwargs(config)
+35            self.chain = [source.get(name, args, kwargs)]
+
+ + +
Parameters
+ +
    +
  • config: a callable, a string, a dictionary with one key and config, or a list of the previous
  • +
  • source: a CallableRegistry to get callables from
  • +
+
+ + +
+
+
+ chain: list[callable] + + +
+ + + + +
+
+
+ config: list + + +
+ + + + +
+
+ +
+ + def + to_dict(self): + + + +
+ +
37    def to_dict(self):
+38        return self.config
+
+ + + + +
+
+
+ + \ No newline at end of file diff --git a/docs/cli_wrapper/validators.html b/docs/cli_wrapper/validators.html new file mode 100644 index 0000000..73e654f --- /dev/null +++ b/docs/cli_wrapper/validators.html @@ -0,0 +1,475 @@ + + + + + + + cli_wrapper.validators API documentation + + + + + + + + + +
+
+

+cli_wrapper.validators

+ + + + + + +
 1import logging
+ 2from pathlib import Path
+ 3from uuid import uuid4
+ 4
+ 5from .util.callable_chain import CallableChain
+ 6from .util.callable_registry import CallableRegistry
+ 7
+ 8_logger = logging.getLogger(__name__)
+ 9
+10core_validators = {
+11    "is_dict": lambda x: isinstance(x, dict),
+12    "is_list": lambda x: isinstance(x, list),
+13    "is_str": lambda x: isinstance(x, str),
+14    "is_str_or_list": lambda x: isinstance(x, (list, str)),
+15    "is_int": lambda x: isinstance(x, int),
+16    "is_bool": lambda x: isinstance(x, bool),
+17    "is_float": lambda x: isinstance(x, float),
+18    "is_alnum": lambda x: x.isalnum(),
+19    "is_alpha": lambda x: x.isalpha(),
+20    "is_digit": lambda x: x.isdigit(),
+21    "is_path": lambda x: isinstance(x, Path),
+22    "starts_alpha": lambda x: len(x) and x[0].isalpha(),
+23    "startswith": lambda x, prefix: x.startswith(prefix),
+24}
+25
+26validators = CallableRegistry({"core": core_validators}, callable_name="Validator")
+27
+28
+29class Validator(CallableChain):
+30    """
+31    @public
+32
+33    A class that provides a validation mechanism for input data.
+34    It uses a list of validators to check if the input data is valid.
+35    They are executed in sequence until one fails.
+36    """
+37
+38    def __init__(self, config):
+39        if callable(config):
+40            id_ = str(uuid4())
+41            validators.register(id_, config)
+42            config = id_
+43        self.config = config
+44        super().__init__(config, validators)
+45
+46    def __call__(self, value):
+47        result = True
+48        config = [self.config] if not isinstance(self.config, list) else self.config
+49        for x, c in zip(self.chain, config):
+50            validator_result = x(value)
+51            _logger.debug(f"Validator {c} result: {validator_result}")
+52            result = result and validator_result
+53            if isinstance(result, str) or not result:
+54                # don't bother doing other validations once one has failed
+55                _logger.debug("...failed")
+56                break
+57        return result
+58
+59    def to_dict(self):
+60        """
+61        Converts the validator configuration to a dictionary.
+62        """
+63        _logger.debug(f"returning validator config: {self.config}")
+64        return self.config
+
+ + +
+
+
+ core_validators = + + {'is_dict': <function <lambda>>, 'is_list': <function <lambda>>, 'is_str': <function <lambda>>, 'is_str_or_list': <function <lambda>>, 'is_int': <function <lambda>>, 'is_bool': <function <lambda>>, 'is_float': <function <lambda>>, 'is_alnum': <function <lambda>>, 'is_alpha': <function <lambda>>, 'is_digit': <function <lambda>>, 'is_path': <function <lambda>>, 'starts_alpha': <function <lambda>>, 'startswith': <function <lambda>>} + + +
+ + + + +
+
+
+ validators = + + CallableRegistry(_all={'core': {'is_dict': <function <lambda>>, 'is_list': <function <lambda>>, 'is_str': <function <lambda>>, 'is_str_or_list': <function <lambda>>, 'is_int': <function <lambda>>, 'is_bool': <function <lambda>>, 'is_float': <function <lambda>>, 'is_alnum': <function <lambda>>, 'is_alpha': <function <lambda>>, 'is_digit': <function <lambda>>, 'is_path': <function <lambda>>, 'starts_alpha': <function <lambda>>, 'startswith': <function <lambda>>}}, callable_name='Validator') + + +
+ + + + +
+
+ +
+ + class + Validator(cli_wrapper.util.callable_chain.CallableChain): + + + +
+ +
30class Validator(CallableChain):
+31    """
+32    @public
+33
+34    A class that provides a validation mechanism for input data.
+35    It uses a list of validators to check if the input data is valid.
+36    They are executed in sequence until one fails.
+37    """
+38
+39    def __init__(self, config):
+40        if callable(config):
+41            id_ = str(uuid4())
+42            validators.register(id_, config)
+43            config = id_
+44        self.config = config
+45        super().__init__(config, validators)
+46
+47    def __call__(self, value):
+48        result = True
+49        config = [self.config] if not isinstance(self.config, list) else self.config
+50        for x, c in zip(self.chain, config):
+51            validator_result = x(value)
+52            _logger.debug(f"Validator {c} result: {validator_result}")
+53            result = result and validator_result
+54            if isinstance(result, str) or not result:
+55                # don't bother doing other validations once one has failed
+56                _logger.debug("...failed")
+57                break
+58        return result
+59
+60    def to_dict(self):
+61        """
+62        Converts the validator configuration to a dictionary.
+63        """
+64        _logger.debug(f"returning validator config: {self.config}")
+65        return self.config
+
+ + +

A class that provides a validation mechanism for input data. +It uses a list of validators to check if the input data is valid. +They are executed in sequence until one fails.

+
+ + +
+ +
+ + Validator(config) + + + +
+ +
39    def __init__(self, config):
+40        if callable(config):
+41            id_ = str(uuid4())
+42            validators.register(id_, config)
+43            config = id_
+44        self.config = config
+45        super().__init__(config, validators)
+
+ + +
Parameters
+ +
    +
  • config: a callable, a string, a dictionary with one key and config, or a list of the previous
  • +
  • source: a CallableRegistry to get callables from
  • +
+
+ + +
+
+
+ config + + +
+ + + + +
+
+ +
+ + def + to_dict(self): + + + +
+ +
60    def to_dict(self):
+61        """
+62        Converts the validator configuration to a dictionary.
+63        """
+64        _logger.debug(f"returning validator config: {self.config}")
+65        return self.config
+
+ + +

Converts the validator configuration to a dictionary.

+
+ + +
+
+
+ + \ No newline at end of file diff --git a/docs/index.html b/docs/index.html new file mode 100644 index 0000000..db6dbb8 --- /dev/null +++ b/docs/index.html @@ -0,0 +1,7 @@ + + + + + + + diff --git a/docs/search.js b/docs/search.js new file mode 100644 index 0000000..2c5d1c9 --- /dev/null +++ b/docs/search.js @@ -0,0 +1,46 @@ +window.pdocSearch = (function(){ +/** elasticlunr - http://weixsong.github.io * Copyright (C) 2017 Oliver Nightingale * Copyright (C) 2017 Wei Song * MIT Licensed */!function(){function e(e){if(null===e||"object"!=typeof e)return e;var t=e.constructor();for(var n in e)e.hasOwnProperty(n)&&(t[n]=e[n]);return t}var t=function(e){var n=new t.Index;return n.pipeline.add(t.trimmer,t.stopWordFilter,t.stemmer),e&&e.call(n,n),n};t.version="0.9.5",lunr=t,t.utils={},t.utils.warn=function(e){return function(t){e.console&&console.warn&&console.warn(t)}}(this),t.utils.toString=function(e){return void 0===e||null===e?"":e.toString()},t.EventEmitter=function(){this.events={}},t.EventEmitter.prototype.addListener=function(){var e=Array.prototype.slice.call(arguments),t=e.pop(),n=e;if("function"!=typeof t)throw new TypeError("last argument must be a function");n.forEach(function(e){this.hasHandler(e)||(this.events[e]=[]),this.events[e].push(t)},this)},t.EventEmitter.prototype.removeListener=function(e,t){if(this.hasHandler(e)){var n=this.events[e].indexOf(t);-1!==n&&(this.events[e].splice(n,1),0==this.events[e].length&&delete this.events[e])}},t.EventEmitter.prototype.emit=function(e){if(this.hasHandler(e)){var t=Array.prototype.slice.call(arguments,1);this.events[e].forEach(function(e){e.apply(void 0,t)},this)}},t.EventEmitter.prototype.hasHandler=function(e){return e in this.events},t.tokenizer=function(e){if(!arguments.length||null===e||void 0===e)return[];if(Array.isArray(e)){var n=e.filter(function(e){return null===e||void 0===e?!1:!0});n=n.map(function(e){return t.utils.toString(e).toLowerCase()});var i=[];return n.forEach(function(e){var n=e.split(t.tokenizer.seperator);i=i.concat(n)},this),i}return e.toString().trim().toLowerCase().split(t.tokenizer.seperator)},t.tokenizer.defaultSeperator=/[\s\-]+/,t.tokenizer.seperator=t.tokenizer.defaultSeperator,t.tokenizer.setSeperator=function(e){null!==e&&void 0!==e&&"object"==typeof e&&(t.tokenizer.seperator=e)},t.tokenizer.resetSeperator=function(){t.tokenizer.seperator=t.tokenizer.defaultSeperator},t.tokenizer.getSeperator=function(){return t.tokenizer.seperator},t.Pipeline=function(){this._queue=[]},t.Pipeline.registeredFunctions={},t.Pipeline.registerFunction=function(e,n){n in t.Pipeline.registeredFunctions&&t.utils.warn("Overwriting existing registered function: "+n),e.label=n,t.Pipeline.registeredFunctions[n]=e},t.Pipeline.getRegisteredFunction=function(e){return e in t.Pipeline.registeredFunctions!=!0?null:t.Pipeline.registeredFunctions[e]},t.Pipeline.warnIfFunctionNotRegistered=function(e){var n=e.label&&e.label in this.registeredFunctions;n||t.utils.warn("Function is not registered with pipeline. This may cause problems when serialising the index.\n",e)},t.Pipeline.load=function(e){var n=new t.Pipeline;return e.forEach(function(e){var i=t.Pipeline.getRegisteredFunction(e);if(!i)throw new Error("Cannot load un-registered function: "+e);n.add(i)}),n},t.Pipeline.prototype.add=function(){var e=Array.prototype.slice.call(arguments);e.forEach(function(e){t.Pipeline.warnIfFunctionNotRegistered(e),this._queue.push(e)},this)},t.Pipeline.prototype.after=function(e,n){t.Pipeline.warnIfFunctionNotRegistered(n);var i=this._queue.indexOf(e);if(-1===i)throw new Error("Cannot find existingFn");this._queue.splice(i+1,0,n)},t.Pipeline.prototype.before=function(e,n){t.Pipeline.warnIfFunctionNotRegistered(n);var i=this._queue.indexOf(e);if(-1===i)throw new Error("Cannot find existingFn");this._queue.splice(i,0,n)},t.Pipeline.prototype.remove=function(e){var t=this._queue.indexOf(e);-1!==t&&this._queue.splice(t,1)},t.Pipeline.prototype.run=function(e){for(var t=[],n=e.length,i=this._queue.length,o=0;n>o;o++){for(var r=e[o],s=0;i>s&&(r=this._queue[s](r,o,e),void 0!==r&&null!==r);s++);void 0!==r&&null!==r&&t.push(r)}return t},t.Pipeline.prototype.reset=function(){this._queue=[]},t.Pipeline.prototype.get=function(){return this._queue},t.Pipeline.prototype.toJSON=function(){return this._queue.map(function(e){return t.Pipeline.warnIfFunctionNotRegistered(e),e.label})},t.Index=function(){this._fields=[],this._ref="id",this.pipeline=new t.Pipeline,this.documentStore=new t.DocumentStore,this.index={},this.eventEmitter=new t.EventEmitter,this._idfCache={},this.on("add","remove","update",function(){this._idfCache={}}.bind(this))},t.Index.prototype.on=function(){var e=Array.prototype.slice.call(arguments);return this.eventEmitter.addListener.apply(this.eventEmitter,e)},t.Index.prototype.off=function(e,t){return this.eventEmitter.removeListener(e,t)},t.Index.load=function(e){e.version!==t.version&&t.utils.warn("version mismatch: current "+t.version+" importing "+e.version);var n=new this;n._fields=e.fields,n._ref=e.ref,n.documentStore=t.DocumentStore.load(e.documentStore),n.pipeline=t.Pipeline.load(e.pipeline),n.index={};for(var i in e.index)n.index[i]=t.InvertedIndex.load(e.index[i]);return n},t.Index.prototype.addField=function(e){return this._fields.push(e),this.index[e]=new t.InvertedIndex,this},t.Index.prototype.setRef=function(e){return this._ref=e,this},t.Index.prototype.saveDocument=function(e){return this.documentStore=new t.DocumentStore(e),this},t.Index.prototype.addDoc=function(e,n){if(e){var n=void 0===n?!0:n,i=e[this._ref];this.documentStore.addDoc(i,e),this._fields.forEach(function(n){var o=this.pipeline.run(t.tokenizer(e[n]));this.documentStore.addFieldLength(i,n,o.length);var r={};o.forEach(function(e){e in r?r[e]+=1:r[e]=1},this);for(var s in r){var u=r[s];u=Math.sqrt(u),this.index[n].addToken(s,{ref:i,tf:u})}},this),n&&this.eventEmitter.emit("add",e,this)}},t.Index.prototype.removeDocByRef=function(e){if(e&&this.documentStore.isDocStored()!==!1&&this.documentStore.hasDoc(e)){var t=this.documentStore.getDoc(e);this.removeDoc(t,!1)}},t.Index.prototype.removeDoc=function(e,n){if(e){var n=void 0===n?!0:n,i=e[this._ref];this.documentStore.hasDoc(i)&&(this.documentStore.removeDoc(i),this._fields.forEach(function(n){var o=this.pipeline.run(t.tokenizer(e[n]));o.forEach(function(e){this.index[n].removeToken(e,i)},this)},this),n&&this.eventEmitter.emit("remove",e,this))}},t.Index.prototype.updateDoc=function(e,t){var t=void 0===t?!0:t;this.removeDocByRef(e[this._ref],!1),this.addDoc(e,!1),t&&this.eventEmitter.emit("update",e,this)},t.Index.prototype.idf=function(e,t){var n="@"+t+"/"+e;if(Object.prototype.hasOwnProperty.call(this._idfCache,n))return this._idfCache[n];var i=this.index[t].getDocFreq(e),o=1+Math.log(this.documentStore.length/(i+1));return this._idfCache[n]=o,o},t.Index.prototype.getFields=function(){return this._fields.slice()},t.Index.prototype.search=function(e,n){if(!e)return[];e="string"==typeof e?{any:e}:JSON.parse(JSON.stringify(e));var i=null;null!=n&&(i=JSON.stringify(n));for(var o=new t.Configuration(i,this.getFields()).get(),r={},s=Object.keys(e),u=0;u0&&t.push(e);for(var i in n)"docs"!==i&&"df"!==i&&this.expandToken(e+i,t,n[i]);return t},t.InvertedIndex.prototype.toJSON=function(){return{root:this.root}},t.Configuration=function(e,n){var e=e||"";if(void 0==n||null==n)throw new Error("fields should not be null");this.config={};var i;try{i=JSON.parse(e),this.buildUserConfig(i,n)}catch(o){t.utils.warn("user configuration parse failed, will use default configuration"),this.buildDefaultConfig(n)}},t.Configuration.prototype.buildDefaultConfig=function(e){this.reset(),e.forEach(function(e){this.config[e]={boost:1,bool:"OR",expand:!1}},this)},t.Configuration.prototype.buildUserConfig=function(e,n){var i="OR",o=!1;if(this.reset(),"bool"in e&&(i=e.bool||i),"expand"in e&&(o=e.expand||o),"fields"in e)for(var r in e.fields)if(n.indexOf(r)>-1){var s=e.fields[r],u=o;void 0!=s.expand&&(u=s.expand),this.config[r]={boost:s.boost||0===s.boost?s.boost:1,bool:s.bool||i,expand:u}}else t.utils.warn("field name in user configuration not found in index instance fields");else this.addAllFields2UserConfig(i,o,n)},t.Configuration.prototype.addAllFields2UserConfig=function(e,t,n){n.forEach(function(n){this.config[n]={boost:1,bool:e,expand:t}},this)},t.Configuration.prototype.get=function(){return this.config},t.Configuration.prototype.reset=function(){this.config={}},lunr.SortedSet=function(){this.length=0,this.elements=[]},lunr.SortedSet.load=function(e){var t=new this;return t.elements=e,t.length=e.length,t},lunr.SortedSet.prototype.add=function(){var e,t;for(e=0;e1;){if(r===e)return o;e>r&&(t=o),r>e&&(n=o),i=n-t,o=t+Math.floor(i/2),r=this.elements[o]}return r===e?o:-1},lunr.SortedSet.prototype.locationFor=function(e){for(var t=0,n=this.elements.length,i=n-t,o=t+Math.floor(i/2),r=this.elements[o];i>1;)e>r&&(t=o),r>e&&(n=o),i=n-t,o=t+Math.floor(i/2),r=this.elements[o];return r>e?o:e>r?o+1:void 0},lunr.SortedSet.prototype.intersect=function(e){for(var t=new lunr.SortedSet,n=0,i=0,o=this.length,r=e.length,s=this.elements,u=e.elements;;){if(n>o-1||i>r-1)break;s[n]!==u[i]?s[n]u[i]&&i++:(t.add(s[n]),n++,i++)}return t},lunr.SortedSet.prototype.clone=function(){var e=new lunr.SortedSet;return e.elements=this.toArray(),e.length=e.elements.length,e},lunr.SortedSet.prototype.union=function(e){var t,n,i;this.length>=e.length?(t=this,n=e):(t=e,n=this),i=t.clone();for(var o=0,r=n.toArray();oCLIWrapper represents calls to CLI tools as an object with native python function calls.

\n\n

Examples

\n\n
from json import loads  # or any other parser\nfrom cli_wrapper import CLIWrapper\nkubectl = CLIWrapper('kubectl')\nkubectl._update_command(\"get\", default_flags={\"output\": \"json\"}, parse=loads)\n# this will run `kubectl get pods --namespace kube-system --output json`\nresult = kubectl.get(\"pods\", namespace=\"kube-system\")\nprint(result)\n\nkubectl = CLIWrapper('kubectl', async_=True)\nkubectl._update_command(\"get\", default_flags={\"output\": \"json\"}, parse=loads)\nresult = await kubectl.get(\"pods\", namespace=\"kube-system\")  # same thing but async\nprint(result)\n
\n\n

You can also override argument names and provide input validators:

\n\n
from json import loads\nfrom cli_wrapper import CLIWrapper\nkubectl = CLIWrapper('kubectl')\nkubectl._update_command(\"get_all\", cli_command=\"get\", default_flags={\"output\": \"json\", \"A\": None}, parse=loads)\nresult = kubectl.get_all(\"pods\")  # this will run `kubectl get pods -A --output json`\nprint(result)\n\ndef validate_pod_name(name):\n    return all(\n        len(name) < 253,\n        name[0].isalnum() and name[-1].isalnum(),\n        all(c.isalnum() or c in ['-', '.'] for c in name[1:-1])\n    )\nkubectl._update_command(\"get\", validators={1: validate_pod_name})\nresult = kubectl.get(\"pod\", \"my-pod!!\")  # raises ValueError\n
\n\n

Callable serialization

\n\n

Argument validation and parser configuration are not straightforward to serialize. To get around this, CLI Wrapper uses\nCallableRegistry and CallableChain. These make it somewhat more straightforward to create more serializable wrapper\nconfigurations.

\n\n

TL;DR

\n\n
    \n
  • Functions that perform validation, argument transformation, or output parsing are registered with a name in a\nCallableRegistry
  • \n
  • CallableChain resolves a serializable structure to a sequence of calls to those functions

    \n\n
      \n
    • a string refers to a function, which will be called directly
    • \n
    • a dict is expected to have one key (the function name), with a value that provides additional configuration:\n
        \n
      • a string as a single positional arg
      • \n
      • a list of positional args
      • \n
      • a dict of kwargs (the key \"args\" will be popped and used as positional args if present)
      • \n
    • \n
    • a list of the previous two
    • \n
  • \n
  • A list of validators is treated as a set of conditions which must be true

  • \n
  • A list of parsers will be piped together in sequence
  • \n
  • Transformers receive an arg name and value, and return another arg and value. They are not chained.
  • \n
\n\n

Implementation

\n\n

Here's how these work:

\n\n

CallableRegistry

\n\n

Callable registries form the basis of serializing callables by mapping strings to functions. If you are doing custom\nparsers and validators and you want these to be serializable, you will use their respective callable registries to\nassociate the code with the serializable name.

\n\n
\n
def greater_than(a, b):\n  return a > b\n\n\nregistry = CallableRegistry(\n  {\n    "core" = {}\n  }\n)\nregistry.register("gt", greater_than)\n\nx = registry.get("gt", [2])\n\nassert(not x(1))\nassert(x(3))\n
\n
\n\n

CallableChain

\n\n

A callable chain is a serializable structure that gets converted to a sequence of calls to things in a\ncli_wrapper.util.callable_registry.CallableRegistry. It is an abstract base class, and so shouldn't be created directly; subclasses are expected to\nimplement __call__. We'll use the .validators.Validator class as an example. validators is a CallableRegistry with all of\nthe base validators (is_dict, is_list, is_str, startswith...)

\n\n
\n
# Say we have these validators that we want to run:\ndef every_letter_is(v, l):\n    return all((x == l.lower()) or (x == l.upper()) for x in v)\n\nvalidators.register("every_letter_is", every_letter_is)\n\nmy_validation = ["is_str", {"every_letter_is": "a"}]\n\nstraight_as = Validator(my_validation)\nassert(straight_as("aaaaAAaa"))\nassert(not straight_as("aaaababa"))\n
\n
\n\n

Validator.__call__ just checks that every validation returns true. Elsewhere, Parser pipes inputs in sequence:

\n\n
\n
parser:\n  - yaml\n  - extract: result \n
\n
\n\n

This would first parse the output as yaml and then extract the \"result\" key from the dictionary returned by the yaml\nstep.

\n\n

from curses import wrapper

\n\n

Validators

\n\n

Validators are used to validate argument values. They are implemented as a\ncli_wrapper.util.callable_chain.CallableChain for serialization. Callables in the chain are called with the value\nsequentially, stopping at the first callable that returns False.

\n\n

Default Validators

\n\n

The default validators are:

\n\n
    \n
  • is_dict
  • \n
  • is_list
  • \n
  • is_str
  • \n
  • is_str_or_list
  • \n
  • is_int
  • \n
  • is_float
  • \n
  • is_bool
  • \n
  • is_path - is a pathlib.Path
  • \n
  • is_alnum - is alphanumeric
  • \n
  • is_alpha - is alphabetic
  • \n
  • starts_alpha - first digit is a letter
  • \n
  • startswith - checks if the string starts with a given prefix
  • \n
\n\n

Custom Validators

\n\n

You can register your own validators in cli_wrapper.validators.validators:

\n\n
    \n
  1. Takes at most one positional argument
  2. \n
  3. When configuring the validator, additional arguments can be supplied using a dictionary:
  4. \n
\n\n
\n
wrapper.update_command_("cmd", validators={"arg":["is_str", {"startswith": {"prefix": "prefix"}}]})\n# or\nwrapper.update_command_("cmd", validators={"arg": ["is_str", {"startswith": "prefix"}]})\n
\n
\n\n

Example

\n\n
\n
from cli_wrapper import CLIWrapper\nfrom cli_wrapper.validators import validators\n\ndef is_alnum_or_dash(value):\n    return all(c.isalnum() or c == "-" for c in value)\nvalidators.register("is_alnum_or_dash", is_alnum_or_dash)\n\nkubectl = CLIWrapper("kubectl")\n# 1 refers to the first positional argument, so in `kubectl.get("pods", "my-pod")` it would refer to `"my-pod"`\nkubectl.update_command_("get", validators={\n 1: ["is_str", "is_alnum_or_dash", "starts_alpha"],\n})\n\nassert kubectl.get("pods", "my-pod")\nthrew = False\ntry:\n    kubectl.get("pods", "level-9000-pod!!")\nexcept ValueError:\n    threw = True\nassert threw\n
\n
\n\n

Parsers

\n\n

Parsers provide a mechanism to convert the output of a CLI tool into a usable structure. They make use of\ncli_wrapper.util.callable_chain.CallableChain to be serializable-ish.

\n\n

Default Parsers

\n\n
    \n
  1. json: uses json.loads to parse stdout
  2. \n
  3. extract: extracts data from the raw output, using the args as a list of nested keys.
  4. \n
  5. yaml: if ruamel.yaml is installed, uses YAML().load_all to read stdout. If load_all only returns one\ndocument, it returns that document. Otherwise, it returns a list of documents. pyyaml is also supported.
  6. \n
  7. dotted_dict: if dotted_dict is installed, converts an input dict or list to a PreserveKeysDottedDict or \na list of them. This lets you refer to most dictionary keys as a.b.c instead of a[\"b\"][\"c\"].
  8. \n
\n\n

These can be combined in a list in the parse argument to cli_wrapper.cli_wrapper.CLIWrapper.update_command_,\nallowing the result of the call to be immediately usable.

\n\n

You can also register your own parsers in cli_wrapper.parsers.parsers, which is a \ncli_wrapper.util.callable_registry.CallableRegistry.

\n\n

Examples

\n\n
\n
from cli_wrapper import CLIWrapper\n\ndef skip_lists(result): \n    if result["kind"] == "List":\n        return result["items"]\n    return result\n\nkubectl = CLIWrapper("kubectl")\n# you can use the parser directly, but you won't be able to serialize the\n# wrapper to json\nkubectl.update_command_(\n   "get",\n   parse=["json", skip_lists, "dotted_dict"],\n   default_flags=["--output", "json"]\n)\n\na = kubectl.get("pods", namespace="kube-system")\nassert isinstance(a, list)\nb = kubectl.get("pods", a[0].metadata.name, namespace="kube-system")\nassert isinstance(b, dict)\nassert b.metadata.name == a[0].metadata.name\n
\n
\n\n

Transformers

\n\n

Argument transformers receive an argument (either a numbered positional argument or a string keywork argument/flag) and\na value. They return a tuple of argument and value that replace the original.

\n\n

The main transformer used by cli-wrapper is cli_wrapper.transformers.snake2kebab, which converts a an_argument_like_this to\nan-argument-like-this and returns the value unchanged. This is the default transformer for all keyword arguments.

\n\n

Transformers are added to a callable registry, so they can be refernced as a string after they're registered.\nTransformers are not currently chained.

\n\n

Other possibilities for transformers

\n\n

1. Write dictionaries to files and return a flag referencing a file

\n\n

Consider a command like kubectl create: the primary argument is a filename or list of files. Say you have your \nmanifest to create as a dictionary:

\n\n
\n
from pathlib import Path\nfrom ruamel.yaml import YAML\nfrom cli_wrapper import transformers, CLIWrapper\n\nmanifest_count = 0\nbase_filename = "my_manifest"\nbase_dir = Path()\ny = YAML()\ndef write_manifest(manifest: dict | list[dict]):\n    global manifest_count\n    manifest_count += 1\n    file = base_dir / f"{base_filename}_{manifest_count}.yaml"\n    with file.open("w") as f:\n        if isinstance(manifest, list):\n            y.dump_all(manifest, f)\n        else:\n            y.dump(manifest, f)\n    return file.as_posix()\n\ndef manifest_transformer(arg, value, writer=write_manifest):\n    return "filename", writer(value)\n\ntransformers.register("manifest", manifest_transformer)\n\n# If you had different writer functions (e.g., different base name), you could register those as partials:\nfrom functools import partial\ntransformers.register("other_manifest", partial(manifest_transformer, writer=my_other_writer))\n\nkubectl = CLIWrapper('kubectl')\nkubectl.update_command_("create", args={"data": {"transformer": "manifest"}})\n\n# will write the manifest to "my_manifest_1.yaml" and execute `kubectl create -f my_manifest_1.yaml`\nkubectl.create(data=my_kubernetes_manifest)\n
\n
\n\n

Possible future changes

\n\n
    \n
  • it might make sense to make transformers a CallableChain similar to parser so a sequence of things can be done on an arg
  • \n
  • it might also make sense to support transformers that break individual args into multiple args with separate values
  • \n
\n"}, {"fullname": "cli_wrapper.cli_wrapper", "modulename": "cli_wrapper.cli_wrapper", "kind": "module", "doc": "

\n"}, {"fullname": "cli_wrapper.cli_wrapper.Argument", "modulename": "cli_wrapper.cli_wrapper", "qualname": "Argument", "kind": "class", "doc": "

Argument represents a command line argument to be passed to the cli_wrapper

\n"}, {"fullname": "cli_wrapper.cli_wrapper.Argument.__init__", "modulename": "cli_wrapper.cli_wrapper", "qualname": "Argument.__init__", "kind": "function", "doc": "

Method generated by attrs for class Argument.

\n", "signature": "(\tliteral_name: str | None = None,\tdefault: str = None,\tvalidator=None,\ttransformer: Union[Callable, str, dict, list[str | dict]] = 'snake2kebab')"}, {"fullname": "cli_wrapper.cli_wrapper.Argument.from_dict", "modulename": "cli_wrapper.cli_wrapper", "qualname": "Argument.from_dict", "kind": "function", "doc": "

Create an Argument from a dictionary

\n\n
Parameters
\n\n
    \n
  • arg_dict: the dictionary to be converted
  • \n
\n\n
Returns
\n\n
\n

Argument object

\n
\n", "signature": "(cls, arg_dict):", "funcdef": "def"}, {"fullname": "cli_wrapper.cli_wrapper.Argument.to_dict", "modulename": "cli_wrapper.cli_wrapper", "qualname": "Argument.to_dict", "kind": "function", "doc": "

Convert the Argument to a dictionary

\n\n
Returns
\n\n
\n

the dictionary representation of the Argument

\n
\n", "signature": "(self):", "funcdef": "def"}, {"fullname": "cli_wrapper.cli_wrapper.Argument.is_valid", "modulename": "cli_wrapper.cli_wrapper", "qualname": "Argument.is_valid", "kind": "function", "doc": "

Validate the value of the argument

\n\n
Parameters
\n\n
    \n
  • value: the value to be validated
  • \n
\n\n
Returns
\n\n
\n

True if valid, False otherwise

\n
\n", "signature": "(self, value):", "funcdef": "def"}, {"fullname": "cli_wrapper.cli_wrapper.Argument.transform", "modulename": "cli_wrapper.cli_wrapper", "qualname": "Argument.transform", "kind": "function", "doc": "

Transform the name and value of the argument

\n\n
Parameters
\n\n
    \n
  • name: the name of the argument
  • \n
  • value: the value to be transformed
  • \n
\n\n
Returns
\n\n
\n

the transformed value

\n
\n", "signature": "(self, name, value, **kwargs):", "funcdef": "def"}, {"fullname": "cli_wrapper.cli_wrapper.Command", "modulename": "cli_wrapper.cli_wrapper", "qualname": "Command", "kind": "class", "doc": "

Command represents a command to be run with the cli_wrapper

\n"}, {"fullname": "cli_wrapper.cli_wrapper.Command.__init__", "modulename": "cli_wrapper.cli_wrapper", "qualname": "Command.__init__", "kind": "function", "doc": "

Method generated by attrs for class Command.

\n", "signature": "(\tcli_command: str | list[str],\tdefault_flags: dict = {},\targs: dict = NOTHING,\tparse=None,\tdefault_transformer: str = 'snake2kebab',\tshort_prefix: str = '-',\tlong_prefix: str = '--',\targ_separator: str = '=')"}, {"fullname": "cli_wrapper.cli_wrapper.Command.from_dict", "modulename": "cli_wrapper.cli_wrapper", "qualname": "Command.from_dict", "kind": "function", "doc": "

Create a Command from a dictionary

\n\n
Parameters
\n\n
    \n
  • command_dict: the dictionary to be converted
  • \n
\n\n
Returns
\n\n
\n

Command object

\n
\n", "signature": "(cls, command_dict, **kwargs):", "funcdef": "def"}, {"fullname": "cli_wrapper.cli_wrapper.Command.to_dict", "modulename": "cli_wrapper.cli_wrapper", "qualname": "Command.to_dict", "kind": "function", "doc": "

Convert the Command to a dictionary.\nExcludes prefixes/separators, because they are set in the CLIWrapper

\n\n
Returns
\n\n
\n

the dictionary representation of the Command

\n
\n", "signature": "(self):", "funcdef": "def"}, {"fullname": "cli_wrapper.cli_wrapper.Command.validate_args", "modulename": "cli_wrapper.cli_wrapper", "qualname": "Command.validate_args", "kind": "function", "doc": "

\n", "signature": "(self, *args, **kwargs):", "funcdef": "def"}, {"fullname": "cli_wrapper.cli_wrapper.Command.build_args", "modulename": "cli_wrapper.cli_wrapper", "qualname": "Command.build_args", "kind": "function", "doc": "

\n", "signature": "(self, *args, **kwargs):", "funcdef": "def"}, {"fullname": "cli_wrapper.cli_wrapper.CLIWrapper", "modulename": "cli_wrapper.cli_wrapper", "qualname": "CLIWrapper", "kind": "class", "doc": "
Parameters
\n\n
    \n
  • path: The path to the CLI tool. This will be passed to subprocess directly, and does not require a full path\nunless the tool is not in the system path.
  • \n
  • env: A dict of environment variables to be set in the subprocess environment, in addition to and overriding\nthose in os.environ.
  • \n
  • trusting: If True, the wrapper will accept any command and pass them to the cli with default configuration.\nOtherwise, it will only allow commands that have been defined with update_command_
  • \n
  • raise_exc: If True, the wrapper will raise an exception if a command returns a non-zero exit code.
  • \n
  • async_: If true, the wrapper will return coroutines that must be awaited.
  • \n
  • default_transformer: The transformer configuration to apply to all arguments. The default of snake2kebab will\nconvert pythonic_snake_case_kwargs to kebab-case-arguments
  • \n
  • short_prefix: The string prefix for single-letter arguments
  • \n
  • long_prefix: The string prefix for arguments longer than 1 letter
  • \n
  • arg_separator: The character that separates argument values from names. Defaults to '=', so\nwrapper.command(arg=value) would become \"wrapper command --arg=value\"
  • \n
\n"}, {"fullname": "cli_wrapper.cli_wrapper.CLIWrapper.__init__", "modulename": "cli_wrapper.cli_wrapper", "qualname": "CLIWrapper.__init__", "kind": "function", "doc": "

Method generated by attrs for class CLIWrapper.

\n", "signature": "(\tpath: str,\tenv: dict[str, str] = None,\tcommands: dict[str, cli_wrapper.cli_wrapper.Command] = {},\ttrusting: bool = True,\traise_exc: bool = False,\tasync_: bool = False,\tdefault_transformer: str = 'snake2kebab',\tshort_prefix: str = '-',\tlong_prefix: str = '--',\targ_separator: str = '=')"}, {"fullname": "cli_wrapper.cli_wrapper.CLIWrapper.update_command_", "modulename": "cli_wrapper.cli_wrapper", "qualname": "CLIWrapper.update_command_", "kind": "function", "doc": "

update the command to be run with the cli_wrapper

\n\n
Parameters
\n\n
    \n
  • command: the command name for the wrapper
  • \n
  • cli_command: the command to be run, if different from the command name
  • \n
  • args: the arguments passed to the command
  • \n
  • default_flags: default flags to be used with the command
  • \n
  • parse: function to parse the output of the command
  • \n
\n\n
Returns
\n", "signature": "(\tself,\tcommand: str,\t*,\tcli_command: str | list[str] = None,\targs: dict[str | int, any] = None,\tdefault_flags: dict = None,\tparse=None):", "funcdef": "def"}, {"fullname": "cli_wrapper.cli_wrapper.CLIWrapper.from_dict", "modulename": "cli_wrapper.cli_wrapper", "qualname": "CLIWrapper.from_dict", "kind": "function", "doc": "

Create a CLIWrapper from a dictionary

\n\n
Parameters
\n\n
    \n
  • cliwrapper_dict: the dictionary to be converted
  • \n
\n\n
Returns
\n\n
\n

CLIWrapper object

\n
\n", "signature": "(cls, cliwrapper_dict):", "funcdef": "def"}, {"fullname": "cli_wrapper.cli_wrapper.CLIWrapper.to_dict", "modulename": "cli_wrapper.cli_wrapper", "qualname": "CLIWrapper.to_dict", "kind": "function", "doc": "

Convert the CLIWrapper to a dictionary

\n\n
Returns
\n\n
\n

a dictionary that can be used to recreate the wrapper using from_dict

\n
\n", "signature": "(self):", "funcdef": "def"}, {"fullname": "cli_wrapper.parsers", "modulename": "cli_wrapper.parsers", "kind": "module", "doc": "

\n"}, {"fullname": "cli_wrapper.parsers.extract", "modulename": "cli_wrapper.parsers", "qualname": "extract", "kind": "function", "doc": "

Extracts a sub-dictionary from a source dictionary based on a given path.\nTODO: this

\n\n
Parameters
\n\n
    \n
  • src: The source dictionary to extract from.
  • \n
  • path: A list of keys representing the path to the sub-dictionary.
  • \n
\n\n
Returns
\n\n
\n

The extracted sub-dictionary.

\n
\n", "signature": "(src: dict, *args) -> dict:", "funcdef": "def"}, {"fullname": "cli_wrapper.parsers.core_parsers", "modulename": "cli_wrapper.parsers", "qualname": "core_parsers", "kind": "variable", "doc": "

\n", "default_value": "{'extract': <function extract>, 'json': <function loads>, 'yaml': <function yaml_loads>, 'dotted_dict': <function dotted_dictify>}"}, {"fullname": "cli_wrapper.parsers.parsers", "modulename": "cli_wrapper.parsers", "qualname": "parsers", "kind": "variable", "doc": "

A CallableRegistry of parsers. These can be chained in sequence to perform \noperations on input.

\n\n

Defaults:\ncore parsers:

\n\n
    \n
  • json - parses the input as json, returns the result
  • \n
  • extract - extracts the specified sub-dictionary from the source dictionary
  • \n
  • yaml - parses the input as yaml, returns the result (requires ruamel.yaml or pyyaml)
  • \n
  • dotted_dict - converts an input dictionary to a dotted_dict (requires dotted_dict)
  • \n
\n", "default_value": "CallableRegistry(_all={'core': {'extract': <function extract>, 'json': <function loads>, 'yaml': <function yaml_loads>, 'dotted_dict': <function dotted_dictify>}}, callable_name='Parser')"}, {"fullname": "cli_wrapper.parsers.Parser", "modulename": "cli_wrapper.parsers", "qualname": "Parser", "kind": "class", "doc": "

@public\nParser class that allows for the chaining of multiple parsers. Callables in the configuration are run as a\npipeline, with the output of one parser being passed as input to the next.

\n", "bases": "cli_wrapper.util.callable_chain.CallableChain"}, {"fullname": "cli_wrapper.parsers.Parser.__init__", "modulename": "cli_wrapper.parsers", "qualname": "Parser.__init__", "kind": "function", "doc": "

@public

\n\n
Parameters
\n\n
    \n
  • config: a callable, a string, a dictionary with one key and config, or a list of the previous
  • \n
  • source: a CallableRegistry to get callables from
  • \n
\n", "signature": "(config)"}, {"fullname": "cli_wrapper.parsers.yaml_loads", "modulename": "cli_wrapper.parsers", "qualname": "yaml_loads", "kind": "function", "doc": "

\n", "signature": "(src: str) -> dict:", "funcdef": "def"}, {"fullname": "cli_wrapper.parsers.dotted_dictify", "modulename": "cli_wrapper.parsers", "qualname": "dotted_dictify", "kind": "function", "doc": "

\n", "signature": "(src, *args, **kwargs):", "funcdef": "def"}, {"fullname": "cli_wrapper.pre_packaged", "modulename": "cli_wrapper.pre_packaged", "kind": "module", "doc": "

\n"}, {"fullname": "cli_wrapper.pre_packaged.get_wrapper", "modulename": "cli_wrapper.pre_packaged", "qualname": "get_wrapper", "kind": "function", "doc": "

Gets a wrapper defined in the beta/stable folders as json.

\n\n
Parameters
\n\n
    \n
  • name: the name of the wrapper to retrieve
  • \n
  • status: stable/beta/None. None will search stable and beta
  • \n
\n\n
Returns
\n\n
\n

the requested wrapper

\n
\n", "signature": "(name, status=None):", "funcdef": "def"}, {"fullname": "cli_wrapper.transformers", "modulename": "cli_wrapper.transformers", "kind": "module", "doc": "

\n"}, {"fullname": "cli_wrapper.transformers.snake2kebab", "modulename": "cli_wrapper.transformers", "qualname": "snake2kebab", "kind": "function", "doc": "

snake.gravity = 0

\n\n

converts a snake_case argument to a kebab-case one

\n", "signature": "(arg: str, value: <built-in function any>) -> tuple[str, any]:", "funcdef": "def"}, {"fullname": "cli_wrapper.transformers.transformers", "modulename": "cli_wrapper.transformers", "qualname": "transformers", "kind": "variable", "doc": "

A callable registry of transformers.

\n\n

Defaults:\ncore group:

\n\n
    \n
  • snake2kebab
  • \n
\n", "default_value": "CallableRegistry(_all={'core': {'snake2kebab': <function snake2kebab>}}, callable_name='Callable thing')"}, {"fullname": "cli_wrapper.util", "modulename": "cli_wrapper.util", "kind": "module", "doc": "

\n"}, {"fullname": "cli_wrapper.util.CallableRegistry", "modulename": "cli_wrapper.util", "qualname": "CallableRegistry", "kind": "class", "doc": "

Stores collections of callables. @public

\n\n
    \n
  • callables are registered by name
  • \n
  • they are retrieved by name with args and kwargs
  • \n
  • calling the callable with positional arguments will call the callable\nwith the args in the call, plus any args and kwargs passed to get()
  • \n
\n"}, {"fullname": "cli_wrapper.util.CallableRegistry.__init__", "modulename": "cli_wrapper.util", "qualname": "CallableRegistry.__init__", "kind": "function", "doc": "

Method generated by attrs for class CallableRegistry.

\n", "signature": "(\tall: dict[str, dict[str, typing.Callable]],\tcallable_name: str = 'Callable thing')"}, {"fullname": "cli_wrapper.util.CallableRegistry.callable_name", "modulename": "cli_wrapper.util", "qualname": "CallableRegistry.callable_name", "kind": "variable", "doc": "

a name of the things in the registry to use in error messages

\n", "annotation": ": str"}, {"fullname": "cli_wrapper.util.CallableRegistry.get", "modulename": "cli_wrapper.util", "qualname": "CallableRegistry.get", "kind": "function", "doc": "

Retrieves a callable function based on the specified parser name.

\n\n
Parameters
\n\n
    \n
  • name: The name of the callable to retrieve.
  • \n
\n\n
Returns
\n\n
\n

The corresponding callable function.

\n
\n\n
Raises
\n\n
    \n
  • KeyError: If the specified callable name is not found.
  • \n
\n", "signature": "(self, name: Union[str, Callable], args=None, kwargs=None) -> Callable:", "funcdef": "def"}, {"fullname": "cli_wrapper.util.CallableRegistry.register", "modulename": "cli_wrapper.util", "qualname": "CallableRegistry.register", "kind": "function", "doc": "

Registers a new callable function with the specified name.

\n\n
Parameters
\n\n
    \n
  • name: The name to associate with the callable.
  • \n
  • callable_: The callable function to register.
  • \n
\n", "signature": "(\tself,\tname: str,\tcallable_: <built-in function callable>,\tgroup='core'):", "funcdef": "def"}, {"fullname": "cli_wrapper.util.CallableRegistry.register_group", "modulename": "cli_wrapper.util", "qualname": "CallableRegistry.register_group", "kind": "function", "doc": "

Registers a new callable group with the specified name.

\n\n
Parameters
\n\n
    \n
  • name: The name to associate with the callable group.
  • \n
  • callables: A dictionary of callables to register in the group.
  • \n
\n", "signature": "(self, name: str, callables: dict = None):", "funcdef": "def"}, {"fullname": "cli_wrapper.util.CallableChain", "modulename": "cli_wrapper.util", "qualname": "CallableChain", "kind": "class", "doc": "

A callable object representing a collection of callables.

\n", "bases": "abc.ABC"}, {"fullname": "cli_wrapper.util.CallableChain.__init__", "modulename": "cli_wrapper.util", "qualname": "CallableChain.__init__", "kind": "function", "doc": "

@public

\n\n
Parameters
\n\n
    \n
  • config: a callable, a string, a dictionary with one key and config, or a list of the previous
  • \n
  • source: a CallableRegistry to get callables from
  • \n
\n", "signature": "(config, source)"}, {"fullname": "cli_wrapper.util.CallableChain.chain", "modulename": "cli_wrapper.util", "qualname": "CallableChain.chain", "kind": "variable", "doc": "

\n", "annotation": ": list[callable]"}, {"fullname": "cli_wrapper.util.CallableChain.config", "modulename": "cli_wrapper.util", "qualname": "CallableChain.config", "kind": "variable", "doc": "

\n", "annotation": ": list"}, {"fullname": "cli_wrapper.util.CallableChain.to_dict", "modulename": "cli_wrapper.util", "qualname": "CallableChain.to_dict", "kind": "function", "doc": "

\n", "signature": "(self):", "funcdef": "def"}, {"fullname": "cli_wrapper.validators", "modulename": "cli_wrapper.validators", "kind": "module", "doc": "

\n"}, {"fullname": "cli_wrapper.validators.core_validators", "modulename": "cli_wrapper.validators", "qualname": "core_validators", "kind": "variable", "doc": "

\n", "default_value": "{'is_dict': <function <lambda>>, 'is_list': <function <lambda>>, 'is_str': <function <lambda>>, 'is_str_or_list': <function <lambda>>, 'is_int': <function <lambda>>, 'is_bool': <function <lambda>>, 'is_float': <function <lambda>>, 'is_alnum': <function <lambda>>, 'is_alpha': <function <lambda>>, 'is_digit': <function <lambda>>, 'is_path': <function <lambda>>, 'starts_alpha': <function <lambda>>, 'startswith': <function <lambda>>}"}, {"fullname": "cli_wrapper.validators.validators", "modulename": "cli_wrapper.validators", "qualname": "validators", "kind": "variable", "doc": "

\n", "default_value": "CallableRegistry(_all={'core': {'is_dict': <function <lambda>>, 'is_list': <function <lambda>>, 'is_str': <function <lambda>>, 'is_str_or_list': <function <lambda>>, 'is_int': <function <lambda>>, 'is_bool': <function <lambda>>, 'is_float': <function <lambda>>, 'is_alnum': <function <lambda>>, 'is_alpha': <function <lambda>>, 'is_digit': <function <lambda>>, 'is_path': <function <lambda>>, 'starts_alpha': <function <lambda>>, 'startswith': <function <lambda>>}}, callable_name='Validator')"}, {"fullname": "cli_wrapper.validators.Validator", "modulename": "cli_wrapper.validators", "qualname": "Validator", "kind": "class", "doc": "

@public

\n\n

A class that provides a validation mechanism for input data.\nIt uses a list of validators to check if the input data is valid.\nThey are executed in sequence until one fails.

\n", "bases": "cli_wrapper.util.callable_chain.CallableChain"}, {"fullname": "cli_wrapper.validators.Validator.__init__", "modulename": "cli_wrapper.validators", "qualname": "Validator.__init__", "kind": "function", "doc": "

@public

\n\n
Parameters
\n\n
    \n
  • config: a callable, a string, a dictionary with one key and config, or a list of the previous
  • \n
  • source: a CallableRegistry to get callables from
  • \n
\n", "signature": "(config)"}, {"fullname": "cli_wrapper.validators.Validator.config", "modulename": "cli_wrapper.validators", "qualname": "Validator.config", "kind": "variable", "doc": "

\n"}, {"fullname": "cli_wrapper.validators.Validator.to_dict", "modulename": "cli_wrapper.validators", "qualname": "Validator.to_dict", "kind": "function", "doc": "

Converts the validator configuration to a dictionary.

\n", "signature": "(self):", "funcdef": "def"}]; + + // mirrored in build-search-index.js (part 1) + // Also split on html tags. this is a cheap heuristic, but good enough. + elasticlunr.tokenizer.setSeperator(/[\s\-.;&_'"=,()]+|<[^>]*>/); + + let searchIndex; + if (docs._isPrebuiltIndex) { + console.info("using precompiled search index"); + searchIndex = elasticlunr.Index.load(docs); + } else { + console.time("building search index"); + // mirrored in build-search-index.js (part 2) + searchIndex = elasticlunr(function () { + this.pipeline.remove(elasticlunr.stemmer); + this.pipeline.remove(elasticlunr.stopWordFilter); + this.addField("qualname"); + this.addField("fullname"); + this.addField("annotation"); + this.addField("default_value"); + this.addField("signature"); + this.addField("bases"); + this.addField("doc"); + this.setRef("fullname"); + }); + for (let doc of docs) { + searchIndex.addDoc(doc); + } + console.timeEnd("building search index"); + } + + return (term) => searchIndex.search(term, { + fields: { + qualname: {boost: 4}, + fullname: {boost: 2}, + annotation: {boost: 2}, + default_value: {boost: 2}, + signature: {boost: 2}, + bases: {boost: 2}, + doc: {boost: 1}, + }, + expand: true + }); +})(); \ No newline at end of file diff --git a/src/cli_wrapper/__init__.py b/src/cli_wrapper/__init__.py index 5f6bce1..4d256f4 100644 --- a/src/cli_wrapper/__init__.py +++ b/src/cli_wrapper/__init__.py @@ -1,10 +1,58 @@ """ -Wraps CLI tools and presents a python object-like interface. +CLIWrapper represents calls to CLI tools as an object with native python function calls. + +# Examples + +``` +from json import loads # or any other parser +from cli_wrapper import CLIWrapper +kubectl = CLIWrapper('kubectl') +kubectl._update_command("get", default_flags={"output": "json"}, parse=loads) +# this will run `kubectl get pods --namespace kube-system --output json` +result = kubectl.get("pods", namespace="kube-system") +print(result) + +kubectl = CLIWrapper('kubectl', async_=True) +kubectl._update_command("get", default_flags={"output": "json"}, parse=loads) +result = await kubectl.get("pods", namespace="kube-system") # same thing but async +print(result) +``` + +You can also override argument names and provide input validators: +``` +from json import loads +from cli_wrapper import CLIWrapper +kubectl = CLIWrapper('kubectl') +kubectl._update_command("get_all", cli_command="get", default_flags={"output": "json", "A": None}, parse=loads) +result = kubectl.get_all("pods") # this will run `kubectl get pods -A --output json` +print(result) + +def validate_pod_name(name): + return all( + len(name) < 253, + name[0].isalnum() and name[-1].isalnum(), + all(c.isalnum() or c in ['-', '.'] for c in name[1:-1]) + ) +kubectl._update_command("get", validators={1: validate_pod_name}) +result = kubectl.get("pod", "my-pod!!") # raises ValueError +``` +.. include:: ../../doc/callable_serialization.md + +.. include:: ../../doc/validators.md +.. include:: ../../doc/parsers.md +.. include:: ../../doc/transformers.md + """ -from .cli_wrapper import CLIWrapper -from .transformers import transformers -from .parsers import parsers -from .validators import validators +# from .cli_wrapper import CLIWrapper +# from .transformers import transformers +# from .parsers import parsers +# from .validators import validators +# from .util import callable_chain, callable_registry +# from .pre_packaged import get_wrapper + +# __all__ = ["CLIWrapper", "get_wrapper", "transformers", "parsers", "validators"] -__all__ = ["CLIWrapper", "transformers", "parsers", "validators"] +""" +.. include:: ./util +""" diff --git a/src/cli_wrapper/cli_wrapper.py b/src/cli_wrapper/cli_wrapper.py index bbbd54b..7079b34 100644 --- a/src/cli_wrapper/cli_wrapper.py +++ b/src/cli_wrapper/cli_wrapper.py @@ -1,53 +1,3 @@ -""" -CLIWrapper represents calls to CLI tools as an object with native python function calls. -For example: -`` -from json import loads # or any other parser -from cli_wrapper import CLIWrapper -kubectl = CLIWrapper('kubectl') -kubectl._update_command("get", default_flags={"output": "json"}, parse=loads) -# this will run `kubectl get pods --namespace kube-system --output json` -result = kubectl.get("pods", namespace="kube-system") -print(result) - -kubectl = CLIWrapper('kubectl', async_=True) -kubectl._update_command("get", default_flags={"output": "json"}, parse=loads) -result = await kubectl.get("pods", namespace="kube-system") # same thing but async -print(result) -`` - -You can also override argument names and provide input validators: -`` -from json import loads -from cli_wrapper import CLIWrapper -kubectl = CLIWrapper('kubectl') -kubectl._update_command("get_all", cli_command="get", default_flags={"output": "json", "A": None}, parse=loads) -result = kubectl.get_all("pods") # this will run `kubectl get pods -A --output json` -print(result) - -def validate_pod_name(name): - return all( - len(name) < 253, - name[0].isalnum() and name[-1].isalnum(), - all(c.isalnum() or c in ['-', '.'] for c in name[1:-1]) - ) -kubectl._update_command("get", validators={1: validate_pod_name}) -result = kubectl.get("pod", "my-pod!!") # raises ValueError -`` - -Attributes: - trusting: if false, only run defined commands, and validate any arguments that have validation. If true, run - any command. This is useful for cli tools that have a lot of commands that you probably won't use, or for - YOLO development. - default_converter: if an argument for a command isn't defined, it will be passed to this. By default, it will - just convert the name to kebab-case. This is useful for commands that have a lot of (rarely-used) arguments - that you don't want to bother defining. - arg_separator: what to put between a flag and its value. default is '=', so `command(arg=val)` would translate - to `command --arg=val`. If you want to use spaces instead, set this to ' ' - - -""" - import asyncio.subprocess import logging import os @@ -62,7 +12,7 @@ def validate_pod_name(name): from .transformers import transformers from .validators import validators, Validator -logger = logging.getLogger(__name__) +_logger = logging.getLogger(__name__) @define @@ -72,9 +22,13 @@ class Argument: """ literal_name: str | None = None + """ @private """ default: str = None + """ @private """ validator: Validator | str | dict | list[str | dict] = field(converter=Validator, default=None) + """ @private """ transformer: Callable | str | dict | list[str | dict] = "snake2kebab" + """ @private """ @classmethod def from_dict(cls, arg_dict): @@ -95,7 +49,7 @@ def to_dict(self): Convert the Argument to a dictionary :return: the dictionary representation of the Argument """ - logger.debug(f"Converting argument {self.literal_name} to dict") + _logger.debug(f"Converting argument {self.literal_name} to dict") return { "literal_name": self.literal_name, "default": self.default, @@ -108,12 +62,12 @@ def is_valid(self, value): :param value: the value to be validated :return: True if valid, False otherwise """ - logger.debug(f"Validating {self.literal_name} with value {value}") + _logger.debug(f"Validating {self.literal_name} with value {value}") return validators.get(self.validator)(value) if self.validator is not None else True def transform(self, name, value, **kwargs): """ - Transform the value of the argument + Transform the name and value of the argument :param name: the name of the argument :param value: the value to be transformed :return: the transformed value @@ -123,7 +77,7 @@ def transform(self, name, value, **kwargs): ) -def cli_command_converter(value: str | list[str]): +def _cli_command_converter(value: str | list[str]): if value is None: return [] if isinstance(value, str): @@ -131,7 +85,7 @@ def cli_command_converter(value: str | list[str]): return value -def arg_converter(value: dict): +def _arg_converter(value: dict): """ Convert the value of the argument to a string :param value: the value to be converted @@ -157,14 +111,22 @@ class Command: # pylint: disable=too-many-instance-attributes Command represents a command to be run with the cli_wrapper """ - cli_command: list[str] | str = field(converter=cli_command_converter) + cli_command: list[str] | str = field(converter=_cli_command_converter) + """ @private """ default_flags: dict = {} - args: dict[str | int, any] = field(factory=dict, converter=arg_converter) + """ @private """ + args: dict[str | int, any] = field(factory=dict, converter=_arg_converter) + """ @private """ parse: Parser = field(converter=Parser, default=None) + """ @private """ default_transformer: str = "snake2kebab" + """ @private """ short_prefix: str = field(repr=False, default="-") + """ @private """ long_prefix: str = field(repr=False, default="--") + """ @private """ arg_separator: str = field(repr=False, default="=") + """ @private """ @classmethod def from_dict(cls, command_dict, **kwargs): @@ -195,7 +157,7 @@ def to_dict(self): Excludes prefixes/separators, because they are set in the CLIWrapper :return: the dictionary representation of the Command """ - logger.debug(f"Converting command {self.cli_command} to dict") + _logger.debug(f"Converting command {self.cli_command} to dict") return { "cli_command": self.cli_command, "default_flags": self.default_flags, @@ -206,9 +168,9 @@ def to_dict(self): def validate_args(self, *args, **kwargs): # TODO: validate everything and raise comprehensive exception instead of just the first one for name, arg in chain(enumerate(args), kwargs.items()): - logger.debug(f"Validating arg {name} with value {arg}") + _logger.debug(f"Validating arg {name} with value {arg}") if name in self.args: - logger.debug("Argument found in args") + _logger.debug("Argument found in args") v = self.args[name].is_valid(arg) if isinstance(name, int): name += 1 # let's call positional arg 0, "Argument 1" @@ -225,13 +187,13 @@ def build_args(self, *args, **kwargs): for arg, value in chain( enumerate(args), kwargs.items(), [(k, v) for k, v in self.default_flags.items() if k not in kwargs] ): - logger.debug(f"arg: {arg}, value: {value}") + _logger.debug(f"arg: {arg}, value: {value}") if arg in self.args: literal_arg = self.args[arg].literal_name if self.args[arg].literal_name is not None else arg arg, value = self.args[arg].transform(literal_arg, value) else: arg, value = transformers.get(self.default_transformer)(arg, value) - logger.debug(f"after: arg: {arg}, value: {value}") + _logger.debug(f"after: arg: {arg}, value: {value}") if isinstance(arg, str): prefix = self.long_prefix if len(arg) > 1 else self.short_prefix if value is not None and not isinstance(value, bool): @@ -244,7 +206,7 @@ def build_args(self, *args, **kwargs): else: positional.append(value) result = positional + params - logger.debug(result) + _logger.debug(result) return result @@ -256,7 +218,7 @@ class CLIWrapper: # pylint: disable=too-many-instance-attributes :param env: A dict of environment variables to be set in the subprocess environment, in addition to and overriding those in os.environ. :param trusting: If True, the wrapper will accept any command and pass them to the cli with default configuration. - Otherwise, it will only allow commands that have been defined with update_command_ + Otherwise, it will only allow commands that have been defined with `update_command_` :param raise_exc: If True, the wrapper will raise an exception if a command returns a non-zero exit code. :param async_: If true, the wrapper will return coroutines that must be awaited. :param default_transformer: The transformer configuration to apply to all arguments. The default of snake2kebab will @@ -268,20 +230,26 @@ class CLIWrapper: # pylint: disable=too-many-instance-attributes """ path: str + """ @private """ env: dict[str, str] = None - commands: dict[str, Command] = {} + """ @private """ + _commands: dict[str, Command] = {} + """ @private """ - """ If true, wrapper will take any commands and pass them to the CLI without further validation""" trusting: bool = True + """ @private """ raise_exc: bool = False + """ @private """ async_: bool = False - """ - This is the transformer configuration that will be applied to all commands (unless those commands specify their own) - """ + """ @private """ default_transformer: str = "snake2kebab" + """ @private """ short_prefix: str = "-" + """ @private """ long_prefix: str = "--" + """ @private """ arg_separator: str = "=" + """ @private """ def _get_command(self, command: str): """ @@ -289,7 +257,7 @@ def _get_command(self, command: str): :param command: the command to be run :return: """ - if command not in self.commands: + if command not in self._commands: if not self.trusting: raise ValueError(f"Command {command} not found in {self.path}") c = Command( @@ -300,7 +268,7 @@ def _get_command(self, command: str): arg_separator=self.arg_separator, ) return c - return self.commands[command] + return self._commands[command] def update_command_( # pylint: disable=too-many-arguments self, @@ -320,7 +288,7 @@ def update_command_( # pylint: disable=too-many-arguments :param parse: function to parse the output of the command :return: """ - self.commands[command] = Command( + self._commands[command] = Command( cli_command=command if cli_command is None else cli_command, args=args if args is not None else {}, default_flags=default_flags if default_flags is not None else {}, @@ -336,7 +304,7 @@ def _run(self, command: str, *args, **kwargs): command_obj.validate_args(*args, **kwargs) command_args = [self.path] + command_obj.build_args(*args, **kwargs) env = os.environ.copy().update(self.env if self.env is not None else {}) - logger.debug(f"Running command: {' '.join(command_args)}") + _logger.debug(f"Running command: {' '.join(command_args)}") # run the command result = subprocess.run(command_args, capture_output=True, text=True, env=env, check=self.raise_exc) if result.returncode != 0: @@ -348,7 +316,7 @@ async def _run_async(self, command: str, *args, **kwargs): command_obj.validate_args(*args, **kwargs) command_args = [self.path] + list(command_obj.build_args(*args, **kwargs)) env = os.environ.copy().update(self.env if self.env is not None else {}) - logger.debug(f"Running command: {', '.join(command_args)}") + _logger.debug(f"Running command: {', '.join(command_args)}") proc = await asyncio.subprocess.create_subprocess_exec( # pylint: disable=no-member *command_args, stdout=asyncio.subprocess.PIPE, @@ -407,19 +375,19 @@ def from_dict(cls, cliwrapper_dict): commands[command] = Command.from_dict(config) return CLIWrapper( - commands=commands, + _commands=commands, **cliwrapper_dict, ) def to_dict(self): """ Convert the CLIWrapper to a dictionary - :return: + :return: a dictionary that can be used to recreate the wrapper using `from_dict` """ return { "path": self.path, "env": self.env, - "commands": {k: v.to_dict() for k, v in self.commands.items()}, + "commands": {k: v.to_dict() for k, v in self._commands.items()}, "trusting": self.trusting, "async_": self.async_, "default_transformer": self.default_transformer, diff --git a/src/cli_wrapper/parsers.py b/src/cli_wrapper/parsers.py index 90c55cc..5400cc3 100644 --- a/src/cli_wrapper/parsers.py +++ b/src/cli_wrapper/parsers.py @@ -3,7 +3,7 @@ from .util.callable_chain import CallableChain from .util.callable_registry import CallableRegistry -logger = logging.getLogger(__name__) +_logger = logging.getLogger(__name__) def extract(src: dict, *args) -> dict: @@ -37,7 +37,10 @@ def extract(src: dict, *args) -> dict: def yaml_loads(src: str) -> dict: # pragma: no cover # pylint: disable=missing-function-docstring yaml = YAML(typ="safe") - return yaml.load(src) + result = list(yaml.load_all(src)) + if len(result) == 1: + return result[0] + return result core_parsers["yaml"] = yaml_loads except ImportError: # pragma: no cover @@ -68,11 +71,24 @@ def dotted_dictify(src, *args, **kwargs): pass parsers = CallableRegistry({"core": core_parsers}, callable_name="Parser") +""" +A `CallableRegistry` of parsers. These can be chained in sequence to perform +operations on input. + +Defaults: +core parsers: + - json - parses the input as json, returns the result + - extract - extracts the specified sub-dictionary from the source dictionary + - yaml - parses the input as yaml, returns the result (requires ruamel.yaml or pyyaml) + - dotted_dict - converts an input dictionary to a dotted_dict (requires dotted_dict) +""" class Parser(CallableChain): """ - Parser class that allows for the chaining of multiple parsers. + @public + Parser class that allows for the chaining of multiple parsers. Callables in the configuration are run as a + pipeline, with the output of one parser being passed as input to the next. """ def __init__(self, config): @@ -82,6 +98,6 @@ def __call__(self, src): # For now, parser expects to be called with one input. result = src for parser in self.chain: - logger.debug(result) + _logger.debug(result) result = parser(result) return result diff --git a/src/cli_wrapper/pre_packaged/__init__.py b/src/cli_wrapper/pre_packaged/__init__.py index c52df80..5adeedd 100644 --- a/src/cli_wrapper/pre_packaged/__init__.py +++ b/src/cli_wrapper/pre_packaged/__init__.py @@ -1,10 +1,16 @@ from json import loads from pathlib import Path -from cli_wrapper import CLIWrapper +from ..cli_wrapper import CLIWrapper def get_wrapper(name, status=None): + """ + Gets a wrapper defined in the beta/stable folders as json. + :param name: the name of the wrapper to retrieve + :param status: stable/beta/None. None will search stable and beta + :return: the requested wrapper + """ if status is None: status = ["stable", "beta"] if isinstance(status, str): diff --git a/src/cli_wrapper/transformers.py b/src/cli_wrapper/transformers.py index a8c6752..9528cfd 100644 --- a/src/cli_wrapper/transformers.py +++ b/src/cli_wrapper/transformers.py @@ -3,7 +3,9 @@ def snake2kebab(arg: str, value: any) -> tuple[str, any]: """ - snake.gravity == 0 + `snake.gravity = 0` + + converts a snake_case argument to a kebab-case one """ if isinstance(arg, str): return arg.replace("_", "-"), value @@ -14,5 +16,13 @@ def snake2kebab(arg: str, value: any) -> tuple[str, any]: core_transformers = { "snake2kebab": snake2kebab, } +""" @private """ transformers = CallableRegistry({"core": core_transformers}) +""" +A callable registry of transformers. + +Defaults: +core group: + - snake2kebab +""" diff --git a/src/cli_wrapper/util/__init__.py b/src/cli_wrapper/util/__init__.py index e69de29..210a6aa 100644 --- a/src/cli_wrapper/util/__init__.py +++ b/src/cli_wrapper/util/__init__.py @@ -0,0 +1,4 @@ +from cli_wrapper.util.callable_chain import CallableChain +from cli_wrapper.util.callable_registry import CallableRegistry + +__all__ = [CallableRegistry.__name__, CallableChain.__name__] diff --git a/src/cli_wrapper/util/callable_chain.py b/src/cli_wrapper/util/callable_chain.py index 57df3a2..e0b6cc8 100644 --- a/src/cli_wrapper/util/callable_chain.py +++ b/src/cli_wrapper/util/callable_chain.py @@ -2,10 +2,19 @@ class CallableChain(ABC): + """ + A callable object representing a collection of callables. + """ + chain: list[callable] config: list def __init__(self, config, source): + """ + @public + :param config: a callable, a string, a dictionary with one key and config, or a list of the previous + :param source: a `CallableRegistry` to get callables from + """ self.chain = [] self.config = config if callable(config): @@ -30,7 +39,8 @@ def to_dict(self): @abstractmethod def __call__(self, value): """ - Calls the chain of functions with the given value. + This function should be overridden by subclasses to determine how the + callable chain is handled. """ raise NotImplementedError() diff --git a/src/cli_wrapper/util/callable_registry.py b/src/cli_wrapper/util/callable_registry.py index 6a08959..7f1c871 100644 --- a/src/cli_wrapper/util/callable_registry.py +++ b/src/cli_wrapper/util/callable_registry.py @@ -5,16 +5,25 @@ @define class CallableRegistry: + """ + Stores collections of callables. @public + - callables are registered by name + - they are retrieved by name with args and kwargs + - calling the callable with positional arguments will call the callable + with the args in the call, plus any args and kwargs passed to get() + """ + _all: dict[str, dict[str, Callable]] callable_name: str = "Callable thing" + """ a name of the things in the registry to use in error messages """ def get(self, name: str | Callable, args=None, kwargs=None) -> Callable: """ - Retrieves a parser function based on the specified parser name. + Retrieves a callable function based on the specified parser name. - :param name: The name of the parser to retrieve. - :return: The corresponding parser function. - :raises KeyError: If the specified parser name is not found. + :param name: The name of the callable to retrieve. + :return: The corresponding callable function. + :raises KeyError: If the specified callable name is not found. """ if args is None: args = [] @@ -27,10 +36,10 @@ def get(self, name: str | Callable, args=None, kwargs=None) -> Callable: if group is not None: if group not in self._all: raise KeyError(f"{self.callable_name} group '{group}' not found.") - parser_group = self._all[group] - if name not in parser_group: + callable_group = self._all[group] + if name not in callable_group: raise KeyError(f"{self.callable_name} '{name}' not found.") - callable_ = parser_group[name] + callable_ = callable_group[name] else: for _, v in self._all.items(): if name in v: @@ -42,9 +51,9 @@ def get(self, name: str | Callable, args=None, kwargs=None) -> Callable: def register(self, name: str, callable_: callable, group="core"): """ - Registers a new parser function with the specified name. + Registers a new callable function with the specified name. - :param name: The name to associate with the parser. + :param name: The name to associate with the callable. :param callable_: The callable function to register. """ ngroup, name = self._parse_name(name) @@ -59,29 +68,29 @@ def register(self, name: str, callable_: callable, group="core"): def register_group(self, name: str, callables: dict = None): """ - Registers a new parser group with the specified name. + Registers a new callable group with the specified name. - :param name: The name to associate with the parser group. - :param callables: A dictionary of parsers to register in the group. + :param name: The name to associate with the callable group. + :param callables: A dictionary of callables to register in the group. """ if name in self._all: raise KeyError(f"{self.callable_name} group '{name}' already registered.") if "." in name: raise KeyError(f"{self.callable_name} group name '{name}' is not valid.") callables = {} if callables is None else callables - bad_parser_names = [x for x in callables.keys() if "." in x] - if bad_parser_names: + bad_callable_names = [x for x in callables.keys() if "." in x] + if bad_callable_names: raise KeyError( - f"{self.callable_name} group '{name}' contains invalid parser names: {', '.join(bad_parser_names)}" + f"{self.callable_name} group '{name}' contains invalid callable names: {', '.join(bad_callable_names)}" ) self._all[name] = callables def _parse_name(self, name: str) -> tuple[str, str]: """ - Parses a name into a group and parser name. + Parses a name into a group and callable name. :param name: The name to parse. - :return: A tuple containing the group and parser name. + :return: A tuple containing the group and callable name. """ if "." not in name: return None, name diff --git a/src/cli_wrapper/validators.py b/src/cli_wrapper/validators.py index 513ee10..6990223 100644 --- a/src/cli_wrapper/validators.py +++ b/src/cli_wrapper/validators.py @@ -5,7 +5,7 @@ from .util.callable_chain import CallableChain from .util.callable_registry import CallableRegistry -logger = logging.getLogger(__name__) +_logger = logging.getLogger(__name__) core_validators = { "is_dict": lambda x: isinstance(x, dict), @@ -15,12 +15,12 @@ "is_int": lambda x: isinstance(x, int), "is_bool": lambda x: isinstance(x, bool), "is_float": lambda x: isinstance(x, float), - "is_alnum": lambda x: isinstance(x, str) and x.isalnum(), - "is_alpha": lambda x: isinstance(x, str) and x.isalpha(), - "is_digit": lambda x: isinstance(x, str) and x.isdigit(), + "is_alnum": lambda x: x.isalnum(), + "is_alpha": lambda x: x.isalpha(), + "is_digit": lambda x: x.isdigit(), "is_path": lambda x: isinstance(x, Path), - "starts_alpha": lambda x: isinstance(x, str) and x[0].isalpha(), - "startswith": lambda x, prefix: isinstance(x, str) and x.startswith(prefix), + "starts_alpha": lambda x: len(x) and x[0].isalpha(), + "startswith": lambda x, prefix: x.startswith(prefix), } validators = CallableRegistry({"core": core_validators}, callable_name="Validator") @@ -28,8 +28,11 @@ class Validator(CallableChain): """ + @public + A class that provides a validation mechanism for input data. It uses a list of validators to check if the input data is valid. + They are executed in sequence until one fails. """ def __init__(self, config): @@ -45,11 +48,11 @@ def __call__(self, value): config = [self.config] if not isinstance(self.config, list) else self.config for x, c in zip(self.chain, config): validator_result = x(value) - logger.debug(f"Validator {c} result: {validator_result}") + _logger.debug(f"Validator {c} result: {validator_result}") result = result and validator_result - if not result: + if isinstance(result, str) or not result: # don't bother doing other validations once one has failed - logger.debug("...failed") + _logger.debug("...failed") break return result @@ -57,5 +60,5 @@ def to_dict(self): """ Converts the validator configuration to a dictionary. """ - logger.debug(f"returning validator config: {self.config}") + _logger.debug(f"returning validator config: {self.config}") return self.config diff --git a/src/help_parser/__main__.py b/src/help_parser/__main__.py index d3be84f..8bce0e7 100644 --- a/src/help_parser/__main__.py +++ b/src/help_parser/__main__.py @@ -34,13 +34,14 @@ def parse_args(argv): default="help", help="The flag to use for getting help (default: 'help').", ) - parser.add_argument( - "--style", - type=str, - choices=["golang", "argparse"], - default="golang", - help="The style of cli help output (default: 'golang').", - ) + # Re-add this when some other help format is implemented + # parser.add_argument( + # "--style", + # type=str, + # choices=["golang", "argparse"], + # default="golang", + # help="The style of cli help output (default: 'golang').", + # ) parser.add_argument( "--default-flags", type=str, @@ -78,6 +79,7 @@ def parse_args(argv): ) config = parser.parse_args(argv) + config.style = "golang" config.default_flags_dict = {} for f in config.default_flags: if "=" not in f: diff --git a/tests/test_cli_wrapper.py b/tests/test_cli_wrapper.py index 8ae6165..a3cebd4 100644 --- a/tests/test_cli_wrapper.py +++ b/tests/test_cli_wrapper.py @@ -23,6 +23,7 @@ def test_argument(self): def is_invalid(value): return value == "invalid" + validators.register("is_invalid", is_invalid) arg = Argument("test", default="default", validator=is_invalid) @@ -160,8 +161,8 @@ def test_cliwrapper(self): r = kubectl.get("pods", namespace="kube-system") assert isinstance(r, str) - kubectl.commands["get"].default_flags = {"output": "json"} - kubectl.commands["get"].parse = ["json"] + kubectl._commands["get"].default_flags = {"output": "json"} + kubectl._commands["get"].parse = ["json"] r = kubectl.get("pods", "-A") assert r["kind"] == "List" @@ -180,7 +181,7 @@ def test_cliwrapper(self): with pytest.raises(ValueError): kubectl.describe("pods", namespace="kube-system") logger.info("no parser") - kubectl.commands["get"].parse = None + kubectl._commands["get"].parse = None r = kubectl.get("pods", namespace="kube-system") assert isinstance(r, str) @@ -195,7 +196,7 @@ async def test_subprocessor_async(self): with pytest.raises(RuntimeError): await kubectl.fake("pods", namespace="kube-system") - kubectl.commands["get"].parse = None + kubectl._commands["get"].parse = None r = await kubectl.get("pods", namespace="kube-system") assert isinstance(r, str) @@ -226,11 +227,11 @@ def validate_resource_name(name): assert cliwrapper.path == "kubectl" assert cliwrapper.trusting is True - assert cliwrapper.commands["get"].cli_command == ["get"] - assert cliwrapper.commands["get"].default_flags == {"output": "json"} - assert cliwrapper.commands["get"].parse('"some json"') == "some json" + assert cliwrapper._commands["get"].cli_command == ["get"] + assert cliwrapper._commands["get"].default_flags == {"output": "json"} + assert cliwrapper._commands["get"].parse('"some json"') == "some json" with pytest.raises(ValueError): - cliwrapper.commands["get"].validate_args("pods", "my_cool_pod!!") + cliwrapper._commands["get"].validate_args("pods", "my_cool_pod!!") with pytest.raises(ValueError): cliwrapper.get("pods", "my_cool_pod!!") From f9f93607842049b0a53a905d70c20ebe5f415923 Mon Sep 17 00:00:00 2001 From: Reid Orsten Date: Wed, 21 May 2025 09:24:27 -0600 Subject: [PATCH 6/7] clear init.py --- src/cli_wrapper/__init__.py | 13 ------------- 1 file changed, 13 deletions(-) diff --git a/src/cli_wrapper/__init__.py b/src/cli_wrapper/__init__.py index 4d256f4..aa802b0 100644 --- a/src/cli_wrapper/__init__.py +++ b/src/cli_wrapper/__init__.py @@ -43,16 +43,3 @@ def validate_pod_name(name): .. include:: ../../doc/transformers.md """ - -# from .cli_wrapper import CLIWrapper -# from .transformers import transformers -# from .parsers import parsers -# from .validators import validators -# from .util import callable_chain, callable_registry -# from .pre_packaged import get_wrapper - -# __all__ = ["CLIWrapper", "get_wrapper", "transformers", "parsers", "validators"] - -""" -.. include:: ./util -""" From 4e6a5a269a096b78195513700d8d8c6e7d576628 Mon Sep 17 00:00:00 2001 From: Reid Orsten Date: Wed, 21 May 2025 09:36:16 -0600 Subject: [PATCH 7/7] fix tests --- src/cli_wrapper/cli_wrapper.py | 2 +- tests/test_serialization.py | 3 +-- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/src/cli_wrapper/cli_wrapper.py b/src/cli_wrapper/cli_wrapper.py index 7079b34..b343770 100644 --- a/src/cli_wrapper/cli_wrapper.py +++ b/src/cli_wrapper/cli_wrapper.py @@ -375,7 +375,7 @@ def from_dict(cls, cliwrapper_dict): commands[command] = Command.from_dict(config) return CLIWrapper( - _commands=commands, + commands=commands, **cliwrapper_dict, ) diff --git a/tests/test_serialization.py b/tests/test_serialization.py index 3f92c04..8cd6831 100644 --- a/tests/test_serialization.py +++ b/tests/test_serialization.py @@ -1,5 +1,4 @@ -from cli_wrapper import CLIWrapper -from cli_wrapper.cli_wrapper import Argument +from cli_wrapper.cli_wrapper import Argument, CLIWrapper class TestSerialization: