newsdataapi 0.3.0__tar.gz → 0.3.2__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.0 → newsdataapi-0.3.2}/PKG-INFO +1 -1
  2. {newsdataapi-0.3.0 → newsdataapi-0.3.2}/src/newsdataapi/__init__.py +1 -1
  3. {newsdataapi-0.3.0 → newsdataapi-0.3.2}/src/newsdataapi/client.py +35 -3
  4. {newsdataapi-0.3.0 → newsdataapi-0.3.2}/src/newsdataapi/websocket.py +2 -0
  5. {newsdataapi-0.3.0 → newsdataapi-0.3.2}/tests/test_unit.py +89 -1
  6. {newsdataapi-0.3.0 → newsdataapi-0.3.2}/.github/workflows/ci.yml +0 -0
  7. {newsdataapi-0.3.0 → newsdataapi-0.3.2}/.github/workflows/publish.yml +0 -0
  8. {newsdataapi-0.3.0 → newsdataapi-0.3.2}/.gitignore +0 -0
  9. {newsdataapi-0.3.0 → newsdataapi-0.3.2}/.python-version +0 -0
  10. {newsdataapi-0.3.0 → newsdataapi-0.3.2}/LICENSE +0 -0
  11. {newsdataapi-0.3.0 → newsdataapi-0.3.2}/README.md +0 -0
  12. {newsdataapi-0.3.0 → newsdataapi-0.3.2}/examples/basic_usage.py +0 -0
  13. {newsdataapi-0.3.0 → newsdataapi-0.3.2}/examples/websocket_usage.py +0 -0
  14. {newsdataapi-0.3.0 → newsdataapi-0.3.2}/newsdata-logo.png +0 -0
  15. {newsdataapi-0.3.0 → newsdataapi-0.3.2}/pyproject.toml +0 -0
  16. {newsdataapi-0.3.0 → newsdataapi-0.3.2}/src/newsdataapi/constants.py +0 -0
  17. {newsdataapi-0.3.0 → newsdataapi-0.3.2}/src/newsdataapi/csv_export.py +0 -0
  18. {newsdataapi-0.3.0 → newsdataapi-0.3.2}/src/newsdataapi/exceptions.py +0 -0
  19. {newsdataapi-0.3.0 → newsdataapi-0.3.2}/src/newsdataapi/py.typed +0 -0
  20. {newsdataapi-0.3.0 → newsdataapi-0.3.2}/tests/__init__.py +0 -0
  21. {newsdataapi-0.3.0 → newsdataapi-0.3.2}/tests/conftest.py +0 -0
  22. {newsdataapi-0.3.0 → newsdataapi-0.3.2}/tests/test_integration.py +0 -0
  23. {newsdataapi-0.3.0 → newsdataapi-0.3.2}/tests/test_websocket.py +0 -0
  24. {newsdataapi-0.3.0 → newsdataapi-0.3.2}/uv.lock +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: newsdataapi
3
- Version: 0.3.0
3
+ Version: 0.3.2
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
@@ -15,7 +15,7 @@ from .exceptions import (
15
15
  )
16
16
  from .websocket import NewsDataApiWebSocket
17
17
 
18
- __version__ = "0.3.0"
18
+ __version__ = "0.3.2"
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,9 +82,11 @@ _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
 
88
+ _QUOTA_EXHAUSTED_CODES = frozenset({"ApiKeyLimitExceeded", "ApiLimitExceeded"})
89
+
87
90
 
88
91
  def _validate_params(user_params: Mapping[str, Any]) -> dict[str, Any]:
89
92
  """Validate and normalize user-provided endpoint parameters.
@@ -101,7 +104,8 @@ def _validate_params(user_params: Mapping[str, Any]) -> dict[str, Any]:
101
104
  * Server-side mutex groups (``q``/``qInTitle``/``qInMeta``,
102
105
  ``country``/``excludecountry``, ``category``/``excludecategory``,
103
106
  ``language``/``excludelanguage``, and
104
- ``domain``/``domainurl``/``excludedomain``) are enforced client-side;
107
+ ``domain``/``domainurl``/``excludedomain``/``excludedomainurl``) are
108
+ enforced client-side;
105
109
  setting more than one from any group raises
106
110
  ``NewsdataValidationError`` before the request leaves.
107
111
  * ``sentiment_score`` requires ``sentiment`` to be set; passing
@@ -298,6 +302,17 @@ def _parse_retry_after(value: str | None) -> int | None:
298
302
  return None
299
303
 
300
304
 
305
+ def _extract_error_code(body: Any) -> str | None:
306
+ """Return the API error code from an error response body, if present."""
307
+ if isinstance(body, dict):
308
+ results = body.get("results")
309
+ if isinstance(results, dict):
310
+ code = results.get("code")
311
+ if isinstance(code, str):
312
+ return code
313
+ return None
314
+
315
+
301
316
  def _redact_url(url: str, param: str = "apikey") -> str:
302
317
  """Return ``url`` with the value of ``param`` replaced by ``REDACTED``.
