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.
- {easybib-0.1.0 → easybib-0.2.0}/PKG-INFO +22 -5
- {easybib-0.1.0 → easybib-0.2.0}/README.md +18 -4
- {easybib-0.1.0 → easybib-0.2.0}/pyproject.toml +4 -1
- {easybib-0.1.0 → easybib-0.2.0}/src/easybib/cli.py +13 -5
- {easybib-0.1.0 → easybib-0.2.0}/src/easybib.egg-info/PKG-INFO +22 -5
- {easybib-0.1.0 → easybib-0.2.0}/src/easybib.egg-info/SOURCES.txt +4 -1
- easybib-0.2.0/src/easybib.egg-info/requires.txt +5 -0
- easybib-0.2.0/tests/test_cli.py +101 -0
- easybib-0.2.0/tests/test_core.py +232 -0
- easybib-0.2.0/tests/test_fetch.py +201 -0
- easybib-0.1.0/src/easybib.egg-info/requires.txt +0 -1
- {easybib-0.1.0 → easybib-0.2.0}/setup.cfg +0 -0
- {easybib-0.1.0 → easybib-0.2.0}/src/easybib/__init__.py +0 -0
- {easybib-0.1.0 → easybib-0.2.0}/src/easybib/__main__.py +0 -0
- {easybib-0.1.0 → easybib-0.2.0}/src/easybib/core.py +0 -0
- {easybib-0.1.0 → easybib-0.2.0}/src/easybib.egg-info/dependency_links.txt +0 -0
- {easybib-0.1.0 → easybib-0.2.0}/src/easybib.egg-info/entry_points.txt +0 -0
- {easybib-0.1.0 → easybib-0.2.0}/src/easybib.egg-info/top_level.txt +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: easybib
|
|
3
|
-
Version: 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
|
+
[](https://github.com/GregoryAshton/easybib/actions/workflows/tests.yml)
|
|
19
|
+
[](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
|
-
|
|
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
|
-
#
|
|
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),
|
|
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
|
|
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
|
+
[](https://github.com/GregoryAshton/easybib/actions/workflows/tests.yml)
|
|
4
|
+
[](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
|
-
|
|
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
|
-
#
|
|
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),
|
|
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
|
|
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.
|
|
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("
|
|
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
|
-
|
|
56
|
+
input_path = Path(args.path)
|
|
53
57
|
all_keys = set()
|
|
54
58
|
all_warnings = []
|
|
55
|
-
|
|
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.
|
|
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
|
+
[](https://github.com/GregoryAshton/easybib/actions/workflows/tests.yml)
|
|
19
|
+
[](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
|
-
|
|
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
|
-
#
|
|
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),
|
|
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
|
|
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,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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|