diff --git a/carnival/__init__.py b/carnival/__init__.py index 7b1beb8..a46637f 100644 --- a/carnival/__init__.py +++ b/carnival/__init__.py @@ -1,8 +1,8 @@ import sys import dotenv import os -from carnival.step import Step, InlineStep from carnival.hosts.base import Host, Connection, Result +from carnival.steps import Step, InlineStep from carnival.role import Role, SingleRole from carnival.hosts.local import LocalHost, localhost_connection from carnival.hosts.ssh import SshHost diff --git a/carnival/exceptions.py b/carnival/exceptions.py index 024c5c1..d520029 100644 --- a/carnival/exceptions.py +++ b/carnival/exceptions.py @@ -10,9 +10,3 @@ class GlobalConnectionError(CarnivalException): """ Global connection switching error """ - - -class StepValidationError(CarnivalException): - """ - Ошибка валидации шага - """ diff --git a/carnival/steps/__init__.py b/carnival/steps/__init__.py new file mode 100644 index 0000000..6e51a27 --- /dev/null +++ b/carnival/steps/__init__.py @@ -0,0 +1,7 @@ +from carnival.steps.step import Step, InlineStep + + +__all__ = ( + "Step", + "InlineStep", +) diff --git a/carnival/steps/_validator_cache.py b/carnival/steps/_validator_cache.py new file mode 100644 index 0000000..da3be50 --- /dev/null +++ b/carnival/steps/_validator_cache.py @@ -0,0 +1,46 @@ +""" + Кеширование валидаторов для Steps + Кеширует значение по Type[Step], Host, fact_id:str(задается в валидаторе) +""" + +import typing + +if typing.TYPE_CHECKING: + from carnival import Host + from carnival.steps.validators import StepValidatorBase + + +StepValidatorBaseT = typing.TypeVar("StepValidatorBaseT", bound="StepValidatorBase") +__vc: typing.Dict[str, typing.Optional[str]] = {} + + +def __build_vc_key(step_class: typing.Type[StepValidatorBaseT], host: "Host", fact_id: str) -> str: + from carnival.utils import get_class_full_name + return f"{get_class_full_name(step_class)}::{host.addr}::{fact_id}" + + +def try_get( + step_class: typing.Type[StepValidatorBaseT], + host: "Host", + fact_id: str, +) -> typing.Tuple[bool, typing.Optional[str]]: + global __vc + + return ( + __build_vc_key(step_class=step_class, host=host, fact_id=fact_id) in __vc, + __vc.get( + __build_vc_key(step_class=step_class, host=host, fact_id=fact_id), + None, + ) + ) + + +def set(step_class: typing.Type[StepValidatorBaseT], host: "Host", fact_id: str, val: typing.Optional[str]) -> None: + global __vc + cachekey = __build_vc_key(step_class=step_class, host=host, fact_id=fact_id) + + is_exist, *_ = try_get(step_class=step_class, host=host, fact_id=fact_id) + if is_exist: + raise ValueError(f"Broken cache: '{cachekey}' already exist!") + + __vc[cachekey] = val diff --git a/carnival/step.py b/carnival/steps/step.py similarity index 80% rename from carnival/step.py rename to carnival/steps/step.py index ea2dbf5..7499c74 100644 --- a/carnival/step.py +++ b/carnival/steps/step.py @@ -1,8 +1,8 @@ import abc import typing - if typing.TYPE_CHECKING: + from carnival.steps.validators import StepValidatorBase from carnival import Connection @@ -25,27 +25,34 @@ class Step: >>> ... """ + def __init__(self) -> None: pass def get_name(self) -> str: return self.__class__.__name__ - def validate(self, c: "Connection") -> None: + def get_validators(self) -> typing.List["StepValidatorBase"]: + """ + Получить список валидаторов для метода `.validate` + """ + return [] + + def validate(self, c: "Connection") -> typing.List[str]: """ Валидатор шага, запускается перед выполнением - Должен выкидывать .StepValidationError в случае ошибки + :param c: Конект к хосту + :return: Список ошибок + """ - :param host: На котором будет выполнен шаг + errors: typing.List[str] = [] - :raises StepValidationError: в случае ошибок валидации + for validator in self.get_validators(): + err = validator.validate(c=c) + if err is not None: + errors.append(err) - >>> from carnival.exceptions import StepValidationError - >>> ... - >>> def validate(self, c: "Connection") -> None: - >>> raise StepValidationError("Step validation is not implemented") - """ - pass + return errors @abc.abstractmethod def run(self, c: "Connection") -> typing.Any: diff --git a/carnival/steps/validators.py b/carnival/steps/validators.py new file mode 100644 index 0000000..4b673a2 --- /dev/null +++ b/carnival/steps/validators.py @@ -0,0 +1,81 @@ +import typing +import abc + +from carnival import cmd +from carnival.steps import _validator_cache +from carnival.templates import render + +from jinja2.exceptions import UndefinedError, TemplateNotFound, TemplateSyntaxError + + +if typing.TYPE_CHECKING: + from carnival import Connection + from carnival.steps.step import Step + + +class StepValidatorBase: + def __init__(self, step: "Step"): + self.step = step + + @abc.abstractmethod + def validate(self, c: "Connection") -> typing.Optional[str]: + raise NotImplementedError + + +class InlineValidator(StepValidatorBase): + def __init__( + self, + if_err_true_fn: typing.Callable[["Connection"], bool], + error_message: str, + fact_id_for_caching: typing.Optional[str] = None, + ): + self.if_err_true_fn = if_err_true_fn + self.error_message = error_message + self.fact_id_for_caching = fact_id_for_caching + + def validate(self, c: "Connection") -> typing.Optional[str]: + if self.fact_id_for_caching is not None: + is_exist, val = _validator_cache.try_get(self.__class__, c.host, self.fact_id_for_caching) + if is_exist: + return val + + val = None + if self.if_err_true_fn(c): + val = self.error_message + + if self.fact_id_for_caching is not None: + _validator_cache.set(self.__class__, c.host, self.fact_id_for_caching, val=val) + return val + + +class CommandRequiredValidator(StepValidatorBase): + def __init__(self, command: str) -> None: + self.command = command + self.fact_id = f"path-{command}-required" + + def validate(self, c: "Connection") -> typing.Optional[str]: + is_exist, val = _validator_cache.try_get(self.__class__, c.host, self.fact_id) + + if is_exist: + return val + + val = None + if not cmd.cli.is_cmd_exist(c, self.command): + val = f"'{self.command}' is required" + + _validator_cache.set(self.__class__, c.host, self.fact_id, val=val) + return val + + +class TemplateValidator(StepValidatorBase): + def __init__(self, template_path: str, context: typing.Dict[str, typing.Any]): + self.template_path = template_path + self.context = context + + def validate(self, c: "Connection") -> typing.Optional[str]: + # TODO: more catch errors maybe? + try: + render(self.template_path, **self.context) + except (UndefinedError, TemplateNotFound, TemplateSyntaxError) as ex: + return f"{ex.__class__.__name__}: {ex}" + return None diff --git a/carnival/task.py b/carnival/task.py index 2060c73..718ad04 100644 --- a/carnival/task.py +++ b/carnival/task.py @@ -5,9 +5,9 @@ from colorama import Fore as F, Style as S, Back as B # type: ignore -from carnival import Step -from carnival.role import Role -from carnival.exceptions import StepValidationError +if typing.TYPE_CHECKING: + from carnival.role import Role + from carnival import Step def _underscore(word: str) -> str: @@ -89,7 +89,7 @@ def run(self) -> None: raise NotImplementedError -RoleT = typing.TypeVar("RoleT", bound=Role) +RoleT = typing.TypeVar("RoleT", bound="Role") class Task(abc.ABC, typing.Generic[RoleT], TaskBase): @@ -118,7 +118,7 @@ def __init__(self, no_validate: bool) -> None: print(f"[WARN]: not hosts for {self.role_class}", file=sys.stderr) @abc.abstractmethod - def get_steps(self) -> typing.List[Step]: + def get_steps(self) -> typing.List["Step"]: """ Список шагов в порядке выполнения """ @@ -134,20 +134,22 @@ def validate(self) -> bool: from carnival.cli import carnival_tasks_module from carnival.tasks_loader import get_task_full_name task_name = get_task_full_name(carnival_tasks_module, self.__class__) - print(f"Validating task {S.BRIGHT}{F.BLUE}{task_name}{F.RESET}{S.RESET_ALL}", end="", flush=True) + print(f"Validating task {S.BRIGHT}{F.BLUE}{task_name}{F.RESET}{S.RESET_ALL} ", end="", flush=True) errors: typing.List[str] = [] for hostrole in self.hostroles: with hostrole.host.connect() as c: self.role = hostrole for step in self.get_steps(): - try: - step.validate(c=c) + step_errors = step.validate(c=c) + + if not step_errors: print(f"{F.GREEN}.{F.RESET}", end="", flush=True) - except StepValidationError as ex: + else: step_name = step.get_name() - errors.append(f"{task_name} -> {step_name} on {hostrole.host}: {F.RED}{ex}{F.RESET}") - print("{Fore.RED}e{Fore.RESET}", end="", flush=True) + for e in step_errors: + errors.append(f"{task_name} -> {step_name} on {hostrole.host}: {F.RED}{e}{F.RESET}") + print(f"{F.RED}e{F.RESET}", end="", flush=True) del self.role if errors: diff --git a/carnival/templates.py b/carnival/templates.py index 6a48a3a..ed7dce7 100644 --- a/carnival/templates.py +++ b/carnival/templates.py @@ -9,10 +9,18 @@ PrefixLoader, ) from jinja2.runtime import StrictUndefined -from jinja2.exceptions import UndefinedError +from jinja2.exceptions import UndefinedError, TemplateSyntaxError from carnival.plugins import discover_plugins + +def escape_yaml(data: str) -> str: + """ + Jinja2 filter for escape yaml dollar sign + """ + return data.replace("$", "$$") + + """ Initialize loader on current working dir and plugin modules """ @@ -21,10 +29,14 @@ FileSystemLoader(os.getcwd()), PrefixLoader({x: PackageLoader(x, package_path="") for x in discover_plugins().keys()}), ]), + keep_trailing_newline=True, undefined=StrictUndefined, ) +j2_env.filters['escape_yaml'] = escape_yaml + + def render(template_path: str, **context: Any) -> str: """ Отрендерить jinja2-шаблон в строку @@ -36,4 +48,10 @@ def render(template_path: str, **context: Any) -> str: template = j2_env.get_template(template_path) return template.render(**context) except UndefinedError as ex: - raise UndefinedError(f"Can't render template {template_path}: {ex}") + raise UndefinedError(f"Can't render template {template_path} - {ex}") from ex + except TemplateSyntaxError as ex: + raise TemplateSyntaxError( + message=f"Can't render template {template_path}:{ex.lineno} - {ex.message}", + lineno=ex.lineno, + filename=ex.filename, + ) from ex diff --git a/carnival_tasks_example.py b/carnival_tasks_example.py index e966682..9e3de5f 100644 --- a/carnival_tasks_example.py +++ b/carnival_tasks_example.py @@ -7,7 +7,6 @@ import os from carnival import cmd, TaskBase, SshHost, Step, Task, Connection, Role -from carnival.exceptions import StepValidationError class PackagesRole(Role): @@ -34,9 +33,12 @@ def __init__(self, packages: typing.List[str], update: bool = True) -> None: self.packages = self.packages.strip() self.update = update - def validate(self, c: Connection) -> None: + def validate(self, c: Connection) -> typing.List[str]: + errors = [] if not self.packages: - raise StepValidationError("packages cant be empty!") + errors.append("packages cant be empty!") + + return errors def run(self, c: Connection) -> None: if self.update: diff --git a/tests/test_utils.py b/tests/test_utils.py index 4518f0b..e69de29 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -1,7 +0,0 @@ -from carnival import utils, LocalHost - - -def test_log(capsys): - utils.log("Hellotest", host=LocalHost()) - captured = capsys.readouterr() - assert captured.out == "💃💃💃 🖥 local> Hellotest\n"