papers-cli 2.17.6__tar.gz → 2.18.1__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 (79) hide show
  1. {papers_cli-2.17.6 → papers_cli-2.18.1}/.github/workflows/tests.yml +2 -3
  2. {papers_cli-2.17.6 → papers_cli-2.18.1}/PKG-INFO +1 -2
  3. {papers_cli-2.17.6 → papers_cli-2.18.1}/docs/adding-entries.md +19 -0
  4. {papers_cli-2.17.6 → papers_cli-2.18.1}/docs/configuration.md +4 -1
  5. {papers_cli-2.17.6 → papers_cli-2.18.1}/docs/features.md +1 -1
  6. {papers_cli-2.17.6 → papers_cli-2.18.1}/docs/installation.md +1 -3
  7. {papers_cli-2.17.6 → papers_cli-2.18.1}/papers/__main__.py +15 -4
  8. {papers_cli-2.17.6 → papers_cli-2.18.1}/papers/_version.py +3 -3
  9. {papers_cli-2.17.6 → papers_cli-2.18.1}/papers/bib.py +33 -24
  10. {papers_cli-2.17.6 → papers_cli-2.18.1}/papers/config.py +69 -51
  11. {papers_cli-2.17.6 → papers_cli-2.18.1}/papers/extract.py +194 -68
  12. {papers_cli-2.17.6 → papers_cli-2.18.1}/papers_cli.egg-info/PKG-INFO +1 -2
  13. {papers_cli-2.17.6 → papers_cli-2.18.1}/papers_cli.egg-info/requires.txt +0 -1
  14. {papers_cli-2.17.6 → papers_cli-2.18.1}/papers_cli.egg-info/scm_version.json +2 -2
  15. {papers_cli-2.17.6 → papers_cli-2.18.1}/pyproject.toml +0 -1
  16. {papers_cli-2.17.6 → papers_cli-2.18.1}/tests/test_add.py +30 -0
  17. papers_cli-2.18.1/tests/test_config_unit.py +233 -0
  18. {papers_cli-2.17.6 → papers_cli-2.18.1}/tests/test_extract_unit.py +141 -0
  19. papers_cli-2.17.6/tests/test_config_unit.py +0 -207
  20. {papers_cli-2.17.6 → papers_cli-2.18.1}/.gitattributes +0 -0
  21. {papers_cli-2.17.6 → papers_cli-2.18.1}/.github/FUNDING.yml +0 -0
  22. {papers_cli-2.17.6 → papers_cli-2.18.1}/.github/workflows/docs.yml +0 -0
  23. {papers_cli-2.17.6 → papers_cli-2.18.1}/.github/workflows/pypi.yml +0 -0
  24. {papers_cli-2.17.6 → papers_cli-2.18.1}/.github/workflows/release.yml +0 -0
  25. {papers_cli-2.17.6 → papers_cli-2.18.1}/.gitignore +0 -0
  26. {papers_cli-2.17.6 → papers_cli-2.18.1}/LICENSE +0 -0
  27. {papers_cli-2.17.6 → papers_cli-2.18.1}/README.md +0 -0
  28. {papers_cli-2.17.6 → papers_cli-2.18.1}/docs/comparison.md +0 -0
  29. {papers_cli-2.17.6 → papers_cli-2.18.1}/docs/git.md +0 -0
  30. {papers_cli-2.17.6 → papers_cli-2.18.1}/docs/index.md +0 -0
  31. {papers_cli-2.17.6 → papers_cli-2.18.1}/docs/library-management.md +0 -0
  32. {papers_cli-2.17.6 → papers_cli-2.18.1}/docs/listing.md +0 -0
  33. {papers_cli-2.17.6 → papers_cli-2.18.1}/docs/quickstart.md +0 -0
  34. {papers_cli-2.17.6 → papers_cli-2.18.1}/docs/renaming.md +0 -0
  35. {papers_cli-2.17.6 → papers_cli-2.18.1}/docs/undo-redo.md +0 -0
  36. {papers_cli-2.17.6 → papers_cli-2.18.1}/mkdocs.yml +0 -0
  37. {papers_cli-2.17.6 → papers_cli-2.18.1}/papers/__init__.py +0 -0
  38. {papers_cli-2.17.6 → papers_cli-2.18.1}/papers/backup.py +0 -0
  39. {papers_cli-2.17.6 → papers_cli-2.18.1}/papers/duplicate.py +0 -0
  40. {papers_cli-2.17.6 → papers_cli-2.18.1}/papers/encoding.py +0 -0
  41. {papers_cli-2.17.6 → papers_cli-2.18.1}/papers/entries.py +0 -0
  42. {papers_cli-2.17.6 → papers_cli-2.18.1}/papers/filename.py +0 -0
  43. {papers_cli-2.17.6 → papers_cli-2.18.1}/papers/install.py +0 -0
  44. {papers_cli-2.17.6 → papers_cli-2.18.1}/papers/latexenc.py +0 -0
  45. {papers_cli-2.17.6 → papers_cli-2.18.1}/papers/utils.py +0 -0
  46. {papers_cli-2.17.6 → papers_cli-2.18.1}/papers_cli.egg-info/SOURCES.txt +0 -0
  47. {papers_cli-2.17.6 → papers_cli-2.18.1}/papers_cli.egg-info/dependency_links.txt +0 -0
  48. {papers_cli-2.17.6 → papers_cli-2.18.1}/papers_cli.egg-info/entry_points.txt +0 -0
  49. {papers_cli-2.17.6 → papers_cli-2.18.1}/papers_cli.egg-info/scm_file_list.json +0 -0
  50. {papers_cli-2.17.6 → papers_cli-2.18.1}/papers_cli.egg-info/top_level.txt +0 -0
  51. {papers_cli-2.17.6 → papers_cli-2.18.1}/requirements.txt +0 -0
  52. {papers_cli-2.17.6 → papers_cli-2.18.1}/scripts/README.md +0 -0
  53. {papers_cli-2.17.6 → papers_cli-2.18.1}/scripts/benchmark_bibtexparser_load.py +0 -0
  54. {papers_cli-2.17.6 → papers_cli-2.18.1}/scripts/generate_dummy_bib.py +0 -0
  55. {papers_cli-2.17.6 → papers_cli-2.18.1}/setup.cfg +0 -0
  56. {papers_cli-2.17.6 → papers_cli-2.18.1}/setup.py +0 -0
  57. {papers_cli-2.17.6 → papers_cli-2.18.1}/tests/__init__.py +0 -0
  58. {papers_cli-2.17.6 → papers_cli-2.18.1}/tests/common.py +0 -0
  59. {papers_cli-2.17.6 → papers_cli-2.18.1}/tests/config.py +0 -0
  60. {papers_cli-2.17.6 → papers_cli-2.18.1}/tests/download.py +0 -0
  61. {papers_cli-2.17.6 → papers_cli-2.18.1}/tests/downloadedpapers/.gitignore +0 -0
  62. {papers_cli-2.17.6 → papers_cli-2.18.1}/tests/downloadedpapers/bg-8-515-2011.pdf +0 -0
  63. {papers_cli-2.17.6 → papers_cli-2.18.1}/tests/downloadedpapers/bg-8-515-2011.txt +0 -0
  64. {papers_cli-2.17.6 → papers_cli-2.18.1}/tests/downloadedpapers/esd-4-11-2013-supplement.pdf +0 -0
  65. {papers_cli-2.17.6 → papers_cli-2.18.1}/tests/downloadedpapers/esd-4-11-2013.pdf +0 -0
  66. {papers_cli-2.17.6 → papers_cli-2.18.1}/tests/test_backup_dirs.py +0 -0
  67. {papers_cli-2.17.6 → papers_cli-2.18.1}/tests/test_bib_unit.py +0 -0
  68. {papers_cli-2.17.6 → papers_cli-2.18.1}/tests/test_biblio.py +0 -0
  69. {papers_cli-2.17.6 → papers_cli-2.18.1}/tests/test_check.py +0 -0
  70. {papers_cli-2.17.6 → papers_cli-2.18.1}/tests/test_duplicates.py +0 -0
  71. {papers_cli-2.17.6 → papers_cli-2.18.1}/tests/test_encoding.py +0 -0
  72. {papers_cli-2.17.6 → papers_cli-2.18.1}/tests/test_extract.py +0 -0
  73. {papers_cli-2.17.6 → papers_cli-2.18.1}/tests/test_filecheck.py +0 -0
  74. {papers_cli-2.17.6 → papers_cli-2.18.1}/tests/test_filename_unit.py +0 -0
  75. {papers_cli-2.17.6 → papers_cli-2.18.1}/tests/test_install.py +0 -0
  76. {papers_cli-2.17.6 → papers_cli-2.18.1}/tests/test_latexenc.py +0 -0
  77. {papers_cli-2.17.6 → papers_cli-2.18.1}/tests/test_list.py +0 -0
  78. {papers_cli-2.17.6 → papers_cli-2.18.1}/tests/test_undo.py +0 -0
  79. {papers_cli-2.17.6 → papers_cli-2.18.1}/tests/test_utils.py +0 -0
