gdmcode 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.
Files changed (131) hide show
  1. gdmcode-0.1.0.dist-info/METADATA +240 -0
  2. gdmcode-0.1.0.dist-info/RECORD +131 -0
  3. gdmcode-0.1.0.dist-info/WHEEL +4 -0
  4. gdmcode-0.1.0.dist-info/entry_points.txt +2 -0
  5. src/__init__.py +1 -0
  6. src/_internal/__init__.py +0 -0
  7. src/_internal/constants.py +244 -0
  8. src/_internal/domain_skills.py +339 -0
  9. src/agent/__init__.py +0 -0
  10. src/agent/commit_classifier.py +91 -0
  11. src/agent/context_budget.py +391 -0
  12. src/agent/daemon.py +681 -0
  13. src/agent/dag_validator.py +153 -0
  14. src/agent/debug_loop.py +473 -0
  15. src/agent/impact_analyzer.py +149 -0
  16. src/agent/impact_graph.py +117 -0
  17. src/agent/loop.py +1410 -0
  18. src/agent/orchestrator.py +141 -0
  19. src/agent/regression_guard.py +251 -0
  20. src/agent/review_gate.py +648 -0
  21. src/agent/risk_scorer.py +169 -0
  22. src/agent/self_healing.py +145 -0
  23. src/agent/smart_test_selector.py +89 -0
  24. src/agent/system_prompt.py +226 -0
  25. src/agent/task_tracker.py +320 -0
  26. src/agent/test_validator.py +210 -0
  27. src/agent/tool_orchestrator.py +402 -0
  28. src/agent/transcript.py +230 -0
  29. src/agent/verification_loop.py +133 -0
  30. src/agent/work_director.py +136 -0
  31. src/agent/worktree_manager.py +53 -0
  32. src/artifacts/__init__.py +16 -0
  33. src/artifacts/artifact_store.py +456 -0
  34. src/artifacts/verification_graph.py +75 -0
  35. src/auth.py +411 -0
  36. src/cli.py +1290 -0
  37. src/commands.py +1398 -0
  38. src/config.py +762 -0
  39. src/cost_tracker.py +348 -0
  40. src/db/__init__.py +4 -0
  41. src/db/migrations.py +337 -0
  42. src/enterprise/__init__.py +3 -0
  43. src/enterprise/audit_log.py +182 -0
  44. src/enterprise/identity.py +90 -0
  45. src/enterprise/rbac.py +100 -0
  46. src/enterprise/team_config.py +125 -0
  47. src/enterprise/usage_analytics.py +261 -0
  48. src/exceptions.py +207 -0
  49. src/git_workflow.py +651 -0
  50. src/integrations/__init__.py +6 -0
  51. src/integrations/github_actions.py +106 -0
  52. src/integrations/mcp_server.py +333 -0
  53. src/integrations/sentry_integration.py +100 -0
  54. src/integrations/sentry_server.py +82 -0
  55. src/integrations/webhook_security.py +19 -0
  56. src/main.py +27 -0
  57. src/memory/__init__.py +0 -0
  58. src/memory/code_index.py +376 -0
  59. src/memory/compressor.py +378 -0
  60. src/memory/context_memory.py +135 -0
  61. src/memory/continuous_memory.py +234 -0
  62. src/memory/conventions.py +495 -0
  63. src/memory/db.py +1119 -0
  64. src/memory/document_index.py +205 -0
  65. src/memory/file_cache.py +128 -0
  66. src/memory/project_scanner.py +178 -0
  67. src/memory/session_store.py +201 -0
  68. src/models/__init__.py +0 -0
  69. src/models/client.py +715 -0
  70. src/models/definitions.py +459 -0
  71. src/models/router.py +418 -0
  72. src/models/schemas.py +389 -0
  73. src/permissions.py +294 -0
  74. src/remote/__init__.py +5 -0
  75. src/remote/command_filter.py +33 -0
  76. src/remote/models.py +31 -0
  77. src/remote/permission_handler.py +79 -0
  78. src/remote/phone_ui.py +48 -0
  79. src/remote/protocol.py +59 -0
  80. src/remote/qr.py +65 -0
  81. src/remote/server.py +586 -0
  82. src/remote/token_manager.py +61 -0
  83. src/remote/tunnel.py +212 -0
  84. src/repl.py +475 -0
  85. src/runtime/__init__.py +1 -0
  86. src/runtime/branch_farm.py +372 -0
  87. src/runtime/replay.py +351 -0
  88. src/sandbox/__init__.py +2 -0
  89. src/sandbox/hermetic.py +214 -0
  90. src/sandbox/policy.py +44 -0
  91. src/sdk/__init__.py +3 -0
  92. src/sdk/plugin_base.py +39 -0
  93. src/sdk/plugin_host.py +100 -0
  94. src/sdk/plugin_loader.py +101 -0
  95. src/security.py +409 -0
  96. src/server/__init__.py +7 -0
  97. src/server/bridge.py +427 -0
  98. src/server/bridge_cli.py +103 -0
  99. src/server/bridge_client.py +170 -0
  100. src/server/protocol_version.py +103 -0
  101. src/session/__init__.py +10 -0
  102. src/session/event_fanout.py +46 -0
  103. src/session/input_broker.py +38 -0
  104. src/session/permission_bridge.py +100 -0
  105. src/tools/__init__.py +160 -0
  106. src/tools/_atomic.py +72 -0
  107. src/tools/agent_tools.py +423 -0
  108. src/tools/ask_user_tool.py +83 -0
  109. src/tools/bash_tool.py +384 -0
  110. src/tools/browser_tool.py +352 -0
  111. src/tools/browser_tools.py +179 -0
  112. src/tools/dep_tools.py +210 -0
  113. src/tools/document_reader.py +167 -0
  114. src/tools/document_tool.py +240 -0
  115. src/tools/document_writer.py +171 -0
  116. src/tools/impact_tools.py +240 -0
  117. src/tools/playwright_tool.py +172 -0
  118. src/tools/quality_tools.py +366 -0
  119. src/tools/read_tools.py +318 -0
  120. src/tools/result_cache.py +157 -0
  121. src/tools/search_tools.py +310 -0
  122. src/tools/shell_tools.py +311 -0
  123. src/tools/write_tools.py +337 -0
  124. src/voice/__init__.py +25 -0
  125. src/voice/audio_capture.py +92 -0
  126. src/voice/audio_playback.py +68 -0
  127. src/voice/errors.py +14 -0
  128. src/voice/models.py +35 -0
  129. src/voice/providers.py +143 -0
  130. src/voice/vad.py +55 -0
  131. src/voice/voice_loop.py +156 -0
