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,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