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,331 @@
1
+ r"""Serialization of `Entry` objects to bibtex text ("export" snippets).
2
+
3
+ Provides {any}`export_entries`, which renders one or more
4
+ {any}`bibdeskparser.entry.Entry` objects to a bibtex snippet in one of
5
+ three formats:
6
+
7
+ - `"default"`: every stored field except the BibDesk bookkeeping fields
8
+ (`date-added`/`date-modified`) and the `bdsk-*` fields -- i.e., the
9
+ `Entry` dict interface plus `keywords` -- Unicode and detexified,
10
+ plus the entry's `bdsk-file-N`/`bdsk-url-N` fields rendered as plain
11
+ relative paths/URLs. This is the format used to build the temporary
12
+ file shown to `$EDITOR` (see the later `editing.py` module): it is
13
+ re-parseable and its fields can be merged back into an `Entry` (the
14
+ `bdsk-file`/`bdsk-url` and `keywords` lines are merged via their
15
+ dedicated properties, not the dict interface, since those fields are
16
+ not part of it).
17
+ - `"raw"`: the same field set/order as `"default"`, but field values are
18
+ the literal TeX-encoded text as stored (i.e., exactly what would end
19
+ up in a `.bib` file on disk), rather than the decoded Unicode dict
20
+ values.
21
+ - `"minimal"`: a small, LaTeX-bibliography-oriented whitelist of fields
22
+ per entry type, covering `article`, `inproceedings`, `incollection`,
23
+ `mastersthesis`, and `phdthesis`, with a best-effort
24
+ `author, title, year` fallback for every other entry type.
25
+
26
+ For `"default"` and `"raw"`, an optional `strings` mapping of macro name
27
+ to Unicode value (e.g., `library.strings`) is used to prepend `@string`
28
+ definitions for every macro name that is actually referenced (as a bare
29
+ field value) by the selected entries, so the exported snippet is
30
+ self-contained. `"minimal"` never emits `@string` definitions. The
31
+ `keywords` field is always literal text, never a macro reference: its
32
+ value is always rendered braced, and it never pulls in an `@string`
33
+ definition, even when a keyword happens to match a macro name.
34
+
35
+ This module intentionally does not import `bibdeskparser.library`
36
+ (which imports this module), to avoid a circular dependency.
37
+
38
+ ```python
39
+ >>> from bibdeskparser.entry import Entry
40
+ >>> from bibdeskparser.exporting import export_entries
41
+ >>> entry = Entry(
42
+ ... "article",
43
+ ... "Key2024",
44
+ ... fields={"author": "Jane Doe", "title": "A Title", "year": "2024"},
45
+ ... )
46
+ >>> text = export_entries([entry])
47
+ >>> text == (
48
+ ... "@article{Key2024,\n"
49
+ ... "\tauthor = {Jane Doe},\n"
50
+ ... "\ttitle = {A Title},\n"
51
+ ... "\tyear = {2024}\n"
52
+ ... "}\n"
53
+ ... )
54
+ True
55
+
56
+ ```
57
+ """
58
+
59
+ import re
60
+
61
+ from .bdskfile import BibDeskFile
62
+ from .macros import is_valid_macro_name, normalize_macro_name
63
+ from .writer import bibdesk_field_order
64
+
65
+ __all__ = []
66
+
67
+ # All members whose name does not start with an underscore must be listed
68
+ # either in __all__ or in __private__
69
+ __private__ = ["export_entries"]
70
+
71
+ _FORMATS = frozenset(("default", "raw", "minimal"))
72
+
73
+ #: Non-normal field keys (never part of any exported format's field
74
+ #: list; `bdsk-file-N`/`bdsk-url-N` are handled separately).
75
+ _DATE_KEYS = frozenset(("date-added", "date-modified"))
76
+
77
+ _BDSK_FILE_RE = re.compile(r"bdsk-file-(\d+)$", re.IGNORECASE)
78
+ _BDSK_URL_RE = re.compile(r"bdsk-url-(\d+)$", re.IGNORECASE)
79
+
80
+ #: Per-entry-type field whitelist for the `"minimal"` format.
81
+ _MINIMAL_FIELDS = {
82
+ "article": (
83
+ "author",
84
+ "title",
85
+ "journal",
86
+ "year",
87
+ "doi",
88
+ "pages",
89
+ "volume",
90
+ "number",
91
+ ),
92
+ "inproceedings": (
93
+ "author",
94
+ "title",
95
+ "booktitle",
96
+ "year",
97
+ "doi",
98
+ "pages",
99
+ "address",
100
+ "editor",
101
+ ),
102
+ "incollection": (
103
+ "author",
104
+ "title",
105
+ "booktitle",
106
+ "year",
107
+ "doi",
108
+ "pages",
109
+ "editor",
110
+ "publisher",
111
+ "volume",
112
+ ),
113
+ "mastersthesis": ("author", "title", "school", "year"),
114
+ "phdthesis": ("author", "title", "school", "year"),
115
+ }
116
+
117
+ #: Best-effort fallback whitelist for any entry type not covered by
118
+ #: `_MINIMAL_FIELDS` (e.g. `book`, `misc`, `techreport`,
119
+ #: `unpublished`, ...). Not tuned per type -- just enough for a minimal
120
+ #: citation.
121
+ _MINIMAL_FALLBACK = ("author", "title", "year")
122
+
123
+
124
+ def _is_normal_field(key):
125
+ """Whether `key` is exported as a regular field: not a
126
+ `date-added`/`date-modified`/`bdsk-*` field. This includes
127
+ `keywords`, which `Entry`'s dict interface hides."""
128
+ lkey = key.lower()
129
+ return lkey not in _DATE_KEYS and not lkey.startswith("bdsk-")
130
+
131
+
132
+ def _normal_fields(entry):
133
+ """The "normal" `Field` objects of `entry`, in BibDesk's field
134
+ order."""
135
+ fields = [f for f in entry._entry.fields if _is_normal_field(f.key)]
136
+ return bibdesk_field_order(fields)
137
+
138
+
139
+ def _strip_braces(value):
140
+ """Strip one matching pair of enclosing `{...}`/`"..."`, if
141
+ present."""
142
+ if isinstance(value, str) and len(value) >= 2:
143
+ if (value[0] == "{" and value[-1] == "}") or (
144
+ value[0] == '"' and value[-1] == '"'
145
+ ):
146
+ return value[1:-1]
147
+ return value
148
+
149
+
150
+ def _render_value(value):
151
+ """Render a Unicode field value: bare if it's a normalized macro
152
+ name, else braced."""
153
+ if is_valid_macro_name(value, normalized=True):
154
+ return value
155
+ return "{" + value + "}"
156
+
157
+
158
+ def _bdsk_bodies(entry):
159
+ """`"name = value"` bodies (no trailing comma/newline) for
160
+ `entry`'s `bdsk-file-N`/`bdsk-url-N` fields, in numeric order:
161
+ files (as plain relative paths) before URLs (brace-stripped)."""
162
+ file_fields = []
163
+ url_fields = []
164
+ for field in entry._entry.fields:
165
+ match = _BDSK_FILE_RE.match(field.key)
166
+ if match:
167
+ file_fields.append((int(match.group(1)), field))
168
+ continue
169
+ match = _BDSK_URL_RE.match(field.key)
170
+ if match:
171
+ url_fields.append((int(match.group(1)), field))
172
+ file_fields.sort(key=lambda item: item[0])
173
+ url_fields.sort(key=lambda item: item[0])
174
+ bodies = []
175
+ for index, field in file_fields:
176
+ value = field.value
177
+ if isinstance(value, BibDeskFile):
178
+ path = value.relative_path
179
+ else:
180
+ path = BibDeskFile.from_field_value(value).relative_path
181
+ bodies.append(f"bdsk-file-{index} = {{{path}}}")
182
+ for index, field in url_fields:
183
+ url = _strip_braces(field.value)
184
+ bodies.append(f"bdsk-url-{index} = {{{url}}}")
185
+ return bodies
186
+
187
+
188
+ def _entry_bodies(entry, fmt):
189
+ """`"name = value"` bodies (no trailing comma/newline), in order,
190
+ for `entry` in the `"default"`/`"raw"` formats."""
191
+ bodies = []
192
+ for field in _normal_fields(entry):
193
+ name = field.key.lower()
194
+ if fmt == "raw":
195
+ bodies.append(f"{name} = {field.value}")
196
+ else:
197
+ # `entry._decode` rather than `entry[field.key]`: the
198
+ # decoding is identical, but the dict interface hides
199
+ # `keywords`, which is still exported.
200
+ value = entry._decode(field.key, field.value)
201
+ if name == "keywords":
202
+ # Keywords are always literal text, never a macro
203
+ # reference: a one-word macro-shaped keyword must not
204
+ # be rendered bare.
205
+ bodies.append(f"{name} = {{{value}}}")
206
+ else:
207
+ bodies.append(f"{name} = {_render_value(value)}")
208
+ bodies.extend(_bdsk_bodies(entry))
209
+ return bodies
210
+
211
+
212
+ def _render_entry(entry, fmt):
213
+ """Render a single `entry` (`"default"`/`"raw"`/`"minimal"`)."""
214
+ if fmt == "minimal":
215
+ return _render_minimal_entry(entry)
216
+ bodies = _entry_bodies(entry, fmt)
217
+ lines = [f"@{entry.entry_type}{{{entry.key},\n"]
218
+ last = len(bodies) - 1
219
+ for i, body in enumerate(bodies):
220
+ tail = "" if i == last else ","
221
+ lines.append(f"\t{body}{tail}\n")
222
+ lines.append("}\n")
223
+ return "".join(lines)
224
+
225
+
226
+ def _render_minimal_entry(entry):
227
+ """Render a single `entry` in the `"minimal"` format."""
228
+ whitelist = _MINIMAL_FIELDS.get(entry.entry_type.lower())
229
+ if whitelist is None:
230
+ whitelist = _MINIMAL_FALLBACK
231
+ lines = [f"@{entry.entry_type}{{{entry.key},\n"]
232
+ for name in whitelist:
233
+ value = entry.get(name)
234
+ if not value:
235
+ continue
236
+ lines.append(f" {name.capitalize()} = {_render_value(value)},\n")
237
+ lines.append("}\n")
238
+ return "".join(lines)
239
+
240
+
241
+ def _is_bare(value):
242
+ """Whether `value` is a string with no enclosing `{...}`/`"..."`
243
+ (a bare macro reference, per BibDesk's convention)."""
244
+ return isinstance(value, str) and bool(value) and value[0] not in '{"'
245
+
246
+
247
+ def _referenced_macro_names(entries):
248
+ """The set of (normalized) macro names referenced as a bare field
249
+ value by any entry in `entries`.
250
+
251
+ The `keywords` field never counts as a macro reference (keywords
252
+ are always literal text), so it never pulls in an `@string`
253
+ definition."""
254
+ names = set()
255
+ for entry in entries:
256
+ for field in entry._entry.fields:
257
+ value = field.value
258
+ if (
259
+ _is_bare(value)
260
+ and field.key.lower() != "keywords"
261
+ and is_valid_macro_name(value, normalized=False)
262
+ ):
263
+ names.add(normalize_macro_name(value))
264
+ return names
265
+
266
+
267
+ def _string_lines(entries, strings):
268
+ """`@string{name = {value}}` lines for every macro referenced by
269
+ `entries` that is defined in `strings`, sorted by name."""
270
+ if not strings:
271
+ return []
272
+ names = sorted(
273
+ name for name in _referenced_macro_names(entries) if name in strings
274
+ )
275
+ return [
276
+ "@string{" + name + " = {" + strings[name] + "}}\n" for name in names
277
+ ]
278
+
279
+
280
+ def export_entries(
281
+ entries, strings=None, format="default", outfile=None
282
+ ): # pylint: disable=redefined-builtin
283
+ """Serialize `entries` (an iterable of `Entry`) to bibtex text.
284
+
285
+ ```python
286
+ export_entries(entries, strings=None, format="default", outfile=None)
287
+ ```
288
+
289
+ Returns the text as a `str` if `outfile` is `None`, else writes it
290
+ (UTF-8) to `outfile` and returns `None`.
291
+
292
+ # Arguments
293
+
294
+ * `entries`: an iterable of {any}`bibdeskparser.entry.Entry`.
295
+ * `strings`: an optional `dict` mapping macro name to its Unicode
296
+ value (e.g. `library.strings`), used to prepend `@string`
297
+ definitions for every macro actually referenced by `entries`
298
+ (see the module docstring); ignored for `format="minimal"`. A
299
+ referenced macro not found in `strings` is silently skipped (not
300
+ an error -- this is a best-effort self-containment feature, not
301
+ a validator).
302
+ * `format`: one of `"default"`, `"raw"`, `"minimal"` (see the
303
+ module docstring); raises {any}`ValueError` for any other value.
304
+ * `outfile`: if given, a path (`str`/`pathlib.Path`) or an
305
+ already-open text file object (anything with a `write` method;
306
+ if already open, it is written to but not closed).
307
+
308
+ Multiple entries are separated by a single blank line; the returned
309
+ (or written) text always ends with exactly one trailing newline.
310
+ """
311
+ if format not in _FORMATS:
312
+ raise ValueError(
313
+ f"format must be one of {sorted(_FORMATS)}, not {format!r}"
314
+ )
315
+ entries = list(entries)
316
+ pieces = []
317
+ if format != "minimal":
318
+ string_lines = _string_lines(entries, strings)
319
+ if string_lines:
320
+ pieces.append("".join(string_lines))
321
+ pieces.append("\n")
322
+ pieces.append("\n".join(_render_entry(entry, format) for entry in entries))
323
+ text = "".join(pieces)
324
+ if outfile is None:
325
+ return text
326
+ if hasattr(outfile, "write"):
327
+ outfile.write(text)
328
+ return None
329
+ with open(outfile, "w", encoding="utf-8") as fh:
330
+ fh.write(text)
331
+ return None
@@ -0,0 +1,119 @@
1
+ """BibDesk static groups (the `BibDesk Static Groups` `@comment`).
2
+
3
+ BibDesk stores manually curated ("static") groups in an `@comment` block
4
+ at the end of the `.bib` file. The body of that comment is the literal
5
+ prefix `BibDesk Static Groups{`, a newline, an Apple XML plist, and a
6
+ closing `}`. The plist is an `<array>` of `<dict>`s with two keys:
7
+ `group name` (the group's display name) and `keys` (the citation keys of
8
+ the group's members, comma-joined). Python's `plistlib` reproduces
9
+ BibDesk's exact XML layout (tab indentation, entity escaping), so a
10
+ parsed block re-serializes byte-for-byte.
11
+
12
+ This module holds only pure (de)serialization functions. The decoded
13
+ form is a plain `dict` mapping each group name to a `tuple` of citation
14
+ keys, in file order; all group *state* (and every mutation of it) lives
15
+ in {any}`bibdeskparser.library.Library`.
16
+ """
17
+
18
+ import plistlib
19
+
20
+ __all__ = []
21
+
22
+ # All members whose name does not start with an underscore must be listed
23
+ # either in __all__ or in __private__
24
+ __private__ = [
25
+ "is_static_groups_comment",
26
+ "parse_static_groups",
27
+ "render_static_groups",
28
+ ]
29
+
30
+ _STATIC_GROUPS_HEAD = "BibDesk Static Groups{"
31
+
32
+
33
+ def is_static_groups_comment(comment):
34
+ """Check whether a comment body holds a static-groups block.
35
+
36
+ ```python
37
+ is_static_groups_comment(comment)
38
+ ```
39
+
40
+ Return `True` if `comment` (the string body of an `@comment` block,
41
+ e.g. the `comment` attribute of a bibtexparser `ExplicitComment`) is
42
+ a `BibDesk Static Groups` block that can be parsed with
43
+ `parse_static_groups`. Non-string input yields `False`.
44
+
45
+ ```python
46
+ >>> from bibdeskparser.groups import is_static_groups_comment
47
+ >>> is_static_groups_comment("BibDesk Static Groups{...}")
48
+ True
49
+ >>> is_static_groups_comment("some other comment")
50
+ False
51
+ >>> is_static_groups_comment(None)
52
+ False
53
+
54
+ ```
55
+ """
56
+ return isinstance(comment, str) and comment.startswith(_STATIC_GROUPS_HEAD)
57
+
58
+
59
+ def parse_static_groups(comment):
60
+ """Parse an `@comment` body of the form `BibDesk Static Groups{...}`.
61
+
62
+ ```python
63
+ parse_static_groups(comment)
64
+ ```
65
+
66
+ The `...` is a newline, an Apple XML plist, and a final newline; the
67
+ trailing `}` closes the head's opening brace. Return a `dict`
68
+ mapping each group name to a `tuple` of the group's citation keys,
69
+ with both the groups and the keys within each group in file order.
70
+
71
+ ```python
72
+ >>> from bibdeskparser.groups import (
73
+ ... parse_static_groups,
74
+ ... render_static_groups,
75
+ ... )
76
+ >>> comment = render_static_groups({"My Papers": ("key1", "key2")})
77
+ >>> parse_static_groups(comment)
78
+ {'My Papers': ('key1', 'key2')}
79
+
80
+ ```
81
+ """
82
+ # After the head comes `\\n<plist xml>\\n}`; drop the head and the
83
+ # trailing `}`. plistlib requires the `<?xml` declaration at offset
84
+ # 0, so strip the leading newline (render_static_groups re-adds it).
85
+ body = comment[len(_STATIC_GROUPS_HEAD) : comment.rindex("}")]
86
+ array = plistlib.loads(body.lstrip().encode("utf-8"))
87
+ return {
88
+ item["group name"]: (
89
+ tuple(item["keys"].split(",")) if item["keys"] else ()
90
+ )
91
+ for item in array
92
+ }
93
+
94
+
95
+ def render_static_groups(groups):
96
+ """Serialize `groups` to the exact `@comment` body BibDesk writes.
97
+
98
+ ```python
99
+ render_static_groups(groups)
100
+ ```
101
+
102
+ * `groups`: a `dict` mapping each group name to an iterable of the
103
+ group's citation keys (as returned by `parse_static_groups`).
104
+
105
+ The result of parsing a BibDesk-written comment with
106
+ `parse_static_groups` and serializing it again is byte-identical to
107
+ the original.
108
+ """
109
+ array = [
110
+ {"group name": name, "keys": ",".join(keys)}
111
+ for name, keys in groups.items()
112
+ ]
113
+ xml = plistlib.dumps(
114
+ array,
115
+ fmt=plistlib.FMT_XML, # pylint: disable=no-member
116
+ ).decode("utf-8")
117
+ # plistlib output ends in `</plist>\n`; the head supplies the
118
+ # leading `\n`.
119
+ return f"{_STATIC_GROUPS_HEAD}\n{xml}}}"
@@ -0,0 +1,236 @@
1
+ """Handling of the header comment in BibDesk `.bib` files.
2
+
3
+ Every `.bib` file saved by BibDesk starts with this header (`NAME` is
4
+ the macOS user's full name, and there is a single trailing space after
5
+ the date and after `(UTF-8)`):
6
+
7
+ ```
8
+ %% This BibTeX bibliography file was created using BibDesk.
9
+ %% http://bibdesk.sourceforge.net/
10
+
11
+
12
+ %% Created for NAME at YYYY-MM-DD HH:MM:SS ±ZZZZ
13
+
14
+
15
+ %% Saved with string encoding Unicode (UTF-8)
16
+ ```
17
+
18
+ The date on the `Created for` line is the time of the last save;
19
+ BibDesk updates it in place on every save. `bibtexparser` parses the
20
+ whole header as a single `ImplicitComment` block (internal blank lines
21
+ and trailing spaces are preserved verbatim, but the trailing space of
22
+ the *last* line is rstripped away; see `restore_trailing_space`).
23
+
24
+ This module extracts data from that header (`parse_header`,
25
+ `peek_timestamp`), updates it in place (`update_header`), and
26
+ synthesizes it for new files (`make_header`).
27
+ """
28
+
29
+ import datetime
30
+ import re
31
+
32
+ __all__ = []
33
+
34
+ # All members whose name does not start with an underscore must be listed
35
+ # either in __all__ or in __private__
36
+ __private__ = [
37
+ "parse_header",
38
+ "make_header",
39
+ "peek_timestamp",
40
+ "update_header",
41
+ "restore_trailing_space",
42
+ ]
43
+
44
+
45
+ #: `strptime`/`strftime` format of the date in the `Created for` line.
46
+ _TIMESTAMP_FMT = "%Y-%m-%d %H:%M:%S %z"
47
+
48
+ #: Regex matching the `Created for` line of a BibDesk header.
49
+ _CREATED_RE = re.compile(
50
+ r"^%% Created for (?P<creator>.+?) at "
51
+ r"(?P<timestamp>\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2} [+-]\d{4})",
52
+ flags=re.MULTILINE,
53
+ )
54
+
55
+ #: First line of the last section of a BibDesk header.
56
+ _SAVED_WITH_PREFIX = "%% Saved with string encoding "
57
+
58
+ #: Template for `make_header` (mind the trailing spaces).
59
+ _HEADER_TEMPLATE = (
60
+ "%% This BibTeX bibliography file was created using BibDesk.\n"
61
+ "%% http://bibdesk.sourceforge.net/\n"
62
+ "\n"
63
+ "\n"
64
+ "%% Created for {creator} at {timestamp} \n"
65
+ "\n"
66
+ "\n"
67
+ "%% Saved with string encoding Unicode (UTF-8) "
68
+ )
69
+
70
+
71
+ def parse_header(comment_text):
72
+ """Extract the creator and save time from a BibDesk header.
73
+
74
+ ```python
75
+ parse_header(comment_text)
76
+ ```
77
+
78
+ Returns a tuple `(creator, timestamp)` with the creator name (str)
79
+ and the last-save time (a timezone-aware
80
+ {any}`datetime.datetime`), extracted from the
81
+ `%% Created for NAME at YYYY-MM-DD HH:MM:SS ±ZZZZ` line of
82
+ `comment_text` (the body of the comment block at the top of the
83
+ `.bib` file). Returns `(None, None)` if no such line is found,
84
+ indicating that the file was not written by BibDesk.
85
+
86
+ ```python
87
+ >>> from bibdeskparser.header import parse_header
88
+ >>> header = (
89
+ ... "%% This BibTeX bibliography file was created using "
90
+ ... "BibDesk.\\n"
91
+ ... "%% http://bibdesk.sourceforge.net/\\n\\n\\n"
92
+ ... "%% Created for Michael Goerz at 2026-07-04 13:45:42 -0400 "
93
+ ... )
94
+ >>> creator, timestamp = parse_header(header)
95
+ >>> creator
96
+ 'Michael Goerz'
97
+ >>> print(timestamp)
98
+ 2026-07-04 13:45:42-04:00
99
+ >>> parse_header("% not a BibDesk header")
100
+ (None, None)
101
+
102
+ ```
103
+ """
104
+ match = _CREATED_RE.search(comment_text)
105
+ if match is None:
106
+ return (None, None)
107
+ timestamp = datetime.datetime.strptime(
108
+ match.group("timestamp"), _TIMESTAMP_FMT
109
+ )
110
+ return (match.group("creator"), timestamp)
111
+
112
+
113
+ def update_header(comment_text, timestamp):
114
+ """Replace the save time in a BibDesk header.
115
+
116
+ ```python
117
+ update_header(comment_text, timestamp)
118
+ ```
119
+
120
+ Returns a copy of `comment_text` in which only the
121
+ `YYYY-MM-DD HH:MM:SS ±ZZZZ` substring of the `Created for` line is
122
+ replaced with `timestamp` (a timezone-aware
123
+ {any}`datetime.datetime`). Everything else, including trailing
124
+ spaces, is preserved byte-for-byte, mirroring how BibDesk updates
125
+ the date in place on every save.
126
+
127
+ Raises {any}`ValueError` if `comment_text` contains no
128
+ `Created for` line (see `parse_header`).
129
+ """
130
+ match = _CREATED_RE.search(comment_text)
131
+ if match is None:
132
+ raise ValueError(
133
+ "Cannot update header: no BibDesk 'Created for' line in "
134
+ f"{comment_text!r}"
135
+ )
136
+ start, end = match.span("timestamp")
137
+ return (
138
+ comment_text[:start]
139
+ + timestamp.strftime(_TIMESTAMP_FMT)
140
+ + comment_text[end:]
141
+ )
142
+
143
+
144
+ def make_header(creator, timestamp):
145
+ """Synthesize the header comment for a new BibDesk `.bib` file.
146
+
147
+ ```python
148
+ make_header(creator, timestamp)
149
+ ```
150
+
151
+ Returns the canonical BibDesk header exactly as BibDesk writes it
152
+ (including the single trailing space after the date and after
153
+ `(UTF-8)`, and the two blank lines between sections), without a
154
+ trailing newline.
155
+
156
+ * `creator`: the name to put on the `Created for` line.
157
+ * `timestamp`: the save time, as a timezone-aware
158
+ {any}`datetime.datetime`.
159
+
160
+ ```python
161
+ >>> import datetime
162
+ >>> from bibdeskparser.header import make_header, parse_header
163
+ >>> timestamp = datetime.datetime(
164
+ ... 2026, 7, 4, 13, 45, 42,
165
+ ... tzinfo=datetime.timezone(datetime.timedelta(hours=-4)),
166
+ ... )
167
+ >>> header = make_header("Michael Goerz", timestamp)
168
+ >>> for line in header.split("\\n"):
169
+ ... print(repr(line))
170
+ '%% This BibTeX bibliography file was created using BibDesk.'
171
+ '%% http://bibdesk.sourceforge.net/'
172
+ ''
173
+ ''
174
+ '%% Created for Michael Goerz at 2026-07-04 13:45:42 -0400 '
175
+ ''
176
+ ''
177
+ '%% Saved with string encoding Unicode (UTF-8) '
178
+ >>> parse_header(header) == ("Michael Goerz", timestamp)
179
+ True
180
+
181
+ ```
182
+ """
183
+ return _HEADER_TEMPLATE.format(
184
+ creator=creator, timestamp=timestamp.strftime(_TIMESTAMP_FMT)
185
+ )
186
+
187
+
188
+ def peek_timestamp(path):
189
+ """Cheaply extract the save time from a `.bib` file on disk.
190
+
191
+ ```python
192
+ peek_timestamp(path)
193
+ ```
194
+
195
+ Reads only the first 20 lines of the file at `path` (without
196
+ parsing it as BibTeX) and returns the timezone-aware
197
+ {any}`datetime.datetime` from the header's `Created for` line, or
198
+ `None` if the file has no BibDesk header. This is intended for
199
+ detecting whether a file changed on disk (e.g., was re-saved by
200
+ BibDesk) since it was read.
201
+ """
202
+ lines = []
203
+ with open(path, encoding="utf-8", errors="replace") as bibfile:
204
+ for line in bibfile:
205
+ lines.append(line)
206
+ if len(lines) >= 20:
207
+ break
208
+ _, timestamp = parse_header("".join(lines))
209
+ return timestamp
210
+
211
+
212
+ def restore_trailing_space(comment_text):
213
+ """Re-append the trailing space to a parsed BibDesk header.
214
+
215
+ ```python
216
+ restore_trailing_space(comment_text)
217
+ ```
218
+
219
+ BibDesk writes the last header line as
220
+ `%% Saved with string encoding Unicode (UTF-8) `, with a single
221
+ trailing space. When `bibtexparser` parses the header into an
222
+ `ImplicitComment`, it preserves *internal* trailing spaces (such as
223
+ the one after the date) but rstrips the whitespace at the very end
224
+ of the comment, so that one space is lost. This function re-appends
225
+ it, so that writing the parsed header back to disk is byte-exact.
226
+
227
+ The space is appended only if the last line of `comment_text`
228
+ starts with `%% Saved with string encoding` and does not already
229
+ end in whitespace; any other text is returned unchanged.
230
+ """
231
+ last_line = comment_text.rsplit("\n", 1)[-1]
232
+ if last_line.startswith(_SAVED_WITH_PREFIX.rstrip()) and (
233
+ last_line == last_line.rstrip()
234
+ ):
235
+ return comment_text + " "
236
+ return comment_text