openalex-local 0.3.0__py3-none-any.whl → 0.3.1__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.
- openalex_local/__init__.py +28 -7
- openalex_local/_cache/__init__.py +45 -0
- openalex_local/_cache/core.py +298 -0
- openalex_local/_cache/export.py +100 -0
- openalex_local/_cache/models.py +17 -0
- openalex_local/_cache/utils.py +85 -0
- openalex_local/_cli/__init__.py +9 -0
- openalex_local/_cli/cli.py +409 -0
- openalex_local/_cli/cli_cache.py +220 -0
- openalex_local/_cli/mcp.py +210 -0
- openalex_local/_cli/mcp_server.py +235 -0
- openalex_local/_core/__init__.py +42 -0
- openalex_local/{api.py → _core/api.py} +137 -19
- openalex_local/_core/config.py +120 -0
- openalex_local/{db.py → _core/db.py} +53 -0
- openalex_local/_core/export.py +252 -0
- openalex_local/{models.py → _core/models.py} +201 -0
- openalex_local/_remote/__init__.py +34 -0
- openalex_local/_remote/base.py +256 -0
- openalex_local/_server/__init__.py +117 -0
- openalex_local/_server/routes.py +175 -0
- openalex_local/aio.py +259 -0
- openalex_local/cache.py +31 -0
- openalex_local/cli.py +4 -205
- openalex_local/jobs.py +169 -0
- openalex_local/remote.py +8 -0
- openalex_local/server.py +8 -0
- openalex_local-0.3.1.dist-info/METADATA +288 -0
- openalex_local-0.3.1.dist-info/RECORD +34 -0
- openalex_local-0.3.1.dist-info/entry_points.txt +2 -0
- openalex_local/config.py +0 -182
- openalex_local-0.3.0.dist-info/METADATA +0 -152
- openalex_local-0.3.0.dist-info/RECORD +0 -13
- openalex_local-0.3.0.dist-info/entry_points.txt +0 -2
- /openalex_local/{fts.py → _core/fts.py} +0 -0
- {openalex_local-0.3.0.dist-info → openalex_local-0.3.1.dist-info}/WHEEL +0 -0
- {openalex_local-0.3.0.dist-info → openalex_local-0.3.1.dist-info}/top_level.txt +0 -0
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
# Timestamp: 2026-01-29
|
|
3
|
+
"""Configuration for openalex_local."""
|
|
4
|
+
|
|
5
|
+
import os as _os
|
|
6
|
+
from pathlib import Path as _Path
|
|
7
|
+
from typing import Optional as _Optional
|
|
8
|
+
|
|
9
|
+
# Default database locations (checked in order)
|
|
10
|
+
DEFAULT_DB_PATHS = [
|
|
11
|
+
_Path("/home/ywatanabe/proj/openalex-local/data/openalex.db"),
|
|
12
|
+
_Path("/home/ywatanabe/proj/openalex_local/data/openalex.db"),
|
|
13
|
+
_Path("/mnt/nas_ug/openalex_local/data/openalex.db"),
|
|
14
|
+
_Path.home() / ".openalex_local" / "openalex.db",
|
|
15
|
+
_Path.cwd() / "data" / "openalex.db",
|
|
16
|
+
]
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def get_db_path() -> _Path:
|
|
20
|
+
"""Get database path from environment or auto-detect."""
|
|
21
|
+
env_path = _os.environ.get("OPENALEX_LOCAL_DB")
|
|
22
|
+
if env_path:
|
|
23
|
+
path = _Path(env_path)
|
|
24
|
+
if path.exists():
|
|
25
|
+
return path
|
|
26
|
+
raise FileNotFoundError(f"OPENALEX_LOCAL_DB path not found: {env_path}")
|
|
27
|
+
|
|
28
|
+
for path in DEFAULT_DB_PATHS:
|
|
29
|
+
if path.exists():
|
|
30
|
+
return path
|
|
31
|
+
|
|
32
|
+
raise FileNotFoundError(
|
|
33
|
+
"OpenAlex database not found. Set OPENALEX_LOCAL_DB environment variable."
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
DEFAULT_PORT = 31292
|
|
38
|
+
DEFAULT_HOST = "0.0.0.0"
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class Config:
|
|
42
|
+
"""Configuration container."""
|
|
43
|
+
|
|
44
|
+
_db_path: _Optional[_Path] = None
|
|
45
|
+
_api_url: _Optional[str] = None
|
|
46
|
+
_mode: str = "auto" # "auto", "db", or "http"
|
|
47
|
+
|
|
48
|
+
@classmethod
|
|
49
|
+
def get_db_path(cls) -> _Path:
|
|
50
|
+
if cls._db_path is None:
|
|
51
|
+
cls._db_path = get_db_path()
|
|
52
|
+
return cls._db_path
|
|
53
|
+
|
|
54
|
+
@classmethod
|
|
55
|
+
def set_db_path(cls, path: str) -> None:
|
|
56
|
+
p = _Path(path)
|
|
57
|
+
if not p.exists():
|
|
58
|
+
raise FileNotFoundError(f"Database not found: {path}")
|
|
59
|
+
cls._db_path = p
|
|
60
|
+
cls._mode = "db"
|
|
61
|
+
|
|
62
|
+
@classmethod
|
|
63
|
+
def get_api_url(cls) -> str:
|
|
64
|
+
if cls._api_url:
|
|
65
|
+
return cls._api_url
|
|
66
|
+
return _os.environ.get(
|
|
67
|
+
"OPENALEX_LOCAL_API_URL", f"http://localhost:{DEFAULT_PORT}"
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
@classmethod
|
|
71
|
+
def set_api_url(cls, url: str) -> None:
|
|
72
|
+
cls._api_url = url.rstrip("/")
|
|
73
|
+
cls._mode = "http"
|
|
74
|
+
|
|
75
|
+
@classmethod
|
|
76
|
+
def set_mode(cls, mode: str) -> None:
|
|
77
|
+
"""Set mode explicitly: 'db', 'http', or 'auto'."""
|
|
78
|
+
if mode not in ("auto", "db", "http"):
|
|
79
|
+
raise ValueError(f"Invalid mode: {mode}. Use 'auto', 'db', or 'http'")
|
|
80
|
+
cls._mode = mode
|
|
81
|
+
|
|
82
|
+
@classmethod
|
|
83
|
+
def get_mode(cls) -> str:
|
|
84
|
+
"""
|
|
85
|
+
Get current mode.
|
|
86
|
+
|
|
87
|
+
Returns:
|
|
88
|
+
"db" if using direct database access
|
|
89
|
+
"http" if using HTTP API
|
|
90
|
+
"""
|
|
91
|
+
if cls._mode == "auto":
|
|
92
|
+
# Check environment variable for explicit mode
|
|
93
|
+
env_mode = _os.environ.get("OPENALEX_LOCAL_MODE", "").lower()
|
|
94
|
+
if env_mode in ("http", "remote", "api"):
|
|
95
|
+
return "http"
|
|
96
|
+
if env_mode in ("db", "local"):
|
|
97
|
+
return "db"
|
|
98
|
+
|
|
99
|
+
# Check if API URL is set explicitly
|
|
100
|
+
if cls._api_url or _os.environ.get("OPENALEX_LOCAL_API_URL"):
|
|
101
|
+
return "http"
|
|
102
|
+
|
|
103
|
+
# Check if local database exists
|
|
104
|
+
try:
|
|
105
|
+
get_db_path()
|
|
106
|
+
return "db"
|
|
107
|
+
except FileNotFoundError:
|
|
108
|
+
# No local DB, try http
|
|
109
|
+
return "http"
|
|
110
|
+
|
|
111
|
+
return cls._mode
|
|
112
|
+
|
|
113
|
+
@classmethod
|
|
114
|
+
def reset(cls) -> None:
|
|
115
|
+
cls._db_path = None
|
|
116
|
+
cls._api_url = None
|
|
117
|
+
cls._mode = "auto"
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
# EOF
|
|
@@ -120,6 +120,59 @@ class Database:
|
|
|
120
120
|
|
|
121
121
|
return result
|
|
122
122
|
|
|
123
|
+
def get_source_metrics(self, issn: str) -> Optional[Dict[str, Any]]:
|
|
124
|
+
"""
|
|
125
|
+
Get source/journal metrics by ISSN.
|
|
126
|
+
|
|
127
|
+
Args:
|
|
128
|
+
issn: Journal ISSN
|
|
129
|
+
|
|
130
|
+
Returns:
|
|
131
|
+
Dictionary with impact_factor, h_index, cited_by_count or None
|
|
132
|
+
"""
|
|
133
|
+
if not issn:
|
|
134
|
+
return None
|
|
135
|
+
|
|
136
|
+
# Try lookup via issn_lookup table first (fast)
|
|
137
|
+
row = self.fetchone(
|
|
138
|
+
"""
|
|
139
|
+
SELECT s.two_year_mean_citedness as impact_factor,
|
|
140
|
+
s.h_index as source_h_index,
|
|
141
|
+
s.cited_by_count as source_cited_by_count,
|
|
142
|
+
s.display_name as source_name
|
|
143
|
+
FROM issn_lookup l
|
|
144
|
+
JOIN sources s ON l.source_id = s.id
|
|
145
|
+
WHERE l.issn = ?
|
|
146
|
+
""",
|
|
147
|
+
(issn,),
|
|
148
|
+
)
|
|
149
|
+
if row:
|
|
150
|
+
return dict(row)
|
|
151
|
+
|
|
152
|
+
# Fallback: search in sources.issns JSON field
|
|
153
|
+
row = self.fetchone(
|
|
154
|
+
"""
|
|
155
|
+
SELECT two_year_mean_citedness as impact_factor,
|
|
156
|
+
h_index as source_h_index,
|
|
157
|
+
cited_by_count as source_cited_by_count,
|
|
158
|
+
display_name as source_name
|
|
159
|
+
FROM sources
|
|
160
|
+
WHERE issn_l = ? OR issns LIKE ?
|
|
161
|
+
""",
|
|
162
|
+
(issn, f'%"{issn}"%'),
|
|
163
|
+
)
|
|
164
|
+
if row:
|
|
165
|
+
return dict(row)
|
|
166
|
+
|
|
167
|
+
return None
|
|
168
|
+
|
|
169
|
+
def has_sources_table(self) -> bool:
|
|
170
|
+
"""Check if sources table exists."""
|
|
171
|
+
row = self.fetchone(
|
|
172
|
+
"SELECT 1 FROM sqlite_master WHERE type='table' AND name='sources'"
|
|
173
|
+
)
|
|
174
|
+
return row is not None
|
|
175
|
+
|
|
123
176
|
|
|
124
177
|
# Singleton connection for convenience functions
|
|
125
178
|
_db: Optional[Database] = None
|
|
@@ -0,0 +1,252 @@
|
|
|
1
|
+
"""Export functionality for Work and SearchResult objects.
|
|
2
|
+
|
|
3
|
+
Supports multiple output formats:
|
|
4
|
+
- text: Human-readable formatted text
|
|
5
|
+
- json: JSON format with all fields
|
|
6
|
+
- bibtex: BibTeX bibliography format
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import json as _json
|
|
10
|
+
from pathlib import Path as _Path
|
|
11
|
+
from typing import TYPE_CHECKING, List, Optional, Union
|
|
12
|
+
|
|
13
|
+
if TYPE_CHECKING:
|
|
14
|
+
from .models import SearchResult, Work
|
|
15
|
+
|
|
16
|
+
__all__ = [
|
|
17
|
+
"save",
|
|
18
|
+
"export_text",
|
|
19
|
+
"export_json",
|
|
20
|
+
"export_bibtex",
|
|
21
|
+
"SUPPORTED_FORMATS",
|
|
22
|
+
]
|
|
23
|
+
|
|
24
|
+
SUPPORTED_FORMATS = ["text", "json", "bibtex"]
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def work_to_text(work: "Work", include_abstract: bool = False) -> str:
|
|
28
|
+
"""Convert a Work to human-readable text format.
|
|
29
|
+
|
|
30
|
+
Args:
|
|
31
|
+
work: Work object to convert
|
|
32
|
+
include_abstract: Whether to include abstract
|
|
33
|
+
|
|
34
|
+
Returns:
|
|
35
|
+
Formatted text string
|
|
36
|
+
"""
|
|
37
|
+
lines = []
|
|
38
|
+
|
|
39
|
+
# Title
|
|
40
|
+
title = work.title or "Untitled"
|
|
41
|
+
year = f"({work.year})" if work.year else ""
|
|
42
|
+
lines.append(f"{title} {year}".strip())
|
|
43
|
+
|
|
44
|
+
# Authors
|
|
45
|
+
if work.authors:
|
|
46
|
+
authors_str = ", ".join(work.authors[:5])
|
|
47
|
+
if len(work.authors) > 5:
|
|
48
|
+
authors_str += f" et al. ({len(work.authors)} authors)"
|
|
49
|
+
lines.append(f"Authors: {authors_str}")
|
|
50
|
+
|
|
51
|
+
# Journal and identifiers
|
|
52
|
+
if work.source:
|
|
53
|
+
source_line = f"Journal: {work.source}"
|
|
54
|
+
if work.volume:
|
|
55
|
+
source_line += f", {work.volume}"
|
|
56
|
+
if work.issue:
|
|
57
|
+
source_line += f"({work.issue})"
|
|
58
|
+
if work.pages:
|
|
59
|
+
source_line += f", {work.pages}"
|
|
60
|
+
lines.append(source_line)
|
|
61
|
+
|
|
62
|
+
if work.doi:
|
|
63
|
+
lines.append(f"DOI: {work.doi}")
|
|
64
|
+
|
|
65
|
+
lines.append(f"OpenAlex ID: {work.openalex_id}")
|
|
66
|
+
|
|
67
|
+
# Citation count
|
|
68
|
+
if work.cited_by_count is not None:
|
|
69
|
+
lines.append(f"Citations: {work.cited_by_count}")
|
|
70
|
+
|
|
71
|
+
# Open access
|
|
72
|
+
if work.is_oa:
|
|
73
|
+
lines.append(f"Open Access: {work.oa_url or 'Yes'}")
|
|
74
|
+
|
|
75
|
+
# Abstract
|
|
76
|
+
if include_abstract and work.abstract:
|
|
77
|
+
lines.append(f"Abstract: {work.abstract}")
|
|
78
|
+
|
|
79
|
+
return "\n".join(lines)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def export_text(
|
|
83
|
+
works: List["Work"],
|
|
84
|
+
include_abstract: bool = False,
|
|
85
|
+
query: Optional[str] = None,
|
|
86
|
+
total: Optional[int] = None,
|
|
87
|
+
elapsed_ms: Optional[float] = None,
|
|
88
|
+
) -> str:
|
|
89
|
+
"""Export works to text format.
|
|
90
|
+
|
|
91
|
+
Args:
|
|
92
|
+
works: List of Work objects
|
|
93
|
+
include_abstract: Whether to include abstracts
|
|
94
|
+
query: Original search query (for header)
|
|
95
|
+
total: Total number of matches
|
|
96
|
+
elapsed_ms: Search time in milliseconds
|
|
97
|
+
|
|
98
|
+
Returns:
|
|
99
|
+
Formatted text string
|
|
100
|
+
"""
|
|
101
|
+
lines = []
|
|
102
|
+
|
|
103
|
+
# Header
|
|
104
|
+
if query is not None:
|
|
105
|
+
lines.append(f"Search: {query}")
|
|
106
|
+
if total is not None:
|
|
107
|
+
lines.append(f"Found: {total:,} matches")
|
|
108
|
+
if elapsed_ms is not None:
|
|
109
|
+
lines.append(f"Time: {elapsed_ms:.1f}ms")
|
|
110
|
+
lines.append("")
|
|
111
|
+
lines.append("=" * 60)
|
|
112
|
+
lines.append("")
|
|
113
|
+
|
|
114
|
+
# Works
|
|
115
|
+
for i, work in enumerate(works, 1):
|
|
116
|
+
lines.append(f"[{i}]")
|
|
117
|
+
lines.append(work_to_text(work, include_abstract=include_abstract))
|
|
118
|
+
lines.append("")
|
|
119
|
+
lines.append("-" * 40)
|
|
120
|
+
lines.append("")
|
|
121
|
+
|
|
122
|
+
return "\n".join(lines)
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def export_json(
|
|
126
|
+
works: List["Work"],
|
|
127
|
+
query: Optional[str] = None,
|
|
128
|
+
total: Optional[int] = None,
|
|
129
|
+
elapsed_ms: Optional[float] = None,
|
|
130
|
+
indent: int = 2,
|
|
131
|
+
) -> str:
|
|
132
|
+
"""Export works to JSON format.
|
|
133
|
+
|
|
134
|
+
Args:
|
|
135
|
+
works: List of Work objects
|
|
136
|
+
query: Original search query
|
|
137
|
+
total: Total number of matches
|
|
138
|
+
elapsed_ms: Search time in milliseconds
|
|
139
|
+
indent: JSON indentation
|
|
140
|
+
|
|
141
|
+
Returns:
|
|
142
|
+
JSON string
|
|
143
|
+
"""
|
|
144
|
+
data = {
|
|
145
|
+
"works": [w.to_dict() for w in works],
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
if query is not None:
|
|
149
|
+
data["query"] = query
|
|
150
|
+
if total is not None:
|
|
151
|
+
data["total"] = total
|
|
152
|
+
if elapsed_ms is not None:
|
|
153
|
+
data["elapsed_ms"] = elapsed_ms
|
|
154
|
+
|
|
155
|
+
return _json.dumps(data, indent=indent, ensure_ascii=False)
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def export_bibtex(works: List["Work"]) -> str:
|
|
159
|
+
"""Export works to BibTeX format.
|
|
160
|
+
|
|
161
|
+
Args:
|
|
162
|
+
works: List of Work objects
|
|
163
|
+
|
|
164
|
+
Returns:
|
|
165
|
+
BibTeX string with all entries
|
|
166
|
+
"""
|
|
167
|
+
entries = [w.citation("bibtex") for w in works]
|
|
168
|
+
return "\n\n".join(entries)
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def save(
|
|
172
|
+
data: Union["Work", "SearchResult", List["Work"]],
|
|
173
|
+
path: Union[str, _Path],
|
|
174
|
+
format: str = "json",
|
|
175
|
+
include_abstract: bool = True,
|
|
176
|
+
) -> str:
|
|
177
|
+
"""Save Work(s) or SearchResult to a file.
|
|
178
|
+
|
|
179
|
+
Args:
|
|
180
|
+
data: Work, SearchResult, or list of Works to save
|
|
181
|
+
path: Output file path
|
|
182
|
+
format: Output format ("text", "json", "bibtex")
|
|
183
|
+
include_abstract: Include abstracts in text format
|
|
184
|
+
|
|
185
|
+
Returns:
|
|
186
|
+
Path to saved file
|
|
187
|
+
|
|
188
|
+
Raises:
|
|
189
|
+
ValueError: If format is not supported
|
|
190
|
+
|
|
191
|
+
Examples:
|
|
192
|
+
>>> from openalex_local import search, save
|
|
193
|
+
>>> results = search("machine learning", limit=10)
|
|
194
|
+
>>> save(results, "results.json")
|
|
195
|
+
>>> save(results, "results.bib", format="bibtex")
|
|
196
|
+
>>> save(results, "results.txt", format="text")
|
|
197
|
+
"""
|
|
198
|
+
from .models import SearchResult, Work
|
|
199
|
+
|
|
200
|
+
if format not in SUPPORTED_FORMATS:
|
|
201
|
+
raise ValueError(
|
|
202
|
+
f"Unsupported format: {format}. "
|
|
203
|
+
f"Supported formats: {', '.join(SUPPORTED_FORMATS)}"
|
|
204
|
+
)
|
|
205
|
+
|
|
206
|
+
path = _Path(path)
|
|
207
|
+
|
|
208
|
+
# Extract works and metadata
|
|
209
|
+
if isinstance(data, Work):
|
|
210
|
+
works = [data]
|
|
211
|
+
query = None
|
|
212
|
+
total = None
|
|
213
|
+
elapsed_ms = None
|
|
214
|
+
elif isinstance(data, SearchResult):
|
|
215
|
+
works = data.works
|
|
216
|
+
query = data.query
|
|
217
|
+
total = data.total
|
|
218
|
+
elapsed_ms = data.elapsed_ms
|
|
219
|
+
elif isinstance(data, list):
|
|
220
|
+
works = data
|
|
221
|
+
query = None
|
|
222
|
+
total = len(data)
|
|
223
|
+
elapsed_ms = None
|
|
224
|
+
else:
|
|
225
|
+
raise TypeError(f"Unsupported data type: {type(data)}")
|
|
226
|
+
|
|
227
|
+
# Generate content
|
|
228
|
+
if format == "text":
|
|
229
|
+
content = export_text(
|
|
230
|
+
works,
|
|
231
|
+
include_abstract=include_abstract,
|
|
232
|
+
query=query,
|
|
233
|
+
total=total,
|
|
234
|
+
elapsed_ms=elapsed_ms,
|
|
235
|
+
)
|
|
236
|
+
elif format == "json":
|
|
237
|
+
content = export_json(
|
|
238
|
+
works,
|
|
239
|
+
query=query,
|
|
240
|
+
total=total,
|
|
241
|
+
elapsed_ms=elapsed_ms,
|
|
242
|
+
)
|
|
243
|
+
elif format == "bibtex":
|
|
244
|
+
content = export_bibtex(works)
|
|
245
|
+
else:
|
|
246
|
+
raise ValueError(f"Unsupported format: {format}")
|
|
247
|
+
|
|
248
|
+
# Write to file
|
|
249
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
250
|
+
path.write_text(content, encoding="utf-8")
|
|
251
|
+
|
|
252
|
+
return str(path)
|
|
@@ -50,6 +50,10 @@ class Work:
|
|
|
50
50
|
referenced_works: List[str] = field(default_factory=list)
|
|
51
51
|
is_oa: bool = False
|
|
52
52
|
oa_url: Optional[str] = None
|
|
53
|
+
# Source/journal metrics (from sources table)
|
|
54
|
+
impact_factor: Optional[float] = None # 2yr_mean_citedness
|
|
55
|
+
source_h_index: Optional[int] = None
|
|
56
|
+
source_cited_by_count: Optional[int] = None
|
|
53
57
|
|
|
54
58
|
@classmethod
|
|
55
59
|
def from_openalex(cls, data: dict) -> "Work":
|
|
@@ -177,6 +181,9 @@ class Work:
|
|
|
177
181
|
referenced_works=data.get("referenced_works", []),
|
|
178
182
|
is_oa=bool(data.get("is_oa", False)),
|
|
179
183
|
oa_url=data.get("oa_url"),
|
|
184
|
+
impact_factor=data.get("impact_factor"),
|
|
185
|
+
source_h_index=data.get("source_h_index"),
|
|
186
|
+
source_cited_by_count=data.get("source_cited_by_count"),
|
|
180
187
|
)
|
|
181
188
|
|
|
182
189
|
def to_dict(self) -> dict:
|
|
@@ -201,8 +208,179 @@ class Work:
|
|
|
201
208
|
"referenced_works": self.referenced_works,
|
|
202
209
|
"is_oa": self.is_oa,
|
|
203
210
|
"oa_url": self.oa_url,
|
|
211
|
+
"impact_factor": self.impact_factor,
|
|
212
|
+
"source_h_index": self.source_h_index,
|
|
213
|
+
"source_cited_by_count": self.source_cited_by_count,
|
|
204
214
|
}
|
|
205
215
|
|
|
216
|
+
def citation(self, style: str = "apa") -> str:
|
|
217
|
+
"""
|
|
218
|
+
Format work as a citation string.
|
|
219
|
+
|
|
220
|
+
Args:
|
|
221
|
+
style: Citation style - "apa" (default) or "bibtex"
|
|
222
|
+
|
|
223
|
+
Returns:
|
|
224
|
+
Formatted citation string
|
|
225
|
+
|
|
226
|
+
Example:
|
|
227
|
+
>>> work.citation() # APA format
|
|
228
|
+
'Piwowar, H., & Priem, J. (2018). The state of OA. PeerJ.'
|
|
229
|
+
>>> work.citation("bibtex") # BibTeX format
|
|
230
|
+
'@article{W2741809807, title={The state of OA}, ...}'
|
|
231
|
+
"""
|
|
232
|
+
if style.lower() == "bibtex":
|
|
233
|
+
return self._citation_bibtex()
|
|
234
|
+
return self._citation_apa()
|
|
235
|
+
|
|
236
|
+
def _citation_apa(self) -> str:
|
|
237
|
+
"""Format as APA citation."""
|
|
238
|
+
parts = []
|
|
239
|
+
|
|
240
|
+
# Authors
|
|
241
|
+
if self.authors:
|
|
242
|
+
if len(self.authors) == 1:
|
|
243
|
+
parts.append(self._format_author_apa(self.authors[0]))
|
|
244
|
+
elif len(self.authors) == 2:
|
|
245
|
+
parts.append(
|
|
246
|
+
f"{self._format_author_apa(self.authors[0])} & "
|
|
247
|
+
f"{self._format_author_apa(self.authors[1])}"
|
|
248
|
+
)
|
|
249
|
+
else:
|
|
250
|
+
formatted = [self._format_author_apa(a) for a in self.authors[:19]]
|
|
251
|
+
if len(self.authors) > 20:
|
|
252
|
+
formatted = (
|
|
253
|
+
formatted[:19]
|
|
254
|
+
+ ["..."]
|
|
255
|
+
+ [self._format_author_apa(self.authors[-1])]
|
|
256
|
+
)
|
|
257
|
+
parts.append(", ".join(formatted[:-1]) + ", & " + formatted[-1])
|
|
258
|
+
|
|
259
|
+
# Year
|
|
260
|
+
if self.year:
|
|
261
|
+
parts.append(f"({self.year})")
|
|
262
|
+
|
|
263
|
+
# Title
|
|
264
|
+
if self.title:
|
|
265
|
+
parts.append(f"{self.title}.")
|
|
266
|
+
|
|
267
|
+
# Source (journal)
|
|
268
|
+
if self.source:
|
|
269
|
+
source_part = f"*{self.source}*"
|
|
270
|
+
if self.volume:
|
|
271
|
+
source_part += f", *{self.volume}*"
|
|
272
|
+
if self.issue:
|
|
273
|
+
source_part += f"({self.issue})"
|
|
274
|
+
if self.pages:
|
|
275
|
+
source_part += f", {self.pages}"
|
|
276
|
+
source_part += "."
|
|
277
|
+
parts.append(source_part)
|
|
278
|
+
|
|
279
|
+
# DOI
|
|
280
|
+
if self.doi:
|
|
281
|
+
parts.append(f"https://doi.org/{self.doi}")
|
|
282
|
+
|
|
283
|
+
return " ".join(parts)
|
|
284
|
+
|
|
285
|
+
def _format_author_apa(self, name: str) -> str:
|
|
286
|
+
"""Format author name for APA (Last, F. M.)."""
|
|
287
|
+
parts = name.split()
|
|
288
|
+
if len(parts) == 1:
|
|
289
|
+
return parts[0]
|
|
290
|
+
last = parts[-1]
|
|
291
|
+
initials = " ".join(f"{p[0]}." for p in parts[:-1] if p)
|
|
292
|
+
return f"{last}, {initials}"
|
|
293
|
+
|
|
294
|
+
def _citation_bibtex(self) -> str:
|
|
295
|
+
"""Format as BibTeX entry."""
|
|
296
|
+
# Determine entry type
|
|
297
|
+
entry_type = "article"
|
|
298
|
+
if self.type:
|
|
299
|
+
type_map = {
|
|
300
|
+
"book": "book",
|
|
301
|
+
"book-chapter": "incollection",
|
|
302
|
+
"proceedings": "inproceedings",
|
|
303
|
+
"proceedings-article": "inproceedings",
|
|
304
|
+
"dissertation": "phdthesis",
|
|
305
|
+
"report": "techreport",
|
|
306
|
+
}
|
|
307
|
+
entry_type = type_map.get(self.type, "article")
|
|
308
|
+
|
|
309
|
+
# Use OpenAlex ID as citation key
|
|
310
|
+
key = self.openalex_id or "unknown"
|
|
311
|
+
|
|
312
|
+
lines = [f"@{entry_type}{{{key},"]
|
|
313
|
+
|
|
314
|
+
if self.title:
|
|
315
|
+
lines.append(f" title = {{{self.title}}},")
|
|
316
|
+
|
|
317
|
+
if self.authors:
|
|
318
|
+
author_str = " and ".join(self.authors)
|
|
319
|
+
lines.append(f" author = {{{author_str}}},")
|
|
320
|
+
|
|
321
|
+
if self.year:
|
|
322
|
+
lines.append(f" year = {{{self.year}}},")
|
|
323
|
+
|
|
324
|
+
if self.source:
|
|
325
|
+
if entry_type == "article":
|
|
326
|
+
lines.append(f" journal = {{{self.source}}},")
|
|
327
|
+
elif entry_type in ("incollection", "inproceedings"):
|
|
328
|
+
lines.append(f" booktitle = {{{self.source}}},")
|
|
329
|
+
|
|
330
|
+
if self.volume:
|
|
331
|
+
lines.append(f" volume = {{{self.volume}}},")
|
|
332
|
+
|
|
333
|
+
if self.issue:
|
|
334
|
+
lines.append(f" number = {{{self.issue}}},")
|
|
335
|
+
|
|
336
|
+
if self.pages:
|
|
337
|
+
lines.append(f" pages = {{{self.pages}}},")
|
|
338
|
+
|
|
339
|
+
if self.publisher:
|
|
340
|
+
lines.append(f" publisher = {{{self.publisher}}},")
|
|
341
|
+
|
|
342
|
+
if self.doi:
|
|
343
|
+
lines.append(f" doi = {{{self.doi}}},")
|
|
344
|
+
|
|
345
|
+
if self.oa_url:
|
|
346
|
+
lines.append(f" url = {{{self.oa_url}}},")
|
|
347
|
+
|
|
348
|
+
lines.append("}")
|
|
349
|
+
|
|
350
|
+
return "\n".join(lines)
|
|
351
|
+
|
|
352
|
+
def to_text(self, include_abstract: bool = False) -> str:
|
|
353
|
+
"""Format as human-readable text.
|
|
354
|
+
|
|
355
|
+
Args:
|
|
356
|
+
include_abstract: Include abstract in output
|
|
357
|
+
|
|
358
|
+
Returns:
|
|
359
|
+
Formatted text string
|
|
360
|
+
"""
|
|
361
|
+
from .export import work_to_text
|
|
362
|
+
|
|
363
|
+
return work_to_text(self, include_abstract=include_abstract)
|
|
364
|
+
|
|
365
|
+
def save(self, path: str, format: str = "json") -> str:
|
|
366
|
+
"""Save work to file.
|
|
367
|
+
|
|
368
|
+
Args:
|
|
369
|
+
path: Output file path
|
|
370
|
+
format: Output format ("text", "json", "bibtex")
|
|
371
|
+
|
|
372
|
+
Returns:
|
|
373
|
+
Path to saved file
|
|
374
|
+
|
|
375
|
+
Examples:
|
|
376
|
+
>>> work = get("W2741809807")
|
|
377
|
+
>>> work.save("paper.json")
|
|
378
|
+
>>> work.save("paper.bib", format="bibtex")
|
|
379
|
+
"""
|
|
380
|
+
from .export import save
|
|
381
|
+
|
|
382
|
+
return save(self, path, format=format)
|
|
383
|
+
|
|
206
384
|
|
|
207
385
|
@dataclass
|
|
208
386
|
class SearchResult:
|
|
@@ -229,3 +407,26 @@ class SearchResult:
|
|
|
229
407
|
|
|
230
408
|
def __getitem__(self, idx):
|
|
231
409
|
return self.works[idx]
|
|
410
|
+
|
|
411
|
+
def save(
|
|
412
|
+
self, path: str, format: str = "json", include_abstract: bool = True
|
|
413
|
+
) -> str:
|
|
414
|
+
"""Save search results to file.
|
|
415
|
+
|
|
416
|
+
Args:
|
|
417
|
+
path: Output file path
|
|
418
|
+
format: Output format ("text", "json", "bibtex")
|
|
419
|
+
include_abstract: Include abstracts in text format
|
|
420
|
+
|
|
421
|
+
Returns:
|
|
422
|
+
Path to saved file
|
|
423
|
+
|
|
424
|
+
Examples:
|
|
425
|
+
>>> results = search("machine learning", limit=10)
|
|
426
|
+
>>> results.save("results.json")
|
|
427
|
+
>>> results.save("results.bib", format="bibtex")
|
|
428
|
+
>>> results.save("results.txt", format="text")
|
|
429
|
+
"""
|
|
430
|
+
from .export import save
|
|
431
|
+
|
|
432
|
+
return save(self, path, format=format, include_abstract=include_abstract)
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
"""Remote API client for openalex_local.
|
|
2
|
+
|
|
3
|
+
Connects to an OpenAlex Local API server instead of direct database access.
|
|
4
|
+
Use this when the database is on a remote server accessible via HTTP.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from typing import Optional
|
|
8
|
+
|
|
9
|
+
from .base import RemoteClient, DEFAULT_API_URL
|
|
10
|
+
|
|
11
|
+
# Module-level client singleton
|
|
12
|
+
_client: Optional[RemoteClient] = None
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def get_client(base_url: str = DEFAULT_API_URL) -> RemoteClient:
|
|
16
|
+
"""Get or create singleton remote client."""
|
|
17
|
+
global _client
|
|
18
|
+
if _client is None or _client.base_url != base_url:
|
|
19
|
+
_client = RemoteClient(base_url)
|
|
20
|
+
return _client
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def reset_client() -> None:
|
|
24
|
+
"""Reset singleton client."""
|
|
25
|
+
global _client
|
|
26
|
+
_client = None
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
__all__ = [
|
|
30
|
+
"RemoteClient",
|
|
31
|
+
"DEFAULT_API_URL",
|
|
32
|
+
"get_client",
|
|
33
|
+
"reset_client",
|
|
34
|
+
]
|