bibdeskparser 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- bibdeskparser/__init__.py +23 -0
- bibdeskparser/bdskfile.py +351 -0
- bibdeskparser/editing.py +673 -0
- bibdeskparser/entry.py +619 -0
- bibdeskparser/exporting.py +331 -0
- bibdeskparser/groups.py +119 -0
- bibdeskparser/header.py +236 -0
- bibdeskparser/library.py +1565 -0
- bibdeskparser/macros.py +109 -0
- bibdeskparser/middleware.py +174 -0
- bibdeskparser/names.py +96 -0
- bibdeskparser/render.py +666 -0
- bibdeskparser/texmap.py +467 -0
- bibdeskparser/writer.py +234 -0
- bibdeskparser-0.1.0.dist-info/METADATA +144 -0
- bibdeskparser-0.1.0.dist-info/RECORD +17 -0
- bibdeskparser-0.1.0.dist-info/WHEEL +4 -0
bibdeskparser/library.py
ADDED
|
@@ -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)
|