diff --git a/CHANGELOG.md b/CHANGELOG.md index de9e3a0..c6bb923 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,17 @@ # Changelog +## Unreleased + +- SDK/CLI: a request with no credentials now gets a 401 whose `detail` is an object (`code: "UNAUTHENTICATED"`, `message`, `signup_url`, `plan`, `allowance_usd`, `cpc`, `cpm`) pointing at free signup: Pay as you go, a one-time $5 signup credit, then $5 per 1,000 records, no card. The SDK raises `AuthenticationError` with the server's `message`, as for any object `detail`; other 401s keep a string `detail`. No SDK code change; older versions show the JSON body as the message. +- SDK/CLI: with `max_records` omitted, discover and the contacts endpoints now return, and bill, up to 500 records instead of 100; pass `max_records=100` to keep the old size. No SDK change; older versions get the new default too. +- SDK/CLI: discover, count and `bulk companies` gain `valid_mx` (`--valid-mx`) to exclude companies with no mail server, omitted unless set. It needs an API release that knows the field. +- SDK/CLI: `client.contacts_run` (sync and async) starts automatic validated contact delivery from prompt, domain or lookalike companies and `people.fit`. Requests support top-level `max_companies`, `per_company`, `relevance`, `email_cap_usd`, exclusions and `create_key`. Removed unreleased step mode, title chips, `start`, `max_contacts`, `auto_stop`, `find_missing`, continuation overrides and wait follow/cap flags. `wait()` polls until done, failed or cancelled. Responses include applied company filters, company metadata and event names, contact totals, progress, email states, skipped-no-MX counts and stop reasons; obsolete flat rows and internal step fields are removed. `rows()` pages company domains. CLI adds `contacts run create|list|get|events|cancel|wait`; live `continue` is companies-only, and `title-preview` remains a separate live route. Creation propagates typed `InsufficientBalanceError` for 403 INSUFFICIENT_BALANCE. No contacts_run deprecation window applies because this surface is unreleased. +- SDK/CLI: `FindEmailRequest.suggested_email` sends an address you think is right: it is verified first, and the right one is recovered if it is not proven. It must be at `domain` or the company's mail domain, else 422. It works on `email.find` and on each request of `email.find_batch`; the CLI's `email find` gains `--suggested-email`. `EmailJobResult` gains `outcome` (`valid`, `valid_catch_all`, `recovered`, `pattern_inferred`, `not_found`, `unknown`, open) and `email`, read from `job.status()` or batch results, and `EnumerationOutput` / `ValidationOutput` gain `method` (how the address was proven). All are optional and need an API release that sends them. +- SDK/CLI: `contacts.search` and `contacts.generate` are deprecated in favor of `client.contacts_run`; the API marks their endpoints deprecated with a 2026-12-31 sunset. They keep working and raise a `DeprecationWarning`; the CLI's `contacts search` and `generate` print a deprecation line on stderr after their output. `contacts.discover`, `lookup`, `match` and `bulk_match` are unchanged. +- SDK (note for maintainers): Contacts run request models live in `discolike/_contacts_run_requests.py`, kept in sync with platform source; the routes retain `openapi=False` for older specs, and `scripts/check_contract.py` checks the run response models only when the spec carries them. Regenerate `_generated/requests.py` with `scripts/gen_requests.py --spec .dev-openapi.json` from the dev spec; `suggested_email` is now in that spec and removed from `PENDING_PROPERTIES`. +- SDK: sync and async transports emit a visible `DiscolikeDeprecationWarning`, a `DeprecationWarning` subclass, on every call whose response carries `Deprecation`. Warnings include `Sunset` and `Link` when present; header values are kept as text, including malformed values. Email submission, reattachment and polling no longer emit hardcoded deprecation warnings. +- SDK/CLI: `ExtractResult` drops `language`; the API no longer returns it from `/extract`. + ## 0.6.2 (2026-10-21) - SDK/CLI: Python 3.15 is supported and tested in CI. diff --git a/README.md b/README.md index 8f60293..9041ec7 100644 --- a/README.md +++ b/README.md @@ -283,6 +283,7 @@ Top-level commands: `discover`, `count`, `match`, `extract`, `validate-icp`, `ap | `client.discover()` / `client.count()` | Find lookalike companies by ICP text, phrases, tech stack, geo, and 40+ other filters | | `client.companies` | Company profiles: firmographics, scores, growth, redirects, vendors, subsidiaries | | `client.contacts` | Search, look up, match, and discover contacts at target companies | +| `client.contacts_run` | Automatically find and deliver validated contacts under a spending cap | | `client.match` | Match company names (plus phone/city/state) to domains — single or bulk CSV | | `client.append()` | Enrich a CSV of domains with DiscoLike datasets | | `client.segment()` / `client.segment_file()` | Auto-segment a list of domains (comma-separated string or CSV upload) | @@ -310,6 +311,36 @@ result = job.wait() `JobTimeoutError` is a client-side wait limit only — the task keeps running server-side (large DiscoGen runs can take hours), so call `wait()` again to resume or fetch `status()` later. Cancelled tasks still return results for every item that finished before cancellation. Send one job per list (up to 10,000 domains) rather than splitting into parallel jobs — concurrent DiscoGen jobs share your LLM provider key and slow each other down. +### Contacts runs + +`client.contacts_run` automatically finds and validates contacts, using database candidates before web fallback. `max_companies` counts companies delivering at least one validated contact; `per_company` caps validated contacts at each company. Set `people.fit` to describe the people you want and optionally `email_cap_usd` to cap delivery spend. + +```python +from discolike.requests import ContactsRunRequest, ContactsRunEventsParams + +run = client.contacts_run.create( + ContactsRunRequest.model_validate({ + "companies": {"source": "prompt", "icp_text": "Independent dental clinics in Ohio"}, + "people": {"fit": "Practice owners and office managers"}, + "max_companies": 50, + "per_company": 3, + "email_cap_usd": 20, + }) +) +page = client.contacts_run.events(run.id, ContactsRunEventsParams(after=0)) +run = client.contacts_run.wait(run.id) # Returns on done, failed or cancelled. +rows = client.contacts_run.rows(run.id) # Pages company domains, keeping their contacts together. +print(run.summary) +``` + +`companies` accepts a prompt (`icp_text` and/or `filters`), domains (`source="domains", domains=[...]`) or lookalike seeds (`source="lookalike", persona_ids=[...]`). `max_companies` is a top-level field, default 500. Responses include `companies`, `companies_total`, `rows_total` and summary totals (`companies_delivered`, `validated_contacts`, `spent_usd`). Domains may be masked as `Company N`; treat them as display labels. Delivered email labels are `Validated` and `Validated (catch-all resolved)`. Events include `next_cursor` for the next call's `after`. + +Runs start automatically. `cancel()` stops unfinished work; `wait()` only polls and a timeout stops local polling. `continue_()` remains available for companies runs and returns 409 for contacts runs. The separate `title_preview()` route remains live, but contacts runs take free-text people fit rather than title chips. A run idle for 30 days answers 410. A creation request exceeding the remaining balance raises `InsufficientBalanceError` (a `PlanAccessError` subclass). + +CLI: `discolike contacts run create|list|get|events|cancel|wait|continue|title-preview`. Create reads `--params-file` plus optional `--email-cap-usd`; `wait` takes `--timeout`; `get --all-rows` fetches all contacts. `get --rows-offset` and `--rows-limit` count company domains for contacts runs. `continue` only advances companies runs and accepts `--generation` and `--redo`. + +`contacts.search` and `contacts.generate` are deprecated in favor of contacts runs; their endpoints sunset on 2026-12-31. + ### Contact generation without an LLM key `contacts.generate` runs on your own search provider plus either your own LLM or DiscoLike Groove, DiscoLike's native extractor. Pass `NATIVE_ENGINE` to skip the LLM entirely: diff --git a/examples/README.md b/examples/README.md index 45d69b9..8ef9b44 100644 --- a/examples/README.md +++ b/examples/README.md @@ -13,11 +13,11 @@ export DISCOLIKE_API_KEY="dl_..." # create one at https://app.discolike.com/ac | [`icp_prompt_search.py`](icp_prompt_search.py) | Turns a plain-English ICP into a company list with similarity scores | `python examples/icp_prompt_search.py "Series A fintechs in Europe selling to SMBs" --country EU` | | [`phrase_match_count.py`](phrase_match_count.py) | Counts sites whose text contains an exact phrase, then lists them | `python examples/phrase_match_count.py "SOC 2" --country US` | | [`enrich_crm_export.py`](enrich_crm_export.py) | Adds firmographics (size, revenue, location, industry, business model) to every domain in a CSV | `python examples/enrich_crm_export.py accounts.csv --domain-column website --output enriched.csv` | -| [`contacts_at_results.py`](contacts_at_results.py) | Discovers companies for an ICP, then finds contacts at the top N filtered by seniority, department, or title | `python examples/contacts_at_results.py "B2B SaaS selling to sales teams" --top 10 --seniority executive --has-email` | +| [`contacts_at_results.py`](contacts_at_results.py) | Automatically finds and delivers validated contacts for an ICP and people fit | `python examples/contacts_at_results.py "B2B SaaS selling to sales teams" --top 10 --fit "Sales executives" --email-cap-usd 20` | | [`agent_signup_to_first_search.py`](agent_signup_to_first_search.py) | Opens a DiscoLike account for a person from an agent, relays `next_step`, and runs a first search once `DISCOLIKE_API_KEY` is set | `python examples/agent_signup_to_first_search.py --email jane@acme.com --first-name Jane --last-name Doe` | | [`discover_and_enrich.py`](discover_and_enrich.py) | Discovers companies for an ICP, then runs a DiscoGen research prompt over them (needs a BYOK LLM provider) | `python examples/discover_and_enrich.py --icp "Cybersecurity for SMBs" --country US --query "What is their pricing model?"` | | [`find_emails_from_csv.py`](find_emails_from_csv.py) | Finds verified work emails for a CSV of first name, last name, domain in batches of 500; only status `found` bills | `python examples/find_emails_from_csv.py people.csv --output emails.csv` | | [`match_crm_contacts.py`](match_crm_contacts.py) | Matches a messy CRM contact export to DiscoLike persona IDs with resumable checkpointing | `python examples/match_crm_contacts.py contacts.csv --output matched.csv` | -| [`cli_recipes.sh`](cli_recipes.sh) | The same searches as `discolike discover`, `discolike count`, `discolike contacts search`, and `discolike signup` one-liners, plus a `discolike bulk` volume pull | `bash examples/cli_recipes.sh` | +| [`cli_recipes.sh`](cli_recipes.sh) | The same searches as `discolike discover`, `discolike count`, `discolike contacts run`, and `discolike signup` one-liners, plus a `discolike bulk` volume pull | `bash examples/cli_recipes.sh` | Every script prints `--help`. Employee ranges are `min,max` strings such as `51,200`; countries are ISO-2 codes or region aliases like `EU`, `DACH`, `APAC`. diff --git a/examples/cli_recipes.sh b/examples/cli_recipes.sh index 4622818..f56543f 100644 --- a/examples/cli_recipes.sh +++ b/examples/cli_recipes.sh @@ -16,11 +16,15 @@ discolike discover --icp-prompt "Series A fintechs in Europe selling to SMBs" -- discolike count --phrase-match "SOC 2" --country US --format json discolike discover --phrase-match "SOC 2" --country US --max-records 50 --format json | jq -r '.[].domain' -# contacts_at_results.py: discover companies, then executives at the first ten of them -discolike discover --icp-prompt "B2B SaaS companies selling to sales teams" --max-records 10 --format json \ - | jq -r '.[].domain' \ - | xargs -I{} printf -- '--domain %s ' {} \ - | xargs discolike contacts search --seniority executive --has-email --format json +# contacts_at_results.py: automatic validated contacts for an ICP and people fit +cat > contacts-run.json <<'JSON' +{"companies":{"source":"prompt","icp_text":"B2B SaaS selling to sales teams"},"people":{"fit":"Sales executives"},"max_companies":10,"per_company":3} +JSON +discolike contacts run create --params-file contacts-run.json --email-cap-usd 20 +# Use the returned run ID: +# discolike contacts run events RUN_ID --after 0 +# discolike contacts run wait RUN_ID --timeout 3600 +# discolike contacts run get RUN_ID --all-rows # enrich_crm_export.py: one company profile by domain discolike company data stripe.com --format json diff --git a/examples/contacts_at_results.py b/examples/contacts_at_results.py index 6bc3f3e..3555f6f 100644 --- a/examples/contacts_at_results.py +++ b/examples/contacts_at_results.py @@ -1,57 +1,49 @@ -"""Discover companies for an ICP, then find decision makers at the top N by seniority, department, or title.""" +"""Automatically find and deliver validated decision-maker contacts for a company ICP.""" from __future__ import annotations import argparse from discolike import Discolike -from discolike.requests import ContactsSearchParams -from discolike.requests import DiscoverParams - -SENIORITIES = ["executive", "vp", "director", "manager", "senior_ic", "mid_level", "entry_level"] +from discolike.requests import ContactsRunRequest def parse_args() -> argparse.Namespace: parser = argparse.ArgumentParser(description=__doc__) parser.add_argument("prompt", help="Natural-language ICP for the company search") parser.add_argument("--country", action="append", help="ISO-2 country code or region alias, repeatable") - parser.add_argument("--top", type=int, default=10, help="Companies to search contacts at (default 10)") - parser.add_argument("--seniority", action="append", choices=SENIORITIES, help="Persona seniority, repeatable") - parser.add_argument("--department", action="append", help="Persona department, e.g. 'Sales - Marketing'") - parser.add_argument("--title", action="append", help="Job title term, e.g. 'Head of Growth', repeatable") - parser.add_argument("--per-company", type=int, default=3, help="Contacts per company (default 3)") - parser.add_argument("--has-email", action="store_true", help="Only contacts with an email address") + parser.add_argument( + "--top", type=int, default=10, help="Target companies delivering validated contacts (default 10)" + ) + parser.add_argument("--fit", default="Senior executives and decision makers", help="Describe the people to find") + parser.add_argument("--per-company", type=int, default=3, help="Validated contacts per company (default 3)") + parser.add_argument("--email-cap-usd", type=float, help="Maximum delivery spend in USD; omit for no cap") return parser.parse_args() def main() -> None: args = parse_args() - client = Discolike() - - companies = client.discover( - DiscoverParams(icp_prompt=args.prompt, country=args.country, max_records=max(args.top, 5)) - ) - domains = [company.domain for company in companies[: args.top] if company.domain] - print(f"Top {len(domains)} companies: {', '.join(domains)}") - - contacts = client.contacts.search( - ContactsSearchParams( - domain=domains, - seniority=args.seniority, - department=args.department, - title=args.title, - has_email=args.has_email, - results_by_company=args.per_company, - max_records=max(len(domains) * args.per_company, 20), - ) - ) - print(f"\n{'domain':<28} {'name':<28} {'title':<40} email") - for contact in contacts: - print( - f"{contact.domain or '':<28} {(contact.name or '')[:28]:<28} " - f"{(contact.title or '')[:40]:<40} {contact.email or ''}" - ) - print(f"\n{len(contacts)} contacts across {len({contact.domain for contact in contacts})} companies") + body = { + "companies": { + "source": "prompt", + "icp_text": args.prompt, + "filters": {"filter_country": args.country} if args.country else {}, + }, + "people": {"fit": args.fit}, + "max_companies": args.top, + "per_company": args.per_company, + } + if args.email_cap_usd is not None: + body["email_cap_usd"] = args.email_cap_usd + with Discolike() as client: + run = client.contacts_run.create(ContactsRunRequest.model_validate(body)) + print(f"Run {run.id}: {run.status}") + run = client.contacts_run.wait(run.id) + print(f"Finished: {run.status}; summary: {run.summary}") + for contact in client.contacts_run.rows(run.id): + print( + f"{contact.domain}\t{contact.name or ''}\t{contact.title or ''}\t{contact.email or ''}\t{contact.email_status or ''}" + ) if __name__ == "__main__": diff --git a/packages/discolike-cli/README.md b/packages/discolike-cli/README.md index d2f58ad..2d51cf6 100644 --- a/packages/discolike-cli/README.md +++ b/packages/discolike-cli/README.md @@ -37,6 +37,10 @@ discolike extract https://stripe.com/enterprise Top-level commands: `discover`, `count`, `match`, `extract`, `validate-icp`, `append`, `segment` — plus `auth`, `bulk`, `company`, `contacts`, `discogen`, `prospecting`, `queries`, `account`, `search-providers`, and `llm-providers` command groups. +### Contacts runs + +`discolike contacts run create --params-file run.json` starts a contacts run from a JSON request (`companies`, `people`, `relevance`, `per_company`, `max_contacts`, `auto_stop`, `find_missing`, `email_cap_usd`, `mode`). `get`, `list`, `continue`, `cancel` and `title-preview` mirror the SDK. `wait RUN_ID` blocks until the run finishes or pauses after a step; `--follow` continues each step for you and stops before `find_emails` unless the run was created with a cap or you pass `--email-cap-usd`. `contacts search`, `count` and `generate` still work but print a deprecation line on stderr after their output: their endpoints sunset on 2026-12-31. `email find --suggested-email` verifies an address you already have before looking for the right one. + ### Volume pulls `discolike bulk` walks past the 10,000-per-search ceiling and pulls contacts for a whole domain list into one CSV, with checkpoint and resume: diff --git a/packages/discolike-cli/src/discolike_cli/_help.py b/packages/discolike-cli/src/discolike_cli/_help.py index ab894b7..dde4b27 100644 --- a/packages/discolike-cli/src/discolike_cli/_help.py +++ b/packages/discolike-cli/src/discolike_cli/_help.py @@ -185,7 +185,7 @@ def format_help(self, ctx: Context, formatter: HelpFormatter) -> None: """, "extract": """\ Output (success, exit 0): - {"text": , "language": } + {"text": } Fetches the page live; use it to confirm a seed domain is the right company. Common errors: diff --git a/packages/discolike-cli/src/discolike_cli/bulk.py b/packages/discolike-cli/src/discolike_cli/bulk.py index a2a1ec1..b05b8c6 100644 --- a/packages/discolike-cli/src/discolike_cli/bulk.py +++ b/packages/discolike-cli/src/discolike_cli/bulk.py @@ -41,6 +41,7 @@ from discolike_cli.discover import PARAM_HELP from discolike_cli.discover import PARAMS_FILE_HELP from discolike_cli.discover import RELEVANCE_HELP +from discolike_cli.discover import VALID_MX_HELP from discolike_cli.discover import VARIANCE_HELP from discolike_cli.discover import _merge_params @@ -159,6 +160,7 @@ def companies_command( state: list[str] | None = typer.Option(None, help="State or region filter (repeatable)."), employee_range: str | None = typer.Option(None, help="Employee count range filter."), revenue_range: str | None = typer.Option(None, help="Revenue range filter."), + valid_mx: bool | None = typer.Option(None, "--valid-mx/--no-valid-mx", help=VALID_MX_HELP), relevance: str | None = typer.Option(None, help=RELEVANCE_HELP), variance: str | None = typer.Option(None, help=VARIANCE_HELP), exclusion_query_id: list[str] | None = typer.Option(None, help=EXCLUSION_QUERY_ID_HELP), @@ -187,6 +189,7 @@ def companies_command( state=state, employee_range=employee_range, revenue_range=revenue_range, + valid_mx=valid_mx, relevance=relevance, variance=variance, ), diff --git a/packages/discolike-cli/src/discolike_cli/contacts.py b/packages/discolike-cli/src/discolike_cli/contacts.py index b29b4ea..1b19ec1 100644 --- a/packages/discolike-cli/src/discolike_cli/contacts.py +++ b/packages/discolike-cli/src/discolike_cli/contacts.py @@ -59,6 +59,11 @@ RESULTS_BY_COMPANY_HELP = "Maximum contacts per company domain (default 5; 0 removes the cap)." INCLUDE_SEARCH_CONTACTS_HELP = "Include contacts from the search index (broader coverage)." CONSENSUS_HELP = "Number of query vectors to combine for consensus search." +# Printed only after success, so a failed command's stderr stays the single JSON error object. +DEPRECATED_COMMAND_WARNING = ( + "warning: `discolike contacts {command}` is deprecated and its endpoint sunsets on 2026-12-31; " + "use `discolike contacts run`." +) def _email_validated_as_has_email(*, email_validated: bool | None, has_email: bool | None) -> bool | None: @@ -174,6 +179,7 @@ def search_command( consensus=consensus, ) emit(get_client(ctx).contacts.search(build_request(ContactsSearchParams, kwargs)), fmt=fmt) + typer.echo(DEPRECATED_COMMAND_WARNING.format(command="search"), err=True) @app.command("count") @@ -510,3 +516,4 @@ def generate_command( ), ) run_job(get_client(ctx).contacts.generate(request), wait=wait, timeout=timeout, fmt=fmt) + typer.echo(DEPRECATED_COMMAND_WARNING.format(command="generate"), err=True) diff --git a/packages/discolike-cli/src/discolike_cli/contacts_run.py b/packages/discolike-cli/src/discolike_cli/contacts_run.py new file mode 100644 index 0000000..f31d6df --- /dev/null +++ b/packages/discolike-cli/src/discolike_cli/contacts_run.py @@ -0,0 +1,169 @@ +from __future__ import annotations + +import pathlib + +import typer + +from discolike.requests import ContactsRunEventsParams +from discolike.requests import ContactsRunGetParams +from discolike.requests import ContactsRunListParams +from discolike.requests import ContactsRunRequest +from discolike.requests import ContinueRunRequest +from discolike.requests import TitlePreviewRequest +from discolike_cli._output import build_request +from discolike_cli._output import emit +from discolike_cli._output import handle_errors +from discolike_cli.discover import _merge_params + +DEFAULT_WAIT_TIMEOUT_SECONDS = 3600.0 + +FORMAT_HELP = "Output format: json or table (table auto-selected on a TTY; falls back to JSON for non-tabular data)." +PARAMS_FILE_HELP = ( + "JSON object with the run request: companies (source prompt, domains or lookalike), relevance, " + "people.fit, max_companies, per_company, email_cap_usd, exclusions and create_key. --param and flags override its values." +) +PARAM_HELP = "Extra key=value body field (repeatable)." +EMAIL_CAP_HELP = "Maximum USD to spend on validated delivery; omit for no cap." +GENERATION_HELP = "Generation of the companies run you are continuing; fetched when omitted." + +app = typer.Typer(help="Find and validate contacts automatically. Poll company progress and delivery totals.") + + +@app.command("create") +@handle_errors +def create_command( + ctx: typer.Context, + params_file: pathlib.Path | None = typer.Option(None, help=PARAMS_FILE_HELP), + param: list[str] | None = typer.Option(None, "--param", help=PARAM_HELP), + email_cap_usd: float | None = typer.Option(None, "--email-cap-usd", help=EMAIL_CAP_HELP), +) -> None: + """Start a contacts run from a JSON request.""" + from discolike_cli.main import get_client + + request = build_request(ContactsRunRequest, _merge_params(param, params_file, email_cap_usd=email_cap_usd)) + emit(get_client(ctx).contacts_run.create(request)) + + +@app.command("list") +@handle_errors +def list_command( + ctx: typer.Context, + before: str | None = typer.Option( + None, help="Only runs created before this time, ISO 8601 with offset, e.g. 2026-10-30T12:00:00Z." + ), + limit: int | None = typer.Option(None, help="Runs per page (default 20, maximum 100)."), + fmt: str | None = typer.Option(None, "--format", help=FORMAT_HELP), +) -> None: + """List your runs, newest first.""" + from discolike_cli.main import get_client + + params = build_request(ContactsRunListParams, _merge_params(None, before=before, limit=limit)) + emit(get_client(ctx).contacts_run.list(params), fmt=fmt) + + +@app.command("get") +@handle_errors +def get_command( + ctx: typer.Context, + run_id: str = typer.Argument(..., help="Run ID."), + rows_offset: int | None = typer.Option(None, help="Company domains to skip for contacts runs."), + rows_limit: int | None = typer.Option( + None, help="Company domains per page for contacts runs (default 100, maximum 1000)." + ), + all_rows: bool = typer.Option(False, "--all-rows", help="Print every row of the run as a list instead."), + fmt: str | None = typer.Option(None, "--format", help=FORMAT_HELP), +) -> None: + """Show a run with a page of its rows.""" + from discolike_cli.main import get_client + + client = get_client(ctx) + if all_rows: + emit(client.contacts_run.rows(run_id), fmt=fmt) + return + params = build_request(ContactsRunGetParams, _merge_params(None, rows_offset=rows_offset, rows_limit=rows_limit)) + emit(client.contacts_run.get(run_id, params)) + + +@app.command("continue") +@handle_errors +def continue_command( + ctx: typer.Context, + run_id: str = typer.Argument(..., help="Run ID."), + generation: int | None = typer.Option(None, help=GENERATION_HELP), + redo: bool | None = typer.Option(None, "--redo/--no-redo", help="Run the current step again."), +) -> None: + """Advance or redo a companies run. Contacts runs proceed automatically (409 here).""" + from discolike_cli.main import get_client + + client = get_client(ctx) + request = build_request( + ContinueRunRequest, + _merge_params( + None, + generation=client.contacts_run.get(run_id).generation if generation is None else generation, + redo=redo, + ), + ) + emit(client.contacts_run.continue_(run_id, request)) + + +@app.command("cancel") +@handle_errors +def cancel_command(ctx: typer.Context, run_id: str = typer.Argument(..., help="Run ID.")) -> None: + """Cancel a run; charges already incurred stay.""" + from discolike_cli.main import get_client + + emit(get_client(ctx).contacts_run.cancel(run_id)) + + +@app.command("wait") +@handle_errors +def wait_command( + ctx: typer.Context, + run_id: str = typer.Argument(..., help="Run ID."), + timeout: float = typer.Option(DEFAULT_WAIT_TIMEOUT_SECONDS, "--timeout", help="Max seconds to wait."), +) -> None: + """Block until done, failed or cancelled. A timeout stops local polling only.""" + from discolike_cli.main import get_client + + emit(get_client(ctx).contacts_run.wait(run_id, max_wait=timeout)) + + +@app.command("events") +@handle_errors +def events_command( + ctx: typer.Context, + run_id: str = typer.Argument(..., help="Run ID."), + after: int | None = typer.Option(None, help="Return events after this cursor."), + limit: int | None = typer.Option(None, help="Events per page (default 100, maximum 500)."), +) -> None: + """Read company progress events and the next cursor.""" + from discolike_cli.main import get_client + + params = build_request(ContactsRunEventsParams, _merge_params(None, after=after, limit=limit)) + emit(get_client(ctx).contacts_run.events(run_id, params)) + + +@app.command("title-preview") +@handle_errors +def title_preview_command( + ctx: typer.Context, + title: list[str] = typer.Option(..., "--title", help="Job title to preview (repeatable)."), + exclude_title: list[str] | None = typer.Option( + None, "--exclude-title", help="Job title to leave out (repeatable)." + ), + match: str = typer.Option("similar", help="similar also accepts close variants of a title; exact does not."), + relevance: str | None = typer.Option(None, help="max_relevance, balanced or max_coverage."), + auto_stop: bool | None = typer.Option(None, "--auto-stop/--no-auto-stop", help="Apply the relevance threshold."), + fmt: str | None = typer.Option(None, "--format", help=FORMAT_HELP), +) -> None: + """Preview the titles a people spec matches, with role scores and contact counts.""" + from discolike_cli.main import get_client + + request = build_request( + TitlePreviewRequest, + _merge_params( + None, titles=title, exclude_titles=exclude_title, match=match, relevance=relevance, auto_stop=auto_stop + ), + ) + emit(get_client(ctx).contacts_run.title_preview(request).titles, fmt=fmt) diff --git a/packages/discolike-cli/src/discolike_cli/discover.py b/packages/discolike-cli/src/discolike_cli/discover.py index 2f42bfd..ab6812e 100644 --- a/packages/discolike-cli/src/discolike_cli/discover.py +++ b/packages/discolike-cli/src/discolike_cli/discover.py @@ -42,6 +42,7 @@ LANGUAGE_HELP = "Filter by site language as an ISO 639-1 code, e.g. en, de, pb, zt (repeatable)." NEGATE_LANGUAGE_HELP = "Exclude this site language (repeatable); same values as --language." EXCLUDE_LEADGEN_HELP = "Exclude suspected lead generation sites (on by default)." +VALID_MX_HELP = "Exclude companies with no mail server (off by default)." COUNT_EXCLUDE_LEADGEN_HELP = "Exclude suspected lead generation sites (off by default for count)." RELEVANCE_HELP = ( "Where the list stops, by estimated ICP fit: MAX_RELEVANCE (tightest), BALANCED, MAX_COVERAGE (longest). " @@ -114,6 +115,7 @@ def discover_command( consensus: int | None = typer.Option( None, help="Deprecated, no-op. Discover applies consensus itself to single-domain lookalike searches." ), + valid_mx: bool | None = typer.Option(None, "--valid-mx/--no-valid-mx", help=VALID_MX_HELP), redirect: bool | None = typer.Option(None, "--redirect/--no-redirect", help=REDIRECT_HELP), exclude_leadgen: bool | None = typer.Option( None, "--exclude-leadgen/--no-exclude-leadgen", help=EXCLUDE_LEADGEN_HELP @@ -193,6 +195,7 @@ def discover_command( relevance=relevance, variance=variance, consensus=consensus, + valid_mx=valid_mx, redirect=redirect, exclude_leadgen=exclude_leadgen, retrieval=retrieval, @@ -245,6 +248,7 @@ def count_command( negate_tech_stack: list[str] | None = typer.Option(None, help="Negate the --tech-stack filter (repeatable)."), min_digital_footprint: int | None = typer.Option(None, help="Minimum digital footprint score."), max_digital_footprint: int | None = typer.Option(None, help="Maximum digital footprint score."), + valid_mx: bool | None = typer.Option(None, "--valid-mx/--no-valid-mx", help=VALID_MX_HELP), redirect: bool | None = typer.Option(None, "--redirect/--no-redirect", help=REDIRECT_HELP), exclude_leadgen: bool | None = typer.Option( None, "--exclude-leadgen/--no-exclude-leadgen", help=COUNT_EXCLUDE_LEADGEN_HELP @@ -291,6 +295,7 @@ def count_command( negate_tech_stack=negate_tech_stack, min_digital_footprint=min_digital_footprint, max_digital_footprint=max_digital_footprint, + valid_mx=valid_mx, redirect=redirect, exclude_leadgen=exclude_leadgen, ), diff --git a/packages/discolike-cli/src/discolike_cli/email.py b/packages/discolike-cli/src/discolike_cli/email.py index 651d813..759d351 100644 --- a/packages/discolike-cli/src/discolike_cli/email.py +++ b/packages/discolike-cli/src/discolike_cli/email.py @@ -86,6 +86,12 @@ def find_command( known_pattern: str | None = typer.Option( None, "--known-pattern", help="Known email local-part pattern for this domain, e.g. first.last." ), + suggested_email: str | None = typer.Option( + None, + "--suggested-email", + help="Address you think is right: verified first, and the right one recovered if it is not proven. " + "Must be at DOMAIN or the company's mail domain.", + ), wait: bool = typer.Option(False, "--wait/--no-wait", help=WAIT_HELP), timeout: float = typer.Option(DEFAULT_WAIT_TIMEOUT_SECONDS, "--timeout", help=TIMEOUT_HELP), fmt: str | None = typer.Option(None, "--format", help=FORMAT_HELP), @@ -95,7 +101,14 @@ def find_command( request = build_request( FindEmailRequest, - _merge_params(None, first_name=first_name, last_name=last_name, domain=domain, known_pattern=known_pattern), + _merge_params( + None, + first_name=first_name, + last_name=last_name, + domain=domain, + known_pattern=known_pattern, + suggested_email=suggested_email, + ), ) job = get_client(ctx).email.find(request) if not wait: diff --git a/packages/discolike-cli/src/discolike_cli/main.py b/packages/discolike-cli/src/discolike_cli/main.py index 1c1634c..5315c9c 100644 --- a/packages/discolike-cli/src/discolike_cli/main.py +++ b/packages/discolike-cli/src/discolike_cli/main.py @@ -18,6 +18,7 @@ from discolike_cli import bulk from discolike_cli import company from discolike_cli import contacts +from discolike_cli import contacts_run from discolike_cli import discogen from discolike_cli import discover from discolike_cli import email @@ -83,6 +84,7 @@ def get_client(ctx: typer.Context) -> Discolike: app.add_typer(bulk.app, name="bulk") app.add_typer(company.app, name="company") app.add_typer(contacts.app, name="contacts") +contacts.app.add_typer(contacts_run.app, name="run") app.add_typer(discogen.app, name="discogen") app.add_typer(email.app, name="email") app.add_typer(queries.app, name="queries") diff --git a/packages/discolike-cli/tests/test_bulk_cli.py b/packages/discolike-cli/tests/test_bulk_cli.py index 8ab497d..0cf0fa8 100644 --- a/packages/discolike-cli/tests/test_bulk_cli.py +++ b/packages/discolike-cli/tests/test_bulk_cli.py @@ -661,3 +661,16 @@ def test_bulk_rejects_non_positive_rate_limit( result = runner.invoke(app, ["bulk", *command, *extra, "--out", str(tmp_path / "c.csv"), "--rate-limit", "0"]) assert result.exit_code == 2, result.output assert recorder.requests == [] + + +@pytest.mark.parametrize(("flags", "expected"), [([], None), (["--valid-mx"], "true"), (["--no-valid-mx"], "false")]) +def test_bulk_companies_valid_mx( + install_build_client: Callable[[Handler], None], tmp_path: Path, flags: list[str], expected: str | None +) -> None: + recorder = Recorder(discover=[_companies("a", 1)]) + install_build_client(recorder) + result = runner.invoke( + app, ["bulk", "companies", "--out", str(tmp_path / "companies.csv"), "--max-companies", "1", *flags] + ) + assert result.exit_code == 0, result.output + assert recorder.params("/v1/discover")[0].get("valid_mx") == expected diff --git a/packages/discolike-cli/tests/test_company.py b/packages/discolike-cli/tests/test_company.py index 3b51b18..f43b7d1 100644 --- a/packages/discolike-cli/tests/test_company.py +++ b/packages/discolike-cli/tests/test_company.py @@ -135,7 +135,7 @@ def test_extract_hits_extract_endpoint_with_url(install_build_client: Callable[[ def handler(request: httpx2.Request) -> httpx2.Response: captured["request"] = request - return httpx2.Response(200, json={"text": "hello", "language": "en"}) + return httpx2.Response(200, json={"text": "hello"}) install_build_client(handler) result = runner.invoke(app, ["extract", "https://acme.com/about"]) @@ -143,7 +143,7 @@ def handler(request: httpx2.Request) -> httpx2.Response: request = captured["request"] assert request.url.path == "/v1/extract" assert request.url.params.get("url") == "https://acme.com/about" - assert json.loads(result.stdout) == {"text": "hello", "language": "en"} + assert json.loads(result.stdout) == {"text": "hello"} def test_extract_domain_option_replaces_url(install_build_client: Callable[[Handler], None]) -> None: @@ -151,7 +151,7 @@ def test_extract_domain_option_replaces_url(install_build_client: Callable[[Hand def handler(request: httpx2.Request) -> httpx2.Response: captured["request"] = request - return httpx2.Response(200, json={"text": "hello", "language": "en"}) + return httpx2.Response(200, json={"text": "hello"}) install_build_client(handler) result = runner.invoke(app, ["extract", "--domain", "acme.com"]) diff --git a/packages/discolike-cli/tests/test_contacts_cli.py b/packages/discolike-cli/tests/test_contacts_cli.py index b8c8cfc..52395dd 100644 --- a/packages/discolike-cli/tests/test_contacts_cli.py +++ b/packages/discolike-cli/tests/test_contacts_cli.py @@ -532,7 +532,7 @@ def test_contacts_without_email_validated_does_not_warn(install_build_client: Ca 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 + assert "email-validated" not in result.stderr def test_contacts_generate_forwards_full_and_partial_domains(install_build_client: Callable[[Handler], None]) -> None: diff --git a/packages/discolike-cli/tests/test_contacts_run_cli.py b/packages/discolike-cli/tests/test_contacts_run_cli.py new file mode 100644 index 0000000..db01d1f --- /dev/null +++ b/packages/discolike-cli/tests/test_contacts_run_cli.py @@ -0,0 +1,209 @@ +from __future__ import annotations + +import json +import pathlib +import time +from collections.abc import Callable +from typing import Any + +import httpx2 +import pytest +from typer.testing import CliRunner + +from discolike_cli.main import app +from discolike_testkit import Handler + +runner = CliRunner() + +RUN_ID = "00000000-0000-0000-0000-0000000000bb" +RUN_PATH = f"/v1/contacts/run/{RUN_ID}" +TIMESTAMP = "2026-10-30T12:00:00Z" +STEPS = ("find_contacts", "find_missing", "review", "find_emails") + + +@pytest.fixture(autouse=True) +def no_sleep(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr(time, "sleep", lambda seconds: None) + + +def run_payload(*, status: str = "waiting", step: int = 0, generation: int = 1) -> dict[str, Any]: + return { + "id": RUN_ID, + "kind": "contacts", + "status": status, + "current_step": STEPS[step], + "generation": generation, + "steps": {STEPS[step]: {"status": "done"}}, + "created_at": TIMESTAMP, + "updated_at": TIMESTAMP, + "expires_at": TIMESTAMP, + } + + +def test_run_create_posts_params_file(install_build_client: Callable[[Handler], None], tmp_path: pathlib.Path) -> None: + bodies: list[dict[str, Any]] = [] + + def handler(request: httpx2.Request) -> httpx2.Response: + bodies.append(json.loads(request.content)) + return httpx2.Response(202, json=run_payload(status="running")) + + install_build_client(handler) + params_file = tmp_path / "run.json" + params_file.write_text( + json.dumps({"companies": {"source": "prompt", "icp_text": "dental clinics"}, "people": {"fit": "Owners"}}) + ) + result = runner.invoke( + app, ["contacts", "run", "create", "--params-file", str(params_file), "--email-cap-usd", "20"] + ) + + assert result.exit_code == 0, result.output + assert bodies == [ + { + "companies": {"source": "prompt", "icp_text": "dental clinics"}, + "people": {"fit": "Owners"}, + "email_cap_usd": "20.0", + } + ] + assert json.loads(result.stdout)["status"] == "running" + + +def test_run_continue_fetches_generation_when_omitted(install_build_client: Callable[[Handler], None]) -> None: + seen: list[tuple[str, str, Any]] = [] + + def handler(request: httpx2.Request) -> httpx2.Response: + body = json.loads(request.content) if request.content else None + seen.append((request.method, request.url.path, body)) + return httpx2.Response(200, json=run_payload(generation=4)) + + install_build_client(handler) + result = runner.invoke(app, ["contacts", "run", "continue", RUN_ID, "--redo"]) + + assert result.exit_code == 0, result.output + assert seen == [ + ("GET", RUN_PATH, None), + ("POST", f"{RUN_PATH}/continue", {"generation": 4, "redo": True}), + ] + + +def test_run_wait_polls_automatically(install_build_client: Callable[[Handler], None]) -> None: + statuses = iter(["ready", "running", "done"]) + + def handler(request: httpx2.Request) -> httpx2.Response: + assert request.method == "GET" + return httpx2.Response(200, json=run_payload(status=next(statuses))) + + install_build_client(handler) + result = runner.invoke(app, ["contacts", "run", "wait", RUN_ID]) + assert result.exit_code == 0, result.output + assert json.loads(result.stdout)["status"] == "done" + + +def test_run_events(install_build_client: Callable[[Handler], None]) -> None: + def handler(request: httpx2.Request) -> httpx2.Response: + assert request.url.path == RUN_PATH + "/events" + assert dict(request.url.params) == {"after": "4", "limit": "10"} + return httpx2.Response(200, json={"events": [], "next_cursor": 5}) + + install_build_client(handler) + result = runner.invoke(app, ["contacts", "run", "events", RUN_ID, "--after", "4", "--limit", "10"]) + assert result.exit_code == 0, result.output + assert json.loads(result.stdout)["next_cursor"] == 5 + + +@pytest.mark.parametrize("flag", ["--follow", "--email-cap-usd"]) +def test_removed_wait_flags(flag: str) -> None: + assert runner.invoke(app, ["contacts", "run", "wait", RUN_ID, flag]).exit_code == 2 + + +def test_run_cancel_and_get(install_build_client: Callable[[Handler], None]) -> None: + seen: list[tuple[str, str, str]] = [] + + def handler(request: httpx2.Request) -> httpx2.Response: + seen.append((request.method, request.url.path, request.url.query.decode())) + return httpx2.Response(200, json=run_payload(status="cancelled")) + + install_build_client(handler) + assert runner.invoke(app, ["contacts", "run", "cancel", RUN_ID]).exit_code == 0 + assert runner.invoke(app, ["contacts", "run", "get", RUN_ID, "--rows-limit", "10"]).exit_code == 0 + assert seen == [("POST", f"{RUN_PATH}/cancel", ""), ("GET", RUN_PATH, "rows_limit=10")] + + +def test_run_list_sends_before(install_build_client: Callable[[Handler], None]) -> None: + queries: list[httpx2.QueryParams] = [] + + def handler(request: httpx2.Request) -> httpx2.Response: + queries.append(request.url.params) + return httpx2.Response(200, json=[run_payload()]) + + install_build_client(handler) + result = runner.invoke(app, ["contacts", "run", "list", "--before", "2026-10-30T00:00:00+00:00", "--limit", "5"]) + assert result.exit_code == 0, result.output + assert queries[0]["before"] == "2026-10-30T00:00:00Z" + assert queries[0]["limit"] == "5" + + +def test_run_title_preview(install_build_client: Callable[[Handler], None]) -> None: + bodies: list[dict[str, Any]] = [] + + def handler(request: httpx2.Request) -> httpx2.Response: + bodies.append(json.loads(request.content)) + return httpx2.Response(200, json={"titles": [{"title": "Owner", "role": 1.0, "count": 3}]}) + + install_build_client(handler) + result = runner.invoke(app, ["contacts", "run", "title-preview", "--title", "Owner", "--format", "json"]) + assert result.exit_code == 0, result.output + assert bodies == [{"titles": ["Owner"], "match": "similar"}] + assert json.loads(result.stdout) == [{"title": "Owner", "role": 1.0, "count": 3}] + + +def test_old_contacts_commands_print_deprecation(install_build_client: Callable[[Handler], None]) -> None: + install_build_client(lambda request: httpx2.Response(200, json=[])) + result = runner.invoke(app, ["contacts", "search", "--name", "Jane"]) + assert result.exit_code == 0, result.output + assert "`discolike contacts search` is deprecated" in result.stderr + assert "use `discolike contacts run`" in result.stderr + + +def test_contacts_discover_prints_no_deprecation(install_build_client: Callable[[Handler], None]) -> None: + install_build_client(lambda request: httpx2.Response(200, json={"results": {}})) + result = runner.invoke(app, ["contacts", "discover", "--domain", "acme.com"]) + assert result.exit_code == 0, result.output + assert "deprecated" not in result.stderr + + +def test_email_find_sends_suggested_email(install_build_client: Callable[[Handler], None]) -> None: + bodies: list[dict[str, Any]] = [] + + def handler(request: httpx2.Request) -> httpx2.Response: + bodies.append(json.loads(request.content)) + return httpx2.Response(202, json={"job_id": "j-1", "status": "queued"}) + + install_build_client(handler) + result = runner.invoke(app, ["email", "find", "Grace", "Hopper", "navy.mil", "--suggested-email", "grace@navy.mil"]) + assert result.exit_code == 0, result.output + assert bodies == [ + {"first_name": "Grace", "last_name": "Hopper", "domain": "navy.mil", "suggested_email": "grace@navy.mil"} + ] + + +def test_run_insufficient_balance(install_build_client: Callable[[Handler], None], tmp_path: pathlib.Path) -> None: + install_build_client( + lambda _: httpx2.Response( + 403, + json={ + "detail": { + "code": "INSUFFICIENT_BALANCE", + "message": "Top up to continue", + "remaining_usd": 1, + "max_affordable_records": 2, + } + }, + ) + ) + params = tmp_path / "run.json" + params.write_text(json.dumps({"companies": {"source": "domains", "domains": ["acme.com"]}})) + result = runner.invoke(app, ["contacts", "run", "create", "--params-file", str(params)]) + assert result.exit_code == 3 + error = json.loads(result.stderr) + assert error["code"] == "insufficient_balance" + assert error["max_affordable_records"] == 2 diff --git a/packages/discolike-cli/tests/test_discover.py b/packages/discolike-cli/tests/test_discover.py index d52db5e..ddc41d9 100644 --- a/packages/discolike-cli/tests/test_discover.py +++ b/packages/discolike-cli/tests/test_discover.py @@ -4,6 +4,7 @@ from collections.abc import Callable import httpx2 +import pytest from typer.testing import CliRunner from discolike_cli.main import app @@ -411,3 +412,20 @@ def test_discover_unauthorized_exits_3(install_build_client: Callable[[Handler], assert result.exit_code == 3 payload = json.loads(result.stderr) assert payload["error"] == "AuthenticationError" + + +@pytest.mark.parametrize("command", ["discover", "count"]) +@pytest.mark.parametrize(("flags", "expected"), [([], None), (["--valid-mx"], "true"), (["--no-valid-mx"], "false")]) +def test_valid_mx_flag( + install_build_client: Callable[[Handler], None], command: str, flags: list[str], expected: str | None +) -> None: + seen: list[httpx2.QueryParams] = [] + + def handler(request: httpx2.Request) -> httpx2.Response: + seen.append(request.url.params) + return _count_ok(request) if command == "count" else _discover_ok(request) + + install_build_client(handler) + result = runner.invoke(app, [command, *flags]) + assert result.exit_code == 0, result.output + assert seen[0].get("valid_mx") == expected diff --git a/packages/discolike-cli/tests/test_sdk_parity.py b/packages/discolike-cli/tests/test_sdk_parity.py index a4a86e5..6e1eae6 100644 --- a/packages/discolike-cli/tests/test_sdk_parity.py +++ b/packages/discolike-cli/tests/test_sdk_parity.py @@ -29,7 +29,10 @@ } # ``discolike bulk`` takes the full vocabulary through --params-file / --param and manages the paging # fields itself; its flags are the handful a volume run needs, not one per SDK field. -PARAMS_FILE_SITES = frozenset({"bulk.companies_command", "bulk.estimate_command", "bulk.contacts_command"}) +# ``contacts run create`` takes its nested companies/people request the same way. +PARAMS_FILE_SITES = frozenset( + {"bulk.companies_command", "bulk.estimate_command", "bulk.contacts_command", "contacts_run.create_command"} +) DICT_ONLY_FIELDS: dict[str, frozenset[str]] = { "ContactGenerateRequest": frozenset({"initial_contact_counts"}), "DiscoGenProcessRequest": frozenset({"previous_discogen_data"}), @@ -97,7 +100,7 @@ def test_cli_forwards_every_sdk_field(site: str, model_name: str, forwarded: set def test_every_request_model_has_a_cli_command() -> None: - used = {model_name for _, model_name, _ in SITES} + used = {model_name for _, model_name, _ in _build_request_sites()} declared = { name for name in dir(requests_module) diff --git a/packages/discolike/README.md b/packages/discolike/README.md index 9085925..4306c28 100644 --- a/packages/discolike/README.md +++ b/packages/discolike/README.md @@ -78,6 +78,28 @@ result = job.wait() `JobTimeoutError` is a client-side wait limit only — the task keeps running server-side (large DiscoGen runs can take hours), so call `wait()` again to resume or fetch `status()` later. Cancelled tasks still return results for every item that finished before cancellation. Send one job per list (up to 10,000 domains) rather than splitting into parallel jobs — concurrent DiscoGen jobs share your LLM provider key and slow each other down. +### Contacts runs + +`client.contacts_run` finds people at companies in four steps: `find_contacts`, `find_missing` (web search at companies with no indexed contact), `review` and `find_emails`. A `step_by_step` run (the default) pauses with `status="waiting"` after each step; `continue_()` starts the next one. `find_emails` bills per result; an optional `email_cap_usd`, given at create or on the continue that enters it, caps that spend, and without one it runs until the account balance runs out. + +```python +from discolike.requests import ContactsRunRequest, ContinueRunRequest + +run = client.contacts_run.create( + ContactsRunRequest.model_validate( + { + "companies": {"source": "prompt", "icp_text": "Independent dental clinics in Ohio"}, + "people": {"titles": ["Practice Owner", "Office Manager"]}, + } + ) +) +run = client.contacts_run.wait(run.id, follow=True) # Stops before find_emails: no cap given. +rows = client.contacts_run.rows(run.id) # Every row, paged for you. +run = client.contacts_run.continue_(run.id, ContinueRunRequest(generation=run.generation, email_cap_usd=20)) +``` + +`companies` takes `{"source": "prompt", "icp_text": ...}` (up to `max_companies`, default 500), `{"source": "domains", "domains": [...]}` or `{"source": "lookalike", "persona_ids": [...]}`. `max_contacts` keeps only the best-fit N contacts across all companies (`per_company` still caps each one), and `auto_stop=False` turns off the relevance cut, keeping every contact up to `per_company`, best fit first; both stay off the wire unless set. `wait(follow=True)` also stops at a failed step, which you continue with `redo=True`. Every continue carries the run's current `generation`; a stale one is a 409 (`ValidationError`), and a run idle for 30 days answers 410. `contacts.search`, `contacts.count` and `contacts.generate` are deprecated in favor of contacts runs; their endpoints sunset on 2026-12-31. + ### Contact generation without an LLM key `contacts.generate` runs on your own search provider plus either your own LLM or DiscoLike Groove, DiscoLike's native extractor. Pass `NATIVE_ENGINE` to skip the LLM entirely: diff --git a/packages/discolike/src/discolike/__init__.py b/packages/discolike/src/discolike/__init__.py index c5c1efd..35f1cde 100644 --- a/packages/discolike/src/discolike/__init__.py +++ b/packages/discolike/src/discolike/__init__.py @@ -18,6 +18,7 @@ from discolike._jobs import JobStatus from discolike._models import DiscolikeModel from discolike._models import DiscolikeRequest +from discolike._transport import DiscolikeDeprecationWarning from discolike._version import __version__ from discolike.resources.contacts import NATIVE_ENGINE from discolike.resources.discogen import NATIVE_ICP_ENGINE @@ -45,6 +46,7 @@ "Company", "Count", "Discolike", + "DiscolikeDeprecationWarning", "DiscolikeError", "DiscolikeModel", "DiscolikeRequest", diff --git a/packages/discolike/src/discolike/_client.py b/packages/discolike/src/discolike/_client.py index dfe1a5b..1b89c4f 100644 --- a/packages/discolike/src/discolike/_client.py +++ b/packages/discolike/src/discolike/_client.py @@ -25,6 +25,8 @@ from discolike.resources.companies import CompaniesResource from discolike.resources.contacts import AsyncContactsResource from discolike.resources.contacts import ContactsResource +from discolike.resources.contacts_run import AsyncContactsRunResource +from discolike.resources.contacts_run import ContactsRunResource from discolike.resources.discogen import AsyncDiscogenResource from discolike.resources.discogen import AsyncValidateResource from discolike.resources.discogen import DiscogenResource @@ -67,6 +69,7 @@ def _attach(self, transport: Transport) -> None: self.account = AccountResource(self._transport) self.companies = CompaniesResource(self._transport) self.contacts = ContactsResource(self._transport) + self.contacts_run = ContactsRunResource(self._transport) self.match = MatchResource(self._transport) self.discogen = DiscogenResource(self._transport) self.email = EmailResource(self._transport) @@ -138,6 +141,7 @@ def _attach(self, transport: AsyncTransport) -> None: self.account = AsyncAccountResource(self._transport) self.companies = AsyncCompaniesResource(self._transport) self.contacts = AsyncContactsResource(self._transport) + self.contacts_run = AsyncContactsRunResource(self._transport) self.match = AsyncMatchResource(self._transport) self.discogen = AsyncDiscogenResource(self._transport) self.email = AsyncEmailResource(self._transport) diff --git a/packages/discolike/src/discolike/_contacts_run_requests.py b/packages/discolike/src/discolike/_contacts_run_requests.py new file mode 100644 index 0000000..eb9d255 --- /dev/null +++ b/packages/discolike/src/discolike/_contacts_run_requests.py @@ -0,0 +1,269 @@ +# Hand-maintained against platform contacts_run.py; do not regenerate from older deployed specs. + +from __future__ import annotations + +from decimal import Decimal +from typing import Annotated +from typing import Literal + +from pydantic import AwareDatetime +from pydantic import ConfigDict +from pydantic import Field + +from discolike._models import DiscolikeRequest + + +class TitlePreviewRequest(DiscolikeRequest): + titles: Annotated[list[str], Field(max_length=50, min_length=1, title="Titles")] + exclude_titles: Annotated[list[str] | None, Field(title="Exclude Titles")] = None + match: Annotated[Literal["similar", "exact"], Field(title="Match")] + relevance: Literal["max_relevance", "balanced", "max_coverage"] = "balanced" + auto_stop: bool = True + + +class DomainCompanies(DiscolikeRequest): + source: Annotated[Literal["domains"], Field(title="Source")] + domains: Annotated[ + list[str], + Field(description="Company domains to find contacts at.", max_length=10000, min_length=1, title="Domains"), + ] + scores: Annotated[ + dict[str, float] | None, + Field(description="Optional relevance score per domain, keyed by domain.", title="Scores"), + ] = None + + +class LookalikeCompanies(DiscolikeRequest): + source: Annotated[Literal["lookalike"], Field(title="Source")] + persona_ids: Annotated[ + list[int], + Field(description="Personas whose companies seed the lookalike search.", min_length=1, title="Persona Ids"), + ] + + +class ContactsPeopleFit(DiscolikeRequest): + model_config = ConfigDict(extra="forbid", str_strip_whitespace=True) + + fit: str = Field(default="Senior executives and decision makers", min_length=1, max_length=4000) + + +class PromptCompanyFilters(DiscolikeRequest): + filter_industry: Annotated[ + list[ + Literal[ + "ACCOUNTING", + "ADVERTISING_AND_MARKETING", + "AGRICULTURE_AND_NATURAL_RESOURCES", + "ALCOHOL_AND_TOBACCO", + "AUTOMOTIVE", + "BIG_DATA_AND_ANALYTICS", + "BIOTECHNOLOGY", + "BLOCKCHAIN_AND_CRYPTOCURRENCY", + "BUSINESS_PRODUCTS_AND_SERVICES", + "CLOUD_COMPUTING", + "COMPUTER_HARDWARE_AND_SEMICONDUCTORS", + "CONGLOMERATES_SHELL_AND_HOLDING_COMPANIES", + "CONSTRUCTION", + "CONSUMER_PRODUCTS", + "CONSUMER_SERVICES", + "CYBERSECURITY", + "DEFENSE_AND_AEROSPACE", + "E-COMMERCE", + "EDUCATION", + "ENERGY", + "ENGINEERING", + "ENTERTAINMENT", + "ENVIRONMENTAL_SERVICES", + "FASHION_TEXTILE_AND_APPAREL", + "FINANCIAL_SERVICES", + "FOOD_AND_BEVERAGE", + "GAMING_AND_ESPORTS", + "GOVERNMENT_SERVICES", + "HEALTHCARE", + "HOSPITALITY", + "HUMAN_RESOURCES", + "INSURANCE", + "IT_SERVICES", + "LEGAL", + "MANUFACTURING", + "MEDIA", + "MINING_AND_METALS", + "NONPROFIT_AND_PHILANTHROPY", + "OIL_AND_GAS", + "PHARMACEUTICALS", + "PRIVATE_EQUITY_AND_VENTURE_CAPITAL", + "REAL_ESTATE", + "RENEWABLE_ENERGY", + "RESTAURANTS", + "RETAIL", + "SAAS", + "SECURITY", + "SOFTWARE", + "SPORTS_AND_RECREATION", + "SUPPLY_CHAIN_AND_PROCUREMENT", + "TELECOMMUNICATIONS", + "TRAVEL", + "WELLNESS_AND_LIFESTYLE", + ] + ] + | None, + Field( + description="Filter by industry category. Valid values: ACCOUNTING, ADVERTISING_AND_MARKETING, AGRICULTURE_AND_NATURAL_RESOURCES, ALCOHOL_AND_TOBACCO, AUTOMOTIVE, BIG_DATA_AND_ANALYTICS, BIOTECHNOLOGY, BLOCKCHAIN_AND_CRYPTOCURRENCY, BUSINESS_PRODUCTS_AND_SERVICES, CLOUD_COMPUTING, COMPUTER_HARDWARE_AND_SEMICONDUCTORS, CONGLOMERATES_SHELL_AND_HOLDING_COMPANIES, CONSTRUCTION, CONSUMER_PRODUCTS, CONSUMER_SERVICES, CYBERSECURITY, DEFENSE_AND_AEROSPACE, E-COMMERCE, EDUCATION, ENERGY, ENGINEERING, ENTERTAINMENT, ENVIRONMENTAL_SERVICES, FASHION_TEXTILE_AND_APPAREL, FINANCIAL_SERVICES, FOOD_AND_BEVERAGE, GAMING_AND_ESPORTS, GOVERNMENT_SERVICES, HEALTHCARE, HOSPITALITY, HUMAN_RESOURCES, INSURANCE, IT_SERVICES, LEGAL, MANUFACTURING, MEDIA, MINING_AND_METALS, NONPROFIT_AND_PHILANTHROPY, OIL_AND_GAS, PHARMACEUTICALS, PRIVATE_EQUITY_AND_VENTURE_CAPITAL, REAL_ESTATE, RENEWABLE_ENERGY, RESTAURANTS, RETAIL, SAAS, SECURITY, SOFTWARE, SPORTS_AND_RECREATION, SUPPLY_CHAIN_AND_PROCUREMENT, TELECOMMUNICATIONS, TRAVEL, WELLNESS_AND_LIFESTYLE.", + title="Filter Industry", + ), + ] = None + negate_filter_industry: Annotated[ + list[ + Literal[ + "ACCOUNTING", + "ADVERTISING_AND_MARKETING", + "AGRICULTURE_AND_NATURAL_RESOURCES", + "ALCOHOL_AND_TOBACCO", + "AUTOMOTIVE", + "BIG_DATA_AND_ANALYTICS", + "BIOTECHNOLOGY", + "BLOCKCHAIN_AND_CRYPTOCURRENCY", + "BUSINESS_PRODUCTS_AND_SERVICES", + "CLOUD_COMPUTING", + "COMPUTER_HARDWARE_AND_SEMICONDUCTORS", + "CONGLOMERATES_SHELL_AND_HOLDING_COMPANIES", + "CONSTRUCTION", + "CONSUMER_PRODUCTS", + "CONSUMER_SERVICES", + "CYBERSECURITY", + "DEFENSE_AND_AEROSPACE", + "E-COMMERCE", + "EDUCATION", + "ENERGY", + "ENGINEERING", + "ENTERTAINMENT", + "ENVIRONMENTAL_SERVICES", + "FASHION_TEXTILE_AND_APPAREL", + "FINANCIAL_SERVICES", + "FOOD_AND_BEVERAGE", + "GAMING_AND_ESPORTS", + "GOVERNMENT_SERVICES", + "HEALTHCARE", + "HOSPITALITY", + "HUMAN_RESOURCES", + "INSURANCE", + "IT_SERVICES", + "LEGAL", + "MANUFACTURING", + "MEDIA", + "MINING_AND_METALS", + "NONPROFIT_AND_PHILANTHROPY", + "OIL_AND_GAS", + "PHARMACEUTICALS", + "PRIVATE_EQUITY_AND_VENTURE_CAPITAL", + "REAL_ESTATE", + "RENEWABLE_ENERGY", + "RESTAURANTS", + "RETAIL", + "SAAS", + "SECURITY", + "SOFTWARE", + "SPORTS_AND_RECREATION", + "SUPPLY_CHAIN_AND_PROCUREMENT", + "TELECOMMUNICATIONS", + "TRAVEL", + "WELLNESS_AND_LIFESTYLE", + ] + ] + | None, + Field( + description="Exclude contacts at companies in specified industries. Valid values: ACCOUNTING, ADVERTISING_AND_MARKETING, AGRICULTURE_AND_NATURAL_RESOURCES, ALCOHOL_AND_TOBACCO, AUTOMOTIVE, BIG_DATA_AND_ANALYTICS, BIOTECHNOLOGY, BLOCKCHAIN_AND_CRYPTOCURRENCY, BUSINESS_PRODUCTS_AND_SERVICES, CLOUD_COMPUTING, COMPUTER_HARDWARE_AND_SEMICONDUCTORS, CONGLOMERATES_SHELL_AND_HOLDING_COMPANIES, CONSTRUCTION, CONSUMER_PRODUCTS, CONSUMER_SERVICES, CYBERSECURITY, DEFENSE_AND_AEROSPACE, E-COMMERCE, EDUCATION, ENERGY, ENGINEERING, ENTERTAINMENT, ENVIRONMENTAL_SERVICES, FASHION_TEXTILE_AND_APPAREL, FINANCIAL_SERVICES, FOOD_AND_BEVERAGE, GAMING_AND_ESPORTS, GOVERNMENT_SERVICES, HEALTHCARE, HOSPITALITY, HUMAN_RESOURCES, INSURANCE, IT_SERVICES, LEGAL, MANUFACTURING, MEDIA, MINING_AND_METALS, NONPROFIT_AND_PHILANTHROPY, OIL_AND_GAS, PHARMACEUTICALS, PRIVATE_EQUITY_AND_VENTURE_CAPITAL, REAL_ESTATE, RENEWABLE_ENERGY, RESTAURANTS, RETAIL, SAAS, SECURITY, SOFTWARE, SPORTS_AND_RECREATION, SUPPLY_CHAIN_AND_PROCUREMENT, TELECOMMUNICATIONS, TRAVEL, WELLNESS_AND_LIFESTYLE.", + title="Negate Filter Industry", + ), + ] = None + 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, EUROPE, ASIA, AFRICA, NORTH_AMERICA, SOUTH_AMERICA, OCEANIA, ANTARCTICA.", + title="Filter Country", + ), + ] = None + negate_filter_country: Annotated[ + list[str] | None, + Field( + description="Exclude contacts at companies in specified countries. Accepts same codes and region aliases as filter_country.", + title="Negate Filter Country", + ), + ] = None + filter_state: Annotated[ + list[str] | None, + Field( + description="Filter by company state/region. Accepts ISO 3166-2 codes or names, listed at https://docs.discolike.com/states/, resolved against the selected countries; a value that resolves under none is ignored.", + title="Filter State", + ), + ] = None + negate_filter_state: Annotated[ + list[str] | None, + Field( + description="Exclude contacts at companies in specified states. Same format as filter_state.", + title="Negate Filter State", + ), + ] = None + employee_range: Annotated[ + str | None, + Field( + description="Filter by employee count range. Format: 'min,max' (e.g., '51,200'). Maps to buckets: 1-10, 11-50, 51-200, 201-500, 501-1000, 1001-5000, 5001-10000, 10001+.", + title="Employee Range", + ), + ] = None + revenue_range: Annotated[ + str | None, + Field( + description="Company revenue range as 'min,max' in raw numbers, e.g. '10000000,50000000'.", + title="Revenue Range", + ), + ] = None + sub_industry: Annotated[ + list[str] | None, + Field( + description="Only include companies in these sub-industries. Accepts a parent-qualified key (CONSTRUCTION/ROOFING), or a bare label (ROOFING) whose parent is unambiguous, case-insensitive.", + title="Sub Industry", + ), + ] = None + + +class ContinueRunRequest(DiscolikeRequest): + model_config = ConfigDict(extra="forbid") + + generation: int + redo: bool = False + + +class PromptCompanies(DiscolikeRequest): + source: Literal["prompt"] + icp_text: str | None = Field(default=None, min_length=3) + filters: PromptCompanyFilters = Field(default_factory=PromptCompanyFilters) + + +class ContactsRunRequest(DiscolikeRequest): + model_config = ConfigDict(extra="forbid") + + companies: Annotated[PromptCompanies | DomainCompanies | LookalikeCompanies, Field(discriminator="source")] + people: ContactsPeopleFit = Field(default_factory=ContactsPeopleFit) + relevance: Literal["max_relevance", "balanced", "max_coverage"] = "balanced" + max_companies: int = Field(default=500, ge=1, le=10000) + per_company: int = Field(default=10, ge=1, le=50) + email_cap_usd: Decimal | None = Field(default=None, ge=0) + exclude_query_ids: list[str] = Field(default_factory=list, max_length=20) + exclude_domains: list[str] = Field(default_factory=list, max_length=100) + create_key: str | None = None + + +class ContactsRunEventsParams(DiscolikeRequest): + after: int = Field(default=0, ge=0) + limit: int = Field(default=100, ge=1, le=500) + + +class ContactsRunListParams(DiscolikeRequest): + before: Annotated[AwareDatetime | None, Field(title="Before")] = None + limit: Annotated[int | None, Field(ge=1, le=100, title="Limit")] = 20 + + +class ContactsRunGetParams(DiscolikeRequest): + rows_offset: Annotated[int | None, Field(ge=0, title="Rows Offset")] = 0 + rows_limit: Annotated[int | None, Field(ge=1, le=1000, title="Rows Limit")] = 100 diff --git a/packages/discolike/src/discolike/_email.py b/packages/discolike/src/discolike/_email.py index 242373d..e1ccfd7 100644 --- a/packages/discolike/src/discolike/_email.py +++ b/packages/discolike/src/discolike/_email.py @@ -16,9 +16,12 @@ from discolike._models import DiscolikeModel from discolike._transport import AsyncTransport from discolike._transport import Transport +from discolike.resources._base import api_route EmailKind = Literal["find", "verify"] EMAIL_TERMINAL_STATUSES = frozenset({"completed", "failed"}) +EmailOutcome = Literal["valid", "valid_catch_all", "recovered", "pattern_inferred", "not_found", "unknown"] | str +EvidenceMethod = Literal["smtp", "google_account", "microsoft_account", "pattern"] | str class EnumerationMatch(DiscolikeModel): @@ -41,6 +44,7 @@ class EnumerationOutput(DiscolikeModel): attempts: int | None = None duration_ms: int | None = None error: str | None = None + method: EvidenceMethod | None = None class ValidationOutput(DiscolikeModel): @@ -55,6 +59,7 @@ class ValidationOutput(DiscolikeModel): duration_ms: int | None = None error: str | None = None reason: str | None = None + method: EvidenceMethod | None = None class EmailJobResult(DiscolikeModel): @@ -62,6 +67,8 @@ class EmailJobResult(DiscolikeModel): status: str | None = None result: EnumerationOutput | ValidationOutput | None = None error: str | None = None + outcome: EmailOutcome | None = None + email: str | None = None class EmailBatchResults(DiscolikeModel): @@ -96,6 +103,8 @@ def _decode_job_result(kind: EmailKind, item: dict[str, Any]) -> EmailJobResult: status=item.get("status"), result=_decode_output(kind, item.get("result")), error=item.get("error"), + outcome=item.get("outcome"), + email=item.get("email"), ) @@ -130,6 +139,7 @@ def __init__(self, transport: Transport, *, job_id: str, kind: EmailKind = "find self.job_id = job_id self.kind = kind + @api_route("GET", "/email/jobs/{job_id}") def status(self) -> EmailJobResult: response = self._transport.request("GET", f"/email/jobs/{self.job_id}") return _decode_job_result(self.kind, response.json()) @@ -165,6 +175,7 @@ def __init__(self, transport: AsyncTransport, *, job_id: str, kind: EmailKind = self.job_id = job_id self.kind = kind + @api_route("GET", "/email/jobs/{job_id}") async def status(self) -> EmailJobResult: response = await self._transport.request("GET", f"/email/jobs/{self.job_id}") return _decode_job_result(self.kind, response.json()) @@ -200,6 +211,7 @@ def __init__(self, transport: Transport, *, batch_id: str, kind: EmailKind) -> N self.batch_id = batch_id self.kind = kind + @api_route("GET", "/email/batch/{batch_id}/results") def results( self, *, @@ -228,6 +240,7 @@ def __init__(self, transport: AsyncTransport, *, batch_id: str, kind: EmailKind) self.batch_id = batch_id self.kind = kind + @api_route("GET", "/email/batch/{batch_id}/results") async def results( self, *, diff --git a/packages/discolike/src/discolike/_generated/requests.py b/packages/discolike/src/discolike/_generated/requests.py index 3749f6b..ea843ba 100644 --- a/packages/discolike/src/discolike/_generated/requests.py +++ b/packages/discolike/src/discolike/_generated/requests.py @@ -63,66 +63,9 @@ class CompaniesPublicLinksParams(DiscolikeRequest): class ContactsSearchParams(DiscolikeRequest): filter_industry: Annotated[ - list[ - Literal[ - "ACCOUNTING", - "ADVERTISING_AND_MARKETING", - "AGRICULTURE_AND_NATURAL_RESOURCES", - "ALCOHOL_AND_TOBACCO", - "AUTOMOTIVE", - "BIG_DATA_AND_ANALYTICS", - "BIOTECHNOLOGY", - "BLOCKCHAIN_AND_CRYPTOCURRENCY", - "BUSINESS_PRODUCTS_AND_SERVICES", - "CLOUD_COMPUTING", - "COMPUTER_HARDWARE_AND_SEMICONDUCTORS", - "CONGLOMERATES_SHELL_AND_HOLDING_COMPANIES", - "CONSTRUCTION", - "CONSUMER_PRODUCTS", - "CONSUMER_SERVICES", - "CYBERSECURITY", - "DEFENSE_AND_AEROSPACE", - "E-COMMERCE", - "EDUCATION", - "ENERGY", - "ENGINEERING", - "ENTERTAINMENT", - "ENVIRONMENTAL_SERVICES", - "FASHION_TEXTILE_AND_APPAREL", - "FINANCIAL_SERVICES", - "FOOD_AND_BEVERAGE", - "GAMING_AND_ESPORTS", - "GOVERNMENT_SERVICES", - "HEALTHCARE", - "HOSPITALITY", - "HUMAN_RESOURCES", - "INSURANCE", - "IT_SERVICES", - "LEGAL", - "MANUFACTURING", - "MEDIA", - "MINING_AND_METALS", - "NONPROFIT_AND_PHILANTHROPY", - "OIL_AND_GAS", - "PHARMACEUTICALS", - "PRIVATE_EQUITY_AND_VENTURE_CAPITAL", - "REAL_ESTATE", - "RENEWABLE_ENERGY", - "RESTAURANTS", - "RETAIL", - "SAAS", - "SECURITY", - "SOFTWARE", - "SPORTS_AND_RECREATION", - "SUPPLY_CHAIN_AND_PROCUREMENT", - "TELECOMMUNICATIONS", - "TRAVEL", - "WELLNESS_AND_LIFESTYLE", - ] - ] - | None, + list[str] | None, Field( - description="Filter by industry category. Valid values: ACCOUNTING, ADVERTISING_AND_MARKETING, AGRICULTURE_AND_NATURAL_RESOURCES, ALCOHOL_AND_TOBACCO, AUTOMOTIVE, BIG_DATA_AND_ANALYTICS, BIOTECHNOLOGY, BLOCKCHAIN_AND_CRYPTOCURRENCY, BUSINESS_PRODUCTS_AND_SERVICES, CLOUD_COMPUTING, COMPUTER_HARDWARE_AND_SEMICONDUCTORS, CONGLOMERATES_SHELL_AND_HOLDING_COMPANIES, CONSTRUCTION, CONSUMER_PRODUCTS, CONSUMER_SERVICES, CYBERSECURITY, DEFENSE_AND_AEROSPACE, E-COMMERCE, EDUCATION, ENERGY, ENGINEERING, ENTERTAINMENT, ENVIRONMENTAL_SERVICES, FASHION_TEXTILE_AND_APPAREL, FINANCIAL_SERVICES, FOOD_AND_BEVERAGE, GAMING_AND_ESPORTS, GOVERNMENT_SERVICES, HEALTHCARE, HOSPITALITY, HUMAN_RESOURCES, INSURANCE, IT_SERVICES, LEGAL, MANUFACTURING, MEDIA, MINING_AND_METALS, NONPROFIT_AND_PHILANTHROPY, OIL_AND_GAS, PHARMACEUTICALS, PRIVATE_EQUITY_AND_VENTURE_CAPITAL, REAL_ESTATE, RENEWABLE_ENERGY, RESTAURANTS, RETAIL, SAAS, SECURITY, SOFTWARE, SPORTS_AND_RECREATION, SUPPLY_CHAIN_AND_PROCUREMENT, TELECOMMUNICATIONS, TRAVEL, WELLNESS_AND_LIFESTYLE.", + description="Filter by industry category (ANY-OF; prefix + to require a category). Valid values: ACCOUNTING, ADVERTISING_AND_MARKETING, AGRICULTURE_AND_NATURAL_RESOURCES, ALCOHOL_AND_TOBACCO, AUTOMOTIVE, BIG_DATA_AND_ANALYTICS, BIOTECHNOLOGY, BLOCKCHAIN_AND_CRYPTOCURRENCY, BUSINESS_PRODUCTS_AND_SERVICES, CLOUD_COMPUTING, COMPUTER_HARDWARE_AND_SEMICONDUCTORS, CONGLOMERATES_SHELL_AND_HOLDING_COMPANIES, CONSTRUCTION, CONSUMER_PRODUCTS, CONSUMER_SERVICES, CYBERSECURITY, DEFENSE_AND_AEROSPACE, E-COMMERCE, EDUCATION, ENERGY, ENGINEERING, ENTERTAINMENT, ENVIRONMENTAL_SERVICES, FASHION_TEXTILE_AND_APPAREL, FINANCIAL_SERVICES, FOOD_AND_BEVERAGE, GAMING_AND_ESPORTS, GOVERNMENT_SERVICES, HEALTHCARE, HOSPITALITY, HUMAN_RESOURCES, INSURANCE, IT_SERVICES, LEGAL, MANUFACTURING, MEDIA, MINING_AND_METALS, NONPROFIT_AND_PHILANTHROPY, OIL_AND_GAS, PHARMACEUTICALS, PRIVATE_EQUITY_AND_VENTURE_CAPITAL, REAL_ESTATE, RENEWABLE_ENERGY, RESTAURANTS, RETAIL, SAAS, SECURITY, SOFTWARE, SPORTS_AND_RECREATION, SUPPLY_CHAIN_AND_PROCUREMENT, TELECOMMUNICATIONS, TRAVEL, WELLNESS_AND_LIFESTYLE.", title="Filter Industry", ), ] = None @@ -294,7 +237,7 @@ class ContactsSearchParams(DiscolikeRequest): skills: Annotated[ list[str] | None, Field( - description="Filter by skills. Multiple skills can be provided.", + description="Accepted and ignored. Contacts are no longer indexed by skills.", title="Skills", ), ] = None @@ -322,14 +265,14 @@ class ContactsSearchParams(DiscolikeRequest): summary: Annotated[ str | None, Field( - description="Match the profile summary text: a contact matches when its summary contains every word of any one term, in any order. Words are separate terms; quote a multi-word term to keep it together, prefix + to require a term.", + description="Matched against the job title: a contact matches when its title matches any one term. Unquoted words are separate terms; a quoted term is a phrase (adjacent, in order); prefix + to require a term.", title="Summary", ), ] = None negate_summary: Annotated[ str | None, Field( - description="Exclude contacts whose profile summary contains every word of any one term, in any order. Quote a multi-word term to keep it together.", + description="Exclude contacts whose job title matches any one term. Unquoted words are separate terms; a quoted term is a phrase (adjacent, in order).", title="Negate Summary", ), ] = None @@ -437,7 +380,7 @@ class ContactsSearchParams(DiscolikeRequest): le=10000, title="Max Records", ), - ] = 100 + ] = 500 max_companies: Annotated[ int | None, Field( @@ -485,66 +428,9 @@ class ContactsSearchParams(DiscolikeRequest): class ContactsCountParams(DiscolikeRequest): filter_industry: Annotated[ - list[ - Literal[ - "ACCOUNTING", - "ADVERTISING_AND_MARKETING", - "AGRICULTURE_AND_NATURAL_RESOURCES", - "ALCOHOL_AND_TOBACCO", - "AUTOMOTIVE", - "BIG_DATA_AND_ANALYTICS", - "BIOTECHNOLOGY", - "BLOCKCHAIN_AND_CRYPTOCURRENCY", - "BUSINESS_PRODUCTS_AND_SERVICES", - "CLOUD_COMPUTING", - "COMPUTER_HARDWARE_AND_SEMICONDUCTORS", - "CONGLOMERATES_SHELL_AND_HOLDING_COMPANIES", - "CONSTRUCTION", - "CONSUMER_PRODUCTS", - "CONSUMER_SERVICES", - "CYBERSECURITY", - "DEFENSE_AND_AEROSPACE", - "E-COMMERCE", - "EDUCATION", - "ENERGY", - "ENGINEERING", - "ENTERTAINMENT", - "ENVIRONMENTAL_SERVICES", - "FASHION_TEXTILE_AND_APPAREL", - "FINANCIAL_SERVICES", - "FOOD_AND_BEVERAGE", - "GAMING_AND_ESPORTS", - "GOVERNMENT_SERVICES", - "HEALTHCARE", - "HOSPITALITY", - "HUMAN_RESOURCES", - "INSURANCE", - "IT_SERVICES", - "LEGAL", - "MANUFACTURING", - "MEDIA", - "MINING_AND_METALS", - "NONPROFIT_AND_PHILANTHROPY", - "OIL_AND_GAS", - "PHARMACEUTICALS", - "PRIVATE_EQUITY_AND_VENTURE_CAPITAL", - "REAL_ESTATE", - "RENEWABLE_ENERGY", - "RESTAURANTS", - "RETAIL", - "SAAS", - "SECURITY", - "SOFTWARE", - "SPORTS_AND_RECREATION", - "SUPPLY_CHAIN_AND_PROCUREMENT", - "TELECOMMUNICATIONS", - "TRAVEL", - "WELLNESS_AND_LIFESTYLE", - ] - ] - | None, + list[str] | None, Field( - description="Filter by industry category. Valid values: ACCOUNTING, ADVERTISING_AND_MARKETING, AGRICULTURE_AND_NATURAL_RESOURCES, ALCOHOL_AND_TOBACCO, AUTOMOTIVE, BIG_DATA_AND_ANALYTICS, BIOTECHNOLOGY, BLOCKCHAIN_AND_CRYPTOCURRENCY, BUSINESS_PRODUCTS_AND_SERVICES, CLOUD_COMPUTING, COMPUTER_HARDWARE_AND_SEMICONDUCTORS, CONGLOMERATES_SHELL_AND_HOLDING_COMPANIES, CONSTRUCTION, CONSUMER_PRODUCTS, CONSUMER_SERVICES, CYBERSECURITY, DEFENSE_AND_AEROSPACE, E-COMMERCE, EDUCATION, ENERGY, ENGINEERING, ENTERTAINMENT, ENVIRONMENTAL_SERVICES, FASHION_TEXTILE_AND_APPAREL, FINANCIAL_SERVICES, FOOD_AND_BEVERAGE, GAMING_AND_ESPORTS, GOVERNMENT_SERVICES, HEALTHCARE, HOSPITALITY, HUMAN_RESOURCES, INSURANCE, IT_SERVICES, LEGAL, MANUFACTURING, MEDIA, MINING_AND_METALS, NONPROFIT_AND_PHILANTHROPY, OIL_AND_GAS, PHARMACEUTICALS, PRIVATE_EQUITY_AND_VENTURE_CAPITAL, REAL_ESTATE, RENEWABLE_ENERGY, RESTAURANTS, RETAIL, SAAS, SECURITY, SOFTWARE, SPORTS_AND_RECREATION, SUPPLY_CHAIN_AND_PROCUREMENT, TELECOMMUNICATIONS, TRAVEL, WELLNESS_AND_LIFESTYLE.", + description="Filter by industry category (ANY-OF; prefix + to require a category). Valid values: ACCOUNTING, ADVERTISING_AND_MARKETING, AGRICULTURE_AND_NATURAL_RESOURCES, ALCOHOL_AND_TOBACCO, AUTOMOTIVE, BIG_DATA_AND_ANALYTICS, BIOTECHNOLOGY, BLOCKCHAIN_AND_CRYPTOCURRENCY, BUSINESS_PRODUCTS_AND_SERVICES, CLOUD_COMPUTING, COMPUTER_HARDWARE_AND_SEMICONDUCTORS, CONGLOMERATES_SHELL_AND_HOLDING_COMPANIES, CONSTRUCTION, CONSUMER_PRODUCTS, CONSUMER_SERVICES, CYBERSECURITY, DEFENSE_AND_AEROSPACE, E-COMMERCE, EDUCATION, ENERGY, ENGINEERING, ENTERTAINMENT, ENVIRONMENTAL_SERVICES, FASHION_TEXTILE_AND_APPAREL, FINANCIAL_SERVICES, FOOD_AND_BEVERAGE, GAMING_AND_ESPORTS, GOVERNMENT_SERVICES, HEALTHCARE, HOSPITALITY, HUMAN_RESOURCES, INSURANCE, IT_SERVICES, LEGAL, MANUFACTURING, MEDIA, MINING_AND_METALS, NONPROFIT_AND_PHILANTHROPY, OIL_AND_GAS, PHARMACEUTICALS, PRIVATE_EQUITY_AND_VENTURE_CAPITAL, REAL_ESTATE, RENEWABLE_ENERGY, RESTAURANTS, RETAIL, SAAS, SECURITY, SOFTWARE, SPORTS_AND_RECREATION, SUPPLY_CHAIN_AND_PROCUREMENT, TELECOMMUNICATIONS, TRAVEL, WELLNESS_AND_LIFESTYLE.", title="Filter Industry", ), ] = None @@ -716,7 +602,7 @@ class ContactsCountParams(DiscolikeRequest): skills: Annotated[ list[str] | None, Field( - description="Filter by skills. Multiple skills can be provided.", + description="Accepted and ignored. Contacts are no longer indexed by skills.", title="Skills", ), ] = None @@ -744,14 +630,14 @@ class ContactsCountParams(DiscolikeRequest): summary: Annotated[ str | None, Field( - description="Match the profile summary text: a contact matches when its summary contains every word of any one term, in any order. Words are separate terms; quote a multi-word term to keep it together, prefix + to require a term.", + description="Matched against the job title: a contact matches when its title matches any one term. Unquoted words are separate terms; a quoted term is a phrase (adjacent, in order); prefix + to require a term.", title="Summary", ), ] = None negate_summary: Annotated[ str | None, Field( - description="Exclude contacts whose profile summary contains every word of any one term, in any order. Quote a multi-word term to keep it together.", + description="Exclude contacts whose job title matches any one term. Unquoted words are separate terms; a quoted term is a phrase (adjacent, in order).", title="Negate Summary", ), ] = None @@ -859,7 +745,7 @@ class ContactsCountParams(DiscolikeRequest): le=10000, title="Max Records", ), - ] = 100 + ] = 500 max_companies: Annotated[ int | None, Field( @@ -939,66 +825,9 @@ class ContactsMatchParams(DiscolikeRequest): class ContactFilters(DiscolikeRequest): filter_industry: Annotated[ - list[ - Literal[ - "ACCOUNTING", - "ADVERTISING_AND_MARKETING", - "AGRICULTURE_AND_NATURAL_RESOURCES", - "ALCOHOL_AND_TOBACCO", - "AUTOMOTIVE", - "BIG_DATA_AND_ANALYTICS", - "BIOTECHNOLOGY", - "BLOCKCHAIN_AND_CRYPTOCURRENCY", - "BUSINESS_PRODUCTS_AND_SERVICES", - "CLOUD_COMPUTING", - "COMPUTER_HARDWARE_AND_SEMICONDUCTORS", - "CONGLOMERATES_SHELL_AND_HOLDING_COMPANIES", - "CONSTRUCTION", - "CONSUMER_PRODUCTS", - "CONSUMER_SERVICES", - "CYBERSECURITY", - "DEFENSE_AND_AEROSPACE", - "E-COMMERCE", - "EDUCATION", - "ENERGY", - "ENGINEERING", - "ENTERTAINMENT", - "ENVIRONMENTAL_SERVICES", - "FASHION_TEXTILE_AND_APPAREL", - "FINANCIAL_SERVICES", - "FOOD_AND_BEVERAGE", - "GAMING_AND_ESPORTS", - "GOVERNMENT_SERVICES", - "HEALTHCARE", - "HOSPITALITY", - "HUMAN_RESOURCES", - "INSURANCE", - "IT_SERVICES", - "LEGAL", - "MANUFACTURING", - "MEDIA", - "MINING_AND_METALS", - "NONPROFIT_AND_PHILANTHROPY", - "OIL_AND_GAS", - "PHARMACEUTICALS", - "PRIVATE_EQUITY_AND_VENTURE_CAPITAL", - "REAL_ESTATE", - "RENEWABLE_ENERGY", - "RESTAURANTS", - "RETAIL", - "SAAS", - "SECURITY", - "SOFTWARE", - "SPORTS_AND_RECREATION", - "SUPPLY_CHAIN_AND_PROCUREMENT", - "TELECOMMUNICATIONS", - "TRAVEL", - "WELLNESS_AND_LIFESTYLE", - ] - ] - | None, + list[str] | None, Field( - description="Filter by industry category. Valid values: ACCOUNTING, ADVERTISING_AND_MARKETING, AGRICULTURE_AND_NATURAL_RESOURCES, ALCOHOL_AND_TOBACCO, AUTOMOTIVE, BIG_DATA_AND_ANALYTICS, BIOTECHNOLOGY, BLOCKCHAIN_AND_CRYPTOCURRENCY, BUSINESS_PRODUCTS_AND_SERVICES, CLOUD_COMPUTING, COMPUTER_HARDWARE_AND_SEMICONDUCTORS, CONGLOMERATES_SHELL_AND_HOLDING_COMPANIES, CONSTRUCTION, CONSUMER_PRODUCTS, CONSUMER_SERVICES, CYBERSECURITY, DEFENSE_AND_AEROSPACE, E-COMMERCE, EDUCATION, ENERGY, ENGINEERING, ENTERTAINMENT, ENVIRONMENTAL_SERVICES, FASHION_TEXTILE_AND_APPAREL, FINANCIAL_SERVICES, FOOD_AND_BEVERAGE, GAMING_AND_ESPORTS, GOVERNMENT_SERVICES, HEALTHCARE, HOSPITALITY, HUMAN_RESOURCES, INSURANCE, IT_SERVICES, LEGAL, MANUFACTURING, MEDIA, MINING_AND_METALS, NONPROFIT_AND_PHILANTHROPY, OIL_AND_GAS, PHARMACEUTICALS, PRIVATE_EQUITY_AND_VENTURE_CAPITAL, REAL_ESTATE, RENEWABLE_ENERGY, RESTAURANTS, RETAIL, SAAS, SECURITY, SOFTWARE, SPORTS_AND_RECREATION, SUPPLY_CHAIN_AND_PROCUREMENT, TELECOMMUNICATIONS, TRAVEL, WELLNESS_AND_LIFESTYLE.", + description="Filter by industry category (ANY-OF; prefix + to require a category). Valid values: ACCOUNTING, ADVERTISING_AND_MARKETING, AGRICULTURE_AND_NATURAL_RESOURCES, ALCOHOL_AND_TOBACCO, AUTOMOTIVE, BIG_DATA_AND_ANALYTICS, BIOTECHNOLOGY, BLOCKCHAIN_AND_CRYPTOCURRENCY, BUSINESS_PRODUCTS_AND_SERVICES, CLOUD_COMPUTING, COMPUTER_HARDWARE_AND_SEMICONDUCTORS, CONGLOMERATES_SHELL_AND_HOLDING_COMPANIES, CONSTRUCTION, CONSUMER_PRODUCTS, CONSUMER_SERVICES, CYBERSECURITY, DEFENSE_AND_AEROSPACE, E-COMMERCE, EDUCATION, ENERGY, ENGINEERING, ENTERTAINMENT, ENVIRONMENTAL_SERVICES, FASHION_TEXTILE_AND_APPAREL, FINANCIAL_SERVICES, FOOD_AND_BEVERAGE, GAMING_AND_ESPORTS, GOVERNMENT_SERVICES, HEALTHCARE, HOSPITALITY, HUMAN_RESOURCES, INSURANCE, IT_SERVICES, LEGAL, MANUFACTURING, MEDIA, MINING_AND_METALS, NONPROFIT_AND_PHILANTHROPY, OIL_AND_GAS, PHARMACEUTICALS, PRIVATE_EQUITY_AND_VENTURE_CAPITAL, REAL_ESTATE, RENEWABLE_ENERGY, RESTAURANTS, RETAIL, SAAS, SECURITY, SOFTWARE, SPORTS_AND_RECREATION, SUPPLY_CHAIN_AND_PROCUREMENT, TELECOMMUNICATIONS, TRAVEL, WELLNESS_AND_LIFESTYLE.", title="Filter Industry", ), ] = None @@ -1170,7 +999,7 @@ class ContactFilters(DiscolikeRequest): skills: Annotated[ list[str] | None, Field( - description="Filter by skills. Multiple skills can be provided.", + description="Accepted and ignored. Contacts are no longer indexed by skills.", title="Skills", ), ] = None @@ -1198,14 +1027,14 @@ class ContactFilters(DiscolikeRequest): summary: Annotated[ str | None, Field( - description="Match the profile summary text: a contact matches when its summary contains every word of any one term, in any order. Words are separate terms; quote a multi-word term to keep it together, prefix + to require a term.", + description="Matched against the job title: a contact matches when its title matches any one term. Unquoted words are separate terms; a quoted term is a phrase (adjacent, in order); prefix + to require a term.", title="Summary", ), ] = None negate_summary: Annotated[ str | None, Field( - description="Exclude contacts whose profile summary contains every word of any one term, in any order. Quote a multi-word term to keep it together.", + description="Exclude contacts whose job title matches any one term. Unquoted words are separate terms; a quoted term is a phrase (adjacent, in order).", title="Negate Summary", ), ] = None @@ -1313,7 +1142,7 @@ class ContactFilters(DiscolikeRequest): le=10000, title="Max Records", ), - ] = 100 + ] = 500 max_companies: Annotated[ int | None, Field( @@ -1574,66 +1403,9 @@ class DiscoverParams(DiscolikeRequest): ), ] = None category: Annotated[ - list[ - Literal[ - "ACCOUNTING", - "ADVERTISING_AND_MARKETING", - "AGRICULTURE_AND_NATURAL_RESOURCES", - "ALCOHOL_AND_TOBACCO", - "AUTOMOTIVE", - "BIG_DATA_AND_ANALYTICS", - "BIOTECHNOLOGY", - "BLOCKCHAIN_AND_CRYPTOCURRENCY", - "BUSINESS_PRODUCTS_AND_SERVICES", - "CLOUD_COMPUTING", - "COMPUTER_HARDWARE_AND_SEMICONDUCTORS", - "CONGLOMERATES_SHELL_AND_HOLDING_COMPANIES", - "CONSTRUCTION", - "CONSUMER_PRODUCTS", - "CONSUMER_SERVICES", - "CYBERSECURITY", - "DEFENSE_AND_AEROSPACE", - "E-COMMERCE", - "EDUCATION", - "ENERGY", - "ENGINEERING", - "ENTERTAINMENT", - "ENVIRONMENTAL_SERVICES", - "FASHION_TEXTILE_AND_APPAREL", - "FINANCIAL_SERVICES", - "FOOD_AND_BEVERAGE", - "GAMING_AND_ESPORTS", - "GOVERNMENT_SERVICES", - "HEALTHCARE", - "HOSPITALITY", - "HUMAN_RESOURCES", - "INSURANCE", - "IT_SERVICES", - "LEGAL", - "MANUFACTURING", - "MEDIA", - "MINING_AND_METALS", - "NONPROFIT_AND_PHILANTHROPY", - "OIL_AND_GAS", - "PHARMACEUTICALS", - "PRIVATE_EQUITY_AND_VENTURE_CAPITAL", - "REAL_ESTATE", - "RENEWABLE_ENERGY", - "RESTAURANTS", - "RETAIL", - "SAAS", - "SECURITY", - "SOFTWARE", - "SPORTS_AND_RECREATION", - "SUPPLY_CHAIN_AND_PROCUREMENT", - "TELECOMMUNICATIONS", - "TRAVEL", - "WELLNESS_AND_LIFESTYLE", - ] - ] - | None, + list[str] | None, Field( - description="Filter by industry category. Valid values: ACCOUNTING, ADVERTISING_AND_MARKETING, AGRICULTURE_AND_NATURAL_RESOURCES, ALCOHOL_AND_TOBACCO, AUTOMOTIVE, BIG_DATA_AND_ANALYTICS, BIOTECHNOLOGY, BLOCKCHAIN_AND_CRYPTOCURRENCY, BUSINESS_PRODUCTS_AND_SERVICES, CLOUD_COMPUTING, COMPUTER_HARDWARE_AND_SEMICONDUCTORS, CONGLOMERATES_SHELL_AND_HOLDING_COMPANIES, CONSTRUCTION, CONSUMER_PRODUCTS, CONSUMER_SERVICES, CYBERSECURITY, DEFENSE_AND_AEROSPACE, E-COMMERCE, EDUCATION, ENERGY, ENGINEERING, ENTERTAINMENT, ENVIRONMENTAL_SERVICES, FASHION_TEXTILE_AND_APPAREL, FINANCIAL_SERVICES, FOOD_AND_BEVERAGE, GAMING_AND_ESPORTS, GOVERNMENT_SERVICES, HEALTHCARE, HOSPITALITY, HUMAN_RESOURCES, INSURANCE, IT_SERVICES, LEGAL, MANUFACTURING, MEDIA, MINING_AND_METALS, NONPROFIT_AND_PHILANTHROPY, OIL_AND_GAS, PHARMACEUTICALS, PRIVATE_EQUITY_AND_VENTURE_CAPITAL, REAL_ESTATE, RENEWABLE_ENERGY, RESTAURANTS, RETAIL, SAAS, SECURITY, SOFTWARE, SPORTS_AND_RECREATION, SUPPLY_CHAIN_AND_PROCUREMENT, TELECOMMUNICATIONS, TRAVEL, WELLNESS_AND_LIFESTYLE.", + description="Filter by industry category (ANY-OF; prefix + to require a category). Valid values: ACCOUNTING, ADVERTISING_AND_MARKETING, AGRICULTURE_AND_NATURAL_RESOURCES, ALCOHOL_AND_TOBACCO, AUTOMOTIVE, BIG_DATA_AND_ANALYTICS, BIOTECHNOLOGY, BLOCKCHAIN_AND_CRYPTOCURRENCY, BUSINESS_PRODUCTS_AND_SERVICES, CLOUD_COMPUTING, COMPUTER_HARDWARE_AND_SEMICONDUCTORS, CONGLOMERATES_SHELL_AND_HOLDING_COMPANIES, CONSTRUCTION, CONSUMER_PRODUCTS, CONSUMER_SERVICES, CYBERSECURITY, DEFENSE_AND_AEROSPACE, E-COMMERCE, EDUCATION, ENERGY, ENGINEERING, ENTERTAINMENT, ENVIRONMENTAL_SERVICES, FASHION_TEXTILE_AND_APPAREL, FINANCIAL_SERVICES, FOOD_AND_BEVERAGE, GAMING_AND_ESPORTS, GOVERNMENT_SERVICES, HEALTHCARE, HOSPITALITY, HUMAN_RESOURCES, INSURANCE, IT_SERVICES, LEGAL, MANUFACTURING, MEDIA, MINING_AND_METALS, NONPROFIT_AND_PHILANTHROPY, OIL_AND_GAS, PHARMACEUTICALS, PRIVATE_EQUITY_AND_VENTURE_CAPITAL, REAL_ESTATE, RENEWABLE_ENERGY, RESTAURANTS, RETAIL, SAAS, SECURITY, SOFTWARE, SPORTS_AND_RECREATION, SUPPLY_CHAIN_AND_PROCUREMENT, TELECOMMUNICATIONS, TRAVEL, WELLNESS_AND_LIFESTYLE.", title="Category", ), ] = None @@ -1875,6 +1647,7 @@ class DiscoverParams(DiscolikeRequest): language: Annotated[ list[ Literal[ + "af", "ar", "az", "bg", @@ -1899,11 +1672,15 @@ class DiscoverParams(DiscolikeRequest): "hi", "hr", "hu", + "hy", "id", + "is", "it", "ja", + "ka", "ko", "ky", + "lb", "lt", "lv", "mk", @@ -1934,13 +1711,14 @@ class DiscoverParams(DiscolikeRequest): ] | None, Field( - description="Filter by site language. Valid values: ar (Arabic), az (Azerbaijani), bg (Bulgarian), bn (Bengali), bs (Bosnian), ca (Catalan), cs (Czech), da (Danish), de (German), el (Greek), en (English), eo (Esperanto), es (Spanish), et (Estonian), eu (Basque), fa (Persian), fi (Finnish), fr (French), ga (Irish), gl (Galician), he (Hebrew), hi (Hindi), hr (Croatian), hu (Hungarian), id (Indonesian), it (Italian), ja (Japanese), ko (Korean), ky (Kyrgyz), lt (Lithuanian), lv (Latvian), mk (Macedonian), ms (Malay), my (Burmese), nb (Norwegian Bokmål), nl (Dutch), pb (Portuguese (Brazilian)), pl (Polish), pt (Portuguese), ro (Romanian), ru (Russian), sk (Slovak), sl (Slovenian), sq (Albanian), sr (Serbian), sv (Swedish), sw (Swahili), th (Thai), tl (Tagalog), tr (Turkish), uk (Ukrainian), ur (Urdu), vi (Vietnamese), zh (Chinese (Simplified)), zt (Chinese (Traditional)).", + description="Filter by site language. Valid values: af (Afrikaans), ar (Arabic), az (Azerbaijani), bg (Bulgarian), bn (Bengali), bs (Bosnian), ca (Catalan), cs (Czech), da (Danish), de (German), el (Greek), en (English), eo (Esperanto), es (Spanish), et (Estonian), eu (Basque), fa (Persian), fi (Finnish), fr (French), ga (Irish), gl (Galician), he (Hebrew), hi (Hindi), hr (Croatian), hu (Hungarian), hy (Armenian), id (Indonesian), is (Icelandic), it (Italian), ja (Japanese), ka (Georgian), ko (Korean), ky (Kyrgyz), lb (Luxembourgish), lt (Lithuanian), lv (Latvian), mk (Macedonian), ms (Malay), my (Burmese), nb (Norwegian Bokmål), nl (Dutch), pb (Portuguese (Brazilian)), pl (Polish), pt (Portuguese), ro (Romanian), ru (Russian), sk (Slovak), sl (Slovenian), sq (Albanian), sr (Serbian), sv (Swedish), sw (Swahili), th (Thai), tl (Tagalog), tr (Turkish), uk (Ukrainian), ur (Urdu), vi (Vietnamese), zh (Chinese (Simplified)), zt (Chinese (Traditional)).", title="Language", ), ] = None negate_language: Annotated[ list[ Literal[ + "af", "ar", "az", "bg", @@ -1965,11 +1743,15 @@ class DiscoverParams(DiscolikeRequest): "hi", "hr", "hu", + "hy", "id", + "is", "it", "ja", + "ka", "ko", "ky", + "lb", "lt", "lv", "mk", @@ -2000,7 +1782,7 @@ class DiscoverParams(DiscolikeRequest): ] | None, Field( - description="Exclude specified languages from results. Filter by site language. Valid values: ar (Arabic), az (Azerbaijani), bg (Bulgarian), bn (Bengali), bs (Bosnian), ca (Catalan), cs (Czech), da (Danish), de (German), el (Greek), en (English), eo (Esperanto), es (Spanish), et (Estonian), eu (Basque), fa (Persian), fi (Finnish), fr (French), ga (Irish), gl (Galician), he (Hebrew), hi (Hindi), hr (Croatian), hu (Hungarian), id (Indonesian), it (Italian), ja (Japanese), ko (Korean), ky (Kyrgyz), lt (Lithuanian), lv (Latvian), mk (Macedonian), ms (Malay), my (Burmese), nb (Norwegian Bokmål), nl (Dutch), pb (Portuguese (Brazilian)), pl (Polish), pt (Portuguese), ro (Romanian), ru (Russian), sk (Slovak), sl (Slovenian), sq (Albanian), sr (Serbian), sv (Swedish), sw (Swahili), th (Thai), tl (Tagalog), tr (Turkish), uk (Ukrainian), ur (Urdu), vi (Vietnamese), zh (Chinese (Simplified)), zt (Chinese (Traditional)).", + description="Exclude specified languages from results. Filter by site language. Valid values: af (Afrikaans), ar (Arabic), az (Azerbaijani), bg (Bulgarian), bn (Bengali), bs (Bosnian), ca (Catalan), cs (Czech), da (Danish), de (German), el (Greek), en (English), eo (Esperanto), es (Spanish), et (Estonian), eu (Basque), fa (Persian), fi (Finnish), fr (French), ga (Irish), gl (Galician), he (Hebrew), hi (Hindi), hr (Croatian), hu (Hungarian), hy (Armenian), id (Indonesian), is (Icelandic), it (Italian), ja (Japanese), ka (Georgian), ko (Korean), ky (Kyrgyz), lb (Luxembourgish), lt (Lithuanian), lv (Latvian), mk (Macedonian), ms (Malay), my (Burmese), nb (Norwegian Bokmål), nl (Dutch), pb (Portuguese (Brazilian)), pl (Polish), pt (Portuguese), ro (Romanian), ru (Russian), sk (Slovak), sl (Slovenian), sq (Albanian), sr (Serbian), sv (Swedish), sw (Swahili), th (Thai), tl (Tagalog), tr (Turkish), uk (Ukrainian), ur (Urdu), vi (Vietnamese), zh (Chinese (Simplified)), zt (Chinese (Traditional)).", title="Negate Language", ), ] = None @@ -2032,6 +1814,10 @@ class DiscoverParams(DiscolikeRequest): title="Negate Business Model", ), ] = None + valid_mx: Annotated[ + bool | None, + Field(description="Only companies with a mail server.", title="Valid Mx"), + ] = False exclude_leadgen: Annotated[ bool | None, Field( @@ -2081,12 +1867,12 @@ class DiscoverParams(DiscolikeRequest): max_records: Annotated[ int | None, Field( - description="Maximum records to return (5-10000). Default 100.", + description="Maximum records to return (5-10000). Default 500.", ge=5, le=10000, title="Max Records", ), - ] = 100 + ] = 500 min_similarity: Annotated[ int | None, Field( @@ -2200,66 +1986,9 @@ class CountParams(DiscolikeRequest): ), ] = None category: Annotated[ - list[ - Literal[ - "ACCOUNTING", - "ADVERTISING_AND_MARKETING", - "AGRICULTURE_AND_NATURAL_RESOURCES", - "ALCOHOL_AND_TOBACCO", - "AUTOMOTIVE", - "BIG_DATA_AND_ANALYTICS", - "BIOTECHNOLOGY", - "BLOCKCHAIN_AND_CRYPTOCURRENCY", - "BUSINESS_PRODUCTS_AND_SERVICES", - "CLOUD_COMPUTING", - "COMPUTER_HARDWARE_AND_SEMICONDUCTORS", - "CONGLOMERATES_SHELL_AND_HOLDING_COMPANIES", - "CONSTRUCTION", - "CONSUMER_PRODUCTS", - "CONSUMER_SERVICES", - "CYBERSECURITY", - "DEFENSE_AND_AEROSPACE", - "E-COMMERCE", - "EDUCATION", - "ENERGY", - "ENGINEERING", - "ENTERTAINMENT", - "ENVIRONMENTAL_SERVICES", - "FASHION_TEXTILE_AND_APPAREL", - "FINANCIAL_SERVICES", - "FOOD_AND_BEVERAGE", - "GAMING_AND_ESPORTS", - "GOVERNMENT_SERVICES", - "HEALTHCARE", - "HOSPITALITY", - "HUMAN_RESOURCES", - "INSURANCE", - "IT_SERVICES", - "LEGAL", - "MANUFACTURING", - "MEDIA", - "MINING_AND_METALS", - "NONPROFIT_AND_PHILANTHROPY", - "OIL_AND_GAS", - "PHARMACEUTICALS", - "PRIVATE_EQUITY_AND_VENTURE_CAPITAL", - "REAL_ESTATE", - "RENEWABLE_ENERGY", - "RESTAURANTS", - "RETAIL", - "SAAS", - "SECURITY", - "SOFTWARE", - "SPORTS_AND_RECREATION", - "SUPPLY_CHAIN_AND_PROCUREMENT", - "TELECOMMUNICATIONS", - "TRAVEL", - "WELLNESS_AND_LIFESTYLE", - ] - ] - | None, + list[str] | None, Field( - description="Filter by industry category. Valid values: ACCOUNTING, ADVERTISING_AND_MARKETING, AGRICULTURE_AND_NATURAL_RESOURCES, ALCOHOL_AND_TOBACCO, AUTOMOTIVE, BIG_DATA_AND_ANALYTICS, BIOTECHNOLOGY, BLOCKCHAIN_AND_CRYPTOCURRENCY, BUSINESS_PRODUCTS_AND_SERVICES, CLOUD_COMPUTING, COMPUTER_HARDWARE_AND_SEMICONDUCTORS, CONGLOMERATES_SHELL_AND_HOLDING_COMPANIES, CONSTRUCTION, CONSUMER_PRODUCTS, CONSUMER_SERVICES, CYBERSECURITY, DEFENSE_AND_AEROSPACE, E-COMMERCE, EDUCATION, ENERGY, ENGINEERING, ENTERTAINMENT, ENVIRONMENTAL_SERVICES, FASHION_TEXTILE_AND_APPAREL, FINANCIAL_SERVICES, FOOD_AND_BEVERAGE, GAMING_AND_ESPORTS, GOVERNMENT_SERVICES, HEALTHCARE, HOSPITALITY, HUMAN_RESOURCES, INSURANCE, IT_SERVICES, LEGAL, MANUFACTURING, MEDIA, MINING_AND_METALS, NONPROFIT_AND_PHILANTHROPY, OIL_AND_GAS, PHARMACEUTICALS, PRIVATE_EQUITY_AND_VENTURE_CAPITAL, REAL_ESTATE, RENEWABLE_ENERGY, RESTAURANTS, RETAIL, SAAS, SECURITY, SOFTWARE, SPORTS_AND_RECREATION, SUPPLY_CHAIN_AND_PROCUREMENT, TELECOMMUNICATIONS, TRAVEL, WELLNESS_AND_LIFESTYLE.", + description="Filter by industry category (ANY-OF; prefix + to require a category). Valid values: ACCOUNTING, ADVERTISING_AND_MARKETING, AGRICULTURE_AND_NATURAL_RESOURCES, ALCOHOL_AND_TOBACCO, AUTOMOTIVE, BIG_DATA_AND_ANALYTICS, BIOTECHNOLOGY, BLOCKCHAIN_AND_CRYPTOCURRENCY, BUSINESS_PRODUCTS_AND_SERVICES, CLOUD_COMPUTING, COMPUTER_HARDWARE_AND_SEMICONDUCTORS, CONGLOMERATES_SHELL_AND_HOLDING_COMPANIES, CONSTRUCTION, CONSUMER_PRODUCTS, CONSUMER_SERVICES, CYBERSECURITY, DEFENSE_AND_AEROSPACE, E-COMMERCE, EDUCATION, ENERGY, ENGINEERING, ENTERTAINMENT, ENVIRONMENTAL_SERVICES, FASHION_TEXTILE_AND_APPAREL, FINANCIAL_SERVICES, FOOD_AND_BEVERAGE, GAMING_AND_ESPORTS, GOVERNMENT_SERVICES, HEALTHCARE, HOSPITALITY, HUMAN_RESOURCES, INSURANCE, IT_SERVICES, LEGAL, MANUFACTURING, MEDIA, MINING_AND_METALS, NONPROFIT_AND_PHILANTHROPY, OIL_AND_GAS, PHARMACEUTICALS, PRIVATE_EQUITY_AND_VENTURE_CAPITAL, REAL_ESTATE, RENEWABLE_ENERGY, RESTAURANTS, RETAIL, SAAS, SECURITY, SOFTWARE, SPORTS_AND_RECREATION, SUPPLY_CHAIN_AND_PROCUREMENT, TELECOMMUNICATIONS, TRAVEL, WELLNESS_AND_LIFESTYLE.", title="Category", ), ] = None @@ -2501,6 +2230,7 @@ class CountParams(DiscolikeRequest): language: Annotated[ list[ Literal[ + "af", "ar", "az", "bg", @@ -2525,11 +2255,15 @@ class CountParams(DiscolikeRequest): "hi", "hr", "hu", + "hy", "id", + "is", "it", "ja", + "ka", "ko", "ky", + "lb", "lt", "lv", "mk", @@ -2560,13 +2294,14 @@ class CountParams(DiscolikeRequest): ] | None, Field( - description="Filter by site language. Valid values: ar (Arabic), az (Azerbaijani), bg (Bulgarian), bn (Bengali), bs (Bosnian), ca (Catalan), cs (Czech), da (Danish), de (German), el (Greek), en (English), eo (Esperanto), es (Spanish), et (Estonian), eu (Basque), fa (Persian), fi (Finnish), fr (French), ga (Irish), gl (Galician), he (Hebrew), hi (Hindi), hr (Croatian), hu (Hungarian), id (Indonesian), it (Italian), ja (Japanese), ko (Korean), ky (Kyrgyz), lt (Lithuanian), lv (Latvian), mk (Macedonian), ms (Malay), my (Burmese), nb (Norwegian Bokmål), nl (Dutch), pb (Portuguese (Brazilian)), pl (Polish), pt (Portuguese), ro (Romanian), ru (Russian), sk (Slovak), sl (Slovenian), sq (Albanian), sr (Serbian), sv (Swedish), sw (Swahili), th (Thai), tl (Tagalog), tr (Turkish), uk (Ukrainian), ur (Urdu), vi (Vietnamese), zh (Chinese (Simplified)), zt (Chinese (Traditional)).", + description="Filter by site language. Valid values: af (Afrikaans), ar (Arabic), az (Azerbaijani), bg (Bulgarian), bn (Bengali), bs (Bosnian), ca (Catalan), cs (Czech), da (Danish), de (German), el (Greek), en (English), eo (Esperanto), es (Spanish), et (Estonian), eu (Basque), fa (Persian), fi (Finnish), fr (French), ga (Irish), gl (Galician), he (Hebrew), hi (Hindi), hr (Croatian), hu (Hungarian), hy (Armenian), id (Indonesian), is (Icelandic), it (Italian), ja (Japanese), ka (Georgian), ko (Korean), ky (Kyrgyz), lb (Luxembourgish), lt (Lithuanian), lv (Latvian), mk (Macedonian), ms (Malay), my (Burmese), nb (Norwegian Bokmål), nl (Dutch), pb (Portuguese (Brazilian)), pl (Polish), pt (Portuguese), ro (Romanian), ru (Russian), sk (Slovak), sl (Slovenian), sq (Albanian), sr (Serbian), sv (Swedish), sw (Swahili), th (Thai), tl (Tagalog), tr (Turkish), uk (Ukrainian), ur (Urdu), vi (Vietnamese), zh (Chinese (Simplified)), zt (Chinese (Traditional)).", title="Language", ), ] = None negate_language: Annotated[ list[ Literal[ + "af", "ar", "az", "bg", @@ -2591,11 +2326,15 @@ class CountParams(DiscolikeRequest): "hi", "hr", "hu", + "hy", "id", + "is", "it", "ja", + "ka", "ko", "ky", + "lb", "lt", "lv", "mk", @@ -2626,7 +2365,7 @@ class CountParams(DiscolikeRequest): ] | None, Field( - description="Exclude specified languages from results. Filter by site language. Valid values: ar (Arabic), az (Azerbaijani), bg (Bulgarian), bn (Bengali), bs (Bosnian), ca (Catalan), cs (Czech), da (Danish), de (German), el (Greek), en (English), eo (Esperanto), es (Spanish), et (Estonian), eu (Basque), fa (Persian), fi (Finnish), fr (French), ga (Irish), gl (Galician), he (Hebrew), hi (Hindi), hr (Croatian), hu (Hungarian), id (Indonesian), it (Italian), ja (Japanese), ko (Korean), ky (Kyrgyz), lt (Lithuanian), lv (Latvian), mk (Macedonian), ms (Malay), my (Burmese), nb (Norwegian Bokmål), nl (Dutch), pb (Portuguese (Brazilian)), pl (Polish), pt (Portuguese), ro (Romanian), ru (Russian), sk (Slovak), sl (Slovenian), sq (Albanian), sr (Serbian), sv (Swedish), sw (Swahili), th (Thai), tl (Tagalog), tr (Turkish), uk (Ukrainian), ur (Urdu), vi (Vietnamese), zh (Chinese (Simplified)), zt (Chinese (Traditional)).", + description="Exclude specified languages from results. Filter by site language. Valid values: af (Afrikaans), ar (Arabic), az (Azerbaijani), bg (Bulgarian), bn (Bengali), bs (Bosnian), ca (Catalan), cs (Czech), da (Danish), de (German), el (Greek), en (English), eo (Esperanto), es (Spanish), et (Estonian), eu (Basque), fa (Persian), fi (Finnish), fr (French), ga (Irish), gl (Galician), he (Hebrew), hi (Hindi), hr (Croatian), hu (Hungarian), hy (Armenian), id (Indonesian), is (Icelandic), it (Italian), ja (Japanese), ka (Georgian), ko (Korean), ky (Kyrgyz), lb (Luxembourgish), lt (Lithuanian), lv (Latvian), mk (Macedonian), ms (Malay), my (Burmese), nb (Norwegian Bokmål), nl (Dutch), pb (Portuguese (Brazilian)), pl (Polish), pt (Portuguese), ro (Romanian), ru (Russian), sk (Slovak), sl (Slovenian), sq (Albanian), sr (Serbian), sv (Swedish), sw (Swahili), th (Thai), tl (Tagalog), tr (Turkish), uk (Ukrainian), ur (Urdu), vi (Vietnamese), zh (Chinese (Simplified)), zt (Chinese (Traditional)).", title="Negate Language", ), ] = None @@ -2658,6 +2397,10 @@ class CountParams(DiscolikeRequest): title="Negate Business Model", ), ] = None + valid_mx: Annotated[ + bool | None, + Field(description="Only companies with a mail server.", title="Valid Mx"), + ] = False exclude_leadgen: Annotated[ bool | None, Field( @@ -2669,12 +2412,25 @@ class CountParams(DiscolikeRequest): class FindEmailRequest(DiscolikeRequest): first_name: Annotated[str, Field(description="First name of the person", title="First Name")] - last_name: Annotated[str, Field(description="Last name of the person", title="Last Name")] + last_name: Annotated[ + str, + Field( + description="Last name; use an empty string to check only first@domain", + title="Last Name", + ), + ] domain: Annotated[str, Field(description="Target email domain", title="Domain")] known_pattern: Annotated[ str | None, Field(description="Known email pattern for this domain", title="Known Pattern"), ] = None + suggested_email: Annotated[ + str | None, + Field( + description="The address you think is right. It is verified first; if it is not proven, the right one is recovered. Its domain must be `domain` or the company's mail domain.", + title="Suggested Email", + ), + ] = None class FindEmailBatchRequest(DiscolikeRequest): @@ -3134,7 +2890,7 @@ class BulkContactMatchRequest(DiscolikeRequest): enrich: Annotated[ bool | None, Field( - description="When true, hydrate full contact data from the vector database (costs credits).", + description="When true, hydrate contact data for free. Emails only echo the email supplied in that query.", title="Enrich", ), ] = False @@ -3187,7 +2943,7 @@ class ProspectingBrief(DiscolikeRequest): ] = None exclude_domains: Annotated[list[str] | None, Field(max_length=1000, title="Exclude Domains")] = None target_companies: Annotated[int | None, Field(ge=1, le=10000, title="Target Companies")] = 1000 - contacts_per_company: Annotated[int | None, Field(ge=1, le=10, title="Contacts Per Company")] = 3 + contacts_per_company: Annotated[int | None, Field(ge=1, le=25, title="Contacts Per Company")] = 3 max_candidates: Annotated[int | None, Field(ge=0, le=100000, title="Max Candidates")] = 0 max_actions: Annotated[int | None, Field(ge=0, le=10000, title="Max Actions")] = 0 validation_integration_id: Annotated[str | None, Field(max_length=128, title="Validation Integration Id")] = None @@ -3242,7 +2998,7 @@ class ProspectingPlanSettings(DiscolikeRequest): max_spend_usd: Annotated[ float | None, Field( - description="Most the run may spend on DiscoLike records and per-call fees, in USD at your plan's rates, before it stops; 0 removes the limit.", + description="Most the run may spend on DiscoLike records, in USD at your plan's rates, before it stops; 0 removes the limit.", ge=0.0, title="Max Spend Usd", ), @@ -3261,7 +3017,7 @@ class ProspectingPlanSettings(DiscolikeRequest): Field( description="Most contacts to find at each qualified company.", ge=1, - le=10, + le=25, title="Contacts Per Company", ), ] = None diff --git a/packages/discolike/src/discolike/_transport.py b/packages/discolike/src/discolike/_transport.py index 4e12af0..e962131 100644 --- a/packages/discolike/src/discolike/_transport.py +++ b/packages/discolike/src/discolike/_transport.py @@ -1,7 +1,10 @@ from __future__ import annotations import asyncio +import re +import threading import time +import warnings from collections.abc import Mapping from typing import Any @@ -18,6 +21,56 @@ BACKOFF_BASE_SECONDS = 0.5 +class DiscolikeDeprecationWarning(DeprecationWarning): + pass + + +# Python hides DeprecationWarning outside __main__ and repeats none by default. +warnings.filterwarnings("always", category=DiscolikeDeprecationWarning) + +# Shared by all transport instances, including sync/async clients and timeout views. +_deprecation_lock = threading.Lock() +_route_templates: set[tuple[str, str]] = set() + + +def register_route(method: str, path: str) -> None: + with _deprecation_lock: + _route_templates.add((method.upper(), path)) + + +def _path_template(method: str, path: str) -> str: + path = path.split("?", 1)[0].split("#", 1)[0] + # Prefer declared SDK routes: these also recognize opaque string IDs. + matches = [] + for route_method, template in _route_templates: + if route_method == method: + pattern = re.sub(r"\\\{[^{}]+\\\}", "[^/]+", re.escape(template)) + if re.fullmatch(pattern, path): + matches.append(template) + if matches: + return min(matches, key=lambda template: (template.count("{"), template)) + # Undeclared/new routes still normalize numeric and UUID identifiers. + return re.sub( + r"(?<=/)(?:[0-9]+|[0-9a-fA-F]{8}(?:-[0-9a-fA-F]{4}){3}-[0-9a-fA-F]{12})(?=/|$)", + "{id}", + path, + ) + + +def _warn_deprecated(response: httpx2.Response, method: str, path: str) -> None: + if "Deprecation" not in response.headers: + return + method = method.upper() + with _deprecation_lock: + template = _path_template(method, path) + # Header presence is the signal. Keep values opaque, so malformed dates, + # structured fields or links cannot break an otherwise valid response. + details = "; ".join( + f"{name}: {response.headers[name]}" for name in ("Deprecation", "Sunset", "Link") if name in response.headers + ) + warnings.warn(f"{method} {template} is deprecated ({details})", DiscolikeDeprecationWarning, stacklevel=3) + + def drop_none(params: Mapping[str, Any] | None) -> dict[str, Any]: return {key: value for key, value in (params or {}).items() if value is not None} @@ -103,6 +156,7 @@ def request( continue except httpx2.TransportError as exc: raise APIConnectionError(f"Connection to DiscoLike API failed: {exc}") from exc + _warn_deprecated(response, method, path) if response.status_code in retryable_statuses and attempt < self._max_retries: time.sleep(_retry_delay(response, attempt)) continue @@ -176,6 +230,7 @@ async def request( continue except httpx2.TransportError as exc: raise APIConnectionError(f"Connection to DiscoLike API failed: {exc}") from exc + _warn_deprecated(response, method, path) if response.status_code in retryable_statuses and attempt < self._max_retries: await asyncio.sleep(_retry_delay(response, attempt)) continue diff --git a/packages/discolike/src/discolike/requests.py b/packages/discolike/src/discolike/requests.py index 7104691..39ffd27 100644 --- a/packages/discolike/src/discolike/requests.py +++ b/packages/discolike/src/discolike/requests.py @@ -1,5 +1,16 @@ """Request models generated from the platform OpenAPI spec by scripts/gen_requests.py.""" +from discolike._contacts_run_requests import ContactsPeopleFit +from discolike._contacts_run_requests import ContactsRunEventsParams +from discolike._contacts_run_requests import ContactsRunGetParams +from discolike._contacts_run_requests import ContactsRunListParams +from discolike._contacts_run_requests import ContactsRunRequest +from discolike._contacts_run_requests import ContinueRunRequest +from discolike._contacts_run_requests import DomainCompanies +from discolike._contacts_run_requests import LookalikeCompanies +from discolike._contacts_run_requests import PromptCompanies +from discolike._contacts_run_requests import PromptCompanyFilters +from discolike._contacts_run_requests import TitlePreviewRequest from discolike._generated.requests import AppendParams from discolike._generated.requests import BulkContactMatchQueryItem from discolike._generated.requests import BulkContactMatchRequest @@ -61,19 +72,29 @@ "ContactsCountParams", "ContactsLookupParams", "ContactsMatchParams", + "ContactsPeopleFit", + "ContactsRunEventsParams", + "ContactsRunGetParams", + "ContactsRunListParams", + "ContactsRunRequest", "ContactsSearchParams", + "ContinueRunRequest", "CountParams", "CreateExclusionListRequest", "DiscoGenPersonaProcessRequest", "DiscoGenProcessRequest", "DiscoverParams", + "DomainCompanies", "FindEmailBatchRequest", "FindEmailRequest", "IntakeAnswer", "LLMProviderCreateRequest", "LLMProviderUpdateRequest", + "LookalikeCompanies", "MatchBulkParams", "MatchCompanyParams", + "PromptCompanies", + "PromptCompanyFilters", "ProspectingApproveRequest", "ProspectingBrief", "ProspectingGetParams", @@ -86,6 +107,7 @@ "SearchProviderRequest", "SegmentFileParams", "SegmentParams", + "TitlePreviewRequest", "UpdateQueryRequest", "ValidateIcpRequest", ] diff --git a/packages/discolike/src/discolike/resources/_base.py b/packages/discolike/src/discolike/resources/_base.py index edc0945..55b2d17 100644 --- a/packages/discolike/src/discolike/resources/_base.py +++ b/packages/discolike/src/discolike/resources/_base.py @@ -8,6 +8,7 @@ from discolike._transport import AsyncTransport from discolike._transport import Transport +from discolike._transport import register_route F = TypeVar("F", bound=Callable[..., Any]) FileInput = pathlib.Path | str | BinaryIO @@ -15,6 +16,7 @@ def api_route(method: str, path: str, *, openapi: bool = True) -> Callable[[F], F]: def stamp(fn: F) -> F: + register_route(method, path) fn.__discolike_route__ = (method, path, openapi) # ty: ignore[unresolved-attribute] return fn diff --git a/packages/discolike/src/discolike/resources/companies.py b/packages/discolike/src/discolike/resources/companies.py index 4f1cc91..81c5307 100644 --- a/packages/discolike/src/discolike/resources/companies.py +++ b/packages/discolike/src/discolike/resources/companies.py @@ -87,7 +87,6 @@ class Growth(DiscolikeModel): class ExtractResult(DiscolikeModel): text: str | None = None - language: str | None = None class Redirect(DiscolikeModel): diff --git a/packages/discolike/src/discolike/resources/contacts_run.py b/packages/discolike/src/discolike/resources/contacts_run.py new file mode 100644 index 0000000..8dc056e --- /dev/null +++ b/packages/discolike/src/discolike/resources/contacts_run.py @@ -0,0 +1,296 @@ +from __future__ import annotations + +import asyncio +import builtins +import time +from datetime import datetime +from decimal import Decimal +from typing import Any +from typing import Literal +from uuid import UUID + +from pydantic import Field + +from discolike._exceptions import JobTimeoutError +from discolike._models import DiscolikeModel +from discolike.requests import ContactsRunEventsParams +from discolike.requests import ContactsRunGetParams +from discolike.requests import ContactsRunListParams +from discolike.requests import ContactsRunRequest +from discolike.requests import ContinueRunRequest +from discolike.requests import TitlePreviewRequest +from discolike.resources._base import AsyncAPIResource +from discolike.resources._base import SyncAPIResource +from discolike.resources._base import api_route +from discolike.resources.prospecting import _deadline + +RUN_PATH = "/contacts/run" +TERMINAL_STATUSES = frozenset({"done", "failed", "cancelled"}) +ROWS_PAGE_SIZE = 1000 +DEFAULT_MAX_WAIT_SECONDS = 3600.0 +DEFAULT_POLL_INTERVAL_SECONDS = 5.0 + +ContactsRunStatus = Literal["ready", "running", "waiting", "done", "failed", "cancelled"] | str +ContactsRunStepName = Literal["find_contacts", "find_missing", "review", "find_emails", "discover", "validate"] | str +ContactsRunStepStatus = Literal["pending", "running", "done", "failed", "skipped", "cancelled"] | str + + +class ContactsRunStep(DiscolikeModel): + status: ContactsRunStepStatus + error: str | None = None + counts: dict[str, int] = Field(default_factory=dict) + estimate_usd: Decimal | None = None + charged_usd: Decimal = Decimal(0) + + +class ContactsRunRow(DiscolikeModel): + row_key: str + persona_id: int | None = None + domain: str + name: str | None = None + title: str | None = None + linkedin_url: str | None = None + seniority: str | None = None + department: str | None = None + debug_only: bool | None = None + reason: str | None = None + company_score: float | None = None + p_fit: float | None = None + fit_label: str | None = None + floor: bool = False + not_fit: bool = False + email: str | None = None + email_status: Literal["Validated", "Validated (catch-all resolved)"] | str | None = None + email_state: Literal["checking", "queued", "skipped"] | str | None = None + + +class RunCompanyResponse(DiscolikeModel): + company: dict[str, Any] = Field(default_factory=dict) + domain: str + status: Literal["pending", "finding_people", "finding_emails", "validated", "skipped", "cancelled", "failed"] | str + people_reviewed: int = 0 + mailboxes_checked: int = 0 + validated_contacts: int = 0 + contacts: list[ContactsRunRow] = Field(default_factory=list) + + +class ContactsRunSummary(DiscolikeModel): + skipped_no_mx: int | None = None + stop_reason: Literal["low_delivery_yield", "source_exhausted"] | str | None = None + companies_delivered: int = 0 + validated_contacts: int = 0 + spent_usd: Decimal = Decimal(0) + + +class ValidatedRunTitle(DiscolikeModel): + title: str | None = None + status: Literal["Validated", "Validated (catch-all resolved)"] | str + + +class ContactsRunEvent(DiscolikeModel): + company_name: str | None = None + seq: int + domain: str + created_at: datetime + kind: Literal["progress", "company_skipped"] | str + status: str + people_reviewed: int = 0 + titles_considered: list[str] = Field(default_factory=list) + mailboxes_checked: int = 0 + validated_titles: list[ValidatedRunTitle] = Field(default_factory=list) + + +class ContactsRunEventsResponse(DiscolikeModel): + events: list[ContactsRunEvent] = Field(default_factory=list) + next_cursor: int = 0 + + +class AppliedCompanyFilters(DiscolikeModel): + """Effective company filters returned by the server, without request validation limits.""" + + filter_industry: list[str] | None = None + negate_filter_industry: list[str] | None = None + filter_country: list[str] | None = None + negate_filter_country: list[str] | None = None + filter_state: list[str] | None = None + negate_filter_state: list[str] | None = None + employee_range: str | None = None + revenue_range: str | None = None + sub_industry: list[str] | None = None + + +class ContactsRun(DiscolikeModel): + applied_company_filters: AppliedCompanyFilters | None = None + contacts_total: int = 0 + progress: dict[str, int] = Field(default_factory=dict) + error: str | None = None + companies: list[RunCompanyResponse] = Field(default_factory=list) + companies_total: int = 0 + summary: ContactsRunSummary | None = None + input: dict[str, Any] = Field(default_factory=dict) + id: str + kind: Literal["contacts", "companies"] | str + status: ContactsRunStatus + current_step: ContactsRunStepName | None = None + generation: int + steps: dict[str, ContactsRunStep] = Field(default_factory=dict) + total_estimate_usd: Decimal | None = None + total_charged_usd: Decimal = Decimal(0) + created_at: datetime + updated_at: datetime + expires_at: datetime + + +class TitlePreviewTitle(DiscolikeModel): + title: str + role: float + count: int + + +class TitlePreview(DiscolikeModel): + searched: list[str] = Field(default_factory=list) + titles: list[TitlePreviewTitle] = Field(default_factory=list) + + +def _path(run_id: str | UUID) -> str: + return f"{RUN_PATH}/{UUID(str(run_id))}" + + +class ContactsRunResource(SyncAPIResource): + @api_route("POST", RUN_PATH, openapi=False) + def create(self, request: ContactsRunRequest) -> ContactsRun: + """Start automatic contact discovery and validated delivery, optionally capped by email_cap_usd.""" + response = self._transport.request("POST", RUN_PATH, json_body=request.to_wire()) + return ContactsRun.model_validate(response.json()) + + @api_route("GET", RUN_PATH, openapi=False) + def list(self, params: ContactsRunListParams | None = None) -> builtins.list[ContactsRun]: + """List your runs, newest first (default 20, maximum 100); page with `before`, the last run's created_at.""" + response = self._transport.request("GET", RUN_PATH, params=params.to_wire() if params else None) + return [ContactsRun.model_validate(item) for item in response.json()] + + @api_route("GET", RUN_PATH + "/{run_id}", openapi=False) + def get(self, run_id: str | UUID, params: ContactsRunGetParams | None = None) -> ContactsRun: + response = self._transport.request("GET", _path(run_id), params=params.to_wire() if params else None) + return ContactsRun.model_validate(response.json()) + + def rows(self, run_id: str | UUID, *, page_size: int = ROWS_PAGE_SIZE) -> builtins.list[ContactsRunRow]: + """Every row of the run, fetched page by page.""" + rows: builtins.list[ContactsRunRow] = [] + offset = 0 + while True: + run = self.get(run_id, ContactsRunGetParams(rows_offset=offset, rows_limit=page_size)) + rows.extend(contact for company in run.companies for contact in company.contacts) + offset += len(run.companies) + if not run.companies or offset >= run.companies_total: + return rows + + @api_route("POST", RUN_PATH + "/{run_id}/continue", openapi=False) + def continue_(self, run_id: str | UUID, request: ContinueRunRequest) -> ContactsRun: + """Advance a companies run; contacts runs proceed automatically and return 409 here.""" + response = self._transport.request("POST", _path(run_id) + "/continue", json_body=request.to_wire()) + return ContactsRun.model_validate(response.json()) + + @api_route("GET", RUN_PATH + "/{run_id}/events", openapi=False) + def events(self, run_id: str | UUID, params: ContactsRunEventsParams | None = None) -> ContactsRunEventsResponse: + response = self._transport.request( + "GET", _path(run_id) + "/events", params=params.to_wire() if params else None + ) + return ContactsRunEventsResponse.model_validate(response.json()) + + @api_route("POST", RUN_PATH + "/{run_id}/cancel", openapi=False) + def cancel(self, run_id: str | UUID) -> ContactsRun: + response = self._transport.request("POST", _path(run_id) + "/cancel") + return ContactsRun.model_validate(response.json()) + + @api_route("POST", RUN_PATH + "/title-preview", openapi=False) + def title_preview(self, request: TitlePreviewRequest) -> TitlePreview: + """The titles a people spec would match, with each one's role score and contact count.""" + response = self._transport.request("POST", RUN_PATH + "/title-preview", json_body=request.to_wire()) + return TitlePreview.model_validate(response.json()) + + def wait( + self, + run_id: str | UUID, + *, + max_wait: float = DEFAULT_MAX_WAIT_SECONDS, + poll_interval: float = DEFAULT_POLL_INTERVAL_SECONDS, + ) -> ContactsRun: + """Poll until done, failed or cancelled. Timeout stops local polling only.""" + deadline = _deadline(max_wait, poll_interval) + while True: + run = self.get(run_id) + if run.status in TERMINAL_STATUSES: + return run + remaining = deadline - time.monotonic() + if remaining <= 0: + raise JobTimeoutError("Timed out waiting for the contacts run; it continues on the server") + time.sleep(min(poll_interval, remaining)) + + +class AsyncContactsRunResource(AsyncAPIResource): + @api_route("POST", RUN_PATH, openapi=False) + async def create(self, request: ContactsRunRequest) -> ContactsRun: + response = await self._transport.request("POST", RUN_PATH, json_body=request.to_wire()) + return ContactsRun.model_validate(response.json()) + + @api_route("GET", RUN_PATH, openapi=False) + async def list(self, params: ContactsRunListParams | None = None) -> builtins.list[ContactsRun]: + response = await self._transport.request("GET", RUN_PATH, params=params.to_wire() if params else None) + return [ContactsRun.model_validate(item) for item in response.json()] + + @api_route("GET", RUN_PATH + "/{run_id}", openapi=False) + async def get(self, run_id: str | UUID, params: ContactsRunGetParams | None = None) -> ContactsRun: + response = await self._transport.request("GET", _path(run_id), params=params.to_wire() if params else None) + return ContactsRun.model_validate(response.json()) + + async def rows(self, run_id: str | UUID, *, page_size: int = ROWS_PAGE_SIZE) -> builtins.list[ContactsRunRow]: + rows: builtins.list[ContactsRunRow] = [] + offset = 0 + while True: + run = await self.get(run_id, ContactsRunGetParams(rows_offset=offset, rows_limit=page_size)) + rows.extend(contact for company in run.companies for contact in company.contacts) + offset += len(run.companies) + if not run.companies or offset >= run.companies_total: + return rows + + @api_route("POST", RUN_PATH + "/{run_id}/continue", openapi=False) + async def continue_(self, run_id: str | UUID, request: ContinueRunRequest) -> ContactsRun: + response = await self._transport.request("POST", _path(run_id) + "/continue", json_body=request.to_wire()) + return ContactsRun.model_validate(response.json()) + + @api_route("GET", RUN_PATH + "/{run_id}/events", openapi=False) + async def events( + self, run_id: str | UUID, params: ContactsRunEventsParams | None = None + ) -> ContactsRunEventsResponse: + response = await self._transport.request( + "GET", _path(run_id) + "/events", params=params.to_wire() if params else None + ) + return ContactsRunEventsResponse.model_validate(response.json()) + + @api_route("POST", RUN_PATH + "/{run_id}/cancel", openapi=False) + async def cancel(self, run_id: str | UUID) -> ContactsRun: + response = await self._transport.request("POST", _path(run_id) + "/cancel") + return ContactsRun.model_validate(response.json()) + + @api_route("POST", RUN_PATH + "/title-preview", openapi=False) + async def title_preview(self, request: TitlePreviewRequest) -> TitlePreview: + response = await self._transport.request("POST", RUN_PATH + "/title-preview", json_body=request.to_wire()) + return TitlePreview.model_validate(response.json()) + + async def wait( + self, + run_id: str | UUID, + *, + max_wait: float = DEFAULT_MAX_WAIT_SECONDS, + poll_interval: float = DEFAULT_POLL_INTERVAL_SECONDS, + ) -> ContactsRun: + deadline = _deadline(max_wait, poll_interval) + while True: + run = await self.get(run_id) + if run.status in TERMINAL_STATUSES: + return run + remaining = deadline - time.monotonic() + if remaining <= 0: + raise JobTimeoutError("Timed out waiting for the contacts run; it continues on the server") + await asyncio.sleep(min(poll_interval, remaining)) diff --git a/packages/discolike/src/discolike/resources/email.py b/packages/discolike/src/discolike/resources/email.py index 085d615..a75064a 100644 --- a/packages/discolike/src/discolike/resources/email.py +++ b/packages/discolike/src/discolike/resources/email.py @@ -43,9 +43,11 @@ def find_batch(self, request: FindEmailBatchRequest) -> EmailBatch: return EmailBatch(self._transport, batch_id=response.json()["batch_id"], kind="find") def batch(self, batch_id: str, *, kind: EmailKind) -> EmailBatch: + """Reattach to a batch.""" return EmailBatch(self._transport, batch_id=batch_id, kind=kind) def job(self, job_id: str, *, kind: EmailKind = "find") -> EmailJob: + """Reattach to a job.""" return EmailJob(self._transport, job_id=job_id, kind=kind) @@ -61,7 +63,9 @@ async def find_batch(self, request: FindEmailBatchRequest) -> AsyncEmailBatch: return AsyncEmailBatch(self._transport, batch_id=response.json()["batch_id"], kind="find") def batch(self, batch_id: str, *, kind: EmailKind) -> AsyncEmailBatch: + """Reattach to a batch.""" return AsyncEmailBatch(self._transport, batch_id=batch_id, kind=kind) def job(self, job_id: str, *, kind: EmailKind = "find") -> AsyncEmailJob: + """Reattach to a job.""" return AsyncEmailJob(self._transport, job_id=job_id, kind=kind) diff --git a/packages/discolike/tests/test_companies.py b/packages/discolike/tests/test_companies.py index d4cc769..246d4b8 100644 --- a/packages/discolike/tests/test_companies.py +++ b/packages/discolike/tests/test_companies.py @@ -97,13 +97,12 @@ def handler(request: httpx2.Request) -> httpx2.Response: def test_extract_parses_text_and_language(make_client: ClientFactory) -> None: def handler(request: httpx2.Request) -> httpx2.Response: - return httpx2.Response(200, json={"language": "en", "text": "Acme makes widgets."}) + return httpx2.Response(200, json={"text": "Acme makes widgets."}) with make_client(handler) as client: result = client.companies.extract(CompaniesExtractParams(domain="acme.com")) assert result.text == "Acme makes widgets." - assert result.language == "en" def test_unset_match_mode_is_not_sent(make_client: ClientFactory) -> None: diff --git a/packages/discolike/tests/test_contacts.py b/packages/discolike/tests/test_contacts.py index 7f8765e..bd7d962 100644 --- a/packages/discolike/tests/test_contacts.py +++ b/packages/discolike/tests/test_contacts.py @@ -343,3 +343,44 @@ def test_route_metadata_stamped() -> None: assert get_discolike_route(ContactsResource.bulk_match) == ("POST", "/contacts/bulk-match", True) assert get_discolike_route(ContactsResource.discover) == ("POST", "/contacts/discover", True) assert get_discolike_route(ContactsResource.generate) == ("POST", "/contacts/discover/generate", True) + + +@pytest.mark.parametrize( + ("method", "request_model"), + [ + ("search", ContactsSearchParams(domain=["a.com"])), + ("generate", ContactGenerateRequest(icp_text="VPs", domains=["a.com"])), + ], +) +def test_old_search_methods_warn_and_still_work(make_client: ClientFactory, method: str, request_model: object) -> None: + def handler(request: httpx2.Request) -> httpx2.Response: + return httpx2.Response( + 200, json=[] if method == "search" else {"count": 1, "task_id": "t-1"}, headers={"Deprecation": "true"} + ) + + with make_client(handler) as client, pytest.warns(DeprecationWarning, match="is deprecated"): + getattr(client.contacts, method)(request_model) + + +async def test_old_search_methods_warn_async(make_async_client: AsyncClientFactory) -> None: + def handler(request: httpx2.Request) -> httpx2.Response: + return httpx2.Response(200, json=[], headers={"Deprecation": "true"}) + + async with make_async_client(handler) as client: + with pytest.warns(DeprecationWarning, match="is deprecated"): + await client.contacts.search(ContactsSearchParams(domain=["a.com"])) + + +def test_discover_lookup_and_match_do_not_warn(make_client: ClientFactory, recwarn: pytest.WarningsRecorder) -> None: + def handler(request: httpx2.Request) -> httpx2.Response: + if request.url.path.endswith("/discover"): + return httpx2.Response(200, json={"results": {}}) + if request.url.path.endswith("/match"): + return httpx2.Response(200, json={"matches": []}) + return httpx2.Response(200, json={"persona_id": 1}) + + with make_client(handler) as client: + client.contacts.discover(ContactFilters(domain=["a.com"])) + client.contacts.lookup(ContactsLookupParams(persona_id=1)) + client.contacts.match(ContactsMatchParams(name="Jane Doe")) + assert not [w for w in recwarn if issubclass(w.category, DeprecationWarning)] diff --git a/packages/discolike/tests/test_contacts_run.py b/packages/discolike/tests/test_contacts_run.py new file mode 100644 index 0000000..91473ba --- /dev/null +++ b/packages/discolike/tests/test_contacts_run.py @@ -0,0 +1,329 @@ +from __future__ import annotations + +import json +from decimal import Decimal +from typing import Any + +import httpx2 +import pydantic +import pytest + +import discolike.resources.contacts_run as module +from discolike import InsufficientBalanceError +from discolike import JobTimeoutError +from discolike.requests import ContactsRunEventsParams +from discolike.requests import ContactsRunGetParams +from discolike.requests import ContactsRunListParams +from discolike.requests import ContactsRunRequest +from discolike.requests import ContinueRunRequest +from discolike.requests import TitlePreviewRequest +from discolike.resources.contacts_run import ContactsRun +from discolike.resources.contacts_run import ContactsRunEventsResponse +from discolike_testkit import AsyncClientFactory +from discolike_testkit import ClientFactory + +RUN_ID = "00000000-0000-0000-0000-0000000000aa" +RUN_PATH = f"/v1/contacts/run/{RUN_ID}" +TIMESTAMP = "2026-10-30T12:00:00Z" + + +def run_payload(status: str = "running", **extra: Any) -> dict[str, Any]: + return { + "id": RUN_ID, + "kind": "contacts", + "status": status, + "generation": 1, + "created_at": TIMESTAMP, + "updated_at": TIMESTAMP, + "expires_at": TIMESTAMP, + **extra, + } + + +def request() -> ContactsRunRequest: + return ContactsRunRequest.model_validate( + { + "companies": {"source": "domains", "domains": ["acme.com"]}, + "people": {"fit": "Owners"}, + } + ) + + +def test_create_wire_and_open_response(make_client: ClientFactory) -> None: + seen: list[httpx2.Request] = [] + + def handler(req: httpx2.Request) -> httpx2.Response: + seen.append(req) + return httpx2.Response( + 202, + json=run_payload( + companies=[ + { + "domain": "Company 1", + "status": "future", + "future": True, + "contacts": [ + {"row_key": "r1", "domain": "Company 1", "email_status": "Validated (catch-all resolved)"}, + ], + } + ], + companies_total=1, + summary={"companies_delivered": 1, "validated_contacts": 1, "spent_usd": "0.10"}, + future=True, + ), + ) + + with make_client(handler) as client: + run = client.contacts_run.create(request()) + assert seen[0].url.path == "/v1/contacts/run" + assert json.loads(seen[0].content) == request().to_wire() + assert run.current_step is None + assert run.companies[0].contacts[0].email_state is None + assert run.companies[0].contacts[0].email_status == "Validated (catch-all resolved)" + assert run.summary is not None + assert run.summary.spent_usd == Decimal("0.10") + assert run.model_extra == {"future": True} + assert run.companies[0].model_extra == {"future": True} + + +@pytest.mark.parametrize( + ("field", "value"), + [ + ("start", True), + ("max_contacts", 5), + ("auto_stop", False), + ("find_missing", {}), + ("mode", "run_all"), + ], +) +def test_removed_fields_rejected(field: str, value: Any) -> None: + with pytest.raises(pydantic.ValidationError): + ContactsRunRequest.model_validate(request().to_wire() | {field: value}) + + +def test_request_defaults_and_supported_fields() -> None: + body = ContactsRunRequest.model_validate( + { + "companies": {"source": "prompt", "filters": {"filter_country": ["US"]}}, + "max_companies": 15, + "per_company": 3, + "email_cap_usd": "12.50", + "create_key": "key", + "exclude_domains": ["acme.com"], + "exclude_query_ids": ["query"], + "relevance": "max_coverage", + } + ) + assert body.people.fit == "Senior executives and decision makers" + assert body.to_wire()["max_companies"] == 15 + assert "people" not in body.to_wire() + with pytest.raises(pydantic.ValidationError): + ContactsRunRequest.model_validate(request().to_wire() | {"people": {"titles": ["Owner"]}}) + with pytest.raises(pydantic.ValidationError): + ContactsRunRequest.model_validate(request().to_wire() | {"per_company": "all"}) + assert ContactsRunRequest.model_validate({"companies": {"source": "lookalike", "persona_ids": [1]}}) + + +def test_live_routes(make_client: ClientFactory) -> None: + seen: list[tuple[str, str, dict[str, Any]]] = [] + + def handler(req: httpx2.Request) -> httpx2.Response: + seen.append((req.method, req.url.path, json.loads(req.content) if req.content else dict(req.url.params))) + if req.url.path.endswith("title-preview"): + return httpx2.Response(200, json={"titles": [], "searched": ["Owner"]}) + if req.url.path.endswith("events"): + return httpx2.Response(200, json={"events": [], "next_cursor": 5}) + return httpx2.Response(200, json=[run_payload()] if req.url.path == "/v1/contacts/run" else run_payload()) + + with make_client(handler) as client: + client.contacts_run.get(RUN_ID, ContactsRunGetParams(rows_offset=2, rows_limit=3)) + client.contacts_run.list(ContactsRunListParams(limit=5)) + client.contacts_run.cancel(RUN_ID) + client.contacts_run.continue_(RUN_ID, ContinueRunRequest(generation=2, redo=True)) + client.contacts_run.title_preview(TitlePreviewRequest(titles=["Owner"], match="similar", relevance="balanced")) + assert client.contacts_run.events(RUN_ID, ContactsRunEventsParams(after=4, limit=10)).next_cursor == 5 + assert seen == [ + ("GET", RUN_PATH, {"rows_offset": "2", "rows_limit": "3"}), + ("GET", "/v1/contacts/run", {"limit": "5"}), + ("POST", RUN_PATH + "/cancel", {}), + ("POST", RUN_PATH + "/continue", {"generation": 2, "redo": True}), + ("POST", "/v1/contacts/run/title-preview", {"titles": ["Owner"], "match": "similar", "relevance": "balanced"}), + ("GET", RUN_PATH + "/events", {"after": "4", "limit": "10"}), + ] + + +def test_open_events() -> None: + page = ContactsRunEventsResponse.model_validate( + { + "next_cursor": 1, + "future": True, + "events": [ + { + "seq": 1, + "domain": "Company 1", + "created_at": TIMESTAMP, + "kind": "future", + "status": "future", + "validated_titles": [{"title": "Owner", "status": "future", "future": True}], + "future": True, + } + ], + } + ) + assert page.events[0].company_name is None + assert page.events[0].validated_titles[0].status == "future" + assert ( + module.ContactsRunEvent.model_validate(page.events[0].model_dump() | {"company_name": "Acme"}).company_name + == "Acme" + ) + assert ContactsRun.model_validate( + run_payload("future", current_step="future", steps={"future": {"status": "future"}}) + ) + + +@pytest.mark.parametrize("terminal", ["done", "failed", "cancelled"]) +def test_wait_only_polls(make_client: ClientFactory, monkeypatch: pytest.MonkeyPatch, terminal: str) -> None: + monkeypatch.setattr(module.time, "sleep", lambda _: None) + statuses = iter(["ready", "running", terminal]) + + def handler(req: httpx2.Request) -> httpx2.Response: + assert req.method == "GET" + return httpx2.Response(200, json=run_payload(next(statuses))) + + with make_client(handler) as client: + assert client.contacts_run.wait(RUN_ID).status == terminal + + +def test_wait_timeout(make_client: ClientFactory, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr(module, "_deadline", lambda *_: 0) + with make_client(lambda _: httpx2.Response(200, json=run_payload())) as client: + with pytest.raises(JobTimeoutError): + client.contacts_run.wait(RUN_ID) + + +def company_page(req: httpx2.Request, *, kind: str = "contacts") -> httpx2.Response: + offset = int(req.url.params["rows_offset"]) + pages = [ + {"domain": "Company 1", "status": "skipped", "contacts": []}, + { + "domain": "Company 2", + "status": "validated", + "contacts": [ + {"row_key": "a", "domain": "Company 2"}, + {"row_key": "b", "domain": "Company 2"}, + ], + }, + {"domain": "Company 3", "status": "validated", "contacts": [{"row_key": "c", "domain": "Company 3"}]}, + ] + assert req.url.params["rows_limit"] == "1" + return httpx2.Response( + 200, json=run_payload(kind=kind, companies=pages[offset : offset + 1], companies_total=3, contacts_total=3) + ) + + +@pytest.mark.parametrize("kind", ["contacts", "companies"]) +def test_rows_pages_domains_including_empty_companies(make_client: ClientFactory, kind: str) -> None: + with make_client(lambda req: company_page(req, kind=kind)) as client: + assert [r.row_key for r in client.contacts_run.rows(RUN_ID, page_size=1)] == ["a", "b", "c"] + + +@pytest.mark.parametrize("kind", ["contacts", "companies"]) +async def test_async_rows(make_async_client: AsyncClientFactory, kind: str) -> None: + async with make_async_client(lambda req: company_page(req, kind=kind)) as client: + assert [r.row_key for r in await client.contacts_run.rows(RUN_ID, page_size=1)] == ["a", "b", "c"] + + +async def test_async_create_wait_events_cancel( + make_async_client: AsyncClientFactory, monkeypatch: pytest.MonkeyPatch +) -> None: + async def no_sleep(_: float) -> None: + pass + + monkeypatch.setattr(module.asyncio, "sleep", no_sleep) + statuses = iter(["ready", "running", "done"]) + + def handler(req: httpx2.Request) -> httpx2.Response: + if req.url.path.endswith("events"): + assert req.url.params["after"] == "5" + return httpx2.Response(200, json={"events": [], "next_cursor": 6}) + if req.url.path.endswith("cancel"): + return httpx2.Response(200, json=run_payload("cancelled")) + if req.method == "POST": + assert json.loads(req.content) == request().to_wire() + return httpx2.Response(202, json=run_payload()) + return httpx2.Response(200, json=run_payload(next(statuses))) + + async with make_async_client(handler) as client: + created = await client.contacts_run.create(request()) + assert (await client.contacts_run.wait(created.id)).status == "done" + assert (await client.contacts_run.events(created.id, ContactsRunEventsParams(after=5))).next_cursor == 6 + assert (await client.contacts_run.cancel(created.id)).status == "cancelled" + + +def test_insufficient_balance(make_client: ClientFactory) -> None: + with make_client( + lambda _: httpx2.Response( + 403, + json={ + "detail": { + "code": "INSUFFICIENT_BALANCE", + "message": "Too expensive", + "remaining_usd": "1.00", + "max_affordable_records": 2, + } + }, + ) + ) as client: + with pytest.raises(InsufficientBalanceError) as exc: + client.contacts_run.create(request()) + assert exc.value.max_affordable_records == 2 + + +def test_response_fields_accept_new_server_values() -> None: + run = ContactsRun.model_validate( + run_payload( + applied_company_filters={ + "filter_industry": ["+FUTURE"], + "negate_filter_industry": ["FUTURE"], + "sub_industry": ["FUTURE"] * 100, + "future": True, + }, + contacts_total=2, + progress={"future": 3}, + summary={"skipped_no_mx": -1, "stop_reason": "future"}, + companies=[ + { + "domain": "acme.com", + "status": "future", + "company": {"name": "Acme", "future": True}, + "contacts": [{"domain": "acme.com", "row_key": "a", "email_state": "future", "future": True}], + } + ], + ) + ) + assert run.contacts_total == 2 + assert run.progress == {"future": 3} + assert run.applied_company_filters is not None + assert run.applied_company_filters.negate_filter_industry == ["FUTURE"] + assert run.applied_company_filters.model_extra == {"future": True} + assert run.summary is not None + assert run.summary.skipped_no_mx == -1 + assert run.summary.stop_reason == "future" + assert run.companies[0].company == {"name": "Acme", "future": True} + assert run.companies[0].contacts[0].email_state == "future" + assert run.companies[0].contacts[0].model_extra == {"future": True} + + +def test_response_fields_default_for_older_servers() -> None: + run = ContactsRun.model_validate(run_payload()) + assert run.applied_company_filters is None + assert run.contacts_total == 0 + assert run.progress == {} + summary = module.ContactsRunSummary() + assert summary.skipped_no_mx is None + assert summary.stop_reason is None + company = module.RunCompanyResponse(domain="acme.com", status="pending") + assert company.company == {} + assert not {"rows", "rows_total"} & ContactsRun.model_fields.keys() + assert not {"child_task_id", "state"} & module.ContactsRunStep.model_fields.keys() + assert not {"company", "source"} & module.ContactsRunRow.model_fields.keys() diff --git a/packages/discolike/tests/test_contract_registry.py b/packages/discolike/tests/test_contract_registry.py index ecde573..6be3e91 100644 --- a/packages/discolike/tests/test_contract_registry.py +++ b/packages/discolike/tests/test_contract_registry.py @@ -7,8 +7,8 @@ from discolike._models import DiscolikeModel from discolike.resources._base import get_discolike_route -# wait orchestrates repeated get() calls and is not a separate route. -ALLOW_UNSTAMPED = {"job", "batch", "wait"} +# wait and rows orchestrate repeated get() calls and are not separate routes. +ALLOW_UNSTAMPED = {"job", "batch", "wait", "rows"} SCRIPT_PATH = pathlib.Path(__file__).parents[3] / "scripts" / "check_contract.py" @@ -66,7 +66,7 @@ def test_check_models_passes_when_spec_matches_model(): check_contract = _load_check_contract() from discolike.resources.companies import ExtractResult - spec = {"components": {"schemas": {"ExtractResponse": {"properties": {"text": {}, "language": {}}}}}} + spec = {"components": {"schemas": {"ExtractResponse": {"properties": {"text": {}}}}}} assert check_contract.check_models(spec, {"ExtractResponse": ExtractResult}) == [] @@ -74,16 +74,16 @@ def test_check_models_reports_field_the_spec_does_not_have(): check_contract = _load_check_contract() from discolike.resources.companies import ExtractResult - spec = {"components": {"schemas": {"ExtractResponse": {"properties": {"text": {}}}}}} + spec = {"components": {"schemas": {"ExtractResponse": {"properties": {}}}}} mismatches = check_contract.check_models(spec, {"ExtractResponse": ExtractResult}) - assert mismatches == ["ExtractResult: field 'language' not in spec schema 'ExtractResponse'"] + assert mismatches == ["ExtractResult: field 'text' not in spec schema 'ExtractResponse'"] def test_check_models_reports_field_the_sdk_does_not_declare(): check_contract = _load_check_contract() from discolike.resources.companies import ExtractResult - spec = {"components": {"schemas": {"ExtractResponse": {"properties": {"text": {}, "language": {}, "summary": {}}}}}} + spec = {"components": {"schemas": {"ExtractResponse": {"properties": {"text": {}, "summary": {}}}}}} mismatches = check_contract.check_models(spec, {"ExtractResponse": ExtractResult}) assert mismatches == ["ExtractResult: spec schema 'ExtractResponse' has field 'summary' the SDK does not declare"] @@ -92,7 +92,7 @@ def test_check_models_ignores_type_and_requiredness_when_spec_gives_no_type_info check_contract = _load_check_contract() from discolike.resources.companies import ExtractResult - spec = {"components": {"schemas": {"ExtractResponse": {"properties": {"text": {}, "language": {}}}}}} + spec = {"components": {"schemas": {"ExtractResponse": {"properties": {"text": {}}}}}} assert check_contract.check_models(spec, {"ExtractResponse": ExtractResult}) == [] @@ -143,7 +143,6 @@ def test_check_models_passes_a_nullable_field_expressed_via_anyof(): "ExtractResponse": { "properties": { "text": {"anyOf": [{"type": "string"}, {"type": "null"}]}, - "language": {"anyOf": [{"type": "string"}, {"type": "null"}]}, }, } } @@ -162,7 +161,6 @@ def test_check_models_passes_a_nullable_field_expressed_via_openapi_nullable_fla "ExtractResponse": { "properties": { "text": {"type": "string", "nullable": True}, - "language": {"type": "string", "nullable": True}, }, } } @@ -185,11 +183,7 @@ def test_check_models_accepts_an_sdk_field_nullable_where_the_spec_is_not(): check_contract = _load_check_contract() from discolike.resources.companies import ExtractResult - spec = { - "components": { - "schemas": {"ExtractResponse": {"properties": {"text": {"type": "string"}, "language": {"type": "string"}}}} - } - } + spec = {"components": {"schemas": {"ExtractResponse": {"properties": {"text": {"type": "string"}}}}}} assert check_contract.check_models(spec, {"ExtractResponse": ExtractResult}) == [] diff --git a/packages/discolike/tests/test_email.py b/packages/discolike/tests/test_email.py index 0b4a5a5..71555bd 100644 --- a/packages/discolike/tests/test_email.py +++ b/packages/discolike/tests/test_email.py @@ -1,6 +1,8 @@ from __future__ import annotations import json +import warnings +from typing import Literal import httpx2 import pydantic @@ -28,6 +30,9 @@ async def fake_sleep(seconds: float) -> None: return None monkeypatch.setattr(jobs_module.asyncio, "sleep", fake_sleep) + with warnings.catch_warnings(): + warnings.simplefilter("error", DeprecationWarning) + yield def _results_sequence(payloads): @@ -536,3 +541,89 @@ async def test_job_wait_honors_server_reported_kind_async(make_async_client: Asy assert isinstance(output, ValidationOutput) assert output.reason == "no_mailbox" + + +def test_find_sends_suggested_email_and_status_reads_outcome(make_client: ClientFactory) -> None: + bodies: list[dict] = [] + + def handler(request: httpx2.Request) -> httpx2.Response: + if request.method == "POST": + bodies.append(json.loads(request.content)) + return httpx2.Response(202, json={"job_id": "j-se", "status": "queued"}) + return httpx2.Response( + 200, + json={ + "job_id": "j-se", + "status": "completed", + "kind": "find", + "result": {"domain": "navy.mil", "status": "found", "method": "smtp"}, + "outcome": "recovered", + "email": "ghopper@navy.mil", + }, + ) + + with make_client(handler) as client: + job = client.email.find( + FindEmailRequest( + first_name="Grace", last_name="Hopper", domain="navy.mil", suggested_email="grace@navy.mil" + ) + ) + status = job.status() + + assert bodies == [ + {"first_name": "Grace", "last_name": "Hopper", "domain": "navy.mil", "suggested_email": "grace@navy.mil"} + ] + assert status.outcome == "recovered" + assert status.email == "ghopper@navy.mil" + assert isinstance(status.result, EnumerationOutput) + assert status.result.method == "smtp" + + +@pytest.mark.parametrize("kind", ["find", "verify"]) +@pytest.mark.parametrize("handle", ["job", "batch"]) +def test_reattachment_is_silent_and_still_polls( + make_client: ClientFactory, handle: str, kind: Literal["find", "verify"] +) -> None: + def handler(request: httpx2.Request) -> httpx2.Response: + assert request.method == "GET" + if handle == "job": + assert request.url.path == "/v1/email/jobs/j-11" + return httpx2.Response(200, json=_VERIFY_JOB_PAYLOAD) + assert request.url.path == "/v1/email/batch/j-11/results" + return httpx2.Response(200, json={"total": 1, "results": [_VERIFY_JOB_PAYLOAD]}) + + with make_client(handler) as client: + attached = client.email.job("j-11", kind=kind) if handle == "job" else client.email.batch("j-11", kind=kind) + with warnings.catch_warnings(): + warnings.simplefilter("error", DeprecationWarning) + output = attached.wait() if isinstance(attached, EmailJob) else attached.results().results[0].result + + assert isinstance(output, ValidationOutput) + assert output.reason == "no_mailbox" + + +@pytest.mark.parametrize("kind", ["find", "verify"]) +@pytest.mark.parametrize("handle", ["job", "batch"]) +async def test_reattachment_is_silent_and_still_polls_async( + make_async_client: AsyncClientFactory, handle: str, kind: Literal["find", "verify"] +) -> None: + def handler(request: httpx2.Request) -> httpx2.Response: + assert request.method == "GET" + if handle == "job": + assert request.url.path == "/v1/email/jobs/j-11" + return httpx2.Response(200, json=_VERIFY_JOB_PAYLOAD) + assert request.url.path == "/v1/email/batch/j-11/results" + return httpx2.Response(200, json={"total": 1, "results": [_VERIFY_JOB_PAYLOAD]}) + + async with make_async_client(handler) as client: + attached = client.email.job("j-11", kind=kind) if handle == "job" else client.email.batch("j-11", kind=kind) + with warnings.catch_warnings(): + warnings.simplefilter("error", DeprecationWarning) + output = ( + await attached.wait() + if isinstance(attached, AsyncEmailJob) + else (await attached.results()).results[0].result + ) + + assert isinstance(output, ValidationOutput) + assert output.reason == "no_mailbox" diff --git a/packages/discolike/tests/test_exceptions.py b/packages/discolike/tests/test_exceptions.py index f0f46cb..3b6295c 100644 --- a/packages/discolike/tests/test_exceptions.py +++ b/packages/discolike/tests/test_exceptions.py @@ -117,3 +117,21 @@ def test_plain_403_stays_plan_access_error() -> None: with pytest.raises(PlanAccessError) as exc_info: raise_for_status(_response(403)) assert type(exc_info.value) is PlanAccessError + + +def test_no_credentials_401_object_detail_uses_its_message() -> None: + body = { + "detail": { + "code": "UNAUTHENTICATED", + "message": "No credentials. A human can create a free account (no card).", + "signup_url": "https://api.discolike.com/v1/public/signup", + "plan": "free", + "allowance_usd": 0.0, + "cpc": 0.0, + "cpm": 5.0, + } + } + with pytest.raises(AuthenticationError) as exc_info: + raise_for_status(_response(401, json_body=body)) + assert str(exc_info.value) == "No credentials. A human can create a free account (no card)." + assert exc_info.value.payload["detail"]["signup_url"] == "https://api.discolike.com/v1/public/signup" diff --git a/packages/discolike/tests/test_prospecting.py b/packages/discolike/tests/test_prospecting.py index 0c48d9a..c579599 100644 --- a/packages/discolike/tests/test_prospecting.py +++ b/packages/discolike/tests/test_prospecting.py @@ -270,7 +270,7 @@ def handler(request: httpx2.Request) -> httpx2.Response: {"goal": "accounts"}, {"max_provider_spend_usd": -1}, {"target_companies": 0}, - {"contacts_per_company": 11}, + {"contacts_per_company": 26}, ], ) def test_update_plan_rejects_bad_run_shape_locally(values: dict) -> None: diff --git a/packages/discolike/tests/test_requests_contract.py b/packages/discolike/tests/test_requests_contract.py index 3b3a774..70ca4a7 100644 --- a/packages/discolike/tests/test_requests_contract.py +++ b/packages/discolike/tests/test_requests_contract.py @@ -1,5 +1,7 @@ """The generated request models must carry the sub-industry and geo filters.""" +import pytest + from discolike.requests import CountParams from discolike.requests import DiscoverParams from discolike.resources.companies import CompanyProfile @@ -136,3 +138,12 @@ def test_coordinates_serialize_under_their_full_names(self) -> None: assert profile.to_dict()["latitude"] == 40.7128 assert profile.to_dict()["longitude"] == -74.006 + + +@pytest.mark.parametrize("model", [DiscoverParams, CountParams]) +@pytest.mark.parametrize("value", [True, False]) +def test_valid_mx_is_optional_and_sent_when_explicit( + model: type[DiscoverParams] | type[CountParams], value: bool +) -> None: + assert "valid_mx" not in model().to_wire() + assert model(valid_mx=value).to_wire()["valid_mx"] is value diff --git a/packages/discolike/tests/test_transport.py b/packages/discolike/tests/test_transport.py index 1d83e01..3840cb6 100644 --- a/packages/discolike/tests/test_transport.py +++ b/packages/discolike/tests/test_transport.py @@ -1,3 +1,6 @@ +import warnings +from concurrent.futures import ThreadPoolExecutor + import httpx2 import pytest @@ -273,3 +276,112 @@ def handler(request: httpx2.Request) -> httpx2.Response: assert seen[0]["read"] == 120.0 assert seen[1]["read"] == 5.0 + + +@pytest.mark.parametrize("use_async", [False, True]) +async def test_deprecation_warns_on_every_call_across_clients(use_async) -> None: + headers = { + "Deprecation": "@1793664000", + "Sunset": "Wed, 03 Nov 2027 00:00:00 GMT", + "Link": '; rel="deprecation"', + } + + def handler(request: httpx2.Request) -> httpx2.Response: + return httpx2.Response(200, json={"ok": True}, headers=headers) + + with warnings.catch_warnings(record=True) as caught: + warnings.simplefilter("always", DeprecationWarning) + for path in ("/email/jobs/123", "/email/jobs/456", "/email/jobs/opaque-job"): + if use_async: + http = httpx2.AsyncClient(transport=httpx2.MockTransport(handler), base_url="https://api.test/v1") + transport = AsyncTransport( + api_key_auth("test-key"), + base_url="https://api.test/v1", + timeout=5.0, + max_retries=0, + http_client=http, + ) + await transport.request("GET", path) + await transport.aclose() + else: + transport = make_transport(handler) + transport.with_timeout(1.0).request("GET", path) + transport.close() + assert len(caught) == 3 + transport = make_transport(handler) + transport.request("GET", "/email/jobs/789") + transport.request("POST", "/email/jobs/123") + transport.request("GET", "/usage") + transport.close() + assert len(caught) == 6 + message = str(caught[0].message) + assert "GET /email/jobs/{job_id}" in message + for name, value in headers.items(): + assert f"{name}: {value}" in message + + +@pytest.mark.parametrize("use_async", [False, True]) +@pytest.mark.parametrize("deprecation", [None, "", "not-a-date; ???", "@999999999999999999999999999999"]) +async def test_deprecation_missing_or_malformed_headers(use_async, deprecation) -> None: + headers = {"Sunset": "not a date", "Link": " httpx2.Response: + return httpx2.Response(200, json={"ok": True}, headers=headers) + + with warnings.catch_warnings(record=True) as caught: + warnings.simplefilter("always", DeprecationWarning) + if use_async: + http = httpx2.AsyncClient(transport=httpx2.MockTransport(handler), base_url="https://api.test/v1") + transport = AsyncTransport( + api_key_auth("test-key"), base_url="https://api.test/v1", timeout=5.0, max_retries=0, http_client=http + ) + response = await transport.request("GET", "/usage") + await transport.aclose() + else: + transport = make_transport(handler) + response = transport.request("GET", "/usage") + transport.close() + assert response.json() == {"ok": True} + assert len(caught) == (0 if deprecation is None else 1) + if caught: + assert "Sunset: not a date" in str(caught[0].message) + assert "Link: None: + def handler(request: httpx2.Request) -> httpx2.Response: + return httpx2.Response(200, json={}, headers={"Deprecation": "true"}) + + def request(job_id: int) -> None: + transport = make_transport(handler) + try: + transport.request("GET", f"/email/jobs/{job_id}") + finally: + transport.close() + + with warnings.catch_warnings(record=True) as caught: + warnings.simplefilter("always", DeprecationWarning) + with ThreadPoolExecutor(max_workers=8) as pool: + list(pool.map(request, range(32))) + assert len(caught) == 32 + + +@pytest.mark.parametrize( + "paths", + [ + ("/future/123", "/future/456"), + ("/future/123e4567-e89b-12d3-a456-426614174000", "/future/123e4567-e89b-12d3-a456-426614174001"), + ("/email/batch/first/results?offset=0", "/email/batch/second/results?offset=1"), + ], +) +def test_deprecation_normalizes_identifiers_and_query_strings(paths) -> None: + transport = make_transport(lambda request: httpx2.Response(200, json={}, headers={"Deprecation": "true"})) + with warnings.catch_warnings(record=True) as caught: + warnings.simplefilter("always", DeprecationWarning) + for path in paths: + transport.request("GET", path) + transport.close() + assert len(caught) == 2 + assert len({str(warning.message) for warning in caught}) == 1 diff --git a/scripts/check_contract.py b/scripts/check_contract.py index 0c7234b..622fb54 100644 --- a/scripts/check_contract.py +++ b/scripts/check_contract.py @@ -27,6 +27,16 @@ from discolike.resources.companies import Score from discolike.resources.companies import Subsidiary from discolike.resources.companies import Vendor +from discolike.resources.contacts_run import ContactsRun +from discolike.resources.contacts_run import ContactsRunEvent +from discolike.resources.contacts_run import ContactsRunEventsResponse +from discolike.resources.contacts_run import ContactsRunRow +from discolike.resources.contacts_run import ContactsRunStep +from discolike.resources.contacts_run import ContactsRunSummary +from discolike.resources.contacts_run import RunCompanyResponse +from discolike.resources.contacts_run import TitlePreview +from discolike.resources.contacts_run import TitlePreviewTitle +from discolike.resources.contacts_run import ValidatedRunTitle from discolike.resources.match import MatchResponse from discolike.resources.prospecting import ProspectingEvent from discolike.resources.prospecting import ProspectingInFlight @@ -61,6 +71,20 @@ "MatchResponse": MatchResponse, "SavedQueriesListResponse": SavedQueries, } +# Schemas of routes the platform hides from its public spec: checked only when the loaded spec carries them. +HIDDEN_MIRRORED_SCHEMAS: dict[str, type[DiscolikeModel]] = { + "ContactsRunResponse": ContactsRun, + "ContactsRunEvent": ContactsRunEvent, + "ContactsRunEventsResponse": ContactsRunEventsResponse, + "ContactsRunSummary": ContactsRunSummary, + "RunCompanyResponse": RunCompanyResponse, + "ValidatedRunTitle": ValidatedRunTitle, + "RunStepResponse": ContactsRunStep, + "StepState": ContactsRunStep, + "RunRow": ContactsRunRow, + "TitlePreviewResponse": TitlePreview, + "TitlePreviewItem": TitlePreviewTitle, +} # Request fields the platform accepts but hides from its OpenAPI schema (SkipJsonSchema), so the spec never lists them. HIDDEN_REQUEST_FIELDS: dict[str, frozenset[str]] = {"ProspectingBrief": frozenset({"checkpoints"})} # Methods that build a route's body from their own arguments instead of taking its request model, mapped @@ -384,9 +408,14 @@ def main() -> int: for route in sorted(skipped, key=lambda r: r.path): print(f"skipped (not in public schema): {route.http_method} {route.path}") - mismatches = check(spec, routes) + check_models(spec) + schemas = spec.get("components", {}).get("schemas", {}) + hidden = {name: model for name, model in HIDDEN_MIRRORED_SCHEMAS.items() if name in schemas} + mismatches = check(spec, routes) + check_models(spec) + check_models(spec, hidden) - print(f"checked {len(checked)} routes, skipped {len(skipped)} routes, {len(MIRRORED_SCHEMAS)} response models") + print( + f"checked {len(checked)} routes, skipped {len(skipped)} routes, " + f"{len(MIRRORED_SCHEMAS) + len(hidden)} response models" + ) if mismatches: print("MISMATCHES:")