persona-dsl 26.1.21.44__py3-none-any.whl

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 (86) hide show
  1. persona_dsl/__init__.py +35 -0
  2. persona_dsl/components/action.py +10 -0
  3. persona_dsl/components/base_step.py +251 -0
  4. persona_dsl/components/combined_step.py +68 -0
  5. persona_dsl/components/expectation.py +10 -0
  6. persona_dsl/components/fact.py +10 -0
  7. persona_dsl/components/goal.py +10 -0
  8. persona_dsl/components/ops.py +7 -0
  9. persona_dsl/components/step.py +75 -0
  10. persona_dsl/expectations/generic/__init__.py +15 -0
  11. persona_dsl/expectations/generic/contains_item.py +19 -0
  12. persona_dsl/expectations/generic/contains_the_text.py +15 -0
  13. persona_dsl/expectations/generic/has_entries.py +21 -0
  14. persona_dsl/expectations/generic/is_equal.py +24 -0
  15. persona_dsl/expectations/generic/is_greater_than.py +18 -0
  16. persona_dsl/expectations/generic/path_equal.py +27 -0
  17. persona_dsl/expectations/web/__init__.py +5 -0
  18. persona_dsl/expectations/web/is_displayed.py +13 -0
  19. persona_dsl/expectations/web/matches_aria_snapshot.py +222 -0
  20. persona_dsl/expectations/web/matches_screenshot.py +160 -0
  21. persona_dsl/generators/__init__.py +5 -0
  22. persona_dsl/generators/api_generator.py +423 -0
  23. persona_dsl/generators/cli.py +431 -0
  24. persona_dsl/generators/page_generator.py +1140 -0
  25. persona_dsl/ops/api/__init__.py +5 -0
  26. persona_dsl/ops/api/json_as.py +104 -0
  27. persona_dsl/ops/api/json_response.py +48 -0
  28. persona_dsl/ops/api/send_request.py +41 -0
  29. persona_dsl/ops/db/__init__.py +5 -0
  30. persona_dsl/ops/db/execute_sql.py +22 -0
  31. persona_dsl/ops/db/fetch_all.py +29 -0
  32. persona_dsl/ops/db/fetch_one.py +22 -0
  33. persona_dsl/ops/kafka/__init__.py +4 -0
  34. persona_dsl/ops/kafka/message_in_topic.py +89 -0
  35. persona_dsl/ops/kafka/send_message.py +35 -0
  36. persona_dsl/ops/soap/__init__.py +4 -0
  37. persona_dsl/ops/soap/call_operation.py +24 -0
  38. persona_dsl/ops/soap/operation_result.py +24 -0
  39. persona_dsl/ops/web/__init__.py +37 -0
  40. persona_dsl/ops/web/aria_snapshot.py +87 -0
  41. persona_dsl/ops/web/click.py +30 -0
  42. persona_dsl/ops/web/current_path.py +17 -0
  43. persona_dsl/ops/web/element_attribute.py +24 -0
  44. persona_dsl/ops/web/element_is_visible.py +27 -0
  45. persona_dsl/ops/web/element_text.py +28 -0
  46. persona_dsl/ops/web/elements_count.py +42 -0
  47. persona_dsl/ops/web/fill.py +41 -0
  48. persona_dsl/ops/web/generate_page_object.py +118 -0
  49. persona_dsl/ops/web/input_value.py +23 -0
  50. persona_dsl/ops/web/navigate.py +52 -0
  51. persona_dsl/ops/web/press_key.py +37 -0
  52. persona_dsl/ops/web/rich_aria_snapshot.py +159 -0
  53. persona_dsl/ops/web/screenshot.py +68 -0
  54. persona_dsl/ops/web/table_data.py +43 -0
  55. persona_dsl/ops/web/wait_for_navigation.py +23 -0
  56. persona_dsl/pages/__init__.py +133 -0
  57. persona_dsl/pages/elements.py +998 -0
  58. persona_dsl/pages/page.py +44 -0
  59. persona_dsl/pages/virtual_page.py +94 -0
  60. persona_dsl/persona.py +125 -0
  61. persona_dsl/pytest_plugin.py +1230 -0
  62. persona_dsl/runtime/dist/persona_bundle.js +1077 -0
  63. persona_dsl/skills/__init__.py +7 -0
  64. persona_dsl/skills/core/base.py +41 -0
  65. persona_dsl/skills/core/skill_definition.py +30 -0
  66. persona_dsl/skills/use_api.py +251 -0
  67. persona_dsl/skills/use_browser.py +78 -0
  68. persona_dsl/skills/use_database.py +129 -0
  69. persona_dsl/skills/use_kafka.py +135 -0
  70. persona_dsl/skills/use_soap.py +66 -0
  71. persona_dsl/utils/__init__.py +0 -0
  72. persona_dsl/utils/artifacts.py +22 -0
  73. persona_dsl/utils/config.py +54 -0
  74. persona_dsl/utils/data_providers.py +159 -0
  75. persona_dsl/utils/decorators.py +80 -0
  76. persona_dsl/utils/metrics.py +69 -0
  77. persona_dsl/utils/naming.py +14 -0
  78. persona_dsl/utils/path.py +202 -0
  79. persona_dsl/utils/retry.py +51 -0
  80. persona_dsl/utils/taas_integration.py +124 -0
  81. persona_dsl/utils/waits.py +112 -0
  82. persona_dsl-26.1.21.44.dist-info/METADATA +233 -0
  83. persona_dsl-26.1.21.44.dist-info/RECORD +86 -0
  84. persona_dsl-26.1.21.44.dist-info/WHEEL +5 -0
  85. persona_dsl-26.1.21.44.dist-info/entry_points.txt +6 -0
  86. persona_dsl-26.1.21.44.dist-info/top_level.txt +1 -0
