bibdeskparser 0.2.0__tar.gz → 0.3.0__tar.gz

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.
Files changed (29) hide show
  1. {bibdeskparser-0.2.0 → bibdeskparser-0.3.0}/PKG-INFO +51 -32
  2. {bibdeskparser-0.2.0 → bibdeskparser-0.3.0}/README.md +44 -29
  3. {bibdeskparser-0.2.0 → bibdeskparser-0.3.0}/pyproject.toml +9 -5
  4. bibdeskparser-0.3.0/src/bibdeskparser/abstracts.py +325 -0
  5. bibdeskparser-0.3.0/src/bibdeskparser/abstracttext.py +443 -0
  6. bibdeskparser-0.3.0/src/bibdeskparser/cli.py +2189 -0
  7. {bibdeskparser-0.2.0 → bibdeskparser-0.3.0}/src/bibdeskparser/config.py +269 -2
  8. {bibdeskparser-0.2.0 → bibdeskparser-0.3.0}/src/bibdeskparser/editing.py +18 -10
  9. {bibdeskparser-0.2.0 → bibdeskparser-0.3.0}/src/bibdeskparser/entry.py +266 -6
  10. bibdeskparser-0.3.0/src/bibdeskparser/fetch.py +368 -0
  11. bibdeskparser-0.3.0/src/bibdeskparser/identifiers.py +51 -0
  12. bibdeskparser-0.3.0/src/bibdeskparser/importing.py +684 -0
  13. {bibdeskparser-0.2.0 → bibdeskparser-0.3.0}/src/bibdeskparser/library.py +519 -56
  14. {bibdeskparser-0.2.0 → bibdeskparser-0.3.0}/src/bibdeskparser/macros.py +31 -1
  15. {bibdeskparser-0.2.0 → bibdeskparser-0.3.0}/src/bibdeskparser/middleware.py +54 -0
  16. bibdeskparser-0.3.0/src/bibdeskparser/preprints.py +364 -0
  17. bibdeskparser-0.2.0/src/bibdeskparser/cli.py +0 -922
  18. {bibdeskparser-0.2.0 → bibdeskparser-0.3.0}/src/bibdeskparser/__init__.py +0 -0
  19. {bibdeskparser-0.2.0 → bibdeskparser-0.3.0}/src/bibdeskparser/bdskfile.py +0 -0
  20. {bibdeskparser-0.2.0 → bibdeskparser-0.3.0}/src/bibdeskparser/entrytypes.py +0 -0
  21. {bibdeskparser-0.2.0 → bibdeskparser-0.3.0}/src/bibdeskparser/exporting.py +0 -0
  22. {bibdeskparser-0.2.0 → bibdeskparser-0.3.0}/src/bibdeskparser/groups.py +0 -0
  23. {bibdeskparser-0.2.0 → bibdeskparser-0.3.0}/src/bibdeskparser/header.py +0 -0
  24. {bibdeskparser-0.2.0 → bibdeskparser-0.3.0}/src/bibdeskparser/names.py +0 -0
  25. {bibdeskparser-0.2.0 → bibdeskparser-0.3.0}/src/bibdeskparser/render.py +0 -0
  26. {bibdeskparser-0.2.0 → bibdeskparser-0.3.0}/src/bibdeskparser/search.py +0 -0
  27. {bibdeskparser-0.2.0 → bibdeskparser-0.3.0}/src/bibdeskparser/specifiers.py +0 -0
  28. {bibdeskparser-0.2.0 → bibdeskparser-0.3.0}/src/bibdeskparser/texmap.py +0 -0
  29. {bibdeskparser-0.2.0 → bibdeskparser-0.3.0}/src/bibdeskparser/writer.py +0 -0
@@ -1,11 +1,11 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: bibdeskparser
3
- Version: 0.2.0
3
+ Version: 0.3.0
4
4
  Summary: Parser for Bibdesk database files
5
5
  Author: Michael Goerz
6
6
  Author-email: Michael Goerz <mail@michaelgoerz.net>
7
7
  License: MIT license
8
- Classifier: Development Status :: 2 - Pre-Alpha
8
+ Classifier: Development Status :: 3 - Alpha
9
9
  Classifier: Intended Audience :: Developers
10
10
  Classifier: License :: OSI Approved :: MIT License
11
11
  Classifier: Natural Language :: English
