starbuck 0.2.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.
- starbuck/__init__.py +4 -0
- starbuck/bibtex.py +222 -0
- starbuck/check.py +511 -0
- starbuck/claims.py +308 -0
- starbuck/cli.py +174 -0
- starbuck/evidence.py +219 -0
- starbuck/ids.py +195 -0
- starbuck/models.py +81 -0
- starbuck/parse.py +440 -0
- starbuck/render.py +66 -0
- starbuck/report.py +384 -0
- starbuck/server.py +230 -0
- starbuck/sources.py +464 -0
- starbuck-0.2.0.dist-info/METADATA +277 -0
- starbuck-0.2.0.dist-info/RECORD +18 -0
- starbuck-0.2.0.dist-info/WHEEL +4 -0
- starbuck-0.2.0.dist-info/entry_points.txt +3 -0
- starbuck-0.2.0.dist-info/licenses/LICENSE +21 -0
starbuck/__init__.py
ADDED
starbuck/bibtex.py
ADDED
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
"""A small BibTeX / BibLaTeX reader: entries, fields, @string macros and LaTeX accents.
|
|
2
|
+
|
|
3
|
+
It reads what reference managers export (Zotero, Better BibTeX, Mendeley, JabRef).
|
|
4
|
+
It does not try to be a full BibTeX implementation; an entry it cannot read is
|
|
5
|
+
reported and skipped, never guessed.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import re
|
|
11
|
+
import unicodedata
|
|
12
|
+
|
|
13
|
+
ENTRY_START = re.compile(r"^[ \t]*@([A-Za-z]+)[ \t]*([{(])", re.MULTILINE)
|
|
14
|
+
MONTHS = {m: str(i) for i, m in enumerate(
|
|
15
|
+
["jan", "feb", "mar", "apr", "may", "jun", "jul", "aug", "sep", "oct", "nov", "dec"], start=1)}
|
|
16
|
+
|
|
17
|
+
ACCENTS = {"'": "\u0301", "`": "\u0300", "^": "\u0302", '"': "\u0308", "~": "\u0303", "=": "\u0304",
|
|
18
|
+
".": "\u0307", "u": "\u0306", "v": "\u030c", "H": "\u030b", "c": "\u0327", "k": "\u0328",
|
|
19
|
+
"r": "\u030a", "d": "\u0323", "b": "\u0331"}
|
|
20
|
+
SYMBOLS = {r"\ss": "ß", r"\o": "ø", r"\O": "Ø", r"\ae": "æ", r"\AE": "Æ", r"\oe": "œ", r"\OE": "Œ",
|
|
21
|
+
r"\aa": "å", r"\AA": "Å", r"\l": "ł", r"\L": "Ł", r"\i": "ı", r"\j": "ȷ"}
|
|
22
|
+
ESCAPED = {r"\&": "&", r"\%": "%", r"\_": "_", r"\$": "$", r"\#": "#", r"\{": "{", r"\}": "}"}
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class BibError(ValueError):
|
|
26
|
+
pass
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def latex_to_text(value: str) -> str:
|
|
30
|
+
s = value
|
|
31
|
+
for k, v in ESCAPED.items():
|
|
32
|
+
s = s.replace(k, v)
|
|
33
|
+
# \'{\i} and \'\i: drop the dotless-i command first so the accent lands on a plain i
|
|
34
|
+
s = re.sub(r"\\i(?![A-Za-z])", "i", s)
|
|
35
|
+
s = re.sub(r"\\j(?![A-Za-z])", "j", s)
|
|
36
|
+
s = re.sub(r"\\([uvHcdbkr])(?:\s*\{(\w)\}|\s+(\w))",
|
|
37
|
+
lambda m: (m.group(2) or m.group(3)) + ACCENTS[m.group(1)], s)
|
|
38
|
+
s = re.sub(r"\\(['`^\"~=.])\s*(?:\{(\w)\}|(\w))",
|
|
39
|
+
lambda m: (m.group(2) or m.group(3)) + ACCENTS[m.group(1)], s)
|
|
40
|
+
for k in sorted(SYMBOLS, key=len, reverse=True):
|
|
41
|
+
s = re.sub(re.escape(k) + r"(?![A-Za-z])\s?", SYMBOLS[k], s)
|
|
42
|
+
s = s.replace("---", "\u2014").replace("--", "\u2013").replace("~", " ")
|
|
43
|
+
s = re.sub(r"\\[A-Za-z]+\*?\s*(?=\{)", "", s) # \emph{x} -> {x}
|
|
44
|
+
s = re.sub(r"\\[A-Za-z]+\*?", "", s)
|
|
45
|
+
s = s.replace("{", "").replace("}", "")
|
|
46
|
+
s = re.sub(r"\s+", " ", s).strip()
|
|
47
|
+
return unicodedata.normalize("NFC", s)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _balanced(text: str, i: int, open_: str, close: str) -> int:
|
|
51
|
+
"""Index just after the bracket that closes text[i] (which must be open_)."""
|
|
52
|
+
depth = 0
|
|
53
|
+
n = len(text)
|
|
54
|
+
while i < n:
|
|
55
|
+
c = text[i]
|
|
56
|
+
if c == "\\":
|
|
57
|
+
i += 2
|
|
58
|
+
continue
|
|
59
|
+
if c == open_:
|
|
60
|
+
depth += 1
|
|
61
|
+
elif c == close:
|
|
62
|
+
depth -= 1
|
|
63
|
+
if depth == 0:
|
|
64
|
+
return i + 1
|
|
65
|
+
i += 1
|
|
66
|
+
raise BibError("unbalanced brackets")
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _value(text: str, i: int, close: str, macros: dict[str, str]) -> tuple[str, int]:
|
|
70
|
+
parts: list[str] = []
|
|
71
|
+
n = len(text)
|
|
72
|
+
while True:
|
|
73
|
+
while i < n and text[i].isspace():
|
|
74
|
+
i += 1
|
|
75
|
+
if i >= n:
|
|
76
|
+
raise BibError("value runs to the end of the file")
|
|
77
|
+
c = text[i]
|
|
78
|
+
if c == "{":
|
|
79
|
+
end = _balanced(text, i, "{", "}")
|
|
80
|
+
parts.append(text[i + 1 : end - 1])
|
|
81
|
+
i = end
|
|
82
|
+
elif c == '"':
|
|
83
|
+
j = i + 1
|
|
84
|
+
depth = 0
|
|
85
|
+
while j < n:
|
|
86
|
+
if text[j] == "\\":
|
|
87
|
+
j += 2
|
|
88
|
+
continue
|
|
89
|
+
if text[j] == "{":
|
|
90
|
+
depth += 1
|
|
91
|
+
elif text[j] == "}":
|
|
92
|
+
depth -= 1
|
|
93
|
+
elif text[j] == '"' and depth == 0:
|
|
94
|
+
break
|
|
95
|
+
j += 1
|
|
96
|
+
if j >= n:
|
|
97
|
+
raise BibError("unclosed quote")
|
|
98
|
+
parts.append(text[i + 1 : j])
|
|
99
|
+
i = j + 1
|
|
100
|
+
else:
|
|
101
|
+
m = re.match(r"[^\s,#}\)]+", text[i:])
|
|
102
|
+
if not m:
|
|
103
|
+
raise BibError(f"unexpected {c!r}")
|
|
104
|
+
word = m.group(0)
|
|
105
|
+
parts.append(macros.get(word.lower(), MONTHS.get(word.lower()[:3], word)) if not word.isdigit()
|
|
106
|
+
else word)
|
|
107
|
+
i += len(word)
|
|
108
|
+
while i < n and text[i].isspace():
|
|
109
|
+
i += 1
|
|
110
|
+
if i < n and text[i] == "#":
|
|
111
|
+
i += 1
|
|
112
|
+
continue
|
|
113
|
+
return "".join(parts), i
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def parse(text: str) -> tuple[list[dict], list[str]]:
|
|
117
|
+
"""Return (entries, problems). An entry is {"type", "key", "fields"} with lower-case field names."""
|
|
118
|
+
entries: list[dict] = []
|
|
119
|
+
problems: list[str] = []
|
|
120
|
+
macros: dict[str, str] = {}
|
|
121
|
+
done = 0 # end of the last entry read, so text inside an entry is never taken for a new one
|
|
122
|
+
for m in ENTRY_START.finditer(text):
|
|
123
|
+
if m.start() < done:
|
|
124
|
+
continue
|
|
125
|
+
kind = m.group(1).lower()
|
|
126
|
+
open_ = m.group(2)
|
|
127
|
+
close = "}" if open_ == "{" else ")"
|
|
128
|
+
start = m.end() - 1
|
|
129
|
+
line = text.count("\n", 0, m.start()) + 1
|
|
130
|
+
try:
|
|
131
|
+
end = _balanced(text, start, open_, close)
|
|
132
|
+
except BibError:
|
|
133
|
+
problems.append(f"line {line}: @{kind} entry is not closed; skipped")
|
|
134
|
+
continue
|
|
135
|
+
done = end
|
|
136
|
+
if kind in ("comment", "preamble"):
|
|
137
|
+
continue
|
|
138
|
+
body = text[start + 1 : end - 1]
|
|
139
|
+
try:
|
|
140
|
+
if kind == "string":
|
|
141
|
+
name, _, rest = body.partition("=")
|
|
142
|
+
value, _ = _value(rest + close, 0, close, macros)
|
|
143
|
+
macros[name.strip().lower()] = value
|
|
144
|
+
continue
|
|
145
|
+
key, sep, rest = body.partition(",")
|
|
146
|
+
key = key.strip()
|
|
147
|
+
if not sep or not key or re.search(r"\s", key):
|
|
148
|
+
problems.append(f"line {line}: @{kind} entry without a readable key; skipped")
|
|
149
|
+
continue
|
|
150
|
+
fields: dict[str, str] = {}
|
|
151
|
+
i = 0
|
|
152
|
+
rest = rest + close
|
|
153
|
+
while True:
|
|
154
|
+
while i < len(rest) and (rest[i].isspace() or rest[i] == ","):
|
|
155
|
+
i += 1
|
|
156
|
+
if i >= len(rest) or rest[i] == close:
|
|
157
|
+
break
|
|
158
|
+
fm = re.match(r"([A-Za-z][\w\-:.+]*)\s*=", rest[i:])
|
|
159
|
+
if not fm:
|
|
160
|
+
raise BibError(f"cannot read a field near {rest[i:i + 20]!r}")
|
|
161
|
+
i += fm.end()
|
|
162
|
+
value, i = _value(rest, i, close, macros)
|
|
163
|
+
fields[fm.group(1).lower()] = value
|
|
164
|
+
entries.append({"type": kind, "key": key, "fields": fields, "line": line})
|
|
165
|
+
except BibError as exc:
|
|
166
|
+
problems.append(f"line {line}: @{kind} entry {body.split(',', 1)[0].strip()!r} skipped ({exc})")
|
|
167
|
+
return entries, problems
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def split_names(value: str) -> list[str]:
|
|
171
|
+
"""Split an author field on ' and ' outside braces."""
|
|
172
|
+
names, depth, cur, i = [], 0, [], 0
|
|
173
|
+
while i < len(value):
|
|
174
|
+
c = value[i]
|
|
175
|
+
if c == "{":
|
|
176
|
+
depth += 1
|
|
177
|
+
elif c == "}":
|
|
178
|
+
depth -= 1
|
|
179
|
+
m = re.match(r"\s+and\s+", value[i:], re.IGNORECASE) if depth == 0 else None
|
|
180
|
+
if m:
|
|
181
|
+
names.append("".join(cur))
|
|
182
|
+
cur = []
|
|
183
|
+
i += m.end()
|
|
184
|
+
continue
|
|
185
|
+
cur.append(c)
|
|
186
|
+
i += 1
|
|
187
|
+
names.append("".join(cur))
|
|
188
|
+
return [n.strip() for n in names if n.strip()]
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def family_name(name: str) -> str:
|
|
192
|
+
n = name.strip()
|
|
193
|
+
if n.startswith("{") and n.endswith("}") and _balanced(n, 0, "{", "}") == len(n):
|
|
194
|
+
return latex_to_text(n) # group author, e.g. {Global Initiative for Asthma}
|
|
195
|
+
depth = 0
|
|
196
|
+
for i, c in enumerate(n):
|
|
197
|
+
if c == "{":
|
|
198
|
+
depth += 1
|
|
199
|
+
elif c == "}":
|
|
200
|
+
depth -= 1
|
|
201
|
+
elif c == "," and depth == 0:
|
|
202
|
+
return latex_to_text(n[:i])
|
|
203
|
+
words, depth, cur = [], 0, []
|
|
204
|
+
for c in n:
|
|
205
|
+
if c == "{":
|
|
206
|
+
depth += 1
|
|
207
|
+
elif c == "}":
|
|
208
|
+
depth -= 1
|
|
209
|
+
if c.isspace() and depth == 0:
|
|
210
|
+
if cur:
|
|
211
|
+
words.append("".join(cur))
|
|
212
|
+
cur = []
|
|
213
|
+
continue
|
|
214
|
+
cur.append(c)
|
|
215
|
+
if cur:
|
|
216
|
+
words.append("".join(cur))
|
|
217
|
+
return latex_to_text(words[-1]) if words else ""
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
def authors(fields: dict[str, str]) -> list[str]:
|
|
221
|
+
raw = fields.get("author") or fields.get("editor") or ""
|
|
222
|
+
return [f for name in split_names(raw) if name.lower() != "others" and (f := family_name(name))]
|