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,1565 @@
1
+ r"""The `Library` class: a full BibDesk `.bib` database.
2
+
3
+ `Library` wraps a `bibtexparser.Library` (`library._library`, private)
4
+ via *composition*, presenting BibDesk's view of a `.bib` database:
5
+
6
+ - `Library` is itself a `dict`-like mapping of citation key to
7
+ {any}`bibdeskparser.entry.Entry` (a
8
+ {py:class}`collections.abc.MutableMapping`); see {any}`Library.entries`.
9
+ - {any}`Library.timestamp`: the save time from the header comment,
10
+ updated by {any}`Library.save`.
11
+ - {any}`Library.strings`: a read-write view of the `@string` macro
12
+ definitions, delegating to `library._library.strings_dict`.
13
+ - {any}`Library.groups`: a read-write `dict`-like view of the `BibDesk
14
+ Static Groups`, mapping each group name to a tuple of citation keys.
15
+ Assigning a tuple of keys creates or replaces a group wholesale, and
16
+ `del` removes one; per-key membership changes go through
17
+ {any}`Library.add_to_group` / {any}`Library.remove_from_group`.
18
+ Since `Library` is the sole gateway for mutating group membership,
19
+ every mutation immediately refreshes the `groups` property of each
20
+ affected {any}`bibdeskparser.entry.Entry`.
21
+ - {any}`Library.keywords`: a read-write `dict`-like view mapping each
22
+ keyword to the tuple of citation keys of the entries carrying it,
23
+ computed on demand from the entries' stored `keywords` fields.
24
+ Those fields are not reachable through the entry `dict` interface,
25
+ so the view is always consistent with the entries; mutations
26
+ (assignment, `del`, {any}`Library.add_to_keyword`,
27
+ {any}`Library.remove_from_keyword`) edit the affected entries'
28
+ stored fields.
29
+ - {any}`Library.duplicate_keys`: citation keys that could not be
30
+ loaded because they duplicate an earlier entry (BibDesk itself
31
+ tolerates this, so `bibdeskparser` does too, read-only).
32
+ - {any}`Library.save`: writes the library back to disk. Entries that
33
+ were not modified since load are re-rendered verbatim (byte-exact
34
+ round-trip); modified or newly added entries are written in
35
+ BibDesk's field order. The header timestamp is only bumped if
36
+ something actually changed. A validation pass rejects entries that
37
+ reference an undefined `@string` macro, and warns (without raising)
38
+ about linked files (see {any}`bibdeskparser.entry.Entry.files`) that
39
+ no longer exist on disk. {any}`StaleFileError` is raised if the
40
+ target file was modified on disk (e.g. resaved by BibDesk) since
41
+ this library was loaded, unless `force=True`.
42
+ - {any}`Library.add_file`, {any}`Library.replace_file`,
43
+ {any}`Library.unlink_file`, {any}`Library.rename_file`: manage an
44
+ entry's linked files ({any}`bibdeskparser.entry.Entry.files`, itself
45
+ read-only). Linked files are stored relative to the library's
46
+ `.bib` file, which only the `Library` knows, so these are `Library`
47
+ operations.
48
+ - {any}`Library.render`, {any}`Library.export`, {any}`Library.edit`,
49
+ {any}`Library.edit_strings`: render a bibliography, export to
50
+ bibtex text, or edit in `$EDITOR`, for one or more selected
51
+ citation keys at once.
52
+
53
+ ```python
54
+ >>> from bibdeskparser.entry import Entry
55
+ >>> from bibdeskparser.library import Library
56
+ >>> bib = Library() # a fresh, empty, in-memory library
57
+ >>> bib.strings["jpb"] = "J. Phys. B"
58
+ >>> bib.strings["jpb"]
59
+ 'J. Phys. B'
60
+ >>> entry = Entry("article", "Key2026", fields={"title": "A Title"})
61
+ >>> bib["Key2026"] = entry
62
+ >>> bib["Key2026"] is entry
63
+ True
64
+ >>> len(bib)
65
+ 1
66
+
67
+ ```
68
+
69
+ Deleting a macro that is still referenced by an entry is rejected:
70
+
71
+ ```python
72
+ >>> import warnings
73
+ >>> with warnings.catch_warnings():
74
+ ... warnings.simplefilter("ignore")
75
+ ... entry["journal"] = "jpb" # a bare macro reference
76
+ >>> del bib.strings["jpb"]
77
+ Traceback (most recent call last):
78
+ ...
79
+ ValueError: cannot delete macro 'jpb': in use by entries ['Key2026']
80
+
81
+ ```
82
+
83
+ Group membership is mutated through `Library` itself, which keeps
84
+ `entry.groups` in sync immediately:
85
+
86
+ ```python
87
+ >>> bib.groups["My Papers"] = () # create an empty group
88
+ >>> bib.add_to_group("My Papers", "Key2026")
89
+ >>> bib["Key2026"].groups
90
+ ('My Papers',)
91
+ >>> bib.groups
92
+ {'My Papers': ('Key2026',)}
93
+
94
+ ```
95
+ """
96
+
97
+ import datetime
98
+ import getpass
99
+ import logging
100
+ import os
101
+ import sys
102
+ import warnings
103
+ from collections.abc import MutableMapping
104
+ from contextlib import contextmanager
105
+ from pathlib import Path
106
+
107
+ import bibtexparser
108
+ from bibtexparser.model import (
109
+ DuplicateBlockKeyBlock,
110
+ ExplicitComment,
111
+ ImplicitComment,
112
+ String,
113
+ )
114
+
115
+ from . import editing
116
+ from .bdskfile import BibDeskFile
117
+ from .entry import Entry, _strip_enclosing
118
+ from .exporting import export_entries
119
+ from .groups import (
120
+ is_static_groups_comment,
121
+ parse_static_groups,
122
+ render_static_groups,
123
+ )
124
+ from .header import make_header, parse_header, peek_timestamp, update_header
125
+ from .macros import is_valid_macro_name, normalize_macro_name
126
+ from .middleware import parse_stack
127
+ from .render import render_entries
128
+ from .writer import bibdesk_field_order, render_library
129
+
130
+ __all__ = ["Library", "StaleFileError"]
131
+
132
+ # All members whose name does not start with an underscore must be listed
133
+ # either in __all__ or in __private__
134
+ __private__ = []
135
+
136
+ try:
137
+ import pwd
138
+ except ImportError: # pragma: no cover - non-POSIX platforms
139
+ pwd = None
140
+
141
+
142
+ class StaleFileError(RuntimeError):
143
+ """Raised by {any}`Library.save` when the target file is newer on
144
+ disk than this library.
145
+
146
+ This means the file was saved (by BibDesk, or by another process)
147
+ after this `Library` was loaded or last saved here, so overwriting
148
+ it now would silently discard those changes. Pass `force=True` to
149
+ {any}`Library.save` to overwrite anyway.
150
+ """
151
+
152
+
153
+ # -- module-private helpers ------------------------------------------- #
154
+
155
+
156
+ @contextmanager
157
+ def _quiet_bibtexparser_block_type_logging():
158
+ """Silence bibtexparser's per-middleware "Unknown block type"
159
+ logging.
160
+
161
+ Every middleware in `parse_stack` calls into
162
+ `bibtexparser`'s `BlockMiddleware.transform_block`, which logs
163
+ this at `WARNING` for any `ParsingFailedBlock` (e.g., a duplicate
164
+ key or duplicate field) since it only special-cases `Entry`,
165
+ `String`, `Preamble`, and comment blocks -- so a single failed
166
+ block produces one log message per middleware. `Library.__init__`
167
+ inspects `library._library.failed_blocks` itself and raises its
168
+ own, single `UserWarning` per condition instead.
169
+ """
170
+ logger = logging.getLogger("bibtexparser.middlewares.middleware")
171
+ previous_level = logger.level
172
+ logger.setLevel(logging.ERROR)
173
+ try:
174
+ yield
175
+ finally:
176
+ logger.setLevel(previous_level)
177
+
178
+
179
+ def _check_unparseable_blocks(failed_blocks):
180
+ """Warn about `failed_blocks` not already covered by
181
+ `Library.duplicate_keys`.
182
+
183
+ * `failed_blocks`: `library._library.failed_blocks`, the
184
+ `bibtexparser.model.ParsingFailedBlock` instances found while
185
+ parsing.
186
+ """
187
+ other = [
188
+ block
189
+ for block in failed_blocks
190
+ if not isinstance(block, DuplicateBlockKeyBlock)
191
+ ]
192
+ if other:
193
+ details = "; ".join(str(block.error) for block in other)
194
+ warnings.warn(
195
+ f"{len(other)} block(s) could not be parsed and were "
196
+ f"skipped: {details}",
197
+ UserWarning,
198
+ stacklevel=3,
199
+ )
200
+
201
+
202
+ def _default_creator():
203
+ """The OS account's full name, for a from-scratch library's header.
204
+
205
+ Uses the Gecos "full name" field of the current user's password
206
+ database entry (stripping a trailing comma, as sometimes found in
207
+ macOS Gecos fields), falling back to the login name (see
208
+ {py:func}`getpass.getuser`) if `pwd` is unavailable or the Gecos
209
+ field is empty.
210
+ """
211
+ if pwd is not None:
212
+ try:
213
+ gecos = pwd.getpwuid(os.getuid()).pw_gecos
214
+ except (KeyError, OSError):
215
+ gecos = ""
216
+ if gecos:
217
+ name = gecos.split(",", 1)[0].strip()
218
+ if name:
219
+ return name
220
+ return getpass.getuser()
221
+
222
+
223
+ def _is_bare_value(value):
224
+ """Whether `value` is not enclosed in `{...}`/`"..."` (i.e. is a
225
+ candidate bare macro reference)."""
226
+ return not (
227
+ len(value) >= 2
228
+ and (
229
+ (value[0] == "{" and value[-1] == "}")
230
+ or (value[0] == '"' and value[-1] == '"')
231
+ )
232
+ )
233
+
234
+
235
+ def _bare_macro_fields(entry):
236
+ """Yield `(field, value)` for every bare (unbraced) string-valued
237
+ field of `entry`: candidate references to a `@string` macro.
238
+
239
+ The `keywords` field is skipped: keywords are always literal text,
240
+ never a macro reference, so it does not participate in macro
241
+ validation, deletion protection, or renaming."""
242
+ for field in entry._entry.fields: # pylint: disable=protected-access
243
+ value = field.value
244
+ if (
245
+ isinstance(value, str)
246
+ and _is_bare_value(value)
247
+ and field.key.lower() != "keywords"
248
+ ):
249
+ yield field, value
250
+
251
+
252
+ def _check_duplicate_macro_values(strings_dict):
253
+ """Warn if two or more distinct macro names expand to the same
254
+ value.
255
+
256
+ * `strings_dict`: a `dict` mapping macro name to
257
+ `bibtexparser.model.String`, as `library._library.strings_dict`.
258
+ """
259
+ by_value = {}
260
+ for name, string in strings_dict.items():
261
+ by_value.setdefault(string.value, []).append(name)
262
+ duplicates = {
263
+ value: sorted(names)
264
+ for value, names in by_value.items()
265
+ if len(names) > 1
266
+ }
267
+ if duplicates:
268
+ details = "; ".join(
269
+ f"{value!r}: {names}" for value, names in duplicates.items()
270
+ )
271
+ warnings.warn(
272
+ f"distinct macros expand to the same value: {details}",
273
+ UserWarning,
274
+ stacklevel=3,
275
+ )
276
+
277
+
278
+ def _delete_file(path):
279
+ """Remove `path` from the filesystem: move it to the Trash where
280
+ possible (macOS, with pyobjc installed), else delete it
281
+ permanently."""
282
+ if sys.platform == "darwin":
283
+ try:
284
+ # pylint: disable=import-outside-toplevel
285
+ from Foundation import ( # pyobjc-framework-Cocoa
286
+ NSURL,
287
+ NSFileManager,
288
+ )
289
+ except ImportError:
290
+ pass
291
+ else:
292
+ url = NSURL.fileURLWithPath_(str(path))
293
+ manager = NSFileManager.defaultManager()
294
+ # The selector name exceeds the line length limit.
295
+ trash_item = getattr(
296
+ manager, "trashItemAtURL_resultingItemURL_error_"
297
+ )
298
+ ok, _resulting_url, _error = trash_item(url, None, None)
299
+ if ok:
300
+ return
301
+ os.remove(path)
302
+
303
+
304
+ def _find_header_block(raw_library):
305
+ """Return `(header_block, creator, timestamp)` for `raw_library` (a
306
+ `bibtexparser.Library`).
307
+
308
+ The header, if present, is always the very first block. Returns
309
+ `(None, None, None)` if there is no such block (e.g. a `.bib` file
310
+ not written by BibDesk).
311
+ """
312
+ blocks = raw_library.blocks
313
+ if blocks and isinstance(blocks[0], ImplicitComment):
314
+ creator, timestamp = parse_header(blocks[0].comment)
315
+ if creator is not None:
316
+ return blocks[0], creator, timestamp
317
+ return None, None, None
318
+
319
+
320
+ def _find_groups_block(raw_library):
321
+ """Return the `ExplicitComment` block holding the `BibDesk Static
322
+ Groups` comment, or `None`."""
323
+ for block in raw_library.blocks:
324
+ if isinstance(block, ExplicitComment) and is_static_groups_comment(
325
+ block.comment
326
+ ):
327
+ return block
328
+ return None
329
+
330
+
331
+ # -- views ------------------------------------------------------------- #
332
+
333
+
334
+ class _StringsView(MutableMapping):
335
+ """Read-write `dict`-like view of {any}`Library.strings`.
336
+
337
+ Backed by `library._library.strings_dict`. Reading strips the
338
+ enclosing `{...}`/`"..."` delimiters; writing re-adds them (after
339
+ stripping any the caller redundantly supplied) and normalizes the
340
+ macro name (validating it and lowercasing it, matching BibDesk's
341
+ case-insensitive macro table). Deleting a macro that is still
342
+ referenced (as a bare field value) by any entry raises
343
+ {any}`ValueError`.
344
+ """
345
+
346
+ def __init__(self, owner):
347
+ self._owner = owner
348
+
349
+ @property
350
+ def _strings_dict(self):
351
+ return self._owner._library.strings_dict
352
+
353
+ def __getitem__(self, name):
354
+ return _strip_enclosing(self._strings_dict[name].value)
355
+
356
+ def __setitem__(self, name, value):
357
+ name = normalize_macro_name(name)
358
+ value = _strip_enclosing(value)
359
+ strings_dict = self._strings_dict
360
+ if name in strings_dict:
361
+ strings_dict[name].value = "{" + value + "}"
362
+ else:
363
+ new_string = String(key=name, value="{" + value + "}")
364
+ self._owner._library.add([new_string], fail_on_duplicate_key=False)
365
+ self._owner._modified = True
366
+ _check_duplicate_macro_values(self._strings_dict)
367
+
368
+ def __delitem__(self, name):
369
+ strings_dict = self._strings_dict
370
+ if name not in strings_dict:
371
+ raise KeyError(name)
372
+ users = self._owner._macro_users(name)
373
+ if users:
374
+ raise ValueError(
375
+ f"cannot delete macro {name!r}: in use by entries "
376
+ f"{sorted(users)}"
377
+ )
378
+ self._owner._library.remove([strings_dict[name]])
379
+ self._owner._modified = True
380
+
381
+ def __iter__(self):
382
+ return iter(self._strings_dict)
383
+
384
+ def __len__(self):
385
+ return len(self._strings_dict)
386
+
387
+ def __repr__(self):
388
+ # Show the full name -> value mapping, like a plain dict.
389
+ return repr(dict(self))
390
+
391
+ def _repr_pretty_(self, p, cycle):
392
+ # IPython/Jupyter pretty-printing hook: same name -> value
393
+ # mapping as `repr`, but indented across multiple lines.
394
+ if cycle:
395
+ p.text("{...}")
396
+ else:
397
+ p.pretty(dict(self))
398
+
399
+
400
+ class _GroupsView(MutableMapping):
401
+ """Read-write `dict`-like view of {any}`Library.groups`, mapping
402
+ each group name to the tuple of the group's citation keys.
403
+
404
+ A stateless facade: every operation forwards to the owning
405
+ `Library`, which holds the group data and immediately refreshes
406
+ the `groups` property of every affected
407
+ {any}`bibdeskparser.entry.Entry`. Values are always tuples, so a
408
+ group's membership cannot be mutated in place: whole groups are
409
+ created, replaced, or deleted through the mapping interface, and
410
+ per-key membership changes go through
411
+ {any}`Library.add_to_group` / {any}`Library.remove_from_group`.
412
+ The `MutableMapping` mixins (`pop`, `popitem`, `clear`, `update`,
413
+ `setdefault`) all funnel through `__setitem__`/`__delitem__`, so
414
+ they maintain the same invariants.
415
+ """
416
+
417
+ def __init__(self, owner):
418
+ self._owner = owner
419
+
420
+ def __getitem__(self, name):
421
+ return self._owner._group_data[name]
422
+
423
+ def __setitem__(self, name, keys):
424
+ self._owner._assign_group(name, keys)
425
+
426
+ def __delitem__(self, name):
427
+ self._owner._delete_group(name)
428
+
429
+ def __iter__(self):
430
+ return iter(self._owner._group_data)
431
+
432
+ def __len__(self):
433
+ return len(self._owner._group_data)
434
+
435
+ def __repr__(self):
436
+ # Show the full name -> keys mapping, like a plain dict.
437
+ return repr(self._owner._group_data)
438
+
439
+ def _repr_pretty_(self, p, cycle):
440
+ # IPython/Jupyter pretty-printing hook: same name -> keys
441
+ # mapping as `repr`, but indented across multiple lines.
442
+ if cycle:
443
+ p.text("{...}")
444
+ else:
445
+ p.pretty(self._owner._group_data)
446
+
447
+
448
+ class _KeywordsView(MutableMapping):
449
+ """Read-write `dict`-like view of {any}`Library.keywords`, mapping
450
+ each keyword to the tuple of citation keys of the entries carrying
451
+ it.
452
+
453
+ A stateless facade: the mapping is computed on demand from the
454
+ entries' stored `keywords` fields (keywords in first-seen entry
455
+ order), so it is always consistent with the entries -- the
456
+ `keywords` field is not reachable through the entry `dict`
457
+ interface, and every mutation forwards to the owning `Library`,
458
+ which edits the affected entries. A keyword exists only inside
459
+ entries' `keywords` fields, so an *empty* keyword cannot be
460
+ represented: assigning `()` is equivalent to deleting the keyword.
461
+ """
462
+
463
+ def __init__(self, owner):
464
+ self._owner = owner
465
+
466
+ def _index(self):
467
+ index = {}
468
+ for key, entry in self._owner._entries.items():
469
+ for keyword in entry.keywords:
470
+ index.setdefault(keyword, []).append(key)
471
+ return {kw: tuple(keys) for (kw, keys) in index.items()}
472
+
473
+ def __getitem__(self, keyword):
474
+ return self._index()[keyword]
475
+
476
+ def __setitem__(self, keyword, keys):
477
+ self._owner._assign_keyword(keyword, keys)
478
+
479
+ def __delitem__(self, keyword):
480
+ self._owner._delete_keyword(keyword)
481
+
482
+ def __iter__(self):
483
+ return iter(self._index())
484
+
485
+ def __len__(self):
486
+ return len(self._index())
487
+
488
+ def __repr__(self):
489
+ # Show the full keyword -> keys mapping, like a plain dict.
490
+ return repr(self._index())
491
+
492
+ def _repr_pretty_(self, p, cycle):
493
+ # IPython/Jupyter pretty-printing hook: same keyword -> keys
494
+ # mapping as `repr`, but indented across multiple lines.
495
+ if cycle:
496
+ p.text("{...}")
497
+ else:
498
+ p.pretty(self._index())
499
+
500
+
501
+ # -- Library ------------------------------------------------------------ #
502
+
503
+
504
+ class Library(MutableMapping):
505
+ """A BibDesk `.bib` database.
506
+
507
+ ```python
508
+ Library(path=None, creator=None)
509
+ ```
510
+
511
+ * `path`: path to a `.bib` file to load (UTF-8 text). If `None`
512
+ (default), a fresh, empty, in-memory library is created instead.
513
+ * `creator`: the name to use on the `Created for` line of a
514
+ synthesized header (see {any}`Library.save`). Only relevant for
515
+ a from-scratch library, or one loaded from a `.bib` file that
516
+ has no BibDesk header of its own; defaults to the OS account's
517
+ full name.
518
+
519
+ Loading a file that contains duplicate citation keys (see
520
+ {any}`Library.duplicate_keys`) emits a `UserWarning`; BibDesk
521
+ itself tolerates such files (keeping only the first entry for each
522
+ key), so this is a read-only, informational condition rather than
523
+ an error.
524
+
525
+ See the module docstring for a complete overview.
526
+ """
527
+
528
+ def __init__(self, path=None, creator=None):
529
+ self._path = path
530
+ self._creator = creator
531
+
532
+ if path is not None:
533
+ text = Path(path).read_text(encoding="utf-8")
534
+ with _quiet_bibtexparser_block_type_logging():
535
+ self._library = bibtexparser.parse_string(
536
+ text, parse_stack=parse_stack()
537
+ )
538
+ else:
539
+ self._library = bibtexparser.Library()
540
+
541
+ self._header_block, parsed_creator, self._timestamp = (
542
+ _find_header_block(self._library)
543
+ )
544
+ if self._creator is None:
545
+ self._creator = parsed_creator or _default_creator()
546
+
547
+ self._groups_block = _find_groups_block(self._library)
548
+ self._group_data = (
549
+ parse_static_groups(self._groups_block.comment)
550
+ if self._groups_block is not None
551
+ else {}
552
+ )
553
+
554
+ self._entries = {}
555
+ # A single one-pass reverse index over the group data to seed
556
+ # every entry's `.groups`, rather than one scan over all groups
557
+ # per entry.
558
+ index = {}
559
+ for name, keys in self._group_data.items():
560
+ for key in keys:
561
+ index.setdefault(key, []).append(name)
562
+ for model_entry in list(self._library.entries):
563
+ entry = Entry._wrap(
564
+ model_entry
565
+ ) # pylint: disable=protected-access
566
+ entry._groups = tuple( # pylint: disable=protected-access
567
+ index.get(entry.key, ())
568
+ )
569
+ self._entries[entry.key] = entry
570
+
571
+ self._strings_view = _StringsView(self)
572
+ self._groups_view = _GroupsView(self)
573
+ self._keywords_view = _KeywordsView(self)
574
+
575
+ _check_duplicate_macro_values(self._library.strings_dict)
576
+ self._modified = False
577
+
578
+ if self.duplicate_keys:
579
+ warnings.warn(
580
+ f"duplicate citation keys found: {self.duplicate_keys}",
581
+ UserWarning,
582
+ stacklevel=2,
583
+ )
584
+ _check_unparseable_blocks(self._library.failed_blocks)
585
+
586
+ def __repr__(self):
587
+ return f"Library({self._path!r})"
588
+
589
+ # -- path ------------------------------------------------------------ #
590
+
591
+ @property
592
+ def path(self):
593
+ """The `.bib` file this library was loaded from, or last saved
594
+ to (a `pathlib.Path`, read-only).
595
+
596
+ `None` for a from-scratch library that has not been saved yet.
597
+ """
598
+ return None if self._path is None else Path(self._path)
599
+
600
+ # -- timestamp ----------------------------------------------------- #
601
+
602
+ @property
603
+ def timestamp(self):
604
+ """The save time from the header comment (a timezone-aware
605
+ `datetime.datetime`, read-only).
606
+
607
+ `None` for a from-scratch library that has not been saved yet.
608
+ Updated by {any}`save` whenever the library was actually
609
+ modified.
610
+ """
611
+ return self._timestamp
612
+
613
+ # -- strings --------------------------------------------------------- #
614
+
615
+ @property
616
+ def strings(self):
617
+ """Read-write `dict`-like view of the `@string` macro
618
+ definitions.
619
+
620
+ Assigning a name validates that it is a valid BibDesk macro
621
+ name (a subset of ASCII, no leading digit) and raises
622
+ `ValueError` if it is not; the name is lowercased to match
623
+ BibDesk's case-insensitive macro table, so `strings["PRA"] =
624
+ ...` defines the same macro as `strings["pra"] = ...`.
625
+ Deleting a name that is still referenced by any entry's field
626
+ raises `ValueError` instead of leaving a dangling reference;
627
+ use {any}`rename_string` to rename a macro everywhere it is
628
+ used in one step.
629
+ """
630
+ return self._strings_view
631
+
632
+ def _macro_users(self, name):
633
+ """Citation keys of entries with a bare field value equal to
634
+ `name` (case-insensitively): the entries that reference the
635
+ macro `name`."""
636
+ lname = name.lower()
637
+ users = []
638
+ for entry in self._entries.values():
639
+ for _, value in _bare_macro_fields(entry):
640
+ if value.lower() == lname:
641
+ users.append(entry.key)
642
+ break
643
+ return users
644
+
645
+ def rename_string(self, old_name, new_name):
646
+ """Rename the macro `old_name` to `new_name`.
647
+
648
+ Updates the `@string` definition itself, and every entry field
649
+ that bare-references `old_name` (case-insensitively) is
650
+ rewritten to reference `new_name` instead (marking that entry
651
+ {any}`bibdeskparser.entry.Entry.dirty`).
652
+
653
+ Raises {any}`KeyError` if `old_name` is not a defined macro,
654
+ and {any}`ValueError` if `new_name` is not a valid macro name
655
+ or already names a different macro.
656
+ """
657
+ strings_dict = self._library.strings_dict
658
+ if old_name not in strings_dict:
659
+ raise KeyError(old_name)
660
+ if not is_valid_macro_name(new_name, normalized=True):
661
+ raise ValueError(f"invalid BibDesk macro name: {new_name!r}")
662
+ if new_name in strings_dict:
663
+ raise ValueError(f"macro {new_name!r} already exists")
664
+ old_string = strings_dict[old_name]
665
+ new_string = String(key=new_name, value=old_string.value)
666
+ self._library.replace(
667
+ old_string, new_string, fail_on_duplicate_key=True
668
+ )
669
+ lname = old_name.lower()
670
+ for entry in self._entries.values():
671
+ for field, value in _bare_macro_fields(entry):
672
+ if value.lower() == lname:
673
+ field.value = new_name
674
+ entry._touch() # pylint: disable=protected-access
675
+ self._modified = True
676
+ _check_duplicate_macro_values(self._library.strings_dict)
677
+
678
+ # -- groups ------------------------------------------------------ #
679
+
680
+ @property
681
+ def groups(self):
682
+ """Read-write `dict`-like view of the `BibDesk Static Groups`,
683
+ mapping each group name to the tuple of the group's citation
684
+ keys.
685
+
686
+ Whole-group operations use the mapping interface --
687
+ `library.groups[name] = (key, ...)` creates or replaces a
688
+ group (an empty tuple creates an empty group), and
689
+ `del library.groups[name]` deletes one -- while per-key
690
+ membership changes go through {any}`add_to_group` /
691
+ {any}`remove_from_group`. Values are always tuples: a group's
692
+ membership cannot be mutated in place, only replaced or edited
693
+ through those methods.
694
+
695
+ Assigned keys must be citation keys of entries in this
696
+ library (`KeyError` otherwise); a single string is rejected
697
+ with `TypeError` (pass a tuple or list of keys), and duplicate
698
+ keys are silently dropped. Keys loaded from a `.bib` file
699
+ whose entries are absent (stale groups data) are preserved.
700
+
701
+ Every mutation immediately refreshes the `groups` property of
702
+ each affected {any}`bibdeskparser.entry.Entry`, so the two
703
+ views are always consistent. Deleting an entry from the
704
+ library (or renaming one via {any}`rekey`) likewise updates
705
+ the group data, so groups never accumulate dangling keys.
706
+ """
707
+ return self._groups_view
708
+
709
+ def _groups_of_key(self, key):
710
+ """The names of the groups containing `key`, in group order (a
711
+ tuple)."""
712
+ return tuple(
713
+ name for name, keys in self._group_data.items() if key in keys
714
+ )
715
+
716
+ def _set_group(self, name, keys):
717
+ """Create/replace (`keys` a tuple) or delete (`keys` is
718
+ `None`) the group `name`.
719
+
720
+ The single choke point through which every group mutation
721
+ goes, so the `groups` property of each affected entry is
722
+ refreshed in exactly one place."""
723
+ old = self._group_data.get(name)
724
+ if keys is None:
725
+ if old is None:
726
+ raise KeyError(name)
727
+ del self._group_data[name]
728
+ new = ()
729
+ else:
730
+ self._group_data[name] = keys
731
+ new = keys
732
+ for key in set(old or ()) ^ set(new):
733
+ entry = self._entries.get(key)
734
+ if entry is not None:
735
+ # pylint: disable=protected-access
736
+ entry._groups = self._groups_of_key(key)
737
+ self._modified = True
738
+
739
+ def _normalize_group_keys(self, keys, current=()):
740
+ """Validate `keys` as group members; return them as a
741
+ deduplicated tuple.
742
+
743
+ Raises `TypeError` if `keys` is a single string (almost
744
+ certainly a mistake: iterating it would yield characters) or
745
+ contains a non-string, and `KeyError` for a key that does not
746
+ correspond to an entry in this library -- except keys already
747
+ in `current` (a group's existing members), so that stale keys
748
+ loaded from a `.bib` file can be carried along."""
749
+ if isinstance(keys, str):
750
+ raise TypeError(
751
+ "group keys must be given as an iterable of citation "
752
+ f"keys, not a single string: {keys!r}"
753
+ )
754
+ current = set(current)
755
+ result = []
756
+ seen = set()
757
+ for key in keys:
758
+ if not isinstance(key, str):
759
+ raise TypeError(
760
+ f"citation key must be a str, not {type(key)!r}"
761
+ )
762
+ if key in seen:
763
+ continue
764
+ seen.add(key)
765
+ if key not in self._entries and key not in current:
766
+ raise KeyError(
767
+ f"cannot add {key!r} to a group: no such entry in "
768
+ "this library"
769
+ )
770
+ result.append(key)
771
+ return tuple(result)
772
+
773
+ def _assign_group(self, name, keys):
774
+ """Backing implementation of `library.groups[name] = keys`."""
775
+ current = self._group_data.get(name, ())
776
+ keys = self._normalize_group_keys(keys, current=current)
777
+ if name not in self._group_data or keys != current:
778
+ self._set_group(name, keys)
779
+
780
+ def _delete_group(self, name):
781
+ """Backing implementation of `del library.groups[name]`."""
782
+ self._set_group(name, None)
783
+
784
+ def add_to_group(self, name, *keys):
785
+ """Add the citation `keys` to the existing group `name`.
786
+
787
+ Keys that are already members are silently skipped. Raises
788
+ `KeyError` if no group named `name` exists (create groups
789
+ explicitly, via `library.groups[name] = ()`), or if any key
790
+ does not correspond to an entry in this library. The `groups`
791
+ property of every affected entry is refreshed immediately.
792
+ """
793
+ current = self._group_data[name]
794
+ keys = self._normalize_group_keys(keys, current=current)
795
+ merged = current + tuple(k for k in keys if k not in current)
796
+ if merged != current:
797
+ self._set_group(name, merged)
798
+
799
+ def remove_from_group(self, name, *keys):
800
+ """Remove the citation `keys` from the group named `name`.
801
+
802
+ Keys that are not members are silently skipped. Raises
803
+ `KeyError` if no group named `name` exists. The `groups`
804
+ property of every affected entry is refreshed immediately.
805
+ """
806
+ current = self._group_data[name]
807
+ remove = set(keys)
808
+ remaining = tuple(k for k in current if k not in remove)
809
+ if remaining != current:
810
+ self._set_group(name, remaining)
811
+
812
+ # -- keywords ------------------------------------------------------- #
813
+
814
+ @property
815
+ def keywords(self):
816
+ """Read-write `dict`-like view mapping each keyword to the
817
+ tuple of citation keys of the entries carrying it.
818
+
819
+ The mapping is always consistent with the entries' stored
820
+ `keywords` fields (which are not reachable through the entry
821
+ `dict` interface; see
822
+ {any}`bibdeskparser.entry.Entry.keywords`).
823
+
824
+ Assigning `library.keywords[keyword] = (key, ...)` makes
825
+ exactly those entries carry `keyword`; `del
826
+ library.keywords[keyword]` removes it from every entry.
827
+ Per-key changes go through {any}`add_to_keyword` /
828
+ {any}`remove_from_keyword`. All of these edit the affected
829
+ entries' stored `keywords` field, bumping their
830
+ `date-modified` and marking them
831
+ {any}`bibdeskparser.entry.Entry.dirty` (unlike group
832
+ mutations, which only affect the groups `@comment` block).
833
+
834
+ A keyword exists only as part of some entry's `keywords`
835
+ field, so an *empty* keyword cannot be represented: assigning
836
+ `()` is equivalent to deleting the keyword.
837
+ """
838
+ return self._keywords_view
839
+
840
+ @staticmethod
841
+ def _check_keyword(keyword):
842
+ """Validate `keyword`; return it stripped of surrounding
843
+ whitespace.
844
+
845
+ Raises `TypeError` for a non-string, and `ValueError` for an
846
+ empty keyword or one containing a comma (the separator in the
847
+ stored `keywords` field)."""
848
+ if not isinstance(keyword, str):
849
+ raise TypeError(f"keyword must be a str, not {type(keyword)!r}")
850
+ keyword = keyword.strip()
851
+ if not keyword:
852
+ raise ValueError("keyword must not be empty")
853
+ if "," in keyword:
854
+ raise ValueError(
855
+ f"invalid keyword {keyword!r}: the comma separates "
856
+ "keywords in the stored keywords field"
857
+ )
858
+ return keyword
859
+
860
+ def add_to_keyword(self, keyword, *keys):
861
+ """Add `keyword` to the entries with the given citation
862
+ `keys`.
863
+
864
+ Edits each affected entry's stored `keywords` field (bumping
865
+ its `date-modified` and marking it
866
+ {any}`bibdeskparser.entry.Entry.dirty`); entries already
867
+ carrying `keyword` are silently skipped. A keyword that no
868
+ entry carried before is thereby created -- keywords exist only
869
+ inside entries, so there is no separate creation step (unlike
870
+ groups). Raises `KeyError` (before any entry is modified) if
871
+ any key is not in this library, and `ValueError` for an
872
+ invalid keyword (empty, or containing a comma).
873
+ """
874
+ keyword = self._check_keyword(keyword)
875
+ entries = [self._entries[key] for key in keys]
876
+ for entry in entries:
877
+ current = entry.keywords
878
+ if keyword not in current:
879
+ # pylint: disable=protected-access
880
+ entry._set_keywords(current + (keyword,))
881
+
882
+ def remove_from_keyword(self, keyword, *keys):
883
+ """Remove `keyword` from the entries with the given citation
884
+ `keys`.
885
+
886
+ The mirror image of {any}`add_to_keyword`: entries not
887
+ carrying `keyword` are silently skipped, and `KeyError` is
888
+ raised (before any entry is modified) if any key is not in
889
+ this library.
890
+ """
891
+ if isinstance(keyword, str):
892
+ keyword = keyword.strip()
893
+ entries = [self._entries[key] for key in keys]
894
+ for entry in entries:
895
+ current = entry.keywords
896
+ if keyword in current:
897
+ # pylint: disable=protected-access
898
+ entry._set_keywords(k for k in current if k != keyword)
899
+
900
+ def _assign_keyword(self, keyword, keys):
901
+ """Backing implementation of `library.keywords[keyword] =
902
+ keys`: make exactly the entries with citation keys `keys`
903
+ carry `keyword` (assigning an empty `keys` removes the keyword
904
+ everywhere)."""
905
+ keyword = self._check_keyword(keyword)
906
+ if isinstance(keys, str):
907
+ raise TypeError(
908
+ "keyword keys must be given as an iterable of citation "
909
+ f"keys, not a single string: {keys!r}"
910
+ )
911
+ keys = list(dict.fromkeys(keys))
912
+ missing = [key for key in keys if key not in self._entries]
913
+ if missing:
914
+ raise KeyError(f"no such entries in this library: {missing}")
915
+ current = [
916
+ key
917
+ for key, entry in self._entries.items()
918
+ if keyword in entry.keywords
919
+ ]
920
+ self.remove_from_keyword(
921
+ keyword, *(key for key in current if key not in keys)
922
+ )
923
+ self.add_to_keyword(
924
+ keyword, *(key for key in keys if key not in current)
925
+ )
926
+
927
+ def _delete_keyword(self, keyword):
928
+ """Backing implementation of `del library.keywords[keyword]`."""
929
+ members = [
930
+ key
931
+ for key, entry in self._entries.items()
932
+ if keyword in entry.keywords
933
+ ]
934
+ if not members:
935
+ raise KeyError(keyword)
936
+ self.remove_from_keyword(keyword, *members)
937
+
938
+ # -- duplicate keys -------------------------------------------------- #
939
+
940
+ @property
941
+ def duplicate_keys(self):
942
+ """Citation keys that could not be loaded because they
943
+ duplicate an earlier entry (a `tuple`, read-only).
944
+
945
+ BibDesk itself tolerates a `.bib` file with duplicate citation
946
+ keys, keeping only the first entry for each one; this property
947
+ lists the keys for which a later, duplicate entry was
948
+ discarded this way.
949
+ """
950
+ return tuple(
951
+ block.key
952
+ for block in self._library.failed_blocks
953
+ if isinstance(block, DuplicateBlockKeyBlock)
954
+ )
955
+
956
+ # -- dict interface (entries) ---------------------------------------- #
957
+
958
+ @property
959
+ def entries(self):
960
+ """All entries in the library, as a `list` of
961
+ {any}`bibdeskparser.entry.Entry`."""
962
+ return list(self._entries.values())
963
+
964
+ def __getitem__(self, key):
965
+ return self._entries[key]
966
+
967
+ def __setitem__(self, key, entry):
968
+ if not isinstance(entry, Entry):
969
+ raise TypeError(f"value must be an Entry, not {type(entry)!r}")
970
+ for existing_key, existing_entry in self._entries.items():
971
+ if existing_entry is entry and existing_key != key:
972
+ # `entry` is already tracked under a different key: this
973
+ # can only be an intentional rename (e.g. `bib[new] =
974
+ # bib[old]`), since a genuinely new/foreign Entry can
975
+ # never be identical to one already in self._entries.
976
+ self.rekey(existing_key, key)
977
+ return
978
+ if entry.key != key:
979
+ # A brand new entry (or one from elsewhere) adopts the key
980
+ # it is being added under. `Entry.key` itself is read-only,
981
+ # so this bypasses the property.
982
+ entry._entry.key = key # pylint: disable=protected-access
983
+ entry._touch() # pylint: disable=protected-access
984
+ # An Entry is assumed to belong to at most one Library; this is
985
+ # not checked/enforced.
986
+ if key in self._entries:
987
+ old = self._entries[key]
988
+ if old is not entry:
989
+ self._library.replace(
990
+ old._entry, # pylint: disable=protected-access
991
+ entry._entry, # pylint: disable=protected-access
992
+ fail_on_duplicate_key=True,
993
+ )
994
+ old._groups = () # pylint: disable=protected-access
995
+ else:
996
+ self._library.add([entry._entry], fail_on_duplicate_key=True)
997
+ # Sets date-added (only if not already set) and marks the
998
+ # entry dirty, matching BibDesk's "adding sets date-added"
999
+ # behavior.
1000
+ entry._touch() # pylint: disable=protected-access
1001
+ entry._groups = ( # pylint: disable=protected-access
1002
+ self._groups_of_key(key)
1003
+ )
1004
+ self._entries[key] = entry
1005
+ self._modified = True
1006
+
1007
+ def __delitem__(self, key):
1008
+ entry = self._entries.pop(key)
1009
+ self._library.remove(
1010
+ [entry._entry]
1011
+ ) # pylint: disable=protected-access
1012
+ entry._groups = () # pylint: disable=protected-access
1013
+ # Deleting an entry also removes its citation key from every
1014
+ # static group, so `groups` never holds dangling keys. The
1015
+ # entry was popped above, so `_set_group`'s refresh skips it.
1016
+ for name, keys in list(self._group_data.items()):
1017
+ if key in keys:
1018
+ self._set_group(name, tuple(k for k in keys if k != key))
1019
+ self._modified = True
1020
+
1021
+ def rekey(self, old_key, new_key):
1022
+ """Rename the entry at `old_key` to `new_key`.
1023
+
1024
+ `Entry.key` is read-only, so this is the only way to rename
1025
+ an entry that is already in the library. The entry's
1026
+ static-group memberships follow the rename: `new_key` replaces
1027
+ `old_key` in place (keeping its position) in every group that
1028
+ contained it. Raises `KeyError` if `old_key` is not present,
1029
+ or `ValueError` if `new_key` is already used by a different
1030
+ entry.
1031
+ """
1032
+ if old_key not in self._entries:
1033
+ raise KeyError(old_key)
1034
+ if new_key == old_key:
1035
+ return
1036
+ if new_key in self._entries:
1037
+ raise ValueError(
1038
+ f"key {new_key!r} is already used by another entry in "
1039
+ "this library"
1040
+ )
1041
+ # Rewrite the group data first (keeping each key's position
1042
+ # within its group), so that the `__delitem__` cascade below
1043
+ # finds nothing left to remove; `__setitem__` then restores
1044
+ # `entry.groups` from the rewritten data.
1045
+ for name, keys in list(self._group_data.items()):
1046
+ if old_key in keys:
1047
+ self._group_data[name] = tuple(
1048
+ new_key if k == old_key else k for k in keys
1049
+ )
1050
+ self._modified = True
1051
+ self[new_key] = self.pop(old_key)
1052
+
1053
+ def __iter__(self):
1054
+ return iter(self._entries)
1055
+
1056
+ def __len__(self):
1057
+ return len(self._entries)
1058
+
1059
+ # -- file attachments -------------------------------------------- #
1060
+
1061
+ def _files_base_dir(self):
1062
+ """The directory that linked-file paths are relative to (the
1063
+ library file's directory), as a resolved `Path`.
1064
+
1065
+ Raises `ValueError` if the library has no file path yet."""
1066
+ if self._path is None:
1067
+ raise ValueError(
1068
+ "cannot modify file attachments of a library that has "
1069
+ "no file path (linked files are stored relative to the "
1070
+ "library's .bib file); save the library first"
1071
+ )
1072
+ return Path(self._path).resolve().parent
1073
+
1074
+ def _resolve_file_arg(self, filename, *, must_exist):
1075
+ """Resolve `filename` (a new file to be attached) to an
1076
+ absolute path, interpreting a relative path against both the
1077
+ library directory and the current working directory (see
1078
+ {any}`add_file` for the exact rules)."""
1079
+ base_dir = self._files_base_dir()
1080
+ path = Path(filename)
1081
+ if path.is_absolute():
1082
+ if must_exist and not path.exists():
1083
+ raise FileNotFoundError(f"No such file: {path}")
1084
+ return path
1085
+ lib_candidate = base_dir / path
1086
+ cwd_candidate = Path.cwd() / path
1087
+ if lib_candidate.resolve() == cwd_candidate.resolve():
1088
+ if must_exist and not lib_candidate.exists():
1089
+ raise FileNotFoundError(f"No such file: {lib_candidate}")
1090
+ return lib_candidate
1091
+ lib_exists = lib_candidate.exists()
1092
+ cwd_exists = cwd_candidate.exists()
1093
+ if lib_exists and cwd_exists:
1094
+ raise ValueError(
1095
+ f"ambiguous filename {str(filename)!r}: exists both "
1096
+ f"relative to the library ({lib_candidate}) and to "
1097
+ f"the current working directory ({cwd_candidate}); "
1098
+ "pass an absolute path"
1099
+ )
1100
+ if cwd_exists:
1101
+ return cwd_candidate
1102
+ if must_exist and not lib_exists:
1103
+ raise FileNotFoundError(
1104
+ f"No such file: {str(filename)!r} (checked relative "
1105
+ f"to the library, {lib_candidate}, and to the current "
1106
+ f"working directory, {cwd_candidate})"
1107
+ )
1108
+ return lib_candidate
1109
+
1110
+ def _match_attachment(self, entry, filename):
1111
+ """The path in `entry.files` that `filename` refers to,
1112
+ resolving a relative `filename` against both the library
1113
+ directory and the current working directory. Raises
1114
+ `ValueError` if `filename` matches no attachment of `entry`,
1115
+ or ambiguously matches two different ones."""
1116
+ base_dir = self._files_base_dir()
1117
+ by_abs_path = {}
1118
+ for rel_path in entry.files:
1119
+ by_abs_path.setdefault((base_dir / rel_path).resolve(), rel_path)
1120
+ path = Path(filename)
1121
+ if path.is_absolute():
1122
+ candidates = [path.resolve()]
1123
+ else:
1124
+ candidates = [(base_dir / path).resolve()]
1125
+ cwd_candidate = (Path.cwd() / path).resolve()
1126
+ if cwd_candidate != candidates[0]:
1127
+ candidates.append(cwd_candidate)
1128
+ matches = {
1129
+ by_abs_path[candidate]
1130
+ for candidate in candidates
1131
+ if candidate in by_abs_path
1132
+ }
1133
+ if len(matches) > 1:
1134
+ raise ValueError(
1135
+ f"ambiguous filename {str(filename)!r}: matches "
1136
+ f"multiple linked files of entry {entry.key!r} "
1137
+ f"({sorted(matches)}); pass an absolute path"
1138
+ )
1139
+ if not matches:
1140
+ raise ValueError(
1141
+ f"{str(filename)!r} is not linked from entry " f"{entry.key!r}"
1142
+ )
1143
+ return matches.pop()
1144
+
1145
+ def _remove_linked_file(self, rel_path):
1146
+ """Delete the linked file `rel_path` (relative to the library
1147
+ directory) from the filesystem, unless it is still linked
1148
+ from any entry, in which case a `UserWarning` is emitted
1149
+ instead. A file already absent from disk is silently
1150
+ ignored."""
1151
+ base_dir = self._files_base_dir()
1152
+ abs_path = (base_dir / rel_path).resolve()
1153
+ still_linked = [
1154
+ entry.key
1155
+ for entry in self._entries.values()
1156
+ if any(
1157
+ (base_dir / rel).resolve() == abs_path for rel in entry.files
1158
+ )
1159
+ ]
1160
+ if still_linked:
1161
+ warnings.warn(
1162
+ f"not removing {rel_path!r}: still linked from "
1163
+ f"entries {still_linked}",
1164
+ UserWarning,
1165
+ stacklevel=3,
1166
+ )
1167
+ return
1168
+ if abs_path.exists():
1169
+ _delete_file(abs_path)
1170
+
1171
+ def add_file(self, key, filename, *, check_that_file_exists=True):
1172
+ """Attach the file `filename` to entry `key`, appending a
1173
+ `bdsk-file-N` field (see
1174
+ {any}`bibdeskparser.entry.Entry.files`).
1175
+
1176
+ * `key`: citation key of the entry (raises `KeyError` if not
1177
+ in the library).
1178
+ * `filename`: path of the file to attach: absolute, or
1179
+ relative to the library's `.bib` directory or to the
1180
+ current working directory. A relative path that exists in
1181
+ *both* places (and they are not the same place) raises
1182
+ `ValueError`; pass an absolute path to disambiguate.
1183
+ * `check_that_file_exists`: if `True` (the default), raise
1184
+ `FileNotFoundError` if `filename` does not exist. If
1185
+ `False`, a nonexistent `filename` is recorded as-is,
1186
+ interpreted relative to the library directory, as a
1187
+ path-only attachment without a macOS bookmark (useful,
1188
+ e.g., for a file that only exists on another machine).
1189
+
1190
+ The stored path is always relative to the library directory.
1191
+ For a file that exists, a macOS bookmark is generated
1192
+ automatically (requires the `bibdeskparser[macos]` extra) so
1193
+ BibDesk can still find the file if it is later moved or
1194
+ renamed; where a bookmark can't be created, the file is
1195
+ attached by path only, with a `UserWarning`.
1196
+
1197
+ Raises `ValueError` if the file is already attached to the
1198
+ entry, or if this library has no file path yet (a
1199
+ from-scratch library must be saved first, so that relative
1200
+ paths are well-defined).
1201
+ """
1202
+ entry = self._entries[key]
1203
+ base_dir = self._files_base_dir()
1204
+ path = self._resolve_file_arg(
1205
+ filename, must_exist=check_that_file_exists
1206
+ )
1207
+ bdsk_file = BibDeskFile(
1208
+ path, relative_to=base_dir, must_exist=check_that_file_exists
1209
+ )
1210
+ if bdsk_file.relative_path in entry.files:
1211
+ raise ValueError(
1212
+ f"{bdsk_file.relative_path!r} is already attached to "
1213
+ f"entry {key!r}"
1214
+ )
1215
+ # pylint: disable=protected-access
1216
+ entry._set_files(entry._file_objects() + [bdsk_file])
1217
+
1218
+ def replace_file(
1219
+ self,
1220
+ key,
1221
+ old_filename,
1222
+ new_filename,
1223
+ *,
1224
+ remove,
1225
+ check_that_file_exists=True,
1226
+ ):
1227
+ """Replace entry `key`'s attached file `old_filename` with
1228
+ `new_filename`, keeping its position in
1229
+ {any}`bibdeskparser.entry.Entry.files`.
1230
+
1231
+ * `key`: citation key of the entry (raises `KeyError` if not
1232
+ in the library).
1233
+ * `old_filename`: the attachment to replace; must match one
1234
+ of the entry's linked files (else `ValueError`). A relative
1235
+ path is matched against both its library-relative and its
1236
+ CWD-relative interpretation (`ValueError` if that is
1237
+ ambiguous).
1238
+ * `new_filename`: the file to attach in its place, resolved
1239
+ exactly like the `filename` argument of {any}`add_file`
1240
+ (raises `ValueError` if it is already attached to the
1241
+ entry).
1242
+ * `remove` (mandatory keyword argument): whether to also
1243
+ delete the old file from the filesystem -- to the Trash on
1244
+ macOS (with the `bibdeskparser[macos]` extra installed),
1245
+ else permanently. The old file is *not* deleted (a
1246
+ `UserWarning` instead) if it is still linked from any
1247
+ entry; a file already absent from disk is silently ignored.
1248
+ * `check_that_file_exists`: as in {any}`add_file`, for
1249
+ `new_filename`.
1250
+
1251
+ Raises `ValueError` if this library has no file path yet
1252
+ (see {any}`add_file`).
1253
+ """
1254
+ entry = self._entries[key]
1255
+ base_dir = self._files_base_dir()
1256
+ old_rel = self._match_attachment(entry, old_filename)
1257
+ path = self._resolve_file_arg(
1258
+ new_filename, must_exist=check_that_file_exists
1259
+ )
1260
+ new_file = BibDeskFile(
1261
+ path, relative_to=base_dir, must_exist=check_that_file_exists
1262
+ )
1263
+ files = entry._file_objects() # pylint: disable=protected-access
1264
+ if any(
1265
+ f.relative_path == new_file.relative_path
1266
+ for f in files
1267
+ if f.relative_path != old_rel
1268
+ ):
1269
+ raise ValueError(
1270
+ f"{new_file.relative_path!r} is already attached to "
1271
+ f"entry {key!r}"
1272
+ )
1273
+ # pylint: disable=protected-access
1274
+ entry._set_files(
1275
+ [new_file if f.relative_path == old_rel else f for f in files]
1276
+ )
1277
+ if remove:
1278
+ self._remove_linked_file(old_rel)
1279
+
1280
+ def unlink_file(self, key, filename, *, remove):
1281
+ """Remove the file `filename` from entry `key`'s attachments
1282
+ ({any}`bibdeskparser.entry.Entry.files`).
1283
+
1284
+ * `key`: citation key of the entry (raises `KeyError` if not
1285
+ in the library).
1286
+ * `filename`: the attachment to unlink, matched like the
1287
+ `old_filename` argument of {any}`replace_file`.
1288
+ * `remove` (mandatory keyword argument): whether to also
1289
+ delete the file from the filesystem, with the exact
1290
+ semantics of {any}`replace_file`'s `remove`.
1291
+
1292
+ Raises `ValueError` if this library has no file path yet
1293
+ (see {any}`add_file`).
1294
+ """
1295
+ entry = self._entries[key]
1296
+ rel_path = self._match_attachment(entry, filename)
1297
+ # pylint: disable=protected-access
1298
+ entry._set_files(
1299
+ [f for f in entry._file_objects() if f.relative_path != rel_path]
1300
+ )
1301
+ if remove:
1302
+ self._remove_linked_file(rel_path)
1303
+
1304
+ def rename_file(self, key, old_filename, new_filename):
1305
+ """Rename (or move) entry `key`'s attached file
1306
+ `old_filename` to `new_filename` on the filesystem, updating
1307
+ *every* entry that links the file (each with a fresh macOS
1308
+ bookmark, where available).
1309
+
1310
+ * `key`: citation key of an entry linking the file (raises
1311
+ `KeyError` if not in the library). Other entries linking
1312
+ the same file are updated as well, so their
1313
+ {any}`bibdeskparser.entry.Entry.files` never go stale.
1314
+ * `old_filename`: the attachment to rename, matched like the
1315
+ `old_filename` argument of {any}`replace_file`; the file
1316
+ must exist on disk (else `FileNotFoundError`).
1317
+ * `new_filename`: the new name. A bare filename (no directory
1318
+ component) renames the file within its current directory;
1319
+ a relative path with a directory component is interpreted
1320
+ relative to the library's `.bib` directory; an absolute
1321
+ path is used as-is. Raises `FileExistsError` if the target
1322
+ already exists.
1323
+
1324
+ Raises `ValueError` if this library has no file path yet
1325
+ (see {any}`add_file`).
1326
+ """
1327
+ entry = self._entries[key]
1328
+ base_dir = self._files_base_dir()
1329
+ old_rel = self._match_attachment(entry, old_filename)
1330
+ old_path = (base_dir / old_rel).resolve()
1331
+ if not old_path.exists():
1332
+ raise FileNotFoundError(f"No such file: {old_path}")
1333
+ new_path = Path(new_filename)
1334
+ if not new_path.is_absolute():
1335
+ if new_path.parent == Path("."):
1336
+ new_path = old_path.parent / new_path
1337
+ else:
1338
+ new_path = base_dir / new_path
1339
+ if new_path.exists():
1340
+ raise FileExistsError(f"File already exists: {new_path}")
1341
+ os.rename(old_path, new_path)
1342
+ new_file = BibDeskFile(new_path, relative_to=base_dir)
1343
+ for other in self._entries.values():
1344
+ # pylint: disable=protected-access
1345
+ files = other._file_objects()
1346
+ changed = False
1347
+ for i, bdsk_file in enumerate(files):
1348
+ resolved = (base_dir / bdsk_file.relative_path).resolve()
1349
+ if resolved == old_path:
1350
+ files[i] = new_file
1351
+ changed = True
1352
+ if changed:
1353
+ other._set_files(files)
1354
+
1355
+ # -- saving ------------------------------------------------------ #
1356
+
1357
+ def _validate_for_save(self, path):
1358
+ """Raise/warn as documented by {any}`save`."""
1359
+ undefined = set()
1360
+ strings_dict = self._library.strings_dict
1361
+ for entry in self._entries.values():
1362
+ for _, value in _bare_macro_fields(entry):
1363
+ if (
1364
+ is_valid_macro_name(value, normalized=True)
1365
+ and value not in strings_dict
1366
+ ):
1367
+ undefined.add(value)
1368
+ if undefined:
1369
+ raise ValueError(
1370
+ "undefined macro(s) referenced by one or more entries: "
1371
+ f"{sorted(undefined)}"
1372
+ )
1373
+
1374
+ base_dir = Path(path).parent
1375
+ for entry in self._entries.values():
1376
+ for rel_path in entry.files:
1377
+ if not (base_dir / rel_path).exists():
1378
+ warnings.warn(
1379
+ f"{entry.key}: linked file does not exist: "
1380
+ f"{rel_path!r}",
1381
+ UserWarning,
1382
+ stacklevel=3,
1383
+ )
1384
+
1385
+ def save(self, path=None, force=False):
1386
+ """Write the library to `path` (default: the path it was
1387
+ loaded from).
1388
+
1389
+ * `path`: destination `.bib` file. Defaults to the path this
1390
+ library was loaded from (or last saved to); raises
1391
+ {any}`ValueError` if there is none (a from-scratch library
1392
+ that has never been given a path).
1393
+ * `force`: bypass the {any}`StaleFileError` check (see below).
1394
+
1395
+ If the library was not modified since it was loaded (no entry
1396
+ is {any}`bibdeskparser.entry.Entry.dirty`, {any}`groups` was
1397
+ not mutated, and no entries/strings were added or removed),
1398
+ the file is written byte-identical to how it was parsed (or,
1399
+ for a from-scratch library, is simply rendered), and the
1400
+ header timestamp is *not* touched. Otherwise: the header
1401
+ timestamp is updated (synthesizing a header if the library did
1402
+ not already have one), the static-groups `@comment` block is
1403
+ re-rendered from the current {any}`groups` (synthesizing the
1404
+ block if the library did not have one but now has groups),
1405
+ dirty/new entries have their fields reordered into BibDesk's
1406
+ canonical order, and {any}`timestamp` is updated.
1407
+
1408
+ Before writing, raises {any}`ValueError` if any entry
1409
+ references an undefined `@string` macro, and warns (without
1410
+ raising) about any linked file
1411
+ ({any}`bibdeskparser.entry.Entry.files`) that does not exist
1412
+ relative to `path`'s directory (such files may legitimately
1413
+ live only on another machine).
1414
+
1415
+ Raises {any}`StaleFileError` if `path` already exists and its
1416
+ header timestamp is strictly newer than {any}`timestamp`
1417
+ (i.e., it was saved -- by BibDesk or otherwise -- after this
1418
+ library was loaded or last saved), unless `force=True`.
1419
+ """
1420
+ path = path if path is not None else self._path
1421
+ if path is None:
1422
+ raise ValueError(
1423
+ "no path given and this library was not loaded from a file"
1424
+ )
1425
+ path = Path(path)
1426
+
1427
+ if path.exists():
1428
+ on_disk_timestamp = peek_timestamp(path)
1429
+ if (
1430
+ on_disk_timestamp is not None
1431
+ and self._timestamp is not None
1432
+ and on_disk_timestamp > self._timestamp
1433
+ and not force
1434
+ ):
1435
+ raise StaleFileError(
1436
+ f"{path} has a newer save timestamp "
1437
+ f"({on_disk_timestamp}) than this library "
1438
+ f"({self._timestamp}); it appears to have been "
1439
+ "modified on disk since it was loaded. Pass "
1440
+ "force=True to overwrite anyway."
1441
+ )
1442
+
1443
+ self._validate_for_save(path)
1444
+
1445
+ pristine = not self._modified and not any(
1446
+ entry.dirty for entry in self._entries.values()
1447
+ )
1448
+
1449
+ if pristine:
1450
+ path.write_text(render_library(self._library), encoding="utf-8")
1451
+ self._path = path
1452
+ return
1453
+
1454
+ # Truncated to whole seconds: that is all the header format
1455
+ # (see `bibdeskparser.header`) can represent, so keeping
1456
+ # sub-second precision in memory would make `self._timestamp`
1457
+ # diverge from what a subsequent load of this same file would
1458
+ # report.
1459
+ now = datetime.datetime.now().astimezone().replace(microsecond=0)
1460
+
1461
+ if self._header_block is not None:
1462
+ self._header_block.comment = update_header(
1463
+ self._header_block.comment, now
1464
+ )
1465
+ else:
1466
+ header_block = ImplicitComment(make_header(self._creator, now))
1467
+ self._library = bibtexparser.Library(
1468
+ blocks=[header_block] + self._library.blocks
1469
+ )
1470
+ self._header_block = header_block
1471
+
1472
+ # Re-render the groups comment unconditionally: when the group
1473
+ # data was not touched, this is byte-identical to the parsed
1474
+ # comment (parse/render round-trip exactly).
1475
+ if self._groups_block is not None:
1476
+ self._groups_block.comment = render_static_groups(self._group_data)
1477
+ elif self._group_data:
1478
+ groups_block = ExplicitComment(
1479
+ render_static_groups(self._group_data)
1480
+ )
1481
+ self._library.add([groups_block], fail_on_duplicate_key=False)
1482
+ self._groups_block = groups_block
1483
+
1484
+ for entry in self._entries.values():
1485
+ if entry.dirty:
1486
+ entry._entry.fields = (
1487
+ bibdesk_field_order( # pylint: disable=protected-access
1488
+ entry._entry.fields # pylint: disable=protected-access
1489
+ )
1490
+ )
1491
+
1492
+ self._timestamp = now
1493
+ path.write_text(render_library(self._library), encoding="utf-8")
1494
+
1495
+ self._path = path
1496
+ self._modified = False
1497
+ for entry in self._entries.values():
1498
+ # `Entry` intentionally has no public "mark clean" method
1499
+ # (only its owning `Library` may reset `dirty`, once the
1500
+ # entry's current state has actually been written to
1501
+ # disk). Both modules are part of the same package, so
1502
+ # this reaches across a module boundary but not a public
1503
+ # API boundary.
1504
+ entry._dirty = False # pylint: disable=protected-access
1505
+
1506
+ # -- render/export/edit ----------------------------------------------#
1507
+
1508
+ def render(self, *keys, format="markdown", style="default"):
1509
+ # pylint: disable=redefined-builtin
1510
+ """Render a bibliography for the entries with the given
1511
+ citation `keys` (at least one required).
1512
+
1513
+ `format` is one of `"markdown"`, `"tex"`, or `"html"`. `style`
1514
+ controls the layout of the citations relative to one another:
1515
+
1516
+ * `"paragraphs"`: each citation is a paragraph, separated by a
1517
+ blank line (for `"html"`, wrapped in `<p>...</p>` instead).
1518
+ * `"numbered list"`: a numbered list (`markdown` `1.`, `2.`;
1519
+ `tex` `enumerate`; `html` `<ol>`).
1520
+ * `"itemized list"`: a bulleted list (`markdown` `-`; `tex`
1521
+ `itemize`; `html` `<ul>`).
1522
+ * `"default"` (the default): like `"paragraphs"`, except a
1523
+ single `"html"` citation is not wrapped in `<p>...</p>`.
1524
+ """
1525
+ return render_entries(
1526
+ [self[key] for key in keys], format=format, style=style
1527
+ )
1528
+
1529
+ def export(self, *keys, format="default", outfile=None):
1530
+ # pylint: disable=redefined-builtin
1531
+ """Export the entries with the given citation `keys` (at
1532
+ least one required) as bibtex text.
1533
+
1534
+ `format` is one of `"default"` (Unicode, as displayed by the
1535
+ `dict` interface), `"raw"` (the literal, possibly TeX-encoded,
1536
+ stored values), or `"minimal"` (only the fields needed to
1537
+ typeset a bibliography). For `"default"`/`"raw"`, the
1538
+ `@string` macro definitions needed to make the selected
1539
+ entries self-contained are included.
1540
+ """
1541
+ return export_entries(
1542
+ [self[key] for key in keys],
1543
+ strings=dict(self.strings),
1544
+ format=format,
1545
+ outfile=outfile,
1546
+ )
1547
+
1548
+ def edit(self, *keys, format="default", editor=None):
1549
+ # pylint: disable=redefined-builtin
1550
+ """Edit the entries with the given citation `keys` (at least
1551
+ one required) together, in `editor` (or `$EDITOR`), merging
1552
+ changes back into them (and into `.strings`) in place.
1553
+ """
1554
+ editing.edit_entries(
1555
+ [self[key] for key in keys],
1556
+ library=self,
1557
+ format=format,
1558
+ editor=editor,
1559
+ )
1560
+
1561
+ def edit_strings(self, editor=None):
1562
+ """Edit `.strings` in `editor` (or `$EDITOR`), merging changes
1563
+ back in place.
1564
+ """
1565
+ editing.edit_strings(self, editor=editor)