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.
Files changed (57) hide show
  1. {papers_cli-2.16 → papers_cli-2.16.2}/.github/workflows/pypi.yml +7 -0
  2. {papers_cli-2.16 → papers_cli-2.16.2}/PKG-INFO +1 -1
  3. {papers_cli-2.16 → papers_cli-2.16.2}/papers/__main__.py +1 -1
  4. {papers_cli-2.16 → papers_cli-2.16.2}/papers/_version.py +3 -3
  5. {papers_cli-2.16 → papers_cli-2.16.2}/papers/bib.py +41 -41
  6. {papers_cli-2.16 → papers_cli-2.16.2}/papers/duplicate.py +70 -1
  7. {papers_cli-2.16 → papers_cli-2.16.2}/papers_cli.egg-info/PKG-INFO +1 -1
  8. {papers_cli-2.16 → papers_cli-2.16.2}/tests/test_bib_unit.py +1 -3
  9. {papers_cli-2.16 → papers_cli-2.16.2}/tests/test_duplicates.py +112 -0
  10. {papers_cli-2.16 → papers_cli-2.16.2}/.gitattributes +0 -0
  11. {papers_cli-2.16 → papers_cli-2.16.2}/.github/FUNDING.yml +0 -0
  12. {papers_cli-2.16 → papers_cli-2.16.2}/.github/workflows/release.yml +0 -0
  13. {papers_cli-2.16 → papers_cli-2.16.2}/.github/workflows/tests.yml +0 -0
  14. {papers_cli-2.16 → papers_cli-2.16.2}/.gitignore +0 -0
  15. {papers_cli-2.16 → papers_cli-2.16.2}/LICENSE +0 -0
  16. {papers_cli-2.16 → papers_cli-2.16.2}/README.md +0 -0
  17. {papers_cli-2.16 → papers_cli-2.16.2}/papers/__init__.py +0 -0
  18. {papers_cli-2.16 → papers_cli-2.16.2}/papers/config.py +0 -0
  19. {papers_cli-2.16 → papers_cli-2.16.2}/papers/encoding.py +0 -0
  20. {papers_cli-2.16 → papers_cli-2.16.2}/papers/entries.py +0 -0
  21. {papers_cli-2.16 → papers_cli-2.16.2}/papers/extract.py +0 -0
  22. {papers_cli-2.16 → papers_cli-2.16.2}/papers/filename.py +0 -0
  23. {papers_cli-2.16 → papers_cli-2.16.2}/papers/latexenc.py +0 -0
  24. {papers_cli-2.16 → papers_cli-2.16.2}/papers/utils.py +0 -0
  25. {papers_cli-2.16 → papers_cli-2.16.2}/papers_cli.egg-info/SOURCES.txt +0 -0
  26. {papers_cli-2.16 → papers_cli-2.16.2}/papers_cli.egg-info/dependency_links.txt +0 -0
  27. {papers_cli-2.16 → papers_cli-2.16.2}/papers_cli.egg-info/entry_points.txt +0 -0
  28. {papers_cli-2.16 → papers_cli-2.16.2}/papers_cli.egg-info/requires.txt +0 -0
  29. {papers_cli-2.16 → papers_cli-2.16.2}/papers_cli.egg-info/top_level.txt +0 -0
  30. {papers_cli-2.16 → papers_cli-2.16.2}/pyproject.toml +0 -0
  31. {papers_cli-2.16 → papers_cli-2.16.2}/requirements.txt +0 -0
  32. {papers_cli-2.16 → papers_cli-2.16.2}/scripts/README.md +0 -0
  33. {papers_cli-2.16 → papers_cli-2.16.2}/scripts/benchmark_bibtexparser_load.py +0 -0
  34. {papers_cli-2.16 → papers_cli-2.16.2}/scripts/generate_dummy_bib.py +0 -0
  35. {papers_cli-2.16 → papers_cli-2.16.2}/setup.cfg +0 -0
  36. {papers_cli-2.16 → papers_cli-2.16.2}/setup.py +0 -0
  37. {papers_cli-2.16 → papers_cli-2.16.2}/tests/__init__.py +0 -0
  38. {papers_cli-2.16 → papers_cli-2.16.2}/tests/common.py +0 -0
  39. {papers_cli-2.16 → papers_cli-2.16.2}/tests/config.py +0 -0
  40. {papers_cli-2.16 → papers_cli-2.16.2}/tests/download.py +0 -0
  41. {papers_cli-2.16 → papers_cli-2.16.2}/tests/downloadedpapers/.gitignore +0 -0
  42. {papers_cli-2.16 → papers_cli-2.16.2}/tests/downloadedpapers/bg-8-515-2011.txt +0 -0
  43. {papers_cli-2.16 → papers_cli-2.16.2}/tests/test_add.py +0 -0
  44. {papers_cli-2.16 → papers_cli-2.16.2}/tests/test_biblio.py +0 -0
  45. {papers_cli-2.16 → papers_cli-2.16.2}/tests/test_check.py +0 -0
  46. {papers_cli-2.16 → papers_cli-2.16.2}/tests/test_config_unit.py +0 -0
  47. {papers_cli-2.16 → papers_cli-2.16.2}/tests/test_encoding.py +0 -0
  48. {papers_cli-2.16 → papers_cli-2.16.2}/tests/test_extract.py +0 -0
  49. {papers_cli-2.16 → papers_cli-2.16.2}/tests/test_extract_unit.py +0 -0
  50. {papers_cli-2.16 → papers_cli-2.16.2}/tests/test_filecheck.py +0 -0
  51. {papers_cli-2.16 → papers_cli-2.16.2}/tests/test_filename_unit.py +0 -0
  52. {papers_cli-2.16 → papers_cli-2.16.2}/tests/test_install.py +0 -0
  53. {papers_cli-2.16 → papers_cli-2.16.2}/tests/test_latexenc.py +0 -0
  54. {papers_cli-2.16 → papers_cli-2.16.2}/tests/test_list.py +0 -0
  55. {papers_cli-2.16 → papers_cli-2.16.2}/tests/test_misc.py +0 -0
  56. {papers_cli-2.16 → papers_cli-2.16.2}/tests/test_undo.py +0 -0
  57. {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
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: papers-cli
3
- Version: 2.16
3
+ Version: 2.16.2
4
4
  Summary: utilities to keep your PDF library organized
5
5
  Author-email: Mahé Perrette <mahe.perrette@gmail.com>
6
6
  License: MIT
@@ -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 = 'g8b22ae064'
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 conflict_resolution_on_insert, entry_diff, merge_files, check_duplicates
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(entry, update_key=update_key, rename=rename, copy=copy, **checkopt)
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
- duplicates = [e for e in self.entries if self.eq(e, entry)]
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
- entries.extend( self.insert_entry(e, **kw) )
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
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: papers-cli
3
- Version: 2.16
3
+ Version: 2.16.2
4
4
  Summary: utilities to keep your PDF library organized
5
5
  Author-email: Mahé Perrette <mahe.perrette@gmail.com>
6
6
  License: MIT
@@ -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