paperstack-cli 0.3.0__tar.gz → 0.3.2__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (31) hide show
  1. {paperstack_cli-0.3.0 → paperstack_cli-0.3.2}/PKG-INFO +50 -7
  2. {paperstack_cli-0.3.0 → paperstack_cli-0.3.2}/README.md +46 -5
  3. {paperstack_cli-0.3.0 → paperstack_cli-0.3.2}/pyproject.toml +14 -4
  4. paperstack_cli-0.3.2/scripts/build/vendor/marked.LICENSE +36 -0
  5. paperstack_cli-0.3.2/src/paperstack/arxiv.py +125 -0
  6. {paperstack_cli-0.3.0 → paperstack_cli-0.3.2}/src/paperstack/citations.py +2 -3
  7. {paperstack_cli-0.3.0 → paperstack_cli-0.3.2}/src/paperstack/cli.py +263 -37
  8. {paperstack_cli-0.3.0 → paperstack_cli-0.3.2}/src/paperstack/content/arxiv_pdf.py +2 -2
  9. {paperstack_cli-0.3.0 → paperstack_cli-0.3.2}/src/paperstack/corpora.py +27 -3
  10. paperstack_cli-0.3.2/src/paperstack/credentials.py +147 -0
  11. {paperstack_cli-0.3.0 → paperstack_cli-0.3.2}/src/paperstack/dblp_build.py +3 -1
  12. {paperstack_cli-0.3.0 → paperstack_cli-0.3.2}/src/paperstack/dblp_index.py +28 -54
  13. {paperstack_cli-0.3.0 → paperstack_cli-0.3.2}/src/paperstack/entrypoint.py +9 -1
  14. {paperstack_cli-0.3.0 → paperstack_cli-0.3.2}/src/paperstack/metadata.py +20 -8
  15. paperstack_cli-0.3.2/src/paperstack/semantic_scholar.py +175 -0
  16. {paperstack_cli-0.3.0 → paperstack_cli-0.3.2}/src/paperstack/viewer.py +38 -10
  17. {paperstack_cli-0.3.0 → paperstack_cli-0.3.2}/.gitignore +0 -0
  18. {paperstack_cli-0.3.0 → paperstack_cli-0.3.2}/LICENSE +0 -0
  19. {paperstack_cli-0.3.0 → paperstack_cli-0.3.2}/scripts/build/site/app.js +0 -0
  20. {paperstack_cli-0.3.0 → paperstack_cli-0.3.2}/scripts/build/site/entry.html +0 -0
  21. {paperstack_cli-0.3.0 → paperstack_cli-0.3.2}/scripts/build/site/favicon.svg +0 -0
  22. {paperstack_cli-0.3.0 → paperstack_cli-0.3.2}/scripts/build/site/index.html +0 -0
  23. {paperstack_cli-0.3.0 → paperstack_cli-0.3.2}/scripts/build/site/style.css +0 -0
  24. {paperstack_cli-0.3.0 → paperstack_cli-0.3.2}/scripts/build/vendor/marked.min.js +0 -0
  25. {paperstack_cli-0.3.0 → paperstack_cli-0.3.2}/src/paperstack/__init__.py +0 -0
  26. {paperstack_cli-0.3.0 → paperstack_cli-0.3.2}/src/paperstack/content/__init__.py +0 -0
  27. {paperstack_cli-0.3.0 → paperstack_cli-0.3.2}/src/paperstack/content/arxiv_source.py +0 -0
  28. {paperstack_cli-0.3.0 → paperstack_cli-0.3.2}/src/paperstack/content/vendor/latexpand +0 -0
  29. {paperstack_cli-0.3.0 → paperstack_cli-0.3.2}/src/paperstack/content/vendor/latexpand.LICENSE +0 -0
  30. {paperstack_cli-0.3.0 → paperstack_cli-0.3.2}/src/paperstack/dblp_catalog.py +0 -0
  31. {paperstack_cli-0.3.0 → paperstack_cli-0.3.2}/src/paperstack/entry_types.py +0 -0
@@ -1,11 +1,13 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: paperstack-cli
3
- Version: 0.3.0
3
+ Version: 0.3.2
4
4
  Summary: Review, inspect, and retrieve research sources from one CLI
