answer42 0.2.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.
mcp_1c/recorder.py ADDED
@@ -0,0 +1,239 @@
1
+ from __future__ import annotations
2
+
3
+ import base64
4
+ import html
5
+ import json
6
+ import os
7
+ import subprocess
8
+ from dataclasses import dataclass, field
9
+ from datetime import datetime
10
+ from pathlib import Path
11
+ from typing import Any
12
+
13
+
14
+ @dataclass
15
+ class RecordingSession:
16
+ active: bool = False
17
+ output_dir: Path = Path("build/recordings/session")
18
+ display: str = ""
19
+ window: bool = False
20
+ frames_dir: Path = Path("build/recordings/session/frames")
21
+ slides_dir: Path = Path("build/recordings/session/slides")
22
+ events: list[dict[str, Any]] = field(default_factory=list)
23
+
24
+ def start(self, output_dir: str, display: str = "", window: bool = False) -> dict[str, Any]:
25
+ self.active = True
26
+ self.output_dir = Path(output_dir)
27
+ self.display = display
28
+ self.window = window
29
+ self.frames_dir = self.output_dir / "frames"
30
+ self.slides_dir = self.output_dir / "slides"
31
+ self.events = []
32
+ self.frames_dir.mkdir(parents=True, exist_ok=True)
33
+ self.slides_dir.mkdir(parents=True, exist_ok=True)
34
+ # MP4/GIF generation is intentionally unsupported. Remove stale files
35
+ # from older runs so evidence directories reflect current outputs only.
36
+ for obsolete in ("recording.mp4", "recording.gif"):
37
+ try:
38
+ (self.output_dir / obsolete).unlink()
39
+ except FileNotFoundError:
40
+ pass
41
+ except OSError:
42
+ pass
43
+ return {"active": True, "output_dir": str(self.output_dir)}
44
+
45
+ def stop(self) -> dict[str, Any]:
46
+ self.active = False
47
+ manifest = self.output_dir / "manifest.json"
48
+ manifest.parent.mkdir(parents=True, exist_ok=True)
49
+ manifest.write_text(json.dumps({"events": self.events}, ensure_ascii=False, indent=2), encoding="utf-8")
50
+ media = self.build_media()
51
+ return {"active": False, "output_dir": str(self.output_dir), "events": len(self.events), **media}
52
+
53
+ def capture(self, screenshot_func, action: str, params: dict[str, Any], result: Any, *, screenshot: bool = True) -> dict[str, Any] | None:
54
+ if not self.active:
55
+ return None
56
+ index = len(self.events) + 1
57
+ slide = self.slides_dir / f"{index:03d}_{safe_name(action)}.svg"
58
+ safe_params = redact(params)
59
+ safe_result = redact(result)
60
+ frame = self.frames_dir / f"{index:03d}_{safe_name(action)}.png"
61
+ if screenshot:
62
+ try:
63
+ screenshot_func(str(frame), self.window, self.display)
64
+ except Exception as exc:
65
+ event = {"index": index, "action": action, "params": params, "result": summarize(result), "screenshot_error": str(exc)}
66
+ self.events.append(event)
67
+ return event
68
+ slide.write_text(make_slide_svg(frame, action, safe_params, safe_result, index), encoding="utf-8")
69
+ else:
70
+ slide.write_text(make_text_slide_svg(action, safe_params, safe_result, index), encoding="utf-8")
71
+ event = {
72
+ "index": index,
73
+ "action": action,
74
+ "params": safe_params,
75
+ "result": summarize(safe_result),
76
+ "slide": str(slide),
77
+ }
78
+ if screenshot:
79
+ event["frame"] = str(frame)
80
+ else:
81
+ event["text_only"] = True
82
+ self.events.append(event)
83
+ return event
84
+
85
+ def build_media(self) -> dict[str, Any]:
86
+ slides = sorted(self.slides_dir.glob("*.svg"))
87
+ if not slides:
88
+ return {"pdf": ""}
89
+ pdf = self.output_dir / "recording.pdf"
90
+ html_deck = self.output_dir / "recording.html"
91
+ html_deck.write_text(make_html_deck(slides), encoding="utf-8")
92
+ browser = first_existing(["google-chrome", "chromium", "chromium-browser", "chrome.exe", "msedge.exe"])
93
+ if browser:
94
+ run([
95
+ browser,
96
+ "--headless=new",
97
+ "--no-sandbox",
98
+ "--disable-gpu",
99
+ f"--print-to-pdf={pdf.resolve()}",
100
+ str(html_deck.resolve()),
101
+ ], timeout=240)
102
+ if not pdf.exists():
103
+ converter_names = ["magick", "magick.exe"] if os.name == "nt" else ["magick", "convert"]
104
+ converter = first_existing(converter_names)
105
+ if converter:
106
+ run([converter, *map(str, slides), str(pdf)], timeout=240)
107
+ if html_deck.exists():
108
+ try:
109
+ html_deck.unlink()
110
+ except OSError:
111
+ pass
112
+ return {"pdf": str(pdf) if pdf.exists() else ""}
113
+
114
+
115
+ def make_html_deck(slides: list[Path]) -> str:
116
+ pages = []
117
+ for slide in slides:
118
+ data = slide.read_text(encoding="utf-8")
119
+ pages.append(f'<section class="page">{data}</section>')
120
+ return '''<!doctype html>
121
+ <html><head><meta charset="utf-8"><style>
122
+ @page { size: 16in 9in; margin: 0; }
123
+ html, body { margin: 0; padding: 0; background: #111827; }
124
+ .page { width: 16in; height: 9in; page-break-after: always; overflow: hidden; }
125
+ .page svg { width: 16in; height: 9in; display: block; }
126
+ </style></head><body>''' + "\n".join(pages) + "</body></html>"
127
+
128
+
129
+ def run(args: list[str], timeout: int = 180) -> None:
130
+ try:
131
+ subprocess.run(args, check=False, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, timeout=timeout)
132
+ except Exception:
133
+ pass
134
+
135
+
136
+ def first_existing(names: list[str]) -> str:
137
+ from shutil import which
138
+ for name in names:
139
+ path = which(name)
140
+ if path:
141
+ return path
142
+ if os.name == "nt":
143
+ env_paths = [
144
+ os.getenv("ProgramFiles"),
145
+ os.getenv("ProgramFiles(x86)"),
146
+ os.getenv("LocalAppData"),
147
+ ]
148
+ candidates = []
149
+ for root in filter(None, env_paths):
150
+ base = Path(str(root))
151
+ candidates.extend([
152
+ base / "Google/Chrome/Application/chrome.exe",
153
+ base / "Microsoft/Edge/Application/msedge.exe",
154
+ base / "Programs/Microsoft VS Code/chrome.exe",
155
+ ])
156
+ for candidate in candidates:
157
+ if candidate.exists():
158
+ return str(candidate)
159
+ return ""
160
+
161
+
162
+ SENSITIVE_KEYS = {"password", "pwd", "pass", "secret", "token", "authorization", "auth"}
163
+
164
+
165
+ def redact(value: Any) -> Any:
166
+ if isinstance(value, dict):
167
+ result = {}
168
+ for key, item in value.items():
169
+ if str(key).lower() in SENSITIVE_KEYS or "password" in str(key).lower():
170
+ result[key] = "***"
171
+ else:
172
+ result[key] = redact(item)
173
+ return result
174
+ if isinstance(value, list):
175
+ return [redact(item) for item in value]
176
+ return value
177
+
178
+
179
+ def safe_name(value: str) -> str:
180
+ result = "".join(ch if ch.isalnum() or ch in "._-" else "_" for ch in value)[:80]
181
+ return result or "action"
182
+
183
+
184
+ def summarize(value: Any, limit: int = 900) -> str:
185
+ try:
186
+ text = json.dumps(value, ensure_ascii=False, default=str)
187
+ except Exception:
188
+ text = str(value)
189
+ return text if len(text) <= limit else text[:limit] + "…"
190
+
191
+
192
+ def make_slide_svg(frame: Path, action: str, params: dict[str, Any], result: Any, index: int) -> str:
193
+ data = base64.b64encode(frame.read_bytes()).decode("ascii")
194
+ param_text = summarize(params, 700)
195
+ result_text = summarize(result, 900)
196
+ now = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
197
+ return f'''<svg xmlns="http://www.w3.org/2000/svg" width="1920" height="1080" viewBox="0 0 1920 1080">
198
+ <rect width="1920" height="1080" fill="#111827"/>
199
+ <rect x="24" y="24" width="1344" height="1032" rx="18" fill="#0b1220"/>
200
+ <image x="48" y="48" width="1296" height="972" href="data:image/png;base64,{data}" preserveAspectRatio="xMidYMid meet"/>
201
+ <rect x="1392" y="24" width="504" height="1032" rx="18" fill="#f8fafc"/>
202
+ <text x="1424" y="78" font-family="Arial" font-size="28" font-weight="700" fill="#111827">#{index} {html.escape(action)}</text>
203
+ <text x="1424" y="116" font-family="Arial" font-size="16" fill="#64748b">{html.escape(now)}</text>
204
+ <text x="1424" y="170" font-family="Arial" font-size="22" font-weight="700" fill="#0f172a">Параметры</text>
205
+ {multiline_text(param_text, 1424, 204, 440, 17, '#334155')}
206
+ <text x="1424" y="550" font-family="Arial" font-size="22" font-weight="700" fill="#0f172a">Результат</text>
207
+ {multiline_text(result_text, 1424, 584, 440, 17, '#334155')}
208
+ </svg>'''
209
+
210
+
211
+ def make_text_slide_svg(action: str, params: dict[str, Any], result: Any, index: int) -> str:
212
+ param_text = summarize(params, 1800)
213
+ result_text = summarize(result, 3000)
214
+ now = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
215
+ return f'''<svg xmlns="http://www.w3.org/2000/svg" width="1920" height="1080" viewBox="0 0 1920 1080">
216
+ <rect width="1920" height="1080" fill="#111827"/>
217
+ <rect x="48" y="40" width="1824" height="1000" rx="18" fill="#f8fafc"/>
218
+ <text x="88" y="96" font-family="Arial" font-size="32" font-weight="700" fill="#111827">#{index} {html.escape(action)}</text>
219
+ <text x="88" y="134" font-family="Arial" font-size="16" fill="#64748b">{html.escape(now)} · text-only MCP evidence (no UI screenshot)</text>
220
+ <text x="88" y="196" font-family="Arial" font-size="24" font-weight="700" fill="#0f172a">Параметры</text>
221
+ {multiline_text(param_text, 88, 232, 1720, 18, '#334155', max_lines=14)}
222
+ <text x="88" y="552" font-family="Arial" font-size="24" font-weight="700" fill="#0f172a">Результат</text>
223
+ {multiline_text(result_text, 88, 588, 1720, 18, '#334155', max_lines=20)}
224
+ </svg>'''
225
+
226
+
227
+ def multiline_text(text: str, x: int, y: int, width: int, font_size: int, color: str, max_lines: int = 22) -> str:
228
+ chars = max(20, width // int(font_size * 0.55))
229
+ lines: list[str] = []
230
+ for raw in text.splitlines() or [""]:
231
+ s = raw
232
+ while len(s) > chars:
233
+ lines.append(s[:chars])
234
+ s = s[chars:]
235
+ lines.append(s)
236
+ out = []
237
+ for i, line in enumerate(lines[:max_lines]):
238
+ out.append(f'<text x="{x}" y="{y + i * (font_size + 5)}" font-family="Arial" font-size="{font_size}" fill="{color}">{html.escape(line)}</text>')
239
+ return "\n ".join(out)
@@ -0,0 +1,83 @@
1
+ """Release helper for Answer42 source checkouts."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import re
7
+ import subprocess
8
+ from pathlib import Path
9
+
10
+ SEMVER_RE = re.compile(r"^\d+\.\d+\.\d+(?:[A-Za-z0-9_.+-]*)?$")
11
+
12
+
13
+ def _run(cmd: list[str], *, dry_run: bool) -> None:
14
+ print("+", " ".join(cmd))
15
+ if not dry_run:
16
+ subprocess.run(cmd, check=True)
17
+
18
+
19
+ def _git_output(args: list[str]) -> str:
20
+ return subprocess.check_output(["git", *args], text=True).strip()
21
+
22
+
23
+ def _replace_version(pyproject: Path, version: str) -> bool:
24
+ text = pyproject.read_text(encoding="utf-8")
25
+ pattern = r'(?m)^version\s*=\s*"([^"]+)"\s*$'
26
+ match = re.search(pattern, text)
27
+ if not match:
28
+ raise RuntimeError(f"Could not find project.version in {pyproject}")
29
+ if match.group(1) == version:
30
+ return False
31
+ new_text, count = re.subn(pattern, f'version = "{version}"', text, count=1)
32
+ if count != 1:
33
+ raise RuntimeError(f"Could not replace project.version in {pyproject}")
34
+ pyproject.write_text(new_text, encoding="utf-8")
35
+ return True
36
+
37
+
38
+ def main(argv: list[str] | None = None) -> int:
39
+ parser = argparse.ArgumentParser(
40
+ description="Bump pyproject.toml version, commit, tag and push to trigger PyPI release CI"
41
+ )
42
+ parser.add_argument("version", help="Version without leading v, e.g. 0.2.1")
43
+ parser.add_argument("--remote", default="origin", help="Git remote to push. Default: origin")
44
+ parser.add_argument("--branch", default="HEAD", help="Branch/ref to push. Default: HEAD")
45
+ parser.add_argument("--dry-run", action="store_true", help="Print commands without executing them")
46
+ parser.add_argument("--no-push", action="store_true", help="Create commit/tag locally but do not push")
47
+ parser.add_argument("--allow-dirty", action="store_true", help="Allow unrelated uncommitted changes")
48
+ args = parser.parse_args(argv)
49
+
50
+ version = args.version.removeprefix("v")
51
+ if not SEMVER_RE.match(version):
52
+ raise SystemExit(f"Invalid version {args.version!r}; expected e.g. 0.2.1")
53
+ tag = f"v{version}"
54
+
55
+ repo_root = Path(_git_output(["rev-parse", "--show-toplevel"]))
56
+ pyproject = repo_root / "pyproject.toml"
57
+ status = _git_output(["status", "--porcelain"])
58
+ if status and not args.allow_dirty:
59
+ raise SystemExit(
60
+ "Working tree is not clean. Commit/stash changes first, or pass --allow-dirty.\n" + status
61
+ )
62
+ existing_tags = _git_output(["tag", "--list", tag])
63
+ if existing_tags:
64
+ raise SystemExit(f"Tag already exists: {tag}")
65
+
66
+ print(f"Bumping {pyproject} to {version}")
67
+ changed = True
68
+ if not args.dry_run:
69
+ changed = _replace_version(pyproject, version)
70
+ else:
71
+ print(f"+ update {pyproject}: version = {version!r}")
72
+
73
+ if changed:
74
+ _run(["git", "add", str(pyproject.relative_to(repo_root))], dry_run=args.dry_run)
75
+ _run(["git", "commit", "-m", f"Release {tag}"], dry_run=args.dry_run)
76
+ else:
77
+ print(f"Version is already {version}; tagging current HEAD without a version commit.")
78
+ _run(["git", "tag", "-a", tag, "-m", f"Release {tag}"], dry_run=args.dry_run)
79
+ if not args.no_push:
80
+ _run(["git", "push", args.remote, args.branch], dry_run=args.dry_run)
81
+ _run(["git", "push", args.remote, tag], dry_run=args.dry_run)
82
+ print("CI will publish PyPI package and create GitLab Release from the pushed tag.")
83
+ return 0