papers-cli 2.17.2__tar.gz → 2.17.4__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 (78) hide show
  1. {papers_cli-2.17.2 → papers_cli-2.17.4}/PKG-INFO +1 -1
  2. {papers_cli-2.17.2 → papers_cli-2.17.4}/docs/installation.md +11 -7
  3. {papers_cli-2.17.2 → papers_cli-2.17.4}/papers/__main__.py +35 -10
  4. {papers_cli-2.17.2 → papers_cli-2.17.4}/papers/_version.py +3 -3
  5. {papers_cli-2.17.2 → papers_cli-2.17.4}/papers/extract.py +11 -11
  6. {papers_cli-2.17.2 → papers_cli-2.17.4}/papers/utils.py +36 -0
  7. {papers_cli-2.17.2 → papers_cli-2.17.4}/papers_cli.egg-info/PKG-INFO +1 -1
  8. {papers_cli-2.17.2 → papers_cli-2.17.4}/papers_cli.egg-info/SOURCES.txt +2 -0
  9. papers_cli-2.17.4/papers_cli.egg-info/scm_file_list.json +71 -0
  10. papers_cli-2.17.4/papers_cli.egg-info/scm_version.json +8 -0
  11. {papers_cli-2.17.2 → papers_cli-2.17.4}/tests/test_add.py +16 -0
  12. {papers_cli-2.17.2 → papers_cli-2.17.4}/tests/test_list.py +84 -11
  13. {papers_cli-2.17.2 → papers_cli-2.17.4}/.gitattributes +0 -0
  14. {papers_cli-2.17.2 → papers_cli-2.17.4}/.github/FUNDING.yml +0 -0
  15. {papers_cli-2.17.2 → papers_cli-2.17.4}/.github/workflows/docs.yml +0 -0
  16. {papers_cli-2.17.2 → papers_cli-2.17.4}/.github/workflows/pypi.yml +0 -0
  17. {papers_cli-2.17.2 → papers_cli-2.17.4}/.github/workflows/release.yml +0 -0
  18. {papers_cli-2.17.2 → papers_cli-2.17.4}/.github/workflows/tests.yml +0 -0
  19. {papers_cli-2.17.2 → papers_cli-2.17.4}/.gitignore +0 -0
  20. {papers_cli-2.17.2 → papers_cli-2.17.4}/LICENSE +0 -0
  21. {papers_cli-2.17.2 → papers_cli-2.17.4}/README.md +0 -0
  22. {papers_cli-2.17.2 → papers_cli-2.17.4}/docs/adding-entries.md +0 -0
  23. {papers_cli-2.17.2 → papers_cli-2.17.4}/docs/comparison.md +0 -0
  24. {papers_cli-2.17.2 → papers_cli-2.17.4}/docs/configuration.md +0 -0
  25. {papers_cli-2.17.2 → papers_cli-2.17.4}/docs/features.md +0 -0
  26. {papers_cli-2.17.2 → papers_cli-2.17.4}/docs/git.md +0 -0
  27. {papers_cli-2.17.2 → papers_cli-2.17.4}/docs/index.md +0 -0
  28. {papers_cli-2.17.2 → papers_cli-2.17.4}/docs/library-management.md +0 -0
  29. {papers_cli-2.17.2 → papers_cli-2.17.4}/docs/listing.md +0 -0
  30. {papers_cli-2.17.2 → papers_cli-2.17.4}/docs/quickstart.md +0 -0
  31. {papers_cli-2.17.2 → papers_cli-2.17.4}/docs/renaming.md +0 -0
  32. {papers_cli-2.17.2 → papers_cli-2.17.4}/docs/undo-redo.md +0 -0
  33. {papers_cli-2.17.2 → papers_cli-2.17.4}/mkdocs.yml +0 -0
  34. {papers_cli-2.17.2 → papers_cli-2.17.4}/papers/__init__.py +0 -0
  35. {papers_cli-2.17.2 → papers_cli-2.17.4}/papers/backup.py +0 -0
  36. {papers_cli-2.17.2 → papers_cli-2.17.4}/papers/bib.py +0 -0
  37. {papers_cli-2.17.2 → papers_cli-2.17.4}/papers/config.py +0 -0
  38. {papers_cli-2.17.2 → papers_cli-2.17.4}/papers/duplicate.py +0 -0
  39. {papers_cli-2.17.2 → papers_cli-2.17.4}/papers/encoding.py +0 -0
  40. {papers_cli-2.17.2 → papers_cli-2.17.4}/papers/entries.py +0 -0
  41. {papers_cli-2.17.2 → papers_cli-2.17.4}/papers/filename.py +0 -0
  42. {papers_cli-2.17.2 → papers_cli-2.17.4}/papers/install.py +0 -0
  43. {papers_cli-2.17.2 → papers_cli-2.17.4}/papers/latexenc.py +0 -0
  44. {papers_cli-2.17.2 → papers_cli-2.17.4}/papers_cli.egg-info/dependency_links.txt +0 -0
  45. {papers_cli-2.17.2 → papers_cli-2.17.4}/papers_cli.egg-info/entry_points.txt +0 -0
  46. {papers_cli-2.17.2 → papers_cli-2.17.4}/papers_cli.egg-info/requires.txt +0 -0
  47. {papers_cli-2.17.2 → papers_cli-2.17.4}/papers_cli.egg-info/top_level.txt +0 -0
  48. {papers_cli-2.17.2 → papers_cli-2.17.4}/pyproject.toml +0 -0
  49. {papers_cli-2.17.2 → papers_cli-2.17.4}/requirements.txt +0 -0
  50. {papers_cli-2.17.2 → papers_cli-2.17.4}/scripts/README.md +0 -0
  51. {papers_cli-2.17.2 → papers_cli-2.17.4}/scripts/benchmark_bibtexparser_load.py +0 -0
  52. {papers_cli-2.17.2 → papers_cli-2.17.4}/scripts/generate_dummy_bib.py +0 -0
  53. {papers_cli-2.17.2 → papers_cli-2.17.4}/setup.cfg +0 -0
  54. {papers_cli-2.17.2 → papers_cli-2.17.4}/setup.py +0 -0
  55. {papers_cli-2.17.2 → papers_cli-2.17.4}/tests/__init__.py +0 -0
  56. {papers_cli-2.17.2 → papers_cli-2.17.4}/tests/common.py +0 -0
  57. {papers_cli-2.17.2 → papers_cli-2.17.4}/tests/config.py +0 -0
  58. {papers_cli-2.17.2 → papers_cli-2.17.4}/tests/download.py +0 -0
  59. {papers_cli-2.17.2 → papers_cli-2.17.4}/tests/downloadedpapers/.gitignore +0 -0
  60. {papers_cli-2.17.2 → papers_cli-2.17.4}/tests/downloadedpapers/bg-8-515-2011.pdf +0 -0
  61. {papers_cli-2.17.2 → papers_cli-2.17.4}/tests/downloadedpapers/bg-8-515-2011.txt +0 -0
  62. {papers_cli-2.17.2 → papers_cli-2.17.4}/tests/downloadedpapers/esd-4-11-2013-supplement.pdf +0 -0
  63. {papers_cli-2.17.2 → papers_cli-2.17.4}/tests/downloadedpapers/esd-4-11-2013.pdf +0 -0
  64. {papers_cli-2.17.2 → papers_cli-2.17.4}/tests/test_backup_dirs.py +0 -0
  65. {papers_cli-2.17.2 → papers_cli-2.17.4}/tests/test_bib_unit.py +0 -0
  66. {papers_cli-2.17.2 → papers_cli-2.17.4}/tests/test_biblio.py +0 -0
  67. {papers_cli-2.17.2 → papers_cli-2.17.4}/tests/test_check.py +0 -0
  68. {papers_cli-2.17.2 → papers_cli-2.17.4}/tests/test_config_unit.py +0 -0
  69. {papers_cli-2.17.2 → papers_cli-2.17.4}/tests/test_duplicates.py +0 -0
  70. {papers_cli-2.17.2 → papers_cli-2.17.4}/tests/test_encoding.py +0 -0
  71. {papers_cli-2.17.2 → papers_cli-2.17.4}/tests/test_extract.py +0 -0
  72. {papers_cli-2.17.2 → papers_cli-2.17.4}/tests/test_extract_unit.py +0 -0
  73. {papers_cli-2.17.2 → papers_cli-2.17.4}/tests/test_filecheck.py +0 -0
  74. {papers_cli-2.17.2 → papers_cli-2.17.4}/tests/test_filename_unit.py +0 -0
  75. {papers_cli-2.17.2 → papers_cli-2.17.4}/tests/test_install.py +0 -0
  76. {papers_cli-2.17.2 → papers_cli-2.17.4}/tests/test_latexenc.py +0 -0
  77. {papers_cli-2.17.2 → papers_cli-2.17.4}/tests/test_undo.py +0 -0
  78. {papers_cli-2.17.2 → papers_cli-2.17.4}/tests/test_utils.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: papers-cli
