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.
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 +221 -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 +146 -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 +1064 -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.20.8.dist-info/METADATA +35 -0
  83. persona_dsl-26.1.20.8.dist-info/RECORD +86 -0
  84. persona_dsl-26.1.20.8.dist-info/WHEEL +5 -0
  85. persona_dsl-26.1.20.8.dist-info/entry_points.txt +6 -0
  86. 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