protoncloud-sdk 5.2.0__tar.gz → 5.3.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 (22) hide show
  1. {protoncloud_sdk-5.2.0 → protoncloud_sdk-5.3.0}/PKG-INFO +49 -2
  2. {protoncloud_sdk-5.2.0 → protoncloud_sdk-5.3.0}/README.md +47 -0
  3. {protoncloud_sdk-5.2.0 → protoncloud_sdk-5.3.0}/pyproject.toml +5 -3
  4. protoncloud_sdk-5.3.0/src/proton/component_runner.py +324 -0
  5. {protoncloud_sdk-5.2.0 → protoncloud_sdk-5.3.0}/src/proton/proton_environment.py +9 -1
  6. {protoncloud_sdk-5.2.0 → protoncloud_sdk-5.3.0}/src/proton/proton_git.py +7 -24
  7. {protoncloud_sdk-5.2.0 → protoncloud_sdk-5.3.0}/src/proton/proton_parameter.py +8 -3
  8. protoncloud_sdk-5.3.0/tests/test_component_runner.py +324 -0
  9. {protoncloud_sdk-5.2.0 → protoncloud_sdk-5.3.0}/tests/test_proton_environment.py +6 -3
  10. protoncloud_sdk-5.3.0/tests/test_proton_parameter.py +41 -0
  11. {protoncloud_sdk-5.2.0 → protoncloud_sdk-5.3.0}/.gitignore +0 -0
  12. {protoncloud_sdk-5.2.0 → protoncloud_sdk-5.3.0}/LICENSE +0 -0
  13. {protoncloud_sdk-5.2.0 → protoncloud_sdk-5.3.0}/src/proton/__init__.py +0 -0
  14. {protoncloud_sdk-5.2.0 → protoncloud_sdk-5.3.0}/src/proton/proton_automation.py +0 -0
  15. {protoncloud_sdk-5.2.0 → protoncloud_sdk-5.3.0}/src/proton/proton_client_parama.py +0 -0
  16. {protoncloud_sdk-5.2.0 → protoncloud_sdk-5.3.0}/src/proton/proton_env.py +0 -0
  17. {protoncloud_sdk-5.2.0 → protoncloud_sdk-5.3.0}/src/proton/proton_files_and_resources.py +0 -0
  18. {protoncloud_sdk-5.2.0 → protoncloud_sdk-5.3.0}/src/proton/proton_logs.py +0 -0
  19. {protoncloud_sdk-5.2.0 → protoncloud_sdk-5.3.0}/src/proton/run_status.py +0 -0
  20. {protoncloud_sdk-5.2.0 → protoncloud_sdk-5.3.0}/tests/test_dados_da_execucao.py +0 -0
  21. {protoncloud_sdk-5.2.0 → protoncloud_sdk-5.3.0}/tests/test_proton_env.py +0 -0
  22. {protoncloud_sdk-5.2.0 → protoncloud_sdk-5.3.0}/tests/test_run_status.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: protoncloud-sdk
3
- Version: 5.2.0
3
+ Version: 5.3.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
@@ -16,7 +16,7 @@ Classifier: Programming Language :: Python :: 3.12
16
16
  Classifier: Programming Language :: Python :: 3.13
17
17
  Classifier: Topic :: Software Development :: Testing
18
18
  Requires-Python: >=3.10
19
- Requires-Dist: requests>=2.31
19
+ Requires-Dist: requests>=2.32.4
20
20
  Description-Content-Type: text/markdown
21
21
 
22
22
  # Proton Cloud SDK (Python)
@@ -265,6 +265,53 @@ no lugar do último, com um `DeprecationWarning` e uma linha `[proton]` no log n
265
265
  primeira vez. Para separar falha de dados de falha de ambiente, registre o
266
266
  motivo no log da execução.
267
267
 
268
+ ## Executor de componentes
269
+
270
+ Desde a 5.3.0 a SDK traz o laço que roda os passos do dataset, que antes cada
271
+ projeto copiava no `tests/test_proton_script.py`. O projeto só diz, pelo nome do
272
+ componente cadastrado no Proton, qual função executar:
273
+
274
+ ```python
275
+ # tests/test_proton_script.py: o único arquivo do projeto que importa a SDK
276
+ from proton.component_runner import run_components
277
+ from automacao.passos import localizar
278
+
279
+
280
+ def test_run_proton_execution():
281
+ run_components(localizar)
282
+ ```
283
+
284
+ `localizar` recebe o nome do componente e devolve o passo: uma função que recebe
285
+ os parâmetros (`dict`) e devolve as saídas (`dict` ou `None`). Também serve um
286
+ `dict` de nome para função. O código de negócio não importa nada do Proton e roda
287
+ igual fora dele.
288
+
289
+ A cada passo, o executor:
290
+
291
+ - marca o início e o fim, inclusive quando o passo falha;
292
+ - passa só os parâmetros daquele passo, já resolvidos pelo Proton (variável,
293
+ referência a saída, valor digitado no disparo, criptografado);
294
+ - grava as saídas devolvidas. Uma saída que não está cadastrada como parâmetro do
295
+ passo vira aviso no log;
296
+ - sobe o que apareceu na pasta `evidencias/` durante o passo: imagens no passo, o
297
+ resto na execução (`evidence_dir` muda a pasta; `None` desliga);
298
+ - manda para a execução o que o projeto registra pelo `logging`, do nível INFO
299
+ para cima (`forward_logs=False` desliga).
300
+
301
+ 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
303
+ não recebe status por cima.
304
+
305
+ Um projeto pode cuidar de várias plataformas (SAP, web, API, desktop), cada uma
306
+ cadastrada como um sistema no Proton e todas ligadas ao mesmo repositório: o executor
307
+ segue no mesmo processo enquanto o projeto tiver o componente do próximo passo, e as
308
+ sessões abertas continuam valendo. Quando o projeto não tem o componente e o passo é de
309
+ outro sistema, o executor devolve o controle ao runner, que chama o projeto daquele
310
+ sistema.
311
+
312
+ Fora de uma execução do Proton, `run_components` lança `ProtonRunError` em vez de
313
+ chamar o servidor.
314
+
268
315
  ## Migrando de uma cópia local da pasta `proton/`
