protoncloud-sdk 5.3.2__tar.gz → 5.4.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {protoncloud_sdk-5.3.2 → protoncloud_sdk-5.4.0}/PKG-INFO +79 -8
- {protoncloud_sdk-5.3.2 → protoncloud_sdk-5.4.0}/README.md +78 -7
- {protoncloud_sdk-5.3.2 → protoncloud_sdk-5.4.0}/pyproject.toml +1 -1
- {protoncloud_sdk-5.3.2 → protoncloud_sdk-5.4.0}/src/proton/component_runner.py +20 -5
- {protoncloud_sdk-5.3.2 → protoncloud_sdk-5.4.0}/src/proton/proton_automation.py +17 -5
- {protoncloud_sdk-5.3.2 → protoncloud_sdk-5.4.0}/src/proton/proton_env.py +47 -1
- protoncloud_sdk-5.4.0/src/proton/proton_logs.py +138 -0
- protoncloud_sdk-5.4.0/src/proton/run_status.py +60 -0
- {protoncloud_sdk-5.3.2 → protoncloud_sdk-5.4.0}/tests/test_component_runner.py +67 -9
- protoncloud_sdk-5.4.0/tests/test_observabilidade.py +135 -0
- {protoncloud_sdk-5.3.2 → protoncloud_sdk-5.4.0}/tests/test_run_status.py +9 -8
- protoncloud_sdk-5.3.2/src/proton/proton_logs.py +0 -68
- protoncloud_sdk-5.3.2/src/proton/run_status.py +0 -21
- {protoncloud_sdk-5.3.2 → protoncloud_sdk-5.4.0}/.gitignore +0 -0
- {protoncloud_sdk-5.3.2 → protoncloud_sdk-5.4.0}/LICENSE +0 -0
- {protoncloud_sdk-5.3.2 → protoncloud_sdk-5.4.0}/src/proton/__init__.py +0 -0
- {protoncloud_sdk-5.3.2 → protoncloud_sdk-5.4.0}/src/proton/proton_client_parama.py +0 -0
- {protoncloud_sdk-5.3.2 → protoncloud_sdk-5.4.0}/src/proton/proton_environment.py +0 -0
- {protoncloud_sdk-5.3.2 → protoncloud_sdk-5.4.0}/src/proton/proton_files_and_resources.py +0 -0
- {protoncloud_sdk-5.3.2 → protoncloud_sdk-5.4.0}/src/proton/proton_git.py +0 -0
- {protoncloud_sdk-5.3.2 → protoncloud_sdk-5.4.0}/src/proton/proton_parameter.py +0 -0
- {protoncloud_sdk-5.3.2 → protoncloud_sdk-5.4.0}/tests/test_dados_da_execucao.py +0 -0
- {protoncloud_sdk-5.3.2 → protoncloud_sdk-5.4.0}/tests/test_download_do_app.py +0 -0
- {protoncloud_sdk-5.3.2 → protoncloud_sdk-5.4.0}/tests/test_proton_env.py +0 -0
- {protoncloud_sdk-5.3.2 → protoncloud_sdk-5.4.0}/tests/test_proton_environment.py +0 -0
- {protoncloud_sdk-5.3.2 → protoncloud_sdk-5.4.0}/tests/test_proton_parameter.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: protoncloud-sdk
|
|
3
|
-
Version: 5.
|
|
3
|
+
Version: 5.4.0
|
|
4
4
|
Summary: Proton Cloud SDK para Python: execução de automações, ambientes, logs, parâmetros e recursos.
|
|
5
5
|
Project-URL: Homepage, https://protoncloud.com.br
|
|
6
6
|
Author: Atomic Solutions
|
|
@@ -230,6 +230,53 @@ set_log(f"url={url} senha={senha}") # no log: url=•••••• senha=•
|
|
|
230
230
|
mascarados.
|
|
231
231
|
- Para os logs próprios do script, use `mask_environment_values(texto)`.
|
|
232
232
|
|
|
233
|
+
## Log da execução
|
|
234
|
+
|
|
235
|
+
Cada linha do log tem um nível: Informação (`info`, o padrão), Alerta (`warning`),
|
|
236
|
+
Erro (`error`) ou Crítico (`critical`). A tela da execução filtra por nível e
|
|
237
|
+
destaca alertas e erros, e a busca do Proton acha a linha entre execuções.
|
|
238
|
+
|
|
239
|
+
```python
|
|
240
|
+
from proton.proton_logs import log_error, log_info, log_warning, set_log
|
|
241
|
+
|
|
242
|
+
log_info("Abrindo a transação BP")
|
|
243
|
+
log_warning("Cadastro já existe: seguindo para o próximo", context={"cnpj": cnpj})
|
|
244
|
+
log_error("Timeout no LoginSAP", context={"tentativa": 3})
|
|
245
|
+
set_log("Mensagem com o nível escolhido", level="critical")
|
|
246
|
+
```
|
|
247
|
+
|
|
248
|
+
- `context` é um `dict` opcional que vai junto com a linha (o item processado, a
|
|
249
|
+
tentativa, o código de retorno). Os valores do ambiente saem mascarados nele
|
|
250
|
+
também.
|
|
251
|
+
- O `logging` do projeto, encaminhado pelo executor de componentes, chega com o
|
|
252
|
+
nível dele: `warning`, `error` e `critical` viram Alerta, Erro e Crítico.
|
|
253
|
+
- `set_log_from_exception(e)` manda o stack trace inteiro como uma linha de Erro.
|
|
254
|
+
- Numa execução do Proton, cada linha também sai na saída do processo, com a hora
|
|
255
|
+
e o nível. O runner sobe essa saída como o arquivo de log da execução: acima do
|
|
256
|
+
limite do log no Proton (10 mil linhas), é nele que o log completo fica.
|
|
257
|
+
|
|
258
|
+
## Correlation ID
|
|
259
|
+
|
|
260
|
+
Cada execução tem um Correlation ID, o mesmo que a tela do Proton mostra. O runner
|
|
261
|
+
o entrega em `PROTON_CORRELATION_ID` (runner 5.0.8 em diante), e a biblioteca o
|
|
262
|
+
envia em toda chamada ao Proton (`X-Correlation-Id` e `traceparent`, no formato
|
|
263
|
+
W3C Trace Context).
|
|
264
|
+
|
|
265
|
+
Para levá-lo às chamadas que a automação faz (SAP, APIs, portais), use
|
|
266
|
+
`correlation_headers()`. Com ele, o log do sistema chamado se acha pelo id da
|
|
267
|
+
execução:
|
|
268
|
+
|
|
269
|
+
```python
|
|
270
|
+
import requests
|
|
271
|
+
from proton.proton_env import correlation_headers, get_correlation_id
|
|
272
|
+
|
|
273
|
+
requests.post(url, json=pedido, headers={**correlation_headers(), "Authorization": token})
|
|
274
|
+
log_info(f"Pedido enviado ({get_correlation_id()})")
|
|
275
|
+
```
|
|
276
|
+
|
|
277
|
+
Fora do Proton, ou com um runner que ainda não entrega o id, `correlation_headers()`
|
|
278
|
+
devolve um dicionário vazio.
|
|
279
|
+
|
|
233
280
|
### Categoria do dataset (Proton 4)
|
|
234
281
|
|
|
235
282
|
No Proton 5 o dataset não tem categoria. `get_dataset_category()` e
|
|
@@ -258,12 +305,35 @@ from proton.run_status import RunStatus
|
|
|
258
305
|
update_run_status(RunStatus.FAILED)
|
|
259
306
|
```
|
|
260
307
|
|
|
261
|
-
O Proton 5 aceita `RUNNING`, `PASSED` e `FAILED`.
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
308
|
+
O Proton 5 aceita `RUNNING`, `PASSED` e `FAILED`. Junto com o `FAILED`, o motivo
|
|
309
|
+
diz por que a execução falhou, sem mudar o status:
|
|
310
|
+
|
|
311
|
+
```python
|
|
312
|
+
from proton.run_status import FailureReason
|
|
313
|
+
|
|
314
|
+
update_run_status(RunStatus.FAILED, reason=FailureReason.BUSINESS)
|
|
315
|
+
```
|
|
316
|
+
|
|
317
|
+
| Motivo | Quando |
|
|
318
|
+
|---|---|
|
|
319
|
+
| `FailureReason.BUSINESS` | A regra de negócio não deixou seguir: cadastro que já existe, nota já recusada. Repetir dá o mesmo resultado. |
|
|
320
|
+
| `FailureReason.TECHNICAL` | Qualquer outro erro: elemento que não apareceu, sistema fora do ar, exceção do código. É o padrão. |
|
|
321
|
+
| `FailureReason.ENVIRONMENT` | O ambiente não deixou rodar: dependência que falta, configuração errada. |
|
|
322
|
+
|
|
323
|
+
Com o executor de componentes, basta lançar `ProtonBusinessError` no passo: ele grava
|
|
324
|
+
a falha com o motivo de negócio. Qualquer outra exceção conta como técnica.
|
|
325
|
+
|
|
326
|
+
```python
|
|
327
|
+
from proton.run_status import ProtonBusinessError
|
|
328
|
+
|
|
329
|
+
if cadastro_existe:
|
|
330
|
+
raise ProtonBusinessError(f"O CNPJ {cnpj} já está cadastrado")
|
|
331
|
+
```
|
|
332
|
+
|
|
333
|
+
Os status do Proton 4 (`FAILED_DATA`, `FAILED_ENVIRONMENT` e `IN_PROCESS`) não
|
|
334
|
+
existem mais: o servidor os recusa, e a biblioteca envia `FAILED` no lugar dos dois
|
|
335
|
+
primeiros, com o motivo de negócio e de ambiente, e `RUNNING` no lugar do último,
|
|
336
|
+
com um `DeprecationWarning` e uma linha `[proton]` no log na primeira vez.
|
|
267
337
|
|
|
268
338
|
## Executor de componentes
|
|
269
339
|
|
|
@@ -299,7 +369,8 @@ A cada passo, o executor:
|
|
|
299
369
|
para cima (`forward_logs=False` desliga).
|
|
300
370
|
|
|
301
371
|
No fim, grava `PASSED` quando não sobra passo. Quando um passo falha, grava o
|
|
302
|
-
stack trace no painel de erro, grava `FAILED`
|
|
372
|
+
stack trace no painel de erro, grava `FAILED` com o motivo (de negócio para
|
|
373
|
+
`ProtonBusinessError`, técnico para o resto) e relança a exceção. Execução parada
|
|
303
374
|
não recebe status por cima.
|
|
304
375
|
|
|
305
376
|
Um projeto pode cuidar de várias plataformas (SAP, web, API, desktop), cada uma
|
|
@@ -209,6 +209,53 @@ set_log(f"url={url} senha={senha}") # no log: url=•••••• senha=•
|
|
|
209
209
|
mascarados.
|
|
210
210
|
- Para os logs próprios do script, use `mask_environment_values(texto)`.
|
|
211
211
|
|
|
212
|
+
## Log da execução
|
|
213
|
+
|
|
214
|
+
Cada linha do log tem um nível: Informação (`info`, o padrão), Alerta (`warning`),
|
|
215
|
+
Erro (`error`) ou Crítico (`critical`). A tela da execução filtra por nível e
|
|
216
|
+
destaca alertas e erros, e a busca do Proton acha a linha entre execuções.
|
|
217
|
+
|
|
218
|
+
```python
|
|
219
|
+
from proton.proton_logs import log_error, log_info, log_warning, set_log
|
|
220
|
+
|
|
221
|
+
log_info("Abrindo a transação BP")
|
|
222
|
+
log_warning("Cadastro já existe: seguindo para o próximo", context={"cnpj": cnpj})
|
|
223
|
+
log_error("Timeout no LoginSAP", context={"tentativa": 3})
|
|
224
|
+
set_log("Mensagem com o nível escolhido", level="critical")
|
|
225
|
+
```
|
|
226
|
+
|
|
227
|
+
- `context` é um `dict` opcional que vai junto com a linha (o item processado, a
|
|
228
|
+
tentativa, o código de retorno). Os valores do ambiente saem mascarados nele
|
|
229
|
+
também.
|
|
230
|
+
- O `logging` do projeto, encaminhado pelo executor de componentes, chega com o
|
|
231
|
+
nível dele: `warning`, `error` e `critical` viram Alerta, Erro e Crítico.
|
|
232
|
+
- `set_log_from_exception(e)` manda o stack trace inteiro como uma linha de Erro.
|
|
233
|
+
- Numa execução do Proton, cada linha também sai na saída do processo, com a hora
|
|
234
|
+
e o nível. O runner sobe essa saída como o arquivo de log da execução: acima do
|
|
235
|
+
limite do log no Proton (10 mil linhas), é nele que o log completo fica.
|
|
236
|
+
|
|
237
|
+
## Correlation ID
|
|
238
|
+
|
|
239
|
+
Cada execução tem um Correlation ID, o mesmo que a tela do Proton mostra. O runner
|
|
240
|
+
o entrega em `PROTON_CORRELATION_ID` (runner 5.0.8 em diante), e a biblioteca o
|
|
241
|
+
envia em toda chamada ao Proton (`X-Correlation-Id` e `traceparent`, no formato
|
|
242
|
+
W3C Trace Context).
|
|
243
|
+
|
|
244
|
+
Para levá-lo às chamadas que a automação faz (SAP, APIs, portais), use
|
|
245
|
+
`correlation_headers()`. Com ele, o log do sistema chamado se acha pelo id da
|
|
246
|
+
execução:
|
|
247
|
+
|
|
248
|
+
```python
|
|
249
|
+
import requests
|
|
250
|
+
from proton.proton_env import correlation_headers, get_correlation_id
|
|
251
|
+
|
|
252
|
+
requests.post(url, json=pedido, headers={**correlation_headers(), "Authorization": token})
|
|
253
|
+
log_info(f"Pedido enviado ({get_correlation_id()})")
|
|
254
|
+
```
|
|
255
|
+
|
|
256
|
+
Fora do Proton, ou com um runner que ainda não entrega o id, `correlation_headers()`
|
|
257
|
+
devolve um dicionário vazio.
|
|
258
|
+
|
|
212
259
|
### Categoria do dataset (Proton 4)
|
|
213
260
|
|
|
214
261
|
No Proton 5 o dataset não tem categoria. `get_dataset_category()` e
|
|
@@ -237,12 +284,35 @@ from proton.run_status import RunStatus
|
|
|
237
284
|
update_run_status(RunStatus.FAILED)
|
|
238
285
|
```
|
|
239
286
|
|
|
240
|
-
O Proton 5 aceita `RUNNING`, `PASSED` e `FAILED`.
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
287
|
+
O Proton 5 aceita `RUNNING`, `PASSED` e `FAILED`. Junto com o `FAILED`, o motivo
|
|
288
|
+
diz por que a execução falhou, sem mudar o status:
|
|
289
|
+
|
|
290
|
+
```python
|
|
291
|
+
from proton.run_status import FailureReason
|
|
292
|
+
|
|
293
|
+
update_run_status(RunStatus.FAILED, reason=FailureReason.BUSINESS)
|
|
294
|
+
```
|
|
295
|
+
|
|
296
|
+
| Motivo | Quando |
|
|
297
|
+
|---|---|
|
|
298
|
+
| `FailureReason.BUSINESS` | A regra de negócio não deixou seguir: cadastro que já existe, nota já recusada. Repetir dá o mesmo resultado. |
|
|
299
|
+
| `FailureReason.TECHNICAL` | Qualquer outro erro: elemento que não apareceu, sistema fora do ar, exceção do código. É o padrão. |
|
|
300
|
+
| `FailureReason.ENVIRONMENT` | O ambiente não deixou rodar: dependência que falta, configuração errada. |
|
|
301
|
+
|
|
302
|
+
Com o executor de componentes, basta lançar `ProtonBusinessError` no passo: ele grava
|
|
303
|
+
a falha com o motivo de negócio. Qualquer outra exceção conta como técnica.
|
|
304
|
+
|
|
305
|
+
```python
|
|
306
|
+
from proton.run_status import ProtonBusinessError
|
|
307
|
+
|
|
308
|
+
if cadastro_existe:
|
|
309
|
+
raise ProtonBusinessError(f"O CNPJ {cnpj} já está cadastrado")
|
|
310
|
+
```
|
|
311
|
+
|
|
312
|
+
Os status do Proton 4 (`FAILED_DATA`, `FAILED_ENVIRONMENT` e `IN_PROCESS`) não
|
|
313
|
+
existem mais: o servidor os recusa, e a biblioteca envia `FAILED` no lugar dos dois
|
|
314
|
+
primeiros, com o motivo de negócio e de ambiente, e `RUNNING` no lugar do último,
|
|
315
|
+
com um `DeprecationWarning` e uma linha `[proton]` no log na primeira vez.
|
|
246
316
|
|
|
247
317
|
## Executor de componentes
|
|
248
318
|
|
|
@@ -278,7 +348,8 @@ A cada passo, o executor:
|
|
|
278
348
|
para cima (`forward_logs=False` desliga).
|
|
279
349
|
|
|
280
350
|
No fim, grava `PASSED` quando não sobra passo. Quando um passo falha, grava o
|
|
281
|
-
stack trace no painel de erro, grava `FAILED`
|
|
351
|
+
stack trace no painel de erro, grava `FAILED` com o motivo (de negócio para
|
|
352
|
+
`ProtonBusinessError`, técnico para o resto) e relança a exceção. Execução parada
|
|
282
353
|
não recebe status por cima.
|
|
283
354
|
|
|
284
355
|
Um projeto pode cuidar de várias plataformas (SAP, web, API, desktop), cada uma
|
|
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "protoncloud-sdk"
|
|
7
|
-
version = "5.
|
|
7
|
+
version = "5.4.0"
|
|
8
8
|
description = "Proton Cloud SDK para Python: execução de automações, ambientes, logs, parâmetros e recursos."
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
requires-python = ">=3.10"
|
|
@@ -39,7 +39,7 @@ from typing import Callable, Optional, Union
|
|
|
39
39
|
|
|
40
40
|
from proton import proton_automation, proton_files_and_resources, proton_logs, proton_parameter
|
|
41
41
|
from proton.proton_env import is_proton_execution
|
|
42
|
-
from proton.run_status import RunStatus
|
|
42
|
+
from proton.run_status import FailureReason, ProtonBusinessError, RunStatus
|
|
43
43
|
|
|
44
44
|
Passo = Callable[[dict], Optional[Mapping]]
|
|
45
45
|
"""Um passo: recebe os parâmetros do componente e devolve as saídas (ou `None`)."""
|
|
@@ -63,7 +63,15 @@ class ProtonRunError(RuntimeError):
|
|
|
63
63
|
class ProtonLogHandler(logging.Handler):
|
|
64
64
|
"""Manda cada registro do `logging` para o log da execução no Proton."""
|
|
65
65
|
|
|
66
|
-
|
|
66
|
+
@staticmethod
|
|
67
|
+
def _nivel(levelno):
|
|
68
|
+
if levelno >= logging.CRITICAL:
|
|
69
|
+
return "critical"
|
|
70
|
+
if levelno >= logging.ERROR:
|
|
71
|
+
return "error"
|
|
72
|
+
if levelno >= logging.WARNING:
|
|
73
|
+
return "warning"
|
|
74
|
+
return "info"
|
|
67
75
|
|
|
68
76
|
def __init__(self, level=logging.INFO):
|
|
69
77
|
super().__init__(level)
|
|
@@ -77,7 +85,7 @@ class ProtonLogHandler(logging.Handler):
|
|
|
77
85
|
|
|
78
86
|
self._enviando.ativo = True
|
|
79
87
|
try:
|
|
80
|
-
proton_logs.set_log(self.
|
|
88
|
+
proton_logs.set_log(self.format(record), level=self._nivel(record.levelno))
|
|
81
89
|
except Exception:
|
|
82
90
|
self.handleError(record)
|
|
83
91
|
finally:
|
|
@@ -195,6 +203,7 @@ def _executar_passo(nome, passo, erro_ao_localizar, evidence_dir):
|
|
|
195
203
|
raise ProtonRunError(f'O Proton não iniciou o passo "{nome}".')
|
|
196
204
|
|
|
197
205
|
antes = _arquivos(evidence_dir)
|
|
206
|
+
passou = False
|
|
198
207
|
|
|
199
208
|
try:
|
|
200
209
|
logger.info("Passo %s: %s", progresso.get("order", "?"), nome)
|
|
@@ -209,6 +218,7 @@ def _executar_passo(nome, passo, erro_ao_localizar, evidence_dir):
|
|
|
209
218
|
|
|
210
219
|
saidas = passo(_parametros_do_passo(progresso, nome))
|
|
211
220
|
_gravar_saidas(nome, saidas)
|
|
221
|
+
passou = True
|
|
212
222
|
|
|
213
223
|
finally:
|
|
214
224
|
try:
|
|
@@ -216,7 +226,10 @@ def _executar_passo(nome, passo, erro_ao_localizar, evidence_dir):
|
|
|
216
226
|
except Exception:
|
|
217
227
|
logger.exception("Falha ao enviar as evidências do passo %s", nome)
|
|
218
228
|
|
|
219
|
-
|
|
229
|
+
# O passo que falhou fica aberto: o Failed da execução (`_registrar_falha`) o fecha
|
|
230
|
+
# como Failed. O Proton marca como Passed o passo que recebe o fim.
|
|
231
|
+
if passou:
|
|
232
|
+
proton_automation.end_component()
|
|
220
233
|
|
|
221
234
|
|
|
222
235
|
def _parametros_do_passo(progresso, nome):
|
|
@@ -302,7 +315,9 @@ def _registrar_falha(erro):
|
|
|
302
315
|
return
|
|
303
316
|
|
|
304
317
|
proton_logs.set_error_log("".join(traceback.format_exception(type(erro), erro, erro.__traceback__)))
|
|
305
|
-
|
|
318
|
+
# A regra de negócio que não deixou seguir é uma falha à parte: repetir dá o mesmo.
|
|
319
|
+
motivo = FailureReason.BUSINESS if isinstance(erro, ProtonBusinessError) else FailureReason.TECHNICAL
|
|
320
|
+
proton_automation.update_run_status(RunStatus.FAILED, reason=motivo)
|
|
306
321
|
except Exception:
|
|
307
322
|
# A falha original é a que importa: um erro ao registrá-la não pode escondê-la.
|
|
308
323
|
logger.exception("Falha ao registrar o erro da execução no Proton")
|
|
@@ -4,7 +4,7 @@ import warnings
|
|
|
4
4
|
import requests
|
|
5
5
|
from proton.proton_env import get_api_url, get_auth_header, get_host, get_id_dataset_run, is_proton_execution
|
|
6
6
|
from proton.proton_environment import ProtonEnvironmentError, load_environment, mask_environment_values
|
|
7
|
-
from proton.run_status import STATUS_DO_PROTON_4, RunStatus
|
|
7
|
+
from proton.run_status import MOTIVO_DO_PROTON_4, STATUS_DO_PROTON_4, FailureReason, RunStatus
|
|
8
8
|
|
|
9
9
|
|
|
10
10
|
def _get_dataset_run_url():
|
|
@@ -79,17 +79,24 @@ def end_component():
|
|
|
79
79
|
_status_avisados = set()
|
|
80
80
|
|
|
81
81
|
|
|
82
|
-
def update_run_status(status:RunStatus):
|
|
82
|
+
def update_run_status(status:RunStatus, reason=None):
|
|
83
83
|
"""
|
|
84
84
|
Atualiza o status da execução.
|
|
85
85
|
|
|
86
|
+
`reason`, só com o `FAILED`: por que falhou (`FailureReason.BUSINESS`, `TECHNICAL` ou
|
|
87
|
+
`ENVIRONMENT`, ou o texto deles). Sem motivo, o Proton conta a falha como técnica.
|
|
88
|
+
|
|
86
89
|
Os status do Proton 4 (`FAILED_DATA`, `FAILED_ENVIRONMENT` e `IN_PROCESS`) não existem
|
|
87
90
|
no Proton 5, que os recusa com HTTP 400. A biblioteca envia "Failed" no lugar dos dois
|
|
88
|
-
primeiros e "Running" no lugar do último, com
|
|
89
|
-
uma linha `[proton]` no log.
|
|
91
|
+
primeiros, com o motivo de negócio e de ambiente, e "Running" no lugar do último, com
|
|
92
|
+
`DeprecationWarning` e, na primeira vez, uma linha `[proton]` no log.
|
|
90
93
|
"""
|
|
91
94
|
if is_proton_execution():
|
|
92
95
|
status_value = status.value if isinstance(status, RunStatus) else str(status)
|
|
96
|
+
motivo = reason.value if isinstance(reason, FailureReason) else (str(reason) if reason else None)
|
|
97
|
+
|
|
98
|
+
if motivo is None and status_value in MOTIVO_DO_PROTON_4:
|
|
99
|
+
motivo = MOTIVO_DO_PROTON_4[status_value]
|
|
93
100
|
|
|
94
101
|
if status_value in STATUS_DO_PROTON_4:
|
|
95
102
|
novo = STATUS_DO_PROTON_4[status_value]
|
|
@@ -102,7 +109,12 @@ def update_run_status(status:RunStatus):
|
|
|
102
109
|
|
|
103
110
|
status_value = novo
|
|
104
111
|
|
|
105
|
-
|
|
112
|
+
corpo = {"status": status_value}
|
|
113
|
+
|
|
114
|
+
if motivo and status_value == RunStatus.FAILED.value:
|
|
115
|
+
corpo["reason"] = motivo
|
|
116
|
+
|
|
117
|
+
_request_json("PUT", f"{_get_dataset_run_url()}/update-status", body=corpo)
|
|
106
118
|
|
|
107
119
|
def get_current_component_system():
|
|
108
120
|
"""
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
from configparser import ConfigParser
|
|
2
2
|
import os
|
|
3
|
+
import re
|
|
4
|
+
import secrets
|
|
3
5
|
|
|
4
6
|
|
|
5
7
|
def _is_localhost(host):
|
|
@@ -153,6 +155,49 @@ def get_api_url(path):
|
|
|
153
155
|
"""
|
|
154
156
|
return f"{get_host().rstrip('/')}/{str(path).lstrip('/')}"
|
|
155
157
|
|
|
158
|
+
_ID_VALIDO = re.compile(r'^[A-Za-z0-9-]{8,64}$')
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def get_correlation_id():
|
|
162
|
+
"""
|
|
163
|
+
O Correlation ID da execução: o mesmo id que a tela do Proton mostra, que o runner
|
|
164
|
+
entrega em `PROTON_CORRELATION_ID` (runner 5.0.8 em diante).
|
|
165
|
+
|
|
166
|
+
Returns:
|
|
167
|
+
str: O id, ou string vazia fora do Proton ou com um runner que ainda não o entrega.
|
|
168
|
+
"""
|
|
169
|
+
valor = (os.getenv('PROTON_CORRELATION_ID') or '').strip()
|
|
170
|
+
return valor if _ID_VALIDO.match(valor) else ''
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def correlation_headers():
|
|
174
|
+
"""
|
|
175
|
+
Os cabeçalhos que levam o Correlation ID às chamadas que a automação faz (SAP, APIs,
|
|
176
|
+
portais): `X-Correlation-Id` e `traceparent` (W3C Trace Context, com o UUID da execução
|
|
177
|
+
como trace-id). Com eles, o log do sistema chamado se acha pelo mesmo id da execução.
|
|
178
|
+
|
|
179
|
+
import requests
|
|
180
|
+
from proton.proton_env import correlation_headers
|
|
181
|
+
|
|
182
|
+
requests.post(url, json=pedido, headers={**correlation_headers(), 'Authorization': token})
|
|
183
|
+
|
|
184
|
+
Returns:
|
|
185
|
+
dict: Os cabeçalhos, ou um dicionário vazio quando não há Correlation ID.
|
|
186
|
+
"""
|
|
187
|
+
correlation_id = get_correlation_id()
|
|
188
|
+
|
|
189
|
+
if not correlation_id:
|
|
190
|
+
return {}
|
|
191
|
+
|
|
192
|
+
cabecalhos = {'X-Correlation-Id': correlation_id}
|
|
193
|
+
trace_id = correlation_id.replace('-', '').lower()
|
|
194
|
+
|
|
195
|
+
if len(trace_id) == 32 and all(letra in '0123456789abcdef' for letra in trace_id):
|
|
196
|
+
cabecalhos['traceparent'] = f'00-{trace_id}-{secrets.token_hex(8)}-01'
|
|
197
|
+
|
|
198
|
+
return cabecalhos
|
|
199
|
+
|
|
200
|
+
|
|
156
201
|
def get_auth_header():
|
|
157
202
|
"""
|
|
158
203
|
Gera o cabeçalho de autenticação para requisições HTTP, baseado no token do Proton.
|
|
@@ -171,7 +216,8 @@ def get_auth_header():
|
|
|
171
216
|
if token.lower().startswith('bearer '):
|
|
172
217
|
token = token[len('bearer '):].strip()
|
|
173
218
|
|
|
174
|
-
|
|
219
|
+
# O Correlation ID vai junto: o log do Proton acha a requisição pelo id da execução.
|
|
220
|
+
return {'Authorization': f'Bearer {token}', **correlation_headers()}
|
|
175
221
|
|
|
176
222
|
def get_id_dataset_run():
|
|
177
223
|
"""
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import traceback
|
|
3
|
+
from datetime import datetime
|
|
4
|
+
|
|
5
|
+
import requests
|
|
6
|
+
|
|
7
|
+
from proton.proton_env import get_auth_header, get_host, get_id_dataset_run, is_proton_execution
|
|
8
|
+
from proton.proton_environment import mask_environment_values
|
|
9
|
+
|
|
10
|
+
LEVELS = ("info", "warning", "error", "critical")
|
|
11
|
+
"""Os níveis do log da execução: Informação, Alerta, Erro e Crítico."""
|
|
12
|
+
|
|
13
|
+
_APELIDOS = {"warn": "warning", "fatal": "critical", "debug": "info", "err": "error"}
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _nivel(level):
|
|
17
|
+
nivel = str(level or "info").strip().lower()
|
|
18
|
+
nivel = _APELIDOS.get(nivel, nivel)
|
|
19
|
+
return nivel if nivel in LEVELS else "info"
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _contexto(context):
|
|
23
|
+
"""O contexto como objeto JSON, com os valores do ambiente mascarados; `None` quando não serve."""
|
|
24
|
+
if not isinstance(context, dict) or not context:
|
|
25
|
+
return None
|
|
26
|
+
|
|
27
|
+
try:
|
|
28
|
+
texto = mask_environment_values(json.dumps(context, ensure_ascii=False, default=str))
|
|
29
|
+
return json.loads(texto)
|
|
30
|
+
except (TypeError, ValueError):
|
|
31
|
+
return {"masked": True}
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _ecoar(log_message, nivel):
|
|
35
|
+
"""
|
|
36
|
+
Escreve a linha na saída do processo, com a hora e o nível. O runner sobe essa saída
|
|
37
|
+
como o arquivo de log da execução: acima do limite do log no Proton, é nele que o
|
|
38
|
+
log completo fica.
|
|
39
|
+
"""
|
|
40
|
+
agora = datetime.now().strftime("%Y-%m-%d %H:%M:%S.%f")[:-3]
|
|
41
|
+
print(f"{agora} [{nivel.upper()}] {log_message}", flush=True)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def set_log(log_message, disable_date_time=False, level="info", context=None):
|
|
45
|
+
"""
|
|
46
|
+
Envia uma entrada de log.
|
|
47
|
+
|
|
48
|
+
Args:
|
|
49
|
+
log_message: O texto da linha.
|
|
50
|
+
disable_date_time: Sem a data na frente, no texto do log (para blocos em Markdown).
|
|
51
|
+
level: O nível: "info" (padrão), "warning", "error" ou "critical". Aceita também os
|
|
52
|
+
nomes do `logging` ("WARN", "FATAL").
|
|
53
|
+
context: Um `dict` opcional que vai junto com a linha (o item processado, a
|
|
54
|
+
tentativa, o código de retorno). A tela mostra ao abrir a linha.
|
|
55
|
+
|
|
56
|
+
Os valores do ambiente da execução saem mascarados (`••••••`), no log do Proton, no
|
|
57
|
+
contexto e no print (ver `mask_environment_values`). Numa execução do Proton, a linha
|
|
58
|
+
também sai na saída do processo, com a hora e o nível.
|
|
59
|
+
"""
|
|
60
|
+
log_message = mask_environment_values(log_message)
|
|
61
|
+
nivel = _nivel(level)
|
|
62
|
+
|
|
63
|
+
if is_proton_execution():
|
|
64
|
+
dataset_run_id = get_id_dataset_run()
|
|
65
|
+
|
|
66
|
+
if dataset_run_id is not None:
|
|
67
|
+
_ecoar(log_message, nivel)
|
|
68
|
+
url = f"{get_host()}/dataset-run-log/{dataset_run_id}"
|
|
69
|
+
|
|
70
|
+
body = {
|
|
71
|
+
"idDatasetRun": dataset_run_id,
|
|
72
|
+
"log": log_message,
|
|
73
|
+
"disableDateTime": disable_date_time,
|
|
74
|
+
"level": nivel,
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
contexto = _contexto(context)
|
|
78
|
+
if contexto:
|
|
79
|
+
body["context"] = contexto
|
|
80
|
+
|
|
81
|
+
return requests.put(url, json=body, headers=get_auth_header())
|
|
82
|
+
else:
|
|
83
|
+
print(log_message)
|
|
84
|
+
|
|
85
|
+
return None
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def log_info(log_message, context=None):
|
|
89
|
+
"""Uma linha de Informação no log da execução."""
|
|
90
|
+
return set_log(log_message, level="info", context=context)
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def log_warning(log_message, context=None):
|
|
94
|
+
"""Uma linha de Alerta: algo fora do esperado que não parou a execução."""
|
|
95
|
+
return set_log(log_message, level="warning", context=context)
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def log_error(log_message, context=None):
|
|
99
|
+
"""Uma linha de Erro."""
|
|
100
|
+
return set_log(log_message, level="error", context=context)
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def log_critical(log_message, context=None):
|
|
104
|
+
"""Uma linha Crítica: o que precisa de atenção imediata."""
|
|
105
|
+
return set_log(log_message, level="critical", context=context)
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def set_error_log(error_message):
|
|
109
|
+
"""
|
|
110
|
+
Envia um log de erro estruturado para o Proton.
|
|
111
|
+
|
|
112
|
+
Os valores do ambiente da execução saem mascarados (`••••••`), como em `set_log`.
|
|
113
|
+
"""
|
|
114
|
+
error_message = mask_environment_values(error_message)
|
|
115
|
+
|
|
116
|
+
if is_proton_execution():
|
|
117
|
+
dataset_run_id = get_id_dataset_run()
|
|
118
|
+
|
|
119
|
+
if dataset_run_id is not None:
|
|
120
|
+
url = f"{get_host()}/dataset-run/{dataset_run_id}/error"
|
|
121
|
+
body = {
|
|
122
|
+
"idDatasetRun": dataset_run_id,
|
|
123
|
+
"error": error_message,
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
return requests.put(url, json=body, headers=get_auth_header())
|
|
127
|
+
else:
|
|
128
|
+
print(error_message)
|
|
129
|
+
|
|
130
|
+
return None
|
|
131
|
+
|
|
132
|
+
# ATENÇÃO -----------------
|
|
133
|
+
def set_log_from_exception(e):
|
|
134
|
+
"""Envia o stack trace da exceção como uma linha de Erro do log."""
|
|
135
|
+
stack_trace = "".join(traceback.format_exception(type(e), e, e.__traceback__))
|
|
136
|
+
# Mascara o stack trace inteiro de uma vez: um valor com quebra de linha (uma chave,
|
|
137
|
+
# por exemplo) escaparia da máscara linha a linha.
|
|
138
|
+
set_log(mask_environment_values(stack_trace).rstrip(), level="error")
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
from enum import Enum
|
|
2
|
+
|
|
3
|
+
class RunStatus(Enum):
|
|
4
|
+
RUNNING = "Running"
|
|
5
|
+
PASSED = "Passed"
|
|
6
|
+
FAILED = "Failed"
|
|
7
|
+
|
|
8
|
+
# Status do Proton 4. O Proton 5 não os tem e responde 400 (invalidRunStatus);
|
|
9
|
+
# `update_run_status` envia "Failed" no lugar dos dois primeiros e "Running" no
|
|
10
|
+
# lugar do último, com um aviso de obsolescência.
|
|
11
|
+
FAILED_DATA = "Failed (Dados)"
|
|
12
|
+
FAILED_ENVIRONMENT = "Failed (Ambiente)"
|
|
13
|
+
IN_PROCESS = "In Process"
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
STATUS_DO_PROTON_4 = {
|
|
17
|
+
RunStatus.FAILED_DATA.value: RunStatus.FAILED.value,
|
|
18
|
+
RunStatus.FAILED_ENVIRONMENT.value: RunStatus.FAILED.value,
|
|
19
|
+
RunStatus.IN_PROCESS.value: RunStatus.RUNNING.value,
|
|
20
|
+
}
|
|
21
|
+
"""Status do Proton 4 e o status do Proton 5 que a biblioteca envia no lugar."""
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class FailureReason(Enum):
|
|
25
|
+
"""
|
|
26
|
+
Por que a execução falhou, junto com o "Failed". O status continua o mesmo; o motivo
|
|
27
|
+
é um campo à parte, que a tela e os painéis usam para separar a falha do robô da falha
|
|
28
|
+
por regra de negócio.
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
BUSINESS = "business_error"
|
|
32
|
+
"""A regra de negócio não deixou seguir: cadastro já existe, nota já recusada."""
|
|
33
|
+
|
|
34
|
+
TECHNICAL = "technical_error"
|
|
35
|
+
"""Qualquer outro erro: elemento que não apareceu, sistema fora do ar, exceção do código."""
|
|
36
|
+
|
|
37
|
+
ENVIRONMENT = "environment_error"
|
|
38
|
+
"""O ambiente não deixou rodar: projeto que não prepara, dependência que falta."""
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
MOTIVO_DO_PROTON_4 = {
|
|
42
|
+
RunStatus.FAILED_DATA.value: FailureReason.BUSINESS.value,
|
|
43
|
+
RunStatus.FAILED_ENVIRONMENT.value: FailureReason.ENVIRONMENT.value,
|
|
44
|
+
}
|
|
45
|
+
"""Os "Failed" do Proton 4 viram o motivo no Proton 5: o que separavam continua separado."""
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class ProtonBusinessError(Exception):
|
|
49
|
+
"""
|
|
50
|
+
Falha de negócio: a regra não deixou seguir, e repetir dá o mesmo resultado (cadastro
|
|
51
|
+
que já existe, nota já recusada, saldo insuficiente). Lance no passo:
|
|
52
|
+
|
|
53
|
+
from proton.run_status import ProtonBusinessError
|
|
54
|
+
|
|
55
|
+
if cadastro_existe:
|
|
56
|
+
raise ProtonBusinessError(f"O CNPJ {cnpj} já está cadastrado")
|
|
57
|
+
|
|
58
|
+
O executor de componentes grava a execução como Falhou com o motivo de negócio. Qualquer
|
|
59
|
+
outra exceção conta como falha técnica.
|
|
60
|
+
"""
|
|
@@ -10,7 +10,7 @@ import pytest
|
|
|
10
10
|
|
|
11
11
|
from proton import component_runner, proton_automation, proton_files_and_resources, proton_logs, proton_parameter
|
|
12
12
|
from proton.component_runner import ProtonRunError, run_components
|
|
13
|
-
from proton.run_status import RunStatus
|
|
13
|
+
from proton.run_status import FailureReason, ProtonBusinessError, RunStatus
|
|
14
14
|
|
|
15
15
|
|
|
16
16
|
class ProtonFalso:
|
|
@@ -25,11 +25,14 @@ class ProtonFalso:
|
|
|
25
25
|
self.eventos = []
|
|
26
26
|
self.saidas = []
|
|
27
27
|
self.logs = []
|
|
28
|
+
self.niveis = []
|
|
29
|
+
self.motivo = None
|
|
28
30
|
self.erros = []
|
|
29
31
|
self.imagens = []
|
|
30
32
|
self.arquivos = []
|
|
31
33
|
self.saidas_fora_do_passo = set()
|
|
32
34
|
self.inicio_falha = False
|
|
35
|
+
self.status_dos_passos = []
|
|
33
36
|
|
|
34
37
|
def _pendente(self):
|
|
35
38
|
return self.passos[self.atual] if self.atual < len(self.passos) else None
|
|
@@ -55,7 +58,9 @@ class ProtonFalso:
|
|
|
55
58
|
return {"id": 100 + self.atual, "order": passo["ordem"], "component": passo["nome"]}
|
|
56
59
|
|
|
57
60
|
def finalizar(self):
|
|
61
|
+
# Como no Proton: o passo que recebe o fim fica Passed.
|
|
58
62
|
self.chamadas.append("end")
|
|
63
|
+
self.status_dos_passos.append((self.aberto["nome"], "Passed"))
|
|
59
64
|
self.aberto = None
|
|
60
65
|
return 2
|
|
61
66
|
|
|
@@ -66,6 +71,10 @@ class ProtonFalso:
|
|
|
66
71
|
for p in self.passos
|
|
67
72
|
]
|
|
68
73
|
|
|
74
|
+
def registrar_log(self, mensagem, disable_date_time=False, level="info", context=None):
|
|
75
|
+
self.logs.append(mensagem)
|
|
76
|
+
self.niveis.append((mensagem, level))
|
|
77
|
+
|
|
69
78
|
def gravar_saida(self, parametro, valor):
|
|
70
79
|
if parametro in self.saidas_fora_do_passo:
|
|
71
80
|
return 404
|
|
@@ -73,10 +82,16 @@ class ProtonFalso:
|
|
|
73
82
|
self.saidas.append((self.aberto["nome"], parametro, valor))
|
|
74
83
|
return 200
|
|
75
84
|
|
|
76
|
-
def atualizar_status(self, status):
|
|
85
|
+
def atualizar_status(self, status, reason=None):
|
|
77
86
|
self.status = status.value
|
|
87
|
+
self.motivo = reason
|
|
78
88
|
self.eventos.append(f"status {status.value}")
|
|
79
89
|
|
|
90
|
+
# Como no Proton: a execução que falha (ou para) fecha o passo aberto com o mesmo status.
|
|
91
|
+
if status.value in ("Failed", "Stopped") and self.aberto:
|
|
92
|
+
self.status_dos_passos.append((self.aberto["nome"], status.value))
|
|
93
|
+
self.aberto = None
|
|
94
|
+
|
|
80
95
|
|
|
81
96
|
@pytest.fixture
|
|
82
97
|
def proton(monkeypatch):
|
|
@@ -92,7 +107,7 @@ def proton(monkeypatch):
|
|
|
92
107
|
monkeypatch.setattr(proton_automation, "get_dataset_run_status", lambda *a: falso.status)
|
|
93
108
|
monkeypatch.setattr(proton_parameter, "get_proton_all_component_parameters", falso.parametros)
|
|
94
109
|
monkeypatch.setattr(proton_parameter, "set_proton_value", falso.gravar_saida)
|
|
95
|
-
monkeypatch.setattr(proton_logs, "set_log", falso.
|
|
110
|
+
monkeypatch.setattr(proton_logs, "set_log", falso.registrar_log)
|
|
96
111
|
monkeypatch.setattr(proton_logs, "set_error_log", falso.erros.append)
|
|
97
112
|
monkeypatch.setattr(proton_files_and_resources, "upload_image", falso.imagens.append)
|
|
98
113
|
monkeypatch.setattr(proton_files_and_resources, "upload_file_resource", falso.arquivos.append)
|
|
@@ -125,6 +140,7 @@ def test_roda_os_passos_na_ordem_com_os_parametros_de_cada_um(proton):
|
|
|
125
140
|
("AdicionarAoCarrinho", {"in_produto": "Mochila", "out_preco": ""}),
|
|
126
141
|
]
|
|
127
142
|
assert falso.chamadas == ["start", "end", "start", "end"]
|
|
143
|
+
assert falso.status_dos_passos == [("FazerLogin", "Passed"), ("AdicionarAoCarrinho", "Passed")]
|
|
128
144
|
assert falso.saidas == [("AdicionarAoCarrinho", "out_preco", "29.99")]
|
|
129
145
|
assert falso.status == RunStatus.PASSED.value
|
|
130
146
|
|
|
@@ -149,7 +165,7 @@ def test_o_mesmo_componente_duas_vezes_recebe_os_parametros_da_sua_posicao(proto
|
|
|
149
165
|
assert produtos == ["Mochila", "Lanterna"]
|
|
150
166
|
|
|
151
167
|
|
|
152
|
-
def
|
|
168
|
+
def test_passo_que_falha_fica_failed_grava_o_erro_real_e_para(proton):
|
|
153
169
|
falso = proton([
|
|
154
170
|
{"nome": "FazerLogin", "ordem": 1},
|
|
155
171
|
{"nome": "FinalizarCompra", "ordem": 2},
|
|
@@ -161,12 +177,51 @@ def test_passo_que_falha_fecha_o_passo_grava_o_erro_real_e_para(proton):
|
|
|
161
177
|
with pytest.raises(AssertionError, match="login recusado"):
|
|
162
178
|
run_components({"FazerLogin": login_recusado, "FinalizarCompra": lambda p: None})
|
|
163
179
|
|
|
164
|
-
|
|
180
|
+
# Sem o fim do passo, que o Proton gravaria como Passed: o Failed da execução o fecha.
|
|
181
|
+
assert falso.chamadas == ["start"]
|
|
182
|
+
assert falso.status_dos_passos == [("FazerLogin", "Failed")]
|
|
165
183
|
assert falso.status == RunStatus.FAILED.value
|
|
166
184
|
assert len(falso.erros) == 1
|
|
167
185
|
# O stack trace é o do passo, e não o da maquinaria de chamada.
|
|
168
186
|
assert "login_recusado" in falso.erros[0]
|
|
169
187
|
assert "AssertionError: login recusado" in falso.erros[0]
|
|
188
|
+
# Erro qualquer é falha técnica.
|
|
189
|
+
assert falso.motivo == FailureReason.TECHNICAL
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def test_falha_de_negocio_vai_com_o_motivo_de_negocio(proton):
|
|
193
|
+
falso = proton([{"nome": "CadastrarFornecedor", "ordem": 1}])
|
|
194
|
+
|
|
195
|
+
def cadastro_existente(params):
|
|
196
|
+
raise ProtonBusinessError("O CNPJ 12.345.678/0001-90 já está cadastrado")
|
|
197
|
+
|
|
198
|
+
with pytest.raises(ProtonBusinessError):
|
|
199
|
+
run_components({"CadastrarFornecedor": cadastro_existente})
|
|
200
|
+
|
|
201
|
+
assert falso.status == RunStatus.FAILED.value
|
|
202
|
+
assert falso.motivo == FailureReason.BUSINESS
|
|
203
|
+
assert "já está cadastrado" in falso.erros[0]
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def test_o_nivel_do_logging_vira_o_nivel_do_log(proton):
|
|
207
|
+
falso = proton([{"nome": "FazerLogin", "ordem": 1}])
|
|
208
|
+
log = logging.getLogger("automacao")
|
|
209
|
+
|
|
210
|
+
def passo(params):
|
|
211
|
+
log.info("abrindo")
|
|
212
|
+
log.warning("demorou")
|
|
213
|
+
log.error("falhou a primeira vez")
|
|
214
|
+
log.critical("sem sistema")
|
|
215
|
+
|
|
216
|
+
run_components({"FazerLogin": passo})
|
|
217
|
+
|
|
218
|
+
niveis = dict(falso.niveis)
|
|
219
|
+
assert niveis["abrindo"] == "info"
|
|
220
|
+
assert niveis["demorou"] == "warning"
|
|
221
|
+
assert niveis["falhou a primeira vez"] == "error"
|
|
222
|
+
assert niveis["sem sistema"] == "critical"
|
|
223
|
+
# Sem o prefixo de antes: o nível vai à parte.
|
|
224
|
+
assert "Aviso: demorou" not in falso.logs
|
|
170
225
|
|
|
171
226
|
|
|
172
227
|
def test_componente_que_nao_existe_no_projeto(proton):
|
|
@@ -175,7 +230,8 @@ def test_componente_que_nao_existe_no_projeto(proton):
|
|
|
175
230
|
with pytest.raises(ProtonRunError, match='"Inexistente" não existe'):
|
|
176
231
|
run_components({})
|
|
177
232
|
|
|
178
|
-
assert falso.chamadas == ["start"
|
|
233
|
+
assert falso.chamadas == ["start"]
|
|
234
|
+
assert falso.status_dos_passos == [("Inexistente", "Failed")]
|
|
179
235
|
assert falso.status == RunStatus.FAILED.value
|
|
180
236
|
|
|
181
237
|
|
|
@@ -232,7 +288,8 @@ def test_componente_que_falta_no_mesmo_sistema_falha_em_vez_de_devolver_ao_runne
|
|
|
232
288
|
with pytest.raises(ProtonRunError, match='"Esquecido" não existe'):
|
|
233
289
|
run_components({"FazerLogin": lambda p: None})
|
|
234
290
|
|
|
235
|
-
assert falso.chamadas == ["start", "end", "start"
|
|
291
|
+
assert falso.chamadas == ["start", "end", "start"]
|
|
292
|
+
assert falso.status_dos_passos == [("FazerLogin", "Passed"), ("Esquecido", "Failed")]
|
|
236
293
|
assert falso.status == RunStatus.FAILED.value
|
|
237
294
|
|
|
238
295
|
|
|
@@ -245,7 +302,8 @@ def test_erro_ao_procurar_o_componente_marca_o_passo_e_falha(proton):
|
|
|
245
302
|
with pytest.raises(ImportError, match="biblioteca do SAP"):
|
|
246
303
|
run_components(localizar)
|
|
247
304
|
|
|
248
|
-
assert falso.chamadas == ["start"
|
|
305
|
+
assert falso.chamadas == ["start"]
|
|
306
|
+
assert falso.status_dos_passos == [("FazerLogin", "Failed")]
|
|
249
307
|
assert falso.status == RunStatus.FAILED.value
|
|
250
308
|
|
|
251
309
|
|
|
@@ -276,7 +334,7 @@ def test_saida_fora_do_passo_avisa_sem_falhar(proton, caplog):
|
|
|
276
334
|
run_components({"FinalizarCompra": lambda p: {"out_total": "32.39"}})
|
|
277
335
|
|
|
278
336
|
assert falso.status == RunStatus.PASSED.value
|
|
279
|
-
assert any("out_total" in log and
|
|
337
|
+
assert any("out_total" in log and nivel == "warning" for log, nivel in falso.niveis)
|
|
280
338
|
|
|
281
339
|
|
|
282
340
|
def test_saida_que_nao_e_dict_falha(proton):
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Observabilidade da execução (Proton 5, migration 01054): o nível e o contexto de cada
|
|
3
|
+
linha do log, a linha na saída do processo, o motivo da falha e o Correlation ID.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
import json
|
|
7
|
+
|
|
8
|
+
import pytest
|
|
9
|
+
|
|
10
|
+
from proton import proton_automation, proton_env, proton_logs
|
|
11
|
+
from proton.run_status import FailureReason, RunStatus
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class Resposta:
|
|
15
|
+
status_code = 200
|
|
16
|
+
text = "{}"
|
|
17
|
+
url = "https://proton.test/api/x"
|
|
18
|
+
|
|
19
|
+
def json(self):
|
|
20
|
+
return {}
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@pytest.fixture
|
|
24
|
+
def execucao(monkeypatch):
|
|
25
|
+
"""Uma execução do Proton, com o token e o Correlation ID que o runner entrega."""
|
|
26
|
+
monkeypatch.setenv("idDatasetRun", "42")
|
|
27
|
+
monkeypatch.setenv("PROTON_HOST", "https://proton.test/api")
|
|
28
|
+
monkeypatch.setenv("PROTON_TOKEN", "proton_run_teste")
|
|
29
|
+
monkeypatch.setenv("PROTON_CORRELATION_ID", "3f2a9c1e-5b7d-4e0a-9c11-2d3e4f5a6b7c")
|
|
30
|
+
monkeypatch.setattr(proton_logs, "is_proton_execution", lambda: True)
|
|
31
|
+
monkeypatch.setattr(proton_automation, "is_proton_execution", lambda: True)
|
|
32
|
+
monkeypatch.setattr(proton_env, "is_proton_execution", lambda: True)
|
|
33
|
+
chamadas = []
|
|
34
|
+
|
|
35
|
+
def put(url, json=None, headers=None):
|
|
36
|
+
chamadas.append({"url": url, "json": json, "headers": headers})
|
|
37
|
+
return Resposta()
|
|
38
|
+
|
|
39
|
+
def request(method, url, params=None, json=None, headers=None):
|
|
40
|
+
chamadas.append({"method": method, "url": url, "json": json, "headers": headers})
|
|
41
|
+
return Resposta()
|
|
42
|
+
|
|
43
|
+
monkeypatch.setattr(proton_logs.requests, "put", put)
|
|
44
|
+
monkeypatch.setattr(proton_automation.requests, "request", request)
|
|
45
|
+
return chamadas
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def test_a_linha_vai_com_o_nivel_e_o_contexto(execucao):
|
|
49
|
+
proton_logs.set_log("Cadastro já existe", level="WARN", context={"cnpj": "12.345.678/0001-90"})
|
|
50
|
+
|
|
51
|
+
corpo = execucao[0]["json"]
|
|
52
|
+
assert corpo["level"] == "warning"
|
|
53
|
+
assert corpo["context"] == {"cnpj": "12.345.678/0001-90"}
|
|
54
|
+
assert corpo["log"] == "Cadastro já existe"
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def test_sem_nivel_e_informacao_e_os_atalhos_dao_o_nivel(execucao):
|
|
58
|
+
proton_logs.set_log("abrindo")
|
|
59
|
+
proton_logs.log_warning("demorou")
|
|
60
|
+
proton_logs.log_error("falhou")
|
|
61
|
+
proton_logs.log_critical("sem sistema", context={"tentativas": 3})
|
|
62
|
+
|
|
63
|
+
assert [chamada["json"]["level"] for chamada in execucao] == ["info", "warning", "error", "critical"]
|
|
64
|
+
assert execucao[3]["json"]["context"] == {"tentativas": 3}
|
|
65
|
+
assert "context" not in execucao[0]["json"]
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def test_a_linha_tambem_sai_na_saida_do_processo_com_hora_e_nivel(execucao, capsys):
|
|
69
|
+
proton_logs.log_error("Timeout no LoginSAP")
|
|
70
|
+
|
|
71
|
+
saida = capsys.readouterr().out
|
|
72
|
+
assert "[ERROR] Timeout no LoginSAP" in saida
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def test_o_segredo_do_ambiente_sai_mascarado_tambem_no_contexto(execucao, monkeypatch):
|
|
76
|
+
monkeypatch.setattr(proton_logs, "mask_environment_values", lambda texto: texto.replace("senha123", "••••••"))
|
|
77
|
+
|
|
78
|
+
proton_logs.set_log("login com senha123", context={"senha": "senha123"})
|
|
79
|
+
|
|
80
|
+
corpo = execucao[0]["json"]
|
|
81
|
+
assert "senha123" not in corpo["log"]
|
|
82
|
+
assert "senha123" not in json.dumps(corpo["context"], ensure_ascii=False)
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def test_o_stack_trace_vai_numa_linha_so_de_erro(execucao):
|
|
86
|
+
try:
|
|
87
|
+
raise ValueError("valor inválido")
|
|
88
|
+
except ValueError as erro:
|
|
89
|
+
proton_logs.set_log_from_exception(erro)
|
|
90
|
+
|
|
91
|
+
assert len(execucao) == 1
|
|
92
|
+
assert execucao[0]["json"]["level"] == "error"
|
|
93
|
+
assert "ValueError: valor inválido" in execucao[0]["json"]["log"]
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def test_o_motivo_da_falha_vai_junto_do_failed(execucao):
|
|
97
|
+
proton_automation.update_run_status(RunStatus.FAILED, reason=FailureReason.BUSINESS)
|
|
98
|
+
proton_automation.update_run_status(RunStatus.PASSED, reason=FailureReason.BUSINESS)
|
|
99
|
+
proton_automation.update_run_status(RunStatus.FAILED)
|
|
100
|
+
|
|
101
|
+
assert execucao[0]["json"] == {"status": "Failed", "reason": "business_error"}
|
|
102
|
+
assert execucao[1]["json"] == {"status": "Passed"}, "o motivo só vale para o Failed"
|
|
103
|
+
assert execucao[2]["json"] == {"status": "Failed"}
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def test_os_failed_do_proton_4_viram_o_motivo(execucao):
|
|
107
|
+
with pytest.warns(DeprecationWarning):
|
|
108
|
+
proton_automation.update_run_status(RunStatus.FAILED_DATA)
|
|
109
|
+
with pytest.warns(DeprecationWarning):
|
|
110
|
+
proton_automation.update_run_status(RunStatus.FAILED_ENVIRONMENT)
|
|
111
|
+
|
|
112
|
+
assert execucao[0]["json"] == {"status": "Failed", "reason": "business_error"}
|
|
113
|
+
assert execucao[1]["json"] == {"status": "Failed", "reason": "environment_error"}
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def test_o_correlation_id_vai_em_toda_chamada_ao_proton(execucao):
|
|
117
|
+
proton_logs.set_log("abrindo")
|
|
118
|
+
|
|
119
|
+
cabecalhos = execucao[0]["headers"]
|
|
120
|
+
assert cabecalhos["X-Correlation-Id"] == "3f2a9c1e-5b7d-4e0a-9c11-2d3e4f5a6b7c"
|
|
121
|
+
versao, trace_id, span_id, flags = cabecalhos["traceparent"].split("-")
|
|
122
|
+
assert (versao, trace_id, flags) == ("00", "3f2a9c1e5b7d4e0a9c112d3e4f5a6b7c", "01")
|
|
123
|
+
assert len(span_id) == 16
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def test_correlation_headers_para_as_chamadas_da_automacao(monkeypatch):
|
|
127
|
+
monkeypatch.setenv("PROTON_CORRELATION_ID", "3f2a9c1e-5b7d-4e0a-9c11-2d3e4f5a6b7c")
|
|
128
|
+
assert proton_env.correlation_headers()["X-Correlation-Id"] == "3f2a9c1e-5b7d-4e0a-9c11-2d3e4f5a6b7c"
|
|
129
|
+
|
|
130
|
+
monkeypatch.setenv("PROTON_CORRELATION_ID", "<script>")
|
|
131
|
+
assert proton_env.correlation_headers() == {}
|
|
132
|
+
|
|
133
|
+
monkeypatch.delenv("PROTON_CORRELATION_ID")
|
|
134
|
+
assert proton_env.get_correlation_id() == ""
|
|
135
|
+
assert proton_env.correlation_headers() == {}
|
|
@@ -52,19 +52,20 @@ def test_status_do_proton_5_vai_como_veio(enviados, recwarn, status, esperado):
|
|
|
52
52
|
|
|
53
53
|
|
|
54
54
|
@pytest.mark.parametrize(
|
|
55
|
-
"status, esperado",
|
|
55
|
+
"status, esperado, motivo",
|
|
56
56
|
[
|
|
57
|
-
(RunStatus.FAILED_DATA, "Failed"),
|
|
58
|
-
(RunStatus.FAILED_ENVIRONMENT, "Failed"),
|
|
59
|
-
(RunStatus.IN_PROCESS, "Running"),
|
|
60
|
-
("Failed (Dados)", "Failed"),
|
|
57
|
+
(RunStatus.FAILED_DATA, "Failed", "business_error"),
|
|
58
|
+
(RunStatus.FAILED_ENVIRONMENT, "Failed", "environment_error"),
|
|
59
|
+
(RunStatus.IN_PROCESS, "Running", None),
|
|
60
|
+
("Failed (Dados)", "Failed", "business_error"),
|
|
61
61
|
],
|
|
62
62
|
)
|
|
63
|
-
def test_status_do_proton_4_vira_o_do_proton_5(enviados, capsys, status, esperado):
|
|
63
|
+
def test_status_do_proton_4_vira_o_do_proton_5(enviados, capsys, status, esperado, motivo):
|
|
64
64
|
with pytest.warns(DeprecationWarning, match="não existe no Proton 5"):
|
|
65
65
|
proton_automation.update_run_status(status)
|
|
66
66
|
|
|
67
|
-
|
|
67
|
+
# O que o Failed do Proton 4 separava (dados, ambiente) segue como o motivo da falha.
|
|
68
|
+
assert enviados[0][2] == ({"status": esperado, "reason": motivo} if motivo else {"status": esperado})
|
|
68
69
|
assert f'a execução recebe "{esperado}"' in capsys.readouterr().out
|
|
69
70
|
|
|
70
71
|
|
|
@@ -74,7 +75,7 @@ def test_aviso_no_log_so_na_primeira_vez(enviados, capsys):
|
|
|
74
75
|
proton_automation.update_run_status(RunStatus.FAILED_DATA)
|
|
75
76
|
|
|
76
77
|
assert capsys.readouterr().out.count("[proton]") == 1
|
|
77
|
-
assert [corpo for _, _, corpo in enviados] == [{"status": "Failed"
|
|
78
|
+
assert [corpo for _, _, corpo in enviados] == [{"status": "Failed", "reason": "business_error"}] * 2
|
|
78
79
|
|
|
79
80
|
|
|
80
81
|
def test_fora_do_proton_nao_envia(monkeypatch, enviados):
|
|
@@ -1,68 +0,0 @@
|
|
|
1
|
-
import traceback
|
|
2
|
-
|
|
3
|
-
import requests
|
|
4
|
-
|
|
5
|
-
from proton.proton_env import get_auth_header, get_host, get_id_dataset_run, is_proton_execution
|
|
6
|
-
from proton.proton_environment import mask_environment_values
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
def set_log(log_message, disable_date_time=False):
|
|
10
|
-
"""
|
|
11
|
-
Envia uma entrada de log.
|
|
12
|
-
|
|
13
|
-
Os valores do ambiente da execução saem mascarados (`••••••`), no log do Proton e no
|
|
14
|
-
print de fora dele (ver `mask_environment_values`).
|
|
15
|
-
"""
|
|
16
|
-
log_message = mask_environment_values(log_message)
|
|
17
|
-
|
|
18
|
-
if is_proton_execution():
|
|
19
|
-
dataset_run_id = get_id_dataset_run()
|
|
20
|
-
|
|
21
|
-
if dataset_run_id is not None:
|
|
22
|
-
url = f"{get_host()}/dataset-run-log/{dataset_run_id}"
|
|
23
|
-
|
|
24
|
-
body = {
|
|
25
|
-
"idDatasetRun": dataset_run_id,
|
|
26
|
-
"log": log_message,
|
|
27
|
-
"disableDateTime": disable_date_time,
|
|
28
|
-
}
|
|
29
|
-
|
|
30
|
-
return requests.put(url, json=body, headers=get_auth_header())
|
|
31
|
-
else:
|
|
32
|
-
print(log_message)
|
|
33
|
-
|
|
34
|
-
return None
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
def set_error_log(error_message):
|
|
38
|
-
"""
|
|
39
|
-
Envia um log de erro estruturado para o Proton.
|
|
40
|
-
|
|
41
|
-
Os valores do ambiente da execução saem mascarados (`••••••`), como em `set_log`.
|
|
42
|
-
"""
|
|
43
|
-
error_message = mask_environment_values(error_message)
|
|
44
|
-
|
|
45
|
-
if is_proton_execution():
|
|
46
|
-
dataset_run_id = get_id_dataset_run()
|
|
47
|
-
|
|
48
|
-
if dataset_run_id is not None:
|
|
49
|
-
url = f"{get_host()}/dataset-run/{dataset_run_id}/error"
|
|
50
|
-
body = {
|
|
51
|
-
"idDatasetRun": dataset_run_id,
|
|
52
|
-
"error": error_message,
|
|
53
|
-
}
|
|
54
|
-
|
|
55
|
-
return requests.put(url, json=body, headers=get_auth_header())
|
|
56
|
-
else:
|
|
57
|
-
print(error_message)
|
|
58
|
-
|
|
59
|
-
return None
|
|
60
|
-
|
|
61
|
-
# ATENÇÃO -----------------
|
|
62
|
-
def set_log_from_exception(e):
|
|
63
|
-
"""Envia um log de exceção formatando o stack trace"""
|
|
64
|
-
stack_trace = "".join(traceback.format_exception(type(e), e, e.__traceback__))
|
|
65
|
-
# Mascara o stack trace inteiro antes de quebrar em linhas: um valor com quebra de
|
|
66
|
-
# linha (uma chave, por exemplo) escaparia da máscara linha a linha.
|
|
67
|
-
for line in mask_environment_values(stack_trace).splitlines():
|
|
68
|
-
set_log(line)
|
|
@@ -1,21 +0,0 @@
|
|
|
1
|
-
from enum import Enum
|
|
2
|
-
|
|
3
|
-
class RunStatus(Enum):
|
|
4
|
-
RUNNING = "Running"
|
|
5
|
-
PASSED = "Passed"
|
|
6
|
-
FAILED = "Failed"
|
|
7
|
-
|
|
8
|
-
# Status do Proton 4. O Proton 5 não os tem e responde 400 (invalidRunStatus);
|
|
9
|
-
# `update_run_status` envia "Failed" no lugar dos dois primeiros e "Running" no
|
|
10
|
-
# lugar do último, com um aviso de obsolescência.
|
|
11
|
-
FAILED_DATA = "Failed (Dados)"
|
|
12
|
-
FAILED_ENVIRONMENT = "Failed (Ambiente)"
|
|
13
|
-
IN_PROCESS = "In Process"
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
STATUS_DO_PROTON_4 = {
|
|
17
|
-
RunStatus.FAILED_DATA.value: RunStatus.FAILED.value,
|
|
18
|
-
RunStatus.FAILED_ENVIRONMENT.value: RunStatus.FAILED.value,
|
|
19
|
-
RunStatus.IN_PROCESS.value: RunStatus.RUNNING.value,
|
|
20
|
-
}
|
|
21
|
-
"""Status do Proton 4 e o status do Proton 5 que a biblioteca envia no lugar."""
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|