newsdataapi 0.3.2__tar.gz → 0.3.4__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.
- {newsdataapi-0.3.2 → newsdataapi-0.3.4}/.github/workflows/ci.yml +1 -1
- {newsdataapi-0.3.2 → newsdataapi-0.3.4}/PKG-INFO +26 -12
- {newsdataapi-0.3.2 → newsdataapi-0.3.4}/README.md +21 -9
- {newsdataapi-0.3.2 → newsdataapi-0.3.4}/pyproject.toml +9 -4
- {newsdataapi-0.3.2 → newsdataapi-0.3.4}/src/newsdataapi/__init__.py +5 -1
- {newsdataapi-0.3.2 → newsdataapi-0.3.4}/src/newsdataapi/client.py +19 -9
- {newsdataapi-0.3.2 → newsdataapi-0.3.4}/src/newsdataapi/exceptions.py +16 -1
- {newsdataapi-0.3.2 → newsdataapi-0.3.4}/src/newsdataapi/websocket.py +19 -4
- {newsdataapi-0.3.2 → newsdataapi-0.3.4}/tests/conftest.py +4 -0
- {newsdataapi-0.3.2 → newsdataapi-0.3.4}/tests/test_unit.py +64 -3
- {newsdataapi-0.3.2 → newsdataapi-0.3.4}/uv.lock +639 -33
- {newsdataapi-0.3.2 → newsdataapi-0.3.4}/.github/workflows/publish.yml +0 -0
- {newsdataapi-0.3.2 → newsdataapi-0.3.4}/.gitignore +0 -0
- {newsdataapi-0.3.2 → newsdataapi-0.3.4}/.python-version +0 -0
- {newsdataapi-0.3.2 → newsdataapi-0.3.4}/LICENSE +0 -0
- {newsdataapi-0.3.2 → newsdataapi-0.3.4}/examples/basic_usage.py +0 -0
- {newsdataapi-0.3.2 → newsdataapi-0.3.4}/examples/websocket_usage.py +0 -0
- {newsdataapi-0.3.2 → newsdataapi-0.3.4}/newsdata-logo.png +0 -0
- {newsdataapi-0.3.2 → newsdataapi-0.3.4}/src/newsdataapi/constants.py +0 -0
- {newsdataapi-0.3.2 → newsdataapi-0.3.4}/src/newsdataapi/csv_export.py +0 -0
- {newsdataapi-0.3.2 → newsdataapi-0.3.4}/src/newsdataapi/py.typed +0 -0
- {newsdataapi-0.3.2 → newsdataapi-0.3.4}/tests/__init__.py +0 -0
- {newsdataapi-0.3.2 → newsdataapi-0.3.4}/tests/test_integration.py +0 -0
- {newsdataapi-0.3.2 → newsdataapi-0.3.4}/tests/test_websocket.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: newsdataapi
|
|
3
|
-
Version: 0.3.
|
|
3
|
+
Version: 0.3.4
|
|
4
4
|
Summary: Official Python client (SDK) for the Newsdata.io News API — fetch real-time, historical, crypto, and stock-market news via REST, with validation, retries, pagination, and typed exceptions.
|
|
5
5
|
Project-URL: Homepage, https://newsdata.io
|
|
6
6
|
Project-URL: Documentation, https://newsdata.io/documentation
|
|
@@ -35,6 +35,8 @@ Classifier: Intended Audience :: Developers
|
|
|
35
35
|
Classifier: License :: OSI Approved :: MIT License
|
|
36
36
|
Classifier: Operating System :: OS Independent
|
|
37
37
|
Classifier: Programming Language :: Python :: 3
|
|
38
|
+
Classifier: Programming Language :: Python :: 3.8
|
|
39
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
38
40
|
Classifier: Programming Language :: Python :: 3.10
|
|
39
41
|
Classifier: Programming Language :: Python :: 3.11
|
|
40
42
|
Classifier: Programming Language :: Python :: 3.12
|
|
@@ -43,9 +45,9 @@ Classifier: Programming Language :: Python :: 3.14
|
|
|
43
45
|
Classifier: Programming Language :: Python :: Implementation :: CPython
|
|
44
46
|
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
45
47
|
Classifier: Typing :: Typed
|
|
46
|
-
Requires-Python: >=3.
|
|
48
|
+
Requires-Python: >=3.8
|
|
47
49
|
Requires-Dist: requests<3,>=2.25
|
|
48
|
-
Requires-Dist: websockets<17,>=16
|
|
50
|
+
Requires-Dist: websockets<17,>=16; python_version >= '3.10'
|
|
49
51
|
Description-Content-Type: text/markdown
|
|
50
52
|
|
|
51
53
|
<div align="center">
|
|
@@ -77,7 +79,7 @@ If you use [uv](https://github.com/astral-sh/uv):
|
|
|
77
79
|
uv add newsdataapi
|
|
78
80
|
```
|
|
79
81
|
|
|
80
|
-
Supports Python 3.
|
|
82
|
+
Supports Python 3.8 through 3.14. The runtime dependencies are `requests` (REST) and `websockets` (real-time streaming). `NewsDataApiWebSocket` requires Python 3.10 or newer, so `websockets` is only installed there; on 3.8 and 3.9 the REST client works as usual.
|
|
81
83
|
|
|
82
84
|
## Quickstart
|
|
83
85
|
|
|
@@ -139,7 +141,7 @@ for page in client.latest_api(q="news", paginate=True, max_pages=5):
|
|
|
139
141
|
|
|
140
142
|
## Real-time news (WebSocket)
|
|
141
143
|
|
|
142
|
-
Register a query first — the returned `registration_id` identifies it from then on:
|
|
144
|
+
Requires Python 3.10 or newer. Register a query first — the returned `registration_id` identifies it from then on:
|
|
143
145
|
|
|
144
146
|
```python
|
|
145
147
|
from newsdataapi import NewsDataApiClient, NewsDataApiWebSocket
|
|
@@ -219,6 +221,8 @@ ws = NewsDataApiWebSocket(
|
|
|
219
221
|
```python
|
|
220
222
|
from newsdataapi import (
|
|
221
223
|
NewsdataAPIError,
|
|
224
|
+
NewsdataApiKeyLimitExceededError,
|
|
225
|
+
NewsdataApiLimitExceededError,
|
|
222
226
|
NewsdataAuthError,
|
|
223
227
|
NewsdataNetworkError,
|
|
224
228
|
NewsdataRateLimitError,
|
|
@@ -228,6 +232,10 @@ try:
|
|
|
228
232
|
client.latest_api(q="news")
|
|
229
233
|
except NewsdataAuthError as e:
|
|
230
234
|
print(f"bad API key (HTTP {e.status_code})")
|
|
235
|
+
except NewsdataApiKeyLimitExceededError:
|
|
236
|
+
print("this API key's own credit cap is exhausted")
|
|
237
|
+
except NewsdataApiLimitExceededError:
|
|
238
|
+
print("account credit pool is exhausted")
|
|
231
239
|
except NewsdataRateLimitError as e:
|
|
232
240
|
print(f"rate limited; retry after {e.retry_after}s")
|
|
233
241
|
except NewsdataAPIError as e:
|
|
@@ -240,18 +248,24 @@ The full hierarchy:
|
|
|
240
248
|
|
|
241
249
|
```
|
|
242
250
|
NewsdataException
|
|
243
|
-
├── NewsdataValidationError
|
|
244
|
-
├── NewsdataAPIError
|
|
245
|
-
│ ├── NewsdataAuthError
|
|
246
|
-
│ ├── NewsdataRateLimitError
|
|
247
|
-
│
|
|
248
|
-
|
|
249
|
-
└──
|
|
251
|
+
├── NewsdataValidationError (also a ValueError; carries .param)
|
|
252
|
+
├── NewsdataAPIError (carries .status_code, .response_body)
|
|
253
|
+
│ ├── NewsdataAuthError (401 / 403)
|
|
254
|
+
│ ├── NewsdataRateLimitError (429; carries .retry_after)
|
|
255
|
+
│ │ ├── NewsdataApiLimitExceededError (account credit pool exhausted)
|
|
256
|
+
│ │ └── NewsdataApiKeyLimitExceededError (this API key's own credit cap exhausted)
|
|
257
|
+
│ └── NewsdataServerError (5xx)
|
|
258
|
+
├── NewsdataNetworkError (carries .original)
|
|
259
|
+
└── NewsdataWebSocketError (real-time stream)
|
|
250
260
|
└── NewsdataWebSocketAuthError (handshake 401 / 403, or policy-violation close 1008)
|
|
251
261
|
```
|
|
252
262
|
|
|
263
|
+
`NewsdataApiLimitExceededError` and `NewsdataApiKeyLimitExceededError` are both subclasses of `NewsdataRateLimitError`, so existing code that only catches `NewsdataRateLimitError` keeps working unchanged — catch the specific subclasses only if you want to react differently to each. Neither is ever retried.
|
|
264
|
+
|
|
253
265
|
`NewsdataException` is always a valid catch-all.
|
|
254
266
|
|
|
267
|
+
When a request succeeds and `include_headers=True`, the response dict's `response_headers` also carries `X-API-Limit-Remaining` (account pool) and, for API keys with a credit cap, `X-APIKEY-Limit-Remaining` (that key's own remaining credits).
|
|
268
|
+
|
|
255
269
|
## Save results to CSV
|
|
256
270
|
|
|
257
271
|
```python
|
|
@@ -27,7 +27,7 @@ If you use [uv](https://github.com/astral-sh/uv):
|
|
|
27
27
|
uv add newsdataapi
|
|
28
28
|
```
|
|
29
29
|
|
|
30
|
-
Supports Python 3.
|
|
30
|
+
Supports Python 3.8 through 3.14. The runtime dependencies are `requests` (REST) and `websockets` (real-time streaming). `NewsDataApiWebSocket` requires Python 3.10 or newer, so `websockets` is only installed there; on 3.8 and 3.9 the REST client works as usual.
|
|
31
31
|
|
|
32
32
|
## Quickstart
|
|
33
33
|
|
|
@@ -89,7 +89,7 @@ for page in client.latest_api(q="news", paginate=True, max_pages=5):
|
|
|
89
89
|
|
|
90
90
|
## Real-time news (WebSocket)
|
|
91
91
|
|
|
92
|
-
Register a query first — the returned `registration_id` identifies it from then on:
|
|
92
|
+
Requires Python 3.10 or newer. Register a query first — the returned `registration_id` identifies it from then on:
|
|
93
93
|
|
|
94
94
|
```python
|
|
95
95
|
from newsdataapi import NewsDataApiClient, NewsDataApiWebSocket
|
|
@@ -169,6 +169,8 @@ ws = NewsDataApiWebSocket(
|
|
|
169
169
|
```python
|
|
170
170
|
from newsdataapi import (
|
|
171
171
|
NewsdataAPIError,
|
|
172
|
+
NewsdataApiKeyLimitExceededError,
|
|
173
|
+
NewsdataApiLimitExceededError,
|
|
172
174
|
NewsdataAuthError,
|
|
173
175
|
NewsdataNetworkError,
|
|
174
176
|
NewsdataRateLimitError,
|
|
@@ -178,6 +180,10 @@ try:
|
|
|
178
180
|
client.latest_api(q="news")
|
|
179
181
|
except NewsdataAuthError as e:
|
|
180
182
|
print(f"bad API key (HTTP {e.status_code})")
|
|
183
|
+
except NewsdataApiKeyLimitExceededError:
|
|
184
|
+
print("this API key's own credit cap is exhausted")
|
|
185
|
+
except NewsdataApiLimitExceededError:
|
|
186
|
+
print("account credit pool is exhausted")
|
|
181
187
|
except NewsdataRateLimitError as e:
|
|
182
188
|
print(f"rate limited; retry after {e.retry_after}s")
|
|
183
189
|
except NewsdataAPIError as e:
|
|
@@ -190,18 +196,24 @@ The full hierarchy:
|
|
|
190
196
|
|
|
191
197
|
```
|
|
192
198
|
NewsdataException
|
|
193
|
-
├── NewsdataValidationError
|
|
194
|
-
├── NewsdataAPIError
|
|
195
|
-
│ ├── NewsdataAuthError
|
|
196
|
-
│ ├── NewsdataRateLimitError
|
|
197
|
-
│
|
|
198
|
-
|
|
199
|
-
└──
|
|
199
|
+
├── NewsdataValidationError (also a ValueError; carries .param)
|
|
200
|
+
├── NewsdataAPIError (carries .status_code, .response_body)
|
|
201
|
+
│ ├── NewsdataAuthError (401 / 403)
|
|
202
|
+
│ ├── NewsdataRateLimitError (429; carries .retry_after)
|
|
203
|
+
│ │ ├── NewsdataApiLimitExceededError (account credit pool exhausted)
|
|
204
|
+
│ │ └── NewsdataApiKeyLimitExceededError (this API key's own credit cap exhausted)
|
|
205
|
+
│ └── NewsdataServerError (5xx)
|
|
206
|
+
├── NewsdataNetworkError (carries .original)
|
|
207
|
+
└── NewsdataWebSocketError (real-time stream)
|
|
200
208
|
└── NewsdataWebSocketAuthError (handshake 401 / 403, or policy-violation close 1008)
|
|
201
209
|
```
|
|
202
210
|
|
|
211
|
+
`NewsdataApiLimitExceededError` and `NewsdataApiKeyLimitExceededError` are both subclasses of `NewsdataRateLimitError`, so existing code that only catches `NewsdataRateLimitError` keeps working unchanged — catch the specific subclasses only if you want to react differently to each. Neither is ever retried.
|
|
212
|
+
|
|
203
213
|
`NewsdataException` is always a valid catch-all.
|
|
204
214
|
|
|
215
|
+
When a request succeeds and `include_headers=True`, the response dict's `response_headers` also carries `X-API-Limit-Remaining` (account pool) and, for API keys with a credit cap, `X-APIKEY-Limit-Remaining` (that key's own remaining credits).
|
|
216
|
+
|
|
205
217
|
## Save results to CSV
|
|
206
218
|
|
|
207
219
|
```python
|
|
@@ -5,11 +5,13 @@ dynamic = ["version"]
|
|
|
5
5
|
readme = "README.md"
|
|
6
6
|
license = { file = "LICENSE" }
|
|
7
7
|
authors = [{ name = "NewsData.io", email = "contact@newsdata.io" }]
|
|
8
|
-
requires-python = ">=3.
|
|
8
|
+
requires-python = ">=3.8"
|
|
9
9
|
|
|
10
10
|
dependencies = [
|
|
11
11
|
"requests>=2.25,<3",
|
|
12
|
-
|
|
12
|
+
# websockets 16 needs Python 3.10+. On 3.8/3.9 it is not installed: the
|
|
13
|
+
# REST client works there, and NewsDataApiWebSocket raises on construction.
|
|
14
|
+
"websockets>=16,<17; python_version >= '3.10'",
|
|
13
15
|
]
|
|
14
16
|
|
|
15
17
|
keywords = [
|
|
@@ -37,6 +39,8 @@ classifiers = [
|
|
|
37
39
|
"License :: OSI Approved :: MIT License",
|
|
38
40
|
"Operating System :: OS Independent",
|
|
39
41
|
"Programming Language :: Python :: 3",
|
|
42
|
+
"Programming Language :: Python :: 3.8",
|
|
43
|
+
"Programming Language :: Python :: 3.9",
|
|
40
44
|
"Programming Language :: Python :: 3.10",
|
|
41
45
|
"Programming Language :: Python :: 3.11",
|
|
42
46
|
"Programming Language :: Python :: 3.12",
|
|
@@ -90,7 +94,7 @@ addopts = [
|
|
|
90
94
|
|
|
91
95
|
[tool.ruff]
|
|
92
96
|
line-length = 100
|
|
93
|
-
target-version = "
|
|
97
|
+
target-version = "py38"
|
|
94
98
|
|
|
95
99
|
[tool.ruff.lint]
|
|
96
100
|
select = [
|
|
@@ -107,6 +111,7 @@ select = [
|
|
|
107
111
|
]
|
|
108
112
|
|
|
109
113
|
[tool.mypy]
|
|
110
|
-
|
|
114
|
+
# mypy no longer accepts 3.8 as a target; the 3.8 CI job covers it at runtime.
|
|
115
|
+
python_version = "3.9"
|
|
111
116
|
strict = true
|
|
112
117
|
files = ["src/newsdataapi"]
|
|
@@ -4,6 +4,8 @@ from .client import NewsDataApiClient
|
|
|
4
4
|
from .csv_export import save_to_csv
|
|
5
5
|
from .exceptions import (
|
|
6
6
|
NewsdataAPIError,
|
|
7
|
+
NewsdataApiKeyLimitExceededError,
|
|
8
|
+
NewsdataApiLimitExceededError,
|
|
7
9
|
NewsdataAuthError,
|
|
8
10
|
NewsdataException,
|
|
9
11
|
NewsdataNetworkError,
|
|
@@ -15,12 +17,14 @@ from .exceptions import (
|
|
|
15
17
|
)
|
|
16
18
|
from .websocket import NewsDataApiWebSocket
|
|
17
19
|
|
|
18
|
-
__version__ = "0.3.
|
|
20
|
+
__version__ = "0.3.4"
|
|
19
21
|
|
|
20
22
|
__all__ = [
|
|
21
23
|
"NewsDataApiClient",
|
|
22
24
|
"NewsDataApiWebSocket",
|
|
23
25
|
"NewsdataAPIError",
|
|
26
|
+
"NewsdataApiKeyLimitExceededError",
|
|
27
|
+
"NewsdataApiLimitExceededError",
|
|
24
28
|
"NewsdataAuthError",
|
|
25
29
|
"NewsdataException",
|
|
26
30
|
"NewsdataNetworkError",
|
|
@@ -18,6 +18,8 @@ from requests.exceptions import RequestException
|
|
|
18
18
|
from . import constants, csv_export
|
|
19
19
|
from .exceptions import (
|
|
20
20
|
NewsdataAPIError,
|
|
21
|
+
NewsdataApiKeyLimitExceededError,
|
|
22
|
+
NewsdataApiLimitExceededError,
|
|
21
23
|
NewsdataAuthError,
|
|
22
24
|
NewsdataException,
|
|
23
25
|
NewsdataNetworkError,
|
|
@@ -85,7 +87,10 @@ _MUTEX_GROUPS: tuple[tuple[str, ...], ...] = (
|
|
|
85
87
|
("domain", "domainurl", "excludedomain", "excludedomainurl"),
|
|
86
88
|
)
|
|
87
89
|
|
|
88
|
-
|
|
90
|
+
_QUOTA_EXHAUSTED_ERRORS: dict[str, type[NewsdataRateLimitError]] = {
|
|
91
|
+
"ApiLimitExceeded": NewsdataApiLimitExceededError,
|
|
92
|
+
"ApiKeyLimitExceeded": NewsdataApiKeyLimitExceededError,
|
|
93
|
+
}
|
|
89
94
|
|
|
90
95
|
|
|
91
96
|
def _validate_params(user_params: Mapping[str, Any]) -> dict[str, Any]:
|
|
@@ -1190,13 +1195,19 @@ class NewsDataApiClient:
|
|
|
1190
1195
|
continue
|
|
1191
1196
|
|
|
1192
1197
|
elapsed = time.perf_counter() - t0
|
|
1193
|
-
|
|
1194
|
-
|
|
1198
|
+
apikey_limit_remaining = response.headers.get("X-APIKEY-Limit-Remaining")
|
|
1199
|
+
log_msg = "%d %s (%.2fs) X-API-Limit-Remaining=%s"
|
|
1200
|
+
log_args = [
|
|
1195
1201
|
response.status_code,
|
|
1196
1202
|
response.reason,
|
|
1197
1203
|
elapsed,
|
|
1198
1204
|
response.headers.get("X-API-Limit-Remaining"),
|
|
1199
|
-
|
|
1205
|
+
]
|
|
1206
|
+
if apikey_limit_remaining is not None:
|
|
1207
|
+
# Only sent for API keys that have a credit cap set.
|
|
1208
|
+
log_msg += " X-APIKEY-Limit-Remaining=%s"
|
|
1209
|
+
log_args.append(apikey_limit_remaining)
|
|
1210
|
+
logger.debug(log_msg, *log_args)
|
|
1200
1211
|
|
|
1201
1212
|
try:
|
|
1202
1213
|
body: Any = response.json()
|
|
@@ -1230,11 +1241,10 @@ class NewsDataApiClient:
|
|
|
1230
1241
|
# Rate limit.
|
|
1231
1242
|
if response.status_code == 429:
|
|
1232
1243
|
retry_after = _parse_retry_after(response.headers.get("Retry-After"))
|
|
1233
|
-
|
|
1234
|
-
|
|
1235
|
-
|
|
1236
|
-
|
|
1237
|
-
raise NewsdataRateLimitError(
|
|
1244
|
+
error_code = _extract_error_code(body)
|
|
1245
|
+
exc_cls = _QUOTA_EXHAUSTED_ERRORS.get(error_code or "", NewsdataRateLimitError)
|
|
1246
|
+
if error_code in _QUOTA_EXHAUSTED_ERRORS or attempt >= self.max_retries:
|
|
1247
|
+
raise exc_cls(
|
|
1238
1248
|
body,
|
|
1239
1249
|
status_code=429,
|
|
1240
1250
|
response_body=body if isinstance(body, dict) else None,
|
|
@@ -40,7 +40,7 @@ class NewsdataAPIError(NewsdataException):
|
|
|
40
40
|
message: Any,
|
|
41
41
|
*,
|
|
42
42
|
status_code: int | None = None,
|
|
43
|
-
response_body: dict[str, Any] | None = None
|
|
43
|
+
response_body: dict[str, Any] | None = None
|
|
44
44
|
) -> None:
|
|
45
45
|
super().__init__(message)
|
|
46
46
|
self.status_code = status_code
|
|
@@ -76,6 +76,21 @@ class NewsdataRateLimitError(NewsdataAPIError):
|
|
|
76
76
|
self.retry_after = retry_after
|
|
77
77
|
|
|
78
78
|
|
|
79
|
+
class NewsdataApiLimitExceededError(NewsdataRateLimitError):
|
|
80
|
+
"""Raised on 429 when the account's overall credit pool is exhausted.
|
|
81
|
+
|
|
82
|
+
Blocks every API key on the account, regardless of any per-key cap.
|
|
83
|
+
Never retried.
|
|
84
|
+
"""
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
class NewsdataApiKeyLimitExceededError(NewsdataRateLimitError):
|
|
88
|
+
"""Raised on 429 when this specific API key's own credit cap
|
|
89
|
+
(``max_credit_limit``) is exhausted, even though the account's overall
|
|
90
|
+
pool may still have credits left. Never retried.
|
|
91
|
+
"""
|
|
92
|
+
|
|
93
|
+
|
|
79
94
|
class NewsdataServerError(NewsdataAPIError):
|
|
80
95
|
"""Raised on 5xx responses when retries are exhausted."""
|
|
81
96
|
|
|
@@ -11,14 +11,11 @@ from __future__ import annotations
|
|
|
11
11
|
import asyncio
|
|
12
12
|
import json
|
|
13
13
|
import logging
|
|
14
|
+
import sys
|
|
14
15
|
import time
|
|
15
16
|
from collections.abc import AsyncIterator, Iterator
|
|
16
17
|
from typing import Any
|
|
17
18
|
|
|
18
|
-
from websockets.asyncio import client as _asyncio_client
|
|
19
|
-
from websockets.exceptions import ConnectionClosedError, InvalidStatus
|
|
20
|
-
from websockets.sync import client as _sync_client
|
|
21
|
-
|
|
22
19
|
from . import constants
|
|
23
20
|
from .client import NewsDataApiClient, _redact_url, _validate_params
|
|
24
21
|
from .exceptions import (
|
|
@@ -27,6 +24,15 @@ from .exceptions import (
|
|
|
27
24
|
NewsdataWebSocketError,
|
|
28
25
|
)
|
|
29
26
|
|
|
27
|
+
# websockets requires Python 3.10+ and is not installed on older versions,
|
|
28
|
+
# where only the REST client is available.
|
|
29
|
+
_WEBSOCKET_SUPPORTED = sys.version_info >= (3, 10)
|
|
30
|
+
|
|
31
|
+
if _WEBSOCKET_SUPPORTED:
|
|
32
|
+
from websockets.asyncio import client as _asyncio_client
|
|
33
|
+
from websockets.exceptions import ConnectionClosedError, InvalidStatus
|
|
34
|
+
from websockets.sync import client as _sync_client
|
|
35
|
+
|
|
30
36
|
logger = logging.getLogger(__name__)
|
|
31
37
|
|
|
32
38
|
|
|
@@ -109,6 +115,9 @@ class NewsDataApiWebSocket:
|
|
|
109
115
|
always raises :class:`~newsdataapi.NewsdataWebSocketAuthError` and is
|
|
110
116
|
never retried.
|
|
111
117
|
|
|
118
|
+
Requires Python 3.10 or newer; on older versions the constructor raises
|
|
119
|
+
:class:`~newsdataapi.NewsdataWebSocketError`.
|
|
120
|
+
|
|
112
121
|
Args:
|
|
113
122
|
api_client: The :class:`~newsdataapi.NewsDataApiClient` that supplies
|
|
114
123
|
the API key and performs the management HTTP calls. Not closed by
|
|
@@ -147,6 +156,12 @@ class NewsDataApiWebSocket:
|
|
|
147
156
|
additional_headers: dict[str, str] | None = None,
|
|
148
157
|
proxy: str | None = None,
|
|
149
158
|
) -> None:
|
|
159
|
+
if not _WEBSOCKET_SUPPORTED:
|
|
160
|
+
raise NewsdataWebSocketError(
|
|
161
|
+
"NewsDataApiWebSocket requires Python 3.10 or newer (this is Python "
|
|
162
|
+
f"{sys.version_info.major}.{sys.version_info.minor}); "
|
|
163
|
+
"NewsDataApiClient works on Python 3.8+"
|
|
164
|
+
)
|
|
150
165
|
self.api_client = api_client
|
|
151
166
|
self._base_url = base_url
|
|
152
167
|
self._reconnect = reconnect
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
|
+
import sys
|
|
5
6
|
from collections.abc import Iterator
|
|
6
7
|
|
|
7
8
|
import pytest
|
|
@@ -9,6 +10,9 @@ import responses
|
|
|
9
10
|
|
|
10
11
|
from newsdataapi import NewsDataApiClient
|
|
11
12
|
|
|
13
|
+
# test_websocket.py imports websockets, which is only installed on Python 3.10+.
|
|
14
|
+
collect_ignore = ["test_websocket.py"] if sys.version_info < (3, 10) else []
|
|
15
|
+
|
|
12
16
|
|
|
13
17
|
@pytest.fixture
|
|
14
18
|
def mocked_responses() -> Iterator[responses.RequestsMock]:
|
|
@@ -7,6 +7,7 @@ API key.
|
|
|
7
7
|
|
|
8
8
|
from __future__ import annotations
|
|
9
9
|
|
|
10
|
+
import sys
|
|
10
11
|
from datetime import datetime, timedelta, timezone
|
|
11
12
|
from email.utils import format_datetime
|
|
12
13
|
from pathlib import Path
|
|
@@ -18,6 +19,8 @@ import responses
|
|
|
18
19
|
from newsdataapi import (
|
|
19
20
|
NewsDataApiClient,
|
|
20
21
|
NewsdataAPIError,
|
|
22
|
+
NewsdataApiKeyLimitExceededError,
|
|
23
|
+
NewsdataApiLimitExceededError,
|
|
21
24
|
NewsDataApiWebSocket,
|
|
22
25
|
NewsdataAuthError,
|
|
23
26
|
NewsdataException,
|
|
@@ -25,6 +28,7 @@ from newsdataapi import (
|
|
|
25
28
|
NewsdataRateLimitError,
|
|
26
29
|
NewsdataServerError,
|
|
27
30
|
NewsdataValidationError,
|
|
31
|
+
NewsdataWebSocketError,
|
|
28
32
|
save_to_csv,
|
|
29
33
|
)
|
|
30
34
|
from newsdataapi.client import (
|
|
@@ -726,6 +730,26 @@ def test_include_headers_merges_into_response(
|
|
|
726
730
|
assert response["response_headers"]["X-API-Limit-Remaining"] == "100"
|
|
727
731
|
|
|
728
732
|
|
|
733
|
+
def test_include_headers_merges_apikey_limit_header(
|
|
734
|
+
mocked_responses: responses.RequestsMock,
|
|
735
|
+
) -> None:
|
|
736
|
+
"""``X-APIKEY-Limit-Remaining`` is only sent for API keys with a credit
|
|
737
|
+
cap, but the client passes it through like any other header."""
|
|
738
|
+
with NewsDataApiClient("k", include_headers=True, max_retries=1) as c:
|
|
739
|
+
mocked_responses.get(
|
|
740
|
+
LATEST_URL,
|
|
741
|
+
json={"status": "success", "results": []},
|
|
742
|
+
status=200,
|
|
743
|
+
headers={
|
|
744
|
+
"X-API-Limit-Remaining": "100",
|
|
745
|
+
"X-APIKEY-Limit-Remaining": "25",
|
|
746
|
+
},
|
|
747
|
+
)
|
|
748
|
+
response = c.latest_api()
|
|
749
|
+
assert isinstance(response, dict)
|
|
750
|
+
assert response["response_headers"]["X-APIKEY-Limit-Remaining"] == "25"
|
|
751
|
+
|
|
752
|
+
|
|
729
753
|
def test_status_error_with_200_raises_api_error(
|
|
730
754
|
client: NewsDataApiClient,
|
|
731
755
|
mocked_responses: responses.RequestsMock,
|
|
@@ -833,18 +857,27 @@ def test_429_exhausted_raises_rate_limit_with_retry_after(
|
|
|
833
857
|
assert exc_info.value.retry_after == 5
|
|
834
858
|
|
|
835
859
|
|
|
836
|
-
@pytest.mark.parametrize(
|
|
860
|
+
@pytest.mark.parametrize(
|
|
861
|
+
("code", "expected_exc"),
|
|
862
|
+
[
|
|
863
|
+
("ApiLimitExceeded", NewsdataApiLimitExceededError),
|
|
864
|
+
("ApiKeyLimitExceeded", NewsdataApiKeyLimitExceededError),
|
|
865
|
+
],
|
|
866
|
+
)
|
|
837
867
|
def test_429_quota_exhausted_raises_without_retry(
|
|
838
868
|
code: str,
|
|
869
|
+
expected_exc: type[NewsdataRateLimitError],
|
|
839
870
|
client: NewsDataApiClient,
|
|
840
871
|
mocked_responses: responses.RequestsMock,
|
|
841
872
|
no_sleep: None,
|
|
842
873
|
) -> None:
|
|
843
|
-
"""A 429 whose error code means exhausted credits is never retried
|
|
874
|
+
"""A 429 whose error code means exhausted credits is never retried,
|
|
875
|
+
and raises the specific subclass for that code."""
|
|
844
876
|
body = {"status": "error", "results": {"message": "limit", "code": code}}
|
|
845
877
|
mocked_responses.get(LATEST_URL, json=body, status=429)
|
|
846
|
-
with pytest.raises(
|
|
878
|
+
with pytest.raises(expected_exc) as exc_info:
|
|
847
879
|
client.latest_api()
|
|
880
|
+
assert isinstance(exc_info.value, NewsdataRateLimitError)
|
|
848
881
|
assert len(mocked_responses.calls) == 1
|
|
849
882
|
assert exc_info.value.response_body == body
|
|
850
883
|
|
|
@@ -1348,7 +1381,23 @@ WS_REGISTER_URL = "https://newsdata.io/api/1/websocket/register"
|
|
|
1348
1381
|
WS_FETCH_URL = "https://newsdata.io/api/1/websocket/fetch"
|
|
1349
1382
|
WS_DELETE_URL = "https://newsdata.io/api/1/websocket/delete"
|
|
1350
1383
|
|
|
1384
|
+
# NewsDataApiWebSocket can't be constructed below Python 3.10.
|
|
1385
|
+
requires_websocket = pytest.mark.skipif(
|
|
1386
|
+
sys.version_info < (3, 10), reason="WebSocket support requires Python 3.10+"
|
|
1387
|
+
)
|
|
1388
|
+
|
|
1389
|
+
|
|
1390
|
+
def test_websocket_requires_python_310(
|
|
1391
|
+
client: NewsDataApiClient,
|
|
1392
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
1393
|
+
) -> None:
|
|
1394
|
+
"""Below Python 3.10, construction fails up front with a clear error."""
|
|
1395
|
+
monkeypatch.setattr("newsdataapi.websocket._WEBSOCKET_SUPPORTED", False)
|
|
1396
|
+
with pytest.raises(NewsdataWebSocketError, match="requires Python 3.10"):
|
|
1397
|
+
NewsDataApiWebSocket(client)
|
|
1398
|
+
|
|
1351
1399
|
|
|
1400
|
+
@requires_websocket
|
|
1352
1401
|
def test_websocket_register_posts_query_params(
|
|
1353
1402
|
client: NewsDataApiClient,
|
|
1354
1403
|
mocked_responses: responses.RequestsMock,
|
|
@@ -1375,6 +1424,7 @@ def test_websocket_register_posts_query_params(
|
|
|
1375
1424
|
assert not sent.body # params travel in the query string, not a body
|
|
1376
1425
|
|
|
1377
1426
|
|
|
1427
|
+
@requires_websocket
|
|
1378
1428
|
def test_websocket_register_always_sends_news_type_latest(
|
|
1379
1429
|
client: NewsDataApiClient,
|
|
1380
1430
|
mocked_responses: responses.RequestsMock,
|
|
@@ -1390,6 +1440,7 @@ def test_websocket_register_always_sends_news_type_latest(
|
|
|
1390
1440
|
assert "news_type=latest" in sent_url
|
|
1391
1441
|
|
|
1392
1442
|
|
|
1443
|
+
@requires_websocket
|
|
1393
1444
|
def test_websocket_register_mutex_validation_applies(
|
|
1394
1445
|
client: NewsDataApiClient,
|
|
1395
1446
|
) -> None:
|
|
@@ -1398,6 +1449,7 @@ def test_websocket_register_mutex_validation_applies(
|
|
|
1398
1449
|
NewsDataApiWebSocket(client).websocket_register(q="a", qInTitle="b")
|
|
1399
1450
|
|
|
1400
1451
|
|
|
1452
|
+
@requires_websocket
|
|
1401
1453
|
def test_websocket_register_raw_query(
|
|
1402
1454
|
client: NewsDataApiClient,
|
|
1403
1455
|
mocked_responses: responses.RequestsMock,
|
|
@@ -1414,6 +1466,7 @@ def test_websocket_register_raw_query(
|
|
|
1414
1466
|
assert "news_type=latest" in sent_url
|
|
1415
1467
|
|
|
1416
1468
|
|
|
1469
|
+
@requires_websocket
|
|
1417
1470
|
def test_websocket_register_raw_query_rejects_unknown_param(
|
|
1418
1471
|
client: NewsDataApiClient,
|
|
1419
1472
|
) -> None:
|
|
@@ -1422,6 +1475,7 @@ def test_websocket_register_raw_query_rejects_unknown_param(
|
|
|
1422
1475
|
NewsDataApiWebSocket(client).websocket_register(raw_query="q=pizza&size=10")
|
|
1423
1476
|
|
|
1424
1477
|
|
|
1478
|
+
@requires_websocket
|
|
1425
1479
|
def test_websocket_register_sends_excludedomainurl(
|
|
1426
1480
|
client: NewsDataApiClient,
|
|
1427
1481
|
mocked_responses: responses.RequestsMock,
|
|
@@ -1439,6 +1493,7 @@ def test_websocket_register_sends_excludedomainurl(
|
|
|
1439
1493
|
assert "excludedomainurl=bbc.com%2Ccnn.com" in sent_url
|
|
1440
1494
|
|
|
1441
1495
|
|
|
1496
|
+
@requires_websocket
|
|
1442
1497
|
def test_websocket_register_duplicate_409(
|
|
1443
1498
|
client: NewsDataApiClient,
|
|
1444
1499
|
mocked_responses: responses.RequestsMock,
|
|
@@ -1466,6 +1521,7 @@ def test_websocket_register_duplicate_409(
|
|
|
1466
1521
|
assert len(mocked_responses.calls) == 1 # 4xx is never retried
|
|
1467
1522
|
|
|
1468
1523
|
|
|
1524
|
+
@requires_websocket
|
|
1469
1525
|
def test_websocket_register_403_raises_auth(
|
|
1470
1526
|
client: NewsDataApiClient,
|
|
1471
1527
|
mocked_responses: responses.RequestsMock,
|
|
@@ -1476,6 +1532,7 @@ def test_websocket_register_403_raises_auth(
|
|
|
1476
1532
|
NewsDataApiWebSocket(client).websocket_register(q="pizza")
|
|
1477
1533
|
|
|
1478
1534
|
|
|
1535
|
+
@requires_websocket
|
|
1479
1536
|
def test_websocket_register_429_exhausts_to_rate_limit(
|
|
1480
1537
|
client: NewsDataApiClient,
|
|
1481
1538
|
mocked_responses: responses.RequestsMock,
|
|
@@ -1488,6 +1545,7 @@ def test_websocket_register_429_exhausts_to_rate_limit(
|
|
|
1488
1545
|
NewsDataApiWebSocket(client).websocket_register(q="pizza")
|
|
1489
1546
|
|
|
1490
1547
|
|
|
1548
|
+
@requires_websocket
|
|
1491
1549
|
def test_websocket_fetch_returns_queries(
|
|
1492
1550
|
client: NewsDataApiClient,
|
|
1493
1551
|
mocked_responses: responses.RequestsMock,
|
|
@@ -1518,6 +1576,7 @@ def test_websocket_fetch_returns_queries(
|
|
|
1518
1576
|
assert "apikey=test_key_xxx" in sent_url
|
|
1519
1577
|
|
|
1520
1578
|
|
|
1579
|
+
@requires_websocket
|
|
1521
1580
|
def test_websocket_delete_sends_registration_id(
|
|
1522
1581
|
client: NewsDataApiClient,
|
|
1523
1582
|
mocked_responses: responses.RequestsMock,
|
|
@@ -1537,6 +1596,7 @@ def test_websocket_delete_sends_registration_id(
|
|
|
1537
1596
|
assert "registration_id=abc123" in sent_url
|
|
1538
1597
|
|
|
1539
1598
|
|
|
1599
|
+
@requires_websocket
|
|
1540
1600
|
def test_websocket_delete_unknown_id_404(
|
|
1541
1601
|
client: NewsDataApiClient,
|
|
1542
1602
|
mocked_responses: responses.RequestsMock,
|
|
@@ -1547,6 +1607,7 @@ def test_websocket_delete_unknown_id_404(
|
|
|
1547
1607
|
assert exc_info.value.status_code == 404
|
|
1548
1608
|
|
|
1549
1609
|
|
|
1610
|
+
@requires_websocket
|
|
1550
1611
|
@pytest.mark.parametrize("bad_id", ["", None, 123])
|
|
1551
1612
|
def test_websocket_delete_rejects_bad_registration_id(
|
|
1552
1613
|
client: NewsDataApiClient,
|