269
316
 
270
317
  Projetos que carregam esta biblioteca como uma pasta `proton/` copiada migram em
@@ -244,6 +244,53 @@ no lugar do último, com um `DeprecationWarning` e uma linha `[proton]` no log n
244
244
  primeira vez. Para separar falha de dados de falha de ambiente, registre o
245
245
  motivo no log da execução.
246
246
 
247
+ ## Executor de componentes
248
+
249
+ Desde a 5.3.0 a SDK traz o laço que roda os passos do dataset, que antes cada
250
+ projeto copiava no `tests/test_proton_script.py`. O projeto só diz, pelo nome do
251
+ componente cadastrado no Proton, qual função executar:
252
+
253
+ ```python
254
+ # tests/test_proton_script.py: o único arquivo do projeto que importa a SDK
255
+ from proton.component_runner import run_components
256
+ from automacao.passos import localizar
257
+
258
+
259
+ def test_run_proton_execution():
260
+ run_components(localizar)
261
+ ```
262
+
263
+ `localizar` recebe o nome do componente e devolve o passo: uma função que recebe
264
+ os parâmetros (`dict`) e devolve as saídas (`dict` ou `None`). Também serve um
265
+ `dict` de nome para função. O código de negócio não importa nada do Proton e roda
266
+ igual fora dele.
267
+
268
+ A cada passo, o executor:
269
+
270
+ - marca o início e o fim, inclusive quando o passo falha;
271
+ - passa só os parâmetros daquele passo, já resolvidos pelo Proton (variável,
272
+ referência a saída, valor digitado no disparo, criptografado);
273
+ - grava as saídas devolvidas. Uma saída que não está cadastrada como parâmetro do
274
+ passo vira aviso no log;
275
+ - sobe o que apareceu na pasta `evidencias/` durante o passo: imagens no passo, o
276
+ resto na execução (`evidence_dir` muda a pasta; `None` desliga);
277
+ - manda para a execução o que o projeto registra pelo `logging`, do nível INFO
278
+ para cima (`forward_logs=False` desliga).
279
+
280
+ 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
282
+ não recebe status por cima.
283
+
284
+ Um projeto pode cuidar de várias plataformas (SAP, web, API, desktop), cada uma
285
+ cadastrada como um sistema no Proton e todas ligadas ao mesmo repositório: o executor
286
+ segue no mesmo processo enquanto o projeto tiver o componente do próximo passo, e as
287
+ sessões abertas continuam valendo. Quando o projeto não tem o componente e o passo é de
288
+ outro sistema, o executor devolve o controle ao runner, que chama o projeto daquele
289
+ sistema.
290
+
291
+ Fora de uma execução do Proton, `run_components` lança `ProtonRunError` em vez de
292
+ chamar o servidor.
293
+
247
294
  ## Migrando de uma cópia local da pasta `proton/`
248
295
 
249
296
  Projetos que carregam esta biblioteca como uma pasta `proton/` copiada migram em
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "protoncloud-sdk"
7
- version = "5.2.0"
7
+ version = "5.3.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"
@@ -21,7 +21,9 @@ classifiers = [
21
21
  "Programming Language :: Python :: 3.13",
22
22
  "Topic :: Software Development :: Testing",
23
23
  ]
24
- dependencies = ["requests>=2.31"]
24
+ # Piso, e não versão fixa: biblioteca não prende quem a usa. A 2.32.4 é a primeira sem a
25
+ # falha do .netrc (CVE-2024-47081), que podia mandar credencial ao host errado.
26
+ dependencies = ["requests>=2.32.4"]
25
27
 
26
28
  # Sem link para o repositorio: ele e privado, e o link publicado no PyPI seria
27
29
  # um 404 para qualquer pessoa de fora.
@@ -43,7 +45,7 @@ packages = ["src/proton"]
43
45
  include = ["src/proton", "tests", "README.md", "LICENSE", "pyproject.toml"]
44
46
 
45
47
  [dependency-groups]
46
- dev = ["pytest>=8"]
48
+ dev = ["pytest>=9.1.1"]
47
49
 
48
50
  [tool.pytest.ini_options]
49
51
  testpaths = ["tests"]
