Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ jobs:
strategy:
fail-fast: false
matrix:
python-version: ["3.10", "3.11", "3.12", "3.13", "3.14"]
python-version: ["3.8", "3.9", "3.10", "3.11", "3.12", "3.13", "3.14"]
steps:
- uses: actions/checkout@v4

Expand Down
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ If you use [uv](https://github.com/astral-sh/uv):
uv add newsdataapi
```

Supports Python 3.10 through 3.14. The runtime dependencies are `requests` (REST) and `websockets` (real-time streaming).
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.

## Quickstart

Expand Down Expand Up @@ -89,7 +89,7 @@ for page in client.latest_api(q="news", paginate=True, max_pages=5):

## Real-time news (WebSocket)

Register a query first — the returned `registration_id` identifies it from then on:
Requires Python 3.10 or newer. Register a query first — the returned `registration_id` identifies it from then on:

```python
from newsdataapi import NewsDataApiClient, NewsDataApiWebSocket
Expand Down
13 changes: 9 additions & 4 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,13 @@ dynamic = ["version"]
readme = "README.md"
license = { file = "LICENSE" }
authors = [{ name = "NewsData.io", email = "contact@newsdata.io" }]
requires-python = ">=3.10"
requires-python = ">=3.8"

dependencies = [
"requests>=2.25,<3",
"websockets>=16,<17",
# websockets 16 needs Python 3.10+. On 3.8/3.9 it is not installed: the
# REST client works there, and NewsDataApiWebSocket raises on construction.
"websockets>=16,<17; python_version >= '3.10'",
]

keywords = [
Expand Down Expand Up @@ -37,6 +39,8 @@ classifiers = [
"License :: OSI Approved :: MIT License",
"Operating System :: OS Independent",
"Programming Language :: Python :: 3",
"Programming Language :: Python :: 3.8",
"Programming Language :: Python :: 3.9",
"Programming Language :: Python :: 3.10",
"Programming Language :: Python :: 3.11",
"Programming Language :: Python :: 3.12",
Expand Down Expand Up @@ -90,7 +94,7 @@ addopts = [

[tool.ruff]
line-length = 100
target-version = "py310"
target-version = "py38"

[tool.ruff.lint]
select = [
Expand All @@ -107,6 +111,7 @@ select = [
]

[tool.mypy]
python_version = "3.10"
# mypy no longer accepts 3.8 as a target; the 3.8 CI job covers it at runtime.
python_version = "3.9"
strict = true
files = ["src/newsdataapi"]
2 changes: 1 addition & 1 deletion src/newsdataapi/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@
)
from .websocket import NewsDataApiWebSocket

__version__ = "0.3.2"
__version__ = "0.3.3"

__all__ = [
"NewsDataApiClient",
Expand Down
23 changes: 19 additions & 4 deletions src/newsdataapi/websocket.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,14 +11,11 @@
import asyncio
import json
import logging
import sys
import time
from collections.abc import AsyncIterator, Iterator
from typing import Any

from websockets.asyncio import client as _asyncio_client
from websockets.exceptions import ConnectionClosedError, InvalidStatus
from websockets.sync import client as _sync_client

from . import constants
from .client import NewsDataApiClient, _redact_url, _validate_params
from .exceptions import (
Expand All @@ -27,6 +24,15 @@
NewsdataWebSocketError,
)

# websockets requires Python 3.10+ and is not installed on older versions,
# where only the REST client is available.
_WEBSOCKET_SUPPORTED = sys.version_info >= (3, 10)

if _WEBSOCKET_SUPPORTED:
from websockets.asyncio import client as _asyncio_client
from websockets.exceptions import ConnectionClosedError, InvalidStatus
from websockets.sync import client as _sync_client

logger = logging.getLogger(__name__)


Expand Down Expand Up @@ -109,6 +115,9 @@ class NewsDataApiWebSocket:
always raises :class:`~newsdataapi.NewsdataWebSocketAuthError` and is
never retried.

Requires Python 3.10 or newer; on older versions the constructor raises
:class:`~newsdataapi.NewsdataWebSocketError`.

Args:
api_client: The :class:`~newsdataapi.NewsDataApiClient` that supplies
the API key and performs the management HTTP calls. Not closed by
Expand Down Expand Up @@ -147,6 +156,12 @@ def __init__(
additional_headers: dict[str, str] | None = None,
proxy: str | None = None,
) -> None:
if not _WEBSOCKET_SUPPORTED:
raise NewsdataWebSocketError(
"NewsDataApiWebSocket requires Python 3.10 or newer (this is Python "
f"{sys.version_info.major}.{sys.version_info.minor}); "
"NewsDataApiClient works on Python 3.8+"
)
self.api_client = api_client
self._base_url = base_url
self._reconnect = reconnect
Expand Down
4 changes: 4 additions & 0 deletions tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,17 @@

from __future__ import annotations

import sys
from collections.abc import Iterator

import pytest
import responses

from newsdataapi import NewsDataApiClient

# test_websocket.py imports websockets, which is only installed on Python 3.10+.
collect_ignore = ["test_websocket.py"] if sys.version_info < (3, 10) else []


@pytest.fixture
def mocked_responses() -> Iterator[responses.RequestsMock]:
Expand Down
30 changes: 30 additions & 0 deletions tests/test_unit.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@

from __future__ import annotations

import sys
from datetime import datetime, timedelta, timezone
from email.utils import format_datetime
from pathlib import Path
Expand All @@ -25,6 +26,7 @@
NewsdataRateLimitError,
NewsdataServerError,
NewsdataValidationError,
NewsdataWebSocketError,
save_to_csv,
)
from newsdataapi.client import (
Expand Down Expand Up @@ -1348,7 +1350,23 @@ def test_raw_query_accepts_excludedomainurl(
WS_FETCH_URL = "https://newsdata.io/api/1/websocket/fetch"
WS_DELETE_URL = "https://newsdata.io/api/1/websocket/delete"

# NewsDataApiWebSocket can't be constructed below Python 3.10.
requires_websocket = pytest.mark.skipif(
sys.version_info < (3, 10), reason="WebSocket support requires Python 3.10+"
)


def test_websocket_requires_python_310(
client: NewsDataApiClient,
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""Below Python 3.10, construction fails up front with a clear error."""
monkeypatch.setattr("newsdataapi.websocket._WEBSOCKET_SUPPORTED", False)
with pytest.raises(NewsdataWebSocketError, match="requires Python 3.10"):
NewsDataApiWebSocket(client)


@requires_websocket
def test_websocket_register_posts_query_params(
client: NewsDataApiClient,
mocked_responses: responses.RequestsMock,
Expand All @@ -1375,6 +1393,7 @@ def test_websocket_register_posts_query_params(
assert not sent.body # params travel in the query string, not a body


@requires_websocket
def test_websocket_register_always_sends_news_type_latest(
client: NewsDataApiClient,
mocked_responses: responses.RequestsMock,
Expand All @@ -1390,6 +1409,7 @@ def test_websocket_register_always_sends_news_type_latest(
assert "news_type=latest" in sent_url


@requires_websocket
def test_websocket_register_mutex_validation_applies(
client: NewsDataApiClient,
) -> None:
Expand All @@ -1398,6 +1418,7 @@ def test_websocket_register_mutex_validation_applies(
NewsDataApiWebSocket(client).websocket_register(q="a", qInTitle="b")


@requires_websocket
def test_websocket_register_raw_query(
client: NewsDataApiClient,
mocked_responses: responses.RequestsMock,
Expand All @@ -1414,6 +1435,7 @@ def test_websocket_register_raw_query(
assert "news_type=latest" in sent_url


@requires_websocket
def test_websocket_register_raw_query_rejects_unknown_param(
client: NewsDataApiClient,
) -> None:
Expand All @@ -1422,6 +1444,7 @@ def test_websocket_register_raw_query_rejects_unknown_param(
NewsDataApiWebSocket(client).websocket_register(raw_query="q=pizza&size=10")


@requires_websocket
def test_websocket_register_sends_excludedomainurl(
client: NewsDataApiClient,
mocked_responses: responses.RequestsMock,
Expand All @@ -1439,6 +1462,7 @@ def test_websocket_register_sends_excludedomainurl(
assert "excludedomainurl=bbc.com%2Ccnn.com" in sent_url


@requires_websocket
def test_websocket_register_duplicate_409(
client: NewsDataApiClient,
mocked_responses: responses.RequestsMock,
Expand Down Expand Up @@ -1466,6 +1490,7 @@ def test_websocket_register_duplicate_409(
assert len(mocked_responses.calls) == 1 # 4xx is never retried


@requires_websocket
def test_websocket_register_403_raises_auth(
client: NewsDataApiClient,
mocked_responses: responses.RequestsMock,
Expand All @@ -1476,6 +1501,7 @@ def test_websocket_register_403_raises_auth(
NewsDataApiWebSocket(client).websocket_register(q="pizza")


@requires_websocket
def test_websocket_register_429_exhausts_to_rate_limit(
client: NewsDataApiClient,
mocked_responses: responses.RequestsMock,
Expand All @@ -1488,6 +1514,7 @@ def test_websocket_register_429_exhausts_to_rate_limit(
NewsDataApiWebSocket(client).websocket_register(q="pizza")


@requires_websocket
def test_websocket_fetch_returns_queries(
client: NewsDataApiClient,
mocked_responses: responses.RequestsMock,
Expand Down Expand Up @@ -1518,6 +1545,7 @@ def test_websocket_fetch_returns_queries(
assert "apikey=test_key_xxx" in sent_url


@requires_websocket
def test_websocket_delete_sends_registration_id(
client: NewsDataApiClient,
mocked_responses: responses.RequestsMock,
Expand All @@ -1537,6 +1565,7 @@ def test_websocket_delete_sends_registration_id(
assert "registration_id=abc123" in sent_url


@requires_websocket
def test_websocket_delete_unknown_id_404(
client: NewsDataApiClient,
mocked_responses: responses.RequestsMock,
Expand All @@ -1547,6 +1576,7 @@ def test_websocket_delete_unknown_id_404(
assert exc_info.value.status_code == 404


@requires_websocket
@pytest.mark.parametrize("bad_id", ["", None, 123])
def test_websocket_delete_rejects_bad_registration_id(
client: NewsDataApiClient,
Expand Down
Loading
Loading