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,1140 @@
1
+ import ast
2
+ import re
3
+ from typing import Any, Dict, List, Optional
4
+
5
+ from unidecode import unidecode
6
+
7
+ from persona_dsl.pages import (
8
+ Alert,
9
+ AlertDialog,
10
+ Article,
11
+ Banner,
12
+ BlockQuote,
13
+ Button,
14
+ Checkbox,
15
+ Code,
16
+ ColumnHeader,
17
+ ComboBox,
18
+ Complementary,
19
+ ContentInfo,
20
+ Dialog,
21
+ Element,
22
+ Figure,
23
+ Form,
24
+ Grid,
25
+ GridCell,
26
+ Group,
27
+ Heading,
28
+ Image,
29
+ Link,
30
+ ListElement,
31
+ ListBox,
32
+ ListItem,
33
+ Log,
34
+ Main,
35
+ Menu,
36
+ MenuBar,
37
+ MenuItem,
38
+ MenuItemCheckbox,
39
+ MenuItemRadio,
40
+ Meter,
41
+ Navigation,
42
+ Paragraph,
43
+ ProgressBar,
44
+ Radio,
45
+ RadioGroup,
46
+ Region,
47
+ RowHeader,
48
+ ScrollBar,
49
+ Search,
50
+ SearchBox,
51
+ Separator,
52
+ Slider,
53
+ SpinButton,
54
+ Status,
55
+ Strong,
56
+ Switch,
57
+ Tab,
58
+ Table,
59
+ TabList,
60
+ TabPanel,
61
+ TableCell,
62
+ TableRow,
63
+ TextField,
64
+ Timer,
65
+ Toolbar,
66
+ Tooltip,
67
+ Tree,
68
+ TreeItem,
69
+ )
70
+
71
+
72
+ class PageGenerator:
73
+ """Генератор страниц 2.0 (LCL-34/35)."""
74
+
75
+ _ROLE_TO_CLASS = {
76
+ "alert": Alert,
77
+ "alertdialog": AlertDialog,
78
+ "article": Article,
79
+ "banner": Banner,
80
+ "blockquote": BlockQuote,
81
+ "button": Button,
82
+ "cell": TableCell,
83
+ "checkbox": Checkbox,
84
+ "code": Code,
85
+ "columnheader": ColumnHeader,
86
+ "combobox": ComboBox,
87
+ "complementary": Complementary,
88
+ "contentinfo": ContentInfo,
89
+ "dialog": Dialog,
90
+ "figure": Figure,
91
+ "form": Form,
92
+ "generic": Element,
93
+ "grid": Grid,
94
+ "gridcell": GridCell,
95
+ "group": Group,
96
+ "heading": Heading,
97
+ "img": Image,
98
+ "link": Link,
99
+ "list": ListElement,
100
+ "listbox": ListBox,
101
+ "listitem": ListItem,
102
+ "log": Log,
103
+ "main": Main,
104
+ "menu": Menu,
105
+ "menubar": MenuBar,
106
+ "menuitem": MenuItem,
107
+ "menuitemcheckbox": MenuItemCheckbox,
108
+ "menuitemradio": MenuItemRadio,
109
+ "meter": Meter,
110
+ "navigation": Navigation,
111
+ "paragraph": Paragraph,
112
+ "progressbar": ProgressBar,
113
+ "radio": Radio,
114
+ "radiogroup": RadioGroup,
115
+ "region": Region,
116
+ "row": TableRow,
117
+ "rowheader": RowHeader,
118
+ "scrollbar": ScrollBar,
119
+ "search": Search,
120
+ "searchbox": SearchBox,
121
+ "separator": Separator,
122
+ "slider": Slider,
123
+ "spinbutton": SpinButton,
124
+ "status": Status,
125
+ "strong": Strong,
126
+ "switch": Switch,
127
+ "tab": Tab,
128
+ "table": Table,
129
+ "tablist": TabList,
130
+ "tabpanel": TabPanel,
131
+ "textbox": TextField,
132
+ "timer": Timer,
133
+ "toolbar": Toolbar,
134
+ "tooltip": Tooltip,
135
+ "tree": Tree,
136
+ "treeitem": TreeItem,
137
+ }
138
+
139
+ _CONTAINER_ROLES = {
140
+ "alert",
141
+ "alertdialog",
142
+ "article",
143
+ "banner",
144
+ "blockquote",
145
+ "complementary",
146
+ "contentinfo",
147
+ "dialog",
148
+ "figure",
149
+ "form",
150
+ "generic",
151
+ "grid",
152
+ "group",
153
+ "heading",
154
+ "list",
155
+ "listitem",
156
+ "main",
157
+ "menu",
158
+ "menubar",
159
+ "navigation",
160
+ "paragraph",
161
+ "radiogroup",
162
+ "region",
163
+ "row",
164
+ "rowgroup",
165
+ "table",
166
+ "tabpanel",
167
+ "toolbar",
168
+ "tree",
169
+ "treeitem",
170
+ }
171
+
172
+ _COLLECTION_ITEM_ROLES = {
173
+ "listitem",
174
+ "menuitem",
175
+ "menuitemcheckbox",
176
+ "menuitemradio",
177
+ "tab",
178
+ "treeitem",
179
+ "row",
180
+ "option",
181
+ "link",
182
+ "button", # Расширено для группировки
183
+ }
184
+
185
+ def __init__(self) -> None:
186
+ # Коллекция test_id для одной страницы (для вычисления общего префикса)
187
+ self._seen_test_ids: List[str] = []
188
+ self._test_id_prefix: str | None = None
189
+
190
+ def _get_unique_name(self, base_name: str, used_names: set[str]) -> str:
191
+ """Генерирует уникальное имя, добавляя суффикс при необходимости."""
192
+ candidate = base_name
193
+ counter = 1
194
+ while candidate in used_names:
195
+ candidate = f"{base_name}_{counter}"
196
+ counter += 1
197
+ return candidate
198
+
199
+ def _sanitize_name(self, name: str) -> str:
200
+ """Санитизация человекочитаемых имён в идентификаторы Python.
201
+
202
+ Правила:
203
+ * очистка от артефактов снепшота (типа [ref=...]);
204
+ * транслитерация в ASCII;
205
+ * удаление всего, кроме букв/цифр/подчёркиваний и пробелов;
206
+ * схлопывание пробелов в подчёркивание;
207
+ * гарантия корректного идентификатора Python.
208
+ """
209
+ s = (name or "").strip()
210
+
211
+ # Очистка от старых артефактов, если вдруг попадутся (хотя мы перешли на JSON)
212
+ if "[ref=" in s:
213
+ s = s.split("[ref=")[0].strip()
214
+
215
+ # Базовая логика
216
+ s = unidecode(s)
217
+ s = s.replace("-", "_")
218
+ # Оставляем только буквы, цифры и пробелы/подчёркивания
219
+ s = re.sub(r"[^\w\s]", "", s)
220
+ s = re.sub(r"\s+", "_", s)
221
+ s = s.strip("_")
222
+
223
+ # Truncate logic
224
+ if len(s) > 50:
225
+ s = s[:50]
226
+ if "_" in s:
227
+ s = s.rsplit("_", 1)[0]
228
+
229
+ parts = [p for p in s.split("_") if p]
230
+ # Removed aggressive truncation: if len(parts) > 4: parts = parts[:3]
231
+ s = "_".join(parts)
232
+
233
+ s = s.lower()
234
+
235
+ if not s:
236
+ return "element"
237
+
238
+ if s and not s[0].isalpha() and s[0] != "_":
239
+ s = "_" + s
240
+
241
+ return s
242
+
243
+ def _parse_aria_node_recursive(
244
+ self,
245
+ nodes: List[Dict[str, Any]] | Dict[str, Any],
246
+ used_names: set[str],
247
+ context: Dict[str, Any],
248
+ ) -> List[Dict[str, Any]]:
249
+ """Строит внутреннее дерево элементов из JSON-структуры (от Runtime).
250
+
251
+ Аргумент nodes ожидает список словарей (детей) или один корневой словарь.
252
+ Каждый словарь имеет структуру: {role, name, ref, props, children, ...}.
253
+ """
254
+ elements: List[Dict[str, Any]] = []
255
+
256
+ # Нормализация входа: работаем всегда со списком узлов
257
+ node_list = nodes if isinstance(nodes, list) else [nodes]
258
+
259
+ for node in node_list:
260
+ if isinstance(node, str):
261
+ # Текстовый узел
262
+ text_val = node.strip()
263
+ if text_val:
264
+ var_name_base = self._sanitize_name(text_val)
265
+ var_name = self._get_unique_name(
266
+ var_name_base or "text", used_names
267
+ )
268
+ used_names.add(var_name)
269
+ elements.append(
270
+ {
271
+ "var_name": var_name,
272
+ "class": Element,
273
+ "role": "generic",
274
+ "aria_name": None,
275
+ "aria_ref": None,
276
+ "test_id": None,
277
+ "placeholder": None,
278
+ "text": text_val,
279
+ "level": None,
280
+ "is_container": False,
281
+ "children": [],
282
+ "accessibility_notes": [],
283
+ }
284
+ )
285
+ continue
286
+
287
+ if not isinstance(node, dict):
288
+ continue
289
+
290
+ role = node.get("role", "generic")
291
+ aria_name = node.get("name", "")
292
+ aria_ref = node.get("ref")
293
+ props = node.get("props") or {}
294
+ raw_children = node.get("children", [])
295
+
296
+ # Извлекаем пропы
297
+ test_id = props.get("testId") or props.get("test_id")
298
+ if test_id:
299
+ self._seen_test_ids.append(test_id)
300
+
301
+ placeholder = props.get("placeholder")
302
+ text_prop = props.get("text")
303
+ level = node.get("level")
304
+
305
+ # Определяем класс элемента
306
+ element_cls = self._ROLE_TO_CLASS.get(role, Element)
307
+
308
+ # Логика именования (Naming)
309
+ # Приоритет: aria_name -> test_id -> role
310
+ var_name_base = ""
311
+
312
+ if aria_name:
313
+ var_name_base = self._sanitize_name(aria_name)
314
+
315
+ if not var_name_base and test_id:
316
+ normalized_tid = self._normalize_test_id_for_name(test_id)
317
+ var_name_base = self._sanitize_name(normalized_tid)
318
+
319
+ if not var_name_base and text_prop:
320
+ # Попробуем взять имя из текста (props.text)
321
+ var_name_base = self._sanitize_name(text_prop)
322
+
323
+ if not var_name_base:
324
+ var_name_base = self._sanitize_name(role)
325
+
326
+ if role == "listitem" and not aria_name and not test_id:
327
+ var_name_base = "item"
328
+
329
+ var_name = var_name_base or role.lower()
330
+ if role == "list" and aria_name:
331
+ var_name = f"{self._sanitize_name(aria_name)}_list"
332
+
333
+ final_var_name = self._get_unique_name(var_name, used_names)
334
+ used_names.add(final_var_name)
335
+
336
+ # Рекурсивная обработка детей
337
+ child_context = context.copy()
338
+ parsed_children = []
339
+
340
+ if raw_children:
341
+ parsed_children = self._parse_aria_node_recursive(
342
+ raw_children, used_names, child_context
343
+ )
344
+
345
+ # Формируем элемент
346
+ # Важно: текст теперь может быть отдельным ребенком, если он пришел как строка в children
347
+
348
+ element_data: Dict[str, Any] = {
349
+ "var_name": final_var_name,
350
+ "class": element_cls,
351
+ "role": role,
352
+ "aria_name": aria_name,
353
+ "aria_ref": aria_ref,
354
+ "test_id": test_id,
355
+ "placeholder": placeholder,
356
+ "text": text_prop, # Текст из props
357
+ "level": level,
358
+ "is_container": role in self._CONTAINER_ROLES or bool(parsed_children),
359
+ "children": parsed_children,
360
+ "accessibility_notes": [],
361
+ }
362
+
363
+ elements.append(element_data)
364
+
365
+ return elements
366
+
367
+ def _flatten_tree_recursive(
368
+ self, elements: List[Dict[str, Any]]
369
+ ) -> List[Dict[str, Any]]:
370
+ """Рекурсивно проходит дерево и делает служебные generic-обёртки прозрачными.
371
+
372
+ Правила:
373
+ - Узлы с role="generic" без aria_name/test_id/semantic-флага и без полезного текста
374
+ не становятся отдельными полями PageObject: их дети поднимаются на уровень выше.
375
+ - Семантические контейнеры (is_semantic_group, aria_name, test_id, meaningful text)
376
+ всегда сохраняются.
377
+ """
378
+ new_elements: List[Dict[str, Any]] = []
379
+ for el in elements:
380
+ # Сначала рекурсивно обрабатываем детей
381
+ if el["children"]:
382
+ el["children"] = self._flatten_tree_recursive(el["children"])
383
+
384
+ role = el.get("role")
385
+ aria_name = (el.get("aria_name") or "").strip()
386
+ test_id = (el.get("test_id") or "").strip()
387
+ text = (el.get("text") or "").strip()
388
+
389
+ # Любой непустой text теперь считаем осмысленным — ничего не выкидываем как артефакт
390
+ has_meaningful_text = bool(text)
391
+
392
+ is_plain_generic = (
393
+ role == "generic"
394
+ and not aria_name
395
+ and not test_id
396
+ and not el.get("is_semantic_group")
397
+ and not has_meaningful_text
398
+ )
399
+
400
+ if is_plain_generic:
401
+ # Делаем generic полностью прозрачным: поднимаем всех детей уровнем выше
402
+ new_elements.extend(el["children"])
403
+ else:
404
+ new_elements.append(el)
405
+
406
+ return new_elements
407
+
408
+ def _find_first_heading(
409
+ self, element: Dict[str, Any], depth: int = 0, max_depth: int = 2
410
+ ) -> Optional[Dict[str, Any]]:
411
+ """
412
+ Рекурсивный поиск первого заголовка для именования секции.
413
+ Останавливается, если встречает уже именованную семантическую группу.
414
+ """
415
+ if depth > max_depth:
416
+ return None
417
+
418
+ # 1. Ищем среди непосредственных детей
419
+ for child in element["children"]:
420
+ # Если ребенок уже стал семантической группой (в post-order обходе),
421
+ # то его заголовок принадлежит ему, а не нам. Пропускаем.
422
+ if child.get("is_semantic_group") or (
423
+ child.get("aria_name") and child["role"] != "heading"
424
+ ):
425
+ continue
426
+
427
+ if child["class"] == Heading and child.get("aria_name"):
428
+ return child
429
+
430
+ # 2. Если не нашли, ищем в безымянных generic-детях
431
+ for child in element["children"]:
432
+ # Аналогично: не спускаемся в уже именованные группы
433
+ if child.get("is_semantic_group") or (
434
+ child.get("aria_name") and child["role"] != "heading"
435
+ ):
436
+ continue
437
+
438
+ if child["role"] == "generic" and not child.get("test_id"):
439
+ found = self._find_first_heading(child, depth + 1, max_depth)
440
+ if found:
441
+ return found
442
+ return None
443
+
444
+ def _construct_locator_from_heading(self, heading_el: Dict[str, Any]) -> str:
445
+ """Создает CSS :has() локатор на основе заголовка."""
446
+ text = heading_el.get("aria_name", "").replace('"', '\\"')
447
+ level = heading_el.get("level")
448
+
449
+ # Если уровень известен, используем h1-h6, иначе роль
450
+ tag = f"h{level}" if level else "[role=heading]"
451
+
452
+ # Используем :has-text для надежности
453
+ return f':has({tag}:has-text("{text}"))'
454
+
455
+ _ROLE_DEFAULT_NAMES = {
456
+ "banner": "header",
457
+ "contentinfo": "footer",
458
+ "navigation": "navigation",
459
+ }
460
+
461
+ def _promote_semantic_wrappers(
462
+ self, elements: List[Dict[str, Any]], used_names: set[str]
463
+ ) -> None:
464
+ for el in elements:
465
+ if el["children"]:
466
+ self._promote_semantic_wrappers(el["children"], used_names)
467
+
468
+ if (
469
+ el["role"] in self._CONTAINER_ROLES
470
+ and not el.get("aria_name")
471
+ and not el.get("test_id")
472
+ ):
473
+ # Используем улучшенный поиск заголовка
474
+ first_heading = self._find_first_heading(el)
475
+
476
+ if first_heading:
477
+ heading_text = first_heading["aria_name"]
478
+ new_base = self._sanitize_name(heading_text)
479
+ if new_base and new_base != "element":
480
+ suffix = "_section"
481
+ if el["role"] == "form":
482
+ suffix = "_form"
483
+ elif el["role"] == "navigation":
484
+ suffix = "_nav"
485
+
486
+ if not new_base.endswith(suffix) and not new_base.endswith(
487
+ suffix.replace("_", "")
488
+ ):
489
+ new_base += suffix
490
+
491
+ new_var_name = self._get_unique_name(new_base, used_names)
492
+ el["var_name"] = new_var_name
493
+ el["is_semantic_group"] = True
494
+
495
+ # Генерируем локатор, чтобы контейнер можно было найти
496
+ if not el.get("locator") and not el.get("test_id"):
497
+ el["locator"] = self._construct_locator_from_heading(
498
+ first_heading
499
+ )
500
+
501
+ used_names.add(new_var_name)
502
+ # Применяем role-based fallback для контейнеров с "плохими" именами
503
+ if (
504
+ el.get("role") in self._ROLE_DEFAULT_NAMES
505
+ and not el.get("aria_name")
506
+ and not el.get("test_id")
507
+ and not el.get("is_semantic_group")
508
+ ):
509
+ default_name = self._ROLE_DEFAULT_NAMES[el["role"]]
510
+ base = self._sanitize_name(default_name)
511
+ new_var_name = self._get_unique_name(base, used_names)
512
+ el["var_name"] = new_var_name
513
+ used_names.add(new_var_name)
514
+
515
+ def _build_init_body_ast(
516
+ self,
517
+ elements: List[Dict[str, Any]],
518
+ parent_var: str,
519
+ parent_identity_components: List[str],
520
+ overrides: Dict[str, Dict[str, str]],
521
+ leaf_overrides: Dict[str, List[str]], # Mapped by Signature -> List[names]
522
+ leaf_seen: Dict[str, int],
523
+ ) -> List[ast.stmt]:
524
+ nodes: List[ast.stmt] = []
525
+ created_list_attrs: set[str] = set()
526
+
527
+ # LCL-34: Умная группировка
528
+ grouped_indices: Dict[str, List[int]] = {}
529
+ for idx, el in enumerate(elements):
530
+ role = el.get("role")
531
+ if role in self._COLLECTION_ITEM_ROLES:
532
+ # Не группируем интерактивные элементы (ссылки, кнопки), если у них есть осмысленное имя или test_id/text.
533
+ is_interactive = role in ("link", "button")
534
+ has_name = bool(el.get("aria_name"))
535
+ has_tid = bool(el.get("test_id"))
536
+ has_text = bool(el.get("text"))
537
+
538
+ if is_interactive and (has_name or has_tid or has_text):
539
+ continue
540
+
541
+ key = f"{role}_{el['class'].__name__}"
542
+ grouped_indices.setdefault(key, []).append(idx)
543
+
544
+ skip_indices: set[int] = set()
545
+ for key, idx_list in grouped_indices.items():
546
+ if len(idx_list) >= 2:
547
+ first_el = elements[idx_list[0]]
548
+ class_name = first_el["class"].__name__
549
+
550
+ base_name = key.split("_")[0]
551
+
552
+ # Универсальные правила именования прототипа
553
+ if base_name in {"generic", "listitem"}:
554
+ base_name = "item"
555
+ elif base_name == "tab":
556
+ base_name = "tab"
557
+ elif base_name == "row":
558
+ base_name = "row"
559
+
560
+ list_base_name = f"{base_name}s"
561
+
562
+ list_attr = list_base_name
563
+ candidate = list_attr
564
+ suffix = 0
565
+ while candidate in created_list_attrs:
566
+ suffix += 1
567
+ candidate = f"{list_attr}_{suffix}"
568
+ list_attr = candidate
569
+ created_list_attrs.add(list_attr)
570
+
571
+ full_var_path = f"{parent_var}.{list_attr}"
572
+ target_node = self._create_attribute_chain(full_var_path)
573
+ parent_ref_node = self._create_attribute_chain(parent_var)
574
+
575
+ proto_keywords = [
576
+ ast.keyword("name", ast.Constant(base_name)),
577
+ ast.keyword("accessible_name", ast.Constant(None)),
578
+ ]
579
+
580
+ element_constructor = ast.Call(
581
+ func=ast.Name(id=class_name, ctx=ast.Load()),
582
+ args=[],
583
+ keywords=proto_keywords,
584
+ )
585
+
586
+ call_node = ast.Call(
587
+ func=ast.Attribute(
588
+ value=parent_ref_node,
589
+ attr="add_element_list",
590
+ ctx=ast.Load(),
591
+ ),
592
+ args=[element_constructor],
593
+ keywords=[ast.keyword("alias", ast.Constant(list_attr))],
594
+ )
595
+
596
+ assign_node = ast.Assign(targets=[target_node], value=call_node)
597
+ nodes.append(assign_node)
598
+ skip_indices.update(idx_list)
599
+
600
+ for idx, el in enumerate(elements):
601
+ if idx in skip_indices:
602
+ continue
603
+
604
+ class_name = el["class"].__name__
605
+ # key = (class_name, el.get("aria_name", ""))
606
+
607
+ # Signature for overrides lookup
608
+ # Note: aria_name here is raw data.
609
+ sig = self._leaf_signature(
610
+ class_name,
611
+ el.get("aria_name", "") or "",
612
+ el.get("text", "") or "",
613
+ el.get("test_id", "") or "",
614
+ el.get("placeholder", "") or "",
615
+ )
616
+
617
+ current_sig_count = leaf_seen.get(sig, 0)
618
+ leaf_seen[sig] = current_sig_count + 1
619
+
620
+ var_name = el["var_name"]
621
+
622
+ # Apply Override if exists
623
+ if sig in leaf_overrides:
624
+ if len(leaf_overrides[sig]) > current_sig_count:
625
+ # Use the existing user-defined name
626
+ var_name = leaf_overrides[sig][current_sig_count]
627
+
628
+ # Also update el["var_name"] so children usage (path building) is correct?
629
+ # Actually var_name is used below for construction.
630
+ # However, if we change var_name here, we must ensure unique check was done or doesn't matter?
631
+ # The user-supplied name is assumed to be valid and unique in the OLD file.
632
+ # But in the NEW generation, we already reserved 'el["var_name"]' in `used_names` during parse.
633
+ # If we swap it, we might collide?
634
+ # Ideally we should push this override into `_parse_aria_node_recursive`, but that's hard.
635
+ # Here we just override the name used in `self.parent.VAR_NAME = ...`.
636
+ # This is safe enough given the user controls it.
637
+
638
+ full_var_path = f"{parent_var}.{var_name}"
639
+ target_node = self._create_attribute_chain(full_var_path)
640
+ parent_ref_node = self._create_attribute_chain(parent_var)
641
+
642
+ keywords = [ast.keyword("name", ast.Constant(var_name))]
643
+
644
+ has_locator_strategy = False
645
+
646
+ if el.get("test_id"):
647
+ keywords.append(ast.keyword("test_id", ast.Constant(el["test_id"])))
648
+ has_locator_strategy = True
649
+ elif el.get("locator"): # Приоритет локатора (например, для секций)
650
+ keywords.append(ast.keyword("locator", ast.Constant(el["locator"])))
651
+ # Если есть локатор, accessible_name может быть не нужен для поиска, но полезен для отладки
652
+ if el.get("aria_name"):
653
+ keywords.append(
654
+ ast.keyword("accessible_name", ast.Constant(el["aria_name"]))
655
+ )
656
+ has_locator_strategy = True
657
+ elif el.get("role") and el.get("aria_name"):
658
+ # aria_name не пустой -> используем его
659
+ keywords.append(
660
+ ast.keyword("accessible_name", ast.Constant(el["aria_name"]))
661
+ )
662
+
663
+ # Для generic элемента (Element) нужно явно передать role="generic",
664
+ # чтобы сработала стратегия get_by_role(role, name=...)
665
+ if el["role"] == "generic":
666
+ keywords.append(ast.keyword("role", ast.Constant("generic")))
667
+
668
+ has_locator_strategy = True
669
+ elif el.get("placeholder"):
670
+ keywords.append(
671
+ ast.keyword("placeholder", ast.Constant(el["placeholder"]))
672
+ )
673
+ has_locator_strategy = True
674
+ elif el.get("text"):
675
+ keywords.append(ast.keyword("text", ast.Constant(el["text"])))
676
+ has_locator_strategy = True
677
+ # elif el.get("role") and el.get("role") != "generic":
678
+ # keywords.append(ast.keyword("accessible_name", ast.Constant("")))
679
+ # has_locator_strategy = True
680
+
681
+ if el.get("aria_ref"):
682
+ keywords.append(ast.keyword("aria_ref", ast.Constant(el["aria_ref"])))
683
+
684
+ element_constructor = ast.Call(
685
+ func=ast.Name(id=class_name, ctx=ast.Load()),
686
+ args=[],
687
+ keywords=keywords,
688
+ )
689
+
690
+ call_node = ast.Call(
691
+ func=ast.Attribute(
692
+ value=parent_ref_node, attr="add_element", ctx=ast.Load()
693
+ ),
694
+ args=[element_constructor],
695
+ keywords=[],
696
+ )
697
+
698
+ # Если элемент не имеет стабильной стратегии поиска и не является контейнером/семантической группой,
699
+ # мы не генерируем для него отдельное предупреждение в коде страницы, чтобы не засорять __init__.
700
+ # Диагностику таких случаев лучше делать на уровне генератора (логирование/ошибка), если потребуется.
701
+ if (
702
+ not has_locator_strategy
703
+ and not el["is_container"]
704
+ and not el.get("is_semantic_group")
705
+ ):
706
+ # LCL-38: Помечаем нестабильные локаторы
707
+ nodes.append(
708
+ ast.Expr(value=ast.Constant(value="TODO: Unstable Locator"))
709
+ )
710
+
711
+ assign_node = ast.Assign(targets=[target_node], value=call_node)
712
+ nodes.append(assign_node)
713
+
714
+ if el["children"]:
715
+ child_nodes = self._build_init_body_ast(
716
+ el["children"],
717
+ full_var_path,
718
+ parent_identity_components,
719
+ overrides,
720
+ leaf_overrides,
721
+ leaf_seen,
722
+ )
723
+ nodes.extend(child_nodes)
724
+ return nodes
725
+
726
+ def _identity_component_key(
727
+ self, class_name: str, aria_name: str, index: int
728
+ ) -> str:
729
+ return f"{class_name}|{aria_name}|{index}"
730
+
731
+ def _leaf_signature(
732
+ self,
733
+ class_name: str,
734
+ aria_name: str,
735
+ text: str = "",
736
+ test_id: str = "",
737
+ placeholder: str = "",
738
+ ) -> str:
739
+ """
740
+ Creates a signature for keying elements in the map.
741
+ We include all stable identifiers.
742
+ Format: ClassName|acc=...|text=...|tid=...|ph=...
743
+ """
744
+ # Normalize to handle None/empty consistently
745
+ an = (aria_name or "").strip()
746
+ tx = (text or "").strip()
747
+ ti = (test_id or "").strip()
748
+ ph = (placeholder or "").strip()
749
+ return f"{class_name}|acc={an}|text={tx}|tid={ti}|ph={ph}"
750
+
751
+ def _extract_field_mappings(self, class_node: ast.ClassDef) -> Dict[str, List[str]]:
752
+ """
753
+ Парсит __init__ существующего класса и строит карту:
754
+ Signature -> List[var_name]
755
+ """
756
+ mappings: Dict[str, List[str]] = {}
757
+
758
+ init_method = next(
759
+ (
760
+ n
761
+ for n in class_node.body
762
+ if isinstance(n, ast.FunctionDef) and n.name == "__init__"
763
+ ),
764
+ None,
765
+ )
766
+
767
+ if init_method:
768
+ for node in init_method.body:
769
+ if isinstance(node, ast.Assign) and len(node.targets) == 1:
770
+ if isinstance(node.targets[0], ast.Attribute):
771
+ target = node.targets[0]
772
+ var_name = target.attr
773
+
774
+ inner_call = None
775
+ if isinstance(node.value, ast.Call):
776
+ # self.foo = self.add_element(...)
777
+ if len(node.value.args) > 0:
778
+ arg0 = node.value.args[0]
779
+ if isinstance(arg0, ast.Call):
780
+ inner_call = arg0
781
+
782
+ if inner_call:
783
+ if isinstance(inner_call.func, ast.Name):
784
+ class_name = inner_call.func.id
785
+ elif isinstance(inner_call.func, ast.Attribute):
786
+ # Handle module.Element?
787
+ class_name = inner_call.func.attr
788
+ else:
789
+ class_name = "Unknown"
790
+
791
+ text_val = ""
792
+ test_id_val = ""
793
+ placeholder_val = ""
794
+ acc_name = ""
795
+
796
+ for kw in inner_call.keywords:
797
+ if isinstance(kw.value, ast.Constant):
798
+ val = kw.value.value
799
+ if kw.arg == "accessible_name":
800
+ acc_name = str(val)
801
+ elif kw.arg == "text":
802
+ text_val = str(val)
803
+ elif kw.arg == "test_id":
804
+ test_id_val = str(val)
805
+ elif kw.arg == "placeholder":
806
+ placeholder_val = str(val)
807
+
808
+ sig = self._leaf_signature(
809
+ class_name,
810
+ acc_name,
811
+ text_val,
812
+ test_id_val,
813
+ placeholder_val,
814
+ )
815
+ mappings.setdefault(sig, []).append(var_name)
816
+
817
+ return mappings
818
+
819
+ def _create_attribute_chain(self, path: str) -> ast.expr:
820
+ parts = path.split(".")
821
+ expr: ast.expr = ast.Name(id=parts[0], ctx=ast.Load())
822
+ for part in parts[1:]:
823
+ expr = ast.Attribute(value=expr, attr=part, ctx=ast.Load())
824
+ return expr
825
+
826
+ def _generate_code(
827
+ self,
828
+ elements_tree: List[Dict[str, Any]],
829
+ class_name: str,
830
+ page_path: Optional[str],
831
+ aria_snapshot_path: Optional[str],
832
+ screenshot_path: Optional[str],
833
+ overrides: Dict[str, Dict[str, str]],
834
+ leaf_overrides: Dict[str, List[str]], # Changed type to List[str]
835
+ ) -> str:
836
+ all_classes = {el["class"] for el in self._flatten_tree(elements_tree)}
837
+ imports_list = sorted({cls.__name__ for cls in all_classes} | {"Element"})
838
+ import_names = [ast.alias(name="Page")] + [
839
+ ast.alias(name=name) for name in imports_list
840
+ ]
841
+ imports_node = ast.ImportFrom(
842
+ module="persona_dsl.pages", names=import_names, level=0
843
+ )
844
+
845
+ class_def = ast.ClassDef(
846
+ name=class_name,
847
+ bases=[ast.Name(id="Page", ctx=ast.Load())],
848
+ keywords=[],
849
+ body=[],
850
+ decorator_list=[],
851
+ type_params=[],
852
+ )
853
+
854
+ init_body: List[ast.stmt] = [
855
+ ast.Expr(
856
+ value=ast.Call(
857
+ func=ast.Attribute(
858
+ value=ast.Call(
859
+ func=ast.Name(id="super", ctx=ast.Load()),
860
+ args=[],
861
+ keywords=[],
862
+ ),
863
+ attr="__init__",
864
+ ctx=ast.Load(),
865
+ ),
866
+ args=[],
867
+ keywords=[],
868
+ )
869
+ )
870
+ ]
871
+
872
+ if page_path:
873
+ init_body.append(
874
+ ast.Assign(
875
+ targets=[
876
+ ast.Attribute(
877
+ value=ast.Name(id="self", ctx=ast.Load()),
878
+ attr="expected_path",
879
+ ctx=ast.Store(),
880
+ )
881
+ ],
882
+ value=ast.Constant(value=page_path),
883
+ )
884
+ )
885
+
886
+ if aria_snapshot_path:
887
+ init_body.append(
888
+ ast.Assign(
889
+ targets=[
890
+ ast.Attribute(
891
+ value=ast.Name(id="self", ctx=ast.Load()),
892
+ attr="static_aria_snapshot_path",
893
+ ctx=ast.Store(),
894
+ )
895
+ ],
896
+ value=ast.Constant(value=aria_snapshot_path),
897
+ )
898
+ )
899
+
900
+ if screenshot_path:
901
+ init_body.append(
902
+ ast.Assign(
903
+ targets=[
904
+ ast.Attribute(
905
+ value=ast.Name(id="self", ctx=ast.Load()),
906
+ attr="static_screenshot_path",
907
+ ctx=ast.Store(),
908
+ )
909
+ ],
910
+ value=ast.Constant(value=screenshot_path),
911
+ )
912
+ )
913
+
914
+ element_nodes = self._build_init_body_ast(
915
+ elements_tree, "self", [], overrides, leaf_overrides, {}
916
+ )
917
+ init_body.extend(element_nodes)
918
+ if not element_nodes:
919
+ init_body.append(ast.Pass())
920
+
921
+ init_func = ast.FunctionDef(
922
+ name="__init__",
923
+ args=ast.arguments(
924
+ posonlyargs=[],
925
+ args=[ast.arg(arg="self")],
926
+ kwonlyargs=[],
927
+ kw_defaults=[],
928
+ defaults=[],
929
+ ),
930
+ body=init_body,
931
+ decorator_list=[],
932
+ returns=ast.Constant(value=None),
933
+ type_params=[],
934
+ )
935
+ class_def.body.append(init_func)
936
+ module = ast.Module(body=[imports_node, class_def], type_ignores=[])
937
+
938
+ code = ast.unparse(ast.fix_missing_locations(module))
939
+ try:
940
+ import black
941
+
942
+ code = black.format_str(code, mode=black.Mode())
943
+ except Exception:
944
+ pass
945
+ return code
946
+
947
+ def _flatten_tree(self, tree: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
948
+ flat_list = []
949
+ for item in tree:
950
+ flat_list.append(item)
951
+ if item["children"]:
952
+ flat_list.extend(self._flatten_tree(item["children"]))
953
+ return flat_list
954
+
955
+ def _compute_test_id_prefix(self) -> str:
956
+ """Находит общий префикс для test_id по токенам '-', '_'."""
957
+ if not self._seen_test_ids or len(self._seen_test_ids) < 2:
958
+ return ""
959
+
960
+ split_ids = []
961
+ for tid in self._seen_test_ids:
962
+ parts = re.split(r"[-_]+", tid)
963
+ split_ids.append(parts)
964
+
965
+ prefix_tokens: List[str] = []
966
+ for idx in range(min(len(parts) for parts in split_ids)):
967
+ token = split_ids[0][idx]
968
+ if all(len(parts) > idx and parts[idx] == token for parts in split_ids):
969
+ prefix_tokens.append(token)
970
+ else:
971
+ break
972
+
973
+ if not prefix_tokens:
974
+ return ""
975
+
976
+ # Общий префикс как строка без привязки к разделителю
977
+ return "-".join(prefix_tokens)
978
+
979
+ def _normalize_test_id_for_name(self, test_id: str) -> str:
980
+ """Удаляет общий префикс test_id при построении имени."""
981
+ if not test_id:
982
+ return ""
983
+
984
+ if self._test_id_prefix is None:
985
+ self._test_id_prefix = self._compute_test_id_prefix() or ""
986
+
987
+ prefix = self._test_id_prefix
988
+ if prefix and test_id.startswith(prefix):
989
+ cut = len(prefix)
990
+ if len(test_id) > cut and test_id[cut] in ("-", "_"):
991
+ cut += 1
992
+ test_id = test_id[cut:]
993
+
994
+ return test_id
995
+
996
+ def generate_from_aria_snapshot(
997
+ self,
998
+ snapshot: Any,
999
+ class_name: str = "GeneratedPage",
1000
+ page_path: Optional[str] = None,
1001
+ aria_snapshot_path: Optional[str] = None,
1002
+ screenshot_path: Optional[str] = None,
1003
+ leaf_overrides: Optional[Dict[str, List[str]]] = None,
1004
+ ) -> str:
1005
+ if not isinstance(snapshot, (dict, list)):
1006
+ snapshot = {}
1007
+
1008
+ # Сбрасываем состояние test_id для новой страницы
1009
+ self._seen_test_ids = []
1010
+ self._test_id_prefix = None
1011
+
1012
+ used_names: set[str] = set()
1013
+ elements_tree = self._parse_aria_node_recursive(
1014
+ snapshot, used_names, context={}
1015
+ )
1016
+ elements_tree = self._flatten_tree_recursive(elements_tree)
1017
+
1018
+ return self._generate_code(
1019
+ elements_tree,
1020
+ class_name,
1021
+ page_path,
1022
+ aria_snapshot_path,
1023
+ screenshot_path,
1024
+ {},
1025
+ leaf_overrides or {},
1026
+ )
1027
+
1028
+ def generate_or_update_from_aria_snapshot(
1029
+ self,
1030
+ snapshot: Any,
1031
+ class_name: str = "GeneratedPage",
1032
+ page_path: Optional[str] = None,
1033
+ aria_snapshot_path: Optional[str] = None,
1034
+ screenshot_path: Optional[str] = None,
1035
+ existing_code: Optional[str] = None,
1036
+ ) -> str:
1037
+ """
1038
+ Генерирует код страницы, пытаясь сохранить пользовательские изменения из existing_code.
1039
+ Сохраняются:
1040
+ 1. Docstrings класса.
1041
+ 2. Пользовательские методы (все, кроме __init__).
1042
+ 3. Декораторы класса.
1043
+ 4. Имена переменных полей (smart mapping).
1044
+ """
1045
+ if not existing_code:
1046
+ return self.generate_from_aria_snapshot(
1047
+ snapshot, class_name, page_path, aria_snapshot_path, screenshot_path
1048
+ )
1049
+
1050
+ # 1. Генерируем "чистую" новую версию для сравнения
1051
+ new_code = self.generate_from_aria_snapshot(
1052
+ snapshot, class_name, page_path, aria_snapshot_path, screenshot_path
1053
+ )
1054
+
1055
+ try:
1056
+ # 3. Находим существующий класс
1057
+ existing_tree = ast.parse(existing_code)
1058
+ print(f"DEBUG: Parsed tree body len: {len(existing_tree.body)}")
1059
+
1060
+ existing_class_node = next(
1061
+ (
1062
+ n
1063
+ for n in existing_tree.body
1064
+ if isinstance(n, ast.ClassDef) and n.name == class_name
1065
+ ),
1066
+ None,
1067
+ )
1068
+ print(
1069
+ f"DEBUG: Found class node: {existing_class_node} for name {class_name}"
1070
+ )
1071
+
1072
+ # 4. Extract field mappings and RE-GENERATE code
1073
+ leaf_overrides = {}
1074
+ if existing_class_node:
1075
+ leaf_overrides = self._extract_field_mappings(existing_class_node)
1076
+
1077
+ if leaf_overrides:
1078
+ new_code = self.generate_from_aria_snapshot(
1079
+ snapshot,
1080
+ class_name,
1081
+ page_path,
1082
+ aria_snapshot_path,
1083
+ screenshot_path,
1084
+ leaf_overrides,
1085
+ )
1086
+
1087
+ # 5. Парсим финальную версию (с правильными именами) для вставки методов
1088
+ new_tree = ast.parse(new_code)
1089
+ new_class_node = next(
1090
+ (
1091
+ node
1092
+ for node in new_tree.body
1093
+ if isinstance(node, ast.ClassDef) and node.name == class_name
1094
+ ),
1095
+ None,
1096
+ )
1097
+
1098
+ if not existing_class_node or not new_class_node:
1099
+ # Fallback: couldn't parse/find classes, return new code as is
1100
+ return new_code
1101
+
1102
+ # 6. Переносим Docstring (если он был у пользователя)
1103
+ existing_docstring = ast.get_docstring(existing_class_node)
1104
+ if existing_docstring:
1105
+ # Проверяем, есть ли уже докстринг в новом
1106
+ if (
1107
+ new_class_node.body
1108
+ and isinstance(new_class_node.body[0], ast.Expr)
1109
+ and isinstance(new_class_node.body[0].value, ast.Constant)
1110
+ and isinstance(new_class_node.body[0].value.value, str)
1111
+ ):
1112
+ new_class_node.body[0].value.value = existing_docstring
1113
+ else:
1114
+ new_class_node.body.insert(
1115
+ 0, ast.Expr(value=ast.Constant(value=existing_docstring))
1116
+ )
1117
+
1118
+ # 7. Переносим пользовательские методы
1119
+ for node in existing_class_node.body:
1120
+ if isinstance(node, ast.FunctionDef):
1121
+ if node.name == "__init__":
1122
+ continue
1123
+ new_class_node.body.append(node)
1124
+ # TODO: Other definitions (Assign constants, decorators etc) can be added here
1125
+
1126
+ # 8. Генерируем итоговый код
1127
+ merged_code = ast.unparse(ast.fix_missing_locations(new_tree))
1128
+
1129
+ try:
1130
+ import black
1131
+
1132
+ merged_code = black.format_str(merged_code, mode=black.Mode())
1133
+ except Exception:
1134
+ pass
1135
+
1136
+ return merged_code
1137
+
1138
+ except Exception:
1139
+ # Fallback to overwrite if merge fails
1140
+ return new_code