newsdataapi 0.3.3__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.3 → newsdataapi-0.3.4}/PKG-INFO +20 -8
- {newsdataapi-0.3.3 → newsdataapi-0.3.4}/README.md +19 -7
- {newsdataapi-0.3.3 → newsdataapi-0.3.4}/src/newsdataapi/__init__.py +5 -1
- {newsdataapi-0.3.3 → newsdataapi-0.3.4}/src/newsdataapi/client.py +19 -9
- {newsdataapi-0.3.3 → newsdataapi-0.3.4}/src/newsdataapi/exceptions.py +16 -1
- {newsdataapi-0.3.3 → newsdataapi-0.3.4}/tests/test_unit.py +34 -3
- {newsdataapi-0.3.3 → newsdataapi-0.3.4}/.github/workflows/ci.yml +0 -0
- {newsdataapi-0.3.3 → newsdataapi-0.3.4}/.github/workflows/publish.yml +0 -0
- {newsdataapi-0.3.3 → newsdataapi-0.3.4}/.gitignore +0 -0
- {newsdataapi-0.3.3 → newsdataapi-0.3.4}/.python-version +0 -0
- {newsdataapi-0.3.3 → newsdataapi-0.3.4}/LICENSE +0 -0
- {newsdataapi-0.3.3 → newsdataapi-0.3.4}/examples/basic_usage.py +0 -0
- {newsdataapi-0.3.3 → newsdataapi-0.3.4}/examples/websocket_usage.py +0 -0
- {newsdataapi-0.3.3 → newsdataapi-0.3.4}/newsdata-logo.png +0 -0
- {newsdataapi-0.3.3 → newsdataapi-0.3.4}/pyproject.toml +0 -0
- {newsdataapi-0.3.3 → newsdataapi-0.3.4}/src/newsdataapi/constants.py +0 -0
- {newsdataapi-0.3.3 → newsdataapi-0.3.4}/src/newsdataapi/csv_export.py +0 -0
- {newsdataapi-0.3.3 → newsdataapi-0.3.4}/src/newsdataapi/py.typed +0 -0
- {newsdataapi-0.3.3 → newsdataapi-0.3.4}/src/newsdataapi/websocket.py +0 -0
- {newsdataapi-0.3.3 → newsdataapi-0.3.4}/tests/__init__.py +0 -0
- {newsdataapi-0.3.3 → newsdataapi-0.3.4}/tests/conftest.py +0 -0
- {newsdataapi-0.3.3 → newsdataapi-0.3.4}/tests/test_integration.py +0 -0
- {newsdataapi-0.3.3 → newsdataapi-0.3.4}/tests/test_websocket.py +0 -0
- {newsdataapi-0.3.3 → newsdataapi-0.3.4}/uv.lock +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
|
|
@@ -221,6 +221,8 @@ ws = NewsDataApiWebSocket(
|
|
|
221
221
|
```python
|
|
222
222
|
from newsdataapi import (
|
|
223
223
|
NewsdataAPIError,
|
|
224
|
+
NewsdataApiKeyLimitExceededError,
|
|
225
|
+
NewsdataApiLimitExceededError,
|
|
224
226
|
NewsdataAuthError,
|
|
225
227
|
NewsdataNetworkError,
|
|
226
228
|
NewsdataRateLimitError,
|
|
@@ -230,6 +232,10 @@ try:
|
|
|
230
232
|
client.latest_api(q="news")
|
|
231
233
|
except NewsdataAuthError as e:
|
|
232
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")
|
|
233
239
|
except NewsdataRateLimitError as e:
|
|
234
240
|
print(f"rate limited; retry after {e.retry_after}s")
|
|
235
241
|
except NewsdataAPIError as e:
|
|
@@ -242,18 +248,24 @@ The full hierarchy:
|
|
|
242
248
|
|
|
243
249
|
```
|
|
244
250
|
NewsdataException
|
|
245
|
-
├── NewsdataValidationError
|
|
246
|
-
├── NewsdataAPIError
|
|
247
|
-
│ ├── NewsdataAuthError
|
|
248
|
-
│ ├── NewsdataRateLimitError
|
|
249
|
-
│
|
|
250
|
-
|
|
251
|
-
└──
|
|
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)
|
|
252
260
|
└── NewsdataWebSocketAuthError (handshake 401 / 403, or policy-violation close 1008)
|
|
253
261
|
```
|
|
254
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
|
+
|
|
255
265
|
`NewsdataException` is always a valid catch-all.
|
|
256
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
|
+
|
|
257
269
|
## Save results to CSV
|
|
258
270
|
|
|
259
271
|
```python
|
|
@@ -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
|
|
@@ -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
|
|
|
@@ -19,6 +19,8 @@ import responses
|
|
|
19
19
|
from newsdataapi import (
|
|
20
20
|
NewsDataApiClient,
|
|
21
21
|
NewsdataAPIError,
|
|
22
|
+
NewsdataApiKeyLimitExceededError,
|
|
23
|
+
NewsdataApiLimitExceededError,
|
|
22
24
|
NewsDataApiWebSocket,
|
|
23
25
|
NewsdataAuthError,
|
|
24
26
|
NewsdataException,
|
|
@@ -728,6 +730,26 @@ def test_include_headers_merges_into_response(
|
|
|
728
730
|
assert response["response_headers"]["X-API-Limit-Remaining"] == "100"
|
|
729
731
|
|
|
730
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
|
+
|
|
731
753
|
def test_status_error_with_200_raises_api_error(
|
|
732
754
|
client: NewsDataApiClient,
|
|
733
755
|
mocked_responses: responses.RequestsMock,
|
|
@@ -835,18 +857,27 @@ def test_429_exhausted_raises_rate_limit_with_retry_after(
|
|
|
835
857
|
assert exc_info.value.retry_after == 5
|
|
836
858
|
|
|
837
859
|
|
|
838
|
-
@pytest.mark.parametrize(
|
|
860
|
+
@pytest.mark.parametrize(
|
|
861
|
+
("code", "expected_exc"),
|
|
862
|
+
[
|
|
863
|
+
("ApiLimitExceeded", NewsdataApiLimitExceededError),
|
|
864
|
+
("ApiKeyLimitExceeded", NewsdataApiKeyLimitExceededError),
|
|
865
|
+
],
|
|
866
|
+
)
|
|
839
867
|
def test_429_quota_exhausted_raises_without_retry(
|
|
840
868
|
code: str,
|
|
869
|
+
expected_exc: type[NewsdataRateLimitError],
|
|
841
870
|
client: NewsDataApiClient,
|
|
842
871
|
mocked_responses: responses.RequestsMock,
|
|
843
872
|
no_sleep: None,
|
|
844
873
|
) -> None:
|
|
845
|
-
"""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."""
|
|
846
876
|
body = {"status": "error", "results": {"message": "limit", "code": code}}
|
|
847
877
|
mocked_responses.get(LATEST_URL, json=body, status=429)
|
|
848
|
-
with pytest.raises(
|
|
878
|
+
with pytest.raises(expected_exc) as exc_info:
|
|
849
879
|
client.latest_api()
|
|
880
|
+
assert isinstance(exc_info.value, NewsdataRateLimitError)
|
|
850
881
|
assert len(mocked_responses.calls) == 1
|
|
851
882
|
assert exc_info.value.response_body == body
|
|
852
883
|
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|