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.
Files changed (24) hide show
  1. {newsdataapi-0.3.2 → newsdataapi-0.3.4}/.github/workflows/ci.yml +1 -1
  2. {newsdataapi-0.3.2 → newsdataapi-0.3.4}/PKG-INFO +26 -12
  3. {newsdataapi-0.3.2 → newsdataapi-0.3.4}/README.md +21 -9
  4. {newsdataapi-0.3.2 → newsdataapi-0.3.4}/pyproject.toml +9 -4
  5. {newsdataapi-0.3.2 → newsdataapi-0.3.4}/src/newsdataapi/__init__.py +5 -1
  6. {newsdataapi-0.3.2 → newsdataapi-0.3.4}/src/newsdataapi/client.py +19 -9
  7. {newsdataapi-0.3.2 → newsdataapi-0.3.4}/src/newsdataapi/exceptions.py +16 -1
  8. {newsdataapi-0.3.2 → newsdataapi-0.3.4}/src/newsdataapi/websocket.py +19 -4
  9. {newsdataapi-0.3.2 → newsdataapi-0.3.4}/tests/conftest.py +4 -0
  10. {newsdataapi-0.3.2 → newsdataapi-0.3.4}/tests/test_unit.py +64 -3
  11. {newsdataapi-0.3.2 → newsdataapi-0.3.4}/uv.lock +639 -33
  12. {newsdataapi-0.3.2 → newsdataapi-0.3.4}/.github/workflows/publish.yml +0 -0
  13. {newsdataapi-0.3.2 → newsdataapi-0.3.4}/.gitignore +0 -0
  14. {newsdataapi-0.3.2 → newsdataapi-0.3.4}/.python-version +0 -0
  15. {newsdataapi-0.3.2 → newsdataapi-0.3.4}/LICENSE +0 -0
  16. {newsdataapi-0.3.2 → newsdataapi-0.3.4}/examples/basic_usage.py +0 -0
  17. {newsdataapi-0.3.2 → newsdataapi-0.3.4}/examples/websocket_usage.py +0 -0
  18. {newsdataapi-0.3.2 → newsdataapi-0.3.4}/newsdata-logo.png +0 -0
  19. {newsdataapi-0.3.2 → newsdataapi-0.3.4}/src/newsdataapi/constants.py +0 -0
  20. {newsdataapi-0.3.2 → newsdataapi-0.3.4}/src/newsdataapi/csv_export.py +0 -0
  21. {newsdataapi-0.3.2 → newsdataapi-0.3.4}/src/newsdataapi/py.typed +0 -0
  22. {newsdataapi-0.3.2 → newsdataapi-0.3.4}/tests/__init__.py +0 -0
  23. {newsdataapi-0.3.2 → newsdataapi-0.3.4}/tests/test_integration.py +0 -0
  24. {newsdataapi-0.3.2 → newsdataapi-0.3.4}/tests/test_websocket.py +0 -0
@@ -21,7 +21,7 @@ jobs:
21
21
  strategy:
22
22
  fail-fast: false
23
23
  matrix:
24
- python-version: ["3.10", "3.11", "3.12", "3.13", "3.14"]
24
+ python-version: ["3.8", "3.9", "3.10", "3.11", "3.12", "3.13", "3.14"]
25
25
  steps:
26
26
  - uses: actions/checkout@v4
27
27
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: newsdataapi
3
- Version: 0.3.2
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.10
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.10 through 3.14. The runtime dependencies are `requests` (REST) and `websockets` (real-time streaming).
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 (also a ValueError; carries .param)
244
- ├── NewsdataAPIError (carries .status_code, .response_body)
245
- │ ├── NewsdataAuthError (401 / 403)
246
- │ ├── NewsdataRateLimitError (429; carries .retry_after)
247
- │ └── NewsdataServerError (5xx)
248
- ├── NewsdataNetworkError (carries .original)
249
- └── 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)
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.10 through 3.14. The runtime dependencies are `requests` (REST) and `websockets` (real-time streaming).
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 (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
@@ -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.10"
8
+ requires-python = ">=3.8"
9
9
 
10
10
  dependencies = [
11
11
  "requests>=2.25,<3",
12
- "websockets>=16,<17",
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 = "py310"
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
- python_version = "3.10"
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.2"
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
 
@@ -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("code", ["ApiKeyLimitExceeded", "ApiLimitExceeded"])
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(NewsdataRateLimitError) as exc_info:
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,