citeable 2026.2.25a0__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.
citeable/__init__.py ADDED
@@ -0,0 +1,40 @@
1
+ """citeable — structured BibTeX citations for cogent3 plugins."""
2
+
3
+ from importlib import metadata as _metadata
4
+
5
+ from citeable._entries import (
6
+ Article,
7
+ Book,
8
+ Citation,
9
+ CitationBase,
10
+ InProceedings,
11
+ Misc,
12
+ Software,
13
+ TechReport,
14
+ Thesis,
15
+ )
16
+ from citeable._json import from_jsons, load_json, to_jsons, write_json
17
+ from citeable._keys import assign_unique_keys, write_bibtex
18
+ from citeable._parser import from_bibtex_string
19
+
20
+ __version__ = _metadata.version("citeable")
21
+
22
+ __all__ = [
23
+ "Article",
24
+ "Book",
25
+ "Citation",
26
+ "CitationBase",
27
+ "InProceedings",
28
+ "Misc",
29
+ "Software",
30
+ "TechReport",
31
+ "Thesis",
32
+ "__version__",
33
+ "assign_unique_keys",
34
+ "from_bibtex_string",
35
+ "from_jsons",
36
+ "load_json",
37
+ "to_jsons",
38
+ "write_bibtex",
39
+ "write_json",
40
+ ]
citeable/_entries.py ADDED
@@ -0,0 +1,585 @@
1
+ """Citation entry type classes for all supported BibTeX types."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from abc import ABC, abstractmethod
6
+
7
+ from citeable._keys import generate_key
8
+ from citeable._validate import extract_surname, require_field, require_non_empty_authors
9
+
10
+
11
+ def _format_bibtex_field(name: str, value: str) -> str:
12
+ return f" {name:<10}= {{{value}}},"
13
+
14
+
15
+ def _author_str(authors: list[str]) -> str:
16
+ return " and ".join(authors)
17
+
18
+
19
+ def _author_summary(authors: list[str]) -> str:
20
+ surname = extract_surname(authors[0])
21
+ return f"{surname} et al." if len(authors) > 1 else surname
22
+
23
+
24
+ def _title_excerpt(title: str, max_len: int = 50) -> str:
25
+ return title if len(title) <= max_len else title[:max_len] + "\u2026"
26
+
27
+
28
+ def _content_fields(obj: object, exclude: set[str]) -> tuple[object, ...]:
29
+ """Return a tuple of content field values for equality/hashing.
30
+
31
+ Lists are converted to tuples so the result is hashable.
32
+ """
33
+ vals: list[object] = []
34
+ vals.extend(
35
+ tuple(v) if isinstance(v, list) else v
36
+ for k, v in sorted(vars(obj).items())
37
+ if k not in exclude
38
+ )
39
+ return tuple(vals)
40
+
41
+
42
+ _EXCLUDED: set[str] = {"key", "app"}
43
+
44
+
45
+ class CitationBase(ABC):
46
+ """Abstract base class for all citation entry types."""
47
+
48
+ author: list[str]
49
+ title: str
50
+ year: int
51
+ doi: str | None
52
+ url: str | None
53
+ note: str | None
54
+ key: str
55
+ app: str | None
56
+
57
+ def _init_base(
58
+ self,
59
+ author: list[str],
60
+ title: str,
61
+ year: int,
62
+ *,
63
+ doi: str | None = None,
64
+ url: str | None = None,
65
+ note: str | None = None,
66
+ key: str | None = None,
67
+ app: str | None = None,
68
+ ) -> None:
69
+ """Set common fields shared by all citation types."""
70
+ require_non_empty_authors(author, type(self).__name__)
71
+ self.author = author
72
+ self.title = title
73
+ self.year = year
74
+ self.doi = doi
75
+ self.url = url
76
+ self.note = note
77
+ self.key = key if key is not None else generate_key(author, year)
78
+ self.app = app
79
+
80
+ def __eq__(self, other: object) -> bool:
81
+ if self is other:
82
+ return True
83
+ if type(self) is not type(other):
84
+ return NotImplemented
85
+ return _content_fields(self, _EXCLUDED) == _content_fields(other, _EXCLUDED)
86
+
87
+ def __hash__(self) -> int:
88
+ return hash((type(self).__name__, _content_fields(self, _EXCLUDED)))
89
+
90
+ def summary(self) -> tuple[str, str]:
91
+ """Return ``(app_name, citation_string)``."""
92
+ app_name = self.app if self.app is not None else ""
93
+ auth = _author_summary(self.author)
94
+ excerpt = _title_excerpt(self.title)
95
+ return (app_name, f"{auth} {self.year} {excerpt}")
96
+
97
+ def __repr__(self) -> str:
98
+ fields = self._repr_fields()
99
+ auto_key = generate_key(self.author, self.year)
100
+ if self.key != auto_key:
101
+ fields.insert(0, ("key", self.key))
102
+ parts = [f" {name}={value!r}," for name, value in fields]
103
+ body = "\n".join(parts)
104
+ return f"{type(self).__name__}(\n{body}\n)"
105
+
106
+ def _append_common_bibtex(self, lines: list[str]) -> None:
107
+ """Append doi/url/note BibTeX fields if set."""
108
+ if self.doi is not None:
109
+ lines.append(_format_bibtex_field("doi", self.doi))
110
+ if self.url is not None:
111
+ lines.append(_format_bibtex_field("url", self.url))
112
+ if self.note is not None:
113
+ lines.append(_format_bibtex_field("note", self.note))
114
+
115
+ def _append_common_optional_repr(self, fields: list[tuple[str, object]]) -> None:
116
+ """Append doi/url/note to repr field list if set."""
117
+ if self.doi is not None:
118
+ fields.append(("doi", self.doi))
119
+ if self.url is not None:
120
+ fields.append(("url", self.url))
121
+ if self.note is not None:
122
+ fields.append(("note", self.note))
123
+
124
+ def to_dict(self) -> dict[str, object]:
125
+ """Return a JSON-serialisable dict including a ``"type"`` discriminator."""
126
+ return {"type": type(self).__name__, **vars(self)}
127
+
128
+ @classmethod
129
+ def from_dict(cls, data: dict[str, object]) -> CitationBase:
130
+ """Reconstruct a citation from a dict produced by :meth:`to_dict`.
131
+
132
+ Raises ``ValueError`` if the ``"type"`` key is missing or unknown.
133
+ """
134
+ data = dict(data) # shallow copy so we don't mutate the caller's dict
135
+ type_name = data.pop("type", None)
136
+ if type_name is None:
137
+ msg = "dict is missing required 'type' key"
138
+ raise ValueError(msg)
139
+ entry_cls = _ENTRY_TYPES.get(str(type_name))
140
+ if entry_cls is None:
141
+ msg = f"unknown citation type {type_name!r}"
142
+ raise ValueError(msg)
143
+ return entry_cls(**data)
144
+
145
+ @abstractmethod
146
+ def _repr_fields(self) -> list[tuple[str, object]]:
147
+ """Return the list of ``(name, value)`` pairs for ``__repr__``."""
148
+
149
+
150
+ # Keep Citation as a public alias for the base class.
151
+ Citation = CitationBase
152
+
153
+
154
+ class Article(CitationBase):
155
+ """An ``@article`` BibTeX entry."""
156
+
157
+ journal: str
158
+ volume: int
159
+ pages: str | None
160
+ article_number: str | None
161
+ number: int | None
162
+
163
+ def __init__(
164
+ self,
165
+ author: list[str],
166
+ title: str,
167
+ year: int,
168
+ journal: str,
169
+ volume: int,
170
+ *,
171
+ pages: str | None = None,
172
+ article_number: str | None = None,
173
+ number: int | None = None,
174
+ doi: str | None = None,
175
+ url: str | None = None,
176
+ note: str | None = None,
177
+ key: str | None = None,
178
+ app: str | None = None,
179
+ ) -> None:
180
+ require_field(journal, "journal", "Article")
181
+ require_field(volume, "volume", "Article")
182
+ if pages is None and article_number is None:
183
+ msg = "Article requires 'pages' or 'article_number'; both are None"
184
+ raise ValueError(msg)
185
+
186
+ self._init_base(
187
+ author, title, year, doi=doi, url=url, note=note, key=key, app=app
188
+ )
189
+ self.journal = journal
190
+ self.volume = volume
191
+ self.pages = pages
192
+ self.article_number = article_number
193
+ self.number = number
194
+
195
+ def __str__(self) -> str:
196
+ lines = [
197
+ f"@article{{{self.key},",
198
+ _format_bibtex_field("author", _author_str(self.author)),
199
+ _format_bibtex_field("title", self.title),
200
+ _format_bibtex_field("journal", self.journal),
201
+ _format_bibtex_field("year", str(self.year)),
202
+ _format_bibtex_field("volume", str(self.volume)),
203
+ ]
204
+ if self.number is not None:
205
+ lines.append(_format_bibtex_field("number", str(self.number)))
206
+ if self.pages is not None:
207
+ lines.append(_format_bibtex_field("pages", self.pages))
208
+ if self.article_number is not None:
209
+ lines.append(_format_bibtex_field("article_number", self.article_number))
210
+ self._append_common_bibtex(lines)
211
+ lines.append("}")
212
+ return "\n".join(lines)
213
+
214
+ def _repr_fields(self) -> list[tuple[str, object]]:
215
+ fields: list[tuple[str, object]] = [
216
+ ("author", self.author),
217
+ ("title", self.title),
218
+ ("year", self.year),
219
+ ("journal", self.journal),
220
+ ("volume", self.volume),
221
+ ]
222
+ if self.pages is not None:
223
+ fields.append(("pages", self.pages))
224
+ if self.article_number is not None:
225
+ fields.append(("article_number", self.article_number))
226
+ if self.number is not None:
227
+ fields.append(("number", self.number))
228
+ self._append_common_optional_repr(fields)
229
+ return fields
230
+
231
+
232
+ class Book(CitationBase):
233
+ """A ``@book`` BibTeX entry."""
234
+
235
+ publisher: str
236
+ edition: str | None
237
+ editor: list[str] | None
238
+
239
+ def __init__(
240
+ self,
241
+ author: list[str],
242
+ title: str,
243
+ year: int,
244
+ publisher: str,
245
+ *,
246
+ edition: str | None = None,
247
+ editor: list[str] | None = None,
248
+ doi: str | None = None,
249
+ url: str | None = None,
250
+ note: str | None = None,
251
+ key: str | None = None,
252
+ app: str | None = None,
253
+ ) -> None:
254
+ require_field(publisher, "publisher", "Book")
255
+
256
+ self._init_base(
257
+ author, title, year, doi=doi, url=url, note=note, key=key, app=app
258
+ )
259
+ self.publisher = publisher
260
+ self.edition = edition
261
+ self.editor = editor
262
+
263
+ def __str__(self) -> str:
264
+ lines = [
265
+ f"@book{{{self.key},",
266
+ _format_bibtex_field("author", _author_str(self.author)),
267
+ _format_bibtex_field("title", self.title),
268
+ _format_bibtex_field("publisher", self.publisher),
269
+ _format_bibtex_field("year", str(self.year)),
270
+ ]
271
+ if self.edition is not None:
272
+ lines.append(_format_bibtex_field("edition", self.edition))
273
+ if self.editor is not None:
274
+ lines.append(_format_bibtex_field("editor", _author_str(self.editor)))
275
+ self._append_common_bibtex(lines)
276
+ lines.append("}")
277
+ return "\n".join(lines)
278
+
279
+ def _repr_fields(self) -> list[tuple[str, object]]:
280
+ fields: list[tuple[str, object]] = [
281
+ ("author", self.author),
282
+ ("title", self.title),
283
+ ("year", self.year),
284
+ ("publisher", self.publisher),
285
+ ]
286
+ if self.edition is not None:
287
+ fields.append(("edition", self.edition))
288
+ if self.editor is not None:
289
+ fields.append(("editor", self.editor))
290
+ self._append_common_optional_repr(fields)
291
+ return fields
292
+
293
+
294
+ class InProceedings(CitationBase):
295
+ """An ``@inproceedings`` BibTeX entry."""
296
+
297
+ booktitle: str
298
+ pages: str | None
299
+ publisher: str | None
300
+ editor: list[str] | None
301
+
302
+ def __init__(
303
+ self,
304
+ author: list[str],
305
+ title: str,
306
+ year: int,
307
+ booktitle: str,
308
+ *,
309
+ pages: str | None = None,
310
+ publisher: str | None = None,
311
+ editor: list[str] | None = None,
312
+ doi: str | None = None,
313
+ url: str | None = None,
314
+ note: str | None = None,
315
+ key: str | None = None,
316
+ app: str | None = None,
317
+ ) -> None:
318
+ require_field(booktitle, "booktitle", "InProceedings")
319
+
320
+ self._init_base(
321
+ author, title, year, doi=doi, url=url, note=note, key=key, app=app
322
+ )
323
+ self.booktitle = booktitle
324
+ self.pages = pages
325
+ self.publisher = publisher
326
+ self.editor = editor
327
+
328
+ def __str__(self) -> str:
329
+ lines = [
330
+ f"@inproceedings{{{self.key},",
331
+ _format_bibtex_field("author", _author_str(self.author)),
332
+ _format_bibtex_field("title", self.title),
333
+ _format_bibtex_field("booktitle", self.booktitle),
334
+ _format_bibtex_field("year", str(self.year)),
335
+ ]
336
+ if self.pages is not None:
337
+ lines.append(_format_bibtex_field("pages", self.pages))
338
+ if self.publisher is not None:
339
+ lines.append(_format_bibtex_field("publisher", self.publisher))
340
+ if self.editor is not None:
341
+ lines.append(_format_bibtex_field("editor", _author_str(self.editor)))
342
+ self._append_common_bibtex(lines)
343
+ lines.append("}")
344
+ return "\n".join(lines)
345
+
346
+ def _repr_fields(self) -> list[tuple[str, object]]:
347
+ fields: list[tuple[str, object]] = [
348
+ ("author", self.author),
349
+ ("title", self.title),
350
+ ("year", self.year),
351
+ ("booktitle", self.booktitle),
352
+ ]
353
+ if self.pages is not None:
354
+ fields.append(("pages", self.pages))
355
+ if self.publisher is not None:
356
+ fields.append(("publisher", self.publisher))
357
+ if self.editor is not None:
358
+ fields.append(("editor", self.editor))
359
+ self._append_common_optional_repr(fields)
360
+ return fields
361
+
362
+
363
+ class TechReport(CitationBase):
364
+ """A ``@techreport`` BibTeX entry."""
365
+
366
+ institution: str
367
+ number: str | None
368
+
369
+ def __init__(
370
+ self,
371
+ author: list[str],
372
+ title: str,
373
+ year: int,
374
+ institution: str,
375
+ *,
376
+ number: str | None = None,
377
+ doi: str | None = None,
378
+ url: str | None = None,
379
+ note: str | None = None,
380
+ key: str | None = None,
381
+ app: str | None = None,
382
+ ) -> None:
383
+ require_field(institution, "institution", "TechReport")
384
+
385
+ self._init_base(
386
+ author, title, year, doi=doi, url=url, note=note, key=key, app=app
387
+ )
388
+ self.institution = institution
389
+ self.number = number
390
+
391
+ def __str__(self) -> str:
392
+ lines = [
393
+ f"@techreport{{{self.key},",
394
+ _format_bibtex_field("author", _author_str(self.author)),
395
+ _format_bibtex_field("title", self.title),
396
+ _format_bibtex_field("institution", self.institution),
397
+ _format_bibtex_field("year", str(self.year)),
398
+ ]
399
+ if self.number is not None:
400
+ lines.append(_format_bibtex_field("number", self.number))
401
+ self._append_common_bibtex(lines)
402
+ lines.append("}")
403
+ return "\n".join(lines)
404
+
405
+ def _repr_fields(self) -> list[tuple[str, object]]:
406
+ fields: list[tuple[str, object]] = [
407
+ ("author", self.author),
408
+ ("title", self.title),
409
+ ("year", self.year),
410
+ ("institution", self.institution),
411
+ ]
412
+ if self.number is not None:
413
+ fields.append(("number", self.number))
414
+ self._append_common_optional_repr(fields)
415
+ return fields
416
+
417
+
418
+ class Thesis(CitationBase):
419
+ """A ``@phdthesis`` or ``@mastersthesis`` BibTeX entry."""
420
+
421
+ school: str
422
+ thesis_type: str
423
+
424
+ def __init__(
425
+ self,
426
+ author: list[str],
427
+ title: str,
428
+ year: int,
429
+ school: str,
430
+ thesis_type: str,
431
+ *,
432
+ doi: str | None = None,
433
+ url: str | None = None,
434
+ note: str | None = None,
435
+ key: str | None = None,
436
+ app: str | None = None,
437
+ ) -> None:
438
+ require_field(school, "school", "Thesis")
439
+ require_field(thesis_type, "thesis_type", "Thesis")
440
+ if thesis_type not in ("phd", "masters"):
441
+ msg = f"Thesis thesis_type must be 'phd' or 'masters'; got {thesis_type!r}"
442
+ raise ValueError(msg)
443
+
444
+ self._init_base(
445
+ author, title, year, doi=doi, url=url, note=note, key=key, app=app
446
+ )
447
+ self.school = school
448
+ self.thesis_type = thesis_type
449
+
450
+ def __str__(self) -> str:
451
+ bib_type = "phdthesis" if self.thesis_type == "phd" else "mastersthesis"
452
+ lines = [
453
+ f"@{bib_type}{{{self.key},",
454
+ _format_bibtex_field("author", _author_str(self.author)),
455
+ _format_bibtex_field("title", self.title),
456
+ _format_bibtex_field("school", self.school),
457
+ _format_bibtex_field("year", str(self.year)),
458
+ ]
459
+ self._append_common_bibtex(lines)
460
+ lines.append("}")
461
+ return "\n".join(lines)
462
+
463
+ def _repr_fields(self) -> list[tuple[str, object]]:
464
+ fields: list[tuple[str, object]] = [
465
+ ("author", self.author),
466
+ ("title", self.title),
467
+ ("year", self.year),
468
+ ("school", self.school),
469
+ ("thesis_type", self.thesis_type),
470
+ ]
471
+ self._append_common_optional_repr(fields)
472
+ return fields
473
+
474
+
475
+ class Software(CitationBase):
476
+ """A ``@software`` BibTeX entry."""
477
+
478
+ publisher: str | None
479
+ version: str | None
480
+ license: str | None
481
+
482
+ def __init__(
483
+ self,
484
+ author: list[str],
485
+ title: str,
486
+ year: int,
487
+ *,
488
+ publisher: str | None = None,
489
+ version: str | None = None,
490
+ license: str | None = None,
491
+ doi: str | None = None,
492
+ url: str | None = None,
493
+ note: str | None = None,
494
+ key: str | None = None,
495
+ app: str | None = None,
496
+ ) -> None:
497
+ self._init_base(
498
+ author, title, year, doi=doi, url=url, note=note, key=key, app=app
499
+ )
500
+ self.publisher = publisher
501
+ self.version = version
502
+ self.license = license
503
+
504
+ def __str__(self) -> str:
505
+ lines = [
506
+ f"@software{{{self.key},",
507
+ _format_bibtex_field("author", _author_str(self.author)),
508
+ _format_bibtex_field("title", self.title),
509
+ _format_bibtex_field("year", str(self.year)),
510
+ ]
511
+ if self.publisher is not None:
512
+ lines.append(_format_bibtex_field("publisher", self.publisher))
513
+ if self.version is not None:
514
+ lines.append(_format_bibtex_field("version", self.version))
515
+ if self.license is not None:
516
+ lines.append(_format_bibtex_field("license", self.license))
517
+ self._append_common_bibtex(lines)
518
+ lines.append("}")
519
+ return "\n".join(lines)
520
+
521
+ def _repr_fields(self) -> list[tuple[str, object]]:
522
+ fields: list[tuple[str, object]] = [
523
+ ("author", self.author),
524
+ ("title", self.title),
525
+ ("year", self.year),
526
+ ]
527
+ if self.publisher is not None:
528
+ fields.append(("publisher", self.publisher))
529
+ if self.version is not None:
530
+ fields.append(("version", self.version))
531
+ if self.license is not None:
532
+ fields.append(("license", self.license))
533
+ self._append_common_optional_repr(fields)
534
+ return fields
535
+
536
+
537
+ class Misc(CitationBase):
538
+ """A ``@misc`` BibTeX entry."""
539
+
540
+ def __init__(
541
+ self,
542
+ author: list[str],
543
+ title: str,
544
+ year: int,
545
+ *,
546
+ doi: str | None = None,
547
+ url: str | None = None,
548
+ note: str | None = None,
549
+ key: str | None = None,
550
+ app: str | None = None,
551
+ ) -> None:
552
+ self._init_base(
553
+ author, title, year, doi=doi, url=url, note=note, key=key, app=app
554
+ )
555
+
556
+ def __str__(self) -> str:
557
+ lines = [
558
+ f"@misc{{{self.key},",
559
+ _format_bibtex_field("author", _author_str(self.author)),
560
+ _format_bibtex_field("title", self.title),
561
+ _format_bibtex_field("year", str(self.year)),
562
+ ]
563
+ self._append_common_bibtex(lines)
564
+ lines.append("}")
565
+ return "\n".join(lines)
566
+
567
+ def _repr_fields(self) -> list[tuple[str, object]]:
568
+ fields: list[tuple[str, object]] = [
569
+ ("author", self.author),
570
+ ("title", self.title),
571
+ ("year", self.year),
572
+ ]
573
+ self._append_common_optional_repr(fields)
574
+ return fields
575
+
576
+
577
+ _ENTRY_TYPES: dict[str, type[CitationBase]] = {
578
+ "Article": Article,
579
+ "Book": Book,
580
+ "InProceedings": InProceedings,
581
+ "TechReport": TechReport,
582
+ "Thesis": Thesis,
583
+ "Software": Software,
584
+ "Misc": Misc,
585
+ }
citeable/_json.py ADDED
@@ -0,0 +1,32 @@
1
+ """JSON serialisation helpers for citation collections."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import pathlib
7
+ from typing import TYPE_CHECKING
8
+
9
+ if TYPE_CHECKING:
10
+ from collections.abc import Iterable
11
+
12
+ from citeable._entries import CitationBase
13
+
14
+
15
+ def to_jsons(citations: Iterable[CitationBase]) -> str:
16
+ """Return a JSON string from an iterable of citations."""
17
+ return json.dumps([c.to_dict() for c in citations])
18
+
19
+
20
+ def from_jsons(data: str) -> list[CitationBase]:
21
+ """Return a list of citations from a JSON string."""
22
+ return [CitationBase.from_dict(d) for d in json.loads(data)]
23
+
24
+
25
+ def write_json(*, citations: Iterable[CitationBase], path: str | pathlib.Path) -> None:
26
+ """Write citations to a JSON file at *path*."""
27
+ pathlib.Path(path).write_text(to_jsons(citations))
28
+
29
+
30
+ def load_json(path: str | pathlib.Path) -> list[CitationBase]:
31
+ """Read citations from a JSON file at *path*."""
32
+ return from_jsons(pathlib.Path(path).read_text())