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,221 @@
1
+ import os
2
+ import shutil
3
+ import time
4
+ import difflib
5
+ import yaml
6
+ from pathlib import Path
7
+ from typing import Optional, List, Any
8
+
9
+ import allure
10
+
11
+ from persona_dsl.components.expectation import Expectation
12
+ from persona_dsl.utils.artifacts import sanitize_filename, artifacts_dir
13
+ from persona_dsl.utils.path import remove_by_path
14
+
15
+
16
+ def _normalize(obj: Any, sort_keys: bool = True) -> Any:
17
+ """
18
+ Рекурсивно нормализует JSON-структуру:
19
+ - сортирует ключи словарей (если sort_keys=True)
20
+ - оставляет списки как есть
21
+ """
22
+ if isinstance(obj, dict):
23
+ items_gen = ((k, _normalize(v, sort_keys)) for k, v in obj.items())
24
+ items: Any
25
+ if sort_keys:
26
+ items = sorted(items_gen, key=lambda kv: kv[0])
27
+ else:
28
+ items = items_gen
29
+ return {k: v for k, v in items}
30
+ if isinstance(obj, list):
31
+ return [_normalize(v, sort_keys) for v in obj]
32
+ return obj
33
+
34
+
35
+ class MatchesAriaSnapshot(Expectation):
36
+ """
37
+ Ожидание: ARIA-снепшот (YAML строка) совпадает с baseline (.yaml).
38
+ - baseline ищется по умолчанию в tests/snapshots/<name>.yaml.
39
+ - при отсутствии baseline:
40
+ * если update=True или UPDATE_SNAPSHOTS=1 — baseline создаётся из actual;
41
+ * иначе — ошибка.
42
+ - перед сравнением можно исключить пути (ignore_paths) и нормализовать порядок ключей (sort_keys=True).
43
+ """
44
+
45
+ def __init__(
46
+ self,
47
+ snapshot_name: str,
48
+ update: bool = False,
49
+ baseline_dir: Optional[str] = None,
50
+ ignore_paths: Optional[List[str]] = None,
51
+ sort_keys: bool = True,
52
+ ):
53
+ """
54
+ Args:
55
+ snapshot_name: имя baseline-файла (например, "home-aria.yaml" или без .yaml).
56
+ update: разрешить создание/обновление baseline.
57
+ baseline_dir: каталог для baseline (по умолчанию tests/snapshots).
58
+ ignore_paths: список путей (dot/bracket), которые следует удалить перед сравнением.
59
+ sort_keys: сортировать ли ключи словарей перед сравнением.
60
+ """
61
+ self.snapshot_name = snapshot_name
62
+ self.update = bool(update)
63
+ self.baseline_dir = baseline_dir
64
+ self.ignore_paths = ignore_paths or []
65
+ self.sort_keys = bool(sort_keys)
66
+
67
+ def _get_step_description(self, persona: Any) -> str:
68
+ return f"совпадает с ARIA-снепшотом '{self.snapshot_name}'"
69
+
70
+ def _perform(self, persona: Any, *args: Any, **kwargs: Any) -> None:
71
+ actual_value = args[0]
72
+ if not isinstance(actual_value, str):
73
+ raise TypeError(
74
+ f"MatchesAriaSnapshot ожидает YAML строку, получено: {type(actual_value)}"
75
+ )
76
+
77
+ # Преобразуем YAML в словарь для нормализации и сравнения
78
+ try:
79
+ actual_dict = yaml.safe_load(actual_value)
80
+ except Exception as e:
81
+ raise ValueError(f"Не удалось распарсить YAML снепшот: {e}") from e
82
+
83
+ snap_name = sanitize_filename(self.snapshot_name)
84
+ if not snap_name.lower().endswith((".yaml", ".yml")):
85
+ snap_name += ".yaml"
86
+ base_dir = (
87
+ Path(self.baseline_dir)
88
+ if self.baseline_dir
89
+ else (Path.cwd() / "tests" / "snapshots")
90
+ )
91
+ baseline_path = base_dir / snap_name
92
+
93
+ # Подготовим копии для нормализации/фильтрации
94
+ def _prepare(val: Any) -> Any:
95
+ import copy
96
+
97
+ v = copy.deepcopy(val)
98
+ for p in self.ignore_paths:
99
+ remove_by_path(v, p, strict=False)
100
+ return _normalize(v, self.sort_keys)
101
+
102
+ actual_norm = _prepare(actual_dict)
103
+
104
+ # Если baseline отсутствует — возможно создаём
105
+ if not baseline_path.exists():
106
+ if self.update or os.getenv("UPDATE_SNAPSHOTS") == "1":
107
+ baseline_path.parent.mkdir(parents=True, exist_ok=True)
108
+ with open(baseline_path, "w", encoding="utf-8") as f:
109
+ yaml.dump(
110
+ actual_norm,
111
+ f,
112
+ default_flow_style=False,
113
+ allow_unicode=True,
114
+ indent=2,
115
+ )
116
+ try:
117
+ allure.attach(
118
+ yaml.dump(
119
+ actual_norm,
120
+ default_flow_style=False,
121
+ allow_unicode=True,
122
+ indent=2,
123
+ ),
124
+ name=f"baseline-created:{snap_name}",
125
+ attachment_type=allure.attachment_type.TEXT,
126
+ )
127
+ except Exception:
128
+ pass
129
+ return
130
+ raise AssertionError(
131
+ f"ARIA baseline не найден: {baseline_path}. "
132
+ f"Запустите с UPDATE_SNAPSHOTS=1 или установите update=True для создания baseline."
133
+ )
134
+
135
+ # Сравнение
136
+ with open(baseline_path, "r", encoding="utf-8") as f:
137
+ try:
138
+ base_data = yaml.safe_load(f)
139
+ except Exception as e:
140
+ raise AssertionError(
141
+ f"Не удалось прочитать baseline YAML {baseline_path}: {e}"
142
+ ) from e
143
+ base_norm = _prepare(base_data)
144
+
145
+ if base_norm == actual_norm:
146
+ return
147
+
148
+ # Если разрешён update — обновляем baseline и выходим
149
+ if self.update or os.getenv("UPDATE_SNAPSHOTS") == "1":
150
+ shutil.copyfile(baseline_path, baseline_path.with_suffix(".bak.yaml"))
151
+ with open(baseline_path, "w", encoding="utf-8") as f:
152
+ yaml.dump(
153
+ actual_norm,
154
+ f,
155
+ default_flow_style=False,
156
+ allow_unicode=True,
157
+ indent=2,
158
+ )
159
+ try:
160
+ allure.attach(
161
+ yaml.dump(
162
+ actual_norm,
163
+ default_flow_style=False,
164
+ allow_unicode=True,
165
+ indent=2,
166
+ ),
167
+ name=f"baseline-updated:{snap_name}",
168
+ attachment_type=allure.attachment_type.TEXT,
169
+ )
170
+ except Exception:
171
+ pass
172
+ return
173
+
174
+ # Иначе — формируем артефакты diff
175
+ ts = int(time.time() * 1000)
176
+ art_dir = artifacts_dir("snapshots")
177
+ actual_path = art_dir / f"{ts}-actual-{snap_name}"
178
+ diff_path = art_dir / f"{ts}-diff-{snap_name}.diff"
179
+
180
+ with open(actual_path, "w", encoding="utf-8") as f:
181
+ yaml.dump(
182
+ actual_norm, f, default_flow_style=False, allow_unicode=True, indent=2
183
+ )
184
+
185
+ base_str = yaml.dump(
186
+ base_norm, default_flow_style=False, allow_unicode=True, indent=2
187
+ ).splitlines(keepends=True)
188
+ act_str = yaml.dump(
189
+ actual_norm, default_flow_style=False, allow_unicode=True, indent=2
190
+ ).splitlines(keepends=True)
191
+ udiff = "".join(
192
+ difflib.unified_diff(
193
+ base_str, act_str, fromfile="baseline", tofile="actual"
194
+ )
195
+ )
196
+
197
+ with open(diff_path, "w", encoding="utf-8") as f:
198
+ f.write(udiff)
199
+
200
+ try:
201
+ allure.attach.file(
202
+ str(baseline_path),
203
+ name=f"baseline:{snap_name}",
204
+ attachment_type=allure.attachment_type.TEXT,
205
+ )
206
+ allure.attach.file(
207
+ str(actual_path),
208
+ name=f"actual:{snap_name}",
209
+ attachment_type=allure.attachment_type.TEXT,
210
+ )
211
+ allure.attach(
212
+ udiff,
213
+ name=f"diff:{snap_name}",
214
+ attachment_type=allure.attachment_type.TEXT,
215
+ )
216
+ except Exception:
217
+ pass
218
+
219
+ raise AssertionError(
220
+ f"ARIA YAML не совпадает с baseline: {baseline_path}. Diff: {diff_path}"
221
+ )
@@ -0,0 +1,160 @@
1
+ import os
2
+ import shutil
3
+ import time
4
+ from pathlib import Path
5
+ from typing import List, Optional, Dict, Any
6
+
7
+ import allure
8
+
9
+ from persona_dsl.components.expectation import Expectation
10
+ from persona_dsl.utils.artifacts import sanitize_filename, artifacts_dir
11
+
12
+
13
+ class MatchesScreenshot(Expectation):
14
+ """
15
+ Ожидание: файл-скриншот совпадает с baseline-снимком (PNG) с допуском threshold.
16
+ - baseline хранится по умолчанию в tests/snapshots/<snapshot_name>.
17
+ - при отсутствии baseline:
18
+ * если update=True или UPDATE_SNAPSHOTS=1 — baseline создаётся из actual;
19
+ * иначе — ошибка.
20
+ - при расхождении > threshold — сохраняется diff и прикрепляется в Allure.
21
+ """
22
+
23
+ def __init__(
24
+ self,
25
+ snapshot_name: str,
26
+ threshold: float = 0.0,
27
+ update: bool = False,
28
+ ignore_regions: Optional[List[Dict[str, int]]] = None,
29
+ baseline_dir: Optional[str] = None,
30
+ ):
31
+ """
32
+ Args:
33
+ snapshot_name: имя файла baseline (можно с подкаталогом), например "home.png".
34
+ threshold: допустимая доля отличающихся пикселей (0..1).
35
+ update: разрешить создание/обновление baseline.
36
+ ignore_regions: области для маскирования различий: [{x,y,w,h}, ...].
37
+ baseline_dir: каталог для baseline; по умолчанию tests/snapshots.
38
+ """
39
+ self.snapshot_name = snapshot_name
40
+ self.threshold = float(threshold)
41
+ self.update = bool(update)
42
+ self.ignore_regions = ignore_regions or []
43
+ self.baseline_dir = baseline_dir
44
+
45
+ def _get_step_description(self, persona: Any) -> str:
46
+ return f"совпадает со скриншотом '{self.snapshot_name}' (threshold={self.threshold})"
47
+
48
+ def _perform(self, persona: Any, *args: Any, **kwargs: Any) -> None:
49
+ actual_path = args[0]
50
+ if not isinstance(actual_path, str):
51
+ raise TypeError(
52
+ f"MatchesScreenshot ожидает строковый путь к PNG, получено: {type(actual_path)}"
53
+ )
54
+
55
+ try:
56
+ from PIL import Image, ImageChops, ImageDraw
57
+ except Exception as e:
58
+ raise RuntimeError(
59
+ "Для сравнения скриншотов требуется пакет 'pillow'. Установите его."
60
+ ) from e
61
+
62
+ # Определяем baseline-путь
63
+ snap_name = sanitize_filename(self.snapshot_name)
64
+ if not snap_name.lower().endswith(".png"):
65
+ snap_name += ".png"
66
+ base_dir = (
67
+ Path(self.baseline_dir)
68
+ if self.baseline_dir
69
+ else (Path.cwd() / "tests" / "snapshots")
70
+ )
71
+ baseline_path = base_dir / snap_name
72
+ actual_path_p = Path(actual_path)
73
+
74
+ # Если baseline отсутствует — либо создаём (update), либо валим
75
+ if not baseline_path.exists():
76
+ if self.update or os.getenv("UPDATE_SNAPSHOTS") == "1":
77
+ baseline_path.parent.mkdir(parents=True, exist_ok=True)
78
+ shutil.copyfile(actual_path_p, baseline_path)
79
+ allure.attach.file(
80
+ str(actual_path_p),
81
+ name=f"baseline-created:{snap_name}",
82
+ attachment_type=allure.attachment_type.PNG,
83
+ )
84
+ return
85
+ raise AssertionError(
86
+ f"Baseline не найден: {baseline_path}. "
87
+ f"Запустите с UPDATE_SNAPSHOTS=1 или установите update=True для обновления baseline."
88
+ )
89
+
90
+ # Загружаем изображения
91
+ with Image.open(baseline_path).convert("RGBA") as img_base, Image.open(
92
+ actual_path_p
93
+ ).convert("RGBA") as img_act:
94
+ # Разные размеры — явная ошибка (можно обновить baseline)
95
+ if img_base.size != img_act.size:
96
+ if self.update or os.getenv("UPDATE_SNAPSHOTS") == "1":
97
+ shutil.copyfile(actual_path_p, baseline_path)
98
+ allure.attach.file(
99
+ str(actual_path_p),
100
+ name=f"baseline-updated:{snap_name}",
101
+ attachment_type=allure.attachment_type.PNG,
102
+ )
103
+ return
104
+ raise AssertionError(
105
+ f"Размеры изображений различаются: baseline={img_base.size}, actual={img_act.size}. "
106
+ f"Обновите baseline (UPDATE_SNAPSHOTS=1 или update=True)."
107
+ )
108
+
109
+ # Маскируем области ignore_regions (одинаково на обоих изображениях)
110
+ if self.ignore_regions:
111
+ draw_b = ImageDraw.Draw(img_base)
112
+ draw_a = ImageDraw.Draw(img_act)
113
+ for r in self.ignore_regions:
114
+ x, y, w, h = (
115
+ int(r.get("x", 0)),
116
+ int(r.get("y", 0)),
117
+ int(r.get("w", 0)),
118
+ int(r.get("h", 0)),
119
+ )
120
+ if w > 0 and h > 0:
121
+ draw_b.rectangle([x, y, x + w, y + h], fill=(0, 0, 0, 255))
122
+ draw_a.rectangle([x, y, x + w, y + h], fill=(0, 0, 0, 255))
123
+
124
+ # Считаем долю отличающихся пикселей
125
+ diff = ImageChops.difference(img_base, img_act).convert("L")
126
+ nonzero = sum(1 for px in list(diff.getdata()) if px != 0)
127
+ total = diff.size[0] * diff.size[1]
128
+ frac = nonzero / total if total > 0 else 0.0
129
+
130
+ if frac <= self.threshold:
131
+ return # Успех
132
+
133
+ # Готовим артефакты: сохраняем actual/baseline/diff и прикрепляем
134
+ ts = int(time.time() * 1000)
135
+ art_dir = artifacts_dir("screenshots")
136
+ diff_path = art_dir / f"{ts}-diff-{snap_name}"
137
+ # Сохраняем визуализацию diff
138
+ diff_img = diff # уже L; можно прикрепить напрямую
139
+ diff_img.save(diff_path)
140
+
141
+ allure.attach.file(
142
+ str(baseline_path),
143
+ name=f"baseline:{snap_name}",
144
+ attachment_type=allure.attachment_type.PNG,
145
+ )
146
+ allure.attach.file(
147
+ str(actual_path_p),
148
+ name=f"actual:{snap_name}",
149
+ attachment_type=allure.attachment_type.PNG,
150
+ )
151
+ allure.attach.file(
152
+ str(diff_path),
153
+ name=f"diff:{snap_name}",
154
+ attachment_type=allure.attachment_type.PNG,
155
+ )
156
+
157
+ raise AssertionError(
158
+ f"Скриншот не совпадает с baseline: {baseline_path}. "
159
+ f"Доля отличий={frac:.6f}, допустимо <= {self.threshold}. Diff сохранён: {diff_path}"
160
+ )
@@ -0,0 +1,5 @@
1
+ # __init__.py for generators module
2
+ from .page_generator import PageGenerator
3
+ from .api_generator import ApiGenerator
4
+
5
+ __all__ = ["PageGenerator", "ApiGenerator"]