@@ -0,0 +1,324 @@
1
+ """
2
+ Executor de componentes: o laço que roda os passos do dataset numa execução do Proton.
3
+
4
+ O projeto de automação entrega uma função que, pelo nome do componente cadastrado no
5
+ Proton, devolve o passo a executar: uma função que recebe os parâmetros (`dict`) e
6
+ devolve as saídas (`dict` ou `None`). O executor cuida do resto, igual em todo projeto:
7
+
8
+ - marca o início e o fim de cada passo, inclusive quando ele falha;
9
+ - passa ao passo só os parâmetros dele, já resolvidos pelo Proton;
10
+ - grava as saídas que o passo devolve;
11
+ - sobe as evidências que o passo deixou na pasta de evidências;
12
+ - manda para a execução o que o projeto registra pelo `logging`;
13
+ - grava o erro e o status uma vez só.
14
+
15
+ Com isso o código de negócio não importa nada do Proton e roda igual fora dele. O
16
+ único arquivo do projeto que conhece a SDK é o ponto de entrada do runner
17
+ (`tests/test_proton_script.py`):
18
+
19
+ from proton.component_runner import run_components
20
+ from automacao.passos import localizar
21
+
22
+ def test_run_proton_execution():
23
+ run_components(localizar)
24
+
25
+ Um projeto pode cuidar de várias plataformas (SAP, web, API, desktop), cada uma cadastrada
26
+ como um sistema no Proton e todas ligadas ao mesmo repositório. O executor segue no mesmo
27
+ processo enquanto o projeto tiver o componente do próximo passo, e as sessões abertas
28
+ (navegador, SAP, aplicativo) continuam valendo. Quando o projeto não tem o componente e o
29
+ passo é de outro sistema, ele devolve o controle ao runner sem gravar status, e o runner
30
+ chama o projeto daquele sistema.
31
+ """
32
+
33
+ import logging
34
+ import threading
35
+ import traceback
36
+ from collections.abc import Mapping
37
+ from pathlib import Path
38
+ from typing import Callable, Optional, Union
39
+
40
+ from proton import proton_automation, proton_files_and_resources, proton_logs, proton_parameter
41
+ from proton.proton_env import is_proton_execution
42
+ from proton.run_status import RunStatus
43
+
44
+ Passo = Callable[[dict], Optional[Mapping]]
45
+ """Um passo: recebe os parâmetros do componente e devolve as saídas (ou `None`)."""
46
+
47
+ Localizador = Union[Callable[[str], Optional[Passo]], Mapping]
48
+ """Do nome do componente no Proton ao passo: uma função ou um `dict`."""
49
+
50
+ LIMITE_DE_PASSOS = 500
51
+ """Proteção contra laço: nenhuma execução real chega perto disso."""
52
+
53
+ _IMAGENS = {".png", ".jpg", ".jpeg", ".gif", ".bmp", ".webp"}
54
+ _STATUS_DE_PARADA = {"Stopped", "Cancelled"}
55
+
56
+ logger = logging.getLogger("proton")
57
+
58
+
59
+ class ProtonRunError(RuntimeError):
60
+ """Falha do executor, e não do passo: passo que não inicia, componente que não existe, laço."""
61
+
62
+
63
+ class ProtonLogHandler(logging.Handler):
64
+ """Manda cada registro do `logging` para o log da execução no Proton."""
65
+
66
+ _PREFIXOS = {logging.WARNING: "Aviso: ", logging.ERROR: "Erro: ", logging.CRITICAL: "Erro: "}
67
+
68
+ def __init__(self, level=logging.INFO):
69
+ super().__init__(level)
70
+ self._enviando = threading.local()
71
+
72
+ def emit(self, record):
73
+ # O envio é uma requisição HTTP, e a biblioteca de HTTP também registra pelo
74
+ # `logging`: sem esta trava, um log em DEBUG geraria outro, sem fim.
75
+ if getattr(self._enviando, "ativo", False):
76
+ return
77
+
78
+ self._enviando.ativo = True
79
+ try:
80
+ proton_logs.set_log(self._PREFIXOS.get(record.levelno, "") + self.format(record))
81
+ except Exception:
82
+ self.handleError(record)
83
+ finally:
84
+ self._enviando.ativo = False
85
+
86
+
87
+ def run_components(resolve: Localizador, *, evidence_dir: Optional[str] = "evidencias",
88
+ forward_logs: bool = True, max_steps: int = LIMITE_DE_PASSOS) -> int:
89
+ """
90
+ Roda, na ordem do dataset, os passos da execução corrente.
91
+
92
+ Args:
93
+ resolve: função que recebe o nome do componente e devolve o passo, ou um `dict`
94
+ de nome para passo. O passo recebe os parâmetros e devolve as saídas.
95
+ evidence_dir: pasta onde o projeto grava prints e arquivos. O que aparecer nela
96
+ durante um passo sobe para o Proton: imagens no passo, o resto na execução.
97
+ `None` desliga o envio.
98
+ forward_logs: manda para a execução o que o projeto registra pelo `logging`
99
+ (nível INFO para cima) enquanto os passos rodam.
100
+ max_steps: limite de passos, contra laço infinito.
101
+
102
+ Returns:
103
+ int: quantos passos rodaram.
104
+
105
+ Raises:
106
+ ProtonRunError: fora de uma execução do Proton, ou quando o executor não consegue
107
+ seguir (passo que não inicia, componente que não existe, laço).
108
+ Exception: a exceção do passo que falhou, depois de gravada no Proton.
109
+ """
110
+ if not is_proton_execution():
111
+ raise ProtonRunError(
112
+ "run_components só roda numa execução do Proton: falta idDatasetRun no ambiente. "
113
+ "Fora do Proton, rode os cenários do projeto direto."
114
+ )
115
+
116
+ localizar = _localizador(resolve)
117
+ handler = _ligar_log() if forward_logs else None
118
+ executados = 0
119
+
120
+ try:
121
+ sistema = proton_automation.get_current_component_system()
122
+
123
+ if not sistema:
124
+ if _foi_parada():
125
+ return 0
126
+ raise ProtonRunError("A execução não tem passo pendente: nada para rodar.")
127
+
128
+ while True:
129
+ nome = proton_automation.get_current_component_name()
130
+
131
+ if not nome:
132
+ break
133
+
134
+ passo, erro_ao_localizar = _procurar(localizar, nome)
135
+
136
+ # O que decide se o passo roda aqui é o projeto ter o componente (o nome é único
137
+ # na organização), e não o sistema: um projeto pode cuidar de várias plataformas.
138
+ if passo is None and erro_ao_localizar is None:
139
+ proximo_sistema = proton_automation.get_current_component_system()
140
+
141
+ if proximo_sistema != sistema:
142
+ logger.info('O componente "%s" (sistema "%s") não é deste projeto: o runner segue com o projeto dele.',
143
+ nome, proximo_sistema)
144
+ return executados
145
+
146
+ if executados >= max_steps:
147
+ raise ProtonRunError(f"Mais de {max_steps} passos na mesma execução: parei para não entrar em laço.")
148
+
149
+ _executar_passo(nome, passo, erro_ao_localizar, evidence_dir)
150
+ executados += 1
151
+
152
+ if _foi_parada():
153
+ return executados
154
+
155
+ proton_automation.update_run_status(RunStatus.PASSED)
156
+ return executados
157
+
158
+ except Exception as erro:
159
+ _registrar_falha(erro)
160
+ raise
161
+
162
+ finally:
163
+ if handler is not None:
164
+ _desligar_log(handler)
165
+
166
+
167
+ def _localizador(resolve):
168
+ if isinstance(resolve, Mapping):
169
+ return resolve.get
170
+
171
+ if callable(resolve):
172
+ return resolve
173
+
174
+ raise TypeError("resolve deve ser uma função (nome -> passo) ou um dict de nome para passo.")
175
+
176
+
177
+ def _procurar(localizar, nome):
178
+ """O passo do componente, ou o erro de quem o procurou (um import quebrado, por exemplo)."""
179
+ try:
180
+ return localizar(nome), None
181
+ except Exception as erro:
182
+ return None, erro
183
+
184
+
185
+ def _executar_passo(nome, passo, erro_ao_localizar, evidence_dir):
186
+ progresso = proton_automation.start_component()
187
+
188
+ # Sem o início marcado, o Proton continuaria apontando para este mesmo passo, e o
189
+ # laço o rodaria de novo para sempre.
190
+ if not progresso:
191
+ raise ProtonRunError(f'O Proton não iniciou o passo "{nome}".')
192
+
193
+ antes = _arquivos(evidence_dir)
194
+
195
+ try:
196
+ logger.info("Passo %s: %s", progresso.get("order", "?"), nome)
197
+
198
+ # O erro de quem procurou o componente só sobe agora, com o passo marcado, para a
199
+ # linha do tempo da execução mostrar em qual passo foi.
200
+ if erro_ao_localizar is not None:
201
+ raise erro_ao_localizar
202
+
203
+ if passo is None:
204
+ raise ProtonRunError(f'O componente "{nome}" não existe no projeto.')
205
+
206
+ saidas = passo(_parametros_do_passo(progresso, nome))
207
+ _gravar_saidas(nome, saidas)
208
+
209
+ finally:
210
+ try:
211
+ _enviar_evidencias(evidence_dir, antes)
212
+ except Exception:
213
+ logger.exception("Falha ao enviar as evidências do passo %s", nome)
214
+
215
+ proton_automation.end_component()
216
+
217
+
218
+ def _parametros_do_passo(progresso, nome):
219
+ """
220
+ Só os parâmetros do passo que está rodando, com os valores já resolvidos pelo Proton
221
+ (variável, referência a saída, valor digitado no disparo, criptografado).
222
+
223
+ A rota devolve todos os passos do dataset; o passo corrente é o de mesma ordem que o
224
+ `start-component` devolveu. Pela ordem, e não pelo nome, porque o mesmo componente
225
+ pode aparecer duas vezes no dataset com parâmetros diferentes.
226
+ """
227
+ componentes = proton_parameter.get_proton_all_component_parameters()
228
+
229
+ if not isinstance(componentes, list):
230
+ return {}
231
+
232
+ ordem = progresso.get("order")
233
+ candidatos = [c for c in componentes if c.get("order") == ordem]
234
+ escolhido = next((c for c in candidatos if c.get("component") == nome), None)
235
+
236
+ if escolhido is None:
237
+ escolhido = next((c for c in componentes if c.get("component") == nome), None)
238
+
239
+ if escolhido is None:
240
+ return {}
241
+
242
+ return {
243
+ p["parameter"]: "" if p.get("value") is None else str(p["value"])
244
+ for p in escolhido.get("params") or []
245
+ if p.get("parameter")
246
+ }
247
+
248
+
249
+ def _gravar_saidas(nome, saidas):
250
+ if saidas is None:
251
+ return
252
+
253
+ if not isinstance(saidas, Mapping):
254
+ raise ProtonRunError(
255
+ f'O componente "{nome}" devolveu {type(saidas).__name__}: o esperado é um dict com as saídas.'
256
+ )
257
+
258
+ for parametro, valor in saidas.items():
259
+ status = proton_parameter.set_proton_value(parametro, "" if valor is None else valor)
260
+
261
+ if status == 404:
262
+ logger.warning('A saída "%s" do componente "%s" não está no passo do dataset. '
263
+ "Cadastre-a como parâmetro do passo para o Proton guardar o valor.", parametro, nome)
264
+
265
+
266
+ def _arquivos(evidence_dir):
267
+ if not evidence_dir:
268
+ return {}
269
+
270
+ pasta = Path(evidence_dir)
271
+
272
+ if not pasta.is_dir():
273
+ return {}
274
+
275
+ return {arquivo: arquivo.stat().st_mtime_ns for arquivo in pasta.rglob("*") if arquivo.is_file()}
276
+
277
+
278
+ def _enviar_evidencias(evidence_dir, antes):
279
+ """Sobe o que apareceu (ou mudou) na pasta de evidências durante o passo."""
280
+ for arquivo, modificado in sorted(_arquivos(evidence_dir).items(), key=lambda item: item[1]):
281
+ if antes.get(arquivo) == modificado:
282
+ continue
283
+
284
+ if arquivo.suffix.lower() in _IMAGENS:
285
+ proton_files_and_resources.upload_image(str(arquivo))
286
+ else:
287
+ proton_files_and_resources.upload_file_resource(str(arquivo))
288
+
289
+
290
+ def _foi_parada():
291
+ """Execução parada ou cancelada por alguém: o executor não grava status por cima."""
292
+ return proton_automation.get_dataset_run_status() in _STATUS_DE_PARADA
293
+
294
+
295
+ def _registrar_falha(erro):
296
+ try:
297
+ if _foi_parada():
298
+ return
299
+
300
+ proton_logs.set_error_log("".join(traceback.format_exception(type(erro), erro, erro.__traceback__)))
301
+ proton_automation.update_run_status(RunStatus.FAILED)
302
+ except Exception:
303
+ # A falha original é a que importa: um erro ao registrá-la não pode escondê-la.
304
+ logger.exception("Falha ao registrar o erro da execução no Proton")
305
+
306
+
307
+ def _ligar_log():
308
+ handler = ProtonLogHandler()
309
+ handler.setFormatter(logging.Formatter("%(message)s"))
310
+ raiz = logging.getLogger()
311
+ handler._nivel_anterior = raiz.level
312
+
313
+ # O padrão do Python é WARNING, que esconderia o logging.info dos passos.
314
+ if raiz.level > logging.INFO:
315
+ raiz.setLevel(logging.INFO)
316
+
317
+ raiz.addHandler(handler)
318
+ return handler
319
+
320
+
321
+ def _desligar_log(handler):
322
+ raiz = logging.getLogger()
323
+ raiz.removeHandler(handler)
324
+ raiz.setLevel(handler._nivel_anterior)
@@ -21,6 +21,7 @@ log da execução, e nunca trazem valores.
21
21
  """
22
22
 
23
23
  import re
24
+ from urllib.parse import quote, quote_plus
24
25
  import threading
25
26
 
26
27
  import requests
@@ -86,8 +87,15 @@ class _Carga:
86
87
  self.error = error
87
88
 
88
89
  # Do mais longo para o mais curto: um valor que contém outro sai inteiro.
90
+ # Também as formas codificadas para URL: um valor que vai na query (a saída gravada
91
+ # pelo set_proton_value, por exemplo) aparece assim na URL e no eco do servidor.
89
92
  valores = sorted(
90
- {valor for valor in self.variables.values() if len(valor) >= TAMANHO_MINIMO_PARA_MASCARAR},
93
+ {
94
+ variante
95
+ for valor in self.variables.values()
96
+ if len(valor) >= TAMANHO_MINIMO_PARA_MASCARAR
97
+ for variante in (valor, quote_plus(valor), quote(valor, safe=""))
98
+ },
91
99
  key=len,
92
100
  reverse=True,
93
101
  )
@@ -1,11 +1,15 @@
1
+ """
2
+ Consulta dos repositórios Git cadastrados no Proton.
3
+
4
+ O clone de repositório saiu da SDK na 5.2.1: baixar o código é papel do runner, e o
5
+ clone levava o token do GitHub na linha de comando.
6
+ """
7
+
1
8
  import os
2
- import subprocess
3
- import threading
4
9
 
5
10
  import requests
6
11
  from proton.proton_client_parama import ProtonClientParam
7
12
  from proton.proton_env import get_api_url, get_auth_header
8
- from proton.proton_logs import set_log
9
13
 
10
14
 
11
15
  def get_remote_repository(system_type):
@@ -62,27 +66,6 @@ def get_remote_header_hash(remote_authorization, repository, branch):
62
66
  else:
63
67
  return ""
64
68
 
65
- def clone_repository(owner_repo, branch, commit_hash, authorization):
66
- """Clona um repositório Git."""
67
- repo_folder = os.path.join(ProtonClientParam._repository_folder(), owner_repo, branch, commit_hash)
68
- os.makedirs(repo_folder, exist_ok=True)
69
-
70
- def run_git_clone(repo_folder, branch, authorization, owner_repo):
71
- try:
72
- url = f"https://{authorization}@github.com/{owner_repo}.git"
73
- subprocess.run(
74
- ["git", "clone", "--branch", branch, url],
75
- cwd=repo_folder,
76
- check=True, # Levanta exceção se o git retornar código de erro
77
- capture_output=True, # Captura o output
78
- text=True
79
- )
80
- except subprocess.CalledProcessError as e:
81
- set_log(f"Erro ao clonar: {e.output}")
82
-
83
- thread = threading.Thread(target=run_git_clone, args=(repo_folder, branch, authorization, owner_repo))
84
- thread.start()
85
-
86
69
  def get_local_repository_path(owner_repo, repository, branch, commit_hash):
87
70
  """Retorna o caminho para um repositório local."""
88
71
  repo_folder = os.path.join(ProtonClientParam._repository_folder(), owner_repo, branch, commit_hash, repository)
@@ -1,3 +1,5 @@
1
+ from urllib.parse import quote_plus
2
+
1
3
  import requests
2
4
  from proton.proton_env import get_auth_header, get_host, get_id_dataset_run, is_proton_execution
3
5
  from proton.proton_environment import mask_environment_values
@@ -16,11 +18,14 @@ def set_proton_value(param, value):
16
18
  value (str ou int): O valor a ser inserido no parâmetro de output.
17
19
  """
