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/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