newsdataapi 0.3.1__tar.gz → 0.3.3__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.1 → newsdataapi-0.3.3}/.github/workflows/ci.yml +1 -1
  2. {newsdataapi-0.3.1 → newsdataapi-0.3.3}/PKG-INFO +7 -5
  3. {newsdataapi-0.3.1 → newsdataapi-0.3.3}/README.md +2 -2
  4. {newsdataapi-0.3.1 → newsdataapi-0.3.3}/pyproject.toml +9 -4
  5. {newsdataapi-0.3.1 → newsdataapi-0.3.3}/src/newsdataapi/__init__.py +1 -1
  6. {newsdataapi-0.3.1 → newsdataapi-0.3.3}/src/newsdataapi/client.py +18 -2
  7. {newsdataapi-0.3.1 → newsdataapi-0.3.3}/src/newsdataapi/websocket.py +21 -4
  8. {newsdataapi-0.3.1 → newsdataapi-0.3.3}/tests/conftest.py +4 -0
  9. {newsdataapi-0.3.1 → newsdataapi-0.3.3}/tests/test_unit.py +103 -1
  10. {newsdataapi-0.3.1 → newsdataapi-0.3.3}/uv.lock +639 -33
  11. {newsdataapi-0.3.1 → newsdataapi-0.3.3}/.github/workflows/publish.yml +0 -0
  12. {newsdataapi-0.3.1 → newsdataapi-0.3.3}/.gitignore +0 -0
  13. {newsdataapi-0.3.1 → newsdataapi-0.3.3}/.python-version +0 -0
  14. {newsdataapi-0.3.1 → newsdataapi-0.3.3}/LICENSE +0 -0
  15. {newsdataapi-0.3.1 → newsdataapi-0.3.3}/examples/basic_usage.py +0 -0
  16. {newsdataapi-0.3.1 → newsdataapi-0.3.3}/examples/websocket_usage.py +0 -0
  17. {newsdataapi-0.3.1 → newsdataapi-0.3.3}/newsdata-logo.png +0 -0
  18. {newsdataapi-0.3.1 → newsdataapi-0.3.3}/src/newsdataapi/constants.py +0 -0
  19. {newsdataapi-0.3.1 → newsdataapi-0.3.3}/src/newsdataapi/csv_export.py +0 -0
  20. {newsdataapi-0.3.1 → newsdataapi-0.3.3}/src/newsdataapi/exceptions.py +0 -0
  21. {newsdataapi-0.3.1 → newsdataapi-0.3.3}/src/newsdataapi/py.typed +0 -0
  22. {newsdataapi-0.3.1 → newsdataapi-0.3.3}/tests/__init__.py +0 -0
  23. {newsdataapi-0.3.1 → newsdataapi-0.3.3}/tests/test_integration.py +0 -0
  24. {newsdataapi-0.3.1 → newsdataapi-0.3.3}/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.1
3
+ Version: 0.3.3
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
@@ -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
@@ -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"]
@@ -15,7 +15,7 @@ from .exceptions import (
15
15
  )
16
16
  from .websocket import NewsDataApiWebSocket
17
17
 
18
- __version__ = "0.3.1"
18
+ __version__ = "0.3.3"
19
19
 
