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.
Files changed (26) hide show
  1. {protoncloud_sdk-5.3.2 → protoncloud_sdk-5.4.0}/PKG-INFO +79 -8
  2. {protoncloud_sdk-5.3.2 → protoncloud_sdk-5.4.0}/README.md +78 -7
  3. {protoncloud_sdk-5.3.2 → protoncloud_sdk-5.4.0}/pyproject.toml +1 -1
  4. {protoncloud_sdk-5.3.2 → protoncloud_sdk-5.4.0}/src/proton/component_runner.py +20 -5
  5. {protoncloud_sdk-5.3.2 → protoncloud_sdk-5.4.0}/src/proton/proton_automation.py +17 -5
  6. {protoncloud_sdk-5.3.2 → protoncloud_sdk-5.4.0}/src/proton/proton_env.py +47 -1
  7. protoncloud_sdk-5.4.0/src/proton/proton_logs.py +138 -0
  8. protoncloud_sdk-5.4.0/src/proton/run_status.py +60 -0
  9. {protoncloud_sdk-5.3.2 → protoncloud_sdk-5.4.0}/tests/test_component_runner.py +67 -9
  10. protoncloud_sdk-5.4.0/tests/test_observabilidade.py +135 -0
  11. {protoncloud_sdk-5.3.2 → protoncloud_sdk-5.4.0}/tests/test_run_status.py +9 -8
  12. protoncloud_sdk-5.3.2/src/proton/proton_logs.py +0 -68
  13. protoncloud_sdk-5.3.2/src/proton/run_status.py +0 -21
  14. {protoncloud_sdk-5.3.2 → protoncloud_sdk-5.4.0}/.gitignore +0 -0
  15. {protoncloud_sdk-5.3.2 → protoncloud_sdk-5.4.0}/LICENSE +0 -0
  16. {protoncloud_sdk-5.3.2 → protoncloud_sdk-5.4.0}/src/proton/__init__.py +0 -0
  17. {protoncloud_sdk-5.3.2 → protoncloud_sdk-5.4.0}/src/proton/proton_client_parama.py +0 -0
  18. {protoncloud_sdk-5.3.2 → protoncloud_sdk-5.4.0}/src/proton/proton_environment.py +0 -0
  19. {protoncloud_sdk-5.3.2 → protoncloud_sdk-5.4.0}/src/proton/proton_files_and_resources.py +0 -0
  20. {protoncloud_sdk-5.3.2 → protoncloud_sdk-5.4.0}/src/proton/proton_git.py +0 -0
  21. {protoncloud_sdk-5.3.2 → protoncloud_sdk-5.4.0}/src/proton/proton_parameter.py +0 -0
  22. {protoncloud_sdk-5.3.2 → protoncloud_sdk-5.4.0}/tests/test_dados_da_execucao.py +0 -0
  23. {protoncloud_sdk-5.3.2 → protoncloud_sdk-5.4.0}/tests/test_download_do_app.py +0 -0
  24. {protoncloud_sdk-5.3.2 → protoncloud_sdk-5.4.0}/tests/test_proton_env.py +0 -0
  25. {protoncloud_sdk-5.3.2 → protoncloud_sdk-5.4.0}/tests/test_proton_environment.py +0 -0
  26. {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.2
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`. Os status do Proton 4
262
- (`FAILED_DATA`, `FAILED_ENVIRONMENT` e `IN_PROCESS`) não existem mais: o servidor
263
- os recusa, e a biblioteca envia `FAILED` no lugar dos dois primeiros e `RUNNING`
264
- no lugar do último, com um `DeprecationWarning` e uma linha `[proton]` no log na
265
- primeira vez. Para separar falha de dados de falha de ambiente, registre o
266
- motivo no log da execução.
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` e relança a exceção. Execução parada
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`. Os status do Proton 4
241
- (`FAILED_DATA`, `FAILED_ENVIRONMENT` e `IN_PROCESS`) não existem mais: o servidor
242
- os recusa, e a biblioteca envia `FAILED` no lugar dos dois primeiros e `RUNNING`
243
- no lugar do último, com um `DeprecationWarning` e uma linha `[proton]` no log na
244
- primeira vez. Para separar falha de dados de falha de ambiente, registre o
245
- motivo no log da execução.
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` e relança a exceção. Execução parada
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.3.2"
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
- _PREFIXOS = {logging.WARNING: "Aviso: ", logging.ERROR: "Erro: ", logging.CRITICAL: "Erro: "}
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._PREFIXOS.get(record.levelno, "") + self.format(record))
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
- proton_automation.end_component()
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
- proton_automation.update_run_status(RunStatus.FAILED)
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 `DeprecationWarning` e, na primeira vez,
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
- _request_json("PUT", f"{_get_dataset_run_url()}/update-status", body={"status": status_value})
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
- return {'Authorization': f'Bearer {token}'}
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.logs.append)
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 test_passo_que_falha_fecha_o_passo_grava_o_erro_real_e_para(proton):
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
- assert falso.chamadas == ["start", "end"]
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", "end"]
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", "end"]
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", "end"]
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 log.startswith("Aviso: ") for log in falso.logs)
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
- assert enviados[0][2] == {"status": esperado}
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"}, {"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