kiwimatecoder 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 (104) hide show
  1. kiwimatecoder/__init__.py +8 -0
  2. kiwimatecoder/__main__.py +12 -0
  3. kiwimatecoder/acp/__init__.py +13 -0
  4. kiwimatecoder/acp/protocol.py +115 -0
  5. kiwimatecoder/acp/server.py +823 -0
  6. kiwimatecoder/acp/stdio.py +32 -0
  7. kiwimatecoder/agent.py +1162 -0
  8. kiwimatecoder/ai.py +68 -0
  9. kiwimatecoder/audit.py +65 -0
  10. kiwimatecoder/browser.py +261 -0
  11. kiwimatecoder/catalog.py +445 -0
  12. kiwimatecoder/checkpoints.py +103 -0
  13. kiwimatecoder/client.py +820 -0
  14. kiwimatecoder/clipboard.py +99 -0
  15. kiwimatecoder/commands.py +6757 -0
  16. kiwimatecoder/config.py +5335 -0
  17. kiwimatecoder/diagnostics.py +295 -0
  18. kiwimatecoder/documents.py +299 -0
  19. kiwimatecoder/evals/__init__.py +39 -0
  20. kiwimatecoder/evals/cases.py +230 -0
  21. kiwimatecoder/evals/render.py +46 -0
  22. kiwimatecoder/evals/runner.py +319 -0
  23. kiwimatecoder/events.py +95 -0
  24. kiwimatecoder/forge.py +172 -0
  25. kiwimatecoder/git.py +164 -0
  26. kiwimatecoder/headless.py +255 -0
  27. kiwimatecoder/hooks.py +170 -0
  28. kiwimatecoder/hunks.py +260 -0
  29. kiwimatecoder/i18n.py +368 -0
  30. kiwimatecoder/images.py +195 -0
  31. kiwimatecoder/index/__init__.py +14 -0
  32. kiwimatecoder/index/builder.py +376 -0
  33. kiwimatecoder/index/embeddings.py +151 -0
  34. kiwimatecoder/index/search.py +247 -0
  35. kiwimatecoder/index/store.py +251 -0
  36. kiwimatecoder/instructions.py +83 -0
  37. kiwimatecoder/jobs.py +594 -0
  38. kiwimatecoder/lsp/__init__.py +56 -0
  39. kiwimatecoder/lsp/client.py +548 -0
  40. kiwimatecoder/lsp/manager.py +353 -0
  41. kiwimatecoder/lsp/protocol.py +79 -0
  42. kiwimatecoder/main.py +4258 -0
  43. kiwimatecoder/mcp/__init__.py +45 -0
  44. kiwimatecoder/mcp/client.py +385 -0
  45. kiwimatecoder/mcp/errors.py +7 -0
  46. kiwimatecoder/mcp/manager.py +253 -0
  47. kiwimatecoder/mcp/protocol.py +132 -0
  48. kiwimatecoder/media.py +904 -0
  49. kiwimatecoder/memory.py +141 -0
  50. kiwimatecoder/network.py +112 -0
  51. kiwimatecoder/notify.py +139 -0
  52. kiwimatecoder/permissions.py +171 -0
  53. kiwimatecoder/pickers.py +417 -0
  54. kiwimatecoder/plugins.py +245 -0
  55. kiwimatecoder/pricing.py +167 -0
  56. kiwimatecoder/prompts.py +213 -0
  57. kiwimatecoder/providers.py +342 -0
  58. kiwimatecoder/redaction.py +66 -0
  59. kiwimatecoder/remote.py +294 -0
  60. kiwimatecoder/repl.py +1388 -0
  61. kiwimatecoder/routing.py +65 -0
  62. kiwimatecoder/sandbox.py +169 -0
  63. kiwimatecoder/sdk.py +146 -0
  64. kiwimatecoder/session.py +574 -0
  65. kiwimatecoder/share.py +386 -0
  66. kiwimatecoder/shell.py +660 -0
  67. kiwimatecoder/skills.py +138 -0
  68. kiwimatecoder/sync.py +513 -0
  69. kiwimatecoder/team.py +249 -0
  70. kiwimatecoder/telemetry.py +392 -0
  71. kiwimatecoder/templates.py +108 -0
  72. kiwimatecoder/tools/__init__.py +190 -0
  73. kiwimatecoder/tools/ask.py +62 -0
  74. kiwimatecoder/tools/base.py +69 -0
  75. kiwimatecoder/tools/browser.py +261 -0
  76. kiwimatecoder/tools/edit_file.py +143 -0
  77. kiwimatecoder/tools/forge.py +207 -0
  78. kiwimatecoder/tools/git.py +158 -0
  79. kiwimatecoder/tools/image.py +300 -0
  80. kiwimatecoder/tools/io.py +346 -0
  81. kiwimatecoder/tools/list_dir.py +67 -0
  82. kiwimatecoder/tools/lsp.py +254 -0
  83. kiwimatecoder/tools/memory.py +115 -0
  84. kiwimatecoder/tools/paths.py +234 -0
  85. kiwimatecoder/tools/read_file.py +113 -0
  86. kiwimatecoder/tools/registry.py +91 -0
  87. kiwimatecoder/tools/run_bash.py +189 -0
  88. kiwimatecoder/tools/search.py +224 -0
  89. kiwimatecoder/tools/selection.py +108 -0
  90. kiwimatecoder/tools/shell.py +158 -0
  91. kiwimatecoder/tools/skill.py +46 -0
  92. kiwimatecoder/tools/task.py +61 -0
  93. kiwimatecoder/tools/todo.py +77 -0
  94. kiwimatecoder/tools/web.py +146 -0
  95. kiwimatecoder/tools/write_file.py +83 -0
  96. kiwimatecoder/ui.py +189 -0
  97. kiwimatecoder/updater.py +502 -0
  98. kiwimatecoder/web.py +499 -0
  99. kiwimatecoder-0.1.0.dist-info/METADATA +2016 -0
  100. kiwimatecoder-0.1.0.dist-info/RECORD +104 -0
  101. kiwimatecoder-0.1.0.dist-info/WHEEL +5 -0
  102. kiwimatecoder-0.1.0.dist-info/entry_points.txt +2 -0
  103. kiwimatecoder-0.1.0.dist-info/licenses/LICENSE +21 -0
  104. kiwimatecoder-0.1.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,295 @@
