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 ADDED
@@ -0,0 +1,4 @@
1
+ """Starbuck: reference-integrity checks for manuscripts."""
2
+
3
+ __version__ = "0.2.0"
4
+ APP_NAME = "starbuck"
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))]