|
50 | 50 | "[ref.envvar]", |
51 | 51 | ) |
52 | 52 | LINKCHECK_BROKEN_RE = re.compile( |
53 | | - r"^(?P<line>\d+) \[(?P<status>broken|redirected|ignored).*?\] " |
54 | | - r"(?P<uri>\S+?)(?: to .*?)?$" |
| 53 | + r"^(?:" |
| 54 | + r"(?P<source>.+?):(?P<line>\d+):\s*" |
| 55 | + r"|(?P<output_line>\d+)\s+" |
| 56 | + r")\[(?P<status>[^\]]+)\]\s+" |
| 57 | + r"(?P<uri>\S+)" |
| 58 | +) |
| 59 | +LINKCHECK_OUTPUT_LINE_RE = re.compile( |
| 60 | + r"^(?:" |
| 61 | + r"(?P<source>.+?):(?P<line>\d+):\s*" |
| 62 | + r"|(?P<output_line>\d+)\s+" |
| 63 | + r")\[(?P<status>[^\]]+)\]\s+" |
| 64 | + r"(?P<uri>\S+)(?P<detail>.*)$" |
55 | 65 | ) |
56 | 66 | SPHINX_PERCENT_RE = re.compile(r"\[\s*(\d+)%\]\s*(.*)$") |
57 | 67 | SPHINX_PHASE_RE = re.compile( |
@@ -114,6 +124,119 @@ def sphinx_line(self, line: str) -> None: |
114 | 124 | def reset_percent(self) -> None: |
115 | 125 | self._last_percent = -1 |
116 | 126 |
|
| 127 | + def replay_log(self, heading: str, lines: Sequence[str]) -> None: |
| 128 | + if self.quiet or not lines: |
| 129 | + return |
| 130 | + self.info(heading) |
| 131 | + for line in lines: |
| 132 | + self.info(f" {line}") |
| 133 | + |
| 134 | + |
| 135 | +def _read_text(path: Path) -> str: |
| 136 | + if not path.is_file(): |
| 137 | + return "" |
| 138 | + return path.read_text(encoding="utf-8", errors="replace") |
| 139 | + |
| 140 | + |
| 141 | +def _sphinx_log_lines(log_text: str) -> list[str]: |
| 142 | + """Extract warning/error lines from a Sphinx build or warning log.""" |
| 143 | + lines: list[str] = [] |
| 144 | + seen: set[str] = set() |
| 145 | + |
| 146 | + for raw_line in log_text.splitlines(): |
| 147 | + stripped = raw_line.strip() |
| 148 | + if not stripped or stripped.startswith("$ ") or stripped.startswith("exit_code:"): |
| 149 | + continue |
| 150 | + if SPHINX_PERCENT_RE.search(stripped) or SPHINX_PHASE_RE.search(stripped): |
| 151 | + continue |
| 152 | + |
| 153 | + normalized = stripped |
| 154 | + match = SPHINX_ISSUE_RE.match(raw_line) |
| 155 | + if match: |
| 156 | + normalized = ( |
| 157 | + f"{match.group('file')}:{match.group('line')}: " |
| 158 | + f"{match.group('level')}: {match.group('message')}" |
| 159 | + ) |
| 160 | + elif stripped.startswith("WARNING: ") or stripped.startswith("ERROR: "): |
| 161 | + normalized = stripped |
| 162 | + elif " WARNING:" not in raw_line and " ERROR:" not in raw_line: |
| 163 | + continue |
| 164 | + |
| 165 | + if normalized not in seen: |
| 166 | + seen.add(normalized) |
| 167 | + lines.append(normalized) |
| 168 | + |
| 169 | + return lines |
| 170 | + |
| 171 | + |
| 172 | +def _linkcheck_output_lines(output_text: str) -> tuple[list[str], list[str]]: |
| 173 | + """Split linkcheck output.txt lines into info (broken) and debug (other) buckets.""" |
| 174 | + info_lines: list[str] = [] |
| 175 | + debug_lines: list[str] = [] |
| 176 | + seen: set[str] = set() |
| 177 | + |
| 178 | + for raw_line in output_text.splitlines(): |
| 179 | + stripped = raw_line.strip() |
| 180 | + if not stripped or stripped in seen: |
| 181 | + continue |
| 182 | + seen.add(stripped) |
| 183 | + |
| 184 | + match = LINKCHECK_OUTPUT_LINE_RE.match(stripped) |
| 185 | + if match: |
| 186 | + status = match.group("status") |
| 187 | + uri = match.group("uri") |
| 188 | + detail = (match.group("detail") or "").rstrip() |
| 189 | + source = match.group("source") |
| 190 | + line_no = match.group("line") or match.group("output_line") |
| 191 | + location = f"{source}:{line_no}" if source and line_no else f"linkcheck:{line_no or '?'}" |
| 192 | + suffix = detail if not detail or detail.startswith((" ", "\t")) else f" {detail}" |
| 193 | + message = f"{location}: [{status}] {uri}{suffix}" |
| 194 | + if status.startswith("broken"): |
| 195 | + info_lines.append(message) |
| 196 | + else: |
| 197 | + debug_lines.append(message) |
| 198 | + continue |
| 199 | + |
| 200 | + if "[broken" in stripped.lower(): |
| 201 | + info_lines.append(stripped) |
| 202 | + elif "[redirected" in stripped.lower() or "[ignored" in stripped.lower(): |
| 203 | + debug_lines.append(stripped) |
| 204 | + else: |
| 205 | + debug_lines.append(stripped) |
| 206 | + |
| 207 | + return info_lines, debug_lines |
| 208 | + |
| 209 | + |
| 210 | +def _emit_sphinx_build_log(progress: ProgressLog, log_path: Path, *, heading: str) -> None: |
| 211 | + lines = _sphinx_log_lines(_read_text(log_path)) |
| 212 | + if lines: |
| 213 | + progress.replay_log(heading, lines) |
| 214 | + elif not progress.quiet: |
| 215 | + progress.info(f"{heading}: no warnings or errors recorded") |
| 216 | + |
| 217 | + |
| 218 | +def _emit_linkcheck_logs( |
| 219 | + progress: ProgressLog, |
| 220 | + log_path: Path, |
| 221 | + output_path: Path, |
| 222 | +) -> None: |
| 223 | + broken_lines, other_lines = _linkcheck_output_lines(_read_text(output_path)) |
| 224 | + warning_lines = _sphinx_log_lines(_read_text(log_path)) |
| 225 | + |
| 226 | + if broken_lines: |
| 227 | + progress.replay_log("External linkcheck broken URLs:", broken_lines) |
| 228 | + elif not progress.quiet: |
| 229 | + progress.info("External linkcheck broken URLs: none") |
| 230 | + |
| 231 | + if other_lines: |
| 232 | + progress.info("External linkcheck other results:") |
| 233 | + for line in other_lines: |
| 234 | + progress.debug(f" {line}") |
| 235 | + |
| 236 | + extra_warnings = [line for line in warning_lines if line not in broken_lines] |
| 237 | + if extra_warnings: |
| 238 | + progress.replay_log("Sphinx linkcheck warnings:", extra_warnings) |
| 239 | + |
117 | 240 |
|
118 | 241 | @dataclass(frozen=True) |
119 | 242 | class DocLinksPaths: |
@@ -317,14 +440,24 @@ def _parse_linkcheck_output(output_path: Path) -> list[LinkIssue]: |
317 | 440 |
|
318 | 441 | issues: list[LinkIssue] = [] |
319 | 442 | for raw_line in output_path.read_text(encoding="utf-8", errors="replace").splitlines(): |
320 | | - match = LINKCHECK_BROKEN_RE.match(raw_line) |
321 | | - if not match or match.group("status") != "broken": |
| 443 | + match = LINKCHECK_BROKEN_RE.match(raw_line.strip()) |
| 444 | + if not match or not match.group("status").startswith("broken"): |
322 | 445 | continue |
| 446 | + |
| 447 | + source = match.group("source") |
| 448 | + line_str = match.group("line") or match.group("output_line") |
| 449 | + if source and line_str: |
| 450 | + issue_source = f"{source}:{line_str}" |
| 451 | + issue_line = int(line_str) |
| 452 | + else: |
| 453 | + issue_source = str(output_path) |
| 454 | + issue_line = int(line_str) if line_str else None |
| 455 | + |
323 | 456 | issues.append( |
324 | 457 | LinkIssue( |
325 | 458 | check="external-link", |
326 | | - source=str(output_path), |
327 | | - line=int(match.group("line")), |
| 459 | + source=issue_source, |
| 460 | + line=issue_line, |
328 | 461 | message="unreachable URL", |
329 | 462 | target=match.group("uri"), |
330 | 463 | ) |
@@ -429,9 +562,8 @@ def check_cross_references( |
429 | 562 | ) |
430 | 563 | ) |
431 | 564 |
|
432 | | - progress.info( |
433 | | - f"Cross-reference check finished with {len(issues)} issue(s); log: {log_path}" |
434 | | - ) |
| 565 | + progress.info(f"Cross-reference check finished with {len(issues)} issue(s)") |
| 566 | + _emit_sphinx_build_log(progress, log_path, heading="Cross-reference Sphinx log") |
435 | 567 | return issues, str(log_path) |
436 | 568 |
|
437 | 569 |
|
@@ -469,9 +601,8 @@ def check_external_links( |
469 | 601 | ) |
470 | 602 | ) |
471 | 603 |
|
472 | | - progress.info( |
473 | | - f"External link check finished with {len(issues)} broken URL(s); log: {log_path}" |
474 | | - ) |
| 604 | + progress.info(f"External link check finished with {len(issues)} broken URL(s)") |
| 605 | + _emit_linkcheck_logs(progress, log_path, output_txt) |
475 | 606 | return issues, str(log_path), str(output_txt) |
476 | 607 |
|
477 | 608 |
|
@@ -617,16 +748,6 @@ def check(self): |
617 | 748 | self.warning(f"Optional documentation links check skipped: {exc}") |
618 | 749 | return |
619 | 750 |
|
620 | | - if report.intersphinx_docsets: |
621 | | - self.debug(f"Intersphinx docsets: {', '.join(report.intersphinx_docsets)}") |
622 | | - else: |
623 | | - self.debug( |
624 | | - "No local NCS docset inventories found; only in-addon :ref: targets validated" |
625 | | - ) |
626 | | - |
627 | | - for log_name, log_path in report.log_paths.items(): |
628 | | - self.debug(f"{log_name}: {log_path}") |
629 | | - |
630 | 751 | for link_issue in report.issues: |
631 | 752 | self.issue(_format_link_issue(link_issue)) |
632 | 753 |
|
|
0 commit comments