readwright 0.3.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.
readwright/helpers.py ADDED
@@ -0,0 +1,494 @@
1
+ """Miscellaneous template helpers: file inclusion, layout, links, project-specific bits."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import html
6
+ import json
7
+ import os
8
+ import re
9
+ import shlex
10
+ import subprocess
11
+ import textwrap
12
+ import tomllib
13
+ from collections.abc import Callable
14
+ from datetime import date
15
+ from pathlib import Path
16
+ from typing import Any
17
+ from urllib.parse import urlencode
18
+
19
+ import yaml
20
+
21
+ from readwright.config import Config
22
+
23
+ LOGO_DIRS = ("", "docs", "assets", "docs/assets", "docs/images", "img", "images")
24
+ LOGO_EXTENSIONS = ("svg", "png", "webp", "jpg")
25
+ VIDEO_EXTENSIONS = ("mp4", "webm", "mov", "gif")
26
+ MODS_TOML_PATHS = (
27
+ "src/main/resources/META-INF/neoforge.mods.toml",
28
+ "src/main/resources/META-INF/mods.toml",
29
+ "src/main/templates/META-INF/neoforge.mods.toml",
30
+ )
31
+ SPDX_URLS = "https://spdx.org/licenses/{id}.html"
32
+ UNSPLASH_CDN = "https://images.unsplash.com"
33
+ UNSPLASH_PHOTO = re.compile(r"(?:photo-)?(?P<id>\d{10,}-[0-9a-f]{10,})")
34
+ MY_HA = "https://my.home-assistant.io"
35
+
36
+
37
+ def _escape(text: str) -> str:
38
+ return html.escape(text, quote=True)
39
+
40
+
41
+ def fenced(text: str, language: str = "") -> str:
42
+ fence = "````" if "```" in text else "```"
43
+ return f"{fence}{language}\n{text.rstrip()}\n{fence}"
44
+
45
+
46
+ def md_table(headers: list[str], rows: list[list[str]]) -> str:
47
+ if not rows:
48
+ return ""
49
+ escape = lambda s: str(s).replace("|", "\\|").replace("\n", " ") # noqa: E731
50
+ lines = [
51
+ "| " + " | ".join(headers) + " |",
52
+ "| " + " | ".join("---" for _ in headers) + " |",
53
+ ]
54
+ lines += ["| " + " | ".join(escape(c) for c in row) + " |" for row in rows]
55
+ return "\n".join(lines)
56
+
57
+
58
+ def flatten(data: Any, prefix: str = "") -> list[tuple[str, str]]:
59
+ if isinstance(data, dict):
60
+ items: list[tuple[str, str]] = []
61
+ for key, value in data.items():
62
+ items += flatten(value, f"{prefix}{key}.")
63
+ return items
64
+ return [(prefix.rstrip("."), _scalar(data))]
65
+
66
+
67
+ def _scalar(value: Any) -> str:
68
+ if isinstance(value, list | tuple):
69
+ return ", ".join(_scalar(v) for v in value)
70
+ if isinstance(value, bool):
71
+ return "true" if value else "false"
72
+ if value is None:
73
+ return ""
74
+ return f"`{value}`"
75
+
76
+
77
+ class Helpers:
78
+ def __init__(self, root: Path, config: Config, warn: Callable[[str], None] | None = None):
79
+ self.root = Path(root)
80
+ self.config = config
81
+ self.warn = warn or (lambda message: None)
82
+
83
+ # ---------------------------------------------------------------- files
84
+ def _path(self, path: str) -> Path:
85
+ return self.root / path
86
+
87
+ def include_file(self, path: str) -> str:
88
+ target = self._path(path)
89
+ if not target.is_file():
90
+ self.warn(f"include_file: '{path}' not found")
91
+ return ""
92
+ return target.read_text().rstrip("\n")
93
+
94
+ def code_block(self, path: str, language: str | None = None) -> str:
95
+ content = self.include_file(path)
96
+ if not content:
97
+ return ""
98
+ lang = language if language is not None else self._path(path).suffix.lstrip(".")
99
+ return fenced(content, lang)
100
+
101
+ def snippet(
102
+ self,
103
+ path: str,
104
+ start: str,
105
+ end: str,
106
+ language: str | None = None,
107
+ dedent: bool = True,
108
+ ) -> str:
109
+ content = self.include_file(path)
110
+ if not content:
111
+ return ""
112
+ lines = content.splitlines()
113
+ try:
114
+ first = next(i for i, line in enumerate(lines) if start in line) + 1
115
+ last = next(i for i, line in enumerate(lines) if end in line and i >= first)
116
+ except StopIteration:
117
+ self.warn(f"snippet: markers '{start}'..'{end}' not found in {path}")
118
+ return ""
119
+ body = "\n".join(lines[first:last])
120
+ if dedent:
121
+ body = textwrap.dedent(body)
122
+ if language is None:
123
+ language = self._path(path).suffix.lstrip(".")
124
+ return fenced(body, language) if language else body
125
+
126
+ def cli_help(self, command: str, language: str = "text", strip_ansi: bool = True) -> str:
127
+ if not self.config.allow_exec:
128
+ raise PermissionError(
129
+ f"cli_help({command!r}) needs `allow_exec: true` in readme.yaml "
130
+ "(it runs the command during render)"
131
+ )
132
+ env = {**os.environ, "NO_COLOR": "1", "TERM": "dumb", "COLUMNS": "80"}
133
+ try:
134
+ result = subprocess.run(
135
+ shlex.split(command), capture_output=True, text=True, cwd=self.root, env=env
136
+ )
137
+ except FileNotFoundError:
138
+ message = f"cli_help: executable not found for {command!r}"
139
+ if self.config.strict:
140
+ raise FileNotFoundError(message) from None
141
+ self.warn(message)
142
+ return ""
143
+ output = result.stdout or result.stderr
144
+ if strip_ansi:
145
+ output = re.sub(r"\x1b\[[0-9;]*[A-Za-z]", "", output)
146
+ return fenced(output, language)
147
+
148
+ # --------------------------------------------------------------- tables
149
+ def _load_data(self, path: str) -> Any:
150
+ target = self._path(path)
151
+ if not target.is_file():
152
+ self.warn(f"'{path}' not found")
153
+ return None
154
+ suffix = target.suffix.lower()
155
+ text = target.read_text()
156
+ if suffix == ".toml":
157
+ return tomllib.loads(text)
158
+ if suffix == ".json":
159
+ return json.loads(text)
160
+ return yaml.safe_load(text)
161
+
162
+ def config_table(
163
+ self,
164
+ path: str,
165
+ section: str | None = None,
166
+ headers: tuple[str, str] = ("Key", "Value"),
167
+ ) -> str:
168
+ data = self._load_data(path)
169
+ if data is None:
170
+ return ""
171
+ for part in (section or "").split("."):
172
+ if part:
173
+ data = data.get(part, {}) if isinstance(data, dict) else {}
174
+ if not isinstance(data, dict):
175
+ self.warn(f"config_table: {path} section '{section}' is not a mapping")
176
+ return ""
177
+ return md_table(list(headers), [[k, v] for k, v in flatten(data)])
178
+
179
+ def env_table(self, path: str = ".env.example") -> str:
180
+ target = self._path(path)
181
+ if not target.is_file():
182
+ self.warn(f"env_table: '{path}' not found")
183
+ return ""
184
+ rows: list[list[str]] = []
185
+ description: list[str] = []
186
+ for raw in target.read_text().splitlines():
187
+ line = raw.strip()
188
+ if line.startswith("#"):
189
+ description.append(line.lstrip("# ").strip())
190
+ elif "=" in line:
191
+ key, _, value = line.partition("=")
192
+ rows.append(
193
+ [
194
+ f"`{key.strip()}`",
195
+ f"`{value.strip()}`" if value.strip() else "",
196
+ " ".join(description),
197
+ ]
198
+ )
199
+ description = []
200
+ else:
201
+ description = []
202
+ return md_table(["Variable", "Default", "Description"], rows)
203
+
204
+ # ---------------------------------------------------------------- links
205
+ def gh_link(self, path: str = "", text: str | None = None) -> str:
206
+ base = self.config.project.url
207
+ if not base:
208
+ raise ValueError("gh_link needs project.owner and project.repo")
209
+ url = f"{base}/{path.lstrip('/')}" if path else base
210
+ return f"[{text}]({url})" if text else url
211
+
212
+ def spdx_link(self, license_id: str | None = None) -> str:
213
+ spdx = license_id or self.config.project.license
214
+ if not spdx:
215
+ return ""
216
+ return f"[{spdx}]({SPDX_URLS.format(id=spdx)})"
217
+
218
+ def my_ha_link(self, redirect: str, text: str | None = None, **params: str) -> str:
219
+ url = f"{MY_HA}/redirect/{redirect}/"
220
+ if params:
221
+ url += "?" + urlencode(params)
222
+ if text:
223
+ return f"[{text}]({url})"
224
+ return f"[![Open your Home Assistant instance]({MY_HA}/badges/{redirect}.svg)]({url})"
225
+
226
+ # --------------------------------------------------------------- layout
227
+ @staticmethod
228
+ def details(summary: str, body: str, open: bool = False) -> str:
229
+ attr = " open" if open else ""
230
+ return f"<details{attr}>\n<summary>{summary}</summary>\n\n{body.strip()}\n\n</details>"
231
+
232
+ @staticmethod
233
+ def callout(kind: str, text: str) -> str:
234
+ kinds = {"note", "tip", "important", "warning", "caution"}
235
+ if kind.lower() not in kinds:
236
+ raise ValueError(f"callout kind must be one of {sorted(kinds)}")
237
+ body = "\n".join(f"> {line}" if line else ">" for line in text.strip().splitlines())
238
+ return f"> [!{kind.upper()}]\n{body}"
239
+
240
+ @staticmethod
241
+ def center(content: str) -> str:
242
+ """Centered block; the blank lines let GitHub render markdown inside the div."""
243
+ return f'<div align="center">\n\n{content.strip()}\n\n</div>'
244
+
245
+ @staticmethod
246
+ def columns(cells: list[str], align: str = "center") -> str:
247
+ row = "\n".join(f'<td align="{align}">\n\n{c.strip()}\n\n</td>' for c in cells)
248
+ return f"<table>\n<tr>\n{row}\n</tr>\n</table>"
249
+
250
+ def _find_asset(self, stem: str, extensions: tuple[str, ...]) -> Path | None:
251
+ for directory in LOGO_DIRS:
252
+ for ext in extensions:
253
+ candidate = self.root / directory / f"{stem}.{ext}"
254
+ if candidate.is_file():
255
+ return candidate
256
+ shots = self.root / self.config.screenshots.dir
257
+ for ext in extensions:
258
+ if (shots / f"{stem}.{ext}").is_file():
259
+ return shots / f"{stem}.{ext}"
260
+ return None
261
+
262
+ def logo(self, width: int | None = None, alt: str | None = None, name: str = "logo") -> str:
263
+ alt = alt or self.config.project.name or "logo"
264
+ dark = self._find_asset(f"{name}-dark", LOGO_EXTENSIONS)
265
+ light = self._find_asset(f"{name}-light", LOGO_EXTENSIONS)
266
+ single = self._find_asset(name, LOGO_EXTENSIONS)
267
+ rel = lambda p: p.relative_to(self.root).as_posix() # noqa: E731
268
+ if dark and light:
269
+ if width:
270
+ return (
271
+ "<picture>\n"
272
+ f' <source media="(prefers-color-scheme: dark)" srcset="{rel(dark)}">\n'
273
+ f' <source media="(prefers-color-scheme: light)" srcset="{rel(light)}">\n'
274
+ f' <img src="{rel(light)}" alt="{_escape(alt)}" width="{width}">\n'
275
+ "</picture>"
276
+ )
277
+ return (
278
+ f"![{alt}]({rel(light)}#gh-light-mode-only)![{alt}]({rel(dark)}#gh-dark-mode-only)"
279
+ )
280
+ src = single or light or dark
281
+ if src is None:
282
+ self.warn(f"logo: no {name}.{{svg,png,webp,jpg}} found")
283
+ return ""
284
+ if width:
285
+ return f'<img src="{rel(src)}" alt="{_escape(alt)}" width="{width}">'
286
+ return f"![{alt}]({rel(src)})"
287
+
288
+ def video(self, name: str, width: int | None = None, alt: str | None = None) -> str:
289
+ if "://" in name:
290
+ src = name
291
+ else:
292
+ found = self._find_asset(name, VIDEO_EXTENSIONS)
293
+ if found is None:
294
+ self.warn(f"video '{name}' not found")
295
+ return ""
296
+ src = found.relative_to(self.root).as_posix()
297
+ alt = alt or name
298
+ width_attr = f' width="{width}"' if width else ""
299
+ if src.lower().endswith(".gif"):
300
+ return f'<img src="{src}" alt="{_escape(alt)}"{width_attr}>'
301
+ return f'<video src="{src}"{width_attr} controls muted loop></video>'
302
+
303
+ def contributors(self, logins: list[str] | None = None, size: int = 64) -> str:
304
+ if logins is None:
305
+ rc = self.root / ".all-contributorsrc"
306
+ if not rc.is_file():
307
+ self.warn("contributors: pass a list or add .all-contributorsrc")
308
+ return ""
309
+ logins = [c["login"] for c in json.loads(rc.read_text()).get("contributors", [])]
310
+ return " ".join(
311
+ f"[![{login}](https://github.com/{login}.png?size={size})](https://github.com/{login})"
312
+ for login in logins
313
+ )
314
+
315
+ # -------------------------------------------------------------- project
316
+ def pyversions_list(self, sep: str = ", ") -> str:
317
+ return sep.join(self.config.project.python_versions)
318
+
319
+ def entry_points_table(self) -> str:
320
+ path = self.root / "pyproject.toml"
321
+ if not path.is_file():
322
+ return ""
323
+ scripts = tomllib.loads(path.read_text()).get("project", {}).get("scripts", {})
324
+ return md_table(
325
+ ["Command", "Entry point"], [[f"`{k}`", f"`{v}`"] for k, v in scripts.items()]
326
+ )
327
+
328
+ def flow_install_cmd(self) -> str:
329
+ name = self.config.project.flow_plugin or self.config.project.name
330
+ return fenced(f"pm install {name}", "text")
331
+
332
+ def mc_versions(self) -> str:
333
+ return self.config.project.minecraft_version or ""
334
+
335
+ def mod_dependencies(self) -> str:
336
+ for rel in MODS_TOML_PATHS:
337
+ path = self.root / rel
338
+ if path.is_file():
339
+ break
340
+ else:
341
+ self.warn("mod_dependencies: no neoforge.mods.toml / mods.toml found")
342
+ return ""
343
+ data = tomllib.loads(path.read_text())
344
+ rows = []
345
+ for deps in data.get("dependencies", {}).values():
346
+ for dep in deps:
347
+ rows.append(
348
+ [
349
+ f"`{dep.get('modId', '')}`",
350
+ dep.get("type", "required" if dep.get("mandatory", True) else "optional"),
351
+ f"`{dep.get('versionRange', '*')}`",
352
+ ]
353
+ )
354
+ return md_table(["Mod", "Type", "Version"], rows)
355
+
356
+ def related_repos(self) -> str:
357
+ rows = []
358
+ for item in self.config.related:
359
+ url = item.url or (
360
+ f"https://github.com/{item.repo}"
361
+ if "/" in item.repo
362
+ else f"https://github.com/{self.config.project.owner}/{item.repo}"
363
+ )
364
+ rows.append([f"[{item.repo}]({url})", item.description])
365
+ return md_table(["Repository", "Description"], rows)
366
+
367
+ # ------------------------------------------------------------- unsplash
368
+ def _utm(self) -> str:
369
+ source = (self.config.project.repo or self.config.project.name or "readwright").replace(
370
+ " ", "_"
371
+ )
372
+ return urlencode({"utm_source": source, "utm_medium": "referral"})
373
+
374
+ def unsplash(
375
+ self,
376
+ photo: str,
377
+ alt: str | None = None,
378
+ width: int | None = 1200,
379
+ height: int | None = None,
380
+ credit: str | None = None,
381
+ user: str | None = None,
382
+ photo_id: str | None = None,
383
+ link: str | None = None,
384
+ quality: int = 80,
385
+ html: bool = False,
386
+ ) -> str:
387
+ """Embed an Unsplash photo by CDN id or images.unsplash.com URL, with attribution.
388
+
389
+ `width`/`height` are what the CDN is asked for; the image is plain markdown unless
390
+ `html=True`, which also sets the <img> width attribute.
391
+ """
392
+ match = UNSPLASH_PHOTO.search(photo)
393
+ if not match:
394
+ raise ValueError(
395
+ f"unsplash: {photo!r} is not a photo id like 'photo-1518770660439-4636190af475' "
396
+ "or an images.unsplash.com URL (right-click the photo, copy image address)"
397
+ )
398
+ params: dict[str, object] = {"auto": "format", "fit": "crop", "q": quality}
399
+ if width:
400
+ params["w"] = width
401
+ if height:
402
+ params["h"] = height
403
+ src = f"{UNSPLASH_CDN}/photo-{match['id']}?{urlencode(params)}"
404
+ alt = alt or "Photo from Unsplash"
405
+ page = link or (f"https://unsplash.com/photos/{photo_id}" if photo_id else None)
406
+ if html:
407
+ width_attr = f' width="{width}"' if width else ""
408
+ image = f'<img src="{src}" alt="{_escape(alt)}"{width_attr}>'
409
+ if page:
410
+ image = f'<a href="{page}">{image}</a>'
411
+ else:
412
+ image = f"![{alt}]({src})"
413
+ if page:
414
+ image = f"[{image}]({page})"
415
+ if not credit:
416
+ self.warn(
417
+ f"unsplash: no credit given for photo {match['id']}; "
418
+ "Unsplash's license requires attribution (pass credit= and user=)"
419
+ )
420
+ return image
421
+ profile = (
422
+ f"https://unsplash.com/@{user}?{self._utm()}"
423
+ if user
424
+ else f"https://unsplash.com/?{self._utm()}"
425
+ )
426
+ site = f"https://unsplash.com/?{self._utm()}"
427
+ if html:
428
+ attribution = (
429
+ f'Photo by <a href="{profile}">{_escape(credit)}</a> on '
430
+ f'<a href="{site}">Unsplash</a>'
431
+ )
432
+ return f"{image}\n<br><sub>{attribution}</sub>"
433
+ return f"{image}\n\n*Photo by [{credit}]({profile}) on [Unsplash]({site})*"
434
+
435
+ def banner(self) -> str:
436
+ cfg = self.config.banner
437
+ if cfg is None:
438
+ return ""
439
+ if cfg.unsplash:
440
+ rendered = self.unsplash(
441
+ cfg.unsplash,
442
+ alt=cfg.alt,
443
+ width=cfg.width,
444
+ height=cfg.height,
445
+ credit=cfg.credit,
446
+ user=cfg.user,
447
+ photo_id=cfg.photo_id,
448
+ link=cfg.link,
449
+ html=cfg.html,
450
+ )
451
+ elif cfg.image:
452
+ alt = cfg.alt or self.config.project.name or "banner"
453
+ if "://" not in cfg.image and not (self.root / cfg.image).is_file():
454
+ self.warn(f"banner image '{cfg.image}' not found")
455
+ if cfg.html:
456
+ width_attr = f' width="{cfg.width}"' if cfg.width else ""
457
+ rendered = f'<img src="{cfg.image}" alt="{_escape(alt)}"{width_attr}>'
458
+ if cfg.link:
459
+ rendered = f'<a href="{cfg.link}">{rendered}</a>'
460
+ else:
461
+ rendered = f"![{alt}]({cfg.image})"
462
+ if cfg.link:
463
+ rendered = f"[{rendered}]({cfg.link})"
464
+ else:
465
+ self.warn("banner: set either 'unsplash' or 'image'")
466
+ return ""
467
+ return self.center(rendered) if cfg.html else rendered
468
+
469
+ # ----------------------------------------------------------------- meta
470
+ def _git(self, *args: str) -> str:
471
+ result = subprocess.run(
472
+ ["git", "-C", str(self.root), *args], capture_output=True, text=True, check=False
473
+ )
474
+ return result.stdout.strip()
475
+
476
+ def git_sha(self, short: bool = True) -> str:
477
+ return self._git("rev-parse", "--short" if short else "HEAD", *(["HEAD"] if short else []))
478
+
479
+ def git_tag(self) -> str:
480
+ return self._git("describe", "--tags", "--abbrev=0")
481
+
482
+ @staticmethod
483
+ def today(fmt: str = "%Y-%m-%d") -> str:
484
+ return date.today().strftime(fmt)
485
+
486
+ def as_globals(self) -> dict[str, Any]:
487
+ names = [
488
+ "include_file", "code_block", "snippet", "cli_help", "config_table", "env_table",
489
+ "gh_link", "spdx_link", "my_ha_link", "details", "callout", "center", "columns",
490
+ "logo", "video", "contributors", "pyversions_list", "entry_points_table",
491
+ "flow_install_cmd", "mc_versions", "mod_dependencies", "related_repos",
492
+ "git_sha", "git_tag", "today", "unsplash", "banner",
493
+ ] # fmt: skip
494
+ return {name: getattr(self, name) for name in names}