github-agent-cli 1.2.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.
ghalib/assets.py ADDED
@@ -0,0 +1,475 @@
1
+ """Repository imagery: banner and social preview, rendered locally and then checked.
2
+
3
+ Why this exists. A repository with no picture reads as abandoned, and the reference
4
+ project's README opens with a 1280x640 banner. The image is easy to forget and easy to
5
+ break, so it is generated from the repository's own metadata and then verified:
6
+
7
+ * the social preview must be PNG/JPG/GIF under 1 MB, at least 640x320, 1280x640 for
8
+ best display (GitHub documentation, "Customizing your repository's social media preview");
9
+ * an SVG is a fine README banner but cannot be uploaded as a social preview;
10
+ * a reference to a missing file renders as a broken box, so every referenced image is
11
+ resolved against the tree.
12
+
13
+ Rendering uses Pillow when it is installed (PNG), and always writes an SVG, which needs
14
+ nothing at all. A missing Pillow lowers the output format, never the honesty of the report.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import base64
20
+ import re
21
+ import struct
22
+ import zlib
23
+ from dataclasses import dataclass, field
24
+ from pathlib import Path
25
+
26
+ from .util import IMAGE_RE, Report, iter_text_files, read_text, rel
27
+
28
+ SOCIAL_SIZE = (1280, 640)
29
+ SOCIAL_MIN = (640, 320)
30
+ SOCIAL_MAX_BYTES = 1_000_000
31
+ IMAGE_SUFFIXES = {".png", ".jpg", ".jpeg", ".gif", ".svg", ".webp", ".avif"}
32
+ SOCIAL_ACCEPTED = {".png", ".jpg", ".jpeg", ".gif"}
33
+
34
+ PALETTE = {
35
+ "bg": "#0d1117",
36
+ "panel": "#161b22",
37
+ "line": "#30363d",
38
+ "text": "#e6edf3",
39
+ "muted": "#8b949e",
40
+ "accent": "#58a6ff",
41
+ "accent2": "#3fb950",
42
+ "warn": "#d29922",
43
+ }
44
+
45
+
46
+ # --------------------------------------------------------------------------
47
+ # image metadata without dependencies
48
+ # --------------------------------------------------------------------------
49
+
50
+ def image_size(path: Path) -> tuple[int, int] | None:
51
+ """Read width/height from the file header. Pure stdlib, no decoding."""
52
+ try:
53
+ raw = Path(path).read_bytes()[:64]
54
+ except OSError:
55
+ return None
56
+ if raw[:8] == b"\x89PNG\r\n\x1a\n" and raw[12:16] == b"IHDR":
57
+ width, height = struct.unpack(">II", raw[16:24])
58
+ return int(width), int(height)
59
+ if raw[:6] in (b"GIF87a", b"GIF89a"):
60
+ width, height = struct.unpack("<HH", raw[6:10])
61
+ return int(width), int(height)
62
+ if raw[:2] == b"\xff\xd8": # JPEG: walk the marker chain
63
+ try:
64
+ data = Path(path).read_bytes()
65
+ except OSError:
66
+ return None
67
+ index = 2
68
+ while index + 9 < len(data):
69
+ if data[index] != 0xFF:
70
+ index += 1
71
+ continue
72
+ marker = data[index + 1]
73
+ if marker in (0xC0, 0xC1, 0xC2, 0xC3, 0xC5, 0xC6, 0xC7, 0xC9, 0xCA, 0xCB, 0xCD, 0xCE, 0xCF):
74
+ height, width = struct.unpack(">HH", data[index + 5:index + 9])
75
+ return int(width), int(height)
76
+ if marker in (0xD8, 0xD9) or 0xD0 <= marker <= 0xD7:
77
+ index += 2
78
+ continue
79
+ length = struct.unpack(">H", data[index + 2:index + 4])[0]
80
+ index += 2 + length
81
+ return None
82
+ if raw[:4] == b"RIFF" and raw[8:12] == b"WEBP":
83
+ return None # not needed for the rules below
84
+ return None
85
+
86
+
87
+ def make_header_png(width: int, height: int) -> bytes:
88
+ """A structurally valid PNG with an IHDR of the given size (used by the self-tests)."""
89
+ def chunk(kind: bytes, payload: bytes) -> bytes:
90
+ return (struct.pack(">I", len(payload)) + kind + payload
91
+ + struct.pack(">I", zlib.crc32(kind + payload) & 0xFFFFFFFF))
92
+
93
+ ihdr = struct.pack(">IIBBBBB", width, height, 8, 2, 0, 0, 0)
94
+ idat = zlib.compress(b"\x00\x00\x00\x00")
95
+ return b"\x89PNG\r\n\x1a\n" + chunk(b"IHDR", ihdr) + chunk(b"IDAT", idat) + chunk(b"IEND", b"")
96
+
97
+
98
+ # --------------------------------------------------------------------------
99
+ # rendering
100
+ # --------------------------------------------------------------------------
101
+
102
+ @dataclass
103
+ class BannerSpec:
104
+ title: str
105
+ tagline: str = ""
106
+ command: str = ""
107
+ chips: list[str] = field(default_factory=list)
108
+ footer: str = ""
109
+ width: int = SOCIAL_SIZE[0]
110
+ height: int = SOCIAL_SIZE[1]
111
+
112
+ def normalised(self) -> "BannerSpec":
113
+ self.chips = [chip for chip in self.chips if chip][:6]
114
+ return self
115
+
116
+
117
+ def _escape(text: str) -> str:
118
+ return (text.replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;")
119
+ .replace('"', "&quot;"))
120
+
121
+
122
+ def _wrap(text: str, limit: int, max_lines: int) -> list[str]:
123
+ words = text.split()
124
+ lines: list[str] = []
125
+ current = ""
126
+ for word in words:
127
+ candidate = f"{current} {word}".strip()
128
+ if len(candidate) <= limit:
129
+ current = candidate
130
+ continue
131
+ if current:
132
+ lines.append(current)
133
+ current = word
134
+ if len(lines) == max_lines - 1:
135
+ break
136
+ if current and len(lines) < max_lines:
137
+ lines.append(current)
138
+ if not lines:
139
+ return []
140
+ remainder = " ".join(words)[len(" ".join(lines)):].strip()
141
+ if remainder and lines:
142
+ lines[-1] = lines[-1].rstrip(" .,;") + "…"
143
+ return lines
144
+
145
+
146
+ def render_svg(spec: BannerSpec) -> str:
147
+ spec = spec.normalised()
148
+ width, height = spec.width, spec.height
149
+ pad = 72
150
+ title_lines = _wrap(spec.title, 26, 2)
151
+ tagline_lines = _wrap(spec.tagline, 62, 3)
152
+ title_size = 64 if len(title_lines) <= 1 else 52
153
+ parts: list[str] = [
154
+ f'<svg xmlns="http://www.w3.org/2000/svg" width="{width}" height="{height}" '
155
+ f'viewBox="0 0 {width} {height}" role="img" aria-label="{_escape(spec.title)}">',
156
+ f' <rect width="{width}" height="{height}" fill="{PALETTE["bg"]}"/>',
157
+ f' <rect x="0" y="0" width="{width}" height="6" fill="{PALETTE["accent"]}"/>',
158
+ f' <rect x="{pad}" y="{pad}" width="{width - 2 * pad}" height="{height - 2 * pad}" '
159
+ f'rx="18" fill="{PALETTE["panel"]}" stroke="{PALETTE["line"]}"/>',
160
+ ]
161
+ y = pad + 96
162
+ for line in title_lines:
163
+ parts.append(
164
+ f' <text x="{pad + 48}" y="{y}" fill="{PALETTE["text"]}" font-size="{title_size}" '
165
+ f'font-weight="700" font-family="Segoe UI, Helvetica, Arial, sans-serif">{_escape(line)}</text>')
166
+ y += int(title_size * 1.18)
167
+ y += 10
168
+ for line in tagline_lines:
169
+ parts.append(
170
+ f' <text x="{pad + 48}" y="{y}" fill="{PALETTE["muted"]}" font-size="26" '
171
+ f'font-family="Segoe UI, Helvetica, Arial, sans-serif">{_escape(line)}</text>')
172
+ y += 36
173
+ if spec.command:
174
+ box_y = height - pad - 128
175
+ parts.append(
176
+ f' <rect x="{pad + 48}" y="{box_y}" width="{width - 2 * pad - 96}" height="56" rx="10" '
177
+ f'fill="{PALETTE["bg"]}" stroke="{PALETTE["line"]}"/>')
178
+ parts.append(
179
+ f' <text x="{pad + 72}" y="{box_y + 36}" fill="{PALETTE["accent2"]}" font-size="22" '
180
+ f'font-family="Consolas, Menlo, monospace">{_escape(spec.command)}</text>')
181
+ chip_x = pad + 48
182
+ chip_y = height - pad - 56
183
+ for chip in spec.chips:
184
+ chip_w = 14 + len(chip) * 12
185
+ parts.append(
186
+ f' <rect x="{chip_x}" y="{chip_y}" width="{chip_w}" height="32" rx="16" '
187
+ f'fill="none" stroke="{PALETTE["line"]}"/>')
188
+ parts.append(
189
+ f' <text x="{chip_x + 12}" y="{chip_y + 22}" fill="{PALETTE["muted"]}" font-size="16" '
190
+ f'font-family="Consolas, Menlo, monospace">{_escape(chip)}</text>')
191
+ chip_x += chip_w + 10
192
+ if spec.footer:
193
+ parts.append(
194
+ f' <text x="{width - pad - 48}" y="{height - 26}" text-anchor="end" fill="{PALETTE["muted"]}" '
195
+ f'font-size="18" font-family="Consolas, Menlo, monospace">{_escape(spec.footer)}</text>')
196
+ parts.append("</svg>")
197
+ return "\n".join(parts) + "\n"
198
+
199
+
200
+ def _font(size: int, bold: bool = False, mono: bool = False):
201
+ from PIL import ImageFont
202
+ candidates = []
203
+ if mono:
204
+ candidates += ["C:/Windows/Fonts/consola.ttf", "/usr/share/fonts/truetype/dejavu/DejaVuSansMono.ttf"]
205
+ if bold:
206
+ candidates += ["C:/Windows/Fonts/segoeuib.ttf", "/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf"]
207
+ candidates += ["C:/Windows/Fonts/segoeui.ttf", "/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf"]
208
+ for candidate in candidates:
209
+ if Path(candidate).exists():
210
+ return ImageFont.truetype(candidate, size)
211
+ return ImageFont.load_default()
212
+
213
+
214
+ def render_png(spec: BannerSpec) -> bytes | None:
215
+ """PNG bytes, or None when Pillow is not installed."""
216
+ try:
217
+ from PIL import Image, ImageDraw
218
+ except ImportError:
219
+ return None
220
+ spec = spec.normalised()
221
+ width, height = spec.width, spec.height
222
+ pad = 72
223
+ image = Image.new("RGB", (width, height), PALETTE["bg"])
224
+ draw = ImageDraw.Draw(image)
225
+ draw.rectangle([0, 0, width, 7], fill=PALETTE["accent"])
226
+ draw.rounded_rectangle([pad, pad, width - pad, height - pad], radius=18,
227
+ fill=PALETTE["panel"], outline=PALETTE["line"], width=2)
228
+
229
+ title_lines = _wrap(spec.title, 26, 2)
230
+ title_font = _font(64 if len(title_lines) <= 1 else 52, bold=True)
231
+ y = pad + 58
232
+ for line in title_lines:
233
+ draw.text((pad + 48, y), line, font=title_font, fill=PALETTE["text"])
234
+ y += title_font.size + 12
235
+ y += 6
236
+ tagline_font = _font(26)
237
+ for line in _wrap(spec.tagline, 62, 3):
238
+ draw.text((pad + 48, y), line, font=tagline_font, fill=PALETTE["muted"])
239
+ y += tagline_font.size + 10
240
+ if spec.command:
241
+ box_y = height - pad - 128
242
+ draw.rounded_rectangle([pad + 48, box_y, width - pad - 48, box_y + 56], radius=10,
243
+ fill=PALETTE["bg"], outline=PALETTE["line"], width=2)
244
+ draw.text((pad + 72, box_y + 15), spec.command, font=_font(22, mono=True), fill=PALETTE["accent2"])
245
+ chip_font = _font(16, mono=True)
246
+ chip_x = pad + 48
247
+ chip_y = height - pad - 56
248
+ for chip in spec.chips:
249
+ chip_w = 14 + int(chip_font.getlength(chip))
250
+ draw.rounded_rectangle([chip_x, chip_y, chip_x + chip_w, chip_y + 32], radius=16,
251
+ outline=PALETTE["line"], width=2)
252
+ draw.text((chip_x + 12, chip_y + 7), chip, font=chip_font, fill=PALETTE["muted"])
253
+ chip_x += chip_w + 10
254
+ if spec.footer:
255
+ footer_font = _font(18, mono=True)
256
+ draw.text((width - pad - 48 - footer_font.getlength(spec.footer), height - 44),
257
+ spec.footer, font=footer_font, fill=PALETTE["muted"])
258
+ from io import BytesIO
259
+ buffer = BytesIO()
260
+ image.save(buffer, format="PNG", optimize=True)
261
+ return buffer.getvalue()
262
+
263
+
264
+ def spec_from_repository(root: Path, *, title: str = "", tagline: str = "", command: str = "",
265
+ chips: list[str] | None = None) -> BannerSpec:
266
+ from . import meta as meta_module
267
+ facts = meta_module.derive(root)
268
+ readme = Path(root) / "README.md"
269
+ summary = ""
270
+ if readme.exists():
271
+ text, _ = read_text(readme)
272
+ for block in text.split("\n\n"):
273
+ stripped = block.strip()
274
+ if stripped.startswith("**") and stripped.endswith("**") and len(stripped) > 20:
275
+ summary = stripped.strip("*").strip()
276
+ break
277
+ if not summary:
278
+ summary = facts.get("summary", "")
279
+ languages = [language for language, _count in facts.get("languages", [])[:3]]
280
+ return BannerSpec(
281
+ title=title or facts.get("title") or Path(root).name,
282
+ tagline=tagline or summary[:180],
283
+ command=command,
284
+ chips=(chips if chips is not None else languages + ["mit"])[:6],
285
+ footer=" · ".join(filter(None, ["MIT", ", ".join(languages)])),
286
+ )
287
+
288
+
289
+ # --------------------------------------------------------------------------
290
+ # writing, checking, suggesting
291
+ # --------------------------------------------------------------------------
292
+
293
+ def write_assets(root: Path, *, out_dir: str = "assets", spec: BannerSpec | None = None,
294
+ write: bool = False, report: Report | None = None) -> Report:
295
+ root = Path(root)
296
+ report = report or Report(title="ghagent assets render", root=str(root))
297
+ spec = (spec or spec_from_repository(root)).normalised()
298
+ target = root / out_dir
299
+ svg_path = target / "social-preview.svg"
300
+ png_path = target / "social-preview.png"
301
+ svg = render_svg(spec)
302
+ png = render_png(spec)
303
+
304
+ report.data.update({
305
+ "svg": rel(svg_path, root),
306
+ "png": rel(png_path, root) if png else "",
307
+ "size": f"{spec.width}x{spec.height}",
308
+ "written": [],
309
+ "png_unavailable": "" if png else "Pillow is not installed: only the SVG was produced",
310
+ })
311
+ if not png:
312
+ report.warn("assets.no-pillow", "Pillow is not available, so no PNG was rendered",
313
+ hint="a social preview must be PNG/JPG/GIF; install Pillow or supply your own image")
314
+ if not write:
315
+ report.info("assets.dry-run", f"would write {rel(svg_path, root)} and {rel(png_path, root)}",
316
+ hint="pass --write to create the files")
317
+ return report
318
+
319
+ target.mkdir(parents=True, exist_ok=True)
320
+ svg_path.write_bytes(svg.replace("\r\n", "\n").encode("utf-8"))
321
+ written = [rel(svg_path, root)]
322
+ if png:
323
+ png_path.write_bytes(png)
324
+ written.append(rel(png_path, root))
325
+ report.data["written"] = written
326
+ report.info("assets.written", f"{len(written)} file(s) under {out_dir}/")
327
+ return report
328
+
329
+
330
+ HTML_IMG_RE = re.compile(r"<img\b[^>]*>", re.IGNORECASE)
331
+ HTML_ATTR_RE = re.compile(r"\b(src|alt)\s*=\s*[\"']([^\"']*)[\"']", re.IGNORECASE)
332
+
333
+
334
+ def readme_references(readme: Path) -> list[tuple[str, str, int]]:
335
+ """(alt, target, line) for every image in a markdown file, markdown or HTML."""
336
+ if not Path(readme).exists():
337
+ return []
338
+ text, _ = read_text(readme)
339
+ found: list[tuple[str, str, int]] = []
340
+ for number, line in enumerate(text.split("\n"), start=1):
341
+ for match in IMAGE_RE.finditer(line):
342
+ found.append((match.group(1), match.group(2), number))
343
+ for match in HTML_IMG_RE.finditer(line):
344
+ attributes = {name.lower(): value for name, value in HTML_ATTR_RE.findall(match.group(0))}
345
+ if attributes.get("src"):
346
+ found.append((attributes.get("alt", ""), attributes["src"], number))
347
+ return found
348
+
349
+
350
+ def check(root: Path, *, report: Report | None = None) -> Report:
351
+ root = Path(root)
352
+ report = report or Report(title="ghagent assets check", root=str(root))
353
+ readme = root / "README.md"
354
+ if not readme.exists():
355
+ report.error("assets.no-readme", "README.md is absent, so no image reference can be examined")
356
+ return report
357
+
358
+ references = readme_references(readme)
359
+ if not references:
360
+ report.error("assets.readme-banner", "README references no image",
361
+ hint="render one with `ghagent assets render --write` and put it above the first section")
362
+ relative_targets: list[str] = []
363
+ for alt, target, line in references:
364
+ if not alt.strip():
365
+ report.warn("assets.alt", f"image without alt text: {target}", path="README.md", line=line)
366
+ if target.startswith(("http://", "//")):
367
+ report.warn("assets.insecure-url", f"image over a plain-http URL: {target}",
368
+ path="README.md", line=line)
369
+ continue
370
+ if target.startswith("https://"):
371
+ continue
372
+ relative_targets.append(target)
373
+ resolved = (root / target.split("?")[0].split("#")[0]).resolve()
374
+ if not resolved.exists():
375
+ report.error("assets.missing-file", f"README references an image that does not exist: {target}",
376
+ path="README.md", line=line,
377
+ hint="commit the file or fix the path; a missing image renders as a broken box")
378
+ continue
379
+ suffix = resolved.suffix.lower()
380
+ size = image_size(resolved)
381
+ if suffix in SOCIAL_ACCEPTED and size:
382
+ report.info("assets.dimensions", f"{target}: {size[0]}x{size[1]}", path="README.md")
383
+
384
+ assets_dir = root / "assets"
385
+ candidates = sorted(p for p in assets_dir.glob("*") if p.suffix.lower() in IMAGE_SUFFIXES) if assets_dir.is_dir() else []
386
+ if not candidates:
387
+ report.warn("assets.no-directory", "no assets/ directory with an image",
388
+ hint="`ghagent assets render --write` creates assets/social-preview.png and .svg")
389
+ report.data["social_preview_ready"] = False
390
+ return report
391
+
392
+ preview = next((p for p in candidates if p.stem.lower() in {"social-preview", "social_preview", "preview"}), None)
393
+ if preview is None:
394
+ report.warn("assets.social-preview", "no file named social-preview.* under assets/",
395
+ hint="the social preview is what link cards show; name it social-preview")
396
+ report.data["social_preview_ready"] = False
397
+ return report
398
+
399
+ size_bytes = preview.stat().st_size
400
+ if preview.suffix.lower() not in SOCIAL_ACCEPTED:
401
+ report.warn("assets.svg-social", f"{rel(preview, root)} cannot be uploaded as a social preview",
402
+ hint="GitHub accepts PNG, JPG or GIF; keep the SVG for the README and add a PNG")
403
+ if size_bytes > SOCIAL_MAX_BYTES:
404
+ report.error("assets.social-size", f"{rel(preview, root)} is {size_bytes} bytes, over the 1 MB limit",
405
+ hint="compress the image; the platform rejects anything larger")
406
+ dimensions = image_size(preview)
407
+ if dimensions is None:
408
+ report.warn("assets.social-dimensions", f"cannot read the size of {rel(preview, root)}",
409
+ hint="PNG, JPEG and GIF headers are readable; other formats are not")
410
+ else:
411
+ width, height = dimensions
412
+ if width < SOCIAL_MIN[0] or height < SOCIAL_MIN[1]:
413
+ report.error("assets.social-dimensions",
414
+ f"{rel(preview, root)} is {width}x{height}, below the 640x320 minimum")
415
+ elif (width, height) != SOCIAL_SIZE:
416
+ report.warn("assets.social-dimensions",
417
+ f"{rel(preview, root)} is {width}x{height}; 1280x640 renders best")
418
+ else:
419
+ report.info("assets.social-dimensions", f"{rel(preview, root)} is 1280x640")
420
+
421
+ ready = (preview.suffix.lower() in SOCIAL_ACCEPTED and size_bytes <= SOCIAL_MAX_BYTES
422
+ and dimensions is not None and dimensions[0] >= SOCIAL_MIN[0] and dimensions[1] >= SOCIAL_MIN[1])
423
+ report.data.update({
424
+ "social_preview_ready": ready,
425
+ "social_preview_file": rel(preview, root),
426
+ "social_preview_bytes": size_bytes,
427
+ "social_preview_dimensions": list(dimensions) if dimensions else None,
428
+ "images_in_assets": len(candidates),
429
+ "readme_references": len(references),
430
+ })
431
+ if ready:
432
+ report.info("assets.upload-hint",
433
+ "upload this file in Settings → Social preview; the file in the repository is not used automatically")
434
+ return report
435
+
436
+
437
+ RECOMMENDATIONS = {
438
+ "banner": "1280x640 social preview: it is what link cards in chat and search show",
439
+ "diagram": "one architecture diagram; prefer a Mermaid block over a picture, because it stays editable",
440
+ "screenshot": "one screenshot of the main screen at 1440 px wide, with personal data removed",
441
+ "terminal": "one terminal capture of the primary command, as a code block rather than an image",
442
+ }
443
+
444
+
445
+ def suggest(root: Path, *, report: Report | None = None) -> Report:
446
+ root = Path(root)
447
+ report = report or Report(title="ghagent assets suggest", root=str(root))
448
+ from . import meta as meta_module
449
+ facts = meta_module.derive(root)
450
+ languages = [language for language, _count in facts.get("languages", [])]
451
+ wanted: list[dict] = [{"kind": "banner", "why": RECOMMENDATIONS["banner"]}]
452
+ if any(language in {"php", "python", "javascript", "typescript", "csharp", "go", "rust"} for language in languages):
453
+ wanted.append({"kind": "diagram", "why": RECOMMENDATIONS["diagram"]})
454
+ readme = root / "README.md"
455
+ has_ui = False
456
+ if readme.exists():
457
+ text, _ = read_text(readme)
458
+ has_ui = any(word in text.lower() for word in ("интерфейс", "ui", "screenshot", "панель", "screen"))
459
+ if has_ui:
460
+ wanted.append({"kind": "screenshot", "why": RECOMMENDATIONS["screenshot"]})
461
+ wanted.append({"kind": "terminal", "why": RECOMMENDATIONS["terminal"]})
462
+ present = set()
463
+ assets_dir = root / "assets"
464
+ if assets_dir.is_dir():
465
+ present = {p.stem.lower() for p in assets_dir.glob("*") if p.suffix.lower() in IMAGE_SUFFIXES}
466
+ for entry in wanted:
467
+ entry["present"] = entry["kind"] in present or (entry["kind"] == "banner" and "social-preview" in present)
468
+ report.data.update({"recommended": wanted, "present_in_assets": sorted(present)})
469
+ missing = [entry["kind"] for entry in wanted if not entry["present"]]
470
+ if missing:
471
+ report.info("assets.missing", "no image for: " + ", ".join(missing),
472
+ hint="render the banner with `assets render --write`; the rest needs a human decision")
473
+ else:
474
+ report.info("assets.complete", "every recommended image kind is present")
475
+ return report