18
20
  if is_proton_execution():
21
+ # O backend decodifica o valor de novo depois de ler a query (troca "+" por espaço
22
+ # e aplica decodeURIComponent), como a SDK Java espera: ela codifica o valor antes
23
+ # de montar a URL. Mandando o valor cru, um "+" virava espaço e um "%" derrubava a
24
+ # gravação com HTTP 500.
19
25
  params = {
20
- "parameter": param
26
+ "parameter": param,
27
+ "value": quote_plus(str(value)),
21
28
  }
22
-
23
- params["value"] = str(value)
24
29
 
25
30
  # Proton 5: a rota e "/set-value"; "/setValue" (Proton 4) responde 404 e o valor nunca era gravado.
26
31
  response = requests.put(f"{_get_dataset_run_url()}/set-value", params=params, headers=get_auth_header())
@@ -0,0 +1,324 @@
1
+ """
2
+ Executor de componentes: o laço que todo projeto copiava (TestProtonScript) agora vive na
3
+ SDK. Os testes simulam o Proton com os passos de uma execução e conferem o contrato:
4
+ início e fim de cada passo, parâmetros só do passo, saídas, evidências, log, erro e status.
5
+ """
6
+
7
+ import logging
8
+
9
+ import pytest
10
+
11
+ from proton import component_runner, proton_automation, proton_files_and_resources, proton_logs, proton_parameter
12
+ from proton.component_runner import ProtonRunError, run_components
13
+ from proton.run_status import RunStatus
14
+
15
+
16
+ class ProtonFalso:
17
+ """A execução no Proton: passos com sistema, ordem e parâmetros, e o que a SDK gravou."""
18
+
19
+ def __init__(self, passos):
20
+ self.passos = passos
21
+ self.atual = 0
22
+ self.aberto = None
23
+ self.status = "Running"
24
+ self.chamadas = []
25
+ self.saidas = []
26
+ self.logs = []
27
+ self.erros = []
28
+ self.imagens = []
29
+ self.arquivos = []
30
+ self.saidas_fora_do_passo = set()
31
+ self.inicio_falha = False
32
+
33
+ def _pendente(self):
34
+ return self.passos[self.atual] if self.atual < len(self.passos) else None
35
+
36
+ def nome(self):
37
+ passo = self._pendente()
38
+ return passo["nome"] if passo else ""
39
+
40
+ def sistema(self):
41
+ passo = self._pendente()
42
+ return passo.get("sistema", "Web") if passo else ""
43
+
44
+ def iniciar(self):
45
+ self.chamadas.append("start")
46
+ passo = self._pendente()
47
+
48
+ if self.inicio_falha or passo is None:
49
+ return {}
50
+
51
+ self.aberto = passo
52
+ self.atual += 1
53
+ return {"id": 100 + self.atual, "order": passo["ordem"], "component": passo["nome"]}
54
+
55
+ def finalizar(self):
56
+ self.chamadas.append("end")
57
+ self.aberto = None
58
+ return 2
59
+
60
+ def parametros(self):
61
+ return [
62
+ {"component": p["nome"], "order": p["ordem"],
63
+ "params": [{"parameter": k, "value": v} for k, v in p.get("parametros", {}).items()]}
64
+ for p in self.passos
65
+ ]
66
+
67
+ def gravar_saida(self, parametro, valor):
68
+ if parametro in self.saidas_fora_do_passo:
69
+ return 404
70
+
71
+ self.saidas.append((self.aberto["nome"], parametro, valor))
72
+ return 200
73
+
74
+ def atualizar_status(self, status):
75
+ self.status = status.value
76
+
77
+
78
+ @pytest.fixture
79
+ def proton(monkeypatch):
80
+ monkeypatch.setenv("idDatasetRun", "42")
81
+
82
+ def montar(passos):
83
+ falso = ProtonFalso(passos)
84
+ monkeypatch.setattr(proton_automation, "get_current_component_name", falso.nome)
85
+ monkeypatch.setattr(proton_automation, "get_current_component_system", falso.sistema)
86
+ monkeypatch.setattr(proton_automation, "start_component", falso.iniciar)
87
+ monkeypatch.setattr(proton_automation, "end_component", falso.finalizar)
88
+ monkeypatch.setattr(proton_automation, "update_run_status", falso.atualizar_status)
89
+ monkeypatch.setattr(proton_automation, "get_dataset_run_status", lambda *a: falso.status)
90
+ monkeypatch.setattr(proton_parameter, "get_proton_all_component_parameters", falso.parametros)
91
+ monkeypatch.setattr(proton_parameter, "set_proton_value", falso.gravar_saida)
92
+ monkeypatch.setattr(proton_logs, "set_log", falso.logs.append)
93
+ monkeypatch.setattr(proton_logs, "set_error_log", falso.erros.append)
94
+ monkeypatch.setattr(proton_files_and_resources, "upload_image", falso.imagens.append)
95
+ monkeypatch.setattr(proton_files_and_resources, "upload_file_resource", falso.arquivos.append)
96
+ return falso
97
+
98
+ return montar
99
+
100
+
101
+ def test_roda_os_passos_na_ordem_com_os_parametros_de_cada_um(proton):
102
+ falso = proton([
103
+ {"nome": "FazerLogin", "ordem": 1, "parametros": {"in_usuario": "ana"}},
104
+ {"nome": "AdicionarAoCarrinho", "ordem": 2, "parametros": {"in_produto": "Mochila", "out_preco": None}},
105
+ ])
106
+ recebidos = []
107
+
108
+ def passo(nome, saidas=None):
109
+ def executar(params):
110
+ recebidos.append((nome, params))
111
+ return saidas
112
+ return executar
113
+
114
+ executados = run_components({
115
+ "FazerLogin": passo("FazerLogin"),
116
+ "AdicionarAoCarrinho": passo("AdicionarAoCarrinho", {"out_preco": "29.99"}),
117
+ })
118
+
119
+ assert executados == 2
120
+ assert recebidos == [
121
+ ("FazerLogin", {"in_usuario": "ana"}),
122
+ ("AdicionarAoCarrinho", {"in_produto": "Mochila", "out_preco": ""}),
123
+ ]
124
+ assert falso.chamadas == ["start", "end", "start", "end"]
125
+ assert falso.saidas == [("AdicionarAoCarrinho", "out_preco", "29.99")]
126
+ assert falso.status == RunStatus.PASSED.value
127
+
128
+
129
+ def test_o_mesmo_componente_duas_vezes_recebe_os_parametros_da_sua_posicao(proton):
130
+ proton([
131
+ {"nome": "AdicionarAoCarrinho", "ordem": 1, "parametros": {"in_produto": "Mochila"}},
132
+ {"nome": "AdicionarAoCarrinho", "ordem": 2, "parametros": {"in_produto": "Lanterna"}},
133
+ ])
134
+ produtos = []
135
+
136
+ run_components(lambda nome: lambda params: produtos.append(params["in_produto"]))
137
+
138
+ assert produtos == ["Mochila", "Lanterna"]
139
+
140
+
141
+ def test_passo_que_falha_fecha_o_passo_grava_o_erro_real_e_para(proton):
142
+ falso = proton([
143
+ {"nome": "FazerLogin", "ordem": 1},
144
+ {"nome": "FinalizarCompra", "ordem": 2},
145
+ ])
146
+
147
+ def login_recusado(params):
148
+ raise AssertionError("login recusado")
149
+
150
+ with pytest.raises(AssertionError, match="login recusado"):
151
+ run_components({"FazerLogin": login_recusado, "FinalizarCompra": lambda p: None})
152
+
153
+ assert falso.chamadas == ["start", "end"]
154
+ assert falso.status == RunStatus.FAILED.value
155
+ assert len(falso.erros) == 1
156
+ # O stack trace é o do passo, e não o da maquinaria de chamada.
157
+ assert "login_recusado" in falso.erros[0]
158
+ assert "AssertionError: login recusado" in falso.erros[0]
159
+
160
+
161
+ def test_componente_que_nao_existe_no_projeto(proton):
162
+ falso = proton([{"nome": "Inexistente", "ordem": 1}])
163
+
164
+ with pytest.raises(ProtonRunError, match='"Inexistente" não existe'):
165
+ run_components({})
166
+
167
+ assert falso.chamadas == ["start", "end"]
168
+ assert falso.status == RunStatus.FAILED.value
169
+
170
+
171
+ def test_passo_que_o_proton_nao_inicia_nao_vira_laco(proton):
172
+ falso = proton([{"nome": "FazerLogin", "ordem": 1}])
173
+ falso.inicio_falha = True
174
+
175
+ with pytest.raises(ProtonRunError, match="não iniciou"):
176
+ run_components({"FazerLogin": lambda p: None})
177
+
178
+ assert falso.chamadas == ["start"]
179
+
180
+
181
+ def test_limite_de_passos(proton):
182
+ proton([{"nome": "Passo", "ordem": i} for i in range(1, 6)])
183
+
184
+ with pytest.raises(ProtonRunError, match="laço"):
185
+ run_components({"Passo": lambda p: None}, max_steps=3)
186
+
187
+
188
+ def test_proximo_passo_de_outro_sistema_que_o_projeto_nao_tem_devolve_ao_runner(proton):
189
+ falso = proton([
190
+ {"nome": "FazerLogin", "ordem": 1, "sistema": "Web"},
191
+ {"nome": "ConferirPedido", "ordem": 2, "sistema": "SAP"},
192
+ ])
193
+
194
+ assert run_components({"FazerLogin": lambda p: None}) == 1
195
+ assert falso.status == "Running"
196
+ assert falso.chamadas == ["start", "end"]
197
+
198
+
199
+ def test_proximo_passo_de_outro_sistema_que_o_projeto_tem_roda_no_mesmo_processo(proton):
200
+ falso = proton([
201
+ {"nome": "CriarPedido", "ordem": 1, "sistema": "SAP"},
202
+ {"nome": "ConferirPedido", "ordem": 2, "sistema": "Portal"},
203
+ {"nome": "ConsultarPedido", "ordem": 3, "sistema": "API"},
204
+ ])
205
+ rodados = []
206
+
207
+ def passo(nome):
208
+ return lambda params: rodados.append(nome)
209
+
210
+ assert run_components(passo) == 3
211
+ assert rodados == ["CriarPedido", "ConferirPedido", "ConsultarPedido"]
212
+ assert falso.status == RunStatus.PASSED.value
213
+
214
+
215
+ def test_componente_que_falta_no_mesmo_sistema_falha_em_vez_de_devolver_ao_runner(proton):
216
+ falso = proton([
217
+ {"nome": "FazerLogin", "ordem": 1, "sistema": "Web"},
218
+ {"nome": "Esquecido", "ordem": 2, "sistema": "Web"},
219
+ ])
220
+
221
+ with pytest.raises(ProtonRunError, match='"Esquecido" não existe'):
222
+ run_components({"FazerLogin": lambda p: None})
223
+
224
+ assert falso.chamadas == ["start", "end", "start", "end"]
225
+ assert falso.status == RunStatus.FAILED.value
226
+
227
+
228
+ def test_erro_ao_procurar_o_componente_marca_o_passo_e_falha(proton):
229
+ falso = proton([{"nome": "FazerLogin", "ordem": 1}])
230
+
231
+ def localizar(nome):
232
+ raise ImportError("falta a biblioteca do SAP")
233
+
234
+ with pytest.raises(ImportError, match="biblioteca do SAP"):
235
+ run_components(localizar)
236
+
237
+ assert falso.chamadas == ["start", "end"]
238
+ assert falso.status == RunStatus.FAILED.value
239
+
240
+
241
+ def test_execucao_parada_nao_recebe_passed(proton):
242
+ falso = proton([{"nome": "FazerLogin", "ordem": 1}])
243
+
244
+ def parar(params):
245
+ falso.status = "Stopped"
246
+
247
+ run_components({"FazerLogin": parar})
248
+
249
+ assert falso.status == "Stopped"
250
+
251
+
252
+ def test_execucao_sem_passo_pendente_nao_passa(proton):
253
+ falso = proton([])
254
+
255
+ with pytest.raises(ProtonRunError, match="não tem passo pendente"):
256
+ run_components({})
257
+
258
+ assert falso.status == RunStatus.FAILED.value
259
+
260
+
261
+ def test_saida_fora_do_passo_avisa_sem_falhar(proton, caplog):
262
+ falso = proton([{"nome": "FinalizarCompra", "ordem": 1}])
263
+ falso.saidas_fora_do_passo = {"out_total"}
264
+
265
+ run_components({"FinalizarCompra": lambda p: {"out_total": "32.39"}})
266
+
267
+ assert falso.status == RunStatus.PASSED.value
268
+ assert any("out_total" in log and log.startswith("Aviso: ") for log in falso.logs)
269
+
270
+
271
+ def test_saida_que_nao_e_dict_falha(proton):
272
+ proton([{"nome": "FinalizarCompra", "ordem": 1}])
273
+
274
+ with pytest.raises(ProtonRunError, match="esperado é um dict"):
275
+ run_components({"FinalizarCompra": lambda p: "32.39"})
276
+
277
+
278
+ def test_evidencias_novas_do_passo_sobem_e_as_antigas_nao(proton, tmp_path):
279
+ pasta = tmp_path / "evidencias"
280
+ pasta.mkdir()
281
+ (pasta / "antiga.png").write_bytes(b"x")
282
+ falso = proton([{"nome": "FinalizarCompra", "ordem": 1}])
283
+
284
+ def finalizar(params):
285
+ (pasta / "pedido.png").write_bytes(b"x")
286
+ (pasta / "pedido.html").write_text("<html/>")
287
+
288
+ run_components({"FinalizarCompra": finalizar}, evidence_dir=str(pasta))
289
+
290
+ assert falso.imagens == [str(pasta / "pedido.png")]
291
+ assert falso.arquivos == [str(pasta / "pedido.html")]
292
+
293
+
294
+ def test_evidencia_do_passo_que_falha_tambem_sobe(proton, tmp_path):
295
+ falso = proton([{"nome": "FinalizarCompra", "ordem": 1}])
296
+
297
+ def finalizar(params):
298
+ (tmp_path / "falha.png").write_bytes(b"x")
299
+ raise AssertionError("total errado")
300
+
301
+ with pytest.raises(AssertionError):
302
+ run_components({"FinalizarCompra": finalizar}, evidence_dir=str(tmp_path))
303
+
304
+ assert falso.imagens == [str(tmp_path / "falha.png")]
305
+
306
+
307
+ def test_log_do_projeto_vai_para_a_execucao_so_durante_os_passos(proton):
308
+ falso = proton([{"nome": "FazerLogin", "ordem": 1}])
309
+ log = logging.getLogger("automacao")
310
+
311
+ run_components({"FazerLogin": lambda p: log.info("tela de produtos aberta")})
312
+ log.warning("depois da execução")
313
+
314
+ assert "tela de produtos aberta" in falso.logs
315
+ assert "Passo 1: FazerLogin" in falso.logs
316
+ assert not any("depois da execução" in item for item in falso.logs)
317
+ assert not any(isinstance(h, component_runner.ProtonLogHandler) for h in logging.getLogger().handlers)
318
+
319
+
320
+ def test_fora_do_proton_avisa_em_vez_de_chamar_o_servidor(monkeypatch):
321
+ monkeypatch.delenv("idDatasetRun", raising=False)
322
+
323
+ with pytest.raises(ProtonRunError, match="só roda numa execução do Proton"):
324
+ run_components({})
@@ -12,7 +12,7 @@ import os
12
12
  import threading