3
- Version: 2.17.2
3
+ Version: 2.17.4
4
4
  Summary: utilities to keep your PDF library organized
5
5
  Author-email: Mahé Perrette <mahe.perrette@gmail.com>
6
6
  License: MIT
@@ -20,13 +20,17 @@ mitigations are built in:
20
20
  - The package also installs the command under the unambiguous name
21
21
  `papers-cli` — call that, or set a shell alias (e.g.
22
22
  `alias papers=papers-cli`), on systems where the names collide.
23
- - Because this command may mask GNOME Papers, `papers somefile.pdf` (a plain
24
- existing file instead of a subcommand) opens the file with the system's
25
- default viewer rather than erroring — which on a GNOME desktop typically
26
- launches GNOME Papers itself, so the viewer keeps working for the common
27
- case. Subcommand names always take precedence (`papers ./list` opens a
28
- file named `list`; `papers list` lists your library), and viewer-specific
29
- flags are not forwarded — call the viewer's binary directly for those.
23
+ - Because this command may mask GNOME Papers, a call whose arguments are all
24
+ existing files (paths or `file://` URIs, as passed by file managers through
25
+ the viewer's desktop entry) is handed over unchanged to the next `papers`
26
+ executable in `$PATH` that is not this tool, i.e. GNOME Papers itself.
27
+ Double-clicking a PDF and `papers somefile.pdf` therefore keep opening the
28
+ viewer. If no other `papers` is found, the files are opened with the
29
+ system's default viewer (`xdg-open`) instead; should that lead back to
30
+ this command, it stops with an error rather than looping. Subcommand names
31
+ always take precedence (`papers ./list` opens a file named `list`;
32
+ `papers list` lists your library), and any flag disables the hand-over —
33
+ call the viewer's binary directly for viewer-specific flags.
30
34
 
31
35
  ## Dependencies
32
36
 
@@ -24,7 +24,7 @@ from papers.bib import (Biblio, FUZZY_RATIO, DEFAULT_SIMILARITY, entry_filecheck
24
24
  backupfile as backupfile_func, isvalidkey, DuplicateKeyError, clean_filesdir,
25
25
  are_duplicates, download_url, get_biblio)
26
26
  from papers.install import resolve_install, apply_install, InputAsker, DefaultAsker
27
- from papers.utils import view_pdf, open_folder, PapersExit
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,
29
29
  git_undo, git_redo, git_restore_state, list_backup_dirs)
