reqstorm 2.0.1__tar.gz → 2.1.0__tar.gz

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 (35) hide show
  1. reqstorm-2.1.0/PKG-INFO +516 -0
  2. reqstorm-2.1.0/README.md +479 -0
  3. {reqstorm-2.0.1 → reqstorm-2.1.0}/pyproject.toml +1 -1
  4. {reqstorm-2.0.1 → reqstorm-2.1.0}/reqstorm/__init__.py +7 -1
  5. {reqstorm-2.0.1 → reqstorm-2.1.0}/reqstorm/_files.py +120 -1
  6. reqstorm-2.1.0/reqstorm/_schema.py +421 -0
  7. reqstorm-2.1.0/reqstorm/_schema_sinks.py +289 -0
  8. reqstorm-2.1.0/reqstorm.egg-info/PKG-INFO +516 -0
  9. {reqstorm-2.0.1 → reqstorm-2.1.0}/reqstorm.egg-info/SOURCES.txt +4 -0
  10. reqstorm-2.1.0/tests/test_schema.py +210 -0
  11. reqstorm-2.1.0/tests/test_schema_db.py +186 -0
  12. reqstorm-2.0.1/PKG-INFO +0 -271
  13. reqstorm-2.0.1/README.md +0 -234
  14. reqstorm-2.0.1/reqstorm.egg-info/PKG-INFO +0 -271
  15. {reqstorm-2.0.1 → reqstorm-2.1.0}/LICENSE +0 -0
  16. {reqstorm-2.0.1 → reqstorm-2.1.0}/reqstorm/_client.py +0 -0
  17. {reqstorm-2.0.1 → reqstorm-2.1.0}/reqstorm/_legacy.py +0 -0
  18. {reqstorm-2.0.1 → reqstorm-2.1.0}/reqstorm/_limits.py +0 -0
  19. {reqstorm-2.0.1 → reqstorm-2.1.0}/reqstorm/_plan.py +0 -0
  20. {reqstorm-2.0.1 → reqstorm-2.1.0}/reqstorm/_progress.py +0 -0
  21. {reqstorm-2.0.1 → reqstorm-2.1.0}/reqstorm/_sync.py +0 -0
  22. {reqstorm-2.0.1 → reqstorm-2.1.0}/reqstorm/py.typed +0 -0
  23. {reqstorm-2.0.1 → reqstorm-2.1.0}/reqstorm.egg-info/dependency_links.txt +0 -0
  24. {reqstorm-2.0.1 → reqstorm-2.1.0}/reqstorm.egg-info/requires.txt +0 -0
  25. {reqstorm-2.0.1 → reqstorm-2.1.0}/reqstorm.egg-info/top_level.txt +0 -0
  26. {reqstorm-2.0.1 → reqstorm-2.1.0}/setup.cfg +0 -0
  27. {reqstorm-2.0.1 → reqstorm-2.1.0}/tests/test_fetch_all.py +0 -0
  28. {reqstorm-2.0.1 → reqstorm-2.1.0}/tests/test_files.py +0 -0
  29. {reqstorm-2.0.1 → reqstorm-2.1.0}/tests/test_legacy.py +0 -0
  30. {reqstorm-2.0.1 → reqstorm-2.1.0}/tests/test_limits.py +0 -0
  31. {reqstorm-2.0.1 → reqstorm-2.1.0}/tests/test_ordered_and_db.py +0 -0
  32. {reqstorm-2.0.1 → reqstorm-2.1.0}/tests/test_report.py +0 -0
  33. {reqstorm-2.0.1 → reqstorm-2.1.0}/tests/test_stream.py +0 -0
  34. {reqstorm-2.0.1 → reqstorm-2.1.0}/tests/test_sync.py +0 -0
  35. {reqstorm-2.0.1 → reqstorm-2.1.0}/tests/test_tls.py +0 -0