1
+ """Environment, config, and provider diagnostics for ``/doctor``."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import importlib.metadata
6
+ import os
7
+ import platform
8
+ import sys
9
+ from dataclasses import dataclass
10
+ from pathlib import Path
11
+
12
+ from rich.console import Console
13
+ from rich.markup import escape
14
+ from rich.table import Table
15
+
16
+ from kiwimatecoder import __version__, audit, catalog, config
17
+ from kiwimatecoder.providers import ProviderConfig
18
+ from kiwimatecoder.session import Session
19
+
20
+ OK = "ok"
21
+ WARN = "warn"
22
+ FAIL = "fail"
23
+
24
+
25
+ @dataclass(frozen=True)
26
+ class Check:
27
+ """One diagnostic result."""
28
+
29
+ name: str
30
+ status: str
31
+ detail: str
32
+
33
+
34
+ def _git_branch(root: Path) -> str | None:
35
+ head = root / ".git" / "HEAD"
36
+ if not head.is_file():
37
+ return None
38
+ try:
39
+ ref = head.read_text(encoding="utf-8").strip()
40
+ except OSError:
41
+ return None
42
+ if ref.startswith("ref: refs/heads/"):
43
+ return ref.split("/")[-1]
44
+ return ref[:7] or None
45
+
46
+
47
+ def _package_version(name: str) -> str:
48
+ try:
49
+ return importlib.metadata.version(name)
50
+ except importlib.metadata.PackageNotFoundError:
51
+ return "not installed"
52
+
53
+
54
+ def _config_checks() -> list[Check]:
55
+ checks: list[Check] = []
56
+ try:
57
+ config_dir = config.ensure_config_dir()
58
+ except OSError as exc:
59
+ return [Check("Config dir", FAIL, f"cannot create {config.CONFIG_DIR}: {exc}")]
60
+
61
+ writable = os.access(config_dir, os.W_OK)
62
+ checks.append(
63
+ Check(
64
+ "Config dir",
65
+ OK if writable else FAIL,
66
+ f"{config_dir}" + ("" if writable else " (not writable)"),
67
+ )
68
+ )
69
+
70
+ if config.CONFIG_FILE.is_file():
71
+ checks.append(Check("Global config", OK, str(config.CONFIG_FILE)))
72
+ else:
73
+ checks.append(Check("Global config", WARN, "not created yet (defaults in use)"))
74
+
75
+ project_path = config.project_config_path()
76
+ if project_path is not None:
77
+ checks.append(Check("Project config", OK, str(project_path)))
78
+ else:
79
+ candidate = Path.cwd() / config.PROJECT_CONFIG_NAME
80
+ checks.append(Check("Project config", OK, f"none (optional; {candidate} not found)"))
81
+
82
+ from kiwimatecoder import team as team_module
83
+
84
+ team_settings = config.get_team()
85
+ if not team_settings["policy_path"]:
86
+ checks.append(Check("Team policy", OK, "none (optional)"))
87
+ else:
88
+ issues = team_module.policy_issues()
89
+ errors = [issue for issue in issues if issue["level"] == "error"]
90
+ detail = (
91
+ f"{team_settings['policy_path']} "
92
+ f"({'enforced' if team_settings['enforce'] else 'advisory'})"
93
+ )
94
+ if errors:
95
+ status = FAIL
96
+ detail += f" — {errors[0]['message']}"
97
+ elif issues:
98
+ status = WARN
99
+ detail += f" — {issues[0]['message']}"
100
+ else:
101
+ status = OK
102
+ checks.append(Check("Team policy", status, detail))
103
+
104
+ sessions = config.ensure_config_dir() / "sessions"
105
+ count = len(list(sessions.glob("*.json"))) if sessions.is_dir() else 0
106
+ checks.append(Check("Saved sessions", OK, f"{count} in {sessions}"))
107
+
108
+ audit_path = audit.audit_log_path()
109
+ if audit_path.exists():
110
+ try:
111
+ size = audit_path.stat().st_size
112
+ except OSError:
113
+ size = 0
114
+ checks.append(Check("Audit log", OK, f"{audit_path} ({size} bytes)"))
115
+ else:
116
+ checks.append(Check("Audit log", OK, f"{audit_path} (not created yet)"))
117
+
118
+ from kiwimatecoder import telemetry as telemetry_module
119
+
120
+ telemetry_settings = config.get_telemetry()
121
+ crash_count = len(telemetry_module.crash_report_paths())
122
+ checks.append(
123
+ Check(
124
+ "Telemetry",
125
+ OK,
126
+ f"level {telemetry_settings['level']} "
127
+ f"({'on' if telemetry_settings['enabled'] else 'off'}) — "
128
+ f"log {telemetry_module.current_log_path()}; "
129
+ f"{crash_count} crash report(s)",
130
+ )
131
+ )
132
+ return checks
133
+
134
+
135
+ def _provider_checks(session: Session) -> list[Check]:
136
+ checks: list[Check] = []
137
+ provider: ProviderConfig = session.provider
138
+ checks.append(
139
+ Check("Provider", OK, f"{provider.id} ({provider.name}) — {provider.base_url}")
140
+ )
141
+ key_source = config.key_source(provider.id)
142
+ if provider.needs_key and key_source["origin"] == "missing":
143
+ checks.append(
144
+ Check(
145
+ "API key",
146
+ WARN,
147
+ f"missing — export {provider.key_env} or run "
148
+ f"`kiwimatecoder setup --provider {provider.id}`",
149
+ )
150
+ )
151
+ else:
152
+ checks.append(Check("API key", OK, config.describe_key(provider.id)))
153
+
154
+ if provider.is_local:
155
+ running = catalog.probe(provider, api_key=config.get_key(provider.id))
156
+ checks.append(
157
+ Check(
158
+ "Local server",
159
+ OK if running else WARN,
160
+ f"{provider.base_url} " + ("is responding" if running else "did not answer"),
161
+ )
162
+ )
163
+
164
+ try:
165
+ model_catalog = config.get_model_catalog(provider.id)
166
+ detail = f"{len(model_catalog.models)} models ({model_catalog.source})"
167
+ if model_catalog.error:
168
+ detail += f" — {model_catalog.error}"
169
+ checks.append(
170
+ Check(
171
+ "Model catalog",
172
+ WARN if model_catalog.error else OK,
173
+ detail,
174
+ )
175
+ )
176
+ if not session.model:
177
+ checks.append(
178
+ Check(
179
+ "Current model",
180
+ WARN,
181
+ "none chosen — pick one with /model or "
182
+ f"`kiwimatecoder config model set <model> --provider {provider.id}`",
183
+ )
184
+ )
185
+ elif model_catalog.models:
186
+ if session.model in model_catalog.models:
187
+ checks.append(Check("Current model", OK, session.model))
188
+ else:
189
+ checks.append(
190
+ Check(
191
+ "Current model",
192
+ WARN,
193
+ f"{session.model} is not in the current catalog (still usable by name)",
194
+ )
195
+ )
196
+ except Exception as exc: # pragma: no cover - defensive
197
+ checks.append(Check("Model catalog", FAIL, str(exc)))
198
+
199
+ for fallback in session.active_providers[1:]:
200
+ if not session.model_for(fallback.id):
201
+ checks.append(
202
+ Check(
203
+ "Fallback model",
204
+ WARN,
205
+ f"none chosen for {fallback.id}, so failover skips it — run "
206
+ f"`kiwimatecoder config model set <model> --provider {fallback.id}`",
207
+ )
208
+ )
209
+ return checks
210
+
211
+
212
+ def _workspace_checks(session: Session) -> list[Check]:
213
+ root = session.workspace_root
214
+ checks: list[Check] = []
215
+ if not root.exists():
216
+ return [Check("Workspace", FAIL, f"{root} does not exist")]
217
+ writable = os.access(root, os.W_OK)
218
+ branch = _git_branch(root)
219
+ detail = str(root) + (f" (git:{branch})" if branch else "")
220
+ checks.append(Check("Workspace", OK if writable else WARN, detail))
221
+ checks.append(
222
+ Check(
223
+ "Permission mode",
224
+ OK,
225
+ f"{session.mode.value}; always-allowed: "
226
+ + (", ".join(sorted(session.always_allowed)) or "none"),
227
+ )
228
+ )
229
+ style = session.output_style
230
+ custom = "custom prompt set" if session.custom_system_prompt else "no custom prompt"
231
+ checks.append(Check("Output style", OK, f"{style} ({custom})"))
232
+ sampling = config.get_sampling()
233
+ checks.append(
234
+ Check(
235
+ "Sampling",
236
+ OK,
237
+ ", ".join(f"{key}={value}" for key, value in sampling.items())
238
+ or "provider defaults",
239
+ )
240
+ )
241
+ return checks
242
+
243
+
244
+ def run_checks(session: Session) -> list[Check]:
245
+ """Collect all diagnostic checks, never raising."""
246
+ checks: list[Check] = [
247
+ Check(
248
+ "Version",
249
+ OK,
250
+ f"kiwimatecoder {__version__} (Python {platform.python_version()}, "
251
+ f"{sys.platform})",
252
+ ),
253
+ Check(
254
+ "Dependencies",
255
+ OK,
256
+ "httpx " + _package_version("httpx")
257
+ + ", rich " + _package_version("rich")
258
+ + ", prompt_toolkit " + _package_version("prompt_toolkit")
259
+ + ", typer " + _package_version("typer"),
260
+ ),
261
+ ]
262
+ checks.extend(_config_checks())
263
+ try:
264
+ checks.extend(_provider_checks(session))
265
+ except Exception as exc: # pragma: no cover - defensive
266
+ checks.append(Check("Provider", FAIL, str(exc)))
267
+ checks.extend(_workspace_checks(session))
268
+ return checks
269
+
270
+
271
+ _STATUS_STYLE = {OK: "green", WARN: "yellow", FAIL: "red"}
272
+
273
+
274
+ def render(checks: list[Check], console: Console) -> None:
275
+ """Print checks as a table and a one-line summary."""
276
+ table = Table(title="KiwiMateCoder diagnostics", show_header=True)
277
+ table.add_column("Check", style="cyan", no_wrap=True)
278
+ table.add_column("Status")
279
+ table.add_column("Details")
280
+ for check in checks:
281
+ style = _STATUS_STYLE.get(check.status, "white")
282
+ table.add_row(
283
+ escape(check.name),
284
+ f"[{style}]{check.status}[/{style}]",
285
+ escape(check.detail),
286
+ )
287
+ console.print(table)
288
+ failures = sum(1 for check in checks if check.status == FAIL)
289
+ warnings = sum(1 for check in checks if check.status == WARN)
290
+ if failures:
291
+ console.print(f"[red]{failures} check(s) failed.[/red]")
292
+ elif warnings:
293
+ console.print(f"[yellow]{warnings} warning(s); nothing critical.[/yellow]")
294
+ else:
295
+ console.print("[green]All checks passed.[/green]")
@@ -0,0 +1,299 @@
1
+ """Pure extractors for notebooks, PDFs, and docx files.
2
+
3
+ ``read_file`` delegates to :func:`extract_document` before its binary check so
4
+ structured documents are surfaced as text instead of being rejected. Every
5
+ extractor is best-effort and never raises: failures come back as a clear,
6
+ model-readable string. Output is capped at :data:`MAX_CHARS` with a truncation
7
+ note.
8
+
9
+ PDF support degrades gracefully: the ``pdftotext`` CLI (poppler) is used when
10
+ available, otherwise a conservative stdlib fallback decompresses FlateDecode
11
+ streams and pulls text out of ``BT``/``ET`` blocks. If neither yields text, the
12
+ result explains how to install poppler.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import base64
18
+ import html
19
+ import json
20
+ import re
21
+ import shutil
22
+ import subprocess
23
+ import zipfile
24
+ import zlib
25
+ from pathlib import Path
26
+ from typing import Any
27
+
28
+ MAX_CHARS = 200_000
29
+ DOCUMENT_SUFFIXES = (".ipynb", ".pdf", ".docx")
30
+ PDFTOTEXT_TIMEOUT = 30
31
+
32
+ PDF_INSTALL_HINT = (
33
+ "Error: no extractable text found in this PDF. Install pdftotext (poppler) "
34
+ "for reliable PDF text extraction, or convert the pages to images and "
35
+ "attach them with view_image."
36
+ )
37
+
38
+
39
+ def _truncate(text: str, source: str) -> str:
40
+ if len(text) <= MAX_CHARS:
41
+ return text
42
+ return (
43
+ text[:MAX_CHARS]
44
+ + f"\n... [truncated: {source} exceeds {MAX_CHARS:,} characters]"
45
+ )
46
+
47
+
48
+ # ---------------------------------------------------------------------------
49
+ # Jupyter notebooks (.ipynb)
50
+ # ---------------------------------------------------------------------------
51
+
52
+
53
+ def _join_text(value: Any) -> str:
54
+ if isinstance(value, list):
55
+ return "".join(str(part) for part in value)
56
+ return str(value or "")
57
+
58
+
59
+ def _image_payload_size(value: Any) -> int:
60
+ if isinstance(value, (bytes, bytearray)):
61
+ return len(value)
62
+ text = str(value)
63
+ try:
64
+ return len(base64.b64decode(text, validate=False))
65
+ except ValueError:
66
+ return len(text)
67
+
68
+
69
+ def _format_notebook_output(output: dict[str, Any]) -> list[str]:
70
+ output_type = str(output.get("output_type") or "")
71
+ if output_type == "stream":
72
+ text = _join_text(output.get("text")).rstrip("\n")
73
+ return ["Output:", text] if text else []
74
+ if output_type == "error":
75
+ name = str(output.get("ename") or "Error")
76
+ value = str(output.get("evalue") or "")
77
+ return [f"Output error: {name}: {value}".rstrip(": ")]
78
+ data = output.get("data")
79
+ if not isinstance(data, dict):
80
+ return []
81
+ rendered: list[str] = []
82
+ text_plain = data.get("text/plain")
83
+ if text_plain:
84
+ rendered.append(_join_text(text_plain).rstrip("\n"))
85
+ for mime, value in data.items():
86
+ if not isinstance(mime, str) or not mime.startswith("image/"):
87
+ continue
88
+ rendered.append(f"[{mime} output, {_image_payload_size(value)} bytes]")
89
+ return ["Output:", *rendered] if rendered else []
90
+
91
+
92
+ def extract_notebook(path: str | Path) -> str:
93
+ """Render a ``.ipynb`` file as Markdown-ish text with cell outputs."""
94
+ notebook_path = Path(path)
95
+ try:
96
+ raw = notebook_path.read_text(encoding="utf-8")
97
+ except OSError as exc:
98
+ return f"Error: could not read notebook '{notebook_path}': {exc}"
99
+ except UnicodeDecodeError as exc:
100
+ return f"Error: '{notebook_path.name}' is not readable UTF-8 text: {exc}"
101
+ try:
102
+ data = json.loads(raw)
103
+ except json.JSONDecodeError as exc:
104
+ return f"Error: '{notebook_path.name}' is not valid notebook JSON: {exc}"
105
+ if not isinstance(data, dict):
106
+ return (
107
+ f"Error: '{notebook_path.name}' is not a notebook "
108
+ "(expected a JSON object)."
109
+ )
110
+ cells = data.get("cells")
111
+ if not isinstance(cells, list):
112
+ return f"Error: '{notebook_path.name}' has no 'cells' list."
113
+
114
+ lines: list[str] = []
115
+ for index, cell in enumerate(cells, 1):
116
+ if not isinstance(cell, dict):
117
+ continue
118
+ cell_type = str(cell.get("cell_type") or "unknown")
119
+ lines.append(f"## Cell {index} ({cell_type})")
120
+ source = _join_text(cell.get("source")).rstrip("\n")
121
+ if source:
122
+ lines.append(source)
123
+ for output in cell.get("outputs") or []:
124
+ if isinstance(output, dict):
125
+ lines.extend(_format_notebook_output(output))
126
+ body = "\n".join(lines).rstrip() + "\n" if lines else ""
127
+ return _truncate(body, notebook_path.name)
128
+
129
+
130
+ # ---------------------------------------------------------------------------
131
+ # Word documents (.docx)
132
+ # ---------------------------------------------------------------------------
133
+
134
+
135
+ def extract_docx(path: str | Path) -> str:
136
+ """Extract text from ``word/document.xml`` inside a ``.docx`` archive."""
137
+ docx_path = Path(path)
138
+ try:
139
+ with zipfile.ZipFile(docx_path) as archive:
140
+ try:
141
+ xml_bytes = archive.read("word/document.xml")
142
+ except KeyError:
143
+ return (
144
+ f"Error: '{docx_path.name}' is not a valid docx file "
145
+ "(missing word/document.xml)."
146
+ )
147
+ except zipfile.BadZipFile:
148
+ return f"Error: '{docx_path.name}' is not a valid docx file (not a zip)."
149
+ except OSError as exc:
150
+ return f"Error: could not read docx '{docx_path.name}': {exc}"
151
+ try:
152
+ xml_text = xml_bytes.decode("utf-8")
153
+ except UnicodeDecodeError as exc:
154
+ return f"Error: '{docx_path.name}' has unreadable document.xml: {exc}"
155
+
156
+ # Preserve structure before stripping tags: paragraphs and line breaks
157
+ # become newlines, tabs become tabs. Entities are decoded last so text like
158
+ # ``&lt;w:p&gt;`` cannot turn into a fake tag.
159
+ xml_text = re.sub(r"</w:p\s*>", "\n", xml_text)
160
+ xml_text = re.sub(r"<w:br\s*/?>", "\n", xml_text)
161
+ xml_text = re.sub(r"<w:tab\s*/?>", "\t", xml_text)
162
+ text = re.sub(r"<[^>]+>", "", xml_text)
163
+ text = html.unescape(text)
164
+ lines = [line.rstrip() for line in text.splitlines()]
165
+ body = "\n".join(lines).strip()
166
+ return _truncate(body + "\n" if body else "", docx_path.name)
167
+
168
+
169
+ # ---------------------------------------------------------------------------
170
+ # PDFs
171
+ # ---------------------------------------------------------------------------
172
+
173
+ _PDF_STREAM_RE = re.compile(rb"stream\r?\n(.*?)\r?\nendstream", re.DOTALL)
174
+ _PDF_STRING_RE = re.compile(r"\((?:\\.|[^\\()])*\)", re.DOTALL)
175
+ _PDF_ESCAPES = {"n": "\n", "r": "\r", "t": "\t", "b": "\b", "f": "\f"}
176
+
177
+
178
+ def _inflate(data: bytes) -> bytes | None:
179
+ for wbits in (zlib.MAX_WBITS, -zlib.MAX_WBITS):
180
+ try:
181
+ return zlib.decompress(data, wbits)
182
+ except zlib.error:
183
+ continue
184
+ return None
185
+
186
+
187
+ def _unescape_pdf_string(value: str) -> str:
188
+ result: list[str] = []
189
+ index = 0
190
+ length = len(value)
191
+ while index < length:
192
+ char = value[index]
193
+ if char != "\\" or index + 1 >= length:
194
+ result.append(char)
195
+ index += 1
196
+ continue
197
+ nxt = value[index + 1]
198
+ if nxt in "\\()":
199
+ result.append(nxt)
200
+ index += 2
201
+ elif nxt in _PDF_ESCAPES:
202
+ result.append(_PDF_ESCAPES[nxt])
203
+ index += 2
204
+ elif nxt.isdigit():
205
+ digits = nxt
206
+ index += 2
207
+ while index < length and len(digits) < 3 and value[index].isdigit():
208
+ digits += value[index]
209
+ index += 1
210
+ result.append(chr(int(digits, 8) & 0xFF))
211
+ else:
212
+ index += 2
213
+ return "".join(result)
214
+
215
+
216
+ def _extract_pdf_text(stream: bytes) -> str:
217
+ """Pull literal strings out of ``BT``/``ET`` blocks of a content stream."""
218
+ text = stream.decode("latin-1", "replace")
219
+ lines: list[str] = []
220
+ for block in re.findall(r"BT(.*?)ET", text, flags=re.DOTALL):
221
+ pieces = [
222
+ _unescape_pdf_string(match.group(0)[1:-1])
223
+ for match in _PDF_STRING_RE.finditer(block)
224
+ ]
225
+ joined = " ".join(piece for piece in pieces if piece)
226
+ if joined:
227
+ lines.append(joined)
228
+ return "\n".join(lines)
229
+
230
+
231
+ def _pdf_fallback(data: bytes) -> str:
232
+ """Conservative stdlib extraction: inflate streams, then read BT/ET text."""
233
+ chunks: list[str] = []
234
+ for match in _PDF_STREAM_RE.finditer(data):
235
+ raw = match.group(1)
236
+ decoded = _inflate(raw)
237
+ if decoded is None:
238
+ decoded = raw
239
+ chunk = _extract_pdf_text(decoded)
240
+ if chunk:
241
+ chunks.append(chunk)
242
+ return "\n".join(chunks).strip()
243
+
244
+
245
+ def extract_pdf(path: str | Path) -> str:
246
+ """Extract PDF text via ``pdftotext`` when present, else the fallback.
247
+
248
+ Never raises; returns :data:`PDF_INSTALL_HINT` when nothing parseable is
249
+ found.
250
+ """
251
+ pdf_path = Path(path)
252
+ executable = shutil.which("pdftotext")
253
+ if executable:
254
+ try:
255
+ completed = subprocess.run(
256
+ # PDF first, "-" as the output file means stdout. (Reversing
257
+ # these would make pdftotext read stdin and overwrite the PDF.)
258
+ [executable, str(pdf_path), "-"],
259
+ capture_output=True,
260
+ timeout=PDFTOTEXT_TIMEOUT,
261
+ check=False,
262
+ )
263
+ except (OSError, subprocess.TimeoutExpired) as exc:
264
+ return f"Error: pdftotext failed: {exc}"
265
+ if completed.returncode == 0:
266
+ stdout = completed.stdout.decode("utf-8", "replace")
267
+ if stdout.strip():
268
+ return _truncate(stdout, pdf_path.name)
269
+
270
+ try:
271
+ data = pdf_path.read_bytes()
272
+ except OSError as exc:
273
+ return f"Error: could not read PDF '{pdf_path.name}': {exc}"
274
+ text = _pdf_fallback(data)
275
+ if text.strip():
276
+ return _truncate(text + "\n", pdf_path.name)
277
+ return PDF_INSTALL_HINT
278
+
279
+
280
+ # ---------------------------------------------------------------------------
281
+ # Dispatch
282
+ # ---------------------------------------------------------------------------
283
+
284
+
285
+ def extract_document(path: str | Path) -> tuple[str, str] | None:
286
+ """Dispatch by suffix, returning ``(content, kind)`` or None.
287
+
288
+ ``kind`` is one of ``"notebook"``, ``"pdf"``, or ``"docx"``. Content may be
289
+ an error string when the file is malformed; callers surface it as-is.
290
+ """
291
+ document_path = Path(path)
292
+ suffix = document_path.suffix.lower()
293
+ if suffix == ".ipynb":
294
+ return extract_notebook(document_path), "notebook"
295
+ if suffix == ".pdf":
296
+ return extract_pdf(document_path), "pdf"
297
+ if suffix == ".docx":
298
+ return extract_docx(document_path), "docx"
299
+ return None
@@ -0,0 +1,39 @@
1
+ """Declarative eval harness for prompt/tool regressions."""
2
+
3
+ from kiwimatecoder.evals.cases import (
4
+ DEFAULT_CASES_DIR,
5
+ CaseError,
6
+ EvalCase,
7
+ Expectation,
8
+ discover_cases,
9
+ load_case,
10
+ parse_case,
11
+ resolve_cases_dir,
12
+ )
13
+ from kiwimatecoder.evals.runner import (
14
+ EvalReport,
15
+ EvalResult,
16
+ EvalTimeout,
17
+ evaluate_case,
18
+ materialize_case,
19
+ run_case,
20
+ run_suite,
21
+ )
22
+
23
+ __all__ = [
24
+ "DEFAULT_CASES_DIR",
25
+ "CaseError",
26
+ "EvalCase",
27
+ "EvalReport",
28
+ "EvalResult",
29
+ "EvalTimeout",
30
+ "Expectation",
31
+ "discover_cases",
32
+ "evaluate_case",
33
+ "load_case",
34
+ "materialize_case",
35
+ "parse_case",
36
+ "resolve_cases_dir",
37
+ "run_case",
38
+ "run_suite",
39
+ ]