30
30
  from papers import __version__
@@ -794,7 +794,10 @@ def get_parser(config=None):
794
794
  keyfmt = argparse.ArgumentParser(add_help=False)
795
795
  grp = keyfmt.add_argument_group('bibtex key format')
796
796
  grp.add_argument('--key-template', default=config.keyformat.template,
797
- help='python template for generating keys (default:%(default)s)')
797
+ help='''python template for generating keys (default:%(default)s).
798
+ The template applies to entries generated from a PDF or a DOI;
799
+ entries added from a bibtex file keep their original key unless
800
+ -u/--update-key is passed.''')
798
801
  grp.add_argument('--key-author-num', type=int, default=config.keyformat.author_num,
799
802
  help='number of authors to include in key (default:%(default)s)')
800
803
  grp.add_argument('--key-author-sep', default=config.keyformat.author_sep,
@@ -884,7 +887,10 @@ def get_parser(config=None):
884
887
  grp.add_argument('--no-merge-files', action='store_true',
885
888
  help='distinct "file" field considered a conflict, all other things being equal')
886
889
  grp.add_argument('-u', '--update-key', action='store_true',
887
- help='update added key according to any existing duplicate (otherwise an error might be raised on identical insert key)')
890
+ help='''regenerate the key of added entries from the key template
891
+ (see "bibtex key format" options); if a duplicate is found, use the
892
+ existing entry's key instead (otherwise an error might be raised on
893
+ identical insert key)''')
888
894
  # grp.add_argument('-f', '--force', action='store_true', help='no interactive')
