From dcb27eecc17cd4862f156698186595f8fdf0689f Mon Sep 17 00:00:00 2001 From: jez Date: Fri, 6 Oct 2023 19:16:30 -0400 Subject: [PATCH] add: initial POC --- .gitignore | 3 + README.md | 119 ++++++ iam/app.py | 190 +++++++++ iam/catalog.py | 870 ++++++++++++++++++++++++++++++++++++++++ iam/cli.py | 585 +++++++++++++++++++++++++++ iam/cli_click.py | 827 ++++++++++++++++++++++++++++++++++++++ iam/cli_views.py | 192 +++++++++ iam/exceptions.py | 18 + iam/framework.py | 355 ++++++++++++++++ iam/idents.py | 45 +++ iam/lib/cli_views.py | 548 +++++++++++++++++++++++++ iam/lib/click_utils.py | 62 +++ iam/lib/utils.py | 276 +++++++++++++ iam/meta.py | 2 + iam/plugins/__init__.py | 0 iam/plugins/base.py | 66 +++ iam/plugins/devops.py | 10 + iam/plugins/devops.yml | 177 ++++++++ iam/plugins/local.py | 178 ++++++++ iam/plugins/local.yml | 322 +++++++++++++++ iam/providers.py | 118 ++++++ poetry.lock | 629 +++++++++++++++++++++++++++++ pyproject.toml | 57 +++ 23 files changed, 5649 insertions(+) create mode 100644 .gitignore create mode 100644 README.md create mode 100644 iam/app.py create mode 100644 iam/catalog.py create mode 100644 iam/cli.py create mode 100755 iam/cli_click.py create mode 100644 iam/cli_views.py create mode 100644 iam/exceptions.py create mode 100644 iam/framework.py create mode 100644 iam/idents.py create mode 100644 iam/lib/cli_views.py create mode 100644 iam/lib/click_utils.py create mode 100644 iam/lib/utils.py create mode 100644 iam/meta.py create mode 100644 iam/plugins/__init__.py create mode 100644 iam/plugins/base.py create mode 100644 iam/plugins/devops.py create mode 100644 iam/plugins/devops.yml create mode 100644 iam/plugins/local.py create mode 100644 iam/plugins/local.yml create mode 100644 iam/providers.py create mode 100644 poetry.lock create mode 100644 pyproject.toml diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..33cb3a7 --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +build/* +__pycache__ +.direnv/* diff --git a/README.md b/README.md new file mode 100644 index 0000000..29baf25 --- /dev/null +++ b/README.md @@ -0,0 +1,119 @@ +# Shell IAM - Identity Access Manager + +Shell IAM is a small python utility that helps you to manage differents +identities along your terminal journey. + +The first step is to determine one or more identities; using `home` and `work` is usually a good start. +Then you will be able to attach resources to each identities. A resource can be anything like an username, an email, environment variable, an account (Unix, google, github ...), git author name, SSH keys, +SSH certificates, TLS files, secrets, tokens... You can extend resources types with a powerful plugin system. To each of those resources, you can attach variables and dependencies. + +When you finished to list all your user resources in a `iam.yml` config file, you can now start to jump from +one identity to others. Be sure you have correctly installed `iam` in your favorite shell config, because +it hooks to your shell like [direnv](https://direnv.net) does. + +To enable an identity, simple run `iam enable home` in your shell session. All your resources related to this +identity are now available in your shell session. You can switch back and forth between your identities, without being worried about mixing/leaking your secrets and environments. When your done your work, disable +the current identity with `iam disable` or directly switch to another identity with `iam enable work`. + +Iam is extensible via a plugin system that allows you to define Services. Each service may provide custom +enable/disable shell scripts, custom commands and more... That let the user to implement virtually +anything. Usually a service will simply load environment variables, but it can also starts process/daemons like a dedicated `ssh-agent`. + +## Quickstart + +Install iam python package: + +```shell +pipx install python-iam +``` + +Install in your shell: + +```shell +iam shell install --shell $SHELL,bash,zsh +``` + +### Initial setup + +Let's create a basic configuration, with 2 identities and two resources: + +```yaml +mkdir -p ~/.config/iam/ +cat < ~/.config/iam/default.yml + +idents: + home: + resources: + + # Mendatory resource to declare identity + account:home: + input: + user: jdoe + name: John + surname: Doe + email: johnny.d@gmail.com + uses: + - auth.ssh_key:home + + # Let's setup a basic ssh_keys + auth.ssh_key:home: + input: + ssh_key_file: ~/.ssh/home/id_rsa + ssh_pub_file: ~/.ssh/home/id_rsa.pub + + work: + resources: + + # Mendatory resource to declare identity + account:work: + input: + user: john-doe327 + name: John + surname: Doe + email: jdoe327@company.com + uses: + - auth.ssh_key:work + - auth.gpg_key:work + + # Let's setup a basic ssh_keys + auth.ssh_key:work: + input: + ssh_key_file: ~/.ssh/work/id_rsa + ssh_pub_file: ~/.ssh/work/id_rsa.pub + auth.gpg_key:work: + input: + gpg_key_file: ~/.gpg/work/gpgkey + gpg_pub_file: ~/.gpg/work/gpgkey.pub + +EOF +``` + +You can add any resources kinds listed here: + +```shell +iam kind list +iam kind show auth.ssh_key +``` + +Just be sure the right part, after the colon is unique. You will be able to make custom resources later via the plugin system. + +You can inspect your current configuration: + +```shell +iam res list +iam res show account +``` + +Then you can see whats happen: + +```shell +iam shell enable home +iam shell enable work +iam shell disable +``` + + + + + + diff --git a/iam/app.py b/iam/app.py new file mode 100644 index 0000000..e2b7558 --- /dev/null +++ b/iam/app.py @@ -0,0 +1,190 @@ +import glob +import logging +import os +from collections import namedtuple +from copy import copy +from pprint import pprint +from types import SimpleNamespace + +from xdg import BaseDirectory + +from . import exceptions as error +from .catalog import Catalog +from .framework import DictItem, scoped_ident +from .idents import Idents +from .lib.utils import import_module, open_yaml +from .meta import NAME, VERSION +from .providers import Providers + +logger = logging.getLogger(__name__) + +IamException = error.IamException + + +class App: + name = NAME + + def __init__(self, ident="_", config_path=None): + # Load application + db = self.load_config_files(config_path=config_path) + + self.load_db(db) + self.init_ident(ident) + + def init_ident(self, ident): + "Init application with ident" + + self.ident_raw = ident + + # # Split ident from scope + # scope = "" + # if "/" in ident: + # parts = ident.split("/", 2) + # ident = parts[0] + # scope = parts[1] + + view = scoped_ident(ident) + + # Save important vars + self.ident_name = view.ident + self.ident_scope = view.scope + + # Fetch ident config + if ident == "_" or not ident: + logger.debug("Enable default ident") + self.ident = self.idents.items_class("default") + else: + self.ident = self.idents.get(self.ident_name) + + # Load user and catalog + self.catalog = Catalog(self, self.ident) + + def load_config_files(self, config_path=None): + "Fetch config from default place" + config_candidates = [] + + # Load file or config + if config_path is not None: + path = config_path + else: + path = os.path.join(BaseDirectory.xdg_config_home, self.name) + + if os.path.isdir(path): + files = glob.glob("*.yml", root_dir=path) + + for file_ in files: + file_ = os.path.join(path, file_) + payloads = open_yaml(file_) + for payload in payloads: + config_candidates.append( + ( + file_, + payload, + ) + ) + + if len(config_candidates) == 0: + _msg = f"Can't find any yaml configuration in: {path}" + raise error.MissingConfigFiles(_msg) + + config = {} + for cand in config_candidates: + path, conf = cand + config.update(conf) + + return config + + def load_db(self, db): + "Load database" + + # Init Databases + self.db = db + self._load_plugins(db.get("plugins", [])) + + # Load providers + providers_conf = {} + providers_conf.update(self.plugin_providers) + providers_conf.update(db.get("providers", {})) + # pprint (providers_conf) + self.providers = Providers("ConfigProviders", providers_conf) + + # Load idents + idents_conf = { + # "_": {}, + } + idents_conf.update(db.get("idents", {})) + self.idents = Idents("ConfigIdents", idents_conf) + + def _load_plugins(self, plugins_refs): + "Load plugins" + + plugins = {} + for plugin_name in plugins_refs: + if not ":" in plugin_name: + plugin_name = plugin_name + ":all" + + load_one = False + if not plugin_name.endswith(":all"): + load_one = plugin_name.split(":", 2)[1] + + mod = None + try: + mod = import_module(plugin_name) + except ModuleNotFoundError as err: + msg = f"Impossible to load module: {plugin_name}" + raise IamException(msg) + print(err) + continue + + if load_one: + mod = {load_one: mod} + + plugins.update(mod) + + self.plugin_providers = plugins + + def user_dump(self, pattern=None): + "Dump ident config" + + self.catalog.dump() + + def get_idents(self, extended=False): + "Return list of identities" + + ret = list(self.idents.get_idents()) + + if extended: + out = ["mrjk/backups", "mrjk/pictures"] + ret.extend(out) + + return ret + + # def get_resource(self, name): + # "Query and resolve a resource" + + # return self.catalog.get_resource(name) + + # # def get_service(self, name, resolve=False): + # # "Query and resolve a service" + + # # return self.catalog.get_service(name, resolve=resolve) + + # def list_resources(self): + # "List all resources" + # return self.catalog.resources + + def list_services(self): + "List all services" + + return self.catalog.services + + ret = {svc.name: svc.list_cmds() for svc in self.catalog.services.values()} + return ret + + def shell_enable(self): + "Enable shell" + return self.catalog.shell_enable() + + def shell_disable(self): + "Disable shell" + return self.catalog.shell_disable() diff --git a/iam/catalog.py b/iam/catalog.py new file mode 100644 index 0000000..4ab3bca --- /dev/null +++ b/iam/catalog.py @@ -0,0 +1,870 @@ +import logging +from collections import namedtuple +from pprint import pprint +from types import SimpleNamespace + +from . import exceptions as error +from .framework import DictCtrl, DictItem, KeyValue, KeyValueExtra +from .idents import Ident +from .lib.utils import format_render, jinja_render, uniq + +logger = logging.getLogger(__name__) + + +IamException = error.IamException + + +# Config classes: Plugin +# ================ + + +# class Plugin(DictItem): +# "Hold provider entities" + +# default_attrs = { +# "desc": "Resource without description", +# "input": {}, +# "needs": [], +# } + + +# class Plugins(DictCtrl): +# "Hold provider entities" + +# default_attrs = { +# "desc": "Resource without description", +# "input": {}, +# "needs": [], +# } + + +# Config classes: Services +# ================ + + +class ServiceCommand(DictItem): + "Hold provider services command" + + default_attrs = { + "desc": "Service without description", + "cmd": "", + } + + # Overrides + # --------------- + + def payload_transform(self, name, kwargs=None): + "Transform short form into long form" + + payload = self._payload + if not isinstance(payload, dict): + payload = {"cmd": payload} + self._payload = payload + + +class Service(DictItem): + "Hold provider services" + + default_attrs = { + "desc": "Service without description", + "enabled": False, + "input": {}, + "resources_lookup": [], + "required_services": [], + "commands": {}, + # "resolved_resources": [], + "resources_matches": 1, + } + + # Overrides + # --------------- + + def prepare(self): + self.catalog = self.parent.catalog + + def init(self): + ret = {} + for cmd_name, cmd in self.commands.items(): + ret[cmd_name] = ServiceCommand(cmd_name, cmd) + self.commands = ret + + # def require_resolved_deps(func): + # "Decorator that ensure resource resolution is called before method call" + + # def wrap(self, *args, **kwargs): + # "Wrapper that ensure dependencies are resolved" + + # if not self._resolved: + # self.resolve_deps(skip_missing=True) + # return func(self, *args, **kwargs) + + # return wrap + + # Catalog dependent is built ! + # --------------- + def resolve_resources(self, catalog): + "Resolve required resources" + + # catalog.res_inst, + # catalog.context + + self._catalog = catalog + + requests = self.resources_lookup + curr_vars = catalog.context.get_vars() + ret = [] + for pattern in requests: + req = pattern.format(**curr_vars) + + if req in catalog.res_inst: + resource = catalog.res_inst[req] + + tmp = { + "resource": resource, + "vars": resource.resolve_inputs(), + } + ret.append(tmp) + + self.resolved_resources_names = [x["resource"].name for x in ret] + self._resolved_resources = ret + + def _prepare_runtime(self): + if not hasattr(self, "_resolved_resources"): + assert ( + self._resolved_resources + ), f"Please run {self}.resolve_resources() first" + + print("Process each item") + processed_vars = [] + + # Deduplicate common resources + for box in self._resolved_resources: + res = box["resource"] + name = res.name + curr_vars = res.resolve_inputs() + + # WIPPPP + expand_inputs(curr_vars) + + var_matches = [x for x in processed_vars if x.value == curr_vars] + if len(var_matches) > 0: + print(f" Skip duplicate resource vars {res.name}") + continue + + # print (f" Process: {res.name}") + processed_vars.append(KeyValueExtra(name, curr_vars, res)) + + # print("PROCESSEDS") + # pprint(processed_vars) + + return processed_vars + + # Workflow + # --------------- + + def run_cmd(self, command=None): + processed_vars = self._prepare_runtime() + + cmd = self.commands.get(command, None) + if not cmd: + choices = ( + ",".join(self.commands.keys()) + or "No available commands for this service" + ) + raise IamException( + f"Unknown command: {command}, please choose one of: {choices}" + ) + + svc_input = self.input + + # Run on each requested resources + max_items = self.resources_matches + for res in processed_vars[0:max_items]: + print("process item:", res.key) + + # if command: + # pprint (res.value) + # # pprint (self.__dict__) + + # # assert False + # pprint (self._catalog.context.get_vars()) + + env_var = {} + env_var.update(svc_input) + env_var.update(res.value) # == res.resolve_inputs() + env_var.update(self._catalog.context.get_vars()) + + # pprint (env_var) + + try: + out = cmd.format(**env_var) + except KeyError as err: + msg = f"Missing variable: {err} for service: {res.key}" + # print (msg) + out = msg + # raise IamException(msg) + + print("\n" + "-- %<--" * 6) + print(out) + print("-- %<--" * 6) + + # def _list_cmds_shell(self): + # "Li" + + # def _list_cmds(self): + + def list_cmds(self): + "List all services commands" + + prefix = self.name.split(".")[-1] + + shell_prefix = "shell_" + ret2 = { + "shell": {}, + "cmds": {}, + } + + for cmd_name, conf in self.commands.items(): + target = ret2["cmds"] + if cmd_name.startswith(shell_prefix): + target = ret2["shell"] + cmd_name = cmd_name[len(shell_prefix) :] + + name = f"{prefix} {cmd_name}" + target[name] = conf.desc + + return ret2 + + def get_linked_resource(self): + "Return linked resource or None" + + catalog = self.catalog + res_name = f"service.{self.name}:{catalog.ident.name}" + ret = catalog.resources.get(res_name, None) + logger.debug(f"Linked resource for service {self.name}: {ret}") + return ret + + def is_active(self): + return True if self.get_linked_resource() is not None else False + + +class Services(DictCtrl): + "Simple wrapper class to manage services" + + items_class = Service + + def prepare(self): + self.catalog = self.parent + + def get_linked_resources(self): + """ + Like get method, but only grab enabled services + Return a list of an object containing service and its + matched resource + """ + + enabled_services = {} + for name, service in self.items(): + res = service.get_linked_resource() + if res: + enabled_services[service.name] = SimpleNamespace( + svc=service, + res=res, # order=None + ) + + return enabled_services + + def get_loading_order(self, reverse=False): + "Process resolution order" + + # Filter out disabled services + enabled_services = self.get_linked_resources() + + # Process resolution order + queue_todo = list(enabled_services.keys()) + queue_done = [] + loop_index = 0 + while len(queue_todo) > 0: + loop_index = loop_index + 1 + _queue_todo = list(queue_todo) + + for svc_name in queue_todo: + service = enabled_services[svc_name].svc + resource = enabled_services[svc_name].res + + # Check services requirements + required_services = list(set(service.required_services)) + # if not svc_name.startswith("service.id:"): + if svc_name != "id": + required_services.insert(0, f"id") + + missing = False + _missing = [] + for req in required_services: + if not req in queue_done: + missing = True + _missing.append(req) + + if not missing: + # Save result + queue_todo.remove(svc_name) + queue_done.append(svc_name) + + if queue_todo == _queue_todo: + print("Loop Index ERROR", loop_index) + raise IamException(f"Loop detexted ! {loop_index}") + + if reverse: + queue_done.reverse() + + for index, svc_name in enumerate(queue_done): + enabled_services[svc_name].order = index + + # Return list of ordered service names, and other useful data + return queue_done, enabled_services + + +# Config classes: ResourcesKinds +# ================ + +ResolvedRes = namedtuple("ResolvedRes", "name inputs") + + +class ResourceKind(DictItem): + "Hold resource kinds" + + default_attrs = { + "desc": "ResourceKind without description", + "input": {}, + "needs": [], + "remap": {}, + } + + +# def config_validate(self): +# if ":" in self.name: +# raise IamException( +# "Colons are not accepted for catalog resources", self.__dict__ +# ) + + +class ResourcesKinds(DictCtrl): + "Simple wrapper class to manage resources kinds" + + items_class = ResourceKind + + # def prepare(self): + # self.catalog = self.parent + + +# Config classes: Resources +# ================ + + +class Resource(DictItem): + "Create a new resource" + + default_attrs = { + "desc": "", + "input": {}, + "uses": [], # Optional + "needs": [], # Required dependencies AND + "loop_limit": 1, + "loop": [], + "_ctx_vars": {}, + } + + # Overrides + # --------------- + + def prepare(self): + self.catalog = self.parent.catalog + + def config_validate(self): + # Fetch info from controller + resources_kind = self.parent.available_resources_kinds + loaded_resources = self.parent.loaded_resources + + # Check overrides + if self.name in loaded_resources: + _msg = f"User resource overrides: {self.name}" + logger.debug(_msg) + + # Check for the kind + kind = self.get_kind() + if not kind in resources_kind: + choices = ", ".join(resources_kind.keys()) + _msg = ( + f"Unknown resource kind: {self.name}, please choose one of: {choices}" + ) + raise error.UnknownResourceKind(_msg) + + def init(self): + "Init each resources" + + self.name_raw = self.name + ctx_vars = self.parent.ctx_vars + + self.name = format_render(self.name, vars=ctx_vars) + # self.name = format_render(self.name, vars=self.catalog.context.get_vars()) + + # Append common base dependency on all resources + if len(self.uses) == 0: + self.uses.insert(0, "account:{user}") + # self.uses = list(set(self.uses)) + + self.uses = uniq(self.uses) + + # Resolution management vars + self._resolved = False + self.resources_deps = None + self.resources_missing = None + + # Helpers + # --------------- + + def _parse_name(self): + ret = self.name.split(":", 2) + + if len(ret) == 2: + return ret[0], ret[1] + + # Raise error if name is invalid + _msg = f"Invalid resource name: {self.name}, missing :" + raise IamException(_msg) + + def get_kind(self): + "Return resource kind" + ret, _ = self._parse_name() + return ret + + def get_name(self): + "Return resource instance name" + + _, ret = self._parse_name() + return ret + + # Methods + # --------------- + + def require_resolved_deps(func): + "Decorator that ensure resource resolution is called before method call" + + def wrap(self, *args, **kwargs): + "Wrapper that ensure dependencies are resolved" + + if not self._resolved: + self.resolve_deps(skip_missing=True) + return func(self, *args, **kwargs) + + return wrap + + def resolve_deps(self, vars=None, add_self=True, cache=True, skip_missing=False): + "Recursive uses dependency resolver" + + if cache and self.resources_deps is not None: + return self.resources_deps + + # try: + # ret, _missing = self._resolve_deps(vars=vars, add_self=add_self) + # except error.UnresolvedResourceDependencies: + # return None + + dependencies, missings = self._resolve_deps(vars=vars, add_self=add_self) + + self.resources_missing = missings + self.resources_deps = dependencies + self._is_active = True if len(missings) == 0 else False + + # Raise error on missing deps + if not skip_missing and len(missings) > 0: + pprint(self.catalog.resources.get()) + _msg = f"Missing deps for {self.name}: {missings}" + raise error.UnresolvedResourceDependencies(_msg) + + if cache: + self._resolved_deps = dependencies + + self._resolved = True + + return dependencies + + def _resolve_deps(self, vars=None, add_self=True, _results=None, _missings=None): + "Recursive dependency helper" + + # Recurisive control + _missings = _missings or [] + _results = _results or [] + if self in _results: + return _results + + # Get root context + # ctx_vars = vars or self.catalog.context.get_vars() + catalog = self.catalog + ctx_vars = catalog.context.get_vars() + + # Check each dependency + uses = self.uses + for dep_name in uses: + # Parse resource name + if ctx_vars: + dep_name = format_render(dep_name, ctx_vars) + + if dep_name == self.name: + continue + + if dep_name in _missings: + _missings.delete(dep_name) + + # Load child or quit if missing + child = catalog.resources.find_closest(dep_name) + if not child: + _missings.append(dep_name) + # _missings.append((self.name, dep_name, )) + + # _msg = f"Undeclared resource: {dep_name}" + # raise error.UnresolvedResourceDependencies(_msg) + continue + + # Avoid already processed resources + if child and child in _results: + continue + + _results.append(child) + child._resolve_deps(vars=vars, _results=_results) + + if add_self: + _results.append(self) + + return _results, _missings + + # Resolved methods + # ================== + + @require_resolved_deps + def is_active(self): + "Return True if this resource is active" + return self._is_active + + # return True if len(self.resources_missing) == 0 else False + + @require_resolved_deps + def resolve_inputs(self, vars=None): + "Resolve uses dependencies recursively" + + # WIPPPP, split this function in 2 !!! + # self.catalog. + + # deps = self.resolve_deps() + deps = self.resources_deps + + output = {} + for res in deps: + output.update(res.input) + + return output + + def loop_extend(self): + "Extend loops from catalog" + + ctx_vars = self.catalog.context.get_vars() + loops = self.loop + loop_limit = self.loop_limit + + matches = [] + stop = False + for loop_search in loops: + if len(matches) >= loop_limit: + stop = True + + if not stop: + loop_search = loop_search.format(**ctx_vars) + res = self.catalog.resources.get(loop_search, skip_missing=True) + if res: + matches.append(res) + + return matches + + +class Resources(DictCtrl): + "Simple wrapper class to manage resources" + + items_class = Resource + + # Overrides + # --------------- + + def prepare(self): + # Save Catalog + self.catalog = self.parent + + self.ctx_vars = self.catalog.context.get_vars() + + self.available_resources_kinds = self.catalog.resources_kind + self.loaded_resources = [] + + # pprint (self.__dict__) + # print ("INIT RES CATALOG") + + def find_closest(self, dep_name): + "Find closest match on type" + assert ":" in dep_name, f"We have unresolved query here: {dep_name}" + + catalog = self.catalog + + # Direct access + child = catalog.resources.get(dep_name, None) + if child: + return child + + # Loop over resources + target = dep_name.split(":")[0] + scope = dep_name.split(":")[1] + + match = None + while target: + # Query other resources + # query_name = f"{target}:{scope}" + matches = catalog.resources.select("startswith", target) + matches = { + key: val for key, val in matches.items() if key.endswith(f":{scope}") + } + + # Check closest matches + if len(matches) == 1: + match = list(matches.values())[0] + # print ("Longer MATCH", match.name) + logger.info( + f"Closest resource of {dep_name} was mapped to: {match.name}" + ) + break + elif len(matches) > 1: + logger.debug( + f"More than one closest resources for {dep_name}: {matches}" + ) + + # raise Exception(f"Bug here, too many matches: {matches}") + + # Reduce again the common lookup pattern + if "." in target: + target = ".".join(target.split(".")[:-1]) + else: + break + + # print ("QUERY", target, scope) + + return match + # view = scoped_ident(ident) + + +# Catalog +# ============================================================ + + +# Context classes +# ================ + + +class Context(DictItem): + "Class that hold a context" + + default_attrs = { + "ident": None, + } + + # Overrides + # --------------- + def config_validate(self): + assert isinstance(self.ident, Ident), f"Gotr: {self.ident}" + + def init(self): + self._vars = { + "ident": self.ident.name, + "user": self.ident.name, + } + + def get_vars(self): + "Return all vars context" + + return dict(self._vars) + + +# Catalog classes +# ================ + + +class Catalog: + "Manage catalog resources" + + def __init__(self, mgr, ident): + # Prepare catalog + self.mgr = mgr + self.ident = ident + + # Prepare context + ctx_conf = { + "ident": ident, + } + self.context = Context("current_context", ctx_conf) + + self._prepare_catalog() + + # Catalog building classes + # ================ + + def _merge_inst_configs(self, merge=True): + plugins_confs = self.mgr.providers.get_resource_configs() + user_confs = self.ident.get_resource_configs() + + final_config = {} + + # Remap config names + for source in [plugins_confs, user_confs]: + # source = source or {} + for name, conf in source.items(): + # Check name + if not ":" in name: + ident_name = f"{name}:{self.ident.name}" + # print (f"Renaming resource '{name}' to '{ident_name}'") + name = ident_name + + # Check config + existing = final_config.get(name, None) + if existing: + # print (f"Overrides: {name}") + + if merge: + new_conf = dict(existing) + new_conf.update(conf) + conf = new_conf + + final_config[name] = conf + + return final_config + + def _prepare_catalog(self): + "Prepare catalog resources configs" + + # Get resources from providers and user + providers = self.mgr.providers + ident = self.ident + + # Build kind catalog + res_kind_configs = providers.get_resource_kinds() + self.resources_kind = ResourcesKinds( + "CatalogResourcesKinds", payload=res_kind_configs, parent=self + ) + + # Build resources catalog + res_inst_configs = self._merge_inst_configs() + self.resources = Resources( + "CatalogResources", payload=res_inst_configs, parent=self + ) + self._resources_config = res_inst_configs + + # Prepare services catalog + services_confs = providers.get_services_configs() + self.services = Services("CatalogServices", payload=services_confs, parent=self) + + def dump(self): + print("Catalog settings") + print("=" * 16) + + # pprint (self.__dict__) + # return + + print("\n=== Catalog resources kinds:") + pprint(self.resources_kind) + + print("\n=== Catalog resources configs:") + pprint(self._resources_config) + + print("\n=== Catalog resources instances:") + pprint(self.resources) + + print("\n=== Catalog services instances:") + # for name, srv in self.services.items(): + # pprint(srv.dump_item()) + pprint({name: srv.dump_item() for name, srv in self.services.items()}) + + # Catalog interfaces + # ================ + + # Catalog helpers + # ================ + + # def resolve_service_order(self, reverse=False): + # "Resolve service order" + + # return self.services.get_loading_order(reverse=reverse) + + # Shell helpers + # ================ + + def shell_enable(self): + "Enable shell" + return self._shell_action() + + def shell_disable(self): + "Disable shell" + return self._shell_action(reverse=True) + + def _shell_action(self, reverse=False): + "Run command order" + + ident = self.ident + vars = self.context.get_vars() + + # Fetch execution order + order, services = self.services.get_loading_order(reverse=reverse) + + # Prepare context + action_name = "shell_enable" + log_action_name = "Enable" + if reverse: + action_name = "shell_disable" + log_action_name = "Disable" + logger.info(f"{log_action_name} identity: {ident.name}") + + # Execute on each plugins commands + output_code = [] + for srv_name in order: + service = services[srv_name].svc + res = services[srv_name].res + + # Load service command + command = service.commands.get(action_name, None) + if not command: + continue + + # Build loop with at least ONE item + cmd = command.cmd + if not cmd: + continue + + # Create context var dict + ctx_vars = dict() + ctx_vars.update(service.input) + ctx_vars.update(vars) + ctx_vars.update(res.resolve_inputs(vars=ctx_vars)) + + loops = res.loop_extend() or [res] + res_vars = [x.resolve_inputs(vars=ctx_vars) for x in loops] + + ctx_vars.update( + { + "item": res_vars[0], + "loop": res_vars, + } + ) + + logger.debug(f"{log_action_name} service: {res.name}") + + # pprint (ctx_vars) + cmd = jinja_render(cmd, ctx_vars) + output_code.append(f"# Loading of {res.name} ({service.name})") + output_code.append(f"# =====================") + output_code.append(cmd) + output_code.append("\n") + + return "\n".join(output_code) diff --git a/iam/cli.py b/iam/cli.py new file mode 100644 index 0000000..ea7a90c --- /dev/null +++ b/iam/cli.py @@ -0,0 +1,585 @@ +# Better case +import logging +import os +import sys +from enum import Enum +from pprint import pformat, pprint +from types import SimpleNamespace + +import coloredlogs +import typer +from typing_extensions import Annotated + +from iam.app import App +from iam.framework import (empty, get_app_logger, iterate_any, open_yaml, + prune, to_csv, to_json, to_yaml) +from rich import box +from rich.console import Console +from rich.pretty import pprint +from rich.prompt import Prompt +from rich.table import Table + +from . import exceptions as error +# from .cli_views import OutputFormat #, view_list, view_show +from .cli_views import ViewItem, ViewList + +IamException = error.IamException + + +# Global vars +# ====================== + + +# See: https://docs.python.org/3.8/library/logging.html#logging-levels +DEFAULT_LOG_LEVEL = 30 # warning + +# Get default ident +DEBUG = os.environ.get("IAM_DEBUG", "False") +DEFAULT_IDENT = os.environ.get("IAM_IDENT", os.environ.get("SHELL_IDENT", "")) +DEFAULT_CONFIG = os.environ.get("IAM_CONFIG", None) +BOX_STYLE = box.MINIMAL +TABLE_MIN_WIDTH = 80 + +table_box_params = { + "box": box.ASCII2, + "min_width": 60, +} + + +# Init app +# ====================== + +logger = logging.getLogger(__name__) + +console = Console() +item_view = ViewItem(output=console.print) +list_view = ViewList(output=console.print) + + +# General CLI +# ====================== +cli = typer.Typer(rich_markup_mode="rich") + + +@cli.callback() +def cli_callback( + ctx: typer.Context, + verbose: Annotated[int, typer.Option("--verbose", "-v", count=True, max=3)] = 0, + config: Annotated[ + str, typer.Option("--config", "-C", help="Configuration directory") + ] = DEFAULT_CONFIG, + fmt: Annotated[ + list_view.formats_enum, typer.Option("--format", "-F", help="Output format") + ] = list_view.default_format, + ident: str = DEFAULT_IDENT, +): + # Calculate log level + log_level = DEFAULT_LOG_LEVEL - verbose * 10 + log_level = log_level if log_level > 0 else 10 + + # Define loggers + loggers = { + "iam": {"level": log_level}, + "iam.cli": {"level": "INFO"}, + } + + # Instanciate logger + get_app_logger(loggers=loggers, level=log_level, colors=True) + + cli_config = SimpleNamespace( + log_level=log_level, + fmt=fmt, + ident=ident, + ) + render_config = { + "fmt": fmt, + "table_settings": table_box_params, + } + + render_item = lambda *args, conf={}: item_view.render( + *args, conf={**render_config, **conf} + ) + render_list = lambda *args, conf={}: list_view.render( + *args, conf={**render_config, **conf} + ) + + # Instanciate app + logger.info(f"Current ident: {ident}") + ctx.obj = SimpleNamespace( + app=App(config_path=config, ident=ident), + cli=cli_config, + render_item=render_item, + render_list=render_list, + ) + + +@cli.command("dump") +def main_dump(ctx: typer.Context): + "Dump ident configuration" + + ctx.obj.app.user_dump() + + +@cli.command("help") +def main_dump(ctx: typer.Context): + "Dump ident configuration" + + # pprint(ctx.parent.__dict__) + + # pprint (cli.__dict__) + # pprint (dir(cli)) + # # help(cli.__class__) + + print("commands") + for item in cli.registered_commands: + pprint(item.__dict__) + + # get_help(ctx) + + # print ("OUTTT") + # pprint ((cli.registered_commands[0])) + # pprint ((cli.registered_commands[0].__dict__)) + + +@cli.command("list") +def main_list(ctx: typer.Context): + "List all items" + + ctx.invoke(kind_list, ctx) + ctx.invoke(res_list, ctx) + ctx.invoke(svc_list, ctx) + + +# Resource Kinds management +# ====================== +# cli_kind = typer.Typer() + + +class KindCmd(str, Enum): + "Kinds sub commands" + LS = "list" + SHOW = "show" + + +@cli.command( + "kind", + hidden=True, + context_settings={"allow_extra_args": True, "ignore_unknown_options": True}, +) +def cli_kind(ctx: typer.Context, cmd: KindCmd): + """Command sources""" + prefix = "kind_" + func = globals()[f"{prefix}{cmd.value}"] + return ctx.invoke(func, ctx) + + +@cli.command("kind list", rich_help_panel="Resources Kind") +def kind_list( + ctx: typer.Context, + name: Annotated[str, typer.Argument()] = "", +): + "List resource kinds" + + # Fetch data + filter = name + resources_kind = ctx.obj.app.catalog.resources_kind + data = [] + for name, item in sorted(resources_kind.items(), key=lambda key: key): + if filter and not name.startswith(filter): + continue + data.append((name, item.desc)) + + # Render data + columns = ["name", "desc"] + conf = { + "table_title": "Resources kinds listing", + } + ctx.obj.render_list(data, columns, conf=conf) + + +@cli.command("kind show", rich_help_panel="Resources Kind") +def kind_show( + ctx: typer.Context, + name: Annotated[str, typer.Argument()] = "", +): + "Show resource kinds" + + # Fetch data + columns = ["name", "desc", "input", "needs", "remap"] + filter = name + resources_kind = ctx.obj.app.catalog.resources_kind + for name, item in sorted(resources_kind.items(), key=lambda key: key): + if filter and not name.startswith(filter): + continue + + data = [ + item.name, + item.desc, + f"{to_yaml(item.input).strip()}", + f"{to_yaml(item.needs)}".strip(), + f"{to_yaml(item.remap)}".strip(), + ] + + # Render data + conf = { + "table_title": f"Resources kind: {data[0]}", + } + ctx.obj.render_item(data, columns, fmt=fmt, conf=conf) + + +# Resource management +# ====================== +# cli_res = typer.Typer() + + +class ResCmds(str, Enum): + "Resources sub commands" + LS = "list" + SHOW = "show" + + +@cli.command( + "res", + hidden=True, + context_settings={"allow_extra_args": True, "ignore_unknown_options": True}, +) +def cli_res(ctx: typer.Context, cmd: ResCmds): + """Command sources""" + pprint(ctx.__dict__) + prefix = "res_" + func = globals()[f"{prefix}{cmd.value}"] + ctx.invoke(res_list, ctx) + # ctx.invoke(func, ctx) + + +@cli.command("res list", rich_help_panel="Resources") +def res_list( + ctx: typer.Context, + name: Annotated[str, typer.Argument()] = "", +): + "List resource kinds" + + # Fetch data + filter = name + resources = ctx.obj.app.catalog.resources + data = [] + for name, res in sorted(resources.items(), key=lambda key: key): + if filter and not name.startswith(filter): + continue + data.append( + ( + res.get_kind(), + res.get_name(), + res.desc, + ) + ) + + # Render data + columns = ["kind", "name", "desc"] + conf = { + "table_title": "Resources listing", + } + ctx.obj.render_list(data, columns, conf=conf) + + +@cli.command("res show", rich_help_panel="Resources") +def res_show( + ctx: typer.Context, + name: Annotated[str, typer.Argument()] = "", + all: bool = False, +): + "Show resource" + + # Fetch data + columns = ["name", "uses", "input"] + columns_all = columns + ["active", "deps", "missing", "loop_max", "loop", "need"] + filter = name + resources = ctx.obj.app.catalog.resources.select("startswith", name) + for name, item in sorted(resources.items(), key=lambda key: key): + if filter and not name.startswith(filter): + continue + + if not all and not item.is_active(): + continue + + data = [ + name, + to_yaml(item.uses).strip(), + to_yaml(item.input).strip(), + ] + if all: + data.extend( + [ + item.is_active(), + ",".join([x.name for x in item.resources_deps]), + ",".join(item.resources_missing), + item.loop_limit, + to_yaml(item.loop).strip(), + to_yaml(item.needs).strip(), + ] + ) + + # Render data + ctx.obj.render_item( + data, + columns_all if all else columns, + conf={ + "table_title": f"Resources kind: {data[0]}", + }, + ) + + +# Services management +# ====================== + + +class ServiceCmds(str, Enum): + "Serviceources sub commands" + LS = "list" + SHOW = "show" + + +@cli.command( + "svc", + hidden=True, + context_settings={"allow_extra_args": True, "ignore_unknown_options": True}, +) +def cli_svc(ctx: typer.Context, cmd: ServiceCmds): + """Command sources""" + prefix = "svc_" + func = globals()[f"{prefix}{cmd.value}"] + return ctx.invoke(func, ctx) + + +@cli.command("svc list", rich_help_panel="Services") +def svc_list( + ctx: typer.Context, name: Annotated[str, typer.Argument()] = "", all: bool = False +): + # filter: str = ""): + "List services" + + columns = ["name", "desc"] + filter = name + services = ctx.obj.app.catalog.services + data = [] + for name, res in sorted(services.items(), key=lambda key: key[1].name): + if filter and not name.startswith(filter): + continue + + # if not all and not res.is_active(): + # continue + + data.append( + ( + # res.get_kind(), + # res.get_name(), + res.name, + res.desc, + ) + ) + + # Render data + ctx.obj.render_list( + data, + columns, + conf={ + "table_title": f"Services listing", + }, + ) + + +@cli.command("svc show", rich_help_panel="Services") +def svc_show( + ctx: typer.Context, + name: Annotated[str, typer.Argument()] = "", + all: bool = False, +): + "Show service" + + columns = [ + "name", + "desc", + "enabled", + "input", + "commands", + "required_svc", + "resource_lookup", + "rest_matches" "dump", + ] + columns_all = columns + ["active", "deps", "missing", "loop_max", "loop", "need"] + + output = {} + services = ctx.obj.app.catalog.services.select("startswith", name) + for name, svc in services.items(): + assert name == svc.name + + data = [ + svc.name, + svc.desc, + svc.enabled, + svc.input, + to_yaml(svc.list_cmds()).strip(), + svc.required_services, + svc.resources_lookup, + svc.resources_matches, + svc.dump_item(), + ] + data = [str(item) for item in data] + + ctx.obj.render_item( + data, + columns_all if all else columns, + conf={ + "table_title": f"Service show: {data[0]}", + }, + ) + + +@cli.command("svc commands", rich_help_panel="Services") +def svc_commands(ctx: typer.Context): + "List services commands" + + ret = { + "shell": {}, + "cmds": {}, + } + for svc_name, svc in ctx.obj.app.list_services().items(): + cmds = svc.list_cmds() + for source in ["shell", "cmds"]: + items = cmds[source] + target = ret[source] + for cmd_name, conf in items.items(): + target[cmd_name] = conf + + pprint(ret, expand_all=True) + + +@cli.command("svc run", rich_help_panel="Services") +def svc_run(ctx: typer.Context, name: str, command: str): + "Run service command" + + ret = ctx.obj.app.services.get(name, resolve=True) + tmp = ret.run_cmd(command) + pprint(tmp) + + +# Shell management +# ====================== + + +class ShellCmds(str, Enum): + "Shellources sub commands" + ENABLE = "enable" + DISABLE = "disable" + ORDER = "order" + IDENTS = "idents" + + +@cli.command( + "shell", + hidden=True, + context_settings={"allow_extra_args": True, "ignore_unknown_options": True}, +) +def cli_shell(ctx: typer.Context, cmd: ShellCmds): + """Command sources""" + prefix = "shell_" + func = globals()[f"{prefix}{cmd.value}"] + return ctx.forward(func) + # return ctx.invoke(func, ctx) + + +@cli.command("shell idents", rich_help_panel="Shell") +def shell_idents(ctx: typer.Context, filter: str = ""): + "List idents" + + ret = ctx.obj.app.get_idents() + + print(" ".join(ret)) + + # pprint(ret, expand_all=True) + + +@cli.command("shell order", rich_help_panel="Shell") +def shell_order(ctx: typer.Context, reverse: bool = False): + "List services by loading order" + + table = Table(title="Identity services", **table_box_params) + table.add_column("Order", style="magenta") + table.add_column("Service", style="cyan") # , justify="right", , no_wrap=True) + + # services , _ = ctx.obj.app.catalog.resolve_service_order(reverse=reverse) + services, _ = ctx.obj.app.catalog.services.get_loading_order(reverse=reverse) + for index, name in enumerate(services): + table.add_row(str(index), name) + + console.print(table) + + +@cli.command("shell enable", rich_help_panel="Shell") +def shell_enable(ctx: typer.Context, ident): + "Enable shell" + + app = ctx.obj.app + + # Disable existing + # logger.info(f"Disabling ident: {app.ident_name}") + ret1 = app.shell_disable() + print("-- %< --" * 8) + print(ret1) + print("-- %< --" * 8) + + # Enable new + # logger.info(f"Enabling ident: {ident}") + app.init_ident(ident) + ret2 = app.shell_enable() + + # Output + print("-- %< --" * 8) + print(ret2) + print("-- %< --" * 8) + + +@cli.command("shell disable", rich_help_panel="Shell") +def shell_disable(ctx: typer.Context): + "Disable shell" + + ret = ctx.obj.app.shell_disable() + + print("-- %< --" * 8) + print(ret) + print("-- %< --" * 8) + + +# Merge all subcommands +# ====================== + +# cli.add_typer(cli_shell, name="shell", help="Manage shell") +# cli.add_typer(cli_res, name="res", help="Manage resources") +# cli.add_typer(cli_svc, name="svc", help="Manage services") +# cli.add_typer(cli_kind, name="kind", help="Manage resource kind") + + +import traceback + + +def run(): + "Run cli" + + if DEBUG.lower() in ["true", "y", "1", "t"]: + cli() + else: + try: + cli() + except IamException as err: + _msg = f"Iam exited with {type(err).__name__} error: {err}" + logger.error(_msg) + sys.exit(1) + + # traceback.print_stack() + # logger.warning + + +# Cli bootsrapping +# ====================== +if __name__ == "__main__": + run() diff --git a/iam/cli_click.py b/iam/cli_click.py new file mode 100755 index 0000000..14fa453 --- /dev/null +++ b/iam/cli_click.py @@ -0,0 +1,827 @@ +#!/usr/bin/env python3 + +import io +import logging +import os +import sys +from enum import Enum +from pprint import pprint +from types import SimpleNamespace + +from iam.app import App +from iam.framework import get_app_logger + +from . import exceptions as error +from .lib.cli_views import VIEW_FORMATS_NAMES, ViewItem, ViewList +from .lib.click_utils import NestedHelpGroup +# from .cli_views import ViewItem, ViewList, VIEW_FORMATS +from .lib.utils import prune, to_yaml + +# import rich +# from rich import box +# from rich.console import Console +# from rich.pretty import pprint +# from rich.prompt import Prompt +# from rich.table import Table + + +# from rich.pretty import pprint + + +# Rich loader +# ====================== + +APP_RICH = True +table_box_params = {} + +if not APP_RICH: + import click + + # Create console + console = SimpleNamespace(print=print) +else: + # Import rich + # Transparent rich proxy for click (help menus) + import rich_click as click + from rich_click import RichGroup + + from rich import box, print + from rich.console import Console + # Overrides defaults + from rich.pretty import pprint + + # Load rich wrappers + class NestedRichHelpGroup(NestedHelpGroup, RichGroup): + "Add rich class" + + NestedHelpGroup = NestedRichHelpGroup + + # Create default rich table settings + table_box_params = { + "box": box.ASCII2, # box.MINIMAL + "min_width": 60, + } + + # Create a console + console = Console() + + +logger = logging.getLogger() + +# Global vars +# ====================== + + +# Init app +# ====================== + +logger = logging.getLogger(__name__) + +item_view = ViewItem(output=console.print) +list_view = ViewList(output=console.print) + + +# General App settings +# =============================== + + +APP_NAME = "iam" +APP_VERSION = "__version__TOFIX" +APP_EXCEPTION = error.IamException + + +# See: https://docs.python.org/3.8/library/logging.html#logging-levels +DEFAULT_LOG_LEVEL = 30 # warning + +# Get default ident +DEBUG = os.environ.get("IAM_DEBUG", "False") +DEFAULT_IDENT = os.environ.get("IAM_IDENT", os.environ.get("SHELL_IDENT", "")) +DEFAULT_FORMAT = os.environ.get("IAM_FORMAT", "table") + + +# list_view.formats_enum + +# Click helpers +# =============================== + +_cmd1_options = [click.option("--format")] + +_cmd2_options = [click.option("--cmd2-opt")] + +CONTEXT_SETTINGS = dict( + show_default=True, + help_option_names=["-h", "--help"], + auto_envvar_prefix=APP_NAME.upper(), +) + + +def global_options(function): + # function = click.option( + # "debug", "--debug", + # is_flag=True, show_default=False, default=False, + # help="Debug" + # )(function) + # function = click.option( + # "force", "--force", "-f" + # is_flag=True, show_default=False, default=False, + # help="Force" + # )(function) + # function = click.option( + # "interactive", "--interactive", "-i" + # is_flag=True, show_default=False, default=False, + # help="Interactive mode" + # )(function) + # function = click.option( + # "recursive", "--recursive", "-r" + # is_flag=True, show_default=False, default=False, + # help="Recursive mode" + # )(function) + + function = click.option( + "quiet", + "--quiet", + "-q", + is_flag=True, + show_default=False, + default=False, + help="Suppress output except warnings and errors.", + )(function) + + function = click.option( + "log_trace", + "--trace", + is_flag=True, + show_default=False, + default=False, + help="Show traceback on errors", + )(function) + + function = click.option( + "-v", + "--verbose", + count=True, + type=click.IntRange(0, int(DEFAULT_LOG_LEVEL / 10), clamp=True), + help="Increase verbosity of output. Can be repeated.", + )(function) + + return function + + +def format_options(function): + # function = click.option( + # 'dry_run', '-n', '--dry-run', + # is_flag=True, show_default=False, default=False, + # help="Dry run, do not take any actions." + # )(function) + + # Duplicates + function = click.option( + "-v", + "--verbose", + count=True, + type=click.IntRange(0, int(DEFAULT_LOG_LEVEL / 10), clamp=True), + help="Increase verbosity of output. Can be repeated.", + )(function) + + # Special output options + function = click.option( + "fmt_name", + "-O", + "--output", + type=click.Choice(VIEW_FORMATS_NAMES, case_sensitive=False), + help="Output format", + default=None, + show_default=DEFAULT_FORMAT, + )(function) + + function = click.option( + "fmt_fields", "-H", "--headers", help="Show specific headers" + )(function) + + function = click.option("fmt_sort", "-S", "--sort", help="Sort columns")(function) + + return function + + +# Base Application example +# =============================== + +# DEFAULT_CONFIG = os.environ.get("IAM_CONFIG", "MISSING") + +DEFAULT_CONFIG = click.get_app_dir(APP_NAME) +# SHOW_DEFAULT_HELP=True + + +def get_var(name, default=None): + real_name = f"{APP_NAME.upper()}_{name.upper()}" + # print ("QUERY", real_name) + return os.environ.get(real_name, default) + + +# @click.group(cls=NestedHelpGroup, context_settings=CONTEXT_SETTINGS) +@click.group(cls=NestedHelpGroup) +@global_options +@format_options +@click.version_option() +@click.option( + "config", + "--config", + "-C", + # envvar='CONFIG', # multiple=True, + type=click.Path(), + default=get_var("CONFIG", DEFAULT_CONFIG), + show_default=get_var("CONFIG", DEFAULT_CONFIG), + help="Configuration directory or file", +) +@click.pass_context +def cli_app( + ctx, + verbose, + config: str = DEFAULT_IDENT, + ident: str = DEFAULT_IDENT, + quiet=False, + log_trace=False, + fmt_fields=None, + fmt_sort=None, + fmt_name=None, +): + """Naval Fate. + + This is the docopt example adopted to Click but with some actual + commands implemented and not just the empty parsing which really + is not all that interesting. + """ + + # Calculate log level + log_level = DEFAULT_LOG_LEVEL - verbose * 10 + log_level = log_level if log_level > 0 else 10 + + # Define loggers + loggers = { + "iam": {"level": log_level}, + "iam.cli": {"level": "INFO"}, + } + + # Instanciate logger + get_app_logger(loggers=loggers, level=log_level, colors=True) + + cli_config = SimpleNamespace( + log_level=log_level, + fmt_name=fmt_name or DEFAULT_FORMAT, + ident=ident, + ) + render_config = { + "fmt_name": cli_config.fmt_name, + "table_settings": table_box_params, + } + + pprint(cli_config) + + render_item = lambda *args, conf={}, **kwargs: item_view.render( + *args, conf={**render_config, **prune(conf)}, **kwargs + ) + render_list = lambda *args, conf={}, **kwargs: list_view.render( + *args, conf={**render_config, **prune(conf)}, **kwargs + ) + + # Instanciate app + logger.info(f"Current ident: {ident}") + ctx.obj = SimpleNamespace( + app=App(config_path=config, ident=ident), + cli=cli_config, + render_item=render_item, + render_list=render_list, + ) + + +@cli_app.command() +@click.pass_context +def tests(ctx): + print("Hello world") + + return + + # pprint(command_tree(cli_app)) + + print("TREEE @") + pprint(command_tree3(cli_app, ctx)) + + all_help(cli_app, ctx) + + # subcommand_obj = cli_app.get_command(ctx, subcommand) + # if subcommand_obj is None: + # click.echo("I don't know that command.") + # else: + # pprint (subcommand_obj) + # pprint (subcommand_obj.__dict__) + # click.echo(subcommand_obj.get_help(ctx)) + + +# Cli Utils +# =============================== + + +# Dedicated help command +# Source: https://stackoverflow.com/a/53137265 +@cli_app.command() +@click.argument("subcommand", nargs=-1) +@click.pass_context +def help(ctx, subcommand): + "Show a command help" + + subcommand = " ".join(subcommand) + subcommand_obj = cli_app.get_command(ctx, subcommand) + if subcommand_obj is None: + click.echo("I don't know that command.") + else: + pprint(subcommand_obj) + pprint(subcommand_obj.__dict__) + click.echo(subcommand_obj.get_help(ctx)) + + +# Global commands +# =============================== + + +@cli_app.command() +@click.pass_context +def dump(ctx): + ctx.obj.app.user_dump() + + +# Resource kind management +# =============================== + + +@cli_app.group("kind") +def cli__kind(): + """Manages Resources Kind.""" + + +@cli__kind.command("list") +@click.argument("filter", required=False) +@format_options +@click.pass_context +def kind_list(ctx, filter, fmt_name=None, fmt_sort=None, fmt_fields=None, verbose=None): + "List resource kinds" + + # Fetch data + # filter = name + resources_kind = ctx.obj.app.catalog.resources_kind + data = [] + for name, item in sorted(resources_kind.items(), key=lambda key: key): + if filter and not name.startswith(filter): + continue + data.append((name, item.desc)) + + # Render data + columns = ["name", "desc"] + conf = { + "table_title": "Resources kinds listing", + "fmt_name": fmt_name, + "fmt_sort": fmt_sort, + "fmt_fields": fmt_fields, + } + ctx.obj.render_list(data, columns, conf=conf) + + +@cli__kind.command("show") +@click.argument("name", required=False) +@format_options +@click.pass_context +def kind_show(ctx, name, fmt_name=None, fmt_fields=None, fmt_sort=None, verbose=None): + "Show resource kind" + + # Fetch data + columns = ["name", "desc", "input", "needs", "remap"] + filter = name + resources_kind = ctx.obj.app.catalog.resources_kind + for name, item in sorted(resources_kind.items(), key=lambda key: key): + if filter and not name.startswith(filter): + continue + + data = [ + item.name, + item.desc, + f"{to_yaml(item.input)}", + f"{to_yaml(item.needs)}", + f"{to_yaml(item.remap)}", + ] + + # Render data + conf = { + "table_title": f"Resources kind: {data[0]}", + "fmt_name": fmt_name, + "fmt_sort": fmt_sort, + "fmt_fields": fmt_fields, + } + ctx.obj.render_item(data, columns, conf=conf) + + +# @cli__kind.command("move") +# @click.argument("cli__kind") +# @click.argument("x", type=float) +# @click.argument("y", type=float) +# @click.option("--speed", metavar="KN", default=10, help="Speed in knots.") +# def ship_move(cli__kind, x, y, speed): +# """Moves SHIP to the new location X,Y.""" +# click.echo(f"Moving cli__kind {cli__kind} to {x},{y} with speed {speed}") + + +# @cli__kind.command("shoot") +# @click.argument("cli__kind") +# @click.argument("x", type=float) +# @click.argument("y", type=float) +# def ship_shoot(cli__kind, x, y): +# """Makes SHIP fire to X,Y.""" +# click.echo(f"Ship {cli__kind} fires to {x},{y}") + + +# Resource res management +# =============================== + + +@cli_app.group("res") +def cli__res(): + """Manages Resources.""" + + +@cli__res.command("list") +@click.argument("filter", required=False) +# @format_options +@click.pass_context +def res_list(ctx, filter, fmt_name=None, fmt_fields=None, fmt_sort=None, verbose=None): + "List resource ress" + + # Fetch data + resources = ctx.obj.app.catalog.resources + data = [] + for name, res in sorted(resources.items(), key=lambda key: key): + if filter and not name.startswith(filter): + continue + data.append( + ( + res.get_kind(), + res.get_name(), + res.desc, + ) + ) + + # Render data + columns = ["kind", "name", "desc"] + conf = { + "table_title": "Resources listing", + "fmt_name": fmt_name, + "fmt_sort": fmt_sort, + "fmt_fields": fmt_fields, + } + ctx.obj.render_list(data, columns, conf=conf) + + +@cli__res.command("show") +@click.argument("name", required=False) +@format_options +@click.pass_context +def res_show(ctx, name, fmt_name=None, fmt_fields=None, fmt_sort=None, verbose=None): + "Show resource res" + + # Fetch data + columns = ["name", "uses", "input"] + columns_all = columns + ["active", "deps", "missing", "loop_max", "loop", "need"] + filter = name + resources = ctx.obj.app.catalog.resources.select("startswith", name) + for name, item in sorted(resources.items(), key=lambda key: key): + if filter and not name.startswith(filter): + continue + + if not all and not item.is_active(): + continue + + data = [ + name, + to_yaml(item.uses).strip(), + to_yaml(item.input).strip(), + ] + if all: + data.extend( + [ + item.is_active(), + ",".join([x.name for x in item.resources_deps]), + ",".join(item.resources_missing), + item.loop_limit, + to_yaml(item.loop).strip(), + to_yaml(item.needs).strip(), + ] + ) + + # Render data + ctx.obj.render_item( + data, + columns_all if all else columns, + conf={ + "table_title": f"Resources kind: {data[0]}", + "fmt_name": fmt_name, + "fmt_sort": fmt_sort, + "fmt_fields": fmt_fields, + }, + ) + + +# Resource svc management +# =============================== + + +@cli_app.group("svc") +def cli__svc(): + """Manages Resources Kind.""" + + +@cli__svc.command("list") +@click.argument("filter", required=False) +@format_options +@click.pass_context +def svc_list(ctx, filter, fmt_name=None, fmt_fields=None, fmt_sort=None, verbose=None): + "List services" + + columns = ["name", "desc"] + services = ctx.obj.app.catalog.services + data = [] + for name, res in sorted(services.items(), key=lambda key: key[1].name): + if filter and not name.startswith(filter): + continue + + # if not all and not res.is_active(): + # continue + + data.append( + ( + # res.get_kind(), + # res.get_name(), + res.name, + res.desc, + ) + ) + + # Render data + ctx.obj.render_list( + data, + columns, + conf={ + "table_title": f"Services listing", + "fmt_name": fmt_name, + "fmt_sort": fmt_sort, + "fmt_fields": fmt_fields, + }, + ) + + +@cli__svc.command("show") +@click.argument("name", required=False) +@format_options +@click.pass_context +def svc_show(ctx, name, fmt_name=None, fmt_fields=None, fmt_sort=None, verbose=None): + "Show service" + + columns = [ + "name", + "desc", + "enabled", + "input", + "commands", + "required_svc", + "resource_lookup", + "rest_matches" "dump", + ] + columns_all = columns + ["active", "deps", "missing", "loop_max", "loop", "need"] + + output = {} + services = ctx.obj.app.catalog.services.select("startswith", name) + for name, svc in services.items(): + assert name == svc.name + + data = [ + svc.name, + svc.desc, + svc.enabled, + svc.input, + to_yaml(svc.list_cmds()).strip(), + svc.required_services, + svc.resources_lookup, + svc.resources_matches, + svc.dump_item(), + ] + data = [str(item) for item in data] + + ctx.obj.render_item( + data, + columns_all if all else columns, + conf={ + "table_title": f"Service show: {data[0]}", + "fmt_name": fmt_name, + "fmt_sort": fmt_sort, + "fmt_fields": fmt_fields, + }, + ) + + +@cli__svc.command("commands") +@format_options +@click.pass_context +def svc_commands(ctx, fmt_name=None, fmt_fields=None, fmt_sort=None, verbose=None): + "Show service" + + columns = ["name", "type", "desc"] + + data = [] + for svc_name, svc in ctx.obj.app.list_services().items(): + cmds = svc.list_cmds() + for source in ["shell", "cmds"]: + items = cmds[source] + # target = ret[source] + for cmd_name, conf in items.items(): + # target[cmd_name] = conf + + data.append([cmd_name, source, conf]) + + # pprint(ret) + # pprint(ret, expand_all=True) + + # Render data + ctx.obj.render_list( + data, + columns, + conf={ + "table_title": f"Services commands", + "fmt_name": fmt_name, + "fmt_sort": fmt_sort, + "fmt_fields": fmt_fields, + }, + ) + + +@cli__svc.command("run") +@click.argument("name") +@click.argument("command") +@format_options +@click.pass_context +def svc_run( + ctx, name, command, fmt_name=None, fmt_fields=None, fmt_sort=None, verbose=None +): + "Show service" + + ret = ctx.obj.app.catalog.services.get(name) + tmp = ret.run_cmd(command) + pprint(tmp) + + +# Shell management +# ====================== + + +@cli_app.group("shell") +def cli__shell(): + """Manages Resources Kind.""" + + +@cli__shell.command("idents") +@format_options +@click.pass_context +def shell_idents( + ctx, fmt_name=None, fmt_fields=None, fmt_sort=None, verbose=None, reverse=False +): + "Shell identities" + columns = ["name"] + data = [[x] for x in ctx.obj.app.get_idents()] + + # print(" ".join(ret)) + + # return + + # columns = ["order", "name"] + # data = [] + # services, _ = ctx.obj.app.catalog.services.get_loading_order(reverse=reverse) + # for index, name in enumerate(services): + # # table.add_row(str(index), name) + # data.append([str(index), name]) + + # Render data + ctx.obj.render_list( + data, + columns, + conf={ + "table_title": f"Shell identities", + "fmt_name": fmt_name, + "fmt_sort": fmt_sort, + "fmt_fields": fmt_fields, + }, + ) + + +@cli__shell.command("order") +@click.option( + "--reverse", is_flag=True, show_default=False, default=False, help="Reverse order" +) +@format_options +@click.pass_context +def shell_order( + ctx, fmt_name=None, fmt_fields=None, fmt_sort=None, verbose=None, reverse=False +): + "Shell loading order" + + columns = ["order", "name"] + data = [] + services, _ = ctx.obj.app.catalog.services.get_loading_order(reverse=reverse) + for index, name in enumerate(services): + # table.add_row(str(index), name) + data.append([str(index), name]) + + # Render data + ctx.obj.render_list( + data, + columns, + conf={ + "table_title": f"Services commands", + "fmt_name": fmt_name, + "fmt_sort": fmt_sort, + "fmt_fields": fmt_fields, + }, + ) + + +@cli__shell.command("enable") +@click.argument("ident") +# @format_options +@click.pass_context +def shell_enable(ctx, ident, verbose=None): + "Enable identity in shell" + + app = ctx.obj.app + + # Disable existing + # logger.info(f"Disabling ident: {app.ident_name}") + ret1 = app.shell_disable() + print("-- %< --" * 8) + print(ret1) + print("-- %< --" * 8) + + # Enable new + # logger.info(f"Enabling ident: {ident}") + app.init_ident(ident) + ret2 = app.shell_enable() + + # Output + print("-- %< --" * 8) + print(ret2) + print("-- %< --" * 8) + + +@cli__shell.command("disable") +# @format_options +@click.pass_context +def shell_disable(ctx, verbose=None): + "Disable identity in shell" + + ret = ctx.obj.app.shell_disable() + + print("-- %< --" * 8) + print(ret) + print("-- %< --" * 8) + + +# Exception handler +# =============================== +def clean_terminate(err): + "Terminate nicely the program depending the exception" + + # Choose dead end way + if APP_EXCEPTION is not None and isinstance(err, APP_EXCEPTION): + err_name = err.__class__.__name__ + logger.error(err) + logger.critical("MyApp exited with error: %s", err_name) + sys.exit(1) + + +# Core application executor +# =============================== + + +def run(): + "Return a MyApp App instance" + + try: + cli_app(**CONTEXT_SETTINGS) + + # pylint: disable=broad-except + except Exception as err: + clean_terminate(err) + + # Developper catchall + raise Exception(err) from err + # logger.error(traceback.format_exc()) + # logger.critical("Uncatched error %s; this may be a bug!", err.__class__) + # logger.critical("Exit 1 with bugs") + # sys.exit(1) + + +if __name__ == "__main__": + run() diff --git a/iam/cli_views.py b/iam/cli_views.py new file mode 100644 index 0000000..ee5391d --- /dev/null +++ b/iam/cli_views.py @@ -0,0 +1,192 @@ +import enum +import logging +from enum import Enum +from pprint import pformat, pprint +from types import SimpleNamespace + +from iam.framework import (empty, get_app_logger, iterate_any, open_yaml, + prune, to_csv, to_json, to_yaml) +from rich import box +from rich.console import Console +from rich.pretty import pprint +from rich.prompt import Prompt +from rich.table import Table + +from .lib.cli_views import ViewItem, ViewKind, ViewList + +# Iam Implementations +# =============================== + + +MISSING_PKG = [] + +try: + import hcl2 +except ModuleNotFoundError: + MISSING_PKG.append("hcl2") + +try: + import yaml +except ModuleNotFoundError: + MISSING_PKG.append("yaml") + + +# Main views +# ====================== + + +# def view_list(data, columns, title=None, fmt=None, **kwargs): +# "Show list view" + +# fmt = fmt or OutputFormat.DEFAULT +# ret = None +# _kwargs = { +# key[len(fmt.value) + 1 :]: val +# for key, val in kwargs.items() +# if key.startswith(fmt.value) +# } + +# # print ("YOOO", fmt, OutputFormat.YAML) +# if fmt == OutputFormat.YAML: +# ret = to_yaml(restructure_list_to_dict(data, columns)) + +# elif fmt == OutputFormat.JSON: +# ret = to_json(restructure_list_to_dict(data, columns), nice=True) + +# elif fmt == OutputFormat.PYTHON: +# ret = pformat(restructure_list_to_dict(data, columns), indent=2) + +# elif fmt == OutputFormat.CSV: +# ret = to_csv(restructure_list_to_csv(data, columns)) + +# elif fmt == OutputFormat.ENV: +# ret = to_vars(restructure_list_to_env(data, columns), export=True) + +# elif fmt == OutputFormat.VARS: +# ret = to_vars(restructure_list_to_env(data, columns)) + +# elif fmt == OutputFormat.RICH_TABLE: +# ret = to_rich_table(data, columns=columns, title=title, **_kwargs) + +# else: +# raise Exception(f"Unmanagable format: {fmt}") + +# console.print(ret) + + +# def view_show(data, columns=None, title=None): +# "Show single view" + +# ret = None +# _kwargs = { +# key[len(fmt.value) + 1 :]: val +# for key, val in kwargs.items() +# if key.startswith(fmt.value) +# } + +# # print ("YOOO", fmt, OutputFormat.YAML) +# if fmt == OutputFormat.YAML: +# ret = to_yaml(data) + +# elif fmt == OutputFormat.JSON: +# ret = to_json(data, indent=2) + +# elif fmt == OutputFormat.PYTHON: +# ret = pformat(data, indent=2) + +# elif fmt == OutputFormat.CSV: +# ret = to_csv(data) + +# elif fmt == OutputFormat.RICH_TABLE: +# data = list(zip(*data)) +# data.insert(0, ["Field", "Value"]) +# ret = to_rich_table(data, **_kwargs) + +# else: +# raise Exception(f"Unmanagable format: {fmt}") + +# return ret + + +# # DEPRECATED +# # ====================== + + +# def output_list(payload, fmt=None, **kwargs): +# "Render output format" +# assert False, "DEPRECATED" +# fmt = fmt or OutputFormat.YAML + +# # Requested format +# # payload = [ +# # ["Header1", "Header2"], # First line is always headers +# # [["val1", "val2"]], # First row +# # [["val1", "val2"]], # Second row, etc ... +# # ] + +# ret = None +# # _kwargs = {key.lstrip(f"{fmt.value}_"): val for key, val in kwargs.items() if key.startswith(fmt.value)} +# _kwargs = { +# key[len(fmt.value) + 1 :]: val +# for key, val in kwargs.items() +# if key.startswith(fmt.value) +# } + +# # print ("YOOO", fmt, OutputFormat.YAML) +# if fmt == OutputFormat.YAML: +# ret = to_yaml(payload) +# elif fmt == OutputFormat.JSON: +# ret = to_json(payload, indent=2) +# elif fmt == OutputFormat.PYTHON: +# ret = pformat(payload, indent=2) +# elif fmt == OutputFormat.CSV: +# ret = to_csv(payload) +# elif fmt == OutputFormat.RICH_TABLE: +# # pprint (kwargs) +# # pprint (_kwargs) +# return to_rich_table(payload, **_kwargs) +# else: +# raise Exception(f"Unmanagable format list: {fmt}") + +# assert isinstance(ret, str) +# return ret + + +# def output_show(payload, fmt=None, **kwargs): +# "Show one item" +# assert False, "DEPRECATED" + +# # Requested format +# # payload = [ +# # ["Header1", "Header2"], # First line is always headers +# # [["val1", "val2"]], # First row ONLY +# # [["val1", "val2"]], # Second row, etc ... +# # ] + +# ret = None +# _kwargs = { +# key[len(fmt.value) + 1 :]: val +# for key, val in kwargs.items() +# if key.startswith(fmt.value) +# } + +# # print ("YOOO", fmt, OutputFormat.YAML) +# if fmt == OutputFormat.YAML: +# ret = to_yaml(payload) + +# elif fmt == OutputFormat.JSON: +# ret = to_json(payload, indent=2) +# elif fmt == OutputFormat.PYTHON: +# ret = pformat(payload, indent=2) +# elif fmt == OutputFormat.CSV: +# ret = to_csv(payload) +# elif fmt == OutputFormat.RICH_TABLE: +# # Return data +# payload = list(zip(*payload)) +# payload.insert(0, ["Field", "Value"]) +# ret = to_rich_table(payload, **_kwargs) + +# else: +# raise Exception(f"Unmanagable format show: {fmt}") + +# return ret diff --git a/iam/exceptions.py b/iam/exceptions.py new file mode 100644 index 0000000..1f1d32a --- /dev/null +++ b/iam/exceptions.py @@ -0,0 +1,18 @@ +class IamException(Exception): + "Iam Exception" + + +class UnresolvedResourceDependencies(IamException): + "Raised when resources dependency is unmet" + + +class UnknownCatalogItem(IamException): + "Raised when resources dependency is unmet" + + +class UnknownResourceKind(IamException): + "Raised when resource refers to unexisting kind" + + +class MissingConfigFiles(IamException): + "Raised when iam can't find any valid configuration file" diff --git a/iam/framework.py b/iam/framework.py new file mode 100644 index 0000000..9ef338d --- /dev/null +++ b/iam/framework.py @@ -0,0 +1,355 @@ +import importlib +import json +import logging +import os +from collections import namedtuple +from copy import copy +from logging.config import dictConfig +from pprint import pprint +from types import SimpleNamespace + +import yaml +from colorama import Back, Fore, Style +from jinja2 import Template + +from . import exceptions as error + +KeyValue = namedtuple("KeyValue", "key value") +KeyValueExtra = namedtuple("KeyValueExtra", "key value extra") + + +logger = logging.getLogger(__name__) + + +# Logging helpers +# ================ + + +def get_app_logger(loggers=None, level="WARNING", colors=False, format="default"): + "Instanciate application logger" + + loggers = loggers or {} + + # Settings + fclass = "logging.Formatter" + # msconds = "" + if colors: + # Require coloredlogs + fclass = "coloredlogs.ColoredFormatter" + # msconds = "%(msecs)03d" + + # Define formatters + formatters = { + "default": { + "()": fclass, + "format": "[%(levelname)s] %(message)s", + # 'datefmt': '%Y-%m-%d %H:%M:%S', + }, + "extended": { + "()": fclass, + "format": "[%(levelname)s] %(name)s: %(message)s", + "datefmt": "%H:%M:%S", + }, + "audit": { + "()": fclass, + "format": "%(asctime)s.%(msecs)03d [%(levelname)s] %(name)s: %(message)s", + "datefmt": "%Y-%m-%d %H:%M:%S", + }, + "debug": { + "()": fclass, + "format": "%(msecs)03d [%(levelname)s] %(name)s: %(message)s [%(filename)s/%(funcName)s:%(lineno)d]", + "datefmt": "%H:%M:%S", + }, + } + + # Assert arguments + if not format in formatters: + choice = ",".join(formatters.keys()) + raise Exception(f"Invalid format: '{format}', please choose one of: {choice}") + + # Logging config + logging_config = { + "version": 1, + "disable_existing_loggers": True, + # How logs looks like + "formatters": formatters, + # Where goes the logs + "handlers": { + "default": { + "level": level, + "formatter": format, + "class": "logging.StreamHandler", + "stream": "ext://sys.stderr", # Default is stderr + }, + }, + # Where logs come from + "loggers": { + # Used to catch ALL logs + "": { # root logger + "handlers": ["default"], + "level": "WARNING", + "propagate": False, + }, + # # Used to catch all logs of myapp and sublibs + # 'myapp': { + # 'handlers': ['default'], + # 'level': 'INFO', + # 'propagate': False + # }, + # # Used to catch cli logs only + # 'myapp.cli': { + # 'handlers': ['default'], + # 'level': 'INFO', + # 'propagate': False + # }, + # # Used to catch app components, instanciated loggers + # 'myapp.comp': { + # 'handlers': ['default'], + # 'level': 'DEBUG', + # 'propagate': False + # }, + }, + } + + # Prepare logger components + for name, conf in loggers.items(): + logging_config["loggers"][name] = { + "propagate": False, + "handlers": ["default"], + } + logging_config["loggers"][name].update(conf) + + # Load logger + logging.config.dictConfig(logging_config) + + +# Exceptions +# ================ + + +# # DEPRECATED +# class IamException(Exception): +# "Iam Exception" + + +# Common classes +# ================ + + +def scoped_ident(*args): + "Serialize and deserialize scoped indents" + + if len(args) == 1: + ident = args[0] + scope = "" + if "/" in args[0]: + parts = payload.split("/", 2) + ident = parts[0] + scope = parts[1] + return SimpleNamespace(ident=ident, scope=scope) + + elif len(args) == 2: + ident = args[0] + scope = args[1] + return f"{ident}/{scope}" + + raise Exception(f"Bad arguments for scoped_ident: {args}") + + +class _DictBase: + default_attrs = {} + payload_type = dict + + def __init__(self, name, payload=None, parent=None, **kwargs): + "Init object" + + self.name = name + self.parent = parent + self._payload_raw = payload + self._payload = self._payload_raw or {} + self.kwargs = kwargs + + # Call internal methods + self.prepare() + self.payload_transform(name, kwargs=kwargs) + self.payload_validate() + + self.config_load() + self.config_validate() + + self.init() + + def __repr__(self): + return f"<{self.__class__.__name__}: {self.name}>" + + # Overrides + # --------------- + + def prepare(self): + "Prepare object" + + def payload_transform(self, name, kwargs=None): + "Transform payload, accessible from self._payload" + + def payload_validate(self): + "Validate payload is expected type" + + payload = self._payload + payload_type = self.payload_type + if not isinstance(payload, payload_type): + # pprint (payload) + assert False + raise IamException( + f"Invalid configuration for {self}, expected a {payload_type} payload, got {type(payload)} instead: {payload}" + ) + + def config_load(self): + "Load configuration, raise Exception if not good" + + def config_validate(self): + "Validate configuration, raise Exception if not good" + + def init(self): + "Init object" + + +class DictItem(_DictBase): + "Dict Item" + + default_attrs = {} + # payload_type = dict + + # Methods + # --------------- + + def payload_transform(self, name, kwargs=None): + "Transform payload, accessible from self._payload" + + self._payload["name"] = name + self._payload.update(kwargs or {}) + + def config_load(self): + "Load configuration from payload" + + # Set default attributes + for key, val in self.default_attrs.items(): + setattr(self, key, copy(val)) + + # Overrides default attributes with payload + payload = self._payload + for key, val in payload.items(): + setattr(self, key, val) + + def dump_item(self): + ret = { + key: val for key, val in self.__dict__.items() if not key.startswith("_") + } + return ret + + +class DictCtrl(_DictBase): + "Dict ctrl" + + items_class = None + + # Overrides + # --------------- + + def prepare(self): + "Prepare controller" + + # assert False + kwargs = self.kwargs + + # Set kwargs + kwargs = kwargs or {} + for key, val in kwargs.items(): + # print ("DICT SET:", key, val) + setattr(self, key, val) + + def config_load(self): + "Prepare controller" + # Set catalog + self._items = {} + if self.items_class: + # Note: self.items_class is derived from DictItem !!! + for item_name, item in self._payload.items(): + # print ("CONFIG_LOAD", item_name) + # pprint (item) + + obj = self.items_class(item_name, payload=item, parent=self) + self._items[obj.name] = obj + + # Dunders + # --------------- + def __contains__(self, name): + return self.has(name) + + # Methods + # --------------- + + def items(self): + return self._items.items() + + def values(self): + return self._items.values() + + def keys(self): + return self._items.keys() + + def has(self, name): + "Return true or false if an item exists" + return True if name in self._items else False + + def select(self, mode, name): + "Select items on criterion" + + name = name or "" + modes = ["startswith", "endswith"] + + if name.endswith(":"): + self.get(name[:-1]) + assert mode in modes, f"Unsupported mode: {mode}" + + ret = {} + for key, value in self._items.items(): + validated = False + if mode == "startswith" and key.startswith(name): + validated = True + elif mode == "endswith" and key.endswith(name): + validated = True + + if validated: + ret[key] = value + + return ret + + def get(self, *args): + "Retrieve an item or raise exception if not exists, args: [name [default]]" + + # Fetch all items + if len(args) == 0: + return self._items + + # Parse argument count + name = None + default = None + use_default = False + if len(args) == 2: + name = args[0] + default = args[1] + use_default = True + elif len(args) == 1: + name = args[0] + + # Return result + if not name in self._items: + if use_default: + return default + + choices = "\n - ".join(self._items.keys()) + msg = f"Missing {self.name} item: {name}, please choose one of:\n - {choices}" + raise error.UnknownCatalogItem(msg) + return self._items[name] + + raise IamException(f"Unsupported arguments for .get(), got: {args}") diff --git a/iam/idents.py b/iam/idents.py new file mode 100644 index 0000000..12ddcc5 --- /dev/null +++ b/iam/idents.py @@ -0,0 +1,45 @@ +import logging +from pprint import pprint + +from . import exceptions as error +from .framework import DictCtrl, DictItem, KeyValue, KeyValueExtra + +logger = logging.getLogger(__name__) + + +class Ident(DictItem): + "Ident instance" + + default_attrs = { + "secrets": {}, + "resources": {}, + "services": {}, + } + + def init(self): + self.secrets = self._payload.get("secrets", {}) + self.resources = self._payload.get("resources", {}) + self.services = self._payload.get("services", {}) + + def get_resource_configs(self): + "Returns resources configurations" + + return self.resources or {} + + +class Idents(DictCtrl): + "Ident controller" + + items_class = Ident + + def get_services_configs(self): + "Return services configurations" + ret = {} + for prov_name, ident in self.items(): + ret.update(ident.services) + return ret + + def get_idents(self): + "Return a list of idents" + + return list(self._items.keys()) diff --git a/iam/lib/cli_views.py b/iam/lib/cli_views.py new file mode 100644 index 0000000..badca40 --- /dev/null +++ b/iam/lib/cli_views.py @@ -0,0 +1,548 @@ +import enum +import importlib.util +import logging +from pprint import pformat, pprint +from types import SimpleNamespace + +from iam.framework import get_app_logger + +from .utils import iterate_any, to_csv, to_hcl, to_json, to_toml, to_yaml + +# Optional deps +MISSING_PKGS = [] +try: + from rich.table import Table +except ModuleNotFoundError: + MISSING_PKGS.append("Table") + + +# MISSING_PKGS = [] +# try: +# from rich.console import Console +# except ModuleNotFoundError: +# MISSING_PKGS.append("Console") + + +# if not "Console" in MISSING_PKGS: +# console = Console() +# else: +# console + + +logger = logging.getLogger(__name__) + + +# Output configuration +# ====================== +VIEW_MODULES = { + # "table": "rich", + "yaml": "pyaml", + "toml": "tomllib", + "hcl": ("hcl2", "python-hcl2"), +} + + +VIEW_FORMATS = { + "DEFAULT": "table", + # CLI + "RICH_TABLE": "table", + # Item + "ENV": "env", + "VARS": "vars", + "CSV": "csv", + "WORDS": "words", + "LINES": "lines", + # Structured + "JSON": "json", + "YAML": "yaml", + "PYTHON": "python", + # "TOML": "toml", + # "HCL": "hcl", + # Shell syntax + # "SHELL": "sh", + # "SHELL_BASH": "bash", + # "SHELL_ZSH": "zsh", + # "SHELL_FISH": "fish", +} + +VIEW_FORMATS_NAMES = list(set(VIEW_FORMATS.values())) + + +# Structurers +# =============================== + + +def restructure_to_item_view(data, columns): + "Restructure data to fit to item view" + + ret = {} + + for index, key in enumerate(columns): + value = data[index] + + ret[key] = value + + return ret + + +def restructure_to_list_view(data, columns): + "Restructure data to fit to list view" + + col_count = len(columns) + + ret = [] + for row in data: + line = {} + for index in range(col_count): + col = columns[index] + cell = row[index] + line[col] = cell + ret.append(line) + + return ret + + +def restructure_to_csv(data, columns): + "Restructure data to fit to CSV view" + col_count = len(columns) + + ret = [columns] + for row in data: + line = [] + for key, val in iterate_any(row): + line.append(val) + ret.append(line) + + return ret + + +def restructure_to_env(data, columns, prefix_index=-1, prefix="", count_var="_count"): + "Restructure data to fit to item env/vars view" + # Prepare + col_count = len(columns) + data_count = len(data) + prefix_col = None + if (prefix_index >= 0) and (prefix_index < col_count): + prefix_col = columns[prefix_index] + + # Loop over each records + ret = [] + for idx, row in enumerate(data): + # Defaults + curr_prefix = "" + curr_suffix = "" + + # Rebuild dict config + dict_payload = {} + for index in range(col_count): + key = columns[index] + value = row[index] + dict_payload[key] = value + + # Extract named prefix if more than one result + if len(data) > 1: + if prefix_col: + curr_prefix = f"{dict_payload.pop(prefix_col)}__" + else: + curr_suffix = f"_{idx}" + + # Build each value settings + payload = [] + for column_name in columns: + if column_name in dict_payload: + key = f"{prefix}{curr_prefix}{column_name}{curr_suffix}" + value = dict_payload[column_name] + payload.append([key, value]) + + ret.extend(payload) + + # Add count var if many records + if count_var and len(data) > 1: + ret.insert(0, [count_var, len(data)]) + + return ret + + +def restructure_to_toml(data, columns, prefix="", root="items"): + ret = {} + for line in data: + name = line[0] + value = {} + if len(line) > 1: + for idx, val in enumerate(line[1:]): + key = columns[idx + 1] + value[key] = val + ret[prefix + name] = value + + if root: + ret = {root: data} + + # pprint (ret) + return ret + + +# Formats +# =============================== + + +def to_vars(data, export=False, comments=True, prefix=""): + "Render to vars or environment vars, Input ormat: [[key, value,]" + + out = [] + for line in data: + assert isinstance(line, (list, set)), f"Got: {line}" + + # Fetch key/value + key = str(line[0]) + value = str(line[1]) if len(line) > 1 else "" + + # # Comments + # if comments and len(line) > 2: + # comments = "# " + ', '.join(line[2:]) + # else: + # comment = "" + content = f"{prefix}{key}='{value}'\t\t" + + # Exports + if export: + content = f"export {prefix.upper()}{key.upper()}='{value}'" + out.append(content) + + return "\n".join(out) + + +def to_rich_table(data, columns=None, title=None, settings=None): + settings = settings or {} + if title: + settings["title"] = title + + columns = columns or [] + rows = data or [] + table = Table(**settings) + for column in columns: + if isinstance(column, dict): + txt, args = column["text"], column.get("args", {}) + table.add_column(str(text), **args) + else: + table.add_column(str(column)) + + # Loop over each lines + for key, cells in iterate_any(rows): + line = [] + if isinstance(key, str): + line = [key] + + # Loop over each cell + for _, cell in iterate_any(cells): + line.append(str(cell)) + + table.add_row(*line) + + return table + + +# Helpers +# =============================== + + +def is_module_present(name): + "Return true if a module is present" + spam_spec = importlib.util.find_spec("spam") + return spam_spec is not None + + +def view_table_sort(row, indexes): + "Helper to sort tables on columns" + ret = [] + for idx in indexes: + ret.append(row[idx]) + + # ret = list(set(ret)) + return ret + + +# Main Views Class +# =============================== + + +class _RootView: + name = "UNSET" + formats_dict = {"DEFAULT": "default"} + formats_modules = {"DEFAULT": None} + + def __init__(self, output=None): + self._init_enum() + self._init_default() + + self.output = output or print + + ## DEPRECATED !!! + def _init_enum(self): + "Create enum" + self.formats_enum = enum.Enum("OutputFormat", self.formats_dict) + + def _init_default(self): + "Init default format" + self.default_format = self.formats_enum.DEFAULT.value + + ## Rendering + def render(self, data, columns, conf=None): + "Render data" + + # assert isinstance(data, dict) + # assert isinstance(columns, dict) + + # Prepare vars + conf = conf or {} + # fmt = ( + # conf.get("fmt_format", None) + # or conf.get("fmt", None) + # or self.default_format + # ) + + # Extract Format config + # --------------------------- + fmt_config = dict(name=None, sort=None, fields=None) + fmt_config.update( + { + key.replace("fmt_", ""): val + for key, val in conf.items() + if key.startswith("fmt_") and val is not None + } + ) + fmt_ = SimpleNamespace(**fmt_config) + print("FORMAT CONFIG") + pprint(conf) + pprint(fmt_) + + assert fmt_.name, f"Format name can't be None: {fmt_.name}" + + # check module presence + pprint(self.formats_modules) + mod_spec = self.formats_modules.get(fmt_.name, None) + print("MOD_NAME", fmt_.name, mod_spec) + if mod_spec: + # Extract specs + mod_package = None + mod_name = mod_spec + if isinstance(mod_spec, (list, set, tuple)): + mod_name = mod_spec[0] + mod_package = mod_spec[1] + + # Check module presence + print("CHECKING MODE NAME", mod_name) + ret = is_module_present(mod_name) + if not ret: + _msg = f"Missing python module '{mod_name}' to support '{fmt_.name}' output." + if mod_package: + _msg = f"{_msg} Please first install package: {mod_package}" + logger.warning(_msg) + # raise Exception(_msg) + + # Fetch renderer method + # --------------------------- + fmt_name = fmt_.name + fn_name = f"to__{fmt_name}" + fn = getattr(self, fn_name, None) + if not callable(fn): + raise Exception( + f"Unsupported format {fmt_.name} for {self.__class__.__name__}: No such {fn_name} method" + ) + + # Sort data + # --------------------------- + if fmt_.sort: + col_names = fmt_.sort.split(",") + # col_name = fmt_.sort + + # Get columns indexes + indexes = [] + for col_name in col_names: + try: + _idx = columns.index(col_name) + except ValueError: + choices = ",".join(columns) + raise Exception( + f"No such column '{col_name}', please choose one of: {choices}" + ) + indexes.append(_idx) + + # Sort data + # print ("SORT COLUMNS", indexes) + data.sort(key=lambda row: view_table_sort(row, indexes)) + + # Filter out fields + # --------------------------- + if fmt_.fields: + fields_keys = fmt_.fields.split(",") + + # Get columns indexes + indexes = [] + for col_name in fields_keys: + try: + _idx = columns.index(col_name) + except ValueError: + choices = ",".join(columns) + raise Exception( + f"No such column '{col_name}', please choose one of: {choices}" + ) + indexes.append(_idx) + + # print ("LIMIT FIELDS !!!", fields_keys, indexes) + rm_cols = [idx for idx, item in enumerate(columns) if not idx in indexes] + + # Recreate smaller table + new_columns = [] + for idx in indexes: + col_name = columns[idx] + new_columns.append(col_name) + + new_data = [] + for row in data: + new_row = [] + for idx in indexes: + new_row.append(row[idx]) + + new_data.append(new_row) + + data = new_data + columns = new_columns + + # Call renderer + # --------------------------- + stripped_conf = { + key.replace(f"{fmt_name}_", ""): val + for key, val in conf.items() + if key.startswith(fmt_name) + } + ret = fn(data, columns, **stripped_conf) + + # Display or return + # --------------------------- + if callable(self.output): + self.output(ret) + else: + return ret + + +# Views Implementations +# =============================== + + +class SpecificView(_RootView): + "Sprecific views (BETA)" + formats_dict = VIEW_FORMATS + formats_modules = VIEW_MODULES + + def to__hcl(self, data, columns): + tmp = restructure_to_list_view(data, columns) + return to_hcl(tmp) + + def to__toml(self, data, columns): + return to_toml(restructure_to_toml(data, columns, root="data")) + + # def to__toml(self, data, columns): + # return to_toml(data) + # def to__toml(self, data, columns): + # return to_toml(restructure_to_list_view(data, columns)) + + +class ViewList(_RootView): + name = "list" + + formats_dict = VIEW_FORMATS + formats_modules = VIEW_MODULES + + # Standard formats + def to__yaml(self, data, columns): + return to_yaml(restructure_to_list_view(data, columns)) + + def to__json(self, data, columns): + return to_json(restructure_to_list_view(data, columns), nice=True) + + def to__python(self, data, columns): + return pformat(restructure_to_list_view(data, columns), indent=2) + + # Rich table output + def to__table(self, data, columns, **kwargs): + "Return a rich table" + return to_rich_table(data, columns=columns, **kwargs) + + # Script helpers + def to__csv(self, data, columns): + return to_csv(restructure_to_csv(data, columns)) + + def to__vars(self, data, columns): + return to_vars(restructure_to_env(data, columns), export=False) + + def to__env(self, data, columns): + return to_vars(restructure_to_env(data, columns), export=True) + + def to__words(self, data, columns, **kwargs): + "Return words" + + ret = [] + for line in data: + for cell in line: + ret.append(cell) + + return " ".join(ret) + + pprint(data) + return None + return to_rich_table(data, columns=columns, **kwargs) + + def to__lines(self, data, columns, **kwargs): + "Return lines" + + ret = [] + for line in data: + ret.append(" ".join(line)) + + return "\n".join(ret) + + +class ViewItem(_RootView): + name = "item" + + formats_dict = VIEW_FORMATS + formats_modules = VIEW_MODULES + + # Standard formats + def to__yaml(self, data, columns): + return to_yaml(restructure_to_item_view(data, columns)) + + def to__json(self, data, columns): + return to_json(restructure_to_item_view(data, columns), nice=True) + + def to__toml(self, data, columns): + return to_toml(restructure_to_toml(data, columns)) + + def to__python(self, data, columns): + return pformat(restructure_to_item_view(data, columns), indent=2) + + # Rich table output + def to__table(self, data, columns, **kwargs): + "Return a rich table" + + # pprint (columns) + # pprint (data) + + # Rotate list + _list = [] + _list.append(columns) + _list.append(data) + data = list(zip(*_list)) + + # Render table + columns = ["Field", "Value"] + return to_rich_table(data, columns=columns, **kwargs) + + def to__csv(self, data, columns): + return to_csv(restructure_to_csv(data, columns)) + + def to__vars(self, data, columns): + return to_vars(restructure_to_env(data, columns), export=False) + + def to__env(self, data, columns): + return to_vars(restructure_to_env(data, columns), export=True) diff --git a/iam/lib/click_utils.py b/iam/lib/click_utils.py new file mode 100644 index 0000000..1f4d8ad --- /dev/null +++ b/iam/lib/click_utils.py @@ -0,0 +1,62 @@ +import click + +# Click Plugins +# =============================== + + +class NestedHelpGroup(click.Group): + # class NestedHelpGroup(RichGroup): + """This class provides a way to show all commands of all children + instead of just the parent. Optionnaly accept to hides groups or not. + + """ + + def get_command(self, ctx, cmd_name: str): + """Given a context and a command name, this returns a :class:`Command` + object if it exists or returns ``None``. + """ + + # Resolve name part by part + parts = cmd_name.split(" ") + curr = self + for idx, part in enumerate(parts): + curr = curr.commands.get(part) + + return curr + + def list_commands(self, ctx) -> list[str]: + "List all children commands" + + return self._resolve_children(self, ctx=ctx, ignore_groups=True) + + @classmethod + def _resolve_children(cls, obj, ctx=None, ignore_groups=False, _parent=None): + "Resolve recursively all children" + # Source: Adapted from https://stackoverflow.com/a/56159096 + + if isinstance(obj, click.Group): + ret = [] + for name, child in obj.commands.items(): + # Build full name + full_name = name + if _parent: + full_name = " ".join([_parent, name]) + + # Save records + record = full_name + if ignore_groups: + if not isinstance(child, click.Group): + ret.append(record) + else: + ret.append(record) + + # Recursive loop + ret.extend( + cls._resolve_children( + child, ctx=ctx, ignore_groups=ignore_groups, _parent=full_name + ) + ) + + return ret + + return [] diff --git a/iam/lib/utils.py b/iam/lib/utils.py new file mode 100644 index 0000000..614eb79 --- /dev/null +++ b/iam/lib/utils.py @@ -0,0 +1,276 @@ +import importlib +import json +import logging +import os +import tomllib +from pprint import pprint + +import yaml + +# Dynamic lib loader +# ================ + +MISSING_PKGS = [] +try: + import yaml +except ModuleNotFoundError: + MISSING_PKGS.append("yaml") + +try: + # TOFIX: Python 3.11 provides tomllib + import tomlkit +except ModuleNotFoundError: + MISSING_PKGS.append("tomlkit") + + +try: + import hcl2 +except ModuleNotFoundError: + MISSING_PKGS.append("hcl2") + +try: + from jinja2 import Template +except ModuleNotFoundError: + MISSING_PKGS.append("jinja2") + + +# Globals +# ================ + +logger = logging.getLogger(__name__) + + +# Helper functions +# ================ + + +def empty(item): + "Check if an item is empty" + + if item is None: + return True + + elif isinstance(item, str): + if item.strip() == "": + return True + + elif isinstance(item, (list, dict)): + if len(item) == 0: + return True + + else: + if item == None: + return True + if item == []: + return True + if item == {}: + return True + if item == tuple(): + return True + + return False + + +def prune(items): + "Prune empty lists, dicts and None values from list or dict" + + if isinstance(items, dict): + ret = {key: val for key, val in items.items() if not empty(val)} + elif isinstance(items, list): + ret = [val for val in items if not empty(val)] + else: + raise Exception(f"Function prune requires a list or a dict, got: {items}") + return ret + + +def jinja_render(payload, vars): + "Parse a string with jinja" + vars = vars or {} + assert isinstance(payload, str) + return Template(payload).render(**vars) + + +def format_render(payload, vars): + "Parse a string with python format" + vars = vars or {} + assert isinstance(payload, str) + return payload.format(**vars) + + +def open_yaml(document): + ret = [] + with open(document, "r") as file: + docs = yaml.safe_load_all(file) + for doc in docs: + ret.append(doc) + + return ret + + +def uniq(items): + "Filter out duplicates items of list while preserving order, first stay, others are discarded" + assert isinstance(items, list), f"Expected a list, got {type(items)}: {items}" + return list(dict.fromkeys(items)) + + +# TODO: add tests +def from_yaml(string): + "Transform YAML string to python dict" + return yaml.safe_load(string) + + +# TODO: add tests +def to_yaml(obj, headers=False): + "Transform obj to YAML" + options = {} + return yaml.safe_dump(obj) + + +# TODO: add tests +def to_json(obj, nice=True): + "Transform JSON string to python dict" + if nice: + return json.dumps(obj, indent=2) + return json.dumps(obj) + + +# TODO: add tests +def from_json(string): + "Transform JSON string to python dict" + return json.loads(string) + + +# TODO: add tests +def to_dict(obj): + """Transform JSON obj/string to python dict + + Useful to transofmr nested dicts as well""" + if not isinstance(obj, str): + obj = json.dumps(obj) + return json.loads(obj) + + +def to_csv(obj, sep=";"): + """List or dict in csv""" + + out = [] + for key, val in iterate_any(obj): + line = [] + if isinstance(key, str): + line = [key] + + for _, row in iterate_any(val): + line.append(str(row)) + + out.append(sep.join(line)) + + out = "\n".join(out) + return out + + +def to_hcl(data): + "Render to HCL" + pprint(data) + return hcl2.loads(data) + + +# # Only for python >3.11 +# def to_toml(data): +# "Render to toml" +# pprint(data) +# return tomllib.dumps(data) + + +def to_toml(data): + "Render to toml" + pprint(data) + if isinstance(data, list): + raise Exception("Bug: Toml can't handle list as top level objects") + # if isinstance(data, list): + # tab = tomlkit.table() + # for idx, line in enumerate(data): + # if isinstance(line, list) and len(line) > 1: + # first_item = line[0] + # other = line[1:] + # tab.add(first_item, other) + # else: + # tab.add(f"line.{str(idx)}", line) + + # data = { + # "table": tab + # } + return tomlkit.dumps(data) + + +def iterate_any(payload): + "Try to iterate on anything, always return key and value" + + try: + iterator = iter(payload) + except TypeError: + print("NOT ITERABLE", payload) + return {}.items() + + # if hasattr(payload, "items"): + try: + return payload.items() + except AttributeError: + pass + + # if hasattr(payload, "count"): + # print ("PAYLOAD", type(payload), payload) + return enumerate(payload) + + assert False + raise Exception(f"Could not iterate over: {payload}") + + # if payload is None: + # return {}.items() + + # if hasattr(payload, "items"): + # return payload.items() + + # # if hasattr(payload, "append"): + # return enumerate(payload) + + # raise Exception(f"Could not iterate over: {payload}") + + +def get_pkg_dir(name): + """Return the dir where the actual paasify source code lives""" + + # pylint: disable=import-outside-toplevel + mod = import_module_pkg(name) + return os.path.dirname(mod.__file__) + + +def import_module_pkg(package): + "Simple helper to load dynamically python modules" + return importlib.import_module(package) + + +def import_module_from(package, *names): + "Allow to import from python packages" + + mod = import_module(package) + + names_len = len(names) + if names_len == 0: + return mod + if names_len == 1: + return getattr(mod, names[0]) + if names_len > 1: + ret = [] + for name in names: + ret.append(getattr(mod, name)) + return set(ret) + + +def import_module(name): + "Simple helper to load python modules" + + if ":" in name: + package, comp = name.rsplit(":", 1) + return import_module_from(package, comp) + + return import_module_pkg(name) diff --git a/iam/meta.py b/iam/meta.py new file mode 100644 index 0000000..3cb95ef --- /dev/null +++ b/iam/meta.py @@ -0,0 +1,2 @@ +NAME = "iam" +VERSION = "0.0.1" diff --git a/iam/plugins/__init__.py b/iam/plugins/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/iam/plugins/base.py b/iam/plugins/base.py new file mode 100644 index 0000000..213f7c4 --- /dev/null +++ b/iam/plugins/base.py @@ -0,0 +1,66 @@ +plugin_base = { + "resources_def": { + # Secrets + "secret": {"desc": "Secret", "input": {"secret": None}}, + "secret.env": { + "desc": "Environment secret" "vars", + "input": {"secret_env": None}, + }, + "secret.file": { + "desc": "File secret", + "input": {"secret_file": None}, + }, + # Auths + "auth": {"desc": "Authentification"}, + "auth.password": {"desc": "Password", "input": {"password": None}}, + "auth.token": { + "desc": "Token", + "input": {"token": None}, + }, + "auth.totp": { + "desc": "One time password", + "input": {"token": None}, + }, + # Accounts + "account": { + "desc": "Account", + "input": {"password": None, "user": None}, + }, + "account.email": {"desc": "Email account", "input": {"email": None}}, + # Services + "service": {"desc": "Session service"}, + # ID + "service.id": {"desc": "Default ident service"}, + }, + "resources": { + "service.id": { + "enabled": True, + }, + }, + "services": { + "id": { + "desc": "Local id", + "commands": { + "shell_enable": { + "desc": "Enable shell ident", + "cmd": "export SHELL_IDENT={{ident}}", + }, + "shell_disable": { + "desc": "Disable shell ident", + "cmd": "unset SHELL_IDENT", + }, + "new": { + "desc": "Create shell identy", + "cmd": "add_ident {{ param }}", + }, + "delete": { + "desc": "Delete shell identy", + "cmd": "rm_ident {{ param }}", + }, + }, + }, + }, +} + + +all = {"base": plugin_base} diff --git a/iam/plugins/devops.py b/iam/plugins/devops.py new file mode 100644 index 0000000..9c16231 --- /dev/null +++ b/iam/plugins/devops.py @@ -0,0 +1,10 @@ +import os + +from iam.lib.utils import get_pkg_dir, open_yaml, to_yaml + +all = None +yml_dir = get_pkg_dir(__name__) +conf_files = open_yaml(os.path.join(yml_dir, "devops.yml")) +for conf in conf_files: + all = conf.get("providers", {}) + break diff --git a/iam/plugins/devops.yml b/iam/plugins/devops.yml new file mode 100644 index 0000000..84b92db --- /dev/null +++ b/iam/plugins/devops.yml @@ -0,0 +1,177 @@ +providers: + + + + # Provider: Github Config + # ================== + github: + + services: + + local.github: + desc: Github client + input: + gh_token: "MISSING_TOKEN" + + commands: + + shell_enable: + desc: Enable git identity + cmd: | + export GH_TOKEN='{{gh_token}}' + export GH_REPO='{{ident}}' + + shell_disable: + desc: Disable git identity + cmd: | + unset GH_TOKEN GH_REPO + + + + resources_def: + + + account.github: + desc: A github account + + + service.local.github: + desc: Configure gh client + uses: + - account.github:{user} + + resources: + + # account.github: + # desc: Github account + # input: + # gh_token: TOKEN789456123 + + service.local.github: + enabled: true + uses: + - account.github:{user} + + + + # Provider: Gitea Config + # ================== + gitea: + + services: + + local.gitea: + desc: Gitea client + input: + gitea_server_url: "MyGiteaServer.com" + + commands: + + shell_enable: + desc: Enable git identity + cmd: | + export GITEA_SERVER_URL='{{gitea_server_url}}' + export GITEA_LOGIN='{{email}}' + + shell_disable: + desc: Disable git identity + cmd: | + unset GITEA_SERVER_URL GITEA_LOGIN + + + resources_def: + + account.gitea: + desc: A gitea account + + + service.local.gitea: + desc: Configure gitea client + needs: + - account.gitea:{user} + + resources: + + # account.gitea: + # desc: Gitea account + # input: + # gh_token: TOKEN789456123 + + service.local.gitea: + enabled: true + uses: + - account.gitea:{user} + + + # Provider: Minio Config + # ================== + minio: + + services: + + local.minio: + desc: Minio client + input: + minio_url: "minio.org" + minio_access: ident + minio_secret: secret + + commands: + + shell_enable: + desc: Enable minio alias + cmd: | + export MINIO_ACCESS_KEY={{minio_access}} + export MINIO_SECRET_KEY={{minio_secret}} + # cmd_FUTURE: | + # $ export MINIO_ACCESS_KEY=$(cat /dev/urandom | LC_CTYPE=C tr -dc 'a-zA-Z0-9' | fold -w 32 | head -n 1) + # # this one is actually a secret, so careful + # $ export MINIO_SECRET_KEY=$(cat /dev/urandom | LC_CTYPE=C tr -dc 'a-zA-Z0-9' | fold -w 32 | head -n 1) + + shell_disable: + desc: Disable minio alias + cmd: | + unset MINIO_ACCESS_KEY MINIO_SECRET_KEY + + + new_alias: + desc: Create alias + cmd: | + mc alias set {{ident}} {{api_url}} {{minio_user}} {{minio_password}} + + rm_alias: + desc: Remove alias + cmd: | + mc alias rm {{ident}} + + + resources_def: + + account.minio: + desc: A minio account + + + service.local.minio: + desc: Configure minio client + uses: + - account.minio:{user} + + resources: + + # account.minio: + # desc: Minio account + # input: + # gh_token: TOKEN789456123 + + service.local.minio: + enabled: true + uses: + - account.minio:{user} + + + +# Other tools +# libvirt-url +# terraform_endpoint +# openstack-endpoint +# minio aliases \ No newline at end of file diff --git a/iam/plugins/local.py b/iam/plugins/local.py new file mode 100644 index 0000000..9ca7922 --- /dev/null +++ b/iam/plugins/local.py @@ -0,0 +1,178 @@ +import os +from pprint import pprint + +from iam.lib.utils import get_pkg_dir, open_yaml, to_yaml + +yml_dir = get_pkg_dir(__name__) +plugin_conf = open_yaml(os.path.join(yml_dir, "local.yml"))[0] +all = plugin_conf.get("providers", {}) + + +# plugin_ssh = { + +# "services": { + +# "local.ssh_key": { +# "desc": "Local ssh key", +# # "input": { +# # "ssh_agent_socket_dir": "/run/user/ssh-agent", +# # "ssh_agent_tmout": "7d", +# # }, +# "commands": { +# "shell_enable": { +# "cmd": """ +# export SSH_AUTH_SOCK={{ssh_agent_socket_dir}}/{{user}} && \ +# ssh-agent -a $SSH_AUTH_SOCK -t {{ssh_agent_tmout}} +# """, +# }, +# "shell_disable": { +# "cmd": "ssh-agent -k && unset SSH_AUTH_SOCK", +# }, +# }, +# }, +# }, + + +# "resources_def": { +# "service.local.ssh_key": { +# "desc": "A local ssh key", +# }, + +# "auth.ssh_certificate": { +# "desc": "SSH Certificates", +# "input": {"ssh_cert_file": None}, +# "needs": ["auth.ssh_key"], +# }, + +# "auth.ssh_key": { +# "desc": "ssh_key", +# "input": { +# "ssh_key_file": None, +# "ssh_key_secret": None +# }, +# "needs": [ +# {"kind": "auth.password", "remap": {"ssh_key_secret": "passord"}} +# ], +# }, + +# "account.ssh": {"desc": "An unix account", "input": {"host": None}}, + + +# }, + +# "resources": { + +# "service.local.ssh_agent": { +# "enabled": True, +# # "contains": [ +# # "auth.ssh_key:{ident}/ed25519", +# # "auth.ssh_key:{ident}/rsa4096", +# # "auth.ssh_key:{ident}/rsa2048", +# # "auth.ssh_key:{ident}/rsa1024", +# # "auth.ssh_key:{ident}", +# # ], +# }, + +# "service.local.ssh_agent_keys": { +# "enabled": True, +# "loop_limit": 3, +# "loop": [ +# "auth.ssh_key:{ident}/ed25519", +# "auth.ssh_key:{ident}/rsa4096", +# "auth.ssh_key:{ident}/rsa2048", +# "auth.ssh_key:{ident}/rsa1024", +# "auth.ssh_key:{ident}", +# ], +# }, + +# }, + +# } + + +# plugin_ssh_agent = { +# "services": { + +# "local.ssh_agent": { +# "desc": "Local ssh-agent", +# "input": { +# "ssh_agent_socket_dir": "/run/user/ssh-agent", +# "ssh_agent_tmout": "7d", +# }, +# "commands": { +# "shell_enable": { +# "cmd": """ +# export SSH_AUTH_SOCK={{ssh_agent_socket_dir}}/{{user}} && \ +# ssh-agent -a $SSH_AUTH_SOCK -t {{ssh_agent_tmout}} +# """, +# }, +# "shell_disable": { +# "cmd": "ssh-agent -k && unset SSH_AUTH_SOCK", +# }, +# }, +# }, + +# "local.ssh_agent_keys": { +# "desc": "Local ssh-agent keys", +# "required_services": [ +# "local.ssh_agent" +# ], +# "commands": { +# "shell_enable": { +# "cmd": """ +# ssh-add {% for item in loop %} {{item.ssh_key_file}} {% endfor %} +# """, +# }, +# "shell_disable": { +# "cmd": "ssh-agent -d {ssh_key_file}", +# }, +# }, +# }, + +# }, + +# "resources_def": { +# "service.local.ssh_agent": { +# "desc": "A local ssh_agent service", +# }, +# "service.local.ssh_agent_keys": { +# "desc": "A local ssh_agent_keys service", +# }, +# }, + +# "resources": { + +# "service.local.ssh_agent": { +# "enabled": True, +# # "contains": [ +# # "auth.ssh_key:{ident}/ed25519", +# # "auth.ssh_key:{ident}/rsa4096", +# # "auth.ssh_key:{ident}/rsa2048", +# # "auth.ssh_key:{ident}/rsa1024", +# # "auth.ssh_key:{ident}", +# # ], +# }, + +# "service.local.ssh_agent_keys": { +# "enabled": True, +# "loop_limit": 3, +# "loop": [ +# "auth.ssh_key:{ident}/ed25519", +# "auth.ssh_key:{ident}/rsa4096", +# "auth.ssh_key:{ident}/rsa2048", +# "auth.ssh_key:{ident}/rsa1024", +# "auth.ssh_key:{ident}", +# ], +# }, + +# }, +# } + + +# all = { +# "ssh": plugin_ssh, +# "ssh_agent": plugin_ssh_agent, +# } + + +# print (to_yaml(all)) diff --git a/iam/plugins/local.yml b/iam/plugins/local.yml new file mode 100644 index 0000000..4c619f2 --- /dev/null +++ b/iam/plugins/local.yml @@ -0,0 +1,322 @@ + +providers: + + # Provider: SSH + # ================== + ssh: + + services: + local.ssh_key: + desc: Local ssh key + inputs: + ssh_key_secret: "" + ssh_key_alg: "ed25519" + + commands: + + new: + desc: Create new SSH key + cmd: | + SSH_KEY_ALG={{ssh_key_alg}} + + SSH_KEY_VERSION="$(date +'%Y%m%d')" + SSH_KEY_HOST="$(hostname -f)" + + SSH_KEY_FILE=$HOME/.ssh/{ident}/{user}_${SSH_KEY_ALG}_${SSH_KEY_VERSION} + SSH_KEY_COMMENT={user}@${SSH_KEY_HOST}:${SSH_KEY_ALG}_${SSH_KEY_VERSION} + + ssh-keygen -f "{SSH_KEY_FILE}" \ + -t ed25519 -a 100 \ + -N "{{ssh_key_secret}}" \ + -C "$SSH_KEY_COMMENT" + + delete: + desc: Delete existing SSH key + cmd: | + find $HOME/.ssh/{ident}/ -name "{user}_*" + + + + + resources_def: + + + auth.ssh_certificate: + desc: SSH Certificates + input: + ssh_cert_file: null + needs: + - auth.ssh_key + + auth.ssh_key: + desc: SSH Keypair + input: + ssh_key_file: null + ssh_key_secret: null + needs: + - kind: auth.password + remap: + ssh_key_secret: passord + + account.ssh: + desc: Unix account + input: + host: null + + + # service.local.ssh_key: + # desc: A local ssh key + + + + # resources: + + # service.local.ssh_agent: + # enabled: true + + # service.local.ssh_agent_keys: + # enabled: true + # loop: + # - auth.ssh_key:{ident}/ed25519 + # - auth.ssh_key:{ident}/rsa4096 + # - auth.ssh_key:{ident}/rsa2048 + # - auth.ssh_key:{ident}/rsa1024 + # - auth.ssh_key:{ident} + # loop_limit: 3 + + + + + # Provider: GPG Agent + # ================== + gpg_agent: + + + resources_def: + + auth.gpg_key: + desc: GPG keypair + input: + gpg_key_file: null + gpg_key_secret: null + needs: + - kind: auth.password + remap: + gpg_key_secret: passord + + + # Provider: SSH Agent + # ================== + ssh_agent: + + services: + + local.ssh_agent: + desc: Local ssh-agent + input: + ssh_agent_socket_dir: /run/user/ssh-agent + ssh_agent_tmout: 7d + + commands: + + shell_enable: + desc: Enable ssh-agent + cmd: | + export SSH_AUTH_SOCK={{ssh_agent_socket_dir}}/{{user}} + ssh-agent -a $SSH_AUTH_SOCK -t {{ssh_agent_tmout}} + # SSH_AGENT_PID= ??? + + shell_disable: + desc: Disable ssh-agent + cmd: ssh-agent -k && unset SSH_AUTH_SOCK + + + + local.ssh_agent_keys: + desc: Local ssh-agent keys + + commands: + enable: + desc: Unload keys into ssh-agent + cmd: ssh-agent -d {ssh_key_file} + + disable: + desc: Load keys into ssh-agent + cmd: | + ssh-add {% for item in loop %} {{item.ssh_key_file}} {% endfor %} + + + required_services: + - local.ssh_agent + + + resources_def: + + service.local.ssh_agent: + desc: Configure ssh-agent daemon + + service.local.ssh_agent_keys: + desc: Configure ssh-agent keys autoloader + + + resources: + + service.local.ssh_agent: + enabled: true + + service.local.ssh_agent_keys: + enabled: true + loop: + - auth.ssh_key:{ident}/ed25519 + - auth.ssh_key:{ident}/rsa4096 + - auth.ssh_key:{ident}/rsa2048 + - auth.ssh_key:{ident}/rsa1024 + - auth.ssh_key:{ident} + loop_limit: 3 + + + + + + # Provider: Git Config + # ================== + git: + + services: + + local.git: + desc: Git identity + # input: + # ssh_agent_socket_dir: /run/user/ssh-agent + # ssh_agent_tmout: 7d + + commands: + + shell_enable: + desc: Enable git identity + cmd: | + export GIT_AUTHOR_NAME='{{ident}}' + export GIT_AUTHOR_EMAIL='{{email}}' + export GIT_COMMITTER_NAME='{{ident}}' + export GIT_COMMITTER_EMAIL='{{email}}' + + + shell_disable: + desc: Disable git identity + cmd: | + unset GIT_AUTHOR_NAME GIT_AUTHOR_EMAIL GIT_COMMITTER_NAME GIT_COMMITTER_EMAIL + + + local.git_home: + desc: Home as git repo + input: + git_dir: "$HOME" + git_work_tree: $HOME/.local/share/home_git + + commands: + + shell_enable: + desc: Enable git home management + cmd: | + export GIT_DIR="{{git_dir}}" + export GIT_WORK_TREE="{{git_work_tree}}/{{ ident }}" + + shell_disable: + desc: Disable git home management + cmd: | + unset GIT_DIR GIT_WORK_TREE + + required_services: + - local.git + + resources_def: + + service.local.git: + desc: Configure git + + service.local.git_home: + desc: Configure home as git repo + + resources: + + service.local.git: + enabled: true + uses: + - account:{user} + + # Disabled by default + service.local.git_home: + + + + # Provider: PS1 Config + # ================== + ps1: + + services: + + local.ps1: + desc: PS1 prompt + input: + enabled: True + + commands: + + shell_enable: + desc: Enable git identity + cmd: | + OLD_PS1=$PS1 + export PS1="\[\033[0;34m\]({{ident}})\[\033[00m\] ${PS1}" + + shell_disable: + desc: Disable git identity + cmd: | + export PS1=$OLD_PS1 + + + + + resources_def: + service.local.ps1: + desc: PS1 prompt + + + resources: + + service.local.ps1: + desc: Custom Ident PS1 + + + +# EXISTING + +# WARN__: Your workspace is already activated +# NOTICE: Enabling id ... +# export SHELL_ID='mrjk' +# export GIT_AUTHOR_NAME='mrjk' +# export GIT_AUTHOR_EMAIL='mrjk.78@gmail.com' +# export GIT_COMMITTER_NAME='mrjk' +# export GIT_COMMITTER_EMAIL='mrjk.78@gmail.com' + +# NOTICE: Enabling gpg ... +# export GNUPGHOME=/home/jez/.config/gpg/mrjk +# export GPG_AGENT_INFO=/run/user/1000/pgp-agent/mrjk/socket +# export GPG_DEFAULT_ID=mrjk +# export GPG_TTY=/dev/pts/48 +# export GNUPGHOME=/home/jez/.config/gpg/mrjk + +# NOTICE: Enabling ssh ... +# export SSH_AUTH_SOCK=/run/user/1000/ssh-agent/mrjk/socket + +# NOTICE: Enabling gh ... +# export GH_TOKEN="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" +# export GH_REPO="mrjk" + +# NOTICE: Enabling gitea ... +# export GITEA_SERVER_URL="ad808bc88fa37bce5e3bb963f1420aa575194d30" +# export GITEA_LOGIN="mrjk@git.jeznet.org" + +# NOTICE: Enabling ps1 ... +# export PS1="\[\](mrjk)\[\] ${IDM_SHELL_PS1}" + +# NOTICE: Identity 'mrjk' is loaded diff --git a/iam/providers.py b/iam/providers.py new file mode 100644 index 0000000..e3e2c58 --- /dev/null +++ b/iam/providers.py @@ -0,0 +1,118 @@ +import logging +from pprint import pprint + +# from .config import ResourceKind +from . import exceptions as error +from .framework import DictCtrl, DictItem, KeyValue, KeyValueExtra + +logger = logging.getLogger(__name__) + +# pprint (logger.__dict__) + + +class Provider(DictItem): + default_attrs = { + "resources_def": {}, + "resources": {}, + "services": {}, + } + + def init(self): + payload = self._payload + + # Create resources Defs + # res_def = payload.get("resources_def", {}) # {} + + # for key, val in payload.get("resources_def", {}).items(): + # res_def[key] = ResourceKind(key, val) + + # self.resources_def = res_def + self.resources_kind = payload.get("resources_def", {}) + + # Create resources instances + res_inst_configs = {} + for key, val in payload.get("resources", {}).items(): + res_inst_configs[key] = val + + self.resources_inst = res_inst_configs + self.resources_conf = res_inst_configs + + self.services_confs = payload.get("services", {}) + + +class Providers(DictCtrl): + items_class = Provider + + def get_resource_kinds(self): + "Returns resources kinds for all providers" + + ret = {} + for provider in self.values(): + res = provider.resources_kind + for name, config in res.items(): + ret[name] = config or {} + + return ret + + def get_resource_configs(self): + "Returns resources configurations for all providers" + + ret = {} + for provider in self.values(): + res = provider.resources_conf + for name, config in res.items(): + ret[name] = config or {} + + return ret + + def get_services_configs(self): + ret = {} + for prov_name, provider in self.items(): + ret.update(provider.services_confs) + return ret + + # def get_catalog_resources_inst(self): + + # ret = {} + # for provider in self.providers.values(): + # # res = provider.resources_def + # for name, config in provider.resources_inst.items(): + # ret[name] = config + + # return ret + + +######## BETA + + +def expand_inputs(payload): + "Expand dict for each of its list value" + + ret = [] + expanded_keys = [key for key, val in payload.items() if isinstance(val, list)] + return + + for exp_key in expanded_keys: + items = len(payload[exp_key]) + + for idx in range(0, items): + tmp = copy(payload) + for subkey in expanded_keys: + tmp[subkey] = payload[subkey][idx] + ret.append(tmp) + + # V1 + # for idx, exp_key in enumerate(expanded_keys): + # tmp = copy(payload) + # tmp[exp_key] = payload[exp_key][idx] + # ret.append(tmp) + + # for key, val in payload.items(): + # if isinstance(val, list): + # # Expand + # pass + + print("expanded_keys", expanded_keys) + # pprint (ret) + + return ret diff --git a/poetry.lock b/poetry.lock new file mode 100644 index 0000000..08c17c1 --- /dev/null +++ b/poetry.lock @@ -0,0 +1,629 @@ +# This file is automatically @generated by Poetry 1.6.0 and should not be changed by hand. + +[[package]] +name = "attrs" +version = "23.1.0" +description = "Classes Without Boilerplate" +optional = false +python-versions = ">=3.7" +files = [ + {file = "attrs-23.1.0-py3-none-any.whl", hash = "sha256:1f28b4522cdc2fb4256ac1a020c78acf9cba2c6b461ccd2c126f3aa8e8335d04"}, + {file = "attrs-23.1.0.tar.gz", hash = "sha256:6279836d581513a26f1bf235f9acd333bc9115683f14f7e8fae46c98fc50e015"}, +] + +[package.extras] +cov = ["attrs[tests]", "coverage[toml] (>=5.3)"] +dev = ["attrs[docs,tests]", "pre-commit"] +docs = ["furo", "myst-parser", "sphinx", "sphinx-notfound-page", "sphinxcontrib-towncrier", "towncrier", "zope-interface"] +tests = ["attrs[tests-no-zope]", "zope-interface"] +tests-no-zope = ["cloudpickle", "hypothesis", "mypy (>=1.1.1)", "pympler", "pytest (>=4.3.0)", "pytest-mypy-plugins", "pytest-xdist[psutil]"] + +[[package]] +name = "autopage" +version = "0.5.1" +description = "A library to provide automatic paging for console output" +optional = false +python-versions = ">=3.6" +files = [ + {file = "autopage-0.5.1-py3-none-any.whl", hash = "sha256:0fbf5efbe78d466a26753e1dee3272423a3adc989d6a778c700e89a3f8ff0d88"}, + {file = "autopage-0.5.1.tar.gz", hash = "sha256:01be3ee61bb714e9090fcc5c10f4cf546c396331c620c6ae50a2321b28ed3199"}, +] + +[[package]] +name = "black" +version = "23.9.1" +description = "The uncompromising code formatter." +optional = false +python-versions = ">=3.8" +files = [ + {file = "black-23.9.1-cp310-cp310-macosx_10_16_arm64.whl", hash = "sha256:d6bc09188020c9ac2555a498949401ab35bb6bf76d4e0f8ee251694664df6301"}, + {file = "black-23.9.1-cp310-cp310-macosx_10_16_universal2.whl", hash = "sha256:13ef033794029b85dfea8032c9d3b92b42b526f1ff4bf13b2182ce4e917f5100"}, + {file = "black-23.9.1-cp310-cp310-macosx_10_16_x86_64.whl", hash = "sha256:75a2dc41b183d4872d3a500d2b9c9016e67ed95738a3624f4751a0cb4818fe71"}, + {file = "black-23.9.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:13a2e4a93bb8ca74a749b6974925c27219bb3df4d42fc45e948a5d9feb5122b7"}, + {file = "black-23.9.1-cp310-cp310-win_amd64.whl", hash = "sha256:adc3e4442eef57f99b5590b245a328aad19c99552e0bdc7f0b04db6656debd80"}, + {file = "black-23.9.1-cp311-cp311-macosx_10_16_arm64.whl", hash = "sha256:8431445bf62d2a914b541da7ab3e2b4f3bc052d2ccbf157ebad18ea126efb91f"}, + {file = "black-23.9.1-cp311-cp311-macosx_10_16_universal2.whl", hash = "sha256:8fc1ddcf83f996247505db6b715294eba56ea9372e107fd54963c7553f2b6dfe"}, + {file = "black-23.9.1-cp311-cp311-macosx_10_16_x86_64.whl", hash = "sha256:7d30ec46de88091e4316b17ae58bbbfc12b2de05e069030f6b747dfc649ad186"}, + {file = "black-23.9.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:031e8c69f3d3b09e1aa471a926a1eeb0b9071f80b17689a655f7885ac9325a6f"}, + {file = "black-23.9.1-cp311-cp311-win_amd64.whl", hash = "sha256:538efb451cd50f43aba394e9ec7ad55a37598faae3348d723b59ea8e91616300"}, + {file = "black-23.9.1-cp38-cp38-macosx_10_16_arm64.whl", hash = "sha256:638619a559280de0c2aa4d76f504891c9860bb8fa214267358f0a20f27c12948"}, + {file = "black-23.9.1-cp38-cp38-macosx_10_16_universal2.whl", hash = "sha256:a732b82747235e0542c03bf352c126052c0fbc458d8a239a94701175b17d4855"}, + {file = "black-23.9.1-cp38-cp38-macosx_10_16_x86_64.whl", hash = "sha256:cf3a4d00e4cdb6734b64bf23cd4341421e8953615cba6b3670453737a72ec204"}, + {file = "black-23.9.1-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:cf99f3de8b3273a8317681d8194ea222f10e0133a24a7548c73ce44ea1679377"}, + {file = "black-23.9.1-cp38-cp38-win_amd64.whl", hash = "sha256:14f04c990259576acd093871e7e9b14918eb28f1866f91968ff5524293f9c573"}, + {file = "black-23.9.1-cp39-cp39-macosx_10_16_arm64.whl", hash = "sha256:c619f063c2d68f19b2d7270f4cf3192cb81c9ec5bc5ba02df91471d0b88c4c5c"}, + {file = "black-23.9.1-cp39-cp39-macosx_10_16_universal2.whl", hash = "sha256:6a3b50e4b93f43b34a9d3ef00d9b6728b4a722c997c99ab09102fd5efdb88325"}, + {file = "black-23.9.1-cp39-cp39-macosx_10_16_x86_64.whl", hash = "sha256:c46767e8df1b7beefb0899c4a95fb43058fa8500b6db144f4ff3ca38eb2f6393"}, + {file = "black-23.9.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:50254ebfa56aa46a9fdd5d651f9637485068a1adf42270148cd101cdf56e0ad9"}, + {file = "black-23.9.1-cp39-cp39-win_amd64.whl", hash = "sha256:403397c033adbc45c2bd41747da1f7fc7eaa44efbee256b53842470d4ac5a70f"}, + {file = "black-23.9.1-py3-none-any.whl", hash = "sha256:6ccd59584cc834b6d127628713e4b6b968e5f79572da66284532525a042549f9"}, + {file = "black-23.9.1.tar.gz", hash = "sha256:24b6b3ff5c6d9ea08a8888f6977eae858e1f340d7260cf56d70a49823236b62d"}, +] + +[package.dependencies] +click = ">=8.0.0" +mypy-extensions = ">=0.4.3" +packaging = ">=22.0" +pathspec = ">=0.9.0" +platformdirs = ">=2" + +[package.extras] +colorama = ["colorama (>=0.4.3)"] +d = ["aiohttp (>=3.7.4)"] +jupyter = ["ipython (>=7.8.0)", "tokenize-rt (>=3.2.0)"] +uvloop = ["uvloop (>=0.15.2)"] + +[[package]] +name = "click" +version = "8.1.7" +description = "Composable command line interface toolkit" +optional = false +python-versions = ">=3.7" +files = [ + {file = "click-8.1.7-py3-none-any.whl", hash = "sha256:ae74fb96c20a0277a1d615f1e4d73c8414f5a98db8b799a7931d1582f3390c28"}, + {file = "click-8.1.7.tar.gz", hash = "sha256:ca9853ad459e787e2192211578cc907e7594e294c7ccc834310722b41b9ca6de"}, +] + +[package.dependencies] +colorama = {version = "*", markers = "platform_system == \"Windows\""} + +[[package]] +name = "cliff" +version = "4.3.0" +description = "Command Line Interface Formulation Framework" +optional = false +python-versions = ">=3.8" +files = [ + {file = "cliff-4.3.0-py3-none-any.whl", hash = "sha256:db3dc8774f47db9aa86796921ff158d0f023630261c2746c4fff12584b75f5b2"}, + {file = "cliff-4.3.0.tar.gz", hash = "sha256:fc5b6ebc8fb815332770b2485ee36c09753937c37cce4f3227cdd4e10b33eacc"}, +] + +[package.dependencies] +autopage = ">=0.4.0" +cmd2 = ">=1.0.0" +importlib-metadata = ">=4.4" +PrettyTable = ">=0.7.2" +PyYAML = ">=3.12" +stevedore = ">=2.0.1" + +[[package]] +name = "cmd2" +version = "2.4.3" +description = "cmd2 - quickly build feature-rich and user-friendly interactive command line applications in Python" +optional = false +python-versions = ">=3.6" +files = [ + {file = "cmd2-2.4.3-py3-none-any.whl", hash = "sha256:f1988ff2fff0ed812a2d25218a081b0fa0108d45b48ba2a9272bb98091b654e6"}, + {file = "cmd2-2.4.3.tar.gz", hash = "sha256:71873c11f72bd19e2b1db578214716f0d4f7c8fa250093c601265a9a717dee52"}, +] + +[package.dependencies] +attrs = ">=16.3.0" +pyperclip = ">=1.6" +pyreadline3 = {version = "*", markers = "sys_platform == \"win32\""} +wcwidth = ">=0.1.7" + +[package.extras] +dev = ["codecov", "doc8", "flake8", "invoke", "mypy", "nox", "pytest (>=4.6)", "pytest-cov", "pytest-mock", "sphinx", "sphinx-autobuild", "sphinx-rtd-theme", "twine (>=1.11)"] +test = ["codecov", "coverage", "gnureadline", "pytest (>=4.6)", "pytest-cov", "pytest-mock"] +validate = ["flake8", "mypy", "types-pkg-resources"] + +[[package]] +name = "colorama" +version = "0.4.6" +description = "Cross-platform colored terminal text." +optional = false +python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7" +files = [ + {file = "colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6"}, + {file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"}, +] + +[[package]] +name = "coloredlogs" +version = "15.0.1" +description = "Colored terminal output for Python's logging module" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" +files = [ + {file = "coloredlogs-15.0.1-py2.py3-none-any.whl", hash = "sha256:612ee75c546f53e92e70049c9dbfcc18c935a2b9a53b66085ce9ef6a6e5c0934"}, + {file = "coloredlogs-15.0.1.tar.gz", hash = "sha256:7c991aa71a4577af2f82600d8f8f3a89f936baeaf9b50a9c197da014e5bf16b0"}, +] + +[package.dependencies] +humanfriendly = ">=9.1" + +[package.extras] +cron = ["capturer (>=2.4)"] + +[[package]] +name = "humanfriendly" +version = "10.0" +description = "Human friendly output for text interfaces using Python" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" +files = [ + {file = "humanfriendly-10.0-py2.py3-none-any.whl", hash = "sha256:1697e1a8a8f550fd43c2865cd84542fc175a61dcb779b6fee18cf6b6ccba1477"}, + {file = "humanfriendly-10.0.tar.gz", hash = "sha256:6b0b831ce8f15f7300721aa49829fc4e83921a9a301cc7f606be6686a2288ddc"}, +] + +[package.dependencies] +pyreadline3 = {version = "*", markers = "sys_platform == \"win32\" and python_version >= \"3.8\""} + +[[package]] +name = "importlib-metadata" +version = "6.8.0" +description = "Read metadata from Python packages" +optional = false +python-versions = ">=3.8" +files = [ + {file = "importlib_metadata-6.8.0-py3-none-any.whl", hash = "sha256:3ebb78df84a805d7698245025b975d9d67053cd94c79245ba4b3eb694abe68bb"}, + {file = "importlib_metadata-6.8.0.tar.gz", hash = "sha256:dbace7892d8c0c4ac1ad096662232f831d4e64f4c4545bd53016a3e9d4654743"}, +] + +[package.dependencies] +zipp = ">=0.5" + +[package.extras] +docs = ["furo", "jaraco.packaging (>=9)", "jaraco.tidelift (>=1.4)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-lint"] +perf = ["ipython"] +testing = ["flufl.flake8", "importlib-resources (>=1.3)", "packaging", "pyfakefs", "pytest (>=6)", "pytest-black (>=0.3.7)", "pytest-checkdocs (>=2.4)", "pytest-cov", "pytest-enabler (>=2.2)", "pytest-mypy (>=0.9.1)", "pytest-perf (>=0.9.2)", "pytest-ruff"] + +[[package]] +name = "isort" +version = "5.12.0" +description = "A Python utility / library to sort Python imports." +optional = false +python-versions = ">=3.8.0" +files = [ + {file = "isort-5.12.0-py3-none-any.whl", hash = "sha256:f84c2818376e66cf843d497486ea8fed8700b340f308f076c6fb1229dff318b6"}, + {file = "isort-5.12.0.tar.gz", hash = "sha256:8bef7dde241278824a6d83f44a544709b065191b95b6e50894bdc722fcba0504"}, +] + +[package.extras] +colors = ["colorama (>=0.4.3)"] +pipfile-deprecated-finder = ["pip-shims (>=0.5.2)", "pipreqs", "requirementslib"] +plugins = ["setuptools"] +requirements-deprecated-finder = ["pip-api", "pipreqs"] + +[[package]] +name = "jinja2" +version = "3.1.2" +description = "A very fast and expressive template engine." +optional = false +python-versions = ">=3.7" +files = [ + {file = "Jinja2-3.1.2-py3-none-any.whl", hash = "sha256:6088930bfe239f0e6710546ab9c19c9ef35e29792895fed6e6e31a023a182a61"}, + {file = "Jinja2-3.1.2.tar.gz", hash = "sha256:31351a702a408a9e7595a8fc6150fc3f43bb6bf7e319770cbc0db9df9437e852"}, +] + +[package.dependencies] +MarkupSafe = ">=2.0" + +[package.extras] +i18n = ["Babel (>=2.7)"] + +[[package]] +name = "markdown-it-py" +version = "3.0.0" +description = "Python port of markdown-it. Markdown parsing, done right!" +optional = false +python-versions = ">=3.8" +files = [ + {file = "markdown-it-py-3.0.0.tar.gz", hash = "sha256:e3f60a94fa066dc52ec76661e37c851cb232d92f9886b15cb560aaada2df8feb"}, + {file = "markdown_it_py-3.0.0-py3-none-any.whl", hash = "sha256:355216845c60bd96232cd8d8c40e8f9765cc86f46880e43a8fd22dc1a1a8cab1"}, +] + +[package.dependencies] +mdurl = ">=0.1,<1.0" + +[package.extras] +benchmarking = ["psutil", "pytest", "pytest-benchmark"] +code-style = ["pre-commit (>=3.0,<4.0)"] +compare = ["commonmark (>=0.9,<1.0)", "markdown (>=3.4,<4.0)", "mistletoe (>=1.0,<2.0)", "mistune (>=2.0,<3.0)", "panflute (>=2.3,<3.0)"] +linkify = ["linkify-it-py (>=1,<3)"] +plugins = ["mdit-py-plugins"] +profiling = ["gprof2dot"] +rtd = ["jupyter_sphinx", "mdit-py-plugins", "myst-parser", "pyyaml", "sphinx", "sphinx-copybutton", "sphinx-design", "sphinx_book_theme"] +testing = ["coverage", "pytest", "pytest-cov", "pytest-regressions"] + +[[package]] +name = "markupsafe" +version = "2.1.3" +description = "Safely add untrusted strings to HTML/XML markup." +optional = false +python-versions = ">=3.7" +files = [ + {file = "MarkupSafe-2.1.3-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:cd0f502fe016460680cd20aaa5a76d241d6f35a1c3350c474bac1273803893fa"}, + {file = "MarkupSafe-2.1.3-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:e09031c87a1e51556fdcb46e5bd4f59dfb743061cf93c4d6831bf894f125eb57"}, + {file = "MarkupSafe-2.1.3-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:68e78619a61ecf91e76aa3e6e8e33fc4894a2bebe93410754bd28fce0a8a4f9f"}, + {file = "MarkupSafe-2.1.3-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:65c1a9bcdadc6c28eecee2c119465aebff8f7a584dd719facdd9e825ec61ab52"}, + {file = "MarkupSafe-2.1.3-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:525808b8019e36eb524b8c68acdd63a37e75714eac50e988180b169d64480a00"}, + {file = "MarkupSafe-2.1.3-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:962f82a3086483f5e5f64dbad880d31038b698494799b097bc59c2edf392fce6"}, + {file = "MarkupSafe-2.1.3-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:aa7bd130efab1c280bed0f45501b7c8795f9fdbeb02e965371bbef3523627779"}, + {file = "MarkupSafe-2.1.3-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:c9c804664ebe8f83a211cace637506669e7890fec1b4195b505c214e50dd4eb7"}, + {file = "MarkupSafe-2.1.3-cp310-cp310-win32.whl", hash = "sha256:10bbfe99883db80bdbaff2dcf681dfc6533a614f700da1287707e8a5d78a8431"}, + {file = "MarkupSafe-2.1.3-cp310-cp310-win_amd64.whl", hash = "sha256:1577735524cdad32f9f694208aa75e422adba74f1baee7551620e43a3141f559"}, + {file = "MarkupSafe-2.1.3-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:ad9e82fb8f09ade1c3e1b996a6337afac2b8b9e365f926f5a61aacc71adc5b3c"}, + {file = "MarkupSafe-2.1.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:3c0fae6c3be832a0a0473ac912810b2877c8cb9d76ca48de1ed31e1c68386575"}, + {file = "MarkupSafe-2.1.3-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b076b6226fb84157e3f7c971a47ff3a679d837cf338547532ab866c57930dbee"}, + {file = "MarkupSafe-2.1.3-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bfce63a9e7834b12b87c64d6b155fdd9b3b96191b6bd334bf37db7ff1fe457f2"}, + {file = "MarkupSafe-2.1.3-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:338ae27d6b8745585f87218a3f23f1512dbf52c26c28e322dbe54bcede54ccb9"}, + {file = "MarkupSafe-2.1.3-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:e4dd52d80b8c83fdce44e12478ad2e85c64ea965e75d66dbeafb0a3e77308fcc"}, + {file = "MarkupSafe-2.1.3-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:df0be2b576a7abbf737b1575f048c23fb1d769f267ec4358296f31c2479db8f9"}, + {file = "MarkupSafe-2.1.3-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:5bbe06f8eeafd38e5d0a4894ffec89378b6c6a625ff57e3028921f8ff59318ac"}, + {file = "MarkupSafe-2.1.3-cp311-cp311-win32.whl", hash = "sha256:dd15ff04ffd7e05ffcb7fe79f1b98041b8ea30ae9234aed2a9168b5797c3effb"}, + {file = "MarkupSafe-2.1.3-cp311-cp311-win_amd64.whl", hash = "sha256:134da1eca9ec0ae528110ccc9e48041e0828d79f24121a1a146161103c76e686"}, + {file = "MarkupSafe-2.1.3-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:8e254ae696c88d98da6555f5ace2279cf7cd5b3f52be2b5cf97feafe883b58d2"}, + {file = "MarkupSafe-2.1.3-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:cb0932dc158471523c9637e807d9bfb93e06a95cbf010f1a38b98623b929ef2b"}, + {file = "MarkupSafe-2.1.3-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9402b03f1a1b4dc4c19845e5c749e3ab82d5078d16a2a4c2cd2df62d57bb0707"}, + {file = "MarkupSafe-2.1.3-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ca379055a47383d02a5400cb0d110cef0a776fc644cda797db0c5696cfd7e18e"}, + {file = "MarkupSafe-2.1.3-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:b7ff0f54cb4ff66dd38bebd335a38e2c22c41a8ee45aa608efc890ac3e3931bc"}, + {file = "MarkupSafe-2.1.3-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:c011a4149cfbcf9f03994ec2edffcb8b1dc2d2aede7ca243746df97a5d41ce48"}, + {file = "MarkupSafe-2.1.3-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:56d9f2ecac662ca1611d183feb03a3fa4406469dafe241673d521dd5ae92a155"}, + {file = "MarkupSafe-2.1.3-cp37-cp37m-win32.whl", hash = "sha256:8758846a7e80910096950b67071243da3e5a20ed2546e6392603c096778d48e0"}, + {file = "MarkupSafe-2.1.3-cp37-cp37m-win_amd64.whl", hash = "sha256:787003c0ddb00500e49a10f2844fac87aa6ce977b90b0feaaf9de23c22508b24"}, + {file = "MarkupSafe-2.1.3-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:2ef12179d3a291be237280175b542c07a36e7f60718296278d8593d21ca937d4"}, + {file = "MarkupSafe-2.1.3-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:2c1b19b3aaacc6e57b7e25710ff571c24d6c3613a45e905b1fde04d691b98ee0"}, + {file = "MarkupSafe-2.1.3-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8afafd99945ead6e075b973fefa56379c5b5c53fd8937dad92c662da5d8fd5ee"}, + {file = "MarkupSafe-2.1.3-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8c41976a29d078bb235fea9b2ecd3da465df42a562910f9022f1a03107bd02be"}, + {file = "MarkupSafe-2.1.3-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d080e0a5eb2529460b30190fcfcc4199bd7f827663f858a226a81bc27beaa97e"}, + {file = "MarkupSafe-2.1.3-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:69c0f17e9f5a7afdf2cc9fb2d1ce6aabdb3bafb7f38017c0b77862bcec2bbad8"}, + {file = "MarkupSafe-2.1.3-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:504b320cd4b7eff6f968eddf81127112db685e81f7e36e75f9f84f0df46041c3"}, + {file = "MarkupSafe-2.1.3-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:42de32b22b6b804f42c5d98be4f7e5e977ecdd9ee9b660fda1a3edf03b11792d"}, + {file = "MarkupSafe-2.1.3-cp38-cp38-win32.whl", hash = "sha256:ceb01949af7121f9fc39f7d27f91be8546f3fb112c608bc4029aef0bab86a2a5"}, + {file = "MarkupSafe-2.1.3-cp38-cp38-win_amd64.whl", hash = "sha256:1b40069d487e7edb2676d3fbdb2b0829ffa2cd63a2ec26c4938b2d34391b4ecc"}, + {file = "MarkupSafe-2.1.3-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:8023faf4e01efadfa183e863fefde0046de576c6f14659e8782065bcece22198"}, + {file = "MarkupSafe-2.1.3-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:6b2b56950d93e41f33b4223ead100ea0fe11f8e6ee5f641eb753ce4b77a7042b"}, + {file = "MarkupSafe-2.1.3-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9dcdfd0eaf283af041973bff14a2e143b8bd64e069f4c383416ecd79a81aab58"}, + {file = "MarkupSafe-2.1.3-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:05fb21170423db021895e1ea1e1f3ab3adb85d1c2333cbc2310f2a26bc77272e"}, + {file = "MarkupSafe-2.1.3-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:282c2cb35b5b673bbcadb33a585408104df04f14b2d9b01d4c345a3b92861c2c"}, + {file = "MarkupSafe-2.1.3-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:ab4a0df41e7c16a1392727727e7998a467472d0ad65f3ad5e6e765015df08636"}, + {file = "MarkupSafe-2.1.3-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:7ef3cb2ebbf91e330e3bb937efada0edd9003683db6b57bb108c4001f37a02ea"}, + {file = "MarkupSafe-2.1.3-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:0a4e4a1aff6c7ac4cd55792abf96c915634c2b97e3cc1c7129578aa68ebd754e"}, + {file = "MarkupSafe-2.1.3-cp39-cp39-win32.whl", hash = "sha256:fec21693218efe39aa7f8599346e90c705afa52c5b31ae019b2e57e8f6542bb2"}, + {file = "MarkupSafe-2.1.3-cp39-cp39-win_amd64.whl", hash = "sha256:3fd4abcb888d15a94f32b75d8fd18ee162ca0c064f35b11134be77050296d6ba"}, + {file = "MarkupSafe-2.1.3.tar.gz", hash = "sha256:af598ed32d6ae86f1b747b82783958b1a4ab8f617b06fe68795c7f026abbdcad"}, +] + +[[package]] +name = "mdurl" +version = "0.1.2" +description = "Markdown URL utilities" +optional = false +python-versions = ">=3.7" +files = [ + {file = "mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8"}, + {file = "mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba"}, +] + +[[package]] +name = "mypy-extensions" +version = "1.0.0" +description = "Type system extensions for programs checked with the mypy type checker." +optional = false +python-versions = ">=3.5" +files = [ + {file = "mypy_extensions-1.0.0-py3-none-any.whl", hash = "sha256:4392f6c0eb8a5668a69e23d168ffa70f0be9ccfd32b5cc2d26a34ae5b844552d"}, + {file = "mypy_extensions-1.0.0.tar.gz", hash = "sha256:75dbf8955dc00442a438fc4d0666508a9a97b6bd41aa2f0ffe9d2f2725af0782"}, +] + +[[package]] +name = "packaging" +version = "23.2" +description = "Core utilities for Python packages" +optional = false +python-versions = ">=3.7" +files = [ + {file = "packaging-23.2-py3-none-any.whl", hash = "sha256:8c491190033a9af7e1d931d0b5dacc2ef47509b34dd0de67ed209b5203fc88c7"}, + {file = "packaging-23.2.tar.gz", hash = "sha256:048fb0e9405036518eaaf48a55953c750c11e1a1b68e0dd1a9d62ed0c092cfc5"}, +] + +[[package]] +name = "pathspec" +version = "0.11.2" +description = "Utility library for gitignore style pattern matching of file paths." +optional = false +python-versions = ">=3.7" +files = [ + {file = "pathspec-0.11.2-py3-none-any.whl", hash = "sha256:1d6ed233af05e679efb96b1851550ea95bbb64b7c490b0f5aa52996c11e92a20"}, + {file = "pathspec-0.11.2.tar.gz", hash = "sha256:e0d8d0ac2f12da61956eb2306b69f9469b42f4deb0f3cb6ed47b9cce9996ced3"}, +] + +[[package]] +name = "pbr" +version = "5.11.1" +description = "Python Build Reasonableness" +optional = false +python-versions = ">=2.6" +files = [ + {file = "pbr-5.11.1-py2.py3-none-any.whl", hash = "sha256:567f09558bae2b3ab53cb3c1e2e33e726ff3338e7bae3db5dc954b3a44eef12b"}, + {file = "pbr-5.11.1.tar.gz", hash = "sha256:aefc51675b0b533d56bb5fd1c8c6c0522fe31896679882e1c4c63d5e4a0fccb3"}, +] + +[[package]] +name = "platformdirs" +version = "3.11.0" +description = "A small Python package for determining appropriate platform-specific dirs, e.g. a \"user data dir\"." +optional = false +python-versions = ">=3.7" +files = [ + {file = "platformdirs-3.11.0-py3-none-any.whl", hash = "sha256:e9d171d00af68be50e9202731309c4e658fd8bc76f55c11c7dd760d023bda68e"}, + {file = "platformdirs-3.11.0.tar.gz", hash = "sha256:cf8ee52a3afdb965072dcc652433e0c7e3e40cf5ea1477cd4b3b1d2eb75495b3"}, +] + +[package.extras] +docs = ["furo (>=2023.7.26)", "proselint (>=0.13)", "sphinx (>=7.1.1)", "sphinx-autodoc-typehints (>=1.24)"] +test = ["appdirs (==1.4.4)", "covdefaults (>=2.3)", "pytest (>=7.4)", "pytest-cov (>=4.1)", "pytest-mock (>=3.11.1)"] + +[[package]] +name = "prettytable" +version = "3.9.0" +description = "A simple Python library for easily displaying tabular data in a visually appealing ASCII table format" +optional = false +python-versions = ">=3.8" +files = [ + {file = "prettytable-3.9.0-py3-none-any.whl", hash = "sha256:a71292ab7769a5de274b146b276ce938786f56c31cf7cea88b6f3775d82fe8c8"}, + {file = "prettytable-3.9.0.tar.gz", hash = "sha256:f4ed94803c23073a90620b201965e5dc0bccf1760b7a7eaf3158cab8aaffdf34"}, +] + +[package.dependencies] +wcwidth = "*" + +[package.extras] +tests = ["pytest", "pytest-cov", "pytest-lazy-fixture"] + +[[package]] +name = "pyaml" +version = "23.9.6" +description = "PyYAML-based module to produce a bit more pretty and readable YAML-serialized data" +optional = false +python-versions = ">=3.8" +files = [ + {file = "pyaml-23.9.6-py3-none-any.whl", hash = "sha256:9dcc67922b7278f3680e573324b2e8a8d2f86c5d09bf640cba83735fb1663e97"}, + {file = "pyaml-23.9.6.tar.gz", hash = "sha256:2b2c39017b718a127bef9f96bc55f89414d960876668d69880aae66f4ba98957"}, +] + +[package.dependencies] +PyYAML = "*" + +[package.extras] +anchors = ["unidecode"] + +[[package]] +name = "pygments" +version = "2.16.1" +description = "Pygments is a syntax highlighting package written in Python." +optional = false +python-versions = ">=3.7" +files = [ + {file = "Pygments-2.16.1-py3-none-any.whl", hash = "sha256:13fc09fa63bc8d8671a6d247e1eb303c4b343eaee81d861f3404db2935653692"}, + {file = "Pygments-2.16.1.tar.gz", hash = "sha256:1daff0494820c69bc8941e407aa20f577374ee88364ee10a98fdbe0aece96e29"}, +] + +[package.extras] +plugins = ["importlib-metadata"] + +[[package]] +name = "pyperclip" +version = "1.8.2" +description = "A cross-platform clipboard module for Python. (Only handles plain text for now.)" +optional = false +python-versions = "*" +files = [ + {file = "pyperclip-1.8.2.tar.gz", hash = "sha256:105254a8b04934f0bc84e9c24eb360a591aaf6535c9def5f29d92af107a9bf57"}, +] + +[[package]] +name = "pyreadline3" +version = "3.4.1" +description = "A python implementation of GNU readline." +optional = false +python-versions = "*" +files = [ + {file = "pyreadline3-3.4.1-py3-none-any.whl", hash = "sha256:b0efb6516fd4fb07b45949053826a62fa4cb353db5be2bbb4a7aa1fdd1e345fb"}, + {file = "pyreadline3-3.4.1.tar.gz", hash = "sha256:6f3d1f7b8a31ba32b73917cefc1f28cc660562f39aea8646d30bd6eff21f7bae"}, +] + +[[package]] +name = "pyxdg" +version = "0.28" +description = "PyXDG contains implementations of freedesktop.org standards in python." +optional = false +python-versions = "*" +files = [ + {file = "pyxdg-0.28-py2.py3-none-any.whl", hash = "sha256:bdaf595999a0178ecea4052b7f4195569c1ff4d344567bccdc12dfdf02d545ab"}, + {file = "pyxdg-0.28.tar.gz", hash = "sha256:3267bb3074e934df202af2ee0868575484108581e6f3cb006af1da35395e88b4"}, +] + +[[package]] +name = "pyyaml" +version = "6.0.1" +description = "YAML parser and emitter for Python" +optional = false +python-versions = ">=3.6" +files = [ + {file = "PyYAML-6.0.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:d858aa552c999bc8a8d57426ed01e40bef403cd8ccdd0fc5f6f04a00414cac2a"}, + {file = "PyYAML-6.0.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:fd66fc5d0da6d9815ba2cebeb4205f95818ff4b79c3ebe268e75d961704af52f"}, + {file = "PyYAML-6.0.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:69b023b2b4daa7548bcfbd4aa3da05b3a74b772db9e23b982788168117739938"}, + {file = "PyYAML-6.0.1-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:81e0b275a9ecc9c0c0c07b4b90ba548307583c125f54d5b6946cfee6360c733d"}, + {file = "PyYAML-6.0.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ba336e390cd8e4d1739f42dfe9bb83a3cc2e80f567d8805e11b46f4a943f5515"}, + {file = "PyYAML-6.0.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:326c013efe8048858a6d312ddd31d56e468118ad4cdeda36c719bf5bb6192290"}, + {file = "PyYAML-6.0.1-cp310-cp310-win32.whl", hash = "sha256:bd4af7373a854424dabd882decdc5579653d7868b8fb26dc7d0e99f823aa5924"}, + {file = "PyYAML-6.0.1-cp310-cp310-win_amd64.whl", hash = "sha256:fd1592b3fdf65fff2ad0004b5e363300ef59ced41c2e6b3a99d4089fa8c5435d"}, + {file = "PyYAML-6.0.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:6965a7bc3cf88e5a1c3bd2e0b5c22f8d677dc88a455344035f03399034eb3007"}, + {file = "PyYAML-6.0.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:f003ed9ad21d6a4713f0a9b5a7a0a79e08dd0f221aff4525a2be4c346ee60aab"}, + {file = "PyYAML-6.0.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:42f8152b8dbc4fe7d96729ec2b99c7097d656dc1213a3229ca5383f973a5ed6d"}, + {file = "PyYAML-6.0.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:062582fca9fabdd2c8b54a3ef1c978d786e0f6b3a1510e0ac93ef59e0ddae2bc"}, + {file = "PyYAML-6.0.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d2b04aac4d386b172d5b9692e2d2da8de7bfb6c387fa4f801fbf6fb2e6ba4673"}, + {file = "PyYAML-6.0.1-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:e7d73685e87afe9f3b36c799222440d6cf362062f78be1013661b00c5c6f678b"}, + {file = "PyYAML-6.0.1-cp311-cp311-win32.whl", hash = "sha256:1635fd110e8d85d55237ab316b5b011de701ea0f29d07611174a1b42f1444741"}, + {file = "PyYAML-6.0.1-cp311-cp311-win_amd64.whl", hash = "sha256:bf07ee2fef7014951eeb99f56f39c9bb4af143d8aa3c21b1677805985307da34"}, + {file = "PyYAML-6.0.1-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:855fb52b0dc35af121542a76b9a84f8d1cd886ea97c84703eaa6d88e37a2ad28"}, + {file = "PyYAML-6.0.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:40df9b996c2b73138957fe23a16a4f0ba614f4c0efce1e9406a184b6d07fa3a9"}, + {file = "PyYAML-6.0.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6c22bec3fbe2524cde73d7ada88f6566758a8f7227bfbf93a408a9d86bcc12a0"}, + {file = "PyYAML-6.0.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:8d4e9c88387b0f5c7d5f281e55304de64cf7f9c0021a3525bd3b1c542da3b0e4"}, + {file = "PyYAML-6.0.1-cp312-cp312-win32.whl", hash = "sha256:d483d2cdf104e7c9fa60c544d92981f12ad66a457afae824d146093b8c294c54"}, + {file = "PyYAML-6.0.1-cp312-cp312-win_amd64.whl", hash = "sha256:0d3304d8c0adc42be59c5f8a4d9e3d7379e6955ad754aa9d6ab7a398b59dd1df"}, + {file = "PyYAML-6.0.1-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:50550eb667afee136e9a77d6dc71ae76a44df8b3e51e41b77f6de2932bfe0f47"}, + {file = "PyYAML-6.0.1-cp36-cp36m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1fe35611261b29bd1de0070f0b2f47cb6ff71fa6595c077e42bd0c419fa27b98"}, + {file = "PyYAML-6.0.1-cp36-cp36m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:704219a11b772aea0d8ecd7058d0082713c3562b4e271b849ad7dc4a5c90c13c"}, + {file = "PyYAML-6.0.1-cp36-cp36m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:afd7e57eddb1a54f0f1a974bc4391af8bcce0b444685d936840f125cf046d5bd"}, + {file = "PyYAML-6.0.1-cp36-cp36m-win32.whl", hash = "sha256:fca0e3a251908a499833aa292323f32437106001d436eca0e6e7833256674585"}, + {file = "PyYAML-6.0.1-cp36-cp36m-win_amd64.whl", hash = "sha256:f22ac1c3cac4dbc50079e965eba2c1058622631e526bd9afd45fedd49ba781fa"}, + {file = "PyYAML-6.0.1-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:b1275ad35a5d18c62a7220633c913e1b42d44b46ee12554e5fd39c70a243d6a3"}, + {file = "PyYAML-6.0.1-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:18aeb1bf9a78867dc38b259769503436b7c72f7a1f1f4c93ff9a17de54319b27"}, + {file = "PyYAML-6.0.1-cp37-cp37m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:596106435fa6ad000c2991a98fa58eeb8656ef2325d7e158344fb33864ed87e3"}, + {file = "PyYAML-6.0.1-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:baa90d3f661d43131ca170712d903e6295d1f7a0f595074f151c0aed377c9b9c"}, + {file = "PyYAML-6.0.1-cp37-cp37m-win32.whl", hash = "sha256:9046c58c4395dff28dd494285c82ba00b546adfc7ef001486fbf0324bc174fba"}, + {file = "PyYAML-6.0.1-cp37-cp37m-win_amd64.whl", hash = "sha256:4fb147e7a67ef577a588a0e2c17b6db51dda102c71de36f8549b6816a96e1867"}, + {file = "PyYAML-6.0.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:1d4c7e777c441b20e32f52bd377e0c409713e8bb1386e1099c2415f26e479595"}, + {file = "PyYAML-6.0.1-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a0cd17c15d3bb3fa06978b4e8958dcdc6e0174ccea823003a106c7d4d7899ac5"}, + {file = "PyYAML-6.0.1-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:28c119d996beec18c05208a8bd78cbe4007878c6dd15091efb73a30e90539696"}, + {file = "PyYAML-6.0.1-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7e07cbde391ba96ab58e532ff4803f79c4129397514e1413a7dc761ccd755735"}, + {file = "PyYAML-6.0.1-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:49a183be227561de579b4a36efbb21b3eab9651dd81b1858589f796549873dd6"}, + {file = "PyYAML-6.0.1-cp38-cp38-win32.whl", hash = "sha256:184c5108a2aca3c5b3d3bf9395d50893a7ab82a38004c8f61c258d4428e80206"}, + {file = "PyYAML-6.0.1-cp38-cp38-win_amd64.whl", hash = "sha256:1e2722cc9fbb45d9b87631ac70924c11d3a401b2d7f410cc0e3bbf249f2dca62"}, + {file = "PyYAML-6.0.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:9eb6caa9a297fc2c2fb8862bc5370d0303ddba53ba97e71f08023b6cd73d16a8"}, + {file = "PyYAML-6.0.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:c8098ddcc2a85b61647b2590f825f3db38891662cfc2fc776415143f599bb859"}, + {file = "PyYAML-6.0.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5773183b6446b2c99bb77e77595dd486303b4faab2b086e7b17bc6bef28865f6"}, + {file = "PyYAML-6.0.1-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b786eecbdf8499b9ca1d697215862083bd6d2a99965554781d0d8d1ad31e13a0"}, + {file = "PyYAML-6.0.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bc1bf2925a1ecd43da378f4db9e4f799775d6367bdb94671027b73b393a7c42c"}, + {file = "PyYAML-6.0.1-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:04ac92ad1925b2cff1db0cfebffb6ffc43457495c9b3c39d3fcae417d7125dc5"}, + {file = "PyYAML-6.0.1-cp39-cp39-win32.whl", hash = "sha256:faca3bdcf85b2fc05d06ff3fbc1f83e1391b3e724afa3feba7d13eeab355484c"}, + {file = "PyYAML-6.0.1-cp39-cp39-win_amd64.whl", hash = "sha256:510c9deebc5c0225e8c96813043e62b680ba2f9c50a08d3724c7f28a747d1486"}, + {file = "PyYAML-6.0.1.tar.gz", hash = "sha256:bfdf460b1736c775f2ba9f6a92bca30bc2095067b8a9d77876d1fad6cc3b4a43"}, +] + +[[package]] +name = "rich" +version = "13.6.0" +description = "Render rich text, tables, progress bars, syntax highlighting, markdown and more to the terminal" +optional = false +python-versions = ">=3.7.0" +files = [ + {file = "rich-13.6.0-py3-none-any.whl", hash = "sha256:2b38e2fe9ca72c9a00170a1a2d20c63c790d0e10ef1fe35eba76e1e7b1d7d245"}, + {file = "rich-13.6.0.tar.gz", hash = "sha256:5c14d22737e6d5084ef4771b62d5d4363165b403455a30a1c8ca39dc7b644bef"}, +] + +[package.dependencies] +markdown-it-py = ">=2.2.0" +pygments = ">=2.13.0,<3.0.0" + +[package.extras] +jupyter = ["ipywidgets (>=7.5.1,<9)"] + +[[package]] +name = "rich-click" +version = "1.6.1" +description = "Format click help output nicely with rich" +optional = false +python-versions = ">=3.7" +files = [ + {file = "rich-click-1.6.1.tar.gz", hash = "sha256:f8ff96693ec6e261d1544e9f7d9a5811c5ef5d74c8adb4978430fc0dac16777e"}, + {file = "rich_click-1.6.1-py3-none-any.whl", hash = "sha256:0fcf4d1a09029d79322dd814ab0b2e66ac183633037561881d45abae8a161d95"}, +] + +[package.dependencies] +click = ">=7" +rich = ">=10.7.0" + +[package.extras] +dev = ["pre-commit"] + +[[package]] +name = "stevedore" +version = "5.1.0" +description = "Manage dynamic plugins for Python applications" +optional = false +python-versions = ">=3.8" +files = [ + {file = "stevedore-5.1.0-py3-none-any.whl", hash = "sha256:8cc040628f3cea5d7128f2e76cf486b2251a4e543c7b938f58d9a377f6694a2d"}, + {file = "stevedore-5.1.0.tar.gz", hash = "sha256:a54534acf9b89bc7ed264807013b505bf07f74dbe4bcfa37d32bd063870b087c"}, +] + +[package.dependencies] +pbr = ">=2.0.0,<2.1.0 || >2.1.0" + +[[package]] +name = "typer" +version = "0.9.0" +description = "Typer, build great CLIs. Easy to code. Based on Python type hints." +optional = false +python-versions = ">=3.6" +files = [ + {file = "typer-0.9.0-py3-none-any.whl", hash = "sha256:5d96d986a21493606a358cae4461bd8cdf83cbf33a5aa950ae629ca3b51467ee"}, + {file = "typer-0.9.0.tar.gz", hash = "sha256:50922fd79aea2f4751a8e0408ff10d2662bd0c8bbfa84755a699f3bada2978b2"}, +] + +[package.dependencies] +click = ">=7.1.1,<9.0.0" +typing-extensions = ">=3.7.4.3" + +[package.extras] +all = ["colorama (>=0.4.3,<0.5.0)", "rich (>=10.11.0,<14.0.0)", "shellingham (>=1.3.0,<2.0.0)"] +dev = ["autoflake (>=1.3.1,<2.0.0)", "flake8 (>=3.8.3,<4.0.0)", "pre-commit (>=2.17.0,<3.0.0)"] +doc = ["cairosvg (>=2.5.2,<3.0.0)", "mdx-include (>=1.4.1,<2.0.0)", "mkdocs (>=1.1.2,<2.0.0)", "mkdocs-material (>=8.1.4,<9.0.0)", "pillow (>=9.3.0,<10.0.0)"] +test = ["black (>=22.3.0,<23.0.0)", "coverage (>=6.2,<7.0)", "isort (>=5.0.6,<6.0.0)", "mypy (==0.910)", "pytest (>=4.4.0,<8.0.0)", "pytest-cov (>=2.10.0,<5.0.0)", "pytest-sugar (>=0.9.4,<0.10.0)", "pytest-xdist (>=1.32.0,<4.0.0)", "rich (>=10.11.0,<14.0.0)", "shellingham (>=1.3.0,<2.0.0)"] + +[[package]] +name = "typing-extensions" +version = "4.8.0" +description = "Backported and Experimental Type Hints for Python 3.8+" +optional = false +python-versions = ">=3.8" +files = [ + {file = "typing_extensions-4.8.0-py3-none-any.whl", hash = "sha256:8f92fc8806f9a6b641eaa5318da32b44d401efaac0f6678c9bc448ba3605faa0"}, + {file = "typing_extensions-4.8.0.tar.gz", hash = "sha256:df8e4339e9cb77357558cbdbceca33c303714cf861d1eef15e1070055ae8b7ef"}, +] + +[[package]] +name = "wcwidth" +version = "0.2.8" +description = "Measures the displayed width of unicode strings in a terminal" +optional = false +python-versions = "*" +files = [ + {file = "wcwidth-0.2.8-py2.py3-none-any.whl", hash = "sha256:77f719e01648ed600dfa5402c347481c0992263b81a027344f3e1ba25493a704"}, + {file = "wcwidth-0.2.8.tar.gz", hash = "sha256:8705c569999ffbb4f6a87c6d1b80f324bd6db952f5eb0b95bc07517f4c1813d4"}, +] + +[[package]] +name = "zipp" +version = "3.17.0" +description = "Backport of pathlib-compatible object wrapper for zip files" +optional = false +python-versions = ">=3.8" +files = [ + {file = "zipp-3.17.0-py3-none-any.whl", hash = "sha256:0e923e726174922dce09c53c59ad483ff7bbb8e572e00c7f7c46b88556409f31"}, + {file = "zipp-3.17.0.tar.gz", hash = "sha256:84e64a1c28cf7e91ed2078bb8cc8c259cb19b76942096c8d7b84947690cabaf0"}, +] + +[package.extras] +docs = ["furo", "jaraco.packaging (>=9.3)", "jaraco.tidelift (>=1.4)", "rst.linker (>=1.9)", "sphinx (<7.2.5)", "sphinx (>=3.5)", "sphinx-lint"] +testing = ["big-O", "jaraco.functools", "jaraco.itertools", "more-itertools", "pytest (>=6)", "pytest-black (>=0.3.7)", "pytest-checkdocs (>=2.4)", "pytest-cov", "pytest-enabler (>=2.2)", "pytest-ignore-flaky", "pytest-mypy (>=0.9.1)", "pytest-ruff"] + +[metadata] +lock-version = "2.0" +python-versions = "^3.11" +content-hash = "6c7761768920ef58522d19560d56ce366cca74dc275b4e019acf10838923d63d" diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..b6d12f7 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,57 @@ +[tool.poetry] +name = "iam" +version = "0.1.0" +description = "" +authors = ["mrjk"] +readme = "README.md" + +#packages = [ +# { include = "iam" }, +## { include = "name_it", from = "src" }, +## { from = "src" }, +#] + +[tool.poetry.dependencies] +python = "^3.11" +typer = "^0.9.0" +pyaml = "^23.9.6" +jinja2 = "^3.1.2" +pyxdg = "^0.28" +coloredlogs = "^15.0.1" +rich = "^13.6.0" +rich-click = "^1.6.1" + + +[tool.poetry.scripts] +iam2 = "iam.cli:run" +iam = "iam.cli_click:run" + +[tool.poetry.group.dev.dependencies] +black = "^23.9.1" +isort = "^5.12.0" + +[build-system] +requires = ["poetry-core"] +build-backend = "poetry.core.masonry.api" + + +### TESTS FOR CLIFF +[tool.poetry.plugins] # Optional super table + +# [tool.poetry.plugins."iam.cliff"] +# "my_plugin" = "iam.cli_cliff:SCUrls" +# "simple" = "iam.cli_cliff:SCUrls" + + +# 'cliff.demo': [ +# 'simple = cliffdemo.simple:Simple', +# 'two_part = cliffdemo.simple:Simple', +# 'error = cliffdemo.simple:Error', +# 'list files = cliffdemo.list:Files', +# 'files = cliffdemo.list:Files', +# 'file = cliffdemo.show:File', +# 'show file = cliffdemo.show:File', +# 'unicode = cliffdemo.encoding:Encoding', +# 'hooked = cliffdemo.hook:Hooked', +# ], +