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,7 @@
1
+ from .use_api import UseAPI
2
+ from .use_browser import UseBrowser
3
+ from .use_database import UseDatabase
4
+ from .use_kafka import UseKafka
5
+ from .use_soap import UseSOAP
6
+
7
+ __all__ = ["UseAPI", "UseBrowser", "UseDatabase", "UseKafka", "UseSOAP"]
@@ -0,0 +1,41 @@
1
+ from typing import Any
2
+
3
+
4
+ class Skill:
5
+ """Базовый класс-обертка для навыка, который управляет экземпляром драйвера/клиента."""
6
+
7
+ def __init__(self, driver: Any):
8
+ self.driver = driver
9
+
10
+ @property
11
+ def driver(self) -> Any:
12
+ return self._driver
13
+
14
+ @driver.setter
15
+ def driver(self, value: Any) -> None:
16
+ self._driver = value
17
+
18
+ @classmethod
19
+ def from_driver(cls, driver: Any) -> "Skill":
20
+ """Фабричный метод для создания экземпляра Навыка из готового драйвера/клиента."""
21
+ return cls(driver)
22
+
23
+ def acquire(self) -> None:
24
+ """Опциональный хук для ленивого создания драйвера."""
25
+ pass
26
+
27
+ def release(self) -> None:
28
+ """Опциональный хук для освобождения ресурсов драйвера."""
29
+ pass
30
+
31
+ def forget(self) -> None:
32
+ """
33
+ Единая точка освобождения ресурсов навыка.
34
+ По умолчанию делегирует в release(), подавляя возможные исключения,
35
+ и сбрасывает драйвер.
36
+ """
37
+ try:
38
+ self.release()
39
+ except Exception:
40
+ pass
41
+ self.driver = None
@@ -0,0 +1,30 @@
1
+ from enum import Enum
2
+
3
+
4
+ class SkillId(str, Enum):
5
+ """Идентификаторы (типы) стандартных навыков."""
6
+
7
+ BROWSER = "browser"
8
+ API = "api"
9
+ DB = "db"
10
+ KAFKA = "kafka"
11
+ SOAP = "soap"
12
+
13
+ def __str__(self) -> str:
14
+ return self.value
15
+
16
+
17
+ class BaseSkill:
18
+ """
19
+ Библиотека стандартных идентификаторов навыков.
20
+ Содержит предопределенные базовые навыки.
21
+ Наследуйтесь от этого класса в своем проекте, чтобы добавить
22
+ кастомные именованные навыки для удобного доступа.
23
+ """
24
+
25
+ # --- Базовые навыки ---
26
+ BROWSER: tuple[SkillId, str] = (SkillId.BROWSER, "default")
27
+ API: tuple[SkillId, str] = (SkillId.API, "default")
28
+ DB: tuple[SkillId, str] = (SkillId.DB, "default")
29
+ KAFKA: tuple[SkillId, str] = (SkillId.KAFKA, "default")
30
+ SOAP: tuple[SkillId, str] = (SkillId.SOAP, "default")
@@ -0,0 +1,251 @@
1
+ import allure
2
+ import requests
3
+ import time
4
+ from typing import Any, cast, Optional
5
+ from pydantic import TypeAdapter, ValidationError
6
+ from persona_dsl.utils.metrics import metrics
7
+ from .core.base import Skill
8
+
9
+
10
+ class UseAPI(Skill):
11
+ """Навык для взаимодействия с REST API."""
12
+
13
+ def __init__(
14
+ self,
15
+ session: requests.Session,
16
+ base_url: str,
17
+ verify_ssl: bool = True,
18
+ timeout: float = 10.0,
19
+ retries: int = 0,
20
+ backoff: float = 0.5,
21
+ log_bodies: bool = False,
22
+ ):
23
+ super().__init__(session)
24
+ self.base_url = base_url
25
+ self.timeout = timeout
26
+ self.retries = retries
27
+ self.backoff = backoff
28
+ self.log_bodies = log_bodies
29
+
30
+ @classmethod
31
+ def at(
32
+ cls,
33
+ base_url: str,
34
+ verify_ssl: bool = True,
35
+ timeout: float = 10.0,
36
+ retries: int = 0,
37
+ backoff: float = 0.5,
38
+ log_bodies: bool = False,
39
+ default_headers: Optional[dict] = None,
40
+ ) -> "UseAPI":
41
+ session = requests.Session()
42
+ session.verify = verify_ssl
43
+ if default_headers:
44
+ session.headers.update(default_headers)
45
+ inst = cls(session, base_url, verify_ssl, timeout, retries, backoff, log_bodies)
46
+ return inst
47
+
48
+ @property
49
+ def session(self) -> requests.Session:
50
+ return cast(requests.Session, self._driver)
51
+
52
+ def _full_url(self, path: str) -> str:
53
+ """Строит полный URL из базового и относительного пути."""
54
+ if path.startswith("http://") or path.startswith("https://"):
55
+ return path
56
+ base = self.base_url.rstrip("/")
57
+ rel = path if path.startswith("/") else f"/{path}"
58
+ return f"{base}{rel}"
59
+
60
+ def _request(self, method: str, path: str, **kwargs: Any) -> requests.Response:
61
+ """Обертка над requests с метриками, ретраями и опциональными вложениями в Allure."""
62
+ url = self._full_url(path)
63
+ status = None
64
+ t0 = time.perf_counter()
65
+ attempts = self.retries + 1
66
+ last_exc = None
67
+ try:
68
+ for attempt in range(attempts):
69
+ try:
70
+ # Преобразуем Pydantic-модели в dict перед отправкой (json/data могут содержать модели или их коллекции)
71
+ def _to_jsonable(obj: Any) -> Any:
72
+ if hasattr(obj, "model_dump"):
73
+ return obj.model_dump()
74
+ if isinstance(obj, dict):
75
+ return {k: _to_jsonable(v) for k, v in obj.items()}
76
+ if isinstance(obj, (list, tuple)):
77
+ return [_to_jsonable(x) for x in obj]
78
+ return obj
79
+
80
+ if "json" in kwargs:
81
+ kwargs["json"] = _to_jsonable(kwargs["json"])
82
+ if "data" in kwargs:
83
+ kwargs["data"] = _to_jsonable(kwargs["data"])
84
+
85
+ resp = self.session.request(
86
+ method=method.upper(),
87
+ url=url,
88
+ timeout=kwargs.pop("timeout", self.timeout),
89
+ **kwargs,
90
+ )
91
+ status = resp.status_code
92
+ # Повторяем при 5xx, если есть попытки
93
+ if 500 <= status < 600 and attempt < self.retries:
94
+ time.sleep(self.backoff * (2**attempt))
95
+ continue
96
+ if self.log_bodies:
97
+ req_body = kwargs.get("json", kwargs.get("data"))
98
+ # Собираем заголовки: session.headers (если доступны) + per-request headers
99
+ combined_headers = {}
100
+ try:
101
+ base_headers_obj = getattr(self.session, "headers", {})
102
+ base_headers = dict(base_headers_obj)
103
+ except Exception:
104
+ base_headers = {}
105
+ req_headers = {}
106
+ try:
107
+ if isinstance(kwargs.get("headers"), dict):
108
+ req_headers = dict(kwargs.get("headers") or {})
109
+ except Exception:
110
+ req_headers = {}
111
+ combined_headers.update(base_headers)
112
+ combined_headers.update(req_headers)
113
+ if "Authorization" in combined_headers:
114
+ combined_headers["Authorization"] = "***"
115
+ allure.attach(
116
+ f"Request: {method.upper()} {url}\nheaders={combined_headers}\nbody={req_body}",
117
+ "HTTP Request",
118
+ allure.attachment_type.TEXT,
119
+ )
120
+ allure.attach(
121
+ f"Response: status={resp.status_code}\nheaders={dict(resp.headers)}\nbody={resp.text[:1000]}",
122
+ "HTTP Response",
123
+ allure.attachment_type.TEXT,
124
+ )
125
+ return resp
126
+ except requests.RequestException as e:
127
+ last_exc = e
128
+ if attempt < self.retries:
129
+ time.sleep(self.backoff * (2**attempt))
130
+ continue
131
+ raise
132
+ except Exception:
133
+ status = -1
134
+ if last_exc:
135
+ raise last_exc
136
+ raise
137
+ finally:
138
+ duration = time.perf_counter() - t0
139
+ tags = {"method": method.upper(), "endpoint": path, "status": status}
140
+ metrics.gauge("api.request.duration", duration, tags)
141
+ metrics.counter("api.request", tags).inc()
142
+ # This code should be unreachable, as the loop will always either return a response
143
+ # or raise an exception.
144
+ raise RuntimeError(
145
+ "Unreachable code in UseAPI._request reached"
146
+ ) # pragma: no cover
147
+
148
+ def get(self, path: str, **kwargs: Any) -> requests.Response:
149
+ return self._request("GET", path, **kwargs)
150
+
151
+ def post(self, path: str, **kwargs: Any) -> requests.Response:
152
+ return self._request("POST", path, **kwargs)
153
+
154
+ def put(self, path: str, **kwargs: Any) -> requests.Response:
155
+ return self._request("PUT", path, **kwargs)
156
+
157
+ def patch(self, path: str, **kwargs: Any) -> requests.Response:
158
+ return self._request("PATCH", path, **kwargs)
159
+
160
+ def head(self, path: str, **kwargs: Any) -> requests.Response:
161
+ return self._request("HEAD", path, **kwargs)
162
+
163
+ def options(self, path: str, **kwargs: Any) -> requests.Response:
164
+ return self._request("OPTIONS", path, **kwargs)
165
+
166
+ def delete(self, path: str, **kwargs: Any) -> requests.Response:
167
+ return self._request("DELETE", path, **kwargs)
168
+
169
+ def _validate_as(self, data: Any, model_type: Any) -> Any:
170
+ """
171
+ Преобразует произвольный JSON в указанный тип с помощью Pydantic v2.
172
+ Поддерживает сложные аннотации (например, list[MyModel]) через TypeAdapter.
173
+ При ошибке валидации выбрасывает исключение с подробной диагностикой (без скрытых фолбеков).
174
+ """
175
+ try:
176
+ return TypeAdapter(model_type).validate_python(data)
177
+ except (ValidationError, TypeError) as e:
178
+ raise ValueError(
179
+ f"Валидация ответа API не прошла для типа {model_type}: {e}"
180
+ ) from e
181
+
182
+ def get_json_as(self, path: str, model_type: Any, **kwargs: Any) -> Any:
183
+ """
184
+ Делает GET и валидирует JSON-ответ как указанный тип (Pydantic v2).
185
+ Пример: api.get_json_as('/users/1', User) или api.get_json_as('/users', list[User])
186
+ """
187
+ resp = self.get(path, **kwargs)
188
+ return self._validate_as(resp.json(), model_type)
189
+
190
+ def post_json_as(self, path: str, model_type: Any, **kwargs: Any) -> Any:
191
+ """
192
+ Делает POST и валидирует JSON-ответ как указанный тип (Pydantic v2).
193
+ """
194
+ resp = self.post(path, **kwargs)
195
+ return self._validate_as(resp.json(), model_type)
196
+
197
+ def put_json_as(self, path: str, model_type: Any, **kwargs: Any) -> Any:
198
+ """
199
+ Делает PUT и валидирует JSON-ответ как указанный тип (Pydantic v2).
200
+ """
201
+ resp = self.put(path, **kwargs)
202
+ return self._validate_as(resp.json(), model_type)
203
+
204
+ def patch_json_as(self, path: str, model_type: Any, **kwargs: Any) -> Any:
205
+ """
206
+ Делает PATCH и валидирует JSON-ответ как указанный тип (Pydantic v2).
207
+ """
208
+ resp = self.patch(path, **kwargs)
209
+ return self._validate_as(resp.json(), model_type)
210
+
211
+ def delete_json_as(self, path: str, model_type: Any, **kwargs: Any) -> Any:
212
+ """
213
+ Делает DELETE и валидирует JSON-ответ как указанный тип (Pydantic v2).
214
+ """
215
+ resp = self.delete(path, **kwargs)
216
+ return self._validate_as(resp.json(), model_type)
217
+
218
+ def head_json_as(self, path: str, model_type: Any, **kwargs: Any) -> Any:
219
+ """
220
+ Делает HEAD и валидирует JSON-ответ как указанный тип (если сервер всё же возвращает тело).
221
+ Примечание: согласно спецификации, ответ HEAD не должен содержать тело; метод добавлен для полноты API.
222
+ """
223
+ resp = self.head(path, **kwargs)
224
+ # Если тело ответа пустое — это валидный случай для HEAD-запроса.
225
+ if not resp.text:
226
+ return None
227
+ try:
228
+ data = resp.json()
229
+ except requests.JSONDecodeError as e:
230
+ raise ValueError(
231
+ f"HEAD {path}: ответ содержит тело, но оно не является валидным JSON: {e}"
232
+ ) from e
233
+ return self._validate_as(data, model_type)
234
+
235
+ def options_json_as(self, path: str, model_type: Any, **kwargs: Any) -> Any:
236
+ """
237
+ Делает OPTIONS и валидирует JSON-ответ как указанный тип (Pydantic v2).
238
+ """
239
+ resp = self.options(path, **kwargs)
240
+ return self._validate_as(resp.json(), model_type)
241
+
242
+ def release(self) -> None:
243
+ """Освобождает ресурсы HTTP-клиента (закрывает Session)."""
244
+ try:
245
+ self.session.close()
246
+ except Exception:
247
+ pass
248
+
249
+ def forget(self) -> None:
250
+ """Совместимость: делегирует в базовый Skill.forget()."""
251
+ return super().forget()
@@ -0,0 +1,78 @@
1
+ from typing import Optional, cast
2
+ from pathlib import Path
3
+
4
+ from playwright.sync_api import Page
5
+
6
+ from .core.base import Skill
7
+
8
+
9
+ class UseBrowser(Skill):
10
+ """Навык, который предоставляет доступ к драйверу страницы Playwright.
11
+
12
+ Хранит и применяет глобальные настройки таймаутов страницы:
13
+ - default_timeout: общий таймаут ожиданий (мс) для всех операций
14
+ - default_navigation_timeout: таймаут навигации/загрузки (мс)
15
+ - inject_runtime: автоматически внедрять Persona Runtime JS (по умолчанию True)
16
+ """
17
+
18
+ def __init__(
19
+ self,
20
+ page: Page,
21
+ base_url: Optional[str] = None,
22
+ default_timeout: Optional[float] = None,
23
+ default_navigation_timeout: Optional[float] = None,
24
+ inject_runtime: bool = True,
25
+ ):
26
+ super().__init__(page)
27
+ self.base_url = base_url
28
+ self._default_timeout = default_timeout
29
+ self._default_navigation_timeout = default_navigation_timeout
30
+ self._inject_runtime = inject_runtime
31
+
32
+ # Применяем таймауты к странице, если заданы
33
+ if self._default_timeout is not None:
34
+ page.set_default_timeout(float(self._default_timeout))
35
+ if self._default_navigation_timeout is not None:
36
+ page.set_default_navigation_timeout(float(self._default_navigation_timeout))
37
+
38
+ if self._inject_runtime:
39
+ self._inject_persona_runtime(page)
40
+
41
+ def _inject_persona_runtime(self, page: Page) -> None:
42
+ """Внедряет JS-ядро Persona Runtime на страницу."""
43
+ try:
44
+ # Путь к бандлу относительно текущего файла
45
+ # persona_dsl/skills/use_browser.py -> ... -> persona_dsl/runtime/dist/persona_bundle.js
46
+ current_dir = Path(__file__).parent
47
+ bundle_path = current_dir.parent / "runtime" / "dist" / "persona_bundle.js"
48
+
49
+ if bundle_path.exists():
50
+ js_content = bundle_path.read_text(encoding="utf-8")
51
+ page.add_init_script(js_content)
52
+ else:
53
+ # В dev-режиме или если не собрано — предупреждаем, но не падаем жестко,
54
+ # хотя функционал Runtime будет недоступен.
55
+ print(f"[Persona] Warning: Runtime bundle not found at {bundle_path}")
56
+ except Exception as e:
57
+ print(f"[Persona] Error injecting runtime: {e}")
58
+
59
+ @property
60
+ def page(self) -> Page:
61
+ return cast(Page, self.driver)
62
+
63
+ @classmethod
64
+ def from_page(
65
+ cls,
66
+ page: Page,
67
+ base_url: Optional[str] = None,
68
+ default_timeout: Optional[float] = None,
69
+ default_navigation_timeout: Optional[float] = None,
70
+ inject_runtime: bool = True,
71
+ ) -> "UseBrowser":
72
+ return cls(
73
+ page,
74
+ base_url=base_url,
75
+ default_timeout=default_timeout,
76
+ default_navigation_timeout=default_navigation_timeout,
77
+ inject_runtime=inject_runtime,
78
+ )
@@ -0,0 +1,129 @@
1
+ import time
2
+ from types import ModuleType
3
+ from typing import Any, Optional
4
+ from urllib.parse import urlparse
5
+ from persona_dsl.utils.metrics import metrics
6
+ from .core.base import Skill
7
+
8
+
9
+ class UseDatabase(Skill):
10
+ """Навык для взаимодействия с реляционными базами данных."""
11
+
12
+ def __init__(self, dsn: str, driver: ModuleType):
13
+ super().__init__(driver)
14
+ self.dsn = dsn
15
+ self.connection: Optional[Any] = None
16
+
17
+ @staticmethod
18
+ def with_dsn(dsn: str, driver: ModuleType) -> "UseDatabase":
19
+ """Фабричный метод для создания экземпляра навыка.
20
+
21
+ Args:
22
+ dsn: DSN-строка для подключения к базе данных.
23
+ driver: Модуль-драйвер для работы с БД (например, psycopg или oracledb).
24
+
25
+ Returns:
26
+ Экземпляр UseDatabase.
27
+ """
28
+ return UseDatabase(dsn, driver)
29
+
30
+ def get_connection(self) -> Any:
31
+ """Возвращает активное соединение с базой данных.
32
+
33
+ Если соединение отсутствует или закрыто, создает новое.
34
+
35
+ Returns:
36
+ Активное соединение с БД.
37
+ """
38
+ need_reconnect = (
39
+ self.connection is None
40
+ or getattr(self.connection, "closed", False)
41
+ or (
42
+ callable(getattr(self.connection, "is_healthy", None))
43
+ and not self.connection.is_healthy()
44
+ )
45
+ )
46
+ if need_reconnect:
47
+ # Поддерживаем корректные способы подключения драйверов:
48
+ # - pg8000: требуем DSN в URL-формате postgresql://user:pass@host:port/db и конструируем именованные аргументы
49
+ # - psycopg/psycopg2: принимают conninfo строкой (передаём как есть)
50
+ # - oracledb: допускает строку вида "user/password@dsn" как позиционный аргумент
51
+ drv_name = getattr(self.driver, "__name__", "") or ""
52
+ if drv_name.startswith("pg8000"):
53
+ u = urlparse(self.dsn)
54
+ if u.scheme not in (
55
+ "postgresql",
56
+ "postgres",
57
+ "postgresql+pg8000",
58
+ "postgres+pg8000",
59
+ ):
60
+ raise ValueError(
61
+ "Для драйвера pg8000 DSN должен быть в URL-формате: postgresql://user:pass@host:port/dbname"
62
+ )
63
+ user = u.username or ""
64
+ password = u.password or ""
65
+ host = u.hostname or ""
66
+ port = int(u.port or 5432)
67
+ db = u.path.lstrip("/") if u.path else ""
68
+ if not (user and host and db):
69
+ raise ValueError(
70
+ "pg8000: некорректный DSN URL (требуются user, host, database). Пример: postgresql://user:pass@localhost:5432/db"
71
+ )
72
+ self.connection = self.driver.connect(
73
+ user=user, password=password, host=host, port=port, database=db
74
+ )
75
+ else:
76
+ # Для остальных драйверов используем conninfo/позиционный аргумент согласно их API.
77
+ self.connection = self.driver.connect(self.dsn)
78
+ return self.connection
79
+
80
+ def execute(self, query: str, params: Optional[Any] = None) -> Any:
81
+ """Выполняет SQL-запрос с метриками и возвращает первую строку результата.
82
+
83
+ Args:
84
+ query: SQL-запрос.
85
+ params: Параметры запроса.
86
+
87
+ Returns:
88
+ Первая строка результата (или None).
89
+ """
90
+ t0 = time.perf_counter()
91
+ status = "ok"
92
+ try:
93
+ conn = self.get_connection()
94
+ cursor = conn.cursor()
95
+ try:
96
+ cursor.execute(query, params or ())
97
+ result = cursor.fetchone()
98
+ conn.commit()
99
+ return result
100
+ finally:
101
+ try:
102
+ cursor.close()
103
+ except Exception:
104
+ pass
105
+ except Exception:
106
+ status = "error"
107
+ raise
108
+ finally:
109
+ duration = time.perf_counter() - t0
110
+ tags = {
111
+ "driver": self.driver.__name__,
112
+ "query": query.split()[0].upper() if query else "UNKNOWN",
113
+ "status": status,
114
+ }
115
+ metrics.gauge("db.query.duration", duration, tags)
116
+ metrics.counter("db.query", tags).inc()
117
+
118
+ def release(self) -> None:
119
+ """Закрывает соединение с БД, если оно открыто."""
120
+ if self.connection:
121
+ if hasattr(self.connection, "closed") and not self.connection.closed:
122
+ self.connection.close()
123
+ elif hasattr(self.connection, "close"): # Для oracledb, где нет closed
124
+ self.connection.close()
125
+ self.connection = None
126
+
127
+ def forget(self) -> None:
128
+ """Совместимость: делегирует в базовый Skill.forget()."""
129
+ return super().forget()
@@ -0,0 +1,135 @@
1
+ import logging
2
+ from typing import Optional, List, Dict, Any
3
+
4
+ from kafka import KafkaConsumer, KafkaProducer
5
+
6
+ from .core.base import Skill
7
+
8
+ logger = logging.getLogger(__name__)
9
+
10
+
11
+ class UseKafka(Skill):
12
+ """Навык для взаимодействия с Kafka."""
13
+
14
+ def __init__(
15
+ self,
16
+ bootstrap_servers: str,
17
+ group_id: Optional[str] = None,
18
+ from_offset: str = "latest", # "earliest" | "latest"
19
+ timeout: float = 10.0,
20
+ ):
21
+ super().__init__(driver=None)
22
+ self.bootstrap_servers = bootstrap_servers
23
+ self.group_id = group_id
24
+ self.from_offset = (
25
+ from_offset if from_offset in ("earliest", "latest") else "latest"
26
+ )
27
+ self.timeout = float(timeout)
28
+ self._producer = None
29
+ self._consumer = None
30
+
31
+ @staticmethod
32
+ def with_servers(
33
+ bootstrap_servers: str,
34
+ group_id: Optional[str] = None,
35
+ from_offset: str = "latest",
36
+ timeout: float = 10.0,
37
+ ) -> "UseKafka":
38
+ """Фабричный метод для создания экземпляра навыка."""
39
+ return UseKafka(
40
+ bootstrap_servers=bootstrap_servers,
41
+ group_id=group_id,
42
+ from_offset=from_offset,
43
+ timeout=timeout,
44
+ )
45
+
46
+ def _get_producer(self) -> Any:
47
+ if self._producer is None:
48
+ self._producer = KafkaProducer(bootstrap_servers=self.bootstrap_servers)
49
+ return self._producer
50
+
51
+ def _get_consumer(self, topic: str) -> Any:
52
+ if self._consumer is None:
53
+ auto_offset_reset = self.from_offset
54
+ consumer_timeout_ms = int(self.timeout * 1000)
55
+ self._consumer = KafkaConsumer(
56
+ topic,
57
+ bootstrap_servers=self.bootstrap_servers,
58
+ group_id=self.group_id,
59
+ enable_auto_commit=False,
60
+ auto_offset_reset=auto_offset_reset,
61
+ consumer_timeout_ms=consumer_timeout_ms,
62
+ )
63
+ return self._consumer
64
+
65
+ def send_message(
66
+ self,
67
+ topic: str,
68
+ message: bytes,
69
+ key: Optional[bytes] = None,
70
+ headers: Optional[List[tuple[str, bytes]]] = None,
71
+ ) -> None:
72
+ """Отправляет сообщение в указанный топик Kafka."""
73
+ producer = self._get_producer()
74
+ try:
75
+ future = producer.send(topic, key=key, value=message, headers=headers or [])
76
+ future.get(timeout=self.timeout)
77
+ producer.flush(timeout=self.timeout)
78
+ except Exception as e:
79
+ logger.error(f"[UseKafka] Ошибка при отправке сообщения в '{topic}': {e}")
80
+ raise
81
+
82
+ def consume(
83
+ self,
84
+ topic: str,
85
+ max_messages: int = 1,
86
+ timeout: Optional[float] = None,
87
+ ) -> List[Dict[str, Any]]:
88
+ """Читает сообщения из топика (до max_messages или до таймаута)."""
89
+ consumer = self._get_consumer(topic)
90
+ results: List[Dict[str, Any]] = []
91
+ end_after = timeout or self.timeout
92
+ try:
93
+ import time
94
+
95
+ start = time.time()
96
+ while len(results) < max_messages and (time.time() - start) < end_after:
97
+ try:
98
+ msg = next(consumer)
99
+ except StopIteration:
100
+ break
101
+ results.append(
102
+ {
103
+ "topic": msg.topic,
104
+ "partition": msg.partition,
105
+ "offset": msg.offset,
106
+ "timestamp": msg.timestamp,
107
+ "key": msg.key,
108
+ "value": msg.value,
109
+ "headers": dict(msg.headers or []),
110
+ }
111
+ )
112
+ except Exception as e:
113
+ logger.error(f"[UseKafka] Ошибка при чтении сообщений из '{topic}': {e}")
114
+ raise
115
+ return results
116
+
117
+ def release(self) -> None:
118
+ """Закрывает все соединения и освобождает ресурсы."""
119
+ try:
120
+ if self._producer:
121
+ self._producer.flush(timeout=self.timeout)
122
+ self._producer.close()
123
+ except Exception:
124
+ pass
125
+ try:
126
+ if self._consumer:
127
+ self._consumer.close()
128
+ except Exception:
129
+ pass
130
+ self._producer = None
131
+ self._consumer = None
132
+
133
+ def forget(self) -> None:
134
+ """Совместимость: делегирует в базовый Skill.forget()."""
135
+ return super().forget()