diff --git a/.fern/metadata.json b/.fern/metadata.json index 0ff0268..e3a258d 100644 --- a/.fern/metadata.json +++ b/.fern/metadata.json @@ -1,7 +1,7 @@ { - "cliVersion": "5.50.6", + "cliVersion": "5.66.0", "generatorName": "fernapi/fern-python-sdk", - "generatorVersion": "5.14.21", + "generatorVersion": "5.17.0", "generatorConfig": { "package_name": "newscatcher_catchall", "client": { @@ -9,7 +9,7 @@ "filename": "client.py" } }, - "originGitCommit": "f9a7ba07dda24e9d9936bb543686bc542355479e", + "originGitCommit": "1e66c85a387d0d844162c965656c6a0d36334c11", "originGitCommitIsDirty": true, "invokedBy": "ci", "requestedVersion": "AUTO", diff --git a/.fern/replay.lock b/.fern/replay.lock index f04ebfb..677000c 100644 --- a/.fern/replay.lock +++ b/.fern/replay.lock @@ -12,5 +12,291 @@ generations: cli_version: unknown generator_versions: fernapi/fern-python-sdk: 5.14.21 -current_generation: e6179f9ae1194808b56ce70363413efa8fb470f4 -patches: [] + - commit_sha: 6ec633406e1f68e6db1f5d63e3b422d7ee24da28 + tree_hash: aa67e54c05e34792ab3896cf901d9cd97a7d111e + timestamp: 2026-07-08T09:47:47.377Z + cli_version: unknown + generator_versions: + fernapi/fern-python-sdk: 5.17.0 +current_generation: 6ec633406e1f68e6db1f5d63e3b422d7ee24da28 +patches: + - id: patch-7c34ccca + content_hash: sha256:8985648436261cded14f5dab01d668399cfbcda46ccd199918ffad9e37882f02 + original_commit: 7c34ccca9dc9cf1a117dcf162f8108e9210e4de8 + original_message: "chore(pyproject): Manually increase the package version" + original_author: kizy25 + base_generation: 129023272cad7c50e64ea9081d2a8b4a76a9f9e7 + files: + - pyproject.toml + - src/newscatcher_catchall/core/client_wrapper.py + patch_content: |+ + From 7c34ccca9dc9cf1a117dcf162f8108e9210e4de8 Mon Sep 17 00:00:00 2001 + From: kizy25 + Date: Thu, 25 Jun 2026 10:48:17 +0200 + Subject: [PATCH] chore(pyproject): Manually increase the package version + + --- + pyproject.toml | 2 +- + src/newscatcher_catchall/core/client_wrapper.py | 4 ++-- + 2 files changed, 3 insertions(+), 3 deletions(-) + + diff --git a/pyproject.toml b/pyproject.toml + index c6579e4..958beb2 100644 + --- a/pyproject.toml + +++ b/pyproject.toml + @@ -4,7 +4,7 @@ dynamic = ["version"] + + [tool.poetry] + name = "newscatcher-catchall-sdk" + -version = "0.0.0.dev0" + +version = "3.1.0" + description = "" + readme = "README.md" + authors = [] + diff --git a/src/newscatcher_catchall/core/client_wrapper.py b/src/newscatcher_catchall/core/client_wrapper.py + index 4841652..aeb35d6 100644 + --- a/src/newscatcher_catchall/core/client_wrapper.py + +++ b/src/newscatcher_catchall/core/client_wrapper.py + @@ -29,12 +29,12 @@ class BaseClientWrapper: + import platform + + headers: typing.Dict[str, str] = { + - "User-Agent": "newscatcher-catchall-sdk/0.0.0.dev0", + + "User-Agent": "newscatcher-catchall-sdk/3.1.0", + "X-Fern-Language": "Python", + "X-Fern-Runtime": f"python/{platform.python_version()}", + "X-Fern-Platform": f"{platform.system().lower()}/{platform.release()}", + "X-Fern-SDK-Name": "newscatcher-catchall-sdk", + - "X-Fern-SDK-Version": "0.0.0.dev0", + + "X-Fern-SDK-Version": "3.1.0", + **(self.get_custom_headers() or {}), + } + headers["x-api-key"] = self.api_key + -- + 2.52.0 + + theirs_snapshot: + pyproject.toml: | + [project] + name = "newscatcher-catchall-sdk" + dynamic = ["version"] + + [tool.poetry] + name = "newscatcher-catchall-sdk" + version = "3.1.0" + description = "" + readme = "README.md" + authors = [] + keywords = [] + license = "MIT" + classifiers = [ + "Intended Audience :: Developers", + "Programming Language :: Python", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", + "Programming Language :: Python :: 3.14", + "Programming Language :: Python :: 3.15", + "Operating System :: OS Independent", + "Operating System :: POSIX", + "Operating System :: MacOS", + "Operating System :: POSIX :: Linux", + "Operating System :: Microsoft :: Windows", + "Topic :: Software Development :: Libraries :: Python Modules", + "Typing :: Typed", + "License :: OSI Approved :: MIT License" + ] + packages = [ + { include = "newscatcher_catchall", from = "src"} + ] + + [tool.poetry.urls] + Repository = 'https://github.com/Newscatcher/newscatcher-catchall-python' + + [tool.poetry.dependencies] + python = "^3.10" + aiohttp = { version = ">=3.14.1,<4", optional = true, python = ">=3.10"} + httpx = ">=0.21.2" + httpx-aiohttp = { version = "0.1.8", optional = true, python = ">=3.10"} + pydantic = ">= 1.9.2" + pydantic-core = ">=2.18.2,<3.0.0" + typing_extensions = ">= 4.0.0" + + [tool.poetry.group.dev.dependencies] + mypy = "==1.13.0" + pytest = "^9.0.3" + pytest-asyncio = "^1.0.0" + pytest-xdist = "^3.6.1" + python-dateutil = "^2.9.0" + types-python-dateutil = "^2.9.0.20240316" + urllib3 = ">=2.6.3,<3.0.0" + ruff = "==0.11.5" + + [tool.pytest.ini_options] + testpaths = [ "tests" ] + asyncio_mode = "auto" + norecursedirs = [ "src" ] + markers = [ + "aiohttp: tests that require httpx_aiohttp to be installed", + ] + + [tool.mypy] + plugins = ["pydantic.mypy"] + + [tool.ruff] + line-length = 120 + + [tool.ruff.lint] + select = [ + "E", # pycodestyle errors + "F", # pyflakes + "I", # isort + ] + ignore = [ + "E402", # Module level import not at top of file + "E501", # Line too long + "E711", # Comparison to `None` should be `cond is not None` + "E712", # Avoid equality comparisons to `True`; use `if ...:` checks + "E721", # Use `is` and `is not` for type comparisons, or `isinstance()` for insinstance checks + "E722", # Do not use bare `except` + "E731", # Do not assign a `lambda` expression, use a `def` + "F821", # Undefined name + "F841" # Local variable ... is assigned to but never used + ] + + [tool.ruff.lint.isort] + section-order = ["future", "standard-library", "third-party", "first-party"] + + [build-system] + requires = ["poetry-core"] + build-backend = "poetry.core.masonry.api" + + [tool.poetry.extras] + aiohttp=["aiohttp", "httpx-aiohttp"] + src/newscatcher_catchall/core/client_wrapper.py: | + # This file was auto-generated by Fern from our API Definition. + + import typing + + import httpx + from .http_client import AsyncHttpClient, HttpClient + from .logging import LogConfig, Logger + + + class BaseClientWrapper: + def __init__( + self, + *, + api_key: str, + headers: typing.Optional[typing.Dict[str, str]] = None, + base_url: str, + timeout: typing.Optional[float] = None, + max_retries: int = 2, + logging: typing.Optional[typing.Union[LogConfig, Logger]] = None, + ): + self.api_key = api_key + self._headers = headers + self._base_url = base_url + self._timeout = timeout + self._max_retries = max_retries + self._logging = logging + + def get_headers(self) -> typing.Dict[str, str]: + import platform + + headers: typing.Dict[str, str] = { + "User-Agent": "newscatcher-catchall-sdk/3.1.0", + "X-Fern-Language": "Python", + "X-Fern-Runtime": f"python/{platform.python_version()}", + "X-Fern-Platform": f"{platform.system().lower()}/{platform.release()}", + "X-Fern-SDK-Name": "newscatcher-catchall-sdk", + "X-Fern-SDK-Version": "3.1.0", + **(self.get_custom_headers() or {}), + } + headers["x-api-key"] = self.api_key + return headers + + def get_custom_headers(self) -> typing.Optional[typing.Dict[str, str]]: + return self._headers + + def get_base_url(self) -> str: + return self._base_url + + def get_timeout(self) -> typing.Optional[float]: + return self._timeout + + def get_max_retries(self) -> int: + return self._max_retries + + + class SyncClientWrapper(BaseClientWrapper): + def __init__( + self, + *, + api_key: str, + headers: typing.Optional[typing.Dict[str, str]] = None, + base_url: str, + timeout: typing.Optional[float] = None, + max_retries: int = 2, + logging: typing.Optional[typing.Union[LogConfig, Logger]] = None, + httpx_client: httpx.Client, + ): + super().__init__( + api_key=api_key, + headers=headers, + base_url=base_url, + timeout=timeout, + max_retries=max_retries, + logging=logging, + ) + self.httpx_client = HttpClient( + httpx_client=httpx_client, + base_headers=self.get_headers, + base_timeout=self.get_timeout, + base_url=self.get_base_url, + base_max_retries=self.get_max_retries(), + logging_config=self._logging, + ) + + + class AsyncClientWrapper(BaseClientWrapper): + def __init__( + self, + *, + api_key: str, + headers: typing.Optional[typing.Dict[str, str]] = None, + base_url: str, + timeout: typing.Optional[float] = None, + max_retries: int = 2, + logging: typing.Optional[typing.Union[LogConfig, Logger]] = None, + async_token: typing.Optional[typing.Callable[[], typing.Awaitable[str]]] = None, + httpx_client: httpx.AsyncClient, + ): + super().__init__( + api_key=api_key, + headers=headers, + base_url=base_url, + timeout=timeout, + max_retries=max_retries, + logging=logging, + ) + self._async_token = async_token + self.httpx_client = AsyncHttpClient( + httpx_client=httpx_client, + base_headers=self.get_headers, + base_timeout=self.get_timeout, + base_url=self.get_base_url, + base_max_retries=self.get_max_retries(), + async_base_headers=self.async_get_headers, + logging_config=self._logging, + ) + + async def async_get_headers(self) -> typing.Dict[str, str]: + headers = self.get_headers() + if self._async_token is not None: + token = await self._async_token() + headers["Authorization"] = f"Bearer {token}" + return headers + status: unresolved diff --git a/README.md b/README.md index e2f2fc4..e486cdc 100644 --- a/README.md +++ b/README.md @@ -173,7 +173,7 @@ client = CatchAllApi(..., timeout=20.0) # Override timeout for a specific method client.jobs.create_job(..., request_options={ - "timeout_in_seconds": 1 + "timeout": 1 }) ``` @@ -219,3 +219,4 @@ On the other hand, contributions to the README are always very welcome! + diff --git a/poetry.lock b/poetry.lock index bc07681..a7caaa7 100644 --- a/poetry.lock +++ b/poetry.lock @@ -2,15 +2,15 @@ [[package]] name = "aiohappyeyeballs" -version = "2.6.2" +version = "2.7.1" description = "Happy Eyeballs for asyncio" optional = true python-versions = ">=3.10" groups = ["main"] markers = "extra == \"aiohttp\"" files = [ - {file = "aiohappyeyeballs-2.6.2-py3-none-any.whl", hash = "sha256:4708045e2d7a6c6bdf8aafa8ed39649eaf926a4543b54560659129e3365953c4"}, - {file = "aiohappyeyeballs-2.6.2.tar.gz", hash = "sha256:e202810ee718bd01fc6ef49e8ea53d023d5cb6b581076d7925aa499fa55dbe64"}, + {file = "aiohappyeyeballs-2.7.1-py3-none-any.whl", hash = "sha256:9243213661e29250eb41368e5daa826fc017156c3b8a11440826b2e3ed376472"}, + {file = "aiohappyeyeballs-2.7.1.tar.gz", hash = "sha256:065665c041c42a5938ed220bdcd7230f22527fbec085e1853d2402c8a3615d9d"}, ] [[package]] @@ -188,14 +188,14 @@ files = [ [[package]] name = "anyio" -version = "4.14.0" +version = "4.14.1" description = "High-level concurrency and networking framework on top of asyncio or Trio" optional = false python-versions = ">=3.10" groups = ["main"] files = [ - {file = "anyio-4.14.0-py3-none-any.whl", hash = "sha256:dd9b7a2a9799ed6552fde617b2c5df02b7fdd7d88392fc48101e51bae46164d9"}, - {file = "anyio-4.14.0.tar.gz", hash = "sha256:b47c1f9ccf73e67021df785332508f99379c68fa7d0684e8e3492cb1d4b23f89"}, + {file = "anyio-4.14.1-py3-none-any.whl", hash = "sha256:4e5533c5b8ff0a24f5d7a176cbe6877129cd183893f66b537f8f227d10527d72"}, + {file = "anyio-4.14.1.tar.gz", hash = "sha256:8d648a3544c1a700e3ff78615cd679e4c5c3f149904287e73687b2596963629e"}, ] [package.dependencies] @@ -1297,14 +1297,14 @@ files = [ [[package]] name = "typing-extensions" -version = "4.15.0" +version = "4.16.0" description = "Backported and Experimental Type Hints for Python 3.9+" optional = false python-versions = ">=3.9" groups = ["main", "dev"] files = [ - {file = "typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548"}, - {file = "typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466"}, + {file = "typing_extensions-4.16.0-py3-none-any.whl", hash = "sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8"}, + {file = "typing_extensions-4.16.0.tar.gz", hash = "sha256:dc983d19a509c94dba722ee6abd33940f7c05a89e243c47e907eb4db6f1a43e5"}, ] [[package]] diff --git a/reference.md b/reference.md index cd104dc..550ac17 100644 --- a/reference.md +++ b/reference.md @@ -385,6 +385,10 @@ client.jobs.create_job( Custom validators for filtering web page clusters. If not provided, validators are generated automatically based on the query. +The system may also inject validators during the `analyzing` stage — for +example, converting a relative time qualifier into an explicit event-date +gate. The returned `validators[]` in the job status shows the complete +applied set, including any system-added ones. @@ -1996,7 +2000,7 @@ Authentication forwarded with each delivery. Supported types:
-**formatter_config:** `typing.Optional[typing.Dict[str, typing.Any]]` — Custom payload transformation configuration. Required only when `type` is `custom`. +**formatter_config:** `typing.Optional[FormatterConfigDto]` — Custom payload formatter. Required when `type` is `custom`.
@@ -2291,7 +2295,7 @@ client.webhooks.update_webhook(
-**formatter_config:** `typing.Optional[typing.Dict[str, typing.Any]]` — Updated formatter configuration. +**formatter_config:** `typing.Optional[FormatterConfigDto]` — Updated custom payload formatter. Set only when `type` is `custom`.
@@ -2796,6 +2800,115 @@ client.webhooks.list_webhooks_for_resource( + + + + +
client.webhooks.trigger_webhook(...) +
+
+ +#### 📝 Description + +
+
+ +
+
+ +Manually dispatches a webhook delivery for a resource on demand, without +waiting for the next job or monitor cycle. + +Use this to re-deliver results after a failed delivery, replay a specific +job's results, or validate a webhook against live data. The webhook must +already be assigned to the resource. +
+
+
+
+ +#### 🔌 Usage + +
+
+ +
+
+ +```python +from newscatcher_catchall import CatchAllApi +from newscatcher_catchall.environment import CatchAllApiEnvironment + +client = CatchAllApi( + api_key="", + environment=CatchAllApiEnvironment.DEFAULT, +) + +client.webhooks.trigger_webhook( + resource_type="job", + resource_id="3fec5b07-8786-46d7-9486-d43ff67eccd4", + webhook_id="a1b2c3d4-e5f6-7890-abcd-ef1234567890", + job_id="3fec5b07-8786-46d7-9486-d43ff67eccd4", +) + +``` +
+
+
+
+ +#### ⚙️ Parameters + +
+
+ +
+
+ +**resource_type:** `MappableResourceType` + +
+
+ +
+
+ +**resource_id:** `str` — Unique resource identifier. + +
+
+ +
+
+ +**webhook_id:** `str` — Identifier of the webhook to trigger. Must be assigned to the resource. + +
+
+ +
+
+ +**job_id:** `typing.Optional[str]` + +Specific job run to deliver. Optional for `job` resources; for +`monitor` and `monitor_group` resources, selects a past run to replay. +When omitted, the latest available results are delivered. + +
+
+ +
+
+ +**request_options:** `typing.Optional[RequestOptions]` — Request-specific configuration. + +
+
+
+
+ +
@@ -3036,10 +3149,10 @@ client.entities.list_entities( Creates a new company entity and begins background enrichment. +Each entity requires a `name` plus at least one of: a `description` or a `domain`. Providing both is recommended — `domain` is the highest-signal identifier because it is unambiguous; a well-written `description` is the best alternative when no domain is available. + The entity status starts as `pending` and transitions to `ready` once -enrichment completes. Provide as much identifying information as -possible — `domain` is the highest-signal field because it is -unambiguous. +enrichment completes. @@ -3065,7 +3178,7 @@ client = CatchAllApi( client.entities.create_entity( name="NewsCatcher", entity_type="company", - description="AI-powered news data provider", + description="NewsCatcher is a data-as-a-service company providing news intelligence APIs including the CatchAll Web Search API (2B+ web pages indexed) and News API (140,000+ sources, 100+ countries).", additional_attributes=AdditionalAttributes( company_attributes=CompanyAttributes( domain="newscatcherapi.com", @@ -3074,7 +3187,7 @@ client.entities.create_entity( "Maksym Sugonyaka" ], alternative_names=[ - "NC", + "NewsCatcher CatchAll", "NewsCatcher API" ], ), @@ -3129,6 +3242,8 @@ client.entities.create_entity( Creates multiple entities in a single request. Each entity is processed independently — a failure in one does not affect others. +Each entity requires a `name` plus at least one of: a `description` or a `domain`. See [Create entity](https://www.newscatcherapi.com/docs/web-search-api/api-reference/entities/create-entity) for the full field reference. + Returns an array of `{id, status}` objects in the same order as the input array. @@ -3412,7 +3527,7 @@ client.entities.update_entity( additional_attributes=AdditionalAttributes( company_attributes=CompanyAttributes( alternative_names=[ - "NC", + "NewsCatcher CatchAll", "NewsCatcher API", "NCA" ], @@ -3629,7 +3744,9 @@ client.datasets.list_datasets(
-Creates a new dataset from a list of existing entity IDs. +Creates a new dataset from a list of existing entity IDs. The optional `description` field here describes the dataset itself — it is separate from the entity-level `description` used for matching. + +Entities must be created before adding them to a dataset. Each entity requires a `name` plus at least one of: a `description` or a `domain`. Use [Create entity](https://www.newscatcherapi.com/docs/web-search-api/api-reference/entities/create-entity) or [Create entities batch](https://www.newscatcherapi.com/docs/web-search-api/api-reference/entities/create-entities-batch) to create entities first. If any of the provided entity IDs do not exist or do not belong to your organization, the request fails with `400`. All entity IDs must @@ -3730,12 +3847,12 @@ client.datasets.create_dataset(
-Creates a new dataset by uploading a CSV file. Each row in the CSV becomes an entity. The `name` and `domain`columns are required; all other columns are optional. +Creates a new dataset by uploading a CSV file. Each row in the CSV becomes an entity. Each row requires a `name` plus at least one of: a `description` or a `domain`; all other columns are optional. Note: `description` in the CSV is the entity's matching description — it is separate from the dataset-level `description` field in the form data. **CSV format:** ```csv name,description,domain,alternative_names,key_persons -NewsCatcher,"AI-powered news data provider",newscatcherapi.com,"NC;NewsCatcher API","Artem Bugara;Maksym Sugonyaka" +NewsCatcher,"NewsCatcher is a data-as-a-service company providing news intelligence APIs including the CatchAll Web Search API (2B+ web pages indexed) and News API (140,000+ sources, 100+ countries).",newscatcherapi.com,"NewsCatcher CatchAll;NewsCatcher API","Artem Bugara;Maksym Sugonyaka" OpenAI,"Artificial intelligence research company",openai.com,"Open AI","Sam Altman" ``` diff --git a/src/newscatcher_catchall/__init__.py b/src/newscatcher_catchall/__init__.py index b217417..29bbcf3 100644 --- a/src/newscatcher_catchall/__init__.py +++ b/src/newscatcher_catchall/__init__.py @@ -58,6 +58,7 @@ EntityType, EntityValidationErrorBody, Error, + FormatterConfigDto, GetPlanLimitsResponseDto, GetWebhookResponseDto, HttpMethod, @@ -220,6 +221,7 @@ "EntityValidationErrorBody": ".types", "Error": ".types", "ForbiddenError": ".errors", + "FormatterConfigDto": ".types", "GetPlanLimitsResponseDto": ".types", "GetVersionResponse": ".meta", "GetWebhookResponseDto": ".types", @@ -401,6 +403,7 @@ def __dir__(): "EntityValidationErrorBody", "Error", "ForbiddenError", + "FormatterConfigDto", "GetPlanLimitsResponseDto", "GetVersionResponse", "GetWebhookResponseDto", diff --git a/src/newscatcher_catchall/client.py b/src/newscatcher_catchall/client.py index b7f052b..8721155 100644 --- a/src/newscatcher_catchall/client.py +++ b/src/newscatcher_catchall/client.py @@ -47,6 +47,12 @@ class CatchAllApi: max_retries : typing.Optional[int] The default maximum number of retries for failed requests. Defaults to 2. Per-request `max_retries` in `request_options` takes precedence over this value. + stream_reconnection_enabled : typing.Optional[bool] + Whether to automatically reconnect on stream disconnection for resumable streaming endpoints. Defaults to True. Per-request `stream_reconnection_enabled` in `request_options` takes precedence over this value. + + max_stream_reconnection_attempts : typing.Optional[int] + The maximum number of reconnection attempts for resumable streaming endpoints. Defaults to no limit. Per-request `max_stream_reconnection_attempts` in `request_options` takes precedence over this value. + follow_redirects : typing.Optional[bool] Whether the default httpx client follows redirects or not, this is irrelevant if a custom httpx client is passed in. @@ -74,13 +80,13 @@ def __init__( headers: typing.Optional[typing.Dict[str, str]] = None, timeout: typing.Optional[float] = None, max_retries: typing.Optional[int] = None, + stream_reconnection_enabled: typing.Optional[bool] = None, + max_stream_reconnection_attempts: typing.Optional[int] = None, follow_redirects: typing.Optional[bool] = True, httpx_client: typing.Optional[httpx.Client] = None, logging: typing.Optional[typing.Union[LogConfig, Logger]] = None, ): - _defaulted_timeout = ( - timeout if timeout is not None else 60 if httpx_client is None else httpx_client.timeout.read - ) + _defaulted_timeout = timeout if timeout is not None else 60 if httpx_client is None else None _defaulted_max_retries = max_retries if max_retries is not None else 2 self._client_wrapper = SyncClientWrapper( base_url=_get_base_url(base_url=base_url, environment=environment), @@ -93,6 +99,8 @@ def __init__( else httpx.Client(timeout=_defaulted_timeout), timeout=_defaulted_timeout, max_retries=_defaulted_max_retries, + stream_reconnection_enabled=stream_reconnection_enabled, + max_stream_reconnection_attempts=max_stream_reconnection_attempts, logging=logging, ) self._jobs: typing.Optional[JobsClient] = None @@ -206,6 +214,12 @@ class AsyncCatchAllApi: max_retries : typing.Optional[int] The default maximum number of retries for failed requests. Defaults to 2. Per-request `max_retries` in `request_options` takes precedence over this value. + stream_reconnection_enabled : typing.Optional[bool] + Whether to automatically reconnect on stream disconnection for resumable streaming endpoints. Defaults to True. Per-request `stream_reconnection_enabled` in `request_options` takes precedence over this value. + + max_stream_reconnection_attempts : typing.Optional[int] + The maximum number of reconnection attempts for resumable streaming endpoints. Defaults to no limit. Per-request `max_stream_reconnection_attempts` in `request_options` takes precedence over this value. + follow_redirects : typing.Optional[bool] Whether the default httpx client follows redirects or not, this is irrelevant if a custom httpx client is passed in. @@ -233,13 +247,13 @@ def __init__( headers: typing.Optional[typing.Dict[str, str]] = None, timeout: typing.Optional[float] = None, max_retries: typing.Optional[int] = None, + stream_reconnection_enabled: typing.Optional[bool] = None, + max_stream_reconnection_attempts: typing.Optional[int] = None, follow_redirects: typing.Optional[bool] = True, httpx_client: typing.Optional[httpx.AsyncClient] = None, logging: typing.Optional[typing.Union[LogConfig, Logger]] = None, ): - _defaulted_timeout = ( - timeout if timeout is not None else 60 if httpx_client is None else httpx_client.timeout.read - ) + _defaulted_timeout = timeout if timeout is not None else 60 if httpx_client is None else None _defaulted_max_retries = max_retries if max_retries is not None else 2 self._client_wrapper = AsyncClientWrapper( base_url=_get_base_url(base_url=base_url, environment=environment), @@ -250,6 +264,8 @@ def __init__( else _make_default_async_client(timeout=_defaulted_timeout, follow_redirects=follow_redirects), timeout=_defaulted_timeout, max_retries=_defaulted_max_retries, + stream_reconnection_enabled=stream_reconnection_enabled, + max_stream_reconnection_attempts=max_stream_reconnection_attempts, logging=logging, ) self._jobs: typing.Optional[AsyncJobsClient] = None diff --git a/src/newscatcher_catchall/core/client_wrapper.py b/src/newscatcher_catchall/core/client_wrapper.py index aeb35d6..4eb5f21 100644 --- a/src/newscatcher_catchall/core/client_wrapper.py +++ b/src/newscatcher_catchall/core/client_wrapper.py @@ -16,6 +16,8 @@ def __init__( base_url: str, timeout: typing.Optional[float] = None, max_retries: int = 2, + stream_reconnection_enabled: typing.Optional[bool] = None, + max_stream_reconnection_attempts: typing.Optional[int] = None, logging: typing.Optional[typing.Union[LogConfig, Logger]] = None, ): self.api_key = api_key @@ -23,13 +25,15 @@ def __init__( self._base_url = base_url self._timeout = timeout self._max_retries = max_retries + self._stream_reconnection_enabled = stream_reconnection_enabled + self._max_stream_reconnection_attempts = max_stream_reconnection_attempts self._logging = logging def get_headers(self) -> typing.Dict[str, str]: import platform headers: typing.Dict[str, str] = { - "User-Agent": "newscatcher-catchall-sdk/3.1.0", + "User-Agent": "newscatcher-catchall-sdk/0.0.0-fern-placeholder", "X-Fern-Language": "Python", "X-Fern-Runtime": f"python/{platform.python_version()}", "X-Fern-Platform": f"{platform.system().lower()}/{platform.release()}", @@ -52,6 +56,12 @@ def get_timeout(self) -> typing.Optional[float]: def get_max_retries(self) -> int: return self._max_retries + def get_stream_reconnection_enabled(self) -> bool: + return self._stream_reconnection_enabled if self._stream_reconnection_enabled is not None else True + + def get_max_stream_reconnection_attempts(self) -> typing.Optional[int]: + return self._max_stream_reconnection_attempts + class SyncClientWrapper(BaseClientWrapper): def __init__( @@ -62,6 +72,8 @@ def __init__( base_url: str, timeout: typing.Optional[float] = None, max_retries: int = 2, + stream_reconnection_enabled: typing.Optional[bool] = None, + max_stream_reconnection_attempts: typing.Optional[int] = None, logging: typing.Optional[typing.Union[LogConfig, Logger]] = None, httpx_client: httpx.Client, ): @@ -71,6 +83,8 @@ def __init__( base_url=base_url, timeout=timeout, max_retries=max_retries, + stream_reconnection_enabled=stream_reconnection_enabled, + max_stream_reconnection_attempts=max_stream_reconnection_attempts, logging=logging, ) self.httpx_client = HttpClient( @@ -92,6 +106,8 @@ def __init__( base_url: str, timeout: typing.Optional[float] = None, max_retries: int = 2, + stream_reconnection_enabled: typing.Optional[bool] = None, + max_stream_reconnection_attempts: typing.Optional[int] = None, logging: typing.Optional[typing.Union[LogConfig, Logger]] = None, async_token: typing.Optional[typing.Callable[[], typing.Awaitable[str]]] = None, httpx_client: httpx.AsyncClient, @@ -102,6 +118,8 @@ def __init__( base_url=base_url, timeout=timeout, max_retries=max_retries, + stream_reconnection_enabled=stream_reconnection_enabled, + max_stream_reconnection_attempts=max_stream_reconnection_attempts, logging=logging, ) self._async_token = async_token diff --git a/src/newscatcher_catchall/core/http_client.py b/src/newscatcher_catchall/core/http_client.py index f686c57..124dce2 100644 --- a/src/newscatcher_catchall/core/http_client.py +++ b/src/newscatcher_catchall/core/http_client.py @@ -312,11 +312,14 @@ def request( force_multipart: typing.Optional[bool] = None, ) -> httpx.Response: base_url = self.get_base_url(base_url) - timeout = ( - request_options.get("timeout_in_seconds") + _timeout = ( + request_options.get("timeout") + if request_options is not None and request_options.get("timeout") is not None + else request_options.get("timeout_in_seconds") if request_options is not None and request_options.get("timeout_in_seconds") is not None else self.base_timeout() ) + timeout = _timeout if _timeout is not None else httpx.USE_CLIENT_DEFAULT json_body, data_body = get_request_body(json=json, data=data, request_options=request_options, omit=omit) @@ -472,11 +475,14 @@ def stream( force_multipart: typing.Optional[bool] = None, ) -> typing.Iterator[httpx.Response]: base_url = self.get_base_url(base_url) - timeout = ( - request_options.get("timeout_in_seconds") + _timeout = ( + request_options.get("timeout") + if request_options is not None and request_options.get("timeout") is not None + else request_options.get("timeout_in_seconds") if request_options is not None and request_options.get("timeout_in_seconds") is not None else self.base_timeout() ) + timeout = _timeout if _timeout is not None else httpx.USE_CLIENT_DEFAULT request_files: typing.Optional[RequestFiles] = ( convert_file_dict_to_httpx_tuples(remove_omit_from_dict(remove_none_from_dict(files), omit)) @@ -601,11 +607,14 @@ async def request( force_multipart: typing.Optional[bool] = None, ) -> httpx.Response: base_url = self.get_base_url(base_url) - timeout = ( - request_options.get("timeout_in_seconds") + _timeout = ( + request_options.get("timeout") + if request_options is not None and request_options.get("timeout") is not None + else request_options.get("timeout_in_seconds") if request_options is not None and request_options.get("timeout_in_seconds") is not None else self.base_timeout() ) + timeout = _timeout if _timeout is not None else httpx.USE_CLIENT_DEFAULT request_files: typing.Optional[RequestFiles] = ( convert_file_dict_to_httpx_tuples(remove_omit_from_dict(remove_none_from_dict(files), omit)) @@ -764,11 +773,14 @@ async def stream( force_multipart: typing.Optional[bool] = None, ) -> typing.AsyncIterator[httpx.Response]: base_url = self.get_base_url(base_url) - timeout = ( - request_options.get("timeout_in_seconds") + _timeout = ( + request_options.get("timeout") + if request_options is not None and request_options.get("timeout") is not None + else request_options.get("timeout_in_seconds") if request_options is not None and request_options.get("timeout_in_seconds") is not None else self.base_timeout() ) + timeout = _timeout if _timeout is not None else httpx.USE_CLIENT_DEFAULT request_files: typing.Optional[RequestFiles] = ( convert_file_dict_to_httpx_tuples(remove_omit_from_dict(remove_none_from_dict(files), omit)) diff --git a/src/newscatcher_catchall/core/http_sse/_api.py b/src/newscatcher_catchall/core/http_sse/_api.py index fd13730..9ca5602 100644 --- a/src/newscatcher_catchall/core/http_sse/_api.py +++ b/src/newscatcher_catchall/core/http_sse/_api.py @@ -2,9 +2,20 @@ import codecs import re +import time from contextlib import asynccontextmanager, contextmanager -from typing import Any, AsyncGenerator, AsyncIterator, Iterator - +from typing import ( + Any, + AsyncContextManager, + AsyncGenerator, + AsyncIterator, + Callable, + ContextManager, + Iterator, + Optional, +) + +import anyio import httpx from ._decoders import SSEDecoder from ._exceptions import SSEError @@ -12,21 +23,60 @@ MAX_LINE_SIZE: int = 1_048_576 # 1 MiB +# Reconnection defaults, mirroring the TypeScript SDK's Stream implementation. +DEFAULT_MAX_RECONNECTION_ATTEMPTS: int = 5 +DEFAULT_RECONNECT_DELAY_MS: int = 1_000 +MAX_RECONNECT_DELAY_MS: int = 30_000 + +# A reconnect callback re-issues the original request (with a ``Last-Event-ID`` +# header set to the supplied event id) and returns a *context manager* yielding +# a fresh streaming ``httpx.Response``. Sync clients supply a sync context +# manager; async clients supply an async one. class EventSource: - def __init__(self, response: httpx.Response) -> None: + def __init__( + self, + response: httpx.Response, + *, + resumable: bool = False, + stream_reconnection_enabled: bool = True, + max_stream_reconnection_attempts: Optional[int] = None, + stream_terminator: Optional[str] = None, + reconnect: Optional[Callable[[str], Any]] = None, + ) -> None: self._response = response + self._resumable = resumable + self._stream_reconnection_enabled = stream_reconnection_enabled + self._max_stream_reconnection_attempts = max_stream_reconnection_attempts + self._stream_terminator = stream_terminator + self._reconnect = reconnect + + @staticmethod + def _is_event_stream(response: httpx.Response) -> bool: + content_type = response.headers.get("content-type", "").partition(";")[0] + return "text/event-stream" in content_type def _check_content_type(self) -> None: - content_type = self._response.headers.get("content-type", "").partition(";")[0] - if "text/event-stream" not in content_type: + if not self._is_event_stream(self._response): + content_type = self._response.headers.get("content-type", "").partition(";")[0] raise SSEError( f"Expected response header Content-Type to contain 'text/event-stream', got {content_type!r}" ) - def _get_charset(self) -> str: + def _is_reconnect_response_usable(self, response: httpx.Response) -> bool: + """Whether a reconnected response can be resumed as an SSE stream. + + ``httpx.stream`` does not raise on non-success status, so a resume that + returns an error page (e.g. ``200 text/html`` or a ``500`` body) would + otherwise be parsed as SSE and yield garbage/zero events. Such a + response is treated as a failed attempt (back off and retry) instead. + """ + return response.status_code < 400 and self._is_event_stream(response) + + def _get_charset(self, response: Optional[httpx.Response] = None) -> str: """Extract charset from Content-Type header, fallback to UTF-8.""" - content_type = self._response.headers.get("content-type", "") + resolved = response if response is not None else self._response + content_type = resolved.headers.get("content-type", "") # Parse charset parameter using regex charset_match = re.search(r"charset=([^;\s]+)", content_type, re.IGNORECASE) @@ -60,14 +110,83 @@ def _normalize_sse_line_endings(buf: str) -> str: return buf[:-1].replace("\r", "\n") + "\r" return buf.replace("\r", "\n") - def iter_sse(self) -> Iterator[ServerSentEvent]: - self._check_content_type() - decoder = SSEDecoder() - charset = self._get_charset() - text_decoder = codecs.getincrementaldecoder(charset)(errors="replace") - + def _new_text_decoder(self, response: Optional[httpx.Response] = None) -> "codecs.IncrementalDecoder": + return codecs.getincrementaldecoder(self._get_charset(response))(errors="replace") + + def _reconnect_applicable(self) -> bool: + """Whether reconnection is configured for this stream at all. + + This is the terminator-gating half of the reconnect decision, kept + separate from :meth:`_should_reconnect` (which additionally requires a + last *dispatched* id and an unexhausted attempt budget). The split lets + a mid-stream transport error terminate consistently: + - a stream that can never reconnect (non-resumable, no terminator, + disabled, or no callback) must re-raise the error to the caller, so a + truncated stream is not mistaken for a clean completion; + - a resumable stream that has merely run out of attempts (or has no id + to resume from) ends cleanly — the same way an exhausted empty/error + -body resume already does, matching the TypeScript ``return``. + """ + return ( + self._resumable + and self._stream_terminator is not None + and self._stream_reconnection_enabled + and self._reconnect is not None + ) + + def _should_reconnect(self, last_dispatched_id: Optional[str], reconnect_attempts: int) -> bool: + """Decide whether a prematurely-ended stream should be reconnected. + + Mirrors the TypeScript ``shouldReconnect`` gating: + - only resumable SSE endpoints with a configured terminator, reconnect + enabled, and a reconnect callback are eligible (see + :meth:`_reconnect_applicable`); + - a last *dispatched* event id must exist to resume from; + - the consecutive-failed-attempt cap must not be exceeded. + """ + if not self._reconnect_applicable(): + return False + if not last_dispatched_id: + return False + max_attempts = ( + self._max_stream_reconnection_attempts + if self._max_stream_reconnection_attempts is not None + else DEFAULT_MAX_RECONNECTION_ATTEMPTS + ) + if reconnect_attempts >= max_attempts: + return False + return True + + def _reconnect_delay_seconds(self, last_retry: Optional[int]) -> float: + """Backoff before a reconnect. + + Uses the server's most recent ``retry:`` directive (milliseconds) when + present, otherwise a default of ``DEFAULT_RECONNECT_DELAY_MS``, clamped + to ``MAX_RECONNECT_DELAY_MS``. + """ + base_ms = last_retry if (last_retry is not None and last_retry > 0) else DEFAULT_RECONNECT_DELAY_MS + return min(base_ms, MAX_RECONNECT_DELAY_MS) / 1000.0 + + def _sleep_before_reconnect(self, last_retry: Optional[int]) -> None: + # ``time.sleep`` blocks the calling thread but remains interruptible by + # signals (e.g. ``KeyboardInterrupt``), which propagate out and abort + # the reconnect without issuing another request. + time.sleep(self._reconnect_delay_seconds(last_retry)) + + async def _asleep_before_reconnect(self, last_retry: Optional[int]) -> None: + # ``anyio.sleep`` is cancellation-aware: if the consumer cancels the task + # or closes the async generator mid-delay, this raises (and no further + # request is issued) instead of blocking for the whole interval. + await anyio.sleep(self._reconnect_delay_seconds(last_retry)) + + def _decode_response( + self, + response: httpx.Response, + decoder: SSEDecoder, + text_decoder: "codecs.IncrementalDecoder", + ) -> Iterator[ServerSentEvent]: buf = "" - for chunk in self._response.iter_bytes(): + for chunk in response.iter_bytes(): buf += text_decoder.decode(chunk) buf = self._normalize_sse_line_endings(buf) @@ -82,34 +201,16 @@ def iter_sse(self) -> Iterator[ServerSentEvent]: f"SSE line exceeded maximum size of {MAX_LINE_SIZE} characters without encountering a newline" ) - # Flush any remaining bytes from the incremental decoder - buf += text_decoder.decode(b"", final=True) - buf = buf.replace("\r\n", "\n").replace("\r", "\n") - - if len(buf) > MAX_LINE_SIZE: - raise SSEError( - f"SSE line exceeded maximum size of {MAX_LINE_SIZE} characters without encountering a newline" - ) - - while "\n" in buf: - line, buf = buf.split("\n", 1) - sse = decoder.decode(line) - if sse is not None: - yield sse - - if buf.strip(): - sse = decoder.decode(buf) - if sse is not None: - yield sse - - async def aiter_sse(self) -> AsyncGenerator[ServerSentEvent, None]: - self._check_content_type() - decoder = SSEDecoder() - charset = self._get_charset() - text_decoder = codecs.getincrementaldecoder(charset)(errors="replace") + yield from self._flush_decoder(buf, decoder, text_decoder) + async def _adecode_response( + self, + response: httpx.Response, + decoder: SSEDecoder, + text_decoder: "codecs.IncrementalDecoder", + ) -> AsyncGenerator[ServerSentEvent, None]: buf = "" - async for chunk in self._response.aiter_bytes(): + async for chunk in response.aiter_bytes(): buf += text_decoder.decode(chunk) buf = self._normalize_sse_line_endings(buf) @@ -124,6 +225,15 @@ async def aiter_sse(self) -> AsyncGenerator[ServerSentEvent, None]: f"SSE line exceeded maximum size of {MAX_LINE_SIZE} characters without encountering a newline" ) + for sse in self._flush_decoder(buf, decoder, text_decoder): + yield sse + + def _flush_decoder( + self, + buf: str, + decoder: SSEDecoder, + text_decoder: "codecs.IncrementalDecoder", + ) -> Iterator[ServerSentEvent]: # Flush any remaining bytes from the incremental decoder buf += text_decoder.decode(b"", final=True) buf = buf.replace("\r\n", "\n").replace("\r", "\n") @@ -144,6 +254,181 @@ async def aiter_sse(self) -> AsyncGenerator[ServerSentEvent, None]: if sse is not None: yield sse + def iter_sse(self) -> Iterator[ServerSentEvent]: + self._check_content_type() + decoder = SSEDecoder() + text_decoder = self._new_text_decoder() + + last_dispatched_id: Optional[str] = None + last_retry: Optional[int] = None + # Consecutive failed reconnection attempts. Reset to 0 whenever an event + # is successfully dispatched (reset-on-progress) — matching browser + # `EventSource` semantics: a server that emits >=1 event then drops on + # every connection can reconnect indefinitely. + reconnect_attempts = 0 + + # ``None`` means there is no live stream to read this iteration (e.g. a + # failed reconnect); the loop then re-evaluates the reconnect decision + # without re-reading an exhausted response. + response: Optional[httpx.Response] = self._response + # Context manager for a response we opened ourselves and must close. + # The initial response is owned by the caller, so it starts as None. + owned_cm: Optional[ContextManager[httpx.Response]] = None + try: + while True: + if response is not None: + events = self._decode_response(response, decoder, text_decoder) + while True: + try: + sse = next(events) + except StopIteration: + break + except SSEError: + # A protocol violation (e.g. an oversized line) is a + # genuine error, not a dropped connection; propagate it. + # Listed first because ``SSEError`` subclasses + # ``httpx.TransportError``. + raise + except httpx.TransportError: + # A transport error mid-stream (e.g. the server dropped + # the connection: ``ReadError``/``RemoteProtocolError``) + # is a premature end. Only swallow it when reconnection + # is configured for this stream; otherwise re-raise so a + # non-resumable stream still surfaces the error to the + # caller instead of looking like a clean completion. + # When reconnection is applicable but the attempt budget + # is exhausted, we ``break`` and end cleanly below — the + # same way an exhausted empty/error-body resume does, so + # give-up is consistent regardless of failure shape. + # ``next`` is used rather than ``for`` so this cannot + # swallow a ``GeneratorExit`` raised at a ``yield``. + if not self._reconnect_applicable(): + raise + break + yield sse + if sse.id: + last_dispatched_id = sse.id + if sse.retry is not None: + last_retry = sse.retry + reconnect_attempts = 0 + + if not self._should_reconnect(last_dispatched_id, reconnect_attempts): + return + reconnect_attempts += 1 + + self._sleep_before_reconnect(last_retry) + + # Close the previously-opened reconnect response before opening + # a new one so we never hold more than one extra connection. + if owned_cm is not None: + owned_cm.__exit__(None, None, None) + owned_cm = None + + assert self._reconnect is not None # guaranteed by _should_reconnect + try: + cm: ContextManager[httpx.Response] = self._reconnect(last_dispatched_id or "") + new_response = cm.__enter__() + except Exception: + # A failed reconnect consumes an attempt; back off and retry. + response = None + continue + owned_cm = cm + if new_response is None or not self._is_reconnect_response_usable(new_response): + # Null/empty body or a non-SSE/error response (e.g. 204/304, + # a 500, or an HTML error page): treat as a failed attempt. + response = None + continue + + response = new_response + # Drop any partial event left over from the dropped stream, but + # keep the last event id (per the SSE spec) and start a fresh + # incremental text decoder for the new connection. + decoder.reset_in_progress_event() + text_decoder = self._new_text_decoder(new_response) + finally: + if owned_cm is not None: + owned_cm.__exit__(None, None, None) + + async def aiter_sse(self) -> AsyncGenerator[ServerSentEvent, None]: + self._check_content_type() + decoder = SSEDecoder() + text_decoder = self._new_text_decoder() + + last_dispatched_id: Optional[str] = None + last_retry: Optional[int] = None + reconnect_attempts = 0 + + response: Optional[httpx.Response] = self._response + owned_cm: Optional[AsyncContextManager[httpx.Response]] = None + try: + while True: + if response is not None: + events = self._adecode_response(response, decoder, text_decoder) + while True: + try: + sse = await events.__anext__() + except StopAsyncIteration: + break + except SSEError: + # A protocol violation (e.g. an oversized line) is a + # genuine error, not a dropped connection; propagate it. + # Listed first because ``SSEError`` subclasses + # ``httpx.TransportError``. + raise + except httpx.TransportError: + # A transport error mid-stream (e.g. the server dropped + # the connection: ``ReadError``/``RemoteProtocolError``) + # is a premature end. Only swallow it when reconnection + # is configured for this stream; otherwise re-raise so a + # non-resumable stream still surfaces the error to the + # caller instead of looking like a clean completion. + # When reconnection is applicable but the attempt budget + # is exhausted, we ``break`` and end cleanly below — the + # same way an exhausted empty/error-body resume does, so + # give-up is consistent regardless of failure shape. + if not self._reconnect_applicable(): + raise + break + yield sse + if sse.id: + last_dispatched_id = sse.id + if sse.retry is not None: + last_retry = sse.retry + reconnect_attempts = 0 + + if not self._should_reconnect(last_dispatched_id, reconnect_attempts): + return + reconnect_attempts += 1 + + await self._asleep_before_reconnect(last_retry) + + if owned_cm is not None: + await owned_cm.__aexit__(None, None, None) + owned_cm = None + + assert self._reconnect is not None # guaranteed by _should_reconnect + try: + cm: AsyncContextManager[httpx.Response] = self._reconnect(last_dispatched_id or "") + new_response = await cm.__aenter__() + except Exception: + response = None + continue + owned_cm = cm + if new_response is None or not self._is_reconnect_response_usable(new_response): + response = None + continue + + response = new_response + decoder.reset_in_progress_event() + text_decoder = self._new_text_decoder(new_response) + finally: + if owned_cm is not None: + # Shield the close so a cancellation delivered while reading a + # reconnected response still fully tears the connection down + # instead of leaking it until the client is closed. + with anyio.CancelScope(shield=True): + await owned_cm.__aexit__(None, None, None) + @contextmanager def connect_sse(client: httpx.Client, method: str, url: str, **kwargs: Any) -> Iterator[EventSource]: diff --git a/src/newscatcher_catchall/core/http_sse/_decoders.py b/src/newscatcher_catchall/core/http_sse/_decoders.py index 339b089..1f6b35e 100644 --- a/src/newscatcher_catchall/core/http_sse/_decoders.py +++ b/src/newscatcher_catchall/core/http_sse/_decoders.py @@ -12,6 +12,19 @@ def __init__(self) -> None: self._last_event_id = "" self._retry: Optional[int] = None + def reset_in_progress_event(self) -> None: + """Discard any partially-parsed (undispatched) event. + + Used when a stream ends mid-event before reconnecting: the buffered + ``event``/``data``/``retry`` fields of the never-dispatched event must + be dropped so they do not corrupt the first event of the reconnected + stream. Per the SSE spec the last event id is *not* reset here — it + persists across connections. + """ + self._event = "" + self._data = [] + self._retry = None + def decode(self, line: str) -> Optional[ServerSentEvent]: # See: https://html.spec.whatwg.org/multipage/server-sent-events.html#event-stream-interpretation # noqa: E501 diff --git a/src/newscatcher_catchall/core/request_options.py b/src/newscatcher_catchall/core/request_options.py index 1b38804..caa6f66 100644 --- a/src/newscatcher_catchall/core/request_options.py +++ b/src/newscatcher_catchall/core/request_options.py @@ -14,7 +14,9 @@ class RequestOptions(typing.TypedDict, total=False): This is used primarily as an optional final parameter for service functions. Attributes: - - timeout_in_seconds: int. The number of seconds to await an API call before timing out. + - timeout: float. The number of seconds to await an API call before timing out. + + - timeout_in_seconds: int. Deprecated alias for `timeout`; both are in seconds. Prefer `timeout`. - max_retries: int. The max number of retries to attempt if the API call fails. @@ -27,9 +29,12 @@ class RequestOptions(typing.TypedDict, total=False): - chunk_size: int. The size, in bytes, to process each chunk of data being streamed back within the response. This equates to leveraging `chunk_size` within `requests` or `httpx`, and is only leveraged for file downloads. """ + timeout: NotRequired[float] timeout_in_seconds: NotRequired[int] max_retries: NotRequired[int] additional_headers: NotRequired[typing.Dict[str, typing.Any]] additional_query_parameters: NotRequired[typing.Dict[str, typing.Any]] additional_body_parameters: NotRequired[typing.Dict[str, typing.Any]] chunk_size: NotRequired[int] + stream_reconnection_enabled: NotRequired[bool] + max_stream_reconnection_attempts: NotRequired[int] diff --git a/src/newscatcher_catchall/datasets/client.py b/src/newscatcher_catchall/datasets/client.py index a3858b3..43a0ca6 100644 --- a/src/newscatcher_catchall/datasets/client.py +++ b/src/newscatcher_catchall/datasets/client.py @@ -121,7 +121,9 @@ def create_dataset( request_options: typing.Optional[RequestOptions] = None, ) -> DatasetResponse: """ - Creates a new dataset from a list of existing entity IDs. + Creates a new dataset from a list of existing entity IDs. The optional `description` field here describes the dataset itself — it is separate from the entity-level `description` used for matching. + + Entities must be created before adding them to a dataset. Each entity requires a `name` plus at least one of: a `description` or a `domain`. Use [Create entity](https://www.newscatcherapi.com/docs/web-search-api/api-reference/entities/create-entity) or [Create entities batch](https://www.newscatcherapi.com/docs/web-search-api/api-reference/entities/create-entities-batch) to create entities first. If any of the provided entity IDs do not exist or do not belong to your organization, the request fails with `400`. All entity IDs must @@ -179,12 +181,12 @@ def create_dataset_from_csv( request_options: typing.Optional[RequestOptions] = None, ) -> CreateDatasetCsvResponse: """ - Creates a new dataset by uploading a CSV file. Each row in the CSV becomes an entity. The `name` and `domain`columns are required; all other columns are optional. + Creates a new dataset by uploading a CSV file. Each row in the CSV becomes an entity. Each row requires a `name` plus at least one of: a `description` or a `domain`; all other columns are optional. Note: `description` in the CSV is the entity's matching description — it is separate from the dataset-level `description` field in the form data. **CSV format:** ```csv name,description,domain,alternative_names,key_persons - NewsCatcher,"AI-powered news data provider",newscatcherapi.com,"NC;NewsCatcher API","Artem Bugara;Maksym Sugonyaka" + NewsCatcher,"NewsCatcher is a data-as-a-service company providing news intelligence APIs including the CatchAll Web Search API (2B+ web pages indexed) and News API (140,000+ sources, 100+ countries).",newscatcherapi.com,"NewsCatcher CatchAll;NewsCatcher API","Artem Bugara;Maksym Sugonyaka" OpenAI,"Artificial intelligence research company",openai.com,"Open AI","Sam Altman" ``` @@ -679,7 +681,9 @@ async def create_dataset( request_options: typing.Optional[RequestOptions] = None, ) -> DatasetResponse: """ - Creates a new dataset from a list of existing entity IDs. + Creates a new dataset from a list of existing entity IDs. The optional `description` field here describes the dataset itself — it is separate from the entity-level `description` used for matching. + + Entities must be created before adding them to a dataset. Each entity requires a `name` plus at least one of: a `description` or a `domain`. Use [Create entity](https://www.newscatcherapi.com/docs/web-search-api/api-reference/entities/create-entity) or [Create entities batch](https://www.newscatcherapi.com/docs/web-search-api/api-reference/entities/create-entities-batch) to create entities first. If any of the provided entity IDs do not exist or do not belong to your organization, the request fails with `400`. All entity IDs must @@ -745,12 +749,12 @@ async def create_dataset_from_csv( request_options: typing.Optional[RequestOptions] = None, ) -> CreateDatasetCsvResponse: """ - Creates a new dataset by uploading a CSV file. Each row in the CSV becomes an entity. The `name` and `domain`columns are required; all other columns are optional. + Creates a new dataset by uploading a CSV file. Each row in the CSV becomes an entity. Each row requires a `name` plus at least one of: a `description` or a `domain`; all other columns are optional. Note: `description` in the CSV is the entity's matching description — it is separate from the dataset-level `description` field in the form data. **CSV format:** ```csv name,description,domain,alternative_names,key_persons - NewsCatcher,"AI-powered news data provider",newscatcherapi.com,"NC;NewsCatcher API","Artem Bugara;Maksym Sugonyaka" + NewsCatcher,"NewsCatcher is a data-as-a-service company providing news intelligence APIs including the CatchAll Web Search API (2B+ web pages indexed) and News API (140,000+ sources, 100+ countries).",newscatcherapi.com,"NewsCatcher CatchAll;NewsCatcher API","Artem Bugara;Maksym Sugonyaka" OpenAI,"Artificial intelligence research company",openai.com,"Open AI","Sam Altman" ``` diff --git a/src/newscatcher_catchall/datasets/raw_client.py b/src/newscatcher_catchall/datasets/raw_client.py index a69ef87..27ed31d 100644 --- a/src/newscatcher_catchall/datasets/raw_client.py +++ b/src/newscatcher_catchall/datasets/raw_client.py @@ -142,7 +142,9 @@ def create_dataset( request_options: typing.Optional[RequestOptions] = None, ) -> HttpResponse[DatasetResponse]: """ - Creates a new dataset from a list of existing entity IDs. + Creates a new dataset from a list of existing entity IDs. The optional `description` field here describes the dataset itself — it is separate from the entity-level `description` used for matching. + + Entities must be created before adding them to a dataset. Each entity requires a `name` plus at least one of: a `description` or a `domain`. Use [Create entity](https://www.newscatcherapi.com/docs/web-search-api/api-reference/entities/create-entity) or [Create entities batch](https://www.newscatcherapi.com/docs/web-search-api/api-reference/entities/create-entities-batch) to create entities first. If any of the provided entity IDs do not exist or do not belong to your organization, the request fails with `400`. All entity IDs must @@ -245,12 +247,12 @@ def create_dataset_from_csv( request_options: typing.Optional[RequestOptions] = None, ) -> HttpResponse[CreateDatasetCsvResponse]: """ - Creates a new dataset by uploading a CSV file. Each row in the CSV becomes an entity. The `name` and `domain`columns are required; all other columns are optional. + Creates a new dataset by uploading a CSV file. Each row in the CSV becomes an entity. Each row requires a `name` plus at least one of: a `description` or a `domain`; all other columns are optional. Note: `description` in the CSV is the entity's matching description — it is separate from the dataset-level `description` field in the form data. **CSV format:** ```csv name,description,domain,alternative_names,key_persons - NewsCatcher,"AI-powered news data provider",newscatcherapi.com,"NC;NewsCatcher API","Artem Bugara;Maksym Sugonyaka" + NewsCatcher,"NewsCatcher is a data-as-a-service company providing news intelligence APIs including the CatchAll Web Search API (2B+ web pages indexed) and News API (140,000+ sources, 100+ countries).",newscatcherapi.com,"NewsCatcher CatchAll;NewsCatcher API","Artem Bugara;Maksym Sugonyaka" OpenAI,"Artificial intelligence research company",openai.com,"Open AI","Sam Altman" ``` @@ -1105,7 +1107,9 @@ async def create_dataset( request_options: typing.Optional[RequestOptions] = None, ) -> AsyncHttpResponse[DatasetResponse]: """ - Creates a new dataset from a list of existing entity IDs. + Creates a new dataset from a list of existing entity IDs. The optional `description` field here describes the dataset itself — it is separate from the entity-level `description` used for matching. + + Entities must be created before adding them to a dataset. Each entity requires a `name` plus at least one of: a `description` or a `domain`. Use [Create entity](https://www.newscatcherapi.com/docs/web-search-api/api-reference/entities/create-entity) or [Create entities batch](https://www.newscatcherapi.com/docs/web-search-api/api-reference/entities/create-entities-batch) to create entities first. If any of the provided entity IDs do not exist or do not belong to your organization, the request fails with `400`. All entity IDs must @@ -1208,12 +1212,12 @@ async def create_dataset_from_csv( request_options: typing.Optional[RequestOptions] = None, ) -> AsyncHttpResponse[CreateDatasetCsvResponse]: """ - Creates a new dataset by uploading a CSV file. Each row in the CSV becomes an entity. The `name` and `domain`columns are required; all other columns are optional. + Creates a new dataset by uploading a CSV file. Each row in the CSV becomes an entity. Each row requires a `name` plus at least one of: a `description` or a `domain`; all other columns are optional. Note: `description` in the CSV is the entity's matching description — it is separate from the dataset-level `description` field in the form data. **CSV format:** ```csv name,description,domain,alternative_names,key_persons - NewsCatcher,"AI-powered news data provider",newscatcherapi.com,"NC;NewsCatcher API","Artem Bugara;Maksym Sugonyaka" + NewsCatcher,"NewsCatcher is a data-as-a-service company providing news intelligence APIs including the CatchAll Web Search API (2B+ web pages indexed) and News API (140,000+ sources, 100+ countries).",newscatcherapi.com,"NewsCatcher CatchAll;NewsCatcher API","Artem Bugara;Maksym Sugonyaka" OpenAI,"Artificial intelligence research company",openai.com,"Open AI","Sam Altman" ``` diff --git a/src/newscatcher_catchall/entities/client.py b/src/newscatcher_catchall/entities/client.py index 7d1f12d..dedf3d2 100644 --- a/src/newscatcher_catchall/entities/client.py +++ b/src/newscatcher_catchall/entities/client.py @@ -113,10 +113,10 @@ def create_entity( """ Creates a new company entity and begins background enrichment. + Each entity requires a `name` plus at least one of: a `description` or a `domain`. Providing both is recommended — `domain` is the highest-signal identifier because it is unambiguous; a well-written `description` is the best alternative when no domain is available. + The entity status starts as `pending` and transitions to `ready` once - enrichment completes. Provide as much identifying information as - possible — `domain` is the highest-signal field because it is - unambiguous. + enrichment completes. Parameters ---------- @@ -126,7 +126,7 @@ def create_entity( entity_type : typing.Optional[EntityType] description : typing.Optional[str] - Free-text description of the entity used for disambiguation when similar names exist. + Free-text description of the entity used for disambiguation when similar names exist. See [Writing effective descriptions](https://www.newscatcherapi.com/docs/web-search-api/concepts/company-search#writing-effective-descriptions) for guidance on improving matching quality. external_entity_id : typing.Optional[str] Optional external identifier for this entity. Free-form string, not enforced as unique. Use it to store your own CRM, data warehouse, or internal database ID so you can join CatchAll results back to your systems. @@ -155,12 +155,12 @@ def create_entity( client.entities.create_entity( name="NewsCatcher", entity_type="company", - description="AI-powered news data provider", + description="NewsCatcher is a data-as-a-service company providing news intelligence APIs including the CatchAll Web Search API (2B+ web pages indexed) and News API (140,000+ sources, 100+ countries).", additional_attributes=AdditionalAttributes( company_attributes=CompanyAttributes( domain="newscatcherapi.com", key_persons=["Artem Bugara", "Maksym Sugonyaka"], - alternative_names=["NC", "NewsCatcher API"], + alternative_names=["NewsCatcher CatchAll", "NewsCatcher API"], ), ), ) @@ -181,6 +181,8 @@ def create_entities_batch( """ Creates multiple entities in a single request. Each entity is processed independently — a failure in one does not affect others. + Each entity requires a `name` plus at least one of: a `description` or a `domain`. See [Create entity](https://www.newscatcherapi.com/docs/web-search-api/api-reference/entities/create-entity) for the full field reference. + Returns an array of `{id, status}` objects in the same order as the input array. Parameters @@ -355,7 +357,11 @@ def update_entity( description="Updated description", additional_attributes=AdditionalAttributes( company_attributes=CompanyAttributes( - alternative_names=["NC", "NewsCatcher API", "NCA"], + alternative_names=[ + "NewsCatcher CatchAll", + "NewsCatcher API", + "NCA", + ], ), ), ) @@ -472,10 +478,10 @@ async def create_entity( """ Creates a new company entity and begins background enrichment. + Each entity requires a `name` plus at least one of: a `description` or a `domain`. Providing both is recommended — `domain` is the highest-signal identifier because it is unambiguous; a well-written `description` is the best alternative when no domain is available. + The entity status starts as `pending` and transitions to `ready` once - enrichment completes. Provide as much identifying information as - possible — `domain` is the highest-signal field because it is - unambiguous. + enrichment completes. Parameters ---------- @@ -485,7 +491,7 @@ async def create_entity( entity_type : typing.Optional[EntityType] description : typing.Optional[str] - Free-text description of the entity used for disambiguation when similar names exist. + Free-text description of the entity used for disambiguation when similar names exist. See [Writing effective descriptions](https://www.newscatcherapi.com/docs/web-search-api/concepts/company-search#writing-effective-descriptions) for guidance on improving matching quality. external_entity_id : typing.Optional[str] Optional external identifier for this entity. Free-form string, not enforced as unique. Use it to store your own CRM, data warehouse, or internal database ID so you can join CatchAll results back to your systems. @@ -519,12 +525,12 @@ async def main() -> None: await client.entities.create_entity( name="NewsCatcher", entity_type="company", - description="AI-powered news data provider", + description="NewsCatcher is a data-as-a-service company providing news intelligence APIs including the CatchAll Web Search API (2B+ web pages indexed) and News API (140,000+ sources, 100+ countries).", additional_attributes=AdditionalAttributes( company_attributes=CompanyAttributes( domain="newscatcherapi.com", key_persons=["Artem Bugara", "Maksym Sugonyaka"], - alternative_names=["NC", "NewsCatcher API"], + alternative_names=["NewsCatcher CatchAll", "NewsCatcher API"], ), ), ) @@ -548,6 +554,8 @@ async def create_entities_batch( """ Creates multiple entities in a single request. Each entity is processed independently — a failure in one does not affect others. + Each entity requires a `name` plus at least one of: a `description` or a `domain`. See [Create entity](https://www.newscatcherapi.com/docs/web-search-api/api-reference/entities/create-entity) for the full field reference. + Returns an array of `{id, status}` objects in the same order as the input array. Parameters @@ -753,7 +761,11 @@ async def main() -> None: description="Updated description", additional_attributes=AdditionalAttributes( company_attributes=CompanyAttributes( - alternative_names=["NC", "NewsCatcher API", "NCA"], + alternative_names=[ + "NewsCatcher CatchAll", + "NewsCatcher API", + "NCA", + ], ), ), ) diff --git a/src/newscatcher_catchall/entities/raw_client.py b/src/newscatcher_catchall/entities/raw_client.py index 2607f89..2188aac 100644 --- a/src/newscatcher_catchall/entities/raw_client.py +++ b/src/newscatcher_catchall/entities/raw_client.py @@ -134,10 +134,10 @@ def create_entity( """ Creates a new company entity and begins background enrichment. + Each entity requires a `name` plus at least one of: a `description` or a `domain`. Providing both is recommended — `domain` is the highest-signal identifier because it is unambiguous; a well-written `description` is the best alternative when no domain is available. + The entity status starts as `pending` and transitions to `ready` once - enrichment completes. Provide as much identifying information as - possible — `domain` is the highest-signal field because it is - unambiguous. + enrichment completes. Parameters ---------- @@ -147,7 +147,7 @@ def create_entity( entity_type : typing.Optional[EntityType] description : typing.Optional[str] - Free-text description of the entity used for disambiguation when similar names exist. + Free-text description of the entity used for disambiguation when similar names exist. See [Writing effective descriptions](https://www.newscatcherapi.com/docs/web-search-api/concepts/company-search#writing-effective-descriptions) for guidance on improving matching quality. external_entity_id : typing.Optional[str] Optional external identifier for this entity. Free-form string, not enforced as unique. Use it to store your own CRM, data warehouse, or internal database ID so you can join CatchAll results back to your systems. @@ -224,6 +224,8 @@ def create_entities_batch( """ Creates multiple entities in a single request. Each entity is processed independently — a failure in one does not affect others. + Each entity requires a `name` plus at least one of: a `description` or a `domain`. See [Create entity](https://www.newscatcherapi.com/docs/web-search-api/api-reference/entities/create-entity) for the full field reference. + Returns an array of `{id, status}` objects in the same order as the input array. Parameters @@ -628,10 +630,10 @@ async def create_entity( """ Creates a new company entity and begins background enrichment. + Each entity requires a `name` plus at least one of: a `description` or a `domain`. Providing both is recommended — `domain` is the highest-signal identifier because it is unambiguous; a well-written `description` is the best alternative when no domain is available. + The entity status starts as `pending` and transitions to `ready` once - enrichment completes. Provide as much identifying information as - possible — `domain` is the highest-signal field because it is - unambiguous. + enrichment completes. Parameters ---------- @@ -641,7 +643,7 @@ async def create_entity( entity_type : typing.Optional[EntityType] description : typing.Optional[str] - Free-text description of the entity used for disambiguation when similar names exist. + Free-text description of the entity used for disambiguation when similar names exist. See [Writing effective descriptions](https://www.newscatcherapi.com/docs/web-search-api/concepts/company-search#writing-effective-descriptions) for guidance on improving matching quality. external_entity_id : typing.Optional[str] Optional external identifier for this entity. Free-form string, not enforced as unique. Use it to store your own CRM, data warehouse, or internal database ID so you can join CatchAll results back to your systems. @@ -718,6 +720,8 @@ async def create_entities_batch( """ Creates multiple entities in a single request. Each entity is processed independently — a failure in one does not affect others. + Each entity requires a `name` plus at least one of: a `description` or a `domain`. See [Create entity](https://www.newscatcherapi.com/docs/web-search-api/api-reference/entities/create-entity) for the full field reference. + Returns an array of `{id, status}` objects in the same order as the input array. Parameters diff --git a/src/newscatcher_catchall/jobs/client.py b/src/newscatcher_catchall/jobs/client.py index e277aa9..ae4033b 100644 --- a/src/newscatcher_catchall/jobs/client.py +++ b/src/newscatcher_catchall/jobs/client.py @@ -228,6 +228,10 @@ def create_job( Custom validators for filtering web page clusters. If not provided, validators are generated automatically based on the query. + The system may also inject validators during the `analyzing` stage — for + example, converting a relative time qualifier into an explicit event-date + gate. The returned `validators[]` in the job status shows the complete + applied set, including any system-added ones. enrichments : typing.Optional[typing.Sequence[EnrichmentSchema]] Custom enrichment fields for data extraction. @@ -728,6 +732,10 @@ async def create_job( Custom validators for filtering web page clusters. If not provided, validators are generated automatically based on the query. + The system may also inject validators during the `analyzing` stage — for + example, converting a relative time qualifier into an explicit event-date + gate. The returned `validators[]` in the job status shows the complete + applied set, including any system-added ones. enrichments : typing.Optional[typing.Sequence[EnrichmentSchema]] Custom enrichment fields for data extraction. diff --git a/src/newscatcher_catchall/jobs/raw_client.py b/src/newscatcher_catchall/jobs/raw_client.py index 86a8d58..388d1ba 100644 --- a/src/newscatcher_catchall/jobs/raw_client.py +++ b/src/newscatcher_catchall/jobs/raw_client.py @@ -326,6 +326,10 @@ def create_job( Custom validators for filtering web page clusters. If not provided, validators are generated automatically based on the query. + The system may also inject validators during the `analyzing` stage — for + example, converting a relative time qualifier into an explicit event-date + gate. The returned `validators[]` in the job status shows the complete + applied set, including any system-added ones. enrichments : typing.Optional[typing.Sequence[EnrichmentSchema]] Custom enrichment fields for data extraction. @@ -1101,6 +1105,10 @@ async def create_job( Custom validators for filtering web page clusters. If not provided, validators are generated automatically based on the query. + The system may also inject validators during the `analyzing` stage — for + example, converting a relative time qualifier into an explicit event-date + gate. The returned `validators[]` in the job status shows the complete + applied set, including any system-added ones. enrichments : typing.Optional[typing.Sequence[EnrichmentSchema]] Custom enrichment fields for data extraction. diff --git a/src/newscatcher_catchall/types/__init__.py b/src/newscatcher_catchall/types/__init__.py index 76e0f9f..683e13e 100644 --- a/src/newscatcher_catchall/types/__init__.py +++ b/src/newscatcher_catchall/types/__init__.py @@ -59,6 +59,7 @@ from .entity_type import EntityType from .entity_validation_error_body import EntityValidationErrorBody from .error import Error + from .formatter_config_dto import FormatterConfigDto from .get_plan_limits_response_dto import GetPlanLimitsResponseDto from .get_webhook_response_dto import GetWebhookResponseDto from .http_method import HttpMethod @@ -182,6 +183,7 @@ "EntityType": ".entity_type", "EntityValidationErrorBody": ".entity_validation_error_body", "Error": ".error", + "FormatterConfigDto": ".formatter_config_dto", "GetPlanLimitsResponseDto": ".get_plan_limits_response_dto", "GetWebhookResponseDto": ".get_webhook_response_dto", "HttpMethod": ".http_method", @@ -329,6 +331,7 @@ def __dir__(): "EntityType", "EntityValidationErrorBody", "Error", + "FormatterConfigDto", "GetPlanLimitsResponseDto", "GetWebhookResponseDto", "HttpMethod", diff --git a/src/newscatcher_catchall/types/company_attributes.py b/src/newscatcher_catchall/types/company_attributes.py index 611aa00..591ab43 100644 --- a/src/newscatcher_catchall/types/company_attributes.py +++ b/src/newscatcher_catchall/types/company_attributes.py @@ -18,11 +18,6 @@ class CompanyAttributes(UniversalBaseModel): The most reliable identifier — strongly recommended when available. """ - description: typing.Optional[str] = pydantic.Field(default=None) - """ - Detailed description of the company used for matching. - """ - key_persons: typing.Optional[typing.List[str]] = pydantic.Field(default=None) """ Names of key people associated with the company (founders, executives, etc.). Improves matching for articles that mention people rather than the company name. diff --git a/src/newscatcher_catchall/types/create_entity_request.py b/src/newscatcher_catchall/types/create_entity_request.py index 3539791..b5f92fd 100644 --- a/src/newscatcher_catchall/types/create_entity_request.py +++ b/src/newscatcher_catchall/types/create_entity_request.py @@ -10,7 +10,9 @@ class CreateEntityRequest(UniversalBaseModel): """ - Request body for creating a single entity. Only `name` is required. + Request body for creating a single entity. The `name` field is required. + You must also provide at least one of: a top-level `description` or a `domain` + inside `additional_attributes.company_attributes`. The more fields you provide, the better the matching quality. """ @@ -22,7 +24,7 @@ class CreateEntityRequest(UniversalBaseModel): entity_type: typing.Optional[EntityType] = None description: typing.Optional[str] = pydantic.Field(default=None) """ - Free-text description of the entity used for disambiguation when similar names exist. + Free-text description of the entity used for disambiguation when similar names exist. See [Writing effective descriptions](https://www.newscatcherapi.com/docs/web-search-api/concepts/company-search#writing-effective-descriptions) for guidance on improving matching quality. """ external_entity_id: typing.Optional[str] = pydantic.Field(default=None) diff --git a/src/newscatcher_catchall/types/dataset_response.py b/src/newscatcher_catchall/types/dataset_response.py index 7dce5c6..5dd5e46 100644 --- a/src/newscatcher_catchall/types/dataset_response.py +++ b/src/newscatcher_catchall/types/dataset_response.py @@ -34,6 +34,24 @@ class DatasetResponse(UniversalBaseModel): Total number of entities in this dataset. """ + entity_status_breakdown: typing.Optional[typing.Dict[str, int]] = pydantic.Field(default=None) + """ + Count of entities grouped by processing status. Keys are status values + (`ready`, `pending`, `enriching`, `failed`); values are entity counts. + """ + + health_score: typing.Optional[float] = pydantic.Field(default=None) + """ + Overall health score of the dataset, from 0 to 100. Reflects how many + entities have sufficient identifying information for reliable matching. + """ + + health_breakdown: typing.Optional[typing.Dict[str, float]] = pydantic.Field(default=None) + """ + Health scores broken down by entity type. Keys are entity types + (e.g. `company`); values are scores from 0 to 100. + """ + latest_status: typing.Optional[DatasetStatus] = None created_by_user_id: typing.Optional[str] = pydantic.Field(default=None) """ diff --git a/src/newscatcher_catchall/types/formatter_config_dto.py b/src/newscatcher_catchall/types/formatter_config_dto.py new file mode 100644 index 0000000..b6014eb --- /dev/null +++ b/src/newscatcher_catchall/types/formatter_config_dto.py @@ -0,0 +1,35 @@ +# This file was auto-generated by Fern from our API Definition. + +import typing + +import pydantic +from ..core.pydantic_utilities import IS_PYDANTIC_V2, UniversalBaseModel + + +class FormatterConfigDto(UniversalBaseModel): + """ + Custom payload formatter for `custom` webhooks. A Liquid template is + rendered at dispatch time to build a fully tailored request body — trim or + rename fields, apply conditional logic, or emit non-JSON formats. + """ + + template: str = pydantic.Field() + """ + Liquid template string rendered at dispatch time. Maximum 10 KB. + """ + + content_type: typing.Optional[str] = pydantic.Field(default=None) + """ + Content-Type of the rendered output. Supported values: `application/json`, + `application/ld+json`, `text/html`, `text/plain`, `text/xml`, + `application/xml`, `text/csv`. + """ + + if IS_PYDANTIC_V2: + model_config: typing.ClassVar[pydantic.ConfigDict] = pydantic.ConfigDict(extra="allow", frozen=True) # type: ignore # Pydantic v2 + else: + + class Config: + frozen = True + smart_union = True + extra = pydantic.Extra.allow diff --git a/src/newscatcher_catchall/types/initialize_response_dto.py b/src/newscatcher_catchall/types/initialize_response_dto.py index caac2ea..06b19a4 100644 --- a/src/newscatcher_catchall/types/initialize_response_dto.py +++ b/src/newscatcher_catchall/types/initialize_response_dto.py @@ -23,7 +23,10 @@ class InitializeResponseDto(UniversalBaseModel): validators: typing.List[ValidatorSchema] = pydantic.Field() """ - Suggested validators for filtering relevant web pages. + Suggested validators for filtering relevant web pages. When the job is + submitted, the system may inject additional validators during the `analyzing` + stage — check the returned `validators[]` in the job status to see the + complete applied set. """ enrichments: typing.List[EnrichmentSchema] = pydantic.Field() diff --git a/src/newscatcher_catchall/types/webhook_response_dto.py b/src/newscatcher_catchall/types/webhook_response_dto.py index f71e80f..6ba183f 100644 --- a/src/newscatcher_catchall/types/webhook_response_dto.py +++ b/src/newscatcher_catchall/types/webhook_response_dto.py @@ -6,6 +6,7 @@ import pydantic from ..core.pydantic_utilities import IS_PYDANTIC_V2, UniversalBaseModel from .delivery_mode import DeliveryMode +from .formatter_config_dto import FormatterConfigDto from .http_method import HttpMethod from .webhook_type import WebhookType @@ -39,9 +40,9 @@ class WebhookResponseDto(UniversalBaseModel): Query parameters appended to the webhook URL. """ - formatter_config: typing.Optional[typing.Dict[str, typing.Any]] = pydantic.Field(default=None) + formatter_config: typing.Optional[FormatterConfigDto] = pydantic.Field(default=None) """ - Custom payload transformation configuration. Used only when `type` is `custom`. + Custom payload formatter. Set only when `type` is `custom`. """ is_active: bool = pydantic.Field() diff --git a/src/newscatcher_catchall/webhooks/client.py b/src/newscatcher_catchall/webhooks/client.py index ec01532..2fc4a7d 100644 --- a/src/newscatcher_catchall/webhooks/client.py +++ b/src/newscatcher_catchall/webhooks/client.py @@ -8,6 +8,7 @@ from ..types.create_webhook_response_dto import CreateWebhookResponseDto from ..types.delivery_history_response_dto import DeliveryHistoryResponseDto from ..types.delivery_mode import DeliveryMode +from ..types.formatter_config_dto import FormatterConfigDto from ..types.get_webhook_response_dto import GetWebhookResponseDto from ..types.http_method import HttpMethod from ..types.list_webhook_resources_response_dto import ListWebhookResourcesResponseDto @@ -94,7 +95,7 @@ def create_webhook( headers: typing.Optional[typing.Dict[str, str]] = OMIT, params: typing.Optional[typing.Dict[str, str]] = OMIT, auth: typing.Optional[CreateWebhookRequestDtoAuth] = OMIT, - formatter_config: typing.Optional[typing.Dict[str, typing.Any]] = OMIT, + formatter_config: typing.Optional[FormatterConfigDto] = OMIT, request_options: typing.Optional[RequestOptions] = None, ) -> CreateWebhookResponseDto: """ @@ -134,8 +135,8 @@ def create_webhook( - `api_key`: Adds a custom header with the specified name and value. - `basic`: Adds an `Authorization: Basic ` header. - formatter_config : typing.Optional[typing.Dict[str, typing.Any]] - Custom payload transformation configuration. Required only when `type` is `custom`. + formatter_config : typing.Optional[FormatterConfigDto] + Custom payload formatter. Required when `type` is `custom`. request_options : typing.Optional[RequestOptions] Request-specific configuration. @@ -250,7 +251,7 @@ def update_webhook( headers: typing.Optional[typing.Dict[str, str]] = OMIT, params: typing.Optional[typing.Dict[str, str]] = OMIT, auth: typing.Optional[UpdateWebhookRequestDtoAuth] = OMIT, - formatter_config: typing.Optional[typing.Dict[str, typing.Any]] = OMIT, + formatter_config: typing.Optional[FormatterConfigDto] = OMIT, is_active: typing.Optional[bool] = OMIT, request_options: typing.Optional[RequestOptions] = None, ) -> UpdateWebhookResponseDto: @@ -283,8 +284,8 @@ def update_webhook( auth : typing.Optional[UpdateWebhookRequestDtoAuth] Updated authentication configuration. Replaces existing auth entirely. - formatter_config : typing.Optional[typing.Dict[str, typing.Any]] - Updated formatter configuration. + formatter_config : typing.Optional[FormatterConfigDto] + Updated custom payload formatter. Set only when `type` is `custom`. is_active : typing.Optional[bool] Set to `false` to disable delivery without deleting the webhook. @@ -578,6 +579,64 @@ def list_webhooks_for_resource( ) return _response.data + def trigger_webhook( + self, + resource_type: MappableResourceType, + resource_id: str, + *, + webhook_id: str, + job_id: typing.Optional[str] = None, + request_options: typing.Optional[RequestOptions] = None, + ) -> None: + """ + Manually dispatches a webhook delivery for a resource on demand, without + waiting for the next job or monitor cycle. + + Use this to re-deliver results after a failed delivery, replay a specific + job's results, or validate a webhook against live data. The webhook must + already be assigned to the resource. + + Parameters + ---------- + resource_type : MappableResourceType + + resource_id : str + Unique resource identifier. + + webhook_id : str + Identifier of the webhook to trigger. Must be assigned to the resource. + + job_id : typing.Optional[str] + Specific job run to deliver. Optional for `job` resources; for + `monitor` and `monitor_group` resources, selects a past run to replay. + When omitted, the latest available results are delivered. + + request_options : typing.Optional[RequestOptions] + Request-specific configuration. + + Returns + ------- + None + + Examples + -------- + from newscatcher_catchall import CatchAllApi + + client = CatchAllApi( + api_key="YOUR_API_KEY", + ) + client.webhooks.trigger_webhook( + resource_type="job", + resource_id="3fec5b07-8786-46d7-9486-d43ff67eccd4", + webhook_id="a1b2c3d4-e5f6-7890-abcd-ef1234567890", + job_id="3fec5b07-8786-46d7-9486-d43ff67eccd4", + ) + """ + _response = self._raw_client.trigger_webhook( + resource_type, resource_id, webhook_id=webhook_id, job_id=job_id, request_options=request_options + ) + return _response.data + def get_webhook_delivery_history( self, *, @@ -714,7 +773,7 @@ async def create_webhook( headers: typing.Optional[typing.Dict[str, str]] = OMIT, params: typing.Optional[typing.Dict[str, str]] = OMIT, auth: typing.Optional[CreateWebhookRequestDtoAuth] = OMIT, - formatter_config: typing.Optional[typing.Dict[str, typing.Any]] = OMIT, + formatter_config: typing.Optional[FormatterConfigDto] = OMIT, request_options: typing.Optional[RequestOptions] = None, ) -> CreateWebhookResponseDto: """ @@ -754,8 +813,8 @@ async def create_webhook( - `api_key`: Adds a custom header with the specified name and value. - `basic`: Adds an `Authorization: Basic ` header. - formatter_config : typing.Optional[typing.Dict[str, typing.Any]] - Custom payload transformation configuration. Required only when `type` is `custom`. + formatter_config : typing.Optional[FormatterConfigDto] + Custom payload formatter. Required when `type` is `custom`. request_options : typing.Optional[RequestOptions] Request-specific configuration. @@ -894,7 +953,7 @@ async def update_webhook( headers: typing.Optional[typing.Dict[str, str]] = OMIT, params: typing.Optional[typing.Dict[str, str]] = OMIT, auth: typing.Optional[UpdateWebhookRequestDtoAuth] = OMIT, - formatter_config: typing.Optional[typing.Dict[str, typing.Any]] = OMIT, + formatter_config: typing.Optional[FormatterConfigDto] = OMIT, is_active: typing.Optional[bool] = OMIT, request_options: typing.Optional[RequestOptions] = None, ) -> UpdateWebhookResponseDto: @@ -927,8 +986,8 @@ async def update_webhook( auth : typing.Optional[UpdateWebhookRequestDtoAuth] Updated authentication configuration. Replaces existing auth entirely. - formatter_config : typing.Optional[typing.Dict[str, typing.Any]] - Updated formatter configuration. + formatter_config : typing.Optional[FormatterConfigDto] + Updated custom payload formatter. Set only when `type` is `custom`. is_active : typing.Optional[bool] Set to `false` to disable delivery without deleting the webhook. @@ -1270,6 +1329,72 @@ async def main() -> None: ) return _response.data + async def trigger_webhook( + self, + resource_type: MappableResourceType, + resource_id: str, + *, + webhook_id: str, + job_id: typing.Optional[str] = None, + request_options: typing.Optional[RequestOptions] = None, + ) -> None: + """ + Manually dispatches a webhook delivery for a resource on demand, without + waiting for the next job or monitor cycle. + + Use this to re-deliver results after a failed delivery, replay a specific + job's results, or validate a webhook against live data. The webhook must + already be assigned to the resource. + + Parameters + ---------- + resource_type : MappableResourceType + + resource_id : str + Unique resource identifier. + + webhook_id : str + Identifier of the webhook to trigger. Must be assigned to the resource. + + job_id : typing.Optional[str] + Specific job run to deliver. Optional for `job` resources; for + `monitor` and `monitor_group` resources, selects a past run to replay. + When omitted, the latest available results are delivered. + + request_options : typing.Optional[RequestOptions] + Request-specific configuration. + + Returns + ------- + None + + Examples + -------- + import asyncio + + from newscatcher_catchall import AsyncCatchAllApi + + client = AsyncCatchAllApi( + api_key="YOUR_API_KEY", + ) + + + async def main() -> None: + await client.webhooks.trigger_webhook( + resource_type="job", + resource_id="3fec5b07-8786-46d7-9486-d43ff67eccd4", + webhook_id="a1b2c3d4-e5f6-7890-abcd-ef1234567890", + job_id="3fec5b07-8786-46d7-9486-d43ff67eccd4", + ) + + + asyncio.run(main()) + """ + _response = await self._raw_client.trigger_webhook( + resource_type, resource_id, webhook_id=webhook_id, job_id=job_id, request_options=request_options + ) + return _response.data + async def get_webhook_delivery_history( self, *, diff --git a/src/newscatcher_catchall/webhooks/raw_client.py b/src/newscatcher_catchall/webhooks/raw_client.py index 8feaaa6..0b813ff 100644 --- a/src/newscatcher_catchall/webhooks/raw_client.py +++ b/src/newscatcher_catchall/webhooks/raw_client.py @@ -20,6 +20,7 @@ from ..types.delivery_history_response_dto import DeliveryHistoryResponseDto from ..types.delivery_mode import DeliveryMode from ..types.error import Error +from ..types.formatter_config_dto import FormatterConfigDto from ..types.get_webhook_response_dto import GetWebhookResponseDto from ..types.http_method import HttpMethod from ..types.list_webhook_resources_response_dto import ListWebhookResourcesResponseDto @@ -133,7 +134,7 @@ def create_webhook( headers: typing.Optional[typing.Dict[str, str]] = OMIT, params: typing.Optional[typing.Dict[str, str]] = OMIT, auth: typing.Optional[CreateWebhookRequestDtoAuth] = OMIT, - formatter_config: typing.Optional[typing.Dict[str, typing.Any]] = OMIT, + formatter_config: typing.Optional[FormatterConfigDto] = OMIT, request_options: typing.Optional[RequestOptions] = None, ) -> HttpResponse[CreateWebhookResponseDto]: """ @@ -173,8 +174,8 @@ def create_webhook( - `api_key`: Adds a custom header with the specified name and value. - `basic`: Adds an `Authorization: Basic ` header. - formatter_config : typing.Optional[typing.Dict[str, typing.Any]] - Custom payload transformation configuration. Required only when `type` is `custom`. + formatter_config : typing.Optional[FormatterConfigDto] + Custom payload formatter. Required when `type` is `custom`. request_options : typing.Optional[RequestOptions] Request-specific configuration. @@ -198,7 +199,9 @@ def create_webhook( "auth": convert_and_respect_annotation_metadata( object_=auth, annotation=CreateWebhookRequestDtoAuth, direction="write" ), - "formatter_config": formatter_config, + "formatter_config": convert_and_respect_annotation_metadata( + object_=formatter_config, annotation=typing.Optional[FormatterConfigDto], direction="write" + ), }, headers={ "content-type": "application/json", @@ -394,7 +397,7 @@ def update_webhook( headers: typing.Optional[typing.Dict[str, str]] = OMIT, params: typing.Optional[typing.Dict[str, str]] = OMIT, auth: typing.Optional[UpdateWebhookRequestDtoAuth] = OMIT, - formatter_config: typing.Optional[typing.Dict[str, typing.Any]] = OMIT, + formatter_config: typing.Optional[FormatterConfigDto] = OMIT, is_active: typing.Optional[bool] = OMIT, request_options: typing.Optional[RequestOptions] = None, ) -> HttpResponse[UpdateWebhookResponseDto]: @@ -427,8 +430,8 @@ def update_webhook( auth : typing.Optional[UpdateWebhookRequestDtoAuth] Updated authentication configuration. Replaces existing auth entirely. - formatter_config : typing.Optional[typing.Dict[str, typing.Any]] - Updated formatter configuration. + formatter_config : typing.Optional[FormatterConfigDto] + Updated custom payload formatter. Set only when `type` is `custom`. is_active : typing.Optional[bool] Set to `false` to disable delivery without deleting the webhook. @@ -455,7 +458,9 @@ def update_webhook( "auth": convert_and_respect_annotation_metadata( object_=auth, annotation=UpdateWebhookRequestDtoAuth, direction="write" ), - "formatter_config": formatter_config, + "formatter_config": convert_and_respect_annotation_metadata( + object_=formatter_config, annotation=typing.Optional[FormatterConfigDto], direction="write" + ), "is_active": is_active, }, headers={ @@ -982,6 +987,99 @@ def list_webhooks_for_resource( ) raise ApiError(status_code=_response.status_code, headers=dict(_response.headers), body=_response_json) + def trigger_webhook( + self, + resource_type: MappableResourceType, + resource_id: str, + *, + webhook_id: str, + job_id: typing.Optional[str] = None, + request_options: typing.Optional[RequestOptions] = None, + ) -> HttpResponse[None]: + """ + Manually dispatches a webhook delivery for a resource on demand, without + waiting for the next job or monitor cycle. + + Use this to re-deliver results after a failed delivery, replay a specific + job's results, or validate a webhook against live data. The webhook must + already be assigned to the resource. + + Parameters + ---------- + resource_type : MappableResourceType + + resource_id : str + Unique resource identifier. + + webhook_id : str + Identifier of the webhook to trigger. Must be assigned to the resource. + + job_id : typing.Optional[str] + Specific job run to deliver. Optional for `job` resources; for + `monitor` and `monitor_group` resources, selects a past run to replay. + When omitted, the latest available results are delivered. + + request_options : typing.Optional[RequestOptions] + Request-specific configuration. + + Returns + ------- + HttpResponse[None] + """ + _response = self._client_wrapper.httpx_client.request( + f"catchAll/webhook/trigger/{encode_path_param(resource_type)}/{encode_path_param(resource_id)}", + method="POST", + params={ + "webhook_id": webhook_id, + "job_id": job_id, + }, + request_options=request_options, + ) + try: + if 200 <= _response.status_code < 300: + return HttpResponse(response=_response, data=None) + if _response.status_code == 403: + raise ForbiddenError( + headers=dict(_response.headers), + body=typing.cast( + Error, + parse_obj_as( + type_=Error, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 404: + raise NotFoundError( + headers=dict(_response.headers), + body=typing.cast( + Error, + parse_obj_as( + type_=Error, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 422: + raise UnprocessableEntityError( + headers=dict(_response.headers), + body=typing.cast( + ValidationErrorResponse, + parse_obj_as( + type_=ValidationErrorResponse, # type: ignore + object_=_response.json(), + ), + ), + ) + _response_json = _response.json() + except JSONDecodeError: + raise ApiError(status_code=_response.status_code, headers=dict(_response.headers), body=_response.text) + except ValidationError as e: + raise ParsingError( + status_code=_response.status_code, headers=dict(_response.headers), body=_response.json(), cause=e + ) + raise ApiError(status_code=_response.status_code, headers=dict(_response.headers), body=_response_json) + def get_webhook_delivery_history( self, *, @@ -1178,7 +1276,7 @@ async def create_webhook( headers: typing.Optional[typing.Dict[str, str]] = OMIT, params: typing.Optional[typing.Dict[str, str]] = OMIT, auth: typing.Optional[CreateWebhookRequestDtoAuth] = OMIT, - formatter_config: typing.Optional[typing.Dict[str, typing.Any]] = OMIT, + formatter_config: typing.Optional[FormatterConfigDto] = OMIT, request_options: typing.Optional[RequestOptions] = None, ) -> AsyncHttpResponse[CreateWebhookResponseDto]: """ @@ -1218,8 +1316,8 @@ async def create_webhook( - `api_key`: Adds a custom header with the specified name and value. - `basic`: Adds an `Authorization: Basic ` header. - formatter_config : typing.Optional[typing.Dict[str, typing.Any]] - Custom payload transformation configuration. Required only when `type` is `custom`. + formatter_config : typing.Optional[FormatterConfigDto] + Custom payload formatter. Required when `type` is `custom`. request_options : typing.Optional[RequestOptions] Request-specific configuration. @@ -1243,7 +1341,9 @@ async def create_webhook( "auth": convert_and_respect_annotation_metadata( object_=auth, annotation=CreateWebhookRequestDtoAuth, direction="write" ), - "formatter_config": formatter_config, + "formatter_config": convert_and_respect_annotation_metadata( + object_=formatter_config, annotation=typing.Optional[FormatterConfigDto], direction="write" + ), }, headers={ "content-type": "application/json", @@ -1439,7 +1539,7 @@ async def update_webhook( headers: typing.Optional[typing.Dict[str, str]] = OMIT, params: typing.Optional[typing.Dict[str, str]] = OMIT, auth: typing.Optional[UpdateWebhookRequestDtoAuth] = OMIT, - formatter_config: typing.Optional[typing.Dict[str, typing.Any]] = OMIT, + formatter_config: typing.Optional[FormatterConfigDto] = OMIT, is_active: typing.Optional[bool] = OMIT, request_options: typing.Optional[RequestOptions] = None, ) -> AsyncHttpResponse[UpdateWebhookResponseDto]: @@ -1472,8 +1572,8 @@ async def update_webhook( auth : typing.Optional[UpdateWebhookRequestDtoAuth] Updated authentication configuration. Replaces existing auth entirely. - formatter_config : typing.Optional[typing.Dict[str, typing.Any]] - Updated formatter configuration. + formatter_config : typing.Optional[FormatterConfigDto] + Updated custom payload formatter. Set only when `type` is `custom`. is_active : typing.Optional[bool] Set to `false` to disable delivery without deleting the webhook. @@ -1500,7 +1600,9 @@ async def update_webhook( "auth": convert_and_respect_annotation_metadata( object_=auth, annotation=UpdateWebhookRequestDtoAuth, direction="write" ), - "formatter_config": formatter_config, + "formatter_config": convert_and_respect_annotation_metadata( + object_=formatter_config, annotation=typing.Optional[FormatterConfigDto], direction="write" + ), "is_active": is_active, }, headers={ @@ -2027,6 +2129,99 @@ async def list_webhooks_for_resource( ) raise ApiError(status_code=_response.status_code, headers=dict(_response.headers), body=_response_json) + async def trigger_webhook( + self, + resource_type: MappableResourceType, + resource_id: str, + *, + webhook_id: str, + job_id: typing.Optional[str] = None, + request_options: typing.Optional[RequestOptions] = None, + ) -> AsyncHttpResponse[None]: + """ + Manually dispatches a webhook delivery for a resource on demand, without + waiting for the next job or monitor cycle. + + Use this to re-deliver results after a failed delivery, replay a specific + job's results, or validate a webhook against live data. The webhook must + already be assigned to the resource. + + Parameters + ---------- + resource_type : MappableResourceType + + resource_id : str + Unique resource identifier. + + webhook_id : str + Identifier of the webhook to trigger. Must be assigned to the resource. + + job_id : typing.Optional[str] + Specific job run to deliver. Optional for `job` resources; for + `monitor` and `monitor_group` resources, selects a past run to replay. + When omitted, the latest available results are delivered. + + request_options : typing.Optional[RequestOptions] + Request-specific configuration. + + Returns + ------- + AsyncHttpResponse[None] + """ + _response = await self._client_wrapper.httpx_client.request( + f"catchAll/webhook/trigger/{encode_path_param(resource_type)}/{encode_path_param(resource_id)}", + method="POST", + params={ + "webhook_id": webhook_id, + "job_id": job_id, + }, + request_options=request_options, + ) + try: + if 200 <= _response.status_code < 300: + return AsyncHttpResponse(response=_response, data=None) + if _response.status_code == 403: + raise ForbiddenError( + headers=dict(_response.headers), + body=typing.cast( + Error, + parse_obj_as( + type_=Error, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 404: + raise NotFoundError( + headers=dict(_response.headers), + body=typing.cast( + Error, + parse_obj_as( + type_=Error, # type: ignore + object_=_response.json(), + ), + ), + ) + if _response.status_code == 422: + raise UnprocessableEntityError( + headers=dict(_response.headers), + body=typing.cast( + ValidationErrorResponse, + parse_obj_as( + type_=ValidationErrorResponse, # type: ignore + object_=_response.json(), + ), + ), + ) + _response_json = _response.json() + except JSONDecodeError: + raise ApiError(status_code=_response.status_code, headers=dict(_response.headers), body=_response.text) + except ValidationError as e: + raise ParsingError( + status_code=_response.status_code, headers=dict(_response.headers), body=_response.json(), cause=e + ) + raise ApiError(status_code=_response.status_code, headers=dict(_response.headers), body=_response_json) + async def get_webhook_delivery_history( self, *, diff --git a/tests/utils/test_http_client.py b/tests/utils/test_http_client.py index 0c538e2..7634cc0 100644 --- a/tests/utils/test_http_client.py +++ b/tests/utils/test_http_client.py @@ -1,6 +1,6 @@ # This file was auto-generated by Fern from our API Definition. -from typing import Any, Dict +from typing import Any, Dict, Tuple, cast from unittest.mock import AsyncMock, MagicMock, patch import httpx @@ -692,3 +692,70 @@ def test_should_not_retry_non_retryable_status_codes(status_code: int) -> None: def test_should_retry_599_upper_boundary() -> None: """Legacy mode retries on >= 500, which includes 599.""" assert _should_retry(_make_response(599)) is True + + +# --------------------------------------------------------------------------- +# RequestOptions timeout resolution tests (timeout / deprecated timeout_in_seconds) +# --------------------------------------------------------------------------- + + +def _sync_client_with_base_timeout(base_timeout: Any) -> Tuple[HttpClient, _DummySyncClient]: + dummy_client = _DummySyncClient() + http_client = HttpClient( + httpx_client=dummy_client, # type: ignore[arg-type] + base_timeout=lambda: base_timeout, + base_headers=lambda: {}, + base_url=lambda: "https://example.com", + ) + return http_client, dummy_client + + +def test_sync_request_options_timeout_used() -> None: + """The new `timeout` request option is passed through to httpx (in seconds).""" + http_client, dummy_client = _sync_client_with_base_timeout(60) + http_client.request(path="/test", method="GET", request_options={"timeout": 30}) + assert dummy_client.last_request_kwargs["timeout"] == 30 + + +def test_sync_request_options_timeout_in_seconds_still_works() -> None: + """The deprecated `timeout_in_seconds` request option remains backwards compatible.""" + http_client, dummy_client = _sync_client_with_base_timeout(60) + http_client.request(path="/test", method="GET", request_options={"timeout_in_seconds": 45}) + assert dummy_client.last_request_kwargs["timeout"] == 45 + + +def test_sync_request_options_timeout_takes_precedence() -> None: + """When both are set, `timeout` wins over `timeout_in_seconds` (same seconds unit).""" + http_client, dummy_client = _sync_client_with_base_timeout(60) + http_client.request(path="/test", method="GET", request_options={"timeout": 30, "timeout_in_seconds": 45}) + assert dummy_client.last_request_kwargs["timeout"] == 30 + + +def test_sync_request_options_timeout_falls_back_to_base() -> None: + """When neither key is set, the client-level base timeout is used.""" + http_client, dummy_client = _sync_client_with_base_timeout(60) + http_client.request(path="/test", method="GET", request_options=None) + assert dummy_client.last_request_kwargs["timeout"] == 60 + + +def test_sync_request_options_timeout_none_falls_back_to_deprecated() -> None: + """An explicit `timeout=None` (dynamic caller) falls back to the deprecated `timeout_in_seconds`.""" + http_client, dummy_client = _sync_client_with_base_timeout(60) + request_options = cast(RequestOptions, {"timeout": None, "timeout_in_seconds": 45}) + http_client.request(path="/test", method="GET", request_options=request_options) + assert dummy_client.last_request_kwargs["timeout"] == 45 + + +@pytest.mark.asyncio +async def test_async_request_options_timeout_takes_precedence() -> None: + """Async: `timeout` wins over the deprecated `timeout_in_seconds`.""" + dummy_client = _DummyAsyncClient() + http_client = AsyncHttpClient( + httpx_client=dummy_client, # type: ignore[arg-type] + base_timeout=lambda: 60, + base_headers=lambda: {}, + base_url=lambda: "https://example.com", + async_base_headers=None, + ) + await http_client.request(path="/test", method="GET", request_options={"timeout": 30, "timeout_in_seconds": 45}) + assert dummy_client.last_request_kwargs["timeout"] == 30