persona-dsl 26.1.20.8__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.
- persona_dsl/__init__.py +35 -0
- persona_dsl/components/action.py +10 -0
- persona_dsl/components/base_step.py +251 -0
- persona_dsl/components/combined_step.py +68 -0
- persona_dsl/components/expectation.py +10 -0
- persona_dsl/components/fact.py +10 -0
- persona_dsl/components/goal.py +10 -0
- persona_dsl/components/ops.py +7 -0
- persona_dsl/components/step.py +75 -0
- persona_dsl/expectations/generic/__init__.py +15 -0
- persona_dsl/expectations/generic/contains_item.py +19 -0
- persona_dsl/expectations/generic/contains_the_text.py +15 -0
- persona_dsl/expectations/generic/has_entries.py +21 -0
- persona_dsl/expectations/generic/is_equal.py +24 -0
- persona_dsl/expectations/generic/is_greater_than.py +18 -0
- persona_dsl/expectations/generic/path_equal.py +27 -0
- persona_dsl/expectations/web/__init__.py +5 -0
- persona_dsl/expectations/web/is_displayed.py +13 -0
- persona_dsl/expectations/web/matches_aria_snapshot.py +221 -0
- persona_dsl/expectations/web/matches_screenshot.py +160 -0
- persona_dsl/generators/__init__.py +5 -0
- persona_dsl/generators/api_generator.py +423 -0
- persona_dsl/generators/cli.py +431 -0
- persona_dsl/generators/page_generator.py +1140 -0
- persona_dsl/ops/api/__init__.py +5 -0
- persona_dsl/ops/api/json_as.py +104 -0
- persona_dsl/ops/api/json_response.py +48 -0
- persona_dsl/ops/api/send_request.py +41 -0
- persona_dsl/ops/db/__init__.py +5 -0
- persona_dsl/ops/db/execute_sql.py +22 -0
- persona_dsl/ops/db/fetch_all.py +29 -0
- persona_dsl/ops/db/fetch_one.py +22 -0
- persona_dsl/ops/kafka/__init__.py +4 -0
- persona_dsl/ops/kafka/message_in_topic.py +89 -0
- persona_dsl/ops/kafka/send_message.py +35 -0
- persona_dsl/ops/soap/__init__.py +4 -0
- persona_dsl/ops/soap/call_operation.py +24 -0
- persona_dsl/ops/soap/operation_result.py +24 -0
- persona_dsl/ops/web/__init__.py +37 -0
- persona_dsl/ops/web/aria_snapshot.py +87 -0
- persona_dsl/ops/web/click.py +30 -0
- persona_dsl/ops/web/current_path.py +17 -0
- persona_dsl/ops/web/element_attribute.py +24 -0
- persona_dsl/ops/web/element_is_visible.py +27 -0
- persona_dsl/ops/web/element_text.py +28 -0
- persona_dsl/ops/web/elements_count.py +42 -0
- persona_dsl/ops/web/fill.py +41 -0
- persona_dsl/ops/web/generate_page_object.py +118 -0
- persona_dsl/ops/web/input_value.py +23 -0
- persona_dsl/ops/web/navigate.py +52 -0
- persona_dsl/ops/web/press_key.py +37 -0
- persona_dsl/ops/web/rich_aria_snapshot.py +146 -0
- persona_dsl/ops/web/screenshot.py +68 -0
- persona_dsl/ops/web/table_data.py +43 -0
- persona_dsl/ops/web/wait_for_navigation.py +23 -0
- persona_dsl/pages/__init__.py +133 -0
- persona_dsl/pages/elements.py +998 -0
- persona_dsl/pages/page.py +44 -0
- persona_dsl/pages/virtual_page.py +94 -0
- persona_dsl/persona.py +125 -0
- persona_dsl/pytest_plugin.py +1064 -0
- persona_dsl/runtime/dist/persona_bundle.js +1077 -0
- persona_dsl/skills/__init__.py +7 -0
- persona_dsl/skills/core/base.py +41 -0
- persona_dsl/skills/core/skill_definition.py +30 -0
- persona_dsl/skills/use_api.py +251 -0
- persona_dsl/skills/use_browser.py +78 -0
- persona_dsl/skills/use_database.py +129 -0
- persona_dsl/skills/use_kafka.py +135 -0
- persona_dsl/skills/use_soap.py +66 -0
- persona_dsl/utils/__init__.py +0 -0
- persona_dsl/utils/artifacts.py +22 -0
- persona_dsl/utils/config.py +54 -0
- persona_dsl/utils/data_providers.py +159 -0
- persona_dsl/utils/decorators.py +80 -0
- persona_dsl/utils/metrics.py +69 -0
- persona_dsl/utils/naming.py +14 -0
- persona_dsl/utils/path.py +202 -0
- persona_dsl/utils/retry.py +51 -0
- persona_dsl/utils/taas_integration.py +124 -0
- persona_dsl/utils/waits.py +112 -0
- persona_dsl-26.1.20.8.dist-info/METADATA +35 -0
- persona_dsl-26.1.20.8.dist-info/RECORD +86 -0
- persona_dsl-26.1.20.8.dist-info/WHEEL +5 -0
- persona_dsl-26.1.20.8.dist-info/entry_points.txt +6 -0
- persona_dsl-26.1.20.8.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
from typing import Any
|
|
2
|
+
|
|
3
|
+
from zeep import Client
|
|
4
|
+
|
|
5
|
+
from .core.base import Skill
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class UseSOAP(Skill):
|
|
9
|
+
"""Навык для взаимодействия с SOAP API."""
|
|
10
|
+
|
|
11
|
+
def __init__(self, wsdl_url: str):
|
|
12
|
+
super().__init__(driver=None)
|
|
13
|
+
self.wsdl_url = wsdl_url
|
|
14
|
+
|
|
15
|
+
@staticmethod
|
|
16
|
+
def at(wsdl_url: str) -> "UseSOAP":
|
|
17
|
+
"""Фабричный метод для создания экземпляра навыка.
|
|
18
|
+
|
|
19
|
+
Args:
|
|
20
|
+
wsdl_url: URL к WSDL-файлу сервиса.
|
|
21
|
+
|
|
22
|
+
Returns:
|
|
23
|
+
Экземпляр UseSOAP.
|
|
24
|
+
"""
|
|
25
|
+
return UseSOAP(wsdl_url)
|
|
26
|
+
|
|
27
|
+
def _get_client(self) -> Client:
|
|
28
|
+
if self.driver is None:
|
|
29
|
+
self.driver = Client(self.wsdl_url)
|
|
30
|
+
return self.driver
|
|
31
|
+
|
|
32
|
+
def call(self, service: str, operation: str, *args: Any, **kwargs: Any) -> Any:
|
|
33
|
+
"""Вызывает операцию SOAP-сервиса."""
|
|
34
|
+
client = self._get_client()
|
|
35
|
+
|
|
36
|
+
if service not in client.wsdl.services:
|
|
37
|
+
raise ValueError(
|
|
38
|
+
f"Сервис '{service}' не найден в WSDL. Доступные: {list(client.wsdl.services.keys())}"
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
# Bind to the service, letting zeep choose the first available port.
|
|
42
|
+
bound_service = client.bind(service, None)
|
|
43
|
+
|
|
44
|
+
try:
|
|
45
|
+
op = getattr(bound_service, operation)
|
|
46
|
+
return op(*args, **kwargs)
|
|
47
|
+
except AttributeError as e:
|
|
48
|
+
raise ValueError(
|
|
49
|
+
f"Операция '{operation}' не найдена в сервисе '{service}'."
|
|
50
|
+
) from e
|
|
51
|
+
|
|
52
|
+
def release(self) -> None:
|
|
53
|
+
"""Освобождает ресурсы SOAP-клиента (если они есть)."""
|
|
54
|
+
if self.driver:
|
|
55
|
+
try:
|
|
56
|
+
# Если driver — это клиент zeep, попробуем закрыть его HTTP-сессию
|
|
57
|
+
transport = getattr(self.driver, "transport", None)
|
|
58
|
+
session = getattr(transport, "session", None) if transport else None
|
|
59
|
+
if session and hasattr(session, "close"):
|
|
60
|
+
session.close()
|
|
61
|
+
except Exception:
|
|
62
|
+
pass
|
|
63
|
+
|
|
64
|
+
def forget(self) -> None:
|
|
65
|
+
"""Совместимость: делегирует в базовый Skill.forget()."""
|
|
66
|
+
return super().forget()
|
|
File without changes
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import re
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def sanitize_filename(name: str) -> str:
|
|
7
|
+
"""Безопасное имя файла для снапшотов/скриншотов."""
|
|
8
|
+
return re.sub(r"[^A-Za-z0-9._-]+", "-", str(name)).strip("-") or "snapshot"
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def artifacts_dir(subdir: str) -> Path:
|
|
12
|
+
"""Каталог для артефактов запуска: <ARTIFACTS>/<run_id>/<subdir> или локальный ./<subdir>."""
|
|
13
|
+
base_dir = Path(os.getenv("TAAS_ARTIFACTS_DIR", str(Path.cwd())))
|
|
14
|
+
run_id = os.getenv("TAAS_RUN_ID")
|
|
15
|
+
out = (base_dir / run_id / subdir) if run_id else (base_dir / subdir)
|
|
16
|
+
out.mkdir(parents=True, exist_ok=True)
|
|
17
|
+
return out
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def screenshots_dir() -> Path:
|
|
21
|
+
"""Каталог для скриншотов."""
|
|
22
|
+
return artifacts_dir("screenshots")
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import yaml
|
|
2
|
+
from dataclasses import dataclass, field
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
from dotenv import load_dotenv
|
|
5
|
+
from typing import Dict, Any, Optional
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
@dataclass
|
|
9
|
+
class Config:
|
|
10
|
+
"""Хранит структурированную конфигурацию для тестового окружения."""
|
|
11
|
+
|
|
12
|
+
skills: Dict[str, Any] = field(default_factory=dict)
|
|
13
|
+
personas: Dict[str, Any] = field(default_factory=dict)
|
|
14
|
+
auth: Dict[str, Any] = field(default_factory=dict)
|
|
15
|
+
reporting: Dict[str, Any] = field(default_factory=dict)
|
|
16
|
+
retries: Dict[str, Any] = field(default_factory=dict)
|
|
17
|
+
env: str = "dev"
|
|
18
|
+
|
|
19
|
+
@staticmethod
|
|
20
|
+
def load(env: str, project_root: Optional[Path] = None) -> "Config":
|
|
21
|
+
"""
|
|
22
|
+
Загружает конфигурацию для указанного окружения.
|
|
23
|
+
1. Читает `config/{env}.yaml`.
|
|
24
|
+
2. Читает `config/auth.yaml` (если существует).
|
|
25
|
+
3. Загружает переменные из `.env.{env}`.
|
|
26
|
+
"""
|
|
27
|
+
root = project_root or Path.cwd()
|
|
28
|
+
config_path = root / "config" / f"{env}.yaml"
|
|
29
|
+
if not config_path.exists():
|
|
30
|
+
raise FileNotFoundError(
|
|
31
|
+
f"Конфигурация для окружения '{env}' не найдена: {config_path}"
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
with open(config_path, "r", encoding="utf-8") as f:
|
|
35
|
+
data = yaml.safe_load(f)
|
|
36
|
+
|
|
37
|
+
auth_data: Dict[str, Any] = {}
|
|
38
|
+
auth_path = root / "config" / "auth.yaml"
|
|
39
|
+
if auth_path.exists():
|
|
40
|
+
with open(auth_path, "r", encoding="utf-8") as f:
|
|
41
|
+
auth_data = yaml.safe_load(f) or {}
|
|
42
|
+
|
|
43
|
+
dotenv_path = root / f".env.{env}"
|
|
44
|
+
if dotenv_path.exists():
|
|
45
|
+
load_dotenv(dotenv_path)
|
|
46
|
+
|
|
47
|
+
return Config(
|
|
48
|
+
skills=data.get("skills", {}),
|
|
49
|
+
personas=data.get("personas", {}),
|
|
50
|
+
auth=auth_data,
|
|
51
|
+
reporting=data.get("reporting", {}),
|
|
52
|
+
retries=data.get("retries", {}),
|
|
53
|
+
env=env,
|
|
54
|
+
)
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import uuid as _uuid
|
|
3
|
+
import random as _random
|
|
4
|
+
import datetime as _dt
|
|
5
|
+
from zoneinfo import ZoneInfo
|
|
6
|
+
from typing import Any, List, Union, Optional
|
|
7
|
+
from faker import Faker
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def _seed() -> int:
|
|
11
|
+
"""
|
|
12
|
+
Детерминированный seed на основе TAAS_RUN_ID, TAAS_ENV и TAAS_VARIANT_ID (если есть) или фиксированный.
|
|
13
|
+
"""
|
|
14
|
+
run_id = os.getenv("TAAS_RUN_ID")
|
|
15
|
+
if not run_id:
|
|
16
|
+
return 42
|
|
17
|
+
env = os.getenv("TAAS_ENV") or ""
|
|
18
|
+
variant = os.getenv("TAAS_VARIANT_ID") or ""
|
|
19
|
+
# Простая хеш-функция по строке "{run_id}:{env}:{variant}"
|
|
20
|
+
acc = 0
|
|
21
|
+
for ch in f"{run_id}:{env}:{variant}":
|
|
22
|
+
acc = (acc * 31 + ord(ch)) % 2_147_483_647
|
|
23
|
+
return acc
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
# Кеши, индексированные по seed'у для обеспечения детерминированности для каждой вариации теста.
|
|
27
|
+
_RNG_PER_SEED: dict[int, _random.Random] = {}
|
|
28
|
+
_FAKER_CACHE_PER_SEED: dict[int, dict[str, Faker]] = {}
|
|
29
|
+
_SEQUENCES_PER_SEED: dict[int, dict[str, int]] = {}
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _get_rng() -> _random.Random:
|
|
33
|
+
"""Возвращает детерминированный экземпляр RNG, привязанный к текущему seed'у."""
|
|
34
|
+
seed = _seed()
|
|
35
|
+
if seed not in _RNG_PER_SEED:
|
|
36
|
+
_RNG_PER_SEED[seed] = _random.Random(seed)
|
|
37
|
+
return _RNG_PER_SEED[seed]
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _seed_with(*parts: str) -> int:
|
|
41
|
+
acc = _seed()
|
|
42
|
+
for part in parts:
|
|
43
|
+
for ch in str(part):
|
|
44
|
+
acc = (acc * 31 + ord(ch)) % 2_147_483_647
|
|
45
|
+
return acc
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _get_faker(locale: str) -> Faker:
|
|
49
|
+
seed = _seed()
|
|
50
|
+
if seed not in _FAKER_CACHE_PER_SEED:
|
|
51
|
+
_FAKER_CACHE_PER_SEED[seed] = {}
|
|
52
|
+
|
|
53
|
+
f = _FAKER_CACHE_PER_SEED[seed].get(locale)
|
|
54
|
+
if not f:
|
|
55
|
+
f = Faker(locale)
|
|
56
|
+
faker_seed = _seed_with("faker", locale)
|
|
57
|
+
f.seed_instance(faker_seed)
|
|
58
|
+
_FAKER_CACHE_PER_SEED[seed][locale] = f
|
|
59
|
+
return f
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def uuid() -> str:
|
|
63
|
+
"""UUID v4 как строка. Детерминированный относительно seed, но соответствующий RFC 4122 (версия/вариант)."""
|
|
64
|
+
# Генерируем 16 детерминированных байт из RNG и выставляем биты версии/варианта
|
|
65
|
+
rng = _get_rng()
|
|
66
|
+
b = bytearray(rng.getrandbits(8) for _ in range(16))
|
|
67
|
+
b[6] = (b[6] & 0x0F) | 0x40 # версия 4
|
|
68
|
+
b[8] = (b[8] & 0x3F) | 0x80 # вариант RFC 4122 (10xxxxxx)
|
|
69
|
+
return str(_uuid.UUID(bytes=bytes(b)))
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def randomInt(min: int, max: int) -> int:
|
|
73
|
+
return _get_rng().randint(int(min), int(max))
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def randomFloat(min: float, max: float, precision: int = 2) -> float:
|
|
77
|
+
value = _get_rng().uniform(float(min), float(max))
|
|
78
|
+
return round(value, int(precision))
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
_ALPHANUM = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def randomString(length: int, alphabet: str = _ALPHANUM) -> str:
|
|
85
|
+
length = int(length)
|
|
86
|
+
return "".join(_get_rng().choice(alphabet) for _ in range(length))
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def now(
|
|
90
|
+
fmt: Optional[str] = None, tz: Optional[str] = None
|
|
91
|
+
) -> Union[str, _dt.datetime]:
|
|
92
|
+
zone = ZoneInfo(tz) if tz else None
|
|
93
|
+
dt = _dt.datetime.now(tz=zone)
|
|
94
|
+
return dt.strftime(fmt) if fmt else dt
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def dateShift(
|
|
98
|
+
days: int, fmt: Optional[str] = None, tz: Optional[str] = None
|
|
99
|
+
) -> Union[str, _dt.datetime]:
|
|
100
|
+
zone = ZoneInfo(tz) if tz else None
|
|
101
|
+
dt = _dt.datetime.now(tz=zone) + _dt.timedelta(days=int(days))
|
|
102
|
+
return dt.strftime(fmt) if fmt else dt
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def fromEnv(name: str, default: Any = None) -> Any:
|
|
106
|
+
return os.getenv(str(name), default)
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def concat(parts: List[Any]) -> str:
|
|
110
|
+
def _to_str(v: Any) -> str:
|
|
111
|
+
return str(v)
|
|
112
|
+
|
|
113
|
+
return "".join(_to_str(p) for p in parts)
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def faker(
|
|
117
|
+
provider: str,
|
|
118
|
+
locale: str = "ru_RU",
|
|
119
|
+
args: List[Any] | None = None,
|
|
120
|
+
kwargs: dict | None = None,
|
|
121
|
+
) -> Any:
|
|
122
|
+
"""
|
|
123
|
+
Вызов провайдера Faker по имени с детерминированным seed на основе TAAS_RUN_ID и локали.
|
|
124
|
+
Пример: faker("first_name", locale="ru_RU")
|
|
125
|
+
"""
|
|
126
|
+
f = _get_faker(locale)
|
|
127
|
+
func = getattr(f, provider)
|
|
128
|
+
return func(*(args or []), **(kwargs or {}))
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def sequence(name: str, seed: int | None = None) -> int:
|
|
132
|
+
"""
|
|
133
|
+
Детерминированная последовательность по имени. Первое значение фиксировано для данного run_id и name.
|
|
134
|
+
"""
|
|
135
|
+
main_seed = _seed()
|
|
136
|
+
if main_seed not in _SEQUENCES_PER_SEED:
|
|
137
|
+
_SEQUENCES_PER_SEED[main_seed] = {}
|
|
138
|
+
|
|
139
|
+
sequences_for_seed = _SEQUENCES_PER_SEED[main_seed]
|
|
140
|
+
if name not in sequences_for_seed:
|
|
141
|
+
start = seed if seed is not None else _seed_with("sequence", name)
|
|
142
|
+
sequences_for_seed[name] = int(start % 1_000_000)
|
|
143
|
+
current = sequences_for_seed[name]
|
|
144
|
+
sequences_for_seed[name] += 1
|
|
145
|
+
return current
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def template(pattern: str, variables: dict) -> str:
|
|
149
|
+
"""
|
|
150
|
+
Простой шаблонизатор строк на основе str.format.
|
|
151
|
+
Любая ошибка форматирования — явная ошибка без фолбеков.
|
|
152
|
+
"""
|
|
153
|
+
safe_vars = {k: str(v) for k, v in (variables or {}).items()}
|
|
154
|
+
try:
|
|
155
|
+
return pattern.format_map(safe_vars)
|
|
156
|
+
except Exception as e:
|
|
157
|
+
raise ValueError(
|
|
158
|
+
f"data_providers.template: ошибка форматирования шаблона {pattern!r} с переменными {safe_vars}: {e}"
|
|
159
|
+
) from e
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import pytest
|
|
2
|
+
from typing import Any, Optional, Sequence, Callable, TypeVar
|
|
3
|
+
|
|
4
|
+
T = TypeVar("T", bound=Callable[..., Any])
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def tag(*tags: str) -> Callable[[T], T]:
|
|
8
|
+
"""Декоратор для добавления тегов (маркеров pytest) к тестам."""
|
|
9
|
+
|
|
10
|
+
def wrapper(func: T) -> T:
|
|
11
|
+
marked_func = func
|
|
12
|
+
for t in tags:
|
|
13
|
+
marked_func = getattr(pytest.mark, t)(marked_func)
|
|
14
|
+
return marked_func
|
|
15
|
+
|
|
16
|
+
return wrapper
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def data_driven(
|
|
20
|
+
source: Optional[Any] = None,
|
|
21
|
+
file: bool = False,
|
|
22
|
+
name_template: Optional[str] = None,
|
|
23
|
+
only: Optional[Sequence[str]] = None,
|
|
24
|
+
) -> Callable[[T], T]:
|
|
25
|
+
"""
|
|
26
|
+
Декоратор для параметризации тестов, управляемый фреймворком.
|
|
27
|
+
|
|
28
|
+
Args:
|
|
29
|
+
source: Источник данных.
|
|
30
|
+
Если file=True, это имя файла (без .yaml) в директории `tests/data`.
|
|
31
|
+
Если file=False, это список словарей с тестовыми данными.
|
|
32
|
+
file: Указывает, как интерпретировать `source`.
|
|
33
|
+
name_template: Необязательный шаблон имени вариации (ids) для pytest, например "{test_id}-{input}".
|
|
34
|
+
only: Необязательный список test_id вариаций, которые нужно включить.
|
|
35
|
+
"""
|
|
36
|
+
|
|
37
|
+
def wrapper(func: T) -> T:
|
|
38
|
+
return pytest.mark.data_driven(
|
|
39
|
+
source=source, file=file, name_template=name_template, only=only
|
|
40
|
+
)(func)
|
|
41
|
+
|
|
42
|
+
return wrapper
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def parametrize_simple(
|
|
46
|
+
arg_name: str,
|
|
47
|
+
values: Sequence[Any],
|
|
48
|
+
ids: Optional[Sequence[str]] = None,
|
|
49
|
+
name_template: Optional[str] = None,
|
|
50
|
+
) -> Callable[[T], T]:
|
|
51
|
+
"""
|
|
52
|
+
Упрощённая альтернатива для простых скалярных наборов: обёртка над pytest.mark.parametrize.
|
|
53
|
+
|
|
54
|
+
Args:
|
|
55
|
+
arg_name: Имя параметра тестовой функции.
|
|
56
|
+
values: Последовательность значений (скаляры или словари).
|
|
57
|
+
ids: Необязательный список id для параметров.
|
|
58
|
+
name_template: Необязательный шаблон формата для id (например, "val-{value}").
|
|
59
|
+
Если значение — словарь, шаблон форматируется через **value;
|
|
60
|
+
иначе доступен плейсхолдер {value}.
|
|
61
|
+
"""
|
|
62
|
+
computed_ids = None
|
|
63
|
+
if ids is not None:
|
|
64
|
+
computed_ids = list(ids)
|
|
65
|
+
elif isinstance(name_template, str) and name_template:
|
|
66
|
+
|
|
67
|
+
def _fmt(v: Any) -> str:
|
|
68
|
+
try:
|
|
69
|
+
if isinstance(v, dict):
|
|
70
|
+
return name_template.format(**v)
|
|
71
|
+
return name_template.format(value=v)
|
|
72
|
+
except Exception:
|
|
73
|
+
return str(v)
|
|
74
|
+
|
|
75
|
+
computed_ids = [_fmt(v) for v in values]
|
|
76
|
+
|
|
77
|
+
def wrapper(func: T) -> T:
|
|
78
|
+
return pytest.mark.parametrize(arg_name, list(values), ids=computed_ids)(func)
|
|
79
|
+
|
|
80
|
+
return wrapper
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import time
|
|
2
|
+
import os
|
|
3
|
+
import logging
|
|
4
|
+
from abc import ABC, abstractmethod
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Any, Dict, List, Optional
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class Reporter(ABC):
|
|
10
|
+
@abstractmethod
|
|
11
|
+
def report(self, metric_name: str, value: float, tags: Dict[str, Any]) -> None:
|
|
12
|
+
pass # pragma: no cover
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class FileReporter(Reporter):
|
|
16
|
+
def __init__(self, output_file: Path):
|
|
17
|
+
self._output_file = output_file
|
|
18
|
+
|
|
19
|
+
def report(self, metric_name: str, value: float, tags: Dict[str, Any]) -> None:
|
|
20
|
+
tag_str = ",".join([f"{k}={v}" for k, v in tags.items()])
|
|
21
|
+
with open(self._output_file, "a", encoding="utf-8") as f:
|
|
22
|
+
f.write(f"{metric_name}{{{tag_str}}} {value} {int(time.time())}\n")
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
logger = logging.getLogger(__name__)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class StdoutReporter(Reporter):
|
|
29
|
+
def report(self, metric_name: str, value: float, tags: Dict[str, Any]) -> None:
|
|
30
|
+
tag_str = ",".join([f"{k}={v}" for k, v in tags.items()])
|
|
31
|
+
logger.info(f"{metric_name}{{{tag_str}}} {value} {int(time.time())}")
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class Counter:
|
|
35
|
+
def __init__(self, metrics_system: "Metrics", name: str, tags: Dict[str, Any]):
|
|
36
|
+
self._metrics = metrics_system
|
|
37
|
+
self._name = name
|
|
38
|
+
self._tags = tags
|
|
39
|
+
|
|
40
|
+
def inc(self, value: int = 1) -> None:
|
|
41
|
+
self._metrics.report(f"{self._name}.count", value, self._tags)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class Metrics:
|
|
45
|
+
def __init__(self) -> None:
|
|
46
|
+
self._reporters: List[Reporter] = []
|
|
47
|
+
|
|
48
|
+
def add_reporter(self, reporter: Reporter) -> None:
|
|
49
|
+
self._reporters.append(reporter)
|
|
50
|
+
|
|
51
|
+
def report(self, metric_name: str, value: float, tags: Dict[str, Any]) -> None:
|
|
52
|
+
for reporter in self._reporters:
|
|
53
|
+
reporter.report(metric_name, value, tags)
|
|
54
|
+
|
|
55
|
+
def gauge(
|
|
56
|
+
self, name: str, value: float, tags: Optional[Dict[str, Any]] = None
|
|
57
|
+
) -> None:
|
|
58
|
+
self.report(name, value, tags or {})
|
|
59
|
+
|
|
60
|
+
def counter(self, name: str, tags: Optional[Dict[str, Any]] = None) -> "Counter":
|
|
61
|
+
return Counter(self, name, tags or {})
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
# Глобальный экземпляр для использования в рамках сессии
|
|
65
|
+
metrics = Metrics()
|
|
66
|
+
log_path = os.getenv("TEST_METRICS_FILE", "test_metrics.log")
|
|
67
|
+
metrics.add_reporter(FileReporter(Path(log_path)))
|
|
68
|
+
if os.getenv("TEST_METRICS_STDOUT", "0") == "1":
|
|
69
|
+
metrics.add_reporter(StdoutReporter())
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import re
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
def to_pascal_case(s: str) -> str:
|
|
5
|
+
"""Преобразует snake_case или kebab-case (и пути) в PascalCase."""
|
|
6
|
+
parts = re.split(r"[^a-zA-Z0-9]+", s)
|
|
7
|
+
return "".join(p.capitalize() for p in parts if p)
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def to_snake_case(name: str) -> str:
|
|
11
|
+
"""Преобразует PascalCase или kebab-case в snake_case."""
|
|
12
|
+
name = re.sub(r"(.)([A-Z][a-z]+)", r"\1_\2", name)
|
|
13
|
+
name = re.sub(r"([a-z0-9])([A-Z])", r"\1_\2", name)
|
|
14
|
+
return name.replace("-", "_").lower()
|
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Any, List, Union
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class PathSyntaxError(ValueError):
|
|
7
|
+
"""Синтаксическая ошибка пути dot/bracket."""
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
Token = Union[str, int]
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def _tokenize(path: str) -> List[Token]:
|
|
14
|
+
"""
|
|
15
|
+
Разбивает строку пути на токены:
|
|
16
|
+
- dot: a.b.c -> ["a","b","c"]
|
|
17
|
+
- bracket index: [0] -> [0]
|
|
18
|
+
- bracket key: ["x.y"] или ['z[0]'] -> ["x.y"], ["z[0]"]
|
|
19
|
+
Поддерживает экранирование в кавычках: \" и \'
|
|
20
|
+
"""
|
|
21
|
+
if not isinstance(path, str):
|
|
22
|
+
raise PathSyntaxError("Путь должен быть строкой")
|
|
23
|
+
if not path:
|
|
24
|
+
return []
|
|
25
|
+
tokens: List[Token] = []
|
|
26
|
+
buf = ""
|
|
27
|
+
i = 0
|
|
28
|
+
n = len(path)
|
|
29
|
+
|
|
30
|
+
def _flush_buf() -> None:
|
|
31
|
+
nonlocal buf
|
|
32
|
+
if buf:
|
|
33
|
+
tokens.append(buf)
|
|
34
|
+
buf = ""
|
|
35
|
+
|
|
36
|
+
while i < n:
|
|
37
|
+
c = path[i]
|
|
38
|
+
if c == ".":
|
|
39
|
+
_flush_buf()
|
|
40
|
+
i += 1
|
|
41
|
+
continue
|
|
42
|
+
if c == "[":
|
|
43
|
+
_flush_buf()
|
|
44
|
+
i += 1
|
|
45
|
+
if i >= n:
|
|
46
|
+
raise PathSyntaxError(f"Ожидалось содержимое после '[' в позиции {i-1}")
|
|
47
|
+
# Ключ в кавычках
|
|
48
|
+
if path[i] in ("'", '"'):
|
|
49
|
+
quote = path[i]
|
|
50
|
+
i += 1
|
|
51
|
+
s = ""
|
|
52
|
+
while i < n:
|
|
53
|
+
ch = path[i]
|
|
54
|
+
if ch == "\\" and i + 1 < n:
|
|
55
|
+
s += path[i + 1]
|
|
56
|
+
i += 2
|
|
57
|
+
continue
|
|
58
|
+
if ch == quote:
|
|
59
|
+
break
|
|
60
|
+
s += ch
|
|
61
|
+
i += 1
|
|
62
|
+
if i >= n or path[i] != quote:
|
|
63
|
+
raise PathSyntaxError(f"Незакрытая кавычка {quote} в позиции {i}")
|
|
64
|
+
i += 1
|
|
65
|
+
if i >= n or path[i] != "]":
|
|
66
|
+
raise PathSyntaxError(
|
|
67
|
+
f"Ожидалась закрывающая ']' после ключа в кавычках (позиция {i})"
|
|
68
|
+
)
|
|
69
|
+
i += 1
|
|
70
|
+
tokens.append(s)
|
|
71
|
+
continue
|
|
72
|
+
# Индекс списка
|
|
73
|
+
j = i
|
|
74
|
+
if j < n and path[j] == "-":
|
|
75
|
+
j += 1
|
|
76
|
+
start_digits = j
|
|
77
|
+
while j < n and path[j].isdigit():
|
|
78
|
+
j += 1
|
|
79
|
+
if j == start_digits:
|
|
80
|
+
raise PathSyntaxError(
|
|
81
|
+
f"Ожидался числовой индекс после '[' (позиция {i-1})"
|
|
82
|
+
)
|
|
83
|
+
if j >= n or path[j] != "]":
|
|
84
|
+
raise PathSyntaxError(
|
|
85
|
+
f"Ожидалась закрывающая ']' после индекса (позиция {j})"
|
|
86
|
+
)
|
|
87
|
+
idx = int(path[i:j])
|
|
88
|
+
tokens.append(idx)
|
|
89
|
+
i = j + 1
|
|
90
|
+
continue
|
|
91
|
+
# Обычный символ идентификатора
|
|
92
|
+
if c in ("'", '"'):
|
|
93
|
+
raise PathSyntaxError(
|
|
94
|
+
f"Недопустимый символ кавычки в идентификаторе в позиции {i}"
|
|
95
|
+
)
|
|
96
|
+
buf += c
|
|
97
|
+
i += 1
|
|
98
|
+
|
|
99
|
+
_flush_buf()
|
|
100
|
+
return tokens
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def tokenize_path(path: str) -> List[Token]:
|
|
104
|
+
"""
|
|
105
|
+
Публичный токенайзер пути. Возвращает список токенов (str|int) по dot/bracket‑нотации.
|
|
106
|
+
"""
|
|
107
|
+
return _tokenize(path)
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def extract_by_path(root: Any, path: str, *, allow_attr: bool = True) -> Any:
|
|
111
|
+
"""
|
|
112
|
+
Достаёт значение из dict/list/объекта по пути вида "a.b[0]['x.y']".
|
|
113
|
+
Бросает понятные ошибки при несоответствии структуры/индексов/ключей.
|
|
114
|
+
"""
|
|
115
|
+
tokens = _tokenize(path)
|
|
116
|
+
cur = root
|
|
117
|
+
for t in tokens:
|
|
118
|
+
if isinstance(t, int):
|
|
119
|
+
if not isinstance(cur, (list, tuple)):
|
|
120
|
+
raise TypeError(
|
|
121
|
+
f"Ожидался список/кортеж для индексации [{t}], получено: {type(cur).__name__}"
|
|
122
|
+
)
|
|
123
|
+
try:
|
|
124
|
+
cur = cur[t]
|
|
125
|
+
except IndexError:
|
|
126
|
+
raise IndexError(
|
|
127
|
+
f"Индекс вне диапазона: [{t}] при длине {len(cur)}"
|
|
128
|
+
) from None
|
|
129
|
+
else:
|
|
130
|
+
if isinstance(cur, dict):
|
|
131
|
+
if t not in cur:
|
|
132
|
+
raise KeyError(f"Ключ отсутствует: '{t}'")
|
|
133
|
+
cur = cur[t]
|
|
134
|
+
elif allow_attr and hasattr(cur, t):
|
|
135
|
+
cur = getattr(cur, t)
|
|
136
|
+
else:
|
|
137
|
+
raise TypeError(
|
|
138
|
+
f"Нельзя обратиться по ключу/атрибуту '{t}' у типа {type(cur).__name__}"
|
|
139
|
+
)
|
|
140
|
+
return cur
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def try_extract_by_path(
|
|
144
|
+
root: Any, path: str, *, default: Any = None, allow_attr: bool = True
|
|
145
|
+
) -> Any:
|
|
146
|
+
"""
|
|
147
|
+
Мягкая версия: вместо исключений возвращает default при любой ошибке.
|
|
148
|
+
"""
|
|
149
|
+
try:
|
|
150
|
+
return extract_by_path(root, path, allow_attr=allow_attr)
|
|
151
|
+
except Exception:
|
|
152
|
+
return default
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def remove_by_path(obj: Any, path: str, *, strict: bool = False) -> None:
|
|
156
|
+
"""
|
|
157
|
+
Удаляет значение по пути из dict/list. Если strict=True — бросает понятные ошибки,
|
|
158
|
+
иначе отсутствующие ветки тихо игнорируются.
|
|
159
|
+
"""
|
|
160
|
+
tokens = _tokenize(path)
|
|
161
|
+
if not tokens:
|
|
162
|
+
if strict:
|
|
163
|
+
raise PathSyntaxError("Пустой путь для удаления")
|
|
164
|
+
return
|
|
165
|
+
cur = obj
|
|
166
|
+
# идём до родителя
|
|
167
|
+
try:
|
|
168
|
+
for t in tokens[:-1]:
|
|
169
|
+
if isinstance(t, int):
|
|
170
|
+
if not isinstance(cur, list):
|
|
171
|
+
raise TypeError(
|
|
172
|
+
f"Ожидался список для индекса [{t}], получено {type(cur).__name__}"
|
|
173
|
+
)
|
|
174
|
+
cur = cur[t]
|
|
175
|
+
else:
|
|
176
|
+
if isinstance(cur, dict):
|
|
177
|
+
cur = cur[t]
|
|
178
|
+
else:
|
|
179
|
+
raise TypeError(
|
|
180
|
+
f"Ожидался dict для ключа '{t}', получено {type(cur).__name__}"
|
|
181
|
+
)
|
|
182
|
+
last = tokens[-1]
|
|
183
|
+
if isinstance(last, int):
|
|
184
|
+
if not isinstance(cur, list):
|
|
185
|
+
raise TypeError(
|
|
186
|
+
f"Ожидался список для индекса [{last}], получено {type(cur).__name__}"
|
|
187
|
+
)
|
|
188
|
+
del cur[last]
|
|
189
|
+
else:
|
|
190
|
+
if not isinstance(cur, dict):
|
|
191
|
+
raise TypeError(
|
|
192
|
+
f"Ожидался dict для ключа '{last}', получено {type(cur).__name__}"
|
|
193
|
+
)
|
|
194
|
+
if strict:
|
|
195
|
+
del cur[last]
|
|
196
|
+
else:
|
|
197
|
+
cur.pop(last, None)
|
|
198
|
+
except Exception:
|
|
199
|
+
if strict:
|
|
200
|
+
raise
|
|
201
|
+
# мягкий режим — игнорируем ошибки
|
|
202
|
+
return
|