@@ -0,0 +1,1230 @@
1
+ from __future__ import annotations
2
+ import os
3
+ import json
4
+ import time
5
+ import logging
6
+ import functools
7
+ from enum import Enum
8
+ from functools import lru_cache
9
+ from contextlib import contextmanager
10
+ from pathlib import Path
11
+ from typing import Any, Dict, Tuple, Type, List, Optional, Generator, TYPE_CHECKING
12
+ from urllib.parse import urlparse
13
+
14
+ import allure
15
+ import pytest
16
+ import yaml
17
+ import requests
18
+
19
+ if TYPE_CHECKING:
20
+ from persona_dsl.components.expectation import Expectation
21
+ from persona_dsl.persona import Persona
22
+ from persona_dsl.utils.config import Config
23
+ from persona_dsl.skills.core.skill_definition import SkillId
24
+
25
+
26
+ logger = logging.getLogger(__name__)
27
+
28
+
29
+ class DataDrivenError(Exception):
30
+ """Кастомное исключение для ошибок в @data_driven."""
31
+
32
+ pass
33
+
34
+
35
+ def _taas_ready() -> bool:
36
+ """
37
+ Проверяет, корректно ли сконфигурирован контекст TaaS:
38
+ - установлен TAAS_RUN_ID
39
+ - TAAS_STREAM_MAXLEN является положительным целым
40
+ """
41
+ if not os.environ.get("TAAS_RUN_ID"):
42
+ return False
43
+ raw = os.getenv("TAAS_STREAM_MAXLEN", "10000")
44
+ try:
45
+ return int(raw) > 0
46
+ except Exception:
47
+ return False
48
+
49
+
50
+ # --- Allure Step Instrumentation ---
51
+ _original_allure_step = allure.step
52
+
53
+
54
+ @contextmanager
55
+ def _instrumented_allure_step(description: str) -> Generator[Any, None, None]:
56
+ """Обертка над allure.step, которая отправляет события в TaaS."""
57
+ from .utils.taas_integration import publish_taas_event
58
+
59
+ start_time = time.time()
60
+ publish_taas_event(
61
+ {
62
+ "event": "step_start",
63
+ "type": "allure_step",
64
+ "data": {"description": description, "timestamp": start_time},
65
+ }
66
+ )
67
+
68
+ status = "passed"
69
+ try:
70
+ with _original_allure_step(description) as s:
71
+ yield s
72
+ except Exception:
73
+ status = "failed"
74
+ raise
75
+ finally:
76
+ end_time = time.time()
77
+ duration = end_time - start_time
78
+ publish_taas_event(
79
+ {
80
+ "event": "step_end",
81
+ "type": "allure_step",
82
+ "data": {
83
+ "description": description,
84
+ "timestamp": end_time,
85
+ "duration": duration,
86
+ "status": status,
87
+ },
88
+ }
89
+ )
90
+
91
+
92
+ # --- End Allure Step Instrumentation ---
93
+
94
+
95
+ def _get_skill_config(
96
+ config: Config, skill_type: str, name: str
97
+ ) -> Optional[Dict[str, Any]]:
98
+ stype_config = config.skills.get(skill_type)
99
+ if stype_config is None:
100
+ return None
101
+
102
+ if name == "default":
103
+ if "default" in stype_config and isinstance(stype_config["default"], dict):
104
+ return stype_config["default"]
105
+
106
+ # If there are no named configs (sub-dicts), the whole thing is the default config
107
+ if not any(isinstance(v, dict) for v in stype_config.values()):
108
+ return stype_config
109
+
110
+ # Otherwise, collect only non-dict values for the default config
111
+ implicit_config = {
112
+ k: v for k, v in stype_config.items() if not isinstance(v, dict)
113
+ }
114
+ return implicit_config or None
115
+
116
+ named_config = stype_config.get(name)
117
+ return named_config if isinstance(named_config, dict) else None
118
+
119
+
120
+ def _create_selenoid_session(
121
+ hub_url: str, capabilities: Dict[str, Any], retries: int = 3, timeout: float = 10.0
122
+ ) -> Tuple[str, str]:
123
+ """
124
+ Создает сессию в Selenoid через WebDriver API с повторными попытками.
125
+ Возвращает кортеж (ws_endpoint, session_id).
126
+ """
127
+ session_url = f"{hub_url.rstrip('/')}/wd/hub/session"
128
+ last_error: Optional[Exception] = None
129
+
130
+ for attempt in range(retries + 1):
131
+ try:
132
+ logger.info(
133
+ f"Попытка создания сессии Selenoid ({attempt + 1}/{retries + 1})..."
134
+ )
135
+ # requests imported at module level
136
+ resp = requests.post(
137
+ session_url, json={"capabilities": capabilities}, timeout=timeout
138
+ )
139
+ if resp.status_code == 200:
140
+ data = resp.json()
141
+ # Формат ответа WebDriver: { "value": { "sessionId": "...", "capabilities": ... } }
142
+ value = data.get("value", {})
143
+ session_id = value.get("sessionId") or data.get("sessionId")
144
+
145
+ if not session_id:
146
+ raise RuntimeError(f"Selenoid не вернул sessionId: {data}")
147
+
148
+ # Формируем WS URL для CDP
149
+ parsed = urlparse(hub_url)
150
+ ws_scheme = "wss" if parsed.scheme == "https" else "ws"
151
+ ws_endpoint = f"{ws_scheme}://{parsed.netloc}/devtools/{session_id}"
152
+
153
+ logger.info(f"Сессия Selenoid создана: {session_id}")
154
+ return ws_endpoint, session_id
155
+ else:
156
+ logger.warning(
157
+ f"Ошибка создания сессии Selenoid (Code {resp.status_code}): {resp.text}"
158
+ )
159
+ except Exception as e:
160
+ last_error = e
161
+ logger.warning(f"Ошибка подключения к Selenoid: {e}")
162
+
163
+ if attempt < retries:
164
+ time.sleep(2.0)
165
+
166
+ raise RuntimeError(
167
+ f"Не удалось создать сессию Selenoid после {retries + 1} попыток. Последняя ошибка: {last_error}"
168
+ )
169
+
170
+
171
+ def _get_persona_config(config: Config, role: str) -> Optional[Dict[str, Any]]:
172
+ personas_config = config.personas
173
+ if role == "default":
174
+ if "default" in personas_config and isinstance(
175
+ personas_config["default"], dict
176
+ ):
177
+ return personas_config["default"]
178
+ implicit_config = {
179
+ k: v for k, v in personas_config.items() if not isinstance(v, dict)
180
+ }
181
+ return implicit_config or None
182
+ named_config = personas_config.get(role)
183
+ return named_config if isinstance(named_config, dict) else None
184
+
185
+
186
+ def _build_chromium_args(cfg: Dict[str, Any]) -> List[str]:
187
+ """Строит список аргументов командной строки для Chromium-based браузеров."""
188
+ args = list(cfg.get("args") or [])
189
+
190
+ if cfg.get("no_sandbox", False):
191
+ args.append("--no-sandbox")
192
+
193
+ disable_features = list(cfg.get("disable_features") or [])
194
+
195
+ if cfg.get("ignore_https_errors", False):
196
+ args.append("--ignore-certificate-errors")
197
+ # Отключаем принудительное обновление до HTTPS и связанные с ним предупреждения
198
+ if "HttpsUpgrades" not in disable_features:
199
+ disable_features.append("HttpsUpgrades")
200
+ if "EnforceHttps" not in disable_features:
201
+ disable_features.append("EnforceHttps")
202
+
203
+ # Для HTTP сайтов отключаем предупреждения о небезопасности
204
+ base_url = cfg.get("base_url")
205
+ if base_url:
206
+ parsed_url = urlparse(base_url)
207
+ if parsed_url.scheme == "http":
208
+ origin = f"{parsed_url.scheme}://{parsed_url.netloc}"
209
+ args.append(f"--unsafely-treat-insecure-origin-as-secure={origin}")
210
+
211
+ if disable_features:
212
+ features_str = ",".join(disable_features)
213
+ args.append(f"--disable-features={features_str}")
214
+
215
+ return args
216
+
217
+
218
+ class PersonaDispatcher:
219
+ def __init__(
220
+ self,
221
+ config: Config,
222
+ persona_class: Type[Persona],
223
+ request: pytest.FixtureRequest,
224
+ ):
225
+ self.config = config
226
+ self.persona_class = persona_class
227
+ self.request = request
228
+ self._personas: Dict[str, Persona] = {}
229
+ self._resources: Dict[str, Any] = {}
230
+
231
+ def __call__(self, role_id: str = "default") -> Persona:
232
+ return self.persona(role_id)
233
+
234
+ def persona(self, role_id: str | Enum = "default") -> Persona:
235
+ role_id_str = role_id.value if isinstance(role_id, Enum) else str(role_id)
236
+ if role_id_str not in self._personas:
237
+ persona_cfg = _get_persona_config(self.config, role_id_str)
238
+ if not persona_cfg:
239
+ if role_id_str == "default":
240
+ # Создаем пустую конфигурацию для персоны по умолчанию
241
+ persona_cfg = {"name": "Персона"}
242
+ else:
243
+ pytest.fail(
244
+ f"Конфигурация для роли '{role_id_str}' не найдена в 'config/{self.config.env}.yaml'"
245
+ )
246
+ persona_name = persona_cfg.get("name", "Персона")
247
+ new_persona = self.persona_class(name=persona_name)
248
+
249
+ # Инициализация RolePersona
250
+ new_persona.role_id = role_id_str
251
+ new_persona.params = persona_cfg or {}
252
+
253
+ auth_key = persona_cfg.get("auth_key")
254
+ if auth_key:
255
+ if not isinstance(auth_key, str):
256
+ pytest.fail(
257
+ f"Поле 'auth_key' для роли '{role_id_str}' должно быть строкой.",
258
+ pytrace=False,
259
+ )
260
+ if auth_key not in self.config.auth:
261
+ pytest.fail(
262
+ f"Ключ авторизации '{auth_key}' для роли '{role_id_str}' не найден в config/auth.yaml",
263
+ pytrace=False,
264
+ )
265
+ secrets_data = self.config.auth.get(auth_key)
266
+ if not isinstance(secrets_data, dict):
267
+ pytest.fail(
268
+ f"Данные для ключа авторизации '{auth_key}' в config/auth.yaml должны быть словарём.",
269
+ pytrace=False,
270
+ )
271
+ new_persona.secrets = secrets_data
272
+ else:
273
+ new_persona.secrets = {}
274
+
275
+ # LCL-37: Прокидываем конфигурацию ретраев
276
+ new_persona.retries_config = self.config.retries or {}
277
+
278
+ def provider(p: Persona, st: SkillId, sn: str) -> Any:
279
+ return self._get_skill_for_persona(p, st, sn, role_id_str)
280
+
281
+ new_persona.set_skill_provider(provider)
282
+ # Экспонируем pytest request внутрь персоны для Ops, которым нужен доступ к CLI-флагам
283
+ setattr(new_persona, "_pytest_request", self.request)
284
+ self._personas[role_id_str] = new_persona
285
+ return self._personas[role_id_str]
286
+
287
+ def _get_skill_for_persona(
288
+ self, persona: Persona, skill_id: Any, name: str, role_id: str
289
+ ) -> Any:
290
+ name_str = name
291
+ # skill_id is SkillId(str, Enum), so .value works if it is an instance,
292
+ # but here we rely on it being string-like or having .value
293
+ skill_val = getattr(skill_id, "value", str(skill_id))
294
+ skill_key = f"{skill_val}.{name_str}"
295
+ if skill_key not in persona.skills.get(skill_val, {}):
296
+ skill_instance = self._create_skill_instance(skill_id, name_str, role_id)
297
+ persona.learn((skill_val, name_str, skill_instance))
298
+ return persona.skills[skill_val][name_str]
299
+
300
+ def _create_skill_instance(self, skill_id: Any, name: str, role_id: str) -> Any:
301
+ cfg = _get_skill_config(self.config, skill_id.value, name)
302
+ if cfg is None:
303
+ pytest.fail(
304
+ f"Конфигурация для навыка '{skill_id.value}.{name}' не найдена."
305
+ )
306
+
307
+ # skill_id can be SkillId enum or string
308
+ skill_val = getattr(skill_id, "value", str(skill_id))
309
+
310
+ if skill_val == "browser":
311
+ from persona_dsl.skills.use_browser import UseBrowser
312
+ from playwright.sync_api import (
313
+ sync_playwright,
314
+ ) # локальный импорт для избежания тяжёлых импортов на уровне модуля
315
+
316
+ if "playwright" not in self._resources:
317
+ self._resources["playwright"] = sync_playwright().start()
318
+ pw = self._resources["playwright"]
319
+ browser_type = getattr(pw, cfg.get("type", "chromium"))
320
+
321
+ selenoid_url = cfg.get("selenoid_url")
322
+ ws_endpoint = cfg.get("ws_endpoint")
323
+
324
+ if selenoid_url:
325
+ # Создание сессии в Selenoid
326
+ selenoid_opts = cfg.get("selenoid_options", {})
327
+
328
+ # Определяем browserName для Selenoid
329
+ requested_type = cfg.get("type", "chromium")
330
+ if requested_type == "sberbrowser":
331
+ browser_name = "sberbrowser"
332
+ # Playwright не знает про sberbrowser, используем chromium
333
+ browser_type = getattr(pw, "chromium")
334
+ elif requested_type == "chromium":
335
+ browser_name = "chrome" # Selenoid обычно использует "chrome" или "chromium"
336
+ else:
337
+ browser_name = requested_type
338
+
339
+ # Базовые capabilities
340
+ caps = {
341
+ "browserName": browser_name,
342
+ "version": cfg.get("version", ""),
343
+ "enableVNC": selenoid_opts.get("enableVNC", True),
344
+ "enableVideo": selenoid_opts.get("enableVideo", False),
345
+ }
346
+ # Добавляем специфичные опции
347
+ if "goog:chromeOptions" in selenoid_opts:
348
+ caps["goog:chromeOptions"] = selenoid_opts["goog:chromeOptions"]
349
+
350
+ # Другие опции первого уровня
351
+ for k, v in selenoid_opts.items():
352
+ if k not in caps:
353
+ caps[k] = v
354
+
355
+ # --- UNIFICATION: Inject config args into capabilities ---
356
+ common_args = _build_chromium_args(cfg)
357
+
358
+ opts_key = "goog:chromeOptions"
359
+ if "browserName" in caps and "firefox" in caps["browserName"]:
360
+ opts_key = "moz:firefoxOptions"
361
+
362
+ if opts_key not in caps:
363
+ caps[opts_key] = {}
364
+ if "args" not in caps[opts_key]:
365
+ caps[opts_key]["args"] = []
366
+
367
+ # 1. Headless (from config)
368
+ if cfg.get("headless", False):
369
+ if "--headless" not in caps[opts_key]["args"]:
370
+ caps[opts_key]["args"].append("--headless")
371
+
372
+ # 2. Common Args (no_sandbox, disable_features, etc)
373
+ # Avoid duplicates
374
+ existing_args = set(caps[opts_key]["args"])
375
+ for arg in common_args:
376
+ if arg not in existing_args:
377
+ caps[opts_key]["args"].append(arg)
378
+
379
+ # 3. Добавление имени теста
380
+ if self.request and self.request.node:
381
+ caps["name"] = self.request.node.name
382
+ if caps.get("enableVideo"):
383
+ caps["videoName"] = f"{self.request.node.name}.mp4"
384
+
385
+ # Все capabilities оборачиваем в alwaysMatch для W3C
386
+ w3c_caps = {"alwaysMatch": caps}
387
+
388
+ retries = cfg.get("session_retries", 3)
389
+ timeout = cfg.get("session_timeout", 30.0)
390
+
391
+ ws_url, session_id = _create_selenoid_session(
392
+ selenoid_url, w3c_caps, retries=retries, timeout=timeout
393
+ )
394
+
395
+ # Сохраняем для очистки
396
+ if "selenoid_sessions" not in self._resources:
397
+ self._resources["selenoid_sessions"] = []
398
+ self._resources["selenoid_sessions"].append(
399
+ {"url": selenoid_url, "id": session_id}
400
+ )
401
+
402
+ self._resources["browser"] = browser_type.connect_over_cdp(
403
+ ws_url,
404
+ slow_mo=float(cfg.get("slow_mo", 0.0)),
405
+ timeout=float(cfg.get("timeout", 30000.0)),
406
+ )
407
+
408
+ elif ws_endpoint:
409
+ # Подключение к удалённому браузеру по WebSocket
410
+ self._resources["browser"] = browser_type.connect_over_cdp(
411
+ ws_endpoint,
412
+ slow_mo=float(cfg.get("slow_mo", 0.0)),
413
+ timeout=float(cfg.get("timeout", 30000.0)),
414
+ )
415
+ else:
416
+ # Локальный запуск
417
+ args = _build_chromium_args(cfg)
418
+
419
+ executable_path = cfg.get("executable_path")
420
+ channel = cfg.get("channel")
421
+ slow_mo = float(cfg.get("slow_mo", 0.0))
422
+ timeout = float(cfg.get("timeout", 30000.0))
423
+
424
+ launch_opts = {
425
+ "headless": cfg.get("headless", True),
426
+ "executable_path": executable_path,
427
+ "args": args if args else None,
428
+ "channel": channel,
429
+ "slow_mo": slow_mo,
430
+ "timeout": timeout,
431
+ }
432
+ launch_opts = {
433
+ k: v for k, v in launch_opts.items() if v is not None
434
+ }
435
+ self._resources["browser"] = browser_type.launch(**launch_opts)
436
+ self._resources["browser_contexts"] = {}
437
+ browser = self._resources["browser"]
438
+ contexts: Dict[str, Any] = self._resources["browser_contexts"]
439
+ if role_id not in contexts:
440
+ context_options = {
441
+ "base_url": cfg.get("base_url"),
442
+ "ignore_https_errors": cfg.get("ignore_https_errors", False),
443
+ }
444
+ viewport = cfg.get("viewport")
445
+ if (
446
+ isinstance(viewport, dict)
447
+ and "width" in viewport
448
+ and "height" in viewport
449
+ ):
450
+ context_options["viewport"] = viewport
451
+ contexts[role_id] = browser.new_context(**context_options)
452
+ context = contexts[role_id]
453
+ # Используем первую страницу в контексте или создаем новую, если ее нет.
454
+ # Это обеспечивает персистентную сессию для роли в рамках теста.
455
+ # Это обеспечивает персистентную сессию для роли в рамках теста.
456
+ if not context.pages:
457
+ page = context.new_page()
458
+ else:
459
+ page = context.pages[0]
460
+
461
+ base_url_from_config = cfg.get("base_url")
462
+
463
+ # Глобальные таймауты страницы из конфига (мс)
464
+ default_timeout = cfg.get("default_timeout", 0.0)
465
+ default_navigation_timeout = cfg.get("default_navigation_timeout", 0.0)
466
+
467
+ # Валидация и применение в навык
468
+ def _to_float_or_none(v: Any) -> Optional[float]:
469
+ if v is None:
470
+ return None
471
+ if not isinstance(v, (int, float, str)):
472
+ pytest.fail(
473
+ f"Параметр таймаута должен быть числом или строкой, а не {type(v).__name__}",
474
+ pytrace=False,
475
+ )
476
+ try:
477
+ return float(v)
478
+ except (ValueError, TypeError):
479
+ pytest.fail(
480
+ f"Параметр таймаута '{v}' не может быть преобразован в число.",
481
+ pytrace=False,
482
+ )
483
+
484
+ default_timeout_f = _to_float_or_none(default_timeout)
485
+ default_navigation_timeout_f = _to_float_or_none(default_navigation_timeout)
486
+
487
+ return UseBrowser.from_page(
488
+ page,
489
+ base_url=base_url_from_config,
490
+ default_timeout=default_timeout_f,
491
+ default_navigation_timeout=default_navigation_timeout_f,
492
+ )
493
+
494
+ if skill_val == "api":
495
+ from persona_dsl.skills.use_api import UseAPI
496
+
497
+ base_url = cfg.get("base_url")
498
+ if not isinstance(base_url, str) or not base_url:
499
+ pytest.fail(
500
+ f"Конфигурация 'skills.api.{name}.base_url' должна быть непустой строкой.",
501
+ pytrace=False,
502
+ )
503
+ verify_ssl = bool(cfg.get("verify_ssl", True))
504
+ timeout = float(cfg.get("timeout", 10.0))
505
+ retries = int(cfg.get("retries", 0))
506
+ backoff = float(cfg.get("backoff", 0.5))
507
+ log_bodies = bool(cfg.get("log_bodies", False))
508
+ headers = cfg.get("headers")
509
+ if headers is not None and not isinstance(headers, dict):
510
+ pytest.fail(
511
+ f"Конфигурация 'skills.api.{name}.headers' должна быть словарём (dict).",
512
+ pytrace=False,
513
+ )
514
+ return UseAPI.at(
515
+ base_url=base_url,
516
+ verify_ssl=verify_ssl,
517
+ timeout=timeout,
518
+ retries=retries,
519
+ backoff=backoff,
520
+ log_bodies=log_bodies,
521
+ default_headers=headers,
522
+ )
523
+
524
+ if skill_val == "db":
525
+ from persona_dsl.skills.use_database import UseDatabase
526
+
527
+ driver_name = cfg.get("driver")
528
+ dsn = cfg.get("dsn")
529
+ if not isinstance(driver_name, str) or not driver_name:
530
+ pytest.fail(
531
+ f"Конфигурация 'skills.db.{name}' должна содержать непустую строку 'driver'."
532
+ )
533
+ if not isinstance(dsn, str) or not dsn:
534
+ pytest.fail(
535
+ f"Конфигурация 'skills.db.{name}.dsn' должна быть непустой строкой."
536
+ )
537
+ user = cfg.get("user")
538
+ password = cfg.get("password")
539
+ # если в dsn есть плейсхолдеры, требуем соответствующие поля
540
+ if ("{user}" in dsn or "{password}" in dsn) and (
541
+ not isinstance(user, str) or not isinstance(password, str)
542
+ ):
543
+ pytest.fail(
544
+ f"Конфигурация 'skills.db.{name}' должна содержать 'user' и 'password' для подстановки в dsn.",
545
+ pytrace=False,
546
+ )
547
+ if "{user}" in dsn or "{password}" in dsn:
548
+ dsn_final = dsn.format(user=user or "", password=password or "")
549
+ else:
550
+ dsn_final = dsn
551
+ try:
552
+ driver_module = __import__(driver_name)
553
+ except (ImportError, ModuleNotFoundError) as e:
554
+ pytest.fail(f"Не удалось импортировать драйвер БД '{driver_name}': {e}")
555
+ return UseDatabase.with_dsn(dsn=dsn_final, driver=driver_module)
556
+
557
+ if skill_val == "kafka":
558
+ from persona_dsl.skills.use_kafka import UseKafka
559
+
560
+ bootstrap = cfg.get("bootstrap_servers")
561
+ if not bootstrap:
562
+ pytest.fail("Конфигурация Kafka должна содержать 'bootstrap_servers'")
563
+ group_id = cfg.get("group_id")
564
+ from_offset = cfg.get("from_offset", "latest")
565
+ timeout = float(cfg.get("timeout", 10.0))
566
+ return UseKafka.with_servers(
567
+ bootstrap_servers=bootstrap,
568
+ group_id=group_id,
569
+ from_offset=from_offset,
570
+ timeout=timeout,
571
+ )
572
+ if skill_val == "soap":
573
+ from persona_dsl.skills.use_soap import UseSOAP
574
+
575
+ wsdl_url = cfg.get("wsdl_url")
576
+ if not wsdl_url:
577
+ pytest.fail("Конфигурация SOAP должна содержать 'wsdl_url'")
578
+ return UseSOAP.at(wsdl_url)
579
+
580
+ raise NotImplementedError(
581
+ f"Ленивое создание для навыка '{skill_val}' не реализовано."
582
+ )
583
+
584
+ def _parse_args_with_role(
585
+ self, args: Tuple[Any, ...]
586
+ ) -> Tuple[str, Tuple[Any, ...]]:
587
+ """Разбирает аргументы, отделяя опциональную роль в начале.
588
+
589
+ Поддерживаются:
590
+ - Enum (BaseRole)
591
+ - str (идентификатор роли)
592
+ """
593
+ role_id = "default"
594
+ if args:
595
+ first = args[0]
596
+ if isinstance(first, Enum):
597
+ role_id = first.value
598
+ args = args[1:]
599
+ elif isinstance(first, str):
600
+ role_id = first
601
+ args = args[1:]
602
+ return role_id, args
603
+
604
+ def make(self, *args: Any) -> None:
605
+ """Выполняет действия, изменяющие состояние системы."""
606
+ from persona_dsl.components.base_step import BaseStep
607
+
608
+ role_id, actions = self._parse_args_with_role(args)
609
+ if not all(isinstance(arg, BaseStep) for arg in actions):
610
+ raise TypeError("Метод 'make' принимает только шаги (BaseStep).")
611
+ persona_instance = self.persona(role_id)
612
+ for item in actions:
613
+ item.execute(persona_instance)
614
+
615
+ # Синонимы для make
616
+ perform = make
617
+ does = make
618
+
619
+ def get(self, *args: Any) -> Any:
620
+ """Получает данные из системы, не изменяя ее состояние."""
621
+ from persona_dsl.components.base_step import BaseStep
622
+
623
+ role_id, facts = self._parse_args_with_role(args)
624
+ if not all(isinstance(arg, BaseStep) for arg in facts):
625
+ raise TypeError("Метод 'get' принимает только шаги (BaseStep).")
626
+ if not facts:
627
+ raise ValueError("Метод 'get' требует хотя бы один Fact.")
628
+
629
+ persona_instance = self.persona(role_id)
630
+ results = [fact.execute(persona_instance) for fact in facts]
631
+
632
+ return results[0] if len(results) == 1 else tuple(results)
633
+
634
+ def check(self, actual_value: Any, *expectations: Expectation) -> None:
635
+ """Проверяет фактическое значение на соответствие ожиданиям."""
636
+ from persona_dsl.components.expectation import Expectation
637
+
638
+ if not all(isinstance(arg, Expectation) for arg in expectations):
639
+ raise TypeError(
640
+ "Метод 'check' может принимать только Expectation в качестве второго и последующих аргументов."
641
+ )
642
+ if not expectations:
643
+ raise ValueError("Метод 'check' требует хотя бы один Expectation.")
644
+
645
+ # Для проверок используется персона по умолчанию, т.к. они не привязаны к роли
646
+ persona_instance = self.persona("default")
647
+ for expectation in expectations:
648
+ expectation.execute(persona_instance, actual_value)
649
+
650
+ def _cleanup_resources(self) -> None:
651
+ for persona in self._personas.values():
652
+ for skill_type in persona.skills.values():
653
+ for skill_instance in skill_type.values():
654
+ if hasattr(skill_instance, "forget"):
655
+ skill_instance.forget()
656
+ # Закрываем все браузерные контексты перед закрытием самого браузера
657
+ if "browser_contexts" in self._resources:
658
+ for context in self._resources["browser_contexts"].values():
659
+ context.close()
660
+ if "browser" in self._resources:
661
+ try:
662
+ self._resources["browser"].close()
663
+ except Exception as e:
664
+ logger.warning(f"Ошибка при закрытии браузера: {e}")
665
+
666
+ # Удаление сессий Selenoid
667
+ if "selenoid_sessions" in self._resources:
668
+ for session in self._resources["selenoid_sessions"]:
669
+ s_url = session["url"]
670
+ s_id = session["id"]
671
+ try:
672
+ del_url = f"{s_url.rstrip('/')}/wd/hub/session/{s_id}"
673
+ logger.info(f"Удаление сессии Selenoid: {s_id}")
674
+ requests.delete(del_url, timeout=5.0)
675
+ except Exception as e:
676
+ logger.warning(f"Ошибка удаления сессии Selenoid {s_id}: {e}")
677
+ if "playwright" in self._resources:
678
+ pw = self._resources["playwright"]
679
+ if not hasattr(pw, "stop") or not callable(pw.stop):
680
+ raise RuntimeError(
681
+ "Playwright-ресурс не имеет метода stop(); нарушен контракт sync_playwright().start()"
682
+ )
683
+ pw.stop()
684
+
685
+
686
+ def pytest_addoption(parser: Any) -> None:
687
+ parser.addoption(
688
+ "--env", action="store", default="dev", help="Окружение: dev/staging/prod"
689
+ )
690
+ parser.addoption(
691
+ "--generate-pages",
692
+ action="store_true",
693
+ default=False,
694
+ help="Генерация PageObject во время тестов (используется с Op GeneratePageObject).",
695
+ )
696
+
697
+
698
+ def pytest_configure(config: Any) -> None:
699
+ """Регистрирует кастомные маркеры и настраивает предупреждения."""
700
+ # 1. Подавляем предупреждения о неизвестных маркерах,
701
+ # чтобы можно было использовать @tag("any_tag") без редактирования pytest.ini
702
+ config.addinivalue_line("filterwarnings", "ignore::pytest.PytestUnknownMarkWarning")
703
+
704
+ # 2. Регистрируем маркер @data_driven из ядра, чтобы не делать это в проекте
705
+ config.addinivalue_line(
706
+ "markers",
707
+ "data_driven: кастомная параметризация теста, управляемая фреймворком",
708
+ )
709
+
710
+ # 3. Регистрируем маркер @persona_retry (LCL-37)
711
+ config.addinivalue_line(
712
+ "markers",
713
+ "persona_retry(max_attempts=1, delay=0.0, ...): автоматический перезапуск теста при падении",
714
+ )
715
+
716
+ # 4. Пробрасываем выбранное окружение в переменную TAAS_ENV для детерминированности генераторов
717
+ try:
718
+ env_opt = config.getoption("--env")
719
+ if env_opt:
720
+ os.environ["TAAS_ENV"] = env_opt
721
+ except Exception:
722
+ # На очень ранних стадиях инициализации getoption может быть недоступен — оставляем окружение как есть.
723
+ pass
724
+
725
+
726
+ @pytest.fixture(scope="session")
727
+ def config(pytestconfig: Any) -> Config:
728
+ """Загружает конфигурацию из файлов в зависимости от окружения."""
729
+ from persona_dsl.utils.config import Config
730
+
731
+ env = pytestconfig.getoption("--env")
732
+ return Config.load(env, project_root=pytestconfig.rootpath)
733
+
734
+
735
+ @pytest.fixture(scope="session")
736
+ def persona_class() -> Type[Persona]:
737
+ """Возвращает базовый класс Persona для использования в диспетчере."""
738
+ from persona_dsl.persona import Persona
739
+
740
+ return Persona
741
+
742
+
743
+ @pytest.fixture(scope="session", autouse=True)
744
+ def _validate_config(pytestconfig: Any) -> None:
745
+ """
746
+ Автоматически используемая фикстура для строгой валидации конфигурации проекта
747
+ на основе деклараций в tests/conftest.py (PERSONA_SKILLS, PERSONA_ROLES).
748
+ - Если деклараций нет — конфигурация не требуется и валидация пропускается.
749
+ - Если декларации есть — файл config/{env}.yaml обязателен; при отсутствии/несоответствии — жёсткая ошибка.
750
+ """
751
+ env = pytestconfig.getoption("--env")
752
+ cfg_path = Path(pytestconfig.rootpath) / "config" / f"{env}.yaml"
753
+ plugins = list(pytestconfig.pluginmanager.get_plugins())
754
+ project_skills: List[Any] = []
755
+ project_roles_enum = None
756
+ for mod in plugins:
757
+ ps = getattr(mod, "PERSONA_SKILLS", None)
758
+ pr = getattr(mod, "PERSONA_ROLES", None)
759
+ if ps and not project_skills:
760
+ project_skills = ps
761
+ if pr and project_roles_enum is None:
762
+ project_roles_enum = pr
763
+
764
+ # Нет деклараций — ничего не валидируем и не загружаем конфиг
765
+ if not project_skills and not project_roles_enum:
766
+ return
767
+
768
+ # Декларации есть — файл обязателен
769
+ if not cfg_path.exists():
770
+ pytest.fail(
771
+ f"Отсутствует файл конфигурации 'config/{env}.yaml', тогда как в conftest.py объявлены "
772
+ f"PERSONA_SKILLS/ROLES. Создайте config/{env}.yaml или уберите декларации.",
773
+ pytrace=False,
774
+ )
775
+
776
+ # Загружаем конфиг и валидируем перечисленные навыки/роли
777
+ # Загружаем конфиг и валидируем перечисленные навыки/роли
778
+ from persona_dsl.utils.config import Config
779
+
780
+ cfg = Config.load(env, project_root=pytestconfig.rootpath)
781
+ errors = []
782
+
783
+ # Проверка навыков
784
+ for skill_item in project_skills:
785
+ if isinstance(skill_item, tuple):
786
+ skill_id, skill_name = skill_item
787
+ else:
788
+ skill_id = skill_item
789
+ skill_name = "default"
790
+
791
+ if not _get_skill_config(cfg, skill_id.value, skill_name):
792
+ error_msg = f" - Именованный навык '{skill_id.value}.{skill_name}'"
793
+ if skill_name == "default":
794
+ error_msg = f" - Базовый навык '{skill_id.value}'"
795
+ errors.append(error_msg)
796
+
797
+ # Проверка ролей
798
+ if project_roles_enum:
799
+ for role_member in project_roles_enum:
800
+ accessible_name = role_member.value
801
+ if not _get_persona_config(cfg, accessible_name):
802
+ errors.append(
803
+ f" - Проектная роль '{accessible_name}' (из {project_roles_enum.__name__})"
804
+ )
805
+
806
+ if errors:
807
+ pytest.fail(
808
+ f"Отсутствует конфигурация в 'config/{env}.yaml' для следующих сущностей, объявленных в conftest.py:\n"
809
+ + ", ".join(errors),
810
+ pytrace=False,
811
+ )
812
+
813
+
814
+ @pytest.fixture(scope="function")
815
+ def persona(
816
+ request: Any, config: Config, persona_class: Type[Persona]
817
+ ) -> Generator[PersonaDispatcher, None, None]:
818
+ dispatcher = PersonaDispatcher(config, persona_class, request)
819
+ yield dispatcher
820
+ dispatcher._cleanup_resources()
821
+
822
+
823
+ @pytest.hookimpl(tryfirst=True, hookwrapper=True)
824
+ def pytest_runtest_makereport(item: Any, call: Any) -> Generator[None, Any, None]:
825
+ outcome = yield
826
+ rep = outcome.get_result()
827
+ if rep.when == "call":
828
+ from persona_dsl.utils.metrics import metrics
829
+
830
+ status = "passed" if rep.passed else "failed"
831
+ tags = {"test_name": item.name, "status": status}
832
+ metrics.gauge("test.duration", rep.duration, tags)
833
+ metrics.counter("test.runs", tags).inc()
834
+ if status == "failed" and "persona" in item.fixturenames:
835
+ dispatcher = item.funcargs.get("persona")
836
+ if dispatcher and "browser" in dispatcher._resources:
837
+ cfg = dispatcher.config
838
+ screenshot_on_fail = cfg.reporting.get("screenshot_on_fail", True)
839
+ pagesource_on_fail = cfg.reporting.get("pagesource_on_fail", True)
840
+ if not screenshot_on_fail and not pagesource_on_fail:
841
+ return
842
+
843
+ browser = dispatcher._resources["browser"]
844
+ if not browser.contexts:
845
+ return
846
+
847
+ from persona_dsl.utils.artifacts import artifacts_dir, sanitize_filename
848
+
849
+ screenshot_dir = artifacts_dir("screenshots")
850
+
851
+ for i, context in enumerate(browser.contexts):
852
+ if not context.pages:
853
+ continue
854
+ page = context.pages[-1]
855
+ context_name = f"context-{i}"
856
+ if screenshot_on_fail:
857
+ screenshot_path = (
858
+ screenshot_dir
859
+ / f"{sanitize_filename(item.name)}-{context_name}.png"
860
+ )
861
+ page.screenshot(path=str(screenshot_path))
862
+ allure.attach.file(
863
+ str(screenshot_path),
864
+ name=f"screenshot-on-fail-{context_name}",
865
+ attachment_type=allure.attachment_type.PNG,
866
+ )
867
+ if pagesource_on_fail:
868
+ page_source = page.content()
869
+ allure.attach(
870
+ page_source,
871
+ name=f"page-source-on-fail-{context_name}",
872
+ attachment_type=allure.attachment_type.HTML,
873
+ )
874
+ setattr(item, "rep_" + rep.when, rep)
875
+
876
+
877
+ @lru_cache(maxsize=None)
878
+ def _load_test_data_file(filename: str) -> List[Any]:
879
+ """Загружает и кеширует тестовые данные из YAML/JSON файла в tests/data."""
880
+ base_dir = Path.cwd() / "tests" / "data"
881
+ name = str(filename)
882
+
883
+ # Список кандидатов: если расширение задано — используем его; иначе пробуем .yaml, .yml, затем .json
884
+ if any(name.endswith(ext) for ext in (".yaml", ".yml", ".json")):
885
+ candidates = [base_dir / name]
886
+ else:
887
+ candidates = [
888
+ base_dir / f"{name}.yaml",
889
+ base_dir / f"{name}.yml",
890
+ base_dir / f"{name}.json",
891
+ ]
892
+
893
+ for path in candidates:
894
+ if path.exists():
895
+ with open(path, "r", encoding="utf-8") as f:
896
+ if path.suffix.lower() == ".json":
897
+ data = json.load(f)
898
+ else:
899
+ data = yaml.safe_load(f)
900
+ if not isinstance(data, list):
901
+ raise DataDrivenError(
902
+ f"Данные в файле {path} должны быть списком (list)."
903
+ )
904
+ return data
905
+
906
+ raise DataDrivenError(
907
+ "Файл с тестовыми данными не найден. Ожидались: "
908
+ + ", ".join(str(p) for p in candidates)
909
+ )
910
+
911
+
912
+ def pytest_sessionstart(session: Any) -> None:
913
+ """Вызывается в начале тестовой сессии для monkey-patching allure.step."""
914
+ if _taas_ready():
915
+ allure.step = _instrumented_allure_step # type: ignore[assignment]
916
+
917
+
918
+ def pytest_sessionfinish(session: Any) -> None:
919
+ """Вызывается в конце тестовой сессии для восстановления allure.step и отправки финального события."""
920
+ if _taas_ready():
921
+ # Отправляем финальное событие из самого процесса pytest, чтобы избежать гонки состояний
922
+ final_status = "COMPLETED" if session.exitstatus == 0 else "FAILED"
923
+ finish_event = {
924
+ "event": "finish",
925
+ "data": {"status": final_status, "return_code": int(session.exitstatus)},
926
+ }
927
+ try:
928
+ from .utils.taas_integration import publish_taas_event
929
+
930
+ publish_taas_event(finish_event)
931
+ except Exception as e:
932
+ try:
933
+ logger.warning("TaaS: не удалось отправить finish событие: %s", e)
934
+ except Exception:
935
+ pass
936
+
937
+ allure.step = _original_allure_step
938
+
939
+
940
+ def _resolve_data_driven_expressions(val: Any) -> Any:
941
+ """Рекурсивно вычисляет SystemVar-выражения в данных."""
942
+ # SystemVar-узел: { "kind":"system", "name": "...", "args": { ... } }
943
+ if isinstance(val, dict):
944
+ if val.get("kind") == "system" and "name" in val:
945
+ name = val.get("name")
946
+ args = val.get("args", {}) or {}
947
+ # Разрешаем аргументы рекурсивно (могут содержать вложенные VarRef/SystemVar/литералы)
948
+ resolved_args = {
949
+ k: _resolve_data_driven_expressions(v) for k, v in args.items()
950
+ }
951
+ from persona_dsl.utils import data_providers as _dp
952
+
953
+ func = getattr(_dp, str(name), None)
954
+ if callable(func):
955
+ try:
956
+ return func(**resolved_args)
957
+ except Exception as e:
958
+ raise ValueError(
959
+ f"@data_driven expr: ошибка вычисления SystemVar '{name}' с args={resolved_args}: {e}"
960
+ )
961
+ raise ValueError(
962
+ f"@data_driven expr: неизвестный провайдер SystemVar '{name}'"
963
+ )
964
+ # Обычный словарь — обрабатываем все значения
965
+ return {k: _resolve_data_driven_expressions(v) for k, v in val.items()}
966
+ # Списки — обрабатываем элементы
967
+ if isinstance(val, list):
968
+ return [_resolve_data_driven_expressions(x) for x in val]
969
+ # Прочие типы — возвращаем как есть
970
+ return val
971
+
972
+
973
+ def pytest_generate_tests(metafunc: Any) -> None:
974
+ """Генерирует параметризованные тесты для маркера @data_driven."""
975
+ for marker in metafunc.definition.iter_markers(name="data_driven"):
976
+ source = marker.args[0] if marker.args else marker.kwargs.get("source")
977
+ is_file = marker.kwargs.get("file", False)
978
+
979
+ if source is None:
980
+ pytest.fail(
981
+ "Источник данных 'source' для @data_driven не был предоставлен.",
982
+ pytrace=False,
983
+ )
984
+
985
+ try:
986
+ scenarios = _load_test_data_file(source) if is_file else source
987
+ except DataDrivenError as e:
988
+ pytest.fail(str(e), pytrace=False)
989
+
990
+ if not isinstance(scenarios, list) or not all(
991
+ isinstance(s, dict) for s in scenarios
992
+ ):
993
+ pytest.fail(
994
+ f"Данные для @data_driven должны быть списком словарей. Получено: {type(scenarios)}",
995
+ pytrace=False,
996
+ )
997
+
998
+ # Требуем variant_id ('test_id') и формируем стабильные id ДО вычисления выражений
999
+ name_template = marker.kwargs.get("name_template")
1000
+ missing = [i for i, s in enumerate(scenarios) if "test_id" not in s]
1001
+ if missing:
1002
+ pytest.fail(
1003
+ f"@data_driven: каждая вариация должна содержать поле 'test_id'. Отсутствует в индексах: {missing}",
1004
+ pytrace=False,
1005
+ )
1006
+ if isinstance(name_template, str) and name_template:
1007
+
1008
+ def _fmt_id(s: Any) -> str:
1009
+ try:
1010
+ return name_template.format(**s)
1011
+ except Exception:
1012
+ return str(s.get("test_id"))
1013
+
1014
+ ids = [_fmt_id(s) for s in scenarios]
1015
+ else:
1016
+ ids = [str(s["test_id"]) for s in scenarios]
1017
+
1018
+ # Сначала вычисляем выражения SystemVar/VarRef для всех сценариев.
1019
+ # Это важно: ошибки провайдеров должны приводить к немедленной диагностике,
1020
+ # даже если далее фильтрация могла бы исключить вариации.
1021
+ resolved_scenarios = []
1022
+ original_variant_id = os.environ.get("TAAS_VARIANT_ID")
1023
+ try:
1024
+ for s in scenarios:
1025
+ test_id = s.get("test_id")
1026
+ if test_id:
1027
+ os.environ["TAAS_VARIANT_ID"] = str(test_id)
1028
+ resolved_scenarios.append(_resolve_data_driven_expressions(s))
1029
+ except ValueError as e:
1030
+ pytest.fail(f"ValueError: {e}", pytrace=False)
1031
+ finally:
1032
+ # Восстанавливаем окружение и состояние генераторов
1033
+ if original_variant_id is None:
1034
+ os.environ.pop("TAAS_VARIANT_ID", None)
1035
+ else:
1036
+ os.environ["TAAS_VARIANT_ID"] = original_variant_id
1037
+
1038
+ # Затем фильтруем вариации по test_id через параметр only и/или переменную окружения TAAS_VARIANT_IDS
1039
+ only_marker = marker.kwargs.get("only")
1040
+ only_env = os.environ.get("TAAS_VARIANT_IDS")
1041
+
1042
+ is_filtered = False
1043
+ active_filter_set: Optional[set] = None
1044
+
1045
+ if only_marker is not None:
1046
+ is_filtered = True
1047
+ try:
1048
+ active_filter_set = {str(x) for x in only_marker}
1049
+ except TypeError:
1050
+ active_filter_set = set()
1051
+
1052
+ if only_env: # Skips None and ""
1053
+ is_filtered = True
1054
+ env_set = {s.strip() for s in only_env.split(",") if s.strip()}
1055
+ if active_filter_set is None:
1056
+ active_filter_set = env_set
1057
+ else:
1058
+ active_filter_set.intersection_update(env_set)
1059
+
1060
+ if is_filtered:
1061
+ final_scenarios = []
1062
+ final_ids = []
1063
+ if active_filter_set: # Filter is not empty
1064
+ for s, i in zip(resolved_scenarios, ids):
1065
+ if str(s.get("test_id")) in active_filter_set:
1066
+ final_scenarios.append(s)
1067
+ final_ids.append(i)
1068
+
1069
+ if not final_scenarios:
1070
+ pytest.fail(
1071
+ "@data_driven: ни одна вариация не осталась после фильтрации (only/TAAS_VARIANT_IDS).",
1072
+ pytrace=False,
1073
+ )
1074
+ resolved_scenarios = final_scenarios
1075
+ ids = final_ids
1076
+
1077
+ if "scenario" in metafunc.fixturenames:
1078
+ if resolved_scenarios:
1079
+ metafunc.parametrize("scenario", resolved_scenarios, ids=ids)
1080
+
1081
+
1082
+ def pytest_runtest_setup(item: Any) -> None:
1083
+ """
1084
+ Перед каждым тестом:
1085
+ 1. Устанавливаем TAAS_VARIANT_ID для детерминированного seed'а.
1086
+ 2. (LCL-37) Оборачиваем тестовую функцию в ретрай-декоратор, если задан маркер @persona_retry.
1087
+ """
1088
+ from persona_dsl.utils.retry import RetryPolicy # Lazy import for coverage
1089
+
1090
+ # 1. TAAS_VARIANT_ID logic
1091
+ prev = os.environ.get("TAAS_VARIANT_ID")
1092
+ variant_id = None
1093
+ callspec = getattr(item, "callspec", None)
1094
+ if callspec and "scenario" in callspec.params:
1095
+ scenario = callspec.params["scenario"]
1096
+
1097
+ if isinstance(scenario, dict):
1098
+ # Устанавливаем TAAS_VARIANT_ID
1099
+ variant_id = str(scenario.get("test_id") or "")
1100
+
1101
+ if variant_id:
1102
+ os.environ["TAAS_VARIANT_ID"] = variant_id
1103
+ setattr(item, "_prev_taas_variant_id", prev)
1104
+
1105
+ # 2. LCL-37: Retry Logic
1106
+ retry_marker = item.get_closest_marker("persona_retry")
1107
+ policy = None
1108
+
1109
+ if retry_marker:
1110
+ kwargs = retry_marker.kwargs.copy()
1111
+ # Простейшая поддержка: передаем kwargs как есть в RetryPolicy
1112
+ # Пользователь должен передавать типы исключений в retry_on, если нужно
1113
+ policy = RetryPolicy(**kwargs)
1114
+ else:
1115
+ # Проверяем глобальный конфиг (если доступен через item.config -> Config)
1116
+ # Это "мягкая" интеграция: если конфиг загружен и там есть retries.default
1117
+ try:
1118
+ env = item.config.getoption("--env")
1119
+ # Загружаем конфиг (он кешируется внутри Config.load если реализован singleton,
1120
+ # но даже если нет - чтение файла допустимо в setup)
1121
+ cfg = Config.load(env, project_root=item.config.rootpath)
1122
+ default_policy = cfg.retries.get("default") if cfg.retries else None
1123
+
1124
+ if default_policy:
1125
+ # Преобразуем dict конфига в RetryPolicy
1126
+ # Исключаем поля, которые не принимает конструктор (если есть лишние)
1127
+ # Но RetryPolicy принимает **kwargs, так что просто распаковываем
1128
+ policy = RetryPolicy(**default_policy)
1129
+ except Exception:
1130
+ # Если конфиг не загрузился или кривой - игнорируем, работаем без ретраев
1131
+ policy = None
1132
+
1133
+ if policy:
1134
+ original_func = item.obj
1135
+
1136
+ @functools.wraps(original_func)
1137
+ def retrying_test_func(*args: Any, **kwargs: Any) -> Any:
1138
+ attempt = 0
1139
+ while True:
1140
+ attempt += 1
1141
+ try:
1142
+ return original_func(*args, **kwargs)
1143
+ except Exception as e:
1144
+ if policy.should_retry(attempt, e):
1145
+ logger.warning(
1146
+ f"Тест '{item.name}' упал (попытка {attempt}/{policy.max_attempts}): {e}. Ретрай через {policy.delay}с."
1147
+ )
1148
+ policy.wait(attempt)
1149
+ if policy.on_retry_action:
1150
+ policy.execute_action()
1151
+ continue
1152
+ raise
1153
+
1154
+ item.obj = retrying_test_func
1155
+
1156
+
1157
+ def pytest_runtest_teardown(item: Any, nextitem: Any) -> None:
1158
+ """
1159
+ Восстанавливаем предыдущее значение TAAS_VARIANT_ID после теста.
1160
+ """
1161
+ prev = getattr(item, "_prev_taas_variant_id", None)
1162
+ if prev is None:
1163
+ os.environ.pop("TAAS_VARIANT_ID", None)
1164
+ else:
1165
+ os.environ["TAAS_VARIANT_ID"] = prev
1166
+
1167
+
1168
+ @pytest.fixture(scope="session")
1169
+ def testdata() -> Any:
1170
+ """Фабричная фикстура для загрузки тестовых данных из `tests/data/*.{yaml,yml,json}`."""
1171
+ return _load_test_data_file
1172
+
1173
+
1174
+ def pytest_runtest_logstart(nodeid: str, location: Any) -> None:
1175
+ """
1176
+ Отправляет JSON-событие в TaaS при старте каждого теста.
1177
+ Работает только в контексте TaaS (когда задан TAAS_RUN_ID), чтобы не влиять на обычный вывод pytest.
1178
+ """
1179
+ if not _taas_ready():
1180
+ return
1181
+ # Используем имя теста (без пути) как description для корневого узла
1182
+ test_name = nodeid.split("::")[-1]
1183
+ event_data = {
1184
+ "event": "test_start",
1185
+ "data": {
1186
+ "node_id": nodeid,
1187
+ "description": test_name,
1188
+ "timestamp": time.time(),
1189
+ },
1190
+ }
1191
+ try:
1192
+ from .utils.taas_integration import publish_taas_event
1193
+
1194
+ publish_taas_event(event_data)
1195
+ except Exception as e:
1196
+ try:
1197
+ logger.warning("TaaS: не удалось отправить событие test_start: %s", str(e))
1198
+ except Exception:
1199
+ pass
1200
+
1201
+
1202
+ def pytest_runtest_logreport(report: Any) -> None:
1203
+ """
1204
+ Отправляет JSON-событие в stdout по завершении каждой фазы теста (setup, call, teardown).
1205
+ Нас интересует только 'call' для отображения итогового статуса.
1206
+ Работает только в контексте TaaS (когда задан TAAS_RUN_ID), чтобы не влиять на обычный вывод pytest.
1207
+ """
1208
+ if not _taas_ready():
1209
+ return
1210
+ if report.when == "call":
1211
+ test_name = report.nodeid.split("::")[-1]
1212
+ event_data = {
1213
+ "event": "test_end",
1214
+ "data": {
1215
+ "node_id": report.nodeid,
1216
+ "description": test_name,
1217
+ "status": report.outcome,
1218
+ "duration": report.duration,
1219
+ "timestamp": time.time(),
1220
+ },
1221
+ }
1222
+ try:
1223
+ from .utils.taas_integration import publish_taas_event
1224
+
1225
+ publish_taas_event(event_data)
1226
+ except Exception as e:
1227
+ try:
1228
+ logger.warning("TaaS: не удалось отправить событие test_end: %s", e)
1229
+ except Exception:
1230
+ pass