13
13
  import warnings
14
14
  from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
15
- from urllib.parse import parse_qs, urlsplit
15
+ from urllib.parse import parse_qs, unquote, urlsplit
16
16
 
17
17
  import pytest
18
18
  import requests
@@ -325,8 +325,11 @@ def test_valores_mascarados_no_log(proton, execucao, capsys, monkeypatch):
325
325
  # Prints da própria lib: rota que ecoa o valor e parâmetro de saída recusado.
326
326
  assert get_current_component_name() == ""
327
327
  assert set_proton_value("SENHA_LIDA", senha) == 400
328
- # O parâmetro de saída é gravado de propósito: o valor enviado vai aberto.
329
- assert proton.valores_gravados[0]["value"] == [SAP_SENHA]
328
+ # O parâmetro de saída é gravado de propósito: o valor enviado vai aberto. O backend
329
+ # decodifica o valor de novo depois de ler a query (troca "+" por espaço e aplica
330
+ # decodeURIComponent), e é aí que ele tem de chegar inteiro.
331
+ enviado = proton.valores_gravados[0]["value"][0]
332
+ assert unquote(enviado.replace("+", " "), errors="strict") == SAP_SENHA
330
333
 
331
334
  # Representação em texto do mapa e mensagens de erro.
332
335
  variaveis = get_environment_variables()
