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.
- 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 +222 -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 +159 -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 +1230 -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.21.44.dist-info/METADATA +233 -0
- persona_dsl-26.1.21.44.dist-info/RECORD +86 -0
- persona_dsl-26.1.21.44.dist-info/WHEEL +5 -0
- persona_dsl-26.1.21.44.dist-info/entry_points.txt +6 -0
- persona_dsl-26.1.21.44.dist-info/top_level.txt +1 -0
|
@@ -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()
|