303
318
 
@@ -467,6 +482,7 @@ class NewsDataApiClient:
467
482
  domain: str | list[str] | None = None,
468
483
  domainurl: str | list[str] | None = None,
469
484
  excludedomain: str | list[str] | None = None,
485
+ excludedomainurl: str | list[str] | None = None,
470
486
  prioritydomain: str | None = None,
471
487
  timeframe: int | str | None = None,
472
488
  timezone: str | None = None,
@@ -515,6 +531,7 @@ class NewsDataApiClient:
515
531
  "domain": domain,
516
532
  "domainurl": domainurl,
517
533
  "excludedomain": excludedomain,
534
+ "excludedomainurl": excludedomainurl,
518
535
  "prioritydomain": prioritydomain,
519
536
  "timeframe": str(timeframe) if timeframe is not None else None,
520
537
  "timezone": timezone,
@@ -558,6 +575,7 @@ class NewsDataApiClient:
558
575
  domain: str | list[str] | None = None,
559
576
  domainurl: str | list[str] | None = None,
560
577
  excludedomain: str | list[str] | None = None,
578
+ excludedomainurl: str | list[str] | None = None,
561
579
  prioritydomain: str | None = None,
562
580
  timezone: str | None = None,
563
581
  size: int | None = None,
@@ -604,6 +622,7 @@ class NewsDataApiClient:
604
622
  "domain": domain,
605
623
  "domainurl": domainurl,
606
624
  "excludedomain": excludedomain,
625
+ "excludedomainurl": excludedomainurl,
607
626
  "prioritydomain": prioritydomain,
608
627
  "timezone": timezone,
609
628
  "size": size,
@@ -671,6 +690,7 @@ class NewsDataApiClient:
671
690
  domain: str | list[str] | None = None,
672
691
  domainurl: str | list[str] | None = None,
673
692
  excludedomain: str | list[str] | None = None,
693
+ excludedomainurl: str | list[str] | None = None,
674
694
  prioritydomain: str | None = None,
675
695
  timeframe: int | str | None = None,
676
696
  timezone: str | None = None,
@@ -710,6 +730,7 @@ class NewsDataApiClient:
710
730
  "domain": domain,
711
731
  "domainurl": domainurl,
712
732
  "excludedomain": excludedomain,
733
+ "excludedomainurl": excludedomainurl,
713
734
  "prioritydomain": prioritydomain,
714
735
  "timeframe": str(timeframe) if timeframe is not None else None,
715
736
  "timezone": timezone,
@@ -749,6 +770,7 @@ class NewsDataApiClient:
749
770
  domain: str | list[str] | None = None,
750
771
  domainurl: str | list[str] | None = None,
751
772
  excludedomain: str | list[str] | None = None,
773
+ excludedomainurl: str | list[str] | None = None,
752
774
  language: str | list[str] | None = None,
753
775
  excludelanguage: str | list[str] | None = None,
754
776
  prioritydomain: str | None = None,
@@ -794,6 +816,7 @@ class NewsDataApiClient:
794
816
  "domain": domain,
795
817
  "domainurl": domainurl,
796
818
  "excludedomain": excludedomain,
819
+ "excludedomainurl": excludedomainurl,
797
820
  "language": language,
798
821
  "excludelanguage": excludelanguage,
799
822
  "prioritydomain": prioritydomain,
@@ -841,6 +864,7 @@ class NewsDataApiClient:
841
864
  domain: str | list[str] | None = None,
842
865
  domainurl: str | list[str] | None = None,
843
866
  excludedomain: str | list[str] | None = None,
867
+ excludedomainurl: str | list[str] | None = None,
844
868
  full_content: bool | None = None,
845
869
  image: bool | None = None,
846
870
  video: bool | None = None,
@@ -888,6 +912,7 @@ class NewsDataApiClient:
888
912
  "domain": domain,
889
913
  "domainurl": domainurl,
890
914
  "excludedomain": excludedomain,
915
+ "excludedomainurl": excludedomainurl,
891
916
  "full_content": full_content,
892
917
  "image": image,
893
918
  "video": video,
@@ -927,6 +952,7 @@ class NewsDataApiClient:
927
952
  domain: str | list[str] | None = None,
