msdevflow 0.6.0

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.
@@ -0,0 +1,574 @@
1
+ #!/usr/bin/env python3
2
+ from __future__ import annotations
3
+
4
+ import argparse
5
+ import importlib
6
+ import json
7
+ import os
8
+ import re
9
+ import shutil
10
+ import stat
11
+ import subprocess
12
+ import sys
13
+ import time
14
+ import urllib.error
15
+ import urllib.parse
16
+ import urllib.request
17
+ from pathlib import Path
18
+ from typing import Any, Callable
19
+
20
+ BASE_URL = "https://www.openlibing.com"
21
+ FAILURE_STATUSES = {"FAILED", "FAILURE", "ERROR"}
22
+
23
+ if os.name == "nt":
24
+ sys.stdout.reconfigure(encoding="utf-8")
25
+ sys.stderr.reconfigure(encoding="utf-8")
26
+ ERROR_PATTERN = re.compile(
27
+ r"\[\s*FAILED\s*\]|Finished:\s*FAILURE|Expected:|Actual:|Assertion|"
28
+ r"Segmentation fault|script returned exit code|:\d+(?::\d+)?:\s*(?:fatal\s+)?error:",
29
+ re.IGNORECASE,
30
+ )
31
+
32
+
33
+ class OpenLibingAuthRequired(RuntimeError):
34
+ pass
35
+
36
+
37
+ class OpenLibingBrowserRequired(OpenLibingAuthRequired):
38
+ def __init__(self, message: str, oauth_url: str) -> None:
39
+ super().__init__(message)
40
+ self.oauth_url = oauth_url
41
+
42
+
43
+ def oauth_authorization_url(base_url: str = BASE_URL) -> str:
44
+ return f"{base_url.rstrip('/')}/gateway/oauth2/authorization/gitcode"
45
+
46
+
47
+ def find_browser() -> str | None:
48
+ candidates = [
49
+ os.getenv("OPENLIBING_BROWSER_PATH", ""),
50
+ os.path.expandvars(r"%PROGRAMFILES%\Google\Chrome\Application\chrome.exe"),
51
+ os.path.expandvars(r"%PROGRAMFILES(X86)%\Google\Chrome\Application\chrome.exe"),
52
+ os.path.expandvars(r"%LOCALAPPDATA%\Google\Chrome\Application\chrome.exe"),
53
+ os.path.expandvars(r"%PROGRAMFILES%\Microsoft\Edge\Application\msedge.exe"),
54
+ os.path.expandvars(r"%PROGRAMFILES(X86)%\Microsoft\Edge\Application\msedge.exe"),
55
+ os.path.expandvars(r"%LOCALAPPDATA%\Microsoft\Edge\Application\msedge.exe"),
56
+ "/usr/bin/google-chrome",
57
+ "/usr/bin/google-chrome-stable",
58
+ "/usr/bin/chromium",
59
+ "/usr/bin/chromium-browser",
60
+ "/usr/bin/microsoft-edge",
61
+ "/usr/bin/microsoft-edge-stable",
62
+ "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
63
+ "/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge",
64
+ ]
65
+ return next((path for path in candidates if path and Path(path).is_file()), None)
66
+
67
+
68
+ def default_profile_dir() -> Path:
69
+ configured = os.getenv("OPENLIBING_PROFILE_DIR")
70
+ return Path(configured).expanduser() if configured else Path.home() / ".claude" / "openlibing-browser-profile"
71
+
72
+
73
+ PROFILE_MARKER = ".msdevflow-openlibing-profile"
74
+ PROFILE_MARKER_CONTENT = "managed-by=msdevflow\n"
75
+ LEGACY_PROFILE_MARKERS = {
76
+ ".gitcode-e2e-openlibing-profile": {
77
+ "managed-by=gitcode-e2e-workflow\n",
78
+ PROFILE_MARKER_CONTENT,
79
+ },
80
+ }
81
+ PROFILE_METADATA = "session.json"
82
+
83
+
84
+ def profile_marker(profile_dir: Path) -> Path:
85
+ return profile_dir / PROFILE_MARKER
86
+
87
+
88
+ def valid_marker(marker: Path, accepted_contents: set[str]) -> bool:
89
+ try:
90
+ return marker.is_file() and marker.read_text(encoding="utf-8") in accepted_contents
91
+ except OSError:
92
+ return False
93
+
94
+
95
+ def managed_profile_marker(profile_dir: Path) -> Path | None:
96
+ marker = profile_marker(profile_dir)
97
+ if valid_marker(marker, {PROFILE_MARKER_CONTENT}):
98
+ return marker
99
+ return next((
100
+ profile_dir / name
101
+ for name, contents in LEGACY_PROFILE_MARKERS.items()
102
+ if valid_marker(profile_dir / name, contents)
103
+ ), None)
104
+
105
+
106
+ def prepare_profile_dir(profile_dir: Path) -> None:
107
+ existing_marker = managed_profile_marker(profile_dir)
108
+ if profile_dir.is_dir() and existing_marker is None and any(profile_dir.iterdir()):
109
+ raise OpenLibingAuthRequired("拒绝使用:非空目录不是由 msdevflow 管理的 openLiBing profile。")
110
+ profile_dir.mkdir(parents=True, exist_ok=True, mode=0o700)
111
+ if os.name != "nt":
112
+ profile_dir.chmod(stat.S_IRWXU)
113
+ marker = profile_marker(profile_dir)
114
+ marker.write_text(PROFILE_MARKER_CONTENT, encoding="utf-8")
115
+ if os.name != "nt":
116
+ marker.chmod(stat.S_IRUSR | stat.S_IWUSR)
117
+ for legacy_name, contents in LEGACY_PROFILE_MARKERS.items():
118
+ legacy_marker = profile_dir / legacy_name
119
+ if valid_marker(legacy_marker, contents):
120
+ legacy_marker.unlink()
121
+
122
+
123
+ def write_profile_metadata(profile_dir: Path, browser_path: str | None) -> None:
124
+ metadata = profile_dir / PROFILE_METADATA
125
+ previous: dict[str, Any] = {}
126
+ if metadata.is_file():
127
+ try:
128
+ previous = json.loads(metadata.read_text(encoding="utf-8"))
129
+ except (OSError, ValueError):
130
+ previous = {}
131
+ now = int(time.time())
132
+ metadata.write_text(json.dumps({
133
+ "created_at": previous.get("created_at", now),
134
+ "last_used_at": now,
135
+ "browser": Path(browser_path).name if browser_path else "playwright-chromium",
136
+ "stores": "gitcode-browser-session; openlibing-storage-cleared-after-use",
137
+ }, ensure_ascii=False, indent=2), encoding="utf-8")
138
+ if os.name != "nt":
139
+ metadata.chmod(stat.S_IRUSR | stat.S_IWUSR)
140
+
141
+
142
+ def profile_status(profile_dir: Path) -> dict[str, Any]:
143
+ managed = managed_profile_marker(profile_dir) is not None
144
+ result: dict[str, Any] = {
145
+ "profile_dir": str(profile_dir),
146
+ "exists": profile_dir.is_dir(),
147
+ "managed": managed,
148
+ "cookie_values_read": False,
149
+ }
150
+ metadata = profile_dir / PROFILE_METADATA
151
+ if managed and metadata.is_file():
152
+ try:
153
+ result["metadata"] = json.loads(metadata.read_text(encoding="utf-8"))
154
+ except (OSError, ValueError):
155
+ result["metadata"] = "unavailable"
156
+ return result
157
+
158
+
159
+ def clear_profile_dir(profile_dir: Path) -> None:
160
+ resolved = profile_dir.resolve()
161
+ if managed_profile_marker(resolved) is None:
162
+ raise RuntimeError("拒绝删除:目录不是由 msdevflow 管理的 openLiBing profile。")
163
+ shutil.rmtree(resolved)
164
+
165
+
166
+ def clear_openlibing_storage(context: Any, page: Any, base_url: str) -> None:
167
+ page.goto("about:blank")
168
+ host = urllib.parse.urlparse(base_url).hostname or "openlibing.com"
169
+ context.clear_cookies(domain=re.compile(rf"(^|\.){re.escape(host)}$"))
170
+ try:
171
+ cdp = context.new_cdp_session(page)
172
+ cdp.send("Storage.clearDataForOrigin", {
173
+ "origin": base_url,
174
+ "storageTypes": "all",
175
+ })
176
+ cdp.detach()
177
+ except Exception:
178
+ # Cookie removal is mandatory; non-Cookie storage cleanup is best-effort.
179
+ pass
180
+ if context.cookies([base_url]):
181
+ raise OpenLibingAuthRequired("无法从持久 profile 清除 openLiBing Cookie,已拒绝保存会话。")
182
+
183
+
184
+ def oauth_requirements_file() -> Path:
185
+ return Path(__file__).with_name("requirements.txt")
186
+
187
+
188
+ def ensure_playwright() -> None:
189
+ try:
190
+ importlib.import_module("playwright.sync_api")
191
+ return
192
+ except ImportError:
193
+ requirements = oauth_requirements_file()
194
+ if not requirements.is_file():
195
+ raise OpenLibingAuthRequired("缺少 openLiBing OAuth 依赖清单,无法自动安装 Playwright。")
196
+ result = subprocess.run(
197
+ [sys.executable, "-m", "pip", "install", "-r", str(requirements)],
198
+ check=False,
199
+ )
200
+ if result.returncode != 0:
201
+ raise OpenLibingAuthRequired("自动安装 Playwright 失败。")
202
+ importlib.invalidate_caches()
203
+ try:
204
+ importlib.import_module("playwright.sync_api")
205
+ except ImportError as error:
206
+ raise OpenLibingAuthRequired("Playwright 安装完成但当前 Python 无法导入。") from error
207
+
208
+
209
+ def playwright_chromium_path() -> Path:
210
+ from playwright.sync_api import sync_playwright
211
+
212
+ with sync_playwright() as playwright:
213
+ return Path(playwright.chromium.executable_path)
214
+
215
+
216
+ def ensure_playwright_browser(base_url: str = BASE_URL) -> str | None:
217
+ executable = find_browser()
218
+ if executable:
219
+ return executable
220
+ try:
221
+ if playwright_chromium_path().is_file():
222
+ return None
223
+ except Exception:
224
+ pass
225
+ raise OpenLibingBrowserRequired(
226
+ "没有可由 Playwright 控制的 Chrome/Edge。请复制 oauth_url 到浏览器继续登录;本次固定 run 尚未验证。",
227
+ oauth_authorization_url(base_url),
228
+ )
229
+
230
+
231
+ def interactive_oauth_login(
232
+ base_url: str = BASE_URL,
233
+ timeout_seconds: int = 300,
234
+ profile_dir: Path | None = None,
235
+ persistent: bool = True,
236
+ ) -> str:
237
+ ensure_playwright()
238
+ executable = ensure_playwright_browser(base_url)
239
+ from playwright.sync_api import sync_playwright
240
+
241
+ selected_profile = (profile_dir or default_profile_dir()).resolve()
242
+ if persistent:
243
+ try:
244
+ prepare_profile_dir(selected_profile)
245
+ except OSError as error:
246
+ raise OpenLibingAuthRequired("无法准备专用浏览器 profile。") from error
247
+ with sync_playwright() as playwright:
248
+ launch_options: dict[str, Any] = {"headless": False}
249
+ if executable:
250
+ launch_options["executable_path"] = executable
251
+ browser = None
252
+ try:
253
+ if persistent:
254
+ context = playwright.chromium.launch_persistent_context(
255
+ user_data_dir=str(selected_profile),
256
+ **launch_options,
257
+ )
258
+ else:
259
+ browser = playwright.chromium.launch(**launch_options)
260
+ context = browser.new_context()
261
+ except Exception as error:
262
+ raise OpenLibingBrowserRequired(
263
+ "Playwright 无法启动可见浏览器。请复制 oauth_url 到浏览器继续登录;本次固定 run 尚未验证。",
264
+ oauth_authorization_url(base_url),
265
+ ) from error
266
+ page = context.pages[0] if context.pages else context.new_page()
267
+ deadline = time.monotonic() + timeout_seconds
268
+ next_progress = time.monotonic()
269
+ token = ""
270
+ try:
271
+ print(
272
+ "请在打开的浏览器中完成 GitCode 登录,并在授权页手动点击授权。专用 profile 会保存 GitCode 浏览器会话;openLiBing token 不会持久化。",
273
+ file=sys.stderr,
274
+ )
275
+ page.goto(oauth_authorization_url(base_url), wait_until="domcontentloaded", timeout=45_000)
276
+ while time.monotonic() < deadline:
277
+ for cookie in context.cookies([base_url]):
278
+ if cookie.get("name") == "token":
279
+ token = str(cookie.get("value", ""))
280
+ break
281
+ if token:
282
+ break
283
+ if time.monotonic() >= next_progress:
284
+ current = urllib.parse.urlparse(page.url)
285
+ stage = "waiting-user-consent" if current.netloc == "gitcode.com" and current.path.startswith("/oauth/authorize") else "waiting-openlibing-callback"
286
+ print(f"{stage}:{current.scheme}://{current.netloc}{current.path}", file=sys.stderr)
287
+ next_progress = time.monotonic() + 15
288
+ page.wait_for_timeout(1000)
289
+ if not token:
290
+ raise OpenLibingAuthRequired("OAuth 登录超时,未获得 openLiBing 会话。")
291
+ if persistent:
292
+ write_profile_metadata(selected_profile, executable)
293
+ clear_openlibing_storage(context, page, base_url)
294
+ finally:
295
+ context.close()
296
+ if browser is not None:
297
+ browser.close()
298
+ return token
299
+
300
+
301
+ class OpenLibingReadClient:
302
+ def __init__(
303
+ self,
304
+ base_url: str = BASE_URL,
305
+ allow_oauth: bool = False,
306
+ oauth_login: Callable[[], str] | None = None,
307
+ ) -> None:
308
+ self.base_url = base_url.rstrip("/")
309
+ self.allow_oauth = allow_oauth
310
+ self.oauth_login = oauth_login or (lambda: interactive_oauth_login(self.base_url))
311
+ self._token = ""
312
+ self.auth_path = "anonymous-read"
313
+
314
+ def _request(self, method: str, path: str, payload: dict[str, Any] | None = None) -> dict[str, Any]:
315
+ headers = {
316
+ "Accept": "application/json",
317
+ "Content-Type": "application/json",
318
+ "User-Agent": "msdevflow/openlibing-read",
319
+ }
320
+ if self._token:
321
+ headers["Csrf-Token-Open-Li-Bing"] = self._token
322
+ request = urllib.request.Request(
323
+ self.base_url + path,
324
+ data=json.dumps(payload).encode() if payload is not None else None,
325
+ headers=headers,
326
+ method=method,
327
+ )
328
+ with urllib.request.urlopen(request, timeout=60) as response:
329
+ return json.loads(response.read().decode())
330
+
331
+ def request(self, method: str, path: str, payload: dict[str, Any] | None = None) -> dict[str, Any]:
332
+ try:
333
+ return self._request(method, path, payload)
334
+ except urllib.error.HTTPError as error:
335
+ if error.code not in (401, 403) or self._token:
336
+ raise
337
+ if not self.allow_oauth:
338
+ raise OpenLibingAuthRequired(
339
+ "openLiBing 只读接口要求认证。经用户确认后使用 --oauth,或连接已部署的 BFF/Adapter。"
340
+ ) from error
341
+ self._token = self.oauth_login()
342
+ self.auth_path = "interactive-oauth"
343
+ return self._request(method, path, payload)
344
+
345
+ def authenticate(self) -> None:
346
+ self._token = self.oauth_login()
347
+ self.auth_path = "interactive-oauth"
348
+
349
+ def detail(self, project_id: str, pipeline_id: str, run_id: str) -> dict[str, Any]:
350
+ query = urllib.parse.urlencode({
351
+ "projectId": project_id,
352
+ "pipelineId": pipeline_id,
353
+ "pipelineRunId": run_id,
354
+ })
355
+ return self.request(
356
+ "GET",
357
+ f"/gateway/openlibing-cicd/project/pipeline/pipeline-run/detail?{query}",
358
+ )
359
+
360
+ def logs(
361
+ self,
362
+ project_id: str,
363
+ pipeline_id: str,
364
+ run_id: str,
365
+ job_run_id: str,
366
+ step_run_id: str,
367
+ limit: int,
368
+ ) -> str:
369
+ response = self.request(
370
+ "POST",
371
+ "/gateway/openlibing-cicd/project/pipeline/exec-log",
372
+ {
373
+ "projectId": project_id,
374
+ "pipelineId": pipeline_id,
375
+ "pipelineRunId": run_id,
376
+ "jobRunId": job_run_id,
377
+ "stepRunId": step_run_id,
378
+ "sort": "desc",
379
+ "limit": limit,
380
+ "startOffset": 0,
381
+ "endOffset": 0,
382
+ },
383
+ )
384
+ return str((response.get("data") or {}).get("log", ""))
385
+
386
+
387
+ def failed_steps(detail: dict[str, Any]) -> list[dict[str, str]]:
388
+ failures: list[dict[str, str]] = []
389
+ for stage in (detail.get("data") or {}).get("stages", []):
390
+ for job in stage.get("jobs", []):
391
+ job_failed = str(job.get("status", "")).upper() in FAILURE_STATUSES
392
+ for step in job.get("steps", []):
393
+ step_failed = str(step.get("status", "")).upper() in FAILURE_STATUSES
394
+ if job_failed or step_failed:
395
+ failures.append({
396
+ "stage": str(stage.get("name", "")),
397
+ "job": str(job.get("name", "")),
398
+ "job_status": str(job.get("status", "")),
399
+ "job_run_id": str(job.get("id", "")),
400
+ "step": str(step.get("name", "")),
401
+ "step_status": str(step.get("status", "")),
402
+ "step_run_id": str(step.get("id", "")),
403
+ })
404
+ return failures
405
+
406
+
407
+ def error_excerpt(log: str, context: int = 3) -> list[str]:
408
+ lines = log.splitlines()
409
+ selected: set[int] = set()
410
+ for index, line in enumerate(lines):
411
+ if ERROR_PATTERN.search(line):
412
+ selected.update(range(max(0, index - context), min(len(lines), index + context + 1)))
413
+ return [lines[index] for index in sorted(selected)][-120:]
414
+
415
+
416
+ def oauth_login_for_args(args: argparse.Namespace) -> Callable[[], str]:
417
+ return lambda: interactive_oauth_login(
418
+ profile_dir=Path(args.profile_dir).expanduser() if args.profile_dir else None,
419
+ persistent=not args.temporary_session,
420
+ )
421
+
422
+
423
+ def diagnose(args: argparse.Namespace) -> int:
424
+ client = OpenLibingReadClient(
425
+ allow_oauth=args.oauth,
426
+ oauth_login=oauth_login_for_args(args),
427
+ )
428
+ detail = client.detail(args.project_id, args.pipeline_id, args.run_id)
429
+ failures = failed_steps(detail)
430
+ result: dict[str, Any] = {
431
+ "auth_path": client.auth_path,
432
+ "run": {
433
+ "id": str((detail.get("data") or {}).get("id", args.run_id)),
434
+ "number": (detail.get("data") or {}).get("run_number"),
435
+ "status": (detail.get("data") or {}).get("status"),
436
+ },
437
+ "failures": [],
438
+ }
439
+ for failure in failures:
440
+ log = client.logs(
441
+ args.project_id,
442
+ args.pipeline_id,
443
+ args.run_id,
444
+ failure["job_run_id"],
445
+ failure["step_run_id"],
446
+ args.limit,
447
+ )
448
+ result["failures"].append({**failure, "error_excerpt": error_excerpt(log)})
449
+ result["auth_path"] = client.auth_path
450
+ print(json.dumps(result, ensure_ascii=False, indent=2))
451
+ return 0
452
+
453
+
454
+ def login_check(args: argparse.Namespace) -> int:
455
+ client = OpenLibingReadClient(
456
+ allow_oauth=True,
457
+ oauth_login=oauth_login_for_args(args),
458
+ )
459
+ client.authenticate()
460
+ detail = client.detail(args.project_id, args.pipeline_id, args.run_id)
461
+ data = detail.get("data") or {}
462
+ print(json.dumps({
463
+ "auth_path": client.auth_path,
464
+ "authenticated": True,
465
+ "run": {
466
+ "id": str(data.get("id", args.run_id)),
467
+ "number": data.get("run_number"),
468
+ "status": data.get("status"),
469
+ },
470
+ "token_output": "redacted",
471
+ "token_persistence": "process-memory-only",
472
+ "browser_session": "temporary" if args.temporary_session else "persistent-dedicated-profile",
473
+ "profile_dir": None if args.temporary_session else str(
474
+ Path(args.profile_dir).expanduser() if args.profile_dir else default_profile_dir()
475
+ ),
476
+ }, ensure_ascii=False, indent=2))
477
+ return 0
478
+
479
+
480
+ def session_status(args: argparse.Namespace) -> int:
481
+ selected = Path(args.profile_dir).expanduser() if args.profile_dir else default_profile_dir()
482
+ print(json.dumps(profile_status(selected.resolve()), ensure_ascii=False, indent=2))
483
+ return 0
484
+
485
+
486
+ def session_clear(args: argparse.Namespace) -> int:
487
+ selected = Path(args.profile_dir).expanduser() if args.profile_dir else default_profile_dir()
488
+ if not args.yes:
489
+ print(json.dumps({
490
+ "error": "confirmation-required",
491
+ "profile_dir": str(selected),
492
+ "message": "清除专用浏览器会话需要 --yes。",
493
+ }, ensure_ascii=False))
494
+ return 4
495
+ clear_profile_dir(selected)
496
+ print(json.dumps({"cleared": True, "profile_dir": str(selected)}, ensure_ascii=False))
497
+ return 0
498
+
499
+
500
+ def add_profile_arguments(command: argparse.ArgumentParser) -> None:
501
+ command.add_argument(
502
+ "--profile-dir",
503
+ default="",
504
+ help="Dedicated browser profile directory (default: ~/.claude/openlibing-browser-profile)",
505
+ )
506
+ command.add_argument(
507
+ "--temporary-session",
508
+ action="store_true",
509
+ help="Do not persist the dedicated GitCode browser session",
510
+ )
511
+
512
+
513
+ def add_run_arguments(command: argparse.ArgumentParser) -> None:
514
+ command.add_argument("--project-id", required=True)
515
+ command.add_argument("--pipeline-id", required=True)
516
+ command.add_argument("--run-id", required=True)
517
+
518
+
519
+ def main() -> int:
520
+ parser = argparse.ArgumentParser(description="Safe openLiBing read-only CI diagnostics")
521
+ subparsers = parser.add_subparsers(dest="command", required=True)
522
+ command = subparsers.add_parser("diagnose", help="Read run detail and failed step logs")
523
+ add_run_arguments(command)
524
+ add_profile_arguments(command)
525
+ command.add_argument("--limit", type=int, default=5000)
526
+ command.add_argument(
527
+ "--oauth",
528
+ action="store_true",
529
+ help="On 401/403, open a local browser for interactive OAuth; never prints or stores token",
530
+ )
531
+ login_command = subparsers.add_parser(
532
+ "login-check",
533
+ help="Force an interactive OAuth login, then verify it with a read-only run request",
534
+ )
535
+ add_run_arguments(login_command)
536
+ add_profile_arguments(login_command)
537
+ status_command = subparsers.add_parser(
538
+ "session-status",
539
+ help="Show dedicated profile metadata without reading cookie values",
540
+ )
541
+ status_command.add_argument("--profile-dir", default="")
542
+ clear_command = subparsers.add_parser(
543
+ "session-clear",
544
+ help="Delete a managed dedicated browser profile",
545
+ )
546
+ clear_command.add_argument("--profile-dir", default="")
547
+ clear_command.add_argument("--yes", action="store_true")
548
+ args = parser.parse_args()
549
+ try:
550
+ if args.command == "login-check":
551
+ return login_check(args)
552
+ if args.command == "session-status":
553
+ return session_status(args)
554
+ if args.command == "session-clear":
555
+ return session_clear(args)
556
+ return diagnose(args)
557
+ except OpenLibingBrowserRequired as error:
558
+ print(json.dumps({
559
+ "error": "browser-required",
560
+ "message": str(error),
561
+ "oauth_url": error.oauth_url,
562
+ "verification": "not-completed",
563
+ }, ensure_ascii=False))
564
+ return 5
565
+ except OpenLibingAuthRequired as error:
566
+ print(json.dumps({"error": "openlibing-auth-required", "message": str(error)}, ensure_ascii=False))
567
+ return 3
568
+ except urllib.error.HTTPError as error:
569
+ print(json.dumps({"error": "openlibing-http-error", "status": error.code}, ensure_ascii=False))
570
+ return 2
571
+
572
+
573
+ if __name__ == "__main__":
574
+ raise SystemExit(main())
@@ -0,0 +1 @@
1
+ playwright>=1.54,<2