diff --git a/CHANGELOG.md b/CHANGELOG.md index 4c4d80c..c63d9fa 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,16 @@ # Changelog +## 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. 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`. +- 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): `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. + ## 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/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/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-cli/src/discolike_cli/contacts.py b/packages/discolike-cli/src/discolike_cli/contacts.py index c4281c0..b29b4ea 100644 --- a/packages/discolike-cli/src/discolike_cli/contacts.py +++ b/packages/discolike-cli/src/discolike_cli/contacts.py @@ -38,7 +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 = "Only contacts with (or without) a validated email address." +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. 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." +) 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 +60,17 @@ INCLUDE_SEARCH_CONTACTS_HELP = "Include contacts from the search index (broader coverage)." CONSENSUS_HELP = "Number of query vectors to combine for consensus search." + +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 if has_email is None else has_email + + app = typer.Typer( help="Find contacts: search and count by filters, look up or match individuals, and run bulk/generative discovery jobs." ) @@ -137,8 +158,7 @@ def search_command( filter_state=filter_state, negate_filter_state=negate_filter_state, employee_range=employee_range, - has_email=has_email, - email_validated=email_validated, + 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, @@ -238,8 +258,7 @@ def count_command( filter_state=filter_state, negate_filter_state=negate_filter_state, employee_range=employee_range, - has_email=has_email, - email_validated=email_validated, + 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, @@ -413,8 +432,7 @@ def discover_command( filter_state=filter_state, negate_filter_state=negate_filter_state, employee_range=employee_range, - has_email=has_email, - email_validated=email_validated, + 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/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_contacts_cli.py b/packages/discolike-cli/tests/test_contacts_cli.py index 002ede3..b8c8cfc 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 @@ -394,7 +395,8 @@ 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_email") == "true" assert params.get("has_phone") == "true" assert params.get("has_mobile") == "false" assert params.get("has_linkedin") == "true" @@ -455,7 +457,7 @@ def handler(request: httpx2.Request) -> httpx2.Response: "persona_id": [7], "filter_state": ["TX"], "negate_filter_state": ["NY"], - "email_validated": True, + "has_email": True, "has_phone": True, "has_mobile": False, "has_linkedin": True, @@ -469,6 +471,70 @@ def handler(request: httpx2.Request) -> httpx2.Response: } +EMAIL_VALIDATED_RESPONSES = { + "search": [], + "count": {"count": 1}, + "discover": {"results": {}, "total_contacts": 0}, +} + + +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: + captured.append(request) + return httpx2.Response(200, json=EMAIL_VALIDATED_RESPONSES[command]) + + install_build_client(handler) + result = runner.invoke(app, ["contacts", command, "--name", "Jane", *flags]) + assert result.exit_code == 0, result.output + (request,) = captured + 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. Treated 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 + + +@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: + 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: + 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-cli/tests/test_prospecting_cli.py b/packages/discolike-cli/tests/test_prospecting_cli.py index 5012f77..094c58a 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,91 @@ 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") | {"plan_version": 4}) + + 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", + } + 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: + bodies = [] + + def handler(request: httpx2.Request) -> httpx2.Response: + bodies.append(json.loads(request.content)) + return httpx2.Response(200, json=run_payload("proposed") | {"plan_version": 3}) + + 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, + } + ] + assert json.loads(result.stdout)["plan_version"] == 3 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/_generated/requests.py b/packages/discolike/src/discolike/_generated/requests.py index fb0de5a..11a159d 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 @@ -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"), @@ -622,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 @@ -765,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 @@ -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"), @@ -1083,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 @@ -1226,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 @@ -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"), @@ -1401,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 @@ -1429,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 @@ -1446,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 @@ -1472,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 @@ -1489,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 @@ -1814,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="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 @@ -2142,7 +2121,12 @@ 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, @@ -2451,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.", + 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 diff --git a/packages/discolike/src/discolike/_models.py b/packages/discolike/src/discolike/_models.py index 35bf7a4..a806b7f 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. Treated as has_email: " + "contact search no longer filters on email verification; verify 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/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_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/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/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} 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 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" },