flow2skill 0.1.0__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.
- flow2skill/__init__.py +6 -0
- flow2skill/__main__.py +3 -0
- flow2skill/cli.py +212 -0
- flow2skill/exporter.py +359 -0
- flow2skill/model.py +398 -0
- flow2skill/parser.py +407 -0
- flow2skill/recorder.py +298 -0
- flow2skill/replay.py +153 -0
- flow2skill/server.py +335 -0
- flow2skill/ui/demo_form.html +36 -0
- flow2skill/ui/index.html +274 -0
- flow2skill-0.1.0.dist-info/METADATA +233 -0
- flow2skill-0.1.0.dist-info/RECORD +17 -0
- flow2skill-0.1.0.dist-info/WHEEL +5 -0
- flow2skill-0.1.0.dist-info/entry_points.txt +2 -0
- flow2skill-0.1.0.dist-info/licenses/LICENSE +21 -0
- flow2skill-0.1.0.dist-info/top_level.txt +1 -0
flow2skill/model.py
ADDED
|
@@ -0,0 +1,398 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import hashlib
|
|
4
|
+
import json
|
|
5
|
+
import re
|
|
6
|
+
from dataclasses import asdict, dataclass, field
|
|
7
|
+
from datetime import datetime, timezone
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Any
|
|
10
|
+
from urllib.parse import parse_qsl, quote, urlencode, urlsplit, urlunsplit
|
|
11
|
+
|
|
12
|
+
SCHEMA_VERSION = "1.0"
|
|
13
|
+
PLACEHOLDER_RE = re.compile(r"^\$\{(F2S_[A-Z0-9_]+)\}$")
|
|
14
|
+
PLACEHOLDER_SCAN_RE = re.compile(r"\$\{(F2S_[A-Z0-9_]+)\}")
|
|
15
|
+
ANY_PLACEHOLDER_SCAN_RE = re.compile(r"\$\{([A-Za-z_][A-Za-z0-9_]*)\}")
|
|
16
|
+
SENSITIVE_SELECTOR_RE = re.compile(
|
|
17
|
+
r"password|passcode|pin|otp|one.?time|token|secret|api.?key|credit|card|cvv|cvc|"
|
|
18
|
+
r"account|phone|mobile|e.?mail|username|login|address|ssn|aadhaar|pan.?card",
|
|
19
|
+
re.IGNORECASE,
|
|
20
|
+
)
|
|
21
|
+
SECRET_QUERY_RE = re.compile(
|
|
22
|
+
r"token|secret|key|password|passcode|code|auth|signature|session|jwt|bearer",
|
|
23
|
+
re.IGNORECASE,
|
|
24
|
+
)
|
|
25
|
+
RISKY_ACTION_RE = re.compile(
|
|
26
|
+
r"publish|post|send|submit|buy|purchase|checkout|pay|delete|remove|destroy|"
|
|
27
|
+
r"confirm|approve|reject|cancel subscription|place order|book now|apply now",
|
|
28
|
+
re.IGNORECASE,
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class FlowValidationError(ValueError):
|
|
33
|
+
"""Raised when a workflow cannot be represented or executed safely."""
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def utc_now() -> str:
|
|
37
|
+
return datetime.now(timezone.utc).replace(microsecond=0).isoformat()
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def slugify(value: str) -> str:
|
|
41
|
+
slug = re.sub(r"[^a-z0-9]+", "-", value.lower()).strip("-")
|
|
42
|
+
return slug[:64] or "recorded-flow"
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def url_variable(prefix: str, key: str) -> str:
|
|
46
|
+
cleaned = re.sub(r"[^A-Z0-9]+", "_", key.upper()).strip("_") or "SECRET"
|
|
47
|
+
return f"F2S_URL_{prefix}{cleaned[:24]}"
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def redact_url(url: str) -> str:
|
|
51
|
+
"""Redact URL credentials and secret-bearing query/fragment values."""
|
|
52
|
+
try:
|
|
53
|
+
parts = urlsplit(url)
|
|
54
|
+
except ValueError:
|
|
55
|
+
return url
|
|
56
|
+
netloc = parts.netloc
|
|
57
|
+
if "@" in netloc:
|
|
58
|
+
netloc = "${F2S_URL_USERINFO}@" + netloc.rsplit("@", 1)[1]
|
|
59
|
+
safe_query = []
|
|
60
|
+
for key, value in parse_qsl(parts.query, keep_blank_values=True):
|
|
61
|
+
safe_query.append(
|
|
62
|
+
(key, f"${{{url_variable('', key)}}}" if SECRET_QUERY_RE.search(key) else value)
|
|
63
|
+
)
|
|
64
|
+
query = urlencode(safe_query, quote_via=quote, safe="${}")
|
|
65
|
+
fragment = parts.fragment
|
|
66
|
+
if fragment:
|
|
67
|
+
fragment_pairs = parse_qsl(fragment, keep_blank_values=True)
|
|
68
|
+
if fragment_pairs and any(SECRET_QUERY_RE.search(key) for key, _ in fragment_pairs):
|
|
69
|
+
fragment = urlencode(
|
|
70
|
+
[
|
|
71
|
+
(
|
|
72
|
+
key,
|
|
73
|
+
f"${{{url_variable('FRAGMENT_', key)}}}"
|
|
74
|
+
if SECRET_QUERY_RE.search(key)
|
|
75
|
+
else value,
|
|
76
|
+
)
|
|
77
|
+
for key, value in fragment_pairs
|
|
78
|
+
],
|
|
79
|
+
quote_via=quote,
|
|
80
|
+
safe="${}",
|
|
81
|
+
)
|
|
82
|
+
elif SECRET_QUERY_RE.search(fragment):
|
|
83
|
+
fragment = "${F2S_URL_FRAGMENT}"
|
|
84
|
+
return urlunsplit((parts.scheme, netloc, parts.path, query, fragment))
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def placeholder_name(selector_text: str, index: int) -> str:
|
|
88
|
+
cleaned = re.sub(r"[^A-Z0-9]+", "_", selector_text.upper()).strip("_")
|
|
89
|
+
cleaned = re.sub(r"^(ENTER|YOUR|THE)_", "", cleaned)
|
|
90
|
+
if not cleaned or cleaned in {"INPUT", "FIELD", "TEXT"}:
|
|
91
|
+
cleaned = "SENSITIVE_VALUE"
|
|
92
|
+
return f"F2S_{cleaned[:32]}_{index}"
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
@dataclass(frozen=True)
|
|
96
|
+
class Selector:
|
|
97
|
+
engine: str
|
|
98
|
+
value: str | None = None
|
|
99
|
+
role: str | None = None
|
|
100
|
+
name: str | None = None
|
|
101
|
+
exact: bool | None = None
|
|
102
|
+
modifiers: tuple[str, ...] = ()
|
|
103
|
+
|
|
104
|
+
def label(self) -> str:
|
|
105
|
+
if self.engine == "page":
|
|
106
|
+
return "page"
|
|
107
|
+
if self.engine == "role":
|
|
108
|
+
suffix = f" named {self.name!r}" if self.name else ""
|
|
109
|
+
return f"{self.role or 'element'}{suffix}"
|
|
110
|
+
return f"{self.engine} {self.value!r}"
|
|
111
|
+
|
|
112
|
+
def searchable_text(self) -> str:
|
|
113
|
+
return " ".join(part for part in (self.engine, self.value, self.role, self.name) if part)
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
@dataclass(frozen=True)
|
|
117
|
+
class Action:
|
|
118
|
+
kind: str
|
|
119
|
+
selector: Selector = field(default_factory=lambda: Selector("page"))
|
|
120
|
+
value: Any = None
|
|
121
|
+
expected: Any = None
|
|
122
|
+
risk: str = "safe"
|
|
123
|
+
source_line: int | None = None
|
|
124
|
+
note: str | None = None
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
@dataclass
|
|
128
|
+
class Workflow:
|
|
129
|
+
name: str
|
|
130
|
+
intent: str
|
|
131
|
+
start_url: str
|
|
132
|
+
actions: list[Action]
|
|
133
|
+
success_criteria: str = "The recorded assertions pass."
|
|
134
|
+
variables: list[str] = field(default_factory=list)
|
|
135
|
+
warnings: list[str] = field(default_factory=list)
|
|
136
|
+
created_at: str = field(default_factory=utc_now)
|
|
137
|
+
schema_version: str = SCHEMA_VERSION
|
|
138
|
+
source: str = "playwright-codegen"
|
|
139
|
+
|
|
140
|
+
@property
|
|
141
|
+
def slug(self) -> str:
|
|
142
|
+
return slugify(self.name)
|
|
143
|
+
|
|
144
|
+
def validate(self) -> None:
|
|
145
|
+
for field_name, value in (
|
|
146
|
+
("name", self.name),
|
|
147
|
+
("intent", self.intent),
|
|
148
|
+
("start_url", self.start_url),
|
|
149
|
+
("success_criteria", self.success_criteria),
|
|
150
|
+
("created_at", self.created_at),
|
|
151
|
+
("schema_version", self.schema_version),
|
|
152
|
+
("source", self.source),
|
|
153
|
+
):
|
|
154
|
+
if not isinstance(value, str):
|
|
155
|
+
raise FlowValidationError(f"Workflow {field_name} must be a string")
|
|
156
|
+
if self.schema_version != SCHEMA_VERSION:
|
|
157
|
+
raise FlowValidationError(f"Unsupported schema version: {self.schema_version}")
|
|
158
|
+
if self.source != "playwright-codegen":
|
|
159
|
+
raise FlowValidationError("Workflow source must be `playwright-codegen`")
|
|
160
|
+
if not self.name.strip():
|
|
161
|
+
raise FlowValidationError("Workflow name is required")
|
|
162
|
+
if not self.actions:
|
|
163
|
+
raise FlowValidationError("Workflow must contain at least one action")
|
|
164
|
+
if self.start_url and not re.match(r"^(https?|file)://", self.start_url, re.IGNORECASE):
|
|
165
|
+
raise FlowValidationError("start_url must use http://, https://, or file://")
|
|
166
|
+
allowed = {
|
|
167
|
+
"goto",
|
|
168
|
+
"click",
|
|
169
|
+
"fill",
|
|
170
|
+
"press",
|
|
171
|
+
"select_option",
|
|
172
|
+
"check",
|
|
173
|
+
"uncheck",
|
|
174
|
+
"hover",
|
|
175
|
+
"assert_visible",
|
|
176
|
+
"assert_text",
|
|
177
|
+
"assert_exact_text",
|
|
178
|
+
"assert_url",
|
|
179
|
+
"assert_value",
|
|
180
|
+
}
|
|
181
|
+
selector_engines = {
|
|
182
|
+
"page",
|
|
183
|
+
"role",
|
|
184
|
+
"label",
|
|
185
|
+
"placeholder",
|
|
186
|
+
"text",
|
|
187
|
+
"test_id",
|
|
188
|
+
"title",
|
|
189
|
+
"alt_text",
|
|
190
|
+
"css",
|
|
191
|
+
}
|
|
192
|
+
placeholders: set[str] = set()
|
|
193
|
+
for action in self.actions:
|
|
194
|
+
if not isinstance(action, Action):
|
|
195
|
+
raise FlowValidationError("Workflow actions must contain Action objects")
|
|
196
|
+
if not isinstance(action.kind, str):
|
|
197
|
+
raise FlowValidationError("Action kind must be a string")
|
|
198
|
+
if not isinstance(action.selector, Selector):
|
|
199
|
+
raise FlowValidationError("Action selector must be a Selector")
|
|
200
|
+
if action.kind not in allowed:
|
|
201
|
+
raise FlowValidationError(f"Unsupported action kind: {action.kind}")
|
|
202
|
+
if not isinstance(action.risk, str) or action.risk not in {
|
|
203
|
+
"safe",
|
|
204
|
+
"review",
|
|
205
|
+
"approval",
|
|
206
|
+
}:
|
|
207
|
+
raise FlowValidationError(f"Invalid risk classification: {action.risk}")
|
|
208
|
+
if not isinstance(action.selector.engine, str):
|
|
209
|
+
raise FlowValidationError("Selector engine must be a string")
|
|
210
|
+
if action.selector.engine not in selector_engines:
|
|
211
|
+
raise FlowValidationError(f"Unsupported selector engine: {action.selector.engine}")
|
|
212
|
+
for field_name, value in (
|
|
213
|
+
("value", action.selector.value),
|
|
214
|
+
("role", action.selector.role),
|
|
215
|
+
("name", action.selector.name),
|
|
216
|
+
):
|
|
217
|
+
if value is not None and not isinstance(value, str):
|
|
218
|
+
raise FlowValidationError(f"Selector {field_name} must be a string or null")
|
|
219
|
+
if action.selector.exact is not None and not isinstance(action.selector.exact, bool):
|
|
220
|
+
raise FlowValidationError("Selector exact must be a boolean or null")
|
|
221
|
+
if not isinstance(action.selector.modifiers, tuple) or not all(
|
|
222
|
+
isinstance(item, str) for item in action.selector.modifiers
|
|
223
|
+
):
|
|
224
|
+
raise FlowValidationError("Selector modifiers must be a tuple of strings")
|
|
225
|
+
for modifier in action.selector.modifiers:
|
|
226
|
+
if modifier != "first" and not re.fullmatch(r"nth:[0-9]+", modifier):
|
|
227
|
+
raise FlowValidationError(f"Unsupported selector modifier: {modifier}")
|
|
228
|
+
expected_risk = classify_risk(action.kind, action.selector, action.value)
|
|
229
|
+
if action.risk != expected_risk:
|
|
230
|
+
raise FlowValidationError(
|
|
231
|
+
f"Risk classification mismatch for {action.kind}: "
|
|
232
|
+
f"expected {expected_risk}, got {action.risk}"
|
|
233
|
+
)
|
|
234
|
+
if action.kind == "goto" and (
|
|
235
|
+
not isinstance(action.value, str)
|
|
236
|
+
or not re.match(r"^(https?|file)://", action.value, re.IGNORECASE)
|
|
237
|
+
):
|
|
238
|
+
raise FlowValidationError("goto actions require a literal http(s) or file URL")
|
|
239
|
+
if action.source_line is not None and not isinstance(action.source_line, int):
|
|
240
|
+
raise FlowValidationError("Action source_line must be an integer or null")
|
|
241
|
+
if action.note is not None and not isinstance(action.note, str):
|
|
242
|
+
raise FlowValidationError("Action note must be a string or null")
|
|
243
|
+
for value in (
|
|
244
|
+
action.value,
|
|
245
|
+
action.expected,
|
|
246
|
+
action.selector.value,
|
|
247
|
+
action.selector.name,
|
|
248
|
+
):
|
|
249
|
+
if isinstance(value, str):
|
|
250
|
+
foreign = [
|
|
251
|
+
name
|
|
252
|
+
for name in ANY_PLACEHOLDER_SCAN_RE.findall(value)
|
|
253
|
+
if not re.fullmatch(r"F2S_[A-Z0-9_]+", name)
|
|
254
|
+
]
|
|
255
|
+
if foreign:
|
|
256
|
+
raise FlowValidationError(
|
|
257
|
+
"Only compiler-owned F2S_* placeholders are allowed"
|
|
258
|
+
)
|
|
259
|
+
placeholders.update(PLACEHOLDER_SCAN_RE.findall(value))
|
|
260
|
+
if not all(isinstance(item, str) for item in self.variables):
|
|
261
|
+
raise FlowValidationError("Workflow variables must be strings")
|
|
262
|
+
if not all(re.fullmatch(r"F2S_[A-Z0-9_]+", item) for item in self.variables):
|
|
263
|
+
raise FlowValidationError("Workflow variables must use the F2S_* namespace")
|
|
264
|
+
if len(set(self.variables)) != len(self.variables):
|
|
265
|
+
raise FlowValidationError("Workflow variables must not contain duplicates")
|
|
266
|
+
if not all(isinstance(item, str) for item in self.warnings):
|
|
267
|
+
raise FlowValidationError("Workflow warnings must be strings")
|
|
268
|
+
if set(self.variables) != placeholders:
|
|
269
|
+
raise FlowValidationError(
|
|
270
|
+
"Workflow variables must exactly match placeholders used by actions"
|
|
271
|
+
)
|
|
272
|
+
first_goto = next((a.value for a in self.actions if a.kind == "goto"), "")
|
|
273
|
+
if first_goto and self.start_url != first_goto:
|
|
274
|
+
raise FlowValidationError("start_url must match the first recorded goto action")
|
|
275
|
+
if not any(action.kind.startswith("assert_") for action in self.actions):
|
|
276
|
+
raise FlowValidationError("Workflow requires at least one executable assertion")
|
|
277
|
+
|
|
278
|
+
def to_dict(self) -> dict[str, Any]:
|
|
279
|
+
self.validate()
|
|
280
|
+
payload = asdict(self)
|
|
281
|
+
payload["slug"] = self.slug
|
|
282
|
+
payload["fingerprint"] = self.fingerprint(include_fingerprint=False)
|
|
283
|
+
return payload
|
|
284
|
+
|
|
285
|
+
@classmethod
|
|
286
|
+
def from_dict(cls, payload: dict[str, Any]) -> Workflow:
|
|
287
|
+
if not isinstance(payload, dict):
|
|
288
|
+
raise FlowValidationError("Workflow manifest must be a JSON object")
|
|
289
|
+
known = {
|
|
290
|
+
"name",
|
|
291
|
+
"intent",
|
|
292
|
+
"start_url",
|
|
293
|
+
"actions",
|
|
294
|
+
"success_criteria",
|
|
295
|
+
"variables",
|
|
296
|
+
"warnings",
|
|
297
|
+
"created_at",
|
|
298
|
+
"schema_version",
|
|
299
|
+
"source",
|
|
300
|
+
"slug",
|
|
301
|
+
"fingerprint",
|
|
302
|
+
}
|
|
303
|
+
extras = set(payload) - known
|
|
304
|
+
if extras:
|
|
305
|
+
raise FlowValidationError(f"Unknown workflow fields: {sorted(extras)}")
|
|
306
|
+
if not isinstance(payload.get("fingerprint"), str):
|
|
307
|
+
raise FlowValidationError("Workflow fingerprint is required")
|
|
308
|
+
for field_name in (
|
|
309
|
+
"name",
|
|
310
|
+
"intent",
|
|
311
|
+
"start_url",
|
|
312
|
+
"success_criteria",
|
|
313
|
+
"created_at",
|
|
314
|
+
"schema_version",
|
|
315
|
+
"source",
|
|
316
|
+
):
|
|
317
|
+
if field_name in payload and not isinstance(payload[field_name], str):
|
|
318
|
+
raise FlowValidationError(f"Workflow {field_name} must be a string")
|
|
319
|
+
for field_name in ("variables", "warnings"):
|
|
320
|
+
if field_name in payload and not isinstance(payload[field_name], list):
|
|
321
|
+
raise FlowValidationError(f"Workflow {field_name} must be a list")
|
|
322
|
+
actions = []
|
|
323
|
+
action_fields = {"kind", "selector", "value", "expected", "risk", "source_line", "note"}
|
|
324
|
+
selector_fields = {"engine", "value", "role", "name", "exact", "modifiers"}
|
|
325
|
+
raw_actions = payload.get("actions", [])
|
|
326
|
+
if not isinstance(raw_actions, list):
|
|
327
|
+
raise FlowValidationError("Workflow actions must be a list")
|
|
328
|
+
try:
|
|
329
|
+
for raw in raw_actions:
|
|
330
|
+
if not isinstance(raw, dict):
|
|
331
|
+
raise FlowValidationError("Every workflow action must be an object")
|
|
332
|
+
action_extras = set(raw) - action_fields
|
|
333
|
+
if action_extras:
|
|
334
|
+
raise FlowValidationError(f"Unknown action fields: {sorted(action_extras)}")
|
|
335
|
+
selector_raw = raw.get("selector") or {"engine": "page"}
|
|
336
|
+
if not isinstance(selector_raw, dict):
|
|
337
|
+
raise FlowValidationError("Action selector must be an object")
|
|
338
|
+
selector_extras = set(selector_raw) - selector_fields
|
|
339
|
+
if selector_extras:
|
|
340
|
+
raise FlowValidationError(f"Unknown selector fields: {sorted(selector_extras)}")
|
|
341
|
+
selector_raw = dict(selector_raw)
|
|
342
|
+
selector_raw["modifiers"] = tuple(selector_raw.get("modifiers") or ())
|
|
343
|
+
actions.append(Action(**{**raw, "selector": Selector(**selector_raw)}))
|
|
344
|
+
except TypeError as exc:
|
|
345
|
+
raise FlowValidationError(f"Malformed workflow action: {exc}") from exc
|
|
346
|
+
workflow = cls(
|
|
347
|
+
name=payload.get("name", ""),
|
|
348
|
+
intent=payload.get("intent", ""),
|
|
349
|
+
start_url=payload.get("start_url", ""),
|
|
350
|
+
actions=actions,
|
|
351
|
+
success_criteria=payload.get("success_criteria", "The recorded assertions pass."),
|
|
352
|
+
variables=list(payload.get("variables") or []),
|
|
353
|
+
warnings=list(payload.get("warnings") or []),
|
|
354
|
+
created_at=payload.get("created_at") or utc_now(),
|
|
355
|
+
schema_version=payload.get("schema_version", SCHEMA_VERSION),
|
|
356
|
+
source=payload.get("source", "playwright-codegen"),
|
|
357
|
+
)
|
|
358
|
+
workflow.validate()
|
|
359
|
+
expected = payload["fingerprint"]
|
|
360
|
+
if expected != workflow.fingerprint(include_fingerprint=False):
|
|
361
|
+
raise FlowValidationError("Workflow fingerprint does not match its contents")
|
|
362
|
+
if payload.get("slug") not in {None, workflow.slug}:
|
|
363
|
+
raise FlowValidationError("Workflow slug does not match its name")
|
|
364
|
+
return workflow
|
|
365
|
+
|
|
366
|
+
@classmethod
|
|
367
|
+
def read(cls, path: str | Path) -> Workflow:
|
|
368
|
+
return cls.from_dict(json.loads(Path(path).read_text(encoding="utf-8")))
|
|
369
|
+
|
|
370
|
+
def fingerprint(self, include_fingerprint: bool = False) -> str:
|
|
371
|
+
payload = asdict(self)
|
|
372
|
+
if include_fingerprint:
|
|
373
|
+
payload["fingerprint"] = ""
|
|
374
|
+
encoded = json.dumps(payload, sort_keys=True, separators=(",", ":"), ensure_ascii=False)
|
|
375
|
+
return hashlib.sha256(encoded.encode("utf-8")).hexdigest()
|
|
376
|
+
|
|
377
|
+
|
|
378
|
+
def classify_risk(kind: str, selector: Selector, value: Any = None) -> str:
|
|
379
|
+
if kind == "goto" and isinstance(value, str) and PLACEHOLDER_SCAN_RE.search(value):
|
|
380
|
+
return "approval"
|
|
381
|
+
mutating = {"click", "press", "fill", "select_option", "check", "uncheck"}
|
|
382
|
+
if kind not in mutating:
|
|
383
|
+
return "safe"
|
|
384
|
+
text = f"{selector.searchable_text()} {value or ''}"
|
|
385
|
+
if kind in {"click", "press"} and RISKY_ACTION_RE.search(text):
|
|
386
|
+
return "approval"
|
|
387
|
+
return "review"
|
|
388
|
+
|
|
389
|
+
|
|
390
|
+
def sanitize_fill_value(
|
|
391
|
+
selector: Selector, value: str, index: int, redact_all_inputs: bool = True
|
|
392
|
+
) -> tuple[str, str | None]:
|
|
393
|
+
if PLACEHOLDER_RE.match(value):
|
|
394
|
+
return value, PLACEHOLDER_RE.match(value).group(1) # type: ignore[union-attr]
|
|
395
|
+
if redact_all_inputs or SENSITIVE_SELECTOR_RE.search(selector.searchable_text()):
|
|
396
|
+
variable = placeholder_name(selector.searchable_text(), index)
|
|
397
|
+
return f"${{{variable}}}", variable
|
|
398
|
+
return value, None
|