reqstorm 2.0.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.
- reqstorm/__init__.py +53 -0
- reqstorm/_client.py +560 -0
- reqstorm/_files.py +548 -0
- reqstorm/_legacy.py +95 -0
- reqstorm/_limits.py +73 -0
- reqstorm/_plan.py +60 -0
- reqstorm/_progress.py +82 -0
- reqstorm/_sync.py +135 -0
- reqstorm/py.typed +0 -0
- reqstorm-2.0.1.dist-info/METADATA +271 -0
- reqstorm-2.0.1.dist-info/RECORD +14 -0
- reqstorm-2.0.1.dist-info/WHEEL +5 -0
- reqstorm-2.0.1.dist-info/licenses/LICENSE +21 -0
- reqstorm-2.0.1.dist-info/top_level.txt +1 -0
reqstorm/_files.py
ADDED
|
@@ -0,0 +1,548 @@
|
|
|
1
|
+
"""Write results to a JSONL or CSV file, or to a database table, as they complete."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import concurrent.futures
|
|
7
|
+
import csv
|
|
8
|
+
import json as jsonlib
|
|
9
|
+
import os
|
|
10
|
+
import re
|
|
11
|
+
import sqlite3
|
|
12
|
+
import time
|
|
13
|
+
from dataclasses import dataclass, field
|
|
14
|
+
from typing import Any, Dict, Iterable, Iterator, List, Optional, Set, Tuple, Union
|
|
15
|
+
|
|
16
|
+
from ._client import Request, Result, _execute
|
|
17
|
+
from ._progress import Progress, ProgressTarget
|
|
18
|
+
|
|
19
|
+
__all__ = ["Summary", "fetch_to_db", "fetch_to_file"]
|
|
20
|
+
|
|
21
|
+
_FILE_FIELDS = [
|
|
22
|
+
"index", "method", "url", "status", "ok", "error", "attempts",
|
|
23
|
+
"elapsed", "final_url", "history", "headers", "body",
|
|
24
|
+
] # fmt: skip
|
|
25
|
+
_SQLITE_SUFFIXES = (".db", ".sqlite", ".sqlite3")
|
|
26
|
+
_TABLE_NAME = re.compile(r"[A-Za-z_][A-Za-z0-9_]*(\.[A-Za-z_][A-Za-z0-9_]*)?")
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@dataclass
|
|
30
|
+
class Summary:
|
|
31
|
+
"""What ``fetch_to_file`` or ``fetch_to_db`` did."""
|
|
32
|
+
|
|
33
|
+
target: str
|
|
34
|
+
total: int
|
|
35
|
+
ok: int
|
|
36
|
+
failed: int
|
|
37
|
+
skipped: int
|
|
38
|
+
elapsed: float
|
|
39
|
+
errors: List[Dict[str, Any]] = field(default_factory=list)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class _Sink:
|
|
43
|
+
"""Where records go. Methods are blocking; they are called from the event loop thread
|
|
44
|
+
unless ``offload`` is True, in which case writes run on a single helper thread."""
|
|
45
|
+
|
|
46
|
+
offload = False
|
|
47
|
+
|
|
48
|
+
def completed(self) -> Set[Tuple[str, str]]:
|
|
49
|
+
return set()
|
|
50
|
+
|
|
51
|
+
def write(self, records: List[Dict[str, Any]]) -> None:
|
|
52
|
+
raise NotImplementedError
|
|
53
|
+
|
|
54
|
+
def close(self) -> None:
|
|
55
|
+
pass
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
class _JsonlSink(_Sink):
|
|
59
|
+
def __init__(self, path: str, append: bool) -> None:
|
|
60
|
+
self.path = path
|
|
61
|
+
needs_newline = append and _missing_final_newline(path)
|
|
62
|
+
self.file = open(path, "a" if append else "w", encoding="utf-8") # noqa: SIM115
|
|
63
|
+
if needs_newline:
|
|
64
|
+
self.file.write("\n")
|
|
65
|
+
|
|
66
|
+
@staticmethod
|
|
67
|
+
def read_completed(path: str) -> Set[Tuple[str, str]]:
|
|
68
|
+
done: Set[Tuple[str, str]] = set()
|
|
69
|
+
if not os.path.exists(path):
|
|
70
|
+
return done
|
|
71
|
+
with open(path, encoding="utf-8") as file:
|
|
72
|
+
for line in file:
|
|
73
|
+
try:
|
|
74
|
+
record = jsonlib.loads(line)
|
|
75
|
+
except ValueError: # a line cut short by an interrupted run
|
|
76
|
+
continue
|
|
77
|
+
if isinstance(record, dict) and record.get("ok"):
|
|
78
|
+
done.add((record.get("method", "GET"), record.get("url", "")))
|
|
79
|
+
return done
|
|
80
|
+
|
|
81
|
+
def write(self, records: List[Dict[str, Any]]) -> None:
|
|
82
|
+
for record in records:
|
|
83
|
+
self.file.write(jsonlib.dumps(record, ensure_ascii=False) + "\n")
|
|
84
|
+
self.file.flush()
|
|
85
|
+
|
|
86
|
+
def close(self) -> None:
|
|
87
|
+
self.file.close()
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
class _CsvSink(_Sink):
|
|
91
|
+
def __init__(self, path: str, append: bool, fields: List[str]) -> None:
|
|
92
|
+
write_header = not append or not os.path.exists(path) or os.path.getsize(path) == 0
|
|
93
|
+
needs_newline = append and not write_header and _missing_final_newline(path)
|
|
94
|
+
self.file = open(path, "a" if append else "w", newline="", encoding="utf-8") # noqa: SIM115
|
|
95
|
+
if needs_newline:
|
|
96
|
+
self.file.write("\r\n")
|
|
97
|
+
self.writer = csv.DictWriter(self.file, fieldnames=fields, extrasaction="ignore")
|
|
98
|
+
if write_header:
|
|
99
|
+
self.writer.writeheader()
|
|
100
|
+
|
|
101
|
+
@staticmethod
|
|
102
|
+
def read_completed(path: str) -> Set[Tuple[str, str]]:
|
|
103
|
+
done: Set[Tuple[str, str]] = set()
|
|
104
|
+
if not os.path.exists(path):
|
|
105
|
+
return done
|
|
106
|
+
with open(path, newline="", encoding="utf-8") as file:
|
|
107
|
+
for row in csv.DictReader(file):
|
|
108
|
+
if row.get("ok") == "True":
|
|
109
|
+
done.add((row.get("method") or "GET", row.get("url") or ""))
|
|
110
|
+
return done
|
|
111
|
+
|
|
112
|
+
def write(self, records: List[Dict[str, Any]]) -> None:
|
|
113
|
+
for record in records:
|
|
114
|
+
row = dict(record)
|
|
115
|
+
for key in ("history", "headers"):
|
|
116
|
+
if key in row:
|
|
117
|
+
row[key] = jsonlib.dumps(row[key], ensure_ascii=False)
|
|
118
|
+
self.writer.writerow(row)
|
|
119
|
+
self.file.flush()
|
|
120
|
+
|
|
121
|
+
def close(self) -> None:
|
|
122
|
+
self.file.close()
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
@dataclass(frozen=True)
|
|
126
|
+
class _Dialect:
|
|
127
|
+
name: str
|
|
128
|
+
placeholder: str
|
|
129
|
+
id_column: str
|
|
130
|
+
integer: str
|
|
131
|
+
real: str
|
|
132
|
+
boolean: str
|
|
133
|
+
text: str
|
|
134
|
+
long_text: str
|
|
135
|
+
blob: str
|
|
136
|
+
json: str
|
|
137
|
+
json_placeholder: str
|
|
138
|
+
created_at: str
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
_SQLITE = _Dialect(
|
|
142
|
+
"sqlite", "?", "id INTEGER PRIMARY KEY AUTOINCREMENT", "INTEGER", "REAL", "INTEGER", "TEXT", "TEXT",
|
|
143
|
+
"BLOB", "TEXT", "?", "created_at TEXT DEFAULT CURRENT_TIMESTAMP",
|
|
144
|
+
) # fmt: skip
|
|
145
|
+
_POSTGRES = _Dialect(
|
|
146
|
+
"postgresql", "%s", "id BIGSERIAL PRIMARY KEY", "INTEGER", "DOUBLE PRECISION", "BOOLEAN", "TEXT", "TEXT",
|
|
147
|
+
"BYTEA", "JSONB", "%s::jsonb", "created_at TIMESTAMPTZ DEFAULT now()",
|
|
148
|
+
) # fmt: skip
|
|
149
|
+
_MYSQL = _Dialect(
|
|
150
|
+
"mysql", "%s", "id BIGINT AUTO_INCREMENT PRIMARY KEY", "INT", "DOUBLE", "BOOLEAN", "VARCHAR(2048)",
|
|
151
|
+
"LONGTEXT", "LONGBLOB", "JSON", "%s", "created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP",
|
|
152
|
+
) # fmt: skip
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def _dialect_for(connection: Any) -> _Dialect:
|
|
156
|
+
module = type(connection).__module__.split(".")[0]
|
|
157
|
+
if module == "sqlite3":
|
|
158
|
+
return _SQLITE
|
|
159
|
+
if module in ("psycopg", "psycopg2"):
|
|
160
|
+
return _POSTGRES
|
|
161
|
+
if module in ("pymysql", "MySQLdb", "mysql"):
|
|
162
|
+
return _MYSQL
|
|
163
|
+
raise TypeError(
|
|
164
|
+
f"unsupported database connection {type(connection).__module__}.{type(connection).__name__}; "
|
|
165
|
+
"use sqlite3, psycopg, psycopg2, PyMySQL, mysqlclient or mysql-connector-python"
|
|
166
|
+
)
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
class _DatabaseSink(_Sink):
|
|
170
|
+
"""Writes one row per result, with queryable columns and JSON for headers and history."""
|
|
171
|
+
|
|
172
|
+
def __init__(self, connection: Any, table: str, body: str, include_headers: bool, close: bool) -> None:
|
|
173
|
+
if not _TABLE_NAME.fullmatch(table):
|
|
174
|
+
raise ValueError(f"invalid table name {table!r}")
|
|
175
|
+
self.connection = connection
|
|
176
|
+
self.table = table
|
|
177
|
+
self.dialect = _dialect_for(connection)
|
|
178
|
+
self.body = body
|
|
179
|
+
self.include_headers = include_headers
|
|
180
|
+
self.close_connection = close
|
|
181
|
+
# sqlite3 connections may only be used from the thread that created them
|
|
182
|
+
self.offload = self.dialect is not _SQLITE
|
|
183
|
+
self.columns = [
|
|
184
|
+
"request_index", "method", "url", "status", "ok",
|
|
185
|
+
"error", "attempts", "elapsed", "final_url", "history",
|
|
186
|
+
] # fmt: skip
|
|
187
|
+
if include_headers:
|
|
188
|
+
self.columns.append("headers")
|
|
189
|
+
if body != "none":
|
|
190
|
+
self.columns.append("body")
|
|
191
|
+
self._create_table()
|
|
192
|
+
|
|
193
|
+
def _create_table(self) -> None:
|
|
194
|
+
d = self.dialect
|
|
195
|
+
body_type = d.blob if self.body == "bytes" else d.long_text
|
|
196
|
+
definitions = [
|
|
197
|
+
d.id_column,
|
|
198
|
+
f"request_index {d.integer}",
|
|
199
|
+
f"method {d.text} NOT NULL",
|
|
200
|
+
f"url {d.long_text if d is not _MYSQL else d.text} NOT NULL",
|
|
201
|
+
f"status {d.integer}",
|
|
202
|
+
f"ok {d.boolean} NOT NULL",
|
|
203
|
+
f"error {d.long_text}",
|
|
204
|
+
f"attempts {d.integer} NOT NULL",
|
|
205
|
+
f"elapsed {d.real}",
|
|
206
|
+
f"final_url {d.long_text}",
|
|
207
|
+
f"history {d.json}",
|
|
208
|
+
f"headers {d.json}",
|
|
209
|
+
f"body {body_type}",
|
|
210
|
+
d.created_at,
|
|
211
|
+
]
|
|
212
|
+
cursor = self.connection.cursor()
|
|
213
|
+
try:
|
|
214
|
+
cursor.execute(f"CREATE TABLE IF NOT EXISTS {self.table} ({', '.join(definitions)})")
|
|
215
|
+
finally:
|
|
216
|
+
cursor.close()
|
|
217
|
+
self.connection.commit()
|
|
218
|
+
|
|
219
|
+
def completed(self) -> Set[Tuple[str, str]]:
|
|
220
|
+
cursor = self.connection.cursor()
|
|
221
|
+
try:
|
|
222
|
+
cursor.execute(
|
|
223
|
+
f"SELECT method, url FROM {self.table} WHERE ok = {self.dialect.placeholder}", (True,)
|
|
224
|
+
)
|
|
225
|
+
done = {(str(method), str(url)) for method, url in cursor.fetchall()}
|
|
226
|
+
finally:
|
|
227
|
+
cursor.close()
|
|
228
|
+
self.connection.commit()
|
|
229
|
+
return done
|
|
230
|
+
|
|
231
|
+
def write(self, records: List[Dict[str, Any]]) -> None:
|
|
232
|
+
d = self.dialect
|
|
233
|
+
placeholders = [
|
|
234
|
+
d.json_placeholder if c in ("history", "headers") else d.placeholder for c in self.columns
|
|
235
|
+
]
|
|
236
|
+
sql = f"INSERT INTO {self.table} ({', '.join(self.columns)}) VALUES ({', '.join(placeholders)})"
|
|
237
|
+
rows = []
|
|
238
|
+
for record in records:
|
|
239
|
+
values = {**record, "request_index": record["index"], "history": jsonlib.dumps(record["history"])}
|
|
240
|
+
if "headers" in record:
|
|
241
|
+
values["headers"] = jsonlib.dumps(record["headers"])
|
|
242
|
+
rows.append(tuple(values.get(column) for column in self.columns))
|
|
243
|
+
cursor = self.connection.cursor()
|
|
244
|
+
try:
|
|
245
|
+
cursor.executemany(sql, rows)
|
|
246
|
+
finally:
|
|
247
|
+
cursor.close()
|
|
248
|
+
self.connection.commit()
|
|
249
|
+
|
|
250
|
+
def close(self) -> None:
|
|
251
|
+
if self.close_connection:
|
|
252
|
+
self.connection.close()
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
def _missing_final_newline(path: str) -> bool:
|
|
256
|
+
if not os.path.exists(path) or os.path.getsize(path) == 0:
|
|
257
|
+
return False
|
|
258
|
+
with open(path, "rb") as file:
|
|
259
|
+
file.seek(-1, os.SEEK_END)
|
|
260
|
+
return file.read(1) != b"\n"
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
class _Writer:
|
|
264
|
+
"""Batches records, keeps them in input order if asked, and runs blocking writes off the loop."""
|
|
265
|
+
|
|
266
|
+
def __init__(self, sink: _Sink, ordered: bool, batch_size: int, flush_interval: float = 1.0) -> None:
|
|
267
|
+
self.sink = sink
|
|
268
|
+
self.ordered = ordered
|
|
269
|
+
self.batch_size = batch_size
|
|
270
|
+
self.flush_interval = flush_interval
|
|
271
|
+
self.batch: List[Dict[str, Any]] = []
|
|
272
|
+
self.waiting: Dict[int, Dict[str, Any]] = {}
|
|
273
|
+
self.next_index = 0
|
|
274
|
+
self.last_flush = time.monotonic()
|
|
275
|
+
self.executor = concurrent.futures.ThreadPoolExecutor(max_workers=1) if sink.offload else None
|
|
276
|
+
|
|
277
|
+
async def add(self, position: int, record: Dict[str, Any]) -> None:
|
|
278
|
+
if self.ordered:
|
|
279
|
+
self.waiting[position] = record
|
|
280
|
+
while self.next_index in self.waiting:
|
|
281
|
+
self.batch.append(self.waiting.pop(self.next_index))
|
|
282
|
+
self.next_index += 1
|
|
283
|
+
else:
|
|
284
|
+
self.batch.append(record)
|
|
285
|
+
if len(self.batch) >= self.batch_size or time.monotonic() - self.last_flush >= self.flush_interval:
|
|
286
|
+
await self.flush()
|
|
287
|
+
|
|
288
|
+
async def flush(self) -> None:
|
|
289
|
+
if not self.batch:
|
|
290
|
+
return
|
|
291
|
+
batch, self.batch = self.batch, []
|
|
292
|
+
self.last_flush = time.monotonic()
|
|
293
|
+
if self.executor is not None:
|
|
294
|
+
await asyncio.get_running_loop().run_in_executor(self.executor, self.sink.write, batch)
|
|
295
|
+
else:
|
|
296
|
+
self.sink.write(batch)
|
|
297
|
+
|
|
298
|
+
async def close(self) -> None:
|
|
299
|
+
try:
|
|
300
|
+
await self.flush()
|
|
301
|
+
finally:
|
|
302
|
+
if self.executor is not None:
|
|
303
|
+
await asyncio.get_running_loop().run_in_executor(self.executor, self.sink.close)
|
|
304
|
+
self.executor.shutdown()
|
|
305
|
+
else:
|
|
306
|
+
self.sink.close()
|
|
307
|
+
|
|
308
|
+
|
|
309
|
+
async def _run_to_sink(
|
|
310
|
+
urls: Iterable[Union[str, Request]],
|
|
311
|
+
make_sink: Any,
|
|
312
|
+
read_completed: Any,
|
|
313
|
+
target: str,
|
|
314
|
+
*,
|
|
315
|
+
body: str,
|
|
316
|
+
include_headers: bool,
|
|
317
|
+
resume: bool,
|
|
318
|
+
ordered: bool,
|
|
319
|
+
progress: ProgressTarget,
|
|
320
|
+
retry_rounds: int,
|
|
321
|
+
retry_round_delay: float,
|
|
322
|
+
batch_size: int,
|
|
323
|
+
options: Dict[str, Any],
|
|
324
|
+
) -> Summary:
|
|
325
|
+
if body not in ("text", "base64", "none", "bytes"):
|
|
326
|
+
raise ValueError("body must be 'text', 'base64', 'bytes' or 'none'")
|
|
327
|
+
if batch_size < 1:
|
|
328
|
+
raise ValueError("batch_size must be at least 1")
|
|
329
|
+
if "callback" in options:
|
|
330
|
+
raise TypeError(
|
|
331
|
+
"fetch_to_file() and fetch_to_db() do not accept `callback`; use fetch_all() or stream()"
|
|
332
|
+
)
|
|
333
|
+
default_method = str(options.get("method", "GET")).upper()
|
|
334
|
+
|
|
335
|
+
sink: _Sink = make_sink()
|
|
336
|
+
try:
|
|
337
|
+
done = (sink.completed() or read_completed()) if resume else set()
|
|
338
|
+
except BaseException:
|
|
339
|
+
sink.close()
|
|
340
|
+
raise
|
|
341
|
+
skipped = 0
|
|
342
|
+
positions: List[int] = [] # index in `urls` of each request actually sent
|
|
343
|
+
|
|
344
|
+
def pending() -> Iterator[Union[str, Request]]:
|
|
345
|
+
nonlocal skipped
|
|
346
|
+
for position, request in enumerate(urls):
|
|
347
|
+
url = request if isinstance(request, str) else request.url
|
|
348
|
+
method = (
|
|
349
|
+
default_method if isinstance(request, str) else (request.method or default_method).upper()
|
|
350
|
+
)
|
|
351
|
+
if (method, url) in done:
|
|
352
|
+
skipped += 1
|
|
353
|
+
continue
|
|
354
|
+
positions.append(position)
|
|
355
|
+
yield request
|
|
356
|
+
|
|
357
|
+
total: Optional[int] = None
|
|
358
|
+
try:
|
|
359
|
+
total = len(urls) # type: ignore[arg-type]
|
|
360
|
+
except TypeError:
|
|
361
|
+
pass
|
|
362
|
+
tracker = Progress.create(
|
|
363
|
+
progress, total=None if total is None else total - len(done) if resume else total
|
|
364
|
+
)
|
|
365
|
+
started = time.monotonic()
|
|
366
|
+
ok = failed = 0
|
|
367
|
+
errors: List[Dict[str, Any]] = []
|
|
368
|
+
writer = _Writer(sink, ordered=ordered, batch_size=batch_size)
|
|
369
|
+
try:
|
|
370
|
+
async for result in _execute(pending(), retry_rounds, retry_round_delay, options):
|
|
371
|
+
record = _record(result, positions[result.index], body, include_headers)
|
|
372
|
+
await writer.add(result.index, record)
|
|
373
|
+
if result.ok:
|
|
374
|
+
ok += 1
|
|
375
|
+
else:
|
|
376
|
+
failed += 1
|
|
377
|
+
errors.append({key: value for key, value in record.items() if key != "body"})
|
|
378
|
+
if tracker is not None:
|
|
379
|
+
tracker.update(result)
|
|
380
|
+
finally:
|
|
381
|
+
await writer.close()
|
|
382
|
+
if tracker is not None:
|
|
383
|
+
tracker.close()
|
|
384
|
+
return Summary(
|
|
385
|
+
target=target,
|
|
386
|
+
total=ok + failed,
|
|
387
|
+
ok=ok,
|
|
388
|
+
failed=failed,
|
|
389
|
+
skipped=skipped,
|
|
390
|
+
elapsed=time.monotonic() - started,
|
|
391
|
+
errors=errors,
|
|
392
|
+
)
|
|
393
|
+
|
|
394
|
+
|
|
395
|
+
def _record(result: Result, position: int, body: str, include_headers: bool) -> Dict[str, Any]:
|
|
396
|
+
record = result.to_dict(body="none" if body == "bytes" else body, include_headers=include_headers)
|
|
397
|
+
record["index"] = position
|
|
398
|
+
if body == "bytes":
|
|
399
|
+
record["body"] = result.body
|
|
400
|
+
return record
|
|
401
|
+
|
|
402
|
+
|
|
403
|
+
async def fetch_to_file(
|
|
404
|
+
urls: Iterable[Union[str, Request]],
|
|
405
|
+
path: Union[str, os.PathLike],
|
|
406
|
+
*,
|
|
407
|
+
format: Optional[str] = None,
|
|
408
|
+
body: str = "text",
|
|
409
|
+
include_headers: bool = False,
|
|
410
|
+
resume: bool = False,
|
|
411
|
+
ordered: bool = False,
|
|
412
|
+
progress: ProgressTarget = False,
|
|
413
|
+
retry_rounds: int = 0,
|
|
414
|
+
retry_round_delay: float = 5.0,
|
|
415
|
+
table: str = "reqstorm_results",
|
|
416
|
+
batch_size: int = 100,
|
|
417
|
+
**options: Any,
|
|
418
|
+
) -> Summary:
|
|
419
|
+
"""Send the requests and write one record per request to ``path`` as soon as it is final.
|
|
420
|
+
|
|
421
|
+
Results are not collected in memory, so this suits very large batches.
|
|
422
|
+
|
|
423
|
+
Args:
|
|
424
|
+
path: ``.csv`` is written as CSV; ``.db``, ``.sqlite`` and ``.sqlite3`` as an SQLite
|
|
425
|
+
database (see ``fetch_to_db``); anything else as JSON Lines.
|
|
426
|
+
format: ``"jsonl"``, ``"csv"`` or ``"sqlite"`` to override the choice made from the name.
|
|
427
|
+
body: ``"text"`` (decoded with the response charset), ``"base64"``, ``"bytes"``
|
|
428
|
+
(SQLite only) or ``"none"`` to leave it out.
|
|
429
|
+
include_headers: Also write the response headers.
|
|
430
|
+
resume: Skip the requests already recorded as successful in an existing file or
|
|
431
|
+
table; the rest (including earlier failures) are sent and appended.
|
|
432
|
+
Without it a JSONL or CSV file is overwritten; database rows are never deleted.
|
|
433
|
+
ordered: Write records in input order. By default they are written as they complete.
|
|
434
|
+
Ordered output holds back records that finish early until the ones before them
|
|
435
|
+
are done, so it uses more memory when a few requests are slow.
|
|
436
|
+
progress: ``True`` to print progress to stderr, or a text stream to print it to.
|
|
437
|
+
retry_rounds: As for ``fetch_all``; each request still gets one record.
|
|
438
|
+
retry_round_delay: Seconds to wait before each retry round.
|
|
439
|
+
table: Table name for SQLite output.
|
|
440
|
+
batch_size: Records written per batch.
|
|
441
|
+
options: Any other option of ``fetch_all``, such as ``method``, ``concurrency``,
|
|
442
|
+
``timeout``, ``retries`` or ``rate_limit``.
|
|
443
|
+
|
|
444
|
+
Each record has ``index`` (position in ``urls``), ``method``, ``url``, ``status``, ``ok``,
|
|
445
|
+
``error``, ``attempts``, ``elapsed``, ``final_url`` and ``history`` (every attempt).
|
|
446
|
+
Returns a ``Summary`` whose ``errors`` lists the failed records.
|
|
447
|
+
"""
|
|
448
|
+
path = os.fspath(path)
|
|
449
|
+
if format is None:
|
|
450
|
+
lowered = path.lower()
|
|
451
|
+
format = (
|
|
452
|
+
"csv" if lowered.endswith(".csv") else "sqlite" if lowered.endswith(_SQLITE_SUFFIXES) else "jsonl"
|
|
453
|
+
)
|
|
454
|
+
if format not in ("jsonl", "csv", "sqlite"):
|
|
455
|
+
raise ValueError("format must be 'jsonl', 'csv' or 'sqlite'")
|
|
456
|
+
if body == "bytes" and format != "sqlite":
|
|
457
|
+
raise ValueError("body='bytes' needs a database; use 'text' or 'base64' for files")
|
|
458
|
+
|
|
459
|
+
if format == "sqlite":
|
|
460
|
+
|
|
461
|
+
def make_sink() -> _Sink:
|
|
462
|
+
connection = sqlite3.connect(path, check_same_thread=False)
|
|
463
|
+
return _DatabaseSink(connection, table, body, include_headers, close=True)
|
|
464
|
+
|
|
465
|
+
read_completed: Any = set
|
|
466
|
+
elif format == "csv":
|
|
467
|
+
fields = [
|
|
468
|
+
f for f in _FILE_FIELDS if (f != "headers" or include_headers) and (f != "body" or body != "none")
|
|
469
|
+
]
|
|
470
|
+
|
|
471
|
+
def make_sink() -> _Sink:
|
|
472
|
+
return _CsvSink(path, append=resume, fields=fields)
|
|
473
|
+
|
|
474
|
+
def read_completed() -> Set[Tuple[str, str]]:
|
|
475
|
+
return _CsvSink.read_completed(path)
|
|
476
|
+
else:
|
|
477
|
+
|
|
478
|
+
def make_sink() -> _Sink:
|
|
479
|
+
return _JsonlSink(path, append=resume)
|
|
480
|
+
|
|
481
|
+
def read_completed() -> Set[Tuple[str, str]]:
|
|
482
|
+
return _JsonlSink.read_completed(path)
|
|
483
|
+
|
|
484
|
+
return await _run_to_sink(
|
|
485
|
+
urls,
|
|
486
|
+
make_sink,
|
|
487
|
+
read_completed,
|
|
488
|
+
path,
|
|
489
|
+
body=body,
|
|
490
|
+
include_headers=include_headers,
|
|
491
|
+
resume=resume,
|
|
492
|
+
ordered=ordered,
|
|
493
|
+
progress=progress,
|
|
494
|
+
retry_rounds=retry_rounds,
|
|
495
|
+
retry_round_delay=retry_round_delay,
|
|
496
|
+
batch_size=batch_size,
|
|
497
|
+
options=options,
|
|
498
|
+
)
|
|
499
|
+
|
|
500
|
+
|
|
501
|
+
async def fetch_to_db(
|
|
502
|
+
urls: Iterable[Union[str, Request]],
|
|
503
|
+
connection: Any,
|
|
504
|
+
*,
|
|
505
|
+
table: str = "reqstorm_results",
|
|
506
|
+
body: str = "text",
|
|
507
|
+
include_headers: bool = False,
|
|
508
|
+
resume: bool = False,
|
|
509
|
+
ordered: bool = False,
|
|
510
|
+
progress: ProgressTarget = False,
|
|
511
|
+
retry_rounds: int = 0,
|
|
512
|
+
retry_round_delay: float = 5.0,
|
|
513
|
+
batch_size: int = 100,
|
|
514
|
+
**options: Any,
|
|
515
|
+
) -> Summary:
|
|
516
|
+
"""Send the requests and insert one row per request into ``table``.
|
|
517
|
+
|
|
518
|
+
``connection`` is an open DB-API connection from ``sqlite3``, ``psycopg`` / ``psycopg2``
|
|
519
|
+
(PostgreSQL), or ``pymysql`` / ``MySQLdb`` / ``mysql.connector`` (MySQL). reqstorm does not
|
|
520
|
+
close it. The table is created if it does not exist, with one column per field
|
|
521
|
+
(``request_index``, ``method``, ``url``, ``status``, ``ok``, ``error``, ``attempts``,
|
|
522
|
+
``elapsed``, ``final_url``), JSON columns for ``history`` and ``headers``, ``body``,
|
|
523
|
+
and ``created_at``. Rows are inserted in batches on a background thread, except for
|
|
524
|
+
sqlite3 connections, which must stay on the thread that created them.
|
|
525
|
+
|
|
526
|
+
Existing rows are never deleted. With ``resume=True``, requests that already have a
|
|
527
|
+
successful row are skipped. ``body="bytes"`` stores the raw body as a binary column.
|
|
528
|
+
The other arguments are as for ``fetch_to_file``.
|
|
529
|
+
"""
|
|
530
|
+
|
|
531
|
+
def make_sink() -> _Sink:
|
|
532
|
+
return _DatabaseSink(connection, table, body, include_headers, close=False)
|
|
533
|
+
|
|
534
|
+
return await _run_to_sink(
|
|
535
|
+
urls,
|
|
536
|
+
make_sink,
|
|
537
|
+
set,
|
|
538
|
+
f"{_dialect_for(connection).name}:{table}",
|
|
539
|
+
body=body,
|
|
540
|
+
include_headers=include_headers,
|
|
541
|
+
resume=resume,
|
|
542
|
+
ordered=ordered,
|
|
543
|
+
progress=progress,
|
|
544
|
+
retry_rounds=retry_rounds,
|
|
545
|
+
retry_round_delay=retry_round_delay,
|
|
546
|
+
batch_size=batch_size,
|
|
547
|
+
options=options,
|
|
548
|
+
)
|
reqstorm/_legacy.py
ADDED
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
"""The reqt 1.x calling style, kept working with a deprecation warning."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import logging
|
|
7
|
+
import warnings
|
|
8
|
+
from typing import Any, Callable, Iterable, Mapping, Optional
|
|
9
|
+
|
|
10
|
+
import aiohttp
|
|
11
|
+
|
|
12
|
+
logger = logging.getLogger("reqstorm")
|
|
13
|
+
|
|
14
|
+
_LEGACY_KEYWORDS = {"request_type", "semaphore_limit"}
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _warn() -> None:
|
|
18
|
+
warnings.warn(
|
|
19
|
+
"Passing a callback as `method` is the reqt 1.x API and will be removed in reqstorm 3.0. "
|
|
20
|
+
"Use `results = await reqstorm.fetch_all(urls)` or `callback=`; see the README.",
|
|
21
|
+
DeprecationWarning,
|
|
22
|
+
stacklevel=4,
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
async def legacy_fetch_all(
|
|
27
|
+
urls: Iterable[str],
|
|
28
|
+
method: Callable[..., Any],
|
|
29
|
+
headers: Optional[Mapping[str, str]] = None,
|
|
30
|
+
verify_ssl: bool = True,
|
|
31
|
+
request_type: str = "GET",
|
|
32
|
+
semaphore_limit: int = 500,
|
|
33
|
+
**unexpected: Any,
|
|
34
|
+
) -> None:
|
|
35
|
+
"""Run the 1.x API: ``method`` is called with each ``aiohttp.ClientResponse``.
|
|
36
|
+
|
|
37
|
+
Unlike 1.x, TLS certificates are verified (pass ``verify_ssl=False`` to opt out)
|
|
38
|
+
and every failed request is logged instead of only connection errors.
|
|
39
|
+
"""
|
|
40
|
+
if unexpected:
|
|
41
|
+
raise TypeError(f"fetch_all() got unexpected keyword arguments: {', '.join(sorted(unexpected))}")
|
|
42
|
+
if not callable(method):
|
|
43
|
+
raise TypeError(
|
|
44
|
+
"`request_type` and `semaphore_limit` belong to the reqt 1.x API, "
|
|
45
|
+
"which needs a callback as `method`"
|
|
46
|
+
)
|
|
47
|
+
_warn()
|
|
48
|
+
semaphore = asyncio.Semaphore(semaphore_limit)
|
|
49
|
+
is_coroutine = asyncio.iscoroutinefunction(method)
|
|
50
|
+
|
|
51
|
+
async with aiohttp.ClientSession(connector=aiohttp.TCPConnector(limit=0)) as session:
|
|
52
|
+
|
|
53
|
+
async def fetch(url: str) -> None:
|
|
54
|
+
async with semaphore:
|
|
55
|
+
try:
|
|
56
|
+
async with session.request(
|
|
57
|
+
request_type.upper(), url, headers=headers, ssl=None if verify_ssl else False
|
|
58
|
+
) as response:
|
|
59
|
+
if is_coroutine:
|
|
60
|
+
await method(response)
|
|
61
|
+
else:
|
|
62
|
+
method(response)
|
|
63
|
+
except asyncio.CancelledError:
|
|
64
|
+
raise
|
|
65
|
+
except Exception:
|
|
66
|
+
logger.exception("reqstorm: request to %s failed", url)
|
|
67
|
+
|
|
68
|
+
await asyncio.gather(*(fetch(url) for url in urls))
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
class Reqt:
|
|
72
|
+
"""The reqt 1.x class. Deprecated: use ``reqstorm.fetch_all``."""
|
|
73
|
+
|
|
74
|
+
def __init__(
|
|
75
|
+
self,
|
|
76
|
+
urls: Iterable[str],
|
|
77
|
+
method: Callable[..., Any],
|
|
78
|
+
headers: Optional[Mapping[str, str]] = None,
|
|
79
|
+
request_type: str = "GET",
|
|
80
|
+
semaphore_limit: int = 500,
|
|
81
|
+
) -> None:
|
|
82
|
+
self.urls = urls
|
|
83
|
+
self.method = method
|
|
84
|
+
self.headers = headers
|
|
85
|
+
self.request_type = request_type
|
|
86
|
+
self.semaphore_limit = semaphore_limit
|
|
87
|
+
|
|
88
|
+
async def fetch_all(self) -> None:
|
|
89
|
+
await legacy_fetch_all(
|
|
90
|
+
self.urls,
|
|
91
|
+
self.method,
|
|
92
|
+
headers=self.headers,
|
|
93
|
+
request_type=self.request_type,
|
|
94
|
+
semaphore_limit=self.semaphore_limit,
|
|
95
|
+
)
|