@@ -8,12 +8,11 @@ jobs:
8
8
  build:
9
9
 
10
10
  runs-on: ubuntu-latest
11
- # 3.15t is allowed to fail: PyMuPDF does not support it yet
12
- continue-on-error: ${{ matrix.python-version == '3.15t' }}
13
11
  strategy:
14
12
  fail-fast: false
15
13
  matrix:
16
- python-version: [ "3.9", "3.10", "3.11", "3.12", "3.13", "3.14", "3.14t", "3.15", "3.15t" ]
14
+ # 3.15t: add when PyMuPDF supports it (https://github.com/pymupdf/PyMuPDF/issues/5170, #113)
15
+ python-version: [ "3.9", "3.10", "3.11", "3.12", "3.13", "3.14", "3.14t", "3.15" ]
17
16
 
18
17
  steps:
19
18
  - uses: actions/checkout@v6
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: papers-cli
3
- Version: 2.17.6
3
+ Version: 2.18.1
4
4
  Summary: utilities to keep your PDF library organized
5
5
  Author-email: Mahé Perrette <mahe.perrette@gmail.com>
6
6
  License: MIT
@@ -13,7 +13,6 @@ Description-Content-Type: text/markdown
13
13
  License-File: LICENSE
14
14
  Requires-Dist: PyMuPDF<2
