diff --git a/.env.example b/.env.example index 0dabcff..08a87ca 100644 --- a/.env.example +++ b/.env.example @@ -12,3 +12,8 @@ NEWSDATA_API_KEY="your_newsdata_api_key_here" # NEWSDATA_MAX_RETRIES=5 # NEWSDATA_RETRY_BACKOFF=2.0 # NEWSDATA_RETRY_BACKOFF_MAX=60.0 + +# Optional: real-time WebSocket settings, used by the `stream_news` tool. +# NEWSDATA_WS_URL=wss://ws.newsdata.io/ws/event +# Ceiling on stream_news's wait_seconds, in seconds (default: 120) +# NEWSDATA_WS_MAX_WAIT=120 diff --git a/README.md b/README.md index 63c9c5c..de3ea53 100644 --- a/README.md +++ b/README.md @@ -72,6 +72,8 @@ cp .env.example .env | `NEWSDATA_MAX_RETRIES` | `5` | Maximum attempts for transient failures (network, 5xx, 429). | | `NEWSDATA_RETRY_BACKOFF` | `2.0` | Base for exponential backoff (`base * 2^(attempt-1)`). Seconds. | | `NEWSDATA_RETRY_BACKOFF_MAX` | `60.0` | Cap on a single retry sleep, seconds. | +| `NEWSDATA_WS_URL` | `wss://ws.newsdata.io/ws/event` | Real-time endpoint used by `stream_news`. | +| `NEWSDATA_WS_MAX_WAIT` | `120` | Ceiling on `stream_news`'s `wait_seconds`, in seconds. | | `NEWSDATA_INTEGRATION_KEY` | _(unset)_ | Used only by `pytest -m integration`. Without it, live-API tests skip. | All values are read at module import time; restart the server after changing them. @@ -160,8 +162,75 @@ Then in **ChatGPT → Settings → Connectors → Add custom connector**, regist | `get_news_counts` | `/api/1/count` | Aggregate article counts over a date range (`hour` / `day` buckets or single `all` total) | | `get_crypto_counts` | `/api/1/crypto/count` | Aggregate crypto article counts over a date range | | `get_market_counts` | `/api/1/market/count` | Aggregate market article counts over a date range | +| `register_realtime_query` | `/api/1/websocket/register` | Register a standing real-time query, returns a `registration_id` | +| `list_realtime_queries` | `/api/1/websocket/fetch` | List the account's registered real-time queries | +| `delete_realtime_query` | `/api/1/websocket/delete` | Delete a registered real-time query | +| `stream_news` | `wss://ws.newsdata.io/ws/event` | Collect real-time articles for a registered query ([below](#real-time-news-websocket)) | -All tools are read-only and idempotent; the MCP-protocol annotations let compatible clients (Claude Code, MCP Inspector, etc.) cache and parallelize calls. +The eight REST news tools are read-only and idempotent; the MCP-protocol annotations let compatible clients (Claude Code, MCP Inspector, etc.) cache and parallelize calls. `register_realtime_query` and `delete_realtime_query` are annotated as mutating (the latter destructive), since they create and remove server-side state. + +--- + +## Real-time news (WebSocket) + +Real time is a **two-step flow**: register a standing query once, then collect +from it as often as you like. + +```text +register_realtime_query( + q="bitcoin", + language="en" +) +→ registration_id: a1b2c3d4e5f6 +``` + +```text +stream_news( + registration_id="a1b2c3d4e5f6", + max_articles=10, + wait_seconds=30 +) +``` + +`register_realtime_query` takes the familiar filter parameters (`q`, `country`, +`language`, `domain`, …). There are **no date or paging filters** — a registered +query matches news as it is published. Registering identical filters twice +answers `Error (HTTP 409)`; call `list_realtime_queries` first to recover the +existing id. + +### Why `stream_news` is bounded + +MCP tools are request/response, so an open-ended stream cannot be a tool. +`stream_news` listens on a live connection and returns as soon as **either** +`max_articles` have arrived **or** `wait_seconds` elapses — whichever comes +first. It always returns within `wait_seconds`. + +Every reply states why it stopped, so the model can act on it: + +| `stopped_because` | Meaning | +|---|---| +| `max_articles` | Hit the cap — there may be more waiting | +| `timeout` | The window elapsed; a `0` count means the feed was simply quiet | +| `connection_closed` | The server ended the feed; returns whatever was collected | + +Call it again to keep listening — the registration stays alive between calls. +An empty result is normal for a narrow query and does **not** mean anything +failed. + +Bounds: `max_articles` is clamped to 1–50, `wait_seconds` to 1–`NEWSDATA_WS_MAX_WAIT` +(120 by default). + +The server always accepts the handshake and then closes with code **1008** when +the connection is refused, carrying one of three reasons: `invalid credentials +or registration not found`, `api limit reached`, or `device limit reached` (more +than 5 devices on one `registration_id`). Those return an `Error` and are not +retried. Every other close code — including `1013` (`send timeout`) — is +transient and surfaces as `stopped_because: connection_closed` with whatever was +collected. + +> **Cost.** Each delivered article consumes **1 API credit per connected +> device**. A broad query over a long window can burn credits quickly — keep +> `max_articles` no higher than you need. --- @@ -247,6 +316,8 @@ Notes on parameter shapes: - Free plan results are delayed relative to paid plans. - Result `size` is capped by plan tier: commonly 10 results on free, up to 50 on paid plans. - The count endpoints return aggregate buckets (one per `interval` slot) rather than article content. +- Real-time coverage needs a plan with WebSocket access; without it `stream_news` returns `Error: invalid credentials or registration not found`. +- Real-time articles are billed per delivered article, per connected device. - Every tool returns plain text (the MCP-protocol return type). Errors come back as `Error (HTTP 4xx): …` with the status code and a friendly message; HTTP 429 errors include a `retry after Ns` hint when the upstream `Retry-After` header was parseable. Full API reference: [https://newsdata.io/documentation](https://newsdata.io/documentation). Machine-readable contract: [OpenAPI 3.1 spec](https://newsdata.io/openapi.json). diff --git a/pyproject.toml b/pyproject.toml index 55f658c..b9f72fc 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -37,6 +37,7 @@ dependencies = [ "mcp[cli]>=1.27.0,<2", "pydantic>=2.7.0,<3", "python-dotenv>=1.2.2,<2", + "websockets>=16,<17", ] [project.scripts] diff --git a/src/newsdata_mcp/_mcp.py b/src/newsdata_mcp/_mcp.py index b1b98c2..870cb85 100644 --- a/src/newsdata_mcp/_mcp.py +++ b/src/newsdata_mcp/_mcp.py @@ -24,6 +24,25 @@ openWorldHint=True, ) +# Registering a real-time query creates server-side state. It is not +# destructive (nothing is lost) and is idempotent in practice — a second +# identical registration answers 409 with the existing id rather than +# creating a duplicate. +CREATE_TOOL = ToolAnnotations( + readOnlyHint=False, + destructiveHint=False, + idempotentHint=True, + openWorldHint=True, +) + +# Deleting a registered query removes server-side state for good. +DESTRUCTIVE_TOOL = ToolAnnotations( + readOnlyHint=False, + destructiveHint=True, + idempotentHint=True, + openWorldHint=True, +) + @asynccontextmanager async def _lifespan(server: FastMCP) -> AsyncIterator[dict[str, Any]]: diff --git a/src/newsdata_mcp/formatters.py b/src/newsdata_mcp/formatters.py index bb8ad40..2cd4965 100644 --- a/src/newsdata_mcp/formatters.py +++ b/src/newsdata_mcp/formatters.py @@ -255,3 +255,91 @@ def format_sources(data: dict[str, Any]) -> str: lines.append("") return "\n".join(lines) + + +def format_registration(data: dict[str, Any]) -> str: + """Render a `websocket/register` response: the new query's + ``registration_id`` plus whatever else the server echoes back. + """ + if data.get("status") != "success": + return _format_error(data) + + results = (data.get("data", {}) or {}).get("results") or {} + if not isinstance(results, dict): + return "Registered, but the response carried no registration details." + + lines = ["registered: yes"] + _append_field(lines, "registration_id", results.get("registration_id")) + for key, value in results.items(): + if key == "registration_id": + continue + _append_field(lines, key, value) + return "\n".join(lines) + + +def format_registered_queries(data: dict[str, Any]) -> str: + """Render a `websocket/fetch` response: one block per registered + real-time query. + """ + if data.get("status") != "success": + return _format_error(data) + + results = (data.get("data", {}) or {}).get("results") or {} + queries = results.get("queries") if isinstance(results, dict) else None + if not queries: + return "No real-time queries are registered on this account." + + lines = [f"registered_queries: {len(queries)}", ""] + for index, query in enumerate(queries, 1): + lines.append(f"Query {index}:") + if isinstance(query, dict): + _append_field(lines, "registration_id", query.get("registration_id")) + for key, value in query.items(): + if key == "registration_id": + continue + _append_field(lines, key, value) + else: + _append_field(lines, "value", query) + lines.append("") + return "\n".join(lines) + + +def format_deletion(data: dict[str, Any], registration_id: str) -> str: + """Render a `websocket/delete` response.""" + if data.get("status") != "success": + return _format_error(data) + return f"deleted: yes\nregistration_id: {registration_id}" + + +def format_stream( + articles: list[dict[str, Any]], + registration_id: str, + stopped_because: str, + waited_seconds: float, +) -> str: + """Render the articles collected by a bounded `stream_news` call. + + ``stopped_because`` explains why collection ended (``max_articles``, + ``timeout``, or ``connection_closed``) so the model can tell "the + feed is quiet" from "I hit the cap and there may be more". + """ + header = [ + f"registration_id: {registration_id}", + f"collected_articles: {len(articles)}", + f"stopped_because: {stopped_because}", + f"waited_seconds: {round(waited_seconds, 1)}", + ] + if not articles: + header.append("") + header.append( + "No matching articles were published during this window. " + "The query is still registered — call again to keep listening." + ) + return "\n".join(header) + + lines = [*header, ""] + for index, article in enumerate(articles, 1): + lines.append(f"Article {index}:") + lines.extend(_format_article_item(article)) + lines.append("") + return "\n".join(lines) diff --git a/src/newsdata_mcp/http.py b/src/newsdata_mcp/http.py index 43bf0f9..96b1121 100644 --- a/src/newsdata_mcp/http.py +++ b/src/newsdata_mcp/http.py @@ -179,7 +179,7 @@ async def close_client() -> None: async def _request_once( - endpoint: str, clean: dict[str, Any] + endpoint: str, clean: dict[str, Any], method: str = "GET" ) -> tuple[dict[str, Any], bool]: """Execute one HTTP attempt. @@ -191,7 +191,8 @@ async def _request_once( response: httpx.Response | None = None try: client = await _get_client() - response = await client.get( + response = await client.request( + method, f"{NEWSDATA_BASE_URL}/{endpoint}", params=clean, ) @@ -307,7 +308,9 @@ async def _request_once( ) -async def fetch(endpoint: str, params: dict[str, Any]) -> dict[str, Any]: +async def fetch( + endpoint: str, params: dict[str, Any], method: str = "GET" +) -> dict[str, Any]: """Public entry point: one logical request, with retries. Permanent failures (auth, validation, soft errors, JSON decode, @@ -324,7 +327,7 @@ async def fetch(endpoint: str, params: dict[str, Any]) -> dict[str, Any]: } clean = _normalize_params(params) - logger.info("Newsdata.io GET /%s (%d params)", endpoint, len(clean)) + logger.info("Newsdata.io %s /%s (%d params)", method, endpoint, len(clean)) last_envelope: dict[str, Any] = { "status": "error", @@ -332,7 +335,7 @@ async def fetch(endpoint: str, params: dict[str, Any]) -> dict[str, Any]: "status_code": None, } for attempt in range(1, MAX_RETRIES + 1): - envelope, retryable = await _request_once(endpoint, clean) + envelope, retryable = await _request_once(endpoint, clean, method) if not retryable: return envelope last_envelope = envelope diff --git a/src/newsdata_mcp/params.py b/src/newsdata_mcp/params.py index bf9c96a..56a45c2 100644 --- a/src/newsdata_mcp/params.py +++ b/src/newsdata_mcp/params.py @@ -130,6 +130,20 @@ def _csv_pattern(*values: str) -> str: ), ] +REGISTRATION_ID = Annotated[ + str, + Field( + min_length=32, + max_length=32, + description=( + "The 32-character `registration_id` of a real-time query, as " + "returned by `register_realtime_query` or listed by " + "`list_realtime_queries`. Do not invent one — look it up." + ), + examples=["9b2d1e8a7c4f4b6e9d3a5c7e1f2a4b6c"], + ), +] + ARTICLE_IDS = Annotated[ str | list[str], Field( diff --git a/src/newsdata_mcp/settings.py b/src/newsdata_mcp/settings.py index 362ec8b..5a8de38 100644 --- a/src/newsdata_mcp/settings.py +++ b/src/newsdata_mcp/settings.py @@ -50,5 +50,26 @@ ) RETRY_BACKOFF_MAX = 60.0 +# Real-time WebSocket endpoint used by the `stream_news` tool. +NEWSDATA_WS_URL = os.getenv("NEWSDATA_WS_URL", "wss://ws.newsdata.io/ws/event") + +# The feed a registered query matches against. +WS_NEWS_TYPE = "latest" + +# Close code the server uses for a permanent connection rejection. +WS_POLICY_VIOLATION = 1008 + +# Hard ceilings for `stream_news`, which must return within a tool call. +try: + WS_MAX_WAIT_SECONDS = float(os.getenv("NEWSDATA_WS_MAX_WAIT", "120")) +except (ValueError, TypeError): + warnings.warn( + "NEWSDATA_WS_MAX_WAIT must be a number; falling back to 120", + stacklevel=2, + ) + WS_MAX_WAIT_SECONDS = 120.0 + +WS_MAX_ARTICLES = 50 + if not NEWSDATA_API_KEY: warnings.warn("NEWSDATA_API_KEY is not set", stacklevel=2) diff --git a/src/newsdata_mcp/tools/__init__.py b/src/newsdata_mcp/tools/__init__.py index c862491..0cbd701 100644 --- a/src/newsdata_mcp/tools/__init__.py +++ b/src/newsdata_mcp/tools/__init__.py @@ -14,6 +14,7 @@ latest, market, market_count, + realtime, sources, ) @@ -25,5 +26,6 @@ "latest", "market", "market_count", + "realtime", "sources", ] diff --git a/src/newsdata_mcp/tools/realtime.py b/src/newsdata_mcp/tools/realtime.py new file mode 100644 index 0000000..cac9521 --- /dev/null +++ b/src/newsdata_mcp/tools/realtime.py @@ -0,0 +1,323 @@ +"""Real-time WebSocket tools. + +The NewsData real-time service is a two-step flow: register a query +once (`register_realtime_query`), then stream the news it matches +(`stream_news`). MCP tools are request/response, so `stream_news` is +deliberately *bounded* — it listens until it has collected +``max_articles`` or ``wait_seconds`` elapses, then returns what it saw. +Call it again to keep listening; the registration persists until it is +deleted. +""" +import asyncio +import json +import logging +import time +from typing import Any + +from websockets.asyncio import client as ws_client +from websockets.exceptions import ConnectionClosed, InvalidStatus + +from .._mcp import CREATE_TOOL, DESTRUCTIVE_TOOL, READ_ONLY_TOOL, mcp +from ..formatters import ( + format_deletion, + format_registered_queries, + format_registration, + format_stream, +) +from ..http import fetch +from ..params import ( + CATEGORY_FILTER, + COUNTRY_FILTER, + CREATOR_FILTER, + DATATYPE_FILTER, + DOMAIN_FILTER, + DOMAIN_URL_FILTER, + EXCLUDE_FIELD_FILTER, + FLAG, + LANGUAGE_FILTER, + ORGANIZATION_FILTER, + PRIORITY_DOMAIN, + QUERY, + REGION_FILTER, + REGISTRATION_ID, + REMOVE_DUPLICATE, + SENTIMENT, + SENTIMENT_SCORE, + TAG_FILTER, + TIMEZONE, +) +from ..settings import ( + NEWSDATA_API_KEY, + NEWSDATA_WS_URL, + WS_MAX_ARTICLES, + WS_MAX_WAIT_SECONDS, + WS_NEWS_TYPE, + WS_POLICY_VIOLATION, +) +from ..validators import check_mutex_groups, check_sentiment_score_requires_sentiment + +logger = logging.getLogger(__name__) + + +@mcp.tool(annotations=CREATE_TOOL) +async def register_realtime_query( + q: QUERY | None = None, + q_in_title: QUERY | None = None, + q_in_meta: QUERY | None = None, + country: COUNTRY_FILTER | None = None, + exclude_country: COUNTRY_FILTER | None = None, + category: CATEGORY_FILTER | None = None, + exclude_category: CATEGORY_FILTER | None = None, + language: LANGUAGE_FILTER | None = None, + exclude_language: LANGUAGE_FILTER | None = None, + domain: DOMAIN_FILTER | None = None, + domainurl: DOMAIN_URL_FILTER | None = None, + exclude_domain: DOMAIN_FILTER | None = None, + priority_domain: PRIORITY_DOMAIN | None = None, + timezone: TIMEZONE | None = None, + full_content: FLAG | None = None, + image: FLAG | None = None, + video: FLAG | None = None, + removeduplicate: REMOVE_DUPLICATE | None = None, + tag: TAG_FILTER | None = None, + sentiment: SENTIMENT | None = None, + sentiment_score: SENTIMENT_SCORE | None = None, + region: REGION_FILTER | None = None, + organization: ORGANIZATION_FILTER | None = None, + creator: CREATOR_FILTER | None = None, + datatype: DATATYPE_FILTER | None = None, + excludefield: EXCLUDE_FIELD_FILTER | None = None, +) -> str: + """ + Use this tool to SET UP a real-time news feed. It registers a standing + query on the account and returns a `registration_id`. It does NOT return + articles — pass the id to `stream_news` for those. + + Register once, then stream many times. Registrations persist across + sessions; use `list_realtime_queries` to see what already exists before + creating another. + + Strict rules (this tool returns Error before any API call if violated): + - Use only ONE of `q`, `q_in_title`, or `q_in_meta`. + - Do NOT combine `country` with `exclude_country`. + - Do NOT combine `language` with `exclude_language`. + - Do NOT combine `domain`, `domainurl`, and/or `exclude_domain` with each other. + - `sentiment_score` requires `sentiment` to also be set. + + Other guidance: + - There are NO date or paging filters here: a registered query matches + news as it is published, not historical news. For historical news use + `get_archive_news`. + - Registering the SAME filters twice answers Error (HTTP 409); the + existing id is in the message. Call `list_realtime_queries` first. + - Narrow the query. A broad one (no filters) will fire constantly. + + Examples: + - `q="bitcoin", language="en"` → English bitcoin news as it breaks + - `category="technology", country="us", priority_domain="top"` + - `q="earnings beat", organization="tesla,nvidia"` + """ + error = ( + check_mutex_groups(locals()) + or check_sentiment_score_requires_sentiment(locals()) + ) + if error: + return f"Error: {error}" + + data = await fetch( + "websocket/register", + { + "q": q, + "qInTitle": q_in_title, + "qInMeta": q_in_meta, + "country": country, + "excludecountry": exclude_country, + "category": category, + "excludecategory": exclude_category, + "language": language, + "excludelanguage": exclude_language, + "domain": domain, + "domainurl": domainurl, + "excludedomain": exclude_domain, + "prioritydomain": priority_domain, + "timezone": timezone, + "full_content": full_content, + "image": image, + "video": video, + "removeduplicate": removeduplicate, + "tag": tag, + "sentiment": sentiment, + "sentiment_score": sentiment_score, + "region": region, + "organization": organization, + "creator": creator, + "datatype": datatype, + "excludefield": excludefield, + "news_type": WS_NEWS_TYPE, + }, + method="POST", + ) + return format_registration(data) + + +@mcp.tool(annotations=READ_ONLY_TOOL) +async def list_realtime_queries() -> str: + """ + Use this tool to SEE which real-time queries are already registered on + the account, with their `registration_id` values and filters. + + Call this before `register_realtime_query` to avoid creating a duplicate + (which the API rejects with 409), and to recover an id you want to pass + to `stream_news` or `delete_realtime_query`. + + Strict rules: none. Takes no parameters. + """ + data = await fetch("websocket/fetch", {}) + return format_registered_queries(data) + + +@mcp.tool(annotations=DESTRUCTIVE_TOOL) +async def delete_realtime_query(registration_id: REGISTRATION_ID) -> str: + """ + Use this tool to PERMANENTLY REMOVE a registered real-time query. + + The registration is gone afterwards and `stream_news` will no longer + accept that `registration_id`. This does not delete any articles — only + the standing query. Confirm with the user before calling it on an id you + did not just create. + + Strict rules: + - `registration_id` is required. Get it from `list_realtime_queries`. + """ + data = await fetch( + "websocket/delete", + {"registration_id": registration_id}, + method="DELETE", + ) + return format_deletion(data, registration_id) + + +@mcp.tool(annotations=READ_ONLY_TOOL) +async def stream_news( + registration_id: REGISTRATION_ID, + max_articles: int = 10, + wait_seconds: float = 30, +) -> str: + """ + Use this tool to COLLECT real-time articles for an already-registered + query. Register it first with `register_realtime_query`. + + This listens on a live connection and returns as soon as EITHER + `max_articles` have arrived OR `wait_seconds` elapses — whichever comes + first. It always returns within `wait_seconds`; it does not run forever. + The reply says `stopped_because: max_articles | timeout | + connection_closed`, so you can tell "the cap was hit, there may be more" + from "the feed was quiet". + + Call it again to keep listening — the registration stays alive, and a new + call picks up whatever has been published since. + + Strict rules: + - `registration_id` is required and must already be registered. + - `max_articles` is clamped to 1..50, `wait_seconds` to 1..120. + + Other guidance: + - An empty result is NORMAL for a narrow query: it means nothing matched + during the window, not that anything failed. Do not retry in a tight + loop; either widen the query or wait longer. + - COST: every article delivered consumes 1 API credit per connected + device. A broad query on a long window can burn credits quickly — keep + `max_articles` no higher than you need. + - At most 5 devices may stream one `registration_id` at a time; beyond + that the connection is rejected with "device limit reached". + - Prefer a longer `wait_seconds` over repeated short calls. + - A rejected connection (bad key, unknown registration_id, exhausted + credits, device limit) returns an Error and is not retried. + + Examples: + - `registration_id="a1b2c3", max_articles=5, wait_seconds=30` + - `registration_id="a1b2c3", max_articles=50, wait_seconds=120` → widest sweep + """ + if not NEWSDATA_API_KEY: + return "Error: NEWSDATA_API_KEY is not configured." + + max_articles = max(1, min(int(max_articles), WS_MAX_ARTICLES)) + wait_seconds = max(1.0, min(float(wait_seconds), WS_MAX_WAIT_SECONDS)) + + url = ( + f"{NEWSDATA_WS_URL}?apikey={NEWSDATA_API_KEY}" + f"®istration_id={registration_id}" + ) + + articles: list[dict[str, Any]] = [] + started = time.monotonic() + stopped_because = "timeout" + + try: + # The whole session is bounded, so a slow handshake cannot make the + # tool outlive its budget. + async with asyncio.timeout(wait_seconds): + async with ws_client.connect(url, open_timeout=None) as websocket: + async for message in websocket: + response = _parse(message) + if response is None: + continue # skip malformed frames + results = response.get("results") + if isinstance(results, list): + articles.extend(a for a in results if isinstance(a, dict)) + elif isinstance(results, dict): + articles.append(results) + if len(articles) >= max_articles: + stopped_because = "max_articles" + break + else: + # Iteration ended on its own: the server closed the feed + # rather than us hitting the cap or the deadline. + stopped_because = "connection_closed" + except TimeoutError: + stopped_because = "timeout" + except InvalidStatus as exc: + status = exc.response.status_code + if status in (401, 403): + return ( + f"Error (HTTP {status}): the real-time connection was rejected. " + "Check the API key, the WebSocket entitlement on the plan, and " + "that the registration_id exists (list_realtime_queries)." + ) + return f"Error (HTTP {status}): could not open the real-time connection." + except ConnectionClosed as exc: + # The server accepts every handshake and then closes with 1008 on a + # permanent failure: "invalid credentials or registration not found", + # "api limit reached", or "device limit reached". + close = exc.rcvd + if close is not None and close.code == WS_POLICY_VIOLATION: + reason = close.reason or "connection rejected" + return f"Error: {reason}" + # Any other close — including 1013 "send timeout" — is transient; + # return whatever was collected before the drop. + stopped_because = "connection_closed" + except OSError as exc: + logger.warning("real-time connection failed: %s", exc) + if not articles: + return f"Error: could not reach the real-time service ({exc})." + stopped_because = "connection_closed" + + del articles[max_articles:] + waited = time.monotonic() - started + return format_stream(articles, registration_id, stopped_because, waited) + + +def _parse(message: Any) -> dict[str, Any] | None: + """Decode one frame, returning None when it isn't a JSON object.""" + if isinstance(message, bytes | bytearray): + try: + message = message.decode("utf-8") + except UnicodeDecodeError: + return None + if not isinstance(message, str): + return None + try: + parsed = json.loads(message) + except ValueError: + return None + return parsed if isinstance(parsed, dict) else None diff --git a/tests/test_realtime.py b/tests/test_realtime.py new file mode 100644 index 0000000..9bfaabc --- /dev/null +++ b/tests/test_realtime.py @@ -0,0 +1,289 @@ +"""Tests for the real-time WebSocket tools. + +The management tools go through `respx` like every other endpoint. The +streaming tool is exercised against a fake `connect()` so no live socket +(or server) is needed. +""" +import json +from typing import Any + +import httpx +import pytest +import respx + +from newsdata_mcp.tools import realtime +from newsdata_mcp.tools.realtime import ( + delete_realtime_query, + list_realtime_queries, + register_realtime_query, + stream_news, +) + + +def article_frame(article_id: str, title: str) -> str: + return json.dumps( + { + "status": "success", + "totalResults": 1, + "results": [{"article_id": article_id, "title": title}], + } + ) + + +class FakeWebSocket: + """Async-iterable stand-in for a live websockets connection.""" + + def __init__(self, messages: list[Any], hang: bool = False): + self._messages = list(messages) + self._hang = hang + + def __aiter__(self): + return self + + async def __anext__(self): + if self._messages: + return self._messages.pop(0) + if self._hang: + # Simulate a quiet feed: never yields, so the tool's own + # timeout decides when to return. + import asyncio + + await asyncio.sleep(3600) + raise StopAsyncIteration + + +class FakeConnect: + """Stands in for `websockets.asyncio.client.connect`.""" + + def __init__(self, socket=None, error: Exception | None = None): + self.socket = socket + self.error = error + self.url: str | None = None + + def __call__(self, url: str, **kwargs): + self.url = url + return self + + async def __aenter__(self): + if self.error is not None: + raise self.error + return self.socket + + async def __aexit__(self, *exc): + return False + + +@pytest.fixture(autouse=True) +def api_key(monkeypatch): + """Every tool needs a key configured.""" + monkeypatch.setattr(realtime, "NEWSDATA_API_KEY", "test-key") + monkeypatch.setattr(realtime, "NEWSDATA_WS_URL", "wss://ws.example.test/ws/event") + + +# ---- register ----------------------------------------------------------- + + +@respx.mock +async def test_register_posts_with_news_type(): + route = respx.post("https://newsdata.io/api/1/websocket/register").mock( + return_value=httpx.Response( + 200, + json={"status": "success", "results": {"registration_id": "9b2d1e8a7c4f4b6e9d3a5c7e1f2a4b6c"}}, + ) + ) + out = await register_realtime_query(q="bitcoin", language="en") + + assert route.called + qs = dict(route.calls[0].request.url.params) + assert qs["news_type"] == "latest" + assert qs["q"] == "bitcoin" + assert qs["language"] == "en" + assert route.calls[0].request.method == "POST" + assert "registration_id: 9b2d1e8a7c4f4b6e9d3a5c7e1f2a4b6c" in out + + +@respx.mock +async def test_register_rejects_mutually_exclusive_filters(): + out = await register_realtime_query(q="a", q_in_title="b") + assert out.startswith("Error:") + assert not respx.calls # short-circuits before any request + + +@respx.mock +async def test_register_requires_sentiment_for_sentiment_score(): + out = await register_realtime_query(sentiment_score=80) + assert out.startswith("Error:") + assert "'sentiment'" in out + + +@respx.mock +async def test_register_surfaces_409_conflict(): + respx.post("https://newsdata.io/api/1/websocket/register").mock( + return_value=httpx.Response( + 409, + json={"status": "error", "results": {"message": "already registered"}}, + ) + ) + out = await register_realtime_query(q="bitcoin") + assert "Error (HTTP 409)" in out + assert "already registered" in out + + +# ---- list --------------------------------------------------------------- + + +@respx.mock +async def test_list_renders_registered_queries(): + respx.get("https://newsdata.io/api/1/websocket/fetch").mock( + return_value=httpx.Response( + 200, + json={ + "status": "success", + "results": { + "queries": [ + {"registration_id": "1111111122223333444455556666aaaa", "q": "bitcoin"}, + {"registration_id": "2222222233334444555566667777bbbb", "q": "ethereum"}, + ] + }, + }, + ) + ) + out = await list_realtime_queries() + assert "registered_queries: 2" in out + assert "registration_id: 1111111122223333444455556666aaaa" in out + assert "registration_id: 2222222233334444555566667777bbbb" in out + + +@respx.mock +async def test_list_handles_no_registrations(): + respx.get("https://newsdata.io/api/1/websocket/fetch").mock( + return_value=httpx.Response( + 200, json={"status": "success", "results": {"queries": []}} + ) + ) + out = await list_realtime_queries() + assert "No real-time queries are registered" in out + + +# ---- delete ------------------------------------------------------------- + + +@respx.mock +async def test_delete_uses_delete_method(): + route = respx.delete("https://newsdata.io/api/1/websocket/delete").mock( + return_value=httpx.Response( + 200, json={"status": "success", "results": {"deleted": True}} + ) + ) + out = await delete_realtime_query(registration_id="9b2d1e8a7c4f4b6e9d3a5c7e1f2a4b6c") + + assert route.calls[0].request.method == "DELETE" + assert dict(route.calls[0].request.url.params)["registration_id"] == "9b2d1e8a7c4f4b6e9d3a5c7e1f2a4b6c" + assert "deleted: yes" in out + assert "registration_id: 9b2d1e8a7c4f4b6e9d3a5c7e1f2a4b6c" in out + + +@respx.mock +async def test_delete_accepts_resultless_success(): + respx.delete("https://newsdata.io/api/1/websocket/delete").mock( + return_value=httpx.Response(200, json={"status": "success"}) + ) + out = await delete_realtime_query(registration_id="9b2d1e8a7c4f4b6e9d3a5c7e1f2a4b6c") + assert "deleted: yes" in out + + +# ---- stream ------------------------------------------------------------- + + +async def test_stream_collects_until_max_articles(monkeypatch): + socket = FakeWebSocket( + [article_frame("a1", "one"), article_frame("a2", "two"), article_frame("a3", "three")] + ) + fake = FakeConnect(socket) + monkeypatch.setattr(realtime.ws_client, "connect", fake) + + out = await stream_news(registration_id="1111111122223333444455556666aaaa", max_articles=2, wait_seconds=5) + + assert "collected_articles: 2" in out + assert "stopped_because: max_articles" in out + assert "title: one" in out + assert "title: two" in out + assert "three" not in out, "should stop at the cap" + + +async def test_stream_sends_apikey_and_registration_id(monkeypatch): + fake = FakeConnect(FakeWebSocket([article_frame("a1", "one")])) + monkeypatch.setattr(realtime.ws_client, "connect", fake) + + await stream_news(registration_id="42424242333344445555666677778888", max_articles=1, wait_seconds=5) + + assert "apikey=test-key" in fake.url + assert "registration_id=42424242333344445555666677778888" in fake.url + + +async def test_stream_skips_malformed_frames(monkeypatch): + socket = FakeWebSocket(["not json at all", article_frame("a1", "one")]) + monkeypatch.setattr(realtime.ws_client, "connect", FakeConnect(socket)) + + out = await stream_news(registration_id="1111111122223333444455556666aaaa", max_articles=1, wait_seconds=5) + + assert "collected_articles: 1" in out + assert "title: one" in out + + +async def test_stream_returns_on_timeout_when_feed_is_quiet(monkeypatch): + # No messages and the socket hangs — only the timeout can end this. + monkeypatch.setattr( + realtime.ws_client, "connect", FakeConnect(FakeWebSocket([], hang=True)) + ) + + out = await stream_news(registration_id="1111111122223333444455556666aaaa", max_articles=10, wait_seconds=1) + + assert "collected_articles: 0" in out + assert "stopped_because: timeout" in out + assert "No matching articles were published" in out + + +async def test_stream_reports_connection_closed(monkeypatch): + # The socket ends its iteration without hitting the cap. + socket = FakeWebSocket([article_frame("a1", "one")]) + monkeypatch.setattr(realtime.ws_client, "connect", FakeConnect(socket)) + + out = await stream_news(registration_id="1111111122223333444455556666aaaa", max_articles=10, wait_seconds=5) + + assert "collected_articles: 1" in out + assert "stopped_because: connection_closed" in out + + +async def test_stream_reports_rejected_handshake(monkeypatch): + from websockets.exceptions import InvalidStatus + + class FakeResponse: + status_code = 401 + + monkeypatch.setattr( + realtime.ws_client, + "connect", + FakeConnect(error=InvalidStatus(FakeResponse())), + ) + + out = await stream_news(registration_id="1111111122223333444455556666aaaa", max_articles=1, wait_seconds=5) + + assert out.startswith("Error (HTTP 401)") + assert "entitlement" in out or "API key" in out + + +async def test_stream_clamps_out_of_range_arguments(monkeypatch): + socket = FakeWebSocket([article_frame(f"a{i}", f"t{i}") for i in range(80)]) + monkeypatch.setattr(realtime.ws_client, "connect", FakeConnect(socket)) + + # max_articles above the ceiling is clamped to 50. + out = await stream_news(registration_id="1111111122223333444455556666aaaa", max_articles=999, wait_seconds=5) + assert "collected_articles: 50" in out + + +async def test_stream_requires_an_api_key(monkeypatch): + monkeypatch.setattr(realtime, "NEWSDATA_API_KEY", None) + out = await stream_news(registration_id="1111111122223333444455556666aaaa") + assert out.startswith("Error:") + assert "NEWSDATA_API_KEY" in out diff --git a/uv.lock b/uv.lock index 184adb4..b8f661e 100644 --- a/uv.lock +++ b/uv.lock @@ -572,6 +572,7 @@ dependencies = [ { name = "mcp", extra = ["cli"] }, { name = "pydantic" }, { name = "python-dotenv" }, + { name = "websockets" }, ] [package.dev-dependencies] @@ -590,6 +591,7 @@ requires-dist = [ { name = "mcp", extras = ["cli"], specifier = ">=1.27.0,<2" }, { name = "pydantic", specifier = ">=2.7.0,<3" }, { name = "python-dotenv", specifier = ">=1.2.2,<2" }, + { name = "websockets", specifier = ">=16,<17" }, ] [package.metadata.requires-dev] @@ -1070,3 +1072,80 @@ sdist = { url = "https://files.pythonhosted.org/packages/eb/2e/62b0d9a2cfc8b4de6 wheels = [ { url = "https://files.pythonhosted.org/packages/c1/88/d0f7512465b166a4e931ccf7e77792be60fb88466a43964c7566cbaff752/uvicorn-0.45.0-py3-none-any.whl", hash = "sha256:2db26f588131aeec7439de00f2dd52d5f210710c1f01e407a52c90b880d1fd4f", size = 69838, upload-time = "2026-04-21T10:43:45.029Z" }, ] + +[[package]] +name = "websockets" +version = "16.1.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/21/f7/bc3a25c5ec26ce62ce487690becc2f3710bbc7b33338f005ad390db0b986/websockets-16.1.1.tar.gz", hash = "sha256:db234eda965dcce15df96bb9709f587cd87d4d52aaf0e80e2f34ec04c7670c57", size = 182204, upload-time = "2026-07-17T22:51:05.858Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/17/9d/681cda21c9eee743203a6cb79b9d3d05adad9aa60ec660c6c9bf4dd619ca/websockets-16.1.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:cc97814dfb786a83b6e2dc2e79351e1b83e6d715647d6887fcabd83026417a00", size = 179600, upload-time = "2026-07-17T22:49:13.92Z" }, + { url = "https://files.pythonhosted.org/packages/fb/8d/6195a88b45e8d2a8f745fc2046e36f885a3c9763e6767d2c46229bf9510c/websockets-16.1.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:e047dc87ef7ca50f4d309bf775ad4a71711c58556d75d7bd0604b2317f43e94b", size = 177272, upload-time = "2026-07-17T22:49:15.453Z" }, + { url = "https://files.pythonhosted.org/packages/73/e3/fe2d498c64dea0095c9a9f9a351af4cd6eef31b618395582bc1f38ba45ff/websockets-16.1.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:01fbdcbac298efe19360b94bc0039c8f746f0220ba570f327577bfee81059175", size = 177542, upload-time = "2026-07-17T22:49:16.875Z" }, + { url = "https://files.pythonhosted.org/packages/fe/ed/f1831681fce0e3242346e5458486003c5f124ed69e5e0b847fd029db4973/websockets-16.1.1-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:0f62863e8a00a6d33c3d6566ec0b89f23787b747ffe0c3bc71ec0e76b82c94b1", size = 187137, upload-time = "2026-07-17T22:49:18.323Z" }, + { url = "https://files.pythonhosted.org/packages/6f/79/4ff9dcc1bb46f6b4c536936dde1fd60f9b564f3304307274db97f4c9496d/websockets-16.1.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8087e82f842609734c9b5a1330464f8e94e346ba0e18c832c08bafa4b0d63c15", size = 188374, upload-time = "2026-07-17T22:49:19.65Z" }, + { url = "https://files.pythonhosted.org/packages/62/c3/5c49b6efb36cab733d23773f6de575e1dba65736ead17d5d2b2a1daef779/websockets-16.1.1-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:2bb5d041a8307d2e18782e7ce777f6fdb1e8c2f5d09291484b18c294b789d9aa", size = 191155, upload-time = "2026-07-17T22:49:21.331Z" }, + { url = "https://files.pythonhosted.org/packages/6e/f6/56ccceda3a4838d18f1d40821480da4775397e8b1eecf4031e20c50e2e90/websockets-16.1.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:1db4de4a0e95673f7545d393c49eeb0c2f18ac1ef93073218c79d5cdb2ee75ab", size = 189011, upload-time = "2026-07-17T22:49:22.889Z" }, + { url = "https://files.pythonhosted.org/packages/86/d6/ad5286241a2bce1107e2798d3bfbd62cf79aee167bdb654f8cb1e9dbf949/websockets-16.1.1-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f17dbe07eb3ea7f99e4df9b7e0efefe80fbf30d37a8cc4d561a0aed310bc8847", size = 187766, upload-time = "2026-07-17T22:49:24.339Z" }, + { url = "https://files.pythonhosted.org/packages/bc/67/d65c970b7e347fdca69479beb7811c2060529956730a7a4e3ae7c66b0e31/websockets-16.1.1-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:4b57693728576d84ede0a77987ab16881b783d2cd9f1dc180a8fbbc3f79c4428", size = 185173, upload-time = "2026-07-17T22:49:25.743Z" }, + { url = "https://files.pythonhosted.org/packages/1d/5b/14af3cd4ee69d8ea9baca58f3dc3cfb1ba78332a347fd478cb096549d60e/websockets-16.1.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:2a636ff1e7a5c4edf71ef0e79adae7f25dba93b4fcbe3dc958733477ffeb0eaf", size = 187809, upload-time = "2026-07-17T22:49:27.147Z" }, + { url = "https://files.pythonhosted.org/packages/7b/11/be301710d70de97e3e7b3586e6d492c9c06d6a61bf1c2202c36cf0c75607/websockets-16.1.1-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:d6bec75c290fe484a8ba4cacdf838501e17c06ecfbbf31eede81a9e431bd7751", size = 186412, upload-time = "2026-07-17T22:49:28.611Z" }, + { url = "https://files.pythonhosted.org/packages/db/07/fe1435bf6fe738a3d3b54dbe0c18dabf12cba4d909ac8b58b539ce27c1f4/websockets-16.1.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:54509b8e92fee4453e152b7558ddef37ce9705a044922f2095a6105e3f80c96f", size = 188290, upload-time = "2026-07-17T22:49:29.965Z" }, + { url = "https://files.pythonhosted.org/packages/8a/0a/81f394aff8efcbb01208c1ced77df0a3c7fcce584a88c7273663697946c2/websockets-16.1.1-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:f0aa4aad3b1b69ad3fd85a0fd0952ec64331c762bd77ec51cc814170873890b2", size = 185844, upload-time = "2026-07-17T22:49:31.447Z" }, + { url = "https://files.pythonhosted.org/packages/39/5c/dd485b995473f415510251fe9bd708f2d24458f439fce958daf8d66dc7c6/websockets-16.1.1-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:42290eb6db4ccaca7012656738214f8514082fb6fa40cdeb61bb9a471b52e383", size = 186823, upload-time = "2026-07-17T22:49:33.104Z" }, + { url = "https://files.pythonhosted.org/packages/9d/0b/f78de76ff446f1e66af12b43c48a35f31744de93cfdec2f4ea67d5d7bbf1/websockets-16.1.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:53260c8930da5771cec89439bff99c20c8cb03ddb9588b980697355a83cd4bd3", size = 187102, upload-time = "2026-07-17T22:49:34.616Z" }, + { url = "https://files.pythonhosted.org/packages/37/a1/4cf892007778eaf84ad162bfc98046e0ed89b63ac55949e3236626b2a23f/websockets-16.1.1-cp312-cp312-win32.whl", hash = "sha256:1d27fa8462ad6a1cb36206a3d0640b2333340def181fae11ed7f9adeaa5c0747", size = 179943, upload-time = "2026-07-17T22:49:36.213Z" }, + { url = "https://files.pythonhosted.org/packages/d9/de/6abe251d28c3a3f217096575400b27750b18e0b1d2fff3a2a239960fea07/websockets-16.1.1-cp312-cp312-win_amd64.whl", hash = "sha256:b436f6ec4fc3a6b4237c84d3f83170ed2b40bb584222f0ac47a0c8a5921980c7", size = 180243, upload-time = "2026-07-17T22:49:37.626Z" }, + { url = "https://files.pythonhosted.org/packages/ce/fd/6ec6c6d2850aea25b1b2aa9901a016980bb87d01e89b3eb00470b1b5d471/websockets-16.1.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:ab59169ace05dcb49a1d4118f0bde139557adf45091bd85747e36bf5de984dd1", size = 179587, upload-time = "2026-07-17T22:49:38.959Z" }, + { url = "https://files.pythonhosted.org/packages/5f/d8/1d299d2dd34087db39831a34cc645ef8a6f89d78efada6983093513cd81c/websockets-16.1.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:5e3b7d601f6f84156b08cc4a5e541c2b50ad7b36cfc302b657a12477c904a5df", size = 177272, upload-time = "2026-07-17T22:49:40.293Z" }, + { url = "https://files.pythonhosted.org/packages/3d/86/0a70d3ae2f0f2256bb41302d9804dbca65d4360281e7feb3e1f94102ac46/websockets-16.1.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:cd2ca96a082a36964aca83e992f72abeb61b7306c1a6cba4c7d06a7b93750cac", size = 177530, upload-time = "2026-07-17T22:49:41.786Z" }, + { url = "https://files.pythonhosted.org/packages/b5/c2/c676c69444d9db448b3f0a55a98dcc534affce0bce961d9d2f0b8499b10a/websockets-16.1.1-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:f5d497865f05bb222cab7016c6034542e84e5f29f49c6fd3f4939cda7197b5b8", size = 187197, upload-time = "2026-07-17T22:49:43.658Z" }, + { url = "https://files.pythonhosted.org/packages/0b/13/88137fbaf726ebe29d62c1117fa11fa2bbb6209dc79d4ad738efbe36a2aa/websockets-16.1.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bae954c382e013d5ea5b190d2830526bfa45ad121c326da0049b8c769f185db6", size = 188433, upload-time = "2026-07-17T22:49:45.147Z" }, + { url = "https://files.pythonhosted.org/packages/01/6d/46c2f2ce6751cb26f39293e1ecbf8544cb01321397cd476c2756b98c216d/websockets-16.1.1-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:e09f753a169951eb4f28c2c774f71069304f66e7277e0f5a2892423599cfa854", size = 189868, upload-time = "2026-07-17T22:49:46.581Z" }, + { url = "https://files.pythonhosted.org/packages/29/2b/170a9e8097636cfde4dc3c592b6e00b18a44a2f5407606d96ca542dd5838/websockets-16.1.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:024193f8551a2b0eafbdd160911012c4e6c228c28430c84433253299a9e42d6a", size = 189059, upload-time = "2026-07-17T22:49:47.972Z" }, + { url = "https://files.pythonhosted.org/packages/a7/48/f0d4ebc9ab4b473b8861b9e20fdb663d515d42f7befdf62cdb60fee7a1ec/websockets-16.1.1-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:aabe464bfd13bd25f4821faf111da6fefdc389f870265a53105580e45b0a2e49", size = 187814, upload-time = "2026-07-17T22:49:49.344Z" }, + { url = "https://files.pythonhosted.org/packages/d5/ba/39a41d3ae8e72696a9492581900611c5a91e2b07563b0bcd2523adea9854/websockets-16.1.1-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a28fcbc9b6baf54a2e23f8655f308e4ccc6afdd7266f8fe7954f320dcda0f785", size = 185229, upload-time = "2026-07-17T22:49:50.787Z" }, + { url = "https://files.pythonhosted.org/packages/3c/36/ac15b604f850d1907f0a85ed721cefe47cd45034b3620069b829746cccbe/websockets-16.1.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:79eace538c6a97e96d0d03d4f9d314f9677f5ed85a8a984992ffd90b13cb8a56", size = 187874, upload-time = "2026-07-17T22:49:52.228Z" }, + { url = "https://files.pythonhosted.org/packages/a8/f3/3fbd5d71d59299c3770faa5884d4f45070236ca5a35ab3a61830812c409a/websockets-16.1.1-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:496af849a472b531f758dbd4d61338f5000538cb1a7b3d20d9d32a264517f509", size = 186469, upload-time = "2026-07-17T22:49:53.776Z" }, + { url = "https://files.pythonhosted.org/packages/b4/fc/dd90349bba58af2a53ef2ddd9c32716c81eb6d59a0687939fff561860878/websockets-16.1.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:5283810d2646741a0d8da2aa733d6aefa0545809afccb2a5d105a26bc45125f1", size = 188347, upload-time = "2026-07-17T22:49:55.202Z" }, + { url = "https://files.pythonhosted.org/packages/4c/f3/f73ba86427682da59b78c11d77ba56d5b801c32e84afe79b274bbd6a9bb2/websockets-16.1.1-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:4e3b680b1e0a27457e727a0d572fd81dffa87b6dbf8b228ab57da64f7d85aead", size = 185903, upload-time = "2026-07-17T22:49:56.75Z" }, + { url = "https://files.pythonhosted.org/packages/34/7c/f95eb20e80104173b3a0a092291f89ea4047ef6e608e0a57ca06eb14eecb/websockets-16.1.1-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:69159730a823dde3ea8d08783e8d47ef135a6d7e8d44eb127e32b321c9db8e3e", size = 186855, upload-time = "2026-07-17T22:49:58.467Z" }, + { url = "https://files.pythonhosted.org/packages/b0/35/dd875b3e050ff232d60fa377707f890e369f74d134f1be32e8f68879747c/websockets-16.1.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:ed5bb271084b46530ee2ddc0410537a9961152c5ccba2fc98c5276d992ccba87", size = 187140, upload-time = "2026-07-17T22:50:00.016Z" }, + { url = "https://files.pythonhosted.org/packages/e8/dc/5cbfcb41824502f6af93b8f3943a4d06c67c23c7d2e31eb18748c4a5b2a7/websockets-16.1.1-cp313-cp313-win32.whl", hash = "sha256:cfb70b4eb56cac4da0a83588f3ad50d46beb0690391082f3d4e2d488c70b68ea", size = 179928, upload-time = "2026-07-17T22:50:01.685Z" }, + { url = "https://files.pythonhosted.org/packages/b0/c1/71e5deb5b7f8f226997ab64908c184ac3105c0155ce2d486f318e5dd08a8/websockets-16.1.1-cp313-cp313-win_amd64.whl", hash = "sha256:d9531d9cbeac99af6f038fb1bc351403531f7d634a2c2e10e2f7c854c6ed5b68", size = 180242, upload-time = "2026-07-17T22:50:03.117Z" }, + { url = "https://files.pythonhosted.org/packages/73/a2/ba78a164eeea4620df4a4df4bd2ed6017438c4655cc0f36f2c0bc0432355/websockets-16.1.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:443aefe96b7fdb132e2a70806cca1f2af49bb3f28e47abcd7c2e9dcf4d8fa1b8", size = 179635, upload-time = "2026-07-17T22:50:05.001Z" }, + { url = "https://files.pythonhosted.org/packages/b9/08/d26d7a7628cd4ac34cbbdb63ac80914ca842ed8e42938c40a53567806df3/websockets-16.1.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:6456ff333092d509127d75a638cb411afae8ff17f092635015d1902efec8a293", size = 177320, upload-time = "2026-07-17T22:50:06.427Z" }, + { url = "https://files.pythonhosted.org/packages/0f/45/ebec83e6269536aa5932533c67b0af5c781f3e73fdbcd68672dcf43f4f44/websockets-16.1.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:fce6c48559c86d1ac3632ccb1bebc7d5442fbe79bd9bb0e40379ee54be2a4051", size = 177544, upload-time = "2026-07-17T22:50:07.834Z" }, + { url = "https://files.pythonhosted.org/packages/c9/d5/abc614d2297f6c1c3e01e61260364457a47c25cc1cf6a879038902bc6aa8/websockets-16.1.1-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:92b820d345f7a3fc7b8163949ee92df910f290c3fc517b3d5301c78065adafe1", size = 187270, upload-time = "2026-07-17T22:50:09.275Z" }, + { url = "https://files.pythonhosted.org/packages/52/71/4c99af3b87dff1b2927981f6876607d4acb45338c665242168d3982f7758/websockets-16.1.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2a606d9c24035242a3e256e9d5b77ed9cd6bccfcb7cf993e5ca3c0f6f68fb6a7", size = 188509, upload-time = "2026-07-17T22:50:10.722Z" }, + { url = "https://files.pythonhosted.org/packages/9b/b4/5c8ca14b0df7eb84ed0524165c5359150210140817a3312aee57bf62a1cf/websockets-16.1.1-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:414e596c75f74e0994084694189d7dc9229fb278e33064d6784b73ffbba3ca31", size = 189882, upload-time = "2026-07-17T22:50:12.293Z" }, + { url = "https://files.pythonhosted.org/packages/25/c1/bedfba9e70557129cb8083748d167bdcc01483dedf0f0df143676df05cbe/websockets-16.1.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:536676848fc5961aca9d20389951f59169508f765637a172403dc5434d722fa0", size = 189114, upload-time = "2026-07-17T22:50:13.789Z" }, + { url = "https://files.pythonhosted.org/packages/df/09/aa835b2787835aebd839114be5de51b797cb480b63ba42b26d34dfe147cb/websockets-16.1.1-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:97fd3a0e8b53efa41970ac1dff3d8cf0d2884cadeb4caaf95db7ad1526926ee3", size = 187861, upload-time = "2026-07-17T22:50:15.179Z" }, + { url = "https://files.pythonhosted.org/packages/20/26/f6408330694dbc9830857d9d23bc14ac4f6875127a480cfdda8d5ca21198/websockets-16.1.1-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:7b1b19636af86a3c7995d4d028dbe376f39b4bf31541146f9c123582a6c94562", size = 185286, upload-time = "2026-07-17T22:50:16.741Z" }, + { url = "https://files.pythonhosted.org/packages/17/9a/e0675e70dd8a80762cf35bb18799d3f290a4890ffe6439bc51d222796083/websockets-16.1.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:41c8e77f17294c0ac18008a7309b99b34ee72247ef10b6dff4c3f8b5ac29896b", size = 187935, upload-time = "2026-07-17T22:50:18.213Z" }, + { url = "https://files.pythonhosted.org/packages/33/c1/3234cfb86afde01b81e9bddcc6e534c440975d60a13991259e833069ab3e/websockets-16.1.1-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:9f63bcef7f4b02b06b35fc01c93b96c43b5e88e1e8868676caacf493d5a31f3a", size = 186444, upload-time = "2026-07-17T22:50:19.67Z" }, + { url = "https://files.pythonhosted.org/packages/89/87/9c15206e1d778923d8daa9657de07aa62ea815e13448319c98458c37b281/websockets-16.1.1-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:dab9eb87869da2d6ed3af3f3adf28414baae6ec9d4df355ffc18889132f3436c", size = 188409, upload-time = "2026-07-17T22:50:21.28Z" }, + { url = "https://files.pythonhosted.org/packages/f2/00/cf5de5c67676de2d3eef8b2a518f168f6796595447a5b7161ba0d012915c/websockets-16.1.1-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:43e3a9fdd7cbf7ba6040c31fae0faf84ca1474fef777c4e37912f1540f854499", size = 185958, upload-time = "2026-07-17T22:50:22.719Z" }, + { url = "https://files.pythonhosted.org/packages/62/c0/731b6ddede2e4136912ec4cff2cffbda35af73546be4762c3d7bd3bd79af/websockets-16.1.1-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:056ae37939ed7e9974f364f5864e76e49182622d8f9751ac1903c0d09b013985", size = 186911, upload-time = "2026-07-17T22:50:24.108Z" }, + { url = "https://files.pythonhosted.org/packages/8c/7f/39c634472c4469a24a7c09cecddffb08fac6d0e74f73881a94ee8a40a196/websockets-16.1.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:a0eadbbf2c30f01efa58e1f110eb6fa293261f6b0b1aa38f7f48707107690af9", size = 187204, upload-time = "2026-07-17T22:50:25.548Z" }, + { url = "https://files.pythonhosted.org/packages/26/89/9667c256c256dafcc62d21328ce7a40067da857969b68ee9af375b0aaf72/websockets-16.1.1-cp314-cp314-win32.whl", hash = "sha256:195c978b065fa40910582464f99d6b15c8b314c68e0546549a55ed83f4735328", size = 179603, upload-time = "2026-07-17T22:50:27.086Z" }, + { url = "https://files.pythonhosted.org/packages/bd/dd/1c099d6c0fc5deb6b46ccdbb6981fdb4b12c917869cb3952408409dc18db/websockets-16.1.1-cp314-cp314-win_amd64.whl", hash = "sha256:4e8d01cc3bcae7bbf8167f944aeafefed590fae5693552bba9794a9df68371cc", size = 179948, upload-time = "2026-07-17T22:50:28.521Z" }, + { url = "https://files.pythonhosted.org/packages/35/25/9956b2d5e0529d5d23924f21bba1440d4c5c88a562e4f08550871ffa97a7/websockets-16.1.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:0ffd3031ea8bda8d61762e84220186105ba3b748b3c8da2ae4f7816fac03e573", size = 179963, upload-time = "2026-07-17T22:50:29.982Z" }, + { url = "https://files.pythonhosted.org/packages/17/06/55ffc976c488b6aee9ea05761ff7c4e88e7c1fd82818c8ca7b556ad2f90c/websockets-16.1.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:84a2cef8deffbd9ab8ee0ea546a2a6a7030c28f44e6cdd4547dbfeb489eb8999", size = 177497, upload-time = "2026-07-17T22:50:31.396Z" }, + { url = "https://files.pythonhosted.org/packages/0c/e8/f7dac2e980bacc92bdc26cebae4ae4d50cae5380732c50980598fc0bbae4/websockets-16.1.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:3df13f73af9b3b38ab1195eb299ecb67a4330c911c97ae04043ff74085728abe", size = 177698, upload-time = "2026-07-17T22:50:32.829Z" }, + { url = "https://files.pythonhosted.org/packages/b2/39/26762f734113e22da2b942c3aca85798e0c0405d64c256549540ff31e5a1/websockets-16.1.1-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:23253dd5bcae3f9aaee0a1d30967a8dbd52e5d3cff93a2e5b84df57b77d4750d", size = 187561, upload-time = "2026-07-17T22:50:34.24Z" }, + { url = "https://files.pythonhosted.org/packages/11/94/c3f330851806b9b02138b774d593478323e73c99238681b4b93efe64e02d/websockets-16.1.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9c1c5705e314449e3308872fe084b8571ce078ee4fc55a98a769bdefe5917392", size = 188732, upload-time = "2026-07-17T22:50:36.088Z" }, + { url = "https://files.pythonhosted.org/packages/d1/f2/eb2c450f052de334ae33cf200ece6e87b0e14d186807074e4eb1cd2cdea2/websockets-16.1.1-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:69e52d175a0a7d1e13b4b67ad41c560b7d98e8c6f6126eb0bda496c784faf8c7", size = 190872, upload-time = "2026-07-17T22:50:38.008Z" }, + { url = "https://files.pythonhosted.org/packages/70/31/2ac8cecf3a74f7fed9132129fc3d90b3998a1554570c11a69b2a8c20332d/websockets-16.1.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:1f79c89b5eb034d1722938a891916582f8f7f503f58ca22518a63c3f2cd18499", size = 189305, upload-time = "2026-07-17T22:50:39.53Z" }, + { url = "https://files.pythonhosted.org/packages/6a/cf/8ab19650d3c0d4562c92e70ab47c257c4aa5c6a713ed87fe63766b31fefc/websockets-16.1.1-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:39f2a024af5c345ffe8fcf1ee18c049c024c94df393bb09b044a6917c77bde43", size = 188033, upload-time = "2026-07-17T22:50:40.912Z" }, + { url = "https://files.pythonhosted.org/packages/66/d7/a49a38a6127a4acb134fb1912b215d900cc657605cff32445bf519f3acc4/websockets-16.1.1-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:952303a7318d4cbe1011400839bb2051c9f84fa0a35923267f5daba34b15d458", size = 185748, upload-time = "2026-07-17T22:50:42.559Z" }, + { url = "https://files.pythonhosted.org/packages/95/3e/ad1fa40388c7f2e0bb2c7930d0090b6c5498594bd1cdaec18864df3d9e97/websockets-16.1.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:249116b4a76063d930a46391ad56e135c286e4562a18309029fc2c73f4ed4c62", size = 188285, upload-time = "2026-07-17T22:50:43.974Z" }, + { url = "https://files.pythonhosted.org/packages/35/b8/d5db28ca264b9104f82196f92dc8843e35fd391f763d42e4ad358f5bc97e/websockets-16.1.1-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:61922544a0587a13fd3f53e4c0e5e606510c7b0d9d22c8444e5fae22a06b38cb", size = 186777, upload-time = "2026-07-17T22:50:45.474Z" }, + { url = "https://files.pythonhosted.org/packages/42/9c/726cb39d0cc43ae848dce4aa2acb04eecc6738b1264ec6d700bf6bcfb9f8/websockets-16.1.1-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:46dcaa042cd1de6c59e7d9269fa63ff7572b6df40510600b678f0826b3c7af51", size = 188682, upload-time = "2026-07-17T22:50:46.973Z" }, + { url = "https://files.pythonhosted.org/packages/be/c7/1168704de8c2dd483edabe4a22cbe4465dd8be8dd95561d214f9fe092871/websockets-16.1.1-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:38565aca3e01ea8734e578fb2118dade0ecb0250533f29e22b8d1a7a196cf4d0", size = 186377, upload-time = "2026-07-17T22:50:48.413Z" }, + { url = "https://files.pythonhosted.org/packages/ca/40/f9ff2d630ffce4e7dfea0b2288e1caf9ebbf9ff8a9ec9396136ce8b94935/websockets-16.1.1-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:42f599f4d48c7e1a3338fdaac3acd075be3b3cf02d4b274f3bf2767aedd3d217", size = 187148, upload-time = "2026-07-17T22:50:49.845Z" }, + { url = "https://files.pythonhosted.org/packages/b5/71/e177c8299f78d7cbe2d14df228643c10c70c0e86e108e092056bbcc16e46/websockets-16.1.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:dcc04fedf83effaeb9cce98abc9469bb1b42ef85f03e01c8c1f4438ef7555737", size = 187578, upload-time = "2026-07-17T22:50:51.619Z" }, + { url = "https://files.pythonhosted.org/packages/49/b2/b6987faf330f5af5c787a2610124c2e8403d51724f9001ec4fff6311fe7a/websockets-16.1.1-cp314-cp314t-win32.whl", hash = "sha256:8483c2096363120eea8b07c06ae7304d520f686665fffd4811fad423930a65d7", size = 179729, upload-time = "2026-07-17T22:50:53.269Z" }, + { url = "https://files.pythonhosted.org/packages/a2/6e/fbac6ed878dd362fbad7d415fa4f84d38e3e33fed8cde45c64e783acf826/websockets-16.1.1-cp314-cp314t-win_amd64.whl", hash = "sha256:bcce07e23e5769375158f5efdcdafa8d5cd014b93c6683865b840ed65b96f231", size = 180072, upload-time = "2026-07-17T22:50:54.969Z" }, + { url = "https://files.pythonhosted.org/packages/be/4d/2d0d67834092e354d2b0498f014a41249a89556bc406cf86f3e1557bb463/websockets-16.1.1-py3-none-any.whl", hash = "sha256:6abbd3e82c731c8e531714466acd5d87b5e88ac3243465337ba71d68e23ae7e3", size = 173814, upload-time = "2026-07-17T22:51:04.184Z" }, +]