@@ -14,10 +14,14 @@ Classifier: Programming Language :: Python :: 3.11
14
14
  Classifier: Programming Language :: Python :: 3.12
15
15
  Classifier: Programming Language :: Python :: 3.13
16
16
  Classifier: Programming Language :: Python :: 3.14
17
+ Requires-Dist: arxiv>=2.1
17
18
  Requires-Dist: bibtexparser==2.0.0b9
18
19
  Requires-Dist: click>=8
20
+ Requires-Dist: habanero>=2.2
21
+ Requires-Dist: httpx>=0.27.2
22
+ Requires-Dist: pylatexenc>=2.10
19
23
  Requires-Dist: tomli>=1.1.0 ; python_full_version < '3.11'
20
- Requires-Dist: pyobjc-framework-cocoa ; sys_platform == 'darwin' and extra == 'macos'
24
+ Requires-Dist: pyobjc-framework-cocoa>=8 ; sys_platform == 'darwin' and extra == 'macos'
21
25
  Requires-Python: >=3.10
22
26
  Project-URL: Homepage, https://github.com/goerz/bibdeskparser
23
27
  Provides-Extra: macos
@@ -101,6 +105,11 @@ independently of any project, with
101
105
  uv tool install bibdeskparser
102
106
  ```
103
107
 
108
+ > **Tip:** If you use the command-line tool frequently, consider creating a
109
+ > shorter symlink for `bibdeskparser`, e.g. `bib`, and set up a config file,
110
+ > with a `default_bib_file` so that you can write
111
+ > `bib import 10.22331/q-2022-12-07-871`.
112
+
104
113
 
105
114
  To install the latest development version from [Github][]:
106
115
 
@@ -111,41 +120,51 @@ pip install git+https://github.com/goerz/bibdeskparser.git@master#egg=bibdeskpar
111
120
 
112
121
  ## Usage
113
122
 
123
+ `Library` loads an existing `.bib` file and behaves like a dict of
124
+ citation key to `Entry`. The examples below use the example database
125
+ shipped in this repository at `tests/Refs/refs.bib`; substitute the
126
+ path to your own library.
127
+
114
128
  ```python
115
- >>> import tempfile
116
- >>> from pathlib import Path
117
- >>> from bibdeskparser import Entry, Library
118
- >>> tmpdir = tempfile.TemporaryDirectory()
119
- >>> bib_path = Path(tmpdir.name) / "references.bib"
120
- >>> bib = Library()
121
- >>> bib["Smith2020"] = Entry(
122
- ... "article",
123
- ... "Smith2020",
124
- ... fields={
125
- ... "title": "A Title",
126
- ... "author": "Smith, John and Doe, Jane",
127
- ... "journal": "J. Test",
128
- ... "year": "2020",
129
- ... },
130
- ... )
131
- >>> bib.save(bib_path)
132
-
133
- >>> bib = Library(bib_path)
134
- >>> entry = bib["Smith2020"]
129
+ >>> from bibdeskparser import Library
130
+ >>> bib = Library("tests/Refs/refs.bib")
131
+ >>> len(bib)
132
+ 61
133
+ >>> entry = bib["GoerzQ2022"]
135
134
  >>> print(entry["title"])
