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/_limits.py ADDED
@@ -0,0 +1,73 @@
1
+ """Per-host request rate limiting."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import re
7
+ import time
8
+ from typing import Dict, Optional, Tuple, Union
9
+
10
+ from yarl import URL
11
+
12
+
13
+ def host_key(url: str) -> Optional[str]:
14
+ """``host:port`` of a URL, or None when it has no host (the request will fail anyway)."""
15
+ try:
16
+ parsed = URL(url)
17
+ except (TypeError, ValueError):
18
+ return None
19
+ if not parsed.host:
20
+ return None
21
+ return f"{parsed.host}:{parsed.port}"
22
+
23
+
24
+ RateLimit = Union[float, int, str, Tuple[float, float]]
25
+
26
+ _UNITS = {
27
+ "s": 1.0, "sec": 1.0, "second": 1.0, "seconds": 1.0,
28
+ "m": 60.0, "min": 60.0, "minute": 60.0, "minutes": 60.0,
29
+ "h": 3600.0, "hr": 3600.0, "hour": 3600.0, "hours": 3600.0,
30
+ "d": 86400.0, "day": 86400.0, "days": 86400.0,
31
+ } # fmt: skip
32
+
33
+
34
+ def parse_rate(rate: RateLimit) -> float:
35
+ """Requests per second from ``5``, ``"100/min"``, ``"30/5min"``, ``"1000/h"`` or ``(100, 60)``."""
36
+ if isinstance(rate, bool):
37
+ raise TypeError("rate_limit must be a number, a string such as '100/min', or (count, seconds)")
38
+ if isinstance(rate, (int, float)):
39
+ per_second = float(rate)
40
+ elif isinstance(rate, tuple):
41
+ count, seconds = rate
42
+ per_second = float(count) / float(seconds)
43
+ elif isinstance(rate, str):
44
+ count_text, _, period = rate.replace(" ", "").partition("/")
45
+ match = re.fullmatch(r"(\d*\.?\d*)([a-z]+)", period.lower())
46
+ if not count_text or not match or match.group(2) not in _UNITS:
47
+ raise ValueError(f"invalid rate_limit {rate!r}; use for example '10/s', '100/min' or '1000/h'")
48
+ multiplier = float(match.group(1)) if match.group(1) else 1.0
49
+ per_second = float(count_text) / (multiplier * _UNITS[match.group(2)])
50
+ else:
51
+ raise TypeError("rate_limit must be a number, a string such as '100/min', or (count, seconds)")
52
+ if not per_second > 0:
53
+ raise ValueError("rate_limit must be greater than 0")
54
+ return per_second
55
+
56
+
57
+ class HostRateLimiter:
58
+ """Spaces out requests evenly so that each host gets at most the given rate."""
59
+
60
+ def __init__(self, rate: RateLimit) -> None:
61
+ self._interval = 1.0 / parse_rate(rate)
62
+ self._next_slot: Dict[str, float] = {}
63
+
64
+ async def wait(self, url: str) -> None:
65
+ host = host_key(url)
66
+ if host is None:
67
+ return
68
+ now = time.monotonic()
69
+ # Reserve the next free slot synchronously, so concurrent callers never share one.
70
+ slot = max(now, self._next_slot.get(host, now))
71
+ self._next_slot[host] = slot + self._interval
72
+ if slot > now:
73
+ await asyncio.sleep(slot - now)
reqstorm/_plan.py ADDED
@@ -0,0 +1,60 @@
1
+ """Estimate how long a batch will take before sending it."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+ from typing import Optional
7
+
8
+ from ._limits import RateLimit, parse_rate
9
+ from ._progress import format_duration
10
+
11
+
12
+ @dataclass(frozen=True)
13
+ class Estimate:
14
+ """How long ``requests`` requests should take, and which setting limits them."""
15
+
16
+ requests: int
17
+ seconds: float
18
+ limited_by: str
19
+
20
+ def __str__(self) -> str:
21
+ return (
22
+ f"{self.requests} requests: about {format_duration(self.seconds)} (limited by {self.limited_by})"
23
+ )
24
+
25
+
26
+ def estimate(
27
+ requests: int,
28
+ *,
29
+ rate_limit: Optional[RateLimit] = None,
30
+ hosts: int = 1,
31
+ concurrency: int = 100,
32
+ concurrency_per_host: int = 0,
33
+ latency: float = 0.5,
34
+ ) -> Estimate:
35
+ """Estimate the duration of a batch, without sending anything.
36
+
37
+ >>> print(reqstorm.estimate(7000, rate_limit="100/min"))
38
+ 7000 requests: about 1h 10m (limited by rate_limit)
39
+
40
+ Args:
41
+ requests: Number of requests.
42
+ rate_limit: The per-host rate limit you plan to use (same formats as ``fetch_all``).
43
+ hosts: How many different hosts the requests are spread evenly over.
44
+ concurrency: The overall concurrency you plan to use.
45
+ concurrency_per_host: The per-host concurrency you plan to use; 0 means none.
46
+ latency: Typical seconds per request (response time); 0.5 by default.
47
+
48
+ Retries are not included; they add time for requests that fail.
49
+ """
50
+ if requests < 0 or hosts < 1 or concurrency < 1 or latency < 0:
51
+ raise ValueError("requests must be >= 0, hosts and concurrency >= 1, latency >= 0")
52
+ in_flight = concurrency
53
+ if concurrency_per_host:
54
+ in_flight = min(in_flight, concurrency_per_host * hosts)
55
+ candidates = {"concurrency": requests * latency / in_flight}
56
+ if rate_limit is not None:
57
+ per_host = requests / hosts
58
+ candidates["rate_limit"] = max(per_host - 1, 0) / parse_rate(rate_limit) + latency
59
+ limited_by = max(candidates, key=lambda name: candidates[name])
60
+ return Estimate(requests=requests, seconds=candidates[limited_by], limited_by=limited_by)
reqstorm/_progress.py ADDED
@@ -0,0 +1,82 @@
1
+ """A small progress line for long batches, with no extra dependency."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import sys
6
+ import time
7
+ from typing import TYPE_CHECKING, Optional, TextIO, Union
8
+
9
+ if TYPE_CHECKING:
10
+ from ._client import Result
11
+
12
+ ProgressTarget = Union[bool, TextIO]
13
+
14
+
15
+ def format_duration(seconds: float) -> str:
16
+ """``42s``, ``3m 05s``, ``1h 10m``."""
17
+ seconds = max(int(round(seconds)), 0)
18
+ hours, rest = divmod(seconds, 3600)
19
+ minutes, secs = divmod(rest, 60)
20
+ if hours:
21
+ return f"{hours}h {minutes:02d}m"
22
+ if minutes:
23
+ return f"{minutes}m {secs:02d}s"
24
+ return f"{secs}s"
25
+
26
+
27
+ class Progress:
28
+ """Prints ``done/total``, successes, failures and the request rate.
29
+
30
+ On a terminal the line is rewritten in place several times a second; when the
31
+ output is redirected (a log file, CI) a new line is written every few seconds.
32
+ """
33
+
34
+ def __init__(self, stream: TextIO, total: Optional[int]) -> None:
35
+ self._stream = stream
36
+ self._total = total
37
+ self._interactive = bool(getattr(stream, "isatty", lambda: False)())
38
+ self._interval = 0.2 if self._interactive else 5.0
39
+ self._started = time.monotonic()
40
+ self._last_print = 0.0
41
+ self.done = 0
42
+ self.ok = 0
43
+ self.failed = 0
44
+
45
+ @classmethod
46
+ def create(cls, target: ProgressTarget, total: Optional[int]) -> Optional[Progress]:
47
+ if target is False or target is None:
48
+ return None
49
+ return cls(sys.stderr if target is True else target, total)
50
+
51
+ def update(self, result: Result) -> None:
52
+ self.done += 1
53
+ if result.ok:
54
+ self.ok += 1
55
+ else:
56
+ self.failed += 1
57
+ now = time.monotonic()
58
+ if now - self._last_print >= self._interval:
59
+ self._last_print = now
60
+ self._write(final=False)
61
+
62
+ def close(self) -> None:
63
+ self._write(final=True)
64
+
65
+ def _line(self) -> str:
66
+ elapsed = max(time.monotonic() - self._started, 1e-9)
67
+ rate = self.done / elapsed
68
+ if self._total:
69
+ count = f"{self.done}/{self._total} ({self.done * 100 // self._total}%)"
70
+ else:
71
+ count = str(self.done)
72
+ line = f"reqstorm: {count} ok {self.ok} failed {self.failed} {rate:.1f} req/s"
73
+ if self._total and rate > 0 and self.done < self._total:
74
+ line += f" ETA {format_duration((self._total - self.done) / rate)}"
75
+ return line
76
+
77
+ def _write(self, final: bool) -> None:
78
+ if self._interactive:
79
+ self._stream.write("\r" + self._line() + ("\n" if final else ""))
80
+ else:
81
+ self._stream.write(self._line() + "\n")
82
+ self._stream.flush()
reqstorm/_sync.py ADDED
@@ -0,0 +1,135 @@
1
+ """Blocking wrappers, for scripts that do not use asyncio."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import concurrent.futures
7
+ import queue
8
+ import threading
9
+ from typing import Any, Awaitable, Iterable, Iterator, TypeVar, Union
10
+
11
+ from ._client import Request, Result, Results, fetch_all, stream
12
+ from ._files import Summary, fetch_to_db, fetch_to_file
13
+
14
+ T = TypeVar("T")
15
+
16
+
17
+ def _run(awaitable: Awaitable[T]) -> T:
18
+ """Run a coroutine to completion, even when called from inside a running event loop.
19
+
20
+ Inside a running loop (for example a Jupyter notebook) ``asyncio.run`` is not allowed,
21
+ so the coroutine runs on a fresh loop in a helper thread instead.
22
+ """
23
+ try:
24
+ asyncio.get_running_loop()
25
+ except RuntimeError:
26
+ return asyncio.run(awaitable) # type: ignore[arg-type]
27
+ with concurrent.futures.ThreadPoolExecutor(max_workers=1) as executor:
28
+ return executor.submit(asyncio.run, awaitable).result() # type: ignore[arg-type]
29
+
30
+
31
+ def fetch_all_sync(urls: Iterable[Union[str, Request]], method: str = "GET", **options: Any) -> Results:
32
+ """Blocking version of ``fetch_all``: send the requests and return their results in order.
33
+
34
+ Takes the same options as ``fetch_all`` except ``session``, which belongs to an event loop.
35
+
36
+ results = reqstorm.fetch_all_sync(urls, concurrency=20, retries=2)
37
+ """
38
+ if "session" in options:
39
+ raise TypeError("fetch_all_sync() does not accept `session`; use fetch_all() inside your event loop")
40
+ return _run(fetch_all(urls, method, **options))
41
+
42
+
43
+ def fetch_to_file_sync(urls: Iterable[Union[str, Request]], path: Any, **options: Any) -> Summary:
44
+ """Blocking version of ``fetch_to_file``."""
45
+ if "session" in options:
46
+ raise TypeError(
47
+ "fetch_to_file_sync() does not accept `session`; use fetch_to_file() inside your event loop"
48
+ )
49
+ return _run(fetch_to_file(urls, path, **options))
50
+
51
+
52
+ def fetch_to_db_sync(urls: Iterable[Union[str, Request]], connection: Any, **options: Any) -> Summary:
53
+ """Blocking version of ``fetch_to_db``.
54
+
55
+ The requests run on a helper thread when called from a running event loop; a sqlite3
56
+ connection must then be opened with ``check_same_thread=False``.
57
+ """
58
+ if "session" in options:
59
+ raise TypeError(
60
+ "fetch_to_db_sync() does not accept `session`; use fetch_to_db() inside your event loop"
61
+ )
62
+ return _run(fetch_to_db(urls, connection, **options))
63
+
64
+
65
+ _FINISHED = object()
66
+
67
+
68
+ def stream_sync(urls: Iterable[Union[str, Request]], method: str = "GET", **options: Any) -> Iterator[Result]:
69
+ """Blocking version of ``stream``: yield each ``Result`` as soon as it completes.
70
+
71
+ The requests run on an event loop in a background thread. Leaving the loop early
72
+ (``break``) stops the remaining requests.
73
+
74
+ for result in reqstorm.stream_sync(urls, concurrency=50):
75
+ print(result.url, result.status)
76
+ """
77
+ if "session" in options:
78
+ raise TypeError("stream_sync() does not accept `session`; use stream() inside your event loop")
79
+ results: queue.Queue[Any] = queue.Queue(maxsize=max(int(options.get("concurrency", 100)) * 2, 1))
80
+ stop = threading.Event()
81
+
82
+ async def produce() -> None:
83
+ generator = stream(urls, method, **options)
84
+ try:
85
+ async for result in generator:
86
+ while not stop.is_set():
87
+ try:
88
+ results.put_nowait(result)
89
+ break
90
+ except queue.Full:
91
+ await asyncio.sleep(0.01) # the consumer is slower: wait without blocking the loop
92
+ if stop.is_set():
93
+ return
94
+ finally:
95
+ await generator.aclose()
96
+
97
+ loop = asyncio.new_event_loop()
98
+ task: asyncio.Future[None] = loop.create_task(produce())
99
+
100
+ def run() -> None:
101
+ asyncio.set_event_loop(loop)
102
+ try:
103
+ loop.run_until_complete(task)
104
+ except asyncio.CancelledError:
105
+ results.put(_FINISHED)
106
+ except BaseException as error:
107
+ results.put(error)
108
+ else:
109
+ results.put(_FINISHED)
110
+ finally:
111
+ loop.run_until_complete(loop.shutdown_asyncgens())
112
+ loop.close()
113
+
114
+ thread = threading.Thread(target=run, name="reqstorm-stream", daemon=True)
115
+ thread.start()
116
+ try:
117
+ while True:
118
+ item = results.get()
119
+ if item is _FINISHED:
120
+ return
121
+ if isinstance(item, BaseException):
122
+ raise item
123
+ yield item
124
+ finally:
125
+ stop.set()
126
+ if not task.done():
127
+ try:
128
+ loop.call_soon_threadsafe(task.cancel) # stop requests still in flight
129
+ except RuntimeError:
130
+ pass # the loop has just finished and closed on its own
131
+ while thread.is_alive(): # let the producer notice `stop` even if the queue is full
132
+ try:
133
+ results.get_nowait()
134
+ except queue.Empty:
135
+ thread.join(0.05)
reqstorm/py.typed ADDED
File without changes
@@ -0,0 +1,271 @@
1
+ Metadata-Version: 2.4
2
+ Name: reqstorm
3
+ Version: 2.0.1
4
+ Summary: Send large numbers of HTTP requests concurrently with asyncio, with retries, timeouts and per-request results.
5
+ Author-email: Melih Colpan <melihcolpan1@gmail.com>
6
+ License-Expression: MIT
7
+ Project-URL: Homepage, https://reqstorm.github.io
8
+ Project-URL: Source, https://github.com/melihcolpan/reqstorm
9
+ Project-URL: Changelog, https://github.com/melihcolpan/reqstorm/blob/master/CHANGELOG.md
10
+ Project-URL: Issues, https://github.com/melihcolpan/reqstorm/issues
11
+ Keywords: asyncio,aiohttp,http,requests,concurrent,bulk,rate-limit,retry,crawler,scraping
12
+ Classifier: Development Status :: 5 - Production/Stable
13
+ Classifier: Framework :: AsyncIO
14
+ Classifier: Intended Audience :: Developers
15
+ Classifier: Operating System :: OS Independent
16
+ Classifier: Programming Language :: Python :: 3
17
+ Classifier: Programming Language :: Python :: 3 :: Only
18
+ Classifier: Programming Language :: Python :: 3.9
19
+ Classifier: Programming Language :: Python :: 3.10
20
+ Classifier: Programming Language :: Python :: 3.11
21
+ Classifier: Programming Language :: Python :: 3.12
22
+ Classifier: Programming Language :: Python :: 3.13
23
+ Classifier: Topic :: Internet :: WWW/HTTP
24
+ Classifier: Typing :: Typed
25
+ Requires-Python: >=3.9
26
+ Description-Content-Type: text/markdown
27
+ License-File: LICENSE
28
+ Requires-Dist: aiohttp<4,>=3.9
29
+ Provides-Extra: test
30
+ Requires-Dist: pytest>=8; extra == "test"
31
+ Requires-Dist: pytest-asyncio>=0.23; extra == "test"
32
+ Requires-Dist: trustme>=1.1; extra == "test"
33
+ Provides-Extra: lint
34
+ Requires-Dist: ruff>=0.6; extra == "lint"
35
+ Requires-Dist: mypy>=1.10; extra == "lint"
36
+ Dynamic: license-file
37
+
38
+ # reqstorm
39
+
40
+ [![PyPI](https://img.shields.io/pypi/v/reqstorm)](https://pypi.org/project/reqstorm/)
41
+ [![Python](https://img.shields.io/pypi/pyversions/reqstorm)](https://pypi.org/project/reqstorm/)
42
+ [![CI](https://github.com/melihcolpan/reqstorm/actions/workflows/ci.yml/badge.svg)](https://github.com/melihcolpan/reqstorm/actions/workflows/ci.yml)
43
+ [![License: MIT](https://img.shields.io/badge/license-MIT-blue)](LICENSE)
44
+
45
+ **Documentation: [reqstorm.github.io](https://reqstorm.github.io)**
46
+
47
+ > **reqstorm** is the new name of **reqt**. If you used reqt, see [Coming from reqt](#coming-from-reqt).
48
+
49
+ **reqstorm** sends large numbers of HTTP requests concurrently and gives you one result per request, with rate limits, retries, progress and output to files or databases built in.
50
+
51
+ ```python
52
+ import reqstorm
53
+
54
+ urls = [f"https://api.example.com/items/{i}" for i in range(7000)]
55
+
56
+ print(reqstorm.estimate(len(urls), rate_limit="100/min"))
57
+ # 7000 requests: about 1h 10m (limited by rate_limit)
58
+
59
+ results = reqstorm.fetch_all_sync(urls, rate_limit="100/min", retries=2, progress=True)
60
+
61
+ print(results.summary())
62
+ # {'total': 7000, 'ok': 6987, 'failed': 13, 'failures': {'HTTP 404': 9, 'TimeoutError': 4}}
63
+ for error in results.errors():
64
+ print(error["url"], error["error"] or error["status"])
65
+ ```
66
+
67
+ - **Every request gets a result.** A timeout, a dropped connection or an invalid URL is recorded on that request and never stops the others. Each result keeps the history of its attempts.
68
+ - **Rate limits per host** in any unit (`"10/s"`, `"100/min"`, `"1000/h"`), plus overall and per-host concurrency limits.
69
+ - **Timeouts and retries**: immediate retries with exponential backoff and `Retry-After` support, and retry rounds that resend what still failed at the end.
70
+ - **Large batches**: stream results as they finish, or write them straight to JSONL, CSV, SQLite, PostgreSQL or MySQL, and resume an interrupted run where it stopped.
71
+ - **Works with or without asyncio**, and inside Jupyter notebooks.
72
+ - **TLS certificates are verified** by default. Fully typed, with a single dependency: [aiohttp](https://docs.aiohttp.org).
73
+
74
+ ## Installation
75
+
76
+ ```console
77
+ $ python -m pip install reqstorm
78
+ ```
79
+
80
+ reqstorm supports Python 3.9 and newer.
81
+
82
+ ## Usage
83
+
84
+ Every function has an async version for code that already runs an event loop: `fetch_all`, `stream`, `fetch_to_file`, `fetch_to_db`. The blocking versions end in `_sync`. They take the same options.
85
+
86
+ ```python
87
+ results = reqstorm.fetch_all_sync(urls) # in a script or a notebook
88
+ results = await reqstorm.fetch_all(urls) # inside async code
89
+ ```
90
+
91
+ ### Results
92
+
93
+ `fetch_all` returns a `Results` list with one `Result` per request, in input order.
94
+
95
+ | `Result` attribute | Meaning |
96
+ |---|---|
97
+ | `ok` | `True` when a response arrived with a status below 400 |
98
+ | `status`, `headers`, `body` | The response (`status` is `None` if none arrived) |
99
+ | `text()`, `json()` | The body decoded as text (using the response charset) or JSON |
100
+ | `error` | The exception that prevented a response, or `None` |
101
+ | `attempts`, `elapsed`, `history` | Number of attempts, total seconds, and each attempt's status, error and duration |
102
+ | `url`, `method`, `final_url`, `index` | What was requested, where redirects ended, and its position in the input |
103
+
104
+ `result.raise_for_error()` raises the request's error, or `reqstorm.HTTPStatusError` for a status of 400 or above. `result.to_dict()` gives the same fields as plain data.
105
+
106
+ The `Results` list has reporting helpers:
107
+
108
+ ```python
109
+ results.succeeded # list of successful results
110
+ results.failed # list of failed results
111
+ results.summary() # {'total': ..., 'ok': ..., 'failed': ..., 'failures': {'HTTP 404': 9, ...}}
112
+ results.errors() # failed requests as dicts with index, url, status, error, attempts and history
113
+ results.to_dicts() # every result as a dict, ready for json.dump or a DataFrame
114
+ ```
115
+
116
+ ### Rate limits and concurrency
117
+
118
+ ```python
119
+ results = reqstorm.fetch_all_sync(
120
+ urls,
121
+ rate_limit="100/min", # per host; also 5 (per second), "10/s", "30/5min", "1000/h", (100, 60)
122
+ concurrency=50, # at most 50 requests in flight in total
123
+ concurrency_per_host=10, # and at most 10 to the same host
124
+ )
125
+ ```
126
+
127
+ The rate limit applies to each host (`host:port`) separately and counts retries too. `reqstorm.estimate(requests, rate_limit=..., hosts=..., concurrency=..., latency=...)` tells you how long a batch should take before you send it.
128
+
129
+ ### Timeouts and retries
130
+
131
+ ```python
132
+ results = reqstorm.fetch_all_sync(
133
+ urls,
134
+ timeout=10, # seconds per attempt, including the body; None disables it
135
+ retries=3, # retry right away: 0.5 s, 1 s, 2 s apart (Retry-After takes precedence)
136
+ backoff=0.5,
137
+ retry_rounds=2, # then resend whatever still failed, after everything else...
138
+ retry_round_delay=30, # ...waiting 30 s before each round
139
+ )
140
+ ```
141
+
142
+ Connection errors, timeouts and the statuses in `retry_statuses` (default: 429, 500, 502, 503, 504) are retried. Invalid URLs and other statuses such as 404 are not.
143
+
144
+ ### Writing results to a file
145
+
146
+ `fetch_to_file` writes each result as soon as it is final, so results never pile up in memory:
147
+
148
+ ```python
149
+ summary = reqstorm.fetch_to_file_sync(urls, "results.jsonl", rate_limit="10/s", progress=True)
150
+ print(summary.ok, summary.failed, summary.errors[:5])
151
+ ```
152
+
153
+ - **Format** follows the file name: `.jsonl` (JSON Lines), `.csv`, or `.db` / `.sqlite` for SQLite. Pass `format=` to override it.
154
+ - **Order:** records are written as requests complete. With `ordered=True` they are written in input order instead; finished records then wait in memory until the ones before them are done.
155
+ - **Resume:** with `resume=True`, requests already recorded as successful are skipped, and the others, including earlier failures, are sent again and appended. Without it a JSONL or CSV file is overwritten.
156
+ - **Fields:** each record has `index`, `method`, `url`, `status`, `ok`, `error`, `attempts`, `elapsed`, `final_url`, `history` and `body`. Use `body="none"` to leave the body out, `body="base64"` for binary responses, and `include_headers=True` to add the response headers.
157
+
158
+ `progress=True` prints a line such as `reqstorm: 3500/7000 (50%) ok 3493 failed 7 1.7 req/s ETA 35m 00s` to stderr.
159
+
160
+ ### Writing results to a database
161
+
162
+ `fetch_to_db` inserts one row per request through a database connection you already have:
163
+
164
+ ```python
165
+ import psycopg # or sqlite3, psycopg2, pymysql, MySQLdb, mysql.connector
166
+
167
+ with psycopg.connect("dbname=crawl") as connection:
168
+ summary = reqstorm.fetch_to_db_sync(urls, connection, table="api_results", resume=True)
169
+ ```
170
+
171
+ reqstorm creates the table if it does not exist. The fields you filter on are real columns, and the variable parts are JSON:
172
+
173
+ | Column | PostgreSQL | MySQL | SQLite |
174
+ |---|---|---|---|
175
+ | `id` | `BIGSERIAL` | `BIGINT AUTO_INCREMENT` | `INTEGER` |
176
+ | `request_index`, `status`, `attempts` | `INTEGER` | `INT` | `INTEGER` |
177
+ | `method`, `url`, `error`, `final_url` | `TEXT` | `VARCHAR` / `LONGTEXT` | `TEXT` |
178
+ | `ok` | `BOOLEAN` | `BOOLEAN` | `INTEGER` |
179
+ | `elapsed` | `DOUBLE PRECISION` | `DOUBLE` | `REAL` |
180
+ | `history`, `headers` | `JSONB` | `JSON` | `TEXT` (JSON) |
181
+ | `body` | `TEXT`, or `BYTEA` with `body="bytes"` | `LONGTEXT` / `LONGBLOB` | `TEXT` / `BLOB` |
182
+ | `created_at` | `TIMESTAMPTZ` | `TIMESTAMP` | `TEXT` |
183
+
184
+ So `SELECT url, error FROM api_results WHERE NOT ok` works directly. Rows are inserted in batches on a background thread, so a remote database does not slow down the requests. Existing rows are never deleted; with `resume=True`, requests that already have a successful row are skipped.
185
+
186
+ ### Methods, headers and bodies
187
+
188
+ Options given to `fetch_all` apply to every request. Use `reqstorm.Request` to vary them per request:
189
+
190
+ ```python
191
+ results = reqstorm.fetch_all_sync(
192
+ [
193
+ "https://api.example.com/items/1",
194
+ reqstorm.Request("https://api.example.com/items", method="POST", json={"name": "new"}),
195
+ ],
196
+ headers={"Authorization": "Bearer ..."}, # merged with each Request's own headers
197
+ )
198
+ ```
199
+
200
+ `fetch_all_sync(urls, "POST", json=...)` sends every plain URL as a POST. `params`, `json` and `data` work the same way.
201
+
202
+ ### Streaming results
203
+
204
+ `stream` (or `stream_sync`) yields each result as soon as it completes. `urls` can be a generator, which is read lazily, so millions of URLs never need to be in memory at once:
205
+
206
+ ```python
207
+ for result in reqstorm.stream_sync(read_urls_from_file(), concurrency=100):
208
+ save(result.url, result.status, result.body)
209
+ ```
210
+
211
+ `fetch_all` also accepts `callback=`, a function or coroutine function called with each result as it completes.
212
+
213
+ ### TLS
214
+
215
+ Certificates are verified by default. To trust a private certificate authority, pass an `ssl.SSLContext`:
216
+
217
+ ```python
218
+ import ssl
219
+
220
+ context = ssl.create_default_context(cafile="internal-ca.pem")
221
+ results = reqstorm.fetch_all_sync(urls, ssl=context)
222
+ ```
223
+
224
+ `verify_ssl=False` turns verification off. Only use it for hosts you control.
225
+
226
+ ### Using your own session
227
+
228
+ In async code, pass an existing `aiohttp.ClientSession` with `session=` to share cookies, connection pools or proxy settings. reqstorm will not close it.
229
+
230
+ ## Coming from reqt
231
+
232
+ reqstorm 2.0 is the next version of reqt, renamed because another project already uses that name. Install `reqstorm` and replace `import reqt` with `import reqstorm`. The `reqt` package on PyPI now just installs reqstorm and shows a deprecation warning, so existing code keeps working in the meantime.
233
+
234
+ reqt 1.x called a function with each raw response and returned nothing. That style still works, with a `DeprecationWarning`:
235
+
236
+ ```python
237
+ async def handle(response): # reqt 1.x
238
+ print(response.status)
239
+
240
+
241
+ await reqstorm.fetch_all(urls=urls, method=handle)
242
+ ```
243
+
244
+ Two things changed even in the 1.x style:
245
+
246
+ - **TLS certificates are now verified.** 1.x silently skipped verification, which let anyone in the network path impersonate the server. Pass `verify_ssl=False` only if you really need the old behaviour for hosts you control.
247
+ - **Every failed request is logged** to the `reqstorm` logger instead of only connection errors; other errors no longer abort the whole batch.
248
+
249
+ The 2.0 equivalent of the example above is:
250
+
251
+ ```python
252
+ results = await reqstorm.fetch_all(urls)
253
+ for result in results:
254
+ print(result.status)
255
+ ```
256
+
257
+ The reqt 1.x style will be removed in reqstorm 3.0.
258
+
259
+ ## Development
260
+
261
+ ```console
262
+ $ python -m pip install -e ".[test,lint]"
263
+ $ pytest
264
+ $ ruff check . && ruff format --check . && mypy reqstorm
265
+ ```
266
+
267
+ The tests run against a local server and need no network access. The PostgreSQL and MySQL tests run when `REQSTORM_TEST_POSTGRES` (a libpq connection string) and `REQSTORM_TEST_MYSQL` (`host:port:user:password:database`) are set.
268
+
269
+ ## License
270
+
271
+ MIT, see [LICENSE](LICENSE).
@@ -0,0 +1,14 @@
1
+ reqstorm/__init__.py,sha256=YaFJLnbIXKf5YON32SAa8VwUDqtgvWWoAwgZxvwXX3s,1136
2
+ reqstorm/_client.py,sha256=CSqcj8h6l_20NYv2lTZOxoHizzIxerNY1VEykPAQ5OI,20216
3
+ reqstorm/_files.py,sha256=5eSQiTwpw7eaEQD57Tt_ozPLWqtI4kMxOe_VPpRGTRE,19905
4
+ reqstorm/_legacy.py,sha256=QV2VEjAbV6ndGw5RXj2eSQS86_kjwEvDW5wRjpSE6xU,3106
5
+ reqstorm/_limits.py,sha256=3_jnp95lTfeN4KjxD5oUZWdFQpi_1L24WvP6b4M5Uyw,2678
6
+ reqstorm/_plan.py,sha256=Zk0mnbjyy45qwiQkP0bpNydrGeMdJPq7842kPhE0IWk,2188
7
+ reqstorm/_progress.py,sha256=ma_jI-5HFZLXS2Q4ACndt2sLVmV6zGvom4v-H7f2LP4,2699
8
+ reqstorm/_sync.py,sha256=TF66fUhlMUveauR_X2KZrxGUNUXIBBcmu7pS2iVNw0A,5066
9
+ reqstorm/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
10
+ reqstorm-2.0.1.dist-info/licenses/LICENSE,sha256=Je4JBpdx1S-w4qdCWMCt7bD6fxW_cmgu9W8Wl2Znkus,1074
11
+ reqstorm-2.0.1.dist-info/METADATA,sha256=6lbvkhF-75znsi2C1AZRY5-o3rAmYpLRsZWn3KHXnqU,12321
12
+ reqstorm-2.0.1.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
13
+ reqstorm-2.0.1.dist-info/top_level.txt,sha256=UcK9wOskk393JpTaZsZGyjvRBoWWyGqOgK3kGsgm3Kg,9
14
+ reqstorm-2.0.1.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (84.0.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2021-2026 Melih Colpan
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1 @@
1
+ reqstorm