20
20
  __all__ = [
21
21
  "NewsDataApiClient",
@@ -47,6 +47,7 @@ _STRING_PARAMS = frozenset(
47
47
  "domain",
48
48
  "domainurl",
49
49
  "excludedomain",
50
+ "excludedomainurl",
50
51
  "timezone",
51
52
  "from_date",
52
53
  "to_date",
@@ -81,7 +82,7 @@ _MUTEX_GROUPS: tuple[tuple[str, ...], ...] = (
81
82
  ("country", "excludecountry"),
82
83
  ("category", "excludecategory"),
83
84
  ("language", "excludelanguage"),
84
- ("domain", "domainurl", "excludedomain"),
85
+ ("domain", "domainurl", "excludedomain", "excludedomainurl"),
85
86
  )
86
87
 
87
88
  _QUOTA_EXHAUSTED_CODES = frozenset({"ApiKeyLimitExceeded", "ApiLimitExceeded"})
@@ -103,7 +104,8 @@ def _validate_params(user_params: Mapping[str, Any]) -> dict[str, Any]:
103
104
  * Server-side mutex groups (``q``/``qInTitle``/``qInMeta``,
104
105
  ``country``/``excludecountry``, ``category``/``excludecategory``,
105
106
  ``language``/``excludelanguage``, and
106
- ``domain``/``domainurl``/``excludedomain``) are enforced client-side;
107
+ ``domain``/``domainurl``/``excludedomain``/``excludedomainurl``) are
108
+ enforced client-side;
107
109
  setting more than one from any group raises
108
110
  ``NewsdataValidationError`` before the request leaves.
109
111
  * ``sentiment_score`` requires ``sentiment`` to be set; passing
@@ -480,6 +482,7 @@ class NewsDataApiClient:
480
482
  domain: str | list[str] | None = None,
481
483
  domainurl: str | list[str] | None = None,
482
484
  excludedomain: str | list[str] | None = None,
485
+ excludedomainurl: str | list[str] | None = None,
483
486
  prioritydomain: str | None = None,
484
487
  timeframe: int | str | None = None,
485
488
  timezone: str | None = None,
@@ -528,6 +531,7 @@ class NewsDataApiClient:
528
531
  "domain": domain,
529
532
  "domainurl": domainurl,
530
533
  "excludedomain": excludedomain,
534
+ "excludedomainurl": excludedomainurl,
531
535
  "prioritydomain": prioritydomain,
532
536
  "timeframe": str(timeframe) if timeframe is not None else None,
533
537
  "timezone": timezone,
@@ -571,6 +575,7 @@ class NewsDataApiClient:
571
575
  domain: str | list[str] | None = None,
572
576
  domainurl: str | list[str] | None = None,
573
577
  excludedomain: str | list[str] | None = None,
578
+ excludedomainurl: str | list[str] | None = None,
574
579
  prioritydomain: str | None = None,
575
580
  timezone: str | None = None,
576
581
  size: int | None = None,
@@ -617,6 +622,7 @@ class NewsDataApiClient:
617
622
  "domain": domain,
618
623
  "domainurl": domainurl,
619
624
  "excludedomain": excludedomain,
625
+ "excludedomainurl": excludedomainurl,
620
626
  "prioritydomain": prioritydomain,
621
627
  "timezone": timezone,
622
628
  "size": size,
@@ -684,6 +690,7 @@ class NewsDataApiClient:
684
690
  domain: str | list[str] | None = None,
685
691
  domainurl: str | list[str] | None = None,
686
692
  excludedomain: str | list[str] | None = None,
693
+ excludedomainurl: str | list[str] | None = None,
687
694
  prioritydomain: str | None = None,
688
695
  timeframe: int | str | None = None,
689
696
  timezone: str | None = None,
@@ -723,6 +730,7 @@ class NewsDataApiClient:
723
730
  "domain": domain,
724
731
  "domainurl": domainurl,
725
732
  "excludedomain": excludedomain,
733
+ "excludedomainurl": excludedomainurl,
726
734
  "prioritydomain": prioritydomain,
727
735
  "timeframe": str(timeframe) if timeframe is not None else None,
728
736
  "timezone": timezone,
@@ -762,6 +770,7 @@ class NewsDataApiClient:
762
770
  domain: str | list[str] | None = None,
763
771
  domainurl: str | list[str] | None = None,
764
772
  excludedomain: str | list[str] | None = None,
773
+ excludedomainurl: str | list[str] | None = None,
765
774
  language: str | list[str] | None = None,
766
775
  excludelanguage: str | list[str] | None = None,
767
776
  prioritydomain: str | None = None,
@@ -807,6 +816,7 @@ class NewsDataApiClient:
807
816
  "domain": domain,
808
817
  "domainurl": domainurl,
809
818
  "excludedomain": excludedomain,
819
+ "excludedomainurl": excludedomainurl,
810
820
  "language": language,
811
821
  "excludelanguage": excludelanguage,
812
822
  "prioritydomain": prioritydomain,
@@ -854,6 +864,7 @@ class NewsDataApiClient:
854
864
  domain: str | list[str] | None = None,
855
865
  domainurl: str | list[str] | None = None,
856
866
  excludedomain: str | list[str] | None = None,
867
+ excludedomainurl: str | list[str] | None = None,
857
868
  full_content: bool | None = None,
858
869
  image: bool | None = None,
859
870
  video: bool | None = None,
@@ -901,6 +912,7 @@ class NewsDataApiClient:
901
912
  "domain": domain,
902
913
  "domainurl": domainurl,
903
914
  "excludedomain": excludedomain,
915
+ "excludedomainurl": excludedomainurl,
904
916
  "full_content": full_content,
905
917
  "image": image,
906
918
  "video": video,
@@ -940,6 +952,7 @@ class NewsDataApiClient:
940
952
  domain: str | list[str] | None = None,
941
953
  domainurl: str | list[str] | None = None,
942
954
  excludedomain: str | list[str] | None = None,
955
+ excludedomainurl: str | list[str] | None = None,
943
956
  full_content: bool | None = None,
944
957
  image: bool | None = None,
945
958
  video: bool | None = None,
@@ -978,6 +991,7 @@ class NewsDataApiClient:
978
991
  "domain": domain,
979
992
  "domainurl": domainurl,
980
993
  "excludedomain": excludedomain,
994
+ "excludedomainurl": excludedomainurl,
981
995
  "full_content": full_content,
982
996
  "image": image,
983
997
  "video": video,
@@ -1011,6 +1025,7 @@ class NewsDataApiClient:
1011
1025
  domain: str | list[str] | None = None,
1012
1026
  domainurl: str | list[str] | None = None,
1013
1027
  excludedomain: str | list[str] | None = None,
1028
+ excludedomainurl: str | list[str] | None = None,
1014
1029
  language: str | list[str] | None = None,
1015
1030
  excludelanguage: str | list[str] | None = None,
1016
1031
  full_content: bool | None = None,
@@ -1055,6 +1070,7 @@ class NewsDataApiClient:
1055
1070
  "domain": domain,
1056
1071
  "domainurl": domainurl,
1057
1072
  "excludedomain": excludedomain,
1073
+ "excludedomainurl": excludedomainurl,
1058
1074
  "language": language,
1059
1075
  "excludelanguage": excludelanguage,
1060
1076
  "full_content": full_content,
@@ -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
@@ -177,6 +192,7 @@ class NewsDataApiWebSocket:
177
192
  domain: str | list[str] | None = None,
178
193
  domainurl: str | list[str] | None = None,
179
194
  excludedomain: str | list[str] | None = None,
195
+ excludedomainurl: str | list[str] | None = None,
180
196
  prioritydomain: str | None = None,
181
197
  timezone: str | None = None,
182
198
  full_content: bool | None = None,
@@ -212,6 +228,7 @@ class NewsDataApiWebSocket:
212
228
  "domain": domain,
213
229
  "domainurl": domainurl,
214
230
  "excludedomain": excludedomain,
231
+ "excludedomainurl": excludedomainurl,
215
232
  "prioritydomain": prioritydomain,
216
233
  "timezone": timezone,
217
234
  "full_content": full_content,
@@ -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
@@ -25,6 +26,7 @@ from newsdataapi import (
25
26
  NewsdataRateLimitError,
26
27
  NewsdataServerError,
27
28
  NewsdataValidationError,
29
+ NewsdataWebSocketError,
28
30
  save_to_csv,
29
31
  )
30
32
  from newsdataapi.client import (
@@ -148,13 +150,23 @@ def test_validate_size_negative_rejected() -> None:
148
150
  ({"country": "us", "excludecountry": "gb"}, "country"),
149
151
  ({"category": "business", "excludecategory": "sports"}, "category"),
150
152
  ({"language": "en", "excludelanguage": "fr"}, "language"),
151
- # Domain 3-way mutex (any two of domain/domainurl/excludedomain)
153
+ # Domain 4-way mutex (any two of
154
+ # domain/domainurl/excludedomain/excludedomainurl)
152
155
  ({"domain": "cnn.com", "domainurl": "https://bbc.com"}, "domain"),
153
156
  ({"domain": "cnn.com", "excludedomain": "fox.com"}, "domain"),
154
157
  (
155
158
  {"domainurl": "https://bbc.com", "excludedomain": "fox.com"},
156
159
  "domainurl",
157
160
  ),
161
+ ({"domain": "cnn.com", "excludedomainurl": "fox.com"}, "domain"),
162
+ (
163
+ {"domainurl": "https://bbc.com", "excludedomainurl": "fox.com"},
164
+ "domainurl",
165
+ ),
166
+ (
167
+ {"excludedomain": "cnn.com", "excludedomainurl": "fox.com"},
168
+ "excludedomain",
169
+ ),
158
170
  (
159
171
  {
160
172
  "domain": "cnn.com",
@@ -1285,6 +1297,51 @@ def test_count_endpoint_url_resolves(
1285
1297
  assert len(mocked_responses.calls) == 1
1286
1298
 
1287
1299
 
1300
+ @pytest.mark.parametrize(
1301
+ ("method_name", "endpoint_path", "args"),
1302
+ [
1303
+ ("latest_api", "latest", ()),
1304
+ ("archive_api", "archive", ()),
1305
+ ("crypto_api", "crypto", ()),
1306
+ ("market_api", "market", ()),
1307
+ ("count_api", "count", ("2024-01-01", "2024-01-31")),
1308
+ ("crypto_count_api", "crypto/count", ("2024-01-01", "2024-01-31")),
1309
+ ("market_count_api", "market/count", ("2024-01-01", "2024-01-31")),
1310
+ ],
1311
+ )
1312
+ def test_excludedomainurl_sent_as_csv(
1313
+ client: NewsDataApiClient,
1314
+ mocked_responses: responses.RequestsMock,
1315
+ method_name: str,
1316
+ endpoint_path: str,
1317
+ args: tuple[str, ...],
1318
+ ) -> None:
1319
+ mocked_responses.get(
1320
+ f"https://newsdata.io/api/1/{endpoint_path}",
1321
+ json={"status": "success", "results": []},
1322
+ status=200,
1323
+ )
1324
+ getattr(client, method_name)(*args, excludedomainurl=["bbc.com", "cnn.com"])
1325
+ sent_url = mocked_responses.calls[0].request.url
1326
+ assert sent_url is not None
1327
+ assert "excludedomainurl=bbc.com%2Ccnn.com" in sent_url
1328
+
1329
+
1330
+ def test_raw_query_accepts_excludedomainurl(
1331
+ client: NewsDataApiClient,
1332
+ mocked_responses: responses.RequestsMock,
1333
+ ) -> None:
1334
+ mocked_responses.get(
1335
+ LATEST_URL,
1336
+ json={"status": "success", "results": []},
1337
+ status=200,
1338
+ )
1339
+ client.latest_api(raw_query="excludedomainurl=bbc.com")
1340
+ sent_url = mocked_responses.calls[0].request.url
1341
+ assert sent_url is not None
1342
+ assert "excludedomainurl=bbc.com" in sent_url
1343
+
1344
+
1288
1345
  # ===========================================================================
1289
1346
  # WebSocket query management (register / fetch / delete)
1290
1347
  # ===========================================================================
@@ -1293,7 +1350,23 @@ WS_REGISTER_URL = "https://newsdata.io/api/1/websocket/register"
1293
1350
  WS_FETCH_URL = "https://newsdata.io/api/1/websocket/fetch"
1294
1351
  WS_DELETE_URL = "https://newsdata.io/api/1/websocket/delete"
1295
1352
 
1353
+ # NewsDataApiWebSocket can't be constructed below Python 3.10.
1354
+ requires_websocket = pytest.mark.skipif(
1355
+ sys.version_info < (3, 10), reason="WebSocket support requires Python 3.10+"
1356
+ )
1357
+
1358
+
1359
+ def test_websocket_requires_python_310(
1360
+ client: NewsDataApiClient,
1361
+ monkeypatch: pytest.MonkeyPatch,
1362
+ ) -> None:
1363
+ """Below Python 3.10, construction fails up front with a clear error."""
1364
+ monkeypatch.setattr("newsdataapi.websocket._WEBSOCKET_SUPPORTED", False)
1365
+ with pytest.raises(NewsdataWebSocketError, match="requires Python 3.10"):
1366
+ NewsDataApiWebSocket(client)
1367
+
1296
1368
 
1369
+ @requires_websocket
1297
1370
  def test_websocket_register_posts_query_params(
1298
1371
  client: NewsDataApiClient,
1299
1372
  mocked_responses: responses.RequestsMock,
@@ -1320,6 +1393,7 @@ def test_websocket_register_posts_query_params(
1320
1393
  assert not sent.body # params travel in the query string, not a body
1321
1394
 
1322
1395
 
1396
+ @requires_websocket
1323
1397
  def test_websocket_register_always_sends_news_type_latest(
1324
1398
  client: NewsDataApiClient,
1325
1399
  mocked_responses: responses.RequestsMock,
@@ -1335,6 +1409,7 @@ def test_websocket_register_always_sends_news_type_latest(
1335
1409
  assert "news_type=latest" in sent_url
1336
1410
 
1337
1411
 
1412
+ @requires_websocket
1338
1413
  def test_websocket_register_mutex_validation_applies(
1339
1414
  client: NewsDataApiClient,
1340
1415
  ) -> None:
@@ -1343,6 +1418,7 @@ def test_websocket_register_mutex_validation_applies(
1343
1418
  NewsDataApiWebSocket(client).websocket_register(q="a", qInTitle="b")
1344
1419
 
1345
1420
 
1421
+ @requires_websocket
1346
1422
  def test_websocket_register_raw_query(
1347
1423
  client: NewsDataApiClient,
1348
1424
  mocked_responses: responses.RequestsMock,
@@ -1359,6 +1435,7 @@ def test_websocket_register_raw_query(
1359
1435
  assert "news_type=latest" in sent_url
1360
1436
 
1361
1437
 
1438
+ @requires_websocket
1362
1439
  def test_websocket_register_raw_query_rejects_unknown_param(
1363
1440
  client: NewsDataApiClient,
1364
1441
  ) -> None:
@@ -1367,6 +1444,25 @@ def test_websocket_register_raw_query_rejects_unknown_param(
1367
1444
  NewsDataApiWebSocket(client).websocket_register(raw_query="q=pizza&size=10")
1368
1445
 
1369
1446
 
1447
+ @requires_websocket
1448
+ def test_websocket_register_sends_excludedomainurl(
1449
+ client: NewsDataApiClient,
1450
+ mocked_responses: responses.RequestsMock,
1451
+ ) -> None:
1452
+ mocked_responses.post(
1453
+ WS_REGISTER_URL,
1454
+ json={"status": "success", "results": {"registration_id": "abc"}},
1455
+ status=200,
1456
+ )
1457
+ NewsDataApiWebSocket(client).websocket_register(
1458
+ q="pizza", excludedomainurl=["bbc.com", "cnn.com"]
1459
+ )
1460
+ sent_url = mocked_responses.calls[0].request.url
1461
+ assert sent_url is not None
1462
+ assert "excludedomainurl=bbc.com%2Ccnn.com" in sent_url
1463
+
1464
+
1465
+ @requires_websocket
1370
1466
  def test_websocket_register_duplicate_409(
1371
1467
  client: NewsDataApiClient,
1372
1468
  mocked_responses: responses.RequestsMock,
@@ -1394,6 +1490,7 @@ def test_websocket_register_duplicate_409(
1394
1490
  assert len(mocked_responses.calls) == 1 # 4xx is never retried
1395
1491
 
1396
1492
 
1493
+ @requires_websocket
1397
1494
  def test_websocket_register_403_raises_auth(
1398
1495
  client: NewsDataApiClient,
1399
1496
  mocked_responses: responses.RequestsMock,
@@ -1404,6 +1501,7 @@ def test_websocket_register_403_raises_auth(
1404
1501
  NewsDataApiWebSocket(client).websocket_register(q="pizza")
1405
1502
 
1406
1503
 
1504
+ @requires_websocket
1407
1505
  def test_websocket_register_429_exhausts_to_rate_limit(
1408
1506
  client: NewsDataApiClient,
1409
1507
  mocked_responses: responses.RequestsMock,
@@ -1416,6 +1514,7 @@ def test_websocket_register_429_exhausts_to_rate_limit(
1416
1514
  NewsDataApiWebSocket(client).websocket_register(q="pizza")
1417
1515
 
1418
1516
 
1517
+ @requires_websocket
1419
1518
  def test_websocket_fetch_returns_queries(
1420
1519
  client: NewsDataApiClient,
1421
1520
  mocked_responses: responses.RequestsMock,
@@ -1446,6 +1545,7 @@ def test_websocket_fetch_returns_queries(
1446
1545
  assert "apikey=test_key_xxx" in sent_url
1447
1546
 
1448
1547
 
1548
+ @requires_websocket
1449
1549
  def test_websocket_delete_sends_registration_id(
1450
1550
  client: NewsDataApiClient,
1451
1551
  mocked_responses: responses.RequestsMock,
@@ -1465,6 +1565,7 @@ def test_websocket_delete_sends_registration_id(
1465
1565
  assert "registration_id=abc123" in sent_url
1466
1566
 
1467
1567
 
1568
+ @requires_websocket
1468
1569
  def test_websocket_delete_unknown_id_404(
1469
1570
  client: NewsDataApiClient,
1470
1571
  mocked_responses: responses.RequestsMock,
@@ -1475,6 +1576,7 @@ def test_websocket_delete_unknown_id_404(
1475
1576
  assert exc_info.value.status_code == 404
1476
1577
 
1477
1578
 
1579
+ @requires_websocket
1478
1580
  @pytest.mark.parametrize("bad_id", ["", None, 123])
1479
1581
  def test_websocket_delete_rejects_bad_registration_id(
1480
1582
  client: NewsDataApiClient,