15
15
  Requires-Dist: unidecode
16
- Requires-Dist: crossrefapi
17
16
  Requires-Dist: bibtexparser>=2.0.0b2
18
17
  Requires-Dist: scholarly
19
18
  Requires-Dist: rapidfuzz
@@ -46,6 +46,25 @@ where, above, the `papers_test` directory contains a few PDF files. For each
46
46
  PDF, `papers` will attempt to extract the metadata and add the relevant file to
47
47
  the bibliography, and rename files into the files directory.
48
48
 
49
+ The metadata of a PDF are found as follows:
50
+
51
+ 1. a DOI in the PDF metadata is used as is;
52
+ 2. otherwise, the DOIs printed in the text (first pages, and the last page,
53
+ where some publishers add a cover page) are looked up in turn, and the
54
+ first one whose title appears in the first pages is used: this skips the
55
+ DOIs of cited papers;
56
+ 3. otherwise, a crossref search on the text of the first pages, whose result
57
+ is also accepted only if its title appears in the text.
58
+
59
+ If none matches, the PDF is not added: pass its DOI with `--doi` (see below),
60
+ or, for a reference without DOI (older papers, books, reports...), describe it
61
+ and attach the PDF:
62
+
63
+ ```
64
+ papers add --title "Self-consistent equilibrium ocean tides" --author "Agnew, Duncan C. and Farrell, W. E." \
65
+ --year 1978 --journal "Geophysical Journal International" --attachment agnew1978.pdf --rename
66
+ ```
67
+
49
68
  ## Add an entry from its DOI
50
69
 
51
70
  If you already know the DOI of a PDF, and don't want to gamble the fulltext
@@ -28,7 +28,10 @@ Type `papers status -v` to check your configuration.
28
28
 
