diff --git a/CHANGELOG.md b/CHANGELOG.md index 6591b16..df458f8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,10 +2,14 @@ ## Unreleased -- SDK/CLI: contacts runs. `client.contacts_run` (sync and async) creates, lists, reads, continues and cancels a stepwise contacts run: `find_contacts`, `find_missing`, `review`, `find_emails`. `ContactsRunRequest` takes `companies` from a prompt (`max_companies` defaults to 500), a domain list or lookalike personas, plus `people` titles, `relevance`, `per_company`, `max_contacts` (keep the best-fit N contacts across all companies; once full, find missing searches no more companies and companies cut by the cap are not billed), `auto_stop` (`False` keeps every contact up to `per_company`, best fit first, instead of cutting at the relevance level), `find_missing` and `email_cap_usd`; a `step_by_step` run pauses with `status="waiting"` after each step until `continue_(run_id, ContinueRunRequest(generation=...))`. `email_cap_usd` is optional: it caps what `find_emails` spends, and without one the step runs until the account balance runs out. `wait(run_id, follow=True, email_cap_usd=...)` continues each step for you and stops at a failed step or, when neither the run nor the call has a cap, before `find_emails`; `rows(run_id)` pages through every row; `title_preview()` shows the titles a people spec matches. A stale `generation` or a failed step continued without `redo=True` is a 409 (`ValidationError`), and a run idle for 30 days answers 410. The CLI adds `contacts run create|list|get|continue|cancel|wait|title-preview`; `create` reads the request from `--params-file`, and `wait` takes `--follow` and `--email-cap-usd`. It needs an API release that serves `/contacts/run`. +- SDK/CLI: 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`, `contacts.count` and `contacts.generate` are deprecated in favor of `client.contacts_run`; the API marks their endpoints deprecated with a 2026-12-31 sunset. They keep working and raise a `DeprecationWarning`; the CLI's `contacts search`, `count` and `generate` print a deprecation line on stderr after their output. `contacts.discover`, `lookup`, `match` and `bulk_match` are unchanged. -- SDK (note for maintainers): `/contacts/run*` is hidden from the platform's public OpenAPI schema, so its request models live in the hand-kept `discolike/_contacts_run_requests.py` (generated once from a local spec), its routes are stamped `openapi=False`, and `scripts/check_contract.py` checks the run response models only when the spec carries them. `suggested_email` reaches `_generated/requests.py` through `PENDING_PROPERTIES` until the dev spec has it; the regeneration also picks up the dev spec's new site languages. +- SDK/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) diff --git a/README.md b/README.md index 0a736da..9041ec7 100644 --- a/README.md +++ b/README.md @@ -283,7 +283,7 @@ Top-level commands: `discover`, `count`, `match`, `extract`, `validate-icp`, `ap | `client.discover()` / `client.count()` | Find lookalike companies by ICP text, phrases, tech stack, geo, and 40+ other filters | | `client.companies` | Company profiles: firmographics, scores, growth, redirects, vendors, subsidiaries | | `client.contacts` | Search, look up, match, and discover contacts at target companies | -| `client.contacts_run` | Find contacts in reviewable steps, then their emails under a spending cap | +| `client.contacts_run` | Automatically find and deliver validated contacts under a spending cap | | `client.match` | Match company names (plus phone/city/state) to domains — single or bulk CSV | | `client.append()` | Enrich a CSV of domains with DiscoLike datasets | | `client.segment()` / `client.segment_file()` | Auto-segment a list of domains (comma-separated string or CSV upload) | @@ -313,25 +313,33 @@ result = job.wait() ### Contacts runs -`client.contacts_run` finds people at companies in four steps: `find_contacts`, `find_missing` (web search at companies with no indexed contact), `review` and `find_emails`. A `step_by_step` run (the default) pauses with `status="waiting"` after each step; `continue_()` starts the next one. `find_emails` bills per result; an optional `email_cap_usd`, given at create or on the continue that enters it, caps that spend, and without one it runs until the account balance runs out. +`client.contacts_run` automatically finds and validates contacts, using database candidates before web fallback. `max_companies` counts companies delivering at least one validated contact; `per_company` caps validated contacts at each company. Set `people.fit` to describe the people you want and optionally `email_cap_usd` to cap delivery spend. ```python -from discolike.requests import ContactsRunRequest, ContinueRunRequest +from discolike.requests import ContactsRunRequest, ContactsRunEventsParams run = client.contacts_run.create( - ContactsRunRequest.model_validate( - { - "companies": {"source": "prompt", "icp_text": "Independent dental clinics in Ohio"}, - "people": {"titles": ["Practice Owner", "Office Manager"]}, - } - ) + ContactsRunRequest.model_validate({ + "companies": {"source": "prompt", "icp_text": "Independent dental clinics in Ohio"}, + "people": {"fit": "Practice owners and office managers"}, + "max_companies": 50, + "per_company": 3, + "email_cap_usd": 20, + }) ) -run = client.contacts_run.wait(run.id, follow=True) # Stops before find_emails: no cap given. -rows = client.contacts_run.rows(run.id) # Every row, paged for you. -run = client.contacts_run.continue_(run.id, ContinueRunRequest(generation=run.generation, email_cap_usd=20)) +page = client.contacts_run.events(run.id, ContactsRunEventsParams(after=0)) +run = client.contacts_run.wait(run.id) # Returns on done, failed or cancelled. +rows = client.contacts_run.rows(run.id) # Pages company domains, keeping their contacts together. +print(run.summary) ``` -`companies` takes `{"source": "prompt", "icp_text": ...}` (up to `max_companies`, default 500), `{"source": "domains", "domains": [...]}` or `{"source": "lookalike", "persona_ids": [...]}`. `max_contacts` keeps only the best-fit N contacts across all companies (`per_company` still caps each one), and `auto_stop=False` turns off the relevance cut, keeping every contact up to `per_company`, best fit first; both stay off the wire unless set. `wait(follow=True)` also stops at a failed step, which you continue with `redo=True`. Every continue carries the run's current `generation`; a stale one is a 409 (`ValidationError`), and a run idle for 30 days answers 410. `contacts.search`, `contacts.count` and `contacts.generate` are deprecated in favor of contacts runs; their endpoints sunset on 2026-12-31. +`companies` accepts a prompt (`icp_text` and/or `filters`), domains (`source="domains", domains=[...]`) or lookalike seeds (`source="lookalike", persona_ids=[...]`). `max_companies` is a top-level field, default 500. Responses include `companies`, `companies_total`, `rows_total` and summary totals (`companies_delivered`, `validated_contacts`, `spent_usd`). Domains may be masked as `Company N`; treat them as display labels. Delivered email labels are `Validated` and `Validated (catch-all resolved)`. Events include `next_cursor` for the next call's `after`. + +Runs start automatically. `cancel()` stops unfinished work; `wait()` only polls and a timeout stops local polling. `continue_()` remains available for companies runs and returns 409 for contacts runs. The separate `title_preview()` route remains live, but contacts runs take free-text people fit rather than title chips. A run idle for 30 days answers 410. A creation request exceeding the remaining balance raises `InsufficientBalanceError` (a `PlanAccessError` subclass). + +CLI: `discolike contacts run create|list|get|events|cancel|wait|continue|title-preview`. Create reads `--params-file` plus optional `--email-cap-usd`; `wait` takes `--timeout`; `get --all-rows` fetches all contacts. `get --rows-offset` and `--rows-limit` count company domains for contacts runs. `continue` only advances companies runs and accepts `--generation` and `--redo`. + +`contacts.search` and `contacts.generate` are deprecated in favor of contacts runs; their endpoints sunset on 2026-12-31. ### Contact generation without an LLM key diff --git a/examples/README.md b/examples/README.md index 45d69b9..8ef9b44 100644 --- a/examples/README.md +++ b/examples/README.md @@ -13,11 +13,11 @@ export DISCOLIKE_API_KEY="dl_..." # create one at https://app.discolike.com/ac | [`icp_prompt_search.py`](icp_prompt_search.py) | Turns a plain-English ICP into a company list with similarity scores | `python examples/icp_prompt_search.py "Series A fintechs in Europe selling to SMBs" --country EU` | | [`phrase_match_count.py`](phrase_match_count.py) | Counts sites whose text contains an exact phrase, then lists them | `python examples/phrase_match_count.py "SOC 2" --country US` | | [`enrich_crm_export.py`](enrich_crm_export.py) | Adds firmographics (size, revenue, location, industry, business model) to every domain in a CSV | `python examples/enrich_crm_export.py accounts.csv --domain-column website --output enriched.csv` | -| [`contacts_at_results.py`](contacts_at_results.py) | Discovers companies for an ICP, then finds contacts at the top N filtered by seniority, department, or title | `python examples/contacts_at_results.py "B2B SaaS selling to sales teams" --top 10 --seniority executive --has-email` | +| [`contacts_at_results.py`](contacts_at_results.py) | Automatically finds and delivers validated contacts for an ICP and people fit | `python examples/contacts_at_results.py "B2B SaaS selling to sales teams" --top 10 --fit "Sales executives" --email-cap-usd 20` | | [`agent_signup_to_first_search.py`](agent_signup_to_first_search.py) | Opens a DiscoLike account for a person from an agent, relays `next_step`, and runs a first search once `DISCOLIKE_API_KEY` is set | `python examples/agent_signup_to_first_search.py --email jane@acme.com --first-name Jane --last-name Doe` | | [`discover_and_enrich.py`](discover_and_enrich.py) | Discovers companies for an ICP, then runs a DiscoGen research prompt over them (needs a BYOK LLM provider) | `python examples/discover_and_enrich.py --icp "Cybersecurity for SMBs" --country US --query "What is their pricing model?"` | | [`find_emails_from_csv.py`](find_emails_from_csv.py) | Finds verified work emails for a CSV of first name, last name, domain in batches of 500; only status `found` bills | `python examples/find_emails_from_csv.py people.csv --output emails.csv` | | [`match_crm_contacts.py`](match_crm_contacts.py) | Matches a messy CRM contact export to DiscoLike persona IDs with resumable checkpointing | `python examples/match_crm_contacts.py contacts.csv --output matched.csv` | -| [`cli_recipes.sh`](cli_recipes.sh) | The same searches as `discolike discover`, `discolike count`, `discolike contacts search`, and `discolike signup` one-liners, plus a `discolike bulk` volume pull | `bash examples/cli_recipes.sh` | +| [`cli_recipes.sh`](cli_recipes.sh) | The same searches as `discolike discover`, `discolike count`, `discolike contacts run`, and `discolike signup` one-liners, plus a `discolike bulk` volume pull | `bash examples/cli_recipes.sh` | Every script prints `--help`. Employee ranges are `min,max` strings such as `51,200`; countries are ISO-2 codes or region aliases like `EU`, `DACH`, `APAC`. diff --git a/examples/cli_recipes.sh b/examples/cli_recipes.sh index 4622818..f56543f 100644 --- a/examples/cli_recipes.sh +++ b/examples/cli_recipes.sh @@ -16,11 +16,15 @@ discolike discover --icp-prompt "Series A fintechs in Europe selling to SMBs" -- discolike count --phrase-match "SOC 2" --country US --format json discolike discover --phrase-match "SOC 2" --country US --max-records 50 --format json | jq -r '.[].domain' -# contacts_at_results.py: discover companies, then executives at the first ten of them -discolike discover --icp-prompt "B2B SaaS companies selling to sales teams" --max-records 10 --format json \ - | jq -r '.[].domain' \ - | xargs -I{} printf -- '--domain %s ' {} \ - | xargs discolike contacts search --seniority executive --has-email --format json +# contacts_at_results.py: automatic validated contacts for an ICP and people fit +cat > contacts-run.json <<'JSON' +{"companies":{"source":"prompt","icp_text":"B2B SaaS selling to sales teams"},"people":{"fit":"Sales executives"},"max_companies":10,"per_company":3} +JSON +discolike contacts run create --params-file contacts-run.json --email-cap-usd 20 +# Use the returned run ID: +# discolike contacts run events RUN_ID --after 0 +# discolike contacts run wait RUN_ID --timeout 3600 +# discolike contacts run get RUN_ID --all-rows # enrich_crm_export.py: one company profile by domain discolike company data stripe.com --format json diff --git a/examples/contacts_at_results.py b/examples/contacts_at_results.py index 6bc3f3e..3555f6f 100644 --- a/examples/contacts_at_results.py +++ b/examples/contacts_at_results.py @@ -1,57 +1,49 @@ -"""Discover companies for an ICP, then find decision makers at the top N by seniority, department, or title.""" +"""Automatically find and deliver validated decision-maker contacts for a company ICP.""" from __future__ import annotations import argparse from discolike import Discolike -from discolike.requests import ContactsSearchParams -from discolike.requests import DiscoverParams - -SENIORITIES = ["executive", "vp", "director", "manager", "senior_ic", "mid_level", "entry_level"] +from discolike.requests import ContactsRunRequest def parse_args() -> argparse.Namespace: parser = argparse.ArgumentParser(description=__doc__) parser.add_argument("prompt", help="Natural-language ICP for the company search") parser.add_argument("--country", action="append", help="ISO-2 country code or region alias, repeatable") - parser.add_argument("--top", type=int, default=10, help="Companies to search contacts at (default 10)") - parser.add_argument("--seniority", action="append", choices=SENIORITIES, help="Persona seniority, repeatable") - parser.add_argument("--department", action="append", help="Persona department, e.g. 'Sales - Marketing'") - parser.add_argument("--title", action="append", help="Job title term, e.g. 'Head of Growth', repeatable") - parser.add_argument("--per-company", type=int, default=3, help="Contacts per company (default 3)") - parser.add_argument("--has-email", action="store_true", help="Only contacts with an email address") + parser.add_argument( + "--top", type=int, default=10, help="Target companies delivering validated contacts (default 10)" + ) + parser.add_argument("--fit", default="Senior executives and decision makers", help="Describe the people to find") + parser.add_argument("--per-company", type=int, default=3, help="Validated contacts per company (default 3)") + parser.add_argument("--email-cap-usd", type=float, help="Maximum delivery spend in USD; omit for no cap") return parser.parse_args() def main() -> None: args = parse_args() - client = Discolike() - - companies = client.discover( - DiscoverParams(icp_prompt=args.prompt, country=args.country, max_records=max(args.top, 5)) - ) - domains = [company.domain for company in companies[: args.top] if company.domain] - print(f"Top {len(domains)} companies: {', '.join(domains)}") - - contacts = client.contacts.search( - ContactsSearchParams( - domain=domains, - seniority=args.seniority, - department=args.department, - title=args.title, - has_email=args.has_email, - results_by_company=args.per_company, - max_records=max(len(domains) * args.per_company, 20), - ) - ) - print(f"\n{'domain':<28} {'name':<28} {'title':<40} email") - for contact in contacts: - print( - f"{contact.domain or '':<28} {(contact.name or '')[:28]:<28} " - f"{(contact.title or '')[:40]:<40} {contact.email or ''}" - ) - print(f"\n{len(contacts)} contacts across {len({contact.domain for contact in contacts})} companies") + body = { + "companies": { + "source": "prompt", + "icp_text": args.prompt, + "filters": {"filter_country": args.country} if args.country else {}, + }, + "people": {"fit": args.fit}, + "max_companies": args.top, + "per_company": args.per_company, + } + if args.email_cap_usd is not None: + body["email_cap_usd"] = args.email_cap_usd + with Discolike() as client: + run = client.contacts_run.create(ContactsRunRequest.model_validate(body)) + print(f"Run {run.id}: {run.status}") + run = client.contacts_run.wait(run.id) + print(f"Finished: {run.status}; summary: {run.summary}") + for contact in client.contacts_run.rows(run.id): + print( + f"{contact.domain}\t{contact.name or ''}\t{contact.title or ''}\t{contact.email or ''}\t{contact.email_status or ''}" + ) if __name__ == "__main__": diff --git a/packages/discolike-cli/src/discolike_cli/_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 3268247..1b19ec1 100644 --- a/packages/discolike-cli/src/discolike_cli/contacts.py +++ b/packages/discolike-cli/src/discolike_cli/contacts.py @@ -280,7 +280,6 @@ def count_command( consensus=consensus, ) emit(get_client(ctx).contacts.count(build_request(ContactsCountParams, kwargs)), fmt=fmt) - typer.echo(DEPRECATED_COMMAND_WARNING.format(command="count"), err=True) @app.command("lookup") diff --git a/packages/discolike-cli/src/discolike_cli/contacts_run.py b/packages/discolike-cli/src/discolike_cli/contacts_run.py index a81ab9f..f31d6df 100644 --- a/packages/discolike-cli/src/discolike_cli/contacts_run.py +++ b/packages/discolike-cli/src/discolike_cli/contacts_run.py @@ -4,6 +4,7 @@ import typer +from discolike.requests import ContactsRunEventsParams from discolike.requests import ContactsRunGetParams from discolike.requests import ContactsRunListParams from discolike.requests import ContactsRunRequest @@ -18,21 +19,14 @@ FORMAT_HELP = "Output format: json or table (table auto-selected on a TTY; falls back to JSON for non-tabular data)." PARAMS_FILE_HELP = ( - "JSON object with the run request: companies (source prompt, domains or lookalike), people, relevance, " - "per_company, find_missing, email_cap_usd, mode. --param and flags override its values." + "JSON object with the run request: companies (source prompt, domains or lookalike), relevance, " + "people.fit, max_companies, per_company, email_cap_usd, exclusions and create_key. --param and flags override its values." ) PARAM_HELP = "Extra key=value body field (repeatable)." -EMAIL_CAP_HELP = ( - "Maximum USD to spend finding emails; omit for no cap. wait --follow stops before find_emails without one." -) -GENERATION_HELP = "Generation of the run you are continuing; fetched from the run when omitted." +EMAIL_CAP_HELP = "Maximum USD to spend on validated delivery; omit for no cap." +GENERATION_HELP = "Generation of the companies run you are continuing; fetched when omitted." -app = typer.Typer( - help=( - "Find contacts in steps: find_contacts, find_missing, review, find_emails. A step-by-step run " - "pauses after each step until you continue it." - ) -) +app = typer.Typer(help="Find and validate contacts automatically. Poll company progress and delivery totals.") @app.command("create") @@ -72,8 +66,10 @@ def list_command( def get_command( ctx: typer.Context, run_id: str = typer.Argument(..., help="Run ID."), - rows_offset: int | None = typer.Option(None, help="Rows to skip."), - rows_limit: int | None = typer.Option(None, help="Rows to return (default 100, maximum 1000)."), + rows_offset: int | None = typer.Option(None, help="Company domains to skip for contacts runs."), + rows_limit: int | None = typer.Option( + None, help="Company domains per page for contacts runs (default 100, maximum 1000)." + ), all_rows: bool = typer.Option(False, "--all-rows", help="Print every row of the run as a list instead."), fmt: str | None = typer.Option(None, "--format", help=FORMAT_HELP), ) -> None: @@ -94,15 +90,9 @@ def continue_command( ctx: typer.Context, run_id: str = typer.Argument(..., help="Run ID."), generation: int | None = typer.Option(None, help=GENERATION_HELP), - relevance: str | None = typer.Option(None, help="New relevance: max_relevance, balanced or max_coverage."), - not_fit_id: list[str] | None = typer.Option( - None, "--not-fit-id", help="Row key to drop as not a fit (repeatable)." - ), - email_cap_usd: float | None = typer.Option(None, "--email-cap-usd", help=EMAIL_CAP_HELP), redo: bool | None = typer.Option(None, "--redo/--no-redo", help="Run the current step again."), - target_step: str | None = typer.Option(None, help="find_missing: go back from review in a new generation."), ) -> None: - """Start the run's next step, or redo the current one.""" + """Advance or redo a companies run. Contacts runs proceed automatically (409 here).""" from discolike_cli.main import get_client client = get_client(ctx) @@ -111,11 +101,7 @@ def continue_command( _merge_params( None, generation=client.contacts_run.get(run_id).generation if generation is None else generation, - relevance=relevance, - not_fit_ids=not_fit_id, - email_cap_usd=email_cap_usd, redo=redo, - target_step=target_step, ), ) emit(client.contacts_run.continue_(run_id, request)) @@ -135,16 +121,27 @@ def cancel_command(ctx: typer.Context, run_id: str = typer.Argument(..., help="R def wait_command( ctx: typer.Context, run_id: str = typer.Argument(..., help="Run ID."), - follow: bool = typer.Option(False, "--follow", help="Continue each paused step instead of returning."), - email_cap_usd: float | None = typer.Option( - None, "--email-cap-usd", help="With --follow, continue into find_emails with this USD cap." - ), timeout: float = typer.Option(DEFAULT_WAIT_TIMEOUT_SECONDS, "--timeout", help="Max seconds to wait."), ) -> None: - """Block until the run finishes or pauses; with --follow, until it finishes or needs you.""" + """Block until done, failed or cancelled. A timeout stops local polling only.""" from discolike_cli.main import get_client - emit(get_client(ctx).contacts_run.wait(run_id, follow=follow, email_cap_usd=email_cap_usd, max_wait=timeout)) + emit(get_client(ctx).contacts_run.wait(run_id, max_wait=timeout)) + + +@app.command("events") +@handle_errors +def events_command( + ctx: typer.Context, + run_id: str = typer.Argument(..., help="Run ID."), + after: int | None = typer.Option(None, help="Return events after this cursor."), + limit: int | None = typer.Option(None, help="Events per page (default 100, maximum 500)."), +) -> None: + """Read company progress events and the next cursor.""" + from discolike_cli.main import get_client + + params = build_request(ContactsRunEventsParams, _merge_params(None, after=after, limit=limit)) + emit(get_client(ctx).contacts_run.events(run_id, params)) @app.command("title-preview") @@ -156,12 +153,17 @@ def title_preview_command( None, "--exclude-title", help="Job title to leave out (repeatable)." ), match: str = typer.Option("similar", help="similar also accepts close variants of a title; exact does not."), + relevance: str | None = typer.Option(None, help="max_relevance, balanced or max_coverage."), + auto_stop: bool | None = typer.Option(None, "--auto-stop/--no-auto-stop", help="Apply the relevance threshold."), fmt: str | None = typer.Option(None, "--format", help=FORMAT_HELP), ) -> None: """Preview the titles a people spec matches, with role scores and contact counts.""" from discolike_cli.main import get_client request = build_request( - TitlePreviewRequest, _merge_params(None, titles=title, exclude_titles=exclude_title, match=match) + TitlePreviewRequest, + _merge_params( + None, titles=title, exclude_titles=exclude_title, match=match, relevance=relevance, auto_stop=auto_stop + ), ) emit(get_client(ctx).contacts_run.title_preview(request).titles, fmt=fmt) diff --git a/packages/discolike-cli/src/discolike_cli/discover.py b/packages/discolike-cli/src/discolike_cli/discover.py index 2f42bfd..ab6812e 100644 --- a/packages/discolike-cli/src/discolike_cli/discover.py +++ b/packages/discolike-cli/src/discolike_cli/discover.py @@ -42,6 +42,7 @@ LANGUAGE_HELP = "Filter by site language as an ISO 639-1 code, e.g. en, de, pb, zt (repeatable)." NEGATE_LANGUAGE_HELP = "Exclude this site language (repeatable); same values as --language." EXCLUDE_LEADGEN_HELP = "Exclude suspected lead generation sites (on by default)." +VALID_MX_HELP = "Exclude companies with no mail server (off by default)." COUNT_EXCLUDE_LEADGEN_HELP = "Exclude suspected lead generation sites (off by default for count)." RELEVANCE_HELP = ( "Where the list stops, by estimated ICP fit: MAX_RELEVANCE (tightest), BALANCED, MAX_COVERAGE (longest). " @@ -114,6 +115,7 @@ def discover_command( consensus: int | None = typer.Option( None, help="Deprecated, no-op. Discover applies consensus itself to single-domain lookalike searches." ), + valid_mx: bool | None = typer.Option(None, "--valid-mx/--no-valid-mx", help=VALID_MX_HELP), redirect: bool | None = typer.Option(None, "--redirect/--no-redirect", help=REDIRECT_HELP), exclude_leadgen: bool | None = typer.Option( None, "--exclude-leadgen/--no-exclude-leadgen", help=EXCLUDE_LEADGEN_HELP @@ -193,6 +195,7 @@ def discover_command( relevance=relevance, variance=variance, consensus=consensus, + valid_mx=valid_mx, redirect=redirect, exclude_leadgen=exclude_leadgen, retrieval=retrieval, @@ -245,6 +248,7 @@ def count_command( negate_tech_stack: list[str] | None = typer.Option(None, help="Negate the --tech-stack filter (repeatable)."), min_digital_footprint: int | None = typer.Option(None, help="Minimum digital footprint score."), max_digital_footprint: int | None = typer.Option(None, help="Maximum digital footprint score."), + valid_mx: bool | None = typer.Option(None, "--valid-mx/--no-valid-mx", help=VALID_MX_HELP), redirect: bool | None = typer.Option(None, "--redirect/--no-redirect", help=REDIRECT_HELP), exclude_leadgen: bool | None = typer.Option( None, "--exclude-leadgen/--no-exclude-leadgen", help=COUNT_EXCLUDE_LEADGEN_HELP @@ -291,6 +295,7 @@ def count_command( negate_tech_stack=negate_tech_stack, min_digital_footprint=min_digital_footprint, max_digital_footprint=max_digital_footprint, + valid_mx=valid_mx, redirect=redirect, exclude_leadgen=exclude_leadgen, ), diff --git a/packages/discolike-cli/tests/test_bulk_cli.py b/packages/discolike-cli/tests/test_bulk_cli.py index 8ab497d..0cf0fa8 100644 --- a/packages/discolike-cli/tests/test_bulk_cli.py +++ b/packages/discolike-cli/tests/test_bulk_cli.py @@ -661,3 +661,16 @@ def test_bulk_rejects_non_positive_rate_limit( result = runner.invoke(app, ["bulk", *command, *extra, "--out", str(tmp_path / "c.csv"), "--rate-limit", "0"]) assert result.exit_code == 2, result.output assert recorder.requests == [] + + +@pytest.mark.parametrize(("flags", "expected"), [([], None), (["--valid-mx"], "true"), (["--no-valid-mx"], "false")]) +def test_bulk_companies_valid_mx( + install_build_client: Callable[[Handler], None], tmp_path: Path, flags: list[str], expected: str | None +) -> None: + recorder = Recorder(discover=[_companies("a", 1)]) + install_build_client(recorder) + result = runner.invoke( + app, ["bulk", "companies", "--out", str(tmp_path / "companies.csv"), "--max-companies", "1", *flags] + ) + assert result.exit_code == 0, result.output + assert recorder.params("/v1/discover")[0].get("valid_mx") == expected diff --git a/packages/discolike-cli/tests/test_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_run_cli.py b/packages/discolike-cli/tests/test_contacts_run_cli.py index fa9e6df..db01d1f 100644 --- a/packages/discolike-cli/tests/test_contacts_run_cli.py +++ b/packages/discolike-cli/tests/test_contacts_run_cli.py @@ -50,7 +50,7 @@ def handler(request: httpx2.Request) -> httpx2.Response: install_build_client(handler) params_file = tmp_path / "run.json" params_file.write_text( - json.dumps({"companies": {"source": "prompt", "icp_text": "dental clinics"}, "people": {"titles": ["Owner"]}}) + json.dumps({"companies": {"source": "prompt", "icp_text": "dental clinics"}, "people": {"fit": "Owners"}}) ) result = runner.invoke( app, ["contacts", "run", "create", "--params-file", str(params_file), "--email-cap-usd", "20"] @@ -60,7 +60,7 @@ def handler(request: httpx2.Request) -> httpx2.Response: assert bodies == [ { "companies": {"source": "prompt", "icp_text": "dental clinics"}, - "people": {"titles": ["Owner"]}, + "people": {"fit": "Owners"}, "email_cap_usd": "20.0", } ] @@ -76,40 +76,43 @@ def handler(request: httpx2.Request) -> httpx2.Response: return httpx2.Response(200, json=run_payload(generation=4)) install_build_client(handler) - result = runner.invoke( - app, ["contacts", "run", "continue", RUN_ID, "--not-fit-id", "r1", "--relevance", "balanced"] - ) + result = runner.invoke(app, ["contacts", "run", "continue", RUN_ID, "--redo"]) assert result.exit_code == 0, result.output assert seen == [ ("GET", RUN_PATH, None), - ("POST", f"{RUN_PATH}/continue", {"generation": 4, "relevance": "balanced", "not_fit_ids": ["r1"]}), + ("POST", f"{RUN_PATH}/continue", {"generation": 4, "redo": True}), ] -def test_run_wait_follow_needs_cap_for_emails(install_build_client: Callable[[Handler], None]) -> None: - state = {"step": 0} - continues: list[dict[str, Any]] = [] +def test_run_wait_polls_automatically(install_build_client: Callable[[Handler], None]) -> None: + statuses = iter(["ready", "running", "done"]) def handler(request: httpx2.Request) -> httpx2.Response: - if request.method == "POST": - continues.append(json.loads(request.content)) - state["step"] += 1 - step = state["step"] - return httpx2.Response( - 200, - json=run_payload(status="done" if step == len(STEPS) - 1 else "waiting", step=step, generation=step + 1), - ) + assert request.method == "GET" + return httpx2.Response(200, json=run_payload(status=next(statuses))) install_build_client(handler) - paused = runner.invoke(app, ["contacts", "run", "wait", RUN_ID, "--follow"]) - assert paused.exit_code == 0, paused.output - assert json.loads(paused.stdout)["current_step"] == "review" + result = runner.invoke(app, ["contacts", "run", "wait", RUN_ID]) + assert result.exit_code == 0, result.output + assert json.loads(result.stdout)["status"] == "done" - finished = runner.invoke(app, ["contacts", "run", "wait", RUN_ID, "--follow", "--email-cap-usd", "15"]) - assert finished.exit_code == 0, finished.output - assert json.loads(finished.stdout)["status"] == "done" - assert continues == [{"generation": 1}, {"generation": 2}, {"generation": 3, "email_cap_usd": "15.0"}] + +def test_run_events(install_build_client: Callable[[Handler], None]) -> None: + def handler(request: httpx2.Request) -> httpx2.Response: + assert request.url.path == RUN_PATH + "/events" + assert dict(request.url.params) == {"after": "4", "limit": "10"} + return httpx2.Response(200, json={"events": [], "next_cursor": 5}) + + install_build_client(handler) + result = runner.invoke(app, ["contacts", "run", "events", RUN_ID, "--after", "4", "--limit", "10"]) + assert result.exit_code == 0, result.output + assert json.loads(result.stdout)["next_cursor"] == 5 + + +@pytest.mark.parametrize("flag", ["--follow", "--email-cap-usd"]) +def test_removed_wait_flags(flag: str) -> None: + assert runner.invoke(app, ["contacts", "run", "wait", RUN_ID, flag]).exit_code == 2 def test_run_cancel_and_get(install_build_client: Callable[[Handler], None]) -> None: @@ -153,12 +156,11 @@ def handler(request: httpx2.Request) -> httpx2.Response: assert json.loads(result.stdout) == [{"title": "Owner", "role": 1.0, "count": 3}] -@pytest.mark.parametrize("command", ["search", "count"]) -def test_old_contacts_commands_print_deprecation(install_build_client: Callable[[Handler], None], command: str) -> None: - install_build_client(lambda request: httpx2.Response(200, json=[] if command == "search" else {"count": 0})) - result = runner.invoke(app, ["contacts", command, "--name", "Jane"]) +def test_old_contacts_commands_print_deprecation(install_build_client: Callable[[Handler], None]) -> None: + install_build_client(lambda request: httpx2.Response(200, json=[])) + result = runner.invoke(app, ["contacts", "search", "--name", "Jane"]) assert result.exit_code == 0, result.output - assert f"`discolike contacts {command}` is deprecated" in result.stderr + assert "`discolike contacts search` is deprecated" in result.stderr assert "use `discolike contacts run`" in result.stderr @@ -182,3 +184,26 @@ def handler(request: httpx2.Request) -> httpx2.Response: assert bodies == [ {"first_name": "Grace", "last_name": "Hopper", "domain": "navy.mil", "suggested_email": "grace@navy.mil"} ] + + +def test_run_insufficient_balance(install_build_client: Callable[[Handler], None], tmp_path: pathlib.Path) -> None: + install_build_client( + lambda _: httpx2.Response( + 403, + json={ + "detail": { + "code": "INSUFFICIENT_BALANCE", + "message": "Top up to continue", + "remaining_usd": 1, + "max_affordable_records": 2, + } + }, + ) + ) + params = tmp_path / "run.json" + params.write_text(json.dumps({"companies": {"source": "domains", "domains": ["acme.com"]}})) + result = runner.invoke(app, ["contacts", "run", "create", "--params-file", str(params)]) + assert result.exit_code == 3 + error = json.loads(result.stderr) + assert error["code"] == "insufficient_balance" + assert error["max_affordable_records"] == 2 diff --git a/packages/discolike-cli/tests/test_discover.py b/packages/discolike-cli/tests/test_discover.py index d52db5e..ddc41d9 100644 --- a/packages/discolike-cli/tests/test_discover.py +++ b/packages/discolike-cli/tests/test_discover.py @@ -4,6 +4,7 @@ from collections.abc import Callable import httpx2 +import pytest from typer.testing import CliRunner from discolike_cli.main import app @@ -411,3 +412,20 @@ def test_discover_unauthorized_exits_3(install_build_client: Callable[[Handler], assert result.exit_code == 3 payload = json.loads(result.stderr) assert payload["error"] == "AuthenticationError" + + +@pytest.mark.parametrize("command", ["discover", "count"]) +@pytest.mark.parametrize(("flags", "expected"), [([], None), (["--valid-mx"], "true"), (["--no-valid-mx"], "false")]) +def test_valid_mx_flag( + install_build_client: Callable[[Handler], None], command: str, flags: list[str], expected: str | None +) -> None: + seen: list[httpx2.QueryParams] = [] + + def handler(request: httpx2.Request) -> httpx2.Response: + seen.append(request.url.params) + return _count_ok(request) if command == "count" else _discover_ok(request) + + install_build_client(handler) + result = runner.invoke(app, [command, *flags]) + assert result.exit_code == 0, result.output + assert seen[0].get("valid_mx") == expected diff --git a/packages/discolike/src/discolike/__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/_contacts_run_requests.py b/packages/discolike/src/discolike/_contacts_run_requests.py index d79a49c..eb9d255 100644 --- a/packages/discolike/src/discolike/_contacts_run_requests.py +++ b/packages/discolike/src/discolike/_contacts_run_requests.py @@ -1,5 +1,4 @@ -# Generated by scripts/gen_requests.py from a local platform spec, then kept by hand: /contacts/run* is -# hidden from the public OpenAPI schema, so the generator and `--check` never see these models. +# Hand-maintained against platform contacts_run.py; do not regenerate from older deployed specs. from __future__ import annotations @@ -8,6 +7,7 @@ from typing import Literal from pydantic import AwareDatetime +from pydantic import ConfigDict from pydantic import Field from discolike._models import DiscolikeRequest @@ -17,6 +17,8 @@ class TitlePreviewRequest(DiscolikeRequest): titles: Annotated[list[str], Field(max_length=50, min_length=1, title="Titles")] exclude_titles: Annotated[list[str] | None, Field(title="Exclude Titles")] = None match: Annotated[Literal["similar", "exact"], Field(title="Match")] + relevance: Literal["max_relevance", "balanced", "max_coverage"] = "balanced" + auto_stop: bool = True class DomainCompanies(DiscolikeRequest): @@ -31,17 +33,6 @@ class DomainCompanies(DiscolikeRequest): ] = None -class FindMissingSpec(DiscolikeRequest): - search_provider_id: Annotated[str | None, Field(title="Search Provider Id")] = None - enabled: Annotated[ - bool | None, Field(description="Search for contacts at companies where none were found.", title="Enabled") - ] = True - provider: Annotated[ - str | None, - Field(description="'discolike' uses DiscoLike search; an integration id uses your own keys.", title="Provider"), - ] = "discolike" - - class LookalikeCompanies(DiscolikeRequest): source: Annotated[Literal["lookalike"], Field(title="Source")] persona_ids: Annotated[ @@ -50,22 +41,10 @@ class LookalikeCompanies(DiscolikeRequest): ] -class PeopleSpec(DiscolikeRequest): - titles: Annotated[ - list[str] | None, - Field( - description="Job titles of the people to find. Optional for a lookalike run, which then uses the seeds' titles.", - max_length=50, - title="Titles", - ), - ] = None - exclude_titles: Annotated[ - list[str] | None, Field(description="Job titles to leave out.", title="Exclude Titles") - ] = None - match: Annotated[ - Literal["similar", "exact"] | None, - Field(description="'similar' also accepts close variants of a title; 'exact' does not.", title="Match"), - ] = "similar" +class ContactsPeopleFit(DiscolikeRequest): + model_config = ConfigDict(extra="forbid", str_strip_whitespace=True) + + fit: str = Field(default="Senior executives and decision makers", min_length=1, max_length=4000) class PromptCompanyFilters(DiscolikeRequest): @@ -249,86 +228,35 @@ class PromptCompanyFilters(DiscolikeRequest): class ContinueRunRequest(DiscolikeRequest): - generation: Annotated[ - int, Field(description="Generation of the run you are continuing, as last returned.", title="Generation") - ] - relevance: Annotated[ - Literal["max_relevance", "balanced", "max_coverage"] | None, - Field(description="Change the relevance setting.", title="ContactsRelevance"), - ] = None - not_fit_ids: Annotated[ - list[str] | None, Field(description="Ids of rows to drop as not a fit.", title="Not Fit Ids") - ] = None - email_cap_usd: Annotated[ - Annotated[Decimal, Field(ge=0)] | None, - Field(description="Maximum spend in USD on finding emails.", title="Email Cap Usd"), - ] = None - redo: Annotated[bool | None, Field(description="Run the current step again.", title="Redo")] = False - target_step: Annotated[ - Literal["find_missing"] | None, - Field(description="Return from review to find missing in a new generation.", title="Target Step"), - ] = None + model_config = ConfigDict(extra="forbid") + + generation: int + redo: bool = False class PromptCompanies(DiscolikeRequest): - max_companies: Annotated[int | None, Field(ge=1, le=10000, title="Max Companies")] = 500 - source: Annotated[Literal["prompt"], Field(title="Source")] - icp_text: Annotated[ - str, Field(description="Plain-language description of the companies you want.", min_length=3, title="Icp Text") - ] - filters: Annotated[ - PromptCompanyFilters | None, Field(description="Company filters applied on top of the description.") - ] = None + source: Literal["prompt"] + icp_text: str | None = Field(default=None, min_length=3) + filters: PromptCompanyFilters = Field(default_factory=PromptCompanyFilters) class ContactsRunRequest(DiscolikeRequest): - start: Annotated[bool | None, Field(title="Start")] = True - exclude_query_ids: Annotated[list[str] | None, Field(max_length=20, title="Exclude Query Ids")] = None - exclude_domains: Annotated[list[str] | None, Field(max_length=100, title="Exclude Domains")] = None - companies: Annotated[ - PromptCompanies | DomainCompanies | LookalikeCompanies, Field(discriminator="source", title="Companies") - ] - people: PeopleSpec - relevance: Annotated[ - Literal["max_relevance", "balanced", "max_coverage"] | None, - Field(description="Trade-off between contact relevance and coverage.", title="ContactsRelevance"), - ] = "balanced" - per_company: Annotated[ - Annotated[int, Field(ge=1, le=50)] | Literal["all"] | None, - Field(description="Maximum contacts per company, or 'all'.", title="Per Company"), - ] = 10 - max_contacts: Annotated[ - Annotated[int, Field(ge=1, le=10000)] | None, - Field( - description="Maximum contacts to keep across all companies, best fit first. Omit to keep up to " - "per_company at every company.", - title="Max Contacts", - ), - ] = None - auto_stop: Annotated[ - bool | None, - Field( - description="Drop contacts below the relevance level's fit floor. False keeps every contact up to " - "per_company, best fit first.", - title="Auto Stop", - ), - ] = True - find_missing: FindMissingSpec | None = None - email_cap_usd: Annotated[ - Annotated[Decimal, Field(ge=0)] | None, - Field(description="Maximum spend in USD on finding emails. No cap when omitted.", title="Email Cap Usd"), - ] = None - mode: Annotated[ - Literal["step_by_step", "run_all"] | None, - Field(description="'step_by_step' pauses for review between steps; 'run_all' does not.", title="Mode"), - ] = "step_by_step" - create_key: Annotated[ - str | None, - Field( - description="Idempotency key: repeating a request with the same key returns the same run.", - title="Create Key", - ), - ] = None + model_config = ConfigDict(extra="forbid") + + companies: Annotated[PromptCompanies | DomainCompanies | LookalikeCompanies, Field(discriminator="source")] + people: ContactsPeopleFit = Field(default_factory=ContactsPeopleFit) + relevance: Literal["max_relevance", "balanced", "max_coverage"] = "balanced" + max_companies: int = Field(default=500, ge=1, le=10000) + per_company: int = Field(default=10, ge=1, le=50) + email_cap_usd: Decimal | None = Field(default=None, ge=0) + exclude_query_ids: list[str] = Field(default_factory=list, max_length=20) + exclude_domains: list[str] = Field(default_factory=list, max_length=100) + create_key: str | None = None + + +class ContactsRunEventsParams(DiscolikeRequest): + after: int = Field(default=0, ge=0) + limit: int = Field(default=100, ge=1, le=500) class ContactsRunListParams(DiscolikeRequest): diff --git a/packages/discolike/src/discolike/_email.py b/packages/discolike/src/discolike/_email.py index f164df0..e1ccfd7 100644 --- a/packages/discolike/src/discolike/_email.py +++ b/packages/discolike/src/discolike/_email.py @@ -16,6 +16,7 @@ from discolike._models import DiscolikeModel from discolike._transport import AsyncTransport from discolike._transport import Transport +from discolike.resources._base import api_route EmailKind = Literal["find", "verify"] EMAIL_TERMINAL_STATUSES = frozenset({"completed", "failed"}) @@ -138,6 +139,7 @@ def __init__(self, transport: Transport, *, job_id: str, kind: EmailKind = "find self.job_id = job_id self.kind = kind + @api_route("GET", "/email/jobs/{job_id}") def status(self) -> EmailJobResult: response = self._transport.request("GET", f"/email/jobs/{self.job_id}") return _decode_job_result(self.kind, response.json()) @@ -173,6 +175,7 @@ def __init__(self, transport: AsyncTransport, *, job_id: str, kind: EmailKind = self.job_id = job_id self.kind = kind + @api_route("GET", "/email/jobs/{job_id}") async def status(self) -> EmailJobResult: response = await self._transport.request("GET", f"/email/jobs/{self.job_id}") return _decode_job_result(self.kind, response.json()) @@ -208,6 +211,7 @@ def __init__(self, transport: Transport, *, batch_id: str, kind: EmailKind) -> N self.batch_id = batch_id self.kind = kind + @api_route("GET", "/email/batch/{batch_id}/results") def results( self, *, @@ -236,6 +240,7 @@ def __init__(self, transport: AsyncTransport, *, batch_id: str, kind: EmailKind) self.batch_id = batch_id self.kind = kind + @api_route("GET", "/email/batch/{batch_id}/results") async def results( self, *, diff --git a/packages/discolike/src/discolike/_generated/requests.py b/packages/discolike/src/discolike/_generated/requests.py index 64489a7..ea843ba 100644 --- a/packages/discolike/src/discolike/_generated/requests.py +++ b/packages/discolike/src/discolike/_generated/requests.py @@ -63,66 +63,9 @@ class CompaniesPublicLinksParams(DiscolikeRequest): class ContactsSearchParams(DiscolikeRequest): filter_industry: Annotated[ - list[ - Literal[ - "ACCOUNTING", - "ADVERTISING_AND_MARKETING", - "AGRICULTURE_AND_NATURAL_RESOURCES", - "ALCOHOL_AND_TOBACCO", - "AUTOMOTIVE", - "BIG_DATA_AND_ANALYTICS", - "BIOTECHNOLOGY", - "BLOCKCHAIN_AND_CRYPTOCURRENCY", - "BUSINESS_PRODUCTS_AND_SERVICES", - "CLOUD_COMPUTING", - "COMPUTER_HARDWARE_AND_SEMICONDUCTORS", - "CONGLOMERATES_SHELL_AND_HOLDING_COMPANIES", - "CONSTRUCTION", - "CONSUMER_PRODUCTS", - "CONSUMER_SERVICES", - "CYBERSECURITY", - "DEFENSE_AND_AEROSPACE", - "E-COMMERCE", - "EDUCATION", - "ENERGY", - "ENGINEERING", - "ENTERTAINMENT", - "ENVIRONMENTAL_SERVICES", - "FASHION_TEXTILE_AND_APPAREL", - "FINANCIAL_SERVICES", - "FOOD_AND_BEVERAGE", - "GAMING_AND_ESPORTS", - "GOVERNMENT_SERVICES", - "HEALTHCARE", - "HOSPITALITY", - "HUMAN_RESOURCES", - "INSURANCE", - "IT_SERVICES", - "LEGAL", - "MANUFACTURING", - "MEDIA", - "MINING_AND_METALS", - "NONPROFIT_AND_PHILANTHROPY", - "OIL_AND_GAS", - "PHARMACEUTICALS", - "PRIVATE_EQUITY_AND_VENTURE_CAPITAL", - "REAL_ESTATE", - "RENEWABLE_ENERGY", - "RESTAURANTS", - "RETAIL", - "SAAS", - "SECURITY", - "SOFTWARE", - "SPORTS_AND_RECREATION", - "SUPPLY_CHAIN_AND_PROCUREMENT", - "TELECOMMUNICATIONS", - "TRAVEL", - "WELLNESS_AND_LIFESTYLE", - ] - ] - | None, + list[str] | None, Field( - description="Filter by industry category. Valid values: ACCOUNTING, ADVERTISING_AND_MARKETING, AGRICULTURE_AND_NATURAL_RESOURCES, ALCOHOL_AND_TOBACCO, AUTOMOTIVE, BIG_DATA_AND_ANALYTICS, BIOTECHNOLOGY, BLOCKCHAIN_AND_CRYPTOCURRENCY, BUSINESS_PRODUCTS_AND_SERVICES, CLOUD_COMPUTING, COMPUTER_HARDWARE_AND_SEMICONDUCTORS, CONGLOMERATES_SHELL_AND_HOLDING_COMPANIES, CONSTRUCTION, CONSUMER_PRODUCTS, CONSUMER_SERVICES, CYBERSECURITY, DEFENSE_AND_AEROSPACE, E-COMMERCE, EDUCATION, ENERGY, ENGINEERING, ENTERTAINMENT, ENVIRONMENTAL_SERVICES, FASHION_TEXTILE_AND_APPAREL, FINANCIAL_SERVICES, FOOD_AND_BEVERAGE, GAMING_AND_ESPORTS, GOVERNMENT_SERVICES, HEALTHCARE, HOSPITALITY, HUMAN_RESOURCES, INSURANCE, IT_SERVICES, LEGAL, MANUFACTURING, MEDIA, MINING_AND_METALS, NONPROFIT_AND_PHILANTHROPY, OIL_AND_GAS, PHARMACEUTICALS, PRIVATE_EQUITY_AND_VENTURE_CAPITAL, REAL_ESTATE, RENEWABLE_ENERGY, RESTAURANTS, RETAIL, SAAS, SECURITY, SOFTWARE, SPORTS_AND_RECREATION, SUPPLY_CHAIN_AND_PROCUREMENT, TELECOMMUNICATIONS, TRAVEL, WELLNESS_AND_LIFESTYLE.", + description="Filter by industry category (ANY-OF; prefix + to require a category). Valid values: ACCOUNTING, ADVERTISING_AND_MARKETING, AGRICULTURE_AND_NATURAL_RESOURCES, ALCOHOL_AND_TOBACCO, AUTOMOTIVE, BIG_DATA_AND_ANALYTICS, BIOTECHNOLOGY, BLOCKCHAIN_AND_CRYPTOCURRENCY, BUSINESS_PRODUCTS_AND_SERVICES, CLOUD_COMPUTING, COMPUTER_HARDWARE_AND_SEMICONDUCTORS, CONGLOMERATES_SHELL_AND_HOLDING_COMPANIES, CONSTRUCTION, CONSUMER_PRODUCTS, CONSUMER_SERVICES, CYBERSECURITY, DEFENSE_AND_AEROSPACE, E-COMMERCE, EDUCATION, ENERGY, ENGINEERING, ENTERTAINMENT, ENVIRONMENTAL_SERVICES, FASHION_TEXTILE_AND_APPAREL, FINANCIAL_SERVICES, FOOD_AND_BEVERAGE, GAMING_AND_ESPORTS, GOVERNMENT_SERVICES, HEALTHCARE, HOSPITALITY, HUMAN_RESOURCES, INSURANCE, IT_SERVICES, LEGAL, MANUFACTURING, MEDIA, MINING_AND_METALS, NONPROFIT_AND_PHILANTHROPY, OIL_AND_GAS, PHARMACEUTICALS, PRIVATE_EQUITY_AND_VENTURE_CAPITAL, REAL_ESTATE, RENEWABLE_ENERGY, RESTAURANTS, RETAIL, SAAS, SECURITY, SOFTWARE, SPORTS_AND_RECREATION, SUPPLY_CHAIN_AND_PROCUREMENT, TELECOMMUNICATIONS, TRAVEL, WELLNESS_AND_LIFESTYLE.", title="Filter Industry", ), ] = None @@ -294,7 +237,7 @@ class ContactsSearchParams(DiscolikeRequest): skills: Annotated[ list[str] | None, Field( - description="Filter by skills. Multiple skills can be provided.", + description="Accepted and ignored. Contacts are no longer indexed by skills.", title="Skills", ), ] = None @@ -322,14 +265,14 @@ class ContactsSearchParams(DiscolikeRequest): summary: Annotated[ str | None, Field( - description="Match the profile summary text: a contact matches when its summary contains every word of any one term, in any order. Words are separate terms; quote a multi-word term to keep it together, prefix + to require a term.", + description="Matched against the job title: a contact matches when its title matches any one term. Unquoted words are separate terms; a quoted term is a phrase (adjacent, in order); prefix + to require a term.", title="Summary", ), ] = None negate_summary: Annotated[ str | None, Field( - description="Exclude contacts whose profile summary contains every word of any one term, in any order. Quote a multi-word term to keep it together.", + description="Exclude contacts whose job title matches any one term. Unquoted words are separate terms; a quoted term is a phrase (adjacent, in order).", title="Negate Summary", ), ] = None @@ -437,7 +380,7 @@ class ContactsSearchParams(DiscolikeRequest): le=10000, title="Max Records", ), - ] = 100 + ] = 500 max_companies: Annotated[ int | None, Field( @@ -485,66 +428,9 @@ class ContactsSearchParams(DiscolikeRequest): class ContactsCountParams(DiscolikeRequest): filter_industry: Annotated[ - list[ - Literal[ - "ACCOUNTING", - "ADVERTISING_AND_MARKETING", - "AGRICULTURE_AND_NATURAL_RESOURCES", - "ALCOHOL_AND_TOBACCO", - "AUTOMOTIVE", - "BIG_DATA_AND_ANALYTICS", - "BIOTECHNOLOGY", - "BLOCKCHAIN_AND_CRYPTOCURRENCY", - "BUSINESS_PRODUCTS_AND_SERVICES", - "CLOUD_COMPUTING", - "COMPUTER_HARDWARE_AND_SEMICONDUCTORS", - "CONGLOMERATES_SHELL_AND_HOLDING_COMPANIES", - "CONSTRUCTION", - "CONSUMER_PRODUCTS", - "CONSUMER_SERVICES", - "CYBERSECURITY", - "DEFENSE_AND_AEROSPACE", - "E-COMMERCE", - "EDUCATION", - "ENERGY", - "ENGINEERING", - "ENTERTAINMENT", - "ENVIRONMENTAL_SERVICES", - "FASHION_TEXTILE_AND_APPAREL", - "FINANCIAL_SERVICES", - "FOOD_AND_BEVERAGE", - "GAMING_AND_ESPORTS", - "GOVERNMENT_SERVICES", - "HEALTHCARE", - "HOSPITALITY", - "HUMAN_RESOURCES", - "INSURANCE", - "IT_SERVICES", - "LEGAL", - "MANUFACTURING", - "MEDIA", - "MINING_AND_METALS", - "NONPROFIT_AND_PHILANTHROPY", - "OIL_AND_GAS", - "PHARMACEUTICALS", - "PRIVATE_EQUITY_AND_VENTURE_CAPITAL", - "REAL_ESTATE", - "RENEWABLE_ENERGY", - "RESTAURANTS", - "RETAIL", - "SAAS", - "SECURITY", - "SOFTWARE", - "SPORTS_AND_RECREATION", - "SUPPLY_CHAIN_AND_PROCUREMENT", - "TELECOMMUNICATIONS", - "TRAVEL", - "WELLNESS_AND_LIFESTYLE", - ] - ] - | None, + list[str] | None, Field( - description="Filter by industry category. Valid values: ACCOUNTING, ADVERTISING_AND_MARKETING, AGRICULTURE_AND_NATURAL_RESOURCES, ALCOHOL_AND_TOBACCO, AUTOMOTIVE, BIG_DATA_AND_ANALYTICS, BIOTECHNOLOGY, BLOCKCHAIN_AND_CRYPTOCURRENCY, BUSINESS_PRODUCTS_AND_SERVICES, CLOUD_COMPUTING, COMPUTER_HARDWARE_AND_SEMICONDUCTORS, CONGLOMERATES_SHELL_AND_HOLDING_COMPANIES, CONSTRUCTION, CONSUMER_PRODUCTS, CONSUMER_SERVICES, CYBERSECURITY, DEFENSE_AND_AEROSPACE, E-COMMERCE, EDUCATION, ENERGY, ENGINEERING, ENTERTAINMENT, ENVIRONMENTAL_SERVICES, FASHION_TEXTILE_AND_APPAREL, FINANCIAL_SERVICES, FOOD_AND_BEVERAGE, GAMING_AND_ESPORTS, GOVERNMENT_SERVICES, HEALTHCARE, HOSPITALITY, HUMAN_RESOURCES, INSURANCE, IT_SERVICES, LEGAL, MANUFACTURING, MEDIA, MINING_AND_METALS, NONPROFIT_AND_PHILANTHROPY, OIL_AND_GAS, PHARMACEUTICALS, PRIVATE_EQUITY_AND_VENTURE_CAPITAL, REAL_ESTATE, RENEWABLE_ENERGY, RESTAURANTS, RETAIL, SAAS, SECURITY, SOFTWARE, SPORTS_AND_RECREATION, SUPPLY_CHAIN_AND_PROCUREMENT, TELECOMMUNICATIONS, TRAVEL, WELLNESS_AND_LIFESTYLE.", + description="Filter by industry category (ANY-OF; prefix + to require a category). Valid values: ACCOUNTING, ADVERTISING_AND_MARKETING, AGRICULTURE_AND_NATURAL_RESOURCES, ALCOHOL_AND_TOBACCO, AUTOMOTIVE, BIG_DATA_AND_ANALYTICS, BIOTECHNOLOGY, BLOCKCHAIN_AND_CRYPTOCURRENCY, BUSINESS_PRODUCTS_AND_SERVICES, CLOUD_COMPUTING, COMPUTER_HARDWARE_AND_SEMICONDUCTORS, CONGLOMERATES_SHELL_AND_HOLDING_COMPANIES, CONSTRUCTION, CONSUMER_PRODUCTS, CONSUMER_SERVICES, CYBERSECURITY, DEFENSE_AND_AEROSPACE, E-COMMERCE, EDUCATION, ENERGY, ENGINEERING, ENTERTAINMENT, ENVIRONMENTAL_SERVICES, FASHION_TEXTILE_AND_APPAREL, FINANCIAL_SERVICES, FOOD_AND_BEVERAGE, GAMING_AND_ESPORTS, GOVERNMENT_SERVICES, HEALTHCARE, HOSPITALITY, HUMAN_RESOURCES, INSURANCE, IT_SERVICES, LEGAL, MANUFACTURING, MEDIA, MINING_AND_METALS, NONPROFIT_AND_PHILANTHROPY, OIL_AND_GAS, PHARMACEUTICALS, PRIVATE_EQUITY_AND_VENTURE_CAPITAL, REAL_ESTATE, RENEWABLE_ENERGY, RESTAURANTS, RETAIL, SAAS, SECURITY, SOFTWARE, SPORTS_AND_RECREATION, SUPPLY_CHAIN_AND_PROCUREMENT, TELECOMMUNICATIONS, TRAVEL, WELLNESS_AND_LIFESTYLE.", title="Filter Industry", ), ] = None @@ -716,7 +602,7 @@ class ContactsCountParams(DiscolikeRequest): skills: Annotated[ list[str] | None, Field( - description="Filter by skills. Multiple skills can be provided.", + description="Accepted and ignored. Contacts are no longer indexed by skills.", title="Skills", ), ] = None @@ -744,14 +630,14 @@ class ContactsCountParams(DiscolikeRequest): summary: Annotated[ str | None, Field( - description="Match the profile summary text: a contact matches when its summary contains every word of any one term, in any order. Words are separate terms; quote a multi-word term to keep it together, prefix + to require a term.", + description="Matched against the job title: a contact matches when its title matches any one term. Unquoted words are separate terms; a quoted term is a phrase (adjacent, in order); prefix + to require a term.", title="Summary", ), ] = None negate_summary: Annotated[ str | None, Field( - description="Exclude contacts whose profile summary contains every word of any one term, in any order. Quote a multi-word term to keep it together.", + description="Exclude contacts whose job title matches any one term. Unquoted words are separate terms; a quoted term is a phrase (adjacent, in order).", title="Negate Summary", ), ] = None @@ -859,7 +745,7 @@ class ContactsCountParams(DiscolikeRequest): le=10000, title="Max Records", ), - ] = 100 + ] = 500 max_companies: Annotated[ int | None, Field( @@ -939,66 +825,9 @@ class ContactsMatchParams(DiscolikeRequest): class ContactFilters(DiscolikeRequest): filter_industry: Annotated[ - list[ - Literal[ - "ACCOUNTING", - "ADVERTISING_AND_MARKETING", - "AGRICULTURE_AND_NATURAL_RESOURCES", - "ALCOHOL_AND_TOBACCO", - "AUTOMOTIVE", - "BIG_DATA_AND_ANALYTICS", - "BIOTECHNOLOGY", - "BLOCKCHAIN_AND_CRYPTOCURRENCY", - "BUSINESS_PRODUCTS_AND_SERVICES", - "CLOUD_COMPUTING", - "COMPUTER_HARDWARE_AND_SEMICONDUCTORS", - "CONGLOMERATES_SHELL_AND_HOLDING_COMPANIES", - "CONSTRUCTION", - "CONSUMER_PRODUCTS", - "CONSUMER_SERVICES", - "CYBERSECURITY", - "DEFENSE_AND_AEROSPACE", - "E-COMMERCE", - "EDUCATION", - "ENERGY", - "ENGINEERING", - "ENTERTAINMENT", - "ENVIRONMENTAL_SERVICES", - "FASHION_TEXTILE_AND_APPAREL", - "FINANCIAL_SERVICES", - "FOOD_AND_BEVERAGE", - "GAMING_AND_ESPORTS", - "GOVERNMENT_SERVICES", - "HEALTHCARE", - "HOSPITALITY", - "HUMAN_RESOURCES", - "INSURANCE", - "IT_SERVICES", - "LEGAL", - "MANUFACTURING", - "MEDIA", - "MINING_AND_METALS", - "NONPROFIT_AND_PHILANTHROPY", - "OIL_AND_GAS", - "PHARMACEUTICALS", - "PRIVATE_EQUITY_AND_VENTURE_CAPITAL", - "REAL_ESTATE", - "RENEWABLE_ENERGY", - "RESTAURANTS", - "RETAIL", - "SAAS", - "SECURITY", - "SOFTWARE", - "SPORTS_AND_RECREATION", - "SUPPLY_CHAIN_AND_PROCUREMENT", - "TELECOMMUNICATIONS", - "TRAVEL", - "WELLNESS_AND_LIFESTYLE", - ] - ] - | None, + list[str] | None, Field( - description="Filter by industry category. Valid values: ACCOUNTING, ADVERTISING_AND_MARKETING, AGRICULTURE_AND_NATURAL_RESOURCES, ALCOHOL_AND_TOBACCO, AUTOMOTIVE, BIG_DATA_AND_ANALYTICS, BIOTECHNOLOGY, BLOCKCHAIN_AND_CRYPTOCURRENCY, BUSINESS_PRODUCTS_AND_SERVICES, CLOUD_COMPUTING, COMPUTER_HARDWARE_AND_SEMICONDUCTORS, CONGLOMERATES_SHELL_AND_HOLDING_COMPANIES, CONSTRUCTION, CONSUMER_PRODUCTS, CONSUMER_SERVICES, CYBERSECURITY, DEFENSE_AND_AEROSPACE, E-COMMERCE, EDUCATION, ENERGY, ENGINEERING, ENTERTAINMENT, ENVIRONMENTAL_SERVICES, FASHION_TEXTILE_AND_APPAREL, FINANCIAL_SERVICES, FOOD_AND_BEVERAGE, GAMING_AND_ESPORTS, GOVERNMENT_SERVICES, HEALTHCARE, HOSPITALITY, HUMAN_RESOURCES, INSURANCE, IT_SERVICES, LEGAL, MANUFACTURING, MEDIA, MINING_AND_METALS, NONPROFIT_AND_PHILANTHROPY, OIL_AND_GAS, PHARMACEUTICALS, PRIVATE_EQUITY_AND_VENTURE_CAPITAL, REAL_ESTATE, RENEWABLE_ENERGY, RESTAURANTS, RETAIL, SAAS, SECURITY, SOFTWARE, SPORTS_AND_RECREATION, SUPPLY_CHAIN_AND_PROCUREMENT, TELECOMMUNICATIONS, TRAVEL, WELLNESS_AND_LIFESTYLE.", + description="Filter by industry category (ANY-OF; prefix + to require a category). Valid values: ACCOUNTING, ADVERTISING_AND_MARKETING, AGRICULTURE_AND_NATURAL_RESOURCES, ALCOHOL_AND_TOBACCO, AUTOMOTIVE, BIG_DATA_AND_ANALYTICS, BIOTECHNOLOGY, BLOCKCHAIN_AND_CRYPTOCURRENCY, BUSINESS_PRODUCTS_AND_SERVICES, CLOUD_COMPUTING, COMPUTER_HARDWARE_AND_SEMICONDUCTORS, CONGLOMERATES_SHELL_AND_HOLDING_COMPANIES, CONSTRUCTION, CONSUMER_PRODUCTS, CONSUMER_SERVICES, CYBERSECURITY, DEFENSE_AND_AEROSPACE, E-COMMERCE, EDUCATION, ENERGY, ENGINEERING, ENTERTAINMENT, ENVIRONMENTAL_SERVICES, FASHION_TEXTILE_AND_APPAREL, FINANCIAL_SERVICES, FOOD_AND_BEVERAGE, GAMING_AND_ESPORTS, GOVERNMENT_SERVICES, HEALTHCARE, HOSPITALITY, HUMAN_RESOURCES, INSURANCE, IT_SERVICES, LEGAL, MANUFACTURING, MEDIA, MINING_AND_METALS, NONPROFIT_AND_PHILANTHROPY, OIL_AND_GAS, PHARMACEUTICALS, PRIVATE_EQUITY_AND_VENTURE_CAPITAL, REAL_ESTATE, RENEWABLE_ENERGY, RESTAURANTS, RETAIL, SAAS, SECURITY, SOFTWARE, SPORTS_AND_RECREATION, SUPPLY_CHAIN_AND_PROCUREMENT, TELECOMMUNICATIONS, TRAVEL, WELLNESS_AND_LIFESTYLE.", title="Filter Industry", ), ] = None @@ -1170,7 +999,7 @@ class ContactFilters(DiscolikeRequest): skills: Annotated[ list[str] | None, Field( - description="Filter by skills. Multiple skills can be provided.", + description="Accepted and ignored. Contacts are no longer indexed by skills.", title="Skills", ), ] = None @@ -1198,14 +1027,14 @@ class ContactFilters(DiscolikeRequest): summary: Annotated[ str | None, Field( - description="Match the profile summary text: a contact matches when its summary contains every word of any one term, in any order. Words are separate terms; quote a multi-word term to keep it together, prefix + to require a term.", + description="Matched against the job title: a contact matches when its title matches any one term. Unquoted words are separate terms; a quoted term is a phrase (adjacent, in order); prefix + to require a term.", title="Summary", ), ] = None negate_summary: Annotated[ str | None, Field( - description="Exclude contacts whose profile summary contains every word of any one term, in any order. Quote a multi-word term to keep it together.", + description="Exclude contacts whose job title matches any one term. Unquoted words are separate terms; a quoted term is a phrase (adjacent, in order).", title="Negate Summary", ), ] = None @@ -1313,7 +1142,7 @@ class ContactFilters(DiscolikeRequest): le=10000, title="Max Records", ), - ] = 100 + ] = 500 max_companies: Annotated[ int | None, Field( @@ -1574,66 +1403,9 @@ class DiscoverParams(DiscolikeRequest): ), ] = None category: Annotated[ - list[ - Literal[ - "ACCOUNTING", - "ADVERTISING_AND_MARKETING", - "AGRICULTURE_AND_NATURAL_RESOURCES", - "ALCOHOL_AND_TOBACCO", - "AUTOMOTIVE", - "BIG_DATA_AND_ANALYTICS", - "BIOTECHNOLOGY", - "BLOCKCHAIN_AND_CRYPTOCURRENCY", - "BUSINESS_PRODUCTS_AND_SERVICES", - "CLOUD_COMPUTING", - "COMPUTER_HARDWARE_AND_SEMICONDUCTORS", - "CONGLOMERATES_SHELL_AND_HOLDING_COMPANIES", - "CONSTRUCTION", - "CONSUMER_PRODUCTS", - "CONSUMER_SERVICES", - "CYBERSECURITY", - "DEFENSE_AND_AEROSPACE", - "E-COMMERCE", - "EDUCATION", - "ENERGY", - "ENGINEERING", - "ENTERTAINMENT", - "ENVIRONMENTAL_SERVICES", - "FASHION_TEXTILE_AND_APPAREL", - "FINANCIAL_SERVICES", - "FOOD_AND_BEVERAGE", - "GAMING_AND_ESPORTS", - "GOVERNMENT_SERVICES", - "HEALTHCARE", - "HOSPITALITY", - "HUMAN_RESOURCES", - "INSURANCE", - "IT_SERVICES", - "LEGAL", - "MANUFACTURING", - "MEDIA", - "MINING_AND_METALS", - "NONPROFIT_AND_PHILANTHROPY", - "OIL_AND_GAS", - "PHARMACEUTICALS", - "PRIVATE_EQUITY_AND_VENTURE_CAPITAL", - "REAL_ESTATE", - "RENEWABLE_ENERGY", - "RESTAURANTS", - "RETAIL", - "SAAS", - "SECURITY", - "SOFTWARE", - "SPORTS_AND_RECREATION", - "SUPPLY_CHAIN_AND_PROCUREMENT", - "TELECOMMUNICATIONS", - "TRAVEL", - "WELLNESS_AND_LIFESTYLE", - ] - ] - | None, + list[str] | None, Field( - description="Filter by industry category. Valid values: ACCOUNTING, ADVERTISING_AND_MARKETING, AGRICULTURE_AND_NATURAL_RESOURCES, ALCOHOL_AND_TOBACCO, AUTOMOTIVE, BIG_DATA_AND_ANALYTICS, BIOTECHNOLOGY, BLOCKCHAIN_AND_CRYPTOCURRENCY, BUSINESS_PRODUCTS_AND_SERVICES, CLOUD_COMPUTING, COMPUTER_HARDWARE_AND_SEMICONDUCTORS, CONGLOMERATES_SHELL_AND_HOLDING_COMPANIES, CONSTRUCTION, CONSUMER_PRODUCTS, CONSUMER_SERVICES, CYBERSECURITY, DEFENSE_AND_AEROSPACE, E-COMMERCE, EDUCATION, ENERGY, ENGINEERING, ENTERTAINMENT, ENVIRONMENTAL_SERVICES, FASHION_TEXTILE_AND_APPAREL, FINANCIAL_SERVICES, FOOD_AND_BEVERAGE, GAMING_AND_ESPORTS, GOVERNMENT_SERVICES, HEALTHCARE, HOSPITALITY, HUMAN_RESOURCES, INSURANCE, IT_SERVICES, LEGAL, MANUFACTURING, MEDIA, MINING_AND_METALS, NONPROFIT_AND_PHILANTHROPY, OIL_AND_GAS, PHARMACEUTICALS, PRIVATE_EQUITY_AND_VENTURE_CAPITAL, REAL_ESTATE, RENEWABLE_ENERGY, RESTAURANTS, RETAIL, SAAS, SECURITY, SOFTWARE, SPORTS_AND_RECREATION, SUPPLY_CHAIN_AND_PROCUREMENT, TELECOMMUNICATIONS, TRAVEL, WELLNESS_AND_LIFESTYLE.", + description="Filter by industry category (ANY-OF; prefix + to require a category). Valid values: ACCOUNTING, ADVERTISING_AND_MARKETING, AGRICULTURE_AND_NATURAL_RESOURCES, ALCOHOL_AND_TOBACCO, AUTOMOTIVE, BIG_DATA_AND_ANALYTICS, BIOTECHNOLOGY, BLOCKCHAIN_AND_CRYPTOCURRENCY, BUSINESS_PRODUCTS_AND_SERVICES, CLOUD_COMPUTING, COMPUTER_HARDWARE_AND_SEMICONDUCTORS, CONGLOMERATES_SHELL_AND_HOLDING_COMPANIES, CONSTRUCTION, CONSUMER_PRODUCTS, CONSUMER_SERVICES, CYBERSECURITY, DEFENSE_AND_AEROSPACE, E-COMMERCE, EDUCATION, ENERGY, ENGINEERING, ENTERTAINMENT, ENVIRONMENTAL_SERVICES, FASHION_TEXTILE_AND_APPAREL, FINANCIAL_SERVICES, FOOD_AND_BEVERAGE, GAMING_AND_ESPORTS, GOVERNMENT_SERVICES, HEALTHCARE, HOSPITALITY, HUMAN_RESOURCES, INSURANCE, IT_SERVICES, LEGAL, MANUFACTURING, MEDIA, MINING_AND_METALS, NONPROFIT_AND_PHILANTHROPY, OIL_AND_GAS, PHARMACEUTICALS, PRIVATE_EQUITY_AND_VENTURE_CAPITAL, REAL_ESTATE, RENEWABLE_ENERGY, RESTAURANTS, RETAIL, SAAS, SECURITY, SOFTWARE, SPORTS_AND_RECREATION, SUPPLY_CHAIN_AND_PROCUREMENT, TELECOMMUNICATIONS, TRAVEL, WELLNESS_AND_LIFESTYLE.", title="Category", ), ] = None @@ -2042,6 +1814,10 @@ class DiscoverParams(DiscolikeRequest): title="Negate Business Model", ), ] = None + valid_mx: Annotated[ + bool | None, + Field(description="Only companies with a mail server.", title="Valid Mx"), + ] = False exclude_leadgen: Annotated[ bool | None, Field( @@ -2091,12 +1867,12 @@ class DiscoverParams(DiscolikeRequest): max_records: Annotated[ int | None, Field( - description="Maximum records to return (5-10000). Default 100.", + description="Maximum records to return (5-10000). Default 500.", ge=5, le=10000, title="Max Records", ), - ] = 100 + ] = 500 min_similarity: Annotated[ int | None, Field( @@ -2210,66 +1986,9 @@ class CountParams(DiscolikeRequest): ), ] = None category: Annotated[ - list[ - Literal[ - "ACCOUNTING", - "ADVERTISING_AND_MARKETING", - "AGRICULTURE_AND_NATURAL_RESOURCES", - "ALCOHOL_AND_TOBACCO", - "AUTOMOTIVE", - "BIG_DATA_AND_ANALYTICS", - "BIOTECHNOLOGY", - "BLOCKCHAIN_AND_CRYPTOCURRENCY", - "BUSINESS_PRODUCTS_AND_SERVICES", - "CLOUD_COMPUTING", - "COMPUTER_HARDWARE_AND_SEMICONDUCTORS", - "CONGLOMERATES_SHELL_AND_HOLDING_COMPANIES", - "CONSTRUCTION", - "CONSUMER_PRODUCTS", - "CONSUMER_SERVICES", - "CYBERSECURITY", - "DEFENSE_AND_AEROSPACE", - "E-COMMERCE", - "EDUCATION", - "ENERGY", - "ENGINEERING", - "ENTERTAINMENT", - "ENVIRONMENTAL_SERVICES", - "FASHION_TEXTILE_AND_APPAREL", - "FINANCIAL_SERVICES", - "FOOD_AND_BEVERAGE", - "GAMING_AND_ESPORTS", - "GOVERNMENT_SERVICES", - "HEALTHCARE", - "HOSPITALITY", - "HUMAN_RESOURCES", - "INSURANCE", - "IT_SERVICES", - "LEGAL", - "MANUFACTURING", - "MEDIA", - "MINING_AND_METALS", - "NONPROFIT_AND_PHILANTHROPY", - "OIL_AND_GAS", - "PHARMACEUTICALS", - "PRIVATE_EQUITY_AND_VENTURE_CAPITAL", - "REAL_ESTATE", - "RENEWABLE_ENERGY", - "RESTAURANTS", - "RETAIL", - "SAAS", - "SECURITY", - "SOFTWARE", - "SPORTS_AND_RECREATION", - "SUPPLY_CHAIN_AND_PROCUREMENT", - "TELECOMMUNICATIONS", - "TRAVEL", - "WELLNESS_AND_LIFESTYLE", - ] - ] - | None, + list[str] | None, Field( - description="Filter by industry category. Valid values: ACCOUNTING, ADVERTISING_AND_MARKETING, AGRICULTURE_AND_NATURAL_RESOURCES, ALCOHOL_AND_TOBACCO, AUTOMOTIVE, BIG_DATA_AND_ANALYTICS, BIOTECHNOLOGY, BLOCKCHAIN_AND_CRYPTOCURRENCY, BUSINESS_PRODUCTS_AND_SERVICES, CLOUD_COMPUTING, COMPUTER_HARDWARE_AND_SEMICONDUCTORS, CONGLOMERATES_SHELL_AND_HOLDING_COMPANIES, CONSTRUCTION, CONSUMER_PRODUCTS, CONSUMER_SERVICES, CYBERSECURITY, DEFENSE_AND_AEROSPACE, E-COMMERCE, EDUCATION, ENERGY, ENGINEERING, ENTERTAINMENT, ENVIRONMENTAL_SERVICES, FASHION_TEXTILE_AND_APPAREL, FINANCIAL_SERVICES, FOOD_AND_BEVERAGE, GAMING_AND_ESPORTS, GOVERNMENT_SERVICES, HEALTHCARE, HOSPITALITY, HUMAN_RESOURCES, INSURANCE, IT_SERVICES, LEGAL, MANUFACTURING, MEDIA, MINING_AND_METALS, NONPROFIT_AND_PHILANTHROPY, OIL_AND_GAS, PHARMACEUTICALS, PRIVATE_EQUITY_AND_VENTURE_CAPITAL, REAL_ESTATE, RENEWABLE_ENERGY, RESTAURANTS, RETAIL, SAAS, SECURITY, SOFTWARE, SPORTS_AND_RECREATION, SUPPLY_CHAIN_AND_PROCUREMENT, TELECOMMUNICATIONS, TRAVEL, WELLNESS_AND_LIFESTYLE.", + description="Filter by industry category (ANY-OF; prefix + to require a category). Valid values: ACCOUNTING, ADVERTISING_AND_MARKETING, AGRICULTURE_AND_NATURAL_RESOURCES, ALCOHOL_AND_TOBACCO, AUTOMOTIVE, BIG_DATA_AND_ANALYTICS, BIOTECHNOLOGY, BLOCKCHAIN_AND_CRYPTOCURRENCY, BUSINESS_PRODUCTS_AND_SERVICES, CLOUD_COMPUTING, COMPUTER_HARDWARE_AND_SEMICONDUCTORS, CONGLOMERATES_SHELL_AND_HOLDING_COMPANIES, CONSTRUCTION, CONSUMER_PRODUCTS, CONSUMER_SERVICES, CYBERSECURITY, DEFENSE_AND_AEROSPACE, E-COMMERCE, EDUCATION, ENERGY, ENGINEERING, ENTERTAINMENT, ENVIRONMENTAL_SERVICES, FASHION_TEXTILE_AND_APPAREL, FINANCIAL_SERVICES, FOOD_AND_BEVERAGE, GAMING_AND_ESPORTS, GOVERNMENT_SERVICES, HEALTHCARE, HOSPITALITY, HUMAN_RESOURCES, INSURANCE, IT_SERVICES, LEGAL, MANUFACTURING, MEDIA, MINING_AND_METALS, NONPROFIT_AND_PHILANTHROPY, OIL_AND_GAS, PHARMACEUTICALS, PRIVATE_EQUITY_AND_VENTURE_CAPITAL, REAL_ESTATE, RENEWABLE_ENERGY, RESTAURANTS, RETAIL, SAAS, SECURITY, SOFTWARE, SPORTS_AND_RECREATION, SUPPLY_CHAIN_AND_PROCUREMENT, TELECOMMUNICATIONS, TRAVEL, WELLNESS_AND_LIFESTYLE.", title="Category", ), ] = None @@ -2678,6 +2397,10 @@ class CountParams(DiscolikeRequest): title="Negate Business Model", ), ] = None + valid_mx: Annotated[ + bool | None, + Field(description="Only companies with a mail server.", title="Valid Mx"), + ] = False exclude_leadgen: Annotated[ bool | None, Field( @@ -2689,7 +2412,13 @@ class CountParams(DiscolikeRequest): class FindEmailRequest(DiscolikeRequest): first_name: Annotated[str, Field(description="First name of the person", title="First Name")] - last_name: Annotated[str, Field(description="Last name of the person", title="Last Name")] + last_name: Annotated[ + str, + Field( + description="Last name; use an empty string to check only first@domain", + title="Last Name", + ), + ] domain: Annotated[str, Field(description="Target email domain", title="Domain")] known_pattern: Annotated[ str | None, @@ -3161,7 +2890,7 @@ class BulkContactMatchRequest(DiscolikeRequest): enrich: Annotated[ bool | None, Field( - description="When true, hydrate full contact data from the vector database (costs credits).", + description="When true, hydrate contact data for free. Emails only echo the email supplied in that query.", title="Enrich", ), ] = False @@ -3214,7 +2943,7 @@ class ProspectingBrief(DiscolikeRequest): ] = None exclude_domains: Annotated[list[str] | None, Field(max_length=1000, title="Exclude Domains")] = None target_companies: Annotated[int | None, Field(ge=1, le=10000, title="Target Companies")] = 1000 - contacts_per_company: Annotated[int | None, Field(ge=1, le=10, title="Contacts Per Company")] = 3 + contacts_per_company: Annotated[int | None, Field(ge=1, le=25, title="Contacts Per Company")] = 3 max_candidates: Annotated[int | None, Field(ge=0, le=100000, title="Max Candidates")] = 0 max_actions: Annotated[int | None, Field(ge=0, le=10000, title="Max Actions")] = 0 validation_integration_id: Annotated[str | None, Field(max_length=128, title="Validation Integration Id")] = None @@ -3269,7 +2998,7 @@ class ProspectingPlanSettings(DiscolikeRequest): max_spend_usd: Annotated[ float | None, Field( - description="Most the run may spend on DiscoLike records and per-call fees, in USD at your plan's rates, before it stops; 0 removes the limit.", + description="Most the run may spend on DiscoLike records, in USD at your plan's rates, before it stops; 0 removes the limit.", ge=0.0, title="Max Spend Usd", ), @@ -3288,7 +3017,7 @@ class ProspectingPlanSettings(DiscolikeRequest): Field( description="Most contacts to find at each qualified company.", ge=1, - le=10, + le=25, title="Contacts Per Company", ), ] = None diff --git a/packages/discolike/src/discolike/_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 3d79801..39ffd27 100644 --- a/packages/discolike/src/discolike/requests.py +++ b/packages/discolike/src/discolike/requests.py @@ -1,13 +1,13 @@ """Request models generated from the platform OpenAPI spec by scripts/gen_requests.py.""" +from discolike._contacts_run_requests import ContactsPeopleFit +from discolike._contacts_run_requests import ContactsRunEventsParams from discolike._contacts_run_requests import ContactsRunGetParams from discolike._contacts_run_requests import ContactsRunListParams from discolike._contacts_run_requests import ContactsRunRequest from discolike._contacts_run_requests import ContinueRunRequest from discolike._contacts_run_requests import DomainCompanies -from discolike._contacts_run_requests import FindMissingSpec from discolike._contacts_run_requests import LookalikeCompanies -from discolike._contacts_run_requests import PeopleSpec from discolike._contacts_run_requests import PromptCompanies from discolike._contacts_run_requests import PromptCompanyFilters from discolike._contacts_run_requests import TitlePreviewRequest @@ -72,6 +72,8 @@ "ContactsCountParams", "ContactsLookupParams", "ContactsMatchParams", + "ContactsPeopleFit", + "ContactsRunEventsParams", "ContactsRunGetParams", "ContactsRunListParams", "ContactsRunRequest", @@ -85,14 +87,12 @@ "DomainCompanies", "FindEmailBatchRequest", "FindEmailRequest", - "FindMissingSpec", "IntakeAnswer", "LLMProviderCreateRequest", "LLMProviderUpdateRequest", "LookalikeCompanies", "MatchBulkParams", "MatchCompanyParams", - "PeopleSpec", "PromptCompanies", "PromptCompanyFilters", "ProspectingApproveRequest", diff --git a/packages/discolike/src/discolike/resources/_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.py b/packages/discolike/src/discolike/resources/contacts.py index 24c9959..d284397 100644 --- a/packages/discolike/src/discolike/resources/contacts.py +++ b/packages/discolike/src/discolike/resources/contacts.py @@ -1,7 +1,5 @@ from __future__ import annotations -import warnings - import pydantic from discolike._jobs import FAMILY_CONTACTMATCH @@ -23,13 +21,6 @@ from discolike.resources.discovery import Count NATIVE_ENGINE = "native" -DEPRECATED_SEARCH_MESSAGE = ( - "contacts.{method}() is deprecated and its endpoint sunsets on 2026-12-31; use client.contacts_run" -) - - -def _warn_deprecated(method: str) -> None: - warnings.warn(DEPRECATED_SEARCH_MESSAGE.format(method=method), DeprecationWarning, stacklevel=3) class Contact(DiscolikeModel): @@ -77,13 +68,11 @@ class ContactsDiscoverResponse(DiscolikeModel): class ContactsResource(SyncAPIResource): @api_route("GET", "/contacts") def search(self, params: ContactsSearchParams) -> list[Contact]: - _warn_deprecated("search") response = self._transport.request("GET", "/contacts", params=params.to_wire()) return [Contact.model_validate(item) for item in response.json()] @api_route("GET", "/contacts/count") def count(self, params: ContactsCountParams) -> Count: - _warn_deprecated("count") return Count.model_validate(self._transport.request("GET", "/contacts/count", params=params.to_wire()).json()) @api_route("GET", "/contacts/lookup") @@ -109,7 +98,6 @@ def discover(self, request: ContactFilters) -> ContactsDiscoverResponse: @api_route("POST", "/contacts/discover/generate") def generate(self, request: ContactGenerateRequest) -> Job: - _warn_deprecated("generate") response = self._transport.request("POST", "/contacts/discover/generate", json_body=request.to_wire()) return Job(self._transport, task_family=FAMILY_DISCOGEN, task_id=response.json()["task_id"]) @@ -117,13 +105,11 @@ def generate(self, request: ContactGenerateRequest) -> Job: class AsyncContactsResource(AsyncAPIResource): @api_route("GET", "/contacts") async def search(self, params: ContactsSearchParams) -> list[Contact]: - _warn_deprecated("search") response = await self._transport.request("GET", "/contacts", params=params.to_wire()) return [Contact.model_validate(item) for item in response.json()] @api_route("GET", "/contacts/count") async def count(self, params: ContactsCountParams) -> Count: - _warn_deprecated("count") response = await self._transport.request("GET", "/contacts/count", params=params.to_wire()) return Count.model_validate(response.json()) @@ -149,6 +135,5 @@ async def discover(self, request: ContactFilters) -> ContactsDiscoverResponse: @api_route("POST", "/contacts/discover/generate") async def generate(self, request: ContactGenerateRequest) -> AsyncJob: - _warn_deprecated("generate") response = await self._transport.request("POST", "/contacts/discover/generate", json_body=request.to_wire()) return AsyncJob(self._transport, task_family=FAMILY_DISCOGEN, task_id=response.json()["task_id"]) diff --git a/packages/discolike/src/discolike/resources/contacts_run.py b/packages/discolike/src/discolike/resources/contacts_run.py index 6f9632e..8dc056e 100644 --- a/packages/discolike/src/discolike/resources/contacts_run.py +++ b/packages/discolike/src/discolike/resources/contacts_run.py @@ -13,6 +13,7 @@ from discolike._exceptions import JobTimeoutError from discolike._models import DiscolikeModel +from discolike.requests import ContactsRunEventsParams from discolike.requests import ContactsRunGetParams from discolike.requests import ContactsRunListParams from discolike.requests import ContactsRunRequest @@ -24,13 +25,7 @@ from discolike.resources.prospecting import _deadline RUN_PATH = "/contacts/run" -CONTACTS_KIND = "contacts" -CONTACTS_STEP_ORDER = ("find_contacts", "find_missing", "review", "find_emails") -FIND_EMAILS_STEP = "find_emails" -EMAIL_CAP_INPUT_KEY = "email_cap_usd" -FAILED_STEP_STATUS = "failed" TERMINAL_STATUSES = frozenset({"done", "failed", "cancelled"}) -PAUSED_STATUSES = frozenset({"ready", "waiting"}) ROWS_PAGE_SIZE = 1000 DEFAULT_MAX_WAIT_SECONDS = 3600.0 DEFAULT_POLL_INTERVAL_SECONDS = 5.0 @@ -42,11 +37,10 @@ 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) - child_task_id: str | None = None - state: dict[str, Any] = Field(default_factory=dict) class ContactsRunRow(DiscolikeModel): @@ -58,23 +52,86 @@ class ContactsRunRow(DiscolikeModel): linkedin_url: str | None = None seniority: str | None = None department: str | None = None - source: Literal["database", "web_search"] | str + debug_only: bool | None = None + reason: str | None = None company_score: float | None = None p_fit: float | None = None fit_label: str | None = None floor: bool = False not_fit: bool = False email: str | None = None - email_status: str | None = None + email_status: Literal["Validated", "Validated (catch-all resolved)"] | str | None = None + 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 + current_step: ContactsRunStepName | None = None generation: int steps: dict[str, ContactsRunStep] = Field(default_factory=dict) total_estimate_usd: Decimal | None = None @@ -82,8 +139,6 @@ class ContactsRun(DiscolikeModel): created_at: datetime updated_at: datetime expires_at: datetime - rows: list[ContactsRunRow] = Field(default_factory=list) - rows_total: int = 0 class TitlePreviewTitle(DiscolikeModel): @@ -93,6 +148,7 @@ class TitlePreviewTitle(DiscolikeModel): class TitlePreview(DiscolikeModel): + searched: list[str] = Field(default_factory=list) titles: list[TitlePreviewTitle] = Field(default_factory=list) @@ -100,36 +156,10 @@ def _path(run_id: str | UUID) -> str: return f"{RUN_PATH}/{UUID(str(run_id))}" -def _follow_request(run: ContactsRun, *, email_cap_usd: Decimal | float | None) -> ContinueRunRequest | None: - if run.kind != CONTACTS_KIND or run.current_step not in CONTACTS_STEP_ORDER: - return None - if run.status == "ready": - return ContinueRunRequest(generation=run.generation) - current = run.steps.get(run.current_step) - if current is not None and current.status == FAILED_STEP_STATUS: - return None - index = CONTACTS_STEP_ORDER.index(run.current_step) + 1 - if index >= len(CONTACTS_STEP_ORDER): - return None - if CONTACTS_STEP_ORDER[index] != FIND_EMAILS_STEP or ( - email_cap_usd is None and run.input.get(EMAIL_CAP_INPUT_KEY) is not None - ): - return ContinueRunRequest(generation=run.generation) - if email_cap_usd is None: - return None - return ContinueRunRequest(generation=run.generation, email_cap_usd=Decimal(str(email_cap_usd))) - - class ContactsRunResource(SyncAPIResource): @api_route("POST", RUN_PATH, openapi=False) def create(self, request: ContactsRunRequest) -> ContactsRun: - """Start a contacts run; it runs find_contacts -> find_missing -> review -> find_emails. - - With mode="step_by_step" (the default) the run pauses with status "waiting" after each step; - call continue_() to move on, or wait(follow=True). An optional email_cap_usd, given here or on - the continue that enters find_emails, caps what find_emails spends; without one it runs until - the account balance runs out. - """ + """Start automatic contact discovery and validated delivery, optionally capped by email_cap_usd.""" response = self._transport.request("POST", RUN_PATH, json_body=request.to_wire()) return ContactsRun.model_validate(response.json()) @@ -147,23 +177,27 @@ def get(self, run_id: str | UUID, params: ContactsRunGetParams | None = None) -> def rows(self, run_id: str | UUID, *, page_size: int = ROWS_PAGE_SIZE) -> builtins.list[ContactsRunRow]: """Every row of the run, fetched page by page.""" rows: builtins.list[ContactsRunRow] = [] + offset = 0 while True: - run = self.get(run_id, ContactsRunGetParams(rows_offset=len(rows), rows_limit=page_size)) - rows.extend(run.rows) - if not run.rows or len(rows) >= run.rows_total: + 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: - """Start the next step, or redo the current one with redo=True. - - Pass the run's current generation. ValidationError with status_code 409 means the run changed - since or its current step failed and needs redo=True; a 410 - (DiscolikeError) means the run sat idle past its expires_at. - """ + """Advance a companies run; contacts runs proceed automatically and return 409 here.""" response = self._transport.request("POST", _path(run_id) + "/continue", json_body=request.to_wire()) return ContactsRun.model_validate(response.json()) + @api_route("GET", RUN_PATH + "/{run_id}/events", openapi=False) + def events(self, run_id: str | UUID, params: ContactsRunEventsParams | None = None) -> ContactsRunEventsResponse: + response = self._transport.request( + "GET", _path(run_id) + "/events", params=params.to_wire() if params else None + ) + return ContactsRunEventsResponse.model_validate(response.json()) + @api_route("POST", RUN_PATH + "/{run_id}/cancel", openapi=False) def cancel(self, run_id: str | UUID) -> ContactsRun: response = self._transport.request("POST", _path(run_id) + "/cancel") @@ -179,28 +213,15 @@ def wait( self, run_id: str | UUID, *, - follow: bool = False, - email_cap_usd: Decimal | float | None = None, max_wait: float = DEFAULT_MAX_WAIT_SECONDS, poll_interval: float = DEFAULT_POLL_INTERVAL_SECONDS, ) -> ContactsRun: - """Poll until the run finishes or pauses (status "ready" or "waiting"). - - With follow=True a paused run is continued step by step instead, stopping at a failed step - (continue it with redo=True) and before find_emails unless the run was created with an - email_cap_usd or one is given here, which is sent on the continue that enters find_emails. - Timeout stops local polling only. - """ + """Poll until done, failed or cancelled. Timeout stops local polling only.""" deadline = _deadline(max_wait, poll_interval) while True: run = self.get(run_id) if run.status in TERMINAL_STATUSES: return run - if run.status in PAUSED_STATUSES: - request = _follow_request(run, email_cap_usd=email_cap_usd) if follow else None - if request is None: - return run - self.continue_(run_id, request) remaining = deadline - time.monotonic() if remaining <= 0: raise JobTimeoutError("Timed out waiting for the contacts run; it continues on the server") @@ -225,10 +246,12 @@ async def get(self, run_id: str | UUID, params: ContactsRunGetParams | None = No async def rows(self, run_id: str | UUID, *, page_size: int = ROWS_PAGE_SIZE) -> builtins.list[ContactsRunRow]: rows: builtins.list[ContactsRunRow] = [] + offset = 0 while True: - run = await self.get(run_id, ContactsRunGetParams(rows_offset=len(rows), rows_limit=page_size)) - rows.extend(run.rows) - if not run.rows or len(rows) >= run.rows_total: + 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) @@ -236,6 +259,15 @@ async def continue_(self, run_id: str | UUID, request: ContinueRunRequest) -> Co response = await self._transport.request("POST", _path(run_id) + "/continue", json_body=request.to_wire()) return ContactsRun.model_validate(response.json()) + @api_route("GET", RUN_PATH + "/{run_id}/events", openapi=False) + async def events( + self, run_id: str | UUID, params: ContactsRunEventsParams | None = None + ) -> ContactsRunEventsResponse: + response = await self._transport.request( + "GET", _path(run_id) + "/events", params=params.to_wire() if params else None + ) + return ContactsRunEventsResponse.model_validate(response.json()) + @api_route("POST", RUN_PATH + "/{run_id}/cancel", openapi=False) async def cancel(self, run_id: str | UUID) -> ContactsRun: response = await self._transport.request("POST", _path(run_id) + "/cancel") @@ -250,8 +282,6 @@ async def wait( self, run_id: str | UUID, *, - follow: bool = False, - email_cap_usd: Decimal | float | None = None, max_wait: float = DEFAULT_MAX_WAIT_SECONDS, poll_interval: float = DEFAULT_POLL_INTERVAL_SECONDS, ) -> ContactsRun: @@ -260,11 +290,6 @@ async def wait( run = await self.get(run_id) if run.status in TERMINAL_STATUSES: return run - if run.status in PAUSED_STATUSES: - request = _follow_request(run, email_cap_usd=email_cap_usd) if follow else None - if request is None: - return run - await self.continue_(run_id, request) remaining = deadline - time.monotonic() if remaining <= 0: raise JobTimeoutError("Timed out waiting for the contacts run; it continues on the server") diff --git a/packages/discolike/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 5de22ad..bd7d962 100644 --- a/packages/discolike/tests/test_contacts.py +++ b/packages/discolike/tests/test_contacts.py @@ -21,8 +21,6 @@ from discolike_testkit import AsyncClientFactory from discolike_testkit import ClientFactory -pytestmark = pytest.mark.filterwarnings("ignore:contacts\\.\\w+\\(\\) is deprecated:DeprecationWarning") - def test_search_builds_query_and_parses_list(make_client: ClientFactory) -> None: seen = {} @@ -351,22 +349,25 @@ def test_route_metadata_stamped() -> None: ("method", "request_model"), [ ("search", ContactsSearchParams(domain=["a.com"])), - ("count", ContactsCountParams(domain=["a.com"])), ("generate", ContactGenerateRequest(icp_text="VPs", domains=["a.com"])), ], ) def test_old_search_methods_warn_and_still_work(make_client: ClientFactory, method: str, request_model: object) -> None: def handler(request: httpx2.Request) -> httpx2.Response: - return httpx2.Response(200, json=[] if method == "search" else {"count": 1, "task_id": "t-1"}) + return httpx2.Response( + 200, json=[] if method == "search" else {"count": 1, "task_id": "t-1"}, headers={"Deprecation": "true"} + ) - with make_client(handler) as client, pytest.warns(DeprecationWarning, match="use client.contacts_run") as caught: + with make_client(handler) as client, pytest.warns(DeprecationWarning, match="is deprecated"): getattr(client.contacts, method)(request_model) - assert caught[0].filename == __file__ async def test_old_search_methods_warn_async(make_async_client: AsyncClientFactory) -> None: - async with make_async_client(lambda request: httpx2.Response(200, json=[])) as client: - with pytest.warns(DeprecationWarning, match=r"contacts\.search\(\) is deprecated"): + def handler(request: httpx2.Request) -> httpx2.Response: + return httpx2.Response(200, json=[], headers={"Deprecation": "true"}) + + async with make_async_client(handler) as client: + with pytest.warns(DeprecationWarning, match="is deprecated"): await client.contacts.search(ContactsSearchParams(domain=["a.com"])) diff --git a/packages/discolike/tests/test_contacts_run.py b/packages/discolike/tests/test_contacts_run.py index 53366f2..91473ba 100644 --- a/packages/discolike/tests/test_contacts_run.py +++ b/packages/discolike/tests/test_contacts_run.py @@ -9,12 +9,16 @@ import pytest import discolike.resources.contacts_run as module +from discolike import InsufficientBalanceError +from discolike import JobTimeoutError +from discolike.requests import ContactsRunEventsParams from discolike.requests import ContactsRunGetParams from discolike.requests import ContactsRunListParams from discolike.requests import ContactsRunRequest from discolike.requests import ContinueRunRequest from discolike.requests import TitlePreviewRequest from discolike.resources.contacts_run import ContactsRun +from discolike.resources.contacts_run import ContactsRunEventsResponse from discolike_testkit import AsyncClientFactory from discolike_testkit import ClientFactory @@ -23,247 +27,303 @@ TIMESTAMP = "2026-10-30T12:00:00Z" -@pytest.fixture(autouse=True) -def no_sleep(monkeypatch: pytest.MonkeyPatch) -> None: - monkeypatch.setattr(module.time, "sleep", lambda seconds: None) - - async def fake_sleep(seconds: float) -> None: - return None - - monkeypatch.setattr(module.asyncio, "sleep", fake_sleep) - - -def run_payload( - *, - status: str = "waiting", - current_step: str = "find_contacts", - step_status: str = "done", - generation: int = 1, - rows: list[dict[str, Any]] | None = None, - rows_total: int = 0, -) -> dict[str, Any]: +def run_payload(status: str = "running", **extra: Any) -> dict[str, Any]: return { - "input": {"people": {"titles": ["Owner"]}}, "id": RUN_ID, "kind": "contacts", "status": status, - "current_step": current_step, - "generation": generation, - "steps": {current_step: {"status": step_status, "estimate_usd": "1.50", "charged_usd": "0.75"}}, - "total_estimate_usd": "1.50", - "total_charged_usd": "0.75", + "generation": 1, "created_at": TIMESTAMP, "updated_at": TIMESTAMP, - "expires_at": "2026-11-29T12:00:00Z", - "rows": rows or [], - "rows_total": rows_total, - "future_field": True, + "expires_at": TIMESTAMP, + **extra, } -def row(key: str) -> dict[str, Any]: - return {"row_key": key, "domain": "acme.com", "name": "Jane Doe", "source": "database"} - - def request() -> ContactsRunRequest: return ContactsRunRequest.model_validate( - {"companies": {"source": "domains", "domains": ["acme.com"]}, "people": {"titles": ["Owner"]}} + { + "companies": {"source": "domains", "domains": ["acme.com"]}, + "people": {"fit": "Owners"}, + } ) -class StepServer: - """Fake run server: each continue enters the next step, which finishes and waits.""" - - order = ("find_contacts", "find_missing", "review", "find_emails") - - def __init__(self) -> None: - self.step = 0 - self.generation = 1 - self.continues: list[dict[str, Any]] = [] +def test_create_wire_and_open_response(make_client: ClientFactory) -> None: + seen: list[httpx2.Request] = [] - def __call__(self, request: httpx2.Request) -> httpx2.Response: - if request.method == "POST" and request.url.path.endswith("/continue"): - self.continues.append(json.loads(request.content)) - self.step += 1 - self.generation += 1 - last = self.step == len(self.order) - 1 + def handler(req: httpx2.Request) -> httpx2.Response: + seen.append(req) return httpx2.Response( - 200, + 202, json=run_payload( - status="done" if last else "waiting", - current_step=self.order[self.step], - generation=self.generation, + companies=[ + { + "domain": "Company 1", + "status": "future", + "future": True, + "contacts": [ + {"row_key": "r1", "domain": "Company 1", "email_status": "Validated (catch-all resolved)"}, + ], + } + ], + companies_total=1, + summary={"companies_delivered": 1, "validated_contacts": 1, "spent_usd": "0.10"}, + future=True, ), ) - -def test_create_posts_body_and_parses_run(make_client: ClientFactory) -> None: - seen: list[httpx2.Request] = [] - - def handler(request: httpx2.Request) -> httpx2.Response: - seen.append(request) - return httpx2.Response(202, json=run_payload(status="running", step_status="running")) - with make_client(handler) as client: run = client.contacts_run.create(request()) - - assert seen[0].method == "POST" assert seen[0].url.path == "/v1/contacts/run" - assert json.loads(seen[0].content) == { - "companies": {"source": "domains", "domains": ["acme.com"]}, - "people": {"titles": ["Owner"]}, - } - assert isinstance(run, ContactsRun) - assert run.status == "running" - assert run.steps["find_contacts"].estimate_usd == Decimal("1.50") - assert run.total_charged_usd == Decimal("0.75") - assert run.model_extra == {"future_field": True} - - -def test_max_contacts_and_auto_stop_sent_only_when_set() -> None: - assert request().to_wire().keys() == {"companies", "people"} - body = ContactsRunRequest.model_validate(request().to_wire() | {"max_contacts": 50, "auto_stop": False}) - assert body.to_wire() == request().to_wire() | {"max_contacts": 50, "auto_stop": False} - - -def test_max_contacts_out_of_range_rejected() -> None: + assert json.loads(seen[0].content) == request().to_wire() + assert run.current_step is None + assert run.companies[0].contacts[0].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( - { - "companies": {"source": "domains", "domains": ["acme.com"]}, - "people": {"titles": ["Owner"]}, - "max_contacts": 0, - } - ) + ContactsRunRequest.model_validate(request().to_wire() | {field: value}) -def test_prompt_companies_default_to_500() -> None: +def test_request_defaults_and_supported_fields() -> None: body = ContactsRunRequest.model_validate( - {"companies": {"source": "prompt", "icp_text": "dental clinics"}, "people": {"titles": ["Owner"]}} + { + "companies": {"source": "prompt", "filters": {"filter_country": ["US"]}}, + "max_companies": 15, + "per_company": 3, + "email_cap_usd": "12.50", + "create_key": "key", + "exclude_domains": ["acme.com"], + "exclude_query_ids": ["query"], + "relevance": "max_coverage", + } ) - assert body.companies.max_companies == 500 # ty: ignore[unresolved-attribute] - - -def test_continue_sends_generation(make_client: ClientFactory) -> None: - seen: list[httpx2.Request] = [] - - def handler(request: httpx2.Request) -> httpx2.Response: - seen.append(request) - return httpx2.Response(202, json=run_payload(status="running", current_step="find_missing", generation=3)) - - with make_client(handler) as client: - run = client.contacts_run.continue_(RUN_ID, ContinueRunRequest(generation=2, relevance="max_coverage")) - - assert seen[0].url.path == f"{RUN_PATH}/continue" - assert json.loads(seen[0].content) == {"generation": 2, "relevance": "max_coverage"} - assert run.generation == 3 + assert body.people.fit == "Senior executives and decision makers" + assert body.to_wire()["max_companies"] == 15 + assert "people" not in body.to_wire() + with pytest.raises(pydantic.ValidationError): + ContactsRunRequest.model_validate(request().to_wire() | {"people": {"titles": ["Owner"]}}) + with pytest.raises(pydantic.ValidationError): + ContactsRunRequest.model_validate(request().to_wire() | {"per_company": "all"}) + assert ContactsRunRequest.model_validate({"companies": {"source": "lookalike", "persona_ids": [1]}}) -def test_get_list_cancel_and_title_preview_routes(make_client: ClientFactory) -> None: - seen: list[tuple[str, str, str]] = [] +def test_live_routes(make_client: ClientFactory) -> None: + seen: list[tuple[str, str, dict[str, Any]]] = [] - def handler(request: httpx2.Request) -> httpx2.Response: - seen.append((request.method, request.url.path, request.url.query.decode())) - if request.url.path.endswith("title-preview"): - return httpx2.Response(200, json={"titles": [{"title": "Owner", "role": 0.9, "count": 12}]}) - if request.url.path == "/v1/contacts/run": - return httpx2.Response(200, json=[run_payload()]) - return httpx2.Response(200, json=run_payload(status="cancelled")) + def handler(req: httpx2.Request) -> httpx2.Response: + seen.append((req.method, req.url.path, json.loads(req.content) if req.content else dict(req.url.params))) + if req.url.path.endswith("title-preview"): + return httpx2.Response(200, json={"titles": [], "searched": ["Owner"]}) + if req.url.path.endswith("events"): + return httpx2.Response(200, json={"events": [], "next_cursor": 5}) + return httpx2.Response(200, json=[run_payload()] if req.url.path == "/v1/contacts/run" else run_payload()) with make_client(handler) as client: - client.contacts_run.get(RUN_ID, ContactsRunGetParams(rows_offset=100, rows_limit=50)) + client.contacts_run.get(RUN_ID, ContactsRunGetParams(rows_offset=2, rows_limit=3)) client.contacts_run.list(ContactsRunListParams(limit=5)) client.contacts_run.cancel(RUN_ID) - preview = client.contacts_run.title_preview(TitlePreviewRequest(titles=["Owner"], match="similar")) - + client.contacts_run.continue_(RUN_ID, ContinueRunRequest(generation=2, redo=True)) + client.contacts_run.title_preview(TitlePreviewRequest(titles=["Owner"], match="similar", relevance="balanced")) + assert client.contacts_run.events(RUN_ID, ContactsRunEventsParams(after=4, limit=10)).next_cursor == 5 assert seen == [ - ("GET", RUN_PATH, "rows_offset=100&rows_limit=50"), - ("GET", "/v1/contacts/run", "limit=5"), - ("POST", f"{RUN_PATH}/cancel", ""), - ("POST", "/v1/contacts/run/title-preview", ""), + ("GET", RUN_PATH, {"rows_offset": "2", "rows_limit": "3"}), + ("GET", "/v1/contacts/run", {"limit": "5"}), + ("POST", RUN_PATH + "/cancel", {}), + ("POST", RUN_PATH + "/continue", {"generation": 2, "redo": True}), + ("POST", "/v1/contacts/run/title-preview", {"titles": ["Owner"], "match": "similar", "relevance": "balanced"}), + ("GET", RUN_PATH + "/events", {"after": "4", "limit": "10"}), ] - assert preview.titles[0].count == 12 - - -def test_wait_without_follow_returns_paused_run(make_client: ClientFactory) -> None: - server = StepServer() - with make_client(server) as client: - run = client.contacts_run.wait(RUN_ID) - assert run.status == "waiting" - assert server.continues == [] - - -def test_wait_follow_stops_before_emails_without_cap(make_client: ClientFactory) -> None: - server = StepServer() - with make_client(server) as client: - run = client.contacts_run.wait(RUN_ID, follow=True) - assert run.status == "waiting" - assert run.current_step == "review" - assert server.continues == [{"generation": 1}, {"generation": 2}] - - -def test_wait_follow_continues_into_emails_with_cap(make_client: ClientFactory) -> None: - server = StepServer() - with make_client(server) as client: - run = client.contacts_run.wait(RUN_ID, follow=True, email_cap_usd=25) - assert run.status == "done" - assert run.current_step == "find_emails" - assert server.continues == [{"generation": 1}, {"generation": 2}, {"generation": 3, "email_cap_usd": "25"}] -def test_wait_follow_enters_emails_with_the_cap_set_at_create(make_client: ClientFactory) -> None: - server = StepServer() - - def handler(request: httpx2.Request) -> httpx2.Response: - response = server(request) - return httpx2.Response(200, json={**response.json(), "input": {"email_cap_usd": "30"}}) - - with make_client(handler) as client: - run = client.contacts_run.wait(RUN_ID, follow=True) - assert run.status == "done" - assert server.continues == [{"generation": 1}, {"generation": 2}, {"generation": 3}] +def test_open_events() -> None: + page = ContactsRunEventsResponse.model_validate( + { + "next_cursor": 1, + "future": True, + "events": [ + { + "seq": 1, + "domain": "Company 1", + "created_at": TIMESTAMP, + "kind": "future", + "status": "future", + "validated_titles": [{"title": "Owner", "status": "future", "future": True}], + "future": True, + } + ], + } + ) + assert page.events[0].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"}}) + ) -def test_wait_follow_stops_at_a_failed_step(make_client: ClientFactory) -> None: - seen: list[str] = [] +@pytest.mark.parametrize("terminal", ["done", "failed", "cancelled"]) +def test_wait_only_polls(make_client: ClientFactory, monkeypatch: pytest.MonkeyPatch, terminal: str) -> None: + monkeypatch.setattr(module.time, "sleep", lambda _: None) + statuses = iter(["ready", "running", terminal]) - def handler(request: httpx2.Request) -> httpx2.Response: - seen.append(request.method) - return httpx2.Response(200, json=run_payload(step_status="failed")) + def handler(req: httpx2.Request) -> httpx2.Response: + assert req.method == "GET" + return httpx2.Response(200, json=run_payload(next(statuses))) with make_client(handler) as client: - run = client.contacts_run.wait(RUN_ID, follow=True, email_cap_usd=25) - assert run.steps["find_contacts"].status == "failed" - assert seen == ["GET"] + assert client.contacts_run.wait(RUN_ID).status == terminal + + +def test_wait_timeout(make_client: ClientFactory, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr(module, "_deadline", lambda *_: 0) + with make_client(lambda _: httpx2.Response(200, json=run_payload())) as client: + with pytest.raises(JobTimeoutError): + client.contacts_run.wait(RUN_ID) + + +def company_page(req: httpx2.Request, *, 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) + ) -def test_rows_pages_until_total(make_client: ClientFactory) -> None: - offsets: list[str | None] = [] - all_rows = [row(f"r{i}") for i in range(5)] +@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"] - def handler(request: httpx2.Request) -> httpx2.Response: - offset = int(request.url.params["rows_offset"]) - limit = int(request.url.params["rows_limit"]) - offsets.append(request.url.params["rows_offset"]) - return httpx2.Response(200, json=run_payload(rows=all_rows[offset : offset + limit], rows_total=5)) - with make_client(handler) as client: - rows = client.contacts_run.rows(RUN_ID, page_size=2) +@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"] - assert [item.row_key for item in rows] == ["r0", "r1", "r2", "r3", "r4"] - assert offsets == ["0", "2", "4"] +async def test_async_create_wait_events_cancel( + make_async_client: AsyncClientFactory, monkeypatch: pytest.MonkeyPatch +) -> None: + async def no_sleep(_: float) -> None: + pass -async def test_async_create_and_wait_follow(make_async_client: AsyncClientFactory) -> None: - server = StepServer() + monkeypatch.setattr(module.asyncio, "sleep", no_sleep) + statuses = iter(["ready", "running", "done"]) - def handler(request: httpx2.Request) -> httpx2.Response: - if request.url.path == "/v1/contacts/run": - return httpx2.Response(202, json=run_payload(status="running", step_status="running")) - return server(request) + def handler(req: httpx2.Request) -> httpx2.Response: + if req.url.path.endswith("events"): + assert req.url.params["after"] == "5" + return httpx2.Response(200, json={"events": [], "next_cursor": 6}) + if req.url.path.endswith("cancel"): + return httpx2.Response(200, json=run_payload("cancelled")) + if req.method == "POST": + assert json.loads(req.content) == request().to_wire() + return httpx2.Response(202, json=run_payload()) + return httpx2.Response(200, json=run_payload(next(statuses))) async with make_async_client(handler) as client: created = await client.contacts_run.create(request()) - run = await client.contacts_run.wait(created.id, follow=True) - assert run.current_step == "review" - assert server.continues == [{"generation": 1}, {"generation": 2}] + assert (await client.contacts_run.wait(created.id)).status == "done" + assert (await client.contacts_run.events(created.id, ContactsRunEventsParams(after=5))).next_cursor == 6 + assert (await client.contacts_run.cancel(created.id)).status == "cancelled" + + +def test_insufficient_balance(make_client: ClientFactory) -> None: + with make_client( + lambda _: httpx2.Response( + 403, + json={ + "detail": { + "code": "INSUFFICIENT_BALANCE", + "message": "Too expensive", + "remaining_usd": "1.00", + "max_affordable_records": 2, + } + }, + ) + ) as client: + with pytest.raises(InsufficientBalanceError) as exc: + client.contacts_run.create(request()) + assert exc.value.max_affordable_records == 2 + + +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 fc01d3a..6be3e91 100644 --- a/packages/discolike/tests/test_contract_registry.py +++ b/packages/discolike/tests/test_contract_registry.py @@ -66,7 +66,7 @@ def test_check_models_passes_when_spec_matches_model(): check_contract = _load_check_contract() from discolike.resources.companies import ExtractResult - spec = {"components": {"schemas": {"ExtractResponse": {"properties": {"text": {}, "language": {}}}}}} + spec = {"components": {"schemas": {"ExtractResponse": {"properties": {"text": {}}}}}} assert check_contract.check_models(spec, {"ExtractResponse": ExtractResult}) == [] @@ -74,16 +74,16 @@ def test_check_models_reports_field_the_spec_does_not_have(): check_contract = _load_check_contract() from discolike.resources.companies import ExtractResult - spec = {"components": {"schemas": {"ExtractResponse": {"properties": {"text": {}}}}}} + spec = {"components": {"schemas": {"ExtractResponse": {"properties": {}}}}} mismatches = check_contract.check_models(spec, {"ExtractResponse": ExtractResult}) - assert mismatches == ["ExtractResult: field 'language' not in spec schema 'ExtractResponse'"] + assert mismatches == ["ExtractResult: field 'text' not in spec schema 'ExtractResponse'"] def test_check_models_reports_field_the_sdk_does_not_declare(): check_contract = _load_check_contract() from discolike.resources.companies import ExtractResult - spec = {"components": {"schemas": {"ExtractResponse": {"properties": {"text": {}, "language": {}, "summary": {}}}}}} + spec = {"components": {"schemas": {"ExtractResponse": {"properties": {"text": {}, "summary": {}}}}}} mismatches = check_contract.check_models(spec, {"ExtractResponse": ExtractResult}) assert mismatches == ["ExtractResult: spec schema 'ExtractResponse' has field 'summary' the SDK does not declare"] @@ -92,7 +92,7 @@ def test_check_models_ignores_type_and_requiredness_when_spec_gives_no_type_info check_contract = _load_check_contract() from discolike.resources.companies import ExtractResult - spec = {"components": {"schemas": {"ExtractResponse": {"properties": {"text": {}, "language": {}}}}}} + spec = {"components": {"schemas": {"ExtractResponse": {"properties": {"text": {}}}}}} assert check_contract.check_models(spec, {"ExtractResponse": ExtractResult}) == [] @@ -143,7 +143,6 @@ def test_check_models_passes_a_nullable_field_expressed_via_anyof(): "ExtractResponse": { "properties": { "text": {"anyOf": [{"type": "string"}, {"type": "null"}]}, - "language": {"anyOf": [{"type": "string"}, {"type": "null"}]}, }, } } @@ -162,7 +161,6 @@ def test_check_models_passes_a_nullable_field_expressed_via_openapi_nullable_fla "ExtractResponse": { "properties": { "text": {"type": "string", "nullable": True}, - "language": {"type": "string", "nullable": True}, }, } } @@ -185,11 +183,7 @@ def test_check_models_accepts_an_sdk_field_nullable_where_the_spec_is_not(): check_contract = _load_check_contract() from discolike.resources.companies import ExtractResult - spec = { - "components": { - "schemas": {"ExtractResponse": {"properties": {"text": {"type": "string"}, "language": {"type": "string"}}}} - } - } + spec = {"components": {"schemas": {"ExtractResponse": {"properties": {"text": {"type": "string"}}}}}} assert check_contract.check_models(spec, {"ExtractResponse": ExtractResult}) == [] diff --git a/packages/discolike/tests/test_email.py b/packages/discolike/tests/test_email.py index 5f4547d..71555bd 100644 --- a/packages/discolike/tests/test_email.py +++ b/packages/discolike/tests/test_email.py @@ -1,6 +1,8 @@ from __future__ import annotations import json +import warnings +from typing import Literal import httpx2 import pydantic @@ -28,6 +30,9 @@ async def fake_sleep(seconds: float) -> None: return None monkeypatch.setattr(jobs_module.asyncio, "sleep", fake_sleep) + with warnings.catch_warnings(): + warnings.simplefilter("error", DeprecationWarning) + yield def _results_sequence(payloads): @@ -572,3 +577,53 @@ def handler(request: httpx2.Request) -> httpx2.Response: assert status.email == "ghopper@navy.mil" assert isinstance(status.result, EnumerationOutput) assert status.result.method == "smtp" + + +@pytest.mark.parametrize("kind", ["find", "verify"]) +@pytest.mark.parametrize("handle", ["job", "batch"]) +def test_reattachment_is_silent_and_still_polls( + make_client: ClientFactory, handle: str, kind: Literal["find", "verify"] +) -> None: + def handler(request: httpx2.Request) -> httpx2.Response: + assert request.method == "GET" + if handle == "job": + assert request.url.path == "/v1/email/jobs/j-11" + return httpx2.Response(200, json=_VERIFY_JOB_PAYLOAD) + assert request.url.path == "/v1/email/batch/j-11/results" + return httpx2.Response(200, json={"total": 1, "results": [_VERIFY_JOB_PAYLOAD]}) + + with make_client(handler) as client: + attached = client.email.job("j-11", kind=kind) if handle == "job" else client.email.batch("j-11", kind=kind) + with warnings.catch_warnings(): + warnings.simplefilter("error", DeprecationWarning) + output = attached.wait() if isinstance(attached, EmailJob) else attached.results().results[0].result + + assert isinstance(output, ValidationOutput) + assert output.reason == "no_mailbox" + + +@pytest.mark.parametrize("kind", ["find", "verify"]) +@pytest.mark.parametrize("handle", ["job", "batch"]) +async def test_reattachment_is_silent_and_still_polls_async( + make_async_client: AsyncClientFactory, handle: str, kind: Literal["find", "verify"] +) -> None: + def handler(request: httpx2.Request) -> httpx2.Response: + assert request.method == "GET" + if handle == "job": + assert request.url.path == "/v1/email/jobs/j-11" + return httpx2.Response(200, json=_VERIFY_JOB_PAYLOAD) + assert request.url.path == "/v1/email/batch/j-11/results" + return httpx2.Response(200, json={"total": 1, "results": [_VERIFY_JOB_PAYLOAD]}) + + async with make_async_client(handler) as client: + attached = client.email.job("j-11", kind=kind) if handle == "job" else client.email.batch("j-11", kind=kind) + with warnings.catch_warnings(): + warnings.simplefilter("error", DeprecationWarning) + output = ( + await attached.wait() + if isinstance(attached, AsyncEmailJob) + else (await attached.results()).results[0].result + ) + + assert isinstance(output, ValidationOutput) + assert output.reason == "no_mailbox" diff --git a/packages/discolike/tests/test_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 715a96f..622fb54 100644 --- a/scripts/check_contract.py +++ b/scripts/check_contract.py @@ -28,10 +28,15 @@ from discolike.resources.companies import Subsidiary from discolike.resources.companies import Vendor from discolike.resources.contacts_run import ContactsRun +from discolike.resources.contacts_run import ContactsRunEvent +from discolike.resources.contacts_run import ContactsRunEventsResponse from discolike.resources.contacts_run import ContactsRunRow from discolike.resources.contacts_run import ContactsRunStep +from discolike.resources.contacts_run import ContactsRunSummary +from discolike.resources.contacts_run import RunCompanyResponse from discolike.resources.contacts_run import TitlePreview from discolike.resources.contacts_run import TitlePreviewTitle +from discolike.resources.contacts_run import ValidatedRunTitle from discolike.resources.match import MatchResponse from discolike.resources.prospecting import ProspectingEvent from discolike.resources.prospecting import ProspectingInFlight @@ -69,6 +74,12 @@ # Schemas of routes the platform hides from its public spec: checked only when the loaded spec carries them. HIDDEN_MIRRORED_SCHEMAS: dict[str, type[DiscolikeModel]] = { "ContactsRunResponse": ContactsRun, + "ContactsRunEvent": ContactsRunEvent, + "ContactsRunEventsResponse": ContactsRunEventsResponse, + "ContactsRunSummary": ContactsRunSummary, + "RunCompanyResponse": RunCompanyResponse, + "ValidatedRunTitle": ValidatedRunTitle, + "RunStepResponse": ContactsRunStep, "StepState": ContactsRunStep, "RunRow": ContactsRunRow, "TitlePreviewResponse": TitlePreview, diff --git a/scripts/gen_requests.py b/scripts/gen_requests.py index 436c5c7..ae1d189 100644 --- a/scripts/gen_requests.py +++ b/scripts/gen_requests.py @@ -100,18 +100,7 @@ # Properties the SDK ships before the deployed spec has them. Merged in only while the spec # lacks them, so each entry clears itself once the platform release lands -- generation prints # the ones that have, to be deleted here. -PENDING_PROPERTIES: dict[str, dict[str, dict[str, Any]]] = { - "FindEmailRequest": { - "suggested_email": { - "anyOf": [{"type": "string"}, {"type": "null"}], - "title": "Suggested Email", - "description": ( - "The address you think is right. It is verified first; if it is not proven, the right one is " - "recovered. Its domain must be `domain` or the company's mail domain." - ), - } - } -} +PENDING_PROPERTIES: dict[str, dict[str, dict[str, Any]]] = {} # Properties generated from this schema rather than the spec's, whatever the spec says. The # platform's sub-industry enum lists parent-qualified keys only; a bare label reaches it through