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
-
+[](https://app.codecov.io/gh/orstensemantics/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.
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:
+defevery_letter_is(v,l):
+ returnall((x==l.lower())or(x==l.upper())forxinv)
+
+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(notstraight_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
When configuring the validator, additional arguments can be supplied using a dictionary:
+
+
+
+
wrapper.update_command_("cmd",validators={"arg":["is_str",{"startswith":{"prefix":"prefix"}}]})
+# or
+wrapper.update_command_("cmd",validators={"arg":["is_str",{"startswith":"prefix"}]})
+
+
+
+
Example
+
+
+
fromcli_wrapperimportCLIWrapper
+fromcli_wrapper.validatorsimportvalidators
+
+defis_alnum_or_dash(value):
+ returnall(c.isalnum()orc=="-"forcinvalue)
+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"],
+})
+
+assertkubectl.get("pods","my-pod")
+threw=False
+try:
+ kubectl.get("pods","level-9000-pod!!")
+exceptValueError:
+ threw=True
+assertthrew
+
extract: extracts data from the raw output, using the args as a list of nested keys.
+
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.
+
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"].
fromcli_wrapperimportCLIWrapper
+
+defskip_lists(result):
+ ifresult["kind"]=="List":
+ returnresult["items"]
+ returnresult
+
+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")
+assertisinstance(a,list)
+b=kubectl.get("pods",a[0].metadata.name,namespace="kube-system")
+assertisinstance(b,dict)
+assertb.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:
+
+
+
frompathlibimportPath
+fromruamel.yamlimportYAML
+fromcli_wrapperimporttransformers,CLIWrapper
+
+manifest_count=0
+base_filename="my_manifest"
+base_dir=Path()
+y=YAML()
+defwrite_manifest(manifest:dict|list[dict]):
+ globalmanifest_count
+ manifest_count+=1
+ file=base_dir/f"{base_filename}_{manifest_count}.yaml"
+ withfile.open("w")asf:
+ ifisinstance(manifest,list):
+ y.dump_all(manifest,f)
+ else:
+ y.dump(manifest,f)
+ returnfile.as_posix()
+
+defmanifest_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:
+fromfunctoolsimportpartial
+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
+
+
+
+
+
+
+
+
+
+
+
+
1importasyncio.subprocess
+ 2importlogging
+ 3importos
+ 4importsubprocess
+ 5fromcopyimportcopy
+ 6fromitertoolsimportchain
+ 7fromtypingimportCallable
+ 8
+ 9fromattrsimportdefine,field
+ 10
+ 11from.parsersimportParser
+ 12from.transformersimporttransformers
+ 13from.validatorsimportvalidators,Validator
+ 14
+ 15_logger=logging.getLogger(__name__)
+ 16
+ 17
+ 18@define
+ 19classArgument:
+ 20"""
+ 21 Argument represents a command line argument to be passed to the cli_wrapper
+ 22 """
+ 23
+ 24literal_name:str|None=None
+ 25""" @private """
+ 26default:str=None
+ 27""" @private """
+ 28validator:Validator|str|dict|list[str|dict]=field(converter=Validator,default=None)
+ 29""" @private """
+ 30transformer:Callable|str|dict|list[str|dict]="snake2kebab"
+ 31""" @private """
+ 32
+ 33@classmethod
+ 34deffrom_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 """
+ 40returnArgument(
+ 41literal_name=arg_dict.get("literal_name",None),
+ 42default=arg_dict.get("default",None),
+ 43validator=arg_dict.get("validator",None),
+ 44transformer=arg_dict.get("transformer",None),
+ 45)
+ 46
+ 47defto_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")
+ 53return{
+ 54"literal_name":self.literal_name,
+ 55"default":self.default,
+ 56"validator":self.validator.to_dict()ifself.validatorisnotNoneelseNone,
+ 57}
+ 58
+ 59defis_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}")
+ 66returnvalidators.get(self.validator)(value)ifself.validatorisnotNoneelseTrue
+ 67
+ 68deftransform(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 """
+ 75return(
+ 76transformers.get(self.transformer)(name,value,**kwargs)ifself.transformerisnotNoneelse(name,value)
+ 77)
+ 78
+ 79
+ 80def_cli_command_converter(value:str|list[str]):
+ 81ifvalueisNone:
+ 82return[]
+ 83ifisinstance(value,str):
+ 84return[value]
+ 85returnvalue
+ 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 """
+ 94value=value.copy()
+ 95fork,vinvalue.items():
+ 96ifisinstance(v,str):
+ 97v={"validator":v}
+ 98ifisinstance(v,dict):
+ 99if"literal_name"notinv:
+100v["literal_name"]=k
+101value[k]=Argument.from_dict(v)
+102ifisinstance(v,Argument):
+103ifv.literal_nameisNone:
+104v.literal_name=k
+105returnvalue
+106
+107
+108@define
+109classCommand:# pylint: disable=too-many-instance-attributes
+110"""
+111 Command represents a command to be run with the cli_wrapper
+112 """
+113
+114cli_command:list[str]|str=field(converter=_cli_command_converter)
+115""" @private """
+116default_flags:dict={}
+117""" @private """
+118args:dict[str|int,any]=field(factory=dict,converter=_arg_converter)
+119""" @private """
+120parse:Parser=field(converter=Parser,default=None)
+121""" @private """
+122default_transformer:str="snake2kebab"
+123""" @private """
+124short_prefix:str=field(repr=False,default="-")
+125""" @private """
+126long_prefix:str=field(repr=False,default="--")
+127""" @private """
+128arg_separator:str=field(repr=False,default="=")
+129""" @private """
+130
+131@classmethod
+132deffrom_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 """
+138command_dict=command_dict.copy()
+139if"args"incommand_dict:
+140fork,vincommand_dict["args"].items():
+141ifisinstance(v,dict):
+142if"literal_name"notinv:
+143v["literal_name"]=k
+144ifisinstance(v,Argument):
+145ifv.literal_nameisNone:
+146v.literal_name=k
+147if"cli_command"notincommand_dict:
+148command_dict["cli_command"]=kwargs.pop("cli_command",None)
+149returnCommand(
+150**command_dict,
+151**kwargs,
+152)
+153
+154defto_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")
+161return{
+162"cli_command":self.cli_command,
+163"default_flags":self.default_flags,
+164"args":{k:v.to_dict()fork,vinself.args.items()},
+165"parse":self.parse.to_dict()ifself.parseisnotNoneelseNone,
+166}
+167
+168defvalidate_args(self,*args,**kwargs):
+169# TODO: validate everything and raise comprehensive exception instead of just the first one
+170forname,arginchain(enumerate(args),kwargs.items()):
+171_logger.debug(f"Validating arg {name} with value {arg}")
+172ifnameinself.args:
+173_logger.debug("Argument found in args")
+174v=self.args[name].is_valid(arg)
+175ifisinstance(name,int):
+176name+=1# let's call positional arg 0, "Argument 1"
+177ifisinstance(v,str):
+178raiseValueError(
+179f"Value '{arg}' is invalid for command {' '.join(self.cli_command)} arg {name}: {v}"
+180)
+181ifnotv:
+182raiseValueError(f"Value '{arg}' is invalid for command {' '.join(self.cli_command)} arg {name}")
+183
+184defbuild_args(self,*args,**kwargs):
+185positional=copy(self.cli_command)ifself.cli_commandisnotNoneelse[]
+186params=[]
+187forarg,valueinchain(
+188enumerate(args),kwargs.items(),[(k,v)fork,vinself.default_flags.items()ifknotinkwargs]
+189):
+190_logger.debug(f"arg: {arg}, value: {value}")
+191ifarginself.args:
+192literal_arg=self.args[arg].literal_nameifself.args[arg].literal_nameisnotNoneelsearg
+193arg,value=self.args[arg].transform(literal_arg,value)
+194else:
+195arg,value=transformers.get(self.default_transformer)(arg,value)
+196_logger.debug(f"after: arg: {arg}, value: {value}")
+197ifisinstance(arg,str):
+198prefix=self.long_prefixiflen(arg)>1elseself.short_prefix
+199ifvalueisnotNoneandnotisinstance(value,bool):
+200ifself.arg_separator!=" ":
+201params.append(f"{prefix}{arg}{self.arg_separator}{value}")
+202else:
+203params.extend([f"{prefix}{arg}",value])
+204else:
+205params.append(f"{prefix}{arg}")
+206else:
+207positional.append(value)
+208result=positional+params
+209_logger.debug(result)
+210returnresult
+211
+212
+213@define
+214classCLIWrapper:# 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
+232path:str
+233""" @private """
+234env:dict[str,str]=None
+235""" @private """
+236_commands:dict[str,Command]={}
+237""" @private """
+238
+239trusting:bool=True
+240""" @private """
+241raise_exc:bool=False
+242""" @private """
+243async_:bool=False
+244""" @private """
+245default_transformer:str="snake2kebab"
+246""" @private """
+247short_prefix:str="-"
+248""" @private """
+249long_prefix:str="--"
+250""" @private """
+251arg_separator:str="="
+252""" @private """
+253
+254def_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 """
+260ifcommandnotinself._commands:
+261ifnotself.trusting:
+262raiseValueError(f"Command {command} not found in {self.path}")
+263c=Command(
+264cli_command=command,
+265default_transformer=self.default_transformer,
+266short_prefix=self.short_prefix,
+267long_prefix=self.long_prefix,
+268arg_separator=self.arg_separator,
+269)
+270returnc
+271returnself._commands[command]
+272
+273defupdate_command_(# pylint: disable=too-many-arguments
+274self,
+275command:str,
+276*,
+277cli_command:str|list[str]=None,
+278args:dict[str|int,any]=None,
+279default_flags:dict=None,
+280parse=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 """
+291self._commands[command]=Command(
+292cli_command=commandifcli_commandisNoneelsecli_command,
+293args=argsifargsisnotNoneelse{},
+294default_flags=default_flagsifdefault_flagsisnotNoneelse{},
+295parse=parse,
+296default_transformer=self.default_transformer,
+297short_prefix=self.short_prefix,
+298long_prefix=self.long_prefix,
+299arg_separator=self.arg_separator,
+300)
+301
+302def_run(self,command:str,*args,**kwargs):
+303command_obj=self._get_command(command)
+304command_obj.validate_args(*args,**kwargs)
+305command_args=[self.path]+command_obj.build_args(*args,**kwargs)
+306env=os.environ.copy().update(self.envifself.envisnotNoneelse{})
+307_logger.debug(f"Running command: {' '.join(command_args)}")
+308# run the command
+309result=subprocess.run(command_args,capture_output=True,text=True,env=env,check=self.raise_exc)
+310ifresult.returncode!=0:
+311raiseRuntimeError(f"Command {command} failed with error: {result.stderr}")
+312returncommand_obj.parse(result.stdout)
+313
+314asyncdef_run_async(self,command:str,*args,**kwargs):
+315command_obj=self._get_command(command)
+316command_obj.validate_args(*args,**kwargs)
+317command_args=[self.path]+list(command_obj.build_args(*args,**kwargs))
+318env=os.environ.copy().update(self.envifself.envisnotNoneelse{})
+319_logger.debug(f"Running command: {', '.join(command_args)}")
+320proc=awaitasyncio.subprocess.create_subprocess_exec(# pylint: disable=no-member
+321*command_args,
+322stdout=asyncio.subprocess.PIPE,
+323stderr=asyncio.subprocess.PIPE,
+324env=env,
+325)
+326
+327stdout,stderr=awaitproc.communicate()
+328ifproc.returncode!=0:
+329raiseRuntimeError(f"Command {command} failed with error: {stderr.decode()}")
+330returncommand_obj.parse(stdout.decode())
+331
+332def__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 """
+338ifself.async_:
+339returnlambda*args,**kwargs:self._run_async(item,*args,**kwargs)
+340returnlambda*args,**kwargs:self._run(item,*args,**kwargs)
+341
+342def__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 """
+351return(self.__getattr__(None))(*args,**kwargs)
+352
+353@classmethod
+354deffrom_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 """
+360cliwrapper_dict=cliwrapper_dict.copy()
+361commands={}
+362command_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}
+368forcommand,configincliwrapper_dict.pop("commands",{}).items():
+369ifisinstance(config,str):
+370config={"cli_command":config}
+371else:
+372if"cli_command"notinconfig:
+373config["cli_command"]=command
+374config=command_config|config
+375commands[command]=Command.from_dict(config)
+376
+377returnCLIWrapper(
+378_commands=commands,
+379**cliwrapper_dict,
+380)
+381
+382defto_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 """
+387return{
+388"path":self.path,
+389"env":self.env,
+390"commands":{k:v.to_dict()fork,vinself._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
+20classArgument:
+21"""
+22 Argument represents a command line argument to be passed to the cli_wrapper
+23 """
+24
+25literal_name:str|None=None
+26""" @private """
+27default:str=None
+28""" @private """
+29validator:Validator|str|dict|list[str|dict]=field(converter=Validator,default=None)
+30""" @private """
+31transformer:Callable|str|dict|list[str|dict]="snake2kebab"
+32""" @private """
+33
+34@classmethod
+35deffrom_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 """
+41returnArgument(
+42literal_name=arg_dict.get("literal_name",None),
+43default=arg_dict.get("default",None),
+44validator=arg_dict.get("validator",None),
+45transformer=arg_dict.get("transformer",None),
+46)
+47
+48defto_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")
+54return{
+55"literal_name":self.literal_name,
+56"default":self.default,
+57"validator":self.validator.to_dict()ifself.validatorisnotNoneelseNone,
+58}
+59
+60defis_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}")
+67returnvalidators.get(self.validator)(value)ifself.validatorisnotNoneelseTrue
+68
+69deftransform(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 """
+76return(
+77transformers.get(self.transformer)(name,value,**kwargs)ifself.transformerisnotNoneelse(name,value)
+78)
+
+
+
+
Argument represents a command line argument to be passed to the cli_wrapper
34@classmethod
+35deffrom_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 """
+41returnArgument(
+42literal_name=arg_dict.get("literal_name",None),
+43default=arg_dict.get("default",None),
+44validator=arg_dict.get("validator",None),
+45transformer=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):
+
+
+
+
+
+
48defto_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")
+54return{
+55"literal_name":self.literal_name,
+56"default":self.default,
+57"validator":self.validator.to_dict()ifself.validatorisnotNoneelseNone,
+58}
+
+
+
+
Convert the Argument to a dictionary
+
+
Returns
+
+
+
the dictionary representation of the Argument
+
+
+
+
+
+
+
+
+
+ def
+ is_valid(self, value):
+
+
+
+
+
+
60defis_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}")
+67returnvalidators.get(self.validator)(value)ifself.validatorisnotNoneelseTrue
+
69deftransform(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 """
+76return(
+77transformers.get(self.transformer)(name,value,**kwargs)ifself.transformerisnotNoneelse(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
+110classCommand:# pylint: disable=too-many-instance-attributes
+111"""
+112 Command represents a command to be run with the cli_wrapper
+113 """
+114
+115cli_command:list[str]|str=field(converter=_cli_command_converter)
+116""" @private """
+117default_flags:dict={}
+118""" @private """
+119args:dict[str|int,any]=field(factory=dict,converter=_arg_converter)
+120""" @private """
+121parse:Parser=field(converter=Parser,default=None)
+122""" @private """
+123default_transformer:str="snake2kebab"
+124""" @private """
+125short_prefix:str=field(repr=False,default="-")
+126""" @private """
+127long_prefix:str=field(repr=False,default="--")
+128""" @private """
+129arg_separator:str=field(repr=False,default="=")
+130""" @private """
+131
+132@classmethod
+133deffrom_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 """
+139command_dict=command_dict.copy()
+140if"args"incommand_dict:
+141fork,vincommand_dict["args"].items():
+142ifisinstance(v,dict):
+143if"literal_name"notinv:
+144v["literal_name"]=k
+145ifisinstance(v,Argument):
+146ifv.literal_nameisNone:
+147v.literal_name=k
+148if"cli_command"notincommand_dict:
+149command_dict["cli_command"]=kwargs.pop("cli_command",None)
+150returnCommand(
+151**command_dict,
+152**kwargs,
+153)
+154
+155defto_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")
+162return{
+163"cli_command":self.cli_command,
+164"default_flags":self.default_flags,
+165"args":{k:v.to_dict()fork,vinself.args.items()},
+166"parse":self.parse.to_dict()ifself.parseisnotNoneelseNone,
+167}
+168
+169defvalidate_args(self,*args,**kwargs):
+170# TODO: validate everything and raise comprehensive exception instead of just the first one
+171forname,arginchain(enumerate(args),kwargs.items()):
+172_logger.debug(f"Validating arg {name} with value {arg}")
+173ifnameinself.args:
+174_logger.debug("Argument found in args")
+175v=self.args[name].is_valid(arg)
+176ifisinstance(name,int):
+177name+=1# let's call positional arg 0, "Argument 1"
+178ifisinstance(v,str):
+179raiseValueError(
+180f"Value '{arg}' is invalid for command {' '.join(self.cli_command)} arg {name}: {v}"
+181)
+182ifnotv:
+183raiseValueError(f"Value '{arg}' is invalid for command {' '.join(self.cli_command)} arg {name}")
+184
+185defbuild_args(self,*args,**kwargs):
+186positional=copy(self.cli_command)ifself.cli_commandisnotNoneelse[]
+187params=[]
+188forarg,valueinchain(
+189enumerate(args),kwargs.items(),[(k,v)fork,vinself.default_flags.items()ifknotinkwargs]
+190):
+191_logger.debug(f"arg: {arg}, value: {value}")
+192ifarginself.args:
+193literal_arg=self.args[arg].literal_nameifself.args[arg].literal_nameisnotNoneelsearg
+194arg,value=self.args[arg].transform(literal_arg,value)
+195else:
+196arg,value=transformers.get(self.default_transformer)(arg,value)
+197_logger.debug(f"after: arg: {arg}, value: {value}")
+198ifisinstance(arg,str):
+199prefix=self.long_prefixiflen(arg)>1elseself.short_prefix
+200ifvalueisnotNoneandnotisinstance(value,bool):
+201ifself.arg_separator!=" ":
+202params.append(f"{prefix}{arg}{self.arg_separator}{value}")
+203else:
+204params.extend([f"{prefix}{arg}",value])
+205else:
+206params.append(f"{prefix}{arg}")
+207else:
+208positional.append(value)
+209result=positional+params
+210_logger.debug(result)
+211returnresult
+
+
+
+
Command represents a command to be run with the cli_wrapper
132@classmethod
+133deffrom_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 """
+139command_dict=command_dict.copy()
+140if"args"incommand_dict:
+141fork,vincommand_dict["args"].items():
+142ifisinstance(v,dict):
+143if"literal_name"notinv:
+144v["literal_name"]=k
+145ifisinstance(v,Argument):
+146ifv.literal_nameisNone:
+147v.literal_name=k
+148if"cli_command"notincommand_dict:
+149command_dict["cli_command"]=kwargs.pop("cli_command",None)
+150returnCommand(
+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):
+
+
+
+
+
+
155defto_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")
+162return{
+163"cli_command":self.cli_command,
+164"default_flags":self.default_flags,
+165"args":{k:v.to_dict()fork,vinself.args.items()},
+166"parse":self.parse.to_dict()ifself.parseisnotNoneelseNone,
+167}
+
+
+
+
Convert the Command to a dictionary.
+Excludes prefixes/separators, because they are set in the CLIWrapper
169defvalidate_args(self,*args,**kwargs):
+170# TODO: validate everything and raise comprehensive exception instead of just the first one
+171forname,arginchain(enumerate(args),kwargs.items()):
+172_logger.debug(f"Validating arg {name} with value {arg}")
+173ifnameinself.args:
+174_logger.debug("Argument found in args")
+175v=self.args[name].is_valid(arg)
+176ifisinstance(name,int):
+177name+=1# let's call positional arg 0, "Argument 1"
+178ifisinstance(v,str):
+179raiseValueError(
+180f"Value '{arg}' is invalid for command {' '.join(self.cli_command)} arg {name}: {v}"
+181)
+182ifnotv:
+183raiseValueError(f"Value '{arg}' is invalid for command {' '.join(self.cli_command)} arg {name}")
+
214@define
+215classCLIWrapper:# 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
+233path:str
+234""" @private """
+235env:dict[str,str]=None
+236""" @private """
+237_commands:dict[str,Command]={}
+238""" @private """
+239
+240trusting:bool=True
+241""" @private """
+242raise_exc:bool=False
+243""" @private """
+244async_:bool=False
+245""" @private """
+246default_transformer:str="snake2kebab"
+247""" @private """
+248short_prefix:str="-"
+249""" @private """
+250long_prefix:str="--"
+251""" @private """
+252arg_separator:str="="
+253""" @private """
+254
+255def_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 """
+261ifcommandnotinself._commands:
+262ifnotself.trusting:
+263raiseValueError(f"Command {command} not found in {self.path}")
+264c=Command(
+265cli_command=command,
+266default_transformer=self.default_transformer,
+267short_prefix=self.short_prefix,
+268long_prefix=self.long_prefix,
+269arg_separator=self.arg_separator,
+270)
+271returnc
+272returnself._commands[command]
+273
+274defupdate_command_(# pylint: disable=too-many-arguments
+275self,
+276command:str,
+277*,
+278cli_command:str|list[str]=None,
+279args:dict[str|int,any]=None,
+280default_flags:dict=None,
+281parse=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 """
+292self._commands[command]=Command(
+293cli_command=commandifcli_commandisNoneelsecli_command,
+294args=argsifargsisnotNoneelse{},
+295default_flags=default_flagsifdefault_flagsisnotNoneelse{},
+296parse=parse,
+297default_transformer=self.default_transformer,
+298short_prefix=self.short_prefix,
+299long_prefix=self.long_prefix,
+300arg_separator=self.arg_separator,
+301)
+302
+303def_run(self,command:str,*args,**kwargs):
+304command_obj=self._get_command(command)
+305command_obj.validate_args(*args,**kwargs)
+306command_args=[self.path]+command_obj.build_args(*args,**kwargs)
+307env=os.environ.copy().update(self.envifself.envisnotNoneelse{})
+308_logger.debug(f"Running command: {' '.join(command_args)}")
+309# run the command
+310result=subprocess.run(command_args,capture_output=True,text=True,env=env,check=self.raise_exc)
+311ifresult.returncode!=0:
+312raiseRuntimeError(f"Command {command} failed with error: {result.stderr}")
+313returncommand_obj.parse(result.stdout)
+314
+315asyncdef_run_async(self,command:str,*args,**kwargs):
+316command_obj=self._get_command(command)
+317command_obj.validate_args(*args,**kwargs)
+318command_args=[self.path]+list(command_obj.build_args(*args,**kwargs))
+319env=os.environ.copy().update(self.envifself.envisnotNoneelse{})
+320_logger.debug(f"Running command: {', '.join(command_args)}")
+321proc=awaitasyncio.subprocess.create_subprocess_exec(# pylint: disable=no-member
+322*command_args,
+323stdout=asyncio.subprocess.PIPE,
+324stderr=asyncio.subprocess.PIPE,
+325env=env,
+326)
+327
+328stdout,stderr=awaitproc.communicate()
+329ifproc.returncode!=0:
+330raiseRuntimeError(f"Command {command} failed with error: {stderr.decode()}")
+331returncommand_obj.parse(stdout.decode())
+332
+333def__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 """
+339ifself.async_:
+340returnlambda*args,**kwargs:self._run_async(item,*args,**kwargs)
+341returnlambda*args,**kwargs:self._run(item,*args,**kwargs)
+342
+343def__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 """
+352return(self.__getattr__(None))(*args,**kwargs)
+353
+354@classmethod
+355deffrom_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 """
+361cliwrapper_dict=cliwrapper_dict.copy()
+362commands={}
+363command_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}
+369forcommand,configincliwrapper_dict.pop("commands",{}).items():
+370ifisinstance(config,str):
+371config={"cli_command":config}
+372else:
+373if"cli_command"notinconfig:
+374config["cli_command"]=command
+375config=command_config|config
+376commands[command]=Command.from_dict(config)
+377
+378returnCLIWrapper(
+379_commands=commands,
+380**cliwrapper_dict,
+381)
+382
+383defto_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 """
+388return{
+389"path":self.path,
+390"env":self.env,
+391"commands":{k:v.to_dict()fork,vinself._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"
274defupdate_command_(# pylint: disable=too-many-arguments
+275self,
+276command:str,
+277*,
+278cli_command:str|list[str]=None,
+279args:dict[str|int,any]=None,
+280default_flags:dict=None,
+281parse=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 """
+292self._commands[command]=Command(
+293cli_command=commandifcli_commandisNoneelsecli_command,
+294args=argsifargsisnotNoneelse{},
+295default_flags=default_flagsifdefault_flagsisnotNoneelse{},
+296parse=parse,
+297default_transformer=self.default_transformer,
+298short_prefix=self.short_prefix,
+299long_prefix=self.long_prefix,
+300arg_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
354@classmethod
+355deffrom_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 """
+361cliwrapper_dict=cliwrapper_dict.copy()
+362commands={}
+363command_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}
+369forcommand,configincliwrapper_dict.pop("commands",{}).items():
+370ifisinstance(config,str):
+371config={"cli_command":config}
+372else:
+373if"cli_command"notinconfig:
+374config["cli_command"]=command
+375config=command_config|config
+376commands[command]=Command.from_dict(config)
+377
+378returnCLIWrapper(
+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):
+
+
+
+
+
+
383defto_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 """
+388return{
+389"path":self.path,
+390"env":self.env,
+391"commands":{k:v.to_dict()fork,vinself._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
+
+
+
+
+
+
+
+
+
+
+
+
1importlogging
+ 2
+ 3from.util.callable_chainimportCallableChain
+ 4from.util.callable_registryimportCallableRegistry
+ 5
+ 6_logger=logging.getLogger(__name__)
+ 7
+ 8
+ 9defextract(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 """
+ 18forkeyinargs:
+ 19src=src[key]
+ 20returnsrc
+ 21
+ 22
+ 23core_parsers={
+ 24"extract":extract,
+ 25}
+ 26
+ 27try:
+ 28fromjsonimportloads
+ 29
+ 30core_parsers["json"]=loads
+ 31exceptImportError:# pragma: no cover
+ 32pass
+ 33try:
+ 34# prefer ruamel.yaml over PyYAML
+ 35fromruamel.yamlimportYAML
+ 36
+ 37defyaml_loads(src:str)->dict:# pragma: no cover
+ 38# pylint: disable=missing-function-docstring
+ 39yaml=YAML(typ="safe")
+ 40result=list(yaml.load_all(src))
+ 41iflen(result)==1:
+ 42returnresult[0]
+ 43returnresult
+ 44
+ 45core_parsers["yaml"]=yaml_loads
+ 46exceptImportError:# pragma: no cover
+ 47pass
+ 48
+ 49if"yaml"notincore_parsers:
+ 50try:# pragma: no cover
+ 51fromyamlimportsafe_loadasyaml_loads
+ 52
+ 53core_parsers["yaml"]=yaml_loads
+ 54exceptImportError:# pragma: no cover
+ 55pass
+ 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.
+ 60fromdotted_dictimportPreserveKeysDottedDict
+ 61
+ 62defdotted_dictify(src,*args,**kwargs):
+ 63ifisinstance(src,list):
+ 64return[dotted_dictify(x,*args,**kwargs)forxinsrc]
+ 65ifisinstance(src,dict):
+ 66returnPreserveKeysDottedDict(src)
+ 67returnsrc
+ 68
+ 69core_parsers["dotted_dict"]=dotted_dictify
+ 70exceptImportError:# pragma: no cover
+ 71pass
+ 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
+ 87classParser(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
+ 94def__init__(self,config):
+ 95super().__init__(config,parsers)
+ 96
+ 97def__call__(self,src):
+ 98# For now, parser expects to be called with one input.
+ 99result=src
+100forparserinself.chain:
+101_logger.debug(result)
+102result=parser(result)
+103returnresult
+
10defextract(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 """
+19forkeyinargs:
+20src=src[key]
+21returnsrc
+
+
+
+
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.
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):
+
+
+
+
+
+
88classParser(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
+ 95def__init__(self,config):
+ 96super().__init__(config,parsers)
+ 97
+ 98def__call__(self,src):
+ 99# For now, parser expects to be called with one input.
+100result=src
+101forparserinself.chain:
+102_logger.debug(result)
+103result=parser(result)
+104returnresult
+
+
+
+
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.
1fromjsonimportloads
+ 2frompathlibimportPath
+ 3
+ 4from..cli_wrapperimportCLIWrapper
+ 5
+ 6
+ 7defget_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 """
+14ifstatusisNone:
+15status=["stable","beta"]
+16ifisinstance(status,str):
+17status=[status]
+18wrapper_config=None
+19fordinstatus:
+20path=Path(__file__).parent/d/f"{name}.json"
+21ifpath.exists():
+22withopen(path,"r",encoding="utf-8")asf:
+23wrapper_config=loads(f.read())
+24ifwrapper_configisNone:
+25raiseValueError(f"Wrapper {name} not found")
+26returnCLIWrapper.from_dict(wrapper_config)
+
+
+
+
+
+
+
+
+ def
+ get_wrapper(name, status=None):
+
+
+
+
+
+
8defget_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 """
+15ifstatusisNone:
+16status=["stable","beta"]
+17ifisinstance(status,str):
+18status=[status]
+19wrapper_config=None
+20fordinstatus:
+21path=Path(__file__).parent/d/f"{name}.json"
+22ifpath.exists():
+23withopen(path,"r",encoding="utf-8")asf:
+24wrapper_config=loads(f.read())
+25ifwrapper_configisNone:
+26raiseValueError(f"Wrapper {name} not found")
+27returnCLIWrapper.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
+
+
+
+
+
+
+
+
+
+
+
+
5defsnake2kebab(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 """
+11ifisinstance(arg,str):
+12returnarg.replace("_","-"),value
+13# don't do anything if the arg is positional
+14returnarg,value
+
+
+
+
snake.gravity = 0
+
+
converts a snake_case argument to a kebab-case one
7@define
+ 8classCallableRegistry:
+ 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]]
+ 18callable_name:str="Callable thing"
+ 19""" a name of the things in the registry to use in error messages """
+ 20
+ 21defget(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 """
+ 29ifargsisNone:
+ 30args=[]
+ 31ifkwargsisNone:
+ 32kwargs={}
+ 33ifcallable(name):
+ 34returnlambda*fargs:name(*fargs,*args,**kwargs)
+ 35callable_=None
+ 36group,name=self._parse_name(name)
+ 37ifgroupisnotNone:
+ 38ifgroupnotinself._all:
+ 39raiseKeyError(f"{self.callable_name} group '{group}' not found.")
+ 40callable_group=self._all[group]
+ 41ifnamenotincallable_group:
+ 42raiseKeyError(f"{self.callable_name} '{name}' not found.")
+ 43callable_=callable_group[name]
+ 44else:
+ 45for_,vinself._all.items():
+ 46ifnameinv:
+ 47callable_=v[name]
+ 48break
+ 49ifcallable_isNone:
+ 50raiseKeyError(f"{self.callable_name} '{name}' not found.")
+ 51returnlambda*fargs:callable_(*fargs,*args,**kwargs)
+ 52
+ 53defregister(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 """
+ 60ngroup,name=self._parse_name(name)
+ 61ifngroupisnotNone:
+ 62ifgroup!="core":
+ 63# approximately, raise an exception if a group is specified in the name and the group arg
+ 64raiseKeyError(f"'{callable_}' already specifies a group.")
+ 65group=ngroup
+ 66ifnameinself._all[group]:
+ 67raiseKeyError(f"{self.callable_name} '{name}' already registered.")
+ 68self._all[group][name]=callable_
+ 69
+ 70defregister_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 """
+ 77ifnameinself._all:
+ 78raiseKeyError(f"{self.callable_name} group '{name}' already registered.")
+ 79if"."inname:
+ 80raiseKeyError(f"{self.callable_name} group name '{name}' is not valid.")
+ 81callables={}ifcallablesisNoneelsecallables
+ 82bad_callable_names=[xforxincallables.keys()if"."inx]
+ 83ifbad_callable_names:
+ 84raiseKeyError(
+ 85f"{self.callable_name} group '{name}' contains invalid callable names: {', '.join(bad_callable_names)}"
+ 86)
+ 87self._all[name]=callables
+ 88
+ 89def_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 """
+ 96if"."notinname:
+ 97returnNone,name
+ 98try:
+ 99group,name=name.split(".")
+100exceptValueErroraserr:
+101raiseKeyError(f"{self.callable_name} name '{name}' is not valid.")fromerr
+102returngroup,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()
21defget(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 """
+29ifargsisNone:
+30args=[]
+31ifkwargsisNone:
+32kwargs={}
+33ifcallable(name):
+34returnlambda*fargs:name(*fargs,*args,**kwargs)
+35callable_=None
+36group,name=self._parse_name(name)
+37ifgroupisnotNone:
+38ifgroupnotinself._all:
+39raiseKeyError(f"{self.callable_name} group '{group}' not found.")
+40callable_group=self._all[group]
+41ifnamenotincallable_group:
+42raiseKeyError(f"{self.callable_name} '{name}' not found.")
+43callable_=callable_group[name]
+44else:
+45for_,vinself._all.items():
+46ifnameinv:
+47callable_=v[name]
+48break
+49ifcallable_isNone:
+50raiseKeyError(f"{self.callable_name} '{name}' not found.")
+51returnlambda*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.
53defregister(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 """
+60ngroup,name=self._parse_name(name)
+61ifngroupisnotNone:
+62ifgroup!="core":
+63# approximately, raise an exception if a group is specified in the name and the group arg
+64raiseKeyError(f"'{callable_}' already specifies a group.")
+65group=ngroup
+66ifnameinself._all[group]:
+67raiseKeyError(f"{self.callable_name} '{name}' already registered.")
+68self._all[group][name]=callable_
+
+
+
+
Registers a new callable function with the specified name.
70defregister_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 """
+77ifnameinself._all:
+78raiseKeyError(f"{self.callable_name} group '{name}' already registered.")
+79if"."inname:
+80raiseKeyError(f"{self.callable_name} group name '{name}' is not valid.")
+81callables={}ifcallablesisNoneelsecallables
+82bad_callable_names=[xforxincallables.keys()if"."inx]
+83ifbad_callable_names:
+84raiseKeyError(
+85f"{self.callable_name} group '{name}' contains invalid callable names: {', '.join(bad_callable_names)}"
+86)
+87self._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):
+
+
+
+
+
+
5classCallableChain(ABC):
+ 6"""
+ 7 A callable object representing a collection of callables.
+ 8 """
+ 9
+10chain:list[callable]
+11config:list
+12
+13def__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 """
+19self.chain=[]
+20self.config=config
+21ifcallable(config):
+22self.chain=[config]
+23ifisinstance(config,str):
+24self.chain=[source.get(config)]
+25ifisinstance(config,list):
+26self.chain=[]
+27forxinconfig:
+28ifcallable(x):
+29self.chain.append(x)
+30else:
+31name,args,kwargs=params_from_kwargs(x)
+32self.chain.append(source.get(name,args,kwargs))
+33ifisinstance(config,dict):
+34name,args,kwargs=params_from_kwargs(config)
+35self.chain=[source.get(name,args,kwargs)]
+36
+37defto_dict(self):
+38returnself.config
+39
+40@abstractmethod
+41def__call__(self,value):
+42"""
+43 This function should be overridden by subclasses to determine how the
+44 callable chain is handled.
+45 """
+46raiseNotImplementedError()
+
+
+
+
A callable object representing a collection of callables.
+
+
+
+
+
+
+
+ CallableChain(config, source)
+
+
+
+
+
+
13def__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 """
+19self.chain=[]
+20self.config=config
+21ifcallable(config):
+22self.chain=[config]
+23ifisinstance(config,str):
+24self.chain=[source.get(config)]
+25ifisinstance(config,list):
+26self.chain=[]
+27forxinconfig:
+28ifcallable(x):
+29self.chain.append(x)
+30else:
+31name,args,kwargs=params_from_kwargs(x)
+32self.chain.append(source.get(name,args,kwargs))
+33ifisinstance(config,dict):
+34name,args,kwargs=params_from_kwargs(config)
+35self.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
1importlogging
+ 2frompathlibimportPath
+ 3fromuuidimportuuid4
+ 4
+ 5from.util.callable_chainimportCallableChain
+ 6from.util.callable_registryimportCallableRegistry
+ 7
+ 8_logger=logging.getLogger(__name__)
+ 9
+10core_validators={
+11"is_dict":lambdax:isinstance(x,dict),
+12"is_list":lambdax:isinstance(x,list),
+13"is_str":lambdax:isinstance(x,str),
+14"is_str_or_list":lambdax:isinstance(x,(list,str)),
+15"is_int":lambdax:isinstance(x,int),
+16"is_bool":lambdax:isinstance(x,bool),
+17"is_float":lambdax:isinstance(x,float),
+18"is_alnum":lambdax:x.isalnum(),
+19"is_alpha":lambdax:x.isalpha(),
+20"is_digit":lambdax:x.isdigit(),
+21"is_path":lambdax:isinstance(x,Path),
+22"starts_alpha":lambdax:len(x)andx[0].isalpha(),
+23"startswith":lambdax,prefix:x.startswith(prefix),
+24}
+25
+26validators=CallableRegistry({"core":core_validators},callable_name="Validator")
+27
+28
+29classValidator(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
+38def__init__(self,config):
+39ifcallable(config):
+40id_=str(uuid4())
+41validators.register(id_,config)
+42config=id_
+43self.config=config
+44super().__init__(config,validators)
+45
+46def__call__(self,value):
+47result=True
+48config=[self.config]ifnotisinstance(self.config,list)elseself.config
+49forx,cinzip(self.chain,config):
+50validator_result=x(value)
+51_logger.debug(f"Validator {c} result: {validator_result}")
+52result=resultandvalidator_result
+53ifisinstance(result,str)ornotresult:
+54# don't bother doing other validations once one has failed
+55_logger.debug("...failed")
+56break
+57returnresult
+58
+59defto_dict(self):
+60"""
+61 Converts the validator configuration to a dictionary.
+62 """
+63_logger.debug(f"returning validator config: {self.config}")
+64returnself.config
+
+
+ class
+ Validator(cli_wrapper.util.callable_chain.CallableChain):
+
+
+
+
+
+
30classValidator(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
+39def__init__(self,config):
+40ifcallable(config):
+41id_=str(uuid4())
+42validators.register(id_,config)
+43config=id_
+44self.config=config
+45super().__init__(config,validators)
+46
+47def__call__(self,value):
+48result=True
+49config=[self.config]ifnotisinstance(self.config,list)elseself.config
+50forx,cinzip(self.chain,config):
+51validator_result=x(value)
+52_logger.debug(f"Validator {c} result: {validator_result}")
+53result=resultandvalidator_result
+54ifisinstance(result,str)ornotresult:
+55# don't bother doing other validations once one has failed
+56_logger.debug("...failed")
+57break
+58returnresult
+59
+60defto_dict(self):
+61"""
+62 Converts the validator configuration to a dictionary.
+63 """
+64_logger.debug(f"returning validator config: {self.config}")
+65returnself.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.
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):
+
+
+
+
+
+
60defto_dict(self):
+61"""
+62 Converts the validator configuration to a dictionary.
+63 """
+64_logger.debug(f"returning validator config: {self.config}")
+65returnself.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.
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:\ndefevery_letter_is(v,l):\n returnall((x==l.lower())or(x==l.upper())forxinv)\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(notstraight_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
Takes at most one positional argument
\n
When configuring the validator, additional arguments can be supplied using a dictionary:
fromcli_wrapperimportCLIWrapper\nfromcli_wrapper.validatorsimportvalidators\n\ndefis_alnum_or_dash(value):\n returnall(c.isalnum()orc=="-"forcinvalue)\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\nassertkubectl.get("pods","my-pod")\nthrew=False\ntry:\n kubectl.get("pods","level-9000-pod!!")\nexceptValueError:\n threw=True\nassertthrew\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
json: uses json.loads to parse stdout
\n
extract: extracts data from the raw output, using the args as a list of nested keys.
\n
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.
\n
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\"].
\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
fromcli_wrapperimportCLIWrapper\n\ndefskip_lists(result): \n ifresult["kind"]=="List":\n returnresult["items"]\n returnresult\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")\nassertisinstance(a,list)\nb=kubectl.get("pods",a[0].metadata.name,namespace="kube-system")\nassertisinstance(b,dict)\nassertb.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
frompathlibimportPath\nfromruamel.yamlimportYAML\nfromcli_wrapperimporttransformers,CLIWrapper\n\nmanifest_count=0\nbase_filename="my_manifest"\nbase_dir=Path()\ny=YAML()\ndefwrite_manifest(manifest:dict|list[dict]):\n globalmanifest_count\n manifest_count+=1\n file=base_dir/f"{base_filename}_{manifest_count}.yaml"\n withfile.open("w")asf:\n ifisinstance(manifest,list):\n y.dump_all(manifest,f)\n else:\n y.dump(manifest,f)\n returnfile.as_posix()\n\ndefmanifest_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:\nfromfunctoolsimportpartial\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
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\"
@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.
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.
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: