crossref-local 0.4.0__py3-none-any.whl → 0.5.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.
- crossref_local/__init__.py +24 -10
- crossref_local/_aio/__init__.py +30 -0
- crossref_local/_aio/_impl.py +238 -0
- crossref_local/_cache/__init__.py +15 -0
- crossref_local/{cache_export.py → _cache/export.py} +27 -10
- crossref_local/_cache/utils.py +93 -0
- crossref_local/_cli/__init__.py +9 -0
- crossref_local/_cli/cli.py +389 -0
- crossref_local/_cli/mcp.py +351 -0
- crossref_local/_cli/mcp_server.py +457 -0
- crossref_local/_cli/search.py +199 -0
- crossref_local/_core/__init__.py +62 -0
- crossref_local/{api.py → _core/api.py} +26 -5
- crossref_local/{citations.py → _core/citations.py} +55 -26
- crossref_local/{config.py → _core/config.py} +40 -22
- crossref_local/{db.py → _core/db.py} +32 -26
- crossref_local/_core/export.py +344 -0
- crossref_local/{fts.py → _core/fts.py} +37 -14
- crossref_local/{models.py → _core/models.py} +120 -6
- crossref_local/_remote/__init__.py +56 -0
- crossref_local/_remote/base.py +378 -0
- crossref_local/_remote/collections.py +175 -0
- crossref_local/_server/__init__.py +140 -0
- crossref_local/_server/middleware.py +25 -0
- crossref_local/_server/models.py +143 -0
- crossref_local/_server/routes_citations.py +98 -0
- crossref_local/_server/routes_collections.py +282 -0
- crossref_local/_server/routes_compat.py +102 -0
- crossref_local/_server/routes_works.py +178 -0
- crossref_local/_server/server.py +19 -0
- crossref_local/aio.py +30 -206
- crossref_local/cache.py +100 -100
- crossref_local/cli.py +5 -515
- crossref_local/jobs.py +169 -0
- crossref_local/mcp_server.py +5 -410
- crossref_local/remote.py +5 -266
- crossref_local/server.py +5 -349
- {crossref_local-0.4.0.dist-info → crossref_local-0.5.1.dist-info}/METADATA +36 -11
- crossref_local-0.5.1.dist-info/RECORD +49 -0
- {crossref_local-0.4.0.dist-info → crossref_local-0.5.1.dist-info}/entry_points.txt +1 -1
- crossref_local/cli_mcp.py +0 -275
- crossref_local-0.4.0.dist-info/RECORD +0 -27
- /crossref_local/{cache_viz.py → _cache/viz.py} +0 -0
- /crossref_local/{cli_cache.py → _cli/cache.py} +0 -0
- /crossref_local/{cli_completion.py → _cli/completion.py} +0 -0
- /crossref_local/{cli_main.py → _cli/main.py} +0 -0
- /crossref_local/{impact_factor → _impact_factor}/__init__.py +0 -0
- /crossref_local/{impact_factor → _impact_factor}/calculator.py +0 -0
- /crossref_local/{impact_factor → _impact_factor}/journal_lookup.py +0 -0
- {crossref_local-0.4.0.dist-info → crossref_local-0.5.1.dist-info}/WHEEL +0 -0
|
@@ -0,0 +1,389 @@
|
|
|
1
|
+
"""Command-line interface for crossref_local."""
|
|
2
|
+
|
|
3
|
+
import sys
|
|
4
|
+
|
|
5
|
+
import click
|
|
6
|
+
from rich.console import Console
|
|
7
|
+
|
|
8
|
+
from .. import __version__, info
|
|
9
|
+
|
|
10
|
+
console = Console()
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class AliasedGroup(click.Group):
|
|
14
|
+
"""Click group that supports command aliases."""
|
|
15
|
+
|
|
16
|
+
def __init__(self, *args, **kwargs):
|
|
17
|
+
super().__init__(*args, **kwargs)
|
|
18
|
+
self._aliases = {}
|
|
19
|
+
|
|
20
|
+
def command(self, *args, aliases=None, **kwargs):
|
|
21
|
+
"""Decorator that registers aliases for commands."""
|
|
22
|
+
|
|
23
|
+
def decorator(f):
|
|
24
|
+
cmd = super(AliasedGroup, self).command(*args, **kwargs)(f)
|
|
25
|
+
if aliases:
|
|
26
|
+
for alias in aliases:
|
|
27
|
+
self._aliases[alias] = cmd.name
|
|
28
|
+
return cmd
|
|
29
|
+
|
|
30
|
+
return decorator
|
|
31
|
+
|
|
32
|
+
def get_command(self, ctx, cmd_name):
|
|
33
|
+
"""Resolve aliases to actual commands."""
|
|
34
|
+
cmd_name = self._aliases.get(cmd_name, cmd_name)
|
|
35
|
+
return super().get_command(ctx, cmd_name)
|
|
36
|
+
|
|
37
|
+
def format_commands(self, ctx, formatter):
|
|
38
|
+
"""Format commands with aliases shown inline."""
|
|
39
|
+
commands = []
|
|
40
|
+
for subcommand in self.list_commands(ctx):
|
|
41
|
+
cmd = self.get_command(ctx, subcommand)
|
|
42
|
+
if cmd is None or cmd.hidden:
|
|
43
|
+
continue
|
|
44
|
+
|
|
45
|
+
# Find aliases for this command
|
|
46
|
+
aliases = [a for a, c in self._aliases.items() if c == subcommand]
|
|
47
|
+
if aliases:
|
|
48
|
+
name = f"{subcommand} ({', '.join(aliases)})"
|
|
49
|
+
else:
|
|
50
|
+
name = subcommand
|
|
51
|
+
|
|
52
|
+
help_text = cmd.get_short_help_str(limit=50)
|
|
53
|
+
commands.append((name, help_text))
|
|
54
|
+
|
|
55
|
+
if commands:
|
|
56
|
+
with formatter.section("Commands"):
|
|
57
|
+
formatter.write_dl(commands)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
CONTEXT_SETTINGS = {"help_option_names": ["-h", "--help"]}
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _print_recursive_help(ctx, param, value):
|
|
64
|
+
"""Callback for --help-recursive flag."""
|
|
65
|
+
if not value or ctx.resilient_parsing:
|
|
66
|
+
return
|
|
67
|
+
|
|
68
|
+
def _print_command_help(cmd, prefix: str, parent_ctx):
|
|
69
|
+
"""Recursively print help for a command and its subcommands."""
|
|
70
|
+
console.print(f"\n[bold cyan]━━━ {prefix} ━━━[/bold cyan]")
|
|
71
|
+
sub_ctx = click.Context(cmd, info_name=prefix.split()[-1], parent=parent_ctx)
|
|
72
|
+
console.print(cmd.get_help(sub_ctx))
|
|
73
|
+
|
|
74
|
+
if isinstance(cmd, click.Group):
|
|
75
|
+
for sub_name, sub_cmd in sorted(cmd.commands.items()):
|
|
76
|
+
_print_command_help(sub_cmd, f"{prefix} {sub_name}", sub_ctx)
|
|
77
|
+
|
|
78
|
+
# Print main help
|
|
79
|
+
console.print("[bold cyan]━━━ crossref-local ━━━[/bold cyan]")
|
|
80
|
+
console.print(ctx.get_help())
|
|
81
|
+
|
|
82
|
+
# Print all subcommands recursively
|
|
83
|
+
for name, cmd in sorted(cli.commands.items()):
|
|
84
|
+
_print_command_help(cmd, f"crossref-local {name}", ctx)
|
|
85
|
+
|
|
86
|
+
ctx.exit(0)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
@click.group(cls=AliasedGroup, context_settings=CONTEXT_SETTINGS)
|
|
90
|
+
@click.version_option(version=__version__, prog_name="crossref-local")
|
|
91
|
+
@click.option("--http", is_flag=True, help="Use HTTP API instead of direct database")
|
|
92
|
+
@click.option(
|
|
93
|
+
"--api-url",
|
|
94
|
+
envvar="CROSSREF_LOCAL_API_URL",
|
|
95
|
+
help="API URL for http mode (default: auto-detect)",
|
|
96
|
+
)
|
|
97
|
+
@click.option(
|
|
98
|
+
"--help-recursive",
|
|
99
|
+
is_flag=True,
|
|
100
|
+
is_eager=True,
|
|
101
|
+
expose_value=False,
|
|
102
|
+
callback=_print_recursive_help,
|
|
103
|
+
help="Show help for all commands recursively.",
|
|
104
|
+
)
|
|
105
|
+
@click.pass_context
|
|
106
|
+
def cli(ctx, http: bool, api_url: str):
|
|
107
|
+
"""Local CrossRef database with 167M+ works and full-text search.
|
|
108
|
+
|
|
109
|
+
Supports both direct database access (db mode) and HTTP API (http mode).
|
|
110
|
+
|
|
111
|
+
\b
|
|
112
|
+
DB mode (default if database found):
|
|
113
|
+
crossref-local search "machine learning"
|
|
114
|
+
|
|
115
|
+
\b
|
|
116
|
+
HTTP mode (connect to API server):
|
|
117
|
+
crossref-local --http search "machine learning"
|
|
118
|
+
"""
|
|
119
|
+
from .._core.config import Config
|
|
120
|
+
|
|
121
|
+
ctx.ensure_object(dict)
|
|
122
|
+
|
|
123
|
+
if api_url:
|
|
124
|
+
Config.set_api_url(api_url)
|
|
125
|
+
elif http:
|
|
126
|
+
Config.set_mode("http")
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
# Register search commands from search module
|
|
130
|
+
from .search import search_by_doi_cmd, search_cmd
|
|
131
|
+
|
|
132
|
+
cli.add_command(search_cmd)
|
|
133
|
+
cli.add_command(search_by_doi_cmd)
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
@cli.command(context_settings=CONTEXT_SETTINGS)
|
|
137
|
+
def status():
|
|
138
|
+
"""Show status and configuration."""
|
|
139
|
+
import os
|
|
140
|
+
|
|
141
|
+
from .._core.config import DEFAULT_API_URLS, DEFAULT_DB_PATHS
|
|
142
|
+
|
|
143
|
+
click.echo("CrossRef Local - Status")
|
|
144
|
+
click.echo("=" * 50)
|
|
145
|
+
click.echo()
|
|
146
|
+
|
|
147
|
+
# Check environment variables
|
|
148
|
+
click.echo("Environment Variables:")
|
|
149
|
+
click.echo()
|
|
150
|
+
|
|
151
|
+
env_vars = [
|
|
152
|
+
(
|
|
153
|
+
"CROSSREF_LOCAL_DB",
|
|
154
|
+
"Path to SQLite database file",
|
|
155
|
+
os.environ.get("CROSSREF_LOCAL_DB"),
|
|
156
|
+
),
|
|
157
|
+
(
|
|
158
|
+
"CROSSREF_LOCAL_API_URL",
|
|
159
|
+
"HTTP API URL (e.g., http://localhost:8333)",
|
|
160
|
+
os.environ.get("CROSSREF_LOCAL_API_URL"),
|
|
161
|
+
),
|
|
162
|
+
(
|
|
163
|
+
"CROSSREF_LOCAL_MODE",
|
|
164
|
+
"Force mode: 'db', 'http', or 'auto'",
|
|
165
|
+
os.environ.get("CROSSREF_LOCAL_MODE"),
|
|
166
|
+
),
|
|
167
|
+
(
|
|
168
|
+
"CROSSREF_LOCAL_HOST",
|
|
169
|
+
"Host for relay server (default: 0.0.0.0)",
|
|
170
|
+
os.environ.get("CROSSREF_LOCAL_HOST"),
|
|
171
|
+
),
|
|
172
|
+
(
|
|
173
|
+
"CROSSREF_LOCAL_PORT",
|
|
174
|
+
"Port for relay server (default: 31291)",
|
|
175
|
+
os.environ.get("CROSSREF_LOCAL_PORT"),
|
|
176
|
+
),
|
|
177
|
+
]
|
|
178
|
+
|
|
179
|
+
for var_name, description, value in env_vars:
|
|
180
|
+
if value:
|
|
181
|
+
if var_name == "CROSSREF_LOCAL_DB":
|
|
182
|
+
stat = " (OK)" if os.path.exists(value) else " (NOT FOUND)"
|
|
183
|
+
else:
|
|
184
|
+
stat = ""
|
|
185
|
+
click.echo(f" {var_name}={value}{stat}")
|
|
186
|
+
click.echo(f" | {description}")
|
|
187
|
+
else:
|
|
188
|
+
click.echo(f" {var_name} (not set)")
|
|
189
|
+
click.echo(f" | {description}")
|
|
190
|
+
click.echo()
|
|
191
|
+
|
|
192
|
+
click.echo()
|
|
193
|
+
|
|
194
|
+
# Check default database paths
|
|
195
|
+
click.echo("Local Database Locations:")
|
|
196
|
+
db_found = None
|
|
197
|
+
for path in DEFAULT_DB_PATHS:
|
|
198
|
+
if path.exists():
|
|
199
|
+
click.echo(f" [OK] {path}")
|
|
200
|
+
if db_found is None:
|
|
201
|
+
db_found = path
|
|
202
|
+
else:
|
|
203
|
+
click.echo(f" [ ] {path}")
|
|
204
|
+
|
|
205
|
+
click.echo()
|
|
206
|
+
|
|
207
|
+
# Check API servers
|
|
208
|
+
click.echo("API Servers:")
|
|
209
|
+
api_found = None
|
|
210
|
+
for url in DEFAULT_API_URLS:
|
|
211
|
+
try:
|
|
212
|
+
import json as json_module
|
|
213
|
+
import urllib.request
|
|
214
|
+
|
|
215
|
+
# Check root endpoint for version
|
|
216
|
+
req = urllib.request.Request(f"{url}/", method="GET")
|
|
217
|
+
req.add_header("Accept", "application/json")
|
|
218
|
+
with urllib.request.urlopen(req, timeout=3) as resp:
|
|
219
|
+
if resp.status == 200:
|
|
220
|
+
data = json_module.loads(resp.read().decode())
|
|
221
|
+
server_version = data.get("version", "unknown")
|
|
222
|
+
|
|
223
|
+
# Check version compatibility
|
|
224
|
+
if server_version == __version__:
|
|
225
|
+
click.echo(f" [OK] {url} (v{server_version})")
|
|
226
|
+
else:
|
|
227
|
+
click.echo(
|
|
228
|
+
f" [WARN] {url} (v{server_version} != v{__version__})"
|
|
229
|
+
)
|
|
230
|
+
click.echo(
|
|
231
|
+
" Server version mismatch - may be incompatible"
|
|
232
|
+
)
|
|
233
|
+
|
|
234
|
+
if api_found is None:
|
|
235
|
+
api_found = url
|
|
236
|
+
else:
|
|
237
|
+
click.echo(f" [ ] {url}")
|
|
238
|
+
except Exception:
|
|
239
|
+
click.echo(f" [ ] {url}")
|
|
240
|
+
|
|
241
|
+
click.echo()
|
|
242
|
+
|
|
243
|
+
# Summary and recommendations
|
|
244
|
+
if db_found:
|
|
245
|
+
click.echo(f"Local database: {db_found}")
|
|
246
|
+
try:
|
|
247
|
+
db_info = info()
|
|
248
|
+
click.echo(f" Works: {db_info.get('works', 0):,}")
|
|
249
|
+
click.echo(f" FTS indexed: {db_info.get('fts_indexed', 0):,}")
|
|
250
|
+
except Exception as e:
|
|
251
|
+
click.echo(f" Error: {e}", err=True)
|
|
252
|
+
click.echo()
|
|
253
|
+
click.echo("Ready! Try:")
|
|
254
|
+
click.echo(' crossref-local search "machine learning"')
|
|
255
|
+
elif api_found:
|
|
256
|
+
click.echo(f"HTTP API available: {api_found}")
|
|
257
|
+
click.echo()
|
|
258
|
+
click.echo("Ready! Try:")
|
|
259
|
+
click.echo(' crossref-local --http search "machine learning"')
|
|
260
|
+
click.echo()
|
|
261
|
+
click.echo("Or set environment:")
|
|
262
|
+
click.echo(" export CROSSREF_LOCAL_MODE=http")
|
|
263
|
+
else:
|
|
264
|
+
click.echo("No database or API server found!")
|
|
265
|
+
click.echo()
|
|
266
|
+
click.echo("Options:")
|
|
267
|
+
click.echo(" 1. Direct database access (db mode):")
|
|
268
|
+
click.echo(" export CROSSREF_LOCAL_DB=/path/to/crossref.db")
|
|
269
|
+
click.echo()
|
|
270
|
+
click.echo(" 2. HTTP API (connect to server):")
|
|
271
|
+
click.echo(" crossref-local --http search 'query'")
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
# Register MCP subcommand group
|
|
275
|
+
from .mcp import mcp, run_mcp_server
|
|
276
|
+
|
|
277
|
+
cli.add_command(mcp)
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
# Backward compatibility alias (hidden)
|
|
281
|
+
@cli.command("run-server-mcp", context_settings=CONTEXT_SETTINGS, hidden=True)
|
|
282
|
+
@click.option(
|
|
283
|
+
"-t", "--transport", type=click.Choice(["stdio", "sse", "http"]), default="stdio"
|
|
284
|
+
)
|
|
285
|
+
@click.option("--host", default="localhost", envvar="CROSSREF_LOCAL_MCP_HOST")
|
|
286
|
+
@click.option("--port", default=8082, type=int, envvar="CROSSREF_LOCAL_MCP_PORT")
|
|
287
|
+
def serve_mcp(transport: str, host: str, port: int):
|
|
288
|
+
"""Run MCP server (deprecated: use 'mcp start' instead)."""
|
|
289
|
+
click.echo(
|
|
290
|
+
"Note: 'run-server-mcp' is deprecated. Use 'crossref-local mcp start'.",
|
|
291
|
+
err=True,
|
|
292
|
+
)
|
|
293
|
+
run_mcp_server(transport, host, port)
|
|
294
|
+
|
|
295
|
+
|
|
296
|
+
@cli.command("relay", context_settings=CONTEXT_SETTINGS)
|
|
297
|
+
@click.option("--host", default=None, envvar="CROSSREF_LOCAL_HOST", help="Host to bind")
|
|
298
|
+
@click.option(
|
|
299
|
+
"--port",
|
|
300
|
+
default=None,
|
|
301
|
+
type=int,
|
|
302
|
+
envvar="CROSSREF_LOCAL_PORT",
|
|
303
|
+
help="Port to listen on (default: 31291)",
|
|
304
|
+
)
|
|
305
|
+
def relay(host: str, port: int):
|
|
306
|
+
"""Run HTTP relay server for remote database access.
|
|
307
|
+
|
|
308
|
+
\b
|
|
309
|
+
This runs a FastAPI server that provides proper full-text search
|
|
310
|
+
using FTS5 index across all 167M+ papers.
|
|
311
|
+
|
|
312
|
+
\b
|
|
313
|
+
Example:
|
|
314
|
+
crossref-local relay # Run on 0.0.0.0:31291
|
|
315
|
+
crossref-local relay --port 8080 # Custom port
|
|
316
|
+
|
|
317
|
+
\b
|
|
318
|
+
Then connect with http mode:
|
|
319
|
+
crossref-local --http search "CRISPR"
|
|
320
|
+
curl "http://localhost:8333/works?q=CRISPR&limit=10"
|
|
321
|
+
"""
|
|
322
|
+
try:
|
|
323
|
+
from .._server import run_server
|
|
324
|
+
except ImportError:
|
|
325
|
+
click.echo(
|
|
326
|
+
"API server requires fastapi and uvicorn. Install with:\n"
|
|
327
|
+
" pip install fastapi uvicorn",
|
|
328
|
+
err=True,
|
|
329
|
+
)
|
|
330
|
+
sys.exit(1)
|
|
331
|
+
|
|
332
|
+
from .._server import DEFAULT_HOST, DEFAULT_PORT
|
|
333
|
+
|
|
334
|
+
host = host or DEFAULT_HOST
|
|
335
|
+
port = port or DEFAULT_PORT
|
|
336
|
+
click.echo(f"Starting CrossRef Local relay server on {host}:{port}")
|
|
337
|
+
click.echo(f"Search endpoint: http://{host}:{port}/works?q=<query>")
|
|
338
|
+
click.echo(f"Docs: http://{host}:{port}/docs")
|
|
339
|
+
run_server(host=host, port=port)
|
|
340
|
+
|
|
341
|
+
|
|
342
|
+
# Deprecated alias for backwards compatibility
|
|
343
|
+
@cli.command("run-server-http", context_settings=CONTEXT_SETTINGS, hidden=True)
|
|
344
|
+
@click.option("--host", default=None, envvar="CROSSREF_LOCAL_HOST")
|
|
345
|
+
@click.option("--port", default=None, type=int, envvar="CROSSREF_LOCAL_PORT")
|
|
346
|
+
@click.pass_context
|
|
347
|
+
def run_server_http_deprecated(ctx, host: str, port: int):
|
|
348
|
+
"""Deprecated: Use 'crossref-local relay' instead."""
|
|
349
|
+
click.echo(
|
|
350
|
+
"Note: 'run-server-http' is deprecated. Use 'crossref-local relay'.",
|
|
351
|
+
err=True,
|
|
352
|
+
)
|
|
353
|
+
ctx.invoke(relay, host=host, port=port)
|
|
354
|
+
|
|
355
|
+
|
|
356
|
+
@cli.command("list-apis", context_settings=CONTEXT_SETTINGS)
|
|
357
|
+
@click.option(
|
|
358
|
+
"-v", "--verbose", count=True, help="Verbosity: -v sig, -vv +doc, -vvv full"
|
|
359
|
+
)
|
|
360
|
+
@click.option("-d", "--max-depth", type=int, default=5, help="Max recursion depth")
|
|
361
|
+
@click.option("--json", "as_json", is_flag=True, help="Output as JSON")
|
|
362
|
+
def list_apis(verbose, max_depth, as_json):
|
|
363
|
+
"""List Python APIs (alias for: scitex introspect api crossref_local)."""
|
|
364
|
+
try:
|
|
365
|
+
from scitex.cli.introspect import api
|
|
366
|
+
|
|
367
|
+
ctx = click.Context(api)
|
|
368
|
+
ctx.invoke(
|
|
369
|
+
api,
|
|
370
|
+
dotted_path="crossref_local",
|
|
371
|
+
verbose=verbose,
|
|
372
|
+
max_depth=max_depth,
|
|
373
|
+
as_json=as_json,
|
|
374
|
+
)
|
|
375
|
+
except ImportError:
|
|
376
|
+
# Fallback if scitex not installed
|
|
377
|
+
click.echo("Install scitex for full API introspection:")
|
|
378
|
+
click.echo(" pip install scitex")
|
|
379
|
+
click.echo()
|
|
380
|
+
click.echo("Or use: scitex introspect api crossref_local")
|
|
381
|
+
|
|
382
|
+
|
|
383
|
+
def main():
|
|
384
|
+
"""Entry point for CLI."""
|
|
385
|
+
cli()
|
|
386
|
+
|
|
387
|
+
|
|
388
|
+
if __name__ == "__main__":
|
|
389
|
+
main()
|