@@ -0,0 +1,41 @@
1
+ """
2
+ Saída gravada pelo set_proton_value: o backend lê a query e decodifica o valor de novo
3
+ (troca "+" por espaço e aplica decodeURIComponent). O valor tem de chegar inteiro, com
4
+ "+", "%", acento e quebra de linha.
5
+ """
6
+
7
+ from urllib.parse import parse_qs, unquote, urlparse
8
+
9
+ import pytest
10
+
11
+ from proton import proton_parameter
12
+
13
+
14
+ class _Resposta:
15
+ status_code = 200
16
+ text = "{}"
17
+
18
+
19
+ @pytest.fixture
20
+ def backend(monkeypatch):
21
+ monkeypatch.setenv("idDatasetRun", "42")
22
+ monkeypatch.setenv("PROTON_HOST", "http://localhost:3333/api")
23
+ monkeypatch.setenv("PROTON_TOKEN", "proton_run_teste")
24
+ gravados = []
25
+
26
+ def put(url, params=None, headers=None):
27
+ # O que o requests põe na URL e o que o Adonis lê da query...
28
+ consulta = parse_qs(urlparse(proton_parameter.requests.Request("PUT", url, params=params).prepare().url).query)
29
+ valor = consulta["value"][0]
30
+ # ...e o que o set-value do backend faz em seguida.
31
+ gravados.append(unquote(valor.replace("+", " "), errors="strict"))
32
+ return _Resposta()
33
+
34
+ monkeypatch.setattr(proton_parameter.requests, "put", put)
35
+ return gravados
36
+
37
+
38
+ @pytest.mark.parametrize("valor", ["São Paulo", "+55 11 91234-5678", "50% de desconto", "a&b=c", "linha 1\nlinha 2", "32.39"])
39
+ def test_valor_chega_inteiro_no_backend(backend, valor):
40
+ assert proton_parameter.set_proton_value("out_valor", valor) == 200
41
+ assert backend == [valor]
File without changes