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,195 @@
1
+ """Shared network security utilities.
2
+
3
+ Provides:
4
+ - SSRF-safe httpx transport that validates resolved IPs via ``ipaddress.is_global``
5
+ - Identifier format validators (DOI, arXiv, PMCID, ISBN)
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import ipaddress
11
+ import re
12
+ import socket
13
+
14
+ import httpx
15
+ from loguru import logger
16
+
17
+ # ---------------------------------------------------------------------------
18
+ # Identifier validators
19
+ # ---------------------------------------------------------------------------
20
+
21
+ DOI_RE = re.compile(r"^10\.\d{4,}/\S+$")
22
+ ARXIV_RE = re.compile(r"^(\d{4}\.\d{4,})(v\d+)?$")
23
+ PMCID_RE = re.compile(r"^PMC\d+$")
24
+ PMID_RE = re.compile(r"^\d{1,9}$")
25
+ ISBN_RE = re.compile(r"^[\d\-]{10,17}$")
26
+ LCCN_RE = re.compile(r"^[\da-z\-]{1,12}$", re.IGNORECASE)
27
+
28
+
29
+ def clean_and_validate_doi(doi: str) -> str | None:
30
+ """Strip URL prefixes and validate DOI format. Returns cleaned DOI or None."""
31
+ clean = doi.removeprefix("https://doi.org/").removeprefix("http://doi.org/").strip()
32
+ if not DOI_RE.match(clean):
33
+ logger.debug("Invalid DOI format, skipping: {}", clean[:80])
34
+ return None
35
+ return clean
36
+
37
+
38
+ def is_valid_arxiv_id(arxiv_id: str) -> bool:
39
+ """Check if string matches arXiv ID format (YYMM.NNNNN)."""
40
+ return bool(ARXIV_RE.match(arxiv_id.strip()))
41
+
42
+
43
+ def is_valid_pmid(pmid: str) -> bool:
44
+ """Check if string looks like a PubMed ID (1-9 digits)."""
45
+ return bool(PMID_RE.match(pmid.strip()))
46
+
47
+
48
+ def is_valid_pmcid(pmcid: str) -> bool:
49
+ """Check if string matches PMC ID format (PMC followed by digits)."""
50
+ return bool(PMCID_RE.match(pmcid.strip()))
51
+
52
+
53
+ def is_valid_isbn(isbn: str) -> bool:
54
+ """Check if string looks like an ISBN (digits and hyphens, 10-17 chars)."""
55
+ return bool(ISBN_RE.match(isbn.strip()))
56
+
57
+
58
+ def is_valid_lccn(lccn: str) -> bool:
59
+ """Check if string looks like a Library of Congress Control Number."""
60
+ return bool(LCCN_RE.match(lccn.strip()))
61
+
62
+
63
+ # ---------------------------------------------------------------------------
64
+ # SSRF-safe IP validation
65
+ # ---------------------------------------------------------------------------
66
+
67
+
68
+ def is_ip_safe(ip_str: str) -> bool:
69
+ """Return True if an IP address is globally routable (not private/reserved)."""
70
+ try:
71
+ return ipaddress.ip_address(ip_str).is_global
72
+ except ValueError:
73
+ return False
74
+
75
+
76
+ def is_hostname_safe(hostname: str) -> bool:
77
+ """Resolve hostname and check that ALL returned IPs are globally routable.
78
+
79
+ Returns False if the hostname is unresolvable or any IP is non-global.
80
+ """
81
+ # Literal IP — check directly
82
+ try:
83
+ addr = ipaddress.ip_address(hostname.strip("[]"))
84
+ return addr.is_global
85
+ except ValueError:
86
+ pass
87
+
88
+ try:
89
+ infos = socket.getaddrinfo(hostname, None, type=socket.SOCK_STREAM)
90
+ except socket.gaierror:
91
+ return False
92
+
93
+ if not infos:
94
+ return False
95
+
96
+ return all(ipaddress.ip_address(info[4][0]).is_global for info in infos)
97
+
98
+
99
+ # ---------------------------------------------------------------------------
100
+ # SSRF-safe httpx transport
101
+ # ---------------------------------------------------------------------------
102
+
103
+
104
+ class SSRFSafeTransport(httpx.AsyncHTTPTransport):
105
+ """httpx transport wrapper that blocks requests to non-global IPs.
106
+
107
+ Validates the resolved IP address of the target hostname before
108
+ connecting. This protects against SSRF via HTTP 3xx redirects —
109
+ httpx resolves the redirect target through this transport, so
110
+ redirects to private/reserved IPs are blocked.
111
+
112
+ Usage::
113
+
114
+ transport = SSRFSafeTransport()
115
+ async with httpx.AsyncClient(transport=transport, ...) as client:
116
+ resp = await client.get(url)
117
+ """
118
+
119
+ async def handle_async_request(self, request: httpx.Request) -> httpx.Response:
120
+ hostname = request.url.host or ""
121
+ if not hostname:
122
+ raise httpx.RequestError("Empty hostname", request=request)
123
+
124
+ if not is_hostname_safe(hostname):
125
+ raise httpx.RequestError(
126
+ f"SSRF blocked: {hostname} resolves to non-public IP",
127
+ request=request,
128
+ )
129
+
130
+ return await super().handle_async_request(request)
131
+
132
+
133
+ class ResponseTooLarge(Exception):
134
+ """Raised when a streaming download exceeds the byte limit."""
135
+
136
+
137
+ async def stream_with_limit(
138
+ client: httpx.AsyncClient,
139
+ url: str,
140
+ max_bytes: int,
141
+ **kwargs: object,
142
+ ) -> httpx.Response:
143
+ """Stream a GET request with gzip-safe size enforcement.
144
+
145
+ Skips the Content-Length pre-check when Content-Encoding is present
146
+ because the header reports compressed wire size, not the decompressed
147
+ size we actually read. Always enforces *max_bytes* on the
148
+ decompressed data stream via ``aiter_bytes()``.
149
+
150
+ Returns a synthetic ``httpx.Response`` with the full body populated.
151
+ Raises ``ResponseTooLarge`` if the limit is exceeded.
152
+ """
153
+ async with client.stream("GET", url, **kwargs) as resp: # type: ignore[arg-type]
154
+ # Non-success responses are small — read eagerly for diagnostics
155
+ if resp.status_code >= 400:
156
+ await resp.aread()
157
+ return resp
158
+
159
+ # Only trust Content-Length when no Content-Encoding
160
+ if not resp.headers.get("content-encoding"):
161
+ cl = resp.headers.get("content-length")
162
+ if cl and int(cl) > max_bytes:
163
+ raise ResponseTooLarge(
164
+ f"Response too large ({cl} bytes, limit {max_bytes})"
165
+ )
166
+
167
+ # Byte-count guard on decompressed data — always enforced
168
+ chunks: list[bytes] = []
169
+ total = 0
170
+ async for chunk in resp.aiter_bytes():
171
+ total += len(chunk)
172
+ if total > max_bytes:
173
+ raise ResponseTooLarge(
174
+ f"Response exceeded {max_bytes} bytes during download"
175
+ )
176
+ chunks.append(chunk)
177
+
178
+ body = b"".join(chunks)
179
+ return httpx.Response(
180
+ status_code=resp.status_code,
181
+ headers=resp.headers,
182
+ content=body,
183
+ request=resp.request,
184
+ )
185
+
186
+
187
+ def ssrf_safe_client(**kwargs: object) -> httpx.AsyncClient:
188
+ """Create an httpx.AsyncClient with SSRF protection.
189
+
190
+ All keyword arguments are forwarded to ``httpx.AsyncClient``.
191
+ ``follow_redirects=True`` is set by default.
192
+ """
193
+ kwargs.setdefault("follow_redirects", True) # type: ignore[arg-type]
194
+ kwargs.setdefault("transport", SSRFSafeTransport()) # type: ignore[arg-type]
195
+ return httpx.AsyncClient(**kwargs) # type: ignore[arg-type]