easybib 0.1.0__tar.gz → 0.2.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: easybib
3
- Version: 0.1.0
3
+ Version: 0.2.0
4
4
  Summary: Automatically fetch BibTeX entries from INSPIRE and ADS for LaTeX projects
5
5
  Author-email: Gregory Ashton <gregory.ashton@ligo.org>
6
6
  License-Expression: MIT
@@ -9,9 +9,15 @@ Project-URL: Repository, https://github.com/GregoryAshton/easybib
9
9
  Requires-Python: >=3.9
10
10
  Description-Content-Type: text/markdown
11
11
  Requires-Dist: requests
12
+ Provides-Extra: test
13
+ Requires-Dist: pytest; extra == "test"
14
+ Requires-Dist: pytest-cov; extra == "test"
12
15
 
13
16
  # easybib
14
17
 
18
+ [![Tests](https://github.com/GregoryAshton/easybib/actions/workflows/tests.yml/badge.svg)](https://github.com/GregoryAshton/easybib/actions/workflows/tests.yml)
19
+ [![codecov](https://codecov.io/gh/GregoryAshton/easybib/branch/main/graph/badge.svg)](https://codecov.io/gh/GregoryAshton/easybib)
20
+
15
21
  Automatically fetch BibTeX entries from [INSPIRE](https://inspirehep.net/) and [NASA/ADS](https://ui.adsabs.harvard.edu/) for LaTeX projects.
16
22
 
17
23
  easybib scans your `.tex` files for citation keys, looks them up on INSPIRE and/or ADS, and writes a `.bib` file with the results. It handles INSPIRE texkeys (e.g. `Author:2020abc`) and ADS bibcodes (e.g. `2016PhRvL.116f1102A`).
@@ -26,9 +32,10 @@ pip install easybib
26
32
 
27
33
  ```bash
28
34
  easybib /path/to/latex/project
35
+ easybib paper.tex
29
36
  ```
30
37
 
31
- This will scan all `.tex` files in the directory, fetch BibTeX entries, and write them to `references.bib`.
38
+ Pass a directory to scan all `.tex` files recursively, or a single `.tex` file. BibTeX entries are fetched and written to `references.bib`.
32
39
 
33
40
  ### Options
34
41
 
@@ -39,13 +46,17 @@ This will scan all `.tex` files in the directory, fetch BibTeX entries, and writ
39
46
  | `-a`, `--max-authors` | Truncate author lists (default: 3, use 0 for no limit) |
40
47
  | `-l`, `--list-keys` | List found citation keys and exit (no fetching) |
41
48
  | `--fresh` | Ignore existing output file and start from scratch |
49
+ | `--ads-api-key` | ADS API key (overrides `ADS_API_KEY` environment variable) |
42
50
 
43
51
  ### Examples
44
52
 
45
53
  ```bash
46
- # Fetch from INSPIRE (no API key needed)
54
+ # Scan a directory
47
55
  easybib ./paper -s inspire
48
56
 
57
+ # Scan a single file
58
+ easybib paper.tex
59
+
49
60
  # Use a custom output file
50
61
  easybib ./paper -o paper.bib
51
62
 
@@ -58,13 +69,19 @@ easybib ./paper -a 0
58
69
 
59
70
  ### ADS API key
60
71
 
61
- When using ADS as the source (the default), set your API key:
72
+ When using ADS as the source (the default), provide your API key either via the command line:
73
+
74
+ ```bash
75
+ easybib ./paper --ads-api-key your-key-here
76
+ ```
77
+
78
+ Or as an environment variable:
62
79
 
63
80
  ```bash
64
81
  export ADS_API_KEY="your-key-here"
65
82
  ```
66
83
 
67
- Get one from https://ui.adsabs.harvard.edu/user/settings/token.
84
+ Get a key from https://ui.adsabs.harvard.edu/user/settings/token.
68
85
 
69
86
  ## How it works
70
87
 
@@ -1,5 +1,8 @@
1
1
  # easybib
2
2
 
3
+ [![Tests](https://github.com/GregoryAshton/easybib/actions/workflows/tests.yml/badge.svg)](https://github.com/GregoryAshton/easybib/actions/workflows/tests.yml)
4
+ [![codecov](https://codecov.io/gh/GregoryAshton/easybib/branch/main/graph/badge.svg)](https://codecov.io/gh/GregoryAshton/easybib)
5
+
3
6
  Automatically fetch BibTeX entries from [INSPIRE](https://inspirehep.net/) and [NASA/ADS](https://ui.adsabs.harvard.edu/) for LaTeX projects.
4
7
 
5
8
  easybib scans your `.tex` files for citation keys, looks them up on INSPIRE and/or ADS, and writes a `.bib` file with the results. It handles INSPIRE texkeys (e.g. `Author:2020abc`) and ADS bibcodes (e.g. `2016PhRvL.116f1102A`).
@@ -14,9 +17,10 @@ pip install easybib
14
17
 
15
18
  ```bash
16
19
  easybib /path/to/latex/project
20
+ easybib paper.tex
17
21
  ```
18
22
 
19
- This will scan all `.tex` files in the directory, fetch BibTeX entries, and write them to `references.bib`.
23
+ Pass a directory to scan all `.tex` files recursively, or a single `.tex` file. BibTeX entries are fetched and written to `references.bib`.
20
24
 
21
25
  ### Options
22
26
 
@@ -27,13 +31,17 @@ This will scan all `.tex` files in the directory, fetch BibTeX entries, and writ
27
31
  | `-a`, `--max-authors` | Truncate author lists (default: 3, use 0 for no limit) |
28
32
  | `-l`, `--list-keys` | List found citation keys and exit (no fetching) |
29
33
  | `--fresh` | Ignore existing output file and start from scratch |
34
+ | `--ads-api-key` | ADS API key (overrides `ADS_API_KEY` environment variable) |
30
35
 
31
36
  ### Examples
32
37
 
33
38
  ```bash
34
- # Fetch from INSPIRE (no API key needed)
39
+ # Scan a directory
35
40
  easybib ./paper -s inspire
36
41
 
42
+ # Scan a single file
43
+ easybib paper.tex
44
+
37
45
  # Use a custom output file
38
46
  easybib ./paper -o paper.bib
39
47
 
@@ -46,13 +54,19 @@ easybib ./paper -a 0
46
54
 
47
55
  ### ADS API key
48
56
 
49
- When using ADS as the source (the default), set your API key:
57
+ When using ADS as the source (the default), provide your API key either via the command line:
58
+
59
+ ```bash
60
+ easybib ./paper --ads-api-key your-key-here
61
+ ```
62
+
63
+ Or as an environment variable:
50
64
 
51
65
  ```bash
52
66
  export ADS_API_KEY="your-key-here"
53
67
  ```
54
68
 
55
- Get one from https://ui.adsabs.harvard.edu/user/settings/token.
69
+ Get a key from https://ui.adsabs.harvard.edu/user/settings/token.
56
70
 
57
71
  ## How it works
58
72
 
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "easybib"
7
- version = "0.1.0"
7
+ version = "0.2.0"
8
8
  description = "Automatically fetch BibTeX entries from INSPIRE and ADS for LaTeX projects"
9
9
  readme = "README.md"
10
10
  license = "MIT"
@@ -16,6 +16,9 @@ dependencies = [
16
16
  "requests",
17
17
  ]
18
18
 
19
+ [project.optional-dependencies]
20
+ test = ["pytest", "pytest-cov"]
21
+
19
22
  [project.urls]
20
23
  Homepage = "https://github.com/GregoryAshton/easybib"
21
24
  Repository = "https://github.com/GregoryAshton/easybib"
@@ -17,9 +17,9 @@ def main():
17
17
  parser = argparse.ArgumentParser(
18
18
  description="Extract citations and download BibTeX from NASA/ADS"
19
19
  )
20
- parser.add_argument("directory", help="Directory containing LaTeX files")
20
+ parser.add_argument("path", help="LaTeX file or directory containing LaTeX files")
21
21
  parser.add_argument(
22
- "-o", "--output", default="references.bib", help="Output BibTeX file"
22
+ "-o", "--output", default="references.bib", help="Output BibTeX file (existing entries are retained)"
23
23
  )
24
24
  parser.add_argument(
25
25
  "-a",
@@ -46,13 +46,21 @@ def main():
46
46
  default="ads",
47
47
  help="Preferred BibTeX source: 'ads' (default), 'inspire', or 'auto' (based on key format)",
48
48
  )
49
+ parser.add_argument(
50
+ "--ads-api-key",
51
+ help="ADS API key (overrides ADS_API_KEY environment variable)",
52
+ )
49
53
  args = parser.parse_args()
50
54
 
51
55
  # Collect all citation keys
52
- tex_dir = Path(args.directory)
56
+ input_path = Path(args.path)
53
57
  all_keys = set()
54
58
  all_warnings = []
55
- for tex_file in tex_dir.glob("**/*.tex"):
59
+ if input_path.is_file():
60
+ tex_files = [input_path]
61
+ else:
62
+ tex_files = input_path.glob("**/*.tex")
63
+ for tex_file in tex_files:
56
64
  keys, warnings = extract_cite_keys(tex_file)
57
65
  all_keys.update(keys)
58
66
  all_warnings.extend(warnings)
@@ -73,7 +81,7 @@ def main():
73
81
  return 0
74
82
 
75
83
  # Check for ADS API key (not required if using --source inspire)
76
- api_key = os.getenv("ADS_API_KEY")
84
+ api_key = args.ads_api_key or os.getenv("ADS_API_KEY")
77
85
  if not api_key and args.source != "inspire":
78
86
  print("Error: ADS_API_KEY environment variable not set")
79
87
  print("Get your API key from: https://ui.adsabs.harvard.edu/user/settings/token")
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: easybib
3
- Version: 0.1.0
3
+ Version: 0.2.0
4
4
  Summary: Automatically fetch BibTeX entries from INSPIRE and ADS for LaTeX projects
5
5
  Author-email: Gregory Ashton <gregory.ashton@ligo.org>
6
6
  License-Expression: MIT
@@ -9,9 +9,15 @@ Project-URL: Repository, https://github.com/GregoryAshton/easybib
9
9
  Requires-Python: >=3.9
10
10
  Description-Content-Type: text/markdown
11
11
  Requires-Dist: requests
12
+ Provides-Extra: test
13
+ Requires-Dist: pytest; extra == "test"
14
+ Requires-Dist: pytest-cov; extra == "test"
12
15
 
13
16
  # easybib
14
17
 
18
+ [![Tests](https://github.com/GregoryAshton/easybib/actions/workflows/tests.yml/badge.svg)](https://github.com/GregoryAshton/easybib/actions/workflows/tests.yml)
19
+ [![codecov](https://codecov.io/gh/GregoryAshton/easybib/branch/main/graph/badge.svg)](https://codecov.io/gh/GregoryAshton/easybib)
20
+
15
21
  Automatically fetch BibTeX entries from [INSPIRE](https://inspirehep.net/) and [NASA/ADS](https://ui.adsabs.harvard.edu/) for LaTeX projects.
16
22
 
17
23
  easybib scans your `.tex` files for citation keys, looks them up on INSPIRE and/or ADS, and writes a `.bib` file with the results. It handles INSPIRE texkeys (e.g. `Author:2020abc`) and ADS bibcodes (e.g. `2016PhRvL.116f1102A`).
@@ -26,9 +32,10 @@ pip install easybib
26
32
 
27
33
  ```bash
28
34
  easybib /path/to/latex/project
35
+ easybib paper.tex
29
36
  ```
30
37
 
31
- This will scan all `.tex` files in the directory, fetch BibTeX entries, and write them to `references.bib`.
38
+ Pass a directory to scan all `.tex` files recursively, or a single `.tex` file. BibTeX entries are fetched and written to `references.bib`.
32
39
 
33
40
  ### Options
34
41
 
@@ -39,13 +46,17 @@ This will scan all `.tex` files in the directory, fetch BibTeX entries, and writ
39
46
  | `-a`, `--max-authors` | Truncate author lists (default: 3, use 0 for no limit) |
40
47
  | `-l`, `--list-keys` | List found citation keys and exit (no fetching) |
41
48
  | `--fresh` | Ignore existing output file and start from scratch |
49
+ | `--ads-api-key` | ADS API key (overrides `ADS_API_KEY` environment variable) |
42
50
 
43
51
  ### Examples
44
52
 
45
53
  ```bash
46
- # Fetch from INSPIRE (no API key needed)
54
+ # Scan a directory
47
55
  easybib ./paper -s inspire
48
56
 
57
+ # Scan a single file
58
+ easybib paper.tex
59
+
49
60
  # Use a custom output file
50
61
  easybib ./paper -o paper.bib
51
62
 
@@ -58,13 +69,19 @@ easybib ./paper -a 0
58
69
 
59
70
  ### ADS API key
60
71
 
61
- When using ADS as the source (the default), set your API key:
72
+ When using ADS as the source (the default), provide your API key either via the command line:
73
+
74
+ ```bash
75
+ easybib ./paper --ads-api-key your-key-here
76
+ ```
77
+
78
+ Or as an environment variable:
62
79
 
63
80
  ```bash
64
81
  export ADS_API_KEY="your-key-here"
65
82
  ```
66
83
 
67
- Get one from https://ui.adsabs.harvard.edu/user/settings/token.
84
+ Get a key from https://ui.adsabs.harvard.edu/user/settings/token.
68
85
 
69
86
  ## How it works
70
87
 
@@ -9,4 +9,7 @@ src/easybib.egg-info/SOURCES.txt
9
9
  src/easybib.egg-info/dependency_links.txt
10
10
  src/easybib.egg-info/entry_points.txt
11
11
  src/easybib.egg-info/requires.txt
12
- src/easybib.egg-info/top_level.txt
12
+ src/easybib.egg-info/top_level.txt
13
+ tests/test_cli.py
14
+ tests/test_core.py
15
+ tests/test_fetch.py
@@ -0,0 +1,5 @@
1
+ requests
2
+
3
+ [test]
4
+ pytest
5
+ pytest-cov
@@ -0,0 +1,101 @@
1
+ """Tests for easybib CLI."""
2
+
3
+ import subprocess
4
+ import sys
5
+ from unittest.mock import patch
6
+
7
+ from easybib.cli import main
8
+
9
+
10
+ class TestListKeys:
11
+ def test_list_keys_single_file(self, tmp_path, capsys):
12
+ tex = tmp_path / "test.tex"
13
+ tex.write_text(r"\cite{Author:2020abc} and \citep{Other:2021xyz}")
14
+ with patch("sys.argv", ["easybib", str(tex), "--list-keys"]):
15
+ result = main()
16
+ assert result == 0
17
+ captured = capsys.readouterr()
18
+ assert "Author:2020abc" in captured.out
19
+ assert "Other:2021xyz" in captured.out
20
+
21
+ def test_list_keys_directory(self, tmp_path, capsys):
22
+ sub = tmp_path / "subdir"
23
+ sub.mkdir()
24
+ (tmp_path / "a.tex").write_text(r"\cite{A:2020abc}")
25
+ (sub / "b.tex").write_text(r"\cite{B:2021xyz}")
26
+ with patch("sys.argv", ["easybib", str(tmp_path), "--list-keys"]):
27
+ result = main()
28
+ assert result == 0
29
+ captured = capsys.readouterr()
30
+ assert "A:2020abc" in captured.out
31
+ assert "B:2021xyz" in captured.out
32
+
33
+
34
+ class TestMissingApiKey:
35
+ def test_error_without_api_key(self, tmp_path, capsys):
36
+ tex = tmp_path / "test.tex"
37
+ tex.write_text(r"\cite{Author:2020abc}")
38
+ with (
39
+ patch("sys.argv", ["easybib", str(tex)]),
40
+ patch.dict("os.environ", {}, clear=True),
41
+ ):
42
+ result = main()
43
+ assert result == 1
44
+ captured = capsys.readouterr()
45
+ assert "ADS_API_KEY" in captured.out
46
+
47
+ def test_inspire_source_no_api_key_ok(self, tmp_path, capsys):
48
+ """Using --source inspire should not require an ADS API key."""
49
+ tex = tmp_path / "test.tex"
50
+ tex.write_text(r"\cite{Author:2020abc}")
51
+ with (
52
+ patch("sys.argv", ["easybib", str(tex), "--source", "inspire"]),
53
+ patch.dict("os.environ", {}, clear=True),
54
+ patch("easybib.cli.fetch_bibtex", return_value=(None, None)),
55
+ ):
56
+ result = main()
57
+ # Should not return 1 for missing API key
58
+ assert result is None
59
+
60
+
61
+ class TestAdsApiKeyOverride:
62
+ def test_flag_overrides_env(self, tmp_path, capsys):
63
+ tex = tmp_path / "test.tex"
64
+ tex.write_text(r"\cite{Author:2020abc}")
65
+ with (
66
+ patch(
67
+ "sys.argv",
68
+ ["easybib", str(tex), "--ads-api-key", "flag-key"],
69
+ ),
70
+ patch.dict("os.environ", {"ADS_API_KEY": "env-key"}, clear=True),
71
+ patch("easybib.cli.fetch_bibtex") as mock_fetch,
72
+ ):
73
+ mock_fetch.return_value = (
74
+ "@article{Author:2020abc,\n title={Test},\n author={Doe, J.},\n}",
75
+ "ADS",
76
+ )
77
+ main()
78
+ # The flag value should be used, not the env var
79
+ call_args = mock_fetch.call_args
80
+ assert call_args[0][1] == "flag-key"
81
+
82
+
83
+ class TestFileVsDirectory:
84
+ def test_single_file(self, tmp_path, capsys):
85
+ tex = tmp_path / "paper.tex"
86
+ tex.write_text(r"\cite{A:2020abc}")
87
+ with patch("sys.argv", ["easybib", str(tex), "--list-keys"]):
88
+ main()
89
+ captured = capsys.readouterr()
90
+ assert "A:2020abc" in captured.out
91
+
92
+ def test_directory_recursive(self, tmp_path, capsys):
93
+ nested = tmp_path / "ch1"
94
+ nested.mkdir()
95
+ (nested / "intro.tex").write_text(r"\cite{A:2020abc}")
96
+ (tmp_path / "main.tex").write_text(r"\cite{B:2021xyz}")
97
+ with patch("sys.argv", ["easybib", str(tmp_path), "--list-keys"]):
98
+ main()
99
+ captured = capsys.readouterr()
100
+ assert "A:2020abc" in captured.out
101
+ assert "B:2021xyz" in captured.out
@@ -0,0 +1,232 @@
1
+ """Tests for easybib.core pure functions."""
2
+
3
+ import pytest
4
+
5
+ from easybib.core import (
6
+ extract_cite_keys,
7
+ extract_existing_bib_keys,
8
+ is_ads_bibcode,
9
+ is_inspire_key,
10
+ replace_bibtex_key,
11
+ truncate_authors,
12
+ )
13
+
14
+
15
+ # --- extract_cite_keys ---
16
+
17
+
18
+ class TestExtractCiteKeys:
19
+ def test_basic_cite(self, tmp_path):
20
+ tex = tmp_path / "test.tex"
21
+ tex.write_text(r"\cite{Author:2020abc}")
22
+ keys, warnings = extract_cite_keys(tex)
23
+ assert keys == ["Author:2020abc"]
24
+ assert warnings == []
25
+
26
+ def test_citep_and_citet(self, tmp_path):
27
+ tex = tmp_path / "test.tex"
28
+ tex.write_text(r"\citep{A:2020abc} and \citet{B:2021xyz}")
29
+ keys, warnings = extract_cite_keys(tex)
30
+ assert set(keys) == {"A:2020abc", "B:2021xyz"}
31
+ assert warnings == []
32
+
33
+ def test_capital_citep(self, tmp_path):
34
+ tex = tmp_path / "test.tex"
35
+ tex.write_text(r"\Citep{Author:2020abc}")
36
+ keys, warnings = extract_cite_keys(tex)
37
+ assert keys == ["Author:2020abc"]
38
+
39
+ def test_optional_args(self, tmp_path):
40
+ tex = tmp_path / "test.tex"
41
+ tex.write_text(r"\citep[e.g.][]{Author:2020abc}")
42
+ keys, warnings = extract_cite_keys(tex)
43
+ assert keys == ["Author:2020abc"]
44
+
45
+ def test_multiple_keys_in_single_cite(self, tmp_path):
46
+ tex = tmp_path / "test.tex"
47
+ tex.write_text(r"\cite{A:2020abc, B:2021xyz}")
48
+ keys, warnings = extract_cite_keys(tex)
49
+ assert set(keys) == {"A:2020abc", "B:2021xyz"}
50
+
51
+ def test_empty_key_warning(self, tmp_path):
52
+ tex = tmp_path / "test.tex"
53
+ tex.write_text(r"\cite{A:2020abc, , B:2021xyz}")
54
+ keys, warnings = extract_cite_keys(tex)
55
+ assert set(keys) == {"A:2020abc", "B:2021xyz"}
56
+ assert len(warnings) == 1
57
+ assert "Empty citation key" in warnings[0]
58
+
59
+ def test_key_without_colon_warning(self, tmp_path):
60
+ tex = tmp_path / "test.tex"
61
+ tex.write_text(r"\cite{nocolon}")
62
+ keys, warnings = extract_cite_keys(tex)
63
+ assert keys == []
64
+ assert len(warnings) == 1
65
+ assert "not an INSPIRE/ADS key" in warnings[0]
66
+
67
+ def test_citeauthor_and_citeyear(self, tmp_path):
68
+ tex = tmp_path / "test.tex"
69
+ tex.write_text(r"\citeauthor{A:2020abc} \citeyear{B:2021xyz}")
70
+ keys, warnings = extract_cite_keys(tex)
71
+ assert set(keys) == {"A:2020abc", "B:2021xyz"}
72
+
73
+ def test_no_citations(self, tmp_path):
74
+ tex = tmp_path / "test.tex"
75
+ tex.write_text(r"No citations here.")
76
+ keys, warnings = extract_cite_keys(tex)
77
+ assert keys == []
78
+ assert warnings == []
79
+
80
+ def test_deduplication_not_applied(self, tmp_path):
81
+ """extract_cite_keys returns all occurrences (dedup is done by the caller)."""
82
+ tex = tmp_path / "test.tex"
83
+ tex.write_text(r"\cite{A:2020abc} \cite{A:2020abc}")
84
+ keys, warnings = extract_cite_keys(tex)
85
+ assert keys == ["A:2020abc", "A:2020abc"]
86
+
87
+
88
+ # --- extract_existing_bib_keys ---
89
+
90
+
91
+ class TestExtractExistingBibKeys:
92
+ def test_parse_keys(self, tmp_path):
93
+ bib = tmp_path / "refs.bib"
94
+ bib.write_text(
95
+ "@article{Author:2020abc,\n"
96
+ " title={Test},\n"
97
+ "}\n\n"
98
+ "@inproceedings{Other:2021xyz,\n"
99
+ " title={Other},\n"
100
+ "}\n"
101
+ )
102
+ keys = extract_existing_bib_keys(bib)
103
+ assert keys == {"Author:2020abc", "Other:2021xyz"}
104
+
105
+ def test_nonexistent_file(self, tmp_path):
106
+ bib = tmp_path / "missing.bib"
107
+ keys = extract_existing_bib_keys(bib)
108
+ assert keys == set()
109
+
110
+ def test_empty_file(self, tmp_path):
111
+ bib = tmp_path / "empty.bib"
112
+ bib.write_text("")
113
+ keys = extract_existing_bib_keys(bib)
114
+ assert keys == set()
115
+
116
+
117
+ # --- replace_bibtex_key ---
118
+
119
+
120
+ class TestReplaceBibtexKey:
121
+ def test_simple_replacement(self):
122
+ bibtex = "@article{OldKey:2020abc,\n title={Test},\n}"
123
+ result = replace_bibtex_key(bibtex, "NewKey:2020xyz")
124
+ assert result.startswith("@article{NewKey:2020xyz,")
125
+
126
+ def test_preserves_content(self):
127
+ bibtex = "@article{OldKey:2020abc,\n title={Test},\n author={Doe},\n}"
128
+ result = replace_bibtex_key(bibtex, "New:2020xyz")
129
+ assert "title={Test}" in result
130
+ assert "author={Doe}" in result
131
+
132
+ def test_only_replaces_first(self):
133
+ bibtex = "@article{A:2020abc,\n note={See also A:2020abc},\n}"
134
+ result = replace_bibtex_key(bibtex, "B:2020xyz")
135
+ assert result.startswith("@article{B:2020xyz,")
136
+ assert "See also A:2020abc" in result
137
+
138
+
139
+ # --- truncate_authors ---
140
+
141
+
142
+ class TestTruncateAuthors:
143
+ def test_truncation(self):
144
+ bibtex = (
145
+ "@article{Key:2020abc,\n"
146
+ " author={Alpha, A. and Beta, B. and Gamma, G. and Delta, D. and Epsilon, E.},\n"
147
+ " title={Test},\n"
148
+ "}"
149
+ )
150
+ result = truncate_authors(bibtex, max_authors=3)
151
+ assert "Alpha, A. and Beta, B. and Gamma, G. and others" in result
152
+ assert "Delta" not in result
153
+
154
+ def test_no_truncation_when_within_limit(self):
155
+ bibtex = (
156
+ "@article{Key:2020abc,\n"
157
+ " author={Alpha, A. and Beta, B.},\n"
158
+ " title={Test},\n"
159
+ "}"
160
+ )
161
+ result = truncate_authors(bibtex, max_authors=3)
162
+ assert result == bibtex
163
+
164
+ def test_max_authors_zero_no_change(self):
165
+ bibtex = (
166
+ "@article{Key:2020abc,\n"
167
+ " author={Alpha, A. and Beta, B. and Gamma, G. and Delta, D.},\n"
168
+ " title={Test},\n"
169
+ "}"
170
+ )
171
+ result = truncate_authors(bibtex, max_authors=0)
172
+ assert result == bibtex
173
+
174
+ def test_max_authors_none_no_change(self):
175
+ bibtex = (
176
+ "@article{Key:2020abc,\n"
177
+ " author={Alpha, A. and Beta, B. and Gamma, G. and Delta, D.},\n"
178
+ " title={Test},\n"
179
+ "}"
180
+ )
181
+ result = truncate_authors(bibtex, max_authors=None)
182
+ assert result == bibtex
183
+
184
+ def test_exact_limit(self):
185
+ bibtex = (
186
+ "@article{Key:2020abc,\n"
187
+ " author={Alpha, A. and Beta, B. and Gamma, G.},\n"
188
+ " title={Test},\n"
189
+ "}"
190
+ )
191
+ result = truncate_authors(bibtex, max_authors=3)
192
+ assert result == bibtex
193
+
194
+
195
+ # --- is_ads_bibcode ---
196
+
197
+
198
+ class TestIsAdsBibcode:
199
+ def test_positive(self):
200
+ assert is_ads_bibcode("2016PhRvL.116f1102A") is True
201
+
202
+ def test_positive_with_ampersand(self):
203
+ assert is_ads_bibcode("2020A&A...641A...6P") is True
204
+
205
+ def test_negative_inspire_key(self):
206
+ assert is_ads_bibcode("Abbott:2016blz") is False
207
+
208
+ def test_negative_short_string(self):
209
+ assert is_ads_bibcode("2020") is False
210
+
211
+ def test_negative_no_leading_year(self):
212
+ assert is_ads_bibcode("PhRvL.116f1102A") is False
213
+
214
+
215
+ # --- is_inspire_key ---
216
+
217
+
218
+ class TestIsInspireKey:
219
+ def test_positive(self):
220
+ assert is_inspire_key("Abbott:2016blz") is True
221
+
222
+ def test_positive_hyphenated(self):
223
+ assert is_inspire_key("LIGO-Scientific:2020abc") is True
224
+
225
+ def test_negative_ads_bibcode(self):
226
+ assert is_inspire_key("2016PhRvL.116f1102A") is False
227
+
228
+ def test_negative_no_colon(self):
229
+ assert is_inspire_key("Abbott2016blz") is False
230
+
231
+ def test_negative_missing_letters(self):
232
+ assert is_inspire_key("Abbott:2016") is False
@@ -0,0 +1,201 @@
1
+ """Tests for easybib.core network functions (mocked)."""
2
+
3
+ from unittest.mock import MagicMock, patch
4
+
5
+ from easybib.core import (
6
+ fetch_bibtex,
7
+ get_ads_bibtex,
8
+ get_ads_info_from_inspire,
9
+ get_inspire_bibtex,
10
+ search_ads_by_arxiv,
11
+ )
12
+
13
+ SAMPLE_BIBTEX = "@article{Author:2020abc,\n title={Test},\n author={Doe, J.},\n}"
14
+
15
+
16
+ # --- get_inspire_bibtex ---
17
+
18
+
19
+ class TestGetInspireBibtex:
20
+ @patch("easybib.core.requests.get")
21
+ def test_success(self, mock_get):
22
+ mock_get.return_value = MagicMock(status_code=200, text=SAMPLE_BIBTEX)
23
+ result = get_inspire_bibtex("Author:2020abc")
24
+ assert result == SAMPLE_BIBTEX.strip()
25
+
26
+ @patch("easybib.core.requests.get")
27
+ def test_empty_response(self, mock_get):
28
+ mock_get.return_value = MagicMock(status_code=200, text=" ")
29
+ result = get_inspire_bibtex("Author:2020abc")
30
+ assert result is None
31
+
32
+ @patch("easybib.core.requests.get")
33
+ def test_non_200(self, mock_get):
34
+ mock_get.return_value = MagicMock(status_code=404, text="")
35
+ result = get_inspire_bibtex("Author:2020abc")
36
+ assert result is None
37
+
38
+
39
+ # --- get_ads_bibtex ---
40
+
41
+
42
+ class TestGetAdsBibtex:
43
+ @patch("easybib.core.requests.post")
44
+ def test_success(self, mock_post):
45
+ mock_post.return_value = MagicMock(
46
+ status_code=200,
47
+ json=MagicMock(return_value={"export": SAMPLE_BIBTEX}),
48
+ )
49
+ result = get_ads_bibtex("2020ApJ...000..000A", "fake-key")
50
+ assert result == SAMPLE_BIBTEX.strip()
51
+
52
+ @patch("easybib.core.requests.post")
53
+ def test_no_records(self, mock_post):
54
+ mock_post.return_value = MagicMock(
55
+ status_code=200,
56
+ json=MagicMock(return_value={"export": "No records found"}),
57
+ )
58
+ result = get_ads_bibtex("2020ApJ...000..000A", "fake-key")
59
+ assert result is None
60
+
61
+ @patch("easybib.core.requests.post")
62
+ def test_non_200(self, mock_post):
63
+ mock_post.return_value = MagicMock(status_code=500)
64
+ result = get_ads_bibtex("2020ApJ...000..000A", "fake-key")
65
+ assert result is None
66
+
67
+
68
+ # --- get_ads_info_from_inspire ---
69
+
70
+
71
+ class TestGetAdsInfoFromInspire:
72
+ @patch("easybib.core.requests.get")
73
+ def test_success(self, mock_get):
74
+ mock_get.return_value = MagicMock(
75
+ status_code=200,
76
+ json=MagicMock(
77
+ return_value={
78
+ "hits": {
79
+ "hits": [
80
+ {
81
+ "metadata": {
82
+ "external_system_identifiers": [
83
+ {"schema": "ADS", "value": "2020ApJ...000..000A"}
84
+ ],
85
+ "arxiv_eprints": [{"value": "2001.12345"}],
86
+ }
87
+ }
88
+ ]
89
+ }
90
+ }
91
+ ),
92
+ )
93
+ bibcode, arxiv_id = get_ads_info_from_inspire("Author:2020abc")
94
+ assert bibcode == "2020ApJ...000..000A"
95
+ assert arxiv_id == "2001.12345"
96
+
97
+ @patch("easybib.core.requests.get")
98
+ def test_no_hits(self, mock_get):
99
+ mock_get.return_value = MagicMock(
100
+ status_code=200,
101
+ json=MagicMock(return_value={"hits": {"hits": []}}),
102
+ )
103
+ bibcode, arxiv_id = get_ads_info_from_inspire("Author:2020abc")
104
+ assert bibcode is None
105
+ assert arxiv_id is None
106
+
107
+ @patch("easybib.core.requests.get")
108
+ def test_non_200(self, mock_get):
109
+ mock_get.return_value = MagicMock(status_code=500)
110
+ bibcode, arxiv_id = get_ads_info_from_inspire("Author:2020abc")
111
+ assert bibcode is None
112
+ assert arxiv_id is None
113
+
114
+
115
+ # --- search_ads_by_arxiv ---
116
+
117
+
118
+ class TestSearchAdsByArxiv:
119
+ @patch("easybib.core.requests.get")
120
+ def test_success(self, mock_get):
121
+ mock_get.return_value = MagicMock(
122
+ status_code=200,
123
+ json=MagicMock(
124
+ return_value={
125
+ "response": {"docs": [{"bibcode": "2020ApJ...000..000A"}]}
126
+ }
127
+ ),
128
+ )
129
+ result = search_ads_by_arxiv("2001.12345", "fake-key")
130
+ assert result == "2020ApJ...000..000A"
131
+
132
+ @patch("easybib.core.requests.get")
133
+ def test_empty_docs(self, mock_get):
134
+ mock_get.return_value = MagicMock(
135
+ status_code=200,
136
+ json=MagicMock(return_value={"response": {"docs": []}}),
137
+ )
138
+ result = search_ads_by_arxiv("2001.12345", "fake-key")
139
+ assert result is None
140
+
141
+ @patch("easybib.core.requests.get")
142
+ def test_non_200(self, mock_get):
143
+ mock_get.return_value = MagicMock(status_code=403)
144
+ result = search_ads_by_arxiv("2001.12345", "fake-key")
145
+ assert result is None
146
+
147
+
148
+ # --- fetch_bibtex (source routing) ---
149
+
150
+
151
+ class TestFetchBibtex:
152
+ @patch("easybib.core.get_inspire_bibtex")
153
+ @patch("easybib.core.get_ads_bibtex")
154
+ @patch("easybib.core.get_ads_info_from_inspire")
155
+ def test_ads_source(self, mock_info, mock_ads, mock_inspire):
156
+ """With source='ads', ADS path is tried (via INSPIRE cross-ref)."""
157
+ mock_info.return_value = ("2020ApJ...000..000A", None)
158
+ mock_ads.return_value = SAMPLE_BIBTEX
159
+ result, source = fetch_bibtex("Author:2020abc", "fake-key", source="ads")
160
+ assert result == SAMPLE_BIBTEX
161
+ assert "ADS" in source
162
+
163
+ @patch("easybib.core.get_inspire_bibtex")
164
+ def test_inspire_source(self, mock_inspire):
165
+ """With source='inspire', INSPIRE is tried first."""
166
+ mock_inspire.return_value = SAMPLE_BIBTEX
167
+ result, source = fetch_bibtex("Author:2020abc", "fake-key", source="inspire")
168
+ assert result == SAMPLE_BIBTEX
169
+ assert "INSPIRE" in source
170
+
171
+ @patch("easybib.core.get_inspire_bibtex")
172
+ def test_auto_source_inspire_key(self, mock_inspire):
173
+ """With source='auto' and an INSPIRE-style key, INSPIRE is tried first."""
174
+ mock_inspire.return_value = SAMPLE_BIBTEX
175
+ result, source = fetch_bibtex("Author:2020abc", "fake-key", source="auto")
176
+ assert result == SAMPLE_BIBTEX
177
+ assert "INSPIRE" in source
178
+
179
+ @patch("easybib.core.get_ads_bibtex")
180
+ def test_auto_source_ads_bibcode(self, mock_ads):
181
+ """With source='auto' and an ADS-style key, ADS is tried first."""
182
+ mock_ads.return_value = SAMPLE_BIBTEX
183
+ result, source = fetch_bibtex(
184
+ "2016PhRvL.116f1102A", "fake-key", source="auto"
185
+ )
186
+ assert result == SAMPLE_BIBTEX
187
+ assert "ADS" in source
188
+
189
+ @patch("easybib.core.get_inspire_bibtex")
190
+ @patch("easybib.core.get_ads_bibtex")
191
+ @patch("easybib.core.get_ads_info_from_inspire")
192
+ @patch("easybib.core.search_ads_by_arxiv")
193
+ def test_not_found(self, mock_search, mock_info, mock_ads, mock_inspire):
194
+ """When nothing is found, returns (None, None)."""
195
+ mock_inspire.return_value = None
196
+ mock_ads.return_value = None
197
+ mock_info.return_value = (None, None)
198
+ mock_search.return_value = None
199
+ result, source = fetch_bibtex("Author:2020abc", "fake-key", source="ads")
200
+ assert result is None
201
+ assert source is None
@@ -1 +0,0 @@
1
- requests
File without changes
File without changes
File without changes
File without changes