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/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"![{alt}]({src})"
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"![{alt}]({light}#gh-light-mode-only)![{alt}]({dark}#gh-dark-mode-only)"
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
+ )