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/images.py
ADDED
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
"""Screenshot discovery and image tag helpers."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import html
|
|
6
|
+
from collections.abc import Callable
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
import yaml
|
|
11
|
+
|
|
12
|
+
from readwright.config import Config
|
|
13
|
+
|
|
14
|
+
EXTENSIONS = ("png", "jpg", "jpeg", "gif", "webp", "svg")
|
|
15
|
+
VARIANTS = ("dark", "light")
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class MissingImageError(FileNotFoundError):
|
|
19
|
+
pass
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def titleize(stem: str) -> str:
|
|
23
|
+
stem = stem.rsplit("/", 1)[-1]
|
|
24
|
+
return " ".join(part.capitalize() for part in stem.replace("_", "-").split("-") if part)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def img_tag(src: str, alt: str, width: int | None) -> str:
|
|
28
|
+
width_attr = f' width="{width}"' if width else ""
|
|
29
|
+
return f'<img src="{src}" alt="{html.escape(alt, quote=True)}"{width_attr}>'
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def md_image(src: str, alt: str) -> str:
|
|
33
|
+
return f""
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def md_pair(dark: str, light: str, alt: str) -> str:
|
|
37
|
+
"""GitHub-only trick: image fragments select light/dark variants in pure markdown."""
|
|
38
|
+
return f""
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def picture_tag(dark: str, light: str, alt: str, width: int | None) -> str:
|
|
42
|
+
return (
|
|
43
|
+
"<picture>\n"
|
|
44
|
+
f' <source media="(prefers-color-scheme: dark)" srcset="{dark}">\n'
|
|
45
|
+
f' <source media="(prefers-color-scheme: light)" srcset="{light}">\n'
|
|
46
|
+
f" {img_tag(light, alt, width)}\n"
|
|
47
|
+
"</picture>"
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
@dataclass
|
|
52
|
+
class Shot:
|
|
53
|
+
name: str
|
|
54
|
+
single: Path | None = None
|
|
55
|
+
dark: Path | None = None
|
|
56
|
+
light: Path | None = None
|
|
57
|
+
|
|
58
|
+
@property
|
|
59
|
+
def is_pair(self) -> bool:
|
|
60
|
+
return self.dark is not None and self.light is not None
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
class ImageHelper:
|
|
64
|
+
def __init__(
|
|
65
|
+
self, root: Path, config: Config, warn: Callable[[str], None] | None = None
|
|
66
|
+
) -> None:
|
|
67
|
+
self.root = Path(root)
|
|
68
|
+
self.config = config
|
|
69
|
+
self.warn = warn or (lambda message: None)
|
|
70
|
+
|
|
71
|
+
@property
|
|
72
|
+
def shots_dir(self) -> Path:
|
|
73
|
+
return self.root / self.config.screenshots.dir
|
|
74
|
+
|
|
75
|
+
def _rel(self, path: Path) -> str:
|
|
76
|
+
return path.relative_to(self.root).as_posix()
|
|
77
|
+
|
|
78
|
+
def _find(self, stem: str) -> Path | None:
|
|
79
|
+
for ext in EXTENSIONS:
|
|
80
|
+
candidate = self.shots_dir / f"{stem}.{ext}"
|
|
81
|
+
if candidate.is_file():
|
|
82
|
+
return candidate
|
|
83
|
+
return None
|
|
84
|
+
|
|
85
|
+
def _lookup(self, name: str) -> Shot:
|
|
86
|
+
return Shot(
|
|
87
|
+
name=name,
|
|
88
|
+
single=self._find(name),
|
|
89
|
+
dark=self._find(f"{name}-dark"),
|
|
90
|
+
light=self._find(f"{name}-light"),
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
def _render_shot(self, shot: Shot, alt: str, width: int | None, force_html: bool) -> str:
|
|
94
|
+
html_mode = force_html or bool(width) or self.config.screenshots.style == "html"
|
|
95
|
+
if shot.is_pair:
|
|
96
|
+
dark, light = self._rel(shot.dark), self._rel(shot.light)
|
|
97
|
+
return picture_tag(dark, light, alt, width) if html_mode else md_pair(dark, light, alt)
|
|
98
|
+
src = shot.single or shot.light or shot.dark
|
|
99
|
+
assert src is not None
|
|
100
|
+
return img_tag(self._rel(src), alt, width) if html_mode else md_image(self._rel(src), alt)
|
|
101
|
+
|
|
102
|
+
def _missing(self, message: str) -> str:
|
|
103
|
+
if self.config.strict:
|
|
104
|
+
raise MissingImageError(message)
|
|
105
|
+
self.warn(message)
|
|
106
|
+
return ""
|
|
107
|
+
|
|
108
|
+
def screenshot(self, name: str, alt: str | None = None, width: int | None = None) -> str:
|
|
109
|
+
shot = self._lookup(name)
|
|
110
|
+
if not (shot.single or shot.dark or shot.light):
|
|
111
|
+
return self._missing(
|
|
112
|
+
f"screenshot '{name}' not found in {self.config.screenshots.dir} "
|
|
113
|
+
f"(tried extensions {', '.join(EXTENSIONS)})"
|
|
114
|
+
)
|
|
115
|
+
force_html = width is not None
|
|
116
|
+
width = width if width is not None else self._style_width()
|
|
117
|
+
return self._render_shot(shot, alt or titleize(name), width, force_html)
|
|
118
|
+
|
|
119
|
+
def _style_width(self) -> int | None:
|
|
120
|
+
return self.config.screenshots.width if self.config.screenshots.style == "html" else None
|
|
121
|
+
|
|
122
|
+
def image(self, path: str, alt: str = "", width: int | None = None) -> str:
|
|
123
|
+
if "://" not in path and not (self.root / path).is_file():
|
|
124
|
+
self.warn(f"image '{path}' not found")
|
|
125
|
+
return img_tag(path, alt, width) if width else md_image(path, alt)
|
|
126
|
+
|
|
127
|
+
def _captions(self, directory: Path) -> dict[str, str]:
|
|
128
|
+
path = directory / "captions.yaml"
|
|
129
|
+
if not path.is_file():
|
|
130
|
+
return {}
|
|
131
|
+
data = yaml.safe_load(path.read_text()) or {}
|
|
132
|
+
return {str(k): str(v) for k, v in data.items()} if isinstance(data, dict) else {}
|
|
133
|
+
|
|
134
|
+
def _discover(self, directory: Path) -> list[Shot]:
|
|
135
|
+
if not directory.is_dir():
|
|
136
|
+
self.warn(f"screenshots dir '{self._rel(directory)}' does not exist")
|
|
137
|
+
return []
|
|
138
|
+
files = sorted(p for p in directory.iterdir() if p.suffix.lstrip(".").lower() in EXTENSIONS)
|
|
139
|
+
shots: dict[str, Shot] = {}
|
|
140
|
+
for path in files:
|
|
141
|
+
stem, variant = path.stem, None
|
|
142
|
+
for candidate in VARIANTS:
|
|
143
|
+
if stem.endswith(f"-{candidate}"):
|
|
144
|
+
stem, variant = stem[: -len(candidate) - 1], candidate
|
|
145
|
+
break
|
|
146
|
+
shot = shots.setdefault(stem, Shot(name=stem))
|
|
147
|
+
if variant is None:
|
|
148
|
+
shot.single = shot.single or path
|
|
149
|
+
else:
|
|
150
|
+
setattr(shot, variant, getattr(shot, variant) or path)
|
|
151
|
+
return list(shots.values())
|
|
152
|
+
|
|
153
|
+
def has_screenshots(self) -> bool:
|
|
154
|
+
return self.shots_dir.is_dir() and any(
|
|
155
|
+
p.suffix.lstrip(".").lower() in EXTENSIONS for p in self.shots_dir.iterdir()
|
|
156
|
+
)
|
|
157
|
+
|
|
158
|
+
def screenshots(
|
|
159
|
+
self,
|
|
160
|
+
columns: int = 2,
|
|
161
|
+
subdir: str | None = None,
|
|
162
|
+
order: list[str] | None = None,
|
|
163
|
+
captions: dict[str, str] | None = None,
|
|
164
|
+
show_captions: bool = True,
|
|
165
|
+
) -> str:
|
|
166
|
+
directory = self.shots_dir / subdir if subdir else self.shots_dir
|
|
167
|
+
shots = self._discover(directory)
|
|
168
|
+
if not shots:
|
|
169
|
+
if directory.is_dir():
|
|
170
|
+
self.warn(f"no screenshots found in {self._rel(directory)}")
|
|
171
|
+
return ""
|
|
172
|
+
all_captions = {**self._captions(directory), **(captions or {})}
|
|
173
|
+
if order:
|
|
174
|
+
by_name = {shot.name: shot for shot in shots}
|
|
175
|
+
missing = [name for name in order if name not in by_name]
|
|
176
|
+
if missing:
|
|
177
|
+
self.warn(f"screenshots order lists unknown names: {', '.join(missing)}")
|
|
178
|
+
ordered = [by_name[n] for n in order if n in by_name]
|
|
179
|
+
shots = ordered + [s for s in shots if s.name not in order]
|
|
180
|
+
columns = max(columns, 1)
|
|
181
|
+
captioned = [(shot, all_captions.get(shot.name, titleize(shot.name))) for shot in shots]
|
|
182
|
+
if self.config.screenshots.style == "html":
|
|
183
|
+
return self._html_gallery(captioned, columns, show_captions)
|
|
184
|
+
return self._markdown_gallery(captioned, columns, show_captions)
|
|
185
|
+
|
|
186
|
+
def _markdown_gallery(
|
|
187
|
+
self, shots: list[tuple[Shot, str]], columns: int, show_captions: bool
|
|
188
|
+
) -> str:
|
|
189
|
+
def row(cells: list[str]) -> str:
|
|
190
|
+
padded = cells + [""] * (columns - len(cells))
|
|
191
|
+
return "| " + " | ".join(padded) + " |"
|
|
192
|
+
|
|
193
|
+
lines: list[str] = []
|
|
194
|
+
for i in range(0, len(shots), columns):
|
|
195
|
+
chunk = shots[i : i + columns]
|
|
196
|
+
lines.append(row([self._render_shot(s, c, None, False) for s, c in chunk]))
|
|
197
|
+
if i == 0:
|
|
198
|
+
lines.append(row([":---:"] * columns))
|
|
199
|
+
if show_captions:
|
|
200
|
+
lines.append(row([c.replace("|", "\\|") for _, c in chunk]))
|
|
201
|
+
return "\n".join(lines)
|
|
202
|
+
|
|
203
|
+
def _html_gallery(
|
|
204
|
+
self, shots: list[tuple[Shot, str]], columns: int, show_captions: bool
|
|
205
|
+
) -> str:
|
|
206
|
+
width = self.config.screenshots.width
|
|
207
|
+
cells = []
|
|
208
|
+
for shot, caption in shots:
|
|
209
|
+
cell = self._render_shot(shot, caption, width, force_html=True)
|
|
210
|
+
if show_captions:
|
|
211
|
+
cell += f"<br><sub>{html.escape(caption)}</sub>"
|
|
212
|
+
cells.append(f'<td align="center">{cell}</td>')
|
|
213
|
+
rows = [
|
|
214
|
+
"<tr>\n" + "\n".join(cells[i : i + columns]) + "\n</tr>"
|
|
215
|
+
for i in range(0, len(cells), columns)
|
|
216
|
+
]
|
|
217
|
+
return "<table>\n" + "\n".join(rows) + "\n</table>"
|
readwright/metadata.py
ADDED
|
@@ -0,0 +1,292 @@
|
|
|
1
|
+
"""Autodetect repository metadata from git and common project manifests."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import re
|
|
7
|
+
import subprocess
|
|
8
|
+
import tomllib
|
|
9
|
+
from dataclasses import dataclass, field
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from xml.etree import ElementTree
|
|
12
|
+
|
|
13
|
+
GITHUB_REMOTE = re.compile(
|
|
14
|
+
r"^(?:git@github\.com:|(?:https?|ssh)://(?:[\w.-]+@)?github\.com/)"
|
|
15
|
+
r"(?P<owner>[^/]+)/(?P<repo>[^/]+?)(?:\.git)?/?$"
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
LICENSE_SIGNATURES: list[tuple[str, str]] = [
|
|
19
|
+
("MIT License", "MIT"),
|
|
20
|
+
("Apache License", "Apache-2.0"),
|
|
21
|
+
("GNU General Public License", "GPL-3.0"),
|
|
22
|
+
("GNU LESSER GENERAL PUBLIC LICENSE", "LGPL-3.0"),
|
|
23
|
+
("GNU AFFERO GENERAL PUBLIC LICENSE", "AGPL-3.0"),
|
|
24
|
+
("BSD 3-Clause", "BSD-3-Clause"),
|
|
25
|
+
("BSD 2-Clause", "BSD-2-Clause"),
|
|
26
|
+
("Mozilla Public License", "MPL-2.0"),
|
|
27
|
+
("ISC License", "ISC"),
|
|
28
|
+
("The Unlicense", "Unlicense"),
|
|
29
|
+
]
|
|
30
|
+
|
|
31
|
+
PYTHON_CLASSIFIER = re.compile(r"Programming Language :: Python :: (\d+\.\d+)$")
|
|
32
|
+
GO_MODULE = re.compile(r"^module\s+(\S+)", re.MULTILINE)
|
|
33
|
+
GRADLE_ROOT_NAME = re.compile(r"""rootProject\.name\s*=\s*['"]([^'"]+)['"]""")
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@dataclass
|
|
37
|
+
class Metadata:
|
|
38
|
+
name: str | None = None
|
|
39
|
+
owner: str | None = None
|
|
40
|
+
repo: str | None = None
|
|
41
|
+
tagline: str | None = None
|
|
42
|
+
version: str | None = None
|
|
43
|
+
license: str | None = None
|
|
44
|
+
pypi: str | None = None
|
|
45
|
+
npm: str | None = None
|
|
46
|
+
crate: str | None = None
|
|
47
|
+
go_module: str | None = None
|
|
48
|
+
nuget: str | None = None
|
|
49
|
+
mod_id: str | None = None
|
|
50
|
+
minecraft_version: str | None = None
|
|
51
|
+
ha_min_version: str | None = None
|
|
52
|
+
flow_plugin: str | None = None
|
|
53
|
+
python_versions: list[str] = field(default_factory=list)
|
|
54
|
+
project_type: str = "generic"
|
|
55
|
+
|
|
56
|
+
@property
|
|
57
|
+
def url(self) -> str | None:
|
|
58
|
+
if self.owner and self.repo:
|
|
59
|
+
return f"https://github.com/{self.owner}/{self.repo}"
|
|
60
|
+
return None
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def parse_github_remote(url: str) -> tuple[str, str] | None:
|
|
64
|
+
match = GITHUB_REMOTE.match(url.strip())
|
|
65
|
+
if not match:
|
|
66
|
+
return None
|
|
67
|
+
return match["owner"], match["repo"]
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def git_remote_url(root: Path) -> str | None:
|
|
71
|
+
try:
|
|
72
|
+
result = subprocess.run(
|
|
73
|
+
["git", "-C", str(root), "remote", "get-url", "origin"],
|
|
74
|
+
capture_output=True,
|
|
75
|
+
text=True,
|
|
76
|
+
check=False,
|
|
77
|
+
)
|
|
78
|
+
except FileNotFoundError:
|
|
79
|
+
return None
|
|
80
|
+
return result.stdout.strip() or None
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def license_from_file(root: Path) -> str | None:
|
|
84
|
+
for candidate in ("LICENSE", "LICENSE.md", "LICENSE.txt", "LICENCE", "COPYING"):
|
|
85
|
+
path = root / candidate
|
|
86
|
+
if path.is_file():
|
|
87
|
+
text = path.read_text(errors="replace").lower()
|
|
88
|
+
for signature, spdx in LICENSE_SIGNATURES:
|
|
89
|
+
if signature.lower() in text:
|
|
90
|
+
return spdx
|
|
91
|
+
return None
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def _license_field(value: object) -> str | None:
|
|
95
|
+
if isinstance(value, str):
|
|
96
|
+
return value
|
|
97
|
+
if isinstance(value, dict):
|
|
98
|
+
text = value.get("text")
|
|
99
|
+
return text if isinstance(text, str) and len(text) < 40 else None
|
|
100
|
+
return None
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def _set(meta: Metadata, **values: object) -> None:
|
|
104
|
+
for key, value in values.items():
|
|
105
|
+
if value:
|
|
106
|
+
setattr(meta, key, value)
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def _apply_pyproject(meta: Metadata, root: Path) -> bool:
|
|
110
|
+
path = root / "pyproject.toml"
|
|
111
|
+
if not path.is_file():
|
|
112
|
+
return False
|
|
113
|
+
project = tomllib.loads(path.read_text()).get("project", {})
|
|
114
|
+
meta.project_type = "python"
|
|
115
|
+
_set(
|
|
116
|
+
meta,
|
|
117
|
+
name=project.get("name"),
|
|
118
|
+
pypi=project.get("name"),
|
|
119
|
+
version=project.get("version"),
|
|
120
|
+
tagline=project.get("description"),
|
|
121
|
+
license=_license_field(project.get("license")),
|
|
122
|
+
python_versions=[
|
|
123
|
+
m.group(1) for c in project.get("classifiers", []) if (m := PYTHON_CLASSIFIER.search(c))
|
|
124
|
+
],
|
|
125
|
+
)
|
|
126
|
+
return True
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def _apply_package_json(meta: Metadata, root: Path) -> bool:
|
|
130
|
+
path = root / "package.json"
|
|
131
|
+
if not path.is_file():
|
|
132
|
+
return False
|
|
133
|
+
data = json.loads(path.read_text())
|
|
134
|
+
meta.project_type = "node"
|
|
135
|
+
_set(
|
|
136
|
+
meta,
|
|
137
|
+
name=data.get("name"),
|
|
138
|
+
npm=data.get("name"),
|
|
139
|
+
version=data.get("version"),
|
|
140
|
+
tagline=data.get("description"),
|
|
141
|
+
license=_license_field(data.get("license")),
|
|
142
|
+
)
|
|
143
|
+
return True
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def _apply_cargo(meta: Metadata, root: Path) -> bool:
|
|
147
|
+
path = root / "Cargo.toml"
|
|
148
|
+
if not path.is_file():
|
|
149
|
+
return False
|
|
150
|
+
package = tomllib.loads(path.read_text()).get("package", {})
|
|
151
|
+
meta.project_type = "rust"
|
|
152
|
+
_set(
|
|
153
|
+
meta,
|
|
154
|
+
name=package.get("name"),
|
|
155
|
+
crate=package.get("name"),
|
|
156
|
+
version=package.get("version"),
|
|
157
|
+
tagline=package.get("description"),
|
|
158
|
+
license=package.get("license"),
|
|
159
|
+
)
|
|
160
|
+
return True
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def _apply_go(meta: Metadata, root: Path) -> bool:
|
|
164
|
+
path = root / "go.mod"
|
|
165
|
+
if not path.is_file():
|
|
166
|
+
return False
|
|
167
|
+
match = GO_MODULE.search(path.read_text())
|
|
168
|
+
meta.project_type = "go"
|
|
169
|
+
if match:
|
|
170
|
+
module = match.group(1)
|
|
171
|
+
_set(meta, go_module=module, name=module.rsplit("/", 1)[-1])
|
|
172
|
+
return True
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def _apply_dotnet(meta: Metadata, root: Path) -> bool:
|
|
176
|
+
projects = sorted(root.glob("*.csproj")) or sorted(root.glob("*/*.csproj"))
|
|
177
|
+
if not projects:
|
|
178
|
+
return False
|
|
179
|
+
path = projects[0]
|
|
180
|
+
meta.project_type = "dotnet"
|
|
181
|
+
try:
|
|
182
|
+
tree = ElementTree.parse(path)
|
|
183
|
+
except ElementTree.ParseError:
|
|
184
|
+
_set(meta, name=path.stem)
|
|
185
|
+
return True
|
|
186
|
+
props = {el.tag: (el.text or "").strip() for pg in tree.iter("PropertyGroup") for el in pg}
|
|
187
|
+
package_id = props.get("PackageId") or props.get("AssemblyName") or path.stem
|
|
188
|
+
_set(
|
|
189
|
+
meta,
|
|
190
|
+
name=package_id,
|
|
191
|
+
nuget=package_id if props.get("PackAsTool") or props.get("PackageId") else None,
|
|
192
|
+
version=props.get("Version") or props.get("PackageVersion"),
|
|
193
|
+
tagline=props.get("Description"),
|
|
194
|
+
license=props.get("PackageLicenseExpression"),
|
|
195
|
+
)
|
|
196
|
+
return True
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def _gradle_properties(root: Path) -> dict[str, str]:
|
|
200
|
+
path = root / "gradle.properties"
|
|
201
|
+
if not path.is_file():
|
|
202
|
+
return {}
|
|
203
|
+
props: dict[str, str] = {}
|
|
204
|
+
for line in path.read_text().splitlines():
|
|
205
|
+
line = line.strip()
|
|
206
|
+
if line and not line.startswith(("#", "!")) and "=" in line:
|
|
207
|
+
key, value = line.split("=", 1)
|
|
208
|
+
props[key.strip()] = value.strip()
|
|
209
|
+
return props
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def _apply_gradle(meta: Metadata, root: Path) -> bool:
|
|
213
|
+
build = next(
|
|
214
|
+
(root / n for n in ("build.gradle", "build.gradle.kts") if (root / n).is_file()), None
|
|
215
|
+
)
|
|
216
|
+
if build is None:
|
|
217
|
+
return False
|
|
218
|
+
props = _gradle_properties(root)
|
|
219
|
+
if props.get("mod_id") or props.get("mod_name") or props.get("minecraft_version"):
|
|
220
|
+
meta.project_type = "minecraft-mod"
|
|
221
|
+
_set(
|
|
222
|
+
meta,
|
|
223
|
+
name=props.get("mod_name") or props.get("mod_id"),
|
|
224
|
+
mod_id=props.get("mod_id"),
|
|
225
|
+
version=props.get("mod_version") or props.get("version"),
|
|
226
|
+
tagline=props.get("mod_description"),
|
|
227
|
+
minecraft_version=props.get("minecraft_version"),
|
|
228
|
+
license=props.get("mod_license"),
|
|
229
|
+
)
|
|
230
|
+
return True
|
|
231
|
+
meta.project_type = "gradle"
|
|
232
|
+
for settings in ("settings.gradle", "settings.gradle.kts"):
|
|
233
|
+
path = root / settings
|
|
234
|
+
if path.is_file() and (m := GRADLE_ROOT_NAME.search(path.read_text())):
|
|
235
|
+
_set(meta, name=m.group(1))
|
|
236
|
+
_set(meta, version=props.get("version"))
|
|
237
|
+
return True
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
def _apply_hacs(meta: Metadata, root: Path) -> bool:
|
|
241
|
+
path = root / "hacs.json"
|
|
242
|
+
if not path.is_file():
|
|
243
|
+
return False
|
|
244
|
+
data = json.loads(path.read_text())
|
|
245
|
+
_apply_package_json(meta, root)
|
|
246
|
+
meta.project_type = "hacs"
|
|
247
|
+
_set(meta, name=data.get("name"), ha_min_version=data.get("homeassistant"))
|
|
248
|
+
return True
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
def _apply_flow_plugin(meta: Metadata, root: Path) -> bool:
|
|
252
|
+
path = root / "plugin.json"
|
|
253
|
+
if not path.is_file():
|
|
254
|
+
return False
|
|
255
|
+
data = json.loads(path.read_text())
|
|
256
|
+
if "ActionKeyword" not in data and "ExecuteFileName" not in data:
|
|
257
|
+
return False
|
|
258
|
+
meta.project_type = "flow-plugin"
|
|
259
|
+
_set(
|
|
260
|
+
meta,
|
|
261
|
+
name=data.get("Name"),
|
|
262
|
+
flow_plugin=data.get("Name"),
|
|
263
|
+
version=data.get("Version"),
|
|
264
|
+
tagline=data.get("Description"),
|
|
265
|
+
)
|
|
266
|
+
return True
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
DETECTORS = (
|
|
270
|
+
_apply_hacs,
|
|
271
|
+
_apply_flow_plugin,
|
|
272
|
+
_apply_pyproject,
|
|
273
|
+
_apply_package_json,
|
|
274
|
+
_apply_cargo,
|
|
275
|
+
_apply_go,
|
|
276
|
+
_apply_dotnet,
|
|
277
|
+
_apply_gradle,
|
|
278
|
+
)
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
def detect(root: Path) -> Metadata:
|
|
282
|
+
root = Path(root)
|
|
283
|
+
meta = Metadata(name=root.resolve().name)
|
|
284
|
+
remote = git_remote_url(root)
|
|
285
|
+
if remote and (parsed := parse_github_remote(remote)):
|
|
286
|
+
meta.owner, meta.repo = parsed
|
|
287
|
+
for detector in DETECTORS:
|
|
288
|
+
if detector(meta, root):
|
|
289
|
+
break
|
|
290
|
+
if not meta.license:
|
|
291
|
+
meta.license = license_from_file(root)
|
|
292
|
+
return meta
|
readwright/py.typed
ADDED
|
File without changes
|
readwright/renderer.py
ADDED
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
"""Jinja environment construction and README rendering."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Callable
|
|
6
|
+
from dataclasses import dataclass, field
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
from jinja2 import (
|
|
10
|
+
BaseLoader,
|
|
11
|
+
ChoiceLoader,
|
|
12
|
+
Environment,
|
|
13
|
+
FileSystemLoader,
|
|
14
|
+
PackageLoader,
|
|
15
|
+
StrictUndefined,
|
|
16
|
+
TemplateNotFound,
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
from readwright.badges import BadgeRegistry, apply_style, shield
|
|
20
|
+
from readwright.changelog import latest_entries
|
|
21
|
+
from readwright.config import DEFAULT_TEMPLATE, Config, user_templates_dir
|
|
22
|
+
from readwright.helpers import Helpers
|
|
23
|
+
from readwright.images import ImageHelper
|
|
24
|
+
from readwright.toc import FENCE, insert_toc, toc_token
|
|
25
|
+
|
|
26
|
+
BASE_TEMPLATE = "base.md.j2"
|
|
27
|
+
MARKER_PREFIX = "<!-- generated by readwright"
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def collapse_blank_lines(text: str) -> str:
|
|
31
|
+
"""Squeeze runs of blank lines down to one, leaving fenced code blocks untouched."""
|
|
32
|
+
lines: list[str] = []
|
|
33
|
+
in_fence = False
|
|
34
|
+
previous_blank = False
|
|
35
|
+
for line in text.splitlines():
|
|
36
|
+
if FENCE.match(line):
|
|
37
|
+
in_fence = not in_fence
|
|
38
|
+
blank = not in_fence and line.strip() == ""
|
|
39
|
+
if blank and previous_blank:
|
|
40
|
+
continue
|
|
41
|
+
lines.append(line)
|
|
42
|
+
previous_blank = blank
|
|
43
|
+
result = "\n".join(lines)
|
|
44
|
+
return result.strip("\n") + "\n"
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def marker(template_name: str) -> str:
|
|
48
|
+
return f"{MARKER_PREFIX} from {template_name}; edit the template, not this file -->\n"
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class TrackingLoader(BaseLoader):
|
|
52
|
+
"""Delegates to another loader and records which template names it served."""
|
|
53
|
+
|
|
54
|
+
def __init__(self, label: str, inner: BaseLoader) -> None:
|
|
55
|
+
self.label = label
|
|
56
|
+
self.inner = inner
|
|
57
|
+
self.served: list[str] = []
|
|
58
|
+
|
|
59
|
+
def get_source(self, environment: Environment, template: str): # type: ignore[override]
|
|
60
|
+
source = self.inner.get_source(environment, template)
|
|
61
|
+
self.served.append(template)
|
|
62
|
+
return source
|
|
63
|
+
|
|
64
|
+
def list_templates(self) -> list[str]:
|
|
65
|
+
return self.inner.list_templates()
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
@dataclass
|
|
69
|
+
class RenderResult:
|
|
70
|
+
text: str
|
|
71
|
+
template_name: str
|
|
72
|
+
warnings: list[str] = field(default_factory=list)
|
|
73
|
+
user_templates: list[str] = field(default_factory=list)
|
|
74
|
+
sources: dict[str, str] = field(default_factory=dict)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def loader_for(spec: str, root: Path) -> BaseLoader:
|
|
78
|
+
"""Build a loader from a config `templates:` entry: a path or `pkg:<package>[/subdir]`."""
|
|
79
|
+
if spec.startswith("pkg:"):
|
|
80
|
+
package, _, subdir = spec[4:].partition("/")
|
|
81
|
+
return PackageLoader(package, subdir or "templates")
|
|
82
|
+
path = Path(spec)
|
|
83
|
+
return FileSystemLoader(str(path if path.is_absolute() else root / path))
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
class Renderer:
|
|
87
|
+
def __init__(
|
|
88
|
+
self,
|
|
89
|
+
root: Path,
|
|
90
|
+
config: Config,
|
|
91
|
+
warn: Callable[[str], None] | None = None,
|
|
92
|
+
user_templates: Path | None = None,
|
|
93
|
+
) -> None:
|
|
94
|
+
self.root = Path(root)
|
|
95
|
+
self.config = config
|
|
96
|
+
self.warnings: list[str] = []
|
|
97
|
+
self._external_warn = warn
|
|
98
|
+
self.badges = BadgeRegistry(config)
|
|
99
|
+
self.images = ImageHelper(self.root, config, warn=self._warn)
|
|
100
|
+
self.helpers = Helpers(self.root, config, warn=self._warn)
|
|
101
|
+
self.loaders: list[TrackingLoader] = [
|
|
102
|
+
TrackingLoader(
|
|
103
|
+
"repo", FileSystemLoader([str(self.root / "templates"), str(self.root)])
|
|
104
|
+
),
|
|
105
|
+
*[TrackingLoader(spec, loader_for(spec, self.root)) for spec in config.templates],
|
|
106
|
+
TrackingLoader(
|
|
107
|
+
"user",
|
|
108
|
+
FileSystemLoader(str(user_templates if user_templates else user_templates_dir())),
|
|
109
|
+
),
|
|
110
|
+
TrackingLoader("readwright", PackageLoader("readwright", "templates")),
|
|
111
|
+
]
|
|
112
|
+
self.user_loader = next(ld for ld in self.loaders if ld.label == "user")
|
|
113
|
+
self.env = self._build_env()
|
|
114
|
+
|
|
115
|
+
def _warn(self, message: str) -> None:
|
|
116
|
+
self.warnings.append(message)
|
|
117
|
+
if self._external_warn:
|
|
118
|
+
self._external_warn(message)
|
|
119
|
+
|
|
120
|
+
def _build_env(self) -> Environment:
|
|
121
|
+
env = Environment(
|
|
122
|
+
loader=ChoiceLoader(list(self.loaders)),
|
|
123
|
+
undefined=StrictUndefined,
|
|
124
|
+
trim_blocks=True,
|
|
125
|
+
lstrip_blocks=True,
|
|
126
|
+
keep_trailing_newline=True,
|
|
127
|
+
autoescape=False,
|
|
128
|
+
)
|
|
129
|
+
env.globals.update(
|
|
130
|
+
badge=self.badges.render,
|
|
131
|
+
badges=self.badges.render_all,
|
|
132
|
+
donate_badges=self.badges.render_donate,
|
|
133
|
+
shield=self._styled_shield,
|
|
134
|
+
screenshot=self.images.screenshot,
|
|
135
|
+
screenshots=self.images.screenshots,
|
|
136
|
+
image=self.images.image,
|
|
137
|
+
has_screenshots=self.images.has_screenshots,
|
|
138
|
+
toc=toc_token,
|
|
139
|
+
changelog=lambda n=1, path=None, level=3: latest_entries(self.root, n, path, level),
|
|
140
|
+
project=self.config.project,
|
|
141
|
+
vars=self.config.vars,
|
|
142
|
+
config=self.config,
|
|
143
|
+
**self.helpers.as_globals(),
|
|
144
|
+
)
|
|
145
|
+
return env
|
|
146
|
+
|
|
147
|
+
def _styled_shield(self, *args, **kwargs) -> str:
|
|
148
|
+
style = kwargs.pop("style", None) or self.config.badges_style
|
|
149
|
+
return apply_style(shield(*args, **kwargs), style)
|
|
150
|
+
|
|
151
|
+
def source_label(self, name: str) -> str:
|
|
152
|
+
for loader in self.loaders:
|
|
153
|
+
try:
|
|
154
|
+
loader.inner.get_source(self.env, name)
|
|
155
|
+
except TemplateNotFound:
|
|
156
|
+
continue
|
|
157
|
+
return loader.label
|
|
158
|
+
raise TemplateNotFound(name)
|
|
159
|
+
|
|
160
|
+
def resolve_template_name(self) -> str:
|
|
161
|
+
name = self.config.template
|
|
162
|
+
try:
|
|
163
|
+
self.env.get_template(name)
|
|
164
|
+
except TemplateNotFound:
|
|
165
|
+
if name != DEFAULT_TEMPLATE:
|
|
166
|
+
raise
|
|
167
|
+
return BASE_TEMPLATE
|
|
168
|
+
return name
|
|
169
|
+
|
|
170
|
+
def render(self) -> RenderResult:
|
|
171
|
+
self.warnings.clear()
|
|
172
|
+
for loader in self.loaders:
|
|
173
|
+
loader.served.clear()
|
|
174
|
+
name = self.resolve_template_name()
|
|
175
|
+
body = self.env.get_template(name).render()
|
|
176
|
+
text = marker(name) + collapse_blank_lines(insert_toc(body))
|
|
177
|
+
return RenderResult(
|
|
178
|
+
text=text,
|
|
179
|
+
template_name=name,
|
|
180
|
+
warnings=list(self.warnings),
|
|
181
|
+
user_templates=list(self.user_loader.served),
|
|
182
|
+
sources={name: ld.label for ld in self.loaders for name in ld.served},
|
|
183
|
+
)
|