@@ -0,0 +1,516 @@
1
+ Metadata-Version: 2.4
2
+ Name: reqstorm
3
+ Version: 2.1.0
4
+ Summary: Send large numbers of HTTP requests concurrently with asyncio, with retries, timeouts and per-request results.
5
+ Author-email: Melih Colpan <colpanmelih@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?logo=pypi&logoColor=white)](https://pypi.org/project/reqstorm/)
41
+ [![Python](https://img.shields.io/pypi/pyversions/reqstorm?logo=python&logoColor=white)](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
+ [![Docs](https://img.shields.io/badge/docs-reqstorm.github.io-5b50e0)](https://reqstorm.github.io)
44
+ [![License: MIT](https://img.shields.io/badge/license-MIT-blue)](LICENSE)
45
+
46
+ **Send thousands of HTTP requests without writing the plumbing.**
47
+
48
+ reqstorm sends large numbers of HTTP requests concurrently and gives you one result per request. Rate limits, retries, progress, error reports and output to files or databases are built in.
49
+
50
+ 📖 **Documentation: [reqstorm.github.io](https://reqstorm.github.io)**
51
+
52
+ > reqstorm is the new name of **reqt**. Coming from reqt? See [Coming from reqt](#coming-from-reqt).
53
+
54
+ ```python
55
+ import reqstorm
56
+
57
+ urls = [
58
+ f"https://api.example.com/items/{i}"
59
+ for i in range(7000)
60
+ ]
61
+
62
+ print(reqstorm.estimate(7000, rate_limit="100/min"))
63
+ # 7000 requests: about 1h 10m
64
+ # (limited by rate_limit)
65
+
66
+ results = reqstorm.fetch_all_sync(
67
+ urls,
68
+ rate_limit="100/min",
69
+ retries=2,
70
+ progress=True,
71
+ )
72
+
73
+ print(results.summary())
74
+ # {'total': 7000, 'ok': 6987, 'failed': 13,
75
+ # 'failures': {'HTTP 404': 9, 'TimeoutError': 4}}
76
+
77
+ for error in results.errors():
78
+ print(error["url"], error["error"] or error["status"])
79
+ ```
80
+
81
+ ## Contents
82
+
83
+ - [Why reqstorm](#why-reqstorm)
84
+ - [Installation](#installation)
85
+ - [Quick start](#quick-start)
86
+ - [Results and reports](#results-and-reports)
87
+ - [Rate limits and concurrency](#rate-limits-and-concurrency)
88
+ - [Timeouts and retries](#timeouts-and-retries)
89
+ - [Writing results to a file](#writing-results-to-a-file)
90
+ - [Writing results to a database](#writing-results-to-a-database)
91
+ - [Structured data: JSON to typed columns](#structured-data-json-to-typed-columns)
92
+ - [Requests, headers and bodies](#requests-headers-and-bodies)
93
+ - [Streaming results](#streaming-results)
94
+ - [TLS and sessions](#tls-and-sessions)
95
+ - [When to use something else](#when-to-use-something-else)
96
+ - [Coming from reqt](#coming-from-reqt)
97
+ - [Development](#development)
98
+ - [License](#license)
99
+
100
+ ## Why reqstorm
101
+
102
+ Sending one request is easy. Sending 7000 to an API that allows 100 a minute, without losing results when a few of them fail, is where the work is:
103
+
104
+ - **Pacing:** stay under the API's rate limit, per host, including retries.
105
+ - **Isolating failures:** one timeout or dropped connection must not stop the other 6999 requests.
106
+ - **Retrying the right failures:** retry 503s and timeouts, never 404s, and back off when the server says so.
107
+ - **Keeping the results:** write them somewhere as they arrive, and resume where you stopped if the run is interrupted.
108
+ - **Knowing what happened:** which requests failed, why, and after how many attempts.
109
+
110
+ reqstorm does all of that for you, with one call.
111
+
112
+ | Feature | What you get |
113
+ |---|---|
114
+ | One result per request | Failures are recorded, never raised; every attempt is kept |
115
+ | Rate limits | Per host, in any unit: `"10/s"`, `"100/min"`, `"1000/h"` |
116
+ | Concurrency | Overall and per host |
117
+ | Retries | Immediate, with backoff and `Retry-After`; plus end-of-run rounds |
118
+ | Reports | `summary()`, `errors()`, `to_dicts()` |
119
+ | Output | JSONL, CSV, SQLite, PostgreSQL, MySQL; ordered or as completed |
120
+ | Typed columns | JSON fields to checked columns, nested paths, arrays to rows |
121
+ | Resume | Skip what already succeeded after an interruption |
122
+ | Planning | `estimate()` before you start, progress with ETA while running |
123
+ | API | Blocking (scripts, Jupyter) and asyncio |
124
+ | Safety | TLS verified, timeouts on, bounded concurrency, fully typed |
125
+
126
+ ## Installation
127
+
128
+ ```console
129
+ $ python -m pip install reqstorm
130
+ ```
131
+
132
+ reqstorm supports Python 3.9 to 3.13 on Linux, macOS and Windows. Its only dependency is [aiohttp](https://docs.aiohttp.org). For PostgreSQL or MySQL output, install the driver you already use (`psycopg`, `psycopg2`, `pymysql`, `mysqlclient` or `mysql-connector-python`).
133
+
134
+ ## Quick start
135
+
136
+ Every function has a blocking version ending in `_sync` for scripts and notebooks, and an async version for code that already runs an event loop. Both take the same options.
137
+
138
+ **In a script or a Jupyter notebook:**
139
+
140
+ ```python
141
+ import reqstorm
142
+
143
+ urls = [
144
+ f"https://httpbin.org/get?page={page}"
145
+ for page in range(1, 101)
146
+ ]
147
+
148
+ results = reqstorm.fetch_all_sync(
149
+ urls,
150
+ concurrency=20,
151
+ timeout=10,
152
+ retries=2,
153
+ )
154
+
155
+ for result in results:
156
+ if result.ok:
157
+ print(result.url, result.status)
158
+ else:
159
+ print(result.url, "failed:",
160
+ result.error or result.status)
161
+ ```
162
+
163
+ **In async code:**
164
+
165
+ ```python
166
+ import asyncio
167
+ import reqstorm
168
+
169
+ async def main():
170
+ results = await reqstorm.fetch_all(
171
+ urls, concurrency=20, timeout=10
172
+ )
173
+ print(results.summary())
174
+
175
+ asyncio.run(main())
176
+ ```
177
+
178
+ | Blocking | async | What it does |
179
+ |---|---|---|
180
+ | `fetch_all_sync` | `fetch_all` | Returns a `Results` list, one `Result` per request |
181
+ | `stream_sync` | `stream` | Yields each result as soon as it completes |
182
+ | `fetch_to_file_sync` | `fetch_to_file` | Writes results to JSONL, CSV or SQLite |
183
+ | `fetch_to_db_sync` | `fetch_to_db` | Inserts results into SQLite, PostgreSQL or MySQL |
184
+ | `estimate` | | Predicts how long a batch takes |
185
+
186
+ ## Results and reports
187
+
188
+ `fetch_all` returns a `Results` list with one `Result` per request, in the order you gave them. A failed request never raises an exception; it is recorded on its result.
189
+
190
+ | `Result` attribute | Meaning |
191
+ |---|---|
192
+ | `ok` | `True` when a response arrived with a status below 400 |
193
+ | `status`, `headers`, `body` | The response; `status` is `None` when none arrived |
194
+ | `text()`, `json()` | The body as text (using the response charset) or as JSON |
195
+ | `error` | The exception that prevented a response, or `None` |
196
+ | `attempts`, `elapsed` | Number of attempts and total seconds |
197
+ | `history` | Every attempt: number, status, error and duration |
198
+ | `url`, `method` | What was requested |
199
+ | `final_url` | Where redirects ended |
200
+ | `index` | Position in your input |
201
+
202
+ ```python
203
+ results.succeeded # successful results
204
+ results.failed # failed results
205
+ results.summary() # counts, failures grouped by reason
206
+ results.errors() # failed requests as plain dicts
207
+ results.to_dicts() # every result as a dict
208
+ ```
209
+
210
+ `errors()` returns plain data, ready for a log file or a DataFrame:
211
+
212
+ ```json
213
+ {
214
+ "index": 12,
215
+ "method": "GET",
216
+ "url": "https://api.example.com/items/12",
217
+ "status": null,
218
+ "ok": false,
219
+ "error": "TimeoutError",
220
+ "attempts": 3,
221
+ "elapsed": 20.415,
222
+ "history": [
223
+ {"attempt": 1, "status": null,
224
+ "error": "TimeoutError", "elapsed": 10.001},
225
+ {"attempt": 2, "status": 503,
226
+ "error": null, "elapsed": 0.412},
227
+ {"attempt": 3, "status": null,
228
+ "error": "TimeoutError", "elapsed": 10.002}
229
+ ]
230
+ }
231
+ ```
232
+
233
+ Prefer exceptions? `result.raise_for_error()` raises the request's error, or `reqstorm.HTTPStatusError` for a status of 400 or above.
234
+
235
+ ## Rate limits and concurrency
236
+
237
+ ```python
238
+ results = reqstorm.fetch_all_sync(
239
+ urls,
240
+ rate_limit="100/min", # per host
241
+ concurrency=50, # in flight, in total
242
+ concurrency_per_host=10, # in flight, per host
243
+ )
244
+ ```
245
+
246
+ | `rate_limit` value | Means |
247
+ |---|---|
248
+ | `5` or `0.5` | Requests per second |
249
+ | `"10/s"` | 10 per second |
250
+ | `"100/min"` | 100 per minute |
251
+ | `"30/5min"` | 30 every 5 minutes |
252
+ | `"1000/h"` | 1000 per hour |
253
+ | `"2/day"` | 2 per day |
254
+ | `(100, 60)` | 100 every 60 seconds |
255
+
256
+ The limit applies to each host (`host:port`) separately and counts retries too, so requests to different APIs never slow each other down. Requests to one host are spaced evenly.
257
+
258
+ **Plan before you send.** `estimate` predicts the duration and names the bottleneck, without sending anything:
259
+
260
+ ```python
261
+ >>> print(reqstorm.estimate(7000, rate_limit="100/min"))
262
+ 7000 requests: about 1h 10m (limited by rate_limit)
263
+ >>> print(reqstorm.estimate(
264
+ ... 7000, rate_limit="100/min", hosts=7))
265
+ 7000 requests: about 10m 00s (limited by rate_limit)
266
+ ```
267
+
268
+ **Watch it run.** `progress=True` prints progress to stderr:
269
+
270
+ ```text
271
+ reqstorm: 3500/7000 (50%) ok 3493 failed 7
272
+ 1.7 req/s ETA 35m 00s
273
+ ```
274
+
275
+ ## Timeouts and retries
276
+
277
+ ```python
278
+ results = reqstorm.fetch_all_sync(
279
+ urls,
280
+ timeout=10, # seconds per attempt
281
+ retries=3, # retry right away...
282
+ backoff=0.5, # ...0.5 s, 1 s, 2 s apart
283
+ retry_rounds=2, # then resend what still
284
+ retry_round_delay=30, # failed, 30 s later
285
+ )
286
+ ```
287
+
288
+ - **`timeout`** is the time allowed for one attempt, including reading the body (default 30 s; `None` disables it). A slow server only fails its own requests.
289
+ - **`retries`** retries a request right away. The delay starts at `backoff` and doubles; a `Retry-After` header (up to 60 s) takes precedence.
290
+ - **`retry_rounds`** holds back requests that still failed for a retryable reason and sends them again after the rest of the batch, for temporary outages. Each request still produces exactly one result, with all its attempts in `history`.
291
+
292
+ | Retried | Not retried |
293
+ |---|---|
294
+ | Connection errors and dropped connections | Invalid URLs |
295
+ | Timeouts | 4xx such as 400, 401, 403, 404 |
296
+ | `retry_statuses` (default 429, 500, 502, 503, 504) | Other statuses |
297
+
298
+ ## Writing results to a file
299
+
300
+ For large batches, write results as they arrive instead of keeping them in memory:
301
+
302
+ ```python
303
+ summary = reqstorm.fetch_to_file_sync(
304
+ urls,
305
+ "results.jsonl",
306
+ rate_limit="10/s",
307
+ progress=True,
308
+ resume=True,
309
+ )
310
+ print(summary.ok, summary.failed, summary.skipped)
311
+ print(summary.errors[:5]) # failed records
312
+ ```
313
+
314
+ - **Format** follows the file name: `.jsonl`, `.csv`, or `.db` / `.sqlite` / `.sqlite3` for SQLite. Pass `format=` to override it.
315
+ - **Order:** records are written as requests complete; `ordered=True` writes them in input order instead (finished records wait in memory for the ones before them).
316
+ - **Resume:** with `resume=True`, requests already recorded as successful are skipped and the rest are sent and appended. A line cut short by an interruption is ignored. Without `resume`, a JSONL or CSV file is overwritten.
317
+ - **Fields:** `index`, `method`, `url`, `status`, `ok`, `error`, `attempts`, `elapsed`, `final_url`, `history` and `body`.
318
+ - **Body:** `body="text"` (default), `"base64"` for binary responses, or `"none"`. `include_headers=True` adds the response headers.
319
+
320
+ ## Writing results to a database
321
+
322
+ `fetch_to_db` inserts one row per request through a connection you already have:
323
+
324
+ ```python
325
+ import psycopg
326
+ import reqstorm
327
+
328
+ with psycopg.connect("dbname=crawl") as connection:
329
+ summary = reqstorm.fetch_to_db_sync(
330
+ urls,
331
+ connection,
332
+ table="api_results",
333
+ resume=True,
334
+ )
335
+ ```
336
+
337
+ Supported drivers: `sqlite3`, `psycopg` and `psycopg2` (PostgreSQL), `pymysql`, `MySQLdb` and `mysql.connector` (MySQL). For SQLite a file name is enough: `fetch_to_file_sync(urls, "results.db")`.
338
+
339
+ reqstorm creates the table if it does not exist. The fields you filter on are real columns; the parts whose shape varies are JSON:
340
+
341
+ | Column | PostgreSQL | MySQL | SQLite |
342
+ |---|---|---|---|
343
+ | `id` | `BIGSERIAL` | `BIGINT AUTO_INCREMENT` | `INTEGER` |
344
+ | `request_index`, `status`, `attempts` | `INTEGER` | `INT` | `INTEGER` |
345
+ | `method`, `url`, `error`, `final_url` | `TEXT` | `VARCHAR` / `LONGTEXT` | `TEXT` |
346
+ | `ok` | `BOOLEAN` | `BOOLEAN` | `INTEGER` |
347
+ | `elapsed` | `DOUBLE PRECISION` | `DOUBLE` | `REAL` |
348
+ | `history`, `headers` | `JSONB` | `JSON` | `TEXT` |
349
+ | `body` | `TEXT` / `BYTEA` | `LONGTEXT` / `LONGBLOB` | `TEXT` / `BLOB` |
350
+ | `created_at` | `TIMESTAMPTZ` | `TIMESTAMP` | `TEXT` |
351
+
352
+ So the questions you ask afterwards are plain SQL:
353
+
354
+ ```sql
355
+ SELECT url, error FROM api_results WHERE NOT ok;
356
+ SELECT status, count(*) FROM api_results
357
+ GROUP BY status;
358
+ ```
359
+
360
+ Rows are inserted in batches on a background thread, so a remote database does not slow the requests down. Existing rows are never deleted. `body="bytes"` stores the raw body in a binary column.
361
+
362
+ ## Structured data: JSON to typed columns
363
+
364
+ Instead of storing raw responses, give a schema and each JSON field goes to its own typed column. Every value is checked first, so a number column never gets a string or a `NaN`.
365
+
366
+ ```python
367
+ from reqstorm import Field
368
+
369
+ schema = {
370
+ "id": Field("id", int, required=True, key=True),
371
+ "name": Field("name", str, required=True),
372
+ "price": Field("pricing.amount", float),
373
+ "tags": Field("tags", "json"),
374
+ "updated": Field("updated_at", "datetime"),
375
+ "page": Field("$.meta.page", int),
376
+ }
377
+
378
+ summary = reqstorm.fetch_to_db_sync(
379
+ urls,
380
+ connection,
381
+ table="products",
382
+ schema=schema,
383
+ explode="items", # one row per array element
384
+ rejects_table="products_rejects",
385
+ )
386
+ print(summary.rows, summary.rejected)
387
+ ```
388
+
389
+ - **Types:** `int`, `float`, `str`, `bool`, `"datetime"` and `"json"` become `BIGINT`, `DOUBLE PRECISION`, `TEXT`, `BOOLEAN`, `TIMESTAMPTZ` and `JSONB` in PostgreSQL, and their equivalents in MySQL and SQLite.
390
+ - **Strict checks:** `10.5` is rejected for an `int`, `"42"` for a number, and NaN or Infinity always. With `coerce=True`, compatible values such as `"12.5"` are converted.
391
+ - **Nested JSON:** dotted paths read nested values (`"pricing.amount"`, `"tags.0"`). Type `"json"` keeps a part whole. `explode` makes each array element a row, and `"$."` paths read from the response root.
392
+ - **Rejected records** are never written, not even partly. They are listed in `summary.errors` with their reasons and, with `rejects_table`, stored with the original record.
393
+ - **No duplicates:** `key=True` fields form the primary key. Running the batch again updates existing rows.
394
+ - **Resume works:** each row records the request it came from (`source_url`).
395
+
396
+ The same schema works for `.db`, `.jsonl` and `.csv` files, and `reqstorm.extract(results, schema)` returns the rows as Python lists. `reqstorm.infer_schema(samples)` drafts a schema from a few responses for you to review.
397
+
398
+ More in the [structured data guide](https://reqstorm.github.io/guide/structured-data/).
399
+
400
+ ## Requests, headers and bodies
401
+
402
+ Options given to `fetch_all` apply to every request; `reqstorm.Request` varies them per request:
403
+
404
+ ```python
405
+ results = reqstorm.fetch_all_sync(
406
+ [
407
+ "https://api.example.com/items/1",
408
+ reqstorm.Request(
409
+ "https://api.example.com/items",
410
+ method="POST",
411
+ json={"name": "new"},
412
+ ),
413
+ ],
414
+ headers={"Authorization": "Bearer ..."},
415
+ )
416
+ ```
417
+
418
+ - `fetch_all_sync(urls, "POST", json=...)` sends every plain URL as a POST.
419
+ - `params`, `json` and `data` work the same way.
420
+ - A `Request`'s own headers are merged on top of the shared ones; its other fields replace the shared value.
421
+
422
+ ## Streaming results
423
+
424
+ `stream_sync` (or `stream` in async code) yields each result as soon as it completes. URLs can come from a generator, which is read lazily, so millions of URLs never need to fit in memory:
425
+
426
+ ```python
427
+ def read_urls():
428
+ with open("urls.txt") as file:
429
+ for line in file:
430
+ yield line.strip()
431
+
432
+ for result in reqstorm.stream_sync(read_urls()):
433
+ save(result.url, result.status, result.body)
434
+ ```
435
+
436
+ Leaving the loop early (`break`) cancels the requests still in flight. `fetch_all` also accepts `callback=`, called with each result as it completes.
437
+
438
+ ## TLS and sessions
439
+
440
+ TLS certificates are verified by default. To trust a private certificate authority, pass an `ssl.SSLContext`:
441
+
442
+ ```python
443
+ import ssl
444
+
445
+ context = ssl.create_default_context(
446
+ cafile="internal-ca.pem"
447
+ )
448
+ results = reqstorm.fetch_all_sync(urls, ssl=context)
449
+ ```
450
+
451
+ `verify_ssl=False` turns verification off; only use it for hosts you control.
452
+
453
+ In async code, `session=` takes an existing `aiohttp.ClientSession` to share cookies, connection pools or proxy settings. reqstorm does not close it.
454
+
455
+ ## When to use something else
456
+
457
+ reqstorm is built for batches. For other jobs, these are better fits:
458
+
459
+ | You need | Use |
460
+ |---|---|
461
+ | A few requests, simple scripts | [requests](https://requests.readthedocs.io) |
462
+ | A general HTTP client, sync or async, HTTP/2 | [httpx](https://www.python-httpx.org) |
463
+ | Full control over an asyncio HTTP client or server | [aiohttp](https://docs.aiohttp.org) |
464
+ | Crawling websites (following links, parsing pages) | [Scrapy](https://scrapy.org) |
465
+
466
+ reqstorm uses aiohttp underneath and adds the batch work: pacing, retries, failure isolation, reports and output.
467
+
468
+ ## Coming from reqt
469
+
470
+ reqstorm 2.0.1 is the next version of reqt, renamed because another project already uses the reqt name.
471
+
472
+ ```console
473
+ $ python -m pip install reqstorm
474
+ ```
475
+
476
+ Then replace `import reqt` with `import reqstorm`. The `reqt` package on PyPI now only installs reqstorm and re-exports it with a deprecation warning, so existing code keeps working while you switch.
477
+
478
+ The reqt 1.x style, which called a function with each raw response, still works with a `DeprecationWarning` and will be removed in reqstorm 3.0:
479
+
480
+ ```python
481
+ async def handle(response): # reqt 1.x style
482
+ print(response.status)
483
+
484
+ await reqstorm.fetch_all(urls=urls, method=handle)
485
+
486
+ # The same today:
487
+ for result in reqstorm.fetch_all_sync(urls):
488
+ print(result.status)
489
+ ```
490
+
491
+ Two things changed even in the 1.x style:
492
+
493
+ - **TLS certificates are now verified.** reqt 1.x skipped verification. Use `verify_ssl=False` only for hosts you control.
494
+ - **Failures no longer stop the batch.** Every failed request is logged to the `reqstorm` logger.
495
+
496
+ ## Development
497
+
498
+ ```console
499
+ $ git clone https://github.com/melihcolpan/reqstorm
500
+ $ cd reqstorm
501
+ $ python -m pip install -e ".[test,lint]"
502
+ $ pytest
503
+ $ ruff check . && ruff format --check . && mypy reqstorm
504
+ ```
505
+
506
+ - The tests run against a local server and a throwaway certificate authority; they need no network access.
507
+ - 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.
508
+ - The documentation is in `docs/` and builds with `pip install -r docs/requirements.txt && mkdocs serve`.
509
+
510
+ CI runs the tests on Python 3.9 to 3.13 (Linux, plus macOS and Windows), against real PostgreSQL and MySQL, and builds the package and the documentation. A GitHub release tagged `vX.Y.Z` publishes to PyPI through trusted publishing.
511
+
512
+ Bug reports and pull requests are welcome on [GitHub](https://github.com/melihcolpan/reqstorm/issues).
513
+
514
+ ## License
515
+
516
+ MIT, see [LICENSE](LICENSE).