aristotle-py 0.1.0__py3-none-any.whl
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.
- aristotle/__init__.py +26 -0
- aristotle/agent_skills/aristotle-cli/SKILL.md +151 -0
- aristotle/authors/__init__.py +6 -0
- aristotle/authors/base.py +32 -0
- aristotle/authors/orcid.py +179 -0
- aristotle/authors/registry.py +50 -0
- aristotle/captcha.py +433 -0
- aristotle/cli.py +1079 -0
- aristotle/config.py +225 -0
- aristotle/config_cli.py +209 -0
- aristotle/doctor.py +113 -0
- aristotle/exceptions.py +25 -0
- aristotle/export.py +118 -0
- aristotle/http.py +98 -0
- aristotle/json_cli.py +160 -0
- aristotle/logging.py +59 -0
- aristotle/models.py +139 -0
- aristotle/progress.py +118 -0
- aristotle/providers/__init__.py +19 -0
- aristotle/providers/arxiv.py +111 -0
- aristotle/providers/base.py +163 -0
- aristotle/providers/crossref.py +72 -0
- aristotle/providers/doi.py +109 -0
- aristotle/providers/openalex.py +156 -0
- aristotle/providers/semantic_scholar.py +130 -0
- aristotle/providers/ssrn.py +224 -0
- aristotle/providers/zenodo.py +127 -0
- aristotle/py.typed +0 -0
- aristotle/registry.py +82 -0
- aristotle/search_results.py +125 -0
- aristotle/service.py +736 -0
- aristotle/workflow.py +94 -0
- aristotle_py-0.1.0.dist-info/METADATA +128 -0
- aristotle_py-0.1.0.dist-info/RECORD +37 -0
- aristotle_py-0.1.0.dist-info/WHEEL +4 -0
- aristotle_py-0.1.0.dist-info/entry_points.txt +13 -0
- aristotle_py-0.1.0.dist-info/licenses/LICENSE +21 -0
aristotle/__init__.py
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
"""Aristotle academic-paper client."""
|
|
2
|
+
|
|
3
|
+
from aristotle.models import (
|
|
4
|
+
Author,
|
|
5
|
+
AuthorWork,
|
|
6
|
+
Download,
|
|
7
|
+
ExportFormat,
|
|
8
|
+
Paper,
|
|
9
|
+
SearchFilters,
|
|
10
|
+
SearchMatch,
|
|
11
|
+
SearchSort,
|
|
12
|
+
)
|
|
13
|
+
from aristotle.service import Aristotle
|
|
14
|
+
|
|
15
|
+
__all__ = [
|
|
16
|
+
"Aristotle",
|
|
17
|
+
"Author",
|
|
18
|
+
"AuthorWork",
|
|
19
|
+
"Download",
|
|
20
|
+
"ExportFormat",
|
|
21
|
+
"Paper",
|
|
22
|
+
"SearchFilters",
|
|
23
|
+
"SearchMatch",
|
|
24
|
+
"SearchSort",
|
|
25
|
+
]
|
|
26
|
+
__version__ = "0.1.0"
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: aristotle-cli
|
|
3
|
+
description: Use the Aristotle command-line tool to search, inspect, and download scholarly papers, or search public ORCID profiles. Use when a user asks you to find or inspect academic literature with the installed `aristotle` command.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Aristotle CLI
|
|
7
|
+
|
|
8
|
+
Use this skill when the `aristotle` command is installed and the user wants paper
|
|
9
|
+
discovery, metadata, citations, PDFs, or public ORCID profile information.
|
|
10
|
+
|
|
11
|
+
## Start with the installed CLI
|
|
12
|
+
|
|
13
|
+
Check availability with `aristotle --help`. Read command-specific help before
|
|
14
|
+
using an unfamiliar command, for example `aristotle search --help` or
|
|
15
|
+
`aristotle config --help`. Installed versions can differ from this document.
|
|
16
|
+
Treat the CLI's help output as authoritative if it conflicts with these examples.
|
|
17
|
+
|
|
18
|
+
The main command groups are:
|
|
19
|
+
|
|
20
|
+
- `search [QUERY]`: search the configured default source, or all paper providers
|
|
21
|
+
concurrently when no default is set. Choose a source with `--provider` (`doi`,
|
|
22
|
+
`ssrn`, `arxiv`, `openalex`, `semantic-scholar`, or `zenodo`); `all` bypasses the
|
|
23
|
+
default. Query may be omitted for author-only paper searches.
|
|
24
|
+
- `view IDENTIFIER`: inspect a paper identified by DOI, arXiv ID, provider ID, or
|
|
25
|
+
supported provider URL.
|
|
26
|
+
- `download IDENTIFIER`: save an available PDF. The default destination is the
|
|
27
|
+
configured `download_dir` (current directory by default); `--output` overrides it.
|
|
28
|
+
- `providers`: list installed paper and author providers. `--check` makes live
|
|
29
|
+
test searches; use it only when live network checks are useful.
|
|
30
|
+
- `author search QUERY` and `author view ORCID`: search or inspect public author
|
|
31
|
+
profiles. ORCID public data does not require credentials.
|
|
32
|
+
- `doctor`: inspect local setup without network access, file writes, or browser launch.
|
|
33
|
+
`--check` adds live metadata searches; JSON output is available.
|
|
34
|
+
- `history`: list recent search requests; `--run N` repeats a request, and
|
|
35
|
+
`--clear` deletes history when requested.
|
|
36
|
+
- `config`: inspect or edit settings. See Configuration below.
|
|
37
|
+
|
|
38
|
+
## Search and inspect
|
|
39
|
+
|
|
40
|
+
Start with a focused query and a reasonable limit. Searches run across providers
|
|
41
|
+
unless `default_provider` or `--provider` is set; `-p all` explicitly searches all
|
|
42
|
+
sources. Examples:
|
|
43
|
+
|
|
44
|
+
```bash
|
|
45
|
+
aristotle search "graph neural networks" --limit 10
|
|
46
|
+
aristotle search "market liquidity" --provider ssrn
|
|
47
|
+
aristotle search "climate adaptation" --from 2020 --to 2024
|
|
48
|
+
aristotle search "machine learning" --author "Jane Smith" --has-pdf
|
|
49
|
+
aristotle search "market microstructure" --sort citations
|
|
50
|
+
aristotle view 10.1038/s41586-023-06004-9
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
Useful filters include `--author`, `--from`/`--from-date`, `--to`/`--to-date`,
|
|
54
|
+
`--has-pdf` (also `--open-access`), and `--match` (`all`, `phrase`, `title`, or
|
|
55
|
+
`provider`). The default `provider` match policy trusts each provider's ranking.
|
|
56
|
+
Future-dated records are hidden unless `--include-future` is passed. Results are
|
|
57
|
+
deduplicated across providers by default; `--no-deduplicate` exposes provider
|
|
58
|
+
duplicates. Sorting supports `relevance`, `newest`, `oldest`, and `citations`.
|
|
59
|
+
|
|
60
|
+
Every command accepts `--format json`, or use the root `--json` flag before the
|
|
61
|
+
command. This includes downloads, config reads/writes, history replay/clear, errors,
|
|
62
|
+
and help. JSON mode emits one document on stdout without colors/progress; runtime
|
|
63
|
+
and usage errors preserve nonzero exit codes. Batch JSON includes per-item failures.
|
|
64
|
+
Secrets remain masked; config writes never echo values and require a value without
|
|
65
|
+
interactive prompting.
|
|
66
|
+
|
|
67
|
+
Search and view also support `bibtex`, `ris`, or `csv` with `--format`.
|
|
68
|
+
JSON and citation exports are useful for downstream processing; keep normal
|
|
69
|
+
human-readable output when the user wants a concise inspection. Exported data is
|
|
70
|
+
written to stdout, so it can be redirected to a file when requested.
|
|
71
|
+
|
|
72
|
+
Search results are saved locally for selection by number. After a search, the
|
|
73
|
+
CLI prints examples using `--result N`; use `aristotle view --result N` or
|
|
74
|
+
`aristotle download --result N` only while that saved result set is the one the
|
|
75
|
+
user intends. Numbers refer to the last displayed page. `search --next` advances
|
|
76
|
+
saved pages, then fetches another native provider batch. Rankings and coverage are
|
|
77
|
+
limited to the current batch; do not describe them as exhaustive. Prefer a stable
|
|
78
|
+
identifier when resuming later. Human-readable searches also store bounded query
|
|
79
|
+
history; exports do not replace the active snapshot or add history entries unless
|
|
80
|
+
`--paginate` is supplied. For JSON pagination, use `search QUERY --paginate --format
|
|
81
|
+
json`, then `search --next --format json`. The response includes `results`,
|
|
82
|
+
`pagination`, and `summary`. Follow `pagination.can_continue`; `has_more: null`
|
|
83
|
+
means another provider batch must be fetched to find out. Pagination shares the
|
|
84
|
+
local saved snapshot with interactive searches; another saved search replaces it.
|
|
85
|
+
Exhaustion returns empty results and preserves the last selected page.
|
|
86
|
+
|
|
87
|
+
## Downloads and access boundaries
|
|
88
|
+
|
|
89
|
+
Only download when the user asks for a PDF or otherwise clearly authorizes saving
|
|
90
|
+
one. Check that the result has a known PDF when possible. Use a destination
|
|
91
|
+
directory such as `~/Papers` or an explicit `.pdf` filename:
|
|
92
|
+
|
|
93
|
+
```bash
|
|
94
|
+
aristotle download 1706.03762 --output ~/Papers
|
|
95
|
+
aristotle download 10.1038/s41586-023-06004-9 --output paper.pdf
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
For user-authorized batches, `download --results 1,3-5` selects the current page and
|
|
99
|
+
`download --input identifiers.txt` reads one identifier per line (up to 100). Use an
|
|
100
|
+
output directory. Individual failures do not stop remaining downloads; exit code 1
|
|
101
|
+
and the summary indicate partial failure. Report failed identifiers to the user.
|
|
102
|
+
|
|
103
|
+
Existing files are protected by default. Do not use `--force` unless the user
|
|
104
|
+
explicitly wants an existing file replaced. Aristotle can try an exact DOI or
|
|
105
|
+
arXiv identifier through compatible providers when its primary provider has no
|
|
106
|
+
PDF. A DOI does not guarantee a free PDF. The tool does not circumvent publisher
|
|
107
|
+
paywalls; report when an openly accessible copy is unavailable. SSRN challenge
|
|
108
|
+
handling uses local Chromium or an optional configured CapSolver service; comply
|
|
109
|
+
with SSRN's terms and respect access controls.
|
|
110
|
+
|
|
111
|
+
## Configuration
|
|
112
|
+
|
|
113
|
+
Configuration precedence, per setting, is:
|
|
114
|
+
|
|
115
|
+
1. `ARISTOTLE_*` process environment variable
|
|
116
|
+
2. nearest `.env` file from the current directory upward
|
|
117
|
+
3. global YAML at `$XDG_CONFIG_HOME/aristotle/config.yaml`, or
|
|
118
|
+
`~/.config/aristotle/config.yaml`
|
|
119
|
+
4. built-in default
|
|
120
|
+
|
|
121
|
+
Use `aristotle config path` to locate the global file and `aristotle config show`
|
|
122
|
+
to inspect effective values and their sources. Secrets are masked by default.
|
|
123
|
+
Never pass `--show-secrets`, read secret values, copy them into a response, or
|
|
124
|
+
print `.env` contents. `config set` and `config unset` write the global config;
|
|
125
|
+
perform them only when requested. Omitting a value for a secret prompts securely:
|
|
126
|
+
|
|
127
|
+
```bash
|
|
128
|
+
aristotle config set openalex_api_key
|
|
129
|
+
aristotle config set timeout 45
|
|
130
|
+
aristotle config unset timeout
|
|
131
|
+
```
|
|
132
|
+
|
|
133
|
+
Available setting names and accepted values can change; consult `config set
|
|
134
|
+
--help` and `config show` before making a change. API keys may increase provider
|
|
135
|
+
limits but are not needed for every source. Workflow settings include
|
|
136
|
+
`default_provider`, `download_dir`, and `history_limit` (0–100; zero disables new
|
|
137
|
+
history writes). Explicit options override defaults; saved history replays its
|
|
138
|
+
original provider and filters.
|
|
139
|
+
|
|
140
|
+
## Agent operating notes
|
|
141
|
+
|
|
142
|
+
- Use exact identifiers supplied by the user when they ask about a specific work.
|
|
143
|
+
- Do not claim a result is peer reviewed, authoritative, or open access based only
|
|
144
|
+
on a search result title. Inspect metadata and describe uncertainty plainly.
|
|
145
|
+
- Provider searches use public metadata services. A provider can fail or time out
|
|
146
|
+
while others still return results; report that limitation when relevant.
|
|
147
|
+
- Put `--verbose` before the subcommand to show structured diagnostics on stderr.
|
|
148
|
+
Diagnostics can contain request metadata; do not include unnecessary output in
|
|
149
|
+
the response.
|
|
150
|
+
- Cite retrieved papers with their identifiers and URLs from Aristotle's output.
|
|
151
|
+
Do not treat search ranking or citation counts as evidence of quality.
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
"""Interface implemented by author-profile providers."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from abc import ABC, abstractmethod
|
|
6
|
+
from typing import ClassVar
|
|
7
|
+
|
|
8
|
+
import httpx
|
|
9
|
+
|
|
10
|
+
from aristotle.models import Author
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class AuthorProvider(ABC):
|
|
14
|
+
"""Implement this interface to add an author-profile source."""
|
|
15
|
+
|
|
16
|
+
name: ClassVar[str]
|
|
17
|
+
|
|
18
|
+
def __init__(self, client: httpx.AsyncClient) -> None:
|
|
19
|
+
self.client = client
|
|
20
|
+
|
|
21
|
+
@classmethod
|
|
22
|
+
@abstractmethod
|
|
23
|
+
def accepts(cls, identifier: str) -> bool:
|
|
24
|
+
"""Return whether this provider recognizes an author identifier."""
|
|
25
|
+
|
|
26
|
+
@abstractmethod
|
|
27
|
+
async def search(self, query: str, limit: int = 10) -> list[Author]:
|
|
28
|
+
"""Search public author profiles."""
|
|
29
|
+
|
|
30
|
+
@abstractmethod
|
|
31
|
+
async def get(self, identifier: str) -> Author:
|
|
32
|
+
"""Fetch one public author profile."""
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
"""ORCID public author search and profile provider."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
from typing import Any, ClassVar
|
|
7
|
+
|
|
8
|
+
from aristotle.authors.base import AuthorProvider
|
|
9
|
+
from aristotle.exceptions import PaperNotFound
|
|
10
|
+
from aristotle.models import Author, AuthorWork
|
|
11
|
+
|
|
12
|
+
_ORCID = re.compile(
|
|
13
|
+
r"^(?:(?:https?://)?orcid\.org/|orcid:)?"
|
|
14
|
+
r"(?P<id>\d{4}-\d{4}-\d{4}-[\dX]{4})$",
|
|
15
|
+
re.IGNORECASE,
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class ORCIDProvider(AuthorProvider):
|
|
20
|
+
"""Use ORCID's anonymous public API; private profile data is never requested."""
|
|
21
|
+
|
|
22
|
+
name: ClassVar[str] = "orcid"
|
|
23
|
+
api_url: ClassVar[str] = "https://pub.orcid.org/v3.0"
|
|
24
|
+
headers: ClassVar[dict[str, str]] = {"Accept": "application/json"}
|
|
25
|
+
|
|
26
|
+
@classmethod
|
|
27
|
+
def accepts(cls, identifier: str) -> bool:
|
|
28
|
+
return bool(_ORCID.match(identifier.strip()))
|
|
29
|
+
|
|
30
|
+
@classmethod
|
|
31
|
+
def clean_id(cls, identifier: str) -> str:
|
|
32
|
+
match = _ORCID.match(identifier.strip())
|
|
33
|
+
if not match:
|
|
34
|
+
raise PaperNotFound(f"Not a valid ORCID iD: {identifier}")
|
|
35
|
+
return match.group("id").upper()
|
|
36
|
+
|
|
37
|
+
async def search(self, query: str, limit: int = 10) -> list[Author]:
|
|
38
|
+
response = await self.client.get(
|
|
39
|
+
f"{self.api_url}/expanded-search/",
|
|
40
|
+
params={"q": query, "rows": limit},
|
|
41
|
+
headers=self.headers,
|
|
42
|
+
)
|
|
43
|
+
response.raise_for_status()
|
|
44
|
+
return [self._expanded(result) for result in response.json().get("expanded-result", [])]
|
|
45
|
+
|
|
46
|
+
async def get(self, identifier: str) -> Author:
|
|
47
|
+
orcid_id = self.clean_id(identifier)
|
|
48
|
+
response = await self.client.get(f"{self.api_url}/{orcid_id}/record", headers=self.headers)
|
|
49
|
+
if response.status_code == 404:
|
|
50
|
+
raise PaperNotFound(f"ORCID profile not found: {orcid_id}")
|
|
51
|
+
response.raise_for_status()
|
|
52
|
+
return self._record(response.json(), orcid_id)
|
|
53
|
+
|
|
54
|
+
@classmethod
|
|
55
|
+
def _expanded(cls, result: dict[str, Any]) -> Author:
|
|
56
|
+
orcid_id = str(result["orcid-id"])
|
|
57
|
+
name = cls._name(
|
|
58
|
+
result.get("given-names"), result.get("family-names"), result.get("credit-name")
|
|
59
|
+
)
|
|
60
|
+
aliases = tuple(str(value) for value in result.get("other-name") or [])
|
|
61
|
+
affiliations = tuple(dict.fromkeys(result.get("institution-name") or []))
|
|
62
|
+
return Author(
|
|
63
|
+
identifier=f"ORCID:{orcid_id}",
|
|
64
|
+
provider=cls.name,
|
|
65
|
+
name=name or orcid_id,
|
|
66
|
+
aliases=aliases,
|
|
67
|
+
affiliations=affiliations,
|
|
68
|
+
urls=(f"https://orcid.org/{orcid_id}",),
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
@classmethod
|
|
72
|
+
def _record(cls, record: dict[str, Any], orcid_id: str) -> Author:
|
|
73
|
+
person = record.get("person") or {}
|
|
74
|
+
name_data = person.get("name") or {}
|
|
75
|
+
name = cls._name(
|
|
76
|
+
cls._content(name_data.get("given-names")),
|
|
77
|
+
cls._content(name_data.get("family-name")),
|
|
78
|
+
cls._content(name_data.get("credit-name")),
|
|
79
|
+
)
|
|
80
|
+
aliases = cls._contents(person.get("other-names"), "other-name")
|
|
81
|
+
keywords = cls._contents(person.get("keywords"), "keyword")
|
|
82
|
+
researcher_urls = tuple(
|
|
83
|
+
value
|
|
84
|
+
for item in (person.get("researcher-urls") or {}).get("researcher-url", [])
|
|
85
|
+
if (value := cls._content(item.get("url")))
|
|
86
|
+
)
|
|
87
|
+
activities = record.get("activities-summary") or {}
|
|
88
|
+
affiliations = cls._affiliations(activities)
|
|
89
|
+
work_groups = (activities.get("works") or {}).get("group", [])
|
|
90
|
+
works = cls._works(work_groups)
|
|
91
|
+
return Author(
|
|
92
|
+
identifier=f"ORCID:{orcid_id}",
|
|
93
|
+
provider=cls.name,
|
|
94
|
+
name=name or orcid_id,
|
|
95
|
+
aliases=aliases,
|
|
96
|
+
biography=cls._content(person.get("biography")),
|
|
97
|
+
affiliations=affiliations,
|
|
98
|
+
keywords=keywords,
|
|
99
|
+
urls=(f"https://orcid.org/{orcid_id}", *researcher_urls),
|
|
100
|
+
works_count=len(work_groups),
|
|
101
|
+
works=works,
|
|
102
|
+
)
|
|
103
|
+
|
|
104
|
+
@staticmethod
|
|
105
|
+
def _name(given: Any, family: Any, credit: Any) -> str:
|
|
106
|
+
if credit:
|
|
107
|
+
return str(credit)
|
|
108
|
+
return " ".join(str(part) for part in (given, family) if part).strip()
|
|
109
|
+
|
|
110
|
+
@staticmethod
|
|
111
|
+
def _content(value: Any) -> str | None:
|
|
112
|
+
if isinstance(value, dict):
|
|
113
|
+
content = value.get("value", value.get("content"))
|
|
114
|
+
return str(content) if content else None
|
|
115
|
+
return str(value) if value else None
|
|
116
|
+
|
|
117
|
+
@classmethod
|
|
118
|
+
def _contents(cls, container: Any, key: str) -> tuple[str, ...]:
|
|
119
|
+
if not isinstance(container, dict):
|
|
120
|
+
return ()
|
|
121
|
+
return tuple(value for item in container.get(key, []) if (value := cls._content(item)))
|
|
122
|
+
|
|
123
|
+
@classmethod
|
|
124
|
+
def _works(cls, groups: list[dict[str, Any]]) -> tuple[AuthorWork, ...]:
|
|
125
|
+
works: list[AuthorWork] = []
|
|
126
|
+
for group in groups:
|
|
127
|
+
summaries = group.get("work-summary") or []
|
|
128
|
+
if not summaries:
|
|
129
|
+
continue
|
|
130
|
+
summary = summaries[0]
|
|
131
|
+
title = cls._content((summary.get("title") or {}).get("title"))
|
|
132
|
+
if not title:
|
|
133
|
+
continue
|
|
134
|
+
external_ids = (summary.get("external-ids") or {}).get("external-id", [])
|
|
135
|
+
identifiers = {
|
|
136
|
+
str(item.get("external-id-type")): str(item.get("external-id-value"))
|
|
137
|
+
for item in external_ids
|
|
138
|
+
if item.get("external-id-type") and item.get("external-id-value")
|
|
139
|
+
}
|
|
140
|
+
url = cls._content(summary.get("url")) or next(
|
|
141
|
+
(
|
|
142
|
+
cls._content(item.get("external-id-url"))
|
|
143
|
+
for item in external_ids
|
|
144
|
+
if cls._content(item.get("external-id-url"))
|
|
145
|
+
),
|
|
146
|
+
None,
|
|
147
|
+
)
|
|
148
|
+
publication_date = summary.get("publication-date") or {}
|
|
149
|
+
date_parts = [
|
|
150
|
+
cls._content(publication_date.get(part)) for part in ("year", "month", "day")
|
|
151
|
+
]
|
|
152
|
+
rendered_date = "-".join(part for part in date_parts if part) or None
|
|
153
|
+
works.append(
|
|
154
|
+
AuthorWork(
|
|
155
|
+
title=title,
|
|
156
|
+
work_type=summary.get("type"),
|
|
157
|
+
publication_date=rendered_date,
|
|
158
|
+
identifiers=identifiers,
|
|
159
|
+
url=url,
|
|
160
|
+
)
|
|
161
|
+
)
|
|
162
|
+
return tuple(works)
|
|
163
|
+
|
|
164
|
+
@staticmethod
|
|
165
|
+
def _affiliations(activities: dict[str, Any]) -> tuple[str, ...]:
|
|
166
|
+
names: list[str] = []
|
|
167
|
+
for section in activities.values():
|
|
168
|
+
if not isinstance(section, dict):
|
|
169
|
+
continue
|
|
170
|
+
for group in section.get("affiliation-group", []):
|
|
171
|
+
for summaries in group.get("summaries", []):
|
|
172
|
+
for summary in summaries.values():
|
|
173
|
+
if not isinstance(summary, dict):
|
|
174
|
+
continue
|
|
175
|
+
organization = summary.get("organization") or {}
|
|
176
|
+
name = organization.get("name")
|
|
177
|
+
if name and name not in names:
|
|
178
|
+
names.append(str(name))
|
|
179
|
+
return tuple(names)
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
"""Discovery and routing for author-profile providers."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from importlib.metadata import entry_points
|
|
6
|
+
from typing import TYPE_CHECKING
|
|
7
|
+
|
|
8
|
+
from aristotle.authors.orcid import ORCIDProvider
|
|
9
|
+
from aristotle.exceptions import PaperNotFound
|
|
10
|
+
|
|
11
|
+
if TYPE_CHECKING:
|
|
12
|
+
import httpx
|
|
13
|
+
|
|
14
|
+
from aristotle.authors.base import AuthorProvider
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class AuthorProviderRegistry:
|
|
18
|
+
"""Catalog built-in and third-party author providers."""
|
|
19
|
+
|
|
20
|
+
def __init__(self, client: httpx.AsyncClient) -> None:
|
|
21
|
+
builtins: list[AuthorProvider] = [ORCIDProvider(client)]
|
|
22
|
+
self._providers = {provider.name: provider for provider in builtins}
|
|
23
|
+
for entry_point in entry_points(group="aristotle.author_providers"):
|
|
24
|
+
if entry_point.name not in self._providers:
|
|
25
|
+
provider_class = entry_point.load()
|
|
26
|
+
self._providers[entry_point.name] = provider_class(client)
|
|
27
|
+
|
|
28
|
+
@property
|
|
29
|
+
def names(self) -> tuple[str, ...]:
|
|
30
|
+
return tuple(self._providers)
|
|
31
|
+
|
|
32
|
+
def get(self, name: str) -> AuthorProvider:
|
|
33
|
+
try:
|
|
34
|
+
return self._providers[name.lower()]
|
|
35
|
+
except KeyError as error:
|
|
36
|
+
choices = ", ".join(self.names)
|
|
37
|
+
raise PaperNotFound(
|
|
38
|
+
f"Unknown author provider {name!r}; choose one of: {choices}"
|
|
39
|
+
) from error
|
|
40
|
+
|
|
41
|
+
def route(self, identifier: str, preferred: str | None = None) -> AuthorProvider:
|
|
42
|
+
if preferred:
|
|
43
|
+
provider = self.get(preferred)
|
|
44
|
+
if not provider.accepts(identifier):
|
|
45
|
+
raise PaperNotFound(f"{identifier!r} is not a valid {preferred} identifier")
|
|
46
|
+
return provider
|
|
47
|
+
for provider in self._providers.values():
|
|
48
|
+
if provider.accepts(identifier):
|
|
49
|
+
return provider
|
|
50
|
+
raise PaperNotFound(f"Could not recognize author identifier {identifier!r}")
|