sciwrite-lint 0.2.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.
Files changed (76) hide show
  1. sciwrite_lint/__init__.py +3 -0
  2. sciwrite_lint/__main__.py +527 -0
  3. sciwrite_lint/_network.py +195 -0
  4. sciwrite_lint/api.py +1484 -0
  5. sciwrite_lint/checks/__init__.py +1 -0
  6. sciwrite_lint/checks/_section_utils.py +111 -0
  7. sciwrite_lint/checks/cite_purpose.py +122 -0
  8. sciwrite_lint/checks/claim_support.py +96 -0
  9. sciwrite_lint/checks/cross_section_consistency.py +185 -0
  10. sciwrite_lint/checks/dangling_cite.py +93 -0
  11. sciwrite_lint/checks/dangling_ref.py +116 -0
  12. sciwrite_lint/checks/full_paper_consistency.py +604 -0
  13. sciwrite_lint/checks/ref_internal_checks.py +919 -0
  14. sciwrite_lint/checks/reference_accuracy.py +277 -0
  15. sciwrite_lint/checks/reference_exists.py +119 -0
  16. sciwrite_lint/checks/reference_unreliable.py +244 -0
  17. sciwrite_lint/checks/registry.py +136 -0
  18. sciwrite_lint/checks/retracted_cite.py +96 -0
  19. sciwrite_lint/checks/structure_promises.py +115 -0
  20. sciwrite_lint/checks/unreferenced_figure.py +70 -0
  21. sciwrite_lint/claims.py +330 -0
  22. sciwrite_lint/claude_backend.py +94 -0
  23. sciwrite_lint/claude_cli.py +405 -0
  24. sciwrite_lint/cli/__init__.py +1 -0
  25. sciwrite_lint/cli/check.py +480 -0
  26. sciwrite_lint/cli/config.py +229 -0
  27. sciwrite_lint/cli/fetch.py +250 -0
  28. sciwrite_lint/cli/misc.py +1202 -0
  29. sciwrite_lint/cli/rank.py +470 -0
  30. sciwrite_lint/cli/verify.py +437 -0
  31. sciwrite_lint/config.py +646 -0
  32. sciwrite_lint/cross_paper.py +174 -0
  33. sciwrite_lint/eval_claims.py +1196 -0
  34. sciwrite_lint/fulltext.py +851 -0
  35. sciwrite_lint/llm_utils.py +386 -0
  36. sciwrite_lint/local_pdfs.py +122 -0
  37. sciwrite_lint/manuscript_store.py +674 -0
  38. sciwrite_lint/models.py +139 -0
  39. sciwrite_lint/pdf/__init__.py +1 -0
  40. sciwrite_lint/pdf/grobid.py +785 -0
  41. sciwrite_lint/pdf/pdf_download.py +258 -0
  42. sciwrite_lint/pipeline.py +2694 -0
  43. sciwrite_lint/prompt_safety.py +30 -0
  44. sciwrite_lint/rate_limiter.py +227 -0
  45. sciwrite_lint/references/__init__.py +1 -0
  46. sciwrite_lint/references/citations.py +715 -0
  47. sciwrite_lint/references/crossref.py +282 -0
  48. sciwrite_lint/references/embedding_store.py +380 -0
  49. sciwrite_lint/references/matching.py +273 -0
  50. sciwrite_lint/references/metadata.py +273 -0
  51. sciwrite_lint/references/reference_store.py +823 -0
  52. sciwrite_lint/references/retraction_watch.py +178 -0
  53. sciwrite_lint/references/workspace_db.py +1390 -0
  54. sciwrite_lint/report.py +163 -0
  55. sciwrite_lint/schemas.py +260 -0
  56. sciwrite_lint/scoring/__init__.py +1 -0
  57. sciwrite_lint/scoring/chain.py +716 -0
  58. sciwrite_lint/scoring/contribution.py +322 -0
  59. sciwrite_lint/scoring/scilint_score.py +611 -0
  60. sciwrite_lint/tex_parser.py +248 -0
  61. sciwrite_lint/usage.py +594 -0
  62. sciwrite_lint/vision/__init__.py +1 -0
  63. sciwrite_lint/vision/cache.py +140 -0
  64. sciwrite_lint/vision/describe.py +311 -0
  65. sciwrite_lint/vision/image_extraction.py +491 -0
  66. sciwrite_lint/vision/pipeline.py +207 -0
  67. sciwrite_lint/vllm/__init__.py +1 -0
  68. sciwrite_lint/vllm/metrics.py +157 -0
  69. sciwrite_lint/vllm/vllm_server.py +445 -0
  70. sciwrite_lint/web.py +369 -0
  71. sciwrite_lint-0.2.0.dist-info/METADATA +268 -0
  72. sciwrite_lint-0.2.0.dist-info/RECORD +76 -0
  73. sciwrite_lint-0.2.0.dist-info/WHEEL +5 -0
  74. sciwrite_lint-0.2.0.dist-info/entry_points.txt +2 -0
  75. sciwrite_lint-0.2.0.dist-info/licenses/LICENSE +21 -0
  76. sciwrite_lint-0.2.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,480 @@
