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/editing.py
ADDED
|
@@ -0,0 +1,673 @@
|
|
|
1
|
+
r"""`$EDITOR` round-trip editing of `Entry` objects and `Library` strings.
|
|
2
|
+
|
|
3
|
+
Provides {any}`edit_entries` and {any}`edit_strings`: pure functions that
|
|
4
|
+
export data to a temporary `.bib`-like file, open it in `$EDITOR`, wait
|
|
5
|
+
for the editor to exit, re-parse the (possibly edited) result, validate
|
|
6
|
+
it, and merge any changes back into the *original*
|
|
7
|
+
{any}`bibdeskparser.entry.Entry` objects (and, optionally, a
|
|
8
|
+
{any}`bibdeskparser.library.Library`'s `.strings`). This includes an
|
|
9
|
+
entry's `keywords = {...}` line, which merges back through the entry's
|
|
10
|
+
keywords accessors ({any}`bibdeskparser.entry.Entry.keywords` is not
|
|
11
|
+
part of the dict interface). Neither function is
|
|
12
|
+
a method on `Library` -- wiring those up (e.g. `Library.edit()`) is
|
|
13
|
+
out of scope for this module.
|
|
14
|
+
|
|
15
|
+
Only the `"default"` export format can be round-tripped this way (see
|
|
16
|
+
"Known limitations" below): re-parsing and merging back relies on its
|
|
17
|
+
exact text shape.
|
|
18
|
+
|
|
19
|
+
## Known limitations
|
|
20
|
+
|
|
21
|
+
* {any}`edit_entries` can only edit the fields of *existing* entries
|
|
22
|
+
matched by citation key: it cannot add or remove entries. An
|
|
23
|
+
original entry whose key does not appear in the edited text is left
|
|
24
|
+
untouched (with a `UserWarning`); an entry block in the edited text
|
|
25
|
+
whose key does not match any original entry is silently ignored
|
|
26
|
+
(also with a `UserWarning`). Renaming a citekey is not supported
|
|
27
|
+
either, since that has wider implications (e.g. `Library` dict-key
|
|
28
|
+
consistency) that are out of scope here.
|
|
29
|
+
* Only `format="default"` is accepted (see {any}`edit_entries`'s
|
|
30
|
+
docstring for why); the parameter still exists for API symmetry with
|
|
31
|
+
`export_entries`/`render_entry`.
|
|
32
|
+
* In {any}`edit_strings`, a macro whose deletion fails (because it is
|
|
33
|
+
still referenced by an entry) is reported as a validation problem
|
|
34
|
+
for that round and can be fixed by reopening the editor; however,
|
|
35
|
+
*other* changes from that same round (new/redefined/renamed macros)
|
|
36
|
+
are **not** rolled back if a later deletion in the same round fails
|
|
37
|
+
-- only failed deletions are retried. This is a deliberate
|
|
38
|
+
simplification (deletions are rare and usually singular); see the
|
|
39
|
+
function's docstring.
|
|
40
|
+
|
|
41
|
+
This module intentionally has no doctest: exercising it means
|
|
42
|
+
launching a real subprocess and waiting on an interactive editor,
|
|
43
|
+
which cannot be done safely inside a doctest. See `tests/test_editing.py`
|
|
44
|
+
for coverage using a scripted fake editor instead.
|
|
45
|
+
"""
|
|
46
|
+
|
|
47
|
+
import os
|
|
48
|
+
import re
|
|
49
|
+
import shlex
|
|
50
|
+
import subprocess
|
|
51
|
+
import tempfile
|
|
52
|
+
import warnings
|
|
53
|
+
from pathlib import Path
|
|
54
|
+
|
|
55
|
+
import bibtexparser
|
|
56
|
+
|
|
57
|
+
from .bdskfile import BibDeskFile
|
|
58
|
+
from .exporting import export_entries
|
|
59
|
+
from .macros import is_valid_macro_name, normalize_macro_name
|
|
60
|
+
|
|
61
|
+
__all__ = []
|
|
62
|
+
|
|
63
|
+
# All members whose name does not start with an underscore must be listed
|
|
64
|
+
# either in __all__ or in __private__
|
|
65
|
+
__private__ = ["edit_entries", "edit_strings"]
|
|
66
|
+
|
|
67
|
+
_DATE_KEYS = frozenset(("date-added", "date-modified"))
|
|
68
|
+
_BDSK_FILE_RE = re.compile(r"bdsk-file-(\d+)$", re.IGNORECASE)
|
|
69
|
+
_BDSK_URL_RE = re.compile(r"bdsk-url-(\d+)$", re.IGNORECASE)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
# -- small local helpers (do not import the private originals) ------- #
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _strip_enclosing(value):
|
|
76
|
+
"""Strip one matching pair of enclosing `{...}`/`"..."` from
|
|
77
|
+
`value`, if present; else return it unchanged (a bare token stays
|
|
78
|
+
bare)."""
|
|
79
|
+
if (
|
|
80
|
+
isinstance(value, str)
|
|
81
|
+
and len(value) >= 2
|
|
82
|
+
and (
|
|
83
|
+
(value[0] == "{" and value[-1] == "}")
|
|
84
|
+
or (value[0] == '"' and value[-1] == '"')
|
|
85
|
+
)
|
|
86
|
+
):
|
|
87
|
+
return value[1:-1]
|
|
88
|
+
return value
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _is_bare(value):
|
|
92
|
+
"""Whether `value` is a non-empty string with no enclosing
|
|
93
|
+
`{...}`/`"..."` (a candidate bare macro reference)."""
|
|
94
|
+
return isinstance(value, str) and bool(value) and value[0] not in '{"'
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _is_bdsk_field(key):
|
|
98
|
+
"""Whether `key` is a `bdsk-file-N`/`bdsk-url-N` field name."""
|
|
99
|
+
return bool(_BDSK_FILE_RE.match(key) or _BDSK_URL_RE.match(key))
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def _split_keywords(raw):
|
|
103
|
+
"""Split a comma-separated `keywords` field value into a tuple of
|
|
104
|
+
stripped, non-empty keywords."""
|
|
105
|
+
return tuple(kw.strip() for kw in raw.split(",") if kw.strip())
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
# -- editor invocation ------------------------------------------------- #
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def _write_temp_file(text):
|
|
112
|
+
"""Write `text` to a new, closed temporary `.bib` file; return its
|
|
113
|
+
`Path`."""
|
|
114
|
+
with tempfile.NamedTemporaryFile(
|
|
115
|
+
mode="w", suffix=".bib", delete=False, encoding="utf-8"
|
|
116
|
+
) as tmp:
|
|
117
|
+
tmp.write(text)
|
|
118
|
+
path = Path(tmp.name)
|
|
119
|
+
return path
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def _run_editor(editor, path):
|
|
123
|
+
"""Resolve and run the editor command on `path`, waiting for it to
|
|
124
|
+
exit.
|
|
125
|
+
|
|
126
|
+
Resolution order: the explicit `editor` argument, else
|
|
127
|
+
`$EDITOR`, else `"vi"`. Any exception raised by a missing command
|
|
128
|
+
or a non-zero exit status (`subprocess.CalledProcessError`)
|
|
129
|
+
propagates to the caller unchanged.
|
|
130
|
+
"""
|
|
131
|
+
command = editor or os.environ.get("EDITOR") or "vi"
|
|
132
|
+
if os.name == "nt":
|
|
133
|
+
# shlex.split is POSIX-only: it treats backslashes as escape
|
|
134
|
+
# characters and mangles Windows paths. Use posix=False and strip
|
|
135
|
+
# the surrounding quotes that non-POSIX mode preserves. posix=False
|
|
136
|
+
# rejects a quote mid-token (a valid way to quote just a space in a
|
|
137
|
+
# Windows path), so fall back to treating the whole command as a
|
|
138
|
+
# single program path in that case.
|
|
139
|
+
try:
|
|
140
|
+
tokens = shlex.split(command, posix=False)
|
|
141
|
+
except ValueError:
|
|
142
|
+
tokens = [command]
|
|
143
|
+
args = []
|
|
144
|
+
for token in tokens:
|
|
145
|
+
if (
|
|
146
|
+
len(token) >= 2
|
|
147
|
+
and token[0] == token[-1]
|
|
148
|
+
and token[0] in ('"', "'")
|
|
149
|
+
):
|
|
150
|
+
token = token[1:-1]
|
|
151
|
+
args.append(token)
|
|
152
|
+
else:
|
|
153
|
+
args = shlex.split(command)
|
|
154
|
+
subprocess.run(args + [str(path)], check=True)
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def _prompt_reopen_or_abandon(problems):
|
|
158
|
+
"""Warn about `problems` (a list of human-readable strings) and
|
|
159
|
+
interactively ask whether to reopen the editor or abandon the
|
|
160
|
+
edit.
|
|
161
|
+
|
|
162
|
+
Returns `True` to reopen, `False` to abandon. If `input()` raises
|
|
163
|
+
`EOFError`/`OSError` (non-interactive stdin, e.g. under pytest/CI),
|
|
164
|
+
or the user's answer is anything other than `"r"`
|
|
165
|
+
(case-insensitively; empty/EOF defaults to abandon), abandonment is
|
|
166
|
+
chosen and an additional `"edit abandoned"` warning is emitted.
|
|
167
|
+
"""
|
|
168
|
+
message = "Validation failed:\n" + "\n".join(problems)
|
|
169
|
+
warnings.warn(message, UserWarning, stacklevel=3)
|
|
170
|
+
try:
|
|
171
|
+
choice = input(f"{message}\n[r]eopen editor to fix, [a]bandon edit? ")
|
|
172
|
+
except (EOFError, OSError):
|
|
173
|
+
choice = "a"
|
|
174
|
+
choice = (choice or "a").strip().lower()
|
|
175
|
+
if choice == "r":
|
|
176
|
+
return True
|
|
177
|
+
warnings.warn("edit abandoned", UserWarning, stacklevel=3)
|
|
178
|
+
return False
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
# -- validation --------------------------------------------------------- #
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def _failed_block_problems(parsed):
|
|
185
|
+
"""Problem strings for every block of `parsed`
|
|
186
|
+
(`bibtexparser.Library`) that failed to parse.
|
|
187
|
+
|
|
188
|
+
`bibtexparser.parse_string` does not raise an exception for a
|
|
189
|
+
syntactically broken block (e.g. unbalanced braces, or a duplicate
|
|
190
|
+
citation key): it silently turns it into a `ParsingFailedBlock` in
|
|
191
|
+
`parsed.failed_blocks` instead. Surfacing those here is what makes
|
|
192
|
+
such editor mistakes trigger the warn-and-reopen-or-abandon flow,
|
|
193
|
+
rather than silently vanishing (as an entry simply "not found in
|
|
194
|
+
the edited text").
|
|
195
|
+
"""
|
|
196
|
+
return [
|
|
197
|
+
f"could not parse block: {block.raw!r}"
|
|
198
|
+
for block in parsed.failed_blocks
|
|
199
|
+
]
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def _validate_parsed_entries(parsed, library):
|
|
203
|
+
"""Collect human-readable validation problems for a `parsed`
|
|
204
|
+
`bibtexparser.Library` (from `parse_string(text, parse_stack=[])`)
|
|
205
|
+
representing edited `Entry` blocks.
|
|
206
|
+
|
|
207
|
+
* Every entry must have a non-empty `entry_type` and `key`.
|
|
208
|
+
* If `library` is given, every bare field value shaped like a
|
|
209
|
+
macro name must resolve to a name already in `library.strings`
|
|
210
|
+
or newly defined by an `@string` block in the edited text
|
|
211
|
+
itself (so a rename/new-definition is not rejected just because
|
|
212
|
+
it is not yet in `library.strings`). The `keywords` field is
|
|
213
|
+
exempt: keywords are always literal text, never a macro
|
|
214
|
+
reference.
|
|
215
|
+
"""
|
|
216
|
+
problems = list(_failed_block_problems(parsed))
|
|
217
|
+
for entry in parsed.entries:
|
|
218
|
+
if not entry.entry_type:
|
|
219
|
+
problems.append(f"entry {entry.key!r}: missing entry type")
|
|
220
|
+
if not entry.key:
|
|
221
|
+
problems.append("an entry is missing its citation key")
|
|
222
|
+
if library is not None:
|
|
223
|
+
defined = set(dict(library.strings))
|
|
224
|
+
for string in parsed.strings:
|
|
225
|
+
if is_valid_macro_name(string.key, normalized=False):
|
|
226
|
+
defined.add(normalize_macro_name(string.key))
|
|
227
|
+
for entry in parsed.entries:
|
|
228
|
+
for field in entry.fields:
|
|
229
|
+
lkey = field.key.lower()
|
|
230
|
+
if (
|
|
231
|
+
_is_bdsk_field(field.key)
|
|
232
|
+
or lkey in _DATE_KEYS
|
|
233
|
+
or lkey == "keywords"
|
|
234
|
+
):
|
|
235
|
+
continue
|
|
236
|
+
value = field.value
|
|
237
|
+
if not _is_bare(value):
|
|
238
|
+
continue
|
|
239
|
+
if not is_valid_macro_name(value, normalized=False):
|
|
240
|
+
continue
|
|
241
|
+
if normalize_macro_name(value) not in defined:
|
|
242
|
+
problems.append(
|
|
243
|
+
f"entry {entry.key!r}: undefined macro "
|
|
244
|
+
f"{value!r} referenced by field {field.key!r}"
|
|
245
|
+
)
|
|
246
|
+
return problems
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
def _file_problems(parsed, entries, base_dir):
|
|
250
|
+
"""Problem strings for the edited `bdsk-file-N` paths of `parsed`.
|
|
251
|
+
|
|
252
|
+
A path that does not match one of the target entry's existing
|
|
253
|
+
attachments (`entry.files`) is a *changed* attachment: it must
|
|
254
|
+
exist as a file relative to `base_dir` (the library's `.bib`
|
|
255
|
+
directory, or the CWD when editing without a library). If the
|
|
256
|
+
library has no file path at all (`base_dir` is `None`),
|
|
257
|
+
attachments cannot be changed at all."""
|
|
258
|
+
problems = []
|
|
259
|
+
originals = {entry.key: entry for entry in entries}
|
|
260
|
+
for parsed_entry in parsed.entries:
|
|
261
|
+
original = originals.get(parsed_entry.key)
|
|
262
|
+
if original is None:
|
|
263
|
+
continue
|
|
264
|
+
existing = set(original.files)
|
|
265
|
+
for field in parsed_entry.fields:
|
|
266
|
+
if not _BDSK_FILE_RE.match(field.key):
|
|
267
|
+
continue
|
|
268
|
+
path = _strip_enclosing(field.value)
|
|
269
|
+
if path in existing:
|
|
270
|
+
continue
|
|
271
|
+
if base_dir is None:
|
|
272
|
+
problems.append(
|
|
273
|
+
f"entry {parsed_entry.key!r}: cannot change "
|
|
274
|
+
f"linked file {path!r}: the library has no file "
|
|
275
|
+
"path yet (save it first)"
|
|
276
|
+
)
|
|
277
|
+
elif not (base_dir / path).exists():
|
|
278
|
+
problems.append(
|
|
279
|
+
f"entry {parsed_entry.key!r}: linked file does "
|
|
280
|
+
f"not exist: {path!r} (relative to {base_dir})"
|
|
281
|
+
)
|
|
282
|
+
return problems
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
def _parse_and_validate_entries(text, library, entries, base_dir):
|
|
286
|
+
"""Parse `text` (edited `Entry` blocks) and validate it.
|
|
287
|
+
|
|
288
|
+
Returns `(parsed_library_or_None, problems)`: a parse failure is
|
|
289
|
+
caught and reported as a single problem, `parsed_library` is then
|
|
290
|
+
`None`."""
|
|
291
|
+
try:
|
|
292
|
+
# An empty parse_stack (no middleware) keeps braces verbatim,
|
|
293
|
+
# matching what export_entries(..., format="default") wrote:
|
|
294
|
+
# in particular, bdsk-file-N fields are plain path strings
|
|
295
|
+
# here, not the base64 blob the normal read middleware stack
|
|
296
|
+
# expects to decode, so running that stack over edited text
|
|
297
|
+
# would fail or corrupt the paths. Field values are then
|
|
298
|
+
# brace-stripped locally (_strip_enclosing) and fed into the
|
|
299
|
+
# target Entry's normal __setitem__, which re-texifies/
|
|
300
|
+
# re-braces/re-validates a plain Unicode string exactly as if
|
|
301
|
+
# the user had set it directly in Python.
|
|
302
|
+
parsed = bibtexparser.parse_string(text, parse_stack=[])
|
|
303
|
+
except Exception as exc: # pylint: disable=broad-except
|
|
304
|
+
return None, [f"parse error: {exc}"]
|
|
305
|
+
problems = _validate_parsed_entries(parsed, library)
|
|
306
|
+
problems.extend(_file_problems(parsed, entries, base_dir))
|
|
307
|
+
return parsed, problems
|
|
308
|
+
|
|
309
|
+
|
|
310
|
+
def _parse_and_validate_strings(text):
|
|
311
|
+
"""Parse `text` (edited `@string` blocks only) and validate it.
|
|
312
|
+
|
|
313
|
+
Returns `(parsed_library_or_None, problems)`, like
|
|
314
|
+
`_parse_and_validate_entries`; validation here only checks
|
|
315
|
+
that every `@string` name is a valid BibDesk macro name."""
|
|
316
|
+
try:
|
|
317
|
+
parsed = bibtexparser.parse_string(text, parse_stack=[])
|
|
318
|
+
except Exception as exc: # pylint: disable=broad-except
|
|
319
|
+
return None, [f"parse error: {exc}"]
|
|
320
|
+
problems = _failed_block_problems(parsed)
|
|
321
|
+
problems.extend(
|
|
322
|
+
f"invalid macro name: {string.key!r}"
|
|
323
|
+
for string in parsed.strings
|
|
324
|
+
if not is_valid_macro_name(string.key, normalized=False)
|
|
325
|
+
)
|
|
326
|
+
return parsed, problems
|
|
327
|
+
|
|
328
|
+
|
|
329
|
+
# -- merging: entries --------------------------------------------------- #
|
|
330
|
+
|
|
331
|
+
|
|
332
|
+
def _merge_fields(original_entry, parsed_entry):
|
|
333
|
+
"""Merge `parsed_entry`'s normal fields into `original_entry`
|
|
334
|
+
in-place: set changed/new fields, delete fields absent from the
|
|
335
|
+
edited block. The `keywords` field is not part of `Entry`'s dict
|
|
336
|
+
interface, so it is merged through the entry's dedicated keywords
|
|
337
|
+
accessors instead."""
|
|
338
|
+
parsed_by_key = {}
|
|
339
|
+
for field in parsed_entry.fields:
|
|
340
|
+
if _is_bdsk_field(field.key) or field.key.lower() in _DATE_KEYS:
|
|
341
|
+
continue
|
|
342
|
+
parsed_by_key[field.key.lower()] = (field.key, field.value)
|
|
343
|
+
keywords_item = parsed_by_key.pop("keywords", None)
|
|
344
|
+
new_keywords = (
|
|
345
|
+
_split_keywords(_strip_enclosing(keywords_item[1]))
|
|
346
|
+
if keywords_item is not None
|
|
347
|
+
else ()
|
|
348
|
+
)
|
|
349
|
+
if new_keywords != original_entry.keywords:
|
|
350
|
+
# pylint: disable=protected-access
|
|
351
|
+
original_entry._set_keywords(new_keywords)
|
|
352
|
+
for key in list(original_entry.keys()):
|
|
353
|
+
if key.lower() not in parsed_by_key:
|
|
354
|
+
del original_entry[key]
|
|
355
|
+
for orig_key, raw_value in parsed_by_key.values():
|
|
356
|
+
value = _strip_enclosing(raw_value)
|
|
357
|
+
if original_entry.get(orig_key) != value:
|
|
358
|
+
original_entry[orig_key] = value
|
|
359
|
+
|
|
360
|
+
|
|
361
|
+
def _merge_files_urls(original_entry, parsed_entry, base_dir):
|
|
362
|
+
"""Merge `parsed_entry`'s `bdsk-file-N`/`bdsk-url-N` fields into
|
|
363
|
+
`original_entry`'s attachments/`.urls`, interpreting file paths
|
|
364
|
+
relative to `base_dir` (the library's `.bib` directory, or the
|
|
365
|
+
CWD when editing without a library).
|
|
366
|
+
|
|
367
|
+
A path matching one of `original_entry`'s current attachments
|
|
368
|
+
keeps that attachment's existing data (bookmark); a changed path
|
|
369
|
+
gets a fresh `BibDeskFile`. Validation (`_file_problems`) has
|
|
370
|
+
already ensured that every changed path exists relative to
|
|
371
|
+
`base_dir` (and that `base_dir` is not `None` when anything
|
|
372
|
+
changed), so `must_exist=False` here only covers the file
|
|
373
|
+
disappearing between validation and merge."""
|
|
374
|
+
file_items = []
|
|
375
|
+
url_items = []
|
|
376
|
+
for field in parsed_entry.fields:
|
|
377
|
+
match = _BDSK_FILE_RE.match(field.key)
|
|
378
|
+
if match:
|
|
379
|
+
file_items.append(
|
|
380
|
+
(int(match.group(1)), _strip_enclosing(field.value))
|
|
381
|
+
)
|
|
382
|
+
continue
|
|
383
|
+
match = _BDSK_URL_RE.match(field.key)
|
|
384
|
+
if match:
|
|
385
|
+
url_items.append(
|
|
386
|
+
(int(match.group(1)), _strip_enclosing(field.value))
|
|
387
|
+
)
|
|
388
|
+
new_files = [path for _, path in sorted(file_items)]
|
|
389
|
+
new_urls = [url for _, url in sorted(url_items)]
|
|
390
|
+
if new_files != original_entry.files:
|
|
391
|
+
# pylint: disable=protected-access
|
|
392
|
+
by_rel_path = {
|
|
393
|
+
f.relative_path: f for f in original_entry._file_objects()
|
|
394
|
+
}
|
|
395
|
+
original_entry._set_files(
|
|
396
|
+
[
|
|
397
|
+
by_rel_path.get(path)
|
|
398
|
+
or BibDeskFile(
|
|
399
|
+
base_dir / path,
|
|
400
|
+
relative_to=base_dir,
|
|
401
|
+
must_exist=False,
|
|
402
|
+
)
|
|
403
|
+
for path in new_files
|
|
404
|
+
]
|
|
405
|
+
)
|
|
406
|
+
if new_urls != original_entry.urls:
|
|
407
|
+
original_entry.urls = new_urls
|
|
408
|
+
|
|
409
|
+
|
|
410
|
+
def _merge_entries(entries, parsed, base_dir):
|
|
411
|
+
"""Merge every parsed entry block in `parsed` back into the
|
|
412
|
+
matching (by citation key) `Entry` of `entries`; warn about keys
|
|
413
|
+
that could not be matched in either direction (see the module
|
|
414
|
+
docstring's "known limitations"). `base_dir` is the directory
|
|
415
|
+
that `bdsk-file-N` paths are relative to (see
|
|
416
|
+
`_merge_files_urls`)."""
|
|
417
|
+
parsed_by_key = {entry.key: entry for entry in parsed.entries}
|
|
418
|
+
original_keys = {entry.key for entry in entries}
|
|
419
|
+
for entry in entries:
|
|
420
|
+
parsed_entry = parsed_by_key.get(entry.key)
|
|
421
|
+
if parsed_entry is None:
|
|
422
|
+
warnings.warn(
|
|
423
|
+
f"entry {entry.key!r} not found in the edited text; "
|
|
424
|
+
"left unchanged (edit_entries cannot delete entries)",
|
|
425
|
+
UserWarning,
|
|
426
|
+
stacklevel=3,
|
|
427
|
+
)
|
|
428
|
+
continue
|
|
429
|
+
if parsed_entry.entry_type != entry.entry_type:
|
|
430
|
+
entry.entry_type = parsed_entry.entry_type
|
|
431
|
+
_merge_fields(entry, parsed_entry)
|
|
432
|
+
_merge_files_urls(entry, parsed_entry, base_dir)
|
|
433
|
+
for key in parsed_by_key:
|
|
434
|
+
if key not in original_keys:
|
|
435
|
+
warnings.warn(
|
|
436
|
+
f"entry {key!r} is new and was ignored (edit_entries "
|
|
437
|
+
"cannot add entries)",
|
|
438
|
+
UserWarning,
|
|
439
|
+
stacklevel=3,
|
|
440
|
+
)
|
|
441
|
+
|
|
442
|
+
|
|
443
|
+
# -- merging: @string macros ---------------------------------------------- #
|
|
444
|
+
|
|
445
|
+
|
|
446
|
+
def _merge_strings(library, before, parsed_strings, allow_delete):
|
|
447
|
+
"""Merge `parsed_strings` (`{name: value}`, from the edited text)
|
|
448
|
+
into `library.strings`, comparing against `before` (`{name:
|
|
449
|
+
value}`, a snapshot of `library.strings` taken before the edit).
|
|
450
|
+
|
|
451
|
+
* Unchanged name/value pairs are ignored.
|
|
452
|
+
* A name already in `before` whose value changed is redefined
|
|
453
|
+
(`library.strings[name] = value`).
|
|
454
|
+
* A new name whose value exactly matches an existing (and not yet
|
|
455
|
+
consumed by another rename this round) macro's original value is
|
|
456
|
+
treated as a rename (`library.rename_string`); otherwise it is a
|
|
457
|
+
new definition.
|
|
458
|
+
* If `allow_delete`, every name in `before` that is absent from
|
|
459
|
+
`parsed_strings` (and was not itself consumed by a rename this
|
|
460
|
+
round) is deleted (`del library.strings[name]`); a `ValueError`
|
|
461
|
+
from a still-in-use macro is caught and returned as a problem
|
|
462
|
+
string rather than propagated, so the caller can re-prompt for
|
|
463
|
+
another editing round instead of crashing -- other changes
|
|
464
|
+
already applied this round (defines/redefines/renames, and any
|
|
465
|
+
*other* successful deletions) are deliberately not rolled back.
|
|
466
|
+
|
|
467
|
+
Returns a list of problem strings (only ever non-empty when
|
|
468
|
+
`allow_delete` is true and a deletion failed).
|
|
469
|
+
"""
|
|
470
|
+
consumed = set()
|
|
471
|
+
for name, value in parsed_strings.items():
|
|
472
|
+
if name in before:
|
|
473
|
+
if before[name] != value:
|
|
474
|
+
library.strings[name] = value
|
|
475
|
+
continue
|
|
476
|
+
rename_from = next(
|
|
477
|
+
(
|
|
478
|
+
old_name
|
|
479
|
+
for old_name, old_value in before.items()
|
|
480
|
+
if old_name not in parsed_strings
|
|
481
|
+
and old_name not in consumed
|
|
482
|
+
and old_value == value
|
|
483
|
+
),
|
|
484
|
+
None,
|
|
485
|
+
)
|
|
486
|
+
if rename_from is not None:
|
|
487
|
+
library.rename_string(rename_from, name)
|
|
488
|
+
consumed.add(rename_from)
|
|
489
|
+
else:
|
|
490
|
+
library.strings[name] = value
|
|
491
|
+
problems = []
|
|
492
|
+
if allow_delete:
|
|
493
|
+
for name in before:
|
|
494
|
+
if name in parsed_strings or name in consumed:
|
|
495
|
+
continue
|
|
496
|
+
try:
|
|
497
|
+
del library.strings[name]
|
|
498
|
+
except ValueError as exc:
|
|
499
|
+
problems.append(str(exc))
|
|
500
|
+
return problems
|
|
501
|
+
|
|
502
|
+
|
|
503
|
+
# -- public API ----------------------------------------------------------- #
|
|
504
|
+
|
|
505
|
+
|
|
506
|
+
def edit_entries(entries, library=None, format="default", editor=None):
|
|
507
|
+
# pylint: disable=redefined-builtin
|
|
508
|
+
"""Edit `entries` together in `$EDITOR`, merging changes back.
|
|
509
|
+
|
|
510
|
+
```python
|
|
511
|
+
edit_entries(entries, library=None, format="default", editor=None)
|
|
512
|
+
```
|
|
513
|
+
|
|
514
|
+
Exports `entries` (an iterable of {any}`bibdeskparser.entry.Entry`)
|
|
515
|
+
to a temporary file via {any}`bibdeskparser.exporting.export_entries`,
|
|
516
|
+
opens it in `editor`, waits for the editor to exit, re-parses the
|
|
517
|
+
result, validates it, and merges the changes back into the
|
|
518
|
+
*original* `Entry` objects (not copies) -- see the module docstring
|
|
519
|
+
for the exact merge algorithm and its known limitations.
|
|
520
|
+
|
|
521
|
+
* `entries`: an iterable of `Entry` to edit together in one file
|
|
522
|
+
(also covers "edit a single entry": pass a one-element list).
|
|
523
|
+
* `library`: optional {any}`bibdeskparser.library.Library`-like
|
|
524
|
+
object (only `.strings` mapping semantics and
|
|
525
|
+
`.rename_string(old, new)` are required -- no import of the
|
|
526
|
+
`Library` class happens here). If given, its current `@string`
|
|
527
|
+
definitions are included in the exported text (so it is
|
|
528
|
+
self-contained) and any `@string` changes detected in the edited
|
|
529
|
+
text are merged back into `library.strings` (including
|
|
530
|
+
macro-rename detection via `library.rename_string`).
|
|
531
|
+
* `format`: only `"default"` is accepted; raises `ValueError`
|
|
532
|
+
otherwise. Re-parsing the edited text relies on the exact shape
|
|
533
|
+
`export_entries(..., format="default")` produces (Unicode
|
|
534
|
+
values; `bdsk-file-N`/`bdsk-url-N` as plain paths/URLs); the
|
|
535
|
+
`"raw"` format is TeX-encoded rather than Unicode, and
|
|
536
|
+
`"minimal"` drops most fields -- neither can be merged back
|
|
537
|
+
correctly.
|
|
538
|
+
* `editor`: a shell command string (may include arguments, e.g.
|
|
539
|
+
`"code --wait"`); resolution order: this argument, else
|
|
540
|
+
`$EDITOR`, else `"vi"`.
|
|
541
|
+
|
|
542
|
+
If the edited text fails to parse or fails validation (see the
|
|
543
|
+
module docstring), a `UserWarning` describing the problem(s) is
|
|
544
|
+
emitted and the user is interactively prompted (via `input()`) to
|
|
545
|
+
either reopen the editor on the same file (preserving their
|
|
546
|
+
partial edit) or abandon the edit entirely. Under non-interactive
|
|
547
|
+
stdin (`input()` raising `EOFError`/`OSError`, e.g. under
|
|
548
|
+
pytest/CI), the edit is silently abandoned. On abandonment,
|
|
549
|
+
`entries`/`library` are left completely unchanged.
|
|
550
|
+
|
|
551
|
+
`bdsk-file-N` paths in the edited text are interpreted relative
|
|
552
|
+
to the directory of `library`'s `.bib` file (falling back to the
|
|
553
|
+
current working directory when no `library` is given). A changed
|
|
554
|
+
path must point to an existing file there; otherwise it is
|
|
555
|
+
reported as a validation problem (see above). If `library` has no
|
|
556
|
+
file path yet (never saved), any change to a `bdsk-file-N` line
|
|
557
|
+
is a validation problem: linked files are stored relative to the
|
|
558
|
+
library's `.bib` file, so the library must be saved first.
|
|
559
|
+
"""
|
|
560
|
+
if format != "default":
|
|
561
|
+
raise ValueError(
|
|
562
|
+
"edit_entries only supports format='default': merging "
|
|
563
|
+
"changes back relies on the exact text shape produced by "
|
|
564
|
+
"export_entries(..., format='default') (unicode values, "
|
|
565
|
+
"bdsk-file-N/bdsk-url-N as plain paths/URLs); the 'raw' "
|
|
566
|
+
"format is TeX-encoded and 'minimal' drops most fields, "
|
|
567
|
+
"so neither can be parsed back and merged correctly"
|
|
568
|
+
)
|
|
569
|
+
entries = list(entries)
|
|
570
|
+
strings = dict(library.strings) if library is not None else None
|
|
571
|
+
if library is None:
|
|
572
|
+
base_dir = Path.cwd()
|
|
573
|
+
else:
|
|
574
|
+
# Reaching into `Library._path` crosses a module boundary, but
|
|
575
|
+
# not a public API boundary (same package); a `library` that
|
|
576
|
+
# has no such attribute is treated like a never-saved one.
|
|
577
|
+
library_path = getattr(library, "_path", None)
|
|
578
|
+
base_dir = (
|
|
579
|
+
Path(library_path).resolve().parent
|
|
580
|
+
if library_path is not None
|
|
581
|
+
else None
|
|
582
|
+
)
|
|
583
|
+
text = export_entries(entries, strings=strings, format="default")
|
|
584
|
+
path = _write_temp_file(text)
|
|
585
|
+
try:
|
|
586
|
+
while True:
|
|
587
|
+
_run_editor(editor, path)
|
|
588
|
+
edited_text = path.read_text(encoding="utf-8")
|
|
589
|
+
parsed, problems = _parse_and_validate_entries(
|
|
590
|
+
edited_text, library, entries, base_dir
|
|
591
|
+
)
|
|
592
|
+
if problems:
|
|
593
|
+
if _prompt_reopen_or_abandon(problems):
|
|
594
|
+
continue
|
|
595
|
+
return
|
|
596
|
+
_merge_entries(entries, parsed, base_dir)
|
|
597
|
+
if library is not None:
|
|
598
|
+
parsed_strings = {
|
|
599
|
+
string.key: _strip_enclosing(string.value)
|
|
600
|
+
for string in parsed.strings
|
|
601
|
+
}
|
|
602
|
+
_merge_strings(
|
|
603
|
+
library, strings, parsed_strings, allow_delete=False
|
|
604
|
+
)
|
|
605
|
+
return
|
|
606
|
+
finally:
|
|
607
|
+
path.unlink(missing_ok=True)
|
|
608
|
+
|
|
609
|
+
|
|
610
|
+
def edit_strings(library, editor=None):
|
|
611
|
+
"""Edit just `library`'s `@string` macro definitions in `$EDITOR`.
|
|
612
|
+
|
|
613
|
+
```python
|
|
614
|
+
edit_strings(library, editor=None)
|
|
615
|
+
```
|
|
616
|
+
|
|
617
|
+
Exports `library.strings` (only, no entries) as a series of
|
|
618
|
+
`@string{name = {value}}` lines, sorted by name, opens it in
|
|
619
|
+
`editor`, waits for the editor to exit, re-parses the result,
|
|
620
|
+
validates it, and merges the changes back into `library.strings`:
|
|
621
|
+
|
|
622
|
+
* A new name/value pair is defined.
|
|
623
|
+
* A changed value is redefined in place.
|
|
624
|
+
* A new name whose value matches a pre-existing (and not yet
|
|
625
|
+
renamed-away) macro's original value is treated as a rename via
|
|
626
|
+
`library.rename_string`.
|
|
627
|
+
* A name that disappeared from the edited text is deleted
|
|
628
|
+
(`del library.strings[name]`); if that macro is still referenced
|
|
629
|
+
by an entry, the resulting `ValueError` is treated as a
|
|
630
|
+
validation problem for that editing round (see the module
|
|
631
|
+
docstring's "known limitations" for why other changes in the
|
|
632
|
+
same round are not rolled back).
|
|
633
|
+
|
|
634
|
+
* `library`: a {any}`bibdeskparser.library.Library`-like object
|
|
635
|
+
(only `.strings` mapping semantics and `.rename_string` are
|
|
636
|
+
used).
|
|
637
|
+
* `editor`: as in {any}`edit_entries`.
|
|
638
|
+
|
|
639
|
+
Validation failure (parse error, or an `@string` name that is not
|
|
640
|
+
a valid BibDesk macro name per
|
|
641
|
+
{any}`bibdeskparser.macros.is_valid_macro_name`, or a failed
|
|
642
|
+
deletion) triggers the same warn-and-prompt-to-reopen-or-abandon
|
|
643
|
+
flow as {any}`edit_entries`.
|
|
644
|
+
"""
|
|
645
|
+
before = dict(library.strings)
|
|
646
|
+
text = "\n".join(
|
|
647
|
+
f"@string{{{name} = {{{value}}}}}"
|
|
648
|
+
for name, value in sorted(before.items())
|
|
649
|
+
)
|
|
650
|
+
if text:
|
|
651
|
+
text += "\n"
|
|
652
|
+
path = _write_temp_file(text)
|
|
653
|
+
try:
|
|
654
|
+
while True:
|
|
655
|
+
_run_editor(editor, path)
|
|
656
|
+
edited_text = path.read_text(encoding="utf-8")
|
|
657
|
+
parsed, problems = _parse_and_validate_strings(edited_text)
|
|
658
|
+
if not problems:
|
|
659
|
+
parsed_strings = {
|
|
660
|
+
string.key: _strip_enclosing(string.value)
|
|
661
|
+
for string in parsed.strings
|
|
662
|
+
}
|
|
663
|
+
problems = _merge_strings(
|
|
664
|
+
library, before, parsed_strings, allow_delete=True
|
|
665
|
+
)
|
|
666
|
+
before = dict(library.strings)
|
|
667
|
+
if problems:
|
|
668
|
+
if _prompt_reopen_or_abandon(problems):
|
|
669
|
+
continue
|
|
670
|
+
return
|
|
671
|
+
return
|
|
672
|
+
finally:
|
|
673
|
+
path.unlink(missing_ok=True)
|