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.
Files changed (24) hide show
  1. {newsdataapi-0.3.3 → newsdataapi-0.3.4}/PKG-INFO +20 -8
  2. {newsdataapi-0.3.3 → newsdataapi-0.3.4}/README.md +19 -7
  3. {newsdataapi-0.3.3 → newsdataapi-0.3.4}/src/newsdataapi/__init__.py +5 -1
  4. {newsdataapi-0.3.3 → newsdataapi-0.3.4}/src/newsdataapi/client.py +19 -9
  5. {newsdataapi-0.3.3 → newsdataapi-0.3.4}/src/newsdataapi/exceptions.py +16 -1
  6. {newsdataapi-0.3.3 → newsdataapi-0.3.4}/tests/test_unit.py +34 -3
  7. {newsdataapi-0.3.3 → newsdataapi-0.3.4}/.github/workflows/ci.yml +0 -0
  8. {newsdataapi-0.3.3 → newsdataapi-0.3.4}/.github/workflows/publish.yml +0 -0
  9. {newsdataapi-0.3.3 → newsdataapi-0.3.4}/.gitignore +0 -0
  10. {newsdataapi-0.3.3 → newsdataapi-0.3.4}/.python-version +0 -0
  11. {newsdataapi-0.3.3 → newsdataapi-0.3.4}/LICENSE +0 -0
  12. {newsdataapi-0.3.3 → newsdataapi-0.3.4}/examples/basic_usage.py +0 -0
  13. {newsdataapi-0.3.3 → newsdataapi-0.3.4}/examples/websocket_usage.py +0 -0
  14. {newsdataapi-0.3.3 → newsdataapi-0.3.4}/newsdata-logo.png +0 -0
  15. {newsdataapi-0.3.3 → newsdataapi-0.3.4}/pyproject.toml +0 -0
  16. {newsdataapi-0.3.3 → newsdataapi-0.3.4}/src/newsdataapi/constants.py +0 -0
  17. {newsdataapi-0.3.3 → newsdataapi-0.3.4}/src/newsdataapi/csv_export.py +0 -0
  18. {newsdataapi-0.3.3 → newsdataapi-0.3.4}/src/newsdataapi/py.typed +0 -0
  19. {newsdataapi-0.3.3 → newsdataapi-0.3.4}/src/newsdataapi/websocket.py +0 -0
  20. {newsdataapi-0.3.3 → newsdataapi-0.3.4}/tests/__init__.py +0 -0
  21. {newsdataapi-0.3.3 → newsdataapi-0.3.4}/tests/conftest.py +0 -0
  22. {newsdataapi-0.3.3 → newsdataapi-0.3.4}/tests/test_integration.py +0 -0
  23. {newsdataapi-0.3.3 → newsdataapi-0.3.4}/tests/test_websocket.py +0 -0
  24. {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
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 (also a ValueError; carries .param)
246
- ├── NewsdataAPIError (carries .status_code, .response_body)
247
- │ ├── NewsdataAuthError (401 / 403)
248
- │ ├── NewsdataRateLimitError (429; carries .retry_after)
249
- │ └── NewsdataServerError (5xx)
250
- ├── NewsdataNetworkError (carries .original)
251
- └── NewsdataWebSocketError (real-time stream)
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 (also a ValueError; carries .param)
194
- ├── NewsdataAPIError (carries .status_code, .response_body)
195
- │ ├── NewsdataAuthError (401 / 403)
196
- │ ├── NewsdataRateLimitError (429; carries .retry_after)
197
- │ └── NewsdataServerError (5xx)
198
- ├── NewsdataNetworkError (carries .original)
199
- └── NewsdataWebSocketError (real-time stream)
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.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
- _QUOTA_EXHAUSTED_CODES = frozenset({"ApiKeyLimitExceeded", "ApiLimitExceeded"})
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
- logger.debug(
1194
- "%d %s (%.2fs) X-API-Limit-Remaining=%s",
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
- if (
1234
- _extract_error_code(body) in _QUOTA_EXHAUSTED_CODES
1235
- or attempt >= self.max_retries
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("code", ["ApiKeyLimitExceeded", "ApiLimitExceeded"])
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(NewsdataRateLimitError) as exc_info:
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