From 607be8587cc4e7cf115b8f8fe22da77a3dcbba7d Mon Sep 17 00:00:00 2001 From: Daniel Yudelevich Date: Tue, 3 Nov 2026 16:32:19 -0800 Subject: [PATCH 1/6] feat(transport): warn from server deprecation headers Hardcoded warnings needed an SDK release for every deprecation and fired on each call, even without a request. The server now sends Deprecation on retired routes, so the transport warns once per endpoint and new deprecations reach existing SDK versions. --- CHANGELOG.md | 1 + packages/discolike/src/discolike/_email.py | 5 + .../discolike/src/discolike/_transport.py | 53 +++++++++ .../src/discolike/resources/_base.py | 2 + .../src/discolike/resources/email.py | 4 + packages/discolike/tests/test_email.py | 55 +++++++++ packages/discolike/tests/test_transport.py | 112 ++++++++++++++++++ 7 files changed, 232 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6591b16..323b01a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,7 @@ - SDK/CLI: `FindEmailRequest.suggested_email` sends an address you think is right: it is verified first, and the right one is recovered if it is not proven. It must be at `domain` or the company's mail domain, else 422. It works on `email.find` and on each request of `email.find_batch`; the CLI's `email find` gains `--suggested-email`. `EmailJobResult` gains `outcome` (`valid`, `valid_catch_all`, `recovered`, `pattern_inferred`, `not_found`, `unknown`, open) and `email`, read from `job.status()` or batch results, and `EnumerationOutput` / `ValidationOutput` gain `method` (how the address was proven). All are optional and need an API release that sends them. - SDK/CLI: `contacts.search`, `contacts.count` and `contacts.generate` are deprecated in favor of `client.contacts_run`; the API marks their endpoints deprecated with a 2026-12-31 sunset. They keep working and raise a `DeprecationWarning`; the CLI's `contacts search`, `count` and `generate` print a deprecation line on stderr after their output. `contacts.discover`, `lookup`, `match` and `bulk_match` are unchanged. - SDK (note for maintainers): `/contacts/run*` is hidden from the platform's public OpenAPI schema, so its request models live in the hand-kept `discolike/_contacts_run_requests.py` (generated once from a local spec), its routes are stamped `openapi=False`, and `scripts/check_contract.py` checks the run response models only when the spec carries them. `suggested_email` reaches `_generated/requests.py` through `PENDING_PROPERTIES` until the dev spec has it; the regeneration also picks up the dev spec's new site languages. +- SDK: sync and async transports emit a `DeprecationWarning` only when a response carries `Deprecation`, once per HTTP method and path template per process across clients and threads. Warnings include `Sunset` and `Link` when present; header values are kept as text, including malformed values. Email submission, reattachment and polling no longer emit hardcoded deprecation warnings. ## 0.6.2 (2026-10-21) diff --git a/packages/discolike/src/discolike/_email.py b/packages/discolike/src/discolike/_email.py index f164df0..e1ccfd7 100644 --- a/packages/discolike/src/discolike/_email.py +++ b/packages/discolike/src/discolike/_email.py @@ -16,6 +16,7 @@ from discolike._models import DiscolikeModel from discolike._transport import AsyncTransport from discolike._transport import Transport +from discolike.resources._base import api_route EmailKind = Literal["find", "verify"] EMAIL_TERMINAL_STATUSES = frozenset({"completed", "failed"}) @@ -138,6 +139,7 @@ def __init__(self, transport: Transport, *, job_id: str, kind: EmailKind = "find self.job_id = job_id self.kind = kind + @api_route("GET", "/email/jobs/{job_id}") def status(self) -> EmailJobResult: response = self._transport.request("GET", f"/email/jobs/{self.job_id}") return _decode_job_result(self.kind, response.json()) @@ -173,6 +175,7 @@ def __init__(self, transport: AsyncTransport, *, job_id: str, kind: EmailKind = self.job_id = job_id self.kind = kind + @api_route("GET", "/email/jobs/{job_id}") async def status(self) -> EmailJobResult: response = await self._transport.request("GET", f"/email/jobs/{self.job_id}") return _decode_job_result(self.kind, response.json()) @@ -208,6 +211,7 @@ def __init__(self, transport: Transport, *, batch_id: str, kind: EmailKind) -> N self.batch_id = batch_id self.kind = kind + @api_route("GET", "/email/batch/{batch_id}/results") def results( self, *, @@ -236,6 +240,7 @@ def __init__(self, transport: AsyncTransport, *, batch_id: str, kind: EmailKind) self.batch_id = batch_id self.kind = kind + @api_route("GET", "/email/batch/{batch_id}/results") async def results( self, *, diff --git a/packages/discolike/src/discolike/_transport.py b/packages/discolike/src/discolike/_transport.py index 4e12af0..16f913b 100644 --- a/packages/discolike/src/discolike/_transport.py +++ b/packages/discolike/src/discolike/_transport.py @@ -1,7 +1,10 @@ from __future__ import annotations import asyncio +import re +import threading import time +import warnings from collections.abc import Mapping from typing import Any @@ -18,6 +21,54 @@ BACKOFF_BASE_SECONDS = 0.5 +# Shared by all transport instances, including sync/async clients and timeout views. +_deprecation_lock = threading.Lock() +_warned_endpoints: set[tuple[str, str]] = set() +_route_templates: set[tuple[str, str]] = set() + + +def register_route(method: str, path: str) -> None: + with _deprecation_lock: + _route_templates.add((method.upper(), path)) + + +def _path_template(method: str, path: str) -> str: + path = path.split("?", 1)[0].split("#", 1)[0] + # Prefer declared SDK routes: these also recognize opaque string IDs. + matches = [] + for route_method, template in _route_templates: + if route_method == method: + pattern = re.sub(r"\\\{[^{}]+\\\}", "[^/]+", re.escape(template)) + if re.fullmatch(pattern, path): + matches.append(template) + if matches: + return min(matches, key=lambda template: (template.count("{"), template)) + # Undeclared/new routes still deduplicate numeric and UUID identifiers. + return re.sub( + r"(?<=/)(?:[0-9]+|[0-9a-fA-F]{8}(?:-[0-9a-fA-F]{4}){3}-[0-9a-fA-F]{12})(?=/|$)", + "{id}", + path, + ) + + +def _warn_deprecated(response: httpx2.Response, method: str, path: str) -> None: + if "Deprecation" not in response.headers: + return + method = method.upper() + with _deprecation_lock: + template = _path_template(method, path) + endpoint = (method, template) + if endpoint in _warned_endpoints: + return + _warned_endpoints.add(endpoint) + # Header presence is the signal. Keep values opaque, so malformed dates, + # structured fields or links cannot break an otherwise valid response. + details = "; ".join( + f"{name}: {response.headers[name]}" for name in ("Deprecation", "Sunset", "Link") if name in response.headers + ) + warnings.warn(f"{method} {template} is deprecated ({details})", DeprecationWarning, stacklevel=3) + + def drop_none(params: Mapping[str, Any] | None) -> dict[str, Any]: return {key: value for key, value in (params or {}).items() if value is not None} @@ -103,6 +154,7 @@ def request( continue except httpx2.TransportError as exc: raise APIConnectionError(f"Connection to DiscoLike API failed: {exc}") from exc + _warn_deprecated(response, method, path) if response.status_code in retryable_statuses and attempt < self._max_retries: time.sleep(_retry_delay(response, attempt)) continue @@ -176,6 +228,7 @@ async def request( continue except httpx2.TransportError as exc: raise APIConnectionError(f"Connection to DiscoLike API failed: {exc}") from exc + _warn_deprecated(response, method, path) if response.status_code in retryable_statuses and attempt < self._max_retries: await asyncio.sleep(_retry_delay(response, attempt)) continue diff --git a/packages/discolike/src/discolike/resources/_base.py b/packages/discolike/src/discolike/resources/_base.py index edc0945..55b2d17 100644 --- a/packages/discolike/src/discolike/resources/_base.py +++ b/packages/discolike/src/discolike/resources/_base.py @@ -8,6 +8,7 @@ from discolike._transport import AsyncTransport from discolike._transport import Transport +from discolike._transport import register_route F = TypeVar("F", bound=Callable[..., Any]) FileInput = pathlib.Path | str | BinaryIO @@ -15,6 +16,7 @@ def api_route(method: str, path: str, *, openapi: bool = True) -> Callable[[F], F]: def stamp(fn: F) -> F: + register_route(method, path) fn.__discolike_route__ = (method, path, openapi) # ty: ignore[unresolved-attribute] return fn diff --git a/packages/discolike/src/discolike/resources/email.py b/packages/discolike/src/discolike/resources/email.py index 085d615..a75064a 100644 --- a/packages/discolike/src/discolike/resources/email.py +++ b/packages/discolike/src/discolike/resources/email.py @@ -43,9 +43,11 @@ def find_batch(self, request: FindEmailBatchRequest) -> EmailBatch: return EmailBatch(self._transport, batch_id=response.json()["batch_id"], kind="find") def batch(self, batch_id: str, *, kind: EmailKind) -> EmailBatch: + """Reattach to a batch.""" return EmailBatch(self._transport, batch_id=batch_id, kind=kind) def job(self, job_id: str, *, kind: EmailKind = "find") -> EmailJob: + """Reattach to a job.""" return EmailJob(self._transport, job_id=job_id, kind=kind) @@ -61,7 +63,9 @@ async def find_batch(self, request: FindEmailBatchRequest) -> AsyncEmailBatch: return AsyncEmailBatch(self._transport, batch_id=response.json()["batch_id"], kind="find") def batch(self, batch_id: str, *, kind: EmailKind) -> AsyncEmailBatch: + """Reattach to a batch.""" return AsyncEmailBatch(self._transport, batch_id=batch_id, kind=kind) def job(self, job_id: str, *, kind: EmailKind = "find") -> AsyncEmailJob: + """Reattach to a job.""" return AsyncEmailJob(self._transport, job_id=job_id, kind=kind) diff --git a/packages/discolike/tests/test_email.py b/packages/discolike/tests/test_email.py index 5f4547d..71555bd 100644 --- a/packages/discolike/tests/test_email.py +++ b/packages/discolike/tests/test_email.py @@ -1,6 +1,8 @@ from __future__ import annotations import json +import warnings +from typing import Literal import httpx2 import pydantic @@ -28,6 +30,9 @@ async def fake_sleep(seconds: float) -> None: return None monkeypatch.setattr(jobs_module.asyncio, "sleep", fake_sleep) + with warnings.catch_warnings(): + warnings.simplefilter("error", DeprecationWarning) + yield def _results_sequence(payloads): @@ -572,3 +577,53 @@ def handler(request: httpx2.Request) -> httpx2.Response: assert status.email == "ghopper@navy.mil" assert isinstance(status.result, EnumerationOutput) assert status.result.method == "smtp" + + +@pytest.mark.parametrize("kind", ["find", "verify"]) +@pytest.mark.parametrize("handle", ["job", "batch"]) +def test_reattachment_is_silent_and_still_polls( + make_client: ClientFactory, handle: str, kind: Literal["find", "verify"] +) -> None: + def handler(request: httpx2.Request) -> httpx2.Response: + assert request.method == "GET" + if handle == "job": + assert request.url.path == "/v1/email/jobs/j-11" + return httpx2.Response(200, json=_VERIFY_JOB_PAYLOAD) + assert request.url.path == "/v1/email/batch/j-11/results" + return httpx2.Response(200, json={"total": 1, "results": [_VERIFY_JOB_PAYLOAD]}) + + with make_client(handler) as client: + attached = client.email.job("j-11", kind=kind) if handle == "job" else client.email.batch("j-11", kind=kind) + with warnings.catch_warnings(): + warnings.simplefilter("error", DeprecationWarning) + output = attached.wait() if isinstance(attached, EmailJob) else attached.results().results[0].result + + assert isinstance(output, ValidationOutput) + assert output.reason == "no_mailbox" + + +@pytest.mark.parametrize("kind", ["find", "verify"]) +@pytest.mark.parametrize("handle", ["job", "batch"]) +async def test_reattachment_is_silent_and_still_polls_async( + make_async_client: AsyncClientFactory, handle: str, kind: Literal["find", "verify"] +) -> None: + def handler(request: httpx2.Request) -> httpx2.Response: + assert request.method == "GET" + if handle == "job": + assert request.url.path == "/v1/email/jobs/j-11" + return httpx2.Response(200, json=_VERIFY_JOB_PAYLOAD) + assert request.url.path == "/v1/email/batch/j-11/results" + return httpx2.Response(200, json={"total": 1, "results": [_VERIFY_JOB_PAYLOAD]}) + + async with make_async_client(handler) as client: + attached = client.email.job("j-11", kind=kind) if handle == "job" else client.email.batch("j-11", kind=kind) + with warnings.catch_warnings(): + warnings.simplefilter("error", DeprecationWarning) + output = ( + await attached.wait() + if isinstance(attached, AsyncEmailJob) + else (await attached.results()).results[0].result + ) + + assert isinstance(output, ValidationOutput) + assert output.reason == "no_mailbox" diff --git a/packages/discolike/tests/test_transport.py b/packages/discolike/tests/test_transport.py index 1d83e01..9b05013 100644 --- a/packages/discolike/tests/test_transport.py +++ b/packages/discolike/tests/test_transport.py @@ -1,3 +1,6 @@ +import warnings +from concurrent.futures import ThreadPoolExecutor + import httpx2 import pytest @@ -12,6 +15,7 @@ @pytest.fixture(autouse=True) def no_sleep(monkeypatch): + monkeypatch.setattr(transport_module, "_warned_endpoints", set()) sleeps: list[float] = [] monkeypatch.setattr(transport_module.time, "sleep", sleeps.append) @@ -273,3 +277,111 @@ def handler(request: httpx2.Request) -> httpx2.Response: assert seen[0]["read"] == 120.0 assert seen[1]["read"] == 5.0 + + +@pytest.mark.parametrize("use_async", [False, True]) +async def test_deprecation_once_per_method_and_template_across_clients(use_async) -> None: + headers = { + "Deprecation": "@1793664000", + "Sunset": "Wed, 03 Nov 2027 00:00:00 GMT", + "Link": '; rel="deprecation"', + } + + def handler(request: httpx2.Request) -> httpx2.Response: + return httpx2.Response(200, json={"ok": True}, headers=headers) + + with warnings.catch_warnings(record=True) as caught: + warnings.simplefilter("always", DeprecationWarning) + for path in ("/email/jobs/123", "/email/jobs/456", "/email/jobs/opaque-job"): + if use_async: + http = httpx2.AsyncClient(transport=httpx2.MockTransport(handler), base_url="https://api.test/v1") + transport = AsyncTransport( + api_key_auth("test-key"), + base_url="https://api.test/v1", + timeout=5.0, + max_retries=0, + http_client=http, + ) + await transport.request("GET", path) + await transport.aclose() + else: + transport = make_transport(handler) + transport.with_timeout(1.0).request("GET", path) + transport.close() + assert len(caught) == 1 + transport = make_transport(handler) + transport.request("GET", "/email/jobs/789") + transport.request("POST", "/email/jobs/123") + transport.request("GET", "/usage") + transport.close() + assert len(caught) == 3 + message = str(caught[0].message) + assert "GET /email/jobs/{job_id}" in message + for name, value in headers.items(): + assert f"{name}: {value}" in message + + +@pytest.mark.parametrize("use_async", [False, True]) +@pytest.mark.parametrize("deprecation", [None, "", "not-a-date; ???", "@999999999999999999999999999999"]) +async def test_deprecation_missing_or_malformed_headers(use_async, deprecation) -> None: + headers = {"Sunset": "not a date", "Link": " httpx2.Response: + return httpx2.Response(200, json={"ok": True}, headers=headers) + + with warnings.catch_warnings(record=True) as caught: + warnings.simplefilter("always", DeprecationWarning) + if use_async: + http = httpx2.AsyncClient(transport=httpx2.MockTransport(handler), base_url="https://api.test/v1") + transport = AsyncTransport( + api_key_auth("test-key"), base_url="https://api.test/v1", timeout=5.0, max_retries=0, http_client=http + ) + response = await transport.request("GET", "/usage") + await transport.aclose() + else: + transport = make_transport(handler) + response = transport.request("GET", "/usage") + transport.close() + assert response.json() == {"ok": True} + assert len(caught) == (0 if deprecation is None else 1) + if caught: + assert "Sunset: not a date" in str(caught[0].message) + assert "Link: None: + def handler(request: httpx2.Request) -> httpx2.Response: + return httpx2.Response(200, json={}, headers={"Deprecation": "true"}) + + def request(job_id: int) -> None: + transport = make_transport(handler) + try: + transport.request("GET", f"/email/jobs/{job_id}") + finally: + transport.close() + + with warnings.catch_warnings(record=True) as caught: + warnings.simplefilter("always", DeprecationWarning) + with ThreadPoolExecutor(max_workers=8) as pool: + list(pool.map(request, range(32))) + assert len(caught) == 1 + + +@pytest.mark.parametrize( + "paths", + [ + ("/future/123", "/future/456"), + ("/future/123e4567-e89b-12d3-a456-426614174000", "/future/123e4567-e89b-12d3-a456-426614174001"), + ("/email/batch/first/results?offset=0", "/email/batch/second/results?offset=1"), + ], +) +def test_deprecation_normalizes_identifiers_and_query_strings(paths) -> None: + transport = make_transport(lambda request: httpx2.Response(200, json={}, headers={"Deprecation": "true"})) + with warnings.catch_warnings(record=True) as caught: + warnings.simplefilter("always", DeprecationWarning) + for path in paths: + transport.request("GET", path) + transport.close() + assert len(caught) == 1 From ad1f82edbe346d6ef9e134168cb037136884e5ca Mon Sep 17 00:00:00 2001 From: Daniel Yudelevich Date: Wed, 4 Nov 2026 07:07:43 -0800 Subject: [PATCH 2/6] feat(contacts-run): match the automatic validated-only run The API dropped steps and tightened validation, so the stepwise client would be rejected with 422 before it ever shipped. --- CHANGELOG.md | 4 +- README.md | 34 +- examples/README.md | 4 +- examples/cli_recipes.sh | 14 +- examples/contacts_at_results.py | 66 ++- .../src/discolike_cli/contacts_run.py | 66 +-- .../tests/test_contacts_run_cli.py | 74 ++-- .../src/discolike/_contacts_run_requests.py | 134 ++----- packages/discolike/src/discolike/requests.py | 8 +- .../src/discolike/resources/contacts_run.py | 163 ++++---- packages/discolike/tests/test_contacts_run.py | 379 +++++++++--------- scripts/check_contract.py | 11 + 12 files changed, 475 insertions(+), 482 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 323b01a..327e10d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,10 +2,10 @@ ## Unreleased -- SDK/CLI: contacts runs. `client.contacts_run` (sync and async) creates, lists, reads, continues and cancels a stepwise contacts run: `find_contacts`, `find_missing`, `review`, `find_emails`. `ContactsRunRequest` takes `companies` from a prompt (`max_companies` defaults to 500), a domain list or lookalike personas, plus `people` titles, `relevance`, `per_company`, `max_contacts` (keep the best-fit N contacts across all companies; once full, find missing searches no more companies and companies cut by the cap are not billed), `auto_stop` (`False` keeps every contact up to `per_company`, best fit first, instead of cutting at the relevance level), `find_missing` and `email_cap_usd`; a `step_by_step` run pauses with `status="waiting"` after each step until `continue_(run_id, ContinueRunRequest(generation=...))`. `email_cap_usd` is optional: it caps what `find_emails` spends, and without one the step runs until the account balance runs out. `wait(run_id, follow=True, email_cap_usd=...)` continues each step for you and stops at a failed step or, when neither the run nor the call has a cap, before `find_emails`; `rows(run_id)` pages through every row; `title_preview()` shows the titles a people spec matches. A stale `generation` or a failed step continued without `redo=True` is a 409 (`ValidationError`), and a run idle for 30 days answers 410. The CLI adds `contacts run create|list|get|continue|cancel|wait|title-preview`; `create` reads the request from `--params-file`, and `wait` takes `--follow` and `--email-cap-usd`. It needs an API release that serves `/contacts/run`. +- SDK/CLI: `client.contacts_run` (sync and async) starts automatic validated contact delivery from prompt, domain or lookalike companies and `people.fit`. Requests support top-level `max_companies`, `per_company`, `relevance`, `email_cap_usd`, exclusions and `create_key`. Removed unreleased step mode, title chips, `start`, `max_contacts`, `auto_stop`, `find_missing`, continuation overrides and wait follow/cap flags. `wait()` polls until done, failed or cancelled. Responses include open company progress, validated email labels, cursor events and summary totals; `rows()` pages company domains. CLI adds `contacts run create|list|get|events|cancel|wait`; live `continue` is companies-only, and `title-preview` remains a separate live route. Creation propagates typed `InsufficientBalanceError` for 403 INSUFFICIENT_BALANCE. No contacts_run deprecation window applies because this surface is unreleased. - SDK/CLI: `FindEmailRequest.suggested_email` sends an address you think is right: it is verified first, and the right one is recovered if it is not proven. It must be at `domain` or the company's mail domain, else 422. It works on `email.find` and on each request of `email.find_batch`; the CLI's `email find` gains `--suggested-email`. `EmailJobResult` gains `outcome` (`valid`, `valid_catch_all`, `recovered`, `pattern_inferred`, `not_found`, `unknown`, open) and `email`, read from `job.status()` or batch results, and `EnumerationOutput` / `ValidationOutput` gain `method` (how the address was proven). All are optional and need an API release that sends them. - SDK/CLI: `contacts.search`, `contacts.count` and `contacts.generate` are deprecated in favor of `client.contacts_run`; the API marks their endpoints deprecated with a 2026-12-31 sunset. They keep working and raise a `DeprecationWarning`; the CLI's `contacts search`, `count` and `generate` print a deprecation line on stderr after their output. `contacts.discover`, `lookup`, `match` and `bulk_match` are unchanged. -- SDK (note for maintainers): `/contacts/run*` is hidden from the platform's public OpenAPI schema, so its request models live in the hand-kept `discolike/_contacts_run_requests.py` (generated once from a local spec), its routes are stamped `openapi=False`, and `scripts/check_contract.py` checks the run response models only when the spec carries them. `suggested_email` reaches `_generated/requests.py` through `PENDING_PROPERTIES` until the dev spec has it; the regeneration also picks up the dev spec's new site languages. +- SDK (note for maintainers): Contacts run request models live in `discolike/_contacts_run_requests.py`, kept in sync with platform source; the routes retain `openapi=False` for older specs, and `scripts/check_contract.py` checks the run response models only when the spec carries them. `suggested_email` reaches `_generated/requests.py` through `PENDING_PROPERTIES` until the dev spec has it; the regeneration also picks up the dev spec's new site languages. - SDK: sync and async transports emit a `DeprecationWarning` only when a response carries `Deprecation`, once per HTTP method and path template per process across clients and threads. Warnings include `Sunset` and `Link` when present; header values are kept as text, including malformed values. Email submission, reattachment and polling no longer emit hardcoded deprecation warnings. ## 0.6.2 (2026-10-21) diff --git a/README.md b/README.md index 0a736da..6576161 100644 --- a/README.md +++ b/README.md @@ -283,7 +283,7 @@ Top-level commands: `discover`, `count`, `match`, `extract`, `validate-icp`, `ap | `client.discover()` / `client.count()` | Find lookalike companies by ICP text, phrases, tech stack, geo, and 40+ other filters | | `client.companies` | Company profiles: firmographics, scores, growth, redirects, vendors, subsidiaries | | `client.contacts` | Search, look up, match, and discover contacts at target companies | -| `client.contacts_run` | Find contacts in reviewable steps, then their emails under a spending cap | +| `client.contacts_run` | Automatically find and deliver validated contacts under a spending cap | | `client.match` | Match company names (plus phone/city/state) to domains — single or bulk CSV | | `client.append()` | Enrich a CSV of domains with DiscoLike datasets | | `client.segment()` / `client.segment_file()` | Auto-segment a list of domains (comma-separated string or CSV upload) | @@ -313,25 +313,33 @@ result = job.wait() ### Contacts runs -`client.contacts_run` finds people at companies in four steps: `find_contacts`, `find_missing` (web search at companies with no indexed contact), `review` and `find_emails`. A `step_by_step` run (the default) pauses with `status="waiting"` after each step; `continue_()` starts the next one. `find_emails` bills per result; an optional `email_cap_usd`, given at create or on the continue that enters it, caps that spend, and without one it runs until the account balance runs out. +`client.contacts_run` automatically finds and validates contacts, using database candidates before web fallback. `max_companies` counts companies delivering at least one validated contact; `per_company` caps validated contacts at each company. Set `people.fit` to describe the people you want and optionally `email_cap_usd` to cap delivery spend. ```python -from discolike.requests import ContactsRunRequest, ContinueRunRequest +from discolike.requests import ContactsRunRequest, ContactsRunEventsParams run = client.contacts_run.create( - ContactsRunRequest.model_validate( - { - "companies": {"source": "prompt", "icp_text": "Independent dental clinics in Ohio"}, - "people": {"titles": ["Practice Owner", "Office Manager"]}, - } - ) + ContactsRunRequest.model_validate({ + "companies": {"source": "prompt", "icp_text": "Independent dental clinics in Ohio"}, + "people": {"fit": "Practice owners and office managers"}, + "max_companies": 50, + "per_company": 3, + "email_cap_usd": 20, + }) ) -run = client.contacts_run.wait(run.id, follow=True) # Stops before find_emails: no cap given. -rows = client.contacts_run.rows(run.id) # Every row, paged for you. -run = client.contacts_run.continue_(run.id, ContinueRunRequest(generation=run.generation, email_cap_usd=20)) +page = client.contacts_run.events(run.id, ContactsRunEventsParams(after=0)) +run = client.contacts_run.wait(run.id) # Returns on done, failed or cancelled. +rows = client.contacts_run.rows(run.id) # Pages company domains, keeping their contacts together. +print(run.summary) ``` -`companies` takes `{"source": "prompt", "icp_text": ...}` (up to `max_companies`, default 500), `{"source": "domains", "domains": [...]}` or `{"source": "lookalike", "persona_ids": [...]}`. `max_contacts` keeps only the best-fit N contacts across all companies (`per_company` still caps each one), and `auto_stop=False` turns off the relevance cut, keeping every contact up to `per_company`, best fit first; both stay off the wire unless set. `wait(follow=True)` also stops at a failed step, which you continue with `redo=True`. Every continue carries the run's current `generation`; a stale one is a 409 (`ValidationError`), and a run idle for 30 days answers 410. `contacts.search`, `contacts.count` and `contacts.generate` are deprecated in favor of contacts runs; their endpoints sunset on 2026-12-31. +`companies` accepts a prompt (`icp_text` and/or `filters`), domains (`source="domains", domains=[...]`) or lookalike seeds (`source="lookalike", persona_ids=[...]`). `max_companies` is a top-level field, default 500. Responses include `companies`, `companies_total`, `rows_total` and summary totals (`companies_delivered`, `validated_contacts`, `spent_usd`). Domains may be masked as `Company N`; treat them as display labels. Delivered email labels are `Validated` and `Validated (catch-all resolved)`. Events include `next_cursor` for the next call's `after`. + +Runs start automatically. `cancel()` stops unfinished work; `wait()` only polls and a timeout stops local polling. `continue_()` remains available for companies runs and returns 409 for contacts runs. The separate `title_preview()` route remains live, but contacts runs take free-text people fit rather than title chips. A run idle for 30 days answers 410. A creation request exceeding the remaining balance raises `InsufficientBalanceError` (a `PlanAccessError` subclass). + +CLI: `discolike contacts run create|list|get|events|cancel|wait|continue|title-preview`. Create reads `--params-file` plus optional `--email-cap-usd`; `wait` takes `--timeout`; `get --all-rows` fetches all contacts. `get --rows-offset` and `--rows-limit` count company domains for contacts runs. `continue` only advances companies runs and accepts `--generation` and `--redo`. + +`contacts.search`, `contacts.count` and `contacts.generate` are deprecated in favor of contacts runs; their endpoints sunset on 2026-12-31. ### Contact generation without an LLM key diff --git a/examples/README.md b/examples/README.md index 45d69b9..8ef9b44 100644 --- a/examples/README.md +++ b/examples/README.md @@ -13,11 +13,11 @@ export DISCOLIKE_API_KEY="dl_..." # create one at https://app.discolike.com/ac | [`icp_prompt_search.py`](icp_prompt_search.py) | Turns a plain-English ICP into a company list with similarity scores | `python examples/icp_prompt_search.py "Series A fintechs in Europe selling to SMBs" --country EU` | | [`phrase_match_count.py`](phrase_match_count.py) | Counts sites whose text contains an exact phrase, then lists them | `python examples/phrase_match_count.py "SOC 2" --country US` | | [`enrich_crm_export.py`](enrich_crm_export.py) | Adds firmographics (size, revenue, location, industry, business model) to every domain in a CSV | `python examples/enrich_crm_export.py accounts.csv --domain-column website --output enriched.csv` | -| [`contacts_at_results.py`](contacts_at_results.py) | Discovers companies for an ICP, then finds contacts at the top N filtered by seniority, department, or title | `python examples/contacts_at_results.py "B2B SaaS selling to sales teams" --top 10 --seniority executive --has-email` | +| [`contacts_at_results.py`](contacts_at_results.py) | Automatically finds and delivers validated contacts for an ICP and people fit | `python examples/contacts_at_results.py "B2B SaaS selling to sales teams" --top 10 --fit "Sales executives" --email-cap-usd 20` | | [`agent_signup_to_first_search.py`](agent_signup_to_first_search.py) | Opens a DiscoLike account for a person from an agent, relays `next_step`, and runs a first search once `DISCOLIKE_API_KEY` is set | `python examples/agent_signup_to_first_search.py --email jane@acme.com --first-name Jane --last-name Doe` | | [`discover_and_enrich.py`](discover_and_enrich.py) | Discovers companies for an ICP, then runs a DiscoGen research prompt over them (needs a BYOK LLM provider) | `python examples/discover_and_enrich.py --icp "Cybersecurity for SMBs" --country US --query "What is their pricing model?"` | | [`find_emails_from_csv.py`](find_emails_from_csv.py) | Finds verified work emails for a CSV of first name, last name, domain in batches of 500; only status `found` bills | `python examples/find_emails_from_csv.py people.csv --output emails.csv` | | [`match_crm_contacts.py`](match_crm_contacts.py) | Matches a messy CRM contact export to DiscoLike persona IDs with resumable checkpointing | `python examples/match_crm_contacts.py contacts.csv --output matched.csv` | -| [`cli_recipes.sh`](cli_recipes.sh) | The same searches as `discolike discover`, `discolike count`, `discolike contacts search`, and `discolike signup` one-liners, plus a `discolike bulk` volume pull | `bash examples/cli_recipes.sh` | +| [`cli_recipes.sh`](cli_recipes.sh) | The same searches as `discolike discover`, `discolike count`, `discolike contacts run`, and `discolike signup` one-liners, plus a `discolike bulk` volume pull | `bash examples/cli_recipes.sh` | Every script prints `--help`. Employee ranges are `min,max` strings such as `51,200`; countries are ISO-2 codes or region aliases like `EU`, `DACH`, `APAC`. diff --git a/examples/cli_recipes.sh b/examples/cli_recipes.sh index 4622818..f56543f 100644 --- a/examples/cli_recipes.sh +++ b/examples/cli_recipes.sh @@ -16,11 +16,15 @@ discolike discover --icp-prompt "Series A fintechs in Europe selling to SMBs" -- discolike count --phrase-match "SOC 2" --country US --format json discolike discover --phrase-match "SOC 2" --country US --max-records 50 --format json | jq -r '.[].domain' -# contacts_at_results.py: discover companies, then executives at the first ten of them -discolike discover --icp-prompt "B2B SaaS companies selling to sales teams" --max-records 10 --format json \ - | jq -r '.[].domain' \ - | xargs -I{} printf -- '--domain %s ' {} \ - | xargs discolike contacts search --seniority executive --has-email --format json +# contacts_at_results.py: automatic validated contacts for an ICP and people fit +cat > contacts-run.json <<'JSON' +{"companies":{"source":"prompt","icp_text":"B2B SaaS selling to sales teams"},"people":{"fit":"Sales executives"},"max_companies":10,"per_company":3} +JSON +discolike contacts run create --params-file contacts-run.json --email-cap-usd 20 +# Use the returned run ID: +# discolike contacts run events RUN_ID --after 0 +# discolike contacts run wait RUN_ID --timeout 3600 +# discolike contacts run get RUN_ID --all-rows # enrich_crm_export.py: one company profile by domain discolike company data stripe.com --format json diff --git a/examples/contacts_at_results.py b/examples/contacts_at_results.py index 6bc3f3e..3555f6f 100644 --- a/examples/contacts_at_results.py +++ b/examples/contacts_at_results.py @@ -1,57 +1,49 @@ -"""Discover companies for an ICP, then find decision makers at the top N by seniority, department, or title.""" +"""Automatically find and deliver validated decision-maker contacts for a company ICP.""" from __future__ import annotations import argparse from discolike import Discolike -from discolike.requests import ContactsSearchParams -from discolike.requests import DiscoverParams - -SENIORITIES = ["executive", "vp", "director", "manager", "senior_ic", "mid_level", "entry_level"] +from discolike.requests import ContactsRunRequest def parse_args() -> argparse.Namespace: parser = argparse.ArgumentParser(description=__doc__) parser.add_argument("prompt", help="Natural-language ICP for the company search") parser.add_argument("--country", action="append", help="ISO-2 country code or region alias, repeatable") - parser.add_argument("--top", type=int, default=10, help="Companies to search contacts at (default 10)") - parser.add_argument("--seniority", action="append", choices=SENIORITIES, help="Persona seniority, repeatable") - parser.add_argument("--department", action="append", help="Persona department, e.g. 'Sales - Marketing'") - parser.add_argument("--title", action="append", help="Job title term, e.g. 'Head of Growth', repeatable") - parser.add_argument("--per-company", type=int, default=3, help="Contacts per company (default 3)") - parser.add_argument("--has-email", action="store_true", help="Only contacts with an email address") + parser.add_argument( + "--top", type=int, default=10, help="Target companies delivering validated contacts (default 10)" + ) + parser.add_argument("--fit", default="Senior executives and decision makers", help="Describe the people to find") + parser.add_argument("--per-company", type=int, default=3, help="Validated contacts per company (default 3)") + parser.add_argument("--email-cap-usd", type=float, help="Maximum delivery spend in USD; omit for no cap") return parser.parse_args() def main() -> None: args = parse_args() - client = Discolike() - - companies = client.discover( - DiscoverParams(icp_prompt=args.prompt, country=args.country, max_records=max(args.top, 5)) - ) - domains = [company.domain for company in companies[: args.top] if company.domain] - print(f"Top {len(domains)} companies: {', '.join(domains)}") - - contacts = client.contacts.search( - ContactsSearchParams( - domain=domains, - seniority=args.seniority, - department=args.department, - title=args.title, - has_email=args.has_email, - results_by_company=args.per_company, - max_records=max(len(domains) * args.per_company, 20), - ) - ) - print(f"\n{'domain':<28} {'name':<28} {'title':<40} email") - for contact in contacts: - print( - f"{contact.domain or '':<28} {(contact.name or '')[:28]:<28} " - f"{(contact.title or '')[:40]:<40} {contact.email or ''}" - ) - print(f"\n{len(contacts)} contacts across {len({contact.domain for contact in contacts})} companies") + body = { + "companies": { + "source": "prompt", + "icp_text": args.prompt, + "filters": {"filter_country": args.country} if args.country else {}, + }, + "people": {"fit": args.fit}, + "max_companies": args.top, + "per_company": args.per_company, + } + if args.email_cap_usd is not None: + body["email_cap_usd"] = args.email_cap_usd + with Discolike() as client: + run = client.contacts_run.create(ContactsRunRequest.model_validate(body)) + print(f"Run {run.id}: {run.status}") + run = client.contacts_run.wait(run.id) + print(f"Finished: {run.status}; summary: {run.summary}") + for contact in client.contacts_run.rows(run.id): + print( + f"{contact.domain}\t{contact.name or ''}\t{contact.title or ''}\t{contact.email or ''}\t{contact.email_status or ''}" + ) if __name__ == "__main__": diff --git a/packages/discolike-cli/src/discolike_cli/contacts_run.py b/packages/discolike-cli/src/discolike_cli/contacts_run.py index a81ab9f..f31d6df 100644 --- a/packages/discolike-cli/src/discolike_cli/contacts_run.py +++ b/packages/discolike-cli/src/discolike_cli/contacts_run.py @@ -4,6 +4,7 @@ import typer +from discolike.requests import ContactsRunEventsParams from discolike.requests import ContactsRunGetParams from discolike.requests import ContactsRunListParams from discolike.requests import ContactsRunRequest @@ -18,21 +19,14 @@ FORMAT_HELP = "Output format: json or table (table auto-selected on a TTY; falls back to JSON for non-tabular data)." PARAMS_FILE_HELP = ( - "JSON object with the run request: companies (source prompt, domains or lookalike), people, relevance, " - "per_company, find_missing, email_cap_usd, mode. --param and flags override its values." + "JSON object with the run request: companies (source prompt, domains or lookalike), relevance, " + "people.fit, max_companies, per_company, email_cap_usd, exclusions and create_key. --param and flags override its values." ) PARAM_HELP = "Extra key=value body field (repeatable)." -EMAIL_CAP_HELP = ( - "Maximum USD to spend finding emails; omit for no cap. wait --follow stops before find_emails without one." -) -GENERATION_HELP = "Generation of the run you are continuing; fetched from the run when omitted." +EMAIL_CAP_HELP = "Maximum USD to spend on validated delivery; omit for no cap." +GENERATION_HELP = "Generation of the companies run you are continuing; fetched when omitted." -app = typer.Typer( - help=( - "Find contacts in steps: find_contacts, find_missing, review, find_emails. A step-by-step run " - "pauses after each step until you continue it." - ) -) +app = typer.Typer(help="Find and validate contacts automatically. Poll company progress and delivery totals.") @app.command("create") @@ -72,8 +66,10 @@ def list_command( def get_command( ctx: typer.Context, run_id: str = typer.Argument(..., help="Run ID."), - rows_offset: int | None = typer.Option(None, help="Rows to skip."), - rows_limit: int | None = typer.Option(None, help="Rows to return (default 100, maximum 1000)."), + rows_offset: int | None = typer.Option(None, help="Company domains to skip for contacts runs."), + rows_limit: int | None = typer.Option( + None, help="Company domains per page for contacts runs (default 100, maximum 1000)." + ), all_rows: bool = typer.Option(False, "--all-rows", help="Print every row of the run as a list instead."), fmt: str | None = typer.Option(None, "--format", help=FORMAT_HELP), ) -> None: @@ -94,15 +90,9 @@ def continue_command( ctx: typer.Context, run_id: str = typer.Argument(..., help="Run ID."), generation: int | None = typer.Option(None, help=GENERATION_HELP), - relevance: str | None = typer.Option(None, help="New relevance: max_relevance, balanced or max_coverage."), - not_fit_id: list[str] | None = typer.Option( - None, "--not-fit-id", help="Row key to drop as not a fit (repeatable)." - ), - email_cap_usd: float | None = typer.Option(None, "--email-cap-usd", help=EMAIL_CAP_HELP), redo: bool | None = typer.Option(None, "--redo/--no-redo", help="Run the current step again."), - target_step: str | None = typer.Option(None, help="find_missing: go back from review in a new generation."), ) -> None: - """Start the run's next step, or redo the current one.""" + """Advance or redo a companies run. Contacts runs proceed automatically (409 here).""" from discolike_cli.main import get_client client = get_client(ctx) @@ -111,11 +101,7 @@ def continue_command( _merge_params( None, generation=client.contacts_run.get(run_id).generation if generation is None else generation, - relevance=relevance, - not_fit_ids=not_fit_id, - email_cap_usd=email_cap_usd, redo=redo, - target_step=target_step, ), ) emit(client.contacts_run.continue_(run_id, request)) @@ -135,16 +121,27 @@ def cancel_command(ctx: typer.Context, run_id: str = typer.Argument(..., help="R def wait_command( ctx: typer.Context, run_id: str = typer.Argument(..., help="Run ID."), - follow: bool = typer.Option(False, "--follow", help="Continue each paused step instead of returning."), - email_cap_usd: float | None = typer.Option( - None, "--email-cap-usd", help="With --follow, continue into find_emails with this USD cap." - ), timeout: float = typer.Option(DEFAULT_WAIT_TIMEOUT_SECONDS, "--timeout", help="Max seconds to wait."), ) -> None: - """Block until the run finishes or pauses; with --follow, until it finishes or needs you.""" + """Block until done, failed or cancelled. A timeout stops local polling only.""" from discolike_cli.main import get_client - emit(get_client(ctx).contacts_run.wait(run_id, follow=follow, email_cap_usd=email_cap_usd, max_wait=timeout)) + emit(get_client(ctx).contacts_run.wait(run_id, max_wait=timeout)) + + +@app.command("events") +@handle_errors +def events_command( + ctx: typer.Context, + run_id: str = typer.Argument(..., help="Run ID."), + after: int | None = typer.Option(None, help="Return events after this cursor."), + limit: int | None = typer.Option(None, help="Events per page (default 100, maximum 500)."), +) -> None: + """Read company progress events and the next cursor.""" + from discolike_cli.main import get_client + + params = build_request(ContactsRunEventsParams, _merge_params(None, after=after, limit=limit)) + emit(get_client(ctx).contacts_run.events(run_id, params)) @app.command("title-preview") @@ -156,12 +153,17 @@ def title_preview_command( None, "--exclude-title", help="Job title to leave out (repeatable)." ), match: str = typer.Option("similar", help="similar also accepts close variants of a title; exact does not."), + relevance: str | None = typer.Option(None, help="max_relevance, balanced or max_coverage."), + auto_stop: bool | None = typer.Option(None, "--auto-stop/--no-auto-stop", help="Apply the relevance threshold."), fmt: str | None = typer.Option(None, "--format", help=FORMAT_HELP), ) -> None: """Preview the titles a people spec matches, with role scores and contact counts.""" from discolike_cli.main import get_client request = build_request( - TitlePreviewRequest, _merge_params(None, titles=title, exclude_titles=exclude_title, match=match) + TitlePreviewRequest, + _merge_params( + None, titles=title, exclude_titles=exclude_title, match=match, relevance=relevance, auto_stop=auto_stop + ), ) emit(get_client(ctx).contacts_run.title_preview(request).titles, fmt=fmt) diff --git a/packages/discolike-cli/tests/test_contacts_run_cli.py b/packages/discolike-cli/tests/test_contacts_run_cli.py index fa9e6df..ba53763 100644 --- a/packages/discolike-cli/tests/test_contacts_run_cli.py +++ b/packages/discolike-cli/tests/test_contacts_run_cli.py @@ -50,7 +50,7 @@ def handler(request: httpx2.Request) -> httpx2.Response: install_build_client(handler) params_file = tmp_path / "run.json" params_file.write_text( - json.dumps({"companies": {"source": "prompt", "icp_text": "dental clinics"}, "people": {"titles": ["Owner"]}}) + json.dumps({"companies": {"source": "prompt", "icp_text": "dental clinics"}, "people": {"fit": "Owners"}}) ) result = runner.invoke( app, ["contacts", "run", "create", "--params-file", str(params_file), "--email-cap-usd", "20"] @@ -60,7 +60,7 @@ def handler(request: httpx2.Request) -> httpx2.Response: assert bodies == [ { "companies": {"source": "prompt", "icp_text": "dental clinics"}, - "people": {"titles": ["Owner"]}, + "people": {"fit": "Owners"}, "email_cap_usd": "20.0", } ] @@ -76,40 +76,43 @@ def handler(request: httpx2.Request) -> httpx2.Response: return httpx2.Response(200, json=run_payload(generation=4)) install_build_client(handler) - result = runner.invoke( - app, ["contacts", "run", "continue", RUN_ID, "--not-fit-id", "r1", "--relevance", "balanced"] - ) + result = runner.invoke(app, ["contacts", "run", "continue", RUN_ID, "--redo"]) assert result.exit_code == 0, result.output assert seen == [ ("GET", RUN_PATH, None), - ("POST", f"{RUN_PATH}/continue", {"generation": 4, "relevance": "balanced", "not_fit_ids": ["r1"]}), + ("POST", f"{RUN_PATH}/continue", {"generation": 4, "redo": True}), ] -def test_run_wait_follow_needs_cap_for_emails(install_build_client: Callable[[Handler], None]) -> None: - state = {"step": 0} - continues: list[dict[str, Any]] = [] +def test_run_wait_polls_automatically(install_build_client: Callable[[Handler], None]) -> None: + statuses = iter(["ready", "running", "done"]) def handler(request: httpx2.Request) -> httpx2.Response: - if request.method == "POST": - continues.append(json.loads(request.content)) - state["step"] += 1 - step = state["step"] - return httpx2.Response( - 200, - json=run_payload(status="done" if step == len(STEPS) - 1 else "waiting", step=step, generation=step + 1), - ) + assert request.method == "GET" + return httpx2.Response(200, json=run_payload(status=next(statuses))) install_build_client(handler) - paused = runner.invoke(app, ["contacts", "run", "wait", RUN_ID, "--follow"]) - assert paused.exit_code == 0, paused.output - assert json.loads(paused.stdout)["current_step"] == "review" + result = runner.invoke(app, ["contacts", "run", "wait", RUN_ID]) + assert result.exit_code == 0, result.output + assert json.loads(result.stdout)["status"] == "done" - finished = runner.invoke(app, ["contacts", "run", "wait", RUN_ID, "--follow", "--email-cap-usd", "15"]) - assert finished.exit_code == 0, finished.output - assert json.loads(finished.stdout)["status"] == "done" - assert continues == [{"generation": 1}, {"generation": 2}, {"generation": 3, "email_cap_usd": "15.0"}] + +def test_run_events(install_build_client: Callable[[Handler], None]) -> None: + def handler(request: httpx2.Request) -> httpx2.Response: + assert request.url.path == RUN_PATH + "/events" + assert dict(request.url.params) == {"after": "4", "limit": "10"} + return httpx2.Response(200, json={"events": [], "next_cursor": 5}) + + install_build_client(handler) + result = runner.invoke(app, ["contacts", "run", "events", RUN_ID, "--after", "4", "--limit", "10"]) + assert result.exit_code == 0, result.output + assert json.loads(result.stdout)["next_cursor"] == 5 + + +@pytest.mark.parametrize("flag", ["--follow", "--email-cap-usd"]) +def test_removed_wait_flags(flag: str) -> None: + assert runner.invoke(app, ["contacts", "run", "wait", RUN_ID, flag]).exit_code == 2 def test_run_cancel_and_get(install_build_client: Callable[[Handler], None]) -> None: @@ -182,3 +185,26 @@ def handler(request: httpx2.Request) -> httpx2.Response: assert bodies == [ {"first_name": "Grace", "last_name": "Hopper", "domain": "navy.mil", "suggested_email": "grace@navy.mil"} ] + + +def test_run_insufficient_balance(install_build_client: Callable[[Handler], None], tmp_path: pathlib.Path) -> None: + install_build_client( + lambda _: httpx2.Response( + 403, + json={ + "detail": { + "code": "INSUFFICIENT_BALANCE", + "message": "Top up to continue", + "remaining_usd": 1, + "max_affordable_records": 2, + } + }, + ) + ) + params = tmp_path / "run.json" + params.write_text(json.dumps({"companies": {"source": "domains", "domains": ["acme.com"]}})) + result = runner.invoke(app, ["contacts", "run", "create", "--params-file", str(params)]) + assert result.exit_code == 3 + error = json.loads(result.stderr) + assert error["code"] == "insufficient_balance" + assert error["max_affordable_records"] == 2 diff --git a/packages/discolike/src/discolike/_contacts_run_requests.py b/packages/discolike/src/discolike/_contacts_run_requests.py index d79a49c..eb9d255 100644 --- a/packages/discolike/src/discolike/_contacts_run_requests.py +++ b/packages/discolike/src/discolike/_contacts_run_requests.py @@ -1,5 +1,4 @@ -# Generated by scripts/gen_requests.py from a local platform spec, then kept by hand: /contacts/run* is -# hidden from the public OpenAPI schema, so the generator and `--check` never see these models. +# Hand-maintained against platform contacts_run.py; do not regenerate from older deployed specs. from __future__ import annotations @@ -8,6 +7,7 @@ from typing import Literal from pydantic import AwareDatetime +from pydantic import ConfigDict from pydantic import Field from discolike._models import DiscolikeRequest @@ -17,6 +17,8 @@ class TitlePreviewRequest(DiscolikeRequest): titles: Annotated[list[str], Field(max_length=50, min_length=1, title="Titles")] exclude_titles: Annotated[list[str] | None, Field(title="Exclude Titles")] = None match: Annotated[Literal["similar", "exact"], Field(title="Match")] + relevance: Literal["max_relevance", "balanced", "max_coverage"] = "balanced" + auto_stop: bool = True class DomainCompanies(DiscolikeRequest): @@ -31,17 +33,6 @@ class DomainCompanies(DiscolikeRequest): ] = None -class FindMissingSpec(DiscolikeRequest): - search_provider_id: Annotated[str | None, Field(title="Search Provider Id")] = None - enabled: Annotated[ - bool | None, Field(description="Search for contacts at companies where none were found.", title="Enabled") - ] = True - provider: Annotated[ - str | None, - Field(description="'discolike' uses DiscoLike search; an integration id uses your own keys.", title="Provider"), - ] = "discolike" - - class LookalikeCompanies(DiscolikeRequest): source: Annotated[Literal["lookalike"], Field(title="Source")] persona_ids: Annotated[ @@ -50,22 +41,10 @@ class LookalikeCompanies(DiscolikeRequest): ] -class PeopleSpec(DiscolikeRequest): - titles: Annotated[ - list[str] | None, - Field( - description="Job titles of the people to find. Optional for a lookalike run, which then uses the seeds' titles.", - max_length=50, - title="Titles", - ), - ] = None - exclude_titles: Annotated[ - list[str] | None, Field(description="Job titles to leave out.", title="Exclude Titles") - ] = None - match: Annotated[ - Literal["similar", "exact"] | None, - Field(description="'similar' also accepts close variants of a title; 'exact' does not.", title="Match"), - ] = "similar" +class ContactsPeopleFit(DiscolikeRequest): + model_config = ConfigDict(extra="forbid", str_strip_whitespace=True) + + fit: str = Field(default="Senior executives and decision makers", min_length=1, max_length=4000) class PromptCompanyFilters(DiscolikeRequest): @@ -249,86 +228,35 @@ class PromptCompanyFilters(DiscolikeRequest): class ContinueRunRequest(DiscolikeRequest): - generation: Annotated[ - int, Field(description="Generation of the run you are continuing, as last returned.", title="Generation") - ] - relevance: Annotated[ - Literal["max_relevance", "balanced", "max_coverage"] | None, - Field(description="Change the relevance setting.", title="ContactsRelevance"), - ] = None - not_fit_ids: Annotated[ - list[str] | None, Field(description="Ids of rows to drop as not a fit.", title="Not Fit Ids") - ] = None - email_cap_usd: Annotated[ - Annotated[Decimal, Field(ge=0)] | None, - Field(description="Maximum spend in USD on finding emails.", title="Email Cap Usd"), - ] = None - redo: Annotated[bool | None, Field(description="Run the current step again.", title="Redo")] = False - target_step: Annotated[ - Literal["find_missing"] | None, - Field(description="Return from review to find missing in a new generation.", title="Target Step"), - ] = None + model_config = ConfigDict(extra="forbid") + + generation: int + redo: bool = False class PromptCompanies(DiscolikeRequest): - max_companies: Annotated[int | None, Field(ge=1, le=10000, title="Max Companies")] = 500 - source: Annotated[Literal["prompt"], Field(title="Source")] - icp_text: Annotated[ - str, Field(description="Plain-language description of the companies you want.", min_length=3, title="Icp Text") - ] - filters: Annotated[ - PromptCompanyFilters | None, Field(description="Company filters applied on top of the description.") - ] = None + source: Literal["prompt"] + icp_text: str | None = Field(default=None, min_length=3) + filters: PromptCompanyFilters = Field(default_factory=PromptCompanyFilters) class ContactsRunRequest(DiscolikeRequest): - start: Annotated[bool | None, Field(title="Start")] = True - exclude_query_ids: Annotated[list[str] | None, Field(max_length=20, title="Exclude Query Ids")] = None - exclude_domains: Annotated[list[str] | None, Field(max_length=100, title="Exclude Domains")] = None - companies: Annotated[ - PromptCompanies | DomainCompanies | LookalikeCompanies, Field(discriminator="source", title="Companies") - ] - people: PeopleSpec - relevance: Annotated[ - Literal["max_relevance", "balanced", "max_coverage"] | None, - Field(description="Trade-off between contact relevance and coverage.", title="ContactsRelevance"), - ] = "balanced" - per_company: Annotated[ - Annotated[int, Field(ge=1, le=50)] | Literal["all"] | None, - Field(description="Maximum contacts per company, or 'all'.", title="Per Company"), - ] = 10 - max_contacts: Annotated[ - Annotated[int, Field(ge=1, le=10000)] | None, - Field( - description="Maximum contacts to keep across all companies, best fit first. Omit to keep up to " - "per_company at every company.", - title="Max Contacts", - ), - ] = None - auto_stop: Annotated[ - bool | None, - Field( - description="Drop contacts below the relevance level's fit floor. False keeps every contact up to " - "per_company, best fit first.", - title="Auto Stop", - ), - ] = True - find_missing: FindMissingSpec | None = None - email_cap_usd: Annotated[ - Annotated[Decimal, Field(ge=0)] | None, - Field(description="Maximum spend in USD on finding emails. No cap when omitted.", title="Email Cap Usd"), - ] = None - mode: Annotated[ - Literal["step_by_step", "run_all"] | None, - Field(description="'step_by_step' pauses for review between steps; 'run_all' does not.", title="Mode"), - ] = "step_by_step" - create_key: Annotated[ - str | None, - Field( - description="Idempotency key: repeating a request with the same key returns the same run.", - title="Create Key", - ), - ] = None + model_config = ConfigDict(extra="forbid") + + companies: Annotated[PromptCompanies | DomainCompanies | LookalikeCompanies, Field(discriminator="source")] + people: ContactsPeopleFit = Field(default_factory=ContactsPeopleFit) + relevance: Literal["max_relevance", "balanced", "max_coverage"] = "balanced" + max_companies: int = Field(default=500, ge=1, le=10000) + per_company: int = Field(default=10, ge=1, le=50) + email_cap_usd: Decimal | None = Field(default=None, ge=0) + exclude_query_ids: list[str] = Field(default_factory=list, max_length=20) + exclude_domains: list[str] = Field(default_factory=list, max_length=100) + create_key: str | None = None + + +class ContactsRunEventsParams(DiscolikeRequest): + after: int = Field(default=0, ge=0) + limit: int = Field(default=100, ge=1, le=500) class ContactsRunListParams(DiscolikeRequest): diff --git a/packages/discolike/src/discolike/requests.py b/packages/discolike/src/discolike/requests.py index 3d79801..39ffd27 100644 --- a/packages/discolike/src/discolike/requests.py +++ b/packages/discolike/src/discolike/requests.py @@ -1,13 +1,13 @@ """Request models generated from the platform OpenAPI spec by scripts/gen_requests.py.""" +from discolike._contacts_run_requests import ContactsPeopleFit +from discolike._contacts_run_requests import ContactsRunEventsParams from discolike._contacts_run_requests import ContactsRunGetParams from discolike._contacts_run_requests import ContactsRunListParams from discolike._contacts_run_requests import ContactsRunRequest from discolike._contacts_run_requests import ContinueRunRequest from discolike._contacts_run_requests import DomainCompanies -from discolike._contacts_run_requests import FindMissingSpec from discolike._contacts_run_requests import LookalikeCompanies -from discolike._contacts_run_requests import PeopleSpec from discolike._contacts_run_requests import PromptCompanies from discolike._contacts_run_requests import PromptCompanyFilters from discolike._contacts_run_requests import TitlePreviewRequest @@ -72,6 +72,8 @@ "ContactsCountParams", "ContactsLookupParams", "ContactsMatchParams", + "ContactsPeopleFit", + "ContactsRunEventsParams", "ContactsRunGetParams", "ContactsRunListParams", "ContactsRunRequest", @@ -85,14 +87,12 @@ "DomainCompanies", "FindEmailBatchRequest", "FindEmailRequest", - "FindMissingSpec", "IntakeAnswer", "LLMProviderCreateRequest", "LLMProviderUpdateRequest", "LookalikeCompanies", "MatchBulkParams", "MatchCompanyParams", - "PeopleSpec", "PromptCompanies", "PromptCompanyFilters", "ProspectingApproveRequest", diff --git a/packages/discolike/src/discolike/resources/contacts_run.py b/packages/discolike/src/discolike/resources/contacts_run.py index 6f9632e..e867827 100644 --- a/packages/discolike/src/discolike/resources/contacts_run.py +++ b/packages/discolike/src/discolike/resources/contacts_run.py @@ -13,6 +13,7 @@ from discolike._exceptions import JobTimeoutError from discolike._models import DiscolikeModel +from discolike.requests import ContactsRunEventsParams from discolike.requests import ContactsRunGetParams from discolike.requests import ContactsRunListParams from discolike.requests import ContactsRunRequest @@ -24,13 +25,7 @@ from discolike.resources.prospecting import _deadline RUN_PATH = "/contacts/run" -CONTACTS_KIND = "contacts" -CONTACTS_STEP_ORDER = ("find_contacts", "find_missing", "review", "find_emails") -FIND_EMAILS_STEP = "find_emails" -EMAIL_CAP_INPUT_KEY = "email_cap_usd" -FAILED_STEP_STATUS = "failed" TERMINAL_STATUSES = frozenset({"done", "failed", "cancelled"}) -PAUSED_STATUSES = frozenset({"ready", "waiting"}) ROWS_PAGE_SIZE = 1000 DEFAULT_MAX_WAIT_SECONDS = 3600.0 DEFAULT_POLL_INTERVAL_SECONDS = 5.0 @@ -42,6 +37,7 @@ class ContactsRunStep(DiscolikeModel): status: ContactsRunStepStatus + error: str | None = None counts: dict[str, int] = Field(default_factory=dict) estimate_usd: Decimal | None = None charged_usd: Decimal = Decimal(0) @@ -58,23 +54,66 @@ class ContactsRunRow(DiscolikeModel): linkedin_url: str | None = None seniority: str | None = None department: str | None = None - source: Literal["database", "web_search"] | str + source: Literal["database", "web_search"] | str | None = None + debug_only: bool | None = None + reason: str | None = None company_score: float | None = None p_fit: float | None = None fit_label: str | None = None floor: bool = False not_fit: bool = False email: str | None = None - email_status: str | None = None + email_status: Literal["Validated", "Validated (catch-all resolved)"] | str | None = None company: dict[str, Any] = Field(default_factory=dict) +class RunCompanyResponse(DiscolikeModel): + domain: str + status: Literal["pending", "finding_people", "finding_emails", "validated", "skipped", "cancelled", "failed"] | str + people_reviewed: int = 0 + mailboxes_checked: int = 0 + validated_contacts: int = 0 + contacts: list[ContactsRunRow] = Field(default_factory=list) + + +class ContactsRunSummary(DiscolikeModel): + companies_delivered: int = 0 + validated_contacts: int = 0 + spent_usd: Decimal = Decimal(0) + + +class ValidatedRunTitle(DiscolikeModel): + title: str | None = None + status: Literal["Validated", "Validated (catch-all resolved)"] | str + + +class ContactsRunEvent(DiscolikeModel): + seq: int + domain: str + created_at: datetime + kind: Literal["progress", "company_skipped"] | str + status: str + people_reviewed: int = 0 + titles_considered: list[str] = Field(default_factory=list) + mailboxes_checked: int = 0 + validated_titles: list[ValidatedRunTitle] = Field(default_factory=list) + + +class ContactsRunEventsResponse(DiscolikeModel): + events: list[ContactsRunEvent] = Field(default_factory=list) + next_cursor: int = 0 + + class ContactsRun(DiscolikeModel): + error: str | None = None + companies: list[RunCompanyResponse] = Field(default_factory=list) + companies_total: int = 0 + summary: ContactsRunSummary | None = None input: dict[str, Any] = Field(default_factory=dict) id: str kind: Literal["contacts", "companies"] | str status: ContactsRunStatus - current_step: ContactsRunStepName + current_step: ContactsRunStepName | None = None generation: int steps: dict[str, ContactsRunStep] = Field(default_factory=dict) total_estimate_usd: Decimal | None = None @@ -93,6 +132,7 @@ class TitlePreviewTitle(DiscolikeModel): class TitlePreview(DiscolikeModel): + searched: list[str] = Field(default_factory=list) titles: list[TitlePreviewTitle] = Field(default_factory=list) @@ -100,36 +140,10 @@ def _path(run_id: str | UUID) -> str: return f"{RUN_PATH}/{UUID(str(run_id))}" -def _follow_request(run: ContactsRun, *, email_cap_usd: Decimal | float | None) -> ContinueRunRequest | None: - if run.kind != CONTACTS_KIND or run.current_step not in CONTACTS_STEP_ORDER: - return None - if run.status == "ready": - return ContinueRunRequest(generation=run.generation) - current = run.steps.get(run.current_step) - if current is not None and current.status == FAILED_STEP_STATUS: - return None - index = CONTACTS_STEP_ORDER.index(run.current_step) + 1 - if index >= len(CONTACTS_STEP_ORDER): - return None - if CONTACTS_STEP_ORDER[index] != FIND_EMAILS_STEP or ( - email_cap_usd is None and run.input.get(EMAIL_CAP_INPUT_KEY) is not None - ): - return ContinueRunRequest(generation=run.generation) - if email_cap_usd is None: - return None - return ContinueRunRequest(generation=run.generation, email_cap_usd=Decimal(str(email_cap_usd))) - - class ContactsRunResource(SyncAPIResource): @api_route("POST", RUN_PATH, openapi=False) def create(self, request: ContactsRunRequest) -> ContactsRun: - """Start a contacts run; it runs find_contacts -> find_missing -> review -> find_emails. - - With mode="step_by_step" (the default) the run pauses with status "waiting" after each step; - call continue_() to move on, or wait(follow=True). An optional email_cap_usd, given here or on - the continue that enters find_emails, caps what find_emails spends; without one it runs until - the account balance runs out. - """ + """Start automatic contact discovery and validated delivery, optionally capped by email_cap_usd.""" response = self._transport.request("POST", RUN_PATH, json_body=request.to_wire()) return ContactsRun.model_validate(response.json()) @@ -147,23 +161,33 @@ def get(self, run_id: str | UUID, params: ContactsRunGetParams | None = None) -> def rows(self, run_id: str | UUID, *, page_size: int = ROWS_PAGE_SIZE) -> builtins.list[ContactsRunRow]: """Every row of the run, fetched page by page.""" rows: builtins.list[ContactsRunRow] = [] + offset = 0 while True: - run = self.get(run_id, ContactsRunGetParams(rows_offset=len(rows), rows_limit=page_size)) - rows.extend(run.rows) - if not run.rows or len(rows) >= run.rows_total: - return rows + run = self.get(run_id, ContactsRunGetParams(rows_offset=offset, rows_limit=page_size)) + if run.kind == "contacts": + rows.extend(contact for company in run.companies for contact in company.contacts) + offset += len(run.companies) + if not run.companies or offset >= run.companies_total: + return rows + else: + rows.extend(run.rows) + offset += len(run.rows) + if not run.rows or offset >= run.rows_total: + return rows @api_route("POST", RUN_PATH + "/{run_id}/continue", openapi=False) def continue_(self, run_id: str | UUID, request: ContinueRunRequest) -> ContactsRun: - """Start the next step, or redo the current one with redo=True. - - Pass the run's current generation. ValidationError with status_code 409 means the run changed - since or its current step failed and needs redo=True; a 410 - (DiscolikeError) means the run sat idle past its expires_at. - """ + """Advance a companies run; contacts runs proceed automatically and return 409 here.""" response = self._transport.request("POST", _path(run_id) + "/continue", json_body=request.to_wire()) return ContactsRun.model_validate(response.json()) + @api_route("GET", RUN_PATH + "/{run_id}/events", openapi=False) + def events(self, run_id: str | UUID, params: ContactsRunEventsParams | None = None) -> ContactsRunEventsResponse: + response = self._transport.request( + "GET", _path(run_id) + "/events", params=params.to_wire() if params else None + ) + return ContactsRunEventsResponse.model_validate(response.json()) + @api_route("POST", RUN_PATH + "/{run_id}/cancel", openapi=False) def cancel(self, run_id: str | UUID) -> ContactsRun: response = self._transport.request("POST", _path(run_id) + "/cancel") @@ -179,28 +203,15 @@ def wait( self, run_id: str | UUID, *, - follow: bool = False, - email_cap_usd: Decimal | float | None = None, max_wait: float = DEFAULT_MAX_WAIT_SECONDS, poll_interval: float = DEFAULT_POLL_INTERVAL_SECONDS, ) -> ContactsRun: - """Poll until the run finishes or pauses (status "ready" or "waiting"). - - With follow=True a paused run is continued step by step instead, stopping at a failed step - (continue it with redo=True) and before find_emails unless the run was created with an - email_cap_usd or one is given here, which is sent on the continue that enters find_emails. - Timeout stops local polling only. - """ + """Poll until done, failed or cancelled. Timeout stops local polling only.""" deadline = _deadline(max_wait, poll_interval) while True: run = self.get(run_id) if run.status in TERMINAL_STATUSES: return run - if run.status in PAUSED_STATUSES: - request = _follow_request(run, email_cap_usd=email_cap_usd) if follow else None - if request is None: - return run - self.continue_(run_id, request) remaining = deadline - time.monotonic() if remaining <= 0: raise JobTimeoutError("Timed out waiting for the contacts run; it continues on the server") @@ -225,17 +236,34 @@ async def get(self, run_id: str | UUID, params: ContactsRunGetParams | None = No async def rows(self, run_id: str | UUID, *, page_size: int = ROWS_PAGE_SIZE) -> builtins.list[ContactsRunRow]: rows: builtins.list[ContactsRunRow] = [] + offset = 0 while True: - run = await self.get(run_id, ContactsRunGetParams(rows_offset=len(rows), rows_limit=page_size)) - rows.extend(run.rows) - if not run.rows or len(rows) >= run.rows_total: - return rows + run = await self.get(run_id, ContactsRunGetParams(rows_offset=offset, rows_limit=page_size)) + if run.kind == "contacts": + rows.extend(contact for company in run.companies for contact in company.contacts) + offset += len(run.companies) + if not run.companies or offset >= run.companies_total: + return rows + else: + rows.extend(run.rows) + offset += len(run.rows) + if not run.rows or offset >= run.rows_total: + return rows @api_route("POST", RUN_PATH + "/{run_id}/continue", openapi=False) async def continue_(self, run_id: str | UUID, request: ContinueRunRequest) -> ContactsRun: response = await self._transport.request("POST", _path(run_id) + "/continue", json_body=request.to_wire()) return ContactsRun.model_validate(response.json()) + @api_route("GET", RUN_PATH + "/{run_id}/events", openapi=False) + async def events( + self, run_id: str | UUID, params: ContactsRunEventsParams | None = None + ) -> ContactsRunEventsResponse: + response = await self._transport.request( + "GET", _path(run_id) + "/events", params=params.to_wire() if params else None + ) + return ContactsRunEventsResponse.model_validate(response.json()) + @api_route("POST", RUN_PATH + "/{run_id}/cancel", openapi=False) async def cancel(self, run_id: str | UUID) -> ContactsRun: response = await self._transport.request("POST", _path(run_id) + "/cancel") @@ -250,8 +278,6 @@ async def wait( self, run_id: str | UUID, *, - follow: bool = False, - email_cap_usd: Decimal | float | None = None, max_wait: float = DEFAULT_MAX_WAIT_SECONDS, poll_interval: float = DEFAULT_POLL_INTERVAL_SECONDS, ) -> ContactsRun: @@ -260,11 +286,6 @@ async def wait( run = await self.get(run_id) if run.status in TERMINAL_STATUSES: return run - if run.status in PAUSED_STATUSES: - request = _follow_request(run, email_cap_usd=email_cap_usd) if follow else None - if request is None: - return run - await self.continue_(run_id, request) remaining = deadline - time.monotonic() if remaining <= 0: raise JobTimeoutError("Timed out waiting for the contacts run; it continues on the server") diff --git a/packages/discolike/tests/test_contacts_run.py b/packages/discolike/tests/test_contacts_run.py index 53366f2..678823e 100644 --- a/packages/discolike/tests/test_contacts_run.py +++ b/packages/discolike/tests/test_contacts_run.py @@ -9,12 +9,16 @@ import pytest import discolike.resources.contacts_run as module +from discolike import InsufficientBalanceError +from discolike import JobTimeoutError +from discolike.requests import ContactsRunEventsParams from discolike.requests import ContactsRunGetParams from discolike.requests import ContactsRunListParams from discolike.requests import ContactsRunRequest from discolike.requests import ContinueRunRequest from discolike.requests import TitlePreviewRequest from discolike.resources.contacts_run import ContactsRun +from discolike.resources.contacts_run import ContactsRunEventsResponse from discolike_testkit import AsyncClientFactory from discolike_testkit import ClientFactory @@ -23,247 +27,244 @@ TIMESTAMP = "2026-10-30T12:00:00Z" -@pytest.fixture(autouse=True) -def no_sleep(monkeypatch: pytest.MonkeyPatch) -> None: - monkeypatch.setattr(module.time, "sleep", lambda seconds: None) - - async def fake_sleep(seconds: float) -> None: - return None - - monkeypatch.setattr(module.asyncio, "sleep", fake_sleep) - - -def run_payload( - *, - status: str = "waiting", - current_step: str = "find_contacts", - step_status: str = "done", - generation: int = 1, - rows: list[dict[str, Any]] | None = None, - rows_total: int = 0, -) -> dict[str, Any]: +def run_payload(status: str = "running", **extra: Any) -> dict[str, Any]: return { - "input": {"people": {"titles": ["Owner"]}}, "id": RUN_ID, "kind": "contacts", "status": status, - "current_step": current_step, - "generation": generation, - "steps": {current_step: {"status": step_status, "estimate_usd": "1.50", "charged_usd": "0.75"}}, - "total_estimate_usd": "1.50", - "total_charged_usd": "0.75", + "generation": 1, "created_at": TIMESTAMP, "updated_at": TIMESTAMP, - "expires_at": "2026-11-29T12:00:00Z", - "rows": rows or [], - "rows_total": rows_total, - "future_field": True, + "expires_at": TIMESTAMP, + **extra, } -def row(key: str) -> dict[str, Any]: - return {"row_key": key, "domain": "acme.com", "name": "Jane Doe", "source": "database"} - - def request() -> ContactsRunRequest: return ContactsRunRequest.model_validate( - {"companies": {"source": "domains", "domains": ["acme.com"]}, "people": {"titles": ["Owner"]}} + { + "companies": {"source": "domains", "domains": ["acme.com"]}, + "people": {"fit": "Owners"}, + } ) -class StepServer: - """Fake run server: each continue enters the next step, which finishes and waits.""" - - order = ("find_contacts", "find_missing", "review", "find_emails") - - def __init__(self) -> None: - self.step = 0 - self.generation = 1 - self.continues: list[dict[str, Any]] = [] +def test_create_wire_and_open_response(make_client: ClientFactory) -> None: + seen: list[httpx2.Request] = [] - def __call__(self, request: httpx2.Request) -> httpx2.Response: - if request.method == "POST" and request.url.path.endswith("/continue"): - self.continues.append(json.loads(request.content)) - self.step += 1 - self.generation += 1 - last = self.step == len(self.order) - 1 + def handler(req: httpx2.Request) -> httpx2.Response: + seen.append(req) return httpx2.Response( - 200, + 202, json=run_payload( - status="done" if last else "waiting", - current_step=self.order[self.step], - generation=self.generation, + companies=[ + { + "domain": "Company 1", + "status": "future", + "future": True, + "contacts": [ + {"row_key": "r1", "domain": "Company 1", "email_status": "Validated (catch-all resolved)"}, + ], + } + ], + companies_total=1, + summary={"companies_delivered": 1, "validated_contacts": 1, "spent_usd": "0.10"}, + future=True, ), ) - -def test_create_posts_body_and_parses_run(make_client: ClientFactory) -> None: - seen: list[httpx2.Request] = [] - - def handler(request: httpx2.Request) -> httpx2.Response: - seen.append(request) - return httpx2.Response(202, json=run_payload(status="running", step_status="running")) - with make_client(handler) as client: run = client.contacts_run.create(request()) - - assert seen[0].method == "POST" assert seen[0].url.path == "/v1/contacts/run" - assert json.loads(seen[0].content) == { - "companies": {"source": "domains", "domains": ["acme.com"]}, - "people": {"titles": ["Owner"]}, - } - assert isinstance(run, ContactsRun) - assert run.status == "running" - assert run.steps["find_contacts"].estimate_usd == Decimal("1.50") - assert run.total_charged_usd == Decimal("0.75") - assert run.model_extra == {"future_field": True} - - -def test_max_contacts_and_auto_stop_sent_only_when_set() -> None: - assert request().to_wire().keys() == {"companies", "people"} - body = ContactsRunRequest.model_validate(request().to_wire() | {"max_contacts": 50, "auto_stop": False}) - assert body.to_wire() == request().to_wire() | {"max_contacts": 50, "auto_stop": False} - - -def test_max_contacts_out_of_range_rejected() -> None: + assert json.loads(seen[0].content) == request().to_wire() + assert run.current_step is None + assert run.companies[0].contacts[0].source is None + assert run.companies[0].contacts[0].email_status == "Validated (catch-all resolved)" + assert run.summary is not None + assert run.summary.spent_usd == Decimal("0.10") + assert run.model_extra == {"future": True} + assert run.companies[0].model_extra == {"future": True} + + +@pytest.mark.parametrize( + ("field", "value"), + [ + ("start", True), + ("max_contacts", 5), + ("auto_stop", False), + ("find_missing", {}), + ("mode", "run_all"), + ], +) +def test_removed_fields_rejected(field: str, value: Any) -> None: with pytest.raises(pydantic.ValidationError): - ContactsRunRequest.model_validate( - { - "companies": {"source": "domains", "domains": ["acme.com"]}, - "people": {"titles": ["Owner"]}, - "max_contacts": 0, - } - ) + ContactsRunRequest.model_validate(request().to_wire() | {field: value}) -def test_prompt_companies_default_to_500() -> None: +def test_request_defaults_and_supported_fields() -> None: body = ContactsRunRequest.model_validate( - {"companies": {"source": "prompt", "icp_text": "dental clinics"}, "people": {"titles": ["Owner"]}} + { + "companies": {"source": "prompt", "filters": {"filter_country": ["US"]}}, + "max_companies": 15, + "per_company": 3, + "email_cap_usd": "12.50", + "create_key": "key", + "exclude_domains": ["acme.com"], + "exclude_query_ids": ["query"], + "relevance": "max_coverage", + } ) - assert body.companies.max_companies == 500 # ty: ignore[unresolved-attribute] - - -def test_continue_sends_generation(make_client: ClientFactory) -> None: - seen: list[httpx2.Request] = [] - - def handler(request: httpx2.Request) -> httpx2.Response: - seen.append(request) - return httpx2.Response(202, json=run_payload(status="running", current_step="find_missing", generation=3)) - - with make_client(handler) as client: - run = client.contacts_run.continue_(RUN_ID, ContinueRunRequest(generation=2, relevance="max_coverage")) - - assert seen[0].url.path == f"{RUN_PATH}/continue" - assert json.loads(seen[0].content) == {"generation": 2, "relevance": "max_coverage"} - assert run.generation == 3 + assert body.people.fit == "Senior executives and decision makers" + assert body.to_wire()["max_companies"] == 15 + assert "people" not in body.to_wire() + with pytest.raises(pydantic.ValidationError): + ContactsRunRequest.model_validate(request().to_wire() | {"people": {"titles": ["Owner"]}}) + with pytest.raises(pydantic.ValidationError): + ContactsRunRequest.model_validate(request().to_wire() | {"per_company": "all"}) + assert ContactsRunRequest.model_validate({"companies": {"source": "lookalike", "persona_ids": [1]}}) -def test_get_list_cancel_and_title_preview_routes(make_client: ClientFactory) -> None: - seen: list[tuple[str, str, str]] = [] +def test_live_routes(make_client: ClientFactory) -> None: + seen: list[tuple[str, str, dict[str, Any]]] = [] - def handler(request: httpx2.Request) -> httpx2.Response: - seen.append((request.method, request.url.path, request.url.query.decode())) - if request.url.path.endswith("title-preview"): - return httpx2.Response(200, json={"titles": [{"title": "Owner", "role": 0.9, "count": 12}]}) - if request.url.path == "/v1/contacts/run": - return httpx2.Response(200, json=[run_payload()]) - return httpx2.Response(200, json=run_payload(status="cancelled")) + def handler(req: httpx2.Request) -> httpx2.Response: + seen.append((req.method, req.url.path, json.loads(req.content) if req.content else dict(req.url.params))) + if req.url.path.endswith("title-preview"): + return httpx2.Response(200, json={"titles": [], "searched": ["Owner"]}) + if req.url.path.endswith("events"): + return httpx2.Response(200, json={"events": [], "next_cursor": 5}) + return httpx2.Response(200, json=[run_payload()] if req.url.path == "/v1/contacts/run" else run_payload()) with make_client(handler) as client: - client.contacts_run.get(RUN_ID, ContactsRunGetParams(rows_offset=100, rows_limit=50)) + client.contacts_run.get(RUN_ID, ContactsRunGetParams(rows_offset=2, rows_limit=3)) client.contacts_run.list(ContactsRunListParams(limit=5)) client.contacts_run.cancel(RUN_ID) - preview = client.contacts_run.title_preview(TitlePreviewRequest(titles=["Owner"], match="similar")) - + client.contacts_run.continue_(RUN_ID, ContinueRunRequest(generation=2, redo=True)) + client.contacts_run.title_preview(TitlePreviewRequest(titles=["Owner"], match="similar", relevance="balanced")) + assert client.contacts_run.events(RUN_ID, ContactsRunEventsParams(after=4, limit=10)).next_cursor == 5 assert seen == [ - ("GET", RUN_PATH, "rows_offset=100&rows_limit=50"), - ("GET", "/v1/contacts/run", "limit=5"), - ("POST", f"{RUN_PATH}/cancel", ""), - ("POST", "/v1/contacts/run/title-preview", ""), + ("GET", RUN_PATH, {"rows_offset": "2", "rows_limit": "3"}), + ("GET", "/v1/contacts/run", {"limit": "5"}), + ("POST", RUN_PATH + "/cancel", {}), + ("POST", RUN_PATH + "/continue", {"generation": 2, "redo": True}), + ("POST", "/v1/contacts/run/title-preview", {"titles": ["Owner"], "match": "similar", "relevance": "balanced"}), + ("GET", RUN_PATH + "/events", {"after": "4", "limit": "10"}), ] - assert preview.titles[0].count == 12 - - -def test_wait_without_follow_returns_paused_run(make_client: ClientFactory) -> None: - server = StepServer() - with make_client(server) as client: - run = client.contacts_run.wait(RUN_ID) - assert run.status == "waiting" - assert server.continues == [] - -def test_wait_follow_stops_before_emails_without_cap(make_client: ClientFactory) -> None: - server = StepServer() - with make_client(server) as client: - run = client.contacts_run.wait(RUN_ID, follow=True) - assert run.status == "waiting" - assert run.current_step == "review" - assert server.continues == [{"generation": 1}, {"generation": 2}] - -def test_wait_follow_continues_into_emails_with_cap(make_client: ClientFactory) -> None: - server = StepServer() - with make_client(server) as client: - run = client.contacts_run.wait(RUN_ID, follow=True, email_cap_usd=25) - assert run.status == "done" - assert run.current_step == "find_emails" - assert server.continues == [{"generation": 1}, {"generation": 2}, {"generation": 3, "email_cap_usd": "25"}] - - -def test_wait_follow_enters_emails_with_the_cap_set_at_create(make_client: ClientFactory) -> None: - server = StepServer() - - def handler(request: httpx2.Request) -> httpx2.Response: - response = server(request) - return httpx2.Response(200, json={**response.json(), "input": {"email_cap_usd": "30"}}) - - with make_client(handler) as client: - run = client.contacts_run.wait(RUN_ID, follow=True) - assert run.status == "done" - assert server.continues == [{"generation": 1}, {"generation": 2}, {"generation": 3}] +def test_open_events() -> None: + page = ContactsRunEventsResponse.model_validate( + { + "next_cursor": 1, + "future": True, + "events": [ + { + "seq": 1, + "domain": "Company 1", + "created_at": TIMESTAMP, + "kind": "future", + "status": "future", + "validated_titles": [{"title": "Owner", "status": "future", "future": True}], + "future": True, + } + ], + } + ) + assert page.events[0].validated_titles[0].status == "future" + assert ContactsRun.model_validate( + run_payload("future", current_step="future", steps={"future": {"status": "future"}}) + ) -def test_wait_follow_stops_at_a_failed_step(make_client: ClientFactory) -> None: - seen: list[str] = [] +@pytest.mark.parametrize("terminal", ["done", "failed", "cancelled"]) +def test_wait_only_polls(make_client: ClientFactory, monkeypatch: pytest.MonkeyPatch, terminal: str) -> None: + monkeypatch.setattr(module.time, "sleep", lambda _: None) + statuses = iter(["ready", "running", terminal]) - def handler(request: httpx2.Request) -> httpx2.Response: - seen.append(request.method) - return httpx2.Response(200, json=run_payload(step_status="failed")) + def handler(req: httpx2.Request) -> httpx2.Response: + assert req.method == "GET" + return httpx2.Response(200, json=run_payload(next(statuses))) with make_client(handler) as client: - run = client.contacts_run.wait(RUN_ID, follow=True, email_cap_usd=25) - assert run.steps["find_contacts"].status == "failed" - assert seen == ["GET"] + assert client.contacts_run.wait(RUN_ID).status == terminal + + +def test_wait_timeout(make_client: ClientFactory, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr(module, "_deadline", lambda *_: 0) + with make_client(lambda _: httpx2.Response(200, json=run_payload())) as client: + with pytest.raises(JobTimeoutError): + client.contacts_run.wait(RUN_ID) + + +def company_page(req: httpx2.Request) -> httpx2.Response: + offset = int(req.url.params["rows_offset"]) + pages = [ + {"domain": "Company 1", "status": "skipped", "contacts": []}, + { + "domain": "Company 2", + "status": "validated", + "contacts": [ + {"row_key": "a", "domain": "Company 2"}, + {"row_key": "b", "domain": "Company 2"}, + ], + }, + {"domain": "Company 3", "status": "validated", "contacts": [{"row_key": "c", "domain": "Company 3"}]}, + ] + assert req.url.params["rows_limit"] == "1" + return httpx2.Response(200, json=run_payload(companies=pages[offset : offset + 1], companies_total=3, rows_total=3)) -def test_rows_pages_until_total(make_client: ClientFactory) -> None: - offsets: list[str | None] = [] - all_rows = [row(f"r{i}") for i in range(5)] +def test_rows_pages_domains_including_empty_companies(make_client: ClientFactory) -> None: + with make_client(company_page) as client: + assert [r.row_key for r in client.contacts_run.rows(RUN_ID, page_size=1)] == ["a", "b", "c"] - def handler(request: httpx2.Request) -> httpx2.Response: - offset = int(request.url.params["rows_offset"]) - limit = int(request.url.params["rows_limit"]) - offsets.append(request.url.params["rows_offset"]) - return httpx2.Response(200, json=run_payload(rows=all_rows[offset : offset + limit], rows_total=5)) - with make_client(handler) as client: - rows = client.contacts_run.rows(RUN_ID, page_size=2) +async def test_async_rows(make_async_client: AsyncClientFactory) -> None: + async with make_async_client(company_page) as client: + assert [r.row_key for r in await client.contacts_run.rows(RUN_ID, page_size=1)] == ["a", "b", "c"] - assert [item.row_key for item in rows] == ["r0", "r1", "r2", "r3", "r4"] - assert offsets == ["0", "2", "4"] +async def test_async_create_wait_events_cancel( + make_async_client: AsyncClientFactory, monkeypatch: pytest.MonkeyPatch +) -> None: + async def no_sleep(_: float) -> None: + pass -async def test_async_create_and_wait_follow(make_async_client: AsyncClientFactory) -> None: - server = StepServer() + monkeypatch.setattr(module.asyncio, "sleep", no_sleep) + statuses = iter(["ready", "running", "done"]) - def handler(request: httpx2.Request) -> httpx2.Response: - if request.url.path == "/v1/contacts/run": - return httpx2.Response(202, json=run_payload(status="running", step_status="running")) - return server(request) + def handler(req: httpx2.Request) -> httpx2.Response: + if req.url.path.endswith("events"): + assert req.url.params["after"] == "5" + return httpx2.Response(200, json={"events": [], "next_cursor": 6}) + if req.url.path.endswith("cancel"): + return httpx2.Response(200, json=run_payload("cancelled")) + if req.method == "POST": + assert json.loads(req.content) == request().to_wire() + return httpx2.Response(202, json=run_payload()) + return httpx2.Response(200, json=run_payload(next(statuses))) async with make_async_client(handler) as client: created = await client.contacts_run.create(request()) - run = await client.contacts_run.wait(created.id, follow=True) - assert run.current_step == "review" - assert server.continues == [{"generation": 1}, {"generation": 2}] + assert (await client.contacts_run.wait(created.id)).status == "done" + assert (await client.contacts_run.events(created.id, ContactsRunEventsParams(after=5))).next_cursor == 6 + assert (await client.contacts_run.cancel(created.id)).status == "cancelled" + + +def test_insufficient_balance(make_client: ClientFactory) -> None: + with make_client( + lambda _: httpx2.Response( + 403, + json={ + "detail": { + "code": "INSUFFICIENT_BALANCE", + "message": "Too expensive", + "remaining_usd": "1.00", + "max_affordable_records": 2, + } + }, + ) + ) as client: + with pytest.raises(InsufficientBalanceError) as exc: + client.contacts_run.create(request()) + assert exc.value.max_affordable_records == 2 diff --git a/scripts/check_contract.py b/scripts/check_contract.py index 715a96f..622fb54 100644 --- a/scripts/check_contract.py +++ b/scripts/check_contract.py @@ -28,10 +28,15 @@ from discolike.resources.companies import Subsidiary from discolike.resources.companies import Vendor from discolike.resources.contacts_run import ContactsRun +from discolike.resources.contacts_run import ContactsRunEvent +from discolike.resources.contacts_run import ContactsRunEventsResponse from discolike.resources.contacts_run import ContactsRunRow from discolike.resources.contacts_run import ContactsRunStep +from discolike.resources.contacts_run import ContactsRunSummary +from discolike.resources.contacts_run import RunCompanyResponse from discolike.resources.contacts_run import TitlePreview from discolike.resources.contacts_run import TitlePreviewTitle +from discolike.resources.contacts_run import ValidatedRunTitle from discolike.resources.match import MatchResponse from discolike.resources.prospecting import ProspectingEvent from discolike.resources.prospecting import ProspectingInFlight @@ -69,6 +74,12 @@ # Schemas of routes the platform hides from its public spec: checked only when the loaded spec carries them. HIDDEN_MIRRORED_SCHEMAS: dict[str, type[DiscolikeModel]] = { "ContactsRunResponse": ContactsRun, + "ContactsRunEvent": ContactsRunEvent, + "ContactsRunEventsResponse": ContactsRunEventsResponse, + "ContactsRunSummary": ContactsRunSummary, + "RunCompanyResponse": RunCompanyResponse, + "ValidatedRunTitle": ValidatedRunTitle, + "RunStepResponse": ContactsRunStep, "StepState": ContactsRunStep, "RunRow": ContactsRunRow, "TitlePreviewResponse": TitlePreview, From b7aa1746d914667527fa8f2e81ed2f8a8d769416 Mon Sep 17 00:00:00 2001 From: Daniel Yudelevich Date: Wed, 4 Nov 2026 17:21:06 -0800 Subject: [PATCH 3/6] fix(contacts): keep count, warn on every deprecated call contacts.count stays supported, so it no longer warns. Deprecation warnings now come from the server's headers on every call and are visible by default, so callers cannot miss a sunset. --- CHANGELOG.md | 4 ++-- README.md | 2 +- .../discolike-cli/src/discolike_cli/contacts.py | 1 - .../tests/test_contacts_run_cli.py | 9 ++++----- packages/discolike/src/discolike/__init__.py | 2 ++ packages/discolike/src/discolike/_transport.py | 16 +++++++++------- .../src/discolike/resources/contacts.py | 15 --------------- packages/discolike/tests/test_contacts.py | 17 +++++++++-------- packages/discolike/tests/test_transport.py | 12 ++++++------ 9 files changed, 33 insertions(+), 45 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 327e10d..d4cd480 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,9 +4,9 @@ - SDK/CLI: `client.contacts_run` (sync and async) starts automatic validated contact delivery from prompt, domain or lookalike companies and `people.fit`. Requests support top-level `max_companies`, `per_company`, `relevance`, `email_cap_usd`, exclusions and `create_key`. Removed unreleased step mode, title chips, `start`, `max_contacts`, `auto_stop`, `find_missing`, continuation overrides and wait follow/cap flags. `wait()` polls until done, failed or cancelled. Responses include open company progress, validated email labels, cursor events and summary totals; `rows()` pages company domains. CLI adds `contacts run create|list|get|events|cancel|wait`; live `continue` is companies-only, and `title-preview` remains a separate live route. Creation propagates typed `InsufficientBalanceError` for 403 INSUFFICIENT_BALANCE. No contacts_run deprecation window applies because this surface is unreleased. - SDK/CLI: `FindEmailRequest.suggested_email` sends an address you think is right: it is verified first, and the right one is recovered if it is not proven. It must be at `domain` or the company's mail domain, else 422. It works on `email.find` and on each request of `email.find_batch`; the CLI's `email find` gains `--suggested-email`. `EmailJobResult` gains `outcome` (`valid`, `valid_catch_all`, `recovered`, `pattern_inferred`, `not_found`, `unknown`, open) and `email`, read from `job.status()` or batch results, and `EnumerationOutput` / `ValidationOutput` gain `method` (how the address was proven). All are optional and need an API release that sends them. -- SDK/CLI: `contacts.search`, `contacts.count` and `contacts.generate` are deprecated in favor of `client.contacts_run`; the API marks their endpoints deprecated with a 2026-12-31 sunset. They keep working and raise a `DeprecationWarning`; the CLI's `contacts search`, `count` and `generate` print a deprecation line on stderr after their output. `contacts.discover`, `lookup`, `match` and `bulk_match` are unchanged. +- SDK/CLI: `contacts.search` and `contacts.generate` are deprecated in favor of `client.contacts_run`; the API marks their endpoints deprecated with a 2026-12-31 sunset. They keep working and raise a `DeprecationWarning`; the CLI's `contacts search` and `generate` print a deprecation line on stderr after their output. `contacts.discover`, `lookup`, `match` and `bulk_match` are unchanged. - SDK (note for maintainers): Contacts run request models live in `discolike/_contacts_run_requests.py`, kept in sync with platform source; the routes retain `openapi=False` for older specs, and `scripts/check_contract.py` checks the run response models only when the spec carries them. `suggested_email` reaches `_generated/requests.py` through `PENDING_PROPERTIES` until the dev spec has it; the regeneration also picks up the dev spec's new site languages. -- SDK: sync and async transports emit a `DeprecationWarning` only when a response carries `Deprecation`, once per HTTP method and path template per process across clients and threads. Warnings include `Sunset` and `Link` when present; header values are kept as text, including malformed values. Email submission, reattachment and polling no longer emit hardcoded deprecation warnings. +- SDK: sync and async transports emit a visible `DiscolikeDeprecationWarning`, a `DeprecationWarning` subclass, on every call whose response carries `Deprecation`. Warnings include `Sunset` and `Link` when present; header values are kept as text, including malformed values. Email submission, reattachment and polling no longer emit hardcoded deprecation warnings. ## 0.6.2 (2026-10-21) diff --git a/README.md b/README.md index 6576161..9041ec7 100644 --- a/README.md +++ b/README.md @@ -339,7 +339,7 @@ Runs start automatically. `cancel()` stops unfinished work; `wait()` only polls CLI: `discolike contacts run create|list|get|events|cancel|wait|continue|title-preview`. Create reads `--params-file` plus optional `--email-cap-usd`; `wait` takes `--timeout`; `get --all-rows` fetches all contacts. `get --rows-offset` and `--rows-limit` count company domains for contacts runs. `continue` only advances companies runs and accepts `--generation` and `--redo`. -`contacts.search`, `contacts.count` and `contacts.generate` are deprecated in favor of contacts runs; their endpoints sunset on 2026-12-31. +`contacts.search` and `contacts.generate` are deprecated in favor of contacts runs; their endpoints sunset on 2026-12-31. ### Contact generation without an LLM key diff --git a/packages/discolike-cli/src/discolike_cli/contacts.py b/packages/discolike-cli/src/discolike_cli/contacts.py index 3268247..1b19ec1 100644 --- a/packages/discolike-cli/src/discolike_cli/contacts.py +++ b/packages/discolike-cli/src/discolike_cli/contacts.py @@ -280,7 +280,6 @@ def count_command( consensus=consensus, ) emit(get_client(ctx).contacts.count(build_request(ContactsCountParams, kwargs)), fmt=fmt) - typer.echo(DEPRECATED_COMMAND_WARNING.format(command="count"), err=True) @app.command("lookup") diff --git a/packages/discolike-cli/tests/test_contacts_run_cli.py b/packages/discolike-cli/tests/test_contacts_run_cli.py index ba53763..db01d1f 100644 --- a/packages/discolike-cli/tests/test_contacts_run_cli.py +++ b/packages/discolike-cli/tests/test_contacts_run_cli.py @@ -156,12 +156,11 @@ def handler(request: httpx2.Request) -> httpx2.Response: assert json.loads(result.stdout) == [{"title": "Owner", "role": 1.0, "count": 3}] -@pytest.mark.parametrize("command", ["search", "count"]) -def test_old_contacts_commands_print_deprecation(install_build_client: Callable[[Handler], None], command: str) -> None: - install_build_client(lambda request: httpx2.Response(200, json=[] if command == "search" else {"count": 0})) - result = runner.invoke(app, ["contacts", command, "--name", "Jane"]) +def test_old_contacts_commands_print_deprecation(install_build_client: Callable[[Handler], None]) -> None: + install_build_client(lambda request: httpx2.Response(200, json=[])) + result = runner.invoke(app, ["contacts", "search", "--name", "Jane"]) assert result.exit_code == 0, result.output - assert f"`discolike contacts {command}` is deprecated" in result.stderr + assert "`discolike contacts search` is deprecated" in result.stderr assert "use `discolike contacts run`" in result.stderr diff --git a/packages/discolike/src/discolike/__init__.py b/packages/discolike/src/discolike/__init__.py index c5c1efd..35f1cde 100644 --- a/packages/discolike/src/discolike/__init__.py +++ b/packages/discolike/src/discolike/__init__.py @@ -18,6 +18,7 @@ from discolike._jobs import JobStatus from discolike._models import DiscolikeModel from discolike._models import DiscolikeRequest +from discolike._transport import DiscolikeDeprecationWarning from discolike._version import __version__ from discolike.resources.contacts import NATIVE_ENGINE from discolike.resources.discogen import NATIVE_ICP_ENGINE @@ -45,6 +46,7 @@ "Company", "Count", "Discolike", + "DiscolikeDeprecationWarning", "DiscolikeError", "DiscolikeModel", "DiscolikeRequest", diff --git a/packages/discolike/src/discolike/_transport.py b/packages/discolike/src/discolike/_transport.py index 16f913b..e962131 100644 --- a/packages/discolike/src/discolike/_transport.py +++ b/packages/discolike/src/discolike/_transport.py @@ -21,9 +21,15 @@ BACKOFF_BASE_SECONDS = 0.5 +class DiscolikeDeprecationWarning(DeprecationWarning): + pass + + +# Python hides DeprecationWarning outside __main__ and repeats none by default. +warnings.filterwarnings("always", category=DiscolikeDeprecationWarning) + # Shared by all transport instances, including sync/async clients and timeout views. _deprecation_lock = threading.Lock() -_warned_endpoints: set[tuple[str, str]] = set() _route_templates: set[tuple[str, str]] = set() @@ -43,7 +49,7 @@ def _path_template(method: str, path: str) -> str: matches.append(template) if matches: return min(matches, key=lambda template: (template.count("{"), template)) - # Undeclared/new routes still deduplicate numeric and UUID identifiers. + # Undeclared/new routes still normalize numeric and UUID identifiers. return re.sub( r"(?<=/)(?:[0-9]+|[0-9a-fA-F]{8}(?:-[0-9a-fA-F]{4}){3}-[0-9a-fA-F]{12})(?=/|$)", "{id}", @@ -57,16 +63,12 @@ def _warn_deprecated(response: httpx2.Response, method: str, path: str) -> None: method = method.upper() with _deprecation_lock: template = _path_template(method, path) - endpoint = (method, template) - if endpoint in _warned_endpoints: - return - _warned_endpoints.add(endpoint) # Header presence is the signal. Keep values opaque, so malformed dates, # structured fields or links cannot break an otherwise valid response. details = "; ".join( f"{name}: {response.headers[name]}" for name in ("Deprecation", "Sunset", "Link") if name in response.headers ) - warnings.warn(f"{method} {template} is deprecated ({details})", DeprecationWarning, stacklevel=3) + warnings.warn(f"{method} {template} is deprecated ({details})", DiscolikeDeprecationWarning, stacklevel=3) def drop_none(params: Mapping[str, Any] | None) -> dict[str, Any]: diff --git a/packages/discolike/src/discolike/resources/contacts.py b/packages/discolike/src/discolike/resources/contacts.py index 24c9959..d284397 100644 --- a/packages/discolike/src/discolike/resources/contacts.py +++ b/packages/discolike/src/discolike/resources/contacts.py @@ -1,7 +1,5 @@ from __future__ import annotations -import warnings - import pydantic from discolike._jobs import FAMILY_CONTACTMATCH @@ -23,13 +21,6 @@ from discolike.resources.discovery import Count NATIVE_ENGINE = "native" -DEPRECATED_SEARCH_MESSAGE = ( - "contacts.{method}() is deprecated and its endpoint sunsets on 2026-12-31; use client.contacts_run" -) - - -def _warn_deprecated(method: str) -> None: - warnings.warn(DEPRECATED_SEARCH_MESSAGE.format(method=method), DeprecationWarning, stacklevel=3) class Contact(DiscolikeModel): @@ -77,13 +68,11 @@ class ContactsDiscoverResponse(DiscolikeModel): class ContactsResource(SyncAPIResource): @api_route("GET", "/contacts") def search(self, params: ContactsSearchParams) -> list[Contact]: - _warn_deprecated("search") response = self._transport.request("GET", "/contacts", params=params.to_wire()) return [Contact.model_validate(item) for item in response.json()] @api_route("GET", "/contacts/count") def count(self, params: ContactsCountParams) -> Count: - _warn_deprecated("count") return Count.model_validate(self._transport.request("GET", "/contacts/count", params=params.to_wire()).json()) @api_route("GET", "/contacts/lookup") @@ -109,7 +98,6 @@ def discover(self, request: ContactFilters) -> ContactsDiscoverResponse: @api_route("POST", "/contacts/discover/generate") def generate(self, request: ContactGenerateRequest) -> Job: - _warn_deprecated("generate") response = self._transport.request("POST", "/contacts/discover/generate", json_body=request.to_wire()) return Job(self._transport, task_family=FAMILY_DISCOGEN, task_id=response.json()["task_id"]) @@ -117,13 +105,11 @@ def generate(self, request: ContactGenerateRequest) -> Job: class AsyncContactsResource(AsyncAPIResource): @api_route("GET", "/contacts") async def search(self, params: ContactsSearchParams) -> list[Contact]: - _warn_deprecated("search") response = await self._transport.request("GET", "/contacts", params=params.to_wire()) return [Contact.model_validate(item) for item in response.json()] @api_route("GET", "/contacts/count") async def count(self, params: ContactsCountParams) -> Count: - _warn_deprecated("count") response = await self._transport.request("GET", "/contacts/count", params=params.to_wire()) return Count.model_validate(response.json()) @@ -149,6 +135,5 @@ async def discover(self, request: ContactFilters) -> ContactsDiscoverResponse: @api_route("POST", "/contacts/discover/generate") async def generate(self, request: ContactGenerateRequest) -> AsyncJob: - _warn_deprecated("generate") response = await self._transport.request("POST", "/contacts/discover/generate", json_body=request.to_wire()) return AsyncJob(self._transport, task_family=FAMILY_DISCOGEN, task_id=response.json()["task_id"]) diff --git a/packages/discolike/tests/test_contacts.py b/packages/discolike/tests/test_contacts.py index 5de22ad..bd7d962 100644 --- a/packages/discolike/tests/test_contacts.py +++ b/packages/discolike/tests/test_contacts.py @@ -21,8 +21,6 @@ from discolike_testkit import AsyncClientFactory from discolike_testkit import ClientFactory -pytestmark = pytest.mark.filterwarnings("ignore:contacts\\.\\w+\\(\\) is deprecated:DeprecationWarning") - def test_search_builds_query_and_parses_list(make_client: ClientFactory) -> None: seen = {} @@ -351,22 +349,25 @@ def test_route_metadata_stamped() -> None: ("method", "request_model"), [ ("search", ContactsSearchParams(domain=["a.com"])), - ("count", ContactsCountParams(domain=["a.com"])), ("generate", ContactGenerateRequest(icp_text="VPs", domains=["a.com"])), ], ) def test_old_search_methods_warn_and_still_work(make_client: ClientFactory, method: str, request_model: object) -> None: def handler(request: httpx2.Request) -> httpx2.Response: - return httpx2.Response(200, json=[] if method == "search" else {"count": 1, "task_id": "t-1"}) + return httpx2.Response( + 200, json=[] if method == "search" else {"count": 1, "task_id": "t-1"}, headers={"Deprecation": "true"} + ) - with make_client(handler) as client, pytest.warns(DeprecationWarning, match="use client.contacts_run") as caught: + with make_client(handler) as client, pytest.warns(DeprecationWarning, match="is deprecated"): getattr(client.contacts, method)(request_model) - assert caught[0].filename == __file__ async def test_old_search_methods_warn_async(make_async_client: AsyncClientFactory) -> None: - async with make_async_client(lambda request: httpx2.Response(200, json=[])) as client: - with pytest.warns(DeprecationWarning, match=r"contacts\.search\(\) is deprecated"): + def handler(request: httpx2.Request) -> httpx2.Response: + return httpx2.Response(200, json=[], headers={"Deprecation": "true"}) + + async with make_async_client(handler) as client: + with pytest.warns(DeprecationWarning, match="is deprecated"): await client.contacts.search(ContactsSearchParams(domain=["a.com"])) diff --git a/packages/discolike/tests/test_transport.py b/packages/discolike/tests/test_transport.py index 9b05013..3840cb6 100644 --- a/packages/discolike/tests/test_transport.py +++ b/packages/discolike/tests/test_transport.py @@ -15,7 +15,6 @@ @pytest.fixture(autouse=True) def no_sleep(monkeypatch): - monkeypatch.setattr(transport_module, "_warned_endpoints", set()) sleeps: list[float] = [] monkeypatch.setattr(transport_module.time, "sleep", sleeps.append) @@ -280,7 +279,7 @@ def handler(request: httpx2.Request) -> httpx2.Response: @pytest.mark.parametrize("use_async", [False, True]) -async def test_deprecation_once_per_method_and_template_across_clients(use_async) -> None: +async def test_deprecation_warns_on_every_call_across_clients(use_async) -> None: headers = { "Deprecation": "@1793664000", "Sunset": "Wed, 03 Nov 2027 00:00:00 GMT", @@ -308,13 +307,13 @@ def handler(request: httpx2.Request) -> httpx2.Response: transport = make_transport(handler) transport.with_timeout(1.0).request("GET", path) transport.close() - assert len(caught) == 1 + assert len(caught) == 3 transport = make_transport(handler) transport.request("GET", "/email/jobs/789") transport.request("POST", "/email/jobs/123") transport.request("GET", "/usage") transport.close() - assert len(caught) == 3 + assert len(caught) == 6 message = str(caught[0].message) assert "GET /email/jobs/{job_id}" in message for name, value in headers.items(): @@ -366,7 +365,7 @@ def request(job_id: int) -> None: warnings.simplefilter("always", DeprecationWarning) with ThreadPoolExecutor(max_workers=8) as pool: list(pool.map(request, range(32))) - assert len(caught) == 1 + assert len(caught) == 32 @pytest.mark.parametrize( @@ -384,4 +383,5 @@ def test_deprecation_normalizes_identifiers_and_query_strings(paths) -> None: for path in paths: transport.request("GET", path) transport.close() - assert len(caught) == 1 + assert len(caught) == 2 + assert len({str(warning.message) for warning in caught}) == 1 From 1d36929a503ca4d47910a4b6b0253ae38e2aa5b9 Mon Sep 17 00:00:00 2001 From: Daniel Yudelevich Date: Mon, 9 Nov 2026 17:53:23 -0800 Subject: [PATCH 4/6] feat(sdk): match the development API spec The contract check against the platform's development spec failed on the contacts run models, which still carried flat rows and internal step fields the API stopped sending and lacked the progress, totals, filters, email state and stop reason it now returns, and on discover and count, which did not know valid_mx. Regenerating the request models from that spec also picks up the larger server default for max_records and clears suggested_email from PENDING_PROPERTIES now that the spec carries it. ExtractResult.language stays for #55. --- CHANGELOG.md | 6 +- .../discolike-cli/src/discolike_cli/bulk.py | 3 + .../src/discolike_cli/discover.py | 5 + packages/discolike-cli/tests/test_bulk_cli.py | 13 + packages/discolike-cli/tests/test_discover.py | 18 + .../src/discolike/_generated/requests.py | 357 +++--------------- .../src/discolike/resources/contacts_run.py | 56 +-- packages/discolike/tests/test_contacts_run.py | 73 +++- packages/discolike/tests/test_prospecting.py | 2 +- .../discolike/tests/test_requests_contract.py | 11 + scripts/gen_requests.py | 13 +- 11 files changed, 195 insertions(+), 362 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d4cd480..89a7b18 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,10 +2,12 @@ ## Unreleased -- SDK/CLI: `client.contacts_run` (sync and async) starts automatic validated contact delivery from prompt, domain or lookalike companies and `people.fit`. Requests support top-level `max_companies`, `per_company`, `relevance`, `email_cap_usd`, exclusions and `create_key`. Removed unreleased step mode, title chips, `start`, `max_contacts`, `auto_stop`, `find_missing`, continuation overrides and wait follow/cap flags. `wait()` polls until done, failed or cancelled. Responses include open company progress, validated email labels, cursor events and summary totals; `rows()` pages company domains. CLI adds `contacts run create|list|get|events|cancel|wait`; live `continue` is companies-only, and `title-preview` remains a separate live route. Creation propagates typed `InsufficientBalanceError` for 403 INSUFFICIENT_BALANCE. No contacts_run deprecation window applies because this surface is unreleased. +- SDK/CLI: with `max_records` omitted, discover and the contacts endpoints now return, and bill, up to 500 records instead of 100; pass `max_records=100` to keep the old size. No SDK change; older versions get the new default too. +- SDK/CLI: discover, count and `bulk companies` gain `valid_mx` (`--valid-mx`) to exclude companies with no mail server, omitted unless set. It needs an API release that knows the field. +- SDK/CLI: `client.contacts_run` (sync and async) starts automatic validated contact delivery from prompt, domain or lookalike companies and `people.fit`. Requests support top-level `max_companies`, `per_company`, `relevance`, `email_cap_usd`, exclusions and `create_key`. Removed unreleased step mode, title chips, `start`, `max_contacts`, `auto_stop`, `find_missing`, continuation overrides and wait follow/cap flags. `wait()` polls until done, failed or cancelled. Responses include applied company filters, company metadata and event names, contact totals, progress, email states, skipped-no-MX counts and stop reasons; obsolete flat rows and internal step fields are removed. `rows()` pages company domains. CLI adds `contacts run create|list|get|events|cancel|wait`; live `continue` is companies-only, and `title-preview` remains a separate live route. Creation propagates typed `InsufficientBalanceError` for 403 INSUFFICIENT_BALANCE. No contacts_run deprecation window applies because this surface is unreleased. - SDK/CLI: `FindEmailRequest.suggested_email` sends an address you think is right: it is verified first, and the right one is recovered if it is not proven. It must be at `domain` or the company's mail domain, else 422. It works on `email.find` and on each request of `email.find_batch`; the CLI's `email find` gains `--suggested-email`. `EmailJobResult` gains `outcome` (`valid`, `valid_catch_all`, `recovered`, `pattern_inferred`, `not_found`, `unknown`, open) and `email`, read from `job.status()` or batch results, and `EnumerationOutput` / `ValidationOutput` gain `method` (how the address was proven). All are optional and need an API release that sends them. - SDK/CLI: `contacts.search` and `contacts.generate` are deprecated in favor of `client.contacts_run`; the API marks their endpoints deprecated with a 2026-12-31 sunset. They keep working and raise a `DeprecationWarning`; the CLI's `contacts search` and `generate` print a deprecation line on stderr after their output. `contacts.discover`, `lookup`, `match` and `bulk_match` are unchanged. -- SDK (note for maintainers): Contacts run request models live in `discolike/_contacts_run_requests.py`, kept in sync with platform source; the routes retain `openapi=False` for older specs, and `scripts/check_contract.py` checks the run response models only when the spec carries them. `suggested_email` reaches `_generated/requests.py` through `PENDING_PROPERTIES` until the dev spec has it; the regeneration also picks up the dev spec's new site languages. +- SDK (note for maintainers): Contacts run request models live in `discolike/_contacts_run_requests.py`, kept in sync with platform source; the routes retain `openapi=False` for older specs, and `scripts/check_contract.py` checks the run response models only when the spec carries them. Regenerate `_generated/requests.py` with `scripts/gen_requests.py --spec .dev-openapi.json` from the dev spec; `suggested_email` is now in that spec and removed from `PENDING_PROPERTIES`. - SDK: sync and async transports emit a visible `DiscolikeDeprecationWarning`, a `DeprecationWarning` subclass, on every call whose response carries `Deprecation`. Warnings include `Sunset` and `Link` when present; header values are kept as text, including malformed values. Email submission, reattachment and polling no longer emit hardcoded deprecation warnings. ## 0.6.2 (2026-10-21) diff --git a/packages/discolike-cli/src/discolike_cli/bulk.py b/packages/discolike-cli/src/discolike_cli/bulk.py index a2a1ec1..b05b8c6 100644 --- a/packages/discolike-cli/src/discolike_cli/bulk.py +++ b/packages/discolike-cli/src/discolike_cli/bulk.py @@ -41,6 +41,7 @@ from discolike_cli.discover import PARAM_HELP from discolike_cli.discover import PARAMS_FILE_HELP from discolike_cli.discover import RELEVANCE_HELP +from discolike_cli.discover import VALID_MX_HELP from discolike_cli.discover import VARIANCE_HELP from discolike_cli.discover import _merge_params @@ -159,6 +160,7 @@ def companies_command( state: list[str] | None = typer.Option(None, help="State or region filter (repeatable)."), employee_range: str | None = typer.Option(None, help="Employee count range filter."), revenue_range: str | None = typer.Option(None, help="Revenue range filter."), + valid_mx: bool | None = typer.Option(None, "--valid-mx/--no-valid-mx", help=VALID_MX_HELP), relevance: str | None = typer.Option(None, help=RELEVANCE_HELP), variance: str | None = typer.Option(None, help=VARIANCE_HELP), exclusion_query_id: list[str] | None = typer.Option(None, help=EXCLUSION_QUERY_ID_HELP), @@ -187,6 +189,7 @@ def companies_command( state=state, employee_range=employee_range, revenue_range=revenue_range, + valid_mx=valid_mx, relevance=relevance, variance=variance, ), diff --git a/packages/discolike-cli/src/discolike_cli/discover.py b/packages/discolike-cli/src/discolike_cli/discover.py index 2f42bfd..ab6812e 100644 --- a/packages/discolike-cli/src/discolike_cli/discover.py +++ b/packages/discolike-cli/src/discolike_cli/discover.py @@ -42,6 +42,7 @@ LANGUAGE_HELP = "Filter by site language as an ISO 639-1 code, e.g. en, de, pb, zt (repeatable)." NEGATE_LANGUAGE_HELP = "Exclude this site language (repeatable); same values as --language." EXCLUDE_LEADGEN_HELP = "Exclude suspected lead generation sites (on by default)." +VALID_MX_HELP = "Exclude companies with no mail server (off by default)." COUNT_EXCLUDE_LEADGEN_HELP = "Exclude suspected lead generation sites (off by default for count)." RELEVANCE_HELP = ( "Where the list stops, by estimated ICP fit: MAX_RELEVANCE (tightest), BALANCED, MAX_COVERAGE (longest). " @@ -114,6 +115,7 @@ def discover_command( consensus: int | None = typer.Option( None, help="Deprecated, no-op. Discover applies consensus itself to single-domain lookalike searches." ), + valid_mx: bool | None = typer.Option(None, "--valid-mx/--no-valid-mx", help=VALID_MX_HELP), redirect: bool | None = typer.Option(None, "--redirect/--no-redirect", help=REDIRECT_HELP), exclude_leadgen: bool | None = typer.Option( None, "--exclude-leadgen/--no-exclude-leadgen", help=EXCLUDE_LEADGEN_HELP @@ -193,6 +195,7 @@ def discover_command( relevance=relevance, variance=variance, consensus=consensus, + valid_mx=valid_mx, redirect=redirect, exclude_leadgen=exclude_leadgen, retrieval=retrieval, @@ -245,6 +248,7 @@ def count_command( negate_tech_stack: list[str] | None = typer.Option(None, help="Negate the --tech-stack filter (repeatable)."), min_digital_footprint: int | None = typer.Option(None, help="Minimum digital footprint score."), max_digital_footprint: int | None = typer.Option(None, help="Maximum digital footprint score."), + valid_mx: bool | None = typer.Option(None, "--valid-mx/--no-valid-mx", help=VALID_MX_HELP), redirect: bool | None = typer.Option(None, "--redirect/--no-redirect", help=REDIRECT_HELP), exclude_leadgen: bool | None = typer.Option( None, "--exclude-leadgen/--no-exclude-leadgen", help=COUNT_EXCLUDE_LEADGEN_HELP @@ -291,6 +295,7 @@ def count_command( negate_tech_stack=negate_tech_stack, min_digital_footprint=min_digital_footprint, max_digital_footprint=max_digital_footprint, + valid_mx=valid_mx, redirect=redirect, exclude_leadgen=exclude_leadgen, ), diff --git a/packages/discolike-cli/tests/test_bulk_cli.py b/packages/discolike-cli/tests/test_bulk_cli.py index 8ab497d..0cf0fa8 100644 --- a/packages/discolike-cli/tests/test_bulk_cli.py +++ b/packages/discolike-cli/tests/test_bulk_cli.py @@ -661,3 +661,16 @@ def test_bulk_rejects_non_positive_rate_limit( result = runner.invoke(app, ["bulk", *command, *extra, "--out", str(tmp_path / "c.csv"), "--rate-limit", "0"]) assert result.exit_code == 2, result.output assert recorder.requests == [] + + +@pytest.mark.parametrize(("flags", "expected"), [([], None), (["--valid-mx"], "true"), (["--no-valid-mx"], "false")]) +def test_bulk_companies_valid_mx( + install_build_client: Callable[[Handler], None], tmp_path: Path, flags: list[str], expected: str | None +) -> None: + recorder = Recorder(discover=[_companies("a", 1)]) + install_build_client(recorder) + result = runner.invoke( + app, ["bulk", "companies", "--out", str(tmp_path / "companies.csv"), "--max-companies", "1", *flags] + ) + assert result.exit_code == 0, result.output + assert recorder.params("/v1/discover")[0].get("valid_mx") == expected diff --git a/packages/discolike-cli/tests/test_discover.py b/packages/discolike-cli/tests/test_discover.py index d52db5e..ddc41d9 100644 --- a/packages/discolike-cli/tests/test_discover.py +++ b/packages/discolike-cli/tests/test_discover.py @@ -4,6 +4,7 @@ from collections.abc import Callable import httpx2 +import pytest from typer.testing import CliRunner from discolike_cli.main import app @@ -411,3 +412,20 @@ def test_discover_unauthorized_exits_3(install_build_client: Callable[[Handler], assert result.exit_code == 3 payload = json.loads(result.stderr) assert payload["error"] == "AuthenticationError" + + +@pytest.mark.parametrize("command", ["discover", "count"]) +@pytest.mark.parametrize(("flags", "expected"), [([], None), (["--valid-mx"], "true"), (["--no-valid-mx"], "false")]) +def test_valid_mx_flag( + install_build_client: Callable[[Handler], None], command: str, flags: list[str], expected: str | None +) -> None: + seen: list[httpx2.QueryParams] = [] + + def handler(request: httpx2.Request) -> httpx2.Response: + seen.append(request.url.params) + return _count_ok(request) if command == "count" else _discover_ok(request) + + install_build_client(handler) + result = runner.invoke(app, [command, *flags]) + assert result.exit_code == 0, result.output + assert seen[0].get("valid_mx") == expected diff --git a/packages/discolike/src/discolike/_generated/requests.py b/packages/discolike/src/discolike/_generated/requests.py index 64489a7..ea843ba 100644 --- a/packages/discolike/src/discolike/_generated/requests.py +++ b/packages/discolike/src/discolike/_generated/requests.py @@ -63,66 +63,9 @@ class CompaniesPublicLinksParams(DiscolikeRequest): class ContactsSearchParams(DiscolikeRequest): filter_industry: Annotated[ - list[ - Literal[ - "ACCOUNTING", - "ADVERTISING_AND_MARKETING", - "AGRICULTURE_AND_NATURAL_RESOURCES", - "ALCOHOL_AND_TOBACCO", - "AUTOMOTIVE", - "BIG_DATA_AND_ANALYTICS", - "BIOTECHNOLOGY", - "BLOCKCHAIN_AND_CRYPTOCURRENCY", - "BUSINESS_PRODUCTS_AND_SERVICES", - "CLOUD_COMPUTING", - "COMPUTER_HARDWARE_AND_SEMICONDUCTORS", - "CONGLOMERATES_SHELL_AND_HOLDING_COMPANIES", - "CONSTRUCTION", - "CONSUMER_PRODUCTS", - "CONSUMER_SERVICES", - "CYBERSECURITY", - "DEFENSE_AND_AEROSPACE", - "E-COMMERCE", - "EDUCATION", - "ENERGY", - "ENGINEERING", - "ENTERTAINMENT", - "ENVIRONMENTAL_SERVICES", - "FASHION_TEXTILE_AND_APPAREL", - "FINANCIAL_SERVICES", - "FOOD_AND_BEVERAGE", - "GAMING_AND_ESPORTS", - "GOVERNMENT_SERVICES", - "HEALTHCARE", - "HOSPITALITY", - "HUMAN_RESOURCES", - "INSURANCE", - "IT_SERVICES", - "LEGAL", - "MANUFACTURING", - "MEDIA", - "MINING_AND_METALS", - "NONPROFIT_AND_PHILANTHROPY", - "OIL_AND_GAS", - "PHARMACEUTICALS", - "PRIVATE_EQUITY_AND_VENTURE_CAPITAL", - "REAL_ESTATE", - "RENEWABLE_ENERGY", - "RESTAURANTS", - "RETAIL", - "SAAS", - "SECURITY", - "SOFTWARE", - "SPORTS_AND_RECREATION", - "SUPPLY_CHAIN_AND_PROCUREMENT", - "TELECOMMUNICATIONS", - "TRAVEL", - "WELLNESS_AND_LIFESTYLE", - ] - ] - | None, + list[str] | None, Field( - description="Filter by industry category. Valid values: ACCOUNTING, ADVERTISING_AND_MARKETING, AGRICULTURE_AND_NATURAL_RESOURCES, ALCOHOL_AND_TOBACCO, AUTOMOTIVE, BIG_DATA_AND_ANALYTICS, BIOTECHNOLOGY, BLOCKCHAIN_AND_CRYPTOCURRENCY, BUSINESS_PRODUCTS_AND_SERVICES, CLOUD_COMPUTING, COMPUTER_HARDWARE_AND_SEMICONDUCTORS, CONGLOMERATES_SHELL_AND_HOLDING_COMPANIES, CONSTRUCTION, CONSUMER_PRODUCTS, CONSUMER_SERVICES, CYBERSECURITY, DEFENSE_AND_AEROSPACE, E-COMMERCE, EDUCATION, ENERGY, ENGINEERING, ENTERTAINMENT, ENVIRONMENTAL_SERVICES, FASHION_TEXTILE_AND_APPAREL, FINANCIAL_SERVICES, FOOD_AND_BEVERAGE, GAMING_AND_ESPORTS, GOVERNMENT_SERVICES, HEALTHCARE, HOSPITALITY, HUMAN_RESOURCES, INSURANCE, IT_SERVICES, LEGAL, MANUFACTURING, MEDIA, MINING_AND_METALS, NONPROFIT_AND_PHILANTHROPY, OIL_AND_GAS, PHARMACEUTICALS, PRIVATE_EQUITY_AND_VENTURE_CAPITAL, REAL_ESTATE, RENEWABLE_ENERGY, RESTAURANTS, RETAIL, SAAS, SECURITY, SOFTWARE, SPORTS_AND_RECREATION, SUPPLY_CHAIN_AND_PROCUREMENT, TELECOMMUNICATIONS, TRAVEL, WELLNESS_AND_LIFESTYLE.", + description="Filter by industry category (ANY-OF; prefix + to require a category). Valid values: ACCOUNTING, ADVERTISING_AND_MARKETING, AGRICULTURE_AND_NATURAL_RESOURCES, ALCOHOL_AND_TOBACCO, AUTOMOTIVE, BIG_DATA_AND_ANALYTICS, BIOTECHNOLOGY, BLOCKCHAIN_AND_CRYPTOCURRENCY, BUSINESS_PRODUCTS_AND_SERVICES, CLOUD_COMPUTING, COMPUTER_HARDWARE_AND_SEMICONDUCTORS, CONGLOMERATES_SHELL_AND_HOLDING_COMPANIES, CONSTRUCTION, CONSUMER_PRODUCTS, CONSUMER_SERVICES, CYBERSECURITY, DEFENSE_AND_AEROSPACE, E-COMMERCE, EDUCATION, ENERGY, ENGINEERING, ENTERTAINMENT, ENVIRONMENTAL_SERVICES, FASHION_TEXTILE_AND_APPAREL, FINANCIAL_SERVICES, FOOD_AND_BEVERAGE, GAMING_AND_ESPORTS, GOVERNMENT_SERVICES, HEALTHCARE, HOSPITALITY, HUMAN_RESOURCES, INSURANCE, IT_SERVICES, LEGAL, MANUFACTURING, MEDIA, MINING_AND_METALS, NONPROFIT_AND_PHILANTHROPY, OIL_AND_GAS, PHARMACEUTICALS, PRIVATE_EQUITY_AND_VENTURE_CAPITAL, REAL_ESTATE, RENEWABLE_ENERGY, RESTAURANTS, RETAIL, SAAS, SECURITY, SOFTWARE, SPORTS_AND_RECREATION, SUPPLY_CHAIN_AND_PROCUREMENT, TELECOMMUNICATIONS, TRAVEL, WELLNESS_AND_LIFESTYLE.", title="Filter Industry", ), ] = None @@ -294,7 +237,7 @@ class ContactsSearchParams(DiscolikeRequest): skills: Annotated[ list[str] | None, Field( - description="Filter by skills. Multiple skills can be provided.", + description="Accepted and ignored. Contacts are no longer indexed by skills.", title="Skills", ), ] = None @@ -322,14 +265,14 @@ class ContactsSearchParams(DiscolikeRequest): summary: Annotated[ str | None, Field( - description="Match the profile summary text: a contact matches when its summary contains every word of any one term, in any order. Words are separate terms; quote a multi-word term to keep it together, prefix + to require a term.", + description="Matched against the job title: a contact matches when its title matches any one term. Unquoted words are separate terms; a quoted term is a phrase (adjacent, in order); prefix + to require a term.", title="Summary", ), ] = None negate_summary: Annotated[ str | None, Field( - description="Exclude contacts whose profile summary contains every word of any one term, in any order. Quote a multi-word term to keep it together.", + description="Exclude contacts whose job title matches any one term. Unquoted words are separate terms; a quoted term is a phrase (adjacent, in order).", title="Negate Summary", ), ] = None @@ -437,7 +380,7 @@ class ContactsSearchParams(DiscolikeRequest): le=10000, title="Max Records", ), - ] = 100 + ] = 500 max_companies: Annotated[ int | None, Field( @@ -485,66 +428,9 @@ class ContactsSearchParams(DiscolikeRequest): class ContactsCountParams(DiscolikeRequest): filter_industry: Annotated[ - list[ - Literal[ - "ACCOUNTING", - "ADVERTISING_AND_MARKETING", - "AGRICULTURE_AND_NATURAL_RESOURCES", - "ALCOHOL_AND_TOBACCO", - "AUTOMOTIVE", - "BIG_DATA_AND_ANALYTICS", - "BIOTECHNOLOGY", - "BLOCKCHAIN_AND_CRYPTOCURRENCY", - "BUSINESS_PRODUCTS_AND_SERVICES", - "CLOUD_COMPUTING", - "COMPUTER_HARDWARE_AND_SEMICONDUCTORS", - "CONGLOMERATES_SHELL_AND_HOLDING_COMPANIES", - "CONSTRUCTION", - "CONSUMER_PRODUCTS", - "CONSUMER_SERVICES", - "CYBERSECURITY", - "DEFENSE_AND_AEROSPACE", - "E-COMMERCE", - "EDUCATION", - "ENERGY", - "ENGINEERING", - "ENTERTAINMENT", - "ENVIRONMENTAL_SERVICES", - "FASHION_TEXTILE_AND_APPAREL", - "FINANCIAL_SERVICES", - "FOOD_AND_BEVERAGE", - "GAMING_AND_ESPORTS", - "GOVERNMENT_SERVICES", - "HEALTHCARE", - "HOSPITALITY", - "HUMAN_RESOURCES", - "INSURANCE", - "IT_SERVICES", - "LEGAL", - "MANUFACTURING", - "MEDIA", - "MINING_AND_METALS", - "NONPROFIT_AND_PHILANTHROPY", - "OIL_AND_GAS", - "PHARMACEUTICALS", - "PRIVATE_EQUITY_AND_VENTURE_CAPITAL", - "REAL_ESTATE", - "RENEWABLE_ENERGY", - "RESTAURANTS", - "RETAIL", - "SAAS", - "SECURITY", - "SOFTWARE", - "SPORTS_AND_RECREATION", - "SUPPLY_CHAIN_AND_PROCUREMENT", - "TELECOMMUNICATIONS", - "TRAVEL", - "WELLNESS_AND_LIFESTYLE", - ] - ] - | None, + list[str] | None, Field( - description="Filter by industry category. Valid values: ACCOUNTING, ADVERTISING_AND_MARKETING, AGRICULTURE_AND_NATURAL_RESOURCES, ALCOHOL_AND_TOBACCO, AUTOMOTIVE, BIG_DATA_AND_ANALYTICS, BIOTECHNOLOGY, BLOCKCHAIN_AND_CRYPTOCURRENCY, BUSINESS_PRODUCTS_AND_SERVICES, CLOUD_COMPUTING, COMPUTER_HARDWARE_AND_SEMICONDUCTORS, CONGLOMERATES_SHELL_AND_HOLDING_COMPANIES, CONSTRUCTION, CONSUMER_PRODUCTS, CONSUMER_SERVICES, CYBERSECURITY, DEFENSE_AND_AEROSPACE, E-COMMERCE, EDUCATION, ENERGY, ENGINEERING, ENTERTAINMENT, ENVIRONMENTAL_SERVICES, FASHION_TEXTILE_AND_APPAREL, FINANCIAL_SERVICES, FOOD_AND_BEVERAGE, GAMING_AND_ESPORTS, GOVERNMENT_SERVICES, HEALTHCARE, HOSPITALITY, HUMAN_RESOURCES, INSURANCE, IT_SERVICES, LEGAL, MANUFACTURING, MEDIA, MINING_AND_METALS, NONPROFIT_AND_PHILANTHROPY, OIL_AND_GAS, PHARMACEUTICALS, PRIVATE_EQUITY_AND_VENTURE_CAPITAL, REAL_ESTATE, RENEWABLE_ENERGY, RESTAURANTS, RETAIL, SAAS, SECURITY, SOFTWARE, SPORTS_AND_RECREATION, SUPPLY_CHAIN_AND_PROCUREMENT, TELECOMMUNICATIONS, TRAVEL, WELLNESS_AND_LIFESTYLE.", + description="Filter by industry category (ANY-OF; prefix + to require a category). Valid values: ACCOUNTING, ADVERTISING_AND_MARKETING, AGRICULTURE_AND_NATURAL_RESOURCES, ALCOHOL_AND_TOBACCO, AUTOMOTIVE, BIG_DATA_AND_ANALYTICS, BIOTECHNOLOGY, BLOCKCHAIN_AND_CRYPTOCURRENCY, BUSINESS_PRODUCTS_AND_SERVICES, CLOUD_COMPUTING, COMPUTER_HARDWARE_AND_SEMICONDUCTORS, CONGLOMERATES_SHELL_AND_HOLDING_COMPANIES, CONSTRUCTION, CONSUMER_PRODUCTS, CONSUMER_SERVICES, CYBERSECURITY, DEFENSE_AND_AEROSPACE, E-COMMERCE, EDUCATION, ENERGY, ENGINEERING, ENTERTAINMENT, ENVIRONMENTAL_SERVICES, FASHION_TEXTILE_AND_APPAREL, FINANCIAL_SERVICES, FOOD_AND_BEVERAGE, GAMING_AND_ESPORTS, GOVERNMENT_SERVICES, HEALTHCARE, HOSPITALITY, HUMAN_RESOURCES, INSURANCE, IT_SERVICES, LEGAL, MANUFACTURING, MEDIA, MINING_AND_METALS, NONPROFIT_AND_PHILANTHROPY, OIL_AND_GAS, PHARMACEUTICALS, PRIVATE_EQUITY_AND_VENTURE_CAPITAL, REAL_ESTATE, RENEWABLE_ENERGY, RESTAURANTS, RETAIL, SAAS, SECURITY, SOFTWARE, SPORTS_AND_RECREATION, SUPPLY_CHAIN_AND_PROCUREMENT, TELECOMMUNICATIONS, TRAVEL, WELLNESS_AND_LIFESTYLE.", title="Filter Industry", ), ] = None @@ -716,7 +602,7 @@ class ContactsCountParams(DiscolikeRequest): skills: Annotated[ list[str] | None, Field( - description="Filter by skills. Multiple skills can be provided.", + description="Accepted and ignored. Contacts are no longer indexed by skills.", title="Skills", ), ] = None @@ -744,14 +630,14 @@ class ContactsCountParams(DiscolikeRequest): summary: Annotated[ str | None, Field( - description="Match the profile summary text: a contact matches when its summary contains every word of any one term, in any order. Words are separate terms; quote a multi-word term to keep it together, prefix + to require a term.", + description="Matched against the job title: a contact matches when its title matches any one term. Unquoted words are separate terms; a quoted term is a phrase (adjacent, in order); prefix + to require a term.", title="Summary", ), ] = None negate_summary: Annotated[ str | None, Field( - description="Exclude contacts whose profile summary contains every word of any one term, in any order. Quote a multi-word term to keep it together.", + description="Exclude contacts whose job title matches any one term. Unquoted words are separate terms; a quoted term is a phrase (adjacent, in order).", title="Negate Summary", ), ] = None @@ -859,7 +745,7 @@ class ContactsCountParams(DiscolikeRequest): le=10000, title="Max Records", ), - ] = 100 + ] = 500 max_companies: Annotated[ int | None, Field( @@ -939,66 +825,9 @@ class ContactsMatchParams(DiscolikeRequest): class ContactFilters(DiscolikeRequest): filter_industry: Annotated[ - list[ - Literal[ - "ACCOUNTING", - "ADVERTISING_AND_MARKETING", - "AGRICULTURE_AND_NATURAL_RESOURCES", - "ALCOHOL_AND_TOBACCO", - "AUTOMOTIVE", - "BIG_DATA_AND_ANALYTICS", - "BIOTECHNOLOGY", - "BLOCKCHAIN_AND_CRYPTOCURRENCY", - "BUSINESS_PRODUCTS_AND_SERVICES", - "CLOUD_COMPUTING", - "COMPUTER_HARDWARE_AND_SEMICONDUCTORS", - "CONGLOMERATES_SHELL_AND_HOLDING_COMPANIES", - "CONSTRUCTION", - "CONSUMER_PRODUCTS", - "CONSUMER_SERVICES", - "CYBERSECURITY", - "DEFENSE_AND_AEROSPACE", - "E-COMMERCE", - "EDUCATION", - "ENERGY", - "ENGINEERING", - "ENTERTAINMENT", - "ENVIRONMENTAL_SERVICES", - "FASHION_TEXTILE_AND_APPAREL", - "FINANCIAL_SERVICES", - "FOOD_AND_BEVERAGE", - "GAMING_AND_ESPORTS", - "GOVERNMENT_SERVICES", - "HEALTHCARE", - "HOSPITALITY", - "HUMAN_RESOURCES", - "INSURANCE", - "IT_SERVICES", - "LEGAL", - "MANUFACTURING", - "MEDIA", - "MINING_AND_METALS", - "NONPROFIT_AND_PHILANTHROPY", - "OIL_AND_GAS", - "PHARMACEUTICALS", - "PRIVATE_EQUITY_AND_VENTURE_CAPITAL", - "REAL_ESTATE", - "RENEWABLE_ENERGY", - "RESTAURANTS", - "RETAIL", - "SAAS", - "SECURITY", - "SOFTWARE", - "SPORTS_AND_RECREATION", - "SUPPLY_CHAIN_AND_PROCUREMENT", - "TELECOMMUNICATIONS", - "TRAVEL", - "WELLNESS_AND_LIFESTYLE", - ] - ] - | None, + list[str] | None, Field( - description="Filter by industry category. Valid values: ACCOUNTING, ADVERTISING_AND_MARKETING, AGRICULTURE_AND_NATURAL_RESOURCES, ALCOHOL_AND_TOBACCO, AUTOMOTIVE, BIG_DATA_AND_ANALYTICS, BIOTECHNOLOGY, BLOCKCHAIN_AND_CRYPTOCURRENCY, BUSINESS_PRODUCTS_AND_SERVICES, CLOUD_COMPUTING, COMPUTER_HARDWARE_AND_SEMICONDUCTORS, CONGLOMERATES_SHELL_AND_HOLDING_COMPANIES, CONSTRUCTION, CONSUMER_PRODUCTS, CONSUMER_SERVICES, CYBERSECURITY, DEFENSE_AND_AEROSPACE, E-COMMERCE, EDUCATION, ENERGY, ENGINEERING, ENTERTAINMENT, ENVIRONMENTAL_SERVICES, FASHION_TEXTILE_AND_APPAREL, FINANCIAL_SERVICES, FOOD_AND_BEVERAGE, GAMING_AND_ESPORTS, GOVERNMENT_SERVICES, HEALTHCARE, HOSPITALITY, HUMAN_RESOURCES, INSURANCE, IT_SERVICES, LEGAL, MANUFACTURING, MEDIA, MINING_AND_METALS, NONPROFIT_AND_PHILANTHROPY, OIL_AND_GAS, PHARMACEUTICALS, PRIVATE_EQUITY_AND_VENTURE_CAPITAL, REAL_ESTATE, RENEWABLE_ENERGY, RESTAURANTS, RETAIL, SAAS, SECURITY, SOFTWARE, SPORTS_AND_RECREATION, SUPPLY_CHAIN_AND_PROCUREMENT, TELECOMMUNICATIONS, TRAVEL, WELLNESS_AND_LIFESTYLE.", + description="Filter by industry category (ANY-OF; prefix + to require a category). Valid values: ACCOUNTING, ADVERTISING_AND_MARKETING, AGRICULTURE_AND_NATURAL_RESOURCES, ALCOHOL_AND_TOBACCO, AUTOMOTIVE, BIG_DATA_AND_ANALYTICS, BIOTECHNOLOGY, BLOCKCHAIN_AND_CRYPTOCURRENCY, BUSINESS_PRODUCTS_AND_SERVICES, CLOUD_COMPUTING, COMPUTER_HARDWARE_AND_SEMICONDUCTORS, CONGLOMERATES_SHELL_AND_HOLDING_COMPANIES, CONSTRUCTION, CONSUMER_PRODUCTS, CONSUMER_SERVICES, CYBERSECURITY, DEFENSE_AND_AEROSPACE, E-COMMERCE, EDUCATION, ENERGY, ENGINEERING, ENTERTAINMENT, ENVIRONMENTAL_SERVICES, FASHION_TEXTILE_AND_APPAREL, FINANCIAL_SERVICES, FOOD_AND_BEVERAGE, GAMING_AND_ESPORTS, GOVERNMENT_SERVICES, HEALTHCARE, HOSPITALITY, HUMAN_RESOURCES, INSURANCE, IT_SERVICES, LEGAL, MANUFACTURING, MEDIA, MINING_AND_METALS, NONPROFIT_AND_PHILANTHROPY, OIL_AND_GAS, PHARMACEUTICALS, PRIVATE_EQUITY_AND_VENTURE_CAPITAL, REAL_ESTATE, RENEWABLE_ENERGY, RESTAURANTS, RETAIL, SAAS, SECURITY, SOFTWARE, SPORTS_AND_RECREATION, SUPPLY_CHAIN_AND_PROCUREMENT, TELECOMMUNICATIONS, TRAVEL, WELLNESS_AND_LIFESTYLE.", title="Filter Industry", ), ] = None @@ -1170,7 +999,7 @@ class ContactFilters(DiscolikeRequest): skills: Annotated[ list[str] | None, Field( - description="Filter by skills. Multiple skills can be provided.", + description="Accepted and ignored. Contacts are no longer indexed by skills.", title="Skills", ), ] = None @@ -1198,14 +1027,14 @@ class ContactFilters(DiscolikeRequest): summary: Annotated[ str | None, Field( - description="Match the profile summary text: a contact matches when its summary contains every word of any one term, in any order. Words are separate terms; quote a multi-word term to keep it together, prefix + to require a term.", + description="Matched against the job title: a contact matches when its title matches any one term. Unquoted words are separate terms; a quoted term is a phrase (adjacent, in order); prefix + to require a term.", title="Summary", ), ] = None negate_summary: Annotated[ str | None, Field( - description="Exclude contacts whose profile summary contains every word of any one term, in any order. Quote a multi-word term to keep it together.", + description="Exclude contacts whose job title matches any one term. Unquoted words are separate terms; a quoted term is a phrase (adjacent, in order).", title="Negate Summary", ), ] = None @@ -1313,7 +1142,7 @@ class ContactFilters(DiscolikeRequest): le=10000, title="Max Records", ), - ] = 100 + ] = 500 max_companies: Annotated[ int | None, Field( @@ -1574,66 +1403,9 @@ class DiscoverParams(DiscolikeRequest): ), ] = None category: Annotated[ - list[ - Literal[ - "ACCOUNTING", - "ADVERTISING_AND_MARKETING", - "AGRICULTURE_AND_NATURAL_RESOURCES", - "ALCOHOL_AND_TOBACCO", - "AUTOMOTIVE", - "BIG_DATA_AND_ANALYTICS", - "BIOTECHNOLOGY", - "BLOCKCHAIN_AND_CRYPTOCURRENCY", - "BUSINESS_PRODUCTS_AND_SERVICES", - "CLOUD_COMPUTING", - "COMPUTER_HARDWARE_AND_SEMICONDUCTORS", - "CONGLOMERATES_SHELL_AND_HOLDING_COMPANIES", - "CONSTRUCTION", - "CONSUMER_PRODUCTS", - "CONSUMER_SERVICES", - "CYBERSECURITY", - "DEFENSE_AND_AEROSPACE", - "E-COMMERCE", - "EDUCATION", - "ENERGY", - "ENGINEERING", - "ENTERTAINMENT", - "ENVIRONMENTAL_SERVICES", - "FASHION_TEXTILE_AND_APPAREL", - "FINANCIAL_SERVICES", - "FOOD_AND_BEVERAGE", - "GAMING_AND_ESPORTS", - "GOVERNMENT_SERVICES", - "HEALTHCARE", - "HOSPITALITY", - "HUMAN_RESOURCES", - "INSURANCE", - "IT_SERVICES", - "LEGAL", - "MANUFACTURING", - "MEDIA", - "MINING_AND_METALS", - "NONPROFIT_AND_PHILANTHROPY", - "OIL_AND_GAS", - "PHARMACEUTICALS", - "PRIVATE_EQUITY_AND_VENTURE_CAPITAL", - "REAL_ESTATE", - "RENEWABLE_ENERGY", - "RESTAURANTS", - "RETAIL", - "SAAS", - "SECURITY", - "SOFTWARE", - "SPORTS_AND_RECREATION", - "SUPPLY_CHAIN_AND_PROCUREMENT", - "TELECOMMUNICATIONS", - "TRAVEL", - "WELLNESS_AND_LIFESTYLE", - ] - ] - | None, + list[str] | None, Field( - description="Filter by industry category. Valid values: ACCOUNTING, ADVERTISING_AND_MARKETING, AGRICULTURE_AND_NATURAL_RESOURCES, ALCOHOL_AND_TOBACCO, AUTOMOTIVE, BIG_DATA_AND_ANALYTICS, BIOTECHNOLOGY, BLOCKCHAIN_AND_CRYPTOCURRENCY, BUSINESS_PRODUCTS_AND_SERVICES, CLOUD_COMPUTING, COMPUTER_HARDWARE_AND_SEMICONDUCTORS, CONGLOMERATES_SHELL_AND_HOLDING_COMPANIES, CONSTRUCTION, CONSUMER_PRODUCTS, CONSUMER_SERVICES, CYBERSECURITY, DEFENSE_AND_AEROSPACE, E-COMMERCE, EDUCATION, ENERGY, ENGINEERING, ENTERTAINMENT, ENVIRONMENTAL_SERVICES, FASHION_TEXTILE_AND_APPAREL, FINANCIAL_SERVICES, FOOD_AND_BEVERAGE, GAMING_AND_ESPORTS, GOVERNMENT_SERVICES, HEALTHCARE, HOSPITALITY, HUMAN_RESOURCES, INSURANCE, IT_SERVICES, LEGAL, MANUFACTURING, MEDIA, MINING_AND_METALS, NONPROFIT_AND_PHILANTHROPY, OIL_AND_GAS, PHARMACEUTICALS, PRIVATE_EQUITY_AND_VENTURE_CAPITAL, REAL_ESTATE, RENEWABLE_ENERGY, RESTAURANTS, RETAIL, SAAS, SECURITY, SOFTWARE, SPORTS_AND_RECREATION, SUPPLY_CHAIN_AND_PROCUREMENT, TELECOMMUNICATIONS, TRAVEL, WELLNESS_AND_LIFESTYLE.", + description="Filter by industry category (ANY-OF; prefix + to require a category). Valid values: ACCOUNTING, ADVERTISING_AND_MARKETING, AGRICULTURE_AND_NATURAL_RESOURCES, ALCOHOL_AND_TOBACCO, AUTOMOTIVE, BIG_DATA_AND_ANALYTICS, BIOTECHNOLOGY, BLOCKCHAIN_AND_CRYPTOCURRENCY, BUSINESS_PRODUCTS_AND_SERVICES, CLOUD_COMPUTING, COMPUTER_HARDWARE_AND_SEMICONDUCTORS, CONGLOMERATES_SHELL_AND_HOLDING_COMPANIES, CONSTRUCTION, CONSUMER_PRODUCTS, CONSUMER_SERVICES, CYBERSECURITY, DEFENSE_AND_AEROSPACE, E-COMMERCE, EDUCATION, ENERGY, ENGINEERING, ENTERTAINMENT, ENVIRONMENTAL_SERVICES, FASHION_TEXTILE_AND_APPAREL, FINANCIAL_SERVICES, FOOD_AND_BEVERAGE, GAMING_AND_ESPORTS, GOVERNMENT_SERVICES, HEALTHCARE, HOSPITALITY, HUMAN_RESOURCES, INSURANCE, IT_SERVICES, LEGAL, MANUFACTURING, MEDIA, MINING_AND_METALS, NONPROFIT_AND_PHILANTHROPY, OIL_AND_GAS, PHARMACEUTICALS, PRIVATE_EQUITY_AND_VENTURE_CAPITAL, REAL_ESTATE, RENEWABLE_ENERGY, RESTAURANTS, RETAIL, SAAS, SECURITY, SOFTWARE, SPORTS_AND_RECREATION, SUPPLY_CHAIN_AND_PROCUREMENT, TELECOMMUNICATIONS, TRAVEL, WELLNESS_AND_LIFESTYLE.", title="Category", ), ] = None @@ -2042,6 +1814,10 @@ class DiscoverParams(DiscolikeRequest): title="Negate Business Model", ), ] = None + valid_mx: Annotated[ + bool | None, + Field(description="Only companies with a mail server.", title="Valid Mx"), + ] = False exclude_leadgen: Annotated[ bool | None, Field( @@ -2091,12 +1867,12 @@ class DiscoverParams(DiscolikeRequest): max_records: Annotated[ int | None, Field( - description="Maximum records to return (5-10000). Default 100.", + description="Maximum records to return (5-10000). Default 500.", ge=5, le=10000, title="Max Records", ), - ] = 100 + ] = 500 min_similarity: Annotated[ int | None, Field( @@ -2210,66 +1986,9 @@ class CountParams(DiscolikeRequest): ), ] = None category: Annotated[ - list[ - Literal[ - "ACCOUNTING", - "ADVERTISING_AND_MARKETING", - "AGRICULTURE_AND_NATURAL_RESOURCES", - "ALCOHOL_AND_TOBACCO", - "AUTOMOTIVE", - "BIG_DATA_AND_ANALYTICS", - "BIOTECHNOLOGY", - "BLOCKCHAIN_AND_CRYPTOCURRENCY", - "BUSINESS_PRODUCTS_AND_SERVICES", - "CLOUD_COMPUTING", - "COMPUTER_HARDWARE_AND_SEMICONDUCTORS", - "CONGLOMERATES_SHELL_AND_HOLDING_COMPANIES", - "CONSTRUCTION", - "CONSUMER_PRODUCTS", - "CONSUMER_SERVICES", - "CYBERSECURITY", - "DEFENSE_AND_AEROSPACE", - "E-COMMERCE", - "EDUCATION", - "ENERGY", - "ENGINEERING", - "ENTERTAINMENT", - "ENVIRONMENTAL_SERVICES", - "FASHION_TEXTILE_AND_APPAREL", - "FINANCIAL_SERVICES", - "FOOD_AND_BEVERAGE", - "GAMING_AND_ESPORTS", - "GOVERNMENT_SERVICES", - "HEALTHCARE", - "HOSPITALITY", - "HUMAN_RESOURCES", - "INSURANCE", - "IT_SERVICES", - "LEGAL", - "MANUFACTURING", - "MEDIA", - "MINING_AND_METALS", - "NONPROFIT_AND_PHILANTHROPY", - "OIL_AND_GAS", - "PHARMACEUTICALS", - "PRIVATE_EQUITY_AND_VENTURE_CAPITAL", - "REAL_ESTATE", - "RENEWABLE_ENERGY", - "RESTAURANTS", - "RETAIL", - "SAAS", - "SECURITY", - "SOFTWARE", - "SPORTS_AND_RECREATION", - "SUPPLY_CHAIN_AND_PROCUREMENT", - "TELECOMMUNICATIONS", - "TRAVEL", - "WELLNESS_AND_LIFESTYLE", - ] - ] - | None, + list[str] | None, Field( - description="Filter by industry category. Valid values: ACCOUNTING, ADVERTISING_AND_MARKETING, AGRICULTURE_AND_NATURAL_RESOURCES, ALCOHOL_AND_TOBACCO, AUTOMOTIVE, BIG_DATA_AND_ANALYTICS, BIOTECHNOLOGY, BLOCKCHAIN_AND_CRYPTOCURRENCY, BUSINESS_PRODUCTS_AND_SERVICES, CLOUD_COMPUTING, COMPUTER_HARDWARE_AND_SEMICONDUCTORS, CONGLOMERATES_SHELL_AND_HOLDING_COMPANIES, CONSTRUCTION, CONSUMER_PRODUCTS, CONSUMER_SERVICES, CYBERSECURITY, DEFENSE_AND_AEROSPACE, E-COMMERCE, EDUCATION, ENERGY, ENGINEERING, ENTERTAINMENT, ENVIRONMENTAL_SERVICES, FASHION_TEXTILE_AND_APPAREL, FINANCIAL_SERVICES, FOOD_AND_BEVERAGE, GAMING_AND_ESPORTS, GOVERNMENT_SERVICES, HEALTHCARE, HOSPITALITY, HUMAN_RESOURCES, INSURANCE, IT_SERVICES, LEGAL, MANUFACTURING, MEDIA, MINING_AND_METALS, NONPROFIT_AND_PHILANTHROPY, OIL_AND_GAS, PHARMACEUTICALS, PRIVATE_EQUITY_AND_VENTURE_CAPITAL, REAL_ESTATE, RENEWABLE_ENERGY, RESTAURANTS, RETAIL, SAAS, SECURITY, SOFTWARE, SPORTS_AND_RECREATION, SUPPLY_CHAIN_AND_PROCUREMENT, TELECOMMUNICATIONS, TRAVEL, WELLNESS_AND_LIFESTYLE.", + description="Filter by industry category (ANY-OF; prefix + to require a category). Valid values: ACCOUNTING, ADVERTISING_AND_MARKETING, AGRICULTURE_AND_NATURAL_RESOURCES, ALCOHOL_AND_TOBACCO, AUTOMOTIVE, BIG_DATA_AND_ANALYTICS, BIOTECHNOLOGY, BLOCKCHAIN_AND_CRYPTOCURRENCY, BUSINESS_PRODUCTS_AND_SERVICES, CLOUD_COMPUTING, COMPUTER_HARDWARE_AND_SEMICONDUCTORS, CONGLOMERATES_SHELL_AND_HOLDING_COMPANIES, CONSTRUCTION, CONSUMER_PRODUCTS, CONSUMER_SERVICES, CYBERSECURITY, DEFENSE_AND_AEROSPACE, E-COMMERCE, EDUCATION, ENERGY, ENGINEERING, ENTERTAINMENT, ENVIRONMENTAL_SERVICES, FASHION_TEXTILE_AND_APPAREL, FINANCIAL_SERVICES, FOOD_AND_BEVERAGE, GAMING_AND_ESPORTS, GOVERNMENT_SERVICES, HEALTHCARE, HOSPITALITY, HUMAN_RESOURCES, INSURANCE, IT_SERVICES, LEGAL, MANUFACTURING, MEDIA, MINING_AND_METALS, NONPROFIT_AND_PHILANTHROPY, OIL_AND_GAS, PHARMACEUTICALS, PRIVATE_EQUITY_AND_VENTURE_CAPITAL, REAL_ESTATE, RENEWABLE_ENERGY, RESTAURANTS, RETAIL, SAAS, SECURITY, SOFTWARE, SPORTS_AND_RECREATION, SUPPLY_CHAIN_AND_PROCUREMENT, TELECOMMUNICATIONS, TRAVEL, WELLNESS_AND_LIFESTYLE.", title="Category", ), ] = None @@ -2678,6 +2397,10 @@ class CountParams(DiscolikeRequest): title="Negate Business Model", ), ] = None + valid_mx: Annotated[ + bool | None, + Field(description="Only companies with a mail server.", title="Valid Mx"), + ] = False exclude_leadgen: Annotated[ bool | None, Field( @@ -2689,7 +2412,13 @@ class CountParams(DiscolikeRequest): class FindEmailRequest(DiscolikeRequest): first_name: Annotated[str, Field(description="First name of the person", title="First Name")] - last_name: Annotated[str, Field(description="Last name of the person", title="Last Name")] + last_name: Annotated[ + str, + Field( + description="Last name; use an empty string to check only first@domain", + title="Last Name", + ), + ] domain: Annotated[str, Field(description="Target email domain", title="Domain")] known_pattern: Annotated[ str | None, @@ -3161,7 +2890,7 @@ class BulkContactMatchRequest(DiscolikeRequest): enrich: Annotated[ bool | None, Field( - description="When true, hydrate full contact data from the vector database (costs credits).", + description="When true, hydrate contact data for free. Emails only echo the email supplied in that query.", title="Enrich", ), ] = False @@ -3214,7 +2943,7 @@ class ProspectingBrief(DiscolikeRequest): ] = None exclude_domains: Annotated[list[str] | None, Field(max_length=1000, title="Exclude Domains")] = None target_companies: Annotated[int | None, Field(ge=1, le=10000, title="Target Companies")] = 1000 - contacts_per_company: Annotated[int | None, Field(ge=1, le=10, title="Contacts Per Company")] = 3 + contacts_per_company: Annotated[int | None, Field(ge=1, le=25, title="Contacts Per Company")] = 3 max_candidates: Annotated[int | None, Field(ge=0, le=100000, title="Max Candidates")] = 0 max_actions: Annotated[int | None, Field(ge=0, le=10000, title="Max Actions")] = 0 validation_integration_id: Annotated[str | None, Field(max_length=128, title="Validation Integration Id")] = None @@ -3269,7 +2998,7 @@ class ProspectingPlanSettings(DiscolikeRequest): max_spend_usd: Annotated[ float | None, Field( - description="Most the run may spend on DiscoLike records and per-call fees, in USD at your plan's rates, before it stops; 0 removes the limit.", + description="Most the run may spend on DiscoLike records, in USD at your plan's rates, before it stops; 0 removes the limit.", ge=0.0, title="Max Spend Usd", ), @@ -3288,7 +3017,7 @@ class ProspectingPlanSettings(DiscolikeRequest): Field( description="Most contacts to find at each qualified company.", ge=1, - le=10, + le=25, title="Contacts Per Company", ), ] = None diff --git a/packages/discolike/src/discolike/resources/contacts_run.py b/packages/discolike/src/discolike/resources/contacts_run.py index e867827..8dc056e 100644 --- a/packages/discolike/src/discolike/resources/contacts_run.py +++ b/packages/discolike/src/discolike/resources/contacts_run.py @@ -41,8 +41,6 @@ class ContactsRunStep(DiscolikeModel): counts: dict[str, int] = Field(default_factory=dict) estimate_usd: Decimal | None = None charged_usd: Decimal = Decimal(0) - child_task_id: str | None = None - state: dict[str, Any] = Field(default_factory=dict) class ContactsRunRow(DiscolikeModel): @@ -54,7 +52,6 @@ class ContactsRunRow(DiscolikeModel): linkedin_url: str | None = None seniority: str | None = None department: str | None = None - source: Literal["database", "web_search"] | str | None = None debug_only: bool | None = None reason: str | None = None company_score: float | None = None @@ -64,10 +61,11 @@ class ContactsRunRow(DiscolikeModel): not_fit: bool = False email: str | None = None email_status: Literal["Validated", "Validated (catch-all resolved)"] | str | None = None - company: dict[str, Any] = Field(default_factory=dict) + email_state: Literal["checking", "queued", "skipped"] | str | None = None class RunCompanyResponse(DiscolikeModel): + company: dict[str, Any] = Field(default_factory=dict) domain: str status: Literal["pending", "finding_people", "finding_emails", "validated", "skipped", "cancelled", "failed"] | str people_reviewed: int = 0 @@ -77,6 +75,8 @@ class RunCompanyResponse(DiscolikeModel): class ContactsRunSummary(DiscolikeModel): + skipped_no_mx: int | None = None + stop_reason: Literal["low_delivery_yield", "source_exhausted"] | str | None = None companies_delivered: int = 0 validated_contacts: int = 0 spent_usd: Decimal = Decimal(0) @@ -88,6 +88,7 @@ class ValidatedRunTitle(DiscolikeModel): class ContactsRunEvent(DiscolikeModel): + company_name: str | None = None seq: int domain: str created_at: datetime @@ -104,7 +105,24 @@ class ContactsRunEventsResponse(DiscolikeModel): next_cursor: int = 0 +class AppliedCompanyFilters(DiscolikeModel): + """Effective company filters returned by the server, without request validation limits.""" + + filter_industry: list[str] | None = None + negate_filter_industry: list[str] | None = None + filter_country: list[str] | None = None + negate_filter_country: list[str] | None = None + filter_state: list[str] | None = None + negate_filter_state: list[str] | None = None + employee_range: str | None = None + revenue_range: str | None = None + sub_industry: list[str] | None = None + + class ContactsRun(DiscolikeModel): + applied_company_filters: AppliedCompanyFilters | None = None + contacts_total: int = 0 + progress: dict[str, int] = Field(default_factory=dict) error: str | None = None companies: list[RunCompanyResponse] = Field(default_factory=list) companies_total: int = 0 @@ -121,8 +139,6 @@ class ContactsRun(DiscolikeModel): created_at: datetime updated_at: datetime expires_at: datetime - rows: list[ContactsRunRow] = Field(default_factory=list) - rows_total: int = 0 class TitlePreviewTitle(DiscolikeModel): @@ -164,16 +180,10 @@ def rows(self, run_id: str | UUID, *, page_size: int = ROWS_PAGE_SIZE) -> builti offset = 0 while True: run = self.get(run_id, ContactsRunGetParams(rows_offset=offset, rows_limit=page_size)) - if run.kind == "contacts": - rows.extend(contact for company in run.companies for contact in company.contacts) - offset += len(run.companies) - if not run.companies or offset >= run.companies_total: - return rows - else: - rows.extend(run.rows) - offset += len(run.rows) - if not run.rows or offset >= run.rows_total: - return rows + rows.extend(contact for company in run.companies for contact in company.contacts) + offset += len(run.companies) + if not run.companies or offset >= run.companies_total: + return rows @api_route("POST", RUN_PATH + "/{run_id}/continue", openapi=False) def continue_(self, run_id: str | UUID, request: ContinueRunRequest) -> ContactsRun: @@ -239,16 +249,10 @@ async def rows(self, run_id: str | UUID, *, page_size: int = ROWS_PAGE_SIZE) -> offset = 0 while True: run = await self.get(run_id, ContactsRunGetParams(rows_offset=offset, rows_limit=page_size)) - if run.kind == "contacts": - rows.extend(contact for company in run.companies for contact in company.contacts) - offset += len(run.companies) - if not run.companies or offset >= run.companies_total: - return rows - else: - rows.extend(run.rows) - offset += len(run.rows) - if not run.rows or offset >= run.rows_total: - return rows + rows.extend(contact for company in run.companies for contact in company.contacts) + offset += len(run.companies) + if not run.companies or offset >= run.companies_total: + return rows @api_route("POST", RUN_PATH + "/{run_id}/continue", openapi=False) async def continue_(self, run_id: str | UUID, request: ContinueRunRequest) -> ContactsRun: diff --git a/packages/discolike/tests/test_contacts_run.py b/packages/discolike/tests/test_contacts_run.py index 678823e..91473ba 100644 --- a/packages/discolike/tests/test_contacts_run.py +++ b/packages/discolike/tests/test_contacts_run.py @@ -78,7 +78,7 @@ def handler(req: httpx2.Request) -> httpx2.Response: assert seen[0].url.path == "/v1/contacts/run" assert json.loads(seen[0].content) == request().to_wire() assert run.current_step is None - assert run.companies[0].contacts[0].source is None + assert run.companies[0].contacts[0].email_state is None assert run.companies[0].contacts[0].email_status == "Validated (catch-all resolved)" assert run.summary is not None assert run.summary.spent_usd == Decimal("0.10") @@ -170,7 +170,12 @@ def test_open_events() -> None: ], } ) + assert page.events[0].company_name is None assert page.events[0].validated_titles[0].status == "future" + assert ( + module.ContactsRunEvent.model_validate(page.events[0].model_dump() | {"company_name": "Acme"}).company_name + == "Acme" + ) assert ContactsRun.model_validate( run_payload("future", current_step="future", steps={"future": {"status": "future"}}) ) @@ -196,7 +201,7 @@ def test_wait_timeout(make_client: ClientFactory, monkeypatch: pytest.MonkeyPatc client.contacts_run.wait(RUN_ID) -def company_page(req: httpx2.Request) -> httpx2.Response: +def company_page(req: httpx2.Request, *, kind: str = "contacts") -> httpx2.Response: offset = int(req.url.params["rows_offset"]) pages = [ {"domain": "Company 1", "status": "skipped", "contacts": []}, @@ -211,16 +216,20 @@ def company_page(req: httpx2.Request) -> httpx2.Response: {"domain": "Company 3", "status": "validated", "contacts": [{"row_key": "c", "domain": "Company 3"}]}, ] assert req.url.params["rows_limit"] == "1" - return httpx2.Response(200, json=run_payload(companies=pages[offset : offset + 1], companies_total=3, rows_total=3)) + return httpx2.Response( + 200, json=run_payload(kind=kind, companies=pages[offset : offset + 1], companies_total=3, contacts_total=3) + ) -def test_rows_pages_domains_including_empty_companies(make_client: ClientFactory) -> None: - with make_client(company_page) as client: +@pytest.mark.parametrize("kind", ["contacts", "companies"]) +def test_rows_pages_domains_including_empty_companies(make_client: ClientFactory, kind: str) -> None: + with make_client(lambda req: company_page(req, kind=kind)) as client: assert [r.row_key for r in client.contacts_run.rows(RUN_ID, page_size=1)] == ["a", "b", "c"] -async def test_async_rows(make_async_client: AsyncClientFactory) -> None: - async with make_async_client(company_page) as client: +@pytest.mark.parametrize("kind", ["contacts", "companies"]) +async def test_async_rows(make_async_client: AsyncClientFactory, kind: str) -> None: + async with make_async_client(lambda req: company_page(req, kind=kind)) as client: assert [r.row_key for r in await client.contacts_run.rows(RUN_ID, page_size=1)] == ["a", "b", "c"] @@ -268,3 +277,53 @@ def test_insufficient_balance(make_client: ClientFactory) -> None: with pytest.raises(InsufficientBalanceError) as exc: client.contacts_run.create(request()) assert exc.value.max_affordable_records == 2 + + +def test_response_fields_accept_new_server_values() -> None: + run = ContactsRun.model_validate( + run_payload( + applied_company_filters={ + "filter_industry": ["+FUTURE"], + "negate_filter_industry": ["FUTURE"], + "sub_industry": ["FUTURE"] * 100, + "future": True, + }, + contacts_total=2, + progress={"future": 3}, + summary={"skipped_no_mx": -1, "stop_reason": "future"}, + companies=[ + { + "domain": "acme.com", + "status": "future", + "company": {"name": "Acme", "future": True}, + "contacts": [{"domain": "acme.com", "row_key": "a", "email_state": "future", "future": True}], + } + ], + ) + ) + assert run.contacts_total == 2 + assert run.progress == {"future": 3} + assert run.applied_company_filters is not None + assert run.applied_company_filters.negate_filter_industry == ["FUTURE"] + assert run.applied_company_filters.model_extra == {"future": True} + assert run.summary is not None + assert run.summary.skipped_no_mx == -1 + assert run.summary.stop_reason == "future" + assert run.companies[0].company == {"name": "Acme", "future": True} + assert run.companies[0].contacts[0].email_state == "future" + assert run.companies[0].contacts[0].model_extra == {"future": True} + + +def test_response_fields_default_for_older_servers() -> None: + run = ContactsRun.model_validate(run_payload()) + assert run.applied_company_filters is None + assert run.contacts_total == 0 + assert run.progress == {} + summary = module.ContactsRunSummary() + assert summary.skipped_no_mx is None + assert summary.stop_reason is None + company = module.RunCompanyResponse(domain="acme.com", status="pending") + assert company.company == {} + assert not {"rows", "rows_total"} & ContactsRun.model_fields.keys() + assert not {"child_task_id", "state"} & module.ContactsRunStep.model_fields.keys() + assert not {"company", "source"} & module.ContactsRunRow.model_fields.keys() diff --git a/packages/discolike/tests/test_prospecting.py b/packages/discolike/tests/test_prospecting.py index 0c48d9a..c579599 100644 --- a/packages/discolike/tests/test_prospecting.py +++ b/packages/discolike/tests/test_prospecting.py @@ -270,7 +270,7 @@ def handler(request: httpx2.Request) -> httpx2.Response: {"goal": "accounts"}, {"max_provider_spend_usd": -1}, {"target_companies": 0}, - {"contacts_per_company": 11}, + {"contacts_per_company": 26}, ], ) def test_update_plan_rejects_bad_run_shape_locally(values: dict) -> None: diff --git a/packages/discolike/tests/test_requests_contract.py b/packages/discolike/tests/test_requests_contract.py index 3b3a774..70ca4a7 100644 --- a/packages/discolike/tests/test_requests_contract.py +++ b/packages/discolike/tests/test_requests_contract.py @@ -1,5 +1,7 @@ """The generated request models must carry the sub-industry and geo filters.""" +import pytest + from discolike.requests import CountParams from discolike.requests import DiscoverParams from discolike.resources.companies import CompanyProfile @@ -136,3 +138,12 @@ def test_coordinates_serialize_under_their_full_names(self) -> None: assert profile.to_dict()["latitude"] == 40.7128 assert profile.to_dict()["longitude"] == -74.006 + + +@pytest.mark.parametrize("model", [DiscoverParams, CountParams]) +@pytest.mark.parametrize("value", [True, False]) +def test_valid_mx_is_optional_and_sent_when_explicit( + model: type[DiscoverParams] | type[CountParams], value: bool +) -> None: + assert "valid_mx" not in model().to_wire() + assert model(valid_mx=value).to_wire()["valid_mx"] is value diff --git a/scripts/gen_requests.py b/scripts/gen_requests.py index 436c5c7..ae1d189 100644 --- a/scripts/gen_requests.py +++ b/scripts/gen_requests.py @@ -100,18 +100,7 @@ # Properties the SDK ships before the deployed spec has them. Merged in only while the spec # lacks them, so each entry clears itself once the platform release lands -- generation prints # the ones that have, to be deleted here. -PENDING_PROPERTIES: dict[str, dict[str, dict[str, Any]]] = { - "FindEmailRequest": { - "suggested_email": { - "anyOf": [{"type": "string"}, {"type": "null"}], - "title": "Suggested Email", - "description": ( - "The address you think is right. It is verified first; if it is not proven, the right one is " - "recovered. Its domain must be `domain` or the company's mail domain." - ), - } - } -} +PENDING_PROPERTIES: dict[str, dict[str, dict[str, Any]]] = {} # Properties generated from this schema rather than the spec's, whatever the spec says. The # platform's sub-industry enum lists parent-qualified keys only; a bare label reaches it through From 50f20c78e9064809f1684753cf50ba2b6abc6898 Mon Sep 17 00:00:00 2001 From: Daniel Yudelevich Date: Thu, 5 Nov 2026 11:32:54 -0800 Subject: [PATCH 5/6] feat(sdk): drop language from ExtractResult The API removes the field from /extract, so the contract check would flag it once that release is live. Merge after the platform release. --- .../discolike-cli/src/discolike_cli/_help.py | 2 +- packages/discolike-cli/tests/test_company.py | 6 +++--- .../src/discolike/resources/companies.py | 1 - packages/discolike/tests/test_companies.py | 3 +-- .../discolike/tests/test_contract_registry.py | 18 ++++++------------ 5 files changed, 11 insertions(+), 19 deletions(-) diff --git a/packages/discolike-cli/src/discolike_cli/_help.py b/packages/discolike-cli/src/discolike_cli/_help.py index ab894b7..dde4b27 100644 --- a/packages/discolike-cli/src/discolike_cli/_help.py +++ b/packages/discolike-cli/src/discolike_cli/_help.py @@ -185,7 +185,7 @@ def format_help(self, ctx: Context, formatter: HelpFormatter) -> None: """, "extract": """\ Output (success, exit 0): - {"text": , "language": } + {"text": } Fetches the page live; use it to confirm a seed domain is the right company. Common errors: diff --git a/packages/discolike-cli/tests/test_company.py b/packages/discolike-cli/tests/test_company.py index 3b51b18..f43b7d1 100644 --- a/packages/discolike-cli/tests/test_company.py +++ b/packages/discolike-cli/tests/test_company.py @@ -135,7 +135,7 @@ def test_extract_hits_extract_endpoint_with_url(install_build_client: Callable[[ def handler(request: httpx2.Request) -> httpx2.Response: captured["request"] = request - return httpx2.Response(200, json={"text": "hello", "language": "en"}) + return httpx2.Response(200, json={"text": "hello"}) install_build_client(handler) result = runner.invoke(app, ["extract", "https://acme.com/about"]) @@ -143,7 +143,7 @@ def handler(request: httpx2.Request) -> httpx2.Response: request = captured["request"] assert request.url.path == "/v1/extract" assert request.url.params.get("url") == "https://acme.com/about" - assert json.loads(result.stdout) == {"text": "hello", "language": "en"} + assert json.loads(result.stdout) == {"text": "hello"} def test_extract_domain_option_replaces_url(install_build_client: Callable[[Handler], None]) -> None: @@ -151,7 +151,7 @@ def test_extract_domain_option_replaces_url(install_build_client: Callable[[Hand def handler(request: httpx2.Request) -> httpx2.Response: captured["request"] = request - return httpx2.Response(200, json={"text": "hello", "language": "en"}) + return httpx2.Response(200, json={"text": "hello"}) install_build_client(handler) result = runner.invoke(app, ["extract", "--domain", "acme.com"]) diff --git a/packages/discolike/src/discolike/resources/companies.py b/packages/discolike/src/discolike/resources/companies.py index 4f1cc91..81c5307 100644 --- a/packages/discolike/src/discolike/resources/companies.py +++ b/packages/discolike/src/discolike/resources/companies.py @@ -87,7 +87,6 @@ class Growth(DiscolikeModel): class ExtractResult(DiscolikeModel): text: str | None = None - language: str | None = None class Redirect(DiscolikeModel): diff --git a/packages/discolike/tests/test_companies.py b/packages/discolike/tests/test_companies.py index d4cc769..246d4b8 100644 --- a/packages/discolike/tests/test_companies.py +++ b/packages/discolike/tests/test_companies.py @@ -97,13 +97,12 @@ def handler(request: httpx2.Request) -> httpx2.Response: def test_extract_parses_text_and_language(make_client: ClientFactory) -> None: def handler(request: httpx2.Request) -> httpx2.Response: - return httpx2.Response(200, json={"language": "en", "text": "Acme makes widgets."}) + return httpx2.Response(200, json={"text": "Acme makes widgets."}) with make_client(handler) as client: result = client.companies.extract(CompaniesExtractParams(domain="acme.com")) assert result.text == "Acme makes widgets." - assert result.language == "en" def test_unset_match_mode_is_not_sent(make_client: ClientFactory) -> None: diff --git a/packages/discolike/tests/test_contract_registry.py b/packages/discolike/tests/test_contract_registry.py index fc01d3a..6be3e91 100644 --- a/packages/discolike/tests/test_contract_registry.py +++ b/packages/discolike/tests/test_contract_registry.py @@ -66,7 +66,7 @@ def test_check_models_passes_when_spec_matches_model(): check_contract = _load_check_contract() from discolike.resources.companies import ExtractResult - spec = {"components": {"schemas": {"ExtractResponse": {"properties": {"text": {}, "language": {}}}}}} + spec = {"components": {"schemas": {"ExtractResponse": {"properties": {"text": {}}}}}} assert check_contract.check_models(spec, {"ExtractResponse": ExtractResult}) == [] @@ -74,16 +74,16 @@ def test_check_models_reports_field_the_spec_does_not_have(): check_contract = _load_check_contract() from discolike.resources.companies import ExtractResult - spec = {"components": {"schemas": {"ExtractResponse": {"properties": {"text": {}}}}}} + spec = {"components": {"schemas": {"ExtractResponse": {"properties": {}}}}} mismatches = check_contract.check_models(spec, {"ExtractResponse": ExtractResult}) - assert mismatches == ["ExtractResult: field 'language' not in spec schema 'ExtractResponse'"] + assert mismatches == ["ExtractResult: field 'text' not in spec schema 'ExtractResponse'"] def test_check_models_reports_field_the_sdk_does_not_declare(): check_contract = _load_check_contract() from discolike.resources.companies import ExtractResult - spec = {"components": {"schemas": {"ExtractResponse": {"properties": {"text": {}, "language": {}, "summary": {}}}}}} + spec = {"components": {"schemas": {"ExtractResponse": {"properties": {"text": {}, "summary": {}}}}}} mismatches = check_contract.check_models(spec, {"ExtractResponse": ExtractResult}) assert mismatches == ["ExtractResult: spec schema 'ExtractResponse' has field 'summary' the SDK does not declare"] @@ -92,7 +92,7 @@ def test_check_models_ignores_type_and_requiredness_when_spec_gives_no_type_info check_contract = _load_check_contract() from discolike.resources.companies import ExtractResult - spec = {"components": {"schemas": {"ExtractResponse": {"properties": {"text": {}, "language": {}}}}}} + spec = {"components": {"schemas": {"ExtractResponse": {"properties": {"text": {}}}}}} assert check_contract.check_models(spec, {"ExtractResponse": ExtractResult}) == [] @@ -143,7 +143,6 @@ def test_check_models_passes_a_nullable_field_expressed_via_anyof(): "ExtractResponse": { "properties": { "text": {"anyOf": [{"type": "string"}, {"type": "null"}]}, - "language": {"anyOf": [{"type": "string"}, {"type": "null"}]}, }, } } @@ -162,7 +161,6 @@ def test_check_models_passes_a_nullable_field_expressed_via_openapi_nullable_fla "ExtractResponse": { "properties": { "text": {"type": "string", "nullable": True}, - "language": {"type": "string", "nullable": True}, }, } } @@ -185,11 +183,7 @@ def test_check_models_accepts_an_sdk_field_nullable_where_the_spec_is_not(): check_contract = _load_check_contract() from discolike.resources.companies import ExtractResult - spec = { - "components": { - "schemas": {"ExtractResponse": {"properties": {"text": {"type": "string"}, "language": {"type": "string"}}}} - } - } + spec = {"components": {"schemas": {"ExtractResponse": {"properties": {"text": {"type": "string"}}}}}} assert check_contract.check_models(spec, {"ExtractResponse": ExtractResult}) == [] From 964c169ddf106fab7cef7a7b32eb4f4850191c43 Mon Sep 17 00:00:00 2001 From: Daniel Yudelevich Date: Thu, 5 Nov 2026 11:33:22 -0800 Subject: [PATCH 6/6] docs(changelog): note ExtractResult language removal --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 89a7b18..df458f8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ - SDK/CLI: `contacts.search` and `contacts.generate` are deprecated in favor of `client.contacts_run`; the API marks their endpoints deprecated with a 2026-12-31 sunset. They keep working and raise a `DeprecationWarning`; the CLI's `contacts search` and `generate` print a deprecation line on stderr after their output. `contacts.discover`, `lookup`, `match` and `bulk_match` are unchanged. - SDK (note for maintainers): Contacts run request models live in `discolike/_contacts_run_requests.py`, kept in sync with platform source; the routes retain `openapi=False` for older specs, and `scripts/check_contract.py` checks the run response models only when the spec carries them. Regenerate `_generated/requests.py` with `scripts/gen_requests.py --spec .dev-openapi.json` from the dev spec; `suggested_email` is now in that spec and removed from `PENDING_PROPERTIES`. - SDK: sync and async transports emit a visible `DiscolikeDeprecationWarning`, a `DeprecationWarning` subclass, on every call whose response carries `Deprecation`. Warnings include `Sunset` and `Link` when present; header values are kept as text, including malformed values. Email submission, reattachment and polling no longer emit hardcoded deprecation warnings. +- SDK/CLI: `ExtractResult` drops `language`; the API no longer returns it from `/extract`. ## 0.6.2 (2026-10-21)