889
895
  grp.add_argument('-m', '--mode', default='i', choices=['u', 'U', 'o', 's', 'r', 'i', 'a'],
890
896
  help='''if duplicates are found, the default is to start an (i)nteractive dialogue,
@@ -1119,6 +1125,10 @@ def handle_logging(o):
1119
1125
  logger.debug("LOGGER LEVEL: "+logging.getLevelName(logger.getEffectiveLevel()))
1120
1126
 
1121
1127
 
1128
+ # set while handing files to the system viewer, to detect a passthrough loop (#107)
1129
+ VIEWER_PASSTHROUGH_ENV = 'PAPERS_CLI_VIEWER_PASSTHROUGH'
1130
+
1131
+
1122
1132
  def main(args=None):
1123
1133
  papers.config.DRYRUN = False # reset in case main() if called directly
1124
1134
  if args is not None:
@@ -1138,16 +1148,31 @@ def main(args=None):
1138
1148
  parser, subparsers = get_parser(config)
1139
1149
 
1140
1150
  # viewer passthrough (#107): this command often masks the GNOME Papers
1141
- # document viewer in $PATH, so `papers somefile.pdf` opens the file(s)
1142
- # with the system viewer instead of erroring. xdg-open resolves the
1143
- # handler via desktop entries, not $PATH, so this typically launches the
1144
- # masked viewer itself. Subcommand names always take precedence, and any
1145
- # flag disables the passthrough (it cannot be meant for this tool).
1151
+ # document viewer in $PATH. Its desktop entry runs `papers %U`, so file
1152
+ # managers and xdg-open land here with file paths or file:// URIs. When
1153
+ # every argument is an existing file, the call is handed over unchanged
1154
+ # to the masked viewer (the next `papers` on $PATH that is not this tool).
1155
+ # xdg-open is only a fallback when there is no such viewer: it resolves
1156
+ # the desktop entry, which may lead back to this command, hence the
1157
+ # environment guard against a loop. Subcommand names always take
1158
+ # precedence, and any flag disables the passthrough (it cannot be meant
1159
+ # for this tool).
1146
1160
  argv = sys.argv[1:]
1147
1161
  if argv and all(not a.startswith('-') for a in argv) \
1148
1162
  and argv[0] not in subparsers.choices \
1149
- and all(os.path.isfile(a) for a in argv):
1150
- for f in argv:
1163
+ and all(os.path.isfile(file_uri_to_path(a)) for a in argv):
1164
+ viewer = find_masked_viewer()
1165
+ if viewer:
1166
+ print(f"papers-cli: passing {' '.join(map(repr, argv))} to {viewer} "
1167
+ f"(see `papers --help` for the bibliography tool)", file=sys.stderr)
1168
+ os.execv(viewer, [viewer] + argv)
1169
+ return # only reached when os.execv is mocked
1170
+ if os.environ.get(VIEWER_PASSTHROUGH_ENV):
1171
+ raise PapersExit("the system viewer for these files leads back to this "
1172
+ "command, and no other `papers` viewer is in $PATH")
1173
+ os.environ[VIEWER_PASSTHROUGH_ENV] = '1' # inherited by the viewer
1174
+ for a in argv:
1175
+ f = file_uri_to_path(a)
1151
1176
  print(f"papers-cli: opening {f!r} with the system viewer "
1152
1177
  f"(see `papers --help` for the bibliography tool)", file=sys.stderr)
1153
1178
  view_pdf(f)
@@ -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.2'
22
- __version_tuple__ = version_tuple = (2, 17, 2)
21
+ __version__ = version = '2.17.4'
22
+ __version_tuple__ = version_tuple = (2, 17, 4)
23
23
 
24
- __commit_id__ = commit_id = 'g7e17d470e'
24
+ __commit_id__ = commit_id = 'gc3df29014'
@@ -37,11 +37,11 @@ class DOIRequestError(ValueError):
37
37
 
38
38
  # PDF parsing / crossref requests
39
39
  # ===============================
40
- def readpdf_fitz(pdf_path, pages=None, first=None, last=None):
41
- import fitz
40
+ def readpdf_pymupdf(pdf_path, pages=None, first=None, last=None):
41
+ import pymupdf
42
42
 
43
43
  # Open the PDF file
44
- document = fitz.open(pdf_path)
44
+ document = pymupdf.open(pdf_path)
45
45
  text = ""
46
46
 
47
47
  # Iterate through the pages
@@ -78,7 +78,7 @@ def readpdf(pdf, first=None, last=None):
78
78
  if not os.path.isfile(pdf):
79
79
  raise ValueError(repr(pdf) + ": not a file")
80
80
  try:
81
- return readpdf_fitz(pdf, first=first, last=last)
81
+ return readpdf_pymupdf(pdf, first=first, last=last)
82
82
  except ImportError:
83
83
  logger.warning("PyMuPDF not installed, using pdftotext")
84
84
  return readpdf_poputils(pdf, first=first, last=last)
@@ -144,11 +144,11 @@ def parse_doi_from_pdf_metadata_poppler(pdf_path):
144
144
  pass
145
145
  return None
146
146
 
147
- def parse_doi_from_pdf_metadata_fitz(pdf_path):
148
- """Extract DOI from PDF metadata using PyMuPDF/fitz."""
149
- import fitz
147
+ def parse_doi_from_pdf_metadata_pymupdf(pdf_path):
148
+ """Extract DOI from PDF metadata using PyMuPDF."""
149
+ import pymupdf
150
150
 
151
- with fitz.open(pdf_path) as doc:
151
+ with pymupdf.open(pdf_path) as doc:
152
152
  metadata = doc.metadata
153
153
 
154
154
  # Try direct metadata fields first
@@ -170,11 +170,11 @@ def parse_doi_from_pdf_metadata_fitz(pdf_path):
170
170
 
171
171
 
172
172
  def parse_doi_from_pdf_metadata(pdf_path):
173
- """Extract DOI from PDF metadata (tries fitz, falls back to poppler)."""
174
- # Try fitz first (no subprocess overhead)
173
+ """Extract DOI from PDF metadata (tries pymupdf, falls back to poppler)."""
174
+ # Try pymupdf first (no subprocess overhead)
175
175
 
176
176
  try:
177
- return parse_doi_from_pdf_metadata_fitz(pdf_path)
177
+ return parse_doi_from_pdf_metadata_pymupdf(pdf_path)
178
178
  except ImportError:
179
179
  # Fall back to poppler-utils
180
180
  return parse_doi_from_pdf_metadata_poppler(pdf_path)
@@ -203,6 +203,42 @@ def view_pdf(filepath):
203
203
  subprocess.call(('xdg-open', filepath))
204
204
 
205
205
 
206
+ def file_uri_to_path(arg):
207
+ """Return the local path of a file:// URI, or `arg` unchanged otherwise
208
+
209
+ Desktop entries launch viewers with `%U`, i.e. with file:// URIs.
210
+ """
211
+ if arg.startswith('file://'):
212
+ from urllib.parse import urlparse, unquote
213
+ return unquote(urlparse(arg).path)
214
+ return arg
215
+
216
+
217
+ def _is_papers_cli_script(path):
218
+ """True if `path` is a console script of this package (`papers` or `papers-cli`)"""
219
+ try:
220
+ with open(path, 'rb') as f:
221
+ head = f.read(1024)
222
+ except OSError:
223
+ return False
224
+ return b'papers.__main__' in head
225
+
226
+
227
+ def find_masked_viewer(name='papers'):
228
+ """Return the first `name` executable on $PATH that is not this tool, or None
229
+
230
+ This tool's `papers` command often masks the GNOME Papers document viewer
231
+ in $PATH (issue #107). Console scripts of this package are recognized by
232
+ their content, so other installs of it (pipx, venvs) are skipped too.
233
+ """
234
+ for d in os.environ.get('PATH', '').split(os.pathsep):
235
+ candidate = os.path.join(d or '.', name)
236
+ if os.path.isfile(candidate) and os.access(candidate, os.X_OK) \
237
+ and not _is_papers_cli_script(candidate):
238
+ return candidate
239
+ return None
240
+
241
+
206
242
  def open_folder(path):
207
243
  system_platform = platform.system()
208
244
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: papers-cli
3
- Version: 2.17.2
3
+ Version: 2.17.4
4
4
  Summary: utilities to keep your PDF library organized
5
5
  Author-email: Mahé Perrette <mahe.perrette@gmail.com>
6
6
  License: MIT
@@ -42,6 +42,8 @@ papers_cli.egg-info/SOURCES.txt
42
42
  papers_cli.egg-info/dependency_links.txt
43
43
  papers_cli.egg-info/entry_points.txt
44
44
  papers_cli.egg-info/requires.txt
45
+ papers_cli.egg-info/scm_file_list.json
46
+ papers_cli.egg-info/scm_version.json
45
47
  papers_cli.egg-info/top_level.txt
46
48
  scripts/README.md
47
49
  scripts/benchmark_bibtexparser_load.py
@@ -0,0 +1,71 @@
1
+ {
2
+ "files": [
3
+ ".gitattributes",
4
+ ".github/FUNDING.yml",
5
+ ".github/workflows/docs.yml",
6
+ ".github/workflows/pypi.yml",
7
+ ".github/workflows/release.yml",
8
+ ".github/workflows/tests.yml",
9
+ ".gitignore",
10
+ "LICENSE",
11
+ "README.md",
12
+ "docs/adding-entries.md",
13
+ "docs/comparison.md",
14
+ "docs/configuration.md",
15
+ "docs/features.md",
16
+ "docs/git.md",
17
+ "docs/index.md",
18
+ "docs/installation.md",
19
+ "docs/library-management.md",
20
+ "docs/listing.md",
21
+ "docs/quickstart.md",
22
+ "docs/renaming.md",
23
+ "docs/undo-redo.md",
24
+ "mkdocs.yml",
25
+ "papers/__init__.py",
26
+ "papers/__main__.py",
27
+ "papers/backup.py",
28
+ "papers/bib.py",
29
+ "papers/config.py",
30
+ "papers/duplicate.py",
31
+ "papers/encoding.py",
32
+ "papers/entries.py",
33
+ "papers/extract.py",
34
+ "papers/filename.py",
35
+ "papers/install.py",
36
+ "papers/latexenc.py",
37
+ "papers/utils.py",
38
+ "pyproject.toml",
39
+ "requirements.txt",
40
+ "scripts/README.md",
41
+ "scripts/benchmark_bibtexparser_load.py",
42
+ "scripts/generate_dummy_bib.py",
43
+ "setup.py",
44
+ "tests/__init__.py",
45
+ "tests/common.py",
46
+ "tests/config.py",
47
+ "tests/download.py",
48
+ "tests/downloadedpapers/.gitignore",
49
+ "tests/downloadedpapers/bg-8-515-2011.pdf",
50
+ "tests/downloadedpapers/bg-8-515-2011.txt",
51
+ "tests/downloadedpapers/esd-4-11-2013-supplement.pdf",
52
+ "tests/downloadedpapers/esd-4-11-2013.pdf",
53
+ "tests/test_add.py",
54
+ "tests/test_backup_dirs.py",
55
+ "tests/test_bib_unit.py",
56
+ "tests/test_biblio.py",
57
+ "tests/test_check.py",
58
+ "tests/test_config_unit.py",
59
+ "tests/test_duplicates.py",
60
+ "tests/test_encoding.py",
61
+ "tests/test_extract.py",
62
+ "tests/test_extract_unit.py",
63
+ "tests/test_filecheck.py",
64
+ "tests/test_filename_unit.py",
65
+ "tests/test_install.py",
66
+ "tests/test_latexenc.py",
67
+ "tests/test_list.py",
68
+ "tests/test_undo.py",
69
+ "tests/test_utils.py"
70
+ ]
71
+ }
@@ -0,0 +1,8 @@
1
+ {
2
+ "tag": "2.17.4",
3
+ "distance": 0,
4
+ "node": "gc3df29014daa16aea45a82ff317ae1cf1cc09ca7",
5
+ "dirty": false,
6
+ "branch": "HEAD",
7
+ "node_date": "2026-10-01"
8
+ }
@@ -196,6 +196,22 @@ class TestAddBib(BibTest):
196
196
  self.assertEqual(bib.db.entries[0]['ID'], self.key1)
197
197
  self.assertTrue(self.key2 not in [e['ID'] for e in self.my.db.entries])
198
198
 
199
+ def test_addbib_cmd_update_key(self):
200
+ # --update-key regenerates the key of entries added from a bibtex file
201
+ # according to the key template (issue #108)
202
+ paperscmd(f'add {self.somebib} --bibtex {self.mybib} --update-key')
203
+ bib = Biblio.load(self.mybib, '')
204
+ self.assertEqual(len(bib.db.entries), 2)
205
+ self.assertEqual(bib.db.entries[0]['ID'], self.key1) # pre-existing entry untouched
206
+ self.assertEqual(bib.db.entries[1]['ID'], self.newkey2) # default template {author}{year}
207
+
208
+ def test_addbib_cmd_update_key_custom_template(self):
209
+ paperscmd(f'add {self.somebib} --bibtex {self.mybib} -u --key-template "{{year}}"')
210
+ bib = Biblio.load(self.mybib, '')
211
+ self.assertEqual(len(bib.db.entries), 2)
212
+ self.assertEqual(bib.db.entries[0]['ID'], self.key1)
213
+ self.assertEqual(bib.db.entries[1]['ID'], self.year)
214
+
199
215
  def test_attachment_fails_with_multiple_entries(self):
200
216
  func = lambda: paperscmd(f'add {self.pdf} {self.pdf} --bibtex {self.mybib} --filesdir {self.filesdir} --attachment {self.pdf}')
201
217
  self.assertRaises(Exception, func)
@@ -1,5 +1,11 @@
1
1
  import bibtexparser
2
+ import os
3
+ import unittest
4
+ from unittest.mock import patch
5
+ from urllib.parse import quote
2
6
  from tests.common import LocalInstallTest, BaseTest, Biblio, tempfile
7
+ from papers.__main__ import VIEWER_PASSTHROUGH_ENV
8
+ from papers.utils import PapersExit, find_masked_viewer, file_uri_to_path
3
9
  from papers.utils import strip_all
4
10
 
5
11
  bibtex = """@article{Perrette_2011,
@@ -258,31 +264,98 @@ class OpenCmdTest(LocalInstallTest):
258
264
 
259
265
  def test_bare_file_argument_opens_viewer(self):
260
266
  # `papers somefile.pdf` behaves like the (possibly masked) document
261
- # viewer: the file is opened with the system viewer (issue #107)
262
- from unittest.mock import patch
267
+ # viewer: without another `papers` in $PATH, the file is opened with
268
+ # the system viewer (issue #107)
263
269
  f = self._path('direct.pdf')
264
270
  open(f, 'w').write('x')
265
- with patch('papers.__main__.view_pdf') as viewer:
271
+ with patch.dict(os.environ), \
272
+ patch('papers.__main__.find_masked_viewer', return_value=None), \
273
+ patch('papers.__main__.view_pdf') as viewer:
274
+ os.environ.pop(VIEWER_PASSTHROUGH_ENV, None)
266
275
  self.papers(f'{f}')
267
276
  viewer.assert_called_once_with(f)
268
277
 
269
278
  def test_subcommand_wins_over_file(self):
270
279
  # a file named like a subcommand does not hijack the CLI
271
- from unittest.mock import patch
272
280
  open(self._path('status'), 'w').write('x')
273
- with patch('papers.__main__.view_pdf') as viewer:
281
+ with patch('papers.__main__.find_masked_viewer') as finder, \
282
+ patch('papers.__main__.view_pdf') as viewer:
274
283
  self.papers('status')
284
+ finder.assert_not_called()
275
285
  viewer.assert_not_called()
276
286
 
277
287
 
278
288
  class ViewerPassthroughNoInstallTest(BaseTest):
279
289
  anotherbib_content = None
280
290
 
291
+ def setUp(self):
292
+ super().setUp()
293
+ self.pdf = self._path('doc.pdf')
294
+ open(self.pdf, 'w').write('x')
295
+ env = patch.dict(os.environ)
296
+ env.start()
297
+ self.addCleanup(env.stop)
298
+ os.environ.pop(VIEWER_PASSTHROUGH_ENV, None)
299
+
281
300
  def test_bare_file_argument_without_install(self):
282
301
  # the viewer passthrough must work without any papers install
283
- from unittest.mock import patch
284
- f = self._path('doc.pdf')
285
- open(f, 'w').write('x')
286
- with patch('papers.__main__.view_pdf') as viewer:
287
- self.papers(f'{f}')
288
- viewer.assert_called_once_with(f)
302
+ with patch('papers.__main__.find_masked_viewer', return_value=None), \
303
+ patch('papers.__main__.view_pdf') as viewer:
304
+ self.papers(f'{self.pdf}')
305
+ viewer.assert_called_once_with(self.pdf)
306
+
307
+ def test_file_uri_argument(self):
308
+ # desktop entries launch the viewer as `papers %U`, i.e. with file:// URIs
309
+ uri = 'file://' + quote(self.pdf)
310
+ with patch('papers.__main__.find_masked_viewer', return_value=None), \
311
+ patch('papers.__main__.view_pdf') as viewer:
312
+ self.papers(f"'{uri}'")
313
+ viewer.assert_called_once_with(self.pdf)
314
+
315
+ def test_masked_viewer_receives_arguments_unchanged(self):
316
+ # the masked viewer is exec'd with the original arguments, instead of
317
+ # xdg-open, which may resolve back to this command
318
+ uri = 'file://' + quote(self.pdf)
319
+ with patch('papers.__main__.find_masked_viewer', return_value='/usr/bin/papers'), \
320
+ patch('papers.__main__.os.execv') as execv, \
321
+ patch('papers.__main__.view_pdf') as viewer:
322
+ self.papers(f"'{uri}' {self.pdf}")
323
+ execv.assert_called_once_with('/usr/bin/papers', ['/usr/bin/papers', uri, self.pdf])
324
+ viewer.assert_not_called()
325
+
326
+ def test_loop_guard(self):
327
+ # handed to the system viewer once already: stop instead of looping
328
+ os.environ[VIEWER_PASSTHROUGH_ENV] = '1'
329
+ with patch('papers.__main__.find_masked_viewer', return_value=None), \
330
+ patch('papers.__main__.view_pdf') as viewer:
331
+ with self.assertRaises(PapersExit):
332
+ self.papers(f'{self.pdf}')
333
+ viewer.assert_not_called()
334
+
335
+
336
+ class FindMaskedViewerTest(unittest.TestCase):
337
+
338
+ def _executable(self, path, content):
339
+ os.makedirs(os.path.dirname(path), exist_ok=True)
340
+ open(path, 'w').write(content)
341
+ os.chmod(path, 0o755)
342
+
343
+ def test_skips_papers_cli_scripts(self):
344
+ with tempfile.TemporaryDirectory() as d:
345
+ script = '#!/usr/bin/python3\nfrom papers.__main__ import main_clean_exit\n'
346
+ self._executable(os.path.join(d, 'venv', 'papers'), script)
347
+ self._executable(os.path.join(d, 'pipx', 'papers'), script)
348
+ self._executable(os.path.join(d, 'usr', 'papers'), '#!/bin/sh\n')
349
+ path = os.pathsep.join(os.path.join(d, x) for x in ['venv', 'empty', 'pipx', 'usr'])
350
+ with patch.dict(os.environ, {'PATH': path}):
351
+ self.assertEqual(find_masked_viewer(), os.path.join(d, 'usr', 'papers'))
352
+
353
+ def test_none_without_other_viewer(self):
354
+ with tempfile.TemporaryDirectory() as d:
355
+ self._executable(os.path.join(d, 'papers'), 'from papers.__main__ import main\n')
356
+ with patch.dict(os.environ, {'PATH': d}):
357
+ self.assertIsNone(find_masked_viewer())
358
+
359
+ def test_file_uri_to_path(self):
360
+ self.assertEqual(file_uri_to_path('file:///tmp/a%20b,%203.pdf'), '/tmp/a b, 3.pdf')
361
+ self.assertEqual(file_uri_to_path('/tmp/a b.pdf'), '/tmp/a b.pdf')
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