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/__init__.py +8 -0
- readwright/badges.py +323 -0
- readwright/changelog.py +46 -0
- readwright/cli.py +397 -0
- readwright/config.py +230 -0
- readwright/helpers.py +494 -0
- readwright/images.py +217 -0
- readwright/metadata.py +292 -0
- readwright/py.typed +0 -0
- readwright/renderer.py +183 -0
- readwright/templates/base.md.j2 +21 -0
- readwright/templates/partials/badges.md.j2 +1 -0
- readwright/templates/partials/contributing.md.j2 +7 -0
- readwright/templates/partials/donate.md.j2 +1 -0
- readwright/templates/partials/header.md.j2 +10 -0
- readwright/templates/partials/install.md.j2 +46 -0
- readwright/templates/partials/license.md.j2 +5 -0
- readwright/templates/partials/screenshots.md.j2 +5 -0
- readwright/templates/partials/usage.md.j2 +5 -0
- readwright/toc.py +60 -0
- readwright-0.3.0.dist-info/METADATA +188 -0
- readwright-0.3.0.dist-info/RECORD +25 -0
- readwright-0.3.0.dist-info/WHEEL +4 -0
- readwright-0.3.0.dist-info/entry_points.txt +2 -0
- readwright-0.3.0.dist-info/licenses/LICENSE +21 -0
readwright/__init__.py
ADDED
readwright/badges.py
ADDED
|
@@ -0,0 +1,323 @@
|
|
|
1
|
+
"""Badge presets and shields.io URL building."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
from collections.abc import Callable
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
from urllib.parse import quote, urlencode
|
|
9
|
+
|
|
10
|
+
from readwright.config import BadgeSpec, Config, CustomBadge
|
|
11
|
+
|
|
12
|
+
SHIELDS = "https://img.shields.io"
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _escape_static(text: str) -> str:
|
|
16
|
+
return quote(text.replace("-", "--").replace("_", "__"), safe="")
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def badge_markdown(alt: str, image_url: str, link: str | None) -> str:
|
|
20
|
+
image = f""
|
|
21
|
+
return f"[{image}]({link})" if link else image
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def shield(
|
|
25
|
+
label: str,
|
|
26
|
+
message: str,
|
|
27
|
+
color: str = "blue",
|
|
28
|
+
link: str | None = None,
|
|
29
|
+
logo: str | None = None,
|
|
30
|
+
style: str | None = None,
|
|
31
|
+
) -> str:
|
|
32
|
+
url = f"{SHIELDS}/badge/{_escape_static(label)}-{_escape_static(message)}-{color}"
|
|
33
|
+
params = {k: v for k, v in (("logo", logo), ("style", style)) if v}
|
|
34
|
+
if params:
|
|
35
|
+
url += "?" + urlencode(params)
|
|
36
|
+
return badge_markdown(label, url, link)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
IMAGE_URL = re.compile(r"!\[[^\]]*\]\((https://img\.shields\.io/[^)\s]+)\)")
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def apply_style(markdown: str, style: str | None) -> str:
|
|
43
|
+
if not style:
|
|
44
|
+
return markdown
|
|
45
|
+
|
|
46
|
+
def add(match: re.Match[str]) -> str:
|
|
47
|
+
url = match.group(1)
|
|
48
|
+
if "style=" in url:
|
|
49
|
+
return match.group(0)
|
|
50
|
+
joiner = "&" if "?" in url else "?"
|
|
51
|
+
return match.group(0).replace(url, f"{url}{joiner}style={style}")
|
|
52
|
+
|
|
53
|
+
return IMAGE_URL.sub(add, markdown)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
@dataclass
|
|
57
|
+
class BadgeContext:
|
|
58
|
+
config: Config
|
|
59
|
+
options: dict[str, object]
|
|
60
|
+
|
|
61
|
+
def require(self, *fields: str) -> list[str]:
|
|
62
|
+
values = []
|
|
63
|
+
for name in fields:
|
|
64
|
+
value = self.options.get(name) or getattr(self.config.project, name, None)
|
|
65
|
+
if not value:
|
|
66
|
+
raise ValueError(f"badge needs project.{name}; set it in readme.yaml")
|
|
67
|
+
values.append(str(value))
|
|
68
|
+
return values
|
|
69
|
+
|
|
70
|
+
def handle(self, preset: str) -> str:
|
|
71
|
+
value = self.options.get("handle") or self.config.donate_handles.get(preset)
|
|
72
|
+
if not value:
|
|
73
|
+
raise ValueError(f"badge '{preset}' needs donate_handles.{preset} in readme.yaml")
|
|
74
|
+
return str(value)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
Preset = Callable[[BadgeContext], str]
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _pypi(ctx: BadgeContext) -> str:
|
|
81
|
+
(pypi,) = ctx.require("pypi")
|
|
82
|
+
return badge_markdown("PyPI", f"{SHIELDS}/pypi/v/{pypi}", f"https://pypi.org/project/{pypi}/")
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _pypi_downloads(ctx: BadgeContext) -> str:
|
|
86
|
+
(pypi,) = ctx.require("pypi")
|
|
87
|
+
return badge_markdown(
|
|
88
|
+
"Downloads", f"{SHIELDS}/pypi/dm/{pypi}", f"https://pypi.org/project/{pypi}/"
|
|
89
|
+
)
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def _python(ctx: BadgeContext) -> str:
|
|
93
|
+
(pypi,) = ctx.require("pypi")
|
|
94
|
+
return badge_markdown(
|
|
95
|
+
"Python", f"{SHIELDS}/pypi/pyversions/{pypi}", f"https://pypi.org/project/{pypi}/"
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def _license(ctx: BadgeContext) -> str:
|
|
100
|
+
owner, repo = ctx.require("owner", "repo")
|
|
101
|
+
return badge_markdown(
|
|
102
|
+
"License",
|
|
103
|
+
f"{SHIELDS}/github/license/{owner}/{repo}",
|
|
104
|
+
f"https://github.com/{owner}/{repo}/blob/main/LICENSE",
|
|
105
|
+
)
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def _ci(ctx: BadgeContext) -> str:
|
|
109
|
+
owner, repo = ctx.require("owner", "repo")
|
|
110
|
+
workflow = str(ctx.options.get("workflow") or ctx.config.project.ci_workflow or "ci.yml")
|
|
111
|
+
return badge_markdown(
|
|
112
|
+
"CI",
|
|
113
|
+
f"{SHIELDS}/github/actions/workflow/status/{owner}/{repo}/{workflow}",
|
|
114
|
+
f"https://github.com/{owner}/{repo}/actions/workflows/{workflow}",
|
|
115
|
+
)
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def _codecov(ctx: BadgeContext) -> str:
|
|
119
|
+
owner, repo = ctx.require("owner", "repo")
|
|
120
|
+
return badge_markdown(
|
|
121
|
+
"Coverage",
|
|
122
|
+
f"{SHIELDS}/codecov/c/github/{owner}/{repo}",
|
|
123
|
+
f"https://codecov.io/gh/{owner}/{repo}",
|
|
124
|
+
)
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def _npm(ctx: BadgeContext) -> str:
|
|
128
|
+
(npm,) = ctx.require("npm")
|
|
129
|
+
return badge_markdown(
|
|
130
|
+
"npm", f"{SHIELDS}/npm/v/{quote(npm, safe='')}", f"https://www.npmjs.com/package/{npm}"
|
|
131
|
+
)
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def _github_release(ctx: BadgeContext) -> str:
|
|
135
|
+
owner, repo = ctx.require("owner", "repo")
|
|
136
|
+
return badge_markdown(
|
|
137
|
+
"Release",
|
|
138
|
+
f"{SHIELDS}/github/v/release/{owner}/{repo}",
|
|
139
|
+
f"https://github.com/{owner}/{repo}/releases/latest",
|
|
140
|
+
)
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def _github_stars(ctx: BadgeContext) -> str:
|
|
144
|
+
owner, repo = ctx.require("owner", "repo")
|
|
145
|
+
return badge_markdown(
|
|
146
|
+
"Stars",
|
|
147
|
+
f"{SHIELDS}/github/stars/{owner}/{repo}",
|
|
148
|
+
f"https://github.com/{owner}/{repo}/stargazers",
|
|
149
|
+
)
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def _pre_commit(ctx: BadgeContext) -> str:
|
|
153
|
+
return shield(
|
|
154
|
+
"pre-commit",
|
|
155
|
+
"enabled",
|
|
156
|
+
"brightgreen",
|
|
157
|
+
logo="pre-commit",
|
|
158
|
+
link="https://github.com/pre-commit/pre-commit",
|
|
159
|
+
)
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def _ruff(ctx: BadgeContext) -> str:
|
|
163
|
+
url = (
|
|
164
|
+
f"{SHIELDS}/endpoint?url=https://raw.githubusercontent.com/astral-sh/ruff/main/"
|
|
165
|
+
"assets/badge/v2.json"
|
|
166
|
+
)
|
|
167
|
+
return badge_markdown("Ruff", url, "https://github.com/astral-sh/ruff")
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def _modrinth(ctx: BadgeContext) -> str:
|
|
171
|
+
slug = str(ctx.options.get("slug") or ctx.config.project.modrinth or "")
|
|
172
|
+
if not slug:
|
|
173
|
+
raise ValueError("badge 'modrinth' needs project.modrinth (the project slug)")
|
|
174
|
+
return badge_markdown(
|
|
175
|
+
"Modrinth",
|
|
176
|
+
f"{SHIELDS}/modrinth/dt/{slug}?logo=modrinth",
|
|
177
|
+
f"https://modrinth.com/mod/{slug}",
|
|
178
|
+
)
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def _curseforge(ctx: BadgeContext) -> str:
|
|
182
|
+
project_id = str(ctx.options.get("id") or ctx.config.project.curseforge or "")
|
|
183
|
+
if not project_id:
|
|
184
|
+
raise ValueError("badge 'curseforge' needs project.curseforge (the numeric project id)")
|
|
185
|
+
return badge_markdown(
|
|
186
|
+
"CurseForge",
|
|
187
|
+
f"{SHIELDS}/curseforge/dt/{project_id}?logo=curseforge",
|
|
188
|
+
f"https://www.curseforge.com/projects/{project_id}",
|
|
189
|
+
)
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def _hacs(ctx: BadgeContext) -> str:
|
|
193
|
+
kind = str(ctx.options.get("kind") or "Custom")
|
|
194
|
+
return shield("HACS", kind, "41BDF5", logo="homeassistant", link="https://hacs.xyz")
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def _ha_version(ctx: BadgeContext) -> str:
|
|
198
|
+
version = str(ctx.options.get("version") or ctx.config.project.ha_min_version or "")
|
|
199
|
+
if not version:
|
|
200
|
+
raise ValueError("badge 'ha-version' needs project.ha_min_version (from hacs.json)")
|
|
201
|
+
return shield(
|
|
202
|
+
"Home Assistant",
|
|
203
|
+
f"{version}+",
|
|
204
|
+
"03A9F4",
|
|
205
|
+
logo="homeassistant",
|
|
206
|
+
link="https://www.home-assistant.io",
|
|
207
|
+
)
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def _version(ctx: BadgeContext) -> str:
|
|
211
|
+
(version,) = ctx.require("version")
|
|
212
|
+
return shield("version", version, "informational", link=ctx.config.project.url)
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def _kofi(ctx: BadgeContext) -> str:
|
|
216
|
+
handle = ctx.handle("kofi")
|
|
217
|
+
return shield("Ko-fi", "support", "FF5E5B", logo="ko-fi", link=f"https://ko-fi.com/{handle}")
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
def _buymeacoffee(ctx: BadgeContext) -> str:
|
|
221
|
+
handle = ctx.handle("buymeacoffee")
|
|
222
|
+
return shield(
|
|
223
|
+
"Buy Me a Coffee",
|
|
224
|
+
"support",
|
|
225
|
+
"FFDD00",
|
|
226
|
+
logo="buy-me-a-coffee",
|
|
227
|
+
link=f"https://www.buymeacoffee.com/{handle}",
|
|
228
|
+
)
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
def _github_sponsors(ctx: BadgeContext) -> str:
|
|
232
|
+
handle = ctx.handle("github-sponsors")
|
|
233
|
+
return badge_markdown(
|
|
234
|
+
"Sponsor",
|
|
235
|
+
f"{SHIELDS}/github/sponsors/{handle}?logo=githubsponsors",
|
|
236
|
+
f"https://github.com/sponsors/{handle}",
|
|
237
|
+
)
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
def _patreon(ctx: BadgeContext) -> str:
|
|
241
|
+
handle = ctx.handle("patreon")
|
|
242
|
+
return shield(
|
|
243
|
+
"Patreon", "support", "F96854", logo="patreon", link=f"https://patreon.com/{handle}"
|
|
244
|
+
)
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
def _paypal(ctx: BadgeContext) -> str:
|
|
248
|
+
handle = ctx.handle("paypal")
|
|
249
|
+
return shield("PayPal", "donate", "00457C", logo="paypal", link=f"https://paypal.me/{handle}")
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
BUILTIN_PRESETS: dict[str, Preset] = {
|
|
253
|
+
"pypi": _pypi,
|
|
254
|
+
"pypi-downloads": _pypi_downloads,
|
|
255
|
+
"python": _python,
|
|
256
|
+
"license": _license,
|
|
257
|
+
"ci": _ci,
|
|
258
|
+
"codecov": _codecov,
|
|
259
|
+
"npm": _npm,
|
|
260
|
+
"github-release": _github_release,
|
|
261
|
+
"github-stars": _github_stars,
|
|
262
|
+
"pre-commit": _pre_commit,
|
|
263
|
+
"ruff": _ruff,
|
|
264
|
+
"version": _version,
|
|
265
|
+
"modrinth": _modrinth,
|
|
266
|
+
"curseforge": _curseforge,
|
|
267
|
+
"hacs": _hacs,
|
|
268
|
+
"ha-version": _ha_version,
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
DONATION_PRESETS: dict[str, Preset] = {
|
|
272
|
+
"kofi": _kofi,
|
|
273
|
+
"buymeacoffee": _buymeacoffee,
|
|
274
|
+
"github-sponsors": _github_sponsors,
|
|
275
|
+
"patreon": _patreon,
|
|
276
|
+
"paypal": _paypal,
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
def _custom_preset(spec: CustomBadge) -> Preset:
|
|
281
|
+
def render(ctx: BadgeContext) -> str:
|
|
282
|
+
return shield(
|
|
283
|
+
spec.label, spec.message, spec.color, link=spec.link, logo=spec.logo, style=spec.style
|
|
284
|
+
)
|
|
285
|
+
|
|
286
|
+
return render
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
class BadgeRegistry:
|
|
290
|
+
def __init__(self, config: Config) -> None:
|
|
291
|
+
self.config = config
|
|
292
|
+
self.presets: dict[str, Preset] = {**BUILTIN_PRESETS, **DONATION_PRESETS}
|
|
293
|
+
self.presets.update(
|
|
294
|
+
{name: _custom_preset(spec) for name, spec in config.badges_custom.items()}
|
|
295
|
+
)
|
|
296
|
+
|
|
297
|
+
def names(self) -> list[str]:
|
|
298
|
+
return list(self.presets)
|
|
299
|
+
|
|
300
|
+
def render(self, preset: str, **options: object) -> str:
|
|
301
|
+
try:
|
|
302
|
+
fn = self.presets[preset]
|
|
303
|
+
except KeyError:
|
|
304
|
+
raise ValueError(
|
|
305
|
+
f"unknown badge preset '{preset}'; run `readwright badges` to list presets"
|
|
306
|
+
) from None
|
|
307
|
+
style = options.pop("style", None) or self.config.badges_style
|
|
308
|
+
return apply_style(fn(BadgeContext(self.config, options)), style)
|
|
309
|
+
|
|
310
|
+
def render_spec(self, spec: BadgeSpec, style: str | None = None) -> str:
|
|
311
|
+
if spec.shield is not None:
|
|
312
|
+
return apply_style(
|
|
313
|
+
_custom_preset(spec.shield)(BadgeContext(self.config, {})),
|
|
314
|
+
style or self.config.badges_style,
|
|
315
|
+
)
|
|
316
|
+
assert spec.preset is not None
|
|
317
|
+
return self.render(spec.preset, **{"style": style, **spec.options})
|
|
318
|
+
|
|
319
|
+
def render_all(self, style: str | None = None) -> str:
|
|
320
|
+
return " ".join(self.render_spec(spec, style) for spec in self.config.badges)
|
|
321
|
+
|
|
322
|
+
def render_donate(self, style: str | None = None) -> str:
|
|
323
|
+
return " ".join(self.render_spec(spec, style) for spec in self.config.donate)
|
readwright/changelog.py
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
"""Read the newest entries from a Keep-a-Changelog style CHANGELOG.md."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
ENTRY_HEADING = re.compile(r"^##\s+")
|
|
9
|
+
CANDIDATES = ("CHANGELOG.md", "CHANGES.md", "HISTORY.md", "changelog.md")
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def find_changelog(root: Path) -> Path | None:
|
|
13
|
+
return next((root / name for name in CANDIDATES if (root / name).is_file()), None)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def split_entries(text: str) -> list[str]:
|
|
17
|
+
entries: list[str] = []
|
|
18
|
+
current: list[str] = []
|
|
19
|
+
for line in text.splitlines():
|
|
20
|
+
if ENTRY_HEADING.match(line):
|
|
21
|
+
if current:
|
|
22
|
+
entries.append("\n".join(current).strip())
|
|
23
|
+
current = [line]
|
|
24
|
+
elif current:
|
|
25
|
+
current.append(line)
|
|
26
|
+
if current:
|
|
27
|
+
entries.append("\n".join(current).strip())
|
|
28
|
+
return [e for e in entries if not e.lower().startswith("## [unreleased]")]
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
HEADING_LINE = re.compile(r"^(#+)(\s+)", re.MULTILINE)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def relevel(entry: str, level: int) -> str:
|
|
35
|
+
"""Shift headings so the entry's own `##` heading becomes `level` hashes deep."""
|
|
36
|
+
shift = level - 2
|
|
37
|
+
if shift == 0:
|
|
38
|
+
return entry
|
|
39
|
+
return HEADING_LINE.sub(lambda m: "#" * max(1, len(m.group(1)) + shift) + m.group(2), entry)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def latest_entries(root: Path, n: int = 1, path: str | None = None, level: int = 3) -> str:
|
|
43
|
+
file = root / path if path else find_changelog(root)
|
|
44
|
+
if file is None or not file.is_file():
|
|
45
|
+
return ""
|
|
46
|
+
return "\n\n".join(relevel(e, level) for e in split_entries(file.read_text())[:n])
|