qplay 0.1__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.
qplay/__init__.py ADDED
@@ -0,0 +1,84 @@
1
+ from typing import Any
2
+
3
+ from .kernel import (
4
+ GlobalState,
5
+ FilePathRealer,
6
+ Kernel,
7
+ K
8
+ )
9
+
10
+ from .utils import (
11
+ DownloadWatcher,
12
+ )
13
+
14
+ from .identylib import (
15
+ IdentityLib,
16
+ IdentityRecord,
17
+ ID,
18
+ Identity,
19
+ Register,
20
+ Resolve,
21
+ Batch,
22
+ MACRO_PREFIX,
23
+ ID_PREFIX,
24
+ )
25
+
26
+ from .play import (
27
+ Element,
28
+ WebPage,
29
+ initplay,
30
+ )
31
+
32
+ from .ptable import (
33
+ WebTable,
34
+ )
35
+
36
+ from .persist import (
37
+ QWeb,
38
+ )
39
+
40
+
41
+ # Direct component access (from initialized kernel)
42
+ G = K.G
43
+ F = K.F
44
+ H = K.H
45
+ T = K.T
46
+
47
+
48
+
49
+ __all__ = [
50
+ # Core components
51
+ 'G', 'F', 'H', 'T', 'K',
52
+
53
+ # Kernel classes
54
+ 'Kernel',
55
+ 'GlobalState',
56
+ 'FilePathRealer',
57
+
58
+ # Utils
59
+ 'DownloadWatcher',
60
+
61
+ # Identity
62
+ 'IdentityLib',
63
+ 'IdentityRecord',
64
+ 'ID',
65
+ 'Identity',
66
+ 'Register',
67
+ 'Resolve',
68
+ 'Batch',
69
+ 'MACRO_PREFIX',
70
+ 'ID_PREFIX',
71
+
72
+ # Playwright browser automation
73
+ 'Element',
74
+ 'WebPage',
75
+ 'initplay',
76
+
77
+ # Persistent Qt web container
78
+ 'QWeb',
79
+
80
+ # Table wrapper
81
+ 'WebTable',
82
+
83
+ ]
84
+
qplay/identylib.py ADDED
@@ -0,0 +1,492 @@
1
+ """
2
+ qplay identylib module - Identity library management
3
+
4
+ Manages XPath-based element identifiers with macro support.
5
+ Provides centralized storage and lookup for element identities.
6
+
7
+ Identity format:
8
+ - xpath: "//div[@id='main']"
9
+ - @id: "@login" -> resolves to xpath "//*[@id='login']"
10
+ - $macro: "$login_btn" -> resolves to stored xpath
11
+ - inline def: "$btn: //button" -> registers and returns xpath
12
+ - parameterized: "$btn('submit')" -> resolves with format substitution
13
+ """
14
+
15
+ import os
16
+ import re
17
+ import json
18
+ import threading
19
+ from typing import Self
20
+ from dataclasses import dataclass
21
+
22
+ from .stdio import Info, Warn, Error
23
+
24
+
25
+ MACRO_PREFIX = "$"
26
+ ID_PREFIX = "@"
27
+ IDENTITY_FILE = "identities.json"
28
+
29
+ # Parameterized macro pattern: $Name('arg1', "arg2", ...)
30
+ _PARAM_PATTERN = re.compile(r"\$([^\(]+)\(([^)]+)\)")
31
+ # String literal pattern for parameter extraction
32
+ _STRING_PATTERN = re.compile(r"['\"][^'\"]*['\"]")
33
+
34
+
35
+ @dataclass
36
+ class IdentityRecord:
37
+ """Identity record with metadata."""
38
+ xpath: str
39
+ desc: str = ""
40
+ tag: str = ""
41
+
42
+
43
+ class IdentityLib:
44
+ """
45
+ Identity library for managing XPath-based element identifiers.
46
+
47
+ Supports macro definitions for shorthand access.
48
+ All identities are stored as XPath expressions.
49
+
50
+ Usage:
51
+ lib = IdentityLib()
52
+ lib.register("$login_btn", "//button[@id='login']", "Login button")
53
+
54
+ # Resolve different identity types
55
+ xpath = lib.resolve("$login_btn") # -> "//button[@id='login']"
56
+ xpath = lib.resolve("@login") # -> "//*[@id='login']"
57
+ xpath = lib.resolve("//div") # -> returns as-is
58
+ xpath = lib.resolve("$btn: //button") # registers and returns
59
+ xpath = lib.resolve("$btn('ok')") # -> "//button[text()='ok']"
60
+ """
61
+
62
+ _instance: "IdentityLib | None" = None
63
+ _initialized: bool = False
64
+ _lock: threading.Lock = threading.Lock()
65
+
66
+ def __new__(cls) -> "IdentityLib":
67
+ if cls._instance is None:
68
+ with cls._lock:
69
+ if cls._instance is None:
70
+ cls._instance = super().__new__(cls)
71
+ return cls._instance
72
+
73
+ def __init__(self):
74
+ with IdentityLib._lock:
75
+ if IdentityLib._initialized:
76
+ return
77
+ IdentityLib._initialized = True
78
+
79
+ self._macros: dict[str, IdentityRecord] = {}
80
+ self._storage_path: str | None = None
81
+
82
+ def init(self, storage_dir: str | None = None) -> Self:
83
+ """
84
+ Initialize library with storage directory.
85
+
86
+ Args:
87
+ storage_dir: Directory for persistence, None for memory-only
88
+
89
+ Returns:
90
+ self for chaining
91
+ """
92
+ if storage_dir is not None:
93
+ self._storage_path = os.path.join(storage_dir, IDENTITY_FILE)
94
+ self._load()
95
+ return self
96
+
97
+ def _load(self) -> None:
98
+ """Load identities from storage."""
99
+ if self._storage_path is None or not os.path.exists(self._storage_path):
100
+ return
101
+
102
+ try:
103
+ with open(self._storage_path, "r", encoding="utf-8") as f:
104
+ data = json.load(f)
105
+ for key, value in data.items():
106
+ if isinstance(value, str):
107
+ self._macros[key] = IdentityRecord(xpath=value)
108
+ elif isinstance(value, dict):
109
+ self._macros[key] = IdentityRecord(
110
+ xpath=value.get("xpath", ""),
111
+ desc=value.get("desc", ""),
112
+ tag=value.get("tag", "")
113
+ )
114
+ Info("idlib", "loaded", f"{len(self._macros)} macros")
115
+ except Exception as e:
116
+ Warn("idlib", "load failed", str(e))
117
+
118
+ def save(self) -> Self:
119
+ """
120
+ Save identities to storage.
121
+
122
+ Returns:
123
+ self for chaining
124
+ """
125
+ if self._storage_path is None:
126
+ Warn("idlib", "no storage path set")
127
+ return self
128
+
129
+ try:
130
+ data: dict[str, dict] = {}
131
+ for key, record in self._macros.items():
132
+ data[key] = {
133
+ "xpath": record.xpath,
134
+ "desc": record.desc,
135
+ "tag": record.tag
136
+ }
137
+ _dir = os.path.dirname(self._storage_path)
138
+ os.makedirs(_dir, exist_ok=True)
139
+ # Atomic write: temp file in same dir, then os.replace (atomic on
140
+ # same filesystem). Prevents a crash mid-write from leaving a
141
+ # truncated/corrupt identities store.
142
+ import tempfile
143
+ _fd, _tmp = tempfile.mkstemp(dir=_dir, suffix=".tmp")
144
+ try:
145
+ with os.fdopen(_fd, "w", encoding="utf-8") as f:
146
+ json.dump(data, f, ensure_ascii=False, indent=2)
147
+ os.replace(_tmp, self._storage_path)
148
+ except Exception:
149
+ try:
150
+ os.remove(_tmp)
151
+ except OSError:
152
+ pass
153
+ raise
154
+ Info("idlib", "saved", f"{len(self._macros)} macros")
155
+ except Exception as e:
156
+ Error("idlib", "save failed", str(e))
157
+ return self
158
+
159
+ def register(self, macro: str, xpath: str, desc: str = "", tag: str = "") -> Self:
160
+ """
161
+ Register a macro identity.
162
+
163
+ Args:
164
+ macro: Macro name (auto-prefixed with $ if missing)
165
+ xpath: XPath expression
166
+ desc: Description
167
+ tag: Category tag
168
+
169
+ Returns:
170
+ self for chaining
171
+ """
172
+ if not macro.startswith(MACRO_PREFIX):
173
+ macro = MACRO_PREFIX + macro
174
+
175
+ self._macros[macro] = IdentityRecord(
176
+ xpath=xpath,
177
+ desc=desc,
178
+ tag=tag
179
+ )
180
+ Info("idlib", "registered", macro)
181
+ return self
182
+
183
+ def unregister(self, macro: str) -> bool:
184
+ """
185
+ Unregister a macro.
186
+
187
+ Args:
188
+ macro: Macro name to remove
189
+
190
+ Returns:
191
+ True if removed, False if not found
192
+ """
193
+ if not macro.startswith(MACRO_PREFIX):
194
+ macro = MACRO_PREFIX + macro
195
+
196
+ if macro in self._macros:
197
+ del self._macros[macro]
198
+ Info("idlib", "unregistered", macro)
199
+ return True
200
+ return False
201
+
202
+ def resolve(self, identity: str) -> str:
203
+ """
204
+ Resolve identity to XPath.
205
+
206
+ Supports:
207
+ - Plain xpath: "//div[@id='main']" -> returns as-is
208
+ - ID prefix: "@login" -> "//*[@id='login']"
209
+ - Macro: "$login_btn" -> resolves from library
210
+ - Inline register: "$btn: //button" -> registers and returns
211
+ - Parameterized: "$btn('ok')" -> applies format
212
+
213
+ Args:
214
+ identity: identity string
215
+
216
+ Returns:
217
+ XPath expression
218
+
219
+ Raises:
220
+ KeyError: If macro not found
221
+ ValueError: If parameterized macro format is invalid
222
+ """
223
+ _ident = identity.strip()
224
+
225
+ if not _ident:
226
+ return _ident
227
+
228
+ # Case 1: ID prefix @id -> xpath "//*[@id='id']"
229
+ if _ident.startswith(ID_PREFIX):
230
+ _id_value = _ident[1:].strip()
231
+ return f"//*[@id='{_id_value}']"
232
+
233
+ # Case 2: Macro prefix $
234
+ if _ident.startswith(MACRO_PREFIX):
235
+ return self._resolveMacro(_ident)
236
+
237
+ # Case 3: Plain xpath or other - return as-is
238
+ return _ident
239
+
240
+ def _resolveMacro(self, identity: str) -> str:
241
+ """
242
+ Resolve macro expression.
243
+
244
+ Handles:
245
+ - $macro -> lookup in library
246
+ - $macro: xpath -> inline register
247
+ - $macro('arg') -> parameterized lookup
248
+ """
249
+ # Check for inline definition: $macro: xpath
250
+ _colon_idx = identity.find(":")
251
+ if _colon_idx != -1:
252
+ # Make sure it's not part of a parameterized call like $macro('arg'): xpath
253
+ _paren_idx = identity.find("(")
254
+ if _paren_idx == -1 or _colon_idx < _paren_idx:
255
+ _key = re.sub(r"\s", "", identity[:_colon_idx])
256
+ if "(" in _key or ")" in _key:
257
+ raise KeyError(f"[IdentityError]: Cannot use '()' in def. Like: '$xxx():YYYY'.")
258
+ _value = identity[_colon_idx + 1:].strip()
259
+ self._library[_key] = IdentityRecord(xpath=_value)
260
+ Info("idlib", "inline registered", _key)
261
+ return _value
262
+
263
+ # Check for parameterized: $macro('arg1', "arg2")
264
+ _match = _PARAM_PATTERN.match(identity)
265
+ if _match:
266
+ _key = re.sub(r"\s", "", _match.group(1))
267
+ if not _key.startswith(MACRO_PREFIX):
268
+ _key = MACRO_PREFIX + _key
269
+ _params_str = _match.group(2)
270
+ # Extract string literals
271
+ _params = re.findall(_STRING_PATTERN, _params_str)
272
+ # Remove quotes from params
273
+ _params = [p[1:-1] for p in _params]
274
+
275
+ if _key not in self._macros:
276
+ raise KeyError(f"[IdentityMissing]: Do not have identity:{_key} in library.")
277
+
278
+ _xpath = self._macros[_key].xpath
279
+ if _params:
280
+ try:
281
+ _xpath = _xpath.format(*_params)
282
+ except Exception as e:
283
+ raise ValueError(f"Format Failed: {e}. \n\traw:'{_xpath}'\n\treplaces:{_params}")
284
+ return _xpath
285
+
286
+ # Simple macro lookup
287
+ if identity not in self._macros:
288
+ raise KeyError(f"Macro '{identity}' not found")
289
+
290
+ return self._macros[identity].xpath
291
+
292
+ def get(self, macro: str) -> IdentityRecord | None:
293
+ """
294
+ Get identity record by macro.
295
+
296
+ Args:
297
+ macro: Macro name
298
+
299
+ Returns:
300
+ IdentityRecord or None
301
+ """
302
+ if not macro.startswith(MACRO_PREFIX):
303
+ macro = MACRO_PREFIX + macro
304
+ return self._macros.get(macro)
305
+
306
+ def test(self, identity: str) -> bool:
307
+ """
308
+ Test if identity is valid (macro exists or valid xpath).
309
+
310
+ Args:
311
+ identity: xpath or $macro to test
312
+
313
+ Returns:
314
+ True if valid
315
+ """
316
+ try:
317
+ self.resolve(identity)
318
+ return True
319
+ except (KeyError, ValueError):
320
+ return False
321
+
322
+ def list(self, tag: str | None = None) -> list[str]:
323
+ """
324
+ List all registered macros.
325
+
326
+ Args:
327
+ tag: Filter by tag, None for all
328
+
329
+ Returns:
330
+ List of macro names
331
+ """
332
+ if tag is None:
333
+ return list(self._macros.keys())
334
+ return [k for k, v in self._macros.items() if v.tag == tag]
335
+
336
+ def clear(self) -> Self:
337
+ """
338
+ Clear all macros.
339
+
340
+ Returns:
341
+ self for chaining
342
+ """
343
+ self._macros.clear()
344
+ Info("idlib", "cleared")
345
+ return self
346
+
347
+ def batch(self, identities: dict[str, str], tag: str = "") -> Self:
348
+ """
349
+ Batch register identities.
350
+
351
+ Args:
352
+ identities: Dict of macro -> xpath
353
+ tag: Default tag for all
354
+
355
+ Returns:
356
+ self for chaining
357
+ """
358
+ for macro, xpath in identities.items():
359
+ self.register(macro, xpath, tag=tag)
360
+ return self
361
+
362
+ def to_dict(self) -> dict[str, dict]:
363
+ """Export all identities to dictionary."""
364
+ return {
365
+ k: {"xpath": v.xpath, "desc": v.desc, "tag": v.tag}
366
+ for k, v in self._macros.items()
367
+ }
368
+
369
+ def from_dict(self, data: dict[str, dict | str]) -> Self:
370
+ """
371
+ Import identities from dictionary.
372
+
373
+ Args:
374
+ data: Dict of macro -> record or xpath string
375
+
376
+ Returns:
377
+ self for chaining
378
+ """
379
+ for key, value in data.items():
380
+ if isinstance(value, str):
381
+ self.register(key, value)
382
+ else:
383
+ self.register(
384
+ key,
385
+ value.get("xpath", ""),
386
+ value.get("desc", ""),
387
+ value.get("tag", "")
388
+ )
389
+ return self
390
+
391
+ @property
392
+ def count(self) -> int:
393
+ """Number of registered macros."""
394
+ return len(self._macros)
395
+
396
+ @property
397
+ def storage(self) -> str | None:
398
+ """Current storage path."""
399
+ return self._storage_path
400
+
401
+ @property
402
+ def _library(self) -> dict[str, IdentityRecord]:
403
+ """Direct access to macro library (for compatibility with inline register)."""
404
+ return self._macros
405
+
406
+
407
+ # Global identity library instance
408
+ ID = IdentityLib()
409
+
410
+
411
+ def Identity(macro: str, xpath: str = "", desc: str = "") -> str:
412
+ """
413
+ Register or resolve identity.
414
+
415
+ If xpath provided: register macro and return xpath
416
+ If xpath empty: resolve macro to xpath
417
+
418
+ Args:
419
+ macro: Macro name (auto-prefixed with $)
420
+ xpath: XPath expression (optional)
421
+ desc: Description (optional)
422
+
423
+ Returns:
424
+ XPath expression
425
+ """
426
+ if xpath:
427
+ ID.register(macro, xpath, desc)
428
+ return xpath
429
+ return ID.resolve(macro)
430
+
431
+
432
+ def Register(macro: str, xpath: str, desc: str = "", tag: str = "") -> IdentityLib:
433
+ """
434
+ Register identity macro.
435
+
436
+ Args:
437
+ macro: Macro name (auto-prefixed with $)
438
+ xpath: XPath expression
439
+ desc: Description
440
+ tag: Category tag
441
+
442
+ Returns:
443
+ IdentityLib instance for chaining
444
+ """
445
+ return ID.register(macro, xpath, desc, tag)
446
+
447
+
448
+ def Resolve(identity: str) -> str:
449
+ """
450
+ Resolve identity to XPath.
451
+
452
+ Supports:
453
+ - Plain xpath: "//div[@id='main']"
454
+ - ID prefix: "@login" -> "//*[@id='login']"
455
+ - Macro: "$login_btn" -> resolves from library
456
+ - Inline: "$btn: //button" -> registers and returns
457
+ - Parameterized: "$btn('ok')" -> applies format
458
+
459
+ Args:
460
+ identity: xpath, @id, $macro, etc.
461
+
462
+ Returns:
463
+ XPath expression
464
+ """
465
+ return ID.resolve(identity)
466
+
467
+
468
+ def Batch(identities: dict[str, str], tag: str = "") -> IdentityLib:
469
+ """
470
+ Batch register identities.
471
+
472
+ Args:
473
+ identities: Dict of macro -> xpath
474
+ tag: Default tag
475
+
476
+ Returns:
477
+ IdentityLib instance
478
+ """
479
+ return ID.batch(identities, tag)
480
+
481
+
482
+ __all__ = [
483
+ "IdentityLib",
484
+ "IdentityRecord",
485
+ "ID",
486
+ "Identity",
487
+ "Register",
488
+ "Resolve",
489
+ "Batch",
490
+ "MACRO_PREFIX",
491
+ "ID_PREFIX",
492
+ ]