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 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,6 @@
1
+ """Built-in author-profile providers."""
2
+
3
+ from aristotle.authors.base import AuthorProvider
4
+ from aristotle.authors.orcid import ORCIDProvider
5
+
6
+ __all__ = ["AuthorProvider", "ORCIDProvider"]
@@ -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}")