bibdeskparser 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.
- bibdeskparser/__init__.py +23 -0
- bibdeskparser/bdskfile.py +351 -0
- bibdeskparser/editing.py +673 -0
- bibdeskparser/entry.py +619 -0
- bibdeskparser/exporting.py +331 -0
- bibdeskparser/groups.py +119 -0
- bibdeskparser/header.py +236 -0
- bibdeskparser/library.py +1565 -0
- bibdeskparser/macros.py +109 -0
- bibdeskparser/middleware.py +174 -0
- bibdeskparser/names.py +96 -0
- bibdeskparser/render.py +666 -0
- bibdeskparser/texmap.py +467 -0
- bibdeskparser/writer.py +234 -0
- bibdeskparser-0.1.0.dist-info/METADATA +144 -0
- bibdeskparser-0.1.0.dist-info/RECORD +17 -0
- bibdeskparser-0.1.0.dist-info/WHEEL +4 -0
bibdeskparser/render.py
ADDED
|
@@ -0,0 +1,666 @@
|
|
|
1
|
+
r"""Render citations in an APS-journal-like numeric style.
|
|
2
|
+
|
|
3
|
+
A citation is assembled from *authors*, *title*, *published-in*,
|
|
4
|
+
*eprint*, and *note* segments, each rendered independently and then
|
|
5
|
+
joined with sentence-like punctuation.
|
|
6
|
+
|
|
7
|
+
Three output formats are supported (the `format` argument of
|
|
8
|
+
{any}`render_entry`/{any}`render_entries`): `"markdown"` (the default),
|
|
9
|
+
`"tex"`, and `"html"`. When rendering several citations at once,
|
|
10
|
+
{any}`render_entries` also takes a `style` argument controlling their
|
|
11
|
+
layout (paragraphs, a numbered list, or an itemized list).
|
|
12
|
+
|
|
13
|
+
The **published-in** segment has dedicated formatting for the
|
|
14
|
+
following `Entry.entry_type` values:
|
|
15
|
+
|
|
16
|
+
- `article`: `journal **volume**, pages (year)`, linked to the DOI (if
|
|
17
|
+
any); the title links to the entry's first URL instead (never the
|
|
18
|
+
DOI).
|
|
19
|
+
- `inproceedings`: `In: *booktitle*, edited by ... (address, month
|
|
20
|
+
year), pages`.
|
|
21
|
+
- `mastersthesis`/`phdthesis`: `label, school (year)`, where `label` is
|
|
22
|
+
`"Master's thesis"`/`"Ph.D. thesis"` unless overridden by a `type`
|
|
23
|
+
field.
|
|
24
|
+
- `book`/`incollection`: `publisher, address (year)`, prefixed by `In:
|
|
25
|
+
*booktitle*, ` for `incollection`.
|
|
26
|
+
- `techreport`: `Technical Report, institution (year)`.
|
|
27
|
+
|
|
28
|
+
Any other entry type (e.g. `misc`, `unpublished`) falls back to a
|
|
29
|
+
minimal `(year)` (or `""` if there is no year); such types are expected
|
|
30
|
+
to carry their full citation information in the `note` field instead.
|
|
31
|
+
|
|
32
|
+
```python
|
|
33
|
+
>>> from bibdeskparser.entry import Entry
|
|
34
|
+
>>> from bibdeskparser.render import render_entry
|
|
35
|
+
>>> article = Entry(
|
|
36
|
+
... "article",
|
|
37
|
+
... "Doe2024",
|
|
38
|
+
... fields={
|
|
39
|
+
... "author": "Doe, Jane and Roe, Richard",
|
|
40
|
+
... "title": "A Great Discovery",
|
|
41
|
+
... "journal": "Phys. Rev. A",
|
|
42
|
+
... "volume": "99",
|
|
43
|
+
... "year": "2024",
|
|
44
|
+
... },
|
|
45
|
+
... )
|
|
46
|
+
>>> render_entry(article, format="html")
|
|
47
|
+
'J. Doe and R. Roe. <i>A Great Discovery</i>. Phys. Rev. A <b>99</b> (2024).'
|
|
48
|
+
|
|
49
|
+
```
|
|
50
|
+
"""
|
|
51
|
+
|
|
52
|
+
import re
|
|
53
|
+
|
|
54
|
+
__all__ = []
|
|
55
|
+
|
|
56
|
+
# All members whose name does not start with an underscore must be listed
|
|
57
|
+
# either in __all__ or in __private__
|
|
58
|
+
__private__ = ["render_entry", "render_entries"]
|
|
59
|
+
|
|
60
|
+
#: Output formats understood by {any}`render_entry`/{any}`render_entries`.
|
|
61
|
+
_VALID_FORMATS = ("markdown", "tex", "html")
|
|
62
|
+
|
|
63
|
+
#: Layout styles understood by {any}`render_entries` (its `style`
|
|
64
|
+
#: argument).
|
|
65
|
+
_VALID_STYLES = ("default", "paragraphs", "numbered list", "itemized list")
|
|
66
|
+
|
|
67
|
+
_MONTH_ABBR = [
|
|
68
|
+
"Jan",
|
|
69
|
+
"Feb",
|
|
70
|
+
"Mar",
|
|
71
|
+
"Apr",
|
|
72
|
+
"May",
|
|
73
|
+
"Jun",
|
|
74
|
+
"Jul",
|
|
75
|
+
"Aug",
|
|
76
|
+
"Sep",
|
|
77
|
+
"Oct",
|
|
78
|
+
"Nov",
|
|
79
|
+
"Dec",
|
|
80
|
+
]
|
|
81
|
+
|
|
82
|
+
_MONTH_FULL = [
|
|
83
|
+
"January",
|
|
84
|
+
"February",
|
|
85
|
+
"March",
|
|
86
|
+
"April",
|
|
87
|
+
"May",
|
|
88
|
+
"June",
|
|
89
|
+
"July",
|
|
90
|
+
"August",
|
|
91
|
+
"September",
|
|
92
|
+
"October",
|
|
93
|
+
"November",
|
|
94
|
+
"December",
|
|
95
|
+
]
|
|
96
|
+
|
|
97
|
+
_BRACES_RE = re.compile(r"[{}]")
|
|
98
|
+
|
|
99
|
+
_PAGE_RANGE_RE = re.compile(r"(\d+)-+(\d+)")
|
|
100
|
+
|
|
101
|
+
#: Markup our own `_link`/`_italic`/`_bold` primitives may produce at
|
|
102
|
+
#: the *start* of a rendered segment, longest-first so that, e.g.,
|
|
103
|
+
#: `"**"` (bold) is stripped before `"*"` (italic).
|
|
104
|
+
_LEADING_MARKUP_RE = re.compile(
|
|
105
|
+
r"^(\*\*|\*|\[|\\textbf\{|\\textit\{|\\href\{[^}]*\}\{"
|
|
106
|
+
r'|<b>|<i>|<a href="[^"]*">)'
|
|
107
|
+
)
|
|
108
|
+
|
|
109
|
+
#: Markup our own primitives may produce at the *end* of a rendered
|
|
110
|
+
#: segment.
|
|
111
|
+
_TRAILING_MARKUP_RE = re.compile(r"(\*\*|\*|\}|</b>|</i>|</a>|\]\([^)]*\))$")
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def _strip_braces(text):
|
|
115
|
+
"""Remove (but not the contents of) any `{...}` from `text`.
|
|
116
|
+
|
|
117
|
+
Used for `title`/`type`/`booktitle` fields, which may carry BibDesk
|
|
118
|
+
title-casing "protection" braces (LaTeX-only, invisible to a
|
|
119
|
+
reader).
|
|
120
|
+
"""
|
|
121
|
+
return _BRACES_RE.sub("", text)
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def _link(text, url, fmt):
|
|
125
|
+
"""Format `text` as a hyperlink to `url` in format `fmt`.
|
|
126
|
+
|
|
127
|
+
Returns `text` unchanged if `url` is falsy. Does not validate
|
|
128
|
+
`fmt` (see the module docstring: validation happens in the public
|
|
129
|
+
entry points).
|
|
130
|
+
"""
|
|
131
|
+
if not url:
|
|
132
|
+
return text
|
|
133
|
+
if fmt == "markdown":
|
|
134
|
+
return f"[{text}]({url})"
|
|
135
|
+
if fmt == "tex":
|
|
136
|
+
return f"\\href{{{url}}}{{{text}}}"
|
|
137
|
+
return f'<a href="{url}">{text}</a>'
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def _italic(text, fmt):
|
|
141
|
+
"""Format `text` in italics in format `fmt`."""
|
|
142
|
+
if fmt == "markdown":
|
|
143
|
+
return f"*{text}*"
|
|
144
|
+
if fmt == "tex":
|
|
145
|
+
return f"\\textit{{{text}}}"
|
|
146
|
+
return f"<i>{text}</i>"
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def _bold(text, fmt):
|
|
150
|
+
"""Format `text` in bold in format `fmt`."""
|
|
151
|
+
if fmt == "markdown":
|
|
152
|
+
return f"**{text}**"
|
|
153
|
+
if fmt == "tex":
|
|
154
|
+
return f"\\textbf{{{text}}}"
|
|
155
|
+
return f"<b>{text}</b>"
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def _strip_leading_markup(text):
|
|
159
|
+
"""Strip markup produced by `_link`/`_italic`/`_bold` from the
|
|
160
|
+
start of `text`, repeatedly (e.g. a bold+linked segment has both a
|
|
161
|
+
link and a bold wrapper at its start)."""
|
|
162
|
+
while True:
|
|
163
|
+
stripped = _LEADING_MARKUP_RE.sub("", text, count=1)
|
|
164
|
+
if stripped == text:
|
|
165
|
+
return text
|
|
166
|
+
text = stripped
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def _strip_trailing_markup(text):
|
|
170
|
+
"""Like `_strip_leading_markup`, but from the end of `text`."""
|
|
171
|
+
while True:
|
|
172
|
+
stripped = _TRAILING_MARKUP_RE.sub("", text, count=1)
|
|
173
|
+
if stripped == text:
|
|
174
|
+
return text
|
|
175
|
+
text = stripped
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def _visible_edges(text):
|
|
179
|
+
"""Return `text` with markup stripped from both ends.
|
|
180
|
+
|
|
181
|
+
Used only to inspect the first/last *visible* character of an
|
|
182
|
+
already-formatted segment (e.g. to decide whether a segment
|
|
183
|
+
"starts with an uppercase letter" although it may actually start
|
|
184
|
+
with a Markdown `"*"` or an HTML `"<i>"`), never returned to a
|
|
185
|
+
caller of {any}`render_entry`.
|
|
186
|
+
"""
|
|
187
|
+
return _strip_trailing_markup(_strip_leading_markup(text))
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def _join_parts(parts):
|
|
191
|
+
r"""Join formatted citation segments with sentence-like punctuation.
|
|
192
|
+
|
|
193
|
+
```python
|
|
194
|
+
_join_parts(parts)
|
|
195
|
+
```
|
|
196
|
+
|
|
197
|
+
`parts` is an iterable of already-formatted strings (some of which
|
|
198
|
+
may be empty/falsy, and are dropped). Consecutive parts are joined
|
|
199
|
+
by inspecting the *visible* text at the boundary (ignoring any
|
|
200
|
+
Markdown/TeX/HTML markup our own formatting primitives may have
|
|
201
|
+
added):
|
|
202
|
+
|
|
203
|
+
- if the next part starts with `"("`, or the current part already
|
|
204
|
+
ends in one of `` :,;.!? ``, join with a single space;
|
|
205
|
+
- otherwise, join with `". "` if the next part starts with an
|
|
206
|
+
uppercase letter, else with `", "`.
|
|
207
|
+
|
|
208
|
+
Finally, a trailing `"."` is appended unless the result already
|
|
209
|
+
ends in one of `` .:!? ``.
|
|
210
|
+
|
|
211
|
+
```python
|
|
212
|
+
>>> _join_parts(["Foo", "Bar"])
|
|
213
|
+
'Foo. Bar.'
|
|
214
|
+
>>> _join_parts(["Foo", "bar"])
|
|
215
|
+
'Foo, bar.'
|
|
216
|
+
>>> _join_parts(["Foo:", "bar"])
|
|
217
|
+
'Foo: bar.'
|
|
218
|
+
>>> _join_parts(["Foo", "(bar)"])
|
|
219
|
+
'Foo (bar).'
|
|
220
|
+
>>> _join_parts(["Foo.", ""])
|
|
221
|
+
'Foo.'
|
|
222
|
+
>>> _join_parts([])
|
|
223
|
+
''
|
|
224
|
+
|
|
225
|
+
```
|
|
226
|
+
"""
|
|
227
|
+
parts = [part for part in parts if part]
|
|
228
|
+
if not parts:
|
|
229
|
+
return ""
|
|
230
|
+
result = parts[0]
|
|
231
|
+
for part in parts[1:]:
|
|
232
|
+
current_visible = _visible_edges(result)
|
|
233
|
+
next_visible = _visible_edges(part)
|
|
234
|
+
if part.startswith("(") or (
|
|
235
|
+
current_visible and current_visible[-1] in ":,;.!?"
|
|
236
|
+
):
|
|
237
|
+
sep = " "
|
|
238
|
+
elif next_visible and next_visible[0].isupper():
|
|
239
|
+
sep = ". "
|
|
240
|
+
else:
|
|
241
|
+
sep = ", "
|
|
242
|
+
result += sep + part
|
|
243
|
+
visible_result = _visible_edges(result)
|
|
244
|
+
if not visible_result or visible_result[-1] not in ".:!?":
|
|
245
|
+
result += "."
|
|
246
|
+
return result
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
def _initial(name_part):
|
|
250
|
+
"""Return the initial(s) of a single first/middle-name part.
|
|
251
|
+
|
|
252
|
+
Hyphenated parts are initialed hyphen-by-hyphen, e.g.
|
|
253
|
+
`"Jean-Paul"` -> `"J.-P."`.
|
|
254
|
+
"""
|
|
255
|
+
if not name_part:
|
|
256
|
+
return ""
|
|
257
|
+
subparts = [p for p in name_part.split("-") if p]
|
|
258
|
+
return "-".join(f"{p[0]}." for p in subparts)
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
def _format_name(name):
|
|
262
|
+
"""Format a single `NameParts` as `"Initials von Last, Jr"`."""
|
|
263
|
+
first_initials = " ".join(_initial(part) for part in name.first if part)
|
|
264
|
+
von = " ".join(part.lower() for part in name.von if part)
|
|
265
|
+
last = " ".join(part for part in name.last if part)
|
|
266
|
+
core = " ".join(part for part in (first_initials, von, last) if part)
|
|
267
|
+
jr = " ".join(part for part in name.jr if part)
|
|
268
|
+
if jr:
|
|
269
|
+
core = f"{core}, {jr}" if core else jr
|
|
270
|
+
return core
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
def _join_names(names, et_al_limit=None, et_al_text=None):
|
|
274
|
+
"""Join a list of `NameParts` into a single string.
|
|
275
|
+
|
|
276
|
+
Uses `", "` between all but the last two names, and `" and "`
|
|
277
|
+
before the final name. If `et_al_limit` is given and there are
|
|
278
|
+
more than `et_al_limit` names, only the first 3 are kept and
|
|
279
|
+
`et_al_text` (e.g. `"*et al.*"`) is appended.
|
|
280
|
+
"""
|
|
281
|
+
formatted = [_format_name(name) for name in names]
|
|
282
|
+
if not formatted:
|
|
283
|
+
return ""
|
|
284
|
+
if et_al_limit is not None and len(formatted) > et_al_limit:
|
|
285
|
+
joined = ", ".join(formatted[:3])
|
|
286
|
+
return f"{joined}, {et_al_text}" if et_al_text else joined
|
|
287
|
+
if len(formatted) == 1:
|
|
288
|
+
return formatted[0]
|
|
289
|
+
if len(formatted) == 2:
|
|
290
|
+
return f"{formatted[0]} and {formatted[1]}"
|
|
291
|
+
return ", ".join(formatted[:-1]) + " and " + formatted[-1]
|
|
292
|
+
|
|
293
|
+
|
|
294
|
+
def _format_authors(entry, fmt):
|
|
295
|
+
"""Format `entry.author` (truncated to "et al." beyond 6 names)."""
|
|
296
|
+
names = entry.author
|
|
297
|
+
if not names:
|
|
298
|
+
return ""
|
|
299
|
+
et_al_text = _italic("et al.", fmt)
|
|
300
|
+
return _join_names(names, et_al_limit=6, et_al_text=et_al_text)
|
|
301
|
+
|
|
302
|
+
|
|
303
|
+
def _format_editors(entry):
|
|
304
|
+
"""Format `entry.editor` (no "et al." truncation)."""
|
|
305
|
+
return _join_names(entry.editor)
|
|
306
|
+
|
|
307
|
+
|
|
308
|
+
def _format_title(entry, fmt):
|
|
309
|
+
"""Format the `title` field, italicized and linked.
|
|
310
|
+
|
|
311
|
+
Linked to `entry.urls[0]` if present; for non-`article` entries,
|
|
312
|
+
falls back to a `doi`-based link if there is no URL. `article`
|
|
313
|
+
entries never fall back to the DOI here, since `_format_published_in`
|
|
314
|
+
links the DOI to the journal info instead.
|
|
315
|
+
"""
|
|
316
|
+
title = entry.get("title", "").strip()
|
|
317
|
+
if not title:
|
|
318
|
+
return ""
|
|
319
|
+
title = _italic(_strip_braces(title), fmt)
|
|
320
|
+
|
|
321
|
+
url = None
|
|
322
|
+
if entry.urls:
|
|
323
|
+
url = entry.urls[0]
|
|
324
|
+
elif entry.entry_type.lower() != "article":
|
|
325
|
+
doi = entry.get("doi", "").strip()
|
|
326
|
+
if doi:
|
|
327
|
+
url = f"https://doi.org/{doi}"
|
|
328
|
+
return _link(title, url, fmt)
|
|
329
|
+
|
|
330
|
+
|
|
331
|
+
def _format_pages(entry):
|
|
332
|
+
"""Format the `pages` field as `"p. N"` or `"pp. N1–N2"`."""
|
|
333
|
+
pages = entry.get("pages", "").strip()
|
|
334
|
+
if not pages:
|
|
335
|
+
return ""
|
|
336
|
+
match = _PAGE_RANGE_RE.match(pages)
|
|
337
|
+
if match:
|
|
338
|
+
return f"pp. {match.group(1)}–{match.group(2)}"
|
|
339
|
+
return f"p. {pages}"
|
|
340
|
+
|
|
341
|
+
|
|
342
|
+
def _format_month(entry):
|
|
343
|
+
"""Format the `month` field as a 3-letter abbreviation, e.g. `"Jan"`.
|
|
344
|
+
|
|
345
|
+
Understands an integer 1-12 or a full/abbreviated month name
|
|
346
|
+
(case-insensitively). Returns `""` if `month` is absent or not
|
|
347
|
+
recognized.
|
|
348
|
+
"""
|
|
349
|
+
month = entry.get("month", "").strip()
|
|
350
|
+
if not month:
|
|
351
|
+
return ""
|
|
352
|
+
if month.isdigit():
|
|
353
|
+
index = int(month)
|
|
354
|
+
if 1 <= index <= 12:
|
|
355
|
+
return _MONTH_ABBR[index - 1]
|
|
356
|
+
return ""
|
|
357
|
+
lower = month.lower()
|
|
358
|
+
for abbr, full in zip(_MONTH_ABBR, _MONTH_FULL):
|
|
359
|
+
if lower in (abbr.lower(), full.lower()):
|
|
360
|
+
return abbr
|
|
361
|
+
return ""
|
|
362
|
+
|
|
363
|
+
|
|
364
|
+
def _format_published_in_article(entry, fmt):
|
|
365
|
+
"""Published-in for `article`: journal/volume/pages, linked to
|
|
366
|
+
DOI, with the year in parens."""
|
|
367
|
+
journal = entry.get("journal", "").strip()
|
|
368
|
+
volume = entry.get("volume", "").strip()
|
|
369
|
+
pages = _format_pages(entry)
|
|
370
|
+
year = entry.get("year", "").strip()
|
|
371
|
+
doi = entry.get("doi", "").strip()
|
|
372
|
+
|
|
373
|
+
segment = journal
|
|
374
|
+
if volume:
|
|
375
|
+
bold_volume = _bold(volume, fmt)
|
|
376
|
+
segment = f"{segment} {bold_volume}" if segment else bold_volume
|
|
377
|
+
if pages:
|
|
378
|
+
segment = f"{segment}, {pages}" if segment else pages
|
|
379
|
+
if doi and segment:
|
|
380
|
+
segment = _link(segment, f"https://doi.org/{doi}", fmt)
|
|
381
|
+
|
|
382
|
+
pieces = [
|
|
383
|
+
piece for piece in (segment, f"({year})" if year else "") if piece
|
|
384
|
+
]
|
|
385
|
+
return " ".join(pieces)
|
|
386
|
+
|
|
387
|
+
|
|
388
|
+
def _format_published_in_inproceedings(entry, fmt):
|
|
389
|
+
"""Published-in for `inproceedings`: booktitle/editors, then
|
|
390
|
+
organization/address/month/year in parens, then pages."""
|
|
391
|
+
booktitle = _strip_braces(entry.get("booktitle", "").strip())
|
|
392
|
+
editors = entry.editor
|
|
393
|
+
organization = entry.get("organization", "").strip()
|
|
394
|
+
address = entry.get("address", "").strip()
|
|
395
|
+
year = entry.get("year", "").strip()
|
|
396
|
+
month = _format_month(entry)
|
|
397
|
+
pages = _format_pages(entry)
|
|
398
|
+
|
|
399
|
+
head = f"In: {_italic(booktitle, fmt)}" if booktitle else ""
|
|
400
|
+
if editors:
|
|
401
|
+
editor_names = _format_editors(entry)
|
|
402
|
+
head = (
|
|
403
|
+
f"{head}, edited by {editor_names}"
|
|
404
|
+
if head
|
|
405
|
+
else f"edited by {editor_names}"
|
|
406
|
+
)
|
|
407
|
+
|
|
408
|
+
paren_bits = [bit for bit in (organization, address) if bit]
|
|
409
|
+
if year:
|
|
410
|
+
paren_bits.append(f"{month} {year}".strip())
|
|
411
|
+
paren = f"({', '.join(paren_bits)})" if paren_bits else ""
|
|
412
|
+
|
|
413
|
+
result = " ".join(piece for piece in (head, paren) if piece)
|
|
414
|
+
if pages:
|
|
415
|
+
result = f"{result}, {pages}" if result else pages
|
|
416
|
+
return result
|
|
417
|
+
|
|
418
|
+
|
|
419
|
+
def _format_published_in_thesis(entry):
|
|
420
|
+
"""Published-in for `mastersthesis`/`phdthesis`: label, school,
|
|
421
|
+
year. `label` defaults to "Master's thesis"/"Ph.D. thesis" but is
|
|
422
|
+
overridden by a `type` field, if present."""
|
|
423
|
+
default_label = (
|
|
424
|
+
"Master's thesis"
|
|
425
|
+
if entry.entry_type.lower() == "mastersthesis"
|
|
426
|
+
else "Ph.D. thesis"
|
|
427
|
+
)
|
|
428
|
+
type_field = entry.get("type", "").strip()
|
|
429
|
+
label = _strip_braces(type_field) if type_field else default_label
|
|
430
|
+
school = entry.get("school", "").strip()
|
|
431
|
+
year = entry.get("year", "").strip()
|
|
432
|
+
|
|
433
|
+
result = ", ".join(bit for bit in (label, school) if bit)
|
|
434
|
+
if year:
|
|
435
|
+
result = f"{result} ({year})" if result else f"({year})"
|
|
436
|
+
return result
|
|
437
|
+
|
|
438
|
+
|
|
439
|
+
def _format_published_in_book(entry, fmt):
|
|
440
|
+
"""Published-in for `book`/`incollection`: publisher, address,
|
|
441
|
+
year; `incollection` is prefixed by the booktitle."""
|
|
442
|
+
prefix = ""
|
|
443
|
+
if entry.entry_type.lower() == "incollection":
|
|
444
|
+
booktitle = _strip_braces(entry.get("booktitle", "").strip())
|
|
445
|
+
if booktitle:
|
|
446
|
+
prefix = f"In: {_italic(booktitle, fmt)}"
|
|
447
|
+
|
|
448
|
+
publisher = entry.get("publisher", "").strip()
|
|
449
|
+
address = entry.get("address", "").strip()
|
|
450
|
+
year = entry.get("year", "").strip()
|
|
451
|
+
|
|
452
|
+
pub_addr = ", ".join(bit for bit in (publisher, address) if bit)
|
|
453
|
+
tail = f"({year})" if year else ""
|
|
454
|
+
if pub_addr and tail:
|
|
455
|
+
rest = f"{pub_addr} {tail}"
|
|
456
|
+
else:
|
|
457
|
+
rest = pub_addr or tail
|
|
458
|
+
|
|
459
|
+
if prefix:
|
|
460
|
+
return f"{prefix}, {rest}" if rest else prefix
|
|
461
|
+
return rest
|
|
462
|
+
|
|
463
|
+
|
|
464
|
+
def _format_published_in_techreport(entry):
|
|
465
|
+
"""Published-in for `techreport`: "Technical Report, institution
|
|
466
|
+
(year)"."""
|
|
467
|
+
institution = entry.get("institution", "").strip()
|
|
468
|
+
year = entry.get("year", "").strip()
|
|
469
|
+
|
|
470
|
+
bits = ["Technical Report"]
|
|
471
|
+
if institution:
|
|
472
|
+
bits.append(institution)
|
|
473
|
+
result = ", ".join(bits)
|
|
474
|
+
if year:
|
|
475
|
+
result = f"{result} ({year})"
|
|
476
|
+
return result
|
|
477
|
+
|
|
478
|
+
|
|
479
|
+
def _format_published_in_fallback(entry):
|
|
480
|
+
"""Published-in for any other entry type: just `"(year)"`, or
|
|
481
|
+
`""` if there is no year."""
|
|
482
|
+
year = entry.get("year", "").strip()
|
|
483
|
+
return f"({year})" if year else ""
|
|
484
|
+
|
|
485
|
+
|
|
486
|
+
def _format_published_in(entry, fmt):
|
|
487
|
+
"""Format the "published-in" segment, dispatching on
|
|
488
|
+
`entry.entry_type` (see the module docstring for which types have
|
|
489
|
+
dedicated formatting)."""
|
|
490
|
+
etype = entry.entry_type.lower()
|
|
491
|
+
if etype == "article":
|
|
492
|
+
return _format_published_in_article(entry, fmt)
|
|
493
|
+
if etype == "inproceedings":
|
|
494
|
+
return _format_published_in_inproceedings(entry, fmt)
|
|
495
|
+
if etype in ("mastersthesis", "phdthesis"):
|
|
496
|
+
return _format_published_in_thesis(entry)
|
|
497
|
+
if etype in ("book", "incollection"):
|
|
498
|
+
return _format_published_in_book(entry, fmt)
|
|
499
|
+
if etype == "techreport":
|
|
500
|
+
return _format_published_in_techreport(entry)
|
|
501
|
+
return _format_published_in_fallback(entry)
|
|
502
|
+
|
|
503
|
+
|
|
504
|
+
def _format_eprint(entry, fmt):
|
|
505
|
+
"""Format the `eprint` field as an arXiv link, e.g.
|
|
506
|
+
`"arXiv:2401.00001 [quant-ph]"`."""
|
|
507
|
+
eprint = entry.get("eprint", "").strip()
|
|
508
|
+
if not eprint:
|
|
509
|
+
return ""
|
|
510
|
+
text = f"arXiv:{eprint}"
|
|
511
|
+
primaryclass = entry.get("primaryclass", "").strip()
|
|
512
|
+
if primaryclass:
|
|
513
|
+
text = f"{text} [{primaryclass}]"
|
|
514
|
+
return _link(text, f"https://arxiv.org/abs/{eprint}", fmt)
|
|
515
|
+
|
|
516
|
+
|
|
517
|
+
def _format_note(entry):
|
|
518
|
+
"""Format the `note` field verbatim (stripped)."""
|
|
519
|
+
return entry.get("note", "").strip()
|
|
520
|
+
|
|
521
|
+
|
|
522
|
+
def render_entry(entry, format="markdown"): # noqa: A002 (shadows builtin)
|
|
523
|
+
# pylint: disable=redefined-builtin
|
|
524
|
+
r"""Render a single citation for `entry`.
|
|
525
|
+
|
|
526
|
+
```python
|
|
527
|
+
render_entry(entry, format="markdown")
|
|
528
|
+
```
|
|
529
|
+
|
|
530
|
+
Assembles the citation from *authors*, *title*, *published-in*,
|
|
531
|
+
*eprint*, and *note* segments (see the module docstring), joined
|
|
532
|
+
with sentence-like punctuation.
|
|
533
|
+
|
|
534
|
+
* `entry`: a {any}`bibdeskparser.entry.Entry`.
|
|
535
|
+
* `format`: one of `"markdown"` (default), `"tex"`, `"html"`.
|
|
536
|
+
|
|
537
|
+
Raises {any}`ValueError` if `format` is not one of the above.
|
|
538
|
+
|
|
539
|
+
```python
|
|
540
|
+
>>> from bibdeskparser.entry import Entry
|
|
541
|
+
>>> entry = Entry(
|
|
542
|
+
... "article",
|
|
543
|
+
... "Doe2024",
|
|
544
|
+
... fields={
|
|
545
|
+
... "author": "Doe, J and Roe, R",
|
|
546
|
+
... "title": "Title",
|
|
547
|
+
... "journal": "PRA",
|
|
548
|
+
... "volume": "1",
|
|
549
|
+
... "year": "2024",
|
|
550
|
+
... "doi": "10.1/x",
|
|
551
|
+
... },
|
|
552
|
+
... )
|
|
553
|
+
>>> render_entry(entry)
|
|
554
|
+
'J. Doe and R. Roe. *Title*. [PRA **1**](https://doi.org/10.1/x) (2024).'
|
|
555
|
+
>>> render_entry(entry, format="bogus")
|
|
556
|
+
Traceback (most recent call last):
|
|
557
|
+
...
|
|
558
|
+
ValueError: format must be one of ('markdown', 'tex', 'html'), not 'bogus'
|
|
559
|
+
|
|
560
|
+
```
|
|
561
|
+
"""
|
|
562
|
+
if format not in _VALID_FORMATS:
|
|
563
|
+
raise ValueError(
|
|
564
|
+
f"format must be one of {_VALID_FORMATS}, not {format!r}"
|
|
565
|
+
)
|
|
566
|
+
parts = [
|
|
567
|
+
_format_authors(entry, format),
|
|
568
|
+
_format_title(entry, format),
|
|
569
|
+
_format_published_in(entry, format),
|
|
570
|
+
_format_eprint(entry, format),
|
|
571
|
+
_format_note(entry),
|
|
572
|
+
]
|
|
573
|
+
return _join_parts(parts)
|
|
574
|
+
|
|
575
|
+
|
|
576
|
+
def render_entries(entries, format="markdown", style="default"): # noqa: A002
|
|
577
|
+
# pylint: disable=redefined-builtin
|
|
578
|
+
r"""Render a bibliography for `entries`.
|
|
579
|
+
|
|
580
|
+
```python
|
|
581
|
+
render_entries(entries, format="markdown", style="default")
|
|
582
|
+
```
|
|
583
|
+
|
|
584
|
+
* `entries`: an iterable of {any}`bibdeskparser.entry.Entry`,
|
|
585
|
+
rendered in the given order.
|
|
586
|
+
* `format`: one of `"markdown"` (default), `"tex"`, `"html"`.
|
|
587
|
+
* `style`: the layout of the citations relative to one another; one
|
|
588
|
+
of:
|
|
589
|
+
* `"paragraphs"`: each citation is a paragraph, separated from the
|
|
590
|
+
next by a blank line (for `"html"`, each citation is wrapped in
|
|
591
|
+
a `<p>...</p>` instead, since blank lines are not significant in
|
|
592
|
+
HTML).
|
|
593
|
+
* `"numbered list"`: a numbered list (`"markdown"`: `1.`, `2.`,
|
|
594
|
+
...; `"tex"`: an `enumerate` environment; `"html"`: an `<ol>`).
|
|
595
|
+
* `"itemized list"`: a bulleted list (`"markdown"`: `-`; `"tex"`:
|
|
596
|
+
an `itemize` environment; `"html"`: a `<ul>`).
|
|
597
|
+
* `"default"` (the default): like `"paragraphs"`, except that a
|
|
598
|
+
single `"html"` citation is *not* wrapped in a `<p>...</p>`.
|
|
599
|
+
|
|
600
|
+
Each citation is rendered with {any}`render_entry`.
|
|
601
|
+
|
|
602
|
+
Raises {any}`ValueError` if `format` or `style` is not one of the
|
|
603
|
+
above.
|
|
604
|
+
|
|
605
|
+
```python
|
|
606
|
+
>>> from bibdeskparser.entry import Entry
|
|
607
|
+
>>> entry1 = Entry(
|
|
608
|
+
... "article",
|
|
609
|
+
... "Doe2024",
|
|
610
|
+
... fields={
|
|
611
|
+
... "author": "Doe, Jane",
|
|
612
|
+
... "title": "A Great Discovery",
|
|
613
|
+
... "journal": "Phys. Rev. A",
|
|
614
|
+
... "volume": "99",
|
|
615
|
+
... "year": "2024",
|
|
616
|
+
... },
|
|
617
|
+
... )
|
|
618
|
+
>>> entry2 = Entry(
|
|
619
|
+
... "mastersthesis",
|
|
620
|
+
... "Smith2020",
|
|
621
|
+
... fields={
|
|
622
|
+
... "author": "Smith, John",
|
|
623
|
+
... "title": "A Thesis",
|
|
624
|
+
... "school": "Test University",
|
|
625
|
+
... "year": "2020",
|
|
626
|
+
... },
|
|
627
|
+
... )
|
|
628
|
+
>>> print(render_entries([entry1, entry2]))
|
|
629
|
+
J. Doe. *A Great Discovery*. Phys. Rev. A **99** (2024).
|
|
630
|
+
<BLANKLINE>
|
|
631
|
+
J. Smith. *A Thesis*. Master's thesis, Test University (2020).
|
|
632
|
+
>>> print(render_entries([entry1, entry2], style="numbered list"))
|
|
633
|
+
1. J. Doe. *A Great Discovery*. Phys. Rev. A **99** (2024).
|
|
634
|
+
2. J. Smith. *A Thesis*. Master's thesis, Test University (2020).
|
|
635
|
+
|
|
636
|
+
```
|
|
637
|
+
"""
|
|
638
|
+
if format not in _VALID_FORMATS:
|
|
639
|
+
raise ValueError(
|
|
640
|
+
f"format must be one of {_VALID_FORMATS}, not {format!r}"
|
|
641
|
+
)
|
|
642
|
+
if style not in _VALID_STYLES:
|
|
643
|
+
raise ValueError(
|
|
644
|
+
f"style must be one of {_VALID_STYLES}, not {style!r}"
|
|
645
|
+
)
|
|
646
|
+
rendered = [render_entry(entry, format) for entry in entries]
|
|
647
|
+
if style in ("default", "paragraphs"):
|
|
648
|
+
if format == "html":
|
|
649
|
+
if style == "default" and len(rendered) == 1:
|
|
650
|
+
return rendered[0]
|
|
651
|
+
return "\n\n".join(f"<p>{item}</p>" for item in rendered)
|
|
652
|
+
return "\n\n".join(rendered)
|
|
653
|
+
# numbered/itemized list styles
|
|
654
|
+
if format == "markdown":
|
|
655
|
+
if style == "numbered list":
|
|
656
|
+
return "\n".join(
|
|
657
|
+
f"{i}. {item}" for i, item in enumerate(rendered, start=1)
|
|
658
|
+
)
|
|
659
|
+
return "\n".join(f"- {item}" for item in rendered)
|
|
660
|
+
env = "enumerate" if style == "numbered list" else "itemize"
|
|
661
|
+
if format == "tex":
|
|
662
|
+
items = "\n".join(f"\\item {item}" for item in rendered)
|
|
663
|
+
return f"\\begin{{{env}}}\n{items}\n\\end{{{env}}}"
|
|
664
|
+
tag = "ol" if style == "numbered list" else "ul"
|
|
665
|
+
items = "\n".join(f"<li>{item}</li>" for item in rendered)
|
|
666
|
+
return f"<{tag}>\n{items}\n</{tag}>"
|