@@ -0,0 +1,171 @@
1
+ """DocumentWriter — create and modify Word/Excel documents; PDF export via LibreOffice.
2
+
3
+ All document library imports are guarded so the module loads without optional deps.
4
+ """
5
+ from __future__ import annotations
6
+ import logging, shutil, subprocess
7
+ from dataclasses import dataclass, field
8
+ from pathlib import Path
9
+ from typing import Optional
10
+
11
+ log = logging.getLogger(__name__)
12
+
13
+ __all__ = ["DocumentWriter", "WriteResult", "DocxSpec", "XlsxSpec"]
14
+
15
+
16
+ @dataclass
17
+ class WriteResult:
18
+ path: str
19
+ format: str
20
+ bytes_written: int = 0
21
+ error: Optional[str] = None
22
+
23
+ @property
24
+ def success(self) -> bool:
25
+ return self.error is None
26
+
27
+
28
+ @dataclass
29
+ class DocxSpec:
30
+ """Declarative spec for a Word document."""
31
+ title: str = ""
32
+ author: str = ""
33
+ sections: list[dict] = field(default_factory=list)
34
+ # Each section: {"heading": str, "level": int, "paragraphs": [str], "table": [[str]]}
35
+
36
+
37
+ @dataclass
38
+ class XlsxSpec:
39
+ """Declarative spec for an Excel workbook."""
40
+ sheets: list[dict] = field(default_factory=list)
41
+ # Each sheet: {"name": str, "headers": [str], "rows": [[str|int|float]]}
42
+
43
+
44
+ class DocumentWriter:
45
+
46
+ # ── Word ──────────────────────────────────────────────────────────────
47
+
48
+ def create_docx(self, spec: DocxSpec, output_path: Path | str) -> WriteResult:
49
+ output_path = Path(output_path)
50
+ try:
51
+ import docx
52
+ except ImportError:
53
+ return WriteResult(str(output_path), "docx",
54
+ error="python-docx required. pip install 'gdm-code[docs]'")
55
+ try:
56
+ doc = docx.Document()
57
+ props = doc.core_properties
58
+ if spec.title:
59
+ props.title = spec.title
60
+ if spec.author:
61
+ props.author = spec.author
62
+ for section in spec.sections:
63
+ heading = section.get("heading", "")
64
+ level = section.get("level", 1)
65
+ if heading:
66
+ doc.add_heading(heading, level=level)
67
+ for para in section.get("paragraphs", []):
68
+ doc.add_paragraph(para)
69
+ table_data = section.get("table")
70
+ if table_data and len(table_data) > 0:
71
+ cols = max(len(r) for r in table_data)
72
+ t = doc.add_table(rows=len(table_data), cols=cols, style="Table Grid")
73
+ for r_idx, row in enumerate(table_data):
74
+ for c_idx, cell in enumerate(row):
75
+ t.cell(r_idx, c_idx).text = str(cell)
76
+ doc.save(str(output_path))
77
+ return WriteResult(str(output_path), "docx",
78
+ bytes_written=output_path.stat().st_size)
79
+ except Exception as exc:
80
+ return WriteResult(str(output_path), "docx", error=str(exc))
81
+
82
+ def append_docx(self, path: Path | str, paragraphs: list[str],
83
+ heading: str = "") -> WriteResult:
84
+ path = Path(path)
85
+ try:
86
+ import docx
87
+ except ImportError:
88
+ return WriteResult(str(path), "docx",
89
+ error="python-docx required. pip install 'gdm-code[docs]'")
90
+ try:
91
+ doc = docx.Document(str(path))
92
+ if heading:
93
+ doc.add_heading(heading, level=2)
94
+ for para in paragraphs:
95
+ doc.add_paragraph(para)
96
+ doc.save(str(path))
97
+ return WriteResult(str(path), "docx", bytes_written=path.stat().st_size)
98
+ except Exception as exc:
99
+ return WriteResult(str(path), "docx", error=str(exc))
100
+
101
+ # ── Excel ─────────────────────────────────────────────────────────────
102
+
103
+ def create_xlsx(self, spec: XlsxSpec, output_path: Path | str) -> WriteResult:
104
+ output_path = Path(output_path)
105
+ try:
106
+ import openpyxl
107
+ from openpyxl.styles import Font
108
+ except ImportError:
109
+ return WriteResult(str(output_path), "xlsx",
110
+ error="openpyxl required. pip install 'gdm-code[docs]'")
111
+ try:
112
+ wb = openpyxl.Workbook()
113
+ wb.remove(wb.active)
114
+ for sheet_spec in spec.sheets:
115
+ ws = wb.create_sheet(title=sheet_spec.get("name", "Sheet"))
116
+ headers = sheet_spec.get("headers", [])
117
+ if headers:
118
+ ws.append(headers)
119
+ for cell in ws[1]:
120
+ cell.font = Font(bold=True)
121
+ for row in sheet_spec.get("rows", []):
122
+ ws.append(list(row))
123
+ wb.save(str(output_path))
124
+ return WriteResult(str(output_path), "xlsx",
125
+ bytes_written=output_path.stat().st_size)
126
+ except Exception as exc:
127
+ return WriteResult(str(output_path), "xlsx", error=str(exc))
128
+
129
+ def update_xlsx_cell(self, path: Path | str, sheet_name: str,
130
+ row: int, col: int, value) -> WriteResult:
131
+ """Update a single cell (1-indexed row/col) in an existing xlsx."""
132
+ path = Path(path)
133
+ try:
134
+ import openpyxl
135
+ except ImportError:
136
+ return WriteResult(str(path), "xlsx",
137
+ error="openpyxl required. pip install 'gdm-code[docs]'")
138
+ try:
139
+ wb = openpyxl.load_workbook(str(path))
140
+ if sheet_name not in wb.sheetnames:
141
+ return WriteResult(str(path), "xlsx",
142
+ error=f"Sheet '{sheet_name}' not found")
143
+ ws = wb[sheet_name]
144
+ ws.cell(row=row, column=col, value=value)
145
+ wb.save(str(path))
146
+ return WriteResult(str(path), "xlsx", bytes_written=path.stat().st_size)
147
+ except Exception as exc:
148
+ return WriteResult(str(path), "xlsx", error=str(exc))
149
+
150
+ # ── PDF conversion ────────────────────────────────────────────────────
151
+
152
+ def docx_to_pdf(self, docx_path: Path | str, output_dir: Path | str = None) -> WriteResult:
153
+ docx_path = Path(docx_path)
154
+ output_dir = Path(output_dir) if output_dir else docx_path.parent
155
+ if not shutil.which("libreoffice"):
156
+ return WriteResult(str(docx_path), "pdf",
157
+ error="LibreOffice not found. Install LibreOffice to enable PDF export.")
158
+ try:
159
+ result = subprocess.run(
160
+ ["libreoffice", "--headless", "--convert-to", "pdf",
161
+ "--outdir", str(output_dir), str(docx_path)],
162
+ capture_output=True, timeout=60,
163
+ )
164
+ if result.returncode != 0:
165
+ return WriteResult(str(docx_path), "pdf",
166
+ error=result.stderr.decode(errors="replace"))
167
+ pdf_path = output_dir / (docx_path.stem + ".pdf")
168
+ return WriteResult(str(pdf_path), "pdf",
169
+ bytes_written=pdf_path.stat().st_size if pdf_path.exists() else 0)
170
+ except Exception as exc:
171
+ return WriteResult(str(docx_path), "pdf", error=str(exc))
@@ -0,0 +1,240 @@
1
+ """Impact analysis tool — blast radius before signature changes.
2
+
3
+ Given a symbol name (function, class, or variable), returns:
4
+ - Every file that calls or imports it
5
+ - Line numbers and context snippets for each call site
6
+ - Suggested test files to run after the change
7
+ - Risk level: low / medium / high based on total caller count
8
+
9
+ Call before any breaking rename or signature change to understand scope.
10
+ """
11
+ from __future__ import annotations
12
+
13
+ import logging
14
+ import re
15
+ import uuid
16
+ from pathlib import Path
17
+ from typing import Any, ClassVar
18
+
19
+ from src.tools import REGISTRY, ToolBase, ToolResult
20
+
21
+ __all__ = ["ImpactAnalysisTool"]
22
+
23
+ log = logging.getLogger(__name__)
24
+
25
+ _EXCLUDED_DIRS: frozenset[str] = frozenset(
26
+ {".git", "__pycache__", "node_modules", ".venv", "venv", "dist", "build"}
27
+ )
28
+ _SOURCE_EXTS: frozenset[str] = frozenset(
29
+ {".py", ".ts", ".js", ".tsx", ".jsx"}
30
+ )
31
+ _MAX_HITS_PER_FILE: int = 5
32
+ _RISK_LOW: int = 2
33
+ _RISK_MEDIUM: int = 10
34
+
35
+
36
+ # ---------------------------------------------------------------------------
37
+ # Tool
38
+ # ---------------------------------------------------------------------------
39
+
40
+ class ImpactAnalysisTool(ToolBase):
41
+ """Analyse the blast radius of a rename or signature change.
42
+
43
+ Returns all call sites for a symbol with file/line info, a risk level
44
+ (low / medium / high), and suggested test targets.
45
+
46
+ **Call before any breaking refactor.**
47
+ """
48
+
49
+ name: ClassVar[str] = "impact_analysis"
50
+ description: ClassVar[str] = (
51
+ "Analyse the blast radius of a rename or signature change. "
52
+ "Returns all callers of a symbol with file/line info, risk level, "
53
+ "and suggested tests to run. Call before any breaking refactor."
54
+ )
55
+ input_schema: ClassVar[dict[str, Any]] = {
56
+ "type": "object",
57
+ "required": ["symbol"],
58
+ "properties": {
59
+ "symbol": {
60
+ "type": "string",
61
+ "description": "The function, class, or variable name to analyse.",
62
+ },
63
+ "project_root": {
64
+ "type": "string",
65
+ "description": "Root directory. Defaults to cwd.",
66
+ },
67
+ "include_tests": {
68
+ "type": "boolean",
69
+ "description": "Include test files in analysis (default: false).",
70
+ },
71
+ "change_type": {
72
+ "type": "string",
73
+ "enum": ["rename", "signature_change", "delete", "behavior_change"],
74
+ "description": "Type of change being made",
75
+ "default": "signature_change",
76
+ },
77
+ "new_signature": {
78
+ "type": "string",
79
+ "description": "New signature (for rename/signature_change)",
80
+ "default": "",
81
+ },
82
+ },
83
+ }
84
+
85
+ def execute(self, args: dict[str, Any]) -> ToolResult:
86
+ symbol: str = (args.get("symbol") or "").strip()
87
+ if not symbol:
88
+ return ToolResult(output="", error="'symbol' is required")
89
+
90
+ root = Path(args.get("project_root") or Path.cwd()).resolve()
91
+ include_tests: bool = args.get("include_tests", False)
92
+ change_type: str = args.get("change_type", "signature_change")
93
+
94
+ try:
95
+ callers = self._find_callers(symbol, root, include_tests)
96
+ is_public = self._is_public_api(symbol, root)
97
+ semver = self._semver_recommendation(symbol, callers, is_public)
98
+
99
+ from src.models.schemas import ImpactReport
100
+ impact_report = ImpactReport(
101
+ symbol=symbol,
102
+ callers_count=len(callers),
103
+ callers=callers,
104
+ affected_tests=[c["file"] for c in callers if "test" in c["file"].lower()],
105
+ is_public_api=is_public,
106
+ semver_recommendation=semver,
107
+ change_type=change_type,
108
+ )
109
+
110
+ tool_result = ToolResult(output=self._format_report(symbol, callers))
111
+ tool_result.metadata["structured"] = impact_report.model_dump()
112
+ return tool_result
113
+ except Exception as exc: # noqa: BLE001
114
+ log.warning("impact_analysis failed for %r: %s", symbol, exc)
115
+ return ToolResult(output="", error=str(exc))
116
+
117
+ # ------------------------------------------------------------------
118
+ # Caller discovery
119
+ # ------------------------------------------------------------------
120
+
121
+ def _find_callers(
122
+ self, symbol: str, root: Path, include_tests: bool
123
+ ) -> list[dict[str, Any]]:
124
+ """Try CodeIndex first (fast), fall back to regex grep."""
125
+ try:
126
+ from src.memory.code_index import CodeIndex
127
+ from src.memory.db import GdmDatabase
128
+
129
+ db = GdmDatabase(project_root=root)
130
+ project_id = str(uuid.uuid5(uuid.NAMESPACE_URL, str(root)))
131
+ idx = CodeIndex(db=db, project_id=project_id, project_root=root)
132
+ raw = idx.find_callers(symbol)
133
+ if raw:
134
+ callers = [{"file": file, "line": line, "context": ""} for file, line in raw]
135
+ if not include_tests:
136
+ callers = [
137
+ c for c in callers
138
+ if "test" not in c["file"].lower()
139
+ ]
140
+ return callers
141
+ except (ImportError, OSError):
142
+ pass # fall through to grep
143
+
144
+ raw_tuples = self._grep_callers(symbol, root)
145
+ callers = [{"file": f, "line": ln, "context": ctx} for f, ln, ctx in raw_tuples]
146
+ if not include_tests:
147
+ callers = [c for c in callers if "test" not in c["file"].lower()]
148
+ return callers
149
+
150
+ def _grep_callers(self, symbol: str, root: Path) -> list[tuple[str, int, str]]:
151
+ """Regex-based fallback: scan Python files for symbol references, excluding definition lines."""
152
+ results: list[tuple[str, int, str]] = []
153
+ for py_file in root.rglob("*.py"):
154
+ if any(part in _EXCLUDED_DIRS for part in py_file.parts):
155
+ continue
156
+ try:
157
+ content = py_file.read_text(errors="ignore")
158
+ except OSError:
159
+ continue
160
+ for i, line in enumerate(content.splitlines(), 1):
161
+ if re.match(rf'^\s*(?:def|class)\s+{re.escape(symbol)}\b', line):
162
+ continue
163
+ if re.search(rf'\b{re.escape(symbol)}\s*[\(\[]', line):
164
+ results.append((str(py_file), i, line.strip()))
165
+ return results
166
+
167
+ # ------------------------------------------------------------------
168
+ # New helper methods
169
+ # ------------------------------------------------------------------
170
+
171
+ def _semver_recommendation(self, symbol: str, callers: list, is_public: bool) -> str:
172
+ if not callers:
173
+ return "PATCH — no callers, internal cleanup"
174
+ if is_public:
175
+ return "MAJOR — exported symbol changed, breaking consumers"
176
+ if len(callers) > 10:
177
+ return "MINOR — internal change affecting many files"
178
+ return "PATCH — local change, limited blast radius"
179
+
180
+ def _is_public_api(self, symbol: str, project_root: Path) -> bool:
181
+ for init in project_root.rglob("__init__.py"):
182
+ try:
183
+ if symbol in init.read_text(errors="ignore"):
184
+ return True
185
+ except OSError:
186
+ continue
187
+ return False
188
+
189
+ # ------------------------------------------------------------------
190
+ # Report formatting
191
+ # ------------------------------------------------------------------
192
+
193
+ def _format_report(
194
+ self, symbol: str, callers: list[dict[str, Any]]
195
+ ) -> str:
196
+ if not callers:
197
+ return (
198
+ f"Symbol `{symbol}` not found in any source file.\n"
199
+ "✅ Safe to rename — no call sites detected."
200
+ )
201
+
202
+ by_file: dict[str, list[dict[str, Any]]] = {}
203
+ test_files: set[str] = set()
204
+ for c in callers:
205
+ by_file.setdefault(c["file"], []).append(c)
206
+ if "test" in c["file"].lower():
207
+ test_files.add(c["file"])
208
+
209
+ total = len(callers)
210
+ file_count = len(by_file)
211
+ risk = "low" if total <= _RISK_LOW else "medium" if total <= _RISK_MEDIUM else "high"
212
+ icons = {"low": "🟢", "medium": "🟡", "high": "🔴"}
213
+
214
+ lines: list[str] = [
215
+ f"## Impact analysis for `{symbol}`",
216
+ "",
217
+ f"Risk: {icons[risk]} **{risk.upper()}** — {total} reference(s) in {file_count} file(s)",
218
+ "",
219
+ "### Call sites",
220
+ ]
221
+
222
+ for filepath, hits in sorted(by_file.items()):
223
+ lines.append(f"\n**{filepath}**")
224
+ for h in hits[:_MAX_HITS_PER_FILE]:
225
+ lines.append(f" L{h['line']}: `{h['context']}`")
226
+ if len(hits) > _MAX_HITS_PER_FILE:
227
+ lines.append(f" … and {len(hits) - _MAX_HITS_PER_FILE} more")
228
+
229
+ lines += ["", "### Suggested tests after change"]
230
+ if test_files:
231
+ for tf in sorted(test_files):
232
+ lines.append(f" pytest {tf}")
233
+ else:
234
+ lines.append(f" pytest -k {symbol!r}")
235
+
236
+ return "\n".join(lines)
237
+
238
+
239
+ # Self-register
240
+ REGISTRY.register(ImpactAnalysisTool())
@@ -0,0 +1,172 @@
1
+ """Playwright-based browser automation tool for gdm agents.
2
+
3
+ Provides a high-level async interface for navigating pages, taking screenshots,
4
+ clicking elements, filling forms, and evaluating JavaScript.
5
+
6
+ playwright is an optional dependency — a clear ImportError with install
7
+ instructions is raised if the package is missing.
8
+ """
9
+ from __future__ import annotations
10
+
11
+ from dataclasses import dataclass
12
+ from typing import Any
13
+
14
+ try:
15
+ from playwright.async_api import async_playwright
16
+ _PLAYWRIGHT_AVAILABLE = True
17
+ except ImportError:
18
+ _PLAYWRIGHT_AVAILABLE = False
19
+ async_playwright = None # type: ignore[assignment]
20
+
21
+ __all__ = ["PlaywrightResult", "PlaywrightTool"]
22
+
23
+ # ---------------------------------------------------------------------------
24
+ # JS safety: patterns blocked in evaluate()
25
+ # ---------------------------------------------------------------------------
26
+
27
+ _DANGEROUS_JS_PATTERNS: tuple[str, ...] = (
28
+ "fetch(",
29
+ "XMLHttpRequest",
30
+ "eval(",
31
+ "import(",
32
+ )
33
+
34
+
35
+ # ---------------------------------------------------------------------------
36
+ # PlaywrightResult
37
+ # ---------------------------------------------------------------------------
38
+
39
+ @dataclass
40
+ class PlaywrightResult:
41
+ """Result of a Playwright browser operation."""
42
+
43
+ success: bool
44
+ data: Any = None
45
+ error: str | None = None
46
+ screenshot: bytes | None = None
47
+
48
+
49
+ # ---------------------------------------------------------------------------
50
+ # PlaywrightTool
51
+ # ---------------------------------------------------------------------------
52
+
53
+ class PlaywrightTool:
54
+ """Async browser automation tool backed by Playwright.
55
+
56
+ Usage (async context manager)::
57
+
58
+ async with PlaywrightTool() as tool:
59
+ await tool.navigate("https://example.com")
60
+ result = await tool.screenshot()
61
+
62
+ Requires playwright to be installed::
63
+
64
+ pip install playwright && playwright install chromium
65
+ """
66
+
67
+ def __init__(self) -> None:
68
+ self._playwright: Any = None
69
+ self._browser: Any = None
70
+ self._page: Any = None
71
+
72
+ # -- Lifecycle -----------------------------------------------------------
73
+
74
+ @staticmethod
75
+ def _check_playwright() -> None:
76
+ """Raise ImportError with install hint if playwright is not available."""
77
+ if not _PLAYWRIGHT_AVAILABLE:
78
+ raise ImportError(
79
+ "Install playwright: pip install playwright && playwright install chromium"
80
+ )
81
+
82
+ async def __aenter__(self) -> "PlaywrightTool":
83
+ self._check_playwright()
84
+ self._playwright = await async_playwright().start()
85
+ self._browser = await self._playwright.chromium.launch(headless=True)
86
+ self._page = await self._browser.new_page()
87
+ return self
88
+
89
+ async def __aexit__(self, exc_type: Any, exc_val: Any, exc_tb: Any) -> None:
90
+ await self.close()
91
+
92
+ # -- Public async API ----------------------------------------------------
93
+
94
+ async def navigate(self, url: str) -> PlaywrightResult:
95
+ """Navigate to *url* and return the page title."""
96
+ self._check_playwright()
97
+ try:
98
+ await self._page.goto(url)
99
+ title = await self._page.title()
100
+ return PlaywrightResult(success=True, data=title)
101
+ except Exception as exc: # noqa: BLE001
102
+ return PlaywrightResult(success=False, error=str(exc))
103
+
104
+ async def screenshot(self, full_page: bool = False) -> PlaywrightResult:
105
+ """Take a screenshot of the current page.
106
+
107
+ Returns raw PNG bytes in ``result.screenshot``.
108
+ """
109
+ self._check_playwright()
110
+ try:
111
+ png_bytes = await self._page.screenshot(full_page=full_page)
112
+ return PlaywrightResult(success=True, screenshot=png_bytes)
113
+ except Exception as exc: # noqa: BLE001
114
+ return PlaywrightResult(success=False, error=str(exc))
115
+
116
+ async def click(self, selector: str) -> PlaywrightResult:
117
+ """Click the element matching *selector*."""
118
+ self._check_playwright()
119
+ try:
120
+ await self._page.click(selector)
121
+ return PlaywrightResult(success=True)
122
+ except Exception as exc: # noqa: BLE001
123
+ return PlaywrightResult(success=False, error=str(exc))
124
+
125
+ async def fill(self, selector: str, value: str) -> PlaywrightResult:
126
+ """Fill an input element matching *selector* with *value*."""
127
+ self._check_playwright()
128
+ try:
129
+ await self._page.fill(selector, value)
130
+ return PlaywrightResult(success=True)
131
+ except Exception as exc: # noqa: BLE001
132
+ return PlaywrightResult(success=False, error=str(exc))
133
+
134
+ async def evaluate(self, expression: str) -> PlaywrightResult:
135
+ """Evaluate a JavaScript *expression* in the page context.
136
+
137
+ Blocks dangerous patterns: ``fetch(``, ``XMLHttpRequest``,
138
+ ``eval(``, ``import(``.
139
+ """
140
+ self._check_playwright()
141
+ for pattern in _DANGEROUS_JS_PATTERNS:
142
+ if pattern in expression:
143
+ return PlaywrightResult(
144
+ success=False,
145
+ error=f"Unsafe JS pattern rejected: '{pattern}'",
146
+ )
147
+ try:
148
+ result = await self._page.evaluate(expression)
149
+ return PlaywrightResult(success=True, data=result)
150
+ except Exception as exc: # noqa: BLE001
151
+ return PlaywrightResult(success=False, error=str(exc))
152
+
153
+ async def get_text(self, selector: str = "body") -> PlaywrightResult:
154
+ """Return the inner text of *selector* (default: ``body``)."""
155
+ self._check_playwright()
156
+ try:
157
+ text = await self._page.inner_text(selector)
158
+ return PlaywrightResult(success=True, data=text)
159
+ except Exception as exc: # noqa: BLE001
160
+ return PlaywrightResult(success=False, error=str(exc))
161
+
162
+ async def close(self) -> None:
163
+ """Close the browser and clean up Playwright resources."""
164
+ try:
165
+ if self._browser is not None:
166
+ await self._browser.close()
167
+ self._browser = None
168
+ if self._playwright is not None:
169
+ await self._playwright.stop()
170
+ self._playwright = None
171
+ except Exception: # noqa: BLE001
172
+ pass