papers-cli 2.16__tar.gz → 2.16.2__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.
- {papers_cli-2.16 → papers_cli-2.16.2}/.github/workflows/pypi.yml +7 -0
- {papers_cli-2.16 → papers_cli-2.16.2}/PKG-INFO +1 -1
- {papers_cli-2.16 → papers_cli-2.16.2}/papers/__main__.py +1 -1
- {papers_cli-2.16 → papers_cli-2.16.2}/papers/_version.py +3 -3
- {papers_cli-2.16 → papers_cli-2.16.2}/papers/bib.py +41 -41
- {papers_cli-2.16 → papers_cli-2.16.2}/papers/duplicate.py +70 -1
- {papers_cli-2.16 → papers_cli-2.16.2}/papers_cli.egg-info/PKG-INFO +1 -1
- {papers_cli-2.16 → papers_cli-2.16.2}/tests/test_bib_unit.py +1 -3
- {papers_cli-2.16 → papers_cli-2.16.2}/tests/test_duplicates.py +112 -0
- {papers_cli-2.16 → papers_cli-2.16.2}/.gitattributes +0 -0
- {papers_cli-2.16 → papers_cli-2.16.2}/.github/FUNDING.yml +0 -0
- {papers_cli-2.16 → papers_cli-2.16.2}/.github/workflows/release.yml +0 -0
- {papers_cli-2.16 → papers_cli-2.16.2}/.github/workflows/tests.yml +0 -0
- {papers_cli-2.16 → papers_cli-2.16.2}/.gitignore +0 -0
- {papers_cli-2.16 → papers_cli-2.16.2}/LICENSE +0 -0
- {papers_cli-2.16 → papers_cli-2.16.2}/README.md +0 -0
- {papers_cli-2.16 → papers_cli-2.16.2}/papers/__init__.py +0 -0
- {papers_cli-2.16 → papers_cli-2.16.2}/papers/config.py +0 -0
- {papers_cli-2.16 → papers_cli-2.16.2}/papers/encoding.py +0 -0
- {papers_cli-2.16 → papers_cli-2.16.2}/papers/entries.py +0 -0
- {papers_cli-2.16 → papers_cli-2.16.2}/papers/extract.py +0 -0
- {papers_cli-2.16 → papers_cli-2.16.2}/papers/filename.py +0 -0
- {papers_cli-2.16 → papers_cli-2.16.2}/papers/latexenc.py +0 -0
- {papers_cli-2.16 → papers_cli-2.16.2}/papers/utils.py +0 -0
- {papers_cli-2.16 → papers_cli-2.16.2}/papers_cli.egg-info/SOURCES.txt +0 -0
- {papers_cli-2.16 → papers_cli-2.16.2}/papers_cli.egg-info/dependency_links.txt +0 -0
- {papers_cli-2.16 → papers_cli-2.16.2}/papers_cli.egg-info/entry_points.txt +0 -0
- {papers_cli-2.16 → papers_cli-2.16.2}/papers_cli.egg-info/requires.txt +0 -0
- {papers_cli-2.16 → papers_cli-2.16.2}/papers_cli.egg-info/top_level.txt +0 -0
- {papers_cli-2.16 → papers_cli-2.16.2}/pyproject.toml +0 -0
- {papers_cli-2.16 → papers_cli-2.16.2}/requirements.txt +0 -0
- {papers_cli-2.16 → papers_cli-2.16.2}/scripts/README.md +0 -0
- {papers_cli-2.16 → papers_cli-2.16.2}/scripts/benchmark_bibtexparser_load.py +0 -0
- {papers_cli-2.16 → papers_cli-2.16.2}/scripts/generate_dummy_bib.py +0 -0
- {papers_cli-2.16 → papers_cli-2.16.2}/setup.cfg +0 -0
- {papers_cli-2.16 → papers_cli-2.16.2}/setup.py +0 -0
- {papers_cli-2.16 → papers_cli-2.16.2}/tests/__init__.py +0 -0
- {papers_cli-2.16 → papers_cli-2.16.2}/tests/common.py +0 -0
- {papers_cli-2.16 → papers_cli-2.16.2}/tests/config.py +0 -0
- {papers_cli-2.16 → papers_cli-2.16.2}/tests/download.py +0 -0
- {papers_cli-2.16 → papers_cli-2.16.2}/tests/downloadedpapers/.gitignore +0 -0
- {papers_cli-2.16 → papers_cli-2.16.2}/tests/downloadedpapers/bg-8-515-2011.txt +0 -0
- {papers_cli-2.16 → papers_cli-2.16.2}/tests/test_add.py +0 -0
- {papers_cli-2.16 → papers_cli-2.16.2}/tests/test_biblio.py +0 -0
- {papers_cli-2.16 → papers_cli-2.16.2}/tests/test_check.py +0 -0
- {papers_cli-2.16 → papers_cli-2.16.2}/tests/test_config_unit.py +0 -0
- {papers_cli-2.16 → papers_cli-2.16.2}/tests/test_encoding.py +0 -0
- {papers_cli-2.16 → papers_cli-2.16.2}/tests/test_extract.py +0 -0
- {papers_cli-2.16 → papers_cli-2.16.2}/tests/test_extract_unit.py +0 -0
- {papers_cli-2.16 → papers_cli-2.16.2}/tests/test_filecheck.py +0 -0
- {papers_cli-2.16 → papers_cli-2.16.2}/tests/test_filename_unit.py +0 -0
- {papers_cli-2.16 → papers_cli-2.16.2}/tests/test_install.py +0 -0
- {papers_cli-2.16 → papers_cli-2.16.2}/tests/test_latexenc.py +0 -0
- {papers_cli-2.16 → papers_cli-2.16.2}/tests/test_list.py +0 -0
- {papers_cli-2.16 → papers_cli-2.16.2}/tests/test_misc.py +0 -0
- {papers_cli-2.16 → papers_cli-2.16.2}/tests/test_undo.py +0 -0
- {papers_cli-2.16 → papers_cli-2.16.2}/tests/test_utils.py +0 -0
|
@@ -1,3 +1,7 @@
|
|
|
1
|
+
# Publishes to PyPI on tag push (e.g. v2.16.1).
|
|
2
|
+
# If this job fails with "400 Bad Request", the package may still be on PyPI: that usually
|
|
3
|
+
# means the workflow ran twice (e.g. tag pushed twice or job re-run) and the second upload
|
|
4
|
+
# failed because the version already exists. Use verbose: true output to see the real error.
|
|
1
5
|
name: Publish to PyPI
|
|
2
6
|
|
|
3
7
|
on:
|
|
@@ -34,3 +38,6 @@ jobs:
|
|
|
34
38
|
|
|
35
39
|
- name: Publish to PyPI
|
|
36
40
|
uses: pypa/gh-action-pypi-publish@release/v1
|
|
41
|
+
with:
|
|
42
|
+
# Show full PyPI error (e.g. "400 File already exists") when upload fails
|
|
43
|
+
verbose: true
|
|
@@ -18,7 +18,7 @@ from papers.extract import extract_pdf_doi, isvaliddoi, extract_pdf_metadata
|
|
|
18
18
|
from papers.extract import fetch_bibtex_by_doi, fetch_bibtex_by_fulltext_crossref, fetch_bibtex_by_fulltext_scholar
|
|
19
19
|
from papers.encoding import parse_file, format_file, family_names, format_entries, standard_name, format_entry, parse_keywords, format_key
|
|
20
20
|
from papers.config import bcolors, Config, search_config, CONFIG_FILE, CONFIG_FILE_LOCAL, DATA_DIR, CONFIG_FILE_LEGACY, BACKUP_DIR
|
|
21
|
-
from papers.duplicate import list_duplicates, list_uniques, edit_entries
|
|
21
|
+
from papers.duplicate import list_duplicates, list_uniques, edit_entries, title_id
|
|
22
22
|
from papers.entries import get_entry_val
|
|
23
23
|
from papers.bib import (Biblio, FUZZY_RATIO, DEFAULT_SIMILARITY, entry_filecheck,
|
|
24
24
|
backupfile as backupfile_func, isvalidkey, DuplicateKeyError, clean_filesdir,
|
|
@@ -28,7 +28,7 @@ version_tuple: VERSION_TUPLE
|
|
|
28
28
|
commit_id: COMMIT_ID
|
|
29
29
|
__commit_id__: COMMIT_ID
|
|
30
30
|
|
|
31
|
-
__version__ = version = '2.16'
|
|
32
|
-
__version_tuple__ = version_tuple = (2, 16)
|
|
31
|
+
__version__ = version = '2.16.2'
|
|
32
|
+
__version_tuple__ = version_tuple = (2, 16, 2)
|
|
33
33
|
|
|
34
|
-
__commit_id__ = commit_id = '
|
|
34
|
+
__commit_id__ = commit_id = 'g60ea4dbb9'
|
|
@@ -35,7 +35,16 @@ from papers.filename import NAMEFORMAT, KEYFORMAT
|
|
|
35
35
|
from papers.utils import bcolors, checksum, move as _move
|
|
36
36
|
import papers.config
|
|
37
37
|
|
|
38
|
-
from papers.duplicate import
|
|
38
|
+
from papers.duplicate import (
|
|
39
|
+
conflict_resolution_on_insert,
|
|
40
|
+
entry_diff,
|
|
41
|
+
merge_files,
|
|
42
|
+
check_duplicates,
|
|
43
|
+
entry_id,
|
|
44
|
+
_build_duplicate_index,
|
|
45
|
+
_get_duplicate_candidates,
|
|
46
|
+
_add_to_duplicate_index,
|
|
47
|
+
)
|
|
39
48
|
|
|
40
49
|
# KEY GENERATION
|
|
41
50
|
# ==============
|
|
@@ -67,41 +76,8 @@ def append_abc(key, keys=[]):
|
|
|
67
76
|
return Key
|
|
68
77
|
|
|
69
78
|
|
|
70
|
-
# DUPLICATE DEFINITION
|
|
71
|
-
#
|
|
72
|
-
|
|
73
|
-
def _remove_unicode(s, replace='_'):
|
|
74
|
-
s2 = []
|
|
75
|
-
for c in s:
|
|
76
|
-
if ord(c) > 128:
|
|
77
|
-
c = replace
|
|
78
|
-
s2.append(c)
|
|
79
|
-
return ''.join(s2)
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
def _simplify_string(s):
|
|
83
|
-
' replace unicode, strip, lower case '
|
|
84
|
-
# try:
|
|
85
|
-
# s = latex_to_unicode(s)
|
|
86
|
-
# except Exception as error:
|
|
87
|
-
# raise
|
|
88
|
-
# logger.warning('simplify string: failed to remove latex: '+str(error))
|
|
89
|
-
s = _remove_unicode(s)
|
|
90
|
-
return s.lower().strip()
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
def author_id(e):
|
|
94
|
-
return _simplify_string(' '.join(family_names(get_entry_val(e, 'author', ''))))
|
|
95
|
-
|
|
96
|
-
def title_id(e):
|
|
97
|
-
return _simplify_string(get_entry_val(e, 'title', ''))
|
|
98
|
-
|
|
99
|
-
def entry_id(e):
|
|
100
|
-
"""entry identifier which is not the bibtex key
|
|
101
|
-
"""
|
|
102
|
-
authortitle = ''.join([author_id(e), title_id(e)])
|
|
103
|
-
return (get_entry_val(e, 'doi', '').lower(), authortitle)
|
|
104
|
-
|
|
79
|
+
# DUPLICATE DEFINITION (entry_id, author_id, title_id live in papers.duplicate)
|
|
80
|
+
# =============================================================================
|
|
105
81
|
|
|
106
82
|
FUZZY_RATIO = 80
|
|
107
83
|
|
|
@@ -172,7 +148,6 @@ def are_duplicates(e1, e2, similarity=DEFAULT_SIMILARITY, fuzzy_ratio=FUZZY_RATI
|
|
|
172
148
|
return score >= target
|
|
173
149
|
|
|
174
150
|
|
|
175
|
-
|
|
176
151
|
def hidden_bibtex(direc):
|
|
177
152
|
" save metadata for a bundle of files "
|
|
178
153
|
dirname = os.path.basename(direc)
|
|
@@ -301,7 +276,10 @@ class Biblio:
|
|
|
301
276
|
# additional checks on DOI etc...
|
|
302
277
|
if check_duplicate:
|
|
303
278
|
logger.debug('check duplicates : TRUE')
|
|
304
|
-
return self.insert_entry_check(
|
|
279
|
+
return self.insert_entry_check(
|
|
280
|
+
entry, update_key=update_key, rename=rename, copy=copy,
|
|
281
|
+
duplicate_index=checkopt.pop('duplicate_index', None), **checkopt
|
|
282
|
+
)
|
|
305
283
|
else:
|
|
306
284
|
logger.debug('check duplicates : FALSE')
|
|
307
285
|
|
|
@@ -335,8 +313,17 @@ class Biblio:
|
|
|
335
313
|
|
|
336
314
|
return [ entry ]
|
|
337
315
|
|
|
338
|
-
def insert_entry_check(self, entry, update_key=False, mergefiles=True, on_conflict='i', rename=False, copy=False):
|
|
339
|
-
|
|
316
|
+
def insert_entry_check(self, entry, update_key=False, mergefiles=True, on_conflict='i', rename=False, copy=False, duplicate_index=None):
|
|
317
|
+
# FUZZY can match on token_set_ratio(authortitle) without same doi/authortitle, so index would miss some duplicates
|
|
318
|
+
if self.similarity == 'FUZZY':
|
|
319
|
+
candidates = self.entries
|
|
320
|
+
elif duplicate_index is not None:
|
|
321
|
+
by_doi, by_authortitle = duplicate_index
|
|
322
|
+
candidates = _get_duplicate_candidates(entry, by_doi, by_authortitle)
|
|
323
|
+
else:
|
|
324
|
+
by_doi, by_authortitle = _build_duplicate_index(self.entries)
|
|
325
|
+
candidates = _get_duplicate_candidates(entry, by_doi, by_authortitle)
|
|
326
|
+
duplicates = [e for e in candidates if self.eq(e, entry)]
|
|
340
327
|
|
|
341
328
|
if not duplicates:
|
|
342
329
|
logger.debug('not a duplicate')
|
|
@@ -426,6 +413,12 @@ class Biblio:
|
|
|
426
413
|
if convert_to_unicode:
|
|
427
414
|
bib = latex_to_unicode_library(bib)
|
|
428
415
|
entries = []
|
|
416
|
+
# Build index once so duplicate check is O(candidates) not O(library size) per entry
|
|
417
|
+
check_dup = kw.get('check_duplicate', True)
|
|
418
|
+
if check_dup and bib.entries:
|
|
419
|
+
duplicate_index = _build_duplicate_index(self.entries)
|
|
420
|
+
else:
|
|
421
|
+
duplicate_index = None
|
|
429
422
|
for e in bib.entries:
|
|
430
423
|
files = []
|
|
431
424
|
if "file" in e:
|
|
@@ -436,7 +429,14 @@ class Biblio:
|
|
|
436
429
|
if files:
|
|
437
430
|
self.set_files(e, files)
|
|
438
431
|
|
|
439
|
-
|
|
432
|
+
run_kw = dict(kw)
|
|
433
|
+
if duplicate_index is not None:
|
|
434
|
+
run_kw['duplicate_index'] = duplicate_index
|
|
435
|
+
added = self.insert_entry(e, **run_kw)
|
|
436
|
+
entries.extend(added)
|
|
437
|
+
if duplicate_index is not None:
|
|
438
|
+
for new_e in added:
|
|
439
|
+
_add_to_duplicate_index(new_e, duplicate_index[0], duplicate_index[1])
|
|
440
440
|
return entries
|
|
441
441
|
|
|
442
442
|
|
|
@@ -18,12 +18,81 @@ import logging
|
|
|
18
18
|
logger = logging.getLogger(__name__)
|
|
19
19
|
|
|
20
20
|
from papers.extract import isvaliddoi, fetch_entry
|
|
21
|
-
from papers.encoding import parse_file, format_file, format_entries
|
|
21
|
+
from papers.encoding import parse_file, format_file, format_entries, family_names
|
|
22
22
|
from papers.entries import update_entry
|
|
23
23
|
|
|
24
24
|
from papers.utils import bcolors, checksum
|
|
25
25
|
|
|
26
26
|
|
|
27
|
+
# ENTRY IDENTITY (for duplicate indexing and comparison)
|
|
28
|
+
# ======================================================
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _remove_unicode(s, replace='_'):
|
|
32
|
+
s2 = []
|
|
33
|
+
for c in s:
|
|
34
|
+
if ord(c) > 128:
|
|
35
|
+
c = replace
|
|
36
|
+
s2.append(c)
|
|
37
|
+
return ''.join(s2)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _simplify_string(s):
|
|
41
|
+
"""Replace unicode, strip, lower case."""
|
|
42
|
+
s = _remove_unicode(s)
|
|
43
|
+
return s.lower().strip()
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def author_id(e):
|
|
47
|
+
return _simplify_string(' '.join(family_names(get_entry_val(e, 'author', ''))))
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def title_id(e):
|
|
51
|
+
return _simplify_string(get_entry_val(e, 'title', ''))
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def entry_id(e):
|
|
55
|
+
"""Entry identifier which is not the bibtex key. Returns (doi_lower, authortitle)."""
|
|
56
|
+
authortitle = ''.join([author_id(e), title_id(e)])
|
|
57
|
+
return (get_entry_val(e, 'doi', '').lower(), authortitle)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _build_duplicate_index(entries):
|
|
61
|
+
"""Build (by_doi, by_authortitle) so duplicate checks only run on candidates. O(n)."""
|
|
62
|
+
by_doi = {}
|
|
63
|
+
by_authortitle = {}
|
|
64
|
+
for e in entries:
|
|
65
|
+
doi, at = entry_id(e)
|
|
66
|
+
if doi:
|
|
67
|
+
by_doi.setdefault(doi, []).append(e)
|
|
68
|
+
if at:
|
|
69
|
+
by_authortitle.setdefault(at, []).append(e)
|
|
70
|
+
return (by_doi, by_authortitle)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _get_duplicate_candidates(entry, by_doi, by_authortitle):
|
|
74
|
+
"""Return list of entries that might duplicate `entry` (same doi or authortitle)."""
|
|
75
|
+
doi, at = entry_id(entry)
|
|
76
|
+
candidates = by_doi.get(doi, []) + by_authortitle.get(at, [])
|
|
77
|
+
# deduplicate by id(); same entry can be in both indices
|
|
78
|
+
seen = set()
|
|
79
|
+
out = []
|
|
80
|
+
for e in candidates:
|
|
81
|
+
if id(e) not in seen:
|
|
82
|
+
seen.add(id(e))
|
|
83
|
+
out.append(e)
|
|
84
|
+
return out
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _add_to_duplicate_index(entry, by_doi, by_authortitle):
|
|
88
|
+
"""Register one entry in the index (after it was added to the library)."""
|
|
89
|
+
doi, at = entry_id(entry)
|
|
90
|
+
if doi:
|
|
91
|
+
by_doi.setdefault(doi, []).append(entry)
|
|
92
|
+
if at:
|
|
93
|
+
by_authortitle.setdefault(at, []).append(entry)
|
|
94
|
+
|
|
95
|
+
|
|
27
96
|
# SEARCH DUPLICATES
|
|
28
97
|
# =================
|
|
29
98
|
|
|
@@ -6,9 +6,6 @@ from papers.bib import (
|
|
|
6
6
|
isvalidkey,
|
|
7
7
|
compare_entries,
|
|
8
8
|
are_duplicates,
|
|
9
|
-
author_id,
|
|
10
|
-
title_id,
|
|
11
|
-
entry_id,
|
|
12
9
|
hidden_bibtex,
|
|
13
10
|
backupfile as backupfile_fn,
|
|
14
11
|
EXACT_DUPLICATES,
|
|
@@ -16,6 +13,7 @@ from papers.bib import (
|
|
|
16
13
|
FAIR_DUPLICATES,
|
|
17
14
|
PARTIAL_DUPLICATES,
|
|
18
15
|
)
|
|
16
|
+
from papers.duplicate import author_id, title_id, entry_id
|
|
19
17
|
|
|
20
18
|
|
|
21
19
|
class TestHiddenBibtex(unittest.TestCase):
|
|
@@ -17,6 +17,7 @@ from papers.duplicate import (
|
|
|
17
17
|
MergedEntry,
|
|
18
18
|
ConflictingField,
|
|
19
19
|
)
|
|
20
|
+
from papers.entries import entry_from_dict, get_entry_val
|
|
20
21
|
from tests.common import PAPERSCMD, paperscmd, BibTest
|
|
21
22
|
|
|
22
23
|
|
|
@@ -204,6 +205,117 @@ class TestDuplicatesPartial(TestDuplicatesFair):
|
|
|
204
205
|
self.assertTrue(self.isduplicate(self.reference, self.conflictdoi))
|
|
205
206
|
|
|
206
207
|
|
|
208
|
+
class TestInsertEntryCheckSimilarityLevels(unittest.TestCase):
|
|
209
|
+
"""
|
|
210
|
+
Test that insert_entry(..., check_duplicate=True) applies each similarity level
|
|
211
|
+
correctly. Protects the duplicate-check logic (and index optimization) from
|
|
212
|
+
regressions: duplicates must be merged, non-duplicates must be added.
|
|
213
|
+
"""
|
|
214
|
+
|
|
215
|
+
def _add_entry(self, biblio, entry_dict, check_duplicate=True, on_conflict='u'):
|
|
216
|
+
entry = entry_from_dict(entry_dict)
|
|
217
|
+
return biblio.insert_entry(
|
|
218
|
+
entry, check_duplicate=check_duplicate, on_conflict=on_conflict
|
|
219
|
+
)
|
|
220
|
+
|
|
221
|
+
def test_exact_duplicate_merged_non_duplicate_added(self):
|
|
222
|
+
b = Biblio(similarity='EXACT')
|
|
223
|
+
e1 = {'ENTRYTYPE': 'article', 'ID': 'Key1', 'author': 'Smith', 'title': 'Same', 'doi': '10.1/a', 'year': '2020'}
|
|
224
|
+
self._add_entry(b, e1)
|
|
225
|
+
self.assertEqual(len(b.entries), 1)
|
|
226
|
+
# Same content (including same key) → entry_content_equal → exact duplicate → merge
|
|
227
|
+
e2 = {'ENTRYTYPE': 'article', 'ID': 'Key1', 'author': 'Smith', 'title': 'Same', 'doi': '10.1/a', 'year': '2020'}
|
|
228
|
+
merged = self._add_entry(b, e2)
|
|
229
|
+
self.assertEqual(len(b.entries), 1, 'EXACT: duplicate should be merged')
|
|
230
|
+
self.assertEqual(get_entry_val(merged[0], 'ID', '').lower(), 'key1', 'EXACT: should keep first entry')
|
|
231
|
+
# Different title → not duplicate → add
|
|
232
|
+
e3 = {'ENTRYTYPE': 'article', 'ID': 'Key3', 'author': 'Smith', 'title': 'Other', 'doi': '10.1/b', 'year': '2020'}
|
|
233
|
+
self._add_entry(b, e3)
|
|
234
|
+
self.assertEqual(len(b.entries), 2, 'EXACT: non-duplicate should be added')
|
|
235
|
+
|
|
236
|
+
def test_good_duplicate_merged_non_duplicate_added(self):
|
|
237
|
+
b = Biblio(similarity='GOOD')
|
|
238
|
+
e1 = {'ENTRYTYPE': 'article', 'ID': 'Key1', 'author': 'Smith, John', 'title': 'A paper', 'doi': '10.1/a', 'year': '2020'}
|
|
239
|
+
self._add_entry(b, e1)
|
|
240
|
+
self.assertEqual(len(b.entries), 1)
|
|
241
|
+
# Same (doi, authortitle), different key/year → GOOD duplicate → merge
|
|
242
|
+
e2 = {'ENTRYTYPE': 'article', 'ID': 'Key2', 'author': 'Smith, John', 'title': 'A paper', 'doi': '10.1/a', 'year': '2021'}
|
|
243
|
+
self._add_entry(b, e2)
|
|
244
|
+
self.assertEqual(len(b.entries), 1, 'GOOD: duplicate should be merged')
|
|
245
|
+
# Different authortitle → not duplicate
|
|
246
|
+
e3 = {'ENTRYTYPE': 'article', 'ID': 'Key3', 'author': 'Jones', 'title': 'Other', 'doi': '10.1/b', 'year': '2020'}
|
|
247
|
+
self._add_entry(b, e3)
|
|
248
|
+
self.assertEqual(len(b.entries), 2, 'GOOD: non-duplicate should be added')
|
|
249
|
+
|
|
250
|
+
def test_fair_duplicate_merged_non_duplicate_added(self):
|
|
251
|
+
b = Biblio(similarity='FAIR')
|
|
252
|
+
e1 = {'ENTRYTYPE': 'article', 'ID': 'Key1', 'author': 'Smith', 'title': 'Paper A', 'doi': '10.1/a', 'year': '2020'}
|
|
253
|
+
self._add_entry(b, e1)
|
|
254
|
+
self.assertEqual(len(b.entries), 1)
|
|
255
|
+
# Same doi, different author/title → FAIR duplicate → merge
|
|
256
|
+
e2 = {'ENTRYTYPE': 'article', 'ID': 'Key2', 'author': 'Jones', 'title': 'Paper B', 'doi': '10.1/a', 'year': '2021'}
|
|
257
|
+
self._add_entry(b, e2)
|
|
258
|
+
self.assertEqual(len(b.entries), 1, 'FAIR: duplicate (same doi) should be merged')
|
|
259
|
+
# Different doi and different authortitle → not duplicate
|
|
260
|
+
e3 = {'ENTRYTYPE': 'article', 'ID': 'Key3', 'author': 'Lee', 'title': 'Other', 'doi': '10.1/b', 'year': '2020'}
|
|
261
|
+
self._add_entry(b, e3)
|
|
262
|
+
self.assertEqual(len(b.entries), 2, 'FAIR: non-duplicate should be added')
|
|
263
|
+
|
|
264
|
+
def test_partial_duplicate_merged_non_duplicate_added(self):
|
|
265
|
+
b = Biblio(similarity='PARTIAL')
|
|
266
|
+
e1 = {'ENTRYTYPE': 'article', 'ID': 'Key1', 'author': 'Smith', 'title': 'Same title', 'doi': '10.1/a', 'year': '2020'}
|
|
267
|
+
self._add_entry(b, e1)
|
|
268
|
+
self.assertEqual(len(b.entries), 1)
|
|
269
|
+
# Same authortitle, different doi → PARTIAL duplicate → merge
|
|
270
|
+
e2 = {'ENTRYTYPE': 'article', 'ID': 'Key2', 'author': 'Smith', 'title': 'Same title', 'doi': '10.1/b', 'year': '2021'}
|
|
271
|
+
self._add_entry(b, e2)
|
|
272
|
+
self.assertEqual(len(b.entries), 1, 'PARTIAL: duplicate (same authortitle) should be merged')
|
|
273
|
+
# Different doi and different authortitle → not duplicate
|
|
274
|
+
e3 = {'ENTRYTYPE': 'article', 'ID': 'Key3', 'author': 'Jones', 'title': 'Other', 'doi': '10.1/c', 'year': '2020'}
|
|
275
|
+
self._add_entry(b, e3)
|
|
276
|
+
self.assertEqual(len(b.entries), 2, 'PARTIAL: non-duplicate should be added')
|
|
277
|
+
|
|
278
|
+
def test_fuzzy_duplicate_merged_non_duplicate_added(self):
|
|
279
|
+
b = Biblio(similarity='FUZZY')
|
|
280
|
+
# Same authortitle → GOOD duplicate (score 103 >= 100) → merge. Exercises FUZZY full-scan path.
|
|
281
|
+
e1 = {'ENTRYTYPE': 'article', 'ID': 'Key1', 'author': 'Smith, John', 'title': 'Climate change impacts', 'doi': '', 'year': '2020'}
|
|
282
|
+
self._add_entry(b, e1)
|
|
283
|
+
self.assertEqual(len(b.entries), 1)
|
|
284
|
+
e2 = {'ENTRYTYPE': 'article', 'ID': 'Key2', 'author': 'Smith, John', 'title': 'Climate change impacts', 'doi': '', 'year': '2021'}
|
|
285
|
+
self._add_entry(b, e2)
|
|
286
|
+
self.assertEqual(len(b.entries), 1, 'FUZZY: duplicate (same authortitle) should be merged')
|
|
287
|
+
# Clearly different → not duplicate
|
|
288
|
+
e3 = {'ENTRYTYPE': 'article', 'ID': 'Key3', 'author': 'Jones', 'title': 'Unrelated topic', 'doi': '10.1/x', 'year': '2020'}
|
|
289
|
+
self._add_entry(b, e3)
|
|
290
|
+
self.assertEqual(len(b.entries), 2, 'FUZZY: non-duplicate should be added')
|
|
291
|
+
|
|
292
|
+
def test_batch_add_with_index_same_result_as_single_add(self):
|
|
293
|
+
"""Index optimization (add_bibtex) must yield same duplicate result as single inserts."""
|
|
294
|
+
bib_with_dup = """@article{Key1,
|
|
295
|
+
author = {Smith},
|
|
296
|
+
title = {Same},
|
|
297
|
+
doi = {10.1/a},
|
|
298
|
+
year = {2020}
|
|
299
|
+
}
|
|
300
|
+
@article{Key2,
|
|
301
|
+
author = {Smith},
|
|
302
|
+
title = {Same},
|
|
303
|
+
doi = {10.1/a},
|
|
304
|
+
year = {2021}
|
|
305
|
+
}
|
|
306
|
+
"""
|
|
307
|
+
b = Biblio(similarity='PARTIAL')
|
|
308
|
+
b.add_bibtex(bib_with_dup, check_duplicate=True, on_conflict='u')
|
|
309
|
+
# Key1 and Key2 are GOOD/PARTIAL duplicates (same doi, same authortitle) → merged to one
|
|
310
|
+
self.assertEqual(len(b.entries), 1, 'add_bibtex with duplicate pair should merge (index path)')
|
|
311
|
+
# Same bib added one-by-one should also merge
|
|
312
|
+
b2 = Biblio(similarity='PARTIAL')
|
|
313
|
+
db = bp_parse_string(bib_with_dup)
|
|
314
|
+
for e in db.entries:
|
|
315
|
+
b2.insert_entry(e, check_duplicate=True, on_conflict='u')
|
|
316
|
+
self.assertEqual(len(b2.entries), 1, 'single inserts with duplicate pair should merge (no-index path)')
|
|
317
|
+
|
|
318
|
+
|
|
207
319
|
class TestDuplicates(TestDuplicatesPartial):
|
|
208
320
|
|
|
209
321
|
@staticmethod
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|