diff --git a/cli_error_handling.py b/cli_error_handling.py new file mode 100644 index 000000000000..de0d1616fd11 --- /dev/null +++ b/cli_error_handling.py @@ -0,0 +1,111 @@ +from __future__ import annotations + +from dataclasses import dataclass +from enum import Enum +from typing import Optional + + +class ErrorCategory(str, Enum): + + + BANCO_DE_DADOS = 'BANCO_DE_DADOS' + REDE = 'REDE' + PERMISSAO = 'PERMISSAO' + AMBIENTE = 'AMBIENTE' + SISTEMA_DESCONHECIDO = 'SISTEMA_DESCONHECIDO' + + + +_DEFAULT_MESSAGES = { + ErrorCategory.SISTEMA_DESCONHECIDO: 'Ocorreu uma falha interna inesperada', +} + + +_SUGGESTIONS = { + ErrorCategory.PERMISSAO: "Execute 'chmod +w ' ou ajuste as permissoes do diretorio", + ErrorCategory.AMBIENTE: "Execute 'invoke install' para reinstalar as dependencias corretas", + ErrorCategory.BANCO_DE_DADOS: 'Verifique se o servico de banco de dados esta ativo e acessivel', + ErrorCategory.REDE: 'Verifique a conectividade de rede e as credenciais do endpoint remoto', +} + + +@dataclass +class StructuredError: + + + category: ErrorCategory + message: str + suggestion: Optional[str] = None + + +class ExceptionCategorizer: + + _TYPE_NAME_MAP = { + 'OperationalError': ErrorCategory.BANCO_DE_DADOS, + 'InterfaceError': ErrorCategory.BANCO_DE_DADOS, + 'DatabaseError': ErrorCategory.BANCO_DE_DADOS, + 'PermissionError': ErrorCategory.PERMISSAO, + 'ModuleNotFoundError': ErrorCategory.AMBIENTE, + 'ImportError': ErrorCategory.AMBIENTE, + 'ConnectionError': ErrorCategory.REDE, + 'ConnectionRefusedError': ErrorCategory.REDE, + 'TimeoutError': ErrorCategory.REDE, + 'URLError': ErrorCategory.REDE, + } + + + _KEYWORD_MAP = ( + (('porta', 'connection refused', 'database', 'banco de dados'), ErrorCategory.BANCO_DE_DADOS), + (('network', 'rede', 'timeout', 'dns'), ErrorCategory.REDE), + (('permission', 'permissao', 'read-only', 'access is denied'), ErrorCategory.PERMISSAO), + ) + + def classify(self, exc: BaseException) -> ErrorCategory: + + type_name = type(exc).__name__ + if type_name in self._TYPE_NAME_MAP: + return self._TYPE_NAME_MAP[type_name] + + message = str(exc).lower() + for keywords, category in self._KEYWORD_MAP: + if any(keyword in message for keyword in keywords): + return category + + return ErrorCategory.SISTEMA_DESCONHECIDO + + +class SuggestionProvider: + + + def get_suggestion(self, category: ErrorCategory) -> Optional[str]: + + return _SUGGESTIONS.get(category) + + +def build_structured_error( + exc: BaseException, + categorizer: Optional[ExceptionCategorizer] = None, + suggestion_provider: Optional[SuggestionProvider] = None, +) -> StructuredError: + + categorizer = categorizer or ExceptionCategorizer() + suggestion_provider = suggestion_provider or SuggestionProvider() + + category = categorizer.classify(exc) + message = str(exc).strip() or _DEFAULT_MESSAGES.get( + category, 'Ocorreu uma falha inesperada' + ) + + return StructuredError( + category=category, + message=message, + suggestion=suggestion_provider.get_suggestion(category), + ) + + +def format_structured_error(error: StructuredError) -> str: + + lines = [f'[ERRO: {error.category.value}] {error.message}'] + if error.suggestion: + lines.append(f'SUGESTAO: {error.suggestion}') + return '\n'.join(lines) \ No newline at end of file diff --git a/tasks.py b/tasks.py index 1e99fdbbb5d0..857fdc5c316f 100644 --- a/tasks.py +++ b/tasks.py @@ -19,6 +19,7 @@ import invoke from invoke import Collection, task from invoke.exceptions import Exit, UnexpectedExit +from cli_error_handling import build_structured_error, format_structured_error def safe_value(fnc): @@ -143,21 +144,14 @@ def task_exception_handler(t, v, tb): """Handle exceptions raised by tasks. The intent here is to provide more 'useful' error messages when tasks fail. + Errors are categorized and rendered in the standard + `[ERRO: CATEGORIA] mensagem` format (US01), with an optional corrective + action suggestion appended when one is known (US02). """ sys.__excepthook__(t, v, tb) - if t is ModuleNotFoundError: - mod_name = str(v).split(' ')[-1].strip("'") - - error(f'Error importing required module: {mod_name}') - warning('- Ensure the correct Python virtual environment is active') - warning( - '- Ensure that the invoke tool is installed in the active Python environment' - ) - warning( - "- Ensure all required packages are installed by running 'invoke install'" - ) - + structured = build_structured_error(v) + error(format_structured_error(structured)) sys.excepthook = task_exception_handler diff --git a/test_cli_error_handling.py b/test_cli_error_handling.py new file mode 100644 index 000000000000..fa7fd76371db --- /dev/null +++ b/test_cli_error_handling.py @@ -0,0 +1,70 @@ +"""Tests for cli_error_handling.py (US01 / US02). + +These mirror the test scenarios documented in +documentacao/cenarios_de_teste. +""" + +from cli_error_handling import ( + ErrorCategory, + build_structured_error, + format_structured_error, +) + + +class FakeOperationalError(Exception): + """Stand-in for a DB driver's OperationalError (avoids a psycopg2 dependency in tests).""" + + +FakeOperationalError.__name__ = 'OperationalError' + + +def test_us01_dados_validos_categoriza_erro_de_banco(): + """Cenario: queda do banco simulada -> [ERRO: BANCO_DE_DADOS] ...""" + exc = FakeOperationalError('Falha de conexao na porta 5432') + result = build_structured_error(exc) + + assert result.category == ErrorCategory.BANCO_DE_DADOS + output = format_structured_error(result) + assert output.startswith('[ERRO: BANCO_DE_DADOS] Falha de conexao na porta 5432') + + +def test_us01_excecao_nao_mapeada_cai_em_sistema_desconhecido(): + """Cenario: erro desconhecido -> [ERRO: SISTEMA_DESCONHECIDO] ...""" + exc = RuntimeError('') # sem mensagem, tipo nao mapeado + result = build_structured_error(exc) + + assert result.category == ErrorCategory.SISTEMA_DESCONHECIDO + assert format_structured_error(result) == ( + '[ERRO: SISTEMA_DESCONHECIDO] Ocorreu uma falha interna inesperada' + ) + + +def test_us02_categoria_com_solucao_conhecida_exibe_sugestao(): + """Cenario: erro de escrita -> linha SUGESTAO: ... e' anexada.""" + exc = PermissionError('Arquivo de config ilegivel') + result = build_structured_error(exc) + output = format_structured_error(result) + + assert output == ( + '[ERRO: PERMISSAO] Arquivo de config ilegivel\n' + "SUGESTAO: Execute 'chmod +w ' ou ajuste as permissoes do diretorio" + ) + + +def test_us02_categoria_sem_solucao_omite_linha_de_sugestao(): + """Cenario: erro sem tratativa cadastrada -> omite a linha de sugestao.""" + exc = RuntimeError('falha nao mapeada qualquer') + result = build_structured_error(exc) + output = format_structured_error(result) + + assert 'SUGESTAO' not in output + + +def test_ambiente_reaproveita_categoria_ja_tratada_pelo_task_exception_handler(): + """ModuleNotFoundError ja era tratado manualmente em tasks.py; garante + que a nova categorizacao cobre o mesmo caso sem duplicar logica. + """ + exc = ModuleNotFoundError("No module named 'invoke'") + result = build_structured_error(exc) + + assert result.category == ErrorCategory.AMBIENTE \ No newline at end of file