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.
@@ -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}>"