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/_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
+ )