From c8c5ce07b3ce3669ade5a4a2266ee106f0211f28 Mon Sep 17 00:00:00 2001 From: Ivan Ogasawara Date: Sat, 3 Feb 2024 20:31:42 -0400 Subject: [PATCH] feat: Add stats plot command --- poetry.lock | 67 +++- pyproject.toml | 3 + src/sugar/core.py | 553 +--------------------------------- src/sugar/inspect.py | 14 + src/sugar/logs.py | 1 + src/sugar/plugins/__init__.py | 1 + src/sugar/plugins/base.py | 532 ++++++++++++++++++++++++++++++++ src/sugar/plugins/ext.py | 42 +++ src/sugar/plugins/stats.py | 237 +++++++++++++++ 9 files changed, 903 insertions(+), 547 deletions(-) create mode 100644 src/sugar/inspect.py create mode 100644 src/sugar/plugins/__init__.py create mode 100644 src/sugar/plugins/base.py create mode 100644 src/sugar/plugins/ext.py create mode 100644 src/sugar/plugins/stats.py diff --git a/poetry.lock b/poetry.lock index dc8bb00..b5e25ae 100644 --- a/poetry.lock +++ b/poetry.lock @@ -921,6 +921,26 @@ test-functional = ["jupytext[test]"] test-integration = ["ipykernel", "jupyter-server (!=2.11)", "jupytext[test-functional]", "nbconvert"] test-ui = ["calysto-bash"] +[[package]] +name = "linkify-it-py" +version = "2.0.2" +description = "Links recognition library with FULL unicode support." +optional = false +python-versions = ">=3.7" +files = [ + {file = "linkify-it-py-2.0.2.tar.gz", hash = "sha256:19f3060727842c254c808e99d465c80c49d2c7306788140987a1a7a29b0d6ad2"}, + {file = "linkify_it_py-2.0.2-py3-none-any.whl", hash = "sha256:a3a24428f6c96f27370d7fe61d2ac0be09017be5190d68d8658233171f1b6541"}, +] + +[package.dependencies] +uc-micro-py = "*" + +[package.extras] +benchmark = ["pytest", "pytest-benchmark"] +dev = ["black", "flake8", "isort", "pre-commit", "pyproject-flake8"] +doc = ["myst-parser", "sphinx", "sphinx-book-theme"] +test = ["coverage", "pytest", "pytest-cov"] + [[package]] name = "makim" version = "1.8.3" @@ -970,6 +990,8 @@ files = [ ] [package.dependencies] +linkify-it-py = {version = ">=1,<3", optional = true, markers = "extra == \"linkify\""} +mdit-py-plugins = {version = "*", optional = true, markers = "extra == \"plugins\""} mdurl = ">=0.1,<1.0" [package.extras] @@ -1602,6 +1624,16 @@ files = [ 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 = "plotille" +version = "5.0.0" +description = "Plot in the terminal using braille dots." +optional = false +python-versions = ">=3.7,<4.0" +files = [ + {file = "plotille-5.0.0.tar.gz", hash = "sha256:99e5ca51a2e4c922ead3a3b0863cc2c6a9a4b3f701944589df10f42ce02ab3dc"}, +] + [[package]] name = "pluggy" version = "1.3.0" @@ -2419,6 +2451,25 @@ files = [ [package.extras] tests = ["pytest", "pytest-cov"] +[[package]] +name = "textual" +version = "0.48.2" +description = "Modern Text User Interface framework" +optional = false +python-versions = ">=3.8,<4.0" +files = [ + {file = "textual-0.48.2-py3-none-any.whl", hash = "sha256:a9d2f225584afb28a6c05b7f7d06d41b54cd02822020f11711d788e5098161db"}, + {file = "textual-0.48.2.tar.gz", hash = "sha256:e092dffa5311f3381cb5f51d56c506143f5c1ee3b1c67f57bb1929cfa73fee07"}, +] + +[package.dependencies] +markdown-it-py = {version = ">=2.1.0", extras = ["linkify", "plugins"]} +rich = ">=13.3.3" +typing-extensions = ">=4.4.0,<5.0.0" + +[package.extras] +syntax = ["tree-sitter (>=0.20.1,<0.21.0)", "tree_sitter_languages (>=1.7.0)"] + [[package]] name = "tinycss2" version = "1.2.1" @@ -2505,6 +2556,20 @@ files = [ {file = "typing_extensions-4.9.0.tar.gz", hash = "sha256:23478f88c37f27d76ac8aee6c905017a143b0b1b886c3c9f66bc2fd94f9f5783"}, ] +[[package]] +name = "uc-micro-py" +version = "1.0.2" +description = "Micro subset of unicode data files for linkify-it-py projects." +optional = false +python-versions = ">=3.7" +files = [ + {file = "uc-micro-py-1.0.2.tar.gz", hash = "sha256:30ae2ac9c49f39ac6dce743bd187fcd2b574b16ca095fa74cd9396795c954c54"}, + {file = "uc_micro_py-1.0.2-py3-none-any.whl", hash = "sha256:8c9110c309db9d9e87302e2f4ad2c3152770930d88ab385cd544e7a7e75f3de0"}, +] + +[package.extras] +test = ["coverage", "pytest", "pytest-cov"] + [[package]] name = "urllib3" version = "2.1.0" @@ -2660,4 +2725,4 @@ testing = ["big-O", "jaraco.functools", "jaraco.itertools", "more-itertools", "p [metadata] lock-version = "2.0" python-versions = ">=3.8.1,<4" -content-hash = "3945359e88649aa80097f69cfc7a27141020fcca5e3e079c47ee60e96dc9db04" +content-hash = "2dce0404c02445da5abf7de4e4ef592c39da81be505c0729074e08c9578194be" diff --git a/pyproject.toml b/pyproject.toml index 8a5bc48..902aabf 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -23,6 +23,9 @@ sh = ">=2.0.0" pyyaml = ">=6" colorama = ">=0.4.6" python-dotenv = ">=0.21.1" +textual = ">=0.48" +rich = ">=13.7" +plotille = ">=5" [tool.poetry.group.dev.dependencies] pytest = ">=7.3.2" diff --git a/src/sugar/core.py b/src/sugar/core.py index 10bf567..df9b01b 100644 --- a/src/sugar/core.py +++ b/src/sugar/core.py @@ -1,555 +1,15 @@ """Sugar class for containers.""" -import io -import os -import sys - -from copy import deepcopy -from pathlib import Path -from typing import Dict, List, Optional, Type +from __future__ import annotations -import dotenv -import sh -import yaml # type: ignore +import os -from jinja2 import Template +from typing import Dict, Optional, Type from sugar import __version__ from sugar.logs import KxgrErrorType, KxgrLogs - - -def escape_template_tag(v: str) -> str: - """Escape template tags for template rendering.""" - return v.replace('{{', r'\{\{').replace('}}', r'\}\}') - - -def unescape_template_tag(v: str) -> str: - """Unescape template tags for template rendering.""" - return v.replace(r'\{\{', '{{').replace(r'\}\}', '}}') - - -class SugarBase: - """SugarBase defined the base structure for the Sugar classes.""" - - actions: List[str] = [] - args: dict = {} - config_file: str = '' - config: dict = {} - # note: it starts with a simple command - # it is replaced later in the execution - compose_app: sh.Command = sh.echo - compose_args: list = [] - defaults: dict = {} - env: dict = {} - options_args: list = [] - cmd_args: list = [] - service_group: dict = {} - service_names: list = [] - - def __init__( - self, - args: dict, - options_args: list = [], - cmd_args: list = [], - ): - """Initialize SugarBase instance.""" - self.args = deepcopy(args) - self.options_args = deepcopy(options_args) - self.cmd_args = deepcopy(cmd_args) - self.config_file = self.args.get('config_file', '') - self.config = dict() - self.compose_args = list() - self.defaults = dict() - self.env = dict() - self.service_group = dict() - self.service_names = list() - - self._load_config() - self._load_env() - self._load_defaults() - self._load_root_services() - self._verify_args() - self._load_compose_app() - self._load_compose_args() - self._verify_config() - self._load_service_names() - - def _call_compose_app( - self, - *args, - services: list = [], - ): - sh_extras = { - '_in': sys.stdin, - '_out': sys.stdout, - '_err': sys.stderr, - '_no_err': True, - '_env': os.environ, - '_bg': True, - '_bg_exc': False, - } - - positional_parameters = ( - self.compose_args - + list(args) - + self.options_args - + services - + self.cmd_args - ) - - if self.args.get('verbose'): - print('>>>', self.compose_app, *positional_parameters) - print('-' * 80) - - p = self.compose_app( - *positional_parameters, - **sh_extras, - ) - - try: - p.wait() - except sh.ErrorReturnCode as e: - KxgrLogs.raise_error(str(e), KxgrErrorType.SH_ERROR_RETURN_CODE) - except KeyboardInterrupt: - pid = p.pid - p.kill() - KxgrLogs.raise_error( - f'Process {pid} killed.', KxgrErrorType.SH_KEYBOARD_INTERRUPT - ) - - def _check_config_file(self): - return Path(self.config_file).exists() - - # Check if services item is given - def _check_services_item(self): - return hasattr(self.config, 'services') - - # set default group main - def _load_root_services(self) -> None: - """Load services attribute in the root of the configuration.""" - # must set the default group - services = self.config.get('services', {}) - - if not services: - return - - self.config['groups'] = { - 'main': { - 'project-name': services.get('project-name'), - 'compose-path': services.get('compose-path'), - 'env-file': services.get('env-file'), - 'services': { - 'default': services.get('default'), - 'available': services.get('available'), - }, - } - } - self.defaults['group'] = 'main' - self.service_group = deepcopy(self.config['groups']['main']) - del self.config['services'] - - def _filter_service_group(self): - groups = self.config['groups'] - - if not self.args.get('service_group'): - default_group = self.defaults.get('group') - if not default_group: - KxgrLogs.raise_error( - 'The service group parameter or default ' - "group configuration weren't defined.", - KxgrErrorType.KXGR_INVALID_PARAMETER, - ) - selected_group_name = default_group - else: - selected_group_name = self.args.get('service_group') - - # Verify if project-name is not null - default_project_name = self.defaults.get('project-name', '') or '' - - for group_name, group_data in groups.items(): - if group_name == selected_group_name: - if default_project_name and 'project-name' not in group_data: - # just use default value if "project-name" is not set - group_data['project-name'] = default_project_name - if not group_data.get('services', {}).get('default'): - # if default is not given or it is empty or null, - # use as default all the services available - default_services = [ - service['name'] - for service in group_data.get('services', {}).get( - 'available' - ) - ] - group_data['services']['default'] = ','.join( - default_services - ) - self.service_group = group_data - return - - KxgrLogs.raise_error( - f'The given group service "{group_name}" was not found ' - 'in the configuration file.', - KxgrErrorType.KXGR_MISSING_PARAMETER, - ) - - def _load_config(self): - with open(self.config_file, 'r') as f: - # escape template tags - content = escape_template_tag(f.read()) - f_content = io.StringIO(content) - self.config = yaml.safe_load(f_content) - - # check if either services or groups are present - if not (self.config.get('services') or self.config.get('groups')): - KxgrLogs.raise_error( - 'Either `services` OR `groups` flag must be given', - KxgrErrorType.KXGR_INVALID_CONFIGURATION, - ) - # check if both services and groups are present - if self.config.get('services') and self.config.get('groups'): - KxgrLogs.raise_error( - '`services` and `groups` flags given, only 1 is allowed.', - KxgrErrorType.KXGR_INVALID_CONFIGURATION, - ) - - def _load_compose_app(self): - compose_cmd = self.config.get('compose-app', '') - if compose_cmd.replace(' ', '-') != 'docker-compose': - KxgrLogs.raise_error( - f'"{self.config["compose-app"]}" not supported yet.', - KxgrErrorType.KXGR_COMPOSE_APP_NOT_SUPPORTED, - ) - - if compose_cmd == 'docker-compose': - self.compose_app = sh.docker_compose - return - self.compose_app = sh.docker - self.compose_args.append('compose') - - def _load_compose_args(self): - self._filter_service_group() - - if self.service_group.get('env-file'): - self.compose_args.extend( - ['--env-file', self.service_group['env-file']] - ) - - compose_path = [] - compose_path_arg = self.service_group['compose-path'] - if isinstance(compose_path_arg, str): - compose_path.append(compose_path_arg) - elif isinstance(compose_path_arg, list): - compose_path.extend(compose_path_arg) - else: - self.KxgrLogs.raise_error( - 'The attribute compose-path` just supports the data ' - f'types `string` or `list`, {type(compose_path_arg)} ' - 'received.', - KxgrErrorType.KXGR_INVALID_CONFIGURATION, - ) - - for p in compose_path: - self.compose_args.extend(['--file', p]) - - if self.service_group.get('project-name'): - self.compose_args.extend( - ['--project-name', self.service_group['project-name']] - ) - - def _load_defaults(self): - _defaults = self.config.get('defaults', {}) - - for k, v in _defaults.items(): - unescaped_value = ( - unescape_template_tag(v) if isinstance(v, str) else str(v) - ) - - _defaults[k] = yaml.safe_load( - Template(unescaped_value).render(env=self.env) - ) - - self.defaults = _defaults - - def _load_env(self): - self.env = dict(os.environ) - - env_file = self.config.get('env-file', '') - - if not env_file: - return - - if not env_file.startswith('/'): - # use .sugar file as reference for the working - # directory for the .env file - env_file = str(Path(self.config_file).parent / env_file) - - if not Path(env_file).exists(): - KxgrLogs.raise_error( - 'The given env-file was not found.', - KxgrErrorType.KXGR_INVALID_CONFIGURATION, - ) - self.env.update(dotenv.dotenv_values(env_file)) - - def _load_service_names(self): - services = self.service_group['services'] - - if self.args.get('all'): - self.service_names = [ - v['name'] - for v in self.service_group.get('services', {}).get( - 'available' - ) - ] - elif self.args.get('services') == '': - KxgrLogs.raise_error( - 'If you want to execute the operation for all services, ' - 'use --all parameter.', - KxgrErrorType.KXGR_INVALID_PARAMETER, - ) - elif self.args.get('services'): - self.service_names = self.args.get('services').split(',') - elif 'default' in services and services['default']: - self.service_names = services['default'].split(',') - - def _verify_args(self): - if not self._check_config_file(): - KxgrLogs.raise_error( - 'Config file .sugar.yaml not found.', - KxgrErrorType.KXGR_INVALID_CONFIGURATION, - ) - - if ( - self.args.get('action') - and self.args.get('action') not in self.actions - ): - KxgrLogs.raise_error( - f'The given action `{self.args.get("action")}` is not ' - f'valid. Use one of them: {",".join(self.actions)}.', - KxgrErrorType.KXGR_INVALID_PARAMETER, - ) - - def _verify_config(self): - if not len(self.config['groups']): - KxgrLogs.raise_error( - 'No service groups found.', - KxgrErrorType.KXGR_INVALID_CONFIGURATION, - ) - - def run(self): - """Run the given sugar command.""" - action = self.args.get('action') - if not isinstance(action, str): - KxgrLogs.raise_error( - 'The given action is not valid.', - KxgrErrorType.KXGR_INVALID_PARAMETER, - ) - return getattr(self, f'_{action.replace("-", "_")}')() - - -class SugarDockerCompose(SugarBase): - """ - SugarDockerCompose provides the docker compose commands. - - This are the commands that is currently provided: - - build [options] [SERVICE...] - config [options] - create [options] [SERVICE...] - down [options] [--rmi type] [--volumes] [--remove-orphans] - events [options] [SERVICE...] - exec [options] SERVICE COMMAND [ARGS...] - images [options] [SERVICE...] - kill [options] [SERVICE...] - logs [options] [SERVICE...] - pause [options] SERVICE... - port [options] SERVICE PRIVATE_PORT - ps [options] [SERVICE...] - pull [options] [SERVICE...] - push [options] [SERVICE...] - restart [options] [SERVICE...] - rm [options] [-f | -s] [SERVICE...] - run [options] [-p TARGET...] [-v VOLUME...] [-e KEY=VAL...] - [-l KEY=VAL...] SERVICE [COMMAND] [ARGS...] - start [options] [SERVICE...] - stop [options] [SERVICE...] - top [options] [SERVICE...] - unpause [options] SERVICE... - up [options] [--scale SERVICE=NUM...] [--no-color] - [--quiet-pull] [SERVICE...] - version [options] - """ - - actions: List[str] = [ - 'build', - 'config', - 'create', - 'down', - 'events', - 'exec', - 'images', - 'kill', - 'logs', - 'pause', - 'port', - 'ps', - 'pull', - 'push', - 'restart', - 'rm', - 'run', - 'start', - 'stop', - 'top', - 'unpause', - 'up', - 'version', - ] - - def __init__(self, *args, **kwargs): - """Initialize SugarDockerCompose instance.""" - super().__init__(*args, **kwargs) - - # container commands - def _build(self): - self._call_compose_app('build', services=self.service_names) - - def _config(self): - self._call_compose_app('config', services=self.service_names) - - def _create(self): - self._call_compose_app('create', services=self.service_names) - - def _down(self): - if self.args.get('all') or self.args.get('services'): - KxgrLogs.raise_error( - "The `down` sub-command doesn't accept `--all` " - 'neither `--services` parameters.', - KxgrErrorType.KXGR_INVALID_PARAMETER, - ) - - self._call_compose_app( - 'down', - '--remove-orphans', - services=[], - ) - - def _events(self): - # port is not complete supported - if not self.args.get('service'): - KxgrLogs.raise_error( - '`exec` sub-command expected --service parameter.', - KxgrErrorType.KXGR_MISSING_PARAMETER, - ) - self._call_compose_app('events', services=[self.args.get('service')]) - - def _exec(self): - if not self.args.get('service'): - KxgrLogs.raise_error( - '`exec` sub-command expected --service parameter.', - KxgrErrorType.KXGR_MISSING_PARAMETER, - ) - - self._call_compose_app('exec', services=[self.args.get('service')]) - - def _images(self): - self._call_compose_app('images', services=self.service_names) - - def _kill(self): - self._call_compose_app('kill', services=self.service_names) - - def _logs(self): - self._call_compose_app('logs', services=self.service_names) - - def _pause(self): - self._call_compose_app('pause', services=self.service_names) - - def _port(self): - # port is not complete supported - if not self.args.get('service'): - KxgrLogs.raise_error( - '`exec` sub-command expected --service parameter.', - KxgrErrorType.KXGR_MISSING_PARAMETER, - ) - # TODO: check how private port could be passed - self._call_compose_app('port', services=[self.args.get('service')]) - - def _ps(self): - self._call_compose_app('ps', services=self.service_names) - - def _pull(self): - self._call_compose_app('pull', services=self.service_names) - - def _push(self): - self._call_compose_app('push', services=self.service_names) - - def _restart(self): - self._call_compose_app('restart', services=self.service_names) - - def _rm(self): - self._call_compose_app('rm', services=self.service_names) - - def _run(self): - if not self.args.get('service'): - KxgrLogs.raise_error( - '`run` sub-command expected --service parameter.', - KxgrErrorType.KXGR_MISSING_PARAMETER, - ) - - self._call_compose_app('run', services=[self.args.get('service')]) - - def _start(self): - self._call_compose_app('start', services=self.service_names) - - def _stop(self): - self._call_compose_app('stop', services=self.service_names) - - def _top(self): - self._call_compose_app('top', services=self.service_names) - - def _unpause(self): - self._call_compose_app('unpause', services=self.service_names) - - def _up(self): - self._call_compose_app('up', services=self.service_names) - - def _version(self): - self._call_compose_app('version', services=[]) - - -class SugarExt(SugarDockerCompose): - """SugarExt provides special commands not available on docker-compose.""" - - def __init__(self, *args, **kwargs): - """Initialize the SugarExt class.""" - self.actions += [ - 'get-ip', - 'restart', - 'start', - 'wait', - ] - - super().__init__(*args, **kwargs) - - def _get_ip(self): - KxgrLogs.raise_error( - '`get-ip` mot implemented yet.', - KxgrErrorType.KXGR_ACTION_NOT_IMPLEMENTED, - ) - - def _restart(self): - options = self.options_args - self.options_args = [] - self._stop() - self.options_args = options - self._start() - - def _start(self): - self._up() - - def _wait(self): - KxgrLogs.raise_error( - '`wait` not implemented yet.', - KxgrErrorType.KXGR_ACTION_NOT_IMPLEMENTED, - ) +from sugar.plugins.base import SugarBase, SugarDockerCompose +from sugar.plugins.ext import SugarExt +from sugar.plugins.stats import SugarStats class Sugar(SugarBase): @@ -558,6 +18,7 @@ class Sugar(SugarBase): plugins_definition: Dict[str, Type[SugarBase]] = { 'main': SugarDockerCompose, 'ext': SugarExt, + 'stats': SugarStats, } plugin: Optional[SugarBase] = None diff --git a/src/sugar/inspect.py b/src/sugar/inspect.py new file mode 100644 index 0000000..d653b1b --- /dev/null +++ b/src/sugar/inspect.py @@ -0,0 +1,14 @@ +"""Functions for inspecting and retrieving information from containers.""" +import subprocess # nosec B404 + + +def get_container_name(container_id: str) -> str: + """Get the container name for the given container_id.""" + cmd = ['docker', 'inspect', '--format={{.Name}}', container_id] + result = subprocess.run( # nosec B603 + cmd, capture_output=True, text=True, check=False + ) + if not result.stdout: + raise Exception('No container name found for the given ID') + # Removing the leading slash from the container name + return result.stdout.strip().lstrip('/') diff --git a/src/sugar/logs.py b/src/sugar/logs.py index ba0a73d..1b9c90a 100644 --- a/src/sugar/logs.py +++ b/src/sugar/logs.py @@ -17,6 +17,7 @@ class KxgrErrorType(Enum): KXGR_MISSING_PARAMETER = 6 KXGR_INVALID_CONFIGURATION = 7 KXGR_ACTION_NOT_IMPLEMENTED = 8 + KXGR_NO_SERVICES_RUNNING = 9 class KxgrLogs: diff --git a/src/sugar/plugins/__init__.py b/src/sugar/plugins/__init__.py new file mode 100644 index 0000000..61b91c7 --- /dev/null +++ b/src/sugar/plugins/__init__.py @@ -0,0 +1 @@ +"""Plugins module.""" diff --git a/src/sugar/plugins/base.py b/src/sugar/plugins/base.py new file mode 100644 index 0000000..0361df4 --- /dev/null +++ b/src/sugar/plugins/base.py @@ -0,0 +1,532 @@ +"""SugarBase classes for containers.""" +from __future__ import annotations + +import io +import os +import sys + +from copy import deepcopy +from pathlib import Path +from typing import Any, Union + +import dotenv +import sh +import yaml # type: ignore + +from jinja2 import Template + +from sugar.logs import KxgrErrorType, KxgrLogs + + +def escape_template_tag(v: str) -> str: + """Escape template tags for template rendering.""" + return v.replace('{{', r'\{\{').replace('}}', r'\}\}') + + +def unescape_template_tag(v: str) -> str: + """Unescape template tags for template rendering.""" + return v.replace(r'\{\{', '{{').replace(r'\}\}', '}}') + + +class SugarBase: + """SugarBase defined the base structure for the Sugar classes.""" + + actions: list[str] = [] + args: dict = {} + config_file: str = '' + config: dict = {} + # note: it starts with a simple command + # it is replaced later in the execution + compose_app: sh.Command = sh.echo + compose_args: list = [] + defaults: dict = {} + env: dict = {} + options_args: list = [] + cmd_args: list = [] + service_group: dict = {} + service_names: list = [] + + def __init__( + self, + args: dict, + options_args: list = [], + cmd_args: list = [], + ): + """Initialize SugarBase instance.""" + self.args = deepcopy(args) + self.options_args = deepcopy(options_args) + self.cmd_args = deepcopy(cmd_args) + self.config_file = self.args.get('config_file', '') + self.config = dict() + self.compose_args = list() + self.defaults = dict() + self.env = dict() + self.service_group = dict() + self.service_names = list() + + self._load_config() + self._load_env() + self._load_defaults() + self._load_root_services() + self._verify_args() + self._load_compose_app() + self._load_compose_args() + self._verify_config() + self._load_service_names() + + def _call_compose_app_core( + self, + *args, + services: list = [], + options_args: list[str] = [], + cmd_args: list[str] = [], + _out: Union[io.TextIOWrapper, io.StringIO, Any] = sys.stdout, + _err: Union[io.TextIOWrapper, io.StringIO, Any] = sys.stderr, + ) -> None: + sh_extras = { + '_in': sys.stdin, + '_out': _out, + '_err': _err, + '_no_err': True, + '_env': os.environ, + '_bg': True, + '_bg_exc': False, + } + + positional_parameters = ( + self.compose_args + + list(args) + + (options_args or self.options_args) + + services + + (cmd_args or self.cmd_args) + ) + + if self.args.get('verbose'): + print('>>>', self.compose_app, *positional_parameters) + print('-' * 80) + + p = self.compose_app( + *positional_parameters, + **sh_extras, + ) + + try: + p.wait() + except sh.ErrorReturnCode as e: + KxgrLogs.raise_error(str(e), KxgrErrorType.SH_ERROR_RETURN_CODE) + except KeyboardInterrupt: + pid = p.pid + p.kill() + KxgrLogs.raise_error( + f'Process {pid} killed.', KxgrErrorType.SH_KEYBOARD_INTERRUPT + ) + + def _call_compose_app( + self, + *args, + services: list = [], + ) -> None: + self._call_compose_app_core( + *args, + services=services, + _out=sys.stdout, + _err=sys.stderr, + ) + + def _check_config_file(self): + return Path(self.config_file).exists() + + # Check if services item is given + def _check_services_item(self): + return hasattr(self.config, 'services') + + # set default group main + def _load_root_services(self) -> None: + """Load services attribute in the root of the configuration.""" + # must set the default group + services = self.config.get('services', {}) + + if not services: + return + + self.config['groups'] = { + 'main': { + 'project-name': services.get('project-name'), + 'compose-path': services.get('compose-path'), + 'env-file': services.get('env-file'), + 'services': { + 'default': services.get('default'), + 'available': services.get('available'), + }, + } + } + self.defaults['group'] = 'main' + self.service_group = deepcopy(self.config['groups']['main']) + del self.config['services'] + + def _filter_service_group(self): + groups = self.config['groups'] + + if not self.args.get('service_group'): + default_group = self.defaults.get('group') + if not default_group: + KxgrLogs.raise_error( + 'The service group parameter or default ' + "group configuration weren't defined.", + KxgrErrorType.KXGR_INVALID_PARAMETER, + ) + selected_group_name = default_group + else: + selected_group_name = self.args.get('service_group') + + # Verify if project-name is not null + default_project_name = self.defaults.get('project-name', '') or '' + + for group_name, group_data in groups.items(): + if group_name == selected_group_name: + if default_project_name and 'project-name' not in group_data: + # just use default value if "project-name" is not set + group_data['project-name'] = default_project_name + if not group_data.get('services', {}).get('default'): + # if default is not given or it is empty or null, + # use as default all the services available + default_services = [ + service['name'] + for service in group_data.get('services', {}).get( + 'available' + ) + ] + group_data['services']['default'] = ','.join( + default_services + ) + self.service_group = group_data + return + + KxgrLogs.raise_error( + f'The given group service "{group_name}" was not found ' + 'in the configuration file.', + KxgrErrorType.KXGR_MISSING_PARAMETER, + ) + + def _load_config(self): + with open(self.config_file, 'r') as f: + # escape template tags + content = escape_template_tag(f.read()) + f_content = io.StringIO(content) + self.config = yaml.safe_load(f_content) + + # check if either services or groups are present + if not (self.config.get('services') or self.config.get('groups')): + KxgrLogs.raise_error( + 'Either `services` OR `groups` flag must be given', + KxgrErrorType.KXGR_INVALID_CONFIGURATION, + ) + # check if both services and groups are present + if self.config.get('services') and self.config.get('groups'): + KxgrLogs.raise_error( + '`services` and `groups` flags given, only 1 is allowed.', + KxgrErrorType.KXGR_INVALID_CONFIGURATION, + ) + + def _load_compose_app(self): + compose_cmd = self.config.get('compose-app', '') + if compose_cmd.replace(' ', '-') != 'docker-compose': + KxgrLogs.raise_error( + f'"{self.config["compose-app"]}" not supported yet.', + KxgrErrorType.KXGR_COMPOSE_APP_NOT_SUPPORTED, + ) + + if compose_cmd == 'docker-compose': + self.compose_app = sh.docker_compose + return + self.compose_app = sh.docker + self.compose_args.append('compose') + + def _load_compose_args(self): + self._filter_service_group() + + if self.service_group.get('env-file'): + self.compose_args.extend( + ['--env-file', self.service_group['env-file']] + ) + + compose_path = [] + compose_path_arg = self.service_group['compose-path'] + if isinstance(compose_path_arg, str): + compose_path.append(compose_path_arg) + elif isinstance(compose_path_arg, list): + compose_path.extend(compose_path_arg) + else: + self.KxgrLogs.raise_error( + 'The attribute compose-path` just supports the data ' + f'types `string` or `list`, {type(compose_path_arg)} ' + 'received.', + KxgrErrorType.KXGR_INVALID_CONFIGURATION, + ) + + for p in compose_path: + self.compose_args.extend(['--file', p]) + + if self.service_group.get('project-name'): + self.compose_args.extend( + ['--project-name', self.service_group['project-name']] + ) + + def _load_defaults(self): + _defaults = self.config.get('defaults', {}) + + for k, v in _defaults.items(): + unescaped_value = ( + unescape_template_tag(v) if isinstance(v, str) else str(v) + ) + + _defaults[k] = yaml.safe_load( + Template(unescaped_value).render(env=self.env) + ) + + self.defaults = _defaults + + def _load_env(self): + self.env = dict(os.environ) + + env_file = self.config.get('env-file', '') + + if not env_file: + return + + if not env_file.startswith('/'): + # use .sugar file as reference for the working + # directory for the .env file + env_file = str(Path(self.config_file).parent / env_file) + + if not Path(env_file).exists(): + KxgrLogs.raise_error( + 'The given env-file was not found.', + KxgrErrorType.KXGR_INVALID_CONFIGURATION, + ) + self.env.update(dotenv.dotenv_values(env_file)) + + def _load_service_names(self): + services = self.service_group['services'] + + if self.args.get('all'): + self.service_names = [ + v['name'] + for v in self.service_group.get('services', {}).get( + 'available' + ) + ] + elif self.args.get('services') == '': + KxgrLogs.raise_error( + 'If you want to execute the operation for all services, ' + 'use --all parameter.', + KxgrErrorType.KXGR_INVALID_PARAMETER, + ) + elif self.args.get('services'): + self.service_names = self.args.get('services').split(',') + elif 'default' in services and services['default']: + self.service_names = services['default'].split(',') + + def _verify_args(self): + if not self._check_config_file(): + KxgrLogs.raise_error( + 'Config file .sugar.yaml not found.', + KxgrErrorType.KXGR_INVALID_CONFIGURATION, + ) + + if ( + self.args.get('action') + and self.args.get('action') not in self.actions + ): + KxgrLogs.raise_error( + f'The given action `{self.args.get("action")}` is not ' + f'valid. Use one of them: {",".join(self.actions)}.', + KxgrErrorType.KXGR_INVALID_PARAMETER, + ) + + def _verify_config(self): + if not len(self.config['groups']): + KxgrLogs.raise_error( + 'No service groups found.', + KxgrErrorType.KXGR_INVALID_CONFIGURATION, + ) + + def run(self): + """Run the given sugar command.""" + action = self.args.get('action') + if not isinstance(action, str): + KxgrLogs.raise_error( + 'The given action is not valid.', + KxgrErrorType.KXGR_INVALID_PARAMETER, + ) + return getattr(self, f'_{action.replace("-", "_")}')() + + +class SugarDockerCompose(SugarBase): + """ + SugarDockerCompose provides the docker compose commands. + + This are the commands that is currently provided: + + build [options] [SERVICE...] + config [options] + create [options] [SERVICE...] + down [options] [--rmi type] [--volumes] [--remove-orphans] + events [options] [SERVICE...] + exec [options] SERVICE COMMAND [ARGS...] + images [options] [SERVICE...] + kill [options] [SERVICE...] + logs [options] [SERVICE...] + pause [options] SERVICE... + port [options] SERVICE PRIVATE_PORT + ps [options] [SERVICE...] + pull [options] [SERVICE...] + push [options] [SERVICE...] + restart [options] [SERVICE...] + rm [options] [-f | -s] [SERVICE...] + run [options] [-p TARGET...] [-v VOLUME...] [-e KEY=VAL...] + [-l KEY=VAL...] SERVICE [COMMAND] [ARGS...] + start [options] [SERVICE...] + stop [options] [SERVICE...] + top [options] [SERVICE...] + unpause [options] SERVICE... + up [options] [--scale SERVICE=NUM...] [--no-color] + [--quiet-pull] [SERVICE...] + version [options] + """ + + actions: list[str] = [ + 'build', + 'config', + 'create', + 'down', + 'events', + 'exec', + 'images', + 'kill', + 'logs', + 'pause', + 'port', + 'ps', + 'pull', + 'push', + 'restart', + 'rm', + 'run', + 'start', + 'stop', + 'top', + 'unpause', + 'up', + 'version', + ] + + def __init__(self, *args, **kwargs): + """Initialize SugarDockerCompose instance.""" + super().__init__(*args, **kwargs) + + # container commands + def _build(self): + self._call_compose_app('build', services=self.service_names) + + def _config(self): + self._call_compose_app('config', services=self.service_names) + + def _create(self): + self._call_compose_app('create', services=self.service_names) + + def _down(self): + if self.args.get('all') or self.args.get('services'): + KxgrLogs.raise_error( + "The `down` sub-command doesn't accept `--all` " + 'neither `--services` parameters.', + KxgrErrorType.KXGR_INVALID_PARAMETER, + ) + + self._call_compose_app( + 'down', + '--remove-orphans', + services=[], + ) + + def _events(self): + # port is not complete supported + if not self.args.get('service'): + KxgrLogs.raise_error( + '`exec` sub-command expected --service parameter.', + KxgrErrorType.KXGR_MISSING_PARAMETER, + ) + self._call_compose_app('events', services=[self.args.get('service')]) + + def _exec(self): + if not self.args.get('service'): + KxgrLogs.raise_error( + '`exec` sub-command expected --service parameter.', + KxgrErrorType.KXGR_MISSING_PARAMETER, + ) + + self._call_compose_app('exec', services=[self.args.get('service')]) + + def _images(self): + self._call_compose_app('images', services=self.service_names) + + def _kill(self): + self._call_compose_app('kill', services=self.service_names) + + def _logs(self): + self._call_compose_app('logs', services=self.service_names) + + def _pause(self): + self._call_compose_app('pause', services=self.service_names) + + def _port(self): + # port is not complete supported + if not self.args.get('service'): + KxgrLogs.raise_error( + '`exec` sub-command expected --service parameter.', + KxgrErrorType.KXGR_MISSING_PARAMETER, + ) + # TODO: check how private port could be passed + self._call_compose_app('port', services=[self.args.get('service')]) + + def _ps(self): + self._call_compose_app('ps', services=self.service_names) + + def _pull(self): + self._call_compose_app('pull', services=self.service_names) + + def _push(self): + self._call_compose_app('push', services=self.service_names) + + def _restart(self): + self._call_compose_app('restart', services=self.service_names) + + def _rm(self): + self._call_compose_app('rm', services=self.service_names) + + def _run(self): + if not self.args.get('service'): + KxgrLogs.raise_error( + '`run` sub-command expected --service parameter.', + KxgrErrorType.KXGR_MISSING_PARAMETER, + ) + + self._call_compose_app('run', services=[self.args.get('service')]) + + def _start(self): + self._call_compose_app('start', services=self.service_names) + + def _stop(self): + self._call_compose_app('stop', services=self.service_names) + + def _top(self): + self._call_compose_app('top', services=self.service_names) + + def _unpause(self): + self._call_compose_app('unpause', services=self.service_names) + + def _up(self): + self._call_compose_app('up', services=self.service_names) + + def _version(self): + self._call_compose_app('version', services=[]) diff --git a/src/sugar/plugins/ext.py b/src/sugar/plugins/ext.py new file mode 100644 index 0000000..c865cb1 --- /dev/null +++ b/src/sugar/plugins/ext.py @@ -0,0 +1,42 @@ +"""SugarExt Plugin class for containers.""" +from __future__ import annotations + +from sugar.logs import KxgrErrorType, KxgrLogs +from sugar.plugins.base import SugarDockerCompose + + +class SugarExt(SugarDockerCompose): + """SugarExt provides special commands not available on docker-compose.""" + + def __init__(self, *args, **kwargs): + """Initialize the SugarExt class.""" + self.actions += [ + 'get-ip', + 'restart', + 'start', + 'wait', + ] + + super().__init__(*args, **kwargs) + + def _get_ip(self): + KxgrLogs.raise_error( + '`get-ip` mot implemented yet.', + KxgrErrorType.KXGR_ACTION_NOT_IMPLEMENTED, + ) + + def _restart(self): + options = self.options_args + self.options_args = [] + self._stop() + self.options_args = options + self._start() + + def _start(self): + self._up() + + def _wait(self): + KxgrLogs.raise_error( + '`wait` not implemented yet.', + KxgrErrorType.KXGR_ACTION_NOT_IMPLEMENTED, + ) diff --git a/src/sugar/plugins/stats.py b/src/sugar/plugins/stats.py new file mode 100644 index 0000000..fcf22c4 --- /dev/null +++ b/src/sugar/plugins/stats.py @@ -0,0 +1,237 @@ +"""Sugar Plugin for Containers Statics.""" +from __future__ import annotations + +import io +import re +import subprocess # nosec B404 +import time + +import plotille + +from rich.text import Text +from textual.app import App, ComposeResult +from textual.reactive import Reactive +from textual.widget import Widget +from textual.widgets import Header + +from sugar.inspect import get_container_name +from sugar.logs import KxgrErrorType, KxgrLogs +from sugar.plugins.base import SugarDockerCompose + + +def get_container_stats(container_name: str) -> tuple[float, float]: + """ + Fetch the current memory and CPU usage of a given Docker container. + + Parameters + ---------- + container_name (str): Name of the Docker container. + + Returns + ------- + tuple: + The current memory usage of the container in MB and CPU usage as + a percentage. + """ + command = ( + f'docker stats {container_name} --no-stream --format ' + f"'{{{{.MemUsage}}}} {{{{.CPUPerc}}}}'" + ) + result = subprocess.run( # nosec B602, B603 + command, capture_output=True, text=True, shell=True, check=False + ) + output = result.stdout.strip().split() + mem_usage_str = output[0].split('/')[0].strip() + cpu_usage_str = output[-1].strip('%') + + mem_usage = float(re.sub(r'[^\d.]', '', mem_usage_str)) + cpu_usage = float(cpu_usage_str) + + return mem_usage, cpu_usage + + +class StatsPlot: + """Plot containers statistic data.""" + + def __init__( + self, + container_names: list[str], + window_duration: int = 60, + interval: int = 1, + ): + """ + Initialize StatsPlot. + + Parameters + ---------- + container_names: list + Names of the Docker containers. + window_duration: int + Duration of the window frame for the data in seconds. + interval: int + Interval between data points, in seconds. + """ + self.container_names = container_names + self.window_duration = window_duration + self.interval = interval + self.start_time = time.time() + + self.fig_mem = plotille.Figure() + self.fig_cpu = plotille.Figure() + + self.stats: dict[str, dict[str, list[str]]] = { + name: {'times': [], 'mem_usages': [], 'cpu_usages': []} + for name in container_names + } + + def plot_stats(self): + """ + Plot containers statistic. + + Plots the memory and CPU usage of multiple Docker containers over + time in a single chart for each metric. + """ + self.fig_mem = plotille.Figure() + self.fig_mem.width = 50 + self.fig_mem.height = 5 + self.fig_cpu = plotille.Figure() + self.fig_cpu.width = 50 + self.fig_cpu.height = 5 + + current_time = time.time() - self.start_time + + for name in self.container_names: + mem_usage, cpu_usage = get_container_stats(name) + + # Update and maintain window for stats + container_stats = self.stats[name] + container_stats['times'].append(round(current_time, 2)) + container_stats['mem_usages'].append(round(mem_usage, 2)) + container_stats['cpu_usages'].append(round(cpu_usage, 2)) + + if len(container_stats['times']) > self.window_duration: + container_stats['times'] = container_stats['times'][ + -self.window_duration : + ] + container_stats['mem_usages'] = container_stats['mem_usages'][ + -self.window_duration : + ] + container_stats['cpu_usages'] = container_stats['cpu_usages'][ + -self.window_duration : + ] + + for name in self.container_names: + container_stats = self.stats[name] + # Add data to plots + self.fig_mem.plot( + container_stats['times'], + container_stats['mem_usages'], + label=name, + ) + self.fig_cpu.plot( + container_stats['times'], + container_stats['cpu_usages'], + label=name, + ) + + +class StatsPlotWidget(Widget): + """Plot Docker Stats Widget.""" + + content: Reactive[str] = Reactive('') + + DEFAULT_CSS = """ + Plot { + width: 100%; + height: 100%; + } + """ + + def __init__(self, container_names: list[str], *args, **kwargs) -> None: + """Initialize StatsPlotWidget.""" + self.container_names = container_names + super().__init__(*args, **kwargs) + + def on_mount(self) -> None: + """Set up the widget.""" + # Set up a periodic update, adjust the interval as needed + interval_time = 1 + self.set_interval( + interval_time, self.update_plot + ) # Update every second + self.stats_plot = StatsPlot( + container_names=self.container_names, + window_duration=60, + interval=interval_time, + ) + + async def update_plot(self) -> None: + """Update plot data.""" + self.stats_plot.plot_stats() + self.content = ( + 'Memory Usage (MB):\n' + + self.stats_plot.fig_mem.show(legend=False) + + '\n\nCPU Usage (%):\n' + + self.stats_plot.fig_cpu.show(legend=True) + ) + + def render(self) -> Text: + """Render the widget.""" + return Text.from_ansi(self.content) + + +class StatsPlotApp(App[str]): + """StatsPlotApp app class.""" + + TITLE = 'Containers Stats' + container_names: list[str] + + def __init__(self, container_names: list[str], *args, **kwargs) -> None: + """Initialize StatsPlotApp.""" + self.container_names = container_names + super().__init__(*args, **kwargs) + + def compose(self) -> ComposeResult: + """Compose the app.""" + yield Header() + yield StatsPlotWidget(self.container_names) + + +class SugarStats(SugarDockerCompose): + """SugarStats provides special commands not available on docker-compose.""" + + def __init__(self, *args, **kwargs): + """Initialize the SugarExt class.""" + self.actions += [ + 'plot', + ] + + super().__init__(*args, **kwargs) + + def _plot(self): + """Call the plot command.""" + _out = io.StringIO() + _err = io.StringIO() + + self._call_compose_app_core( + 'ps', + services=self.service_names, + options_args=['-q'], + _out=_out, + _err=_err, + ) + + raw_out = _out.getvalue() + + if not raw_out: + service_names = ', '.join(self.service_names) + KxgrLogs.raise_error( + f'No container found for the services: {service_names}', + KxgrErrorType.KXGR_NO_SERVICES_RUNNING, + ) + + containers_ids = [cids for cids in raw_out.split('\n') if cids] + + containers_names = [get_container_name(cid) for cid in containers_ids] + app = StatsPlotApp(containers_names) + app.run()