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.
- sciwrite_lint/__init__.py +3 -0
- sciwrite_lint/__main__.py +527 -0
- sciwrite_lint/_network.py +195 -0
- sciwrite_lint/api.py +1484 -0
- sciwrite_lint/checks/__init__.py +1 -0
- sciwrite_lint/checks/_section_utils.py +111 -0
- sciwrite_lint/checks/cite_purpose.py +122 -0
- sciwrite_lint/checks/claim_support.py +96 -0
- sciwrite_lint/checks/cross_section_consistency.py +185 -0
- sciwrite_lint/checks/dangling_cite.py +93 -0
- sciwrite_lint/checks/dangling_ref.py +116 -0
- sciwrite_lint/checks/full_paper_consistency.py +604 -0
- sciwrite_lint/checks/ref_internal_checks.py +919 -0
- sciwrite_lint/checks/reference_accuracy.py +277 -0
- sciwrite_lint/checks/reference_exists.py +119 -0
- sciwrite_lint/checks/reference_unreliable.py +244 -0
- sciwrite_lint/checks/registry.py +136 -0
- sciwrite_lint/checks/retracted_cite.py +96 -0
- sciwrite_lint/checks/structure_promises.py +115 -0
- sciwrite_lint/checks/unreferenced_figure.py +70 -0
- sciwrite_lint/claims.py +330 -0
- sciwrite_lint/claude_backend.py +94 -0
- sciwrite_lint/claude_cli.py +405 -0
- sciwrite_lint/cli/__init__.py +1 -0
- sciwrite_lint/cli/check.py +480 -0
- sciwrite_lint/cli/config.py +229 -0
- sciwrite_lint/cli/fetch.py +250 -0
- sciwrite_lint/cli/misc.py +1202 -0
- sciwrite_lint/cli/rank.py +470 -0
- sciwrite_lint/cli/verify.py +437 -0
- sciwrite_lint/config.py +646 -0
- sciwrite_lint/cross_paper.py +174 -0
- sciwrite_lint/eval_claims.py +1196 -0
- sciwrite_lint/fulltext.py +851 -0
- sciwrite_lint/llm_utils.py +386 -0
- sciwrite_lint/local_pdfs.py +122 -0
- sciwrite_lint/manuscript_store.py +674 -0
- sciwrite_lint/models.py +139 -0
- sciwrite_lint/pdf/__init__.py +1 -0
- sciwrite_lint/pdf/grobid.py +785 -0
- sciwrite_lint/pdf/pdf_download.py +258 -0
- sciwrite_lint/pipeline.py +2694 -0
- sciwrite_lint/prompt_safety.py +30 -0
- sciwrite_lint/rate_limiter.py +227 -0
- sciwrite_lint/references/__init__.py +1 -0
- sciwrite_lint/references/citations.py +715 -0
- sciwrite_lint/references/crossref.py +282 -0
- sciwrite_lint/references/embedding_store.py +380 -0
- sciwrite_lint/references/matching.py +273 -0
- sciwrite_lint/references/metadata.py +273 -0
- sciwrite_lint/references/reference_store.py +823 -0
- sciwrite_lint/references/retraction_watch.py +178 -0
- sciwrite_lint/references/workspace_db.py +1390 -0
- sciwrite_lint/report.py +163 -0
- sciwrite_lint/schemas.py +260 -0
- sciwrite_lint/scoring/__init__.py +1 -0
- sciwrite_lint/scoring/chain.py +716 -0
- sciwrite_lint/scoring/contribution.py +322 -0
- sciwrite_lint/scoring/scilint_score.py +611 -0
- sciwrite_lint/tex_parser.py +248 -0
- sciwrite_lint/usage.py +594 -0
- sciwrite_lint/vision/__init__.py +1 -0
- sciwrite_lint/vision/cache.py +140 -0
- sciwrite_lint/vision/describe.py +311 -0
- sciwrite_lint/vision/image_extraction.py +491 -0
- sciwrite_lint/vision/pipeline.py +207 -0
- sciwrite_lint/vllm/__init__.py +1 -0
- sciwrite_lint/vllm/metrics.py +157 -0
- sciwrite_lint/vllm/vllm_server.py +445 -0
- sciwrite_lint/web.py +369 -0
- sciwrite_lint-0.2.0.dist-info/METADATA +268 -0
- sciwrite_lint-0.2.0.dist-info/RECORD +76 -0
- sciwrite_lint-0.2.0.dist-info/WHEEL +5 -0
- sciwrite_lint-0.2.0.dist-info/entry_points.txt +2 -0
- sciwrite_lint-0.2.0.dist-info/licenses/LICENSE +21 -0
- 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]
|