paperkit 0.1.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.
- paperkit/__init__.py +6 -0
- paperkit/archive.py +183 -0
- paperkit/bibliography.py +321 -0
- paperkit/builder.py +186 -0
- paperkit/checks.py +563 -0
- paperkit/cli.py +595 -0
- paperkit/config.py +437 -0
- paperkit/docker.py +265 -0
- paperkit/pages.py +254 -0
- paperkit/profiles/__init__.py +56 -0
- paperkit/profiles/acm.py +10 -0
- paperkit/py.typed +0 -0
- paperkit/report.py +246 -0
- paperkit/reproducible.py +158 -0
- paperkit/texlive.py +310 -0
- paperkit/text_export.py +165 -0
- paperkit-0.1.0.dist-info/METADATA +381 -0
- paperkit-0.1.0.dist-info/RECORD +21 -0
- paperkit-0.1.0.dist-info/WHEEL +4 -0
- paperkit-0.1.0.dist-info/entry_points.txt +2 -0
- paperkit-0.1.0.dist-info/licenses/LICENSE +21 -0
paperkit/pages.py
ADDED
|
@@ -0,0 +1,254 @@
|
|
|
1
|
+
# Copyright (C) 2026 Vrije Universiteit Brussel. All rights reserved.
|
|
2
|
+
# SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
"""Page-count analysis from PDF + aux file.
|
|
5
|
+
|
|
6
|
+
Extracts:
|
|
7
|
+
* total pages - via pdfinfo
|
|
8
|
+
* refs start page - from .aux TOC entries ("References"/"Bibliography"),
|
|
9
|
+
falling back to a pdftotext heading scan
|
|
10
|
+
* ack start page - same, matching "Acknowledg..."
|
|
11
|
+
* before refs/ack - pages occupied by the content preceding each section
|
|
12
|
+
(a section starting mid-page counts that page)
|
|
13
|
+
|
|
14
|
+
Patterns are configurable for non-English papers. Page limits in
|
|
15
|
+
[page_limits] flip the check to ERROR when exceeded.
|
|
16
|
+
|
|
17
|
+
Page numbers are the same in single- and double-column layouts (a page is a
|
|
18
|
+
page), so this module needs no layout awareness.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
import re
|
|
22
|
+
import shutil
|
|
23
|
+
import subprocess
|
|
24
|
+
from dataclasses import dataclass
|
|
25
|
+
from pathlib import Path
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@dataclass
|
|
29
|
+
class PageStats:
|
|
30
|
+
total: int | None = None
|
|
31
|
+
refs_page: int | None = None
|
|
32
|
+
ack_page: int | None = None
|
|
33
|
+
# Number of pages occupied by the content preceding each section. By
|
|
34
|
+
# default refs and ack form one exempt tail, so both hold the pages before
|
|
35
|
+
# whichever comes first: content of one exempt section must not count
|
|
36
|
+
# against the other's limit. With ack_counts_as_content the tail is the
|
|
37
|
+
# references alone, and before_ack becomes the pages before the
|
|
38
|
+
# acknowledgments. The page is counted when the section starts mid-page.
|
|
39
|
+
before_refs: int | None = None
|
|
40
|
+
before_ack: int | None = None
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
# Matches \@writefile{toc}{\contentsline {section}{TITLE}{PAGE}{ANCHOR}...}
|
|
44
|
+
# where TITLE may contain balanced nested braces. We pull TITLE with a manual
|
|
45
|
+
# brace scanner since regex can't track arbitrary nesting.
|
|
46
|
+
_TOC_PREFIX = r"\@writefile{toc}{\contentsline {section}{"
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _parse_toc_line(line: str) -> tuple[str, int] | None:
|
|
50
|
+
"""Return (raw_title, page) from one \\@writefile{toc} section line."""
|
|
51
|
+
line = line.lstrip()
|
|
52
|
+
if not line.startswith(_TOC_PREFIX):
|
|
53
|
+
return None
|
|
54
|
+
rest = line[len(_TOC_PREFIX) :]
|
|
55
|
+
|
|
56
|
+
# Scan balanced braces for the title.
|
|
57
|
+
depth = 1
|
|
58
|
+
i = 0
|
|
59
|
+
while i < len(rest) and depth > 0:
|
|
60
|
+
c = rest[i]
|
|
61
|
+
if c == "{":
|
|
62
|
+
depth += 1
|
|
63
|
+
elif c == "}":
|
|
64
|
+
depth -= 1
|
|
65
|
+
if depth == 0:
|
|
66
|
+
break
|
|
67
|
+
i += 1
|
|
68
|
+
if depth != 0 or i >= len(rest):
|
|
69
|
+
return None
|
|
70
|
+
title = rest[:i]
|
|
71
|
+
rest = rest[i + 1 :]
|
|
72
|
+
|
|
73
|
+
# Expect {PAGE}
|
|
74
|
+
if not rest.startswith("{"):
|
|
75
|
+
return None
|
|
76
|
+
page_end = rest.find("}", 1)
|
|
77
|
+
if page_end < 0:
|
|
78
|
+
return None
|
|
79
|
+
page_str = rest[1:page_end].strip()
|
|
80
|
+
try:
|
|
81
|
+
return title, int(page_str)
|
|
82
|
+
except ValueError:
|
|
83
|
+
return None # roman numerals or odd page anchors: skip
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _clean_title(title: str) -> str:
|
|
87
|
+
"""Strip \\numberline{...}, \\mbox{...}, and other LaTeX command wrappers."""
|
|
88
|
+
# \numberline {X} or \numberline{X}
|
|
89
|
+
title = re.sub(r"\\numberline\s*\{[^{}]*\}", "", title)
|
|
90
|
+
# Drop \mbox{...} wrappers around section numbers
|
|
91
|
+
title = re.sub(r"\\mbox\s*\{([^{}]*)\}", r"\1", title)
|
|
92
|
+
# Drop single-arg \cmd{content} wrappers, keep content
|
|
93
|
+
for _ in range(3):
|
|
94
|
+
title = re.sub(r"\\[a-zA-Z]+\s*\{([^{}]*)\}", r"\1", title)
|
|
95
|
+
# Remove remaining lone commands
|
|
96
|
+
title = re.sub(r"\\[a-zA-Z@]+", "", title)
|
|
97
|
+
return title.replace("{", "").replace("}", "").strip()
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def parse_toc(aux_path: Path) -> list[tuple[str, int]]:
|
|
101
|
+
"""Read .aux and return [(cleaned_title, page)] in document order."""
|
|
102
|
+
if not aux_path.is_file():
|
|
103
|
+
return []
|
|
104
|
+
out: list[tuple[str, int]] = []
|
|
105
|
+
for line in aux_path.read_text(errors="replace").splitlines():
|
|
106
|
+
parsed = _parse_toc_line(line)
|
|
107
|
+
if parsed is None:
|
|
108
|
+
continue
|
|
109
|
+
title, page = parsed
|
|
110
|
+
out.append((_clean_title(title), page))
|
|
111
|
+
return out
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def find_section_page(toc: list[tuple[str, int]], pattern: str) -> int | None:
|
|
115
|
+
"""First TOC entry whose title matches *pattern* (used with re.search)."""
|
|
116
|
+
rx = re.compile(pattern)
|
|
117
|
+
for title, page in toc:
|
|
118
|
+
if rx.search(title):
|
|
119
|
+
return page
|
|
120
|
+
return None
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def _pdf_text_pages(pdf_path: Path) -> list[str] | None:
|
|
124
|
+
"""Extract per-page text via pdftotext (pages are form-feed separated)."""
|
|
125
|
+
if not pdf_path.is_file() or shutil.which("pdftotext") is None:
|
|
126
|
+
return None
|
|
127
|
+
proc = subprocess.run(
|
|
128
|
+
["pdftotext", str(pdf_path), "-"],
|
|
129
|
+
capture_output=True,
|
|
130
|
+
text=True,
|
|
131
|
+
)
|
|
132
|
+
if proc.returncode != 0:
|
|
133
|
+
return None
|
|
134
|
+
return proc.stdout.split("\f")
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def _is_heading_line(line: str, rx: re.Pattern[str]) -> bool:
|
|
138
|
+
"""True if *line* looks like a standalone section heading matching *rx*.
|
|
139
|
+
|
|
140
|
+
Headings render as short lines; small-caps fonts may introduce spurious
|
|
141
|
+
spaces ("R EFERENCES"), so we also match with spaces stripped.
|
|
142
|
+
"""
|
|
143
|
+
line = line.strip()
|
|
144
|
+
if not 0 < len(line) < 30:
|
|
145
|
+
return False
|
|
146
|
+
return bool(rx.search(line) or rx.search(line.replace(" ", "")))
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def find_heading_pos(pages_text: list[str], pattern: str) -> tuple[int, int] | None:
|
|
150
|
+
"""Locate *pattern* as a standalone heading line in the rendered PDF text.
|
|
151
|
+
|
|
152
|
+
Returns (page_num, line_index) of the first match, or None. Also serves
|
|
153
|
+
as fallback detection for sections that don't write to \\@writefile{toc},
|
|
154
|
+
such as IEEEtran's `\\section*{Acknowledgment}`.
|
|
155
|
+
"""
|
|
156
|
+
rx = re.compile(pattern)
|
|
157
|
+
for page_num, page_text in enumerate(pages_text, start=1):
|
|
158
|
+
for line_idx, raw_line in enumerate(page_text.splitlines()):
|
|
159
|
+
if _is_heading_line(raw_line, rx):
|
|
160
|
+
return page_num, line_idx
|
|
161
|
+
return None
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def body_end_page(pages_text: list[str], heading_pos: tuple[int, int]) -> int:
|
|
165
|
+
"""Page on which the body content preceding *heading_pos* ends.
|
|
166
|
+
|
|
167
|
+
If the heading sits at the top of its page (nothing but a running header
|
|
168
|
+
or page number above it), the body ended on the previous page; otherwise
|
|
169
|
+
it shares the heading's page. In double-column layouts pdftotext emits
|
|
170
|
+
text in reading order, so a heading at the top of the second column is
|
|
171
|
+
correctly preceded by first-column body text.
|
|
172
|
+
"""
|
|
173
|
+
page, line_idx = heading_pos
|
|
174
|
+
lines = pages_text[page - 1].splitlines()[:line_idx]
|
|
175
|
+
preceding = sum(1 for raw_line in lines if raw_line.strip())
|
|
176
|
+
# Tolerate a few short lines above the heading (running header, page number).
|
|
177
|
+
return page if preceding > 2 else page - 1
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def get_total_pages(pdf_path: Path) -> int | None:
|
|
181
|
+
"""Return total PDF pages via pdfinfo, or None if it can't be read."""
|
|
182
|
+
if not pdf_path.is_file() or shutil.which("pdfinfo") is None:
|
|
183
|
+
return None
|
|
184
|
+
proc = subprocess.run(["pdfinfo", str(pdf_path)], capture_output=True, text=True)
|
|
185
|
+
for line in proc.stdout.splitlines():
|
|
186
|
+
if line.startswith("Pages:"):
|
|
187
|
+
try:
|
|
188
|
+
return int(line.split(":", 1)[1].strip())
|
|
189
|
+
except ValueError:
|
|
190
|
+
return None
|
|
191
|
+
return None
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def content_end_pages(
|
|
195
|
+
pages_text: list[str],
|
|
196
|
+
refs_pos: tuple[int, int] | None,
|
|
197
|
+
ack_pos: tuple[int, int] | None,
|
|
198
|
+
ack_counts_as_content: bool = False,
|
|
199
|
+
) -> tuple[int | None, int | None]:
|
|
200
|
+
"""Pages occupied by the content before the references and before the acks.
|
|
201
|
+
|
|
202
|
+
By default the two sections form one exempt tail: both values are the
|
|
203
|
+
pages before whichever heading comes first, so acknowledgments sitting
|
|
204
|
+
between the body and the references do not count against either limit.
|
|
205
|
+
|
|
206
|
+
With *ack_counts_as_content* only the references are exempt, as at venues
|
|
207
|
+
that count acknowledgments as text: the page budget then ends at the
|
|
208
|
+
references heading, and the acknowledgments fall inside it.
|
|
209
|
+
"""
|
|
210
|
+
if ack_counts_as_content:
|
|
211
|
+
before_refs = body_end_page(pages_text, refs_pos) if refs_pos else None
|
|
212
|
+
before_ack = body_end_page(pages_text, ack_pos) if ack_pos else None
|
|
213
|
+
return before_refs, before_ack
|
|
214
|
+
|
|
215
|
+
positions = [pos for pos in (refs_pos, ack_pos) if pos is not None]
|
|
216
|
+
if not positions:
|
|
217
|
+
return None, None
|
|
218
|
+
end = body_end_page(pages_text, min(positions))
|
|
219
|
+
return (end if refs_pos else None), (end if ack_pos else None)
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def gather(
|
|
223
|
+
pdf_path: Path | None,
|
|
224
|
+
aux_path: Path | None,
|
|
225
|
+
refs_pattern: str,
|
|
226
|
+
ack_pattern: str,
|
|
227
|
+
ack_counts_as_content: bool = False,
|
|
228
|
+
) -> PageStats:
|
|
229
|
+
"""Collect all page stats; missing inputs leave fields as None."""
|
|
230
|
+
stats = PageStats()
|
|
231
|
+
pages_text: list[str] | None = None
|
|
232
|
+
if pdf_path is not None:
|
|
233
|
+
stats.total = get_total_pages(pdf_path)
|
|
234
|
+
pages_text = _pdf_text_pages(pdf_path)
|
|
235
|
+
if aux_path is not None:
|
|
236
|
+
toc = parse_toc(aux_path)
|
|
237
|
+
stats.refs_page = find_section_page(toc, refs_pattern)
|
|
238
|
+
stats.ack_page = find_section_page(toc, ack_pattern)
|
|
239
|
+
if pages_text:
|
|
240
|
+
refs_pos = find_heading_pos(pages_text, refs_pattern)
|
|
241
|
+
ack_pos = find_heading_pos(pages_text, ack_pattern)
|
|
242
|
+
# Fallback for sections missing from the TOC (e.g. IEEEtran's
|
|
243
|
+
# `\section*{Acknowledgment}` doesn't emit `\@writefile{toc}`).
|
|
244
|
+
if stats.refs_page is None and refs_pos is not None:
|
|
245
|
+
stats.refs_page = refs_pos[0]
|
|
246
|
+
if stats.ack_page is None and ack_pos is not None:
|
|
247
|
+
stats.ack_page = ack_pos[0]
|
|
248
|
+
stats.before_refs, stats.before_ack = content_end_pages(
|
|
249
|
+
pages_text,
|
|
250
|
+
refs_pos,
|
|
251
|
+
ack_pos,
|
|
252
|
+
ack_counts_as_content,
|
|
253
|
+
)
|
|
254
|
+
return stats
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
# Copyright (C) 2026 Vrije Universiteit Brussel. All rights reserved.
|
|
2
|
+
# SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
"""Profile registry and base class."""
|
|
5
|
+
|
|
6
|
+
from paperkit.config import PaperkitConfig
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class BaseProfile:
|
|
10
|
+
"""Base profile with sensible defaults."""
|
|
11
|
+
|
|
12
|
+
def docker_packages(self) -> list[str]:
|
|
13
|
+
"""Extra packages beyond the base set (see docker._BASE_PACKAGES)."""
|
|
14
|
+
return []
|
|
15
|
+
|
|
16
|
+
# Reordering arguments, dropped when sorting is off.
|
|
17
|
+
SORT_ARGS = ("--sort=key", "--sort-fields")
|
|
18
|
+
|
|
19
|
+
def bibtex_tidy_args(self, sort: bool = True) -> list[str]:
|
|
20
|
+
"""Arguments for bibtex-tidy; *sort* also reorders entries and fields."""
|
|
21
|
+
args = [
|
|
22
|
+
"--curly",
|
|
23
|
+
"--blank-lines",
|
|
24
|
+
"--sort=key",
|
|
25
|
+
"--duplicates=key,doi,citation",
|
|
26
|
+
"--no-escape",
|
|
27
|
+
"--sort-fields",
|
|
28
|
+
"--trailing-commas",
|
|
29
|
+
"--remove-empty-fields",
|
|
30
|
+
"--enclosing-braces=title",
|
|
31
|
+
"--align=13",
|
|
32
|
+
]
|
|
33
|
+
return args if sort else [a for a in args if a not in self.SORT_ARGS]
|
|
34
|
+
|
|
35
|
+
def bibtex_tidy_version(self) -> str:
|
|
36
|
+
return "1.14.0"
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
_PROFILES: dict[str, type[BaseProfile]] = {}
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def register_profile(name: str, cls: type[BaseProfile]) -> None:
|
|
43
|
+
_PROFILES[name] = cls
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def get_profile(config: PaperkitConfig) -> BaseProfile:
|
|
47
|
+
cls = _PROFILES.get(config.profile_name, BaseProfile)
|
|
48
|
+
return cls()
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
# Register built-in profiles
|
|
52
|
+
register_profile("base", BaseProfile)
|
|
53
|
+
|
|
54
|
+
from paperkit.profiles.acm import ACMProfile # noqa: E402
|
|
55
|
+
|
|
56
|
+
register_profile("acm", ACMProfile)
|
paperkit/profiles/acm.py
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
# Copyright (C) 2026 Vrije Universiteit Brussel. All rights reserved.
|
|
2
|
+
# SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
"""ACM sigconf profile."""
|
|
5
|
+
|
|
6
|
+
from paperkit.profiles import BaseProfile
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class ACMProfile(BaseProfile):
|
|
10
|
+
"""Profile for ACM sigconf papers (acmart class)."""
|
paperkit/py.typed
ADDED
|
File without changes
|
paperkit/report.py
ADDED
|
@@ -0,0 +1,246 @@
|
|
|
1
|
+
# Copyright (C) 2026 Vrije Universiteit Brussel. All rights reserved.
|
|
2
|
+
# SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
"""Build report formatting with colors and summary table."""
|
|
5
|
+
|
|
6
|
+
import re
|
|
7
|
+
import shutil
|
|
8
|
+
import subprocess
|
|
9
|
+
|
|
10
|
+
from paperkit import reproducible, texlive
|
|
11
|
+
from paperkit.builder import BuildResult
|
|
12
|
+
from paperkit.checks import CheckResult, CheckStatus
|
|
13
|
+
from paperkit.config import PaperkitConfig
|
|
14
|
+
|
|
15
|
+
# ANSI color codes
|
|
16
|
+
_RESET = "\033[0m"
|
|
17
|
+
_BOLD = "\033[1m"
|
|
18
|
+
_DIM = "\033[2m"
|
|
19
|
+
_GREEN = "\033[32m"
|
|
20
|
+
_YELLOW = "\033[33m"
|
|
21
|
+
_RED = "\033[31m"
|
|
22
|
+
_CYAN = "\033[36m"
|
|
23
|
+
_WHITE = "\033[37m"
|
|
24
|
+
|
|
25
|
+
# Status decorations
|
|
26
|
+
_STATUS_STYLE: dict[CheckStatus, tuple[str, str, str]] = {
|
|
27
|
+
# emoji color label
|
|
28
|
+
CheckStatus.CLEAN: ("\u2705", _GREEN, "pass"),
|
|
29
|
+
CheckStatus.SKIPPED: ("\u23ed\ufe0f ", _YELLOW, "skip"),
|
|
30
|
+
CheckStatus.WARNING: ("\u26a0\ufe0f ", _YELLOW, "warn"),
|
|
31
|
+
CheckStatus.ERROR: ("\u274c", _RED, "FAIL"),
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _styled(text: str, *codes: str) -> str:
|
|
36
|
+
return "".join(codes) + text + _RESET
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _status_icon(status: CheckStatus) -> str:
|
|
40
|
+
emoji, _, _ = _STATUS_STYLE[status]
|
|
41
|
+
return emoji
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _status_colored(text: str, status: CheckStatus) -> str:
|
|
45
|
+
_, color, _ = _STATUS_STYLE[status]
|
|
46
|
+
return _styled(text, color)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def get_environment_info(config: PaperkitConfig, build_result: BuildResult) -> dict[str, str]:
|
|
50
|
+
"""Collect environment info as a dict."""
|
|
51
|
+
info: dict[str, str] = {}
|
|
52
|
+
|
|
53
|
+
# The log banner names the engine that produced the PDF, wherever this
|
|
54
|
+
# report runs; probing the engine on PATH is only a fallback.
|
|
55
|
+
banner = texlive.read_log_banner(build_result.log)
|
|
56
|
+
if banner:
|
|
57
|
+
info["TeX Live"] = str(banner)
|
|
58
|
+
elif shutil.which(config.engine):
|
|
59
|
+
proc = subprocess.run([config.engine, "--version"], capture_output=True, text=True)
|
|
60
|
+
if proc.stdout:
|
|
61
|
+
first = proc.stdout.splitlines()[0]
|
|
62
|
+
info["TeX Live"] = f"{first} (host {config.engine}, no build log)"
|
|
63
|
+
if config.texlive.source == "upstream":
|
|
64
|
+
info["TeX Live source"] = texlive.describe(config.texlive)
|
|
65
|
+
info["Source date"] = str(reproducible.resolve(config.source_date_epoch, config.root))
|
|
66
|
+
|
|
67
|
+
if config.profile_name == "acm" and build_result.log and build_result.log.is_file():
|
|
68
|
+
text = build_result.log.read_text(errors="replace")
|
|
69
|
+
m = re.search(r"acmart (\d{4}/\d{2}/\d{2} v[\d.]+)", text)
|
|
70
|
+
info["acmart"] = m.group(1) if m else "not found"
|
|
71
|
+
|
|
72
|
+
return info
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def print_report(
|
|
76
|
+
build_result: BuildResult,
|
|
77
|
+
check_results: list[CheckResult],
|
|
78
|
+
env_info: dict[str, str] | None = None,
|
|
79
|
+
) -> None:
|
|
80
|
+
"""Print formatted build report with colors and summary table."""
|
|
81
|
+
print()
|
|
82
|
+
|
|
83
|
+
# -- Detail sections --
|
|
84
|
+
_print_details(check_results)
|
|
85
|
+
|
|
86
|
+
# -- Summary table --
|
|
87
|
+
print(_styled(" Summary", _BOLD))
|
|
88
|
+
print()
|
|
89
|
+
|
|
90
|
+
# Environment info
|
|
91
|
+
if env_info:
|
|
92
|
+
for key, val in env_info.items():
|
|
93
|
+
print(f" {_styled(key.rjust(24), _DIM)} {val}")
|
|
94
|
+
print()
|
|
95
|
+
|
|
96
|
+
_print_rows(check_results)
|
|
97
|
+
print()
|
|
98
|
+
|
|
99
|
+
# -- Overall verdict --
|
|
100
|
+
worst = _worst_status(check_results)
|
|
101
|
+
if worst == CheckStatus.ERROR:
|
|
102
|
+
verdict = _styled(" \U0001f4a5 Build has errors", _BOLD, _RED)
|
|
103
|
+
elif worst == CheckStatus.WARNING:
|
|
104
|
+
verdict = _styled(" \u26a0\ufe0f Build OK with warnings", _BOLD, _YELLOW)
|
|
105
|
+
else:
|
|
106
|
+
verdict = _styled(" \u2728 All checks passed", _BOLD, _GREEN)
|
|
107
|
+
print(verdict)
|
|
108
|
+
|
|
109
|
+
# Log location
|
|
110
|
+
print(f" {_styled(f'Logs: {build_result.output_dir}/*.log, *.blg', _DIM)}")
|
|
111
|
+
print()
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def _print_details(check_results: list[CheckResult]) -> None:
|
|
115
|
+
"""Detail sections for non-clean checks (and clean ones with extra info)."""
|
|
116
|
+
has_details = False
|
|
117
|
+
for cr in check_results:
|
|
118
|
+
if cr.status != CheckStatus.CLEAN or _has_extra_info(cr):
|
|
119
|
+
if not has_details:
|
|
120
|
+
print(_styled(" Details", _BOLD, _DIM))
|
|
121
|
+
print()
|
|
122
|
+
has_details = True
|
|
123
|
+
_print_check_detail(cr)
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def _print_rows(check_results: list[CheckResult]) -> None:
|
|
127
|
+
"""One summary row per check."""
|
|
128
|
+
for cr in check_results:
|
|
129
|
+
icon = _status_icon(cr.status)
|
|
130
|
+
colored_summary = _status_colored(_check_summary(cr), cr.status)
|
|
131
|
+
print(f" {icon} {_styled(cr.name.rjust(24), _BOLD)} {colored_summary}")
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def _has_extra_info(cr: CheckResult) -> bool:
|
|
135
|
+
"""Check if a result has info worth showing beyond the summary."""
|
|
136
|
+
if cr.show_details:
|
|
137
|
+
return True
|
|
138
|
+
# bibtex-tidy reports version in messages even when clean
|
|
139
|
+
return cr.name == "BibTeX tidy" and any("bibtex-tidy:" in m for m in cr.messages)
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def _check_summary(cr: CheckResult) -> str:
|
|
143
|
+
"""One-line summary for the table."""
|
|
144
|
+
if not cr.messages:
|
|
145
|
+
return cr.status.value
|
|
146
|
+
# Use first message as summary, strip leading whitespace
|
|
147
|
+
return cr.messages[0].strip()
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def _print_check_detail(cr: CheckResult) -> None:
|
|
151
|
+
"""Print detailed messages for a check (warnings/errors)."""
|
|
152
|
+
icon = _status_icon(cr.status)
|
|
153
|
+
_, color, _ = _STATUS_STYLE[cr.status]
|
|
154
|
+
print(f" {icon} {_styled(cr.name, _BOLD, color)}")
|
|
155
|
+
for msg in cr.messages:
|
|
156
|
+
print(f" {_status_colored(msg, cr.status)}")
|
|
157
|
+
print()
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
# Statuses that make the overall verdict a warning.
|
|
161
|
+
_WARNING_LEVEL = (CheckStatus.WARNING, CheckStatus.SKIPPED)
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def _worst_status(results: list[CheckResult]) -> CheckStatus:
|
|
165
|
+
"""Worst verdict across results (CLEAN, WARNING or ERROR), ignoring advisory ones."""
|
|
166
|
+
binding = [cr for cr in results if not cr.advisory]
|
|
167
|
+
if any(cr.status == CheckStatus.ERROR for cr in binding):
|
|
168
|
+
return CheckStatus.ERROR
|
|
169
|
+
if any(cr.status in _WARNING_LEVEL for cr in binding):
|
|
170
|
+
return CheckStatus.WARNING
|
|
171
|
+
return CheckStatus.CLEAN
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def print_multi_summary(
|
|
175
|
+
runs: list[tuple[str, BuildResult, list[CheckResult]]],
|
|
176
|
+
shared: list[CheckResult] | None = None,
|
|
177
|
+
) -> None:
|
|
178
|
+
"""Print a one-line-per-main summary across all builds.
|
|
179
|
+
|
|
180
|
+
*shared* holds project-wide results (bibliography checks, one per bib
|
|
181
|
+
file), printed once in their own block and included in the verdict.
|
|
182
|
+
"""
|
|
183
|
+
if shared:
|
|
184
|
+
print(_styled(" Bibliography", _BOLD))
|
|
185
|
+
print()
|
|
186
|
+
_print_details(shared)
|
|
187
|
+
_print_rows(shared)
|
|
188
|
+
print()
|
|
189
|
+
|
|
190
|
+
print(_styled(" Per-project summary", _BOLD))
|
|
191
|
+
print()
|
|
192
|
+
|
|
193
|
+
width = max(len(name) for name, _, _ in runs) if runs else 0
|
|
194
|
+
counts = {CheckStatus.CLEAN: 0, CheckStatus.WARNING: 0, CheckStatus.ERROR: 0}
|
|
195
|
+
failed = 0
|
|
196
|
+
|
|
197
|
+
for name, build_result, check_results in runs:
|
|
198
|
+
if not build_result.success:
|
|
199
|
+
failed += 1
|
|
200
|
+
counts[CheckStatus.ERROR] += 1
|
|
201
|
+
icon = _status_icon(CheckStatus.ERROR)
|
|
202
|
+
label = _status_colored("build failed", CheckStatus.ERROR)
|
|
203
|
+
else:
|
|
204
|
+
worst = _worst_status(check_results)
|
|
205
|
+
counts[worst] += 1
|
|
206
|
+
icon = _status_icon(worst)
|
|
207
|
+
if worst == CheckStatus.CLEAN:
|
|
208
|
+
label = _status_colored("all checks passed", worst)
|
|
209
|
+
else:
|
|
210
|
+
level = _WARNING_LEVEL if worst == CheckStatus.WARNING else (worst,)
|
|
211
|
+
bad = [cr.name for cr in check_results if cr.status in level]
|
|
212
|
+
summary = ", ".join(bad) if bad else worst.value
|
|
213
|
+
label = _status_colored(summary, worst)
|
|
214
|
+
print(f" {icon} {_styled(name.ljust(width), _BOLD)} {label}")
|
|
215
|
+
|
|
216
|
+
print()
|
|
217
|
+
n = len(runs)
|
|
218
|
+
parts = [
|
|
219
|
+
f"{counts[CheckStatus.CLEAN]} pass",
|
|
220
|
+
f"{counts[CheckStatus.WARNING]} warn",
|
|
221
|
+
f"{counts[CheckStatus.ERROR]} fail",
|
|
222
|
+
]
|
|
223
|
+
breakdown = ", ".join(parts)
|
|
224
|
+
shared_worst = _worst_status(shared or [])
|
|
225
|
+
if shared_worst != CheckStatus.CLEAN:
|
|
226
|
+
breakdown += f"; bibliography: {_STATUS_STYLE[shared_worst][2]}"
|
|
227
|
+
if failed:
|
|
228
|
+
verdict = _styled(
|
|
229
|
+
f" \U0001f4a5 {failed}/{n} project(s) failed to build ({breakdown})",
|
|
230
|
+
_BOLD,
|
|
231
|
+
_RED,
|
|
232
|
+
)
|
|
233
|
+
elif counts[CheckStatus.WARNING] or shared_worst != CheckStatus.CLEAN:
|
|
234
|
+
verdict = _styled(
|
|
235
|
+
f" \u26a0\ufe0f {n} project(s) built, see warnings ({breakdown})",
|
|
236
|
+
_BOLD,
|
|
237
|
+
_YELLOW,
|
|
238
|
+
)
|
|
239
|
+
else:
|
|
240
|
+
verdict = _styled(
|
|
241
|
+
f" \u2728 All {n} project(s) clean ({breakdown})",
|
|
242
|
+
_BOLD,
|
|
243
|
+
_GREEN,
|
|
244
|
+
)
|
|
245
|
+
print(verdict)
|
|
246
|
+
print()
|