29
29
  You also notice a cache directory. All internet requests such as crossref
30
30
  requests are saved in the cache directory. This happens regardless of whether
31
- `papers` is installed or not.
31
+ `papers` is installed or not. The metadata of each DOI is a json file under
32
+ `doi/`, e.g. `doi/10.1126/science.1166510.json` (DOIs that do not exist are
33
+ remembered for a day). Deleting the directory, or any file in it, is safe:
34
+ the requests are made again when needed.
32
35
 
33
36
  ## Local install
34
37
 
@@ -3,7 +3,7 @@
3
3
  ## Current features
4
4
 
5
5
  - parse PDF to extract DOI
6
- - fetch bibtex entry from DOI (using crossref API)
6
+ - fetch bibtex entry from DOI (using crossref API, or doi.org for DOIs registered elsewhere, e.g. DataCite)
7
7
  - fetch bibtex entry by fulltext search (using crossref API or google scholar)
8
8
  - create and maintain bibtex file
9
9
  - add entry as PDF (`papers add ...`)
@@ -37,10 +37,8 @@ mitigations are built in:
37
37
  - python 3.9+
38
38
  - [PyMuPDF](https://github.com/pymupdf/PyMuPDF) (preferred) or
39
39
  [poppler-utils](https://en.wikipedia.org/wiki/Poppler_(software))
40
- (only `pdftotext`; deprecated): convert PDF to text for parsing
40
+ (only `pdftotext` and `pdfinfo`; deprecated): convert PDF to text for parsing
41
41
  - [bibtexparser](https://bibtexparser.readthedocs.io): parse bibtex files
42
- - [crossrefapi](https://github.com/fabiobatalha/crossrefapi): make polite
43
- requests to the crossref API
44
42
  - [scholarly](https://github.com/OrganicIrradiation/scholarly): interface for
45
43
  google scholar
46
44
  - [rapidfuzz](https://github.com/rhasspy/rapidfuzz): calculate score to sort
@@ -13,16 +13,16 @@ import fnmatch # unix-like match
13
13
 
14
14
  import papers
15
15
  from papers import logger
16
- from papers.extract import extract_pdf_doi, isvaliddoi, extract_pdf_metadata
16
+ from papers.extract import extract_pdf_doi, isvaliddoi, extract_pdf_metadata, prefetch_pdf_metadata
17
17
  from papers.extract import fetch_bibtex_by_doi, fetch_bibtex_by_fulltext_crossref, fetch_bibtex_by_fulltext_scholar
18
18
  from papers.encoding import parse_file, format_file, family_names, format_entries, standard_name, format_entry, parse_keywords, format_key
19
19
  from papers.config import (bcolors, Config, search_config, CONFIG_FILE, CONFIG_FILE_LOCAL,
20
20
  DATA_DIR, CONFIG_FILE_LEGACY, CONFIG_FILE_LEGACY_XDG)
21
21
  from papers.duplicate import list_duplicates, list_uniques, edit_entries, title_id
22
- from papers.entries import get_entry_val, entry_content_equal
22
+ from papers.entries import get_entry_val, entry_content_equal, entry_from_dict
23
23
  from papers.bib import (Biblio, FUZZY_RATIO, DEFAULT_SIMILARITY, entry_filecheck,
24
24
  backupfile as backupfile_func, isvalidkey, DuplicateKeyError, clean_filesdir,
25
- are_duplicates, download_url, get_biblio)
25
+ are_duplicates, download_url, get_biblio, scan_dir_walk)
26
26
  from papers.install import resolve_install, apply_install, InputAsker, DefaultAsker
27
27
  from papers.utils import view_pdf, open_folder, PapersExit, file_uri_to_path, find_masked_viewer
28
28
  from papers.backup import (silent_backup_bib, restore_from_backupdir,
@@ -329,9 +329,20 @@ def addcmd(parser, o, config):
329
329
  metadata = edit_entries(metadata)
330
330
  o.edit = False
331
331
 
332
- entries.extend( biblio.insert_entry(metadata, **kw) )
332
+ entries.extend( biblio.insert_entry(entry_from_dict(metadata), rename=o.rename, copy=o.copy, **kw) )
333
333
 
334
334
 
335
+ # several PDFs: fetch their metadata concurrently first
336
+ if not o.doi and not o.scholar and not (o.no_query_doi and o.no_query_fulltext):
337
+ pdfs = []
338
+ for file in o.file:
339
+ if os.path.isdir(file) and o.recursive:
340
+ pdfs.extend(path for kind, path in scan_dir_walk(file) if kind == 'file' and path.lower().endswith('.pdf'))
341
+ elif os.path.isfile(file) and file.lower().endswith('.pdf'):
342
+ pdfs.append(file)
343
+ if len(pdfs) > 1:
344
+ prefetch_pdf_metadata(pdfs, search_doi=not o.no_query_doi, search_fulltext=not o.no_query_fulltext)
345
+
335
346
  for file in o.file:
336
347
  try:
337
348
  if os.path.isdir(file):
@@ -18,7 +18,7 @@ version_tuple: tuple[int | str, ...]
18
18
  commit_id: str | None
19
19
  __commit_id__: str | None
20
20
 
21
- __version__ = version = '2.17.6'
22
- __version_tuple__ = version_tuple = (2, 17, 6)
21
+ __version__ = version = '2.18.1'
22
+ __version_tuple__ = version_tuple = (2, 18, 1)
23
23
 
24
- __commit_id__ = commit_id = 'g2f4e6bd97'
24
+ __commit_id__ = commit_id = 'g0c4d92d88'
@@ -260,6 +260,26 @@ def backupfile(bibtex):
260
260
  class DuplicateKeyError(ValueError):
261
261
  pass
262
262
 
263
+ def scan_dir_walk(direc):
264
+ """the files scanned by Biblio.scan_dir: yield ('entry', dir) for entry
265
+ directories (with a hidden bibtex), ('file', path) for other files"""
266
+ top = os.path.abspath(direc)
267
+ for root, direcs, files in os.walk(direc):
268
+ dirname = os.path.basename(root)
269
+ # never skip the scan root itself: basename('.') == '.' looks hidden
270
+ if os.path.abspath(root) != top:
271
+ if dirname.startswith('.'): continue
272
+ if dirname.startswith('_'): continue
273
+
274
+ if os.path.exists(hidden_bibtex(root)):
275
+ yield 'entry', root
276
+ continue
277
+
278
+ for file in files:
279
+ if not file.startswith('.'):
280
+ yield 'file', os.path.join(root, file)
281
+
282
+
263
283
  class Biblio:
264
284
  """
265
285
  The bibtex object that we operate on, which is mainly used to read and write to dynamically, and can then send the changes to be stored in a specified bibtex file on disk.
@@ -575,36 +595,25 @@ class Biblio:
575
595
 
576
596
  def scan_dir_iter(self, direc, search_doi=True, search_fulltext=True, **kw):
577
597
 
578
- top = os.path.abspath(direc)
579
- for root, direcs, files in os.walk(direc):
580
- dirname = os.path.basename(root)
581
- # never skip the scan root itself: basename('.') == '.' looks hidden
582
- if os.path.abspath(root) != top:
583
- if dirname.startswith('.'): continue
584
- if dirname.startswith('_'): continue
585
-
586
- # maybe a special entry directory?
587
- if os.path.exists(hidden_bibtex(root)):
598
+ for kind, path in scan_dir_walk(direc):
599
+ # a special entry directory
600
+ if kind == 'entry':
588
601
  logger.debug('read from hidden bibtex')
589
602
  try:
590
- entry = read_entry_dir(root, relative_to=self.relative_to)
603
+ entry = read_entry_dir(path, relative_to=self.relative_to)
591
604
  yield from self.insert_entry(entry, **kw)
592
605
  except Exception as error:
593
- logger.warning(root+'::'+str(error))
606
+ logger.warning(path+'::'+str(error))
594
607
  continue
595
608
 
596
- for file in files:
597
- if file.startswith('.'):
598
- continue
599
- path = os.path.join(root, file)
600
- try:
601
- if file.lower().endswith('.pdf'):
602
- yield from self.add_pdf(path, search_doi=search_doi, search_fulltext=search_fulltext, **kw)
603
- elif file.lower().endswith('.bib'):
604
- yield from self.add_bibtex_file(path, **kw)
605
- except Exception as error:
606
- logger.warning(path+'::'+str(error))
607
- continue
609
+ try:
610
+ if path.lower().endswith('.pdf'):
611
+ yield from self.add_pdf(path, search_doi=search_doi, search_fulltext=search_fulltext, **kw)
612
+ elif path.lower().endswith('.bib'):
613
+ yield from self.add_bibtex_file(path, **kw)
614
+ except Exception as error:
615
+ logger.warning(path+'::'+str(error))
616
+ continue
608
617
 
609
618
  def scan_dir(self, direc, **kw):
610
619
  " like scan_dir_iter but returns a list"
@@ -4,12 +4,10 @@ import copy
4
4
  import tempfile
5
5
  from pathlib import Path
6
6
  import hashlib
7
+ import time
8
+ from urllib.parse import quote
7
9
  import platformdirs
8
10
 
9
- try:
10
- import fcntl # not available on Windows
11
- except ImportError:
12
- fcntl = None
13
11
  from papers.entries import parse_string
14
12
  from papers import logger
15
13
  from papers.filename import Format, NAMEFORMAT, KEYFORMAT
@@ -246,24 +244,17 @@ class Config:
246
244
  return '\n'.join(lines)
247
245
 
248
246
 
249
- def _init_cache():
250
- if not os.path.exists(CACHE_DIR):
251
- logger.info('make cache directory for DOI requests: '+CACHE_DIR)
252
- os.makedirs(CACHE_DIR)
253
-
254
-
255
- @contextlib.contextmanager
256
- def _cache_lock(file):
257
- """advisory inter-process lock on a sidecar file (no-op without fcntl)"""
258
- if fcntl is None:
259
- yield
260
- return
261
- with open(file + '.lock', 'w') as lockfile:
262
- fcntl.flock(lockfile, fcntl.LOCK_EX)
263
- try:
264
- yield
265
- finally:
266
- fcntl.flock(lockfile, fcntl.LOCK_UN)
247
+ def _cache_path(folder, key):
248
+ """one json file per key: <folder>/<prefix>/<rest>.json, split at the first
249
+ slash so that DOIs are grouped by registrant (10.1126/science.1166510 ->
250
+ 10.1126/science.1166510.json); characters unsafe in file names are quoted"""
251
+ head, sep, tail = key.partition('/')
252
+ if not sep:
253
+ head, tail = key[:2], key
254
+ name = quote(tail, safe='')
255
+ if len(name) > 200: # file name length limit
256
+ name = hashlib.sha256(tail.encode('utf-8')).hexdigest()
257
+ return os.path.join(folder, quote(head, safe=''), name + '.json')
267
258
 
268
259
 
269
260
  def _read_cache_file(file):
@@ -272,17 +263,18 @@ def _read_cache_file(file):
272
263
  return json.load(open(file))
273
264
  except (json.JSONDecodeError, OSError) as error:
274
265
  # a corrupt cache file must not break every query
275
- logger.warning(f"unreadable cache file {file}: {error} -- starting over with an empty cache")
276
- return {}
266
+ logger.warning(f"unreadable cache file {file}: {error} -- ignored")
267
+ return None
277
268
 
278
269
 
279
- def _write_cache_file(cache, file):
280
- """atomic replace, so an interrupted write cannot truncate the cache"""
281
- fd, tmp = tempfile.mkstemp(dir=os.path.dirname(file) or '.',
270
+ def _write_cache_file(data, file):
271
+ """atomic replace, so an interrupted write cannot truncate the file"""
272
+ os.makedirs(os.path.dirname(file), exist_ok=True)
273
+ fd, tmp = tempfile.mkstemp(dir=os.path.dirname(file),
282
274
  prefix=os.path.basename(file) + '.', suffix='.tmp')
283
275
  try:
284
276
  with os.fdopen(fd, 'w') as f:
285
- json.dump(cache, f)
277
+ json.dump(data, f)
286
278
  os.replace(tmp, file)
287
279
  except BaseException:
288
280
  with contextlib.suppress(OSError):
@@ -290,33 +282,59 @@ def _write_cache_file(cache, file):
290
282
  raise
291
283
 
292
284
 
293
- def cached(file, hashed_key=False):
285
+ def _migrate_legacy_cache(legacy, folder, migrate):
286
+ """split a cache of previous versions (one json file for all keys) into
287
+ the per-key files; the legacy file is kept as <legacy>.bak"""
288
+ data = _read_cache_file(legacy)
289
+ if data:
290
+ logger.info(f'migrate cache {legacy} to {folder}')
291
+ for key, value in data.items():
292
+ if migrate:
293
+ value = migrate(value)
294
+ if value is not None:
295
+ # lower-case: DOIs are case-insensitive, and looked up in lower case
296
+ _write_cache_file({'value': value}, _cache_path(folder, key.lower()))
297
+ with contextlib.suppress(OSError): # another process may have migrated it
298
+ os.replace(legacy, legacy + '.bak')
299
+
300
+
301
+ NOT_FOUND_TTL = 24 * 3600 # seconds
294
302
 
295
- file = os.path.join(CACHE_DIR, file)
296
303
 
304
+ def cached(name, hashed_key=False, legacy=None, migrate=None, trim=None, not_found=None):
305
+ """cache the results of fun(key) as json files under CACHE_DIR/name/
306
+
307
+ legacy: cache file of previous versions, migrated on first use (with
308
+ migrate(value) -> value, or None to drop it)
309
+ trim: applied to results before they are stored
310
+ not_found: exception class raised by fun when the key does not exist:
311
+ it is remembered (and raised again) for NOT_FOUND_TTL seconds
312
+ """
297
313
  def decorator(fun):
298
- # load lazily on first call so that importing papers does not touch the cache
299
- cache = None
300
- def decorated(doi):
301
- nonlocal cache
302
- if cache is None:
303
- _init_cache()
304
- cache = _read_cache_file(file)
314
+ def decorated(arg):
315
+ key = arg
316
+ folder = os.path.join(CACHE_DIR, name)
317
+ if legacy and not DRYRUN and os.path.exists(os.path.join(CACHE_DIR, legacy)):
318
+ _migrate_legacy_cache(os.path.join(CACHE_DIR, legacy), folder, migrate)
305
319
  if hashed_key: # use hashed parameter as key (for full text query)
306
- key = hashlib.sha256(doi.encode('utf-8')).hexdigest()[:6]
307
- else:
308
- key = doi
309
- if key in cache:
310
- logger.debug('load from cache: '+repr((file, key)))
311
- return cache[key]
312
- else:
313
- res = cache[key] = fun(doi)
314
- if not DRYRUN:
315
- with _cache_lock(file):
316
- # merge entries written meanwhile by other processes,
317
- # keeping our own values for keys present in both
318
- cache = {**_read_cache_file(file), **cache}
319
- _write_cache_file(cache, file)
320
+ key = hashlib.sha256(arg.encode('utf-8')).hexdigest()[:16]
321
+ file = _cache_path(folder, key)
322
+ data = _read_cache_file(file)
323
+ if data and 'value' in data:
324
+ logger.debug('load from cache: '+repr((name, key)))
325
+ return data['value']
326
+ if data and not_found and time.time() - data.get('not_found', 0) < NOT_FOUND_TTL:
327
+ raise not_found(f'{key}: not found (cached until {time.ctime(data["not_found"] + NOT_FOUND_TTL)})')
328
+ try:
329
+ res = fun(arg)
330
+ except Exception as error:
331
+ if not_found and isinstance(error, not_found) and not DRYRUN:
332
+ _write_cache_file({'not_found': time.time()}, file)
333
+ raise
334
+ if trim:
335
+ res = trim(res)
336
+ if not DRYRUN:
337
+ _write_cache_file({'value': res}, file)
320
338
  return res
321
339
  return decorated
322
340
  return decorator