928
953
  domainurl: str | list[str] | None = None,
929
954
  excludedomain: str | list[str] | None = None,
955
+ excludedomainurl: str | list[str] | None = None,
930
956
  full_content: bool | None = None,
931
957
  image: bool | None = None,
932
958
  video: bool | None = None,
@@ -965,6 +991,7 @@ class NewsDataApiClient:
965
991
  "domain": domain,
966
992
  "domainurl": domainurl,
967
993
  "excludedomain": excludedomain,
994
+ "excludedomainurl": excludedomainurl,
968
995
  "full_content": full_content,
969
996
  "image": image,
970
997
  "video": video,
@@ -998,6 +1025,7 @@ class NewsDataApiClient:
998
1025
  domain: str | list[str] | None = None,
999
1026
  domainurl: str | list[str] | None = None,
1000
1027
  excludedomain: str | list[str] | None = None,
1028
+ excludedomainurl: str | list[str] | None = None,
1001
1029
  language: str | list[str] | None = None,
1002
1030
  excludelanguage: str | list[str] | None = None,
1003
1031
  full_content: bool | None = None,
@@ -1042,6 +1070,7 @@ class NewsDataApiClient:
1042
1070
  "domain": domain,
1043
1071
  "domainurl": domainurl,
1044
1072
  "excludedomain": excludedomain,
1073
+ "excludedomainurl": excludedomainurl,
1045
1074
  "language": language,
1046
1075
  "excludelanguage": excludelanguage,
1047
1076
  "full_content": full_content,
@@ -1201,7 +1230,10 @@ class NewsDataApiClient:
1201
1230
  # Rate limit.
1202
1231
  if response.status_code == 429:
1203
1232
  retry_after = _parse_retry_after(response.headers.get("Retry-After"))