1
+ """CLI handlers for 'check' and 'checks' commands."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import asyncio
7
+ from pathlib import Path
8
+ from typing import Any
9
+
10
+ from loguru import logger
11
+
12
+ from sciwrite_lint.config import LintConfig
13
+ from sciwrite_lint.models import Finding
14
+ from sciwrite_lint.report import format_findings
15
+
16
+
17
+ def _resolve_helpers(
18
+ args: argparse.Namespace, config: LintConfig
19
+ ) -> list[tuple[str, Path]]:
20
+ """Import and call _resolve_input_files from __main__."""
21
+ from sciwrite_lint.__main__ import _resolve_input_files
22
+
23
+ return _resolve_input_files(args, config)
24
+
25
+
26
+ async def run_llm_checks_batched(
27
+ checks: list[tuple],
28
+ tex_path: Path,
29
+ config: LintConfig,
30
+ ) -> list[Finding]:
31
+ """Run all LLM checks batched by thinking mode.
32
+
33
+ All LLM checks must implement the build_queries/process_results protocol:
34
+ - build_queries(tex_path, config) -> list of (system, user, schema, name) tuples
35
+ - process_results(results) -> list[Finding]
36
+
37
+ Each check can set a ``thinking`` attribute ("off", "low", "medium", "high")
38
+ to control chain-of-thought reasoning. Queries are grouped by thinking mode
39
+ so each group runs as a single batch call.
40
+ """
41
+ from sciwrite_lint.llm_utils import llm_query_batch
42
+
43
+ # Phase 1: collect queries, grouped by (thinking, max_tokens)
44
+ # max_tokens comes from build_queries._max_tokens (set by full-paper
45
+ # checks based on paper size); None means use model default.
46
+ BatchKey = tuple[str, int | None]
47
+ queries_by_batch: dict[BatchKey, list[tuple[str, str, dict, str]]] = {}
48
+ check_slices: list[tuple[Any, Any, BatchKey, int, int]] = []
49
+ build_failures: list[Finding] = []
50
+
51
+ for meta, fn in checks:
52
+ build = getattr(fn, "build_queries", None)
53
+ if build is None:
54
+ raise RuntimeError(f"LLM check {meta.id} missing build_queries")
55
+ try:
56
+ queries = build(tex_path, config)
57
+ thinking = getattr(fn, "thinking", "off")
58
+ mt: int | None = getattr(build, "_max_tokens", None)
59
+ batch_key: BatchKey = (thinking, mt)
60
+ if batch_key not in queries_by_batch:
61
+ queries_by_batch[batch_key] = []
62
+ start = len(queries_by_batch[batch_key])
63
+ queries_by_batch[batch_key].extend(queries)
64
+ check_slices.append((meta, fn, batch_key, start, len(queries)))
65
+ except Exception as e:
66
+ logger.error(f"Check {meta.id} build_queries failed: {e}")
67
+ build_failures.append(
68
+ Finding(
69
+ level="info",
70
+ rule_id=meta.id,
71
+ message=f"Check {meta.id} could not run (internal error)",
72
+ context=f"{type(e).__name__}: {e!s}"[:200],
73
+ )
74
+ )
75
+
76
+ # Phase 2: one batch call per (thinking, max_tokens) group
77
+ results_by_batch: dict[BatchKey, list[dict | None]] = {}
78
+ for (mode, mt), batch_queries in queries_by_batch.items():
79
+ if batch_queries:
80
+ try:
81
+ results_by_batch[(mode, mt)] = await llm_query_batch(
82
+ batch_queries,
83
+ config=config,
84
+ thinking=mode,
85
+ max_tokens=mt,
86
+ )
87
+ except Exception as e:
88
+ logger.error(f"LLM batch query failed (thinking={mode}): {e}")
89
+ results_by_batch[(mode, mt)] = [None] * len(batch_queries)
90
+
91
+ # Phase 3: distribute results to each check
92
+ findings: list[Finding] = []
93
+ for meta, fn, batch_key, start, count in check_slices:
94
+ try:
95
+ if count > 0:
96
+ process = getattr(fn, "process_results")
97
+ mode_results = results_by_batch.get(batch_key, [])
98
+ check_results = mode_results[start : start + count]
99
+ check_findings = process(check_results)
100
+ else:
101
+ check_findings = []
102
+
103
+ for f in check_findings:
104
+ override = config.effective_severity(meta.id, meta.severity)
105
+ if override != f.level:
106
+ f.level = override # type: ignore[assignment]
107
+ findings.extend(check_findings)
108
+ except Exception as e:
109
+ logger.error(f"Check {meta.id} failed: {e}")
110
+ findings.append(
111
+ Finding(
112
+ level="info",
113
+ rule_id=meta.id,
114
+ message=f"Check {meta.id} could not run (internal error)",
115
+ context=f"{type(e).__name__}: {e!s}"[:200],
116
+ )
117
+ )
118
+
119
+ return build_failures + findings
120
+
121
+
122
+ def run_check(args: argparse.Namespace) -> int:
123
+ """Run the full check pipeline: rules + verify + fetch + parse + claims.
124
+
125
+ Single-paper case dispatches to ``run_full_check`` (sequential pipeline).
126
+ Multi-paper case (2+ papers) dispatches to ``run_papers_staged`` for the
127
+ batch-staged pipeline — GPU stages run once with all papers, vLLM and
128
+ network stages run concurrently up to ``--concurrency``.
129
+ """
130
+ from sciwrite_lint.pipeline import preflight, run_full_check
131
+
132
+ from sciwrite_lint.__main__ import _load_config, _resolve_paper
133
+
134
+ config = _load_config(args)
135
+ fmt = args.format or config.output_format
136
+ fresh = getattr(args, "fresh", False)
137
+ concurrency = getattr(args, "concurrency", 2)
138
+
139
+ # Explicit file — no paper config, run text + LLM rules only
140
+ if hasattr(args, "file") and args.file:
141
+ p = Path(args.file)
142
+ if p.suffix.lower() == ".pdf":
143
+ return run_check_pdf(p, config, fmt)
144
+ return run_check_quick(args, config, fmt)
145
+
146
+ # Resolve to paper configs (need bib_path for claims)
147
+ paper_filter = getattr(args, "paper", None)
148
+ paper_configs: list[tuple[str, Path, Any]] = []
149
+ if paper_filter:
150
+ pc = _resolve_paper(config, paper_filter)
151
+ if not pc:
152
+ return 2
153
+ paper_configs.append((pc.name, pc.file_path, pc))
154
+ elif config.papers:
155
+ for pc in config.papers:
156
+ paper_configs.append((pc.name, pc.file_path, pc))
157
+ else:
158
+ logger.error("No papers registered. Use --paper or add [[papers]] to config.")
159
+ return 2
160
+
161
+ # Filter out missing files up front so the run/batch path sees only
162
+ # papers that actually exist.
163
+ runnable: list[tuple[str, Path, Any]] = []
164
+ all_ok = True
165
+ for name, tex_path, pc in paper_configs:
166
+ if not tex_path.exists():
167
+ logger.error(f"Error: {tex_path} not found")
168
+ all_ok = False
169
+ continue
170
+ runnable.append((name, tex_path, pc))
171
+
172
+ if not runnable:
173
+ return 1
174
+
175
+ # Preflight
176
+ errors = asyncio.run(preflight(config))
177
+ if errors:
178
+ logger.error("Prerequisites not met:")
179
+ for e in errors:
180
+ logger.error(f" ✗ {e}")
181
+ return 2
182
+
183
+ # Multi-paper: use batch-staged pipeline (single GPU model load per
184
+ # stage, concurrent vLLM/network across papers).
185
+ if len(runnable) > 1:
186
+ return _run_check_batch(runnable, config, fmt, fresh, concurrency, all_ok)
187
+
188
+ # Single paper: keep the existing sequential path
189
+ name, tex_path, pc = runnable[0]
190
+ print(f"\n{'=' * 60}")
191
+ print(f"Checking {name} ({tex_path.name})")
192
+ print(f"{'=' * 60}")
193
+
194
+ # PDF files: build ManuscriptContext via GROBID, then run full pipeline
195
+ if tex_path.suffix.lower() == ".pdf":
196
+ from sciwrite_lint.pipeline import build_pdf_context
197
+
198
+ asyncio.run(build_pdf_context(tex_path, config))
199
+
200
+ findings = asyncio.run(run_full_check(name, tex_path, pc, config, fresh=fresh))
201
+
202
+ label = f"{name} ({tex_path.name})" if name != tex_path.stem else tex_path.name
203
+ print()
204
+ format_findings(findings, label, fmt=fmt, color=config.color)
205
+ _print_integrity_summary(name, findings, config)
206
+
207
+ if any(f.level == "error" for f in findings):
208
+ all_ok = False
209
+
210
+ return 0 if all_ok else 1
211
+
212
+
213
+ def _run_check_batch(
214
+ runnable: list[tuple[str, Path, Any]],
215
+ config: LintConfig,
216
+ fmt: str,
217
+ fresh: bool,
218
+ concurrency: int,
219
+ all_ok: bool,
220
+ ) -> int:
221
+ """Run multiple papers via the batch-staged pipeline."""
222
+ from sciwrite_lint.pipeline import build_pdf_context, run_papers_staged
223
+
224
+ print(f"\n{'=' * 60}")
225
+ print(
226
+ f"Checking {len(runnable)} papers via batch-staged pipeline "
227
+ f"(concurrency={concurrency})"
228
+ )
229
+ print(f"{'=' * 60}")
230
+
231
+ # PDFs need ManuscriptContext built upfront (GROBID parse) before staged
232
+ # pipeline runs — same pattern as eval_real_world/runner.py.
233
+ async def _build_pdf_contexts() -> None:
234
+ for _name, tex_path, _pc in runnable:
235
+ if tex_path.suffix.lower() == ".pdf":
236
+ await build_pdf_context(tex_path, config)
237
+
238
+ asyncio.run(_build_pdf_contexts())
239
+
240
+ # run_papers_staged expects (name, tex_path, paper_config, lint_config) tuples
241
+ staged_input = [(name, tex_path, pc, config) for name, tex_path, pc in runnable]
242
+
243
+ try:
244
+ results = asyncio.run(
245
+ run_papers_staged(staged_input, fresh=fresh, concurrency=concurrency)
246
+ )
247
+ except Exception as e:
248
+ logger.error(f"Batch-staged pipeline failed: {e}")
249
+ return 1
250
+
251
+ # Format output for each paper, in original order
252
+ result_map = {r.paper_name: r for r in results}
253
+ for name, tex_path, _pc in runnable:
254
+ sr = result_map.get(name)
255
+ print(f"\n{'=' * 60}")
256
+ print(f"{name} ({tex_path.name})")
257
+ print(f"{'=' * 60}")
258
+ if not sr or sr.error:
259
+ err = sr.error if sr else "missing from batch results"
260
+ logger.error(f"[{name}] Pipeline failed: {err}")
261
+ all_ok = False
262
+ continue
263
+
264
+ label = f"{name} ({tex_path.name})" if name != tex_path.stem else tex_path.name
265
+ format_findings(sr.findings, label, fmt=fmt, color=config.color)
266
+ _print_integrity_summary(name, sr.findings, config)
267
+
268
+ if any(f.level == "error" for f in sr.findings):
269
+ all_ok = False
270
+
271
+ return 0 if all_ok else 1
272
+
273
+
274
+ def _print_integrity_summary(
275
+ paper_name: str,
276
+ findings: list[Finding],
277
+ config: LintConfig,
278
+ ) -> None:
279
+ """Compute integrity, save report, print summary."""
280
+ import json
281
+
282
+ from sciwrite_lint.scoring.scilint_score import (
283
+ _aggregate_ref_claims,
284
+ _score_reference,
285
+ compute_integrity,
286
+ )
287
+
288
+ output_dir = config.results_dir
289
+
290
+ # Load claims, ref internal scores, and metadata from workspace.db
291
+ ws = config.paper_workspace(paper_name)
292
+ claims: list[dict] = []
293
+ ref_internal_scores: dict[str, float] | None = None
294
+ metadata_map = None
295
+
296
+ if ws.root.exists():
297
+ from sciwrite_lint.references.metadata import load_all_metadata
298
+ from sciwrite_lint.references.workspace_db import (
299
+ get_db,
300
+ load_all_ref_internal_scores,
301
+ load_claim_results,
302
+ )
303
+
304
+ with get_db(ws.root) as conn:
305
+ claims = load_claim_results(conn)
306
+ scores = load_all_ref_internal_scores(conn)
307
+ if scores:
308
+ ref_internal_scores = scores
309
+
310
+ metadata_map = load_all_metadata(ws.root)
311
+
312
+ findings_dicts = [f.model_dump() for f in findings]
313
+ result = compute_integrity(
314
+ findings_dicts,
315
+ claims,
316
+ metadata_map=metadata_map,
317
+ ref_internal_scores=ref_internal_scores,
318
+ )
319
+
320
+ # Build per-reference detail for report (matches target format)
321
+ by_ref = _aggregate_ref_claims(claims)
322
+ ref_details: list[dict] = []
323
+ for key, ref_claims in sorted(by_ref.items()):
324
+ rs = _score_reference(ref_claims)
325
+ reliability_score = result.reference_reliability.get(key)
326
+
327
+ # Build reliability breakdown
328
+ reliability: dict[str, Any] = {}
329
+ if metadata_map and key in metadata_map:
330
+ meta = metadata_map[key]
331
+ reliability["score"] = (
332
+ round(reliability_score, 4) if reliability_score is not None else None
333
+ )
334
+ reliability["tier"] = meta.access.get("tier", "")
335
+ reliability["retracted"] = bool(meta.canonical.get("retracted"))
336
+ reliability["metadata_mismatches"] = meta.mismatches
337
+ consistency = ref_internal_scores.get(key) if ref_internal_scores else None
338
+ reliability["consistency"] = (
339
+ round(consistency, 4) if consistency is not None else None
340
+ )
341
+ elif reliability_score is not None:
342
+ reliability["score"] = round(reliability_score, 4)
343
+
344
+ # Compute per-ref SciLint Score (reliability only, contribution=1.0)
345
+ ref_scilint = (
346
+ round(reliability_score, 4) if reliability_score is not None else None
347
+ )
348
+
349
+ # Build signals list
350
+ signals: list[str] = []
351
+ if metadata_map and key in metadata_map:
352
+ meta = metadata_map[key]
353
+ if meta.access.get("tier") == "T3":
354
+ signals.append("not found in APIs")
355
+ if meta.canonical.get("retracted"):
356
+ signals.append("RETRACTED")
357
+ signals.extend(meta.mismatches)
358
+ if rs.verdict == "NOT_SUPPORTED":
359
+ signals.append("claim not supported")
360
+ elif rs.verdict == "PARTIALLY_SUPPORTS":
361
+ signals.append("claim partially supported")
362
+
363
+ detail: dict[str, Any] = {
364
+ "key": key,
365
+ "scilint_score": ref_scilint,
366
+ "verdict": rs.verdict,
367
+ "purpose": {
368
+ "role": rs.purpose,
369
+ "weight": round(rs.weight, 2),
370
+ },
371
+ "reliability": reliability if reliability else None,
372
+ "signals": signals,
373
+ }
374
+ ref_details.append(detail)
375
+
376
+ # Save report
377
+ report = {
378
+ "paper": paper_name,
379
+ "scilint_score": round(
380
+ result.internal_consistency * result.referencing_quality, 4
381
+ ),
382
+ "internal_consistency": round(result.internal_consistency, 4),
383
+ "referencing_quality": round(result.referencing_quality, 4),
384
+ "contribution": None,
385
+ "total_findings": len(findings_dicts),
386
+ "errors": sum(1 for f in findings_dicts if f.get("level") == "error"),
387
+ "warnings": sum(1 for f in findings_dicts if f.get("level") == "warning"),
388
+ "references": ref_details,
389
+ "findings": findings_dicts,
390
+ }
391
+
392
+ output_dir.mkdir(parents=True, exist_ok=True)
393
+ report_path = output_dir / f"check_{paper_name}.json"
394
+ report_path.write_text(
395
+ json.dumps(report, indent=2, ensure_ascii=False) + "\n",
396
+ encoding="utf-8",
397
+ )
398
+ logger.info(f"Check report saved to {report_path}")
399
+
400
+ # Print summary
401
+ scilint = result.internal_consistency * result.referencing_quality
402
+ print(f"\n SciLint Score: {scilint:.2f}")
403
+ print(f" Internal Consistency: {result.internal_consistency:.2f}")
404
+ print(f" Referencing Quality: {result.referencing_quality:.2f}")
405
+ print(" (Run 'sciwrite-lint contributions' to add contribution axes)")
406
+
407
+
408
+ def run_check_pdf(pdf_path: Path, config: LintConfig, fmt: str) -> int:
409
+ """Run checks on a PDF file. Requires GROBID."""
410
+ from sciwrite_lint.pipeline import (
411
+ build_pdf_context,
412
+ run_llm_checks_batched as pipeline_llm_batched,
413
+ run_text_checks,
414
+ )
415
+
416
+ if not pdf_path.exists():
417
+ logger.error(f"Error: {pdf_path} not found")
418
+ return 2
419
+
420
+ async def _do_check() -> list[Finding]:
421
+ await build_pdf_context(pdf_path, config)
422
+ findings = run_text_checks(pdf_path, config)
423
+ findings.extend(await pipeline_llm_batched(pdf_path, config))
424
+ return findings
425
+
426
+ findings = asyncio.run(_do_check())
427
+
428
+ label = pdf_path.name
429
+ print()
430
+ format_findings(findings, label, fmt=fmt, color=config.color)
431
+
432
+ return 0 if not any(f.level == "error" for f in findings) else 1
433
+
434
+
435
+ def run_check_quick(
436
+ args: argparse.Namespace,
437
+ config: LintConfig,
438
+ fmt: str,
439
+ ) -> int:
440
+ """Quick mode: text + LLM rules only, no verify/fetch/parse/claims."""
441
+ from sciwrite_lint.pipeline import (
442
+ run_llm_checks_batched as pipeline_llm_batched,
443
+ run_text_checks,
444
+ )
445
+
446
+ papers = _resolve_helpers(args, config)
447
+ if not papers:
448
+ return 2
449
+
450
+ all_ok = True
451
+ for name, tex_path in papers:
452
+ if not tex_path.exists():
453
+ logger.error(f"Error: {tex_path} not found")
454
+ all_ok = False
455
+ continue
456
+
457
+ findings = run_text_checks(tex_path, config)
458
+ findings.extend(asyncio.run(pipeline_llm_batched(tex_path, config)))
459
+
460
+ label = f"{name} ({tex_path.name})" if name != tex_path.stem else tex_path.name
461
+ format_findings(findings, label, fmt=fmt, color=config.color)
462
+
463
+ if any(f.level == "error" for f in findings):
464
+ all_ok = False
465
+
466
+ return 0 if all_ok else 1
467
+
468
+
469
+ def run_checks_list(args: argparse.Namespace) -> int:
470
+ """List all registered checks."""
471
+ from sciwrite_lint.checks.registry import ensure_checks_loaded, list_checks
472
+
473
+ ensure_checks_loaded()
474
+ all_checks = list_checks()
475
+
476
+ for meta in all_checks:
477
+ print(f" {meta.id:<32} [{meta.category}] {meta.description}")
478
+
479
+ print(f"\n {len(all_checks)} checks registered.")
480
+ return 0
@@ -0,0 +1,229 @@
1
+ """CLI handlers for config management (polite email, API keys)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import re
7
+ from pathlib import Path
8
+ from typing import TYPE_CHECKING
9
+
10
+ from loguru import logger
11
+
12
+ if TYPE_CHECKING:
13
+ from sciwrite_lint.config import LintConfig
14
+
15
+
16
+ # Services that accept API keys, stored in ~/.sciwrite-lint/<filename>
17
+ _API_KEY_SERVICES: dict[str, dict[str, str]] = {
18
+ "semantic-scholar": {
19
+ "file": "s2_api_key",
20
+ "description": "Semantic Scholar (1 → 100 req/s)",
21
+ "url": "https://www.semanticscholar.org/product/api#api-key",
22
+ },
23
+ "ncbi": {
24
+ "file": "ncbi_api_key",
25
+ "description": "NCBI / PubMed Central (3 → 10 req/s)",
26
+ "url": "https://www.ncbi.nlm.nih.gov/account/settings/",
27
+ },
28
+ "core": {
29
+ "file": "core_api_key",
30
+ "description": "CORE (institutional repository access)",
31
+ "url": "https://core.ac.uk/services/api",
32
+ },
33
+ }
34
+
35
+ _KEY_DIR = Path.home() / ".sciwrite-lint"
36
+
37
+
38
+ def check_api_config(config: "LintConfig", needs_email: bool = False) -> list[str]:
39
+ """Validate API configuration. Returns list of errors (hard stops).
40
+
41
+ Also logs warnings for missing optional API keys.
42
+
43
+ Args:
44
+ config: Resolved LintConfig.
45
+ needs_email: If True, missing polite_email is an error (not just a warning).
46
+ """
47
+ errors: list[str] = []
48
+
49
+ # polite_email: required for Unpaywall + Retraction Watch
50
+ if not config.polite_email:
51
+ if needs_email:
52
+ errors.append(
53
+ "polite_email not set — Unpaywall and Retraction Watch will not work. "
54
+ "Set with: sciwrite-lint config set-email you@example.com"
55
+ )
56
+ else:
57
+ logger.warning(
58
+ "polite_email not set — Unpaywall and Retraction Watch disabled. "
59
+ "Set with: sciwrite-lint config set-email you@example.com"
60
+ )
61
+
62
+ # Optional API keys: warn about rate limit benefits
63
+ missing_keys: list[str] = []
64
+ for service, info in _API_KEY_SERVICES.items():
65
+ if not _read_key(info["file"]):
66
+ missing_keys.append(f"{service} ({info['description']})")
67
+
68
+ if missing_keys:
69
+ logger.info(
70
+ "Optional API keys not configured (slower rate limits): {}. "
71
+ "See: sciwrite-lint config show",
72
+ ", ".join(missing_keys),
73
+ )
74
+
75
+ return errors
76
+
77
+
78
+ def _read_key(filename: str) -> str | None:
79
+ """Read an API key from ~/.sciwrite-lint/{filename}."""
80
+ path = _KEY_DIR / filename
81
+ if path.exists():
82
+ val = path.read_text().strip()
83
+ return val or None
84
+ return None
85
+
86
+
87
+ def _mask(value: str) -> str:
88
+ """Mask all but last 4 characters of a secret."""
89
+ if len(value) <= 4:
90
+ return "****"
91
+ return "*" * (len(value) - 4) + value[-4:]
92
+
93
+
94
+ def run_config(args: argparse.Namespace) -> int:
95
+ """Dispatch config subcommands."""
96
+ action = getattr(args, "config_action", None)
97
+ if action == "show":
98
+ return _show(args)
99
+ if action == "set-email":
100
+ return _set_email(args)
101
+ if action == "set-key":
102
+ return _set_key(args)
103
+ if action == "remove-key":
104
+ return _remove_key(args)
105
+ # No subcommand — print help (handled in __main__.py)
106
+ return 0
107
+
108
+
109
+ def _show(args: argparse.Namespace) -> int:
110
+ """Show current polite email and API key status."""
111
+ from sciwrite_lint.__main__ import _load_config
112
+
113
+ config = _load_config(args)
114
+
115
+ print("Polite email and API key configuration\n")
116
+
117
+ # Polite email
118
+ email = config.polite_email
119
+ if email:
120
+ print(f" polite email: {email}")
121
+ print(" → CrossRef polite pool, Unpaywall, Retraction Watch")
122
+ else:
123
+ print(" polite email: (not set)")
124
+ print(" ⚠ CrossRef: slower rate limits without polite pool")
125
+ print(" ⚠ Unpaywall: will not work without an email")
126
+ print(" ⚠ Retraction Watch: will not work without an email")
127
+ print(" Set with: sciwrite-lint config set-email you@example.com")
128
+ print()
129
+
130
+ # API keys
131
+ print(" API keys (stored in ~/.sciwrite-lint/):\n")
132
+ for service, info in _API_KEY_SERVICES.items():
133
+ key = _read_key(info["file"])
134
+ if key:
135
+ print(f" {service:<20} {_mask(key)} ({info['description']})")
136
+ else:
137
+ print(f" {service:<20} (not set) — {info['description']}")
138
+ print(f" Get one at: {info['url']}")
139
+
140
+ print()
141
+ return 0
142
+
143
+
144
+ def _set_email(args: argparse.Namespace) -> int:
145
+ """Set polite email in .sciwrite-lint.toml."""
146
+ email = args.email.strip()
147
+ if not re.match(r"^[^@\s]+@[^@\s]+\.[^@\s]+$", email):
148
+ logger.error("Invalid email address: {}", email)
149
+ return 1
150
+
151
+ from sciwrite_lint.config import find_config
152
+
153
+ toml_path = find_config()
154
+ if toml_path is None:
155
+ logger.error("No .sciwrite-lint.toml found. Run `sciwrite-lint init` first.")
156
+ return 1
157
+
158
+ content = toml_path.read_text()
159
+
160
+ # Case 1: [api] section exists with polite_email (commented or not)
161
+ if re.search(r"^#?\s*polite_email\s*=", content, re.MULTILINE):
162
+ content = re.sub(
163
+ r"^#?\s*polite_email\s*=.*$",
164
+ f'polite_email = "{email}"',
165
+ content,
166
+ count=1,
167
+ flags=re.MULTILINE,
168
+ )
169
+ # Case 2: [api] section exists but no polite_email line
170
+ elif re.search(r"^\[api\]", content, re.MULTILINE):
171
+ content = re.sub(
172
+ r"^(\[api\].*)$",
173
+ rf'\1\npolite_email = "{email}"',
174
+ content,
175
+ count=1,
176
+ flags=re.MULTILINE,
177
+ )
178
+ # Case 3: no [api] section at all
179
+ else:
180
+ content = content.rstrip() + f'\n\n[api]\npolite_email = "{email}"\n'
181
+
182
+ toml_path.write_text(content)
183
+ print(f"Set polite email to: {email}")
184
+ print(" → CrossRef polite pool (faster rate limits)")
185
+ print(" → Unpaywall (full-text access)")
186
+ print(" → Retraction Watch (retraction database)")
187
+ return 0
188
+
189
+
190
+ def _set_key(args: argparse.Namespace) -> int:
191
+ """Save an API key to ~/.sciwrite-lint/<service>."""
192
+ service = args.service
193
+ if service not in _API_KEY_SERVICES:
194
+ names = ", ".join(_API_KEY_SERVICES)
195
+ logger.error("Unknown service '{}'. Available: {}", service, names)
196
+ return 1
197
+
198
+ key = args.key.strip()
199
+ if not key:
200
+ logger.error("API key cannot be empty")
201
+ return 1
202
+
203
+ info = _API_KEY_SERVICES[service]
204
+ _KEY_DIR.mkdir(exist_ok=True)
205
+ key_path = _KEY_DIR / info["file"]
206
+ key_path.write_text(key + "\n")
207
+ key_path.chmod(0o600)
208
+
209
+ print(f"Saved {service} API key to {key_path}")
210
+ print(f" → {info['description']}")
211
+ return 0
212
+
213
+
214
+ def _remove_key(args: argparse.Namespace) -> int:
215
+ """Remove a stored API key."""
216
+ service = args.service
217
+ if service not in _API_KEY_SERVICES:
218
+ names = ", ".join(_API_KEY_SERVICES)
219
+ logger.error("Unknown service '{}'. Available: {}", service, names)
220
+ return 1
221
+
222
+ info = _API_KEY_SERVICES[service]
223
+ key_path = _KEY_DIR / info["file"]
224
+ if key_path.exists():
225
+ key_path.unlink()
226
+ print(f"Removed {service} API key ({key_path})")
227
+ else:
228
+ print(f"No {service} API key found (nothing to remove)")
229
+ return 0