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
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
"""Parser for [BibDesk](https://bibdesk.sourceforge.io) `.bib` files.
|
|
2
|
+
|
|
3
|
+
This package builds on [bibtexparser](
|
|
4
|
+
https://bibtexparser.readthedocs.io/en/main/) to read and write BibTeX
|
|
5
|
+
databases as maintained by the BibDesk application.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from importlib.metadata import version
|
|
9
|
+
|
|
10
|
+
from .entry import Entry, Value
|
|
11
|
+
from .library import Library, StaleFileError
|
|
12
|
+
|
|
13
|
+
__version__ = version("bibdeskparser")
|
|
14
|
+
|
|
15
|
+
# All members whose name does not start with an underscore must be listed
|
|
16
|
+
# either in __all__ or in __private__
|
|
17
|
+
__all__ = [
|
|
18
|
+
"Library",
|
|
19
|
+
"Entry",
|
|
20
|
+
"Value",
|
|
21
|
+
"StaleFileError",
|
|
22
|
+
]
|
|
23
|
+
__private__ = []
|
|
@@ -0,0 +1,351 @@
|
|
|
1
|
+
"""Support for BibDesk local file attachments (`bdsk-file-N` fields).
|
|
2
|
+
|
|
3
|
+
BibDesk links local files to an entry via `bdsk-file-1`, `bdsk-file-2`,
|
|
4
|
+
etc. Each field contains a base64 payload that encodes
|
|
5
|
+
an Apple *binary plist* dictionary with a `relativePath` string and,
|
|
6
|
+
optionally, `bookmark` or `aliasData` bytes. The `BibDeskFile` class
|
|
7
|
+
decodes and re-encodes these values byte-exactly; it is used
|
|
8
|
+
internally by `Entry.files` (see `entry.py`), which is the public way
|
|
9
|
+
to read or set an entry's linked files.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
import base64
|
|
13
|
+
import os
|
|
14
|
+
import plistlib
|
|
15
|
+
import struct
|
|
16
|
+
import sys
|
|
17
|
+
import warnings
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
from typing import Optional
|
|
20
|
+
|
|
21
|
+
# All members whose name does not start with an underscore must be listed
|
|
22
|
+
# either in __all__ or in __private__
|
|
23
|
+
__all__ = []
|
|
24
|
+
__private__ = ["BibDeskFile", "bookmark_for_path"]
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class BibDeskFile:
|
|
28
|
+
"""Decoded BibDesk local file attachment (`bdsk-file-N` field).
|
|
29
|
+
|
|
30
|
+
```python
|
|
31
|
+
BibDeskFile(
|
|
32
|
+
path,
|
|
33
|
+
bookmark=None,
|
|
34
|
+
alias_data=None,
|
|
35
|
+
relative_to=None,
|
|
36
|
+
must_exist=True,
|
|
37
|
+
)
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
A `bdsk-file-N` field value in a `.bib` file written by BibDesk is
|
|
41
|
+
`{` + base64 + `}`, where the base64 payload is an Apple binary
|
|
42
|
+
plist encoding a dictionary with a `relativePath` string and
|
|
43
|
+
(usually) a `bookmark` data blob (or, in legacy files, `aliasData`).
|
|
44
|
+
|
|
45
|
+
Arguments:
|
|
46
|
+
|
|
47
|
+
* `path`: Path to the attached file (absolute, or relative to the
|
|
48
|
+
current working directory).
|
|
49
|
+
* `bookmark`: macOS URL bookmark bytes, or `None`.
|
|
50
|
+
* `alias_data`: Legacy HFS alias bytes, or `None`.
|
|
51
|
+
* `relative_to`: `.bib` file path or its directory (absolute, or
|
|
52
|
+
relative to the current working directory); defaults to the
|
|
53
|
+
current working directory.
|
|
54
|
+
* `must_exist`: whether to require `path` to point to an existing
|
|
55
|
+
file (raising {py:class}`FileNotFoundError` otherwise). With
|
|
56
|
+
`must_exist=False`, a nonexistent `path` yields a path-only
|
|
57
|
+
attachment, without a bookmark and without any warning.
|
|
58
|
+
|
|
59
|
+
At most one of `bookmark` and `alias_data` may be given. If both are
|
|
60
|
+
`None` and the file exists, a bookmark is created automatically on
|
|
61
|
+
macOS (requires `pyobjc-framework-Cocoa`). Where a bookmark cannot
|
|
62
|
+
be created, the instance falls back to a path-only attachment with
|
|
63
|
+
a {py:class}`UserWarning` (BibDesk will generate a bookmark on its
|
|
64
|
+
next save).
|
|
65
|
+
|
|
66
|
+
Use {py:meth}`from_field_value` and {py:meth}`to_field_value` to
|
|
67
|
+
convert from and to the string value of a `bdsk-file-N` field:
|
|
68
|
+
|
|
69
|
+
```python
|
|
70
|
+
>>> from bibdeskparser.bdskfile import BibDeskFile
|
|
71
|
+
>>> value = (
|
|
72
|
+
... "{YnBsaXN0MDDRAQJccmVsYXRpdmVQYXRoWXBhcGVyLnBkZggLG"
|
|
73
|
+
... "AAAAAAAAAEBAAAAAAAAAAMAAAAAAAAAAAAAAAAAAAAi}"
|
|
74
|
+
... )
|
|
75
|
+
>>> bdsk_file = BibDeskFile.from_field_value(value)
|
|
76
|
+
>>> bdsk_file
|
|
77
|
+
BibDeskFile("paper.pdf")
|
|
78
|
+
>>> bdsk_file.to_field_value() == value
|
|
79
|
+
True
|
|
80
|
+
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
Attributes:
|
|
84
|
+
|
|
85
|
+
* `relative_path`: Path of the file relative to the `.bib`
|
|
86
|
+
directory, e.g. `Smith2023.pdf`, `Subdir/Smith2023.pdf`, or
|
|
87
|
+
`../GoodReader/Smith2023.pdf`. BibDesk's primary locator: it is
|
|
88
|
+
tried first when resolving the attachment (and is all that is
|
|
89
|
+
available on a different machine).
|
|
90
|
+
* `bookmark`: macOS URL bookmark
|
|
91
|
+
(`NSURL bookmarkDataWithOptions:0`), written by default by
|
|
92
|
+
modern BibDesk (since ~2012). The fallback locator when
|
|
93
|
+
`relative_path` no longer resolves: it tracks the file by
|
|
94
|
+
inode, so it survives renames and moves within the same volume,
|
|
95
|
+
and BibDesk repairs the stored path from it on its next save.
|
|
96
|
+
`None` for path-only or legacy alias entries.
|
|
97
|
+
* `alias_data`: Legacy HFS Resource Manager alias (`FSNewAlias()`),
|
|
98
|
+
present in `.bib` files saved by older BibDesk versions or with
|
|
99
|
+
`BDSKSaveLinkedFilesAsAliasKey=YES`. `None` in modern files.
|
|
100
|
+
Read-only in practice: keep the existing value when
|
|
101
|
+
round-tripping; use `bookmark` for new attachments.
|
|
102
|
+
|
|
103
|
+
The underlying plist uses the keys `relativePath`, `bookmark`, and
|
|
104
|
+
`aliasData`.
|
|
105
|
+
"""
|
|
106
|
+
|
|
107
|
+
def __init__(
|
|
108
|
+
self,
|
|
109
|
+
path: "str | Path",
|
|
110
|
+
bookmark: Optional[bytes] = None,
|
|
111
|
+
alias_data: Optional[bytes] = None,
|
|
112
|
+
relative_to: "str | Path | None" = None,
|
|
113
|
+
*,
|
|
114
|
+
must_exist: bool = True,
|
|
115
|
+
) -> None:
|
|
116
|
+
if bookmark is not None and alias_data is not None:
|
|
117
|
+
raise ValueError(
|
|
118
|
+
"at most one of bookmark and alias_data may be set"
|
|
119
|
+
)
|
|
120
|
+
if relative_to is not None:
|
|
121
|
+
base = Path(relative_to).resolve()
|
|
122
|
+
else:
|
|
123
|
+
base = Path.cwd()
|
|
124
|
+
if base.is_file():
|
|
125
|
+
base = base.parent
|
|
126
|
+
abs_path = Path(path).resolve()
|
|
127
|
+
if bookmark is None and alias_data is None:
|
|
128
|
+
if not abs_path.exists():
|
|
129
|
+
if must_exist:
|
|
130
|
+
raise FileNotFoundError(f"No such file: {abs_path}")
|
|
131
|
+
# An intentional path-only attachment: no bookmark can
|
|
132
|
+
# be created for a file that does not exist (BibDesk
|
|
133
|
+
# adds one on its next save, once the file appears).
|
|
134
|
+
else:
|
|
135
|
+
if sys.platform == "darwin":
|
|
136
|
+
try:
|
|
137
|
+
bookmark = bookmark_for_path(str(abs_path))
|
|
138
|
+
except (ImportError, OSError):
|
|
139
|
+
pass
|
|
140
|
+
if bookmark is None:
|
|
141
|
+
warnings.warn(
|
|
142
|
+
f"Could not create a macOS bookmark for "
|
|
143
|
+
f"{abs_path}; falling back to a path-only "
|
|
144
|
+
"attachment",
|
|
145
|
+
UserWarning,
|
|
146
|
+
stacklevel=2,
|
|
147
|
+
)
|
|
148
|
+
if abs_path.drive.lower() != base.drive.lower():
|
|
149
|
+
raise ValueError(
|
|
150
|
+
f"Cannot attach {abs_path}: it is on a different drive "
|
|
151
|
+
f"than {base} and so has no relative path"
|
|
152
|
+
)
|
|
153
|
+
self._relative_path = Path(os.path.relpath(abs_path, base)).as_posix()
|
|
154
|
+
self._bookmark = bookmark
|
|
155
|
+
self._alias_data = alias_data
|
|
156
|
+
|
|
157
|
+
@property
|
|
158
|
+
def relative_path(self) -> str:
|
|
159
|
+
"""Path of the file relative to the `.bib` directory."""
|
|
160
|
+
return self._relative_path
|
|
161
|
+
|
|
162
|
+
@property
|
|
163
|
+
def bookmark(self) -> Optional[bytes]:
|
|
164
|
+
"""The macOS URL bookmark bytes, or `None`."""
|
|
165
|
+
return self._bookmark
|
|
166
|
+
|
|
167
|
+
@property
|
|
168
|
+
def alias_data(self) -> Optional[bytes]:
|
|
169
|
+
"""The legacy HFS alias bytes, or `None`."""
|
|
170
|
+
return self._alias_data
|
|
171
|
+
|
|
172
|
+
def __repr__(self) -> str:
|
|
173
|
+
return f'BibDeskFile("{self._relative_path}")'
|
|
174
|
+
|
|
175
|
+
def __eq__(self, other) -> bool:
|
|
176
|
+
if not isinstance(other, BibDeskFile):
|
|
177
|
+
return NotImplemented
|
|
178
|
+
return (
|
|
179
|
+
self._relative_path == other._relative_path
|
|
180
|
+
and self._bookmark == other._bookmark
|
|
181
|
+
and self._alias_data == other._alias_data
|
|
182
|
+
)
|
|
183
|
+
|
|
184
|
+
@classmethod
|
|
185
|
+
def from_field_value(cls, value: str) -> "BibDeskFile":
|
|
186
|
+
"""Parse a `{base64...}` field value from a `.bib` entry."""
|
|
187
|
+
if value.startswith("{") and value.endswith("}"):
|
|
188
|
+
inner = value[1:-1]
|
|
189
|
+
else:
|
|
190
|
+
inner = value
|
|
191
|
+
plist = plistlib.loads(base64.b64decode(inner))
|
|
192
|
+
obj = object.__new__(cls)
|
|
193
|
+
obj._relative_path = plist.get("relativePath") or ""
|
|
194
|
+
obj._bookmark = None
|
|
195
|
+
if "bookmark" in plist:
|
|
196
|
+
obj._bookmark = bytes(plist["bookmark"])
|
|
197
|
+
obj._alias_data = None
|
|
198
|
+
if "aliasData" in plist:
|
|
199
|
+
obj._alias_data = bytes(plist["aliasData"])
|
|
200
|
+
return obj
|
|
201
|
+
|
|
202
|
+
def to_field_value(self) -> str:
|
|
203
|
+
"""Encode to the `{base64...}` string for a `.bib` file."""
|
|
204
|
+
return "{" + base64.b64encode(self._to_plist_bytes()).decode() + "}"
|
|
205
|
+
|
|
206
|
+
def _to_plist_bytes(self) -> bytes:
|
|
207
|
+
"""Serialize as a binary plist matching Cocoa's byte layout.
|
|
208
|
+
|
|
209
|
+
`NSPropertyListSerialization` writes `relativePath` as the
|
|
210
|
+
first key (hash-table order, not alphabetical); we replicate
|
|
211
|
+
this so that unmodified entries produce bytes identical to the
|
|
212
|
+
original BibDesk output.
|
|
213
|
+
"""
|
|
214
|
+
path_key = _bplist_str("relativePath")
|
|
215
|
+
path_val = _bplist_str(self.relative_path)
|
|
216
|
+
|
|
217
|
+
if self.bookmark is not None or self.alias_data is not None:
|
|
218
|
+
if self.bookmark is not None:
|
|
219
|
+
data_key, data_val = "bookmark", self.bookmark
|
|
220
|
+
else:
|
|
221
|
+
data_key, data_val = "aliasData", self.alias_data
|
|
222
|
+
# 5 objects:
|
|
223
|
+
# 0=root-dict, 1=path-key, 2=data-key, 3=path-val, 4=data-val
|
|
224
|
+
root = bytes([0xD2, 0x01, 0x02, 0x03, 0x04])
|
|
225
|
+
objs = [
|
|
226
|
+
root,
|
|
227
|
+
path_key,
|
|
228
|
+
_bplist_str(data_key),
|
|
229
|
+
path_val,
|
|
230
|
+
_bplist_data(data_val),
|
|
231
|
+
]
|
|
232
|
+
else:
|
|
233
|
+
# Path-only: 3 objects: 0=root-dict, 1=path-key, 2=path-val
|
|
234
|
+
root = bytes([0xD1, 0x01, 0x02])
|
|
235
|
+
objs = [root, path_key, path_val]
|
|
236
|
+
|
|
237
|
+
return _bplist_assemble(objs)
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
def bookmark_for_path(path: str) -> bytes:
|
|
241
|
+
"""Create a macOS URL bookmark for a local file (macOS only).
|
|
242
|
+
|
|
243
|
+
Calls `-[NSURL bookmarkDataWithOptions:0
|
|
244
|
+
includingResourceValuesForKeys:nil relativeToURL:nil error:NULL]`
|
|
245
|
+
via pyobjc, replicating exactly the Cocoa API BibDesk uses
|
|
246
|
+
(`BDSKBookmarkLinkedFile.initWithURL:delegate:`). The resulting
|
|
247
|
+
bytes go into {py:attr}`BibDeskFile.bookmark`. The file must exist
|
|
248
|
+
on disk.
|
|
249
|
+
|
|
250
|
+
Requires macOS and `pyobjc-framework-Cocoa` (install
|
|
251
|
+
`bibdeskparser[macos]`). For cross-platform code, use
|
|
252
|
+
{py:class}`BibDeskFile` directly; it falls back to a path-only
|
|
253
|
+
entry where bookmarks are unavailable (BibDesk auto-creates the
|
|
254
|
+
bookmark on its next save).
|
|
255
|
+
|
|
256
|
+
Raises:
|
|
257
|
+
|
|
258
|
+
* {py:class}`NotImplementedError`: on platforms other than macOS.
|
|
259
|
+
* {py:class}`ImportError`: if pyobjc is not installed.
|
|
260
|
+
* {py:class}`OSError`: if Cocoa fails to create the bookmark
|
|
261
|
+
(e.g., because the file does not exist).
|
|
262
|
+
"""
|
|
263
|
+
if sys.platform != "darwin":
|
|
264
|
+
raise NotImplementedError(
|
|
265
|
+
"bookmark_for_path is macOS-only; use BibDeskFile() "
|
|
266
|
+
"for cross-platform code"
|
|
267
|
+
)
|
|
268
|
+
try:
|
|
269
|
+
# pylint: disable=import-outside-toplevel
|
|
270
|
+
from Foundation import NSURL # pyobjc-framework-Cocoa
|
|
271
|
+
except ImportError as exc:
|
|
272
|
+
raise ImportError(
|
|
273
|
+
"bookmark_for_path requires pyobjc-framework-Cocoa; "
|
|
274
|
+
"install the 'bibdeskparser[macos]' extra"
|
|
275
|
+
) from exc
|
|
276
|
+
url = NSURL.fileURLWithPath_(os.path.abspath(path))
|
|
277
|
+
# The selector name exceeds the line length limit, hence `getattr`.
|
|
278
|
+
make_bookmark = getattr(
|
|
279
|
+
url,
|
|
280
|
+
"bookmarkDataWithOptions_includingResourceValuesForKeys_"
|
|
281
|
+
"relativeToURL_error_",
|
|
282
|
+
)
|
|
283
|
+
data, err = make_bookmark(0, None, None, None)
|
|
284
|
+
if data is None:
|
|
285
|
+
raise OSError(f"Could not create bookmark for {path!r}: {err}")
|
|
286
|
+
return bytes(data)
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
def _bplist_count(n: int) -> bytes:
|
|
290
|
+
"""Encode an integer count for an extended-length object header."""
|
|
291
|
+
if n < 256:
|
|
292
|
+
return bytes([0x10, n])
|
|
293
|
+
if n < 65536:
|
|
294
|
+
return bytes([0x11]) + struct.pack(">H", n)
|
|
295
|
+
if n < 2**32:
|
|
296
|
+
return bytes([0x12]) + struct.pack(">I", n)
|
|
297
|
+
return bytes([0x13]) + struct.pack(">Q", n)
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
def _bplist_str(s: str) -> bytes:
|
|
301
|
+
"""Encode a string object for a binary plist."""
|
|
302
|
+
try:
|
|
303
|
+
b = s.encode("ascii")
|
|
304
|
+
n = len(b)
|
|
305
|
+
if n < 15:
|
|
306
|
+
marker = bytes([0x50 | n])
|
|
307
|
+
else:
|
|
308
|
+
marker = bytes([0x5F]) + _bplist_count(n)
|
|
309
|
+
return marker + b
|
|
310
|
+
except UnicodeEncodeError:
|
|
311
|
+
b = s.encode("utf-16-be")
|
|
312
|
+
n = len(s) # character count, not byte count
|
|
313
|
+
if n < 15:
|
|
314
|
+
marker = bytes([0x60 | n])
|
|
315
|
+
else:
|
|
316
|
+
marker = bytes([0x6F]) + _bplist_count(n)
|
|
317
|
+
return marker + b
|
|
318
|
+
|
|
319
|
+
|
|
320
|
+
def _bplist_data(b: bytes) -> bytes:
|
|
321
|
+
"""Encode a data object for a binary plist."""
|
|
322
|
+
n = len(b)
|
|
323
|
+
if n < 15:
|
|
324
|
+
marker = bytes([0x40 | n])
|
|
325
|
+
else:
|
|
326
|
+
marker = bytes([0x4F]) + _bplist_count(n)
|
|
327
|
+
return marker + b
|
|
328
|
+
|
|
329
|
+
|
|
330
|
+
def _bplist_assemble(objs: list) -> bytes:
|
|
331
|
+
"""Assemble a `bplist00` from a pre-ordered list of encoded objects."""
|
|
332
|
+
header = b"bplist00"
|
|
333
|
+
cur = len(header)
|
|
334
|
+
offsets = []
|
|
335
|
+
for obj in objs:
|
|
336
|
+
offsets.append(cur)
|
|
337
|
+
cur += len(obj)
|
|
338
|
+
|
|
339
|
+
ot_start = cur
|
|
340
|
+
if ot_start < 256:
|
|
341
|
+
off_size, off_fmt = 1, ">B"
|
|
342
|
+
elif ot_start < 65536:
|
|
343
|
+
off_size, off_fmt = 2, ">H"
|
|
344
|
+
elif ot_start < 2**32:
|
|
345
|
+
off_size, off_fmt = 4, ">I"
|
|
346
|
+
else:
|
|
347
|
+
off_size, off_fmt = 8, ">Q"
|
|
348
|
+
|
|
349
|
+
ot = b"".join(struct.pack(off_fmt, o) for o in offsets)
|
|
350
|
+
trailer = struct.pack(">5xBBBQQQ", 0, off_size, 1, len(objs), 0, ot_start)
|
|
351
|
+
return header + b"".join(objs) + ot + trailer
|