5
5
  Project-URL: Repository, https://github.com/MilkClouds/paperstack
6
6
  Project-URL: Issues, https://github.com/MilkClouds/paperstack/issues
7
+ License-Expression: Apache-2.0
7
8
  License-File: LICENSE
8
- Requires-Python: >=3.11.4
9
+ Requires-Python: >=3.11
10
+ Requires-Dist: filelock>=3.20
9
11
  Requires-Dist: polars>=1.43
10
12
  Requires-Dist: python-dotenv>=1.2.2
11
13
  Requires-Dist: pyyaml>=6
@@ -21,7 +23,8 @@ under its maintainer's control.
21
23
 
22
24
  It can search typed reviews, initialize and validate entries, inspect external paper metadata, read arXiv sources,
23
25
  manage a selected-venue DBLP index, and build a static viewer. A synthetic corpus lives in
24
- [`examples/corpus`](examples/corpus/README.md); no assessment of real work is bundled with the package.
26
+ [`examples/corpus`](https://github.com/MilkClouds/paperstack/tree/main/examples/corpus); no assessment of real work is
27
+ bundled with the package.
25
28
 
26
29
  ## Install
27
30
 
@@ -35,16 +38,33 @@ PDF conversion is optional:
35
38
  uv tool install 'paperstack-cli[pdf]'
36
39
  ```
37
40
 
41
+ ## AI agent skill
42
+
43
+ Paperstack ships an [Agent Skills](https://agentskills.io)-compatible skill for Codex, Claude Code, and other
44
+ compatible agents. Install the CLI first, then add the skill globally with the open
45
+ [`skills`](https://github.com/vercel-labs/skills) CLI:
46
+
47
+ ```bash
48
+ npx skills add MilkClouds/paperstack --skill paperstack -g
49
+ ```
50
+
51
+ The installer detects supported agents such as Codex and Claude Code; pass `-a codex -a claude-code` to target them
52
+ explicitly, or omit `-g` for a project-local installation. The shared [`SKILL.md`](skills/paperstack/SKILL.md) keeps
53
+ corpus selection, source inspection, entry authoring, validation, and viewer workflows consistent without exposing a
54
+ private corpus. Use `npx skills update paperstack -g` to update it later.
55
+
38
56
  ## Choose a corpus
39
57
 
40
58
  Register either a local working tree or a GitHub repository. The first registered corpus becomes active.
41
59
 
42
60
  ```bash
43
61
  paperstack corpus add work --path ~/research/reviews
62
+ paperstack corpus init new --path ~/research/new-corpus
44
63
  paperstack corpus add private --repo OWNER/private-reviews
45
64
  paperstack corpus list
46
65
  paperstack corpus use private
47
66
  paperstack corpus remove work
67
+ paperstack corpus remove private --purge-cache --yes
48
68
  ```
49
69
 
50
70
  Private GitHub repositories use the existing `gh` login without storing its token:
@@ -58,6 +78,20 @@ paperstack review sync
58
78
  then a surrounding corpus working tree, then the active profile. Profiles are stored in
59
79
  `${XDG_CONFIG_HOME:-~/.config}/paperstack/config.json`.
60
80
 
81
+ Provider credentials can be stored once without adding them to a project `.env`:
82
+
83
+ ```bash
84
+ paperstack config set semantic-scholar.api-key
85
+ paperstack config status
86
+ paperstack config paths
87
+ paperstack config unset semantic-scholar.api-key
88
+ ```
89
+
90
+ `set` prompts without echoing the value; use `--stdin` to pipe it from a password manager. Credentials are written
91
+ atomically to `${XDG_CONFIG_HOME:-~/.config}/paperstack/credentials.json`, with mode `0600` on POSIX systems.
92
+ Exported environment variables take priority, followed by the nearest `.env`, then the credential store. GitHub
93
+ Actions can therefore keep using repository secrets through environment variables.
94
+
61
95
  ## Review corpus
62
96
 
63
97
  ```bash
@@ -72,7 +106,8 @@ paperstack review citations --fetch
72
106
  ```
73
107
 
74
108
  Paper entries, talks, and posts use separate contracts under `entries/{papers,talks,posts}/`. The complete schemas,
75
- writing guide, and source-reading workflow are in [Entry authoring](docs/ENTRIES.md).
109
+ writing guide, and source-reading workflow are in
110
+ [Entry authoring](https://github.com/MilkClouds/paperstack/blob/main/docs/ENTRIES.md).
76
111
 
77
112
  ## Inspect papers
78
113
 
@@ -80,15 +115,22 @@ These commands use external source records and do not select a citation or make
80
115
 
81
116
  ```bash
82
117
  paperstack paper search "Attention Is All You Need" --source dblp
118
+ paperstack paper search "robot learning" --source semantic-scholar --year 2024-2026
119
+ paperstack paper search 'ti:"robot learning"' --source arxiv --category cs.RO --sort date
83
120
  paperstack paper metadata arxiv:2106.09685
84
121
  paperstack paper metadata doi:10.1109/CVPR.2016.90 --source crossref
122
+ paperstack paper citations arxiv:2106.09685 --limit 50
123
+ paperstack paper references doi:10.48550/arXiv.2106.09685 --limit 50
85
124
  paperstack paper read arxiv:2604.23073 --outline
86
125
  paperstack paper read arxiv:2604.23073 --section 6
87
126
  paperstack paper pdf arxiv:2602.09017
88
127
  ```
89
128
 
90
129
  `metadata` accepts `arxiv:`, `doi:`, `dblp:`, and `openreview:` references. `read` and `pdf` require an `arxiv:`
91
- reference. Structured commands expose scoped `--json` flags; networked commands expose scoped `--offline` flags.
130
+ reference. `authors`, `citations`, and `references` use Semantic Scholar and also accept its `s2:`, `corpus:`, `acl:`,
131
+ `pmid:`, and `mag:` identifiers. Structured commands expose scoped `--json` flags; networked commands expose scoped
132
+ `--offline` flags. Paperstack reports source records but does not synthesize a citation entry or choose which version
133
+ of a work should be cited.
92
134
 
93
135
  ## Build a viewer
94
136
 
@@ -110,7 +152,8 @@ paperstack index dblp update
110
152
  ```
111
153
 
112
154
  Installed snapshots are content-addressed and verified by SHA-256, schema, and embedded metadata before an atomic
113
- pointer switch. See [DBLP snapshot releases](docs/DBLP_RELEASES.md) for release maintenance.
155
+ pointer switch. See
156
+ [DBLP snapshot releases](https://github.com/MilkClouds/paperstack/blob/main/docs/DBLP_RELEASES.md) for maintenance.
114
157
 
115
158
  ## Configuration
116
159
 
@@ -122,7 +165,7 @@ pointer switch. See [DBLP snapshot releases](docs/DBLP_RELEASES.md) for release
122
165
  | `PAPERSTACK_PAPERS_DIR` | arXiv source and PDF cache |
123
166
  | `SEMANTIC_SCHOLAR_API_KEY` | Optional higher-rate Semantic Scholar access |
124
167
  | `OPENREVIEW_ACCESS_TOKEN` | Optional OpenReview access cookie value |
125
- | `XDG_CONFIG_HOME` | Corpus profile configuration root |
168
+ | `XDG_CONFIG_HOME` | Corpus profile and credential configuration root |
126
169
  | `XDG_CACHE_HOME` | Remote corpus and paper cache root |
127
170
  | `XDG_DATA_HOME` | DBLP index root |
128
171
 
@@ -6,7 +6,8 @@ under its maintainer's control.
6
6
 
7
7
  It can search typed reviews, initialize and validate entries, inspect external paper metadata, read arXiv sources,
8
8
  manage a selected-venue DBLP index, and build a static viewer. A synthetic corpus lives in
9
- [`examples/corpus`](examples/corpus/README.md); no assessment of real work is bundled with the package.
9
+ [`examples/corpus`](https://github.com/MilkClouds/paperstack/tree/main/examples/corpus); no assessment of real work is
10
+ bundled with the package.
10
11
 
11
12
  ## Install
12
13
 
@@ -20,16 +21,33 @@ PDF conversion is optional:
20
21
  uv tool install 'paperstack-cli[pdf]'
21
22
  ```
22
23
 
24
+ ## AI agent skill
25
+
26
+ Paperstack ships an [Agent Skills](https://agentskills.io)-compatible skill for Codex, Claude Code, and other
27
+ compatible agents. Install the CLI first, then add the skill globally with the open
28
+ [`skills`](https://github.com/vercel-labs/skills) CLI:
29
+
30
+ ```bash
31
+ npx skills add MilkClouds/paperstack --skill paperstack -g
32
+ ```
33
+
34
+ The installer detects supported agents such as Codex and Claude Code; pass `-a codex -a claude-code` to target them
35
+ explicitly, or omit `-g` for a project-local installation. The shared [`SKILL.md`](skills/paperstack/SKILL.md) keeps
36
+ corpus selection, source inspection, entry authoring, validation, and viewer workflows consistent without exposing a
37
+ private corpus. Use `npx skills update paperstack -g` to update it later.
38
+
23
39
  ## Choose a corpus
24
40
 
25
41
  Register either a local working tree or a GitHub repository. The first registered corpus becomes active.
26
42
 
27
43
  ```bash
28
44
  paperstack corpus add work --path ~/research/reviews
45
+ paperstack corpus init new --path ~/research/new-corpus
29
46
  paperstack corpus add private --repo OWNER/private-reviews
30
47
  paperstack corpus list
31
48
  paperstack corpus use private
32
49
  paperstack corpus remove work
50
+ paperstack corpus remove private --purge-cache --yes
33
51
  ```
34
52
 
35
53
  Private GitHub repositories use the existing `gh` login without storing its token:
@@ -43,6 +61,20 @@ paperstack review sync
43
61
  then a surrounding corpus working tree, then the active profile. Profiles are stored in
44
62
  `${XDG_CONFIG_HOME:-~/.config}/paperstack/config.json`.
45
63
 
64
+ Provider credentials can be stored once without adding them to a project `.env`:
65
+
66
+ ```bash
67
+ paperstack config set semantic-scholar.api-key
68
+ paperstack config status
69
+ paperstack config paths
70
+ paperstack config unset semantic-scholar.api-key
71
+ ```
72
+
73
+ `set` prompts without echoing the value; use `--stdin` to pipe it from a password manager. Credentials are written
74
+ atomically to `${XDG_CONFIG_HOME:-~/.config}/paperstack/credentials.json`, with mode `0600` on POSIX systems.
75
+ Exported environment variables take priority, followed by the nearest `.env`, then the credential store. GitHub
76
+ Actions can therefore keep using repository secrets through environment variables.
77
+
46
78
  ## Review corpus
47
79
 
48
80
  ```bash
@@ -57,7 +89,8 @@ paperstack review citations --fetch
57
89
  ```
58
90
 
59
91
  Paper entries, talks, and posts use separate contracts under `entries/{papers,talks,posts}/`. The complete schemas,
60
- writing guide, and source-reading workflow are in [Entry authoring](docs/ENTRIES.md).
92
+ writing guide, and source-reading workflow are in
93
+ [Entry authoring](https://github.com/MilkClouds/paperstack/blob/main/docs/ENTRIES.md).
61
94
 
62
95
  ## Inspect papers
63
96
 
@@ -65,15 +98,22 @@ These commands use external source records and do not select a citation or make
65
98
 
66
99
  ```bash
67
100
  paperstack paper search "Attention Is All You Need" --source dblp
101
+ paperstack paper search "robot learning" --source semantic-scholar --year 2024-2026
102
+ paperstack paper search 'ti:"robot learning"' --source arxiv --category cs.RO --sort date
68
103
  paperstack paper metadata arxiv:2106.09685
69
104
  paperstack paper metadata doi:10.1109/CVPR.2016.90 --source crossref
105
+ paperstack paper citations arxiv:2106.09685 --limit 50
106
+ paperstack paper references doi:10.48550/arXiv.2106.09685 --limit 50
70
107
  paperstack paper read arxiv:2604.23073 --outline
71
108
  paperstack paper read arxiv:2604.23073 --section 6
72
109
  paperstack paper pdf arxiv:2602.09017
73
110
  ```
74
111
 
75
112
  `metadata` accepts `arxiv:`, `doi:`, `dblp:`, and `openreview:` references. `read` and `pdf` require an `arxiv:`
76
- reference. Structured commands expose scoped `--json` flags; networked commands expose scoped `--offline` flags.
113
+ reference. `authors`, `citations`, and `references` use Semantic Scholar and also accept its `s2:`, `corpus:`, `acl:`,
114
+ `pmid:`, and `mag:` identifiers. Structured commands expose scoped `--json` flags; networked commands expose scoped
115
+ `--offline` flags. Paperstack reports source records but does not synthesize a citation entry or choose which version
116
+ of a work should be cited.
77
117
 
78
118
  ## Build a viewer
79
119
 
@@ -95,7 +135,8 @@ paperstack index dblp update
95
135
  ```
96
136
 
97
137
  Installed snapshots are content-addressed and verified by SHA-256, schema, and embedded metadata before an atomic
98
- pointer switch. See [DBLP snapshot releases](docs/DBLP_RELEASES.md) for release maintenance.
138
+ pointer switch. See
139
+ [DBLP snapshot releases](https://github.com/MilkClouds/paperstack/blob/main/docs/DBLP_RELEASES.md) for maintenance.
99
140
 
100
141
  ## Configuration
101
142
 
@@ -107,7 +148,7 @@ pointer switch. See [DBLP snapshot releases](docs/DBLP_RELEASES.md) for release
107
148
  | `PAPERSTACK_PAPERS_DIR` | arXiv source and PDF cache |
108
149
  | `SEMANTIC_SCHOLAR_API_KEY` | Optional higher-rate Semantic Scholar access |
109
150
  | `OPENREVIEW_ACCESS_TOKEN` | Optional OpenReview access cookie value |
110
- | `XDG_CONFIG_HOME` | Corpus profile configuration root |
151
+ | `XDG_CONFIG_HOME` | Corpus profile and credential configuration root |
111
152
  | `XDG_CACHE_HOME` | Remote corpus and paper cache root |
112
153
  | `XDG_DATA_HOME` | DBLP index root |
113
154
 
@@ -1,10 +1,12 @@
1
1
  [project]
2
2
  name = "paperstack-cli"
3
- version = "0.3.0"
3
+ dynamic = ["version"]
4
4
  description = "Review, inspect, and retrieve research sources from one CLI"
5
5
  readme = "README.md"
6
- requires-python = ">=3.11.4" # First 3.11 release with tarfile.extractall(filter=).
6
+ license = "Apache-2.0"
7
+ requires-python = ">=3.11"
7
8
  dependencies = [
9
+ "filelock>=3.20",
8
10
  "polars>=1.43",
9
11
  "python-dotenv>=1.2.2",
10
12
  "pyyaml>=6",
@@ -34,13 +36,21 @@ packages = ["src/paperstack"]
34
36
  [tool.hatch.build.targets.wheel.force-include]
35
37
  "scripts/build/site" = "paperstack/viewer_assets/site"
36
38
  "scripts/build/vendor/marked.min.js" = "paperstack/viewer_assets/vendor/marked.min.js"
39
+ "scripts/build/vendor/marked.LICENSE" = "paperstack/viewer_assets/vendor/marked.LICENSE"
37
40
 
38
41
  [tool.hatch.build.targets.sdist]
39
- include = ["/src/paperstack", "/scripts/build/site", "/scripts/build/vendor/marked.min.js", "/README.md", "/pyproject.toml", "/LICENSE"]
42
+ include = ["/src/paperstack", "/scripts/build/site", "/scripts/build/vendor/marked.min.js", "/scripts/build/vendor/marked.LICENSE", "/README.md", "/pyproject.toml", "/LICENSE"]
40
43
 
41
44
  [tool.ruff]
42
45
  line-length = 119
43
46
 
47
+ [tool.hatch.version]
48
+ source = "vcs"
49
+
50
+ [tool.hatch.version.raw-options]
51
+ tag_regex = "^v(?P<version>\\d+\\.\\d+\\.\\d+(?:[a-zA-Z0-9.-]*)?)$"
52
+ git_describe_command = ["git", "describe", "--dirty", "--tags", "--long", "--match", "v[0-9]*"]
53
+
44
54
  [build-system]
45
- requires = ["hatchling"]
55
+ requires = ["hatchling", "hatch-vcs"]
46
56
  build-backend = "hatchling.build"
@@ -0,0 +1,36 @@
1
+ ## Marked
2
+
3
+ Copyright (c) 2018+, MarkedJS (https://github.com/markedjs/)
4
+ Copyright (c) 2011-2018, Christopher Jeffrey (https://github.com/chjj/)
5
+
6
+ Permission is hereby granted, free of charge, to any person obtaining a copy
7
+ of this software and associated documentation files (the "Software"), to deal
8
+ in the Software without restriction, including without limitation the rights
9
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
10
+ copies of the Software, and to permit persons to whom the Software is
11
+ furnished to do so, subject to the following conditions:
12
+
13
+ The above copyright notice and this permission notice shall be included in
14
+ all copies or substantial portions of the Software.
15
+
16
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
17
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
18
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
19
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
20
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
21
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
22
+ THE SOFTWARE.
23
+
24
+ ## Markdown
25
+
26
+ Copyright © 2004, John Gruber
27
+ http://daringfireball.net/
28
+ All rights reserved.
29
+
30
+ Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met:
31
+
32
+ * Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer.
33
+ * Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution.
34
+ * Neither the name “Markdown” nor the names of its contributors may be used to endorse or promote products derived from this software without specific prior written permission.
35
+
36
+ This software is provided by the copyright holders and contributors “as is” and any express or implied warranties, including, but not limited to, the implied warranties of merchantability and fitness for a particular purpose are disclaimed. In no event shall the copyright owner or contributors be liable for any direct, indirect, incidental, special, exemplary, or consequential damages (including, but not limited to, procurement of substitute goods or services; loss of use, data, or profits; or business interruption) however caused and on any theory of liability, whether in contract, strict liability, or tort (including negligence or otherwise) arising in any way out of the use of this software, even if advised of the possibility of such damage.
@@ -0,0 +1,125 @@
1
+ """Bounded arXiv discovery with source-backed metadata."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ import urllib.parse
7
+ import xml.etree.ElementTree as ET
8
+ from datetime import UTC, datetime
9
+
10
+ from . import metadata
11
+
12
+ ATOM = "http://www.w3.org/2005/Atom"
13
+ ARXIV = "http://arxiv.org/schemas/atom"
14
+ NS = {"atom": ATOM, "arxiv": ARXIV}
15
+ MAX_RESULTS = 100
16
+ _CATEGORY = re.compile(r"^[a-z-]+(?:\.[A-Za-z-]+)?$")
17
+
18
+
19
+ def _date(value: str, *, end: bool = False) -> str:
20
+ try:
21
+ parsed = datetime.fromisoformat(value)
22
+ except ValueError as exc:
23
+ raise ValueError(f"invalid date {value!r}; use YYYY-MM-DD or ISO 8601") from exc
24
+ if len(value) == 10:
25
+ parsed = parsed.replace(hour=23 if end else 0, minute=59 if end else 0)
26
+ elif parsed.tzinfo is not None:
27
+ parsed = parsed.astimezone(UTC)
28
+ return parsed.strftime("%Y%m%d%H%M")
29
+
30
+
31
+ def _query(
32
+ query: str,
33
+ *,
34
+ categories: list[str] | None = None,
35
+ date_from: str | None = None,
36
+ date_to: str | None = None,
37
+ ) -> str:
38
+ parts = []
39
+ if query.strip():
40
+ parts.append(f"({query.strip()})")
41
+ if categories:
42
+ invalid = [item for item in categories if not _CATEGORY.fullmatch(item)]
43
+ if invalid:
44
+ raise ValueError(f"invalid arXiv category: {invalid[0]}")
45
+ parts.append("(" + " OR ".join(f"cat:{item}" for item in categories) + ")")
46
+ if date_from or date_to:
47
+ start = _date(date_from) if date_from else "199107010000"
48
+ end = _date(date_to, end=True) if date_to else datetime.now(UTC).strftime("%Y%m%d%H%M")
49
+ if start > end:
50
+ raise ValueError("date-from must not be after date-to")
51
+ parts.append(f"submittedDate:[{start}+TO+{end}]")
52
+ if not parts:
53
+ raise ValueError("arXiv search needs a query, category, or date range")
54
+ return " AND ".join(parts)
55
+
56
+
57
+ def _text(entry: ET.Element, name: str) -> str | None:
58
+ value = entry.findtext(name, None, NS)
59
+ return " ".join(value.split()) if value else None
60
+
61
+
62
+ def _entry(entry: ET.Element) -> dict:
63
+ raw_id = _text(entry, "atom:id") or ""
64
+ versioned = raw_id.rsplit("/abs/", 1)[-1]
65
+ arxiv_id = re.sub(r"v\d+$", "", versioned)
66
+ links = {node.get("title") or node.get("rel"): node.get("href") for node in entry.findall("atom:link", NS)}
67
+ categories = [node.get("term") for node in entry.findall("atom:category", NS) if node.get("term")]
68
+ primary = entry.find("arxiv:primary_category", NS)
69
+ authors = [_text(node, "atom:name") for node in entry.findall("atom:author", NS)]
70
+ return {
71
+ "id": arxiv_id,
72
+ "versioned_id": versioned,
73
+ "title": _text(entry, "atom:title"),
74
+ "authors": [author for author in authors if author],
75
+ "abstract": _text(entry, "atom:summary"),
76
+ "categories": categories,
77
+ "primary_category": primary.get("term") if primary is not None else (categories[0] if categories else None),
78
+ "published": _text(entry, "atom:published"),
79
+ "updated": _text(entry, "atom:updated"),
80
+ "comment": _text(entry, "arxiv:comment"),
81
+ "journal_ref": _text(entry, "arxiv:journal_ref"),
82
+ "doi": _text(entry, "arxiv:doi"),
83
+ "url": f"https://arxiv.org/abs/{versioned}",
84
+ "pdf_url": links.get("pdf") or f"https://arxiv.org/pdf/{versioned}",
85
+ }
86
+
87
+
88
+ def parse_feed(raw: bytes | str) -> list[dict]:
89
+ root = ET.fromstring(raw)
90
+ return [_entry(entry) for entry in root.findall("atom:entry", NS)]
91
+
92
+
93
+ def search(
94
+ query: str,
95
+ *,
96
+ categories: list[str] | None = None,
97
+ date_from: str | None = None,
98
+ date_to: str | None = None,
99
+ limit: int = 10,
100
+ sort: str = "relevance",
101
+ ) -> dict:
102
+ if not 1 <= limit <= MAX_RESULTS:
103
+ raise ValueError(f"limit must be between 1 and {MAX_RESULTS}")
104
+ if sort not in ("relevance", "date"):
105
+ raise ValueError("sort must be relevance or date")
106
+ built = _query(query, categories=categories, date_from=date_from, date_to=date_to)
107
+ params = urllib.parse.urlencode(
108
+ {
109
+ "search_query": built,
110
+ "max_results": limit,
111
+ "sortBy": "submittedDate" if sort == "date" else "relevance",
112
+ "sortOrder": "descending",
113
+ }
114
+ )
115
+ params = params.replace("%2BTO%2B", "+TO+")
116
+ url = f"https://export.arxiv.org/api/query?{params}"
117
+ return metadata._safe(
118
+ lambda: metadata._result(
119
+ "arxiv",
120
+ url,
121
+ {"query": built, "matches": parse_feed(metadata.request(url))},
122
+ ),
123
+ "arxiv",
124
+ url,
125
+ )
@@ -3,13 +3,12 @@
3
3
  from __future__ import annotations
4
4
 
5
5
  import json
6
- import os
7
6
  import re
8
7
  import urllib.parse
9
8
  from datetime import UTC, datetime
10
9
  from pathlib import Path
11
10
 
12
- from . import metadata
11
+ from . import credentials, metadata
13
12
 
14
13
  S2_BATCH_API = "https://api.semanticscholar.org/graph/v1/paper/batch"
15
14
  BATCH_SIZE = 500
@@ -44,7 +43,7 @@ def fetch(arxiv_ids: list[str]) -> dict[str, int]:
44
43
  """Fetch citation counts in aligned Semantic Scholar batches."""
45
44
  counts: dict[str, int] = {}
46
45
  headers = {"Content-Type": "application/json"}
47
- if api_key := os.environ.get("SEMANTIC_SCHOLAR_API_KEY"):
46
+ if api_key := credentials.get(credentials.SEMANTIC_SCHOLAR_API_KEY):
48
47
  headers["x-api-key"] = api_key
49
48
 
50
49
  for start in range(0, len(arxiv_ids), BATCH_SIZE):