136
- A Title
137
- >>> print(entry.author[0].last[0]) # 'Smith'
138
- Smith
139
-
140
- >>> entry["title"] = "A Better Title"
141
- >>> bib.groups["Favorites"] = ("Smith2020",) # BibDesk static group
142
- >>> entry.groups
143
- ('Favorites',)
135
+ Quantum Optimal Control via Semi-Automatic Differentiation
136
+ >>> print(entry.author[0].last[0])
137
+ Goerz
138
+ >>> entry.files # linked PDF attachment, relative to the .bib file
139
+ ['GoerzQ2022.pdf']
140
+
141
+ ```
142
+
143
+ Full-text search and rendering a formatted citation:
144
+
145
+ ```python
146
+ >>> [e.key for e in bib.search("tractor atom interferometry")]
147
+ ['RaithelQST2022']
148
+ >>> print(bib.render("RaithelQST2022"))
149
+ G. Raithel, A. Duspayev, B. Dash, *et al.* *Principles of tractor atom interferometry*. [Quantum Sci. Technol. **8**, p. 014001](https://doi.org/10.1088/2058-9565/ac9429) (2022), [arXiv:2207.09023](https://arxiv.org/abs/2207.09023).
150
+
151
+ ```
152
+
153
+ Any change is written back with `save()`, preserving BibDesk's file
154
+ format byte-for-byte for everything that was not touched:
155
+
156
+ ```python
157
+ >>> entry["note"] = "Implemented in the QuantumControl.jl framework."
158
+ >>> bib.groups["To Read"] = ("BrifNJP2010", "KochEPJQT2022")
159
+ >>> bib["KochEPJQT2022"].groups
160
+ ('To Read',)
144
161
  >>> bib.save()
145
- >>> tmpdir.cleanup()
146
162
 
147
163
  ```
148
164
 
165
+ A new `.bib` file can also be created from scratch, with `Library()`
166
+ in Python or `bibdeskparser create` on the command line.
167
+
149
168
 
150
169
  ## Development
151
170
 
@@ -76,6 +76,11 @@ independently of any project, with
76
76
  uv tool install bibdeskparser
77
77
  ```
78
78
 
79
+ > **Tip:** If you use the command-line tool frequently, consider creating a
80
+ > shorter symlink for `bibdeskparser`, e.g. `bib`, and set up a config file,
81
+ > with a `default_bib_file` so that you can write
82
+ > `bib import 10.22331/q-2022-12-07-871`.
83
+
79
84
 
80
85
  To install the latest development version from [Github][]:
81
86
 
@@ -86,41 +91,51 @@ pip install git+https://github.com/goerz/bibdeskparser.git@master#egg=bibdeskpar
86
91
 
87
92
  ## Usage
88
93
 
94
+ `Library` loads an existing `.bib` file and behaves like a dict of
95
+ citation key to `Entry`. The examples below use the example database
96
+ shipped in this repository at `tests/Refs/refs.bib`; substitute the
97
+ path to your own library.
98
+
89
99
  ```python
90
- >>> import tempfile
91
- >>> from pathlib import Path
92
- >>> from bibdeskparser import Entry, Library
93
- >>> tmpdir = tempfile.TemporaryDirectory()
94
- >>> bib_path = Path(tmpdir.name) / "references.bib"
95
- >>> bib = Library()
96
- >>> bib["Smith2020"] = Entry(
97
- ... "article",
98
- ... "Smith2020",
99
- ... fields={
100
- ... "title": "A Title",
101
- ... "author": "Smith, John and Doe, Jane",
102
- ... "journal": "J. Test",
103
- ... "year": "2020",
104
- ... },
105
- ... )
106
- >>> bib.save(bib_path)
107
-
108
- >>> bib = Library(bib_path)
109
- >>> entry = bib["Smith2020"]
100
+ >>> from bibdeskparser import Library
101
+ >>> bib = Library("tests/Refs/refs.bib")
102
+ >>> len(bib)
103
+ 61
104
+ >>> entry = bib["GoerzQ2022"]
110
105
  >>> print(entry["title"])
111
- A Title
112
- >>> print(entry.author[0].last[0]) # 'Smith'
113
- Smith
114
-
115
- >>> entry["title"] = "A Better Title"
116
- >>> bib.groups["Favorites"] = ("Smith2020",) # BibDesk static group
117
- >>> entry.groups
118
- ('Favorites',)
106
+ Quantum Optimal Control via Semi-Automatic Differentiation
107
+ >>> print(entry.author[0].last[0])
108
+ Goerz
109
+ >>> entry.files # linked PDF attachment, relative to the .bib file
110
+ ['GoerzQ2022.pdf']
111
+
112
+ ```
113
+
114
+ Full-text search and rendering a formatted citation:
115
+
116
+ ```python
117
+ >>> [e.key for e in bib.search("tractor atom interferometry")]
118
+ ['RaithelQST2022']
119
+ >>> print(bib.render("RaithelQST2022"))
120
+ G. Raithel, A. Duspayev, B. Dash, *et al.* *Principles of tractor atom interferometry*. [Quantum Sci. Technol. **8**, p. 014001](https://doi.org/10.1088/2058-9565/ac9429) (2022), [arXiv:2207.09023](https://arxiv.org/abs/2207.09023).
121
+
122
+ ```
123
+
124
+ Any change is written back with `save()`, preserving BibDesk's file
125
+ format byte-for-byte for everything that was not touched:
126
+
127
+ ```python
128
+ >>> entry["note"] = "Implemented in the QuantumControl.jl framework."
129
+ >>> bib.groups["To Read"] = ("BrifNJP2010", "KochEPJQT2022")
130
+ >>> bib["KochEPJQT2022"].groups
131
+ ('To Read',)
119
132
  >>> bib.save()
120
- >>> tmpdir.cleanup()
121
133
 
122
134
  ```
123
135
 
136
+ A new `.bib` file can also be created from scratch, with `Library()`
137
+ in Python or `bibdeskparser create` on the command line.
138
+
124
139
 
125
140
  ## Development
126
141
 
@@ -4,7 +4,7 @@ build-backend = "uv_build"
4
4
 
5
5
  [project]
6
6
  name = "bibdeskparser"
7
- version = "0.2.0"
7
+ version = "0.3.0"
8
8
  description = "Parser for Bibdesk database files"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.10"
@@ -13,7 +13,7 @@ authors = [
13
13
  { name = "Michael Goerz", email = "mail@michaelgoerz.net" },
14
14
  ]
15
15
  classifiers = [
16
- "Development Status :: 2 - Pre-Alpha",
16
+ "Development Status :: 3 - Alpha",
17
17
  "Intended Audience :: Developers",
18
18
  "License :: OSI Approved :: MIT License",
19
19
  "Natural Language :: English",
@@ -24,8 +24,12 @@ classifiers = [
24
24
  "Programming Language :: Python :: 3.14",
25
25
  ]
26
26
  dependencies = [
27
+ "arxiv>=2.1",
27
28
  "bibtexparser==2.0.0b9",
28
29
  "click>=8",
30
+ "habanero>=2.2",
31
+ "httpx>=0.27.2",
32
+ "pylatexenc>=2.10",
29
33
  "tomli>=1.1.0; python_version < '3.11'",
30
34
  ]
31
35
 
@@ -33,14 +37,14 @@ dependencies = [
33
37
  bibdeskparser = "bibdeskparser.cli:main"
34
38
 
35
39
  [project.optional-dependencies]
36
- macos = ["pyobjc-framework-Cocoa; sys_platform == 'darwin'"]
40
+ macos = ["pyobjc-framework-Cocoa>=8; sys_platform == 'darwin'"]
37
41
 
38
42
  [project.urls]
39
43
  Homepage = "https://github.com/goerz/bibdeskparser"
40
44
 
41
45
  [dependency-groups]
42
46
  dev = [
43
- "pytest>=7",
47
+ "pytest>=7.2",
44
48
  "pytest-cov>=4",
45
49
  "pytest-xdist>=3",
46
50
  "packaging>=21",
@@ -71,7 +75,7 @@ release = [
71
75
  "click>=8",
72
76
  "gitpython>=3",
73
77
  "packaging>=21",
74
- "pytest>=7",
78
+ "pytest>=7.2",
75
79
  ]
76
80
 
77
81
  [tool.black]
@@ -0,0 +1,325 @@
1
+ """Retrieving abstracts from online sources and PDF attachments.
2
+
3
+ Provides {func}`fetch_abstract`, the free function backing
4
+ {meth}`bibdeskparser.Library.add_abstract`: gather candidate abstracts
5
+ for an entry from Crossref (by DOI), from the text of the entry's PDF
6
+ attachment, from the arXiv API (by eprint), and from Semantic Scholar
7
+ as a last resort; clean and validate each candidate (via
8
+ {mod}`bibdeskparser.abstracttext`); and combine the candidates into a
9
+ single result with an explicit confidence level.
10
+
11
+ This module is imported lazily (by `Library.add_abstract`): it pulls in
12
+ the network client libraries and `bibdeskparser.abstracttext` (and
13
+ through it `pylatexenc`), which nothing else in the package needs.
14
+ """
15
+
16
+ import re
17
+ import subprocess
18
+ import time
19
+ import urllib.parse
20
+ from collections import namedtuple
21
+
22
+ import arxiv
23
+ import httpx
24
+ from habanero import Crossref
25
+
26
+ from .abstracttext import _clean_text, _jats_to_text, _overlap, _validate
27
+ from .identifiers import _arxiv_id, _normalize_doi
28
+
29
+ __all__ = []
30
+
31
+ # All members whose name does not start with an underscore must be listed
32
+ # either in __all__ or in __private__
33
+ __private__ = [
34
+ "AbstractResult",
35
+ "CONFIDENCE_LEVELS",
36
+ "fetch_abstract",
37
+ ]
38
+
39
+
40
+ #: The result of `fetch_abstract` (and of `Library.add_abstract`,
41
+ #: which sets `applied`); see the `Library.add_abstract` docstring for
42
+ #: the meaning of each field.
43
+ AbstractResult = namedtuple(
44
+ "AbstractResult", ["abstract", "source", "confidence", "note", "applied"]
45
+ )
46
+
47
+ #: Confidence levels, in increasing order of trust.
48
+ CONFIDENCE_LEVELS = ("none", "low", "medium", "high")
49
+
50
+
51
+ # --------------------------------------------------------------------- #
52
+ # Candidate cleaning (delegated to `abstracttext`)
53
+ # --------------------------------------------------------------------- #
54
+
55
+
56
+ def _valid_or_none(text, label, note):
57
+ """`_clean_text(text)` if it validates, else `None` (appending
58
+ `label`-invalid:reason to the `note` list)."""
59
+ text = _clean_text(text)
60
+ if not text:
61
+ return None
62
+ ok, reason = _validate(text)
63
+ if not ok:
64
+ note.append(f"{label}-invalid:{reason}")
65
+ return None
66
+ return text
67
+
68
+
69
+ # --------------------------------------------------------------------- #
70
+ # PDF extraction
71
+ # --------------------------------------------------------------------- #
72
+
73
+
74
+ def _pdftotext(pdf_path, pages=4):
75
+ """The text of the first `pages` pages of `pdf_path` via the
76
+ `pdftotext` command-line tool; `(text, None)`, or `(None, reason)`
77
+ if the tool is missing or fails."""
78
+ cmd = ["pdftotext", "-q", "-f", "1", "-l", str(pages), str(pdf_path), "-"]
79
+ try:
80
+ out = subprocess.run(cmd, capture_output=True, timeout=60, check=False)
81
+ except FileNotFoundError:
82
+ return None, "pdftotext-missing"
83
+ # pylint: disable-next=broad-except
84
+ except Exception:
85
+ return None, "pdftotext-failed"
86
+ if out.returncode != 0:
87
+ return None, "pdftotext-failed"
88
+ return out.stdout.decode("utf-8", "replace"), None
89
+
90
+
91
+ # section headers that mark the END of an abstract
92
+ _END = re.compile(
93
+ r"(?im)^\s*("
94
+ r"DOI:|PACS|Keywords|Key words|Subject Areas|"
95
+ r"I\.\s+INTRODUCTION|1\.\s+Introduction|1\s+Introduction|"
96
+ r"INTRODUCTION\b|Contents\b|CONTENTS\b|"
97
+ r"\(Some figures may appear|Published by|©|"
98
+ r"This is an open access|Received\b)"
99
+ )
100
+
101
+ _RECVD_LINE = re.compile(
102
+ r"(?im)^.*?\b(Received|Dated|published|Published)\b.*\)\s*$"
103
+ )
104
+
105
+ _ABS_HEADER = re.compile(
106
+ r"(?im)^\s*(a\s?b\s?s\s?t\s?r\s?a\s?c\s?t|A\s?B\s?S\s?T\s?R\s?A\s?C\s?T)"
107
+ r"\.?\s*[:\-—]?\s*$"
108
+ )
109
+
110
+ _PARA_END = re.compile(r"\n[ \t]*\n") # blank line = paragraph break
111
+
112
+
113
+ def _abstract_until(rest):
114
+ """Trim `rest` to the abstract: up to the earliest of a blank
115
+ line, a PACS/DOI line, or a section header (`_END`)."""
116
+ ends = []
117
+ end_match = _END.search(rest)
118
+ if end_match:
119
+ ends.append(end_match.start())
120
+ # skip a leading blank line (between header and body), then find
121
+ # the first real paragraph break
122
+ leading = re.match(r"\s*", rest)
123
+ para = _PARA_END.search(rest, leading.end())
124
+ if para:
125
+ ends.append(para.start())
126
+ cut = min(ends) if ends else min(len(rest), 3500)
127
+ return rest[:cut].strip()
128
+
129
+
130
+ def _extract_pdf_abstract(text):
131
+ """The abstract paragraph of the PDF `text`, as
132
+ `(abstract_or_None, note)`; publisher-agnostic heuristics."""
133
+ if not text:
134
+ return None, "no-pdf-text"
135
+ # pdftotext sometimes renders the parens around "(Published ...)"
136
+ # as CJK brackets; normalize those (and sibling glyphs) first
137
+ text = text.translate(str.maketrans("共兲⬍⬎", "()<>"))
138
+
139
+ # APS / arXiv: the paragraph after a "(Received .../ Dated .../
140
+ # published ...)" line. Try each such line in reading order; the
141
+ # first that yields a valid paragraph wins (long review articles
142
+ # repeat "published" deep in the body).
143
+ for match in _RECVD_LINE.finditer(text):
144
+ block = _abstract_until(text[match.end() :])
145
+ if 150 < len(block) < 4000:
146
+ return block, "pdf-received"
147
+
148
+ # IOP / Elsevier: an explicit (possibly spaced) "Abstract" header
149
+ for match in _ABS_HEADER.finditer(text):
150
+ block = _abstract_until(text[match.end() :])
151
+ if 150 < len(block) < 4000:
152
+ return block, "pdf-abstract-header"
153
+
154
+ # inline "Abstract. ..." (text on the same line)
155
+ match = re.search(r"(?im)^\s*(abstract|ABSTRACT)[.:\-—]\s+(?=\S)", text)
156
+ if match:
157
+ block = _abstract_until(text[match.end() :])
158
+ if 150 < len(block) < 4000:
159
+ return block, "pdf-abstract-inline"
160
+
161
+ return None, "pdf-no-delimiter"
162
+
163
+
164
+ # --------------------------------------------------------------------- #
165
+ # Online sources
166
+ # --------------------------------------------------------------------- #
167
+
168
+
169
+ def _crossref_abstract(doi):
170
+ """The raw (JATS) abstract deposited with Crossref for `doi`, or
171
+ `None` (no deposit, or any network/data error)."""
172
+ try:
173
+ response = Crossref().works(ids=doi)
174
+ if not isinstance(response, dict):
175
+ return None
176
+ message = response.get("message") or {}
177
+ return message.get("abstract")
178
+ # pylint: disable-next=broad-except
179
+ except Exception:
180
+ return None
181
+
182
+
183
+ def _arxiv_summary(arxiv_id):
184
+ """The abstract ("summary") of `arxiv_id` from the arXiv API, or
185
+ `None` (no such paper, or any network error). `arxiv.Client`
186
+ enforces the arXiv API's rate limits."""
187
+ try:
188
+ search = arxiv.Search(id_list=[arxiv_id])
189
+ results = list(arxiv.Client().results(search))
190
+ return results[0].summary if results else None
191
+ # pylint: disable-next=broad-except
192
+ except Exception:
193
+ return None
194
+
195
+
196
+ _SS_API = "https://api.semanticscholar.org/graph/v1/paper/"
197
+
198
+
199
+ def _semanticscholar_abstract(ident):
200
+ """The abstract for `ident` (`"DOI:..."` or `"arXiv:..."`) from
201
+ the Semantic Scholar Graph API, or `None`. The public API is
202
+ rate-limited (~1 request/second): retry twice with a backoff."""
203
+ url = _SS_API + urllib.parse.quote(ident, safe=":/") + "?fields=abstract"
204
+ for attempt in range(3):
205
+ try:
206
+ response = httpx.get(url, timeout=30)
207
+ if response.status_code == 404:
208
+ return None
209
+ response.raise_for_status()
210
+ return response.json().get("abstract")
211
+ # pylint: disable-next=broad-except
212
+ except Exception:
213
+ time.sleep(1 + 2 * attempt)
214
+ return None
215
+
216
+
217
+ # --------------------------------------------------------------------- #
218
+ # Arbitration
219
+ # --------------------------------------------------------------------- #
220
+
221
+ # Token overlap above which two sources describe the same text (they
222
+ # are never identical: unicode cleanup, de-hyphenation, and JATS
223
+ # conversion all differ between sources).
224
+ _AGREE = 0.5
225
+
226
+
227
+ def _result(abstract, source, confidence, note):
228
+ return AbstractResult(abstract, source, confidence, "; ".join(note), False)
229
+
230
+
231
+ def fetch_abstract(*, doi=None, eprint=None, key=None, pdf_path=None):
232
+ """Fetch the best-supported abstract for an entry; the free
233
+ function backing {meth}`bibdeskparser.Library.add_abstract` (see
234
+ there for the source-selection and confidence semantics).
235
+
236
+ All identifying data is passed explicitly: the entry's `doi` and
237
+ `eprint` field values (either may be `None`), its citation `key`
238
+ (used only to guess an arXiv identifier when `eprint` is absent),
239
+ and the resolved path of its PDF attachment (`None` if it has
240
+ none). Returns an `AbstractResult` with `applied=False`; network
241
+ problems never raise (an unreachable source is skipped, see the
242
+ result's `note`).
243
+ """
244
+ note = []
245
+ doi = _normalize_doi(doi)
246
+ arxiv_id, arxiv_certain = _arxiv_id(eprint, key)
247
+
248
+ # --- Crossref (publisher JATS deposit, by DOI) -------------------- #
249
+ cr_text = None
250
+ if doi:
251
+ raw = _crossref_abstract(doi)
252
+ if raw is None:
253
+ note.append("cr-miss")
254
+ else:
255
+ cr_text = _valid_or_none(_jats_to_text(raw), "cr", note)
256
+
257
+ # --- PDF ----------------------------------------------------------- #
258
+ pdf_text = None
259
+ pdf_high = False
260
+ if pdf_path is None:
261
+ note.append("no-pdf")
262
+ else:
263
+ text, error = _pdftotext(pdf_path)
264
+ if text is None:
265
+ note.append(error)
266
+ else:
267
+ block, pdf_note = _extract_pdf_abstract(text)
268
+ note.append(pdf_note)
269
+ pdf_text = _valid_or_none(block, "pdf", note)
270
+ pdf_high = pdf_note in ("pdf-received", "pdf-abstract-header")
271
+
272
+ # The DOI guarantees the Crossref abstract belongs to this exact
273
+ # paper; when it agrees with the PDF, prefer Crossref (cleaner
274
+ # unicode). When the two *disagree*, one of them grabbed the wrong
275
+ # text: keep the PDF (it is literally this file) but flag it.
276
+ if cr_text and pdf_text:
277
+ overlap = _overlap(pdf_text, cr_text)
278
+ note.append(f"ov={overlap:.2f}")
279
+ if overlap >= _AGREE:
280
+ return _result(cr_text, "crossref", "high", note)
281
+ note.append("cr-disagree")
282
+ return _result(pdf_text, "pdf", "low", note)
283
+ if cr_text:
284
+ return _result(cr_text, "crossref", "high", note)
285
+ # An unambiguous PDF extraction needs no online confirmation
286
+ # (fast path: skips the rate-limited arXiv/Semantic Scholar calls)
287
+ if pdf_text and pdf_high:
288
+ return _result(pdf_text, "pdf", "high", note)
289
+
290
+ # --- arXiv (by eprint, or an id guessed from the key) ------------- #
291
+ if arxiv_id:
292
+ ax_text = _valid_or_none(_arxiv_summary(arxiv_id), "arxiv", note)
293
+ confidence = "high" if arxiv_certain else "medium"
294
+ if ax_text and pdf_text:
295
+ overlap = _overlap(pdf_text, ax_text)
296
+ note.append(f"arxiv-ov={overlap:.2f}")
297
+ if overlap >= _AGREE:
298
+ return _result(ax_text, "arxiv", confidence, note)
299
+ note.append("arxiv-disagree")
300
+ return _result(pdf_text, "pdf", "low", note)
301
+ if ax_text:
302
+ return _result(ax_text, "arxiv", confidence, note)
303
+
304
+ # --- Semantic Scholar (clean fallback; only when needed) ---------- #
305
+ ident = certain = None
306
+ if doi:
307
+ ident, certain = f"DOI:{doi}", True
308
+ elif arxiv_id:
309
+ ident, certain = f"arXiv:{arxiv_id}", arxiv_certain
310
+ if ident:
311
+ ss_text = _valid_or_none(_semanticscholar_abstract(ident), "ss", note)
312
+ confidence = "high" if certain else "medium"
313
+ if ss_text and pdf_text:
314
+ overlap = _overlap(pdf_text, ss_text)
315
+ note.append(f"ss-ov={overlap:.2f}")
316
+ if overlap >= _AGREE:
317
+ return _result(ss_text, "semanticscholar", confidence, note)
318
+ note.append("ss-disagree")
319
+ return _result(pdf_text, "pdf", "low", note)
320
+ if ss_text:
321
+ return _result(ss_text, "semanticscholar", confidence, note)
322
+
323
+ if pdf_text:
324
+ return _result(pdf_text, "pdf", "medium", note)
325
+ return _result("", "none", "none", note)