1204
- if attempt >= self.max_retries:
1233
+ if (
1234
+ _extract_error_code(body) in _QUOTA_EXHAUSTED_CODES
1235
+ or attempt >= self.max_retries
1236
+ ):
1205
1237
  raise NewsdataRateLimitError(
1206
1238
  body,
1207
1239
  status_code=429,
@@ -177,6 +177,7 @@ class NewsDataApiWebSocket:
177
177
  domain: str | list[str] | None = None,
178
178
  domainurl: str | list[str] | None = None,
179
179
  excludedomain: str | list[str] | None = None,
180
+ excludedomainurl: str | list[str] | None = None,
180
181
  prioritydomain: str | None = None,
181
182
  timezone: str | None = None,
182
183
  full_content: bool | None = None,
@@ -212,6 +213,7 @@ class NewsDataApiWebSocket:
212
213
  "domain": domain,
213
214
  "domainurl": domainurl,
214
215
  "excludedomain": excludedomain,
216
+ "excludedomainurl": excludedomainurl,
215
217
  "prioritydomain": prioritydomain,
216
218
  "timezone": timezone,
217
219
  "full_content": full_content,
@@ -148,13 +148,23 @@ def test_validate_size_negative_rejected() -> None:
148
148
  ({"country": "us", "excludecountry": "gb"}, "country"),
149
149
  ({"category": "business", "excludecategory": "sports"}, "category"),
150
150
  ({"language": "en", "excludelanguage": "fr"}, "language"),
151
- # Domain 3-way mutex (any two of domain/domainurl/excludedomain)
151
+ # Domain 4-way mutex (any two of
152
+ # domain/domainurl/excludedomain/excludedomainurl)
152
153
  ({"domain": "cnn.com", "domainurl": "https://bbc.com"}, "domain"),
153
154
  ({"domain": "cnn.com", "excludedomain": "fox.com"}, "domain"),
154
155
  (
155
156
  {"domainurl": "https://bbc.com", "excludedomain": "fox.com"},
156
157
  "domainurl",
157
158
  ),
159
+ ({"domain": "cnn.com", "excludedomainurl": "fox.com"}, "domain"),
160
+ (
161
+ {"domainurl": "https://bbc.com", "excludedomainurl": "fox.com"},
162
+ "domainurl",
163
+ ),
164
+ (
165
+ {"excludedomain": "cnn.com", "excludedomainurl": "fox.com"},
166
+ "excludedomain",
167
+ ),
158
168
  (
159
169
  {
160
170
  "domain": "cnn.com",
@@ -823,6 +833,22 @@ def test_429_exhausted_raises_rate_limit_with_retry_after(
823
833
  assert exc_info.value.retry_after == 5
824
834
 
825
835
 
836
+ @pytest.mark.parametrize("code", ["ApiKeyLimitExceeded", "ApiLimitExceeded"])
837
+ def test_429_quota_exhausted_raises_without_retry(
838
+ code: str,
839
+ client: NewsDataApiClient,
840
+ mocked_responses: responses.RequestsMock,
841
+ no_sleep: None,
842
+ ) -> None:
843
+ """A 429 whose error code means exhausted credits is never retried."""
844
+ body = {"status": "error", "results": {"message": "limit", "code": code}}
845
+ mocked_responses.get(LATEST_URL, json=body, status=429)
846
+ with pytest.raises(NewsdataRateLimitError) as exc_info:
847
+ client.latest_api()
848
+ assert len(mocked_responses.calls) == 1
849
+ assert exc_info.value.response_body == body
850
+
851
+
826
852
  def test_500_then_200_succeeds(
827
853
  client: NewsDataApiClient,
828
854
  mocked_responses: responses.RequestsMock,
@@ -1269,6 +1295,51 @@ def test_count_endpoint_url_resolves(
1269
1295
  assert len(mocked_responses.calls) == 1
1270
1296
 
1271
1297
 
1298
+ @pytest.mark.parametrize(
1299
+ ("method_name", "endpoint_path", "args"),
1300
+ [
1301
+ ("latest_api", "latest", ()),
1302
+ ("archive_api", "archive", ()),
1303
+ ("crypto_api", "crypto", ()),
1304
+ ("market_api", "market", ()),
1305
+ ("count_api", "count", ("2024-01-01", "2024-01-31")),
1306
+ ("crypto_count_api", "crypto/count", ("2024-01-01", "2024-01-31")),
1307
+ ("market_count_api", "market/count", ("2024-01-01", "2024-01-31")),
1308
+ ],
1309
+ )
1310
+ def test_excludedomainurl_sent_as_csv(
1311
+ client: NewsDataApiClient,
1312
+ mocked_responses: responses.RequestsMock,
1313
+ method_name: str,
1314
+ endpoint_path: str,
1315
+ args: tuple[str, ...],
1316
+ ) -> None:
1317
+ mocked_responses.get(
1318
+ f"https://newsdata.io/api/1/{endpoint_path}",
1319
+ json={"status": "success", "results": []},
1320
+ status=200,
1321
+ )
1322
+ getattr(client, method_name)(*args, excludedomainurl=["bbc.com", "cnn.com"])
1323
+ sent_url = mocked_responses.calls[0].request.url
1324
+ assert sent_url is not None
1325
+ assert "excludedomainurl=bbc.com%2Ccnn.com" in sent_url
1326
+
1327
+
1328
+ def test_raw_query_accepts_excludedomainurl(
1329
+ client: NewsDataApiClient,
1330
+ mocked_responses: responses.RequestsMock,
1331
+ ) -> None:
1332
+ mocked_responses.get(
1333
+ LATEST_URL,
1334
+ json={"status": "success", "results": []},
1335
+ status=200,
1336
+ )
1337
+ client.latest_api(raw_query="excludedomainurl=bbc.com")
1338
+ sent_url = mocked_responses.calls[0].request.url
1339
+ assert sent_url is not None
1340
+ assert "excludedomainurl=bbc.com" in sent_url
1341
+
1342
+
1272
1343
  # ===========================================================================
1273
1344
  # WebSocket query management (register / fetch / delete)
1274
1345
  # ===========================================================================
@@ -1351,6 +1422,23 @@ def test_websocket_register_raw_query_rejects_unknown_param(
1351
1422
  NewsDataApiWebSocket(client).websocket_register(raw_query="q=pizza&size=10")
1352
1423
 
1353
1424
 
1425
+ def test_websocket_register_sends_excludedomainurl(
1426
+ client: NewsDataApiClient,
1427
+ mocked_responses: responses.RequestsMock,
1428
+ ) -> None:
1429
+ mocked_responses.post(
1430
+ WS_REGISTER_URL,
1431
+ json={"status": "success", "results": {"registration_id": "abc"}},
1432
+ status=200,
1433
+ )
1434
+ NewsDataApiWebSocket(client).websocket_register(
1435
+ q="pizza", excludedomainurl=["bbc.com", "cnn.com"]
1436
+ )
1437
+ sent_url = mocked_responses.calls[0].request.url
1438
+ assert sent_url is not None
1439
+ assert "excludedomainurl=bbc.com%2Ccnn.com" in sent_url
1440
+
1441
+
1354
1442
  def test_websocket_register_duplicate_409(
1355
1443
  client: NewsDataApiClient,
1356
1444
  mocked_responses: responses.RequestsMock,
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes