From 2ef71e763fb608bd6ddf987c424d3a14c2461ac4 Mon Sep 17 00:00:00 2001 From: Daniel Yudelevich Date: Wed, 7 Oct 2026 12:53:17 -0700 Subject: [PATCH 01/11] fix(cli): deprecate --email-validated The API's email_validated contact filter never meant what it said: the index flag behind it only recorded that a third-party verifier had run at some point, not its verdict, and about a fifth of "validated" addresses were catch-all. discolike-platform!971 makes the API accept and ignore it, and contact results now carry email_validated as null until the user runs email verification. The generator drops deprecated spec fields, so the field is removed from ContactsSearchParams, ContactsCountParams and ContactFilters now, ahead of the deploy, matching what the next regen produces. Callers that still pass it keep working (extra="allow" sends it through, the API ignores it) and get a DeprecationWarning from the request base. The CLI keeps --email-validated/--no-email-validated so existing scripts do not break, but warns on stderr and stops sending it. The bulk contacts CSV keeps its email_validated column for downstream readers. gen_requests.py --check and check_contract.py report the three fields against api-dev until the platform change deploys there. --- CHANGELOG.md | 6 +++ .../src/discolike_cli/contacts.py | 18 +++++++-- .../discolike-cli/tests/test_contacts_cli.py | 37 ++++++++++++++++++- .../src/discolike/_generated/requests.py | 21 ----------- packages/discolike/src/discolike/_models.py | 15 ++++++++ .../discolike/tests/test_requests_module.py | 19 ++++++++++ 6 files changed, 89 insertions(+), 27 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4c4d80c..4bb7cb7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # Changelog +## Unreleased + +- SDK: the `email_validated` contact filter on `contacts.search`, `contacts.count` and `contacts.discover` is deprecated, and the API now ignores it. The flag behind it only recorded that a third-party verifier had run at some point, not what it found, so a large share of "validated" addresses were catch-all. It is dropped from `ContactsSearchParams`, `ContactsCountParams` and `ContactFilters`; passing it still works (it goes out as an extra field) but raises a `DeprecationWarning`. Contact results keep their `email_validated` field, which is now `None` until you run email verification on the contact, then `True` or `False`. +- CLI: `contacts search`, `count` and `discover` still accept `--email-validated/--no-email-validated` so existing scripts keep running, but the flag is ignored: it prints a deprecation warning on stderr and is not sent. `bulk contacts` keeps its `email_validated` CSV column, empty unless the contact was verified. +- SDK (note for maintainers): `email_validated` was removed from `discolike/_generated/requests.py` by hand, ahead of the platform deploy that marks it deprecated in the OpenAPI spec (the generator drops deprecated fields). Until api-dev serves that spec, `scripts/gen_requests.py --check` reports the three fields as a diff and `scripts/check_contract.py` reports them as undeclared params; that is the pending release, not drift. + ## 0.5.0 (2026-10-06) - **Breaking:** Python 3.10 is no longer supported (end of life October 2026); the SDK and CLI require Python 3.11+. diff --git a/packages/discolike-cli/src/discolike_cli/contacts.py b/packages/discolike-cli/src/discolike_cli/contacts.py index c4281c0..6f009ad 100644 --- a/packages/discolike-cli/src/discolike_cli/contacts.py +++ b/packages/discolike-cli/src/discolike_cli/contacts.py @@ -38,7 +38,11 @@ NEGATE_FILTER_STATE_HELP = "Exclude contacts at companies in this state (repeatable)." PERSON_STATE_HELP = "Filter by contact state/region (repeatable)." PERSONA_ID_HELP = "Filter by persona ID (repeatable)." -EMAIL_VALIDATED_HELP = "Only contacts with (or without) a validated email address." +EMAIL_VALIDATED_HELP = "Deprecated, ignored. Run email verification instead." +EMAIL_VALIDATED_WARNING = ( + "warning: --email-validated is deprecated and ignored; contact search no longer filters on email " + "validation. Run email verification on the results instead." +) HAS_PHONE_HELP = "Only contacts with (or without) a phone number." HAS_MOBILE_HELP = "Only contacts with (or without) a mobile phone number." HAS_LINKEDIN_HELP = "Only contacts with (or without) a LinkedIn profile." @@ -50,6 +54,12 @@ INCLUDE_SEARCH_CONTACTS_HELP = "Include contacts from the search index (broader coverage)." CONSENSUS_HELP = "Number of query vectors to combine for consensus search." + +def _warn_email_validated(*, email_validated: bool | None) -> None: + if email_validated is not None: + typer.echo(EMAIL_VALIDATED_WARNING, err=True) + + app = typer.Typer( help="Find contacts: search and count by filters, look up or match individuals, and run bulk/generative discovery jobs." ) @@ -111,6 +121,7 @@ def search_command( """Search contacts matching the given filters.""" from discolike_cli.main import get_client + _warn_email_validated(email_validated=email_validated) kwargs = _merge_params( param, params_file, @@ -138,7 +149,6 @@ def search_command( negate_filter_state=negate_filter_state, employee_range=employee_range, has_email=has_email, - email_validated=email_validated, has_phone=has_phone, has_mobile=has_mobile, has_linkedin=has_linkedin, @@ -212,6 +222,7 @@ def count_command( """Count contacts matching the given filters.""" from discolike_cli.main import get_client + _warn_email_validated(email_validated=email_validated) kwargs = _merge_params( param, params_file, @@ -239,7 +250,6 @@ def count_command( negate_filter_state=negate_filter_state, employee_range=employee_range, has_email=has_email, - email_validated=email_validated, has_phone=has_phone, has_mobile=has_mobile, has_linkedin=has_linkedin, @@ -387,6 +397,7 @@ def discover_command( """Discover contacts grouped by company for the given filters.""" from discolike_cli.main import get_client + _warn_email_validated(email_validated=email_validated) kwargs = _merge_params( param, params_file, @@ -414,7 +425,6 @@ def discover_command( negate_filter_state=negate_filter_state, employee_range=employee_range, has_email=has_email, - email_validated=email_validated, has_phone=has_phone, has_mobile=has_mobile, has_linkedin=has_linkedin, diff --git a/packages/discolike-cli/tests/test_contacts_cli.py b/packages/discolike-cli/tests/test_contacts_cli.py index 002ede3..ad551b5 100644 --- a/packages/discolike-cli/tests/test_contacts_cli.py +++ b/packages/discolike-cli/tests/test_contacts_cli.py @@ -394,7 +394,7 @@ def _assert_new_contact_filters(params: httpx2.QueryParams) -> None: assert params.get_list("persona_id") == ["7"] assert params.get_list("filter_state") == ["TX"] assert params.get_list("negate_filter_state") == ["NY"] - assert params.get("email_validated") == "true" + assert "email_validated" not in params assert params.get("has_phone") == "true" assert params.get("has_mobile") == "false" assert params.get("has_linkedin") == "true" @@ -455,7 +455,6 @@ def handler(request: httpx2.Request) -> httpx2.Response: "persona_id": [7], "filter_state": ["TX"], "negate_filter_state": ["NY"], - "email_validated": True, "has_phone": True, "has_mobile": False, "has_linkedin": True, @@ -469,6 +468,40 @@ def handler(request: httpx2.Request) -> httpx2.Response: } +EMAIL_VALIDATED_RESPONSES = { + "search": [], + "count": {"count": 1}, + "discover": {"results": {}, "total_contacts": 0}, +} + + +@pytest.mark.parametrize("command", sorted(EMAIL_VALIDATED_RESPONSES)) +@pytest.mark.parametrize("flag", ["--email-validated", "--no-email-validated"]) +def test_contacts_email_validated_warns_and_is_not_sent( + install_build_client: Callable[[Handler], None], command: str, flag: str +) -> None: + captured: list[httpx2.Request] = [] + + def handler(request: httpx2.Request) -> httpx2.Response: + captured.append(request) + return httpx2.Response(200, json=EMAIL_VALIDATED_RESPONSES[command]) + + install_build_client(handler) + result = runner.invoke(app, ["contacts", command, "--name", "Jane", flag]) + assert result.exit_code == 0, result.output + assert "--email-validated is deprecated and ignored" in result.stderr + (request,) = captured + assert "email_validated" not in request.url.params + assert b"email_validated" not in request.content + + +def test_contacts_without_email_validated_does_not_warn(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 "deprecated" not in result.stderr + + def test_contacts_generate_forwards_full_and_partial_domains(install_build_client: Callable[[Handler], None]) -> None: captured: dict[str, object] = {} diff --git a/packages/discolike/src/discolike/_generated/requests.py b/packages/discolike/src/discolike/_generated/requests.py index fb0de5a..1dfd1dc 100644 --- a/packages/discolike/src/discolike/_generated/requests.py +++ b/packages/discolike/src/discolike/_generated/requests.py @@ -358,13 +358,6 @@ class ContactsSearchParams(DiscolikeRequest): bool | None, Field(description="Only include contacts with email addresses.", title="Has Email"), ] = False - email_validated: Annotated[ - bool | None, - Field( - description="Only include contacts with validated email addresses.", - title="Email Validated", - ), - ] = False has_phone: Annotated[ bool | None, Field(description="Only include contacts with phone numbers.", title="Has Phone"), @@ -787,13 +780,6 @@ class ContactsCountParams(DiscolikeRequest): bool | None, Field(description="Only include contacts with email addresses.", title="Has Email"), ] = False - email_validated: Annotated[ - bool | None, - Field( - description="Only include contacts with validated email addresses.", - title="Email Validated", - ), - ] = False has_phone: Annotated[ bool | None, Field(description="Only include contacts with phone numbers.", title="Has Phone"), @@ -1248,13 +1234,6 @@ class ContactFilters(DiscolikeRequest): bool | None, Field(description="Only include contacts with email addresses.", title="Has Email"), ] = False - email_validated: Annotated[ - bool | None, - Field( - description="Only include contacts with validated email addresses.", - title="Email Validated", - ), - ] = False has_phone: Annotated[ bool | None, Field(description="Only include contacts with phone numbers.", title="Has Phone"), diff --git a/packages/discolike/src/discolike/_models.py b/packages/discolike/src/discolike/_models.py index 35bf7a4..6b659dc 100644 --- a/packages/discolike/src/discolike/_models.py +++ b/packages/discolike/src/discolike/_models.py @@ -1,6 +1,7 @@ from __future__ import annotations import math +import warnings from collections.abc import Callable from typing import Any @@ -23,6 +24,12 @@ RADIUS_MILE_SUFFIX = "mi" MAX_GEO_SHAPES = 10 MESSAGE_CONTENT_FIELDS = frozenset({"text", "intake"}) +DEPRECATED_REQUEST_FIELDS: dict[str, str] = { + "email_validated": ( + "email_validated is deprecated and ignored by the API: contact search no longer filters on " + "email validation. Verify the returned emails instead." + ), +} class DiscolikeModel(pydantic.BaseModel): @@ -67,6 +74,14 @@ def _validate_geo_shapes(self) -> DiscolikeRequest: raise ValueError(f"{total} geo shapes (lat/lon, geo and bbox together); at most {MAX_GEO_SHAPES}") return self + @pydantic.model_validator(mode="before") + @classmethod + def _warn_deprecated_fields(cls, data: object) -> object: + if isinstance(data, dict): + for name in sorted(DEPRECATED_REQUEST_FIELDS.keys() & data.keys()): + warnings.warn(DEPRECATED_REQUEST_FIELDS[name], DeprecationWarning, stacklevel=2) + return data + # Mirrors the platform's text_or_intake rule on ProspectingMessageRequest, which the spec cannot express. @pydantic.model_validator(mode="after") def _validate_message_content(self) -> DiscolikeRequest: diff --git a/packages/discolike/tests/test_requests_module.py b/packages/discolike/tests/test_requests_module.py index c40db90..21d52f4 100644 --- a/packages/discolike/tests/test_requests_module.py +++ b/packages/discolike/tests/test_requests_module.py @@ -1,8 +1,13 @@ +import warnings + import pydantic import pytest import discolike.requests as requests_module from discolike._models import DiscolikeRequest +from discolike.requests import ContactFilters +from discolike.requests import ContactsCountParams +from discolike.requests import ContactsSearchParams from discolike.requests import DiscoverParams from discolike.requests import LLMProviderUpdateRequest from discolike.requests import MatchCompanyParams @@ -38,3 +43,17 @@ def test_required_nullable_field_survives_generation() -> None: def test_models_allow_extra_fields() -> None: assert MatchCompanyParams.model_validate({"name": "Acme", "future_flag": 1}).to_wire()["future_flag"] == 1 + + +@pytest.mark.parametrize("model", [ContactsSearchParams, ContactsCountParams, ContactFilters]) +def test_email_validated_is_deprecated_but_still_accepted(model: type[DiscolikeRequest]) -> None: + assert "email_validated" not in model.model_fields + with pytest.warns(DeprecationWarning, match="email_validated is deprecated"): + request = model.model_validate({"email_validated": True}) + assert request.to_wire() == {"email_validated": True} + + +def test_unset_email_validated_does_not_warn() -> None: + with warnings.catch_warnings(): + warnings.simplefilter("error") + assert ContactsSearchParams(has_email=True).to_wire() == {"has_email": True} From 87af5dab5b2cb3a260b796a0e96a38faa73ec3a0 Mon Sep 17 00:00:00 2001 From: Daniel Yudelevich Date: Wed, 7 Oct 2026 12:57:00 -0700 Subject: [PATCH 02/11] Match the docs wording for the email_validated deprecation --- packages/discolike-cli/src/discolike_cli/contacts.py | 8 +++++--- packages/discolike/src/discolike/_models.py | 4 ++-- 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/packages/discolike-cli/src/discolike_cli/contacts.py b/packages/discolike-cli/src/discolike_cli/contacts.py index 6f009ad..ffe9f83 100644 --- a/packages/discolike-cli/src/discolike_cli/contacts.py +++ b/packages/discolike-cli/src/discolike_cli/contacts.py @@ -38,10 +38,12 @@ NEGATE_FILTER_STATE_HELP = "Exclude contacts at companies in this state (repeatable)." PERSON_STATE_HELP = "Filter by contact state/region (repeatable)." PERSONA_ID_HELP = "Filter by persona ID (repeatable)." -EMAIL_VALIDATED_HELP = "Deprecated, ignored. Run email verification instead." +EMAIL_VALIDATED_HELP = ( + "Deprecated, ignored. Contact search no longer filters on email verification; verify emails instead." +) EMAIL_VALIDATED_WARNING = ( - "warning: --email-validated is deprecated and ignored; contact search no longer filters on email " - "validation. Run email verification on the results instead." + "warning: --email-validated is deprecated and ignored. " + "Contact search no longer filters on email verification; verify emails instead." ) HAS_PHONE_HELP = "Only contacts with (or without) a phone number." HAS_MOBILE_HELP = "Only contacts with (or without) a mobile phone number." diff --git a/packages/discolike/src/discolike/_models.py b/packages/discolike/src/discolike/_models.py index 6b659dc..224e4fb 100644 --- a/packages/discolike/src/discolike/_models.py +++ b/packages/discolike/src/discolike/_models.py @@ -26,8 +26,8 @@ MESSAGE_CONTENT_FIELDS = frozenset({"text", "intake"}) DEPRECATED_REQUEST_FIELDS: dict[str, str] = { "email_validated": ( - "email_validated is deprecated and ignored by the API: contact search no longer filters on " - "email validation. Verify the returned emails instead." + "email_validated is deprecated and ignored. " + "Contact search no longer filters on email verification; verify emails instead." ), } From 6002df0dc112b8083f6f434f5cebd72a3b7630d2 Mon Sep 17 00:00:00 2001 From: Daniel Yudelevich Date: Wed, 7 Oct 2026 13:00:13 -0700 Subject: [PATCH 03/11] fix(cli): map --email-validated to has_email Platform !971 changed course: the API no longer ignores email_validated=true but applies it as has_email=true, so old callers keep getting contacts that have an email instead of silently widening to contacts with none. Dropping the CLI flag would have widened exactly that way, so --email-validated now sends has_email=true (winning over --no-has-email, as the API's validator does) and --no-email-validated warns and does nothing. The SDK passthrough already reaches the API's mapping; only its warning text changes. check_contract.py skipped query params only when "deprecated" sat on the parameter object, but FastAPI puts it inside the parameter's schema. gen_requests.py already honoured that location, so against the spec built from the !971 branch the generator dropped email_validated while the contract check still demanded it on the two GET routes. --- CHANGELOG.md | 7 +-- .../src/discolike_cli/contacts.py | 29 +++++------ .../discolike-cli/tests/test_contacts_cli.py | 48 +++++++++++++++---- packages/discolike/src/discolike/_models.py | 4 +- .../discolike/tests/test_contract_registry.py | 12 +++++ scripts/check_contract.py | 4 +- 6 files changed, 75 insertions(+), 29 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4bb7cb7..dc0d225 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,9 +2,10 @@ ## Unreleased -- SDK: the `email_validated` contact filter on `contacts.search`, `contacts.count` and `contacts.discover` is deprecated, and the API now ignores it. The flag behind it only recorded that a third-party verifier had run at some point, not what it found, so a large share of "validated" addresses were catch-all. It is dropped from `ContactsSearchParams`, `ContactsCountParams` and `ContactFilters`; passing it still works (it goes out as an extra field) but raises a `DeprecationWarning`. Contact results keep their `email_validated` field, which is now `None` until you run email verification on the contact, then `True` or `False`. -- CLI: `contacts search`, `count` and `discover` still accept `--email-validated/--no-email-validated` so existing scripts keep running, but the flag is ignored: it prints a deprecation warning on stderr and is not sent. `bulk contacts` keeps its `email_validated` CSV column, empty unless the contact was verified. -- SDK (note for maintainers): `email_validated` was removed from `discolike/_generated/requests.py` by hand, ahead of the platform deploy that marks it deprecated in the OpenAPI spec (the generator drops deprecated fields). Until api-dev serves that spec, `scripts/gen_requests.py --check` reports the three fields as a diff and `scripts/check_contract.py` reports them as undeclared params; that is the pending release, not drift. +- SDK: the `email_validated` contact filter on `contacts.search`, `contacts.count` and `contacts.discover` is deprecated: the API treats it as `has_email`. The flag behind it only recorded that a third-party verifier had run at some point, not what it found, so a large share of "validated" addresses were catch-all. Emails are not pre-verified; run email verification instead. It is dropped from `ContactsSearchParams`, `ContactsCountParams` and `ContactFilters`; passing it still works (it goes out as an extra field and the API applies it as `has_email`) but raises a `DeprecationWarning`. Contact results keep their `email_validated` field, which is now `None` until you run email verification on the contact, then `True` or `False`. +- CLI: `contacts search`, `count` and `discover` keep `--email-validated/--no-email-validated` so existing scripts keep running. `--email-validated` is now the same as `--has-email` and prints a deprecation warning on stderr; `--no-email-validated` warns and has no effect. Neither sends `email_validated`. `bulk contacts` keeps its `email_validated` CSV column, empty unless the contact was verified. +- SDK (note for maintainers): `email_validated` was removed from `discolike/_generated/requests.py` by hand, ahead of the platform deploy that marks it deprecated in the OpenAPI spec (the generator drops deprecated fields). Until the deployed spec carries that flag, `scripts/gen_requests.py --check` reports the three fields as a diff and `scripts/check_contract.py` reports them as undeclared params; that is the pending release, not drift. +- SDK (note for maintainers): `scripts/check_contract.py` now skips a query parameter marked deprecated inside its `schema`, which is where FastAPI puts a deprecated model field. `scripts/gen_requests.py` already dropped those, so the two scripts disagreed on GET routes. ## 0.5.0 (2026-10-06) diff --git a/packages/discolike-cli/src/discolike_cli/contacts.py b/packages/discolike-cli/src/discolike_cli/contacts.py index ffe9f83..0f82bcb 100644 --- a/packages/discolike-cli/src/discolike_cli/contacts.py +++ b/packages/discolike-cli/src/discolike_cli/contacts.py @@ -38,13 +38,12 @@ NEGATE_FILTER_STATE_HELP = "Exclude contacts at companies in this state (repeatable)." PERSON_STATE_HELP = "Filter by contact state/region (repeatable)." PERSONA_ID_HELP = "Filter by persona ID (repeatable)." -EMAIL_VALIDATED_HELP = ( - "Deprecated, ignored. Contact search no longer filters on email verification; verify emails instead." -) +EMAIL_VALIDATED_HELP = "Deprecated: same as --has-email. Emails are not pre-verified; run email verification instead." EMAIL_VALIDATED_WARNING = ( - "warning: --email-validated is deprecated and ignored. " - "Contact search no longer filters on email verification; verify emails instead." + "warning: --email-validated is deprecated: same as --has-email. " + "Emails are not pre-verified; run email verification instead." ) +NO_EMAIL_VALIDATED_WARNING = "warning: --no-email-validated is deprecated and has no effect." HAS_PHONE_HELP = "Only contacts with (or without) a phone number." HAS_MOBILE_HELP = "Only contacts with (or without) a mobile phone number." HAS_LINKEDIN_HELP = "Only contacts with (or without) a LinkedIn profile." @@ -57,9 +56,14 @@ CONSENSUS_HELP = "Number of query vectors to combine for consensus search." -def _warn_email_validated(*, email_validated: bool | None) -> None: - if email_validated is not None: - typer.echo(EMAIL_VALIDATED_WARNING, err=True) +def _email_validated_as_has_email(*, email_validated: bool | None, has_email: bool | None) -> bool | None: + if email_validated is None: + return has_email + if not email_validated: + typer.echo(NO_EMAIL_VALIDATED_WARNING, err=True) + return has_email + typer.echo(EMAIL_VALIDATED_WARNING, err=True) + return True app = typer.Typer( @@ -123,7 +127,6 @@ def search_command( """Search contacts matching the given filters.""" from discolike_cli.main import get_client - _warn_email_validated(email_validated=email_validated) kwargs = _merge_params( param, params_file, @@ -150,7 +153,7 @@ def search_command( filter_state=filter_state, negate_filter_state=negate_filter_state, employee_range=employee_range, - has_email=has_email, + has_email=_email_validated_as_has_email(email_validated=email_validated, has_email=has_email), has_phone=has_phone, has_mobile=has_mobile, has_linkedin=has_linkedin, @@ -224,7 +227,6 @@ def count_command( """Count contacts matching the given filters.""" from discolike_cli.main import get_client - _warn_email_validated(email_validated=email_validated) kwargs = _merge_params( param, params_file, @@ -251,7 +253,7 @@ def count_command( filter_state=filter_state, negate_filter_state=negate_filter_state, employee_range=employee_range, - has_email=has_email, + has_email=_email_validated_as_has_email(email_validated=email_validated, has_email=has_email), has_phone=has_phone, has_mobile=has_mobile, has_linkedin=has_linkedin, @@ -399,7 +401,6 @@ def discover_command( """Discover contacts grouped by company for the given filters.""" from discolike_cli.main import get_client - _warn_email_validated(email_validated=email_validated) kwargs = _merge_params( param, params_file, @@ -426,7 +427,7 @@ def discover_command( filter_state=filter_state, negate_filter_state=negate_filter_state, employee_range=employee_range, - has_email=has_email, + has_email=_email_validated_as_has_email(email_validated=email_validated, has_email=has_email), has_phone=has_phone, has_mobile=has_mobile, has_linkedin=has_linkedin, diff --git a/packages/discolike-cli/tests/test_contacts_cli.py b/packages/discolike-cli/tests/test_contacts_cli.py index ad551b5..96a8d64 100644 --- a/packages/discolike-cli/tests/test_contacts_cli.py +++ b/packages/discolike-cli/tests/test_contacts_cli.py @@ -6,6 +6,7 @@ import httpx2 import pytest from typer.testing import CliRunner +from typer.testing import Result from discolike_cli.main import app from discolike_testkit import Handler @@ -395,6 +396,7 @@ def _assert_new_contact_filters(params: httpx2.QueryParams) -> None: assert params.get_list("filter_state") == ["TX"] assert params.get_list("negate_filter_state") == ["NY"] assert "email_validated" not in params + assert params.get("has_email") == "true" assert params.get("has_phone") == "true" assert params.get("has_mobile") == "false" assert params.get("has_linkedin") == "true" @@ -455,6 +457,7 @@ def handler(request: httpx2.Request) -> httpx2.Response: "persona_id": [7], "filter_state": ["TX"], "negate_filter_state": ["NY"], + "has_email": True, "has_phone": True, "has_mobile": False, "has_linkedin": True, @@ -475,11 +478,9 @@ def handler(request: httpx2.Request) -> httpx2.Response: } -@pytest.mark.parametrize("command", sorted(EMAIL_VALIDATED_RESPONSES)) -@pytest.mark.parametrize("flag", ["--email-validated", "--no-email-validated"]) -def test_contacts_email_validated_warns_and_is_not_sent( - install_build_client: Callable[[Handler], None], command: str, flag: str -) -> None: +def _invoke_contacts( + install_build_client: Callable[[Handler], None], command: str, *flags: str +) -> tuple[Result, dict[str, object]]: captured: list[httpx2.Request] = [] def handler(request: httpx2.Request) -> httpx2.Response: @@ -487,12 +488,41 @@ def handler(request: httpx2.Request) -> httpx2.Response: return httpx2.Response(200, json=EMAIL_VALIDATED_RESPONSES[command]) install_build_client(handler) - result = runner.invoke(app, ["contacts", command, "--name", "Jane", flag]) + result = runner.invoke(app, ["contacts", command, "--name", "Jane", *flags]) assert result.exit_code == 0, result.output - assert "--email-validated is deprecated and ignored" in result.stderr (request,) = captured - assert "email_validated" not in request.url.params - assert b"email_validated" not in request.content + if request.method == "GET": + return result, { + key: json.loads(value) if value in ("true", "false") else value for key, value in request.url.params.items() + } + return result, json.loads(request.content) + + +@pytest.mark.parametrize("command", sorted(EMAIL_VALIDATED_RESPONSES)) +def test_contacts_email_validated_warns_and_sends_has_email( + install_build_client: Callable[[Handler], None], command: str +) -> None: + result, sent = _invoke_contacts(install_build_client, command, "--email-validated") + assert "--email-validated is deprecated: same as --has-email" in result.stderr + assert sent["has_email"] is True + assert "email_validated" not in sent + + +@pytest.mark.parametrize("command", sorted(EMAIL_VALIDATED_RESPONSES)) +def test_contacts_no_email_validated_warns_and_sends_nothing( + install_build_client: Callable[[Handler], None], command: str +) -> None: + result, sent = _invoke_contacts(install_build_client, command, "--no-email-validated") + assert "--no-email-validated is deprecated and has no effect" in result.stderr + assert "has_email" not in sent + assert "email_validated" not in sent + + +def test_contacts_email_validated_wins_over_no_has_email_like_the_api( + install_build_client: Callable[[Handler], None], +) -> None: + _, sent = _invoke_contacts(install_build_client, "search", "--no-has-email", "--email-validated") + assert sent["has_email"] is True def test_contacts_without_email_validated_does_not_warn(install_build_client: Callable[[Handler], None]) -> None: diff --git a/packages/discolike/src/discolike/_models.py b/packages/discolike/src/discolike/_models.py index 224e4fb..b05f00a 100644 --- a/packages/discolike/src/discolike/_models.py +++ b/packages/discolike/src/discolike/_models.py @@ -26,8 +26,8 @@ MESSAGE_CONTENT_FIELDS = frozenset({"text", "intake"}) DEPRECATED_REQUEST_FIELDS: dict[str, str] = { "email_validated": ( - "email_validated is deprecated and ignored. " - "Contact search no longer filters on email verification; verify emails instead." + "email_validated is deprecated: the API treats it as has_email. " + "Emails are not pre-verified; run email verification instead." ), } diff --git a/packages/discolike/tests/test_contract_registry.py b/packages/discolike/tests/test_contract_registry.py index 06b5962..ecde573 100644 --- a/packages/discolike/tests/test_contract_registry.py +++ b/packages/discolike/tests/test_contract_registry.py @@ -277,6 +277,18 @@ def test_check_passes_when_model_fields_match_spec_params(): assert check_contract.check(spec, routes) == [] +def test_check_skips_query_params_deprecated_inside_their_schema(): + check_contract = _load_check_contract() + from discolike.requests import MatchCompanyParams + + routes = _route(check_contract, "MatchResource", "company") + spec = _spec_with_params("/match", list(MatchCompanyParams.model_fields)) + spec["paths"]["/match"]["get"]["parameters"].append( + {"name": "legacy_flag", "in": "query", "schema": {"type": "boolean", "deprecated": True}} + ) + assert check_contract.check(spec, routes) == [] + + def test_check_reports_model_field_the_spec_lacks(): check_contract = _load_check_contract() from discolike.requests import MatchCompanyParams diff --git a/scripts/check_contract.py b/scripts/check_contract.py index afffab6..0c7234b 100644 --- a/scripts/check_contract.py +++ b/scripts/check_contract.py @@ -156,7 +156,9 @@ def _spec_request_fields(*, spec: dict, operation: dict) -> set[str]: fields = { parameter["name"] for parameter in operation.get("parameters", []) - if parameter["in"] == QUERY_LOCATION and not parameter.get("deprecated", False) + if parameter["in"] == QUERY_LOCATION + and not parameter.get("deprecated", False) + and not parameter.get("schema", {}).get("deprecated", False) } fields |= { name From 90add8dc8029f195b5f2c312579d9af08af2fd66 Mon Sep 17 00:00:00 2001 From: Daniel Yudelevich Date: Wed, 7 Oct 2026 13:02:20 -0700 Subject: [PATCH 04/11] fix(sdk,cli): one email_validated deprecation sentence everywhere The API, docs, SDK and CLI now carry the same deprecation text, so a user reading any surface sees the same contract. An explicit --has-email or --no-has-email now beats --email-validated: the user said what they want for has_email, and the deprecated flag only fills that in when they did not. --- CHANGELOG.md | 4 ++-- .../discolike-cli/src/discolike_cli/contacts.py | 15 ++++++++++----- packages/discolike-cli/tests/test_contacts_cli.py | 13 ++++++++----- packages/discolike/src/discolike/_models.py | 4 ++-- 4 files changed, 22 insertions(+), 14 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index dc0d225..9b5fb7f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,8 +2,8 @@ ## Unreleased -- SDK: the `email_validated` contact filter on `contacts.search`, `contacts.count` and `contacts.discover` is deprecated: the API treats it as `has_email`. The flag behind it only recorded that a third-party verifier had run at some point, not what it found, so a large share of "validated" addresses were catch-all. Emails are not pre-verified; run email verification instead. It is dropped from `ContactsSearchParams`, `ContactsCountParams` and `ContactFilters`; passing it still works (it goes out as an extra field and the API applies it as `has_email`) but raises a `DeprecationWarning`. Contact results keep their `email_validated` field, which is now `None` until you run email verification on the contact, then `True` or `False`. -- CLI: `contacts search`, `count` and `discover` keep `--email-validated/--no-email-validated` so existing scripts keep running. `--email-validated` is now the same as `--has-email` and prints a deprecation warning on stderr; `--no-email-validated` warns and has no effect. Neither sends `email_validated`. `bulk contacts` keeps its `email_validated` CSV column, empty unless the contact was verified. +- SDK: the `email_validated` contact filter on `contacts.search`, `contacts.count` and `contacts.discover` is deprecated. Treated as has_email: contact search no longer filters on email verification; verify emails instead. The flag behind it only recorded that a third-party verifier had run at some point, not what it found, so a large share of "validated" addresses were catch-all. It is dropped from `ContactsSearchParams`, `ContactsCountParams` and `ContactFilters`; passing it still works (it goes out as an extra field and the API applies it as `has_email`) but raises a `DeprecationWarning`. Contact results keep their `email_validated` field, which is now `None` until you run email verification on the contact, then `True` or `False`. +- CLI: `contacts search`, `count` and `discover` keep `--email-validated/--no-email-validated` so existing scripts keep running. `--email-validated` is deprecated and treated as `--has-email` (an explicit `--has-email` or `--no-has-email` takes precedence) and prints a deprecation warning on stderr; `--no-email-validated` warns and has no effect. Neither sends `email_validated`. `bulk contacts` keeps its `email_validated` CSV column, empty unless the contact was verified. - SDK (note for maintainers): `email_validated` was removed from `discolike/_generated/requests.py` by hand, ahead of the platform deploy that marks it deprecated in the OpenAPI spec (the generator drops deprecated fields). Until the deployed spec carries that flag, `scripts/gen_requests.py --check` reports the three fields as a diff and `scripts/check_contract.py` reports them as undeclared params; that is the pending release, not drift. - SDK (note for maintainers): `scripts/check_contract.py` now skips a query parameter marked deprecated inside its `schema`, which is where FastAPI puts a deprecated model field. `scripts/gen_requests.py` already dropped those, so the two scripts disagreed on GET routes. diff --git a/packages/discolike-cli/src/discolike_cli/contacts.py b/packages/discolike-cli/src/discolike_cli/contacts.py index 0f82bcb..b29b4ea 100644 --- a/packages/discolike-cli/src/discolike_cli/contacts.py +++ b/packages/discolike-cli/src/discolike_cli/contacts.py @@ -38,12 +38,17 @@ NEGATE_FILTER_STATE_HELP = "Exclude contacts at companies in this state (repeatable)." PERSON_STATE_HELP = "Filter by contact state/region (repeatable)." PERSONA_ID_HELP = "Filter by persona ID (repeatable)." -EMAIL_VALIDATED_HELP = "Deprecated: same as --has-email. Emails are not pre-verified; run email verification instead." +EMAIL_VALIDATED_HELP = ( + "Deprecated. Treated as has_email: contact search no longer filters on email verification; verify emails instead." +) EMAIL_VALIDATED_WARNING = ( - "warning: --email-validated is deprecated: same as --has-email. " - "Emails are not pre-verified; run email verification instead." + "warning: --email-validated is deprecated. Treated as has_email: " + "contact search no longer filters on email verification; verify emails instead." +) +NO_EMAIL_VALIDATED_WARNING = ( + "warning: --no-email-validated is deprecated and has no effect. " + "Contact search no longer filters on email verification; verify emails instead." ) -NO_EMAIL_VALIDATED_WARNING = "warning: --no-email-validated is deprecated and has no effect." HAS_PHONE_HELP = "Only contacts with (or without) a phone number." HAS_MOBILE_HELP = "Only contacts with (or without) a mobile phone number." HAS_LINKEDIN_HELP = "Only contacts with (or without) a LinkedIn profile." @@ -63,7 +68,7 @@ def _email_validated_as_has_email(*, email_validated: bool | None, has_email: bo typer.echo(NO_EMAIL_VALIDATED_WARNING, err=True) return has_email typer.echo(EMAIL_VALIDATED_WARNING, err=True) - return True + return True if has_email is None else has_email app = typer.Typer( diff --git a/packages/discolike-cli/tests/test_contacts_cli.py b/packages/discolike-cli/tests/test_contacts_cli.py index 96a8d64..b8c8cfc 100644 --- a/packages/discolike-cli/tests/test_contacts_cli.py +++ b/packages/discolike-cli/tests/test_contacts_cli.py @@ -503,7 +503,7 @@ def test_contacts_email_validated_warns_and_sends_has_email( install_build_client: Callable[[Handler], None], command: str ) -> None: result, sent = _invoke_contacts(install_build_client, command, "--email-validated") - assert "--email-validated is deprecated: same as --has-email" in result.stderr + assert "--email-validated is deprecated. Treated as has_email" in result.stderr assert sent["has_email"] is True assert "email_validated" not in sent @@ -518,11 +518,14 @@ def test_contacts_no_email_validated_warns_and_sends_nothing( assert "email_validated" not in sent -def test_contacts_email_validated_wins_over_no_has_email_like_the_api( - install_build_client: Callable[[Handler], None], +@pytest.mark.parametrize(("has_email_flag", "expected"), [("--has-email", True), ("--no-has-email", False)]) +def test_contacts_explicit_has_email_wins_over_email_validated( + install_build_client: Callable[[Handler], None], has_email_flag: str, expected: bool ) -> None: - _, sent = _invoke_contacts(install_build_client, "search", "--no-has-email", "--email-validated") - assert sent["has_email"] is True + result, sent = _invoke_contacts(install_build_client, "search", has_email_flag, "--email-validated") + assert "--email-validated is deprecated" in result.stderr + assert sent["has_email"] is expected + assert "email_validated" not in sent def test_contacts_without_email_validated_does_not_warn(install_build_client: Callable[[Handler], None]) -> None: diff --git a/packages/discolike/src/discolike/_models.py b/packages/discolike/src/discolike/_models.py index b05f00a..a806b7f 100644 --- a/packages/discolike/src/discolike/_models.py +++ b/packages/discolike/src/discolike/_models.py @@ -26,8 +26,8 @@ MESSAGE_CONTENT_FIELDS = frozenset({"text", "intake"}) DEPRECATED_REQUEST_FIELDS: dict[str, str] = { "email_validated": ( - "email_validated is deprecated: the API treats it as has_email. " - "Emails are not pre-verified; run email verification instead." + "email_validated is deprecated. Treated as has_email: " + "contact search no longer filters on email verification; verify emails instead." ), } From d968763342929626d3f16e2eb15b29fc6d21ce12 Mon Sep 17 00:00:00 2001 From: Daniel Yudelevich Date: Wed, 7 Oct 2026 15:37:50 -0700 Subject: [PATCH 05/11] Add prospecting plan command to the CLI The API now leaves a plan's contact engine, company check engine or search provider unselected when an organization has several providers and none is the default, and approve returns 409 until one is picked. The SDK already wraps PATCH /prospecting/runs/{id}/plan, but CLI users had no way to reach it, so such a run could not be approved from the CLI. `prospecting plan` forwards every ProspectingPlanSettings field and sends only the flags passed, so it also covers resizing and spend limits without restarting the run. --- CHANGELOG.md | 1 + packages/discolike-cli/README.md | 8 +- .../src/discolike_cli/prospecting.py | 77 +++++++++++++++- .../tests/test_prospecting_cli.py | 90 +++++++++++++++++++ 4 files changed, 173 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9b5fb7f..5d76494 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,7 @@ - CLI: `contacts search`, `count` and `discover` keep `--email-validated/--no-email-validated` so existing scripts keep running. `--email-validated` is deprecated and treated as `--has-email` (an explicit `--has-email` or `--no-has-email` takes precedence) and prints a deprecation warning on stderr; `--no-email-validated` warns and has no effect. Neither sends `email_validated`. `bulk contacts` keeps its `email_validated` CSV column, empty unless the contact was verified. - SDK (note for maintainers): `email_validated` was removed from `discolike/_generated/requests.py` by hand, ahead of the platform deploy that marks it deprecated in the OpenAPI spec (the generator drops deprecated fields). Until the deployed spec carries that flag, `scripts/gen_requests.py --check` reports the three fields as a diff and `scripts/check_contract.py` reports them as undeclared params; that is the pending release, not drift. - SDK (note for maintainers): `scripts/check_contract.py` now skips a query parameter marked deprecated inside its `schema`, which is where FastAPI puts a deprecated model field. `scripts/gen_requests.py` already dropped those, so the two scripts disagreed on GET routes. +- CLI: `prospecting plan RUN_ID --plan-version N` changes a proposed plan before approval, wrapping `update_plan()`: `--contact-integration-id`, `--validation-integration-id`, `--search-provider-id` (`none` skips web research), `--target-companies`, `--contacts-per-company`, `--deliverable`, `--goal`, `--max-spend-usd` and `--max-provider-spend-usd` (`0` removes a limit), sending only the flags passed. When an organization has several AI or search providers and none is the default, the plan leaves that engine at `selected: null` and `approve` returns 409 until one is picked; `plan` is how a CLI user picks it. The run comes back at a new `plan_version`, which is the one to approve. ## 0.5.0 (2026-10-06) diff --git a/packages/discolike-cli/README.md b/packages/discolike-cli/README.md index 44aa9ec..d2f58ad 100644 --- a/packages/discolike-cli/README.md +++ b/packages/discolike-cli/README.md @@ -55,8 +55,10 @@ discolike bulk contacts --domains-file companies.csv --per-company 10 --summary discolike prospecting start --brief "Find 100 US logistics companies and 3 operations directors each" --idempotency-key logistics-1 discolike prospecting start --brief "Find lookalikes of our customers and their CTOs" --customers-file customers.csv --idempotency-key seeded-1 discolike prospecting wait RUN_ID -# Review the proposed plan, then approve the exact version you saw: -discolike prospecting approve RUN_ID --plan-version 1 +# Review the proposed plan; change its engines or size if needed (returns a new plan_version): +discolike prospecting plan RUN_ID --plan-version 1 --contact-integration-id LLM_ID --search-provider-id SEARCH_ID +# Then approve the exact version you saw: +discolike prospecting approve RUN_ID --plan-version 2 discolike prospecting list --limit 20 discolike prospecting message RUN_ID --text "Make it 250 companies" --idempotency-key logistics-edit-1 discolike prospecting status RUN_ID --events-after 12 --messages-after 8 --limit 100 @@ -65,6 +67,8 @@ discolike prospecting cancel RUN_ID `wait` returns on `proposed`, `needs_input`, `completed`, `failed`, or `cancelled`. A timeout stops polling only. Inspect status and stop reason; completion does not guarantee full coverage. Message replies arrive through `status --messages-after`; follow `next_message_seq` and `reply_pending`. A "segment these" request keeps `reply_pending` true until the segments message is posted, which can take more than an hour. If grouping stops without an outcome, the flag clears on its own after about 90 minutes. +`plan` changes a proposed plan before approval and sends only the flags you pass: `--contact-integration-id`, `--validation-integration-id` and `--search-provider-id` take option ids from the plan message's `contact_engine`, `company_check_engine` and `search_provider` (`--search-provider-id none` skips web research), and `--target-companies`, `--contacts-per-company`, `--deliverable`, `--goal`, `--max-spend-usd` and `--max-provider-spend-usd` (`0` removes a limit) resize or re-scope it. When your organization has several AI or search providers and none is the default, the plan shows that engine with `selected: null` and `approve` returns 409 until `plan` picks one. The run comes back re-estimated at a new `plan_version`; approve that one. + `start` and `approve` default to pausing at checkpoints, like the web chat: a pilot check on large lists (`pilot`), a search drifting off target (`tail_quality`), candidates running out short of the target (`short`), the target being met (`target_reached`), another round of companies when the ones checked yield too few contacts (`top_up`), and, on wave runs, the researched contacts found before their emails are looked up (`contacts_review`, asked with `--auto` too; contacts already in DiscoLike's database get their lookups during research, which approving the plan starts; picking "Find emails" by number sends the question's `seq`, which the API needs to accept it). Pass `--auto` to never pause (except at a `top_up` round that would pass a spending limit); a poor pilot is then sharpened once and the run continues with a notice, stopping with `pilot_failed` only if the re-pilot fit is still under 20%. If sharpening itself fails, the run continues on the original criteria. On a terminal, `wait` shows the question, any sample companies and numbered replies at a checkpoint, sends your pick or your own text, and keeps waiting. Without a terminal, or with `--no-input`, it prints the run on stdout, a `needs_input` envelope (`message`, `stop_reason`, `suggested_replies`, `sample`) on stderr, and exits 7; answer with `prospecting message --text ""` (add `--question-seq` with the question's `seq` to answer only that question) and run `wait` again. Omit `--target-companies` and `--contacts-per-company` to infer counts from the brief (fallback 1,000 and 3; up to 10 contacts per company). Explicit values override the text. `--deliverable accounts` returns the checked companies only, with no people; `--goal companies` takes `--target-companies` matching companies and works only those instead of adding rounds until the contact target is met. Both default to `leads`. `--max-candidates` and `--max-actions` are automatic when omitted or `0`; their maxima are 100,000 and 10,000. Targets allow up to 10,000 companies, status pages up to 500 rows, and lists up to 50 runs. Work caps do not cap provider charges. diff --git a/packages/discolike-cli/src/discolike_cli/prospecting.py b/packages/discolike-cli/src/discolike_cli/prospecting.py index 334e9ad..e017a0c 100644 --- a/packages/discolike-cli/src/discolike_cli/prospecting.py +++ b/packages/discolike-cli/src/discolike_cli/prospecting.py @@ -19,6 +19,7 @@ from discolike.requests import ProspectingGetParams from discolike.requests import ProspectingListParams from discolike.requests import ProspectingMessageRequest +from discolike.requests import ProspectingPlanSettings from discolike.resources.prospecting import ProspectingMessage from discolike.resources.prospecting import ProspectingRun from discolike_cli._inputs import read_domains_file @@ -43,6 +44,18 @@ SEED_SEGMENT_HELP = ( "A customer segment id from the plan card to expand (repeatable); omitted keeps the card's selection." ) +PLAN_DELIVERABLE_HELP = "leads finds people at the companies; accounts returns the checked companies only." +PLAN_GOAL_HELP = ( + "leads keeps adding rounds until the target is met; companies works only --target-companies matching companies." +) +SEARCH_PROVIDER_HELP = "An option id from the plan's search_provider; none skips web research." +SPEND_HELP_SUFFIX = "0 removes the limit." +PLAN_HELP = ( + "Change a proposed plan's engines, size, deliverable or spending limits before approving it. Flags left out " + "keep their current choice; engine ids come from the latest plan message's contact_engine, " + "company_check_engine and search_provider options, and an engine shown with selected null must be picked " + "here before approve. The run comes back re-estimated at a new plan_version: approve that one." +) RESULTS_SEGMENT_HELP = "Group the finished results into segments; omitted keeps the brief's setting." QUESTION_SEQ_HELP = ( "The seq of the question this suggested reply answers; if a newer question was posted since, it answers none." @@ -322,7 +335,10 @@ def approve_command( seed_segment: list[int] | None = typer.Option(None, "--seed-segment", help=SEED_SEGMENT_HELP), segment: bool | None = typer.Option(None, "--segment/--no-segment", help=RESULTS_SEGMENT_HELP), ) -> None: - """Approve the reviewed plan version and start research.""" + """Approve the reviewed plan version and start research. + + A plan with an engine left unselected (selected null) returns 409 until `prospecting plan` picks one. + """ from discolike_cli.main import get_client emit( @@ -342,6 +358,65 @@ def approve_command( ) +@app.command("plan", help=PLAN_HELP) +@handle_errors +def plan_command( + ctx: typer.Context, + run_id: str = typer.Argument(...), + plan_version: int = typer.Option(..., "--plan-version", min=1, help="The plan version you reviewed."), + contact_integration_id: str | None = typer.Option( + None, "--contact-integration-id", help="An option id from the plan's contact_engine." + ), + validation_integration_id: str | None = typer.Option( + None, "--validation-integration-id", help="An option id from the plan's company_check_engine." + ), + search_provider_id: str | None = typer.Option(None, "--search-provider-id", help=SEARCH_PROVIDER_HELP), + target_companies: int | None = typer.Option( + None, "--target-companies", min=1, max=10000, help="Qualified companies to aim for." + ), + contacts_per_company: int | None = typer.Option( + None, "--contacts-per-company", min=1, max=10, help="Most contacts to find at each company." + ), + deliverable: str | None = typer.Option(None, "--deliverable", help=PLAN_DELIVERABLE_HELP), + goal: str | None = typer.Option(None, "--goal", help=PLAN_GOAL_HELP), + max_spend_usd: float | None = typer.Option( + None, + "--max-spend-usd", + min=0, + help=f"USD limit on DiscoLike records and per-call fees at your plan's rates; {SPEND_HELP_SUFFIX}", + ), + max_provider_spend_usd: float | None = typer.Option( + None, + "--max-provider-spend-usd", + min=0, + help=f"USD limit on your own AI and search provider keys; {SPEND_HELP_SUFFIX}", + ), +) -> None: + from discolike_cli.main import get_client + + emit( + get_client(ctx).prospecting.update_plan( + run_id, + build_request( + ProspectingPlanSettings, + _merge_params( + None, + plan_version=plan_version, + contact_integration_id=contact_integration_id, + validation_integration_id=validation_integration_id, + search_provider_id=search_provider_id, + target_companies=target_companies, + contacts_per_company=contacts_per_company, + deliverable=deliverable, + goal=goal, + max_spend_usd=max_spend_usd, + max_provider_spend_usd=max_provider_spend_usd, + ), + ), + ) + ) + + @app.command("message") @handle_errors def message_command( diff --git a/packages/discolike-cli/tests/test_prospecting_cli.py b/packages/discolike-cli/tests/test_prospecting_cli.py index 5012f77..c1f15b0 100644 --- a/packages/discolike-cli/tests/test_prospecting_cli.py +++ b/packages/discolike-cli/tests/test_prospecting_cli.py @@ -189,6 +189,10 @@ def handler(request: httpx2.Request) -> httpx2.Response: "arguments", [ ["approve", RUN_ID], + ["plan", RUN_ID], + ["plan", RUN_ID, "--plan-version", "1", "--contacts-per-company", "11"], + ["plan", RUN_ID, "--plan-version", "1", "--deliverable", "people"], + ["plan", RUN_ID, "--plan-version", "1", "--max-spend-usd", "-1"], ["message", RUN_ID, "--text", "Continue"], ["message", RUN_ID, "--text", "Continue", "--idempotency-key", "k", "--question-seq", "0"], ["list", "--limit", "51"], @@ -455,3 +459,89 @@ def handler(request: httpx2.Request) -> httpx2.Response: {"plan_version": 1, "checkpoints": "ask", "seed_segments": [1, 2], "segment": True}, {"plan_version": 1, "checkpoints": "ask"}, ] + + +def test_plan_sends_only_the_settings_passed(install_build_client: Callable[[Handler], None]) -> None: + seen = [] + + def handler(request: httpx2.Request) -> httpx2.Response: + seen.append(request) + return httpx2.Response(200, json=run_payload("proposed")) + + install_build_client(handler) + result = runner.invoke( + app, + [ + "prospecting", + "plan", + RUN_ID, + "--plan-version", + "3", + "--contact-integration-id", + "llm-1", + "--search-provider-id", + "none", + ], + ) + assert result.exit_code == 0, result.output + assert seen[0].method == "PATCH" + assert seen[0].url.path.endswith(f"/prospecting/runs/{RUN_ID}/plan") + assert json.loads(seen[0].content) == { + "plan_version": 3, + "contact_integration_id": "llm-1", + "search_provider_id": "none", + } + assert json.loads(result.stdout)["run_id"] == RUN_ID + + +def test_plan_forwards_every_setting(install_build_client: Callable[[Handler], None]) -> None: + bodies = [] + + def handler(request: httpx2.Request) -> httpx2.Response: + bodies.append(json.loads(request.content)) + return httpx2.Response(200, json=run_payload("proposed")) + + install_build_client(handler) + result = runner.invoke( + app, + [ + "prospecting", + "plan", + RUN_ID, + "--plan-version", + "2", + "--contact-integration-id", + "llm-1", + "--validation-integration-id", + "llm-2", + "--search-provider-id", + "search-1", + "--target-companies", + "250", + "--contacts-per-company", + "5", + "--deliverable", + "accounts", + "--goal", + "companies", + "--max-spend-usd", + "0", + "--max-provider-spend-usd", + "12.5", + ], + ) + assert result.exit_code == 0, result.output + assert bodies == [ + { + "plan_version": 2, + "contact_integration_id": "llm-1", + "validation_integration_id": "llm-2", + "search_provider_id": "search-1", + "target_companies": 250, + "contacts_per_company": 5, + "deliverable": "accounts", + "goal": "companies", + "max_spend_usd": 0.0, + "max_provider_spend_usd": 12.5, + } + ] From cf9ae955db6755a2ce74f973c992ef0232fb3078 Mon Sep 17 00:00:00 2001 From: Daniel Yudelevich Date: Wed, 7 Oct 2026 15:57:04 -0700 Subject: [PATCH 06/11] =?UTF-8?q?fix:=20Greptile=20review=20=E2=80=94=20as?= =?UTF-8?q?sert=20the=20plan=20version=20the=20CLI=20prints?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The plan tests' responses had no plan_version, so the model's default of 1 hid whether the new version reached stdout; users need that value for their next approve. --- packages/discolike-cli/tests/test_prospecting_cli.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/packages/discolike-cli/tests/test_prospecting_cli.py b/packages/discolike-cli/tests/test_prospecting_cli.py index c1f15b0..094c58a 100644 --- a/packages/discolike-cli/tests/test_prospecting_cli.py +++ b/packages/discolike-cli/tests/test_prospecting_cli.py @@ -466,7 +466,7 @@ def test_plan_sends_only_the_settings_passed(install_build_client: Callable[[Han def handler(request: httpx2.Request) -> httpx2.Response: seen.append(request) - return httpx2.Response(200, json=run_payload("proposed")) + return httpx2.Response(200, json=run_payload("proposed") | {"plan_version": 4}) install_build_client(handler) result = runner.invoke( @@ -491,7 +491,8 @@ def handler(request: httpx2.Request) -> httpx2.Response: "contact_integration_id": "llm-1", "search_provider_id": "none", } - assert json.loads(result.stdout)["run_id"] == RUN_ID + printed = json.loads(result.stdout) + assert (printed["run_id"], printed["plan_version"]) == (RUN_ID, 4) def test_plan_forwards_every_setting(install_build_client: Callable[[Handler], None]) -> None: @@ -499,7 +500,7 @@ def test_plan_forwards_every_setting(install_build_client: Callable[[Handler], N def handler(request: httpx2.Request) -> httpx2.Response: bodies.append(json.loads(request.content)) - return httpx2.Response(200, json=run_payload("proposed")) + return httpx2.Response(200, json=run_payload("proposed") | {"plan_version": 3}) install_build_client(handler) result = runner.invoke( @@ -545,3 +546,4 @@ def handler(request: httpx2.Request) -> httpx2.Response: "max_provider_spend_usd": 12.5, } ] + assert json.loads(result.stdout)["plan_version"] == 3 From b330df7bfb5e93c5f6e70d909e0d98a867bc51ee Mon Sep 17 00:00:00 2001 From: Daniel Yudelevich Date: Fri, 9 Oct 2026 09:34:12 -0700 Subject: [PATCH 07/11] List continent region codes in country filter descriptions The API now expands EUROPE, ASIA, AFRICA, NORTH_AMERICA, SOUTH_AMERICA, OCEANIA and ANTARCTICA; EU stays the 27 member states. Hand-edited ahead of the platform deploy, like email_validated. --- CHANGELOG.md | 1 + .../src/discolike/_generated/requests.py | 16 ++++++++-------- 2 files changed, 9 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5d76494..8a58e31 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,7 @@ ## Unreleased +- SDK: `country`, `person_country` and `filter_country` descriptions list the continent region codes the API now accepts (`EUROPE`, `ASIA`, `AFRICA`, `NORTH_AMERICA`, `SOUTH_AMERICA`, `OCEANIA`, `ANTARCTICA`). `EU` stays the 27 member states. The fields are plain strings, so older SDK versions can send these too. Edited in `discolike/_generated/requests.py` by hand ahead of the platform deploy; `scripts/gen_requests.py --check` reports the difference until the deployed spec carries it. - SDK: the `email_validated` contact filter on `contacts.search`, `contacts.count` and `contacts.discover` is deprecated. Treated as has_email: contact search no longer filters on email verification; verify emails instead. The flag behind it only recorded that a third-party verifier had run at some point, not what it found, so a large share of "validated" addresses were catch-all. It is dropped from `ContactsSearchParams`, `ContactsCountParams` and `ContactFilters`; passing it still works (it goes out as an extra field and the API applies it as `has_email`) but raises a `DeprecationWarning`. Contact results keep their `email_validated` field, which is now `None` until you run email verification on the contact, then `True` or `False`. - CLI: `contacts search`, `count` and `discover` keep `--email-validated/--no-email-validated` so existing scripts keep running. `--email-validated` is deprecated and treated as `--has-email` (an explicit `--has-email` or `--no-has-email` takes precedence) and prints a deprecation warning on stderr; `--no-email-validated` warns and has no effect. Neither sends `email_validated`. `bulk contacts` keeps its `email_validated` CSV column, empty unless the contact was verified. - SDK (note for maintainers): `email_validated` was removed from `discolike/_generated/requests.py` by hand, ahead of the platform deploy that marks it deprecated in the OpenAPI spec (the generator drops deprecated fields). Until the deployed spec carries that flag, `scripts/gen_requests.py --check` reports the three fields as a diff and `scripts/check_contract.py` reports them as undeclared params; that is the pending release, not drift. diff --git a/packages/discolike/src/discolike/_generated/requests.py b/packages/discolike/src/discolike/_generated/requests.py index 1dfd1dc..c5d12b6 100644 --- a/packages/discolike/src/discolike/_generated/requests.py +++ b/packages/discolike/src/discolike/_generated/requests.py @@ -193,7 +193,7 @@ class ContactsSearchParams(DiscolikeRequest): filter_country: Annotated[ list[str] | None, Field( - description="Filter by company country using ISO-3166-1 alpha-2 codes (e.g., US, GB, DE). Also accepts region aliases: EU, LATAM, MENA, APAC, NORDICS, DACH, BENELUX, GCC, ASEAN, CEE, ANZ.", + description="Filter by company country using ISO-3166-1 alpha-2 codes (e.g., US, GB, DE). Also accepts region aliases: EU, LATAM, MENA, APAC, NORDICS, DACH, BENELUX, GCC, ASEAN, CEE, ANZ, EUROPE, ASIA, AFRICA, NORTH_AMERICA, SOUTH_AMERICA, OCEANIA, ANTARCTICA.", title="Filter Country", ), ] = None @@ -336,7 +336,7 @@ class ContactsSearchParams(DiscolikeRequest): person_country: Annotated[ list[str] | None, Field( - description="Filter by contact's country using ISO-3166-1 alpha-2 codes (e.g., US, GB, DE). Also accepts region aliases: EU, LATAM, MENA, APAC, NORDICS, DACH, BENELUX, GCC, ASEAN, CEE, ANZ.", + description="Filter by contact's country using ISO-3166-1 alpha-2 codes (e.g., US, GB, DE). Also accepts region aliases: EU, LATAM, MENA, APAC, NORDICS, DACH, BENELUX, GCC, ASEAN, CEE, ANZ, EUROPE, ASIA, AFRICA, NORTH_AMERICA, SOUTH_AMERICA, OCEANIA, ANTARCTICA.", title="Person Country", ), ] = None @@ -615,7 +615,7 @@ class ContactsCountParams(DiscolikeRequest): filter_country: Annotated[ list[str] | None, Field( - description="Filter by company country using ISO-3166-1 alpha-2 codes (e.g., US, GB, DE). Also accepts region aliases: EU, LATAM, MENA, APAC, NORDICS, DACH, BENELUX, GCC, ASEAN, CEE, ANZ.", + description="Filter by company country using ISO-3166-1 alpha-2 codes (e.g., US, GB, DE). Also accepts region aliases: EU, LATAM, MENA, APAC, NORDICS, DACH, BENELUX, GCC, ASEAN, CEE, ANZ, EUROPE, ASIA, AFRICA, NORTH_AMERICA, SOUTH_AMERICA, OCEANIA, ANTARCTICA.", title="Filter Country", ), ] = None @@ -758,7 +758,7 @@ class ContactsCountParams(DiscolikeRequest): person_country: Annotated[ list[str] | None, Field( - description="Filter by contact's country using ISO-3166-1 alpha-2 codes (e.g., US, GB, DE). Also accepts region aliases: EU, LATAM, MENA, APAC, NORDICS, DACH, BENELUX, GCC, ASEAN, CEE, ANZ.", + description="Filter by contact's country using ISO-3166-1 alpha-2 codes (e.g., US, GB, DE). Also accepts region aliases: EU, LATAM, MENA, APAC, NORDICS, DACH, BENELUX, GCC, ASEAN, CEE, ANZ, EUROPE, ASIA, AFRICA, NORTH_AMERICA, SOUTH_AMERICA, OCEANIA, ANTARCTICA.", title="Person Country", ), ] = None @@ -1069,7 +1069,7 @@ class ContactFilters(DiscolikeRequest): filter_country: Annotated[ list[str] | None, Field( - description="Filter by company country using ISO-3166-1 alpha-2 codes (e.g., US, GB, DE). Also accepts region aliases: EU, LATAM, MENA, APAC, NORDICS, DACH, BENELUX, GCC, ASEAN, CEE, ANZ.", + description="Filter by company country using ISO-3166-1 alpha-2 codes (e.g., US, GB, DE). Also accepts region aliases: EU, LATAM, MENA, APAC, NORDICS, DACH, BENELUX, GCC, ASEAN, CEE, ANZ, EUROPE, ASIA, AFRICA, NORTH_AMERICA, SOUTH_AMERICA, OCEANIA, ANTARCTICA.", title="Filter Country", ), ] = None @@ -1212,7 +1212,7 @@ class ContactFilters(DiscolikeRequest): person_country: Annotated[ list[str] | None, Field( - description="Filter by contact's country using ISO-3166-1 alpha-2 codes (e.g., US, GB, DE). Also accepts region aliases: EU, LATAM, MENA, APAC, NORDICS, DACH, BENELUX, GCC, ASEAN, CEE, ANZ.", + description="Filter by contact's country using ISO-3166-1 alpha-2 codes (e.g., US, GB, DE). Also accepts region aliases: EU, LATAM, MENA, APAC, NORDICS, DACH, BENELUX, GCC, ASEAN, CEE, ANZ, EUROPE, ASIA, AFRICA, NORTH_AMERICA, SOUTH_AMERICA, OCEANIA, ANTARCTICA.", title="Person Country", ), ] = None @@ -1793,7 +1793,7 @@ class DiscoverParams(DiscolikeRequest): country: Annotated[ list[str] | None, Field( - description="Filter by ISO-3166-1 alpha-2 country codes (e.g., US, GB, DE). Also accepts region aliases: EU, LATAM, MENA, APAC, NORDICS, DACH, BENELUX, GCC, ASEAN, CEE, ANZ.", + description="Filter by ISO-3166-1 alpha-2 country codes (e.g., US, GB, DE). Also accepts region aliases: EU, LATAM, MENA, APAC, NORDICS, DACH, BENELUX, GCC, ASEAN, CEE, ANZ, EUROPE, ASIA, AFRICA, NORTH_AMERICA, SOUTH_AMERICA, OCEANIA, ANTARCTICA.", title="Country", ), ] = None @@ -2430,7 +2430,7 @@ class CountParams(DiscolikeRequest): country: Annotated[ list[str] | None, Field( - description="Filter by ISO-3166-1 alpha-2 country codes (e.g., US, GB, DE). Also accepts region aliases: EU, LATAM, MENA, APAC, NORDICS, DACH, BENELUX, GCC, ASEAN, CEE, ANZ.", + description="Filter by ISO-3166-1 alpha-2 country codes (e.g., US, GB, DE). Also accepts region aliases: EU, LATAM, MENA, APAC, NORDICS, DACH, BENELUX, GCC, ASEAN, CEE, ANZ, EUROPE, ASIA, AFRICA, NORTH_AMERICA, SOUTH_AMERICA, OCEANIA, ANTARCTICA.", title="Country", ), ] = None From 1ddfd57d28baae8c492015d91cbac4402afef604 Mon Sep 17 00:00:00 2001 From: Daniel Yudelevich Date: Fri, 9 Oct 2026 16:01:08 -0700 Subject: [PATCH 08/11] Limit DiscoverParams.offset to 10,000 to match the API cap The API now answers 422 above 10,000 instead of silently clamping, so validate client-side and point deep pagination at exclusion lists. Hand-edited ahead of the platform deploy; gen_requests --check will flag it until the deployed spec carries the bound. --- CHANGELOG.md | 1 + packages/discolike/src/discolike/_generated/requests.py | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8a58e31..f1c0e9c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,7 @@ ## Unreleased +- SDK: `DiscoverParams.offset` is limited to 0-10,000, matching the API, which now answers 422 above that instead of silently capping it. To go deeper, save the domains you already have as an exclusion list, pass it as `exclusion_query_id` and page again from 0. Edited in `discolike/_generated/requests.py` by hand ahead of the platform deploy; `scripts/gen_requests.py --check` reports the difference until the deployed spec carries it. - SDK: `country`, `person_country` and `filter_country` descriptions list the continent region codes the API now accepts (`EUROPE`, `ASIA`, `AFRICA`, `NORTH_AMERICA`, `SOUTH_AMERICA`, `OCEANIA`, `ANTARCTICA`). `EU` stays the 27 member states. The fields are plain strings, so older SDK versions can send these too. Edited in `discolike/_generated/requests.py` by hand ahead of the platform deploy; `scripts/gen_requests.py --check` reports the difference until the deployed spec carries it. - SDK: the `email_validated` contact filter on `contacts.search`, `contacts.count` and `contacts.discover` is deprecated. Treated as has_email: contact search no longer filters on email verification; verify emails instead. The flag behind it only recorded that a third-party verifier had run at some point, not what it found, so a large share of "validated" addresses were catch-all. It is dropped from `ContactsSearchParams`, `ContactsCountParams` and `ContactFilters`; passing it still works (it goes out as an extra field and the API applies it as `has_email`) but raises a `DeprecationWarning`. Contact results keep their `email_validated` field, which is now `None` until you run email verification on the contact, then `True` or `False`. - CLI: `contacts search`, `count` and `discover` keep `--email-validated/--no-email-validated` so existing scripts keep running. `--email-validated` is deprecated and treated as `--has-email` (an explicit `--has-email` or `--no-has-email` takes precedence) and prints a deprecation warning on stderr; `--no-email-validated` warns and has no effect. Neither sends `email_validated`. `bulk contacts` keeps its `email_validated` CSV column, empty unless the contact was verified. diff --git a/packages/discolike/src/discolike/_generated/requests.py b/packages/discolike/src/discolike/_generated/requests.py index c5d12b6..1f08057 100644 --- a/packages/discolike/src/discolike/_generated/requests.py +++ b/packages/discolike/src/discolike/_generated/requests.py @@ -2121,7 +2121,7 @@ class DiscoverParams(DiscolikeRequest): ] = "UNRESTRICTED" offset: Annotated[ int | None, - Field(description="Records to skip for pagination.", ge=0, title="Offset"), + Field(description="Records to skip for pagination.", ge=0, le=10000, title="Offset"), ] = 0 exclusion_query_id: Annotated[ list[str] | None, From 7f21a3f8bb08d705687637ac5138ebc602868ef9 Mon Sep 17 00:00:00 2001 From: Yegor Dolgopolov Date: Mon, 12 Oct 2026 17:05:02 -0700 Subject: [PATCH 09/11] Release 0.6.0: prospecting plan command, email_validated deprecation, work-in-progress limits Bump the SDK and CLI to 0.6.0 in _version.py, both pyproject.toml files, the discolike[cli] extra pin and uv.lock, and date the Unreleased changelog section. Adds one changelog entry for the API's new limits on queued email lookups and running async tasks: the transport already waits out their 429 with Retry-After: 60, so the entry only says how long a submit can block. --- CHANGELOG.md | 3 ++- packages/discolike-cli/pyproject.toml | 4 ++-- packages/discolike/pyproject.toml | 2 +- packages/discolike/src/discolike/_version.py | 2 +- packages/discolike/tests/test_package.py | 2 +- uv.lock | 2 +- 6 files changed, 8 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f1c0e9c..3579357 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,7 +1,8 @@ # Changelog -## Unreleased +## 0.6.0 (2026-10-12) +- SDK/CLI: the API now limits how much work one account can have queued or running: email lookups (2,000 times the plan multiplier) and three DiscoGen, bulk match, contact bulk match and segment tasks. A submit over a limit is answered 429 with `Retry-After: 60`, which the SDK already waits out and retries, so with the default `max_retries=3` the call blocks for up to three minutes and then raises `RateLimitError` carrying the count and the limit in its message. Pass `max_retries=0` to get the error at once. No SDK change; older versions behave the same. - SDK: `DiscoverParams.offset` is limited to 0-10,000, matching the API, which now answers 422 above that instead of silently capping it. To go deeper, save the domains you already have as an exclusion list, pass it as `exclusion_query_id` and page again from 0. Edited in `discolike/_generated/requests.py` by hand ahead of the platform deploy; `scripts/gen_requests.py --check` reports the difference until the deployed spec carries it. - SDK: `country`, `person_country` and `filter_country` descriptions list the continent region codes the API now accepts (`EUROPE`, `ASIA`, `AFRICA`, `NORTH_AMERICA`, `SOUTH_AMERICA`, `OCEANIA`, `ANTARCTICA`). `EU` stays the 27 member states. The fields are plain strings, so older SDK versions can send these too. Edited in `discolike/_generated/requests.py` by hand ahead of the platform deploy; `scripts/gen_requests.py --check` reports the difference until the deployed spec carries it. - SDK: the `email_validated` contact filter on `contacts.search`, `contacts.count` and `contacts.discover` is deprecated. Treated as has_email: contact search no longer filters on email verification; verify emails instead. The flag behind it only recorded that a third-party verifier had run at some point, not what it found, so a large share of "validated" addresses were catch-all. It is dropped from `ContactsSearchParams`, `ContactsCountParams` and `ContactFilters`; passing it still works (it goes out as an extra field and the API applies it as `has_email`) but raises a `DeprecationWarning`. Contact results keep their `email_validated` field, which is now `None` until you run email verification on the contact, then `True` or `False`. diff --git a/packages/discolike-cli/pyproject.toml b/packages/discolike-cli/pyproject.toml index 9d06da3..5ec2b3e 100644 --- a/packages/discolike-cli/pyproject.toml +++ b/packages/discolike-cli/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "discolike-cli" -version = "0.5.0" +version = "0.6.0" description = "Official CLI for the DiscoLike API" readme = "README.md" license = "MIT" @@ -12,7 +12,7 @@ license-files = ["LICENSE"] requires-python = ">=3.11" authors = [{ name = "DiscoLike", email = "support@discolike.com" }] dependencies = [ - "discolike==0.5.0", + "discolike==0.6.0", "typer>=0.26.1,<0.28", "rich>=13.0", ] diff --git a/packages/discolike/pyproject.toml b/packages/discolike/pyproject.toml index ec3d9d3..9ad1bcd 100644 --- a/packages/discolike/pyproject.toml +++ b/packages/discolike/pyproject.toml @@ -33,7 +33,7 @@ classifiers = [ ] [project.optional-dependencies] -cli = ["discolike-cli==0.5.0"] +cli = ["discolike-cli==0.6.0"] [project.urls] Homepage = "https://www.discolike.com" diff --git a/packages/discolike/src/discolike/_version.py b/packages/discolike/src/discolike/_version.py index 3d18726..906d362 100644 --- a/packages/discolike/src/discolike/_version.py +++ b/packages/discolike/src/discolike/_version.py @@ -1 +1 @@ -__version__ = "0.5.0" +__version__ = "0.6.0" diff --git a/packages/discolike/tests/test_package.py b/packages/discolike/tests/test_package.py index b9f92c5..a6bf703 100644 --- a/packages/discolike/tests/test_package.py +++ b/packages/discolike/tests/test_package.py @@ -2,4 +2,4 @@ def test_version() -> None: - assert discolike.__version__ == "0.5.0" + assert discolike.__version__ == "0.6.0" diff --git a/uv.lock b/uv.lock index 486c218..429b470 100644 --- a/uv.lock +++ b/uv.lock @@ -343,7 +343,7 @@ dev = [ [[package]] name = "discolike-cli" -version = "0.5.0" +version = "0.6.0" source = { editable = "packages/discolike-cli" } dependencies = [ { name = "discolike" }, From 6c049b6be4008e91b68e3b7e3f599d81f450bfc6 Mon Sep 17 00:00:00 2001 From: Yegor Dolgopolov Date: Mon, 12 Oct 2026 17:07:27 -0700 Subject: [PATCH 10/11] Regenerate request models from the deployed production spec The platform release is live, so the offset cap, continent codes and the email_validated deprecation no longer need hand edits. gen_requests.py --check is clean; the changelog drops the notes that said it would not be. --- CHANGELOG.md | 6 +++--- .../src/discolike/_generated/requests.py | 21 ++++++++++++------- 2 files changed, 16 insertions(+), 11 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3579357..e4b2144 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,11 +3,11 @@ ## 0.6.0 (2026-10-12) - SDK/CLI: the API now limits how much work one account can have queued or running: email lookups (2,000 times the plan multiplier) and three DiscoGen, bulk match, contact bulk match and segment tasks. A submit over a limit is answered 429 with `Retry-After: 60`, which the SDK already waits out and retries, so with the default `max_retries=3` the call blocks for up to three minutes and then raises `RateLimitError` carrying the count and the limit in its message. Pass `max_retries=0` to get the error at once. No SDK change; older versions behave the same. -- SDK: `DiscoverParams.offset` is limited to 0-10,000, matching the API, which now answers 422 above that instead of silently capping it. To go deeper, save the domains you already have as an exclusion list, pass it as `exclusion_query_id` and page again from 0. Edited in `discolike/_generated/requests.py` by hand ahead of the platform deploy; `scripts/gen_requests.py --check` reports the difference until the deployed spec carries it. -- SDK: `country`, `person_country` and `filter_country` descriptions list the continent region codes the API now accepts (`EUROPE`, `ASIA`, `AFRICA`, `NORTH_AMERICA`, `SOUTH_AMERICA`, `OCEANIA`, `ANTARCTICA`). `EU` stays the 27 member states. The fields are plain strings, so older SDK versions can send these too. Edited in `discolike/_generated/requests.py` by hand ahead of the platform deploy; `scripts/gen_requests.py --check` reports the difference until the deployed spec carries it. +- SDK: `DiscoverParams.offset` is limited to 0-10,000, matching the API, which now answers 422 above that instead of silently capping it. To go deeper, save the domains you already have as an exclusion list, pass it as `exclusion_query_id` and page again from 0. +- SDK: `country`, `person_country` and `filter_country` descriptions list the continent region codes the API now accepts (`EUROPE`, `ASIA`, `AFRICA`, `NORTH_AMERICA`, `SOUTH_AMERICA`, `OCEANIA`, `ANTARCTICA`). `EU` stays the 27 member states. The fields are plain strings, so older SDK versions can send these too. - SDK: the `email_validated` contact filter on `contacts.search`, `contacts.count` and `contacts.discover` is deprecated. Treated as has_email: contact search no longer filters on email verification; verify emails instead. The flag behind it only recorded that a third-party verifier had run at some point, not what it found, so a large share of "validated" addresses were catch-all. It is dropped from `ContactsSearchParams`, `ContactsCountParams` and `ContactFilters`; passing it still works (it goes out as an extra field and the API applies it as `has_email`) but raises a `DeprecationWarning`. Contact results keep their `email_validated` field, which is now `None` until you run email verification on the contact, then `True` or `False`. - CLI: `contacts search`, `count` and `discover` keep `--email-validated/--no-email-validated` so existing scripts keep running. `--email-validated` is deprecated and treated as `--has-email` (an explicit `--has-email` or `--no-has-email` takes precedence) and prints a deprecation warning on stderr; `--no-email-validated` warns and has no effect. Neither sends `email_validated`. `bulk contacts` keeps its `email_validated` CSV column, empty unless the contact was verified. -- SDK (note for maintainers): `email_validated` was removed from `discolike/_generated/requests.py` by hand, ahead of the platform deploy that marks it deprecated in the OpenAPI spec (the generator drops deprecated fields). Until the deployed spec carries that flag, `scripts/gen_requests.py --check` reports the three fields as a diff and `scripts/check_contract.py` reports them as undeclared params; that is the pending release, not drift. +- SDK (note for maintainers): `discolike/_generated/requests.py` is regenerated from the production spec, which now marks `email_validated` deprecated and carries the offset cap and continent codes, so `scripts/gen_requests.py --check` is clean again. - SDK (note for maintainers): `scripts/check_contract.py` now skips a query parameter marked deprecated inside its `schema`, which is where FastAPI puts a deprecated model field. `scripts/gen_requests.py` already dropped those, so the two scripts disagreed on GET routes. - CLI: `prospecting plan RUN_ID --plan-version N` changes a proposed plan before approval, wrapping `update_plan()`: `--contact-integration-id`, `--validation-integration-id`, `--search-provider-id` (`none` skips web research), `--target-companies`, `--contacts-per-company`, `--deliverable`, `--goal`, `--max-spend-usd` and `--max-provider-spend-usd` (`0` removes a limit), sending only the flags passed. When an organization has several AI or search providers and none is the default, the plan leaves that engine at `selected: null` and `approve` returns 409 until one is picked; `plan` is how a CLI user picks it. The run comes back at a new `plan_version`, which is the one to approve. diff --git a/packages/discolike/src/discolike/_generated/requests.py b/packages/discolike/src/discolike/_generated/requests.py index 1f08057..11a159d 100644 --- a/packages/discolike/src/discolike/_generated/requests.py +++ b/packages/discolike/src/discolike/_generated/requests.py @@ -1380,7 +1380,7 @@ class ContactGenerateRequest(DiscolikeRequest): integration_id: Annotated[ str | None, Field( - description="LLM provider integration UUID, or 'native' for keyless extraction without title validation. Omit for the org default (falls back to native when none is set).", + description="LLM provider integration UUID, or 'native' for keyless extraction without title validation. Omit for native when it is available and a search provider resolves (the org default, or its only one), else the org default LLM.", title="Integration Id", ), ] = None @@ -1408,7 +1408,7 @@ class DiscoGenProcessRequest(DiscolikeRequest): integration_id: Annotated[ str | None, Field( - description="LLM provider integration UUID (omit for org default), or 'native-icp' to score an ICP validation prompt with the native model", + description="LLM provider integration UUID (omit for the org default, or its only integration), or 'native-icp' to score an ICP validation prompt with the native model", title="Integration Id", ), ] = None @@ -1425,7 +1425,7 @@ class DiscoGenProcessRequest(DiscolikeRequest): search_provider_id: Annotated[ str | None, Field( - description="Search provider integration UUID (omit for org default)", + description="Search provider integration UUID (omit for the org default, or its only provider)", title="Search Provider Id", ), ] = None @@ -1451,7 +1451,7 @@ class DiscoGenPersonaProcessRequest(DiscolikeRequest): integration_id: Annotated[ str | None, Field( - description="LLM provider integration UUID (omit for org default), or 'native-icp' to score an ICP validation prompt with the native model", + description="LLM provider integration UUID (omit for the org default, or its only integration), or 'native-icp' to score an ICP validation prompt with the native model", title="Integration Id", ), ] = None @@ -1468,7 +1468,7 @@ class DiscoGenPersonaProcessRequest(DiscolikeRequest): search_provider_id: Annotated[ str | None, Field( - description="Search provider integration UUID (omit for org default)", + description="Search provider integration UUID (omit for the org default, or its only provider)", title="Search Provider Id", ), ] = None @@ -1793,7 +1793,7 @@ class DiscoverParams(DiscolikeRequest): country: Annotated[ list[str] | None, Field( - description="Filter by ISO-3166-1 alpha-2 country codes (e.g., US, GB, DE). Also accepts region aliases: EU, LATAM, MENA, APAC, NORDICS, DACH, BENELUX, GCC, ASEAN, CEE, ANZ, EUROPE, ASIA, AFRICA, NORTH_AMERICA, SOUTH_AMERICA, OCEANIA, ANTARCTICA.", + description="ISO-3166-1 alpha-2 country codes (e.g., US, GB, DE) or region aliases: EU, LATAM, MENA, APAC, NORDICS, DACH, BENELUX, GCC, ASEAN, CEE, ANZ, EUROPE, ASIA, AFRICA, NORTH_AMERICA, SOUTH_AMERICA, OCEANIA, ANTARCTICA.", title="Country", ), ] = None @@ -2121,7 +2121,12 @@ class DiscoverParams(DiscolikeRequest): ] = "UNRESTRICTED" offset: Annotated[ int | None, - Field(description="Records to skip for pagination.", ge=0, le=10000, title="Offset"), + Field( + description="Records to skip for pagination.", + ge=0, + le=10000, + title="Offset", + ), ] = 0 exclusion_query_id: Annotated[ list[str] | None, @@ -2430,7 +2435,7 @@ class CountParams(DiscolikeRequest): country: Annotated[ list[str] | None, Field( - description="Filter by ISO-3166-1 alpha-2 country codes (e.g., US, GB, DE). Also accepts region aliases: EU, LATAM, MENA, APAC, NORDICS, DACH, BENELUX, GCC, ASEAN, CEE, ANZ, EUROPE, ASIA, AFRICA, NORTH_AMERICA, SOUTH_AMERICA, OCEANIA, ANTARCTICA.", + description="ISO-3166-1 alpha-2 country codes (e.g., US, GB, DE) or region aliases: EU, LATAM, MENA, APAC, NORDICS, DACH, BENELUX, GCC, ASEAN, CEE, ANZ, EUROPE, ASIA, AFRICA, NORTH_AMERICA, SOUTH_AMERICA, OCEANIA, ANTARCTICA.", title="Country", ), ] = None From 30ba009153a5c030bef9491dfe41d27a74ecafcd Mon Sep 17 00:00:00 2001 From: Yegor Dolgopolov Date: Mon, 12 Oct 2026 17:19:59 -0700 Subject: [PATCH 11/11] Say the max_retries advice is SDK-only in the 0.6.0 changelog The CLI builds its client with no retry option, so a CLI command over a work-in-progress limit waits out the retries and exits 4. --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e4b2144..c63d9fa 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,7 +2,7 @@ ## 0.6.0 (2026-10-12) -- SDK/CLI: the API now limits how much work one account can have queued or running: email lookups (2,000 times the plan multiplier) and three DiscoGen, bulk match, contact bulk match and segment tasks. A submit over a limit is answered 429 with `Retry-After: 60`, which the SDK already waits out and retries, so with the default `max_retries=3` the call blocks for up to three minutes and then raises `RateLimitError` carrying the count and the limit in its message. Pass `max_retries=0` to get the error at once. No SDK change; older versions behave the same. +- SDK/CLI: the API now limits how much work one account can have queued or running: email lookups (2,000 times the plan multiplier) and three DiscoGen, bulk match, contact bulk match and segment tasks. A submit over a limit is answered 429 with `Retry-After: 60`, which the SDK already waits out and retries, so with the default `max_retries=3` the call blocks for up to three minutes and then raises `RateLimitError` carrying the count and the limit in its message. In the SDK, `Discolike(max_retries=0)` gets the error at once; the CLI has no retry option, so its commands wait and then exit 4. No SDK change; older versions behave the same. - SDK: `DiscoverParams.offset` is limited to 0-10,000, matching the API, which now answers 422 above that instead of silently capping it. To go deeper, save the domains you already have as an exclusion list, pass it as `exclusion_query_id` and page again from 0. - SDK: `country`, `person_country` and `filter_country` descriptions list the continent region codes the API now accepts (`EUROPE`, `ASIA`, `AFRICA`, `NORTH_AMERICA`, `SOUTH_AMERICA`, `OCEANIA`, `ANTARCTICA`). `EU` stays the 27 member states. The fields are plain strings, so older SDK versions can send these too. - SDK: the `email_validated` contact filter on `contacts.search`, `contacts.count` and `contacts.discover` is deprecated. Treated as has_email: contact search no longer filters on email verification; verify emails instead. The flag behind it only recorded that a third-party verifier had run at some point, not what it found, so a large share of "validated" addresses were catch-all. It is dropped from `ContactsSearchParams`, `ContactsCountParams` and `ContactFilters`; passing it still works (it goes out as an extra field and the API applies it as `has_email`) but raises a `DeprecationWarning`. Contact results keep their `email_validated` field, which is now `None` until you run email verification on the contact, then `True` or `False`.