Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
111 changes: 111 additions & 0 deletions cli_error_handling.py
Original file line number Diff line number Diff line change
@@ -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 <arquivo>' 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)
18 changes: 6 additions & 12 deletions tasks.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down Expand Up @@ -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

Expand Down
70 changes: 70 additions & 0 deletions test_cli_error_handling.py
Original file line number Diff line number Diff line change
@@ -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 <arquivo>' 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