diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d9b1697..6cf64f0 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -13,7 +13,7 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - python-version: ['3.10', '3.11', '3.12', '3.13', '3.14'] + python-version: ['3.11', '3.12', '3.13', '3.14'] steps: - uses: actions/checkout@v4 - uses: astral-sh/setup-uv@v5 @@ -36,7 +36,7 @@ jobs: - resolution: highest python-version: '3.14' - resolution: lowest-direct - python-version: '3.10' + python-version: '3.11' steps: - uses: actions/checkout@v4 - uses: astral-sh/setup-uv@v5 diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index c29f46e..3b5dddb 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -21,7 +21,7 @@ repos: rev: v3.21.2 hooks: - id: pyupgrade - args: [--py310-plus, --keep-mock] + args: [--py311-plus, --keep-mock] - repo: https://github.com/bwhmather/ssort rev: 0.16.0 hooks: diff --git a/CHANGELOG.md b/CHANGELOG.md index 396de75..4c4d80c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,20 @@ # Changelog +## 0.5.0 (2026-10-06) + +- **Breaking:** Python 3.10 is no longer supported (end of life October 2026); the SDK and CLI require Python 3.11+. +- **Breaking:** the polling deadline on `Job.wait()`, `EmailJob.wait()` and `EmailBatch.results()` (sync and async) is renamed from `timeout=` to `max_wait=`, and `signup()` / `async_signup()` take `request_timeout=` instead of `timeout=`. Behavior is unchanged; the client constructor, `with_options(timeout=...)` and the CLI's `--timeout` keep their names. +- SDK: response enums such as `JobStatus` are open, so a value a newer API adds parses instead of failing. Compare against the documented values and treat anything else as unknown. +- SDK/CLI: managed prospecting. `client.prospecting` starts, reads, lists, approves, messages, renames, cancels, deletes and waits on runs (sync and async), with typed plans, chat, progress and saved contact lists (`saved_query_ids`, split into parts for large results). `ProspectingBrief.checkpoints="ask"` pauses the run at a pilot, a drifting search, a shortfall or the target for you to answer; `customer_domains` groups your customers into segments and finds lookalikes of each, picked on approval with `seed_segments`. `rename()` retitles a run in any status with `ProspectingRunUpdate(title=...)` and returns its `list()` summary; result lists it already saved keep their names. `cancel()` stops a run and keeps it; `delete()` cancels an active run, then removes it from `list()` and `get()` (charges already incurred stay). The CLI adds `prospecting start/status/list/approve/message/cancel/wait`, asks at checkpoints on a terminal, and exits 7 with the question on stderr under `--no-input`. +- SDK: `client.prospecting.update_plan(run_id, ProspectingPlanSettings(plan_version=..., ...))` (sync and async) changes a proposed plan's contact engine, company-check engine, search provider (`"none"` skips web research) and `max_spend_usd` (a USD spending limit on records and per-request fees at the plan's rates; `0` removes it) before approval, then returns the run with the re-estimated plan at a new `plan_version`. Prospecting `stop_reason` gains `candidate_limit` (candidate cap reached) and `credit_limit` (stopped at the spending limit, results kept); `candidates_exhausted` now means only that the search ran dry. `stop_reason` stays an open string. Runs without a chosen contact engine research on DiscoLike Groove, or find indexed contacts only with no search provider. +- SDK: `client.prospecting.answer_intake(run_id, answers, idempotency_key=..., summary=None)` (sync and async) answers the guided-intake card a run asks before it drafts a plan. `answers` maps a question key (`company_activity`, `industry`, `geography`, `company_size`, `persona_roles`, `list_size`) to `IntakeAnswer(values=[...], other="...")`; the card's fields are on the `kind="question"` message's `data`. `ProspectingMessageRequest.text` is now optional, so a message can carry `intake` alone; a message with neither `text` nor any `intake` answer is rejected locally, as the API rejects it. +- SDK: `ProspectingInFlight` gains `progress` (0-100 or `None`) for the running item. +- SDK/CLI: prospecting run shape and limits. `ProspectingPlanSettings` gains `target_companies`, `contacts_per_company`, `max_provider_spend_usd` (USD on your own AI and search provider keys; `0` removes it), `deliverable` (`"leads"`, or `"accounts"` for the checked companies only) and `goal` (`"leads"`, or `"companies"` to work only `target_companies` matching companies), all optional. `ProspectingBrief` takes `deliverable` and `goal` too, sent only when set, and the CLI's `prospecting start` gains `--deliverable` and `--goal`. `update_plan()` rewrites the latest plan message in place under the same `seq`, so read the plan from the run it returns. `ProspectingRun` gains `pipeline_phase` (`"companies"`, `"people"` or `None`) and `provider_cost_usd`, and its `brief` reports `deliverable` and `goal`. `CHECKPOINT_STOP_REASONS` gains `top_up`: a run asks before another round of companies (replies `continue`, `raise`, `shrink`, `finish`), and an auto run asks too when that round would pass a spending limit. `stop_reason` gains `provider_limit` and `companies_worked`. A saved contact list now carries every fit company as a row with its fit columns (`ICP Fit`, `Confidence`, `Reasoning`, `Segment`, `Customer segment`); a company with no contact has `contacts: []`, and an accounts run saves company rows only. Contacts per company go up to 10 and default to 3 when the brief gives no count, and intake answers accept the `deliverable` key. +- SDK: `ProspectingRunUpdate` takes `checkpoints` (`"ask"` or `"auto"`), and `title` is now optional, so `client.prospecting.rename(run_id, ProspectingRunUpdate(checkpoints="auto"))` switches a run between Manual and Auto mid-run. Switching to Auto while the run waits on a checkpoint answers it with continue and resumes. A mode switch needs the same plan, quota and payment method as `start()`, and raises on a finished run or while another of your runs is active (409). A title-only rename behaves as before. +- SDK/CLI: `CHECKPOINT_STOP_REASONS` gains `contacts_review`, the pause a wave run makes in both modes before looking up its researched contacts' emails (replies "Find emails", "Find more companies first", "Finish"). `ProspectingMessageRequest` takes an optional `question_seq` and the CLI's `prospecting message` gains `--question-seq`, so a suggested reply answers only the question it was picked from; omitted, the body is unchanged. "Find emails" counts only with `question_seq`, and the CLI's interactive `wait` sends it when you pick a reply by number (its `--no-input` envelope carries `question_seq` too). +- SDK: a wave run's plan message `data` may carry `email_lookup_note`: approving the plan also starts email lookups for contacts already in DiscoLike's database while web research runs, so the `contacts_review` question reports what those found and sizes only the remaining lookups. Message `data` is a plain dict, so no SDK change is needed to read it. +- SDK: `ProspectingRun` gains `provider_ai_cost_usd` and `provider_search_cost_usd`, the AI and search providers' parts of `provider_cost_usd` (`None` for runs started before the split was recorded). With DiscoLike Groove, research adds no AI cost; its web searches bill your own search provider, about two queries per researched company at that provider's per-query price, never DiscoLike credits. The plan message's `data["estimate"]` likewise gains `ai_cost_usd` and `search_cost_usd` beside the total `llm_cost_usd`; it is a plain dict, so no SDK change is needed to read them. + ## 0.4.1 (2026-09-23) - CLI: fix `ImportError: cannot import name 'Abort' from 'typer._click.exceptions'` on every command in a fresh 0.4.0 install. Typer 0.27 moved `Abort`; the CLI now imports the public `typer.Abort` and requires `typer>=0.26.1,<0.28`, since it still relies on Typer's vendored Click for its error envelope and help formatting. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index fba3e74..eae594e 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -24,7 +24,7 @@ uv run pytest packages/discolike/tests -q uv run pytest packages/discolike-cli/tests -q ``` -CI runs the same checks across Python 3.10–3.14. +CI runs the same checks across Python 3.11–3.14. ## Branches @@ -42,6 +42,30 @@ models in `discolike.requests` track dev, not prod. The prod spec lags, so both `check_contract.py` and `gen_requests.py --check` against prod stay red until the platform deploys; don't regenerate against prod to "fix" it. +## Compatibility + +Released SDK versions stay installed long after a new one ships, and they +all talk to the same live API. Keep them working: + +- **Response models parse anything a newer server can send.** Models allow + extra fields (`extra="allow"`). Response enums are open + (`Literal[...] | str`), never a closed `Literal`. Response fields carry no + request-side limits (`max_length`, `ge`/`le`). Don't reuse a request model + as a response field (see `ProspectingRunBrief`). A new response field gets + a default, so the SDK still parses servers from before it existed. +- **The SDK may be looser than the spec, never stricter.** The contract check + flags only drift that breaks parsing: the SDK requiring a field the spec + makes optional, or the spec allowing a type the SDK rejects. +- **Public signatures don't change silently.** Never rename or remove a + public method, kwarg, exception, or exported name in a patch release. When + one has to go, keep the old spelling working with a `DeprecationWarning` + that names the replacement for at least one minor release, and record the + removal under **Breaking** in `CHANGELOG.md`. +- **New request fields are optional** and omitted from the wire when unset, so + a newer SDK still works against an API that hasn't deployed them yet. +- **Python support follows upstream EOL.** Drop a version once it reaches + end of life, as a **Breaking** CHANGELOG line. + ## Reporting bugs Open a GitHub issue with the package name, version, and a minimal diff --git a/README.md b/README.md index 37ff589..f179a66 100644 --- a/README.md +++ b/README.md @@ -63,7 +63,7 @@ Or run the CLI without installing: uvx --from discolike-cli discolike --help ``` -Requires Python 3.10+. +Requires Python 3.11+. ## Authentication @@ -274,6 +274,7 @@ Top-level commands: `discover`, `count`, `match`, `extract`, `validate-icp`, `ap | 4 | Rate limited | | 5 | Network error | | 6 | Not found | +| 7 | Needs input: `prospecting wait` reached a checkpoint with no terminal to ask (or `--no-input`) | ## What's in the box @@ -421,3 +422,59 @@ Committed request models track the dev spec (`--spec-url https://api.dev.discoli ## License [MIT](LICENSE) + + +### Managed prospecting + +REST starts in `drafting`, then waits at `proposed` for approval. Review the plan and approve its exact version before research starts. + +```python +from discolike.requests import ( + ProspectingApproveRequest, ProspectingBrief, ProspectingGetParams, + ProspectingListParams, ProspectingMessageRequest, ProspectingPlanSettings, ProspectingRunUpdate, +) + +run = client.prospecting.start( + ProspectingBrief(brief="Find 100 US logistics companies and 3 operations directors each"), + idempotency_key="logistics-search-2026-09-26", +) +run = client.prospecting.wait(run.run_id) +print(run.status, run.plan, run.messages) # Review before approving. +# After reviewing a proposed plan: +# client.prospecting.approve(run.run_id, ProspectingApproveRequest(plan_version=run.plan_version)) +# run = client.prospecting.wait(run.run_id) + +recent = client.prospecting.list(ProspectingListParams(limit=20)) +message = client.prospecting.message( + run.run_id, ProspectingMessageRequest(text="Make it 250 companies"), + idempotency_key="logistics-target-edit-1", +) +page = client.prospecting.get( + run.run_id, + ProspectingGetParams(offset=0, limit=100, events_after=run.next_event_seq, messages_after=run.next_message_seq), +) +# Pick engines or a credit limit from the plan card's options before approving; approve the returned plan_version. +# run = client.prospecting.update_plan(run.run_id, ProspectingPlanSettings(plan_version=run.plan_version, max_spend_usd=25)) +# client.prospecting.rename(run.run_id, ProspectingRunUpdate(title="Logistics ops leaders")) # Any status. +# client.prospecting.rename(run.run_id, ProspectingRunUpdate(checkpoints="auto")) # Stop pausing; answers an open checkpoint. +# client.prospecting.cancel(run.run_id) # Stop the run; it and its results stay readable. +# client.prospecting.delete(run.run_id) # Cancel if active, then remove it from list() and get(). +``` + +The async client exposes the same methods with `await`. Starts and messages require separate idempotency keys; reuse each key when retrying that operation. Approving an already approved version is safe. A stale plan version is rejected: fetch the current plan and review it again. + +`wait()` returns on `proposed`, `needs_input`, `completed`, `failed`, or `cancelled`. Inspect `status`, `stop_reason`, and `error`; completion does not guarantee the target was reached. A local timeout stops polling only. Partial results remain available. Use `get()` with event and message cursors to receive the agent's reply after sending a message; `reply_pending` indicates a pending reply. After a "segment these" request on a finished run it stays true past the acknowledgement until the segments message is posted, which can take more than an hour, and clears on its own after about 90 minutes if grouping stops without an outcome. A `needs_input` question can be answered with `message()`. + +Checkpoints: `ProspectingBrief(checkpoints=...)` picks how a run handles its decision points. `"auto"`, the API default, never pauses. A run of 500+ target companies from a brief (not a domain list) checks its first companies before looking up contacts; under 80% fit, auto sharpens the criteria once and continues the run with a notice. Only if the re-pilot fit is still under 20% does it stop with `stop_reason="pilot_failed"` and `pilot_sample` holding the checked companies (`domain`, `name`, `company_fit`, `reason`); start a new run with a sharper brief in that case. If sharpening itself fails, the run continues on the original criteria. A search drifting off target is dropped, a run whose search runs dry finishes as `candidates_exhausted`, and a met target finishes the run. `"ask"` pauses with `status="needs_input"` and a `stop_reason` in `CHECKPOINT_STOP_REASONS` (`pilot`, `tail_quality`, `short`, `target_reached`, `top_up`, `contacts_review`). `contacts_review` pauses a wave run in both modes after it has checked a wave of companies and found and qualified their contacts, before looking up the researched contacts' emails. Contacts already in DiscoLike's database have their emails looked up while research runs, since approving the plan covers them (the plan message's `data.email_lookup_note` says so), and early emails that reach the target end the run with no review. The question gives the wave's counts, what the early lookups found, and the estimate for the remaining emails, and its replies are "Find emails", "Find more companies first" (absent when no wave has room) and "Finish" (keeps the companies and contacts, no emails). "Find emails" is consent to spend on lookups, so it counts only when sent with `question_seq` set to the review question's `seq`; without it the run asks again. Switching to auto leaves it open, and MCP runs answer Find emails themselves. The latest `kind="question"` message carries `data.suggested_replies` and, at a pilot, `data.sample`; answer with `message()` using a suggested reply's exact text (or free-text steering), then `wait()` again. Pass `question_seq=` (the question message's `seq`) on `ProspectingMessageRequest` with a suggested reply so it answers that question only; if a newer question was posted since, it answers none and the agent says the question changed. Choosing to finish at a checkpoint ends the run with `stop_reason="user_finished"`. `ProspectingApproveRequest(checkpoints=...)` overrides the brief's mode at approval; `None` keeps it. `checkpoints` on the brief is not in the published OpenAPI schema; the SDK sends it anyway. + +Top-up rounds: when the companies checked so far yield too few contacts for the target, the run sizes another round of companies. `"ask"` pauses on it with `stop_reason="top_up"`; `"auto"` runs it with a notice unless it would pass the run's DiscoLike credit or AI provider spending limit, in which case it pauses too. The question's suggested replies map to `continue` (run another round), `raise` (raise the limit and run it), `shrink` (run the smaller round the limits leave room for) and `finish`; only the ones that apply are offered. + +Intake: a run can pause on a `kind="question"` message whose `data` lists a few option fields (keys `company_activity`, `industry`, `geography`, `company_size`, `persona_roles`, `list_size`). Answer it with `client.prospecting.answer_intake(run.run_id, {"company_activity": IntakeAnswer(values=["sell"]), "geography": IntakeAnswer(other="Ohio")}, idempotency_key=...)`: `values` are the option values you picked and `other` is free text. `IntakeAnswer` comes from `discolike.requests`. Invalid answers return 422; an answer sent after the card was superseded returns 409. Typing free text with `message()` instead still works. + +Initial planning extracts company counts and contacts per company from the brief. Omitted settings keep that inference available, falling back to 1,000 companies and 3 contacts per company. Explicit settings, including explicit defaults, override the text. Targets support 1–10,000 companies and 1–10 contacts per company. Candidate and action caps default to automatic (`0`); explicit maxima are 100,000 candidates and 10,000 actions. Result pages support up to 500 rows; recent-run lists support up to 50. Approved runs expose a stable `saved_query_id` for saved results. + +Run controls: `update_plan(run_id, ProspectingPlanSettings(plan_version=..., ...))` changes a proposed plan before approval. `contact_integration_id`, `validation_integration_id` and `search_provider_id` take an option id from the plan message's `contact_engine`, `company_check_engine` and `search_provider` (`data.contact_engine.options` and so on); omitted fields keep their choice, `search_provider_id="none"` skips web research, and `max_spend_usd` sets the most the run may spend on DiscoLike records and per-request fees, in USD at your plan's rates (`0` removes the limit; a plan with no per-record price is a 422). `max_provider_spend_usd` caps what the run may spend on your own AI and search provider keys, in USD (`0` removes it); once reached the run starts no new work and batches already running finish, so the total can end slightly above it. `target_companies` and `contacts_per_company` resize the plan, and its open limits are re-derived around them. `deliverable` is `"leads"` (find people at the companies) or `"accounts"` (return the checked companies only); `goal` is `"leads"` (keep adding rounds until the contact target is met) or `"companies"` (take `target_companies` matching companies and work only those). The same `deliverable` and `goal` are optional on `ProspectingBrief` at start, both defaulting to `"leads"` server-side, and the run's `brief` reports them. The plan is re-estimated at a new `plan_version`, and that is the version to approve; the latest plan message is rewritten in place under the same `seq`, so read it from the returned run's `messages` rather than past a `messages_after` cursor. An id outside the plan's options is a 422; a run that is not awaiting approval, a stale `plan_version`, or a run that cannot switch to `"accounts"` is a 409. With no engine chosen, contact research runs on DiscoLike Groove when you have a search provider and finds indexed contacts only when you have none. New `stop_reason` values, all plain strings: `candidate_limit` (the run reached its candidate cap; raise `max_candidates` or ask for fewer companies), `credit_limit` (the run stopped at its spending limit and kept its results), `provider_limit` (the run reached its AI provider spending limit), `companies_worked` (a `goal="companies"` run worked through the companies it was asked to take), and `candidates_exhausted` now means only that the search ran dry. A run reports `provider_cost_usd`, what it has spent so far on your AI and search provider keys as the providers report it (`None` when it recorded none; a custom AI endpoint reports no price), and `pipeline_phase`, `"companies"` while a phased run checks companies and `"people"` while it finds people at them (`None` for runs that interleave both). + +Saved results: each saved contact list (`saved_query_ids`) holds one row per fit company, carrying its `ICP Fit`, `Confidence`, `Reasoning`, `Segment` and `Customer segment` columns where known, with the people found there under `contacts`. A fit company with no contact found is still saved, with `contacts: []`, and an `"accounts"` run saves company rows only. An automatic candidate cap may grow once from the run's measured yield; a `max_candidates` you set never does. Interrupted indexed contact searches are rerun rather than skipped. + +Existing processing charges and configured BYOK/BYOS integrations apply. Wizard interpretation, segmentation, and prompt preparation use platform credentials. Agent coordination and independent contact qualification use your contact LLM integration; native contacts use your validation LLM integration or organization default, so this workflow requires a customer LLM even with native extraction. Missing keys and provider errors never fall back to platform keys. Limits bound work, not provider dollar spend. Email finder outcomes are exposed; raw email verification is not a public API. diff --git a/packages/discolike-cli/README.md b/packages/discolike-cli/README.md index 82f4262..44aa9ec 100644 --- a/packages/discolike-cli/README.md +++ b/packages/discolike-cli/README.md @@ -14,7 +14,7 @@ Or run it without installing: uvx --from discolike-cli discolike --help ``` -Requires Python 3.10+. Installing this package gives you the `discolike` command. +Requires Python 3.11+. Installing this package gives you the `discolike` command. ## Authentication @@ -35,7 +35,7 @@ discolike company data stripe.com discolike extract https://stripe.com/enterprise ``` -Top-level commands: `discover`, `count`, `match`, `extract`, `validate-icp`, `append`, `segment` — plus `auth`, `bulk`, `company`, `contacts`, `discogen`, `queries`, `account`, `search-providers`, and `llm-providers` command groups. +Top-level commands: `discover`, `count`, `match`, `extract`, `validate-icp`, `append`, `segment` — plus `auth`, `bulk`, `company`, `contacts`, `discogen`, `prospecting`, `queries`, `account`, `search-providers`, and `llm-providers` command groups. ### Volume pulls @@ -49,6 +49,26 @@ discolike bulk contacts --domains-file companies.csv --per-company 10 --summary `companies` saves each page as an exclusion list (`-round-N`) and excludes it from the next page; rerunning with the same `--out` resumes from the CSV. `contacts` slices the domain list at `10000 / per-company` domains per call and records finished slices in `.checkpoint`. Both keep one call in flight under `--rate-limit` (default 10/min, the Pro rate on `/discover` and `/contacts`), retry on 429/5xx, and print a JSON summary at the end. Filters come from `--params-file`, `--param` and the common flags; the paging fields are managed for you. +### Managed prospecting + +```bash +discolike prospecting start --brief "Find 100 US logistics companies and 3 operations directors each" --idempotency-key logistics-1 +discolike prospecting start --brief "Find lookalikes of our customers and their CTOs" --customers-file customers.csv --idempotency-key seeded-1 +discolike prospecting wait RUN_ID +# Review the proposed plan, then approve the exact version you saw: +discolike prospecting approve RUN_ID --plan-version 1 +discolike prospecting list --limit 20 +discolike prospecting message RUN_ID --text "Make it 250 companies" --idempotency-key logistics-edit-1 +discolike prospecting status RUN_ID --events-after 12 --messages-after 8 --limit 100 +discolike prospecting cancel RUN_ID +``` + +`wait` returns on `proposed`, `needs_input`, `completed`, `failed`, or `cancelled`. A timeout stops polling only. Inspect status and stop reason; completion does not guarantee full coverage. Message replies arrive through `status --messages-after`; follow `next_message_seq` and `reply_pending`. A "segment these" request keeps `reply_pending` true until the segments message is posted, which can take more than an hour. If grouping stops without an outcome, the flag clears on its own after about 90 minutes. + +`start` and `approve` default to pausing at checkpoints, like the web chat: a pilot check on large lists (`pilot`), a search drifting off target (`tail_quality`), candidates running out short of the target (`short`), the target being met (`target_reached`), another round of companies when the ones checked yield too few contacts (`top_up`), and, on wave runs, the researched contacts found before their emails are looked up (`contacts_review`, asked with `--auto` too; contacts already in DiscoLike's database get their lookups during research, which approving the plan starts; picking "Find emails" by number sends the question's `seq`, which the API needs to accept it). Pass `--auto` to never pause (except at a `top_up` round that would pass a spending limit); a poor pilot is then sharpened once and the run continues with a notice, stopping with `pilot_failed` only if the re-pilot fit is still under 20%. If sharpening itself fails, the run continues on the original criteria. On a terminal, `wait` shows the question, any sample companies and numbered replies at a checkpoint, sends your pick or your own text, and keeps waiting. Without a terminal, or with `--no-input`, it prints the run on stdout, a `needs_input` envelope (`message`, `stop_reason`, `suggested_replies`, `sample`) on stderr, and exits 7; answer with `prospecting message --text ""` (add `--question-seq` with the question's `seq` to answer only that question) and run `wait` again. + +Omit `--target-companies` and `--contacts-per-company` to infer counts from the brief (fallback 1,000 and 3; up to 10 contacts per company). Explicit values override the text. `--deliverable accounts` returns the checked companies only, with no people; `--goal companies` takes `--target-companies` matching companies and works only those instead of adding rounds until the contact target is met. Both default to `leads`. `--max-candidates` and `--max-actions` are automatic when omitted or `0`; their maxima are 100,000 and 10,000. Targets allow up to 10,000 companies, status pages up to 500 rows, and lists up to 50 runs. Work caps do not cap provider charges. + ### Conventions - Results print as JSON to stdout; errors print as JSON (`error`, `message`, `status_code`) to stderr. @@ -67,6 +87,7 @@ discolike bulk contacts --domains-file companies.csv --per-company 10 --summary | 4 | Rate limited | | 5 | Network error | | 6 | Not found | +| 7 | Needs input: `prospecting wait` reached a checkpoint with no terminal to ask (or `--no-input`) | ## Links diff --git a/packages/discolike-cli/pyproject.toml b/packages/discolike-cli/pyproject.toml index 5202784..9d06da3 100644 --- a/packages/discolike-cli/pyproject.toml +++ b/packages/discolike-cli/pyproject.toml @@ -4,15 +4,15 @@ build-backend = "hatchling.build" [project] name = "discolike-cli" -version = "0.4.1" +version = "0.5.0" description = "Official CLI for the DiscoLike API" readme = "README.md" license = "MIT" license-files = ["LICENSE"] -requires-python = ">=3.10" +requires-python = ">=3.11" authors = [{ name = "DiscoLike", email = "support@discolike.com" }] dependencies = [ - "discolike==0.4.1", + "discolike==0.5.0", "typer>=0.26.1,<0.28", "rich>=13.0", ] @@ -21,7 +21,6 @@ classifiers = [ "Development Status :: 4 - Beta", "Intended Audience :: Developers", "Programming Language :: Python :: 3", - "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", "Programming Language :: Python :: 3.13", diff --git a/packages/discolike-cli/src/discolike_cli/_help.py b/packages/discolike-cli/src/discolike_cli/_help.py index 7a9913e..ab894b7 100644 --- a/packages/discolike-cli/src/discolike_cli/_help.py +++ b/packages/discolike-cli/src/discolike_cli/_help.py @@ -74,6 +74,9 @@ def format_help(self, ctx: Context, formatter: HelpFormatter) -> None: 4 rate_limited HTTP 429; wait "retry_after" seconds, then retry 5 network_error could not reach the API 6 not_found HTTP 404 + 7 needs_input `prospecting wait` stopped at a checkpoint with no terminal to ask + (or --no-input): the run is on stdout, the question and + "suggested_replies" in the stderr envelope Environment: {ENV_API_KEY} API key; overrides the config file written by `discolike auth login`. diff --git a/packages/discolike-cli/src/discolike_cli/_output.py b/packages/discolike-cli/src/discolike_cli/_output.py index 0b3a7be..0bf6a94 100644 --- a/packages/discolike-cli/src/discolike_cli/_output.py +++ b/packages/discolike-cli/src/discolike_cli/_output.py @@ -43,6 +43,7 @@ NotFoundError: 6, } DEFAULT_EXIT_CODE = 1 +NEEDS_INPUT_EXIT_CODE = 7 # Stable, snake_case error codes for agents and scripts to branch on. The class # name in `error` is kept for backwards compatibility; `code` is the contract. @@ -82,7 +83,7 @@ class SupportsWait(Protocol): task_id: str task_family: str - def wait(self, *, timeout: float, on_poll: Callable[[_JobStatusLike], None] | None = None) -> _JobStatusLike: ... + def wait(self, *, max_wait: float, on_poll: Callable[[_JobStatusLike], None] | None = None) -> _JobStatusLike: ... def _normalize(data: Any) -> Any: # noqa: ANN401 -- accepts arbitrary JSON-serializable CLI output data @@ -203,5 +204,5 @@ def run_job(job: SupportsWait, *, wait: bool, timeout: float, fmt: str | None = def _on_poll(status: _JobStatusLike) -> None: sys.stderr.write(f"progress: {status.progress}%\n") - final = job.wait(timeout=timeout, on_poll=_on_poll) + final = job.wait(max_wait=timeout, on_poll=_on_poll) emit(final.results if final.results is not None else final.to_dict(), fmt=fmt) diff --git a/packages/discolike-cli/src/discolike_cli/auth.py b/packages/discolike-cli/src/discolike_cli/auth.py index ccd86ba..d9b353e 100644 --- a/packages/discolike-cli/src/discolike_cli/auth.py +++ b/packages/discolike-cli/src/discolike_cli/auth.py @@ -4,8 +4,8 @@ import secrets import sys import webbrowser +from datetime import UTC from datetime import datetime -from datetime import timezone from typing import Any from typing import NoReturn from urllib.parse import urlparse @@ -88,7 +88,7 @@ def _offer_signup(ctx: typer.Context) -> None: def _iso(epoch_seconds: float) -> str: - return datetime.fromtimestamp(epoch_seconds, tz=timezone.utc).isoformat() + return datetime.fromtimestamp(epoch_seconds, tz=UTC).isoformat() def _global_key_source(ctx: typer.Context) -> str: diff --git a/packages/discolike-cli/src/discolike_cli/bulk.py b/packages/discolike-cli/src/discolike_cli/bulk.py index 7e622ee..43b136d 100644 --- a/packages/discolike-cli/src/discolike_cli/bulk.py +++ b/packages/discolike-cli/src/discolike_cli/bulk.py @@ -382,8 +382,8 @@ def _persona_ids_in(path: pathlib.Path) -> set[str]: ICP_PROMPT_HELP = "Natural-language ICP prompt used to derive contact filters." -SUMMARY_HELP = "Filter by profile summary text (semantic search); ranks who comes back per company." -NEGATE_SUMMARY_HELP = "Exclude contacts matching this summary description." +SUMMARY_HELP = "Match profile summary text: every word of any one term, in any order. Quote a multi-word term to keep it together, prefix + to require it. Ranks who comes back per company." +NEGATE_SUMMARY_HELP = "Exclude contacts whose profile summary contains every word of any one term, in any order." HAS_EMAIL_HELP = "Only contacts with an email address (on by default)." diff --git a/packages/discolike-cli/src/discolike_cli/contacts.py b/packages/discolike-cli/src/discolike_cli/contacts.py index be81c1e..c4281c0 100644 --- a/packages/discolike-cli/src/discolike_cli/contacts.py +++ b/packages/discolike-cli/src/discolike_cli/contacts.py @@ -30,8 +30,8 @@ TIMEOUT_HELP = "Max seconds to wait with --wait." PARAM_HELP = "Extra key=value query parameter forwarded to the SDK (repeatable)." JOBSTART_DATE_HELP = "Job start date filter: min date or 'min,max' range, e.g. 2025-01-01 or 2025-01-01,2025-06-30." -SUMMARY_HELP = "Filter by profile summary text (semantic search)." -NEGATE_SUMMARY_HELP = "Exclude contacts matching this summary description." +SUMMARY_HELP = "Match profile summary text: every word of any one term, in any order. Quote a multi-word term to keep it together, prefix + to require it." +NEGATE_SUMMARY_HELP = "Exclude contacts whose profile summary contains every word of any one term, in any order." NAME_HELP = "Filter by contact name (partial match supported)." SKILLS_HELP = "Filter by skill (repeatable)." FILTER_STATE_HELP = "Filter by company state/region (repeatable)." diff --git a/packages/discolike-cli/src/discolike_cli/discogen.py b/packages/discolike-cli/src/discolike_cli/discogen.py index b1ff0f6..9632cc6 100644 --- a/packages/discolike-cli/src/discolike_cli/discogen.py +++ b/packages/discolike-cli/src/discolike_cli/discogen.py @@ -44,7 +44,7 @@ app = typer.Typer(help="Run DiscoGen research jobs and check status of or cancel any async task (see --family)") -class TaskFamily(str, enum.Enum): +class TaskFamily(enum.StrEnum): discogen = "discogen" bulkmatch = "bulkmatch" contactmatch = "contactmatch" diff --git a/packages/discolike-cli/src/discolike_cli/email.py b/packages/discolike-cli/src/discolike_cli/email.py index c591f7f..651d813 100644 --- a/packages/discolike-cli/src/discolike_cli/email.py +++ b/packages/discolike-cli/src/discolike_cli/email.py @@ -71,7 +71,7 @@ def _fetch_batch_snapshot(batch: EmailBatch) -> EmailBatchResults: """Fetch the batch results once, whether or not the batch has finished.""" snapshot: dict[str, EmailBatchResults] = {} try: - return batch.results(timeout=0, on_poll=lambda results: snapshot.update(latest=results)) + return batch.results(max_wait=0, on_poll=lambda results: snapshot.update(latest=results)) except JobTimeoutError: return snapshot["latest"] @@ -101,7 +101,7 @@ def find_command( if not wait: emit({"job_id": job.job_id, "hint": f"poll with: discolike email job {job.job_id}"}) return - emit(job.wait(timeout=timeout, on_poll=_job_status_to_stderr), fmt=fmt) + emit(job.wait(max_wait=timeout, on_poll=_job_status_to_stderr), fmt=fmt) @app.command("find-batch") @@ -136,7 +136,7 @@ def find_batch_command( if not wait: emit({"batch_id": batch.batch_id, "hint": f"fetch with: discolike email results {batch.batch_id}"}) return - emit(batch.results(timeout=timeout, on_poll=_batch_progress_to_stderr), fmt=fmt) + emit(batch.results(max_wait=timeout, on_poll=_batch_progress_to_stderr), fmt=fmt) @app.command("results") @@ -156,7 +156,7 @@ def results_command( raise typer.BadParameter(f"--kind must be one of: {', '.join(EMAIL_KINDS)}") batch = get_client(ctx).email.batch(batch_id, kind=kind) # type: ignore[arg-type] if wait: - emit(batch.results(timeout=timeout, on_poll=_batch_progress_to_stderr), fmt=fmt) + emit(batch.results(max_wait=timeout, on_poll=_batch_progress_to_stderr), fmt=fmt) return emit(_fetch_batch_snapshot(batch), fmt=fmt) @@ -175,6 +175,6 @@ def job_command( job = get_client(ctx).email.job(job_id) if wait: - emit(job.wait(timeout=timeout, on_poll=_job_status_to_stderr), fmt=fmt) + emit(job.wait(max_wait=timeout, on_poll=_job_status_to_stderr), fmt=fmt) return emit(job.status(), fmt=fmt) diff --git a/packages/discolike-cli/src/discolike_cli/main.py b/packages/discolike-cli/src/discolike_cli/main.py index 6270907..1c1634c 100644 --- a/packages/discolike-cli/src/discolike_cli/main.py +++ b/packages/discolike-cli/src/discolike_cli/main.py @@ -23,6 +23,7 @@ from discolike_cli import email from discolike_cli import enrich from discolike_cli import match +from discolike_cli import prospecting from discolike_cli import providers from discolike_cli import queries from discolike_cli import signup @@ -85,6 +86,7 @@ def get_client(ctx: typer.Context) -> Discolike: app.add_typer(discogen.app, name="discogen") app.add_typer(email.app, name="email") app.add_typer(queries.app, name="queries") +app.add_typer(prospecting.app, name="prospecting") app.add_typer(account.app, name="account") app.add_typer(providers.search_providers_app, name="search-providers") app.add_typer(providers.llm_providers_app, name="llm-providers") diff --git a/packages/discolike-cli/src/discolike_cli/prospecting.py b/packages/discolike-cli/src/discolike_cli/prospecting.py new file mode 100644 index 0000000..334e9ad --- /dev/null +++ b/packages/discolike-cli/src/discolike_cli/prospecting.py @@ -0,0 +1,363 @@ +from __future__ import annotations + +import json +import pathlib +import sys +import time +from typing import Any +from typing import Literal +from typing import NamedTuple +from uuid import uuid4 + +import typer + +from discolike import CHECKPOINT_STOP_REASONS +from discolike import Discolike +from discolike import JobTimeoutError +from discolike.requests import ProspectingApproveRequest +from discolike.requests import ProspectingBrief +from discolike.requests import ProspectingGetParams +from discolike.requests import ProspectingListParams +from discolike.requests import ProspectingMessageRequest +from discolike.resources.prospecting import ProspectingMessage +from discolike.resources.prospecting import ProspectingRun +from discolike_cli._inputs import read_domains_file +from discolike_cli._output import NEEDS_INPUT_EXIT_CODE +from discolike_cli._output import build_request +from discolike_cli._output import emit +from discolike_cli._output import handle_errors +from discolike_cli.discover import _merge_params + +app = typer.Typer(help="Run managed prospecting; processing and provider charges apply.") + +AUTO_HELP = "Never pause to ask: a poor pilot is sharpened once and the run continues; it only stops if the re-pilot fit is still under 20%. A top_up round that would pass a spending limit still asks. Default: pause at checkpoints." +DELIVERABLE_HELP = "leads (default) finds people at the companies; accounts returns the checked companies only." +GOAL_HELP = ( + "leads (default) keeps adding rounds until the target is met; companies works only --target-companies " + "matching companies." +) +CUSTOMERS_FILE_HELP = ( + "Your customers (CSV with a 'domain' column, or one per line): grouped into segments, lookalikes of each are " + "found. Not with --domain or --company-name." +) +SEED_SEGMENT_HELP = ( + "A customer segment id from the plan card to expand (repeatable); omitted keeps the card's selection." +) +RESULTS_SEGMENT_HELP = "Group the finished results into segments; omitted keeps the brief's setting." +QUESTION_SEQ_HELP = ( + "The seq of the question this suggested reply answers; if a newer question was posted since, it answers none." +) +NO_INPUT_HELP = f"Never prompt: at a checkpoint, print the question and exit {NEEDS_INPUT_EXIT_CODE}." +NEEDS_INPUT_CODE = "needs_input" +WAIT_HELP = ( + "Return the first page on proposed, needs_input, completed, failed, or cancelled. Approve proposed plans; " + "timeout stops polling only.\n\n" + "At a checkpoint (stop_reason pilot, tail_quality, short, target_reached, top_up or contacts_review) a terminal " + "shows the question, " + "any sample companies and numbered replies, sends your pick or your own text, and keeps waiting. Without a " + "terminal, or with --no-input, it prints the run on stdout and a needs_input envelope with the question, " + f"suggested_replies and question_seq on stderr, then exits {NEEDS_INPUT_EXIT_CODE}; answer with `prospecting message --text " + "` and wait again." +) +TIMEOUT_MESSAGE = "Timed out waiting for prospecting; the run continues on the server" + + +class Pause(NamedTuple): + question: str | None + suggested_replies: list[str] + sample: list[dict[str, Any]] + question_seq: int | None + + +class Answer(NamedTuple): + text: str + picked: bool + + +def _checkpoints(*, auto: bool) -> Literal["ask", "auto"]: + return "auto" if auto else "ask" + + +def _is_interactive() -> bool: + return sys.stdin.isatty() + + +def _at_checkpoint(run: ProspectingRun) -> bool: + return run.status == "needs_input" and run.stop_reason in CHECKPOINT_STOP_REASONS + + +def _latest_question(client: Discolike, run: ProspectingRun) -> ProspectingMessage | None: + """The run's newest question, paging past the first page of messages when there are more.""" + messages = list(run.messages) + cursor = run.next_message_seq + while page := client.prospecting.get(run.run_id, ProspectingGetParams(limit=1, messages_after=cursor)).messages: + messages.extend(page) + cursor = page[-1].seq + return next((message for message in reversed(messages) if message.kind == "question"), None) + + +def _pause(run: ProspectingRun, question: ProspectingMessage | None) -> Pause: + data = (question.data if question else None) or {} + return Pause( + question=question.content if question else run.error, + suggested_replies=list(data.get("suggested_replies", [])), + sample=list(data.get("sample", [])), + question_seq=question.seq if question else None, + ) + + +def _report_pause(run: ProspectingRun, pause: Pause) -> typer.Exit: + emit(run) + envelope = { + "error": "NeedsInput", + "code": NEEDS_INPUT_CODE, + "message": pause.question, + "status_code": None, + "exit_code": NEEDS_INPUT_EXIT_CODE, + "run_id": str(run.run_id), + "stop_reason": run.stop_reason, + "suggested_replies": pause.suggested_replies, + "sample": pause.sample, + "question_seq": pause.question_seq, + } + print(json.dumps(envelope, default=str), file=sys.stderr) + return typer.Exit(code=NEEDS_INPUT_EXIT_CODE) + + +def _ask(pause: Pause) -> Answer: + if pause.question: + typer.echo(pause.question, err=True) + for company in pause.sample: + reason = company.get("reason") + typer.echo(f" {company.get('domain')}: {reason}" if reason else f" {company.get('domain')}", err=True) + replies = pause.suggested_replies + for number, reply in enumerate(replies, start=1): + typer.echo(f" {number}. {reply}", err=True) + while True: + answer = typer.prompt("Pick a number or type your answer", err=True).strip() + if answer.isdigit() and 1 <= int(answer) <= len(replies): + return Answer(text=replies[int(answer) - 1], picked=True) + if answer and not answer.isdigit(): + return Answer(text=answer, picked=False) + + +def _reply_request(pause: Pause, *, answer: Answer) -> ProspectingMessageRequest: + """A picked suggested reply names its question, which is how a contacts review takes consent to find emails.""" + if answer.picked and pause.question_seq is not None: + return ProspectingMessageRequest(text=answer.text, question_seq=pause.question_seq) + return ProspectingMessageRequest(text=answer.text) + + +def _remaining(deadline: float) -> float: + remaining = deadline - time.monotonic() + if remaining <= 0: + raise JobTimeoutError(TIMEOUT_MESSAGE) + return remaining + + +def _await_reply(client: Discolike, *, run_id: str, after: int, deadline: float, poll_interval: float) -> None: + """Poll until the agent has answered the message at seq `after`, then print its reply.""" + while True: + run = client.prospecting.get(run_id, ProspectingGetParams(limit=1, messages_after=after)) + if not run.reply_pending: + for message in run.messages: + if message.role == "agent": + typer.echo(message.content, err=True) + return + time.sleep(min(poll_interval, _remaining(deadline))) + + +def _wait_answering( + client: Discolike, *, run_id: str, timeout: float, poll_interval: float, no_input: bool +) -> ProspectingRun: + deadline = time.monotonic() + timeout + while True: + run = client.prospecting.wait(run_id, max_wait=_remaining(deadline), poll_interval=poll_interval) + if not _at_checkpoint(run): + return run + pause = _pause(run, _latest_question(client, run)) + if no_input or not _is_interactive(): + raise _report_pause(run, pause) + sent = client.prospecting.message( + run_id, _reply_request(pause, answer=_ask(pause)), idempotency_key=f"cli-checkpoint-{uuid4()}" + ) + _await_reply(client, run_id=run_id, after=sent.seq, deadline=deadline, poll_interval=poll_interval) + + +@app.command("start") +@handle_errors +def start_command( + ctx: typer.Context, + brief: str = typer.Option(..., "--brief", help="Company criteria and buyer roles."), + idempotency_key: str = typer.Option(..., "--idempotency-key", help="Reuse this key when retrying this submission."), + domain: list[str] | None = typer.Option(None, "--domain", help="Starting domain (repeatable)."), + company_name: list[str] | None = typer.Option(None, "--company-name", help="Company to match (repeatable)."), + customers_file: pathlib.Path | None = typer.Option(None, "--customers-file", help=CUSTOMERS_FILE_HELP), + exclude_domain: list[str] | None = typer.Option(None, "--exclude-domain", help="Suppressed domain (repeatable)."), + target_companies: int | None = typer.Option( + None, + "--target-companies", + min=1, + max=10000, + help="Override the company count in the brief; otherwise inferred, default 1000.", + ), + contacts_per_company: int | None = typer.Option( + None, + "--contacts-per-company", + min=1, + max=10, + help="Override contacts per company; otherwise inferred, default 3.", + ), + max_candidates: int | None = typer.Option( + None, "--max-candidates", min=0, max=100000, help="Candidate work cap; omitted or 0 means automatic." + ), + max_actions: int | None = typer.Option( + None, "--max-actions", min=0, max=10000, help="Action work cap; omitted or 0 means automatic." + ), + validation_integration_id: str | None = typer.Option(None, "--validation-integration-id"), + contact_integration_id: str | None = typer.Option(None, "--contact-integration-id"), + search_provider_id: str | None = typer.Option(None, "--search-provider-id"), + segment: bool = typer.Option(False, "--segment/--no-segment"), + auto: bool = typer.Option(False, "--auto", help=AUTO_HELP), + deliverable: str | None = typer.Option(None, "--deliverable", help=DELIVERABLE_HELP), + goal: str | None = typer.Option(None, "--goal", help=GOAL_HELP), +) -> None: + """Draft a plan. Wait for proposed, review it, then approve its plan version.""" + from discolike_cli.main import get_client + + request = build_request( + ProspectingBrief, + _merge_params( + None, + brief=brief, + domains=domain, + company_names=company_name, + customer_domains=read_domains_file(customers_file) if customers_file is not None else None, + exclude_domains=exclude_domain, + target_companies=target_companies, + contacts_per_company=contacts_per_company, + max_candidates=max_candidates, + max_actions=max_actions, + validation_integration_id=validation_integration_id, + contact_integration_id=contact_integration_id, + search_provider_id=search_provider_id, + segment=segment, + checkpoints=_checkpoints(auto=auto), + deliverable=deliverable, + goal=goal, + ), + ) + emit(get_client(ctx).prospecting.start(request, idempotency_key=idempotency_key)) + + +@app.command("status") +@handle_errors +def status_command( + ctx: typer.Context, + run_id: str = typer.Argument(...), + offset: int = typer.Option(0, "--offset", min=0), + limit: int = typer.Option(100, "--limit", min=1, max=500), + events_after: int = typer.Option(0, "--events-after", min=0), + messages_after: int = typer.Option(0, "--messages-after", min=0), +) -> None: + """Retrieve status and one page of partial results.""" + from discolike_cli.main import get_client + + params = build_request( + ProspectingGetParams, + {"offset": offset, "limit": limit, "events_after": events_after, "messages_after": messages_after}, + ) + emit(get_client(ctx).prospecting.get(run_id, params)) + + +@app.command("cancel") +@handle_errors +def cancel_command(ctx: typer.Context, run_id: str = typer.Argument(...)) -> None: + """Stop new work; retain partial results and incurred charges.""" + from discolike_cli.main import get_client + + emit(get_client(ctx).prospecting.cancel(run_id)) + + +@app.command("wait", help=WAIT_HELP) +@handle_errors +def wait_command( + ctx: typer.Context, + run_id: str = typer.Argument(...), + timeout: float = typer.Option(3600, "--timeout", min=0.01), + poll_interval: float = typer.Option(5, "--poll-interval", min=5), + no_input: bool = typer.Option(False, "--no-input", help=NO_INPUT_HELP), +) -> None: + from discolike_cli.main import get_client + + emit( + _wait_answering(get_client(ctx), run_id=run_id, timeout=timeout, poll_interval=poll_interval, no_input=no_input) + ) + + +@app.command("list") +@handle_errors +def list_command( + ctx: typer.Context, + limit: int = typer.Option(20, "--limit", min=1, max=50), + before: str | None = typer.Option(None, "--before", help="Page past this run ID (last run ID from a prior page)."), +) -> None: + """List recent organization runs, newest first.""" + from discolike_cli.main import get_client + + emit( + get_client(ctx).prospecting.list( + build_request(ProspectingListParams, _merge_params(None, limit=limit, before=before)) + ) + ) + + +@app.command("approve") +@handle_errors +def approve_command( + ctx: typer.Context, + run_id: str = typer.Argument(...), + plan_version: int = typer.Option(..., "--plan-version", min=1), + auto: bool = typer.Option(False, "--auto", help=AUTO_HELP), + seed_segment: list[int] | None = typer.Option(None, "--seed-segment", help=SEED_SEGMENT_HELP), + segment: bool | None = typer.Option(None, "--segment/--no-segment", help=RESULTS_SEGMENT_HELP), +) -> None: + """Approve the reviewed plan version and start research.""" + from discolike_cli.main import get_client + + emit( + get_client(ctx).prospecting.approve( + run_id, + build_request( + ProspectingApproveRequest, + _merge_params( + None, + plan_version=plan_version, + checkpoints=_checkpoints(auto=auto), + seed_segments=seed_segment, + segment=segment, + ), + ), + ) + ) + + +@app.command("message") +@handle_errors +def message_command( + ctx: typer.Context, + run_id: str = typer.Argument(...), + text: str = typer.Option(..., "--text"), + idempotency_key: str = typer.Option(..., "--idempotency-key", help="Reuse when retrying this message."), + question_seq: int | None = typer.Option(None, "--question-seq", min=1, help=QUESTION_SEQ_HELP), +) -> None: + """Send steering or a clarification answer; poll status --messages-after for the reply.""" + from discolike_cli.main import get_client + + emit( + get_client(ctx).prospecting.message( + run_id, + build_request(ProspectingMessageRequest, _merge_params(None, text=text, question_seq=question_seq)), + idempotency_key=idempotency_key, + ) + ) diff --git a/packages/discolike-cli/src/discolike_cli/queries.py b/packages/discolike-cli/src/discolike_cli/queries.py index bd35625..8c569fe 100644 --- a/packages/discolike-cli/src/discolike_cli/queries.py +++ b/packages/discolike-cli/src/discolike_cli/queries.py @@ -2,7 +2,7 @@ import csv import json -from enum import Enum +from enum import StrEnum from pathlib import Path import typer @@ -25,7 +25,7 @@ app = typer.Typer(help="Manage saved queries and exclusion lists for reusable targeting.") -class SaveResultsAction(str, Enum): +class SaveResultsAction(StrEnum): discover = "discover" segment = "segment" contacts = "contacts" diff --git a/packages/discolike-cli/tests/test_output.py b/packages/discolike-cli/tests/test_output.py index fe6950f..0cbd44a 100644 --- a/packages/discolike-cli/tests/test_output.py +++ b/packages/discolike-cli/tests/test_output.py @@ -235,8 +235,8 @@ def __init__(self, *, task_id: str, task_family: str, final: _FakeJobStatus) -> self._final = final self.wait_calls: list[dict[str, object]] = [] - def wait(self, *, timeout: float, on_poll: Callable[[_FakeJobStatus], None] | None = None) -> _FakeJobStatus: - self.wait_calls.append({"timeout": timeout}) + def wait(self, *, max_wait: float, on_poll: Callable[[_FakeJobStatus], None] | None = None) -> _FakeJobStatus: + self.wait_calls.append({"max_wait": max_wait}) if on_poll is not None: on_poll(_FakeJobStatus(progress=50, results=None)) return self._final @@ -260,7 +260,7 @@ def test_run_job_with_wait_emits_final_results_and_progress(capsys: pytest.Captu captured = capsys.readouterr() assert json.loads(captured.out) == [{"domain": "acme.com"}] assert "progress: 50%" in captured.err - assert job.wait_calls == [{"timeout": 30.0}] + assert job.wait_calls == [{"max_wait": 30.0}] def test_run_job_with_wait_emits_full_status_when_no_results(capsys: pytest.CaptureFixture[str]) -> None: diff --git a/packages/discolike-cli/tests/test_prospecting_cli.py b/packages/discolike-cli/tests/test_prospecting_cli.py new file mode 100644 index 0000000..5012f77 --- /dev/null +++ b/packages/discolike-cli/tests/test_prospecting_cli.py @@ -0,0 +1,457 @@ +from __future__ import annotations + +import json +from collections.abc import Callable +from pathlib import Path + +import httpx2 +import pytest +from typer.testing import CliRunner + +import discolike_cli.prospecting as prospecting_cli +from discolike_cli._output import NEEDS_INPUT_EXIT_CODE +from discolike_cli.main import app +from discolike_testkit import Handler +from discolike_testkit.prospecting import message_payload +from discolike_testkit.prospecting import run_payload +from discolike_testkit.prospecting import summary_payload + +runner = CliRunner() +RUN_ID = "00000000-0000-0000-0000-000000000001" + + +def test_start_forwards_key_and_brief(install_build_client: Callable[[Handler], None]) -> None: + seen = [] + + def handler(request: httpx2.Request) -> httpx2.Response: + seen.append(request) + return httpx2.Response(202, json=run_payload("queued")) + + install_build_client(handler) + result = runner.invoke( + app, + [ + "prospecting", + "start", + "--brief", + "US logistics companies and operations directors", + "--idempotency-key", + "pilot-key", + "--exclude-domain", + "excluded.com", + "--target-companies", + "10", + ], + ) + assert result.exit_code == 0, result.output + assert seen[0].headers["Idempotency-Key"] == "pilot-key" + assert json.loads(seen[0].content)["exclude_domains"] == ["excluded.com"] + assert json.loads(result.stdout)["run_id"] == RUN_ID + + +def test_start_forwards_deliverable_and_goal(install_build_client: Callable[[Handler], None]) -> None: + seen = [] + + def handler(request: httpx2.Request) -> httpx2.Response: + seen.append(json.loads(request.content)) + return httpx2.Response(202, json=run_payload("queued")) + + install_build_client(handler) + result = runner.invoke( + app, + [ + "prospecting", + "start", + "--brief", + "US logistics companies, accounts only", + "--idempotency-key", + "accounts", + "--deliverable", + "accounts", + "--goal", + "companies", + ], + ) + assert result.exit_code == 0, result.output + assert (seen[0]["deliverable"], seen[0]["goal"]) == ("accounts", "companies") + + +def test_status_paginates(install_build_client: Callable[[Handler], None]) -> None: + def handler(request: httpx2.Request) -> httpx2.Response: + assert request.url.params["offset"] == "100" + assert request.url.params["limit"] == "20" + return httpx2.Response(200, json=run_payload("needs_input")) + + install_build_client(handler) + result = runner.invoke(app, ["prospecting", "status", RUN_ID, "--offset", "100", "--limit", "20"]) + assert result.exit_code == 0, result.output + assert json.loads(result.stdout)["status"] == "needs_input" + + +def test_status_lists_every_saved_query_part(install_build_client: Callable[[Handler], None]) -> None: + other_query_id = "00000000-0000-0000-0000-000000000002" + + def handler(request: httpx2.Request) -> httpx2.Response: + return httpx2.Response( + 200, json=run_payload("completed") | {"saved_query_id": RUN_ID, "saved_query_ids": [RUN_ID, other_query_id]} + ) + + install_build_client(handler) + result = runner.invoke(app, ["prospecting", "status", RUN_ID]) + assert result.exit_code == 0, result.output + assert json.loads(result.stdout)["saved_query_ids"] == [RUN_ID, other_query_id] + + +def test_cancel_and_wait_preserve_terminal_outcomes(install_build_client: Callable[[Handler], None]) -> None: + methods = [] + + def handler(request: httpx2.Request) -> httpx2.Response: + methods.append((request.method, request.url.path)) + return httpx2.Response(200, json=run_payload("cancelled")) + + install_build_client(handler) + for command in ("cancel", "wait"): + result = runner.invoke(app, ["prospecting", command, RUN_ID]) + assert result.exit_code == 0, result.output + assert json.loads(result.stdout)["status"] == "cancelled" + assert methods == [("POST", f"/v1/prospecting/runs/{RUN_ID}/cancel"), ("GET", f"/v1/prospecting/runs/{RUN_ID}")] + + +def test_start_omits_unspecified_quantities_and_caps(install_build_client: Callable[[Handler], None]) -> None: + seen = [] + + def handler(request: httpx2.Request) -> httpx2.Response: + seen.append(json.loads(request.content)) + return httpx2.Response(202, json=run_payload("queued")) + + install_build_client(handler) + result = runner.invoke( + app, + [ + "prospecting", + "start", + "--brief", + "Find 1000 software companies and three founders each", + "--idempotency-key", + "counts", + ], + ) + assert result.exit_code == 0, result.output + for key in ("target_companies", "contacts_per_company", "max_candidates", "max_actions"): + assert key not in seen[0] + + +def test_chat_commands_send_exact_payloads(install_build_client: Callable[[Handler], None]) -> None: + seen = [] + + def handler(request: httpx2.Request) -> httpx2.Response: + seen.append(request) + if request.url.path.endswith("/messages"): + return httpx2.Response(202, json=message_payload()) + if request.url.path.endswith("/runs"): + return httpx2.Response(200, json=[summary_payload()]) + return httpx2.Response(200, json=run_payload("proposed")) + + install_build_client(handler) + commands = [ + ["list", "--before", RUN_ID], + ["approve", RUN_ID, "--plan-version", "2"], + ["message", RUN_ID, "--text", "Make it 100 companies", "--idempotency-key", "cli-message"], + ["status", RUN_ID, "--events-after", "12", "--messages-after", "8", "--limit", "500"], + ["wait", RUN_ID], + ] + for command in commands: + result = runner.invoke(app, ["prospecting", *command]) + assert result.exit_code == 0, result.output + assert dict(seen[0].url.params) == {"limit": "20", "before": RUN_ID} + assert json.loads(seen[1].content) == {"plan_version": 2, "checkpoints": "ask"} + assert json.loads(seen[2].content) == {"text": "Make it 100 companies"} + assert seen[2].headers["Idempotency-Key"] == "cli-message" + assert dict(seen[3].url.params) == {"offset": "0", "limit": "500", "events_after": "12", "messages_after": "8"} + assert len(seen) == 5 + + +def test_message_forwards_question_seq(install_build_client: Callable[[Handler], None]) -> None: + seen = [] + + def handler(request: httpx2.Request) -> httpx2.Response: + seen.append(request) + return httpx2.Response(202, json=message_payload()) + + install_build_client(handler) + command = ["message", RUN_ID, "--text", "Find emails", "--idempotency-key", "cli-review", "--question-seq", "9"] + result = runner.invoke(app, ["prospecting", *command]) + assert result.exit_code == 0, result.output + assert json.loads(seen[0].content) == {"text": "Find emails", "question_seq": 9} + + +@pytest.mark.parametrize( + "arguments", + [ + ["approve", RUN_ID], + ["message", RUN_ID, "--text", "Continue"], + ["message", RUN_ID, "--text", "Continue", "--idempotency-key", "k", "--question-seq", "0"], + ["list", "--limit", "51"], + ["list", "--before", "not-a-uuid"], + ["status", RUN_ID, "--messages-after", "-1"], + ], +) +def test_chat_commands_validate_before_network( + install_build_client: Callable[[Handler], None], arguments: list[str] +) -> None: + def handler(request: httpx2.Request) -> httpx2.Response: + pytest.fail("invalid command must not reach the network") + + install_build_client(handler) + assert runner.invoke(app, ["prospecting", *arguments]).exit_code == 2 + + +@pytest.mark.parametrize(("target", "contacts", "candidates", "actions"), [(25, 2, 0, 0), (10000, 5, 100000, 10000)]) +def test_explicit_defaults_and_larger_caps_are_preserved( + install_build_client: Callable[[Handler], None], target: int, contacts: int, candidates: int, actions: int +) -> None: + seen = [] + + def handler(request: httpx2.Request) -> httpx2.Response: + seen.append(json.loads(request.content)) + return httpx2.Response(202, json=run_payload()) + + install_build_client(handler) + result = runner.invoke( + app, + [ + "prospecting", + "start", + "--brief", + "Find software companies and founders", + "--idempotency-key", + "explicit", + "--target-companies", + str(target), + "--contacts-per-company", + str(contacts), + "--max-candidates", + str(candidates), + "--max-actions", + str(actions), + ], + ) + assert result.exit_code == 0, result.output + assert { + key: seen[0][key] for key in ("target_companies", "contacts_per_company", "max_candidates", "max_actions") + } == { + "target_companies": target, + "contacts_per_company": contacts, + "max_candidates": candidates, + "max_actions": actions, + } + + +@pytest.mark.parametrize(("flags", "mode"), [([], "ask"), (["--auto"], "auto")]) +def test_start_and_approve_ask_at_checkpoints_unless_auto( + install_build_client: Callable[[Handler], None], flags: list[str], mode: str +) -> None: + bodies = [] + + def handler(request: httpx2.Request) -> httpx2.Response: + bodies.append(json.loads(request.content)) + return httpx2.Response(202, json=run_payload("queued")) + + install_build_client(handler) + start = ["prospecting", "start", "--brief", "US logistics companies", "--idempotency-key", "mode", *flags] + for command in (start, ["prospecting", "approve", RUN_ID, "--plan-version", "1", *flags]): + result = runner.invoke(app, command) + assert result.exit_code == 0, result.output + assert [body["checkpoints"] for body in bodies] == [mode, mode] + + +PILOT_REPLIES = ["Run the full list", "Stop here"] +PILOT_SAMPLE = [{"domain": "fits.com", "name": "Fits", "company_fit": "Yes", "reason": "Runs a trucking fleet"}] +PILOT_QUESTION = "I checked the first 20 companies: 18 fit your criteria (90%). Here are some of them." + + +def _question_message(seq: int) -> dict: + return message_payload() | { + "seq": seq, + "role": "agent", + "kind": "question", + "content": PILOT_QUESTION, + "data": {"reason": "pilot", "suggested_replies": PILOT_REPLIES, "sample": PILOT_SAMPLE}, + } + + +def _emitted(stdout: str) -> dict: + """CliRunner echoes typed input to stdout, which a real terminal does not; the JSON follows it.""" + return json.loads(stdout[stdout.index("{") :]) + + +def _checkpoint_handler(seen: list[httpx2.Request], *, answer_seq: int = 20) -> Handler: + """A run paused at the pilot whose question is past the first message page; any answer resumes it.""" + + def handler(request: httpx2.Request) -> httpx2.Response: + seen.append(request) + answered = any(sent.method == "POST" for sent in seen) + after = request.url.params.get("messages_after") + if request.method == "POST": + return httpx2.Response(202, json=message_payload() | {"seq": answer_seq}) + if after == str(answer_seq): + ack = message_payload() | {"seq": answer_seq + 1, "role": "agent", "kind": "ack", "content": "On it."} + return httpx2.Response(200, json=run_payload("running") | {"messages": [ack]}) + if after == "7": + return httpx2.Response(200, json=run_payload("needs_input") | {"messages": [_question_message(8)]}) + if after is not None: + return httpx2.Response(200, json=run_payload("needs_input")) + if answered: + return httpx2.Response(200, json=run_payload("completed")) + paused = {"stop_reason": "pilot", "error": PILOT_QUESTION, "next_message_seq": 7} + return httpx2.Response(200, json=run_payload("needs_input") | paused) + + return handler + + +@pytest.mark.parametrize( + ("typed", "posted"), + [ + ("1", {"text": "Run the full list", "question_seq": 8}), + ("Only fleets over 50 trucks", {"text": "Only fleets over 50 trucks"}), + ], +) +def test_wait_asks_at_a_checkpoint_and_keeps_waiting( + install_build_client: Callable[[Handler], None], + monkeypatch: pytest.MonkeyPatch, + typed: str, + posted: dict, +) -> None: + seen: list[httpx2.Request] = [] + install_build_client(_checkpoint_handler(seen)) + monkeypatch.setattr(prospecting_cli, "_is_interactive", lambda: True) + + result = runner.invoke(app, ["prospecting", "wait", RUN_ID], input=f"{typed}\n") + + assert result.exit_code == 0, result.output + assert _emitted(result.stdout)["status"] == "completed" + (message,) = [request for request in seen if request.method == "POST"] + assert json.loads(message.content) == posted + assert message.headers["Idempotency-Key"].startswith("cli-checkpoint-") + for shown in (PILOT_QUESTION, "fits.com: Runs a trucking fleet", "1. Run the full list", "2. Stop here", "On it."): + assert shown in result.stderr + + +def test_wait_reprompts_for_a_number_out_of_range( + install_build_client: Callable[[Handler], None], monkeypatch: pytest.MonkeyPatch +) -> None: + seen: list[httpx2.Request] = [] + install_build_client(_checkpoint_handler(seen)) + monkeypatch.setattr(prospecting_cli, "_is_interactive", lambda: True) + + result = runner.invoke(app, ["prospecting", "wait", RUN_ID], input="3\n2\n") + + assert result.exit_code == 0, result.output + (message,) = [request for request in seen if request.method == "POST"] + assert json.loads(message.content) == {"text": "Stop here", "question_seq": 8} + + +@pytest.mark.parametrize(("interactive", "flags"), [(False, []), (True, ["--no-input"])]) +def test_wait_without_a_terminal_reports_the_checkpoint_and_exits_needs_input( + install_build_client: Callable[[Handler], None], + monkeypatch: pytest.MonkeyPatch, + interactive: bool, + flags: list[str], +) -> None: + seen: list[httpx2.Request] = [] + install_build_client(_checkpoint_handler(seen)) + monkeypatch.setattr(prospecting_cli, "_is_interactive", lambda: interactive) + + result = runner.invoke(app, ["prospecting", "wait", RUN_ID, *flags]) + + assert result.exit_code == NEEDS_INPUT_EXIT_CODE + assert json.loads(result.stdout)["stop_reason"] == "pilot" + envelope = json.loads(result.stderr.splitlines()[-1]) + assert envelope == { + "error": "NeedsInput", + "code": "needs_input", + "message": PILOT_QUESTION, + "status_code": None, + "exit_code": NEEDS_INPUT_EXIT_CODE, + "run_id": RUN_ID, + "stop_reason": "pilot", + "suggested_replies": PILOT_REPLIES, + "sample": PILOT_SAMPLE, + "question_seq": 8, + } + assert all(request.method == "GET" for request in seen) + + +def test_wait_returns_other_needs_input_pauses_unchanged( + install_build_client: Callable[[Handler], None], monkeypatch: pytest.MonkeyPatch +) -> None: + def handler(request: httpx2.Request) -> httpx2.Response: + return httpx2.Response(200, json=run_payload("needs_input") | {"stop_reason": "question"}) + + install_build_client(handler) + monkeypatch.setattr(prospecting_cli, "_is_interactive", lambda: False) + + result = runner.invoke(app, ["prospecting", "wait", RUN_ID]) + + assert result.exit_code == 0, result.output + assert json.loads(result.stdout)["stop_reason"] == "question" + + +def test_start_reads_customers_from_a_file(install_build_client: Callable[[Handler], None], tmp_path: Path) -> None: + customers = tmp_path / "customers.csv" + customers.write_text("domain\nAcme.com\nwww.example.com\nacme.com\n") + bodies = [] + + def handler(request: httpx2.Request) -> httpx2.Response: + bodies.append(json.loads(request.content)) + return httpx2.Response(202, json=run_payload("drafting")) + + install_build_client(handler) + result = runner.invoke( + app, + [ + "prospecting", + "start", + "--brief", + "Lookalikes of our customers and their CTOs", + "--idempotency-key", + "seeded", + "--customers-file", + str(customers), + ], + ) + assert result.exit_code == 0, result.output + assert bodies[0]["customer_domains"] == ["acme.com", "example.com"] + + +def test_approve_sends_the_chosen_segments_and_results_grouping( + install_build_client: Callable[[Handler], None], +) -> None: + bodies = [] + + def handler(request: httpx2.Request) -> httpx2.Response: + bodies.append(json.loads(request.content)) + return httpx2.Response(202, json=run_payload("queued")) + + install_build_client(handler) + chosen = runner.invoke( + app, + [ + "prospecting", + "approve", + RUN_ID, + "--plan-version", + "1", + "--seed-segment", + "1", + "--seed-segment", + "2", + "--segment", + ], + ) + default = runner.invoke(app, ["prospecting", "approve", RUN_ID, "--plan-version", "1"]) + assert chosen.exit_code == default.exit_code == 0, chosen.output + default.output + assert bodies == [ + {"plan_version": 1, "checkpoints": "ask", "seed_segments": [1, 2], "segment": True}, + {"plan_version": 1, "checkpoints": "ask"}, + ] diff --git a/packages/discolike-cli/tests/test_sdk_parity.py b/packages/discolike-cli/tests/test_sdk_parity.py index ba1b1c3..a4a86e5 100644 --- a/packages/discolike-cli/tests/test_sdk_parity.py +++ b/packages/discolike-cli/tests/test_sdk_parity.py @@ -25,6 +25,7 @@ "ContactsCountParams": frozenset({"icp_text"}), "ContactFilters": frozenset({"icp_text"}), "FindEmailBatchRequest": frozenset({"source_query_id", "refs", "round"}), + "ProspectingMessageRequest": frozenset({"intake"}), } # ``discolike bulk`` takes the full vocabulary through --params-file / --param and manages the paging # fields itself; its flags are the handful a volume run needs, not one per SDK field. diff --git a/packages/discolike-testkit/pyproject.toml b/packages/discolike-testkit/pyproject.toml index 4534e5f..cb21394 100644 --- a/packages/discolike-testkit/pyproject.toml +++ b/packages/discolike-testkit/pyproject.toml @@ -7,7 +7,7 @@ name = "discolike-testkit" version = "0.0.0" description = "Shared test helpers for the discolike workspace. Not published." license = "MIT" -requires-python = ">=3.10" +requires-python = ">=3.11" dependencies = [ "discolike", "httpx2>=2.9", diff --git a/packages/discolike-testkit/src/discolike_testkit/prospecting.py b/packages/discolike-testkit/src/discolike_testkit/prospecting.py new file mode 100644 index 0000000..b795d2b --- /dev/null +++ b/packages/discolike-testkit/src/discolike_testkit/prospecting.py @@ -0,0 +1,30 @@ +"""Complete minimal REST prospecting responses shared by SDK and CLI tests.""" + +from typing import Any + +RUN_ID = "00000000-0000-0000-0000-000000000001" +CREATED_AT = "2026-09-26T12:00:00Z" + + +def run_payload(status: str = "drafting") -> dict[str, Any]: + return { + "run_id": RUN_ID, + "status": status, + "max_actions": 30, + "created_at": CREATED_AT, + "updated_at": CREATED_AT, + "brief": {"brief": "US logistics companies and operations leaders"}, + "target_companies": 25, + "contacts_per_company": 2, + "companies": [{"domain": "example.com"}], + } + + +def message_payload() -> dict[str, Any]: + return {"seq": 8, "created_at": CREATED_AT, "role": "user", "kind": "text", "content": "Make it 100 companies"} + + +def summary_payload() -> dict[str, Any]: + return {key: value for key, value in run_payload().items() if key not in {"max_actions", "companies", "brief"}} | { + "brief": "US logistics companies and operations leaders" + } diff --git a/packages/discolike/README.md b/packages/discolike/README.md index 152a58a..9085925 100644 --- a/packages/discolike/README.md +++ b/packages/discolike/README.md @@ -10,7 +10,7 @@ For the terminal, see [`discolike-cli`](https://pypi.org/project/discolike-cli/) pip install discolike ``` -Requires Python 3.10+. +Requires Python 3.11+. ## Authentication @@ -122,6 +122,18 @@ The engine decides the result columns, so read `job.column_name` instead of hard Two errors are specific to the native engine: a 400 `ValidationError` when the ICP text does not yield a Mandatory / Reject if / Nice-to-have prompt, and a 503 `ServerError` when no ICP-fit engine is available. Task lifecycle, polling and statuses are the same either way. +## Managed prospecting + +`client.prospecting.start(ProspectingBrief(...), idempotency_key="...")` drafts a plan. Call `wait(run_id)`, review its `plan` and `messages`, then approve the returned `plan_version` with `approve(run_id, ProspectingApproveRequest(plan_version=...))`. + +`wait()` returns on `proposed`, `needs_input`, `completed`, `failed`, or `cancelled`; timeout stops local polling only. Use `message(run_id, ProspectingMessageRequest(text="..."), idempotency_key="...")` to steer or answer a question, and `get(run_id, ProspectingGetParams(events_after=..., messages_after=...))` for new events and replies. `list(ProspectingListParams(limit=20))` lists recent organization runs, up to 50. `rename(run_id, ProspectingRunUpdate(title="..."))` retitles a run in any status and returns its `list()` summary (titles are 1-80 characters after trimming; result lists it already saved keep their names). `rename(run_id, ProspectingRunUpdate(checkpoints="auto"))` switches a run between Manual (`"ask"`) and Auto (`"auto"`) mid-run; switching to Auto while it waits on a checkpoint answers it with continue. `cancel(run_id)` stops a run and returns it; `delete(run_id)` cancels an active run, then removes it so `list()` omits it and `get()` raises `NotFoundError` (charges already incurred stay). The async client has the same methods with `await`. Import these request models from `discolike.requests`. + +`update_plan(run_id, ProspectingPlanSettings(plan_version=..., ...))` changes a proposed plan's `contact_integration_id`, `validation_integration_id`, `search_provider_id` (`"none"` skips web research) and `max_spend_usd` (a USD spending limit on records and per-request fees at the plan's rates; `0` removes it) using option ids from the plan message's `contact_engine`, `company_check_engine` and `search_provider`, then returns the run with a re-estimated plan at a new `plan_version` to approve. It also takes `max_provider_spend_usd` (USD on your own AI and search provider keys; `0` removes it), `target_companies`, `contacts_per_company`, `deliverable` (`"leads"` or `"accounts"`, the checked companies only) and `goal` (`"leads"`, or `"companies"` to work only `target_companies` matching companies); `deliverable` and `goal` are optional on `ProspectingBrief` too. The latest plan message is rewritten in place under the same `seq`, so read it from the returned run's `messages`. An unknown option id is a 422; a run not awaiting approval, a stale `plan_version` or a run that cannot switch to `"accounts"` is a 409. `stop_reason` also reports `candidate_limit`, `credit_limit`, `provider_limit` (AI provider spending limit reached) and `companies_worked` (a `goal="companies"` run finished its companies); `candidates_exhausted` means only that the search ran dry. Runs report `provider_cost_usd` and `pipeline_phase` (`"companies"`, `"people"` or `None`). + +Checkpoints: `ProspectingBrief(checkpoints=...)` picks how a run handles its decision points. `"auto"`, the API default, never pauses. A run of 500+ target companies from a brief (not a domain list) checks its first companies before looking up contacts; under 80% fit, auto sharpens the criteria once and continues the run with a notice. Only if the re-pilot fit is still under 20% does it stop with `stop_reason="pilot_failed"` and `pilot_sample` holding the checked companies (`domain`, `name`, `company_fit`, `reason`); start a new run with a sharper brief in that case. If sharpening itself fails, the run continues on the original criteria. A search drifting off target is dropped, a run short of candidates finishes as `candidates_exhausted`, and a met target finishes the run. `"ask"` pauses with `status="needs_input"` and a `stop_reason` in `CHECKPOINT_STOP_REASONS` (`pilot`, `tail_quality`, `short`, `target_reached`, `top_up`, `contacts_review`). `contacts_review` pauses a wave run in both modes after it has checked a wave of companies and found and qualified their contacts, before looking up the researched contacts' emails. Contacts already in DiscoLike's database have their emails looked up while research runs, since approving the plan covers them (the plan message's `data.email_lookup_note` says so), and early emails that reach the target end the run with no review. The question gives the wave's counts, what the early lookups found, and the estimate for the remaining emails, and its replies are "Find emails", "Find more companies first" (absent when no wave has room) and "Finish" (keeps the companies and contacts, no emails). "Find emails" is consent to spend on lookups, so it counts only when sent with `question_seq` set to the review question's `seq`; without it the run asks again. Switching to auto leaves it open, and MCP runs answer Find emails themselves. `top_up` asks before another round of companies when the ones checked yield too few contacts; auto runs that round with a notice unless it would pass a spending limit, and then pauses too. Its replies map to `continue`, `raise` (raise the limit), `shrink` (a smaller round) and `finish`. The latest `kind="question"` message carries `data.suggested_replies` and, at a pilot, `data.sample`; answer with `message()` using a suggested reply's exact text (or free-text steering), then `wait()` again. Pass `question_seq=` (the question message's `seq`) on `ProspectingMessageRequest` with a suggested reply so it answers that question only; if a newer question was posted since, it answers none and the agent says the question changed. Choosing to finish at a checkpoint ends the run with `stop_reason="user_finished"`. `ProspectingApproveRequest(checkpoints=...)` overrides the brief's mode at approval; `None` keeps it. `checkpoints` on the brief is not in the published OpenAPI schema; the SDK sends it anyway. + +Omit target counts to infer them from the brief; explicit values override the text. Work caps default to automatic (`0`). Partial results and `saved_query_id` remain available after stopping. Large results are split into several saved contact lists rather than being cut off; `saved_query_ids` carries every list for the run in order, with `saved_query_id` always the first entry, and the parts are final once the run reaches a terminal status. Each list has one row per fit company with its `ICP Fit`, `Confidence`, `Reasoning`, `Segment` and `Customer segment` columns and its people under `contacts`; a company with no contact found has `contacts: []`, and an `"accounts"` run saves company rows only. Customer integration charges apply; work caps do not cap provider dollar spend, `max_provider_spend_usd` does. + ## Links - **API documentation**: [docs.discolike.com](https://docs.discolike.com) diff --git a/packages/discolike/pyproject.toml b/packages/discolike/pyproject.toml index 17b53d8..ec3d9d3 100644 --- a/packages/discolike/pyproject.toml +++ b/packages/discolike/pyproject.toml @@ -9,7 +9,7 @@ description = "Official Python SDK for the DiscoLike API" readme = "README.md" license = "MIT" license-files = ["LICENSE"] -requires-python = ">=3.10" +requires-python = ">=3.11" authors = [{ name = "DiscoLike", email = "support@discolike.com" }] dependencies = [ "authlib>=1.8.0", @@ -22,7 +22,6 @@ classifiers = [ "Development Status :: 4 - Beta", "Intended Audience :: Developers", "Programming Language :: Python :: 3", - "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", "Programming Language :: Python :: 3.13", @@ -34,7 +33,7 @@ classifiers = [ ] [project.optional-dependencies] -cli = ["discolike-cli==0.4.1"] +cli = ["discolike-cli==0.5.0"] [project.urls] Homepage = "https://www.discolike.com" diff --git a/packages/discolike/src/discolike/__init__.py b/packages/discolike/src/discolike/__init__.py index f992306..dfe1eaa 100644 --- a/packages/discolike/src/discolike/__init__.py +++ b/packages/discolike/src/discolike/__init__.py @@ -27,11 +27,13 @@ from discolike.resources.email import EnumerationMatch from discolike.resources.email import EnumerationOutput from discolike.resources.email import ValidationOutput +from discolike.resources.prospecting import CHECKPOINT_STOP_REASONS from discolike.signup import SignupResult from discolike.signup import async_signup from discolike.signup import signup __all__ = [ + "CHECKPOINT_STOP_REASONS", "NATIVE_ENGINE", "NATIVE_ICP_ENGINE", "APIConnectionError", diff --git a/packages/discolike/src/discolike/_client.py b/packages/discolike/src/discolike/_client.py index fc4998d..dfe1a5b 100644 --- a/packages/discolike/src/discolike/_client.py +++ b/packages/discolike/src/discolike/_client.py @@ -40,6 +40,8 @@ from discolike.resources.enrich import EnrichResource from discolike.resources.match import AsyncMatchResource from discolike.resources.match import MatchResource +from discolike.resources.prospecting import AsyncProspectingResource +from discolike.resources.prospecting import ProspectingResource from discolike.resources.providers import AsyncLLMProvidersResource from discolike.resources.providers import AsyncSearchProvidersResource from discolike.resources.providers import LLMProvidersResource @@ -60,6 +62,22 @@ def _build_auth(*, api_key: str | None, auth: Credential | None) -> DiscolikeAut class Discolike: + def _attach(self, transport: Transport) -> None: + self._transport = transport + self.account = AccountResource(self._transport) + self.companies = CompaniesResource(self._transport) + self.contacts = ContactsResource(self._transport) + self.match = MatchResource(self._transport) + self.discogen = DiscogenResource(self._transport) + self.email = EmailResource(self._transport) + self.prospecting = ProspectingResource(self._transport) + self.queries = QueriesResource(self._transport) + self.search_providers = SearchProvidersResource(self._transport) + self.llm_providers = LLMProvidersResource(self._transport) + self._discovery = DiscoveryResource(self._transport) + self._validate = ValidateResource(self._transport) + self._enrich = EnrichResource(self._transport) + def __init__( self, *, @@ -80,21 +98,6 @@ def __init__( ) ) - def _attach(self, transport: Transport) -> None: - self._transport = transport - self.account = AccountResource(self._transport) - self.companies = CompaniesResource(self._transport) - self.contacts = ContactsResource(self._transport) - self.match = MatchResource(self._transport) - self.discogen = DiscogenResource(self._transport) - self.email = EmailResource(self._transport) - self.queries = QueriesResource(self._transport) - self.search_providers = SearchProvidersResource(self._transport) - self.llm_providers = LLMProvidersResource(self._transport) - self._discovery = DiscoveryResource(self._transport) - self._validate = ValidateResource(self._transport) - self._enrich = EnrichResource(self._transport) - def with_options(self, *, timeout: float | httpx2.Timeout) -> Discolike: """A client view with a different request timeout, sharing this client's connection pool.""" clone = object.__new__(Discolike) @@ -130,6 +133,22 @@ def __exit__(self, *exc_info: object) -> None: class AsyncDiscolike: + def _attach(self, transport: AsyncTransport) -> None: + self._transport = transport + self.account = AsyncAccountResource(self._transport) + self.companies = AsyncCompaniesResource(self._transport) + self.contacts = AsyncContactsResource(self._transport) + self.match = AsyncMatchResource(self._transport) + self.discogen = AsyncDiscogenResource(self._transport) + self.email = AsyncEmailResource(self._transport) + self.prospecting = AsyncProspectingResource(self._transport) + self.queries = AsyncQueriesResource(self._transport) + self.search_providers = AsyncSearchProvidersResource(self._transport) + self.llm_providers = AsyncLLMProvidersResource(self._transport) + self._discovery = AsyncDiscoveryResource(self._transport) + self._validate = AsyncValidateResource(self._transport) + self._enrich = AsyncEnrichResource(self._transport) + def __init__( self, *, @@ -150,21 +169,6 @@ def __init__( ) ) - def _attach(self, transport: AsyncTransport) -> None: - self._transport = transport - self.account = AsyncAccountResource(self._transport) - self.companies = AsyncCompaniesResource(self._transport) - self.contacts = AsyncContactsResource(self._transport) - self.match = AsyncMatchResource(self._transport) - self.discogen = AsyncDiscogenResource(self._transport) - self.email = AsyncEmailResource(self._transport) - self.queries = AsyncQueriesResource(self._transport) - self.search_providers = AsyncSearchProvidersResource(self._transport) - self.llm_providers = AsyncLLMProvidersResource(self._transport) - self._discovery = AsyncDiscoveryResource(self._transport) - self._validate = AsyncValidateResource(self._transport) - self._enrich = AsyncEnrichResource(self._transport) - def with_options(self, *, timeout: float | httpx2.Timeout) -> AsyncDiscolike: """A client view with a different request timeout, sharing this client's connection pool.""" clone = object.__new__(AsyncDiscolike) diff --git a/packages/discolike/src/discolike/_email.py b/packages/discolike/src/discolike/_email.py index 27bc013..242373d 100644 --- a/packages/discolike/src/discolike/_email.py +++ b/packages/discolike/src/discolike/_email.py @@ -5,9 +5,9 @@ from collections.abc import Callable from typing import Any from typing import Literal +from typing import assert_never import pydantic -from typing_extensions import assert_never from discolike._exceptions import JobFailedError from discolike._exceptions import JobTimeoutError @@ -137,11 +137,11 @@ def status(self) -> EmailJobResult: def wait( self, *, - timeout: float = DEFAULT_WAIT_TIMEOUT_SECONDS, + max_wait: float = DEFAULT_WAIT_TIMEOUT_SECONDS, poll_interval: float = DEFAULT_POLL_INTERVAL_SECONDS, on_poll: Callable[[EmailJobResult], None] | None = None, ) -> EnumerationOutput | ValidationOutput: - deadline = time.monotonic() + timeout + deadline = time.monotonic() + max_wait while True: current = self.status() if on_poll is not None: @@ -153,7 +153,7 @@ def wait( raise JobFailedError(f"email {self.kind} job completed without a result", payload=current.to_dict()) return current.result if time.monotonic() >= deadline: - raise _timeout_error("Email job", self.job_id, timeout) + raise _timeout_error("Email job", self.job_id, max_wait) time.sleep(poll_interval) @@ -172,11 +172,11 @@ async def status(self) -> EmailJobResult: async def wait( self, *, - timeout: float = DEFAULT_WAIT_TIMEOUT_SECONDS, + max_wait: float = DEFAULT_WAIT_TIMEOUT_SECONDS, poll_interval: float = DEFAULT_POLL_INTERVAL_SECONDS, on_poll: Callable[[EmailJobResult], None] | None = None, ) -> EnumerationOutput | ValidationOutput: - deadline = time.monotonic() + timeout + deadline = time.monotonic() + max_wait while True: current = await self.status() if on_poll is not None: @@ -188,7 +188,7 @@ async def wait( raise JobFailedError(f"email {self.kind} job completed without a result", payload=current.to_dict()) return current.result if time.monotonic() >= deadline: - raise _timeout_error("Email job", self.job_id, timeout) + raise _timeout_error("Email job", self.job_id, max_wait) await asyncio.sleep(poll_interval) @@ -203,11 +203,11 @@ def __init__(self, transport: Transport, *, batch_id: str, kind: EmailKind) -> N def results( self, *, - timeout: float = DEFAULT_WAIT_TIMEOUT_SECONDS, + max_wait: float = DEFAULT_WAIT_TIMEOUT_SECONDS, poll_interval: float = DEFAULT_POLL_INTERVAL_SECONDS, on_poll: Callable[[EmailBatchResults], None] | None = None, ) -> EmailBatchResults: - deadline = time.monotonic() + timeout + deadline = time.monotonic() + max_wait while True: raw = self._transport.request("GET", f"/email/batch/{self.batch_id}/results").json() parsed = _decode_batch_results(self.kind, raw) @@ -216,7 +216,7 @@ def results( if _batch_is_done(raw): return parsed if time.monotonic() >= deadline: - raise _timeout_error("Email batch", self.batch_id, timeout) + raise _timeout_error("Email batch", self.batch_id, max_wait) time.sleep(poll_interval) @@ -231,11 +231,11 @@ def __init__(self, transport: AsyncTransport, *, batch_id: str, kind: EmailKind) async def results( self, *, - timeout: float = DEFAULT_WAIT_TIMEOUT_SECONDS, + max_wait: float = DEFAULT_WAIT_TIMEOUT_SECONDS, poll_interval: float = DEFAULT_POLL_INTERVAL_SECONDS, on_poll: Callable[[EmailBatchResults], None] | None = None, ) -> EmailBatchResults: - deadline = time.monotonic() + timeout + deadline = time.monotonic() + max_wait while True: response = await self._transport.request("GET", f"/email/batch/{self.batch_id}/results") raw = response.json() @@ -245,5 +245,5 @@ async def results( if _batch_is_done(raw): return parsed if time.monotonic() >= deadline: - raise _timeout_error("Email batch", self.batch_id, timeout) + raise _timeout_error("Email batch", self.batch_id, max_wait) await asyncio.sleep(poll_interval) diff --git a/packages/discolike/src/discolike/_generated/requests.py b/packages/discolike/src/discolike/_generated/requests.py index 9f3b048..fb0de5a 100644 --- a/packages/discolike/src/discolike/_generated/requests.py +++ b/packages/discolike/src/discolike/_generated/requests.py @@ -5,6 +5,7 @@ from typing import Annotated from typing import Any from typing import Literal +from uuid import UUID from pydantic import Field @@ -321,14 +322,14 @@ class ContactsSearchParams(DiscolikeRequest): summary: Annotated[ str | None, Field( - description="Filter by profile summary text (semantic search).", + 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.", title="Summary", ), ] = None negate_summary: Annotated[ str | None, Field( - description="Exclude contacts matching this summary description.", + 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.", title="Negate Summary", ), ] = None @@ -750,14 +751,14 @@ class ContactsCountParams(DiscolikeRequest): summary: Annotated[ str | None, Field( - description="Filter by profile summary text (semantic search).", + 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.", title="Summary", ), ] = None negate_summary: Annotated[ str | None, Field( - description="Exclude contacts matching this summary description.", + 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.", title="Negate Summary", ), ] = None @@ -1211,14 +1212,14 @@ class ContactFilters(DiscolikeRequest): summary: Annotated[ str | None, Field( - description="Filter by profile summary text (semantic search).", + 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.", title="Summary", ), ] = None negate_summary: Annotated[ str | None, Field( - description="Exclude contacts matching this summary description.", + 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.", title="Negate Summary", ), ] = None @@ -1460,7 +1461,7 @@ class DiscoGenProcessRequest(DiscolikeRequest): ), ] context_mode: Annotated[Literal["website", "profile", "domain"] | None, Field(title="Context Mode")] = "website" - previous_discogen_data: Annotated[dict[str, Any] | None, Field(title="Previous Discogen Data")] = None + previous_discogen_data: Annotated[dict[str, dict[str, Any]] | None, Field(title="Previous Discogen Data")] = None class DiscoGenPersonaProcessRequest(DiscolikeRequest): @@ -1506,7 +1507,7 @@ class DiscoGenPersonaProcessRequest(DiscolikeRequest): Literal["name_only", "profile", "profile_summary", "company", "full"] | None, Field(title="Context Mode"), ] = "profile" - previous_discogen_data: Annotated[dict[str, Any] | None, Field(title="Previous Discogen Data")] = None + previous_discogen_data: Annotated[dict[str, dict[str, Any]] | None, Field(title="Previous Discogen Data")] = None class ValidateIcpRequest(DiscolikeRequest): @@ -2906,6 +2907,21 @@ class MatchBulkParams(DiscolikeRequest): ] = 50 +class ProspectingListParams(DiscolikeRequest): + limit: Annotated[int | None, Field(ge=1, le=50, title="Limit")] = 20 + before: Annotated[ + UUID | None, + Field(description="Return the runs created before this run.", title="Before"), + ] = None + + +class ProspectingGetParams(DiscolikeRequest): + offset: Annotated[int | None, Field(ge=0, title="Offset")] = 0 + limit: Annotated[int | None, Field(ge=1, le=500, title="Limit")] = 100 + events_after: Annotated[int | None, Field(ge=0, title="Events After")] = 0 + messages_after: Annotated[int | None, Field(ge=0, title="Messages After")] = 0 + + class LLMProviderCreateRequest(DiscolikeRequest): integration_name: Annotated[ str, @@ -3125,6 +3141,11 @@ class BulkContactMatchQueryItem(DiscolikeRequest): ] = None +class IntakeAnswer(DiscolikeRequest): + values: Annotated[list[str] | None, Field(title="Values")] = None + other: Annotated[str | None, Field(max_length=200, title="Other")] = None + + class BulkContactMatchRequest(DiscolikeRequest): queries: Annotated[ list[BulkContactMatchQueryItem], @@ -3146,3 +3167,149 @@ class BulkContactMatchRequest(DiscolikeRequest): int | None, Field(description="Maximum matches per query (1-20).", ge=1, le=20, title="Limit"), ] = 10 + + +class ProspectingApproveRequest(DiscolikeRequest): + plan_version: Annotated[int, Field(ge=1, title="Plan Version")] + checkpoints: Literal["ask", "auto"] | None = None + seed_segments: Annotated[list[int] | None, Field(min_length=1, title="Seed Segments")] = None + segment: Annotated[bool | None, Field(title="Segment")] = None + + +class ProspectingMessageRequest(DiscolikeRequest): + text: Annotated[str | None, Field(max_length=4000, min_length=1, title="Text")] = None + intake: Annotated[ + dict[ + Literal[ + "deliverable", "company_activity", "industry", "geography", "company_size", "persona_roles", "list_size" + ], + IntakeAnswer, + ] + | None, + Field(title="Intake"), + ] = None + question_seq: Annotated[ + int | None, + Field( + description="The seq of the question message whose suggested reply this is; the reply answers that question only, never a newer one.", + ge=1, + title="Question Seq", + ), + ] = None + + +class ProspectingBrief(DiscolikeRequest): + brief: Annotated[str, Field(max_length=4000, min_length=10, title="Brief")] + domains: Annotated[list[str] | None, Field(max_length=1000, title="Domains")] = None + company_names: Annotated[list[str] | None, Field(max_length=100, title="Company Names")] = None + customer_domains: Annotated[ + list[str] | None, + Field( + description="Your customers' domains, grouped into segments; lookalikes of each are found. Cannot be combined with domains or company_names.", + max_length=1000, + title="Customer Domains", + ), + ] = 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 + 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 + contact_integration_id: Annotated[str | None, Field(max_length=128, title="Contact Integration Id")] = None + search_provider_id: Annotated[str | None, Field(max_length=128, title="Search Provider Id")] = None + segment: Annotated[bool | None, Field(title="Segment")] = False + deliverable: Annotated[ + Literal["leads", "accounts"] | None, + Field(description="leads finds people at the companies; accounts returns the checked companies only."), + ] = "leads" + goal: Annotated[ + Literal["leads", "companies"] | None, + Field( + description="leads keeps adding rounds until the target is met; companies takes target_companies matching companies and works only those." + ), + ] = "leads" + checkpoints: Annotated[ + Literal["ask", "auto"] | None, + Field( + description="ask: pause at checkpoints (pilot, tail_quality, short, target_reached, top_up, contacts_review) with status needs_input and a question to answer through message(). auto: never pause; a poor pilot is sharpened once and the run continues with a notice, stopping with stop_reason pilot_failed only if the re-pilot fit is still under 20%. Either mode pauses at top_up when another round would pass a spending limit, and at contacts_review before a wave's email lookups.", + title="Checkpoints", + ), + ] = "auto" + + +class ProspectingPlanSettings(DiscolikeRequest): + plan_version: Annotated[int, Field(ge=1, title="Plan Version")] + contact_integration_id: Annotated[ + str | None, + Field( + description="Contact research engine: an option id from the plan's contact_engine.", + max_length=128, + title="Contact Integration Id", + ), + ] = None + validation_integration_id: Annotated[ + str | None, + Field( + description="Company check engine: an option id from the plan's company_check_engine.", + max_length=128, + title="Validation Integration Id", + ), + ] = None + search_provider_id: Annotated[ + str | None, + Field( + description="Search provider: an option id from the plan's search_provider; 'none' skips web research.", + max_length=128, + title="Search Provider Id", + ), + ] = None + 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.", + ge=0.0, + title="Max Spend Usd", + ), + ] = None + target_companies: Annotated[ + int | None, + Field( + description="How many qualified companies the run aims for; the plan's limits are re-derived around it.", + ge=1, + le=10000, + title="Target Companies", + ), + ] = None + contacts_per_company: Annotated[ + int | None, + Field( + description="Most contacts to find at each qualified company.", + ge=1, + le=10, + title="Contacts Per Company", + ), + ] = None + deliverable: Annotated[ + Literal["leads", "accounts"] | None, + Field(description="leads finds people at the companies; accounts returns the checked companies only."), + ] = None + goal: Annotated[ + Literal["leads", "companies"] | None, + Field( + description="leads keeps adding rounds until the target is met; companies takes target_companies matching companies and works only those." + ), + ] = None + max_provider_spend_usd: Annotated[ + float | None, + Field( + description="Most the run may spend on your own AI and search provider keys, in USD, before it stops starting new work; batches already running finish, so the total can end slightly above it. 0 removes the limit.", + ge=0.0, + title="Max Provider Spend Usd", + ), + ] = None + + +class ProspectingRunUpdate(DiscolikeRequest): + title: Annotated[str | None, Field(max_length=80, min_length=1, title="Title")] = None + checkpoints: Literal["ask", "auto"] | None = None diff --git a/packages/discolike/src/discolike/_jobs.py b/packages/discolike/src/discolike/_jobs.py index b3f9355..71371ed 100644 --- a/packages/discolike/src/discolike/_jobs.py +++ b/packages/discolike/src/discolike/_jobs.py @@ -42,7 +42,7 @@ class JobStatus(DiscolikeModel): # model's built-in search only; on a BYOS run read search_provider instead. estimated_cost: float | None = None cost_metadata: dict[str, dict[str, Any]] | None = None - title_validation: Literal["llm", "none"] | None = None + title_validation: Literal["llm", "none"] | str | None = None class Job: @@ -62,11 +62,11 @@ def cancel(self) -> None: def wait( self, *, - timeout: float = DEFAULT_WAIT_TIMEOUT_SECONDS, + max_wait: float = DEFAULT_WAIT_TIMEOUT_SECONDS, poll_interval: float = DEFAULT_POLL_INTERVAL_SECONDS, on_poll: Callable[[JobStatus], None] | None = None, ) -> JobStatus: - deadline = time.monotonic() + timeout + deadline = time.monotonic() + max_wait while True: current = self.status() if on_poll is not None: @@ -77,7 +77,7 @@ def wait( return current if time.monotonic() >= deadline: raise JobTimeoutError( - f"Task {self.task_id} did not finish within {timeout:.0f}s — it is still running " + f"Task {self.task_id} did not finish within {max_wait:.0f}s — it is still running " f"server-side; call wait() again to resume or check status() later" ) time.sleep(poll_interval) @@ -102,11 +102,11 @@ async def cancel(self) -> None: async def wait( self, *, - timeout: float = DEFAULT_WAIT_TIMEOUT_SECONDS, + max_wait: float = DEFAULT_WAIT_TIMEOUT_SECONDS, poll_interval: float = DEFAULT_POLL_INTERVAL_SECONDS, on_poll: Callable[[JobStatus], None] | None = None, ) -> JobStatus: - deadline = time.monotonic() + timeout + deadline = time.monotonic() + max_wait while True: current = await self.status() if on_poll is not None: @@ -117,7 +117,7 @@ async def wait( return current if time.monotonic() >= deadline: raise JobTimeoutError( - f"Task {self.task_id} did not finish within {timeout:.0f}s — it is still running " + f"Task {self.task_id} did not finish within {max_wait:.0f}s — it is still running " f"server-side; call wait() again to resume or check status() later" ) await asyncio.sleep(poll_interval) diff --git a/packages/discolike/src/discolike/_models.py b/packages/discolike/src/discolike/_models.py index 914f48a..35bf7a4 100644 --- a/packages/discolike/src/discolike/_models.py +++ b/packages/discolike/src/discolike/_models.py @@ -22,6 +22,7 @@ RADIUS_KM_SUFFIX = "km" RADIUS_MILE_SUFFIX = "mi" MAX_GEO_SHAPES = 10 +MESSAGE_CONTENT_FIELDS = frozenset({"text", "intake"}) class DiscolikeModel(pydantic.BaseModel): @@ -66,6 +67,15 @@ def _validate_geo_shapes(self) -> DiscolikeRequest: raise ValueError(f"{total} geo shapes (lat/lon, geo and bbox together); at most {MAX_GEO_SHAPES}") return self + # Mirrors the platform's text_or_intake rule on ProspectingMessageRequest, which the spec cannot express. + @pydantic.model_validator(mode="after") + def _validate_message_content(self) -> DiscolikeRequest: + if not type(self).model_fields.keys() >= MESSAGE_CONTENT_FIELDS: + return self + if getattr(self, "text", None) is None and not getattr(self, "intake", None): + raise ValueError("send text or intake answers") + return self + def to_wire(self) -> dict[str, Any]: return self.model_dump(mode="json", exclude_unset=True, by_alias=True) diff --git a/packages/discolike/src/discolike/_transport.py b/packages/discolike/src/discolike/_transport.py index 0b54f57..4e12af0 100644 --- a/packages/discolike/src/discolike/_transport.py +++ b/packages/discolike/src/discolike/_transport.py @@ -75,6 +75,7 @@ def request( path: str, *, params: Mapping[str, Any] | None = None, + headers: Mapping[str, str] | None = None, json_body: Any = None, # noqa: ANN401 -- forwarded verbatim to httpx2.Client.request files: Any = None, # noqa: ANN401 -- forwarded verbatim to httpx2.Client.request data: Any = None, # noqa: ANN401 -- forwarded verbatim to httpx2.Client.request @@ -86,7 +87,14 @@ def request( for attempt in range(self._max_retries + 1): try: response = self._client.request( - method, path, params=clean_params, json=json_body, files=files, data=data, timeout=timeout + method, + path, + params=clean_params, + json=json_body, + files=files, + data=data, + timeout=timeout, + headers=headers, ) except retryable_exceptions as exc: if attempt == self._max_retries: @@ -140,6 +148,7 @@ async def request( path: str, *, params: Mapping[str, Any] | None = None, + headers: Mapping[str, str] | None = None, json_body: Any = None, # noqa: ANN401 -- forwarded verbatim to httpx2.Client.request files: Any = None, # noqa: ANN401 -- forwarded verbatim to httpx2.Client.request data: Any = None, # noqa: ANN401 -- forwarded verbatim to httpx2.Client.request @@ -151,7 +160,14 @@ async def request( for attempt in range(self._max_retries + 1): try: response = await self._client.request( - method, path, params=clean_params, json=json_body, files=files, data=data, timeout=timeout + method, + path, + params=clean_params, + json=json_body, + files=files, + data=data, + timeout=timeout, + headers=headers, ) except retryable_exceptions as exc: if attempt == self._max_retries: diff --git a/packages/discolike/src/discolike/_version.py b/packages/discolike/src/discolike/_version.py index 3d26edf..3d18726 100644 --- a/packages/discolike/src/discolike/_version.py +++ b/packages/discolike/src/discolike/_version.py @@ -1 +1 @@ -__version__ = "0.4.1" +__version__ = "0.5.0" diff --git a/packages/discolike/src/discolike/requests.py b/packages/discolike/src/discolike/requests.py index 9cdb359..7104691 100644 --- a/packages/discolike/src/discolike/requests.py +++ b/packages/discolike/src/discolike/requests.py @@ -24,10 +24,18 @@ from discolike._generated.requests import DiscoverParams from discolike._generated.requests import FindEmailBatchRequest from discolike._generated.requests import FindEmailRequest +from discolike._generated.requests import IntakeAnswer from discolike._generated.requests import LLMProviderCreateRequest from discolike._generated.requests import LLMProviderUpdateRequest from discolike._generated.requests import MatchBulkParams from discolike._generated.requests import MatchCompanyParams +from discolike._generated.requests import ProspectingApproveRequest +from discolike._generated.requests import ProspectingBrief +from discolike._generated.requests import ProspectingGetParams +from discolike._generated.requests import ProspectingListParams +from discolike._generated.requests import ProspectingMessageRequest +from discolike._generated.requests import ProspectingPlanSettings +from discolike._generated.requests import ProspectingRunUpdate from discolike._generated.requests import QueriesListParams from discolike._generated.requests import SaveResultsRequest from discolike._generated.requests import SearchProviderRequest @@ -61,10 +69,18 @@ "DiscoverParams", "FindEmailBatchRequest", "FindEmailRequest", + "IntakeAnswer", "LLMProviderCreateRequest", "LLMProviderUpdateRequest", "MatchBulkParams", "MatchCompanyParams", + "ProspectingApproveRequest", + "ProspectingBrief", + "ProspectingGetParams", + "ProspectingListParams", + "ProspectingMessageRequest", + "ProspectingPlanSettings", + "ProspectingRunUpdate", "QueriesListParams", "SaveResultsRequest", "SearchProviderRequest", diff --git a/packages/discolike/src/discolike/resources/prospecting.py b/packages/discolike/src/discolike/resources/prospecting.py new file mode 100644 index 0000000..5c34df8 --- /dev/null +++ b/packages/discolike/src/discolike/resources/prospecting.py @@ -0,0 +1,475 @@ +from __future__ import annotations + +import asyncio +import builtins +import math +import time +from datetime import datetime +from typing import Any +from typing import Literal +from uuid import UUID + +from pydantic import Field + +from discolike._exceptions import JobTimeoutError +from discolike._models import DiscolikeModel +from discolike.requests import IntakeAnswer +from discolike.requests import ProspectingApproveRequest +from discolike.requests import ProspectingBrief +from discolike.requests import ProspectingGetParams +from discolike.requests import ProspectingListParams +from discolike.requests import ProspectingMessageRequest +from discolike.requests import ProspectingPlanSettings +from discolike.requests import ProspectingRunUpdate +from discolike.resources._base import AsyncAPIResource +from discolike.resources._base import SyncAPIResource +from discolike.resources._base import api_route + +WAIT_STATUSES = frozenset({"proposed", "completed", "needs_input", "failed", "cancelled"}) +CHECKPOINT_STOP_REASONS = frozenset({"pilot", "tail_quality", "short", "target_reached", "top_up", "contacts_review"}) +# Response enums stay open (`| str`) so a value the platform adds later never fails parsing in released SDKs. +ProspectingStatus = ( + Literal["drafting", "proposed", "queued", "running", "needs_input", "completed", "failed", "cancelled"] | str +) +ProspectingStage = ( + Literal["plan", "discover", "validate", "contacts", "generate", "verify", "segment", "seed_segment"] | str +) + +IntakeKey = Literal[ + "deliverable", "company_activity", "industry", "geography", "company_size", "persona_roles", "list_size" +] +ProspectingDeliverable = Literal["leads", "accounts"] | str +ProspectingGoal = Literal["leads", "companies"] | str + + +class ProspectingPlan(DiscolikeModel): + company_queries: list[dict[str, Any]] = Field(default_factory=list) + contact_filters: dict[str, Any] = Field(default_factory=dict) + company_criteria: str + persona_criteria: str + issues: list[str] = Field(default_factory=list) + + +class ProspectingRunBrief(DiscolikeModel): + brief: str + domains: list[str] | None = None + company_names: list[str] | None = None + customer_domains: list[str] | None = None + exclude_domains: list[str] | None = None + target_companies: int | None = None + contacts_per_company: int | None = None + max_candidates: int | None = None + max_actions: int | None = None + validation_integration_id: str | None = None + contact_integration_id: str | None = None + search_provider_id: str | None = None + segment: bool | None = None + checkpoints: Literal["ask", "auto"] | str | None = None + selected_seed_segments: list[int] | None = None + deliverable: ProspectingDeliverable = Field( + default="leads", description="leads finds people at the companies; accounts returns the checked companies only." + ) + goal: ProspectingGoal = Field( + default="leads", + description="leads keeps adding rounds until the target is met; companies works only target_companies " + "matching companies.", + ) + + +class ProspectingEvent(DiscolikeModel): + seq: int + created_at: datetime + stage: str | None = None + kind: Literal["queued", "decision", "started", "progress", "result", "stopped", "warning"] | str + message: str + data: dict[str, Any] | None = None + + +class ProspectingMessage(DiscolikeModel): + seq: int + created_at: datetime + role: Literal["user", "agent"] | str + kind: Literal["text", "plan", "milestone", "question", "ack", "error"] | str + content: str + data: dict[str, Any] | None = None + + +class ProspectingInFlight(DiscolikeModel): + stage: ProspectingStage + items: int + plan_version: int + state: Literal["dispatching", "running"] | str + started_at: datetime + progress: int | None = None + + +class ProspectingRunSummary(DiscolikeModel): + run_id: UUID + status: ProspectingStatus + title: str | None = None + stop_reason: str | None = None + stage: str | None = None + brief: str + target_companies: int + contacts_per_company: int + qualified_companies: int = 0 + accepted_contacts: int = 0 + created_at: datetime + updated_at: datetime + + +class ProspectingRun(DiscolikeModel): + run_id: UUID + status: ProspectingStatus + stop_reason: str | None = None + stage: str | None = None + phase: str | None = None + created_at: datetime + updated_at: datetime + brief: ProspectingRunBrief + target_companies: int + contacts_per_company: int + plan: ProspectingPlan | None = None + last_decision: dict[str, Any] | None = None + events: list[ProspectingEvent] = Field(default_factory=list) + next_event_seq: int = 0 + waiting_for_worker: bool = False + companies: list[dict[str, Any]] = Field(default_factory=list) + contacts: list[dict[str, Any]] = Field(default_factory=list) + total_companies: int = 0 + total_contacts: int = 0 + qualified_companies: int = 0 + accepted_contacts: int = 0 + offset: int = 0 + limit: int = 100 + actions_used: int = 0 + max_actions: int + error: str | None = None + title: str | None = None + plan_version: int = 1 + approved_plan_version: int | None = None + saved_query_id: UUID | None = None + saved_query_ids: list[UUID] = Field( + default_factory=list, + description="Every saved contact list for this run, in order. Large results are split across several " + "lists; the first is saved_query_id. Parts are final once the run reaches a terminal status. Each row is " + "a fit company with its ICP Fit, Confidence, Reasoning, Segment and Customer segment columns and its " + "contacts; a company with no contact has contacts=[], and an accounts run saves company rows only.", + ) + messages: list[ProspectingMessage] = Field(default_factory=list) + next_message_seq: int = 0 + in_flight: list[ProspectingInFlight] = Field(default_factory=list) + fit_companies: int = 0 + emails_found: int = 0 + pipeline_phase: Literal["companies", "people"] | str | None = Field( + default=None, + description="Which phase a phased run is in: checking companies, or finding people at them. " + "None for runs that interleave both.", + ) + provider_cost_usd: float | None = Field( + default=None, + description="What this run has spent so far on your own AI and search provider keys (USD), as the " + "providers report it; a custom AI endpoint reports no price. None when the run recorded no provider cost.", + ) + provider_ai_cost_usd: float | None = Field( + default=None, + description="The AI provider's part of provider_cost_usd (USD). None when provider_cost_usd is, and for " + "runs that recorded their provider cost only in total.", + ) + provider_search_cost_usd: float | None = Field( + default=None, + description="The search provider's part of provider_cost_usd (USD). None when provider_cost_usd is, and " + "for runs that recorded their provider cost only in total.", + ) + reply_pending: bool = Field( + default=False, + description="The agent still owes a reply to a user message. After a 'segment these' request on a " + "finished run it stays true past the acknowledgement until the segments message is posted, which can " + "take more than an hour, and clears on its own after about 90 minutes if grouping stops without an outcome.", + ) + chat_closed: bool = Field( + default=False, + description="The chat was closed for off-topic use: every new message gets the same fixed reply. " + "An approved run keeps working and its saved lists still fill.", + ) + pilot_sample: list[dict[str, Any]] = Field( + default_factory=list, + description="Checked companies (domain, name, company_fit, reason) when stop_reason is pilot_failed.", + ) + companies_saved_query_id: UUID | None = Field( + default=None, description="The saved list of the run's companies; None until the run has a saved list." + ) + + +def _intake_request(*, answers: dict[IntakeKey, IntakeAnswer], summary: str | None) -> ProspectingMessageRequest: + if summary is None: + return ProspectingMessageRequest(intake=answers) + return ProspectingMessageRequest(text=summary, intake=answers) + + +def _key(value: str) -> str: + if not value.strip() or len(value) > 128: + raise ValueError("idempotency_key must contain 1-128 characters") + if not value.isascii() or not value.isprintable(): + raise ValueError("idempotency_key must be printable ASCII with no control characters") + return value + + +def _path(run_id: str | UUID) -> str: + return f"/prospecting/runs/{UUID(str(run_id))}" + + +def _deadline(max_wait: float, poll_interval: float) -> float: + if not math.isfinite(max_wait) or max_wait <= 0 or not math.isfinite(poll_interval) or poll_interval < 5: + raise ValueError("max_wait must be finite and positive; poll_interval must be finite and at least 5 seconds") + return time.monotonic() + max_wait + + +class ProspectingResource(SyncAPIResource): + @api_route("GET", "/prospecting/runs") + def list(self, params: ProspectingListParams | None = None) -> builtins.list[ProspectingRunSummary]: + """List recent organization runs, newest first; default 20, maximum 50. + + Pass `before` (a run ID) to page past a full page of results. + """ + response = self._transport.request("GET", "/prospecting/runs", params=params.to_wire() if params else None) + return [ProspectingRunSummary.model_validate(row) for row in response.json()] + + @api_route("POST", "/prospecting/runs/{run_id}/approve") + def approve(self, run_id: str | UUID, request: ProspectingApproveRequest) -> ProspectingRun: + """Approve the reviewed plan version; repeating the same approval is safe. + + `checkpoints` on the request overrides the brief's mode; None keeps it. + `seed_segments` picks which customer segments a seeded plan expands (None keeps the card's default); + `segment` turns results grouping on or off. + """ + response = self._transport.request("POST", _path(run_id) + "/approve", json_body=request.to_wire()) + return ProspectingRun.model_validate(response.json()) + + @api_route("POST", "/prospecting/runs/{run_id}/messages") + def message( + self, run_id: str | UUID, request: ProspectingMessageRequest, *, idempotency_key: str + ) -> ProspectingMessage: + """Send a chat message; poll get() with ProspectingGetParams(messages_after=...) for the agent's reply.""" + response = self._transport.request( + "POST", + _path(run_id) + "/messages", + json_body=request.to_wire(), + headers={"Idempotency-Key": _key(idempotency_key)}, + ) + return ProspectingMessage.model_validate(response.json()) + + @api_route("POST", "/prospecting/runs/{run_id}/messages") + def answer_intake( + self, + run_id: str | UUID, + answers: dict[IntakeKey, IntakeAnswer], + *, + idempotency_key: str, + summary: str | None = None, + ) -> ProspectingMessage: + """Answer the intake card on a run; `answers` maps each question key to its picked `values` and/or `other` text.""" + return self.message(run_id, _intake_request(answers=answers, summary=summary), idempotency_key=idempotency_key) + + @api_route("POST", "/prospecting/runs") + def start(self, request: ProspectingBrief, *, idempotency_key: str) -> ProspectingRun: + """Draft a plan for approval; retain the key when retrying this submission.""" + response = self._transport.request( + "POST", "/prospecting/runs", json_body=request.to_wire(), headers={"Idempotency-Key": _key(idempotency_key)} + ) + return ProspectingRun.model_validate(response.json()) + + @api_route("GET", "/prospecting/runs/{run_id}") + def get(self, run_id: str | UUID, params: ProspectingGetParams | None = None) -> ProspectingRun: + response = self._transport.request("GET", _path(run_id), params=params.to_wire() if params else None) + return ProspectingRun.model_validate(response.json()) + + @api_route("POST", "/prospecting/runs/{run_id}/cancel") + def cancel(self, run_id: str | UUID) -> ProspectingRun: + response = self._transport.request("POST", _path(run_id) + "/cancel") + return ProspectingRun.model_validate(response.json()) + + @api_route("PATCH", "/prospecting/runs/{run_id}/plan") + def update_plan(self, run_id: str | UUID, request: ProspectingPlanSettings) -> ProspectingRun: + """Change a proposed plan's engines, size, deliverable and spending limits; returns the re-estimated run. + + Fields left unset keep their current choice; ids come from the latest plan message's + contact_engine, company_check_engine and search_provider options, and search_provider_id="none" + skips web research. max_spend_usd (USD for records and per-call fees at the plan's rates) and + max_provider_spend_usd (USD on your own AI and search provider keys) each take 0 to remove the limit; + engines left unset are re-chosen around the picks. target_companies and contacts_per_company resize + the plan, deliverable="accounts" returns checked companies only, and goal="companies" works only + target_companies matching companies. Pass the current plan_version: the plan is re-estimated at a new + plan_version, which is the one to approve, and the latest plan message is rewritten in place (same seq), + so read it from this response's messages rather than past a messages_after cursor. A 422 means an id is + not one of the plan's options or the plan has no per-record price, a 409 that the run is not awaiting + approval, plan_version is stale, or the run cannot switch to accounts. + """ + response = self._transport.request("PATCH", _path(run_id) + "/plan", json_body=request.to_wire()) + return ProspectingRun.model_validate(response.json()) + + @api_route("PATCH", "/prospecting/runs/{run_id}") + def rename(self, run_id: str | UUID, request: ProspectingRunUpdate) -> ProspectingRunSummary: + """Rename the run or switch its checkpoint mode; returns its summary as list() shows it. + + Send title, checkpoints, or both. The server strips the title, which must then be 1-80 characters; + a title alone works in any status. A title set before the agent plans is kept. Result lists the run + already saved keep their names. checkpoints="ask" pauses at later checkpoints, "auto" never pauses, + and switching to "auto" while the run waits on a checkpoint answers it with continue and resumes. + A mode switch is gated like start(), and a 409 means the run has finished or another of your runs + is active. + """ + response = self._transport.request("PATCH", _path(run_id), json_body=request.to_wire()) + return ProspectingRunSummary.model_validate(response.json()) + + @api_route("DELETE", "/prospecting/runs/{run_id}") + def delete(self, run_id: str | UUID) -> None: + """Cancel the run if it is still active, then hide it; charges already incurred stay. + + The run is gone from list() and get() afterwards, which raise NotFoundError for it. + """ + self._transport.request("DELETE", _path(run_id)) + + def wait(self, run_id: str | UUID, *, max_wait: float = 3600, poll_interval: float = 5) -> ProspectingRun: + """Return the first page when approval, input, or a terminal outcome is ready. + + A proposed run needs approve() with its plan_version before research starts. + Inspect status and stop_reason; completed does not guarantee the target was met. + A run in checkpoints="ask" mode returns needs_input with a stop_reason in + CHECKPOINT_STOP_REASONS; answer the latest kind="question" message through message(), + then wait again. Either mode pauses at top_up when another round would pass a spending limit. + Timeout stops local polling only. Fetch subsequent pages with get(). + """ + deadline = _deadline(max_wait, poll_interval) + while True: + run = self.get(run_id) + if run.status in WAIT_STATUSES: + return run + remaining = deadline - time.monotonic() + if remaining <= 0: + raise JobTimeoutError("Timed out waiting for prospecting; the run continues on the server") + time.sleep(min(poll_interval, remaining)) + + +class AsyncProspectingResource(AsyncAPIResource): + @api_route("GET", "/prospecting/runs") + async def list(self, params: ProspectingListParams | None = None) -> builtins.list[ProspectingRunSummary]: + """List recent organization runs, newest first; default 20, maximum 50. + + Pass `before` (a run ID) to page past a full page of results. + """ + response = await self._transport.request( + "GET", "/prospecting/runs", params=params.to_wire() if params else None + ) + return [ProspectingRunSummary.model_validate(row) for row in response.json()] + + @api_route("POST", "/prospecting/runs/{run_id}/approve") + async def approve(self, run_id: str | UUID, request: ProspectingApproveRequest) -> ProspectingRun: + """Approve the reviewed plan version; repeating the same approval is safe. + + `checkpoints` on the request overrides the brief's mode; None keeps it. + `seed_segments` picks which customer segments a seeded plan expands (None keeps the card's default); + `segment` turns results grouping on or off. + """ + response = await self._transport.request("POST", _path(run_id) + "/approve", json_body=request.to_wire()) + return ProspectingRun.model_validate(response.json()) + + @api_route("POST", "/prospecting/runs/{run_id}/messages") + async def message( + self, run_id: str | UUID, request: ProspectingMessageRequest, *, idempotency_key: str + ) -> ProspectingMessage: + """Send a chat message; poll get() with ProspectingGetParams(messages_after=...) for the agent's reply.""" + response = await self._transport.request( + "POST", + _path(run_id) + "/messages", + json_body=request.to_wire(), + headers={"Idempotency-Key": _key(idempotency_key)}, + ) + return ProspectingMessage.model_validate(response.json()) + + @api_route("POST", "/prospecting/runs/{run_id}/messages") + async def answer_intake( + self, + run_id: str | UUID, + answers: dict[IntakeKey, IntakeAnswer], + *, + idempotency_key: str, + summary: str | None = None, + ) -> ProspectingMessage: + """Answer the intake card on a run; `answers` maps each question key to its picked `values` and/or `other` text.""" + return await self.message( + run_id, _intake_request(answers=answers, summary=summary), idempotency_key=idempotency_key + ) + + @api_route("POST", "/prospecting/runs") + async def start(self, request: ProspectingBrief, *, idempotency_key: str) -> ProspectingRun: + response = await self._transport.request( + "POST", "/prospecting/runs", json_body=request.to_wire(), headers={"Idempotency-Key": _key(idempotency_key)} + ) + return ProspectingRun.model_validate(response.json()) + + @api_route("GET", "/prospecting/runs/{run_id}") + async def get(self, run_id: str | UUID, params: ProspectingGetParams | None = None) -> ProspectingRun: + response = await self._transport.request("GET", _path(run_id), params=params.to_wire() if params else None) + return ProspectingRun.model_validate(response.json()) + + @api_route("POST", "/prospecting/runs/{run_id}/cancel") + async def cancel(self, run_id: str | UUID) -> ProspectingRun: + response = await self._transport.request("POST", _path(run_id) + "/cancel") + return ProspectingRun.model_validate(response.json()) + + @api_route("PATCH", "/prospecting/runs/{run_id}/plan") + async def update_plan(self, run_id: str | UUID, request: ProspectingPlanSettings) -> ProspectingRun: + """Change a proposed plan's engines, size, deliverable and spending limits; returns the re-estimated run. + + Fields left unset keep their current choice; ids come from the latest plan message's + contact_engine, company_check_engine and search_provider options, and search_provider_id="none" + skips web research. max_spend_usd (USD for records and per-call fees at the plan's rates) and + max_provider_spend_usd (USD on your own AI and search provider keys) each take 0 to remove the limit; + engines left unset are re-chosen around the picks. target_companies and contacts_per_company resize + the plan, deliverable="accounts" returns checked companies only, and goal="companies" works only + target_companies matching companies. Pass the current plan_version: the plan is re-estimated at a new + plan_version, which is the one to approve, and the latest plan message is rewritten in place (same seq), + so read it from this response's messages rather than past a messages_after cursor. A 422 means an id is + not one of the plan's options or the plan has no per-record price, a 409 that the run is not awaiting + approval, plan_version is stale, or the run cannot switch to accounts. + """ + response = await self._transport.request("PATCH", _path(run_id) + "/plan", json_body=request.to_wire()) + return ProspectingRun.model_validate(response.json()) + + @api_route("PATCH", "/prospecting/runs/{run_id}") + async def rename(self, run_id: str | UUID, request: ProspectingRunUpdate) -> ProspectingRunSummary: + """Rename the run or switch its checkpoint mode; returns its summary as list() shows it. + + Send title, checkpoints, or both. The server strips the title, which must then be 1-80 characters; + a title alone works in any status. A title set before the agent plans is kept. Result lists the run + already saved keep their names. checkpoints="ask" pauses at later checkpoints, "auto" never pauses, + and switching to "auto" while the run waits on a checkpoint answers it with continue and resumes. + A mode switch is gated like start(), and a 409 means the run has finished or another of your runs + is active. + """ + response = await self._transport.request("PATCH", _path(run_id), json_body=request.to_wire()) + return ProspectingRunSummary.model_validate(response.json()) + + @api_route("DELETE", "/prospecting/runs/{run_id}") + async def delete(self, run_id: str | UUID) -> None: + """Cancel the run if it is still active, then hide it; charges already incurred stay. + + The run is gone from list() and get() afterwards, which raise NotFoundError for it. + """ + await self._transport.request("DELETE", _path(run_id)) + + async def wait(self, run_id: str | UUID, *, max_wait: float = 3600, poll_interval: float = 5) -> ProspectingRun: + """Return the first page on proposed/needs_input/completed/failed/cancelled; inspect status. + + A needs_input run with a stop_reason in CHECKPOINT_STOP_REASONS waits for an answer via message(). + """ + deadline = _deadline(max_wait, poll_interval) + while True: + run = await self.get(run_id) + if run.status in WAIT_STATUSES: + return run + remaining = deadline - time.monotonic() + if remaining <= 0: + raise JobTimeoutError("Timed out waiting for prospecting; the run continues on the server") + await asyncio.sleep(min(poll_interval, remaining)) diff --git a/packages/discolike/src/discolike/signup.py b/packages/discolike/src/discolike/signup.py index d15b15d..fc73943 100644 --- a/packages/discolike/src/discolike/signup.py +++ b/packages/discolike/src/discolike/signup.py @@ -78,7 +78,7 @@ def signup( last_name: str, agent: str | None = None, base_url: str = DEFAULT_BASE_URL, - timeout: float = DEFAULT_TIMEOUT_SECONDS, + request_timeout: float = DEFAULT_TIMEOUT_SECONDS, http_client: httpx2.Client | None = None, allow_new_email: bool = False, ) -> SignupResult: @@ -87,7 +87,7 @@ def signup( first_name = validate_name(first_name, field="first_name") last_name = validate_name(last_name, field="last_name") _check_email_change(email, allow_new_email) - client = http_client or httpx2.Client(timeout=timeout) + client = http_client or httpx2.Client(timeout=request_timeout) try: response = client.post( _signup_url(base_url), @@ -111,14 +111,14 @@ async def async_signup( last_name: str, agent: str | None = None, base_url: str = DEFAULT_BASE_URL, - timeout: float = DEFAULT_TIMEOUT_SECONDS, + request_timeout: float = DEFAULT_TIMEOUT_SECONDS, http_client: httpx2.AsyncClient | None = None, allow_new_email: bool = False, ) -> SignupResult: first_name = validate_name(first_name, field="first_name") last_name = validate_name(last_name, field="last_name") _check_email_change(email, allow_new_email) - client = http_client or httpx2.AsyncClient(timeout=timeout) + client = http_client or httpx2.AsyncClient(timeout=request_timeout) try: response = await client.post( _signup_url(base_url), diff --git a/packages/discolike/tests/test_contract_registry.py b/packages/discolike/tests/test_contract_registry.py index 00fca89..06b5962 100644 --- a/packages/discolike/tests/test_contract_registry.py +++ b/packages/discolike/tests/test_contract_registry.py @@ -2,10 +2,13 @@ import inspect import pathlib import sys +import typing +from discolike._models import DiscolikeModel from discolike.resources._base import get_discolike_route -ALLOW_UNSTAMPED = {"job", "batch"} +# wait orchestrates repeated get() calls and is not a separate route. +ALLOW_UNSTAMPED = {"job", "batch", "wait"} SCRIPT_PATH = pathlib.Path(__file__).parents[3] / "scripts" / "check_contract.py" @@ -85,6 +88,143 @@ def test_check_models_reports_field_the_sdk_does_not_declare(): assert mismatches == ["ExtractResult: spec schema 'ExtractResponse' has field 'summary' the SDK does not declare"] +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": {}}}}}} + assert check_contract.check_models(spec, {"ExtractResponse": ExtractResult}) == [] + + +def test_check_models_reports_a_type_change(): + check_contract = _load_check_contract() + from discolike.resources.prospecting import ProspectingRun + + schema = _spec_schema_for(ProspectingRun) + schema["properties"]["chat_closed"] = {"type": "string"} + spec = {"components": {"schemas": {"ProspectingRunResponse": schema}}} + mismatches = check_contract.check_models(spec, {"ProspectingRunResponse": ProspectingRun}) + assert mismatches == [ + "ProspectingRun: field 'chat_closed' has type boolean but spec schema 'ProspectingRunResponse' declares string" + ] + + +def test_check_models_reports_an_array_item_type_change(): + check_contract = _load_check_contract() + from discolike.resources.prospecting import ProspectingRun + + schema = _spec_schema_for(ProspectingRun) + schema["properties"]["saved_query_ids"] = {"type": "array", "items": {"type": "integer"}} + spec = {"components": {"schemas": {"ProspectingRunResponse": schema}}} + mismatches = check_contract.check_models(spec, {"ProspectingRunResponse": ProspectingRun}) + assert mismatches == [ + "ProspectingRun: field 'saved_query_ids' has type array of string but spec schema " + "'ProspectingRunResponse' declares array of integer" + ] + + +def test_check_models_accepts_a_spec_narrowing_an_optional_array_to_null(): + check_contract = _load_check_contract() + from discolike.resources.companies import CompanyProfile + + schema = _spec_schema_for(CompanyProfile) + schema["properties"]["phones"] = {"type": "null"} + spec = {"components": {"schemas": {"CompanyResult": schema}}} + assert check_contract.check_models(spec, {"CompanyResult": CompanyProfile}) == [] + + +def test_check_models_passes_a_nullable_field_expressed_via_anyof(): + check_contract = _load_check_contract() + from discolike.resources.companies import ExtractResult + + spec = { + "components": { + "schemas": { + "ExtractResponse": { + "properties": { + "text": {"anyOf": [{"type": "string"}, {"type": "null"}]}, + "language": {"anyOf": [{"type": "string"}, {"type": "null"}]}, + }, + } + } + } + } + assert check_contract.check_models(spec, {"ExtractResponse": ExtractResult}) == [] + + +def test_check_models_passes_a_nullable_field_expressed_via_openapi_nullable_flag(): + check_contract = _load_check_contract() + from discolike.resources.companies import ExtractResult + + spec = { + "components": { + "schemas": { + "ExtractResponse": { + "properties": { + "text": {"type": "string", "nullable": True}, + "language": {"type": "string", "nullable": True}, + }, + } + } + } + } + assert check_contract.check_models(spec, {"ExtractResponse": ExtractResult}) == [] + + +def test_check_models_accepts_a_field_the_spec_marks_required_but_the_sdk_keeps_optional(): + check_contract = _load_check_contract() + from discolike.resources.prospecting import ProspectingRun + + schema = _spec_schema_for(ProspectingRun) + schema["required"] = [*schema.get("required", []), "chat_closed"] + spec = {"components": {"schemas": {"ProspectingRunResponse": schema}}} + assert check_contract.check_models(spec, {"ProspectingRunResponse": ProspectingRun}) == [] + + +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"}}}} + } + } + assert check_contract.check_models(spec, {"ExtractResponse": ExtractResult}) == [] + + +def test_check_models_reports_a_spec_field_turning_nullable_under_a_non_null_sdk_field(): + check_contract = _load_check_contract() + from discolike.resources.prospecting import ProspectingRun + + schema = _spec_schema_for(ProspectingRun) + schema["properties"]["chat_closed"] = {"anyOf": [{"type": "boolean"}, {"type": "null"}]} + spec = {"components": {"schemas": {"ProspectingRunResponse": schema}}} + assert check_contract.check_models(spec, {"ProspectingRunResponse": ProspectingRun}) == [ + "ProspectingRun: field 'chat_closed' has type boolean but spec schema " + "'ProspectingRunResponse' declares boolean | null" + ] + + +def test_check_models_reports_a_field_the_sdk_requires_but_the_spec_does_not(): + check_contract = _load_check_contract() + from discolike.resources.prospecting import ProspectingRun + + schema = _spec_schema_for(ProspectingRun) + schema["required"] = [field for field in schema["required"] if field != "run_id"] + spec = {"components": {"schemas": {"ProspectingRunResponse": schema}}} + mismatches = check_contract.check_models(spec, {"ProspectingRunResponse": ProspectingRun}) + assert mismatches == [ + "ProspectingRun: field 'run_id' is optional in spec schema 'ProspectingRunResponse' but required on the " + "SDK model" + ] + + +def _spec_schema_for(model: type[DiscolikeModel]) -> dict: + schema = model.model_json_schema() + return {"properties": schema.get("properties", {}), "required": list(schema.get("required", []))} + + def test_check_models_reports_missing_schema(): check_contract = _load_check_contract() from discolike.resources.companies import ExtractResult @@ -211,3 +351,173 @@ def test_check_compares_json_body_properties_bidirectionally(): "components": {"schemas": {"FindEmailRequest": {"properties": properties}}}, } assert check_contract.check(spec, routes) == [] + + +def _prospecting_start_spec(names: list[str]) -> dict: + body = {"content": {"application/json": {"schema": {"$ref": "#/components/schemas/ProspectingBrief"}}}} + return { + "paths": {"/prospecting/runs": {"post": {"requestBody": body}}}, + "components": {"schemas": {"ProspectingBrief": {"properties": {name: {} for name in names}}}}, + } + + +def test_check_allows_the_brief_checkpoints_field_the_spec_hides(): + check_contract = _load_check_contract() + from discolike.requests import ProspectingBrief + + routes = _route(check_contract, "ProspectingResource", "start") + names = [name for name in ProspectingBrief.model_fields if name != "checkpoints"] + assert check_contract.check(_prospecting_start_spec(names), routes) == [] + + +def test_check_still_reports_other_brief_fields_the_spec_lacks(): + check_contract = _load_check_contract() + from discolike.requests import ProspectingBrief + + routes = _route(check_contract, "ProspectingResource", "start") + names = [name for name in ProspectingBrief.model_fields if name not in {"checkpoints", "segment"}] + assert check_contract.check(_prospecting_start_spec(names), routes) == [ + "ProspectingResource.start (POST /prospecting/runs): field 'segment' of ProspectingBrief not found in spec" + ] + + +def _prospecting_run_spec(*, status: dict, status_schema: dict | None = None) -> dict: + from discolike.resources.prospecting import ProspectingRun + + schema = _spec_schema_for(ProspectingRun) + schema["properties"]["status"] = status + schemas: dict[str, dict] = {"ProspectingRunResponse": schema} + if status_schema is not None: + schemas["ProspectingStatus"] = status_schema + return {"components": {"schemas": schemas}} + + +def test_check_models_resolves_a_named_enum_ref_to_its_type(): + check_contract = _load_check_contract() + from discolike.resources.prospecting import ProspectingRun + + spec = _prospecting_run_spec( + status={"$ref": "#/components/schemas/ProspectingStatus"}, + status_schema={"type": "string", "enum": ["running", "completed"]}, + ) + assert check_contract.check_models(spec, {"ProspectingRunResponse": ProspectingRun}) == [] + + +def test_check_models_still_reports_a_ref_to_a_different_type(): + check_contract = _load_check_contract() + from discolike.resources.prospecting import ProspectingRun + + spec = _prospecting_run_spec( + status={"$ref": "#/components/schemas/ProspectingStatus"}, status_schema={"type": "integer", "enum": [1, 2]} + ) + assert check_contract.check_models(spec, {"ProspectingRunResponse": ProspectingRun}) == [ + "ProspectingRun: field 'status' has type string but spec schema 'ProspectingRunResponse' declares integer" + ] + + +def test_check_models_accepts_the_saved_companies_id_the_sdk_keeps_optional(): + check_contract = _load_check_contract() + from discolike.resources.prospecting import ProspectingRun + + schema = _spec_schema_for(ProspectingRun) + schema["required"] = [*schema["required"], "companies_saved_query_id"] + spec = {"components": {"schemas": {"ProspectingRunResponse": schema}}} + assert check_contract.check_models(spec, {"ProspectingRunResponse": ProspectingRun}) == [] + + +def _intake_spec( + *, keys: list[str] | None, answer_fields: list[str], body_fields: tuple[str, ...] = ("text", "intake") +) -> dict: + intake = { + "anyOf": [ + { + "type": "object", + "additionalProperties": {"$ref": "#/components/schemas/IntakeAnswer"}, + } + | ({} if keys is None else {"propertyNames": {"enum": keys}}), + {"type": "null"}, + ] + } + properties = {"text": {"anyOf": [{"type": "string"}, {"type": "null"}]}, "intake": intake} + body = {"content": {"application/json": {"schema": {"$ref": "#/components/schemas/ProspectingMessageRequest"}}}} + return { + "paths": {"/prospecting/runs/{run_id}/messages": {"post": {"requestBody": body}}}, + "components": { + "schemas": { + "ProspectingMessageRequest": {"properties": {name: properties[name] for name in body_fields}}, + "IntakeAnswer": {"properties": {name: {} for name in answer_fields}}, + } + }, + } + + +def _sdk_intake_keys() -> list[str]: + from discolike.resources.prospecting import IntakeKey + + return list(typing.get_args(IntakeKey)) + + +def test_collect_routes_keeps_answer_intake_beside_message_on_the_shared_route(): + check_contract = _load_check_contract() + (message,) = _route(check_contract, "ProspectingResource", "message") + (answer_intake,) = _route(check_contract, "ProspectingResource", "answer_intake") + assert message.path == answer_intake.path + assert answer_intake.request_model is None + assert set(answer_intake.body_arguments) == {"intake", "text"} + + +def test_check_passes_answer_intake_against_a_matching_spec(): + check_contract = _load_check_contract() + from discolike.requests import IntakeAnswer + + routes = _route(check_contract, "ProspectingResource", "answer_intake") + spec = _intake_spec(keys=_sdk_intake_keys(), answer_fields=list(IntakeAnswer.model_fields)) + assert check_contract.check(spec, routes) == [] + + +def test_check_reports_intake_key_drift_on_answer_intake(): + check_contract = _load_check_contract() + from discolike.requests import IntakeAnswer + + routes = _route(check_contract, "ProspectingResource", "answer_intake") + keys = [key for key in _sdk_intake_keys() if key != "list_size"] + ["budget"] + label = "ProspectingResource.answer_intake (POST /prospecting/runs/{run_id}/messages)" + assert check_contract.check(_intake_spec(keys=keys, answer_fields=list(IntakeAnswer.model_fields)), routes) == [ + f"{label}: key 'list_size' of body field 'intake' not found in spec", + f"{label}: spec key 'budget' of body field 'intake' not accepted by the SDK", + ] + + +def test_check_reports_intake_answer_field_drift_on_answer_intake(): + check_contract = _load_check_contract() + routes = _route(check_contract, "ProspectingResource", "answer_intake") + spec = _intake_spec(keys=_sdk_intake_keys(), answer_fields=["values", "ranking"]) + label = "ProspectingResource.answer_intake (POST /prospecting/runs/{run_id}/messages)" + assert check_contract.check(spec, routes) == [ + f"{label}: field 'other' of IntakeAnswer not found in spec", + f"{label}: spec param 'ranking' not declared on IntakeAnswer", + ] + + +def test_check_reports_a_body_field_answer_intake_fills_that_the_spec_dropped(): + check_contract = _load_check_contract() + from discolike.requests import IntakeAnswer + + routes = _route(check_contract, "ProspectingResource", "answer_intake") + spec = _intake_spec(keys=_sdk_intake_keys(), answer_fields=list(IntakeAnswer.model_fields), body_fields=("intake",)) + assert check_contract.check(spec, routes) == [ + "ProspectingResource.answer_intake (POST /prospecting/runs/{run_id}/messages): builds body field 'text' not " + "found in spec" + ] + + +def test_check_reports_the_spec_dropping_the_intake_key_constraint(): + check_contract = _load_check_contract() + from discolike.requests import IntakeAnswer + + routes = _route(check_contract, "ProspectingResource", "answer_intake") + spec = _intake_spec(keys=None, answer_fields=list(IntakeAnswer.model_fields)) + assert check_contract.check(spec, routes) == [ + "ProspectingResource.answer_intake (POST /prospecting/runs/{run_id}/messages): spec accepts any key of body " + "field 'intake' but the SDK restricts them" + ] diff --git a/packages/discolike/tests/test_email.py b/packages/discolike/tests/test_email.py index 682b7f2..0b4a5a5 100644 --- a/packages/discolike/tests/test_email.py +++ b/packages/discolike/tests/test_email.py @@ -122,7 +122,7 @@ def test_verify_batch_results_polls_and_decodes_validation_output(make_client: C with make_client(handler) as client: batch = client.email.batch("b-2", kind="verify") - results = batch.results(timeout=60.0, poll_interval=1.0) + results = batch.results(max_wait=60.0, poll_interval=1.0) assert results.batch_id == "b-2" assert results.total == 1 @@ -179,7 +179,7 @@ def test_find_batch_results_decodes_enumeration_output_and_failed_item(make_clie ) with make_client(handler) as client: - results = client.email.batch("b-1", kind="find").results(timeout=60.0, poll_interval=1.0) + results = client.email.batch("b-1", kind="find").results(max_wait=60.0, poll_interval=1.0) assert results.total == 2 assert results.failed == 1 @@ -238,7 +238,7 @@ def handler(request: httpx2.Request) -> httpx2.Response: job = client.email.find(FindEmailRequest(first_name="Grace", last_name="Hopper", domain="navy.mil")) assert isinstance(job, EmailJob) assert job.job_id == "j-9" - output = job.wait(timeout=60.0, poll_interval=1.0) + output = job.wait(max_wait=60.0, poll_interval=1.0) assert seen["path"] == "/v1/email/find" assert seen["body"] == {"first_name": "Grace", "last_name": "Hopper", "domain": "navy.mil"} @@ -279,7 +279,7 @@ def handler(request: httpx2.Request) -> httpx2.Response: with make_client(handler) as client: job = client.email.find(FindEmailRequest(first_name="No", last_name="One", domain="void.dev")) with pytest.raises(JobFailedError, match="boom"): - job.wait(timeout=60.0) + job.wait(max_wait=60.0) def test_job_reattaches_without_http_call(make_client: ClientFactory) -> None: @@ -352,7 +352,7 @@ async def test_verify_batch_async_results_decodes(make_async_client: AsyncClient async with make_async_client(handler) as client: batch = client.email.batch("b-async2", kind="verify") - results = await batch.results(timeout=60.0, poll_interval=1.0) + results = await batch.results(max_wait=60.0, poll_interval=1.0) item = results.results[0] assert isinstance(item.result, ValidationOutput) @@ -392,7 +392,7 @@ def handler(request: httpx2.Request) -> httpx2.Response: async with make_async_client(handler) as client: job = await client.email.find(FindEmailRequest(first_name="Ada", last_name="Lovelace", domain="acme.com")) assert isinstance(job, AsyncEmailJob) - output = await job.wait(timeout=60.0, poll_interval=1.0) + output = await job.wait(max_wait=60.0, poll_interval=1.0) assert isinstance(output, EnumerationOutput) assert output.result is not None @@ -454,7 +454,7 @@ def test_batch_can_still_re_attach_to_a_verify_batch(make_client: ClientFactory) with make_client(handler) as client: batch = client.email.batch("b-9", kind="verify") - results = batch.results(timeout=60.0, poll_interval=1.0) + results = batch.results(max_wait=60.0, poll_interval=1.0) assert isinstance(batch, EmailBatch) assert batch.batch_id == "b-9" @@ -495,7 +495,7 @@ def test_server_reported_kind_overrides_handle_kind(make_client: ClientFactory) ) with make_client(handler) as client: - results = client.email.batch("b-10", kind="find").results(timeout=60.0, poll_interval=1.0) + results = client.email.batch("b-10", kind="find").results(max_wait=60.0, poll_interval=1.0) assert isinstance(results.results[0].result, ValidationOutput) @@ -524,7 +524,7 @@ def test_job_wait_honors_server_reported_kind(make_client: ClientFactory) -> Non ValidationOutput. The server-reported kind picks the model, so wait() must not gate the result on the handle's kind.""" with make_client(_verify_job_handler) as client: - output = client.email.job("j-11").wait(timeout=60.0, poll_interval=1.0) + output = client.email.job("j-11").wait(max_wait=60.0, poll_interval=1.0) assert isinstance(output, ValidationOutput) assert output.reason == "no_mailbox" @@ -532,7 +532,7 @@ def test_job_wait_honors_server_reported_kind(make_client: ClientFactory) -> Non async def test_job_wait_honors_server_reported_kind_async(make_async_client: AsyncClientFactory) -> None: async with make_async_client(_verify_job_handler) as client: - output = await client.email.job("j-11").wait(timeout=60.0, poll_interval=1.0) + output = await client.email.job("j-11").wait(max_wait=60.0, poll_interval=1.0) assert isinstance(output, ValidationOutput) assert output.reason == "no_mailbox" diff --git a/packages/discolike/tests/test_gen_requests.py b/packages/discolike/tests/test_gen_requests.py index 93f4630..5204595 100644 --- a/packages/discolike/tests/test_gen_requests.py +++ b/packages/discolike/tests/test_gen_requests.py @@ -214,14 +214,17 @@ def test_normalize_schema_strips_scalar_item_constraints(gen) -> None: } -def test_apply_overlays_fills_only_what_the_spec_is_missing(gen) -> None: +def test_apply_overlays_fills_only_what_the_spec_is_missing(gen, monkeypatch, capsys) -> None: + pending = {"lat": {"type": "number", "description": "pending"}, "radius": {"type": "string"}} + monkeypatch.setattr(gen, "PENDING_PROPERTIES", {"DiscoverParams": pending}) kept = {"DiscoverParams": {"type": "object", "properties": {"lat": {"type": "number", "description": "deployed"}}}} properties = gen.apply_overlays(kept=kept)["DiscoverParams"]["properties"] assert properties["lat"] == {"type": "number", "description": "deployed"} - assert properties["radius"]["type"] == "string" + assert properties["radius"] == {"type": "string"} assert properties["sub_industry"]["items"] == {"type": "string"} + assert "drop it from PENDING_PROPERTIES" in capsys.readouterr().out def test_apply_overlays_pins_sub_industry_over_a_spec_enum(gen) -> None: @@ -233,6 +236,14 @@ def test_apply_overlays_pins_sub_industry_over_a_spec_enum(gen) -> None: assert sub_industry["items"] == {"type": "string"} +def test_apply_overlays_pins_the_brief_checkpoint_modes_the_spec_hides(gen) -> None: + kept = {"ProspectingBrief": {"type": "object", "properties": {"brief": {"type": "string"}}}} + + brief = gen.apply_overlays(kept=kept)["ProspectingBrief"]["properties"]["checkpoints"] + + assert (brief["enum"], brief["default"]) == (["ask", "auto"], "auto") + + def test_apply_overlays_skips_schemas_this_run_does_not_generate(gen) -> None: assert gen.apply_overlays(kept={}) == {} @@ -263,5 +274,5 @@ def test_compare_prints_a_diff_and_returns_one_on_drift(gen, capsys) -> None: def test_collect_routes_covers_every_stamped_sync_route(gen) -> None: routes = gen.collect_routes() - assert len(routes) == 48 + assert len(routes) == 57 assert all(not route.class_name.startswith("Async") for route in routes) diff --git a/packages/discolike/tests/test_jobs.py b/packages/discolike/tests/test_jobs.py index cba2095..5d38905 100644 --- a/packages/discolike/tests/test_jobs.py +++ b/packages/discolike/tests/test_jobs.py @@ -50,7 +50,7 @@ def test_wait_polls_to_completion() -> None: {"status": "completed", "progress": 100, "results": [{"domain": "a.com"}]}, ] ) - final = make_job(handler).wait(timeout=60.0, poll_interval=1.0) + final = make_job(handler).wait(max_wait=60.0, poll_interval=1.0) assert final.status == "completed" assert final.results == [{"domain": "a.com"}] @@ -104,7 +104,7 @@ def test_status_without_title_validation_defaults_to_none() -> None: def test_wait_failed_raises() -> None: handler = _status_sequence([{"status": "failed", "progress": 100, "result": "LLM exploded"}]) with pytest.raises(JobFailedError, match="LLM exploded"): - make_job(handler).wait(timeout=60.0) + make_job(handler).wait(max_wait=60.0) def test_wait_timeout(monkeypatch) -> None: @@ -117,7 +117,7 @@ def mock_monotonic(): monkeypatch.setattr(jobs_module.time, "monotonic", mock_monotonic) handler = _status_sequence([{"status": "in_progress", "progress": 1}]) with pytest.raises(JobTimeoutError, match="t-1"): - make_job(handler).wait(timeout=50.0) + make_job(handler).wait(max_wait=50.0) def test_cancel_issues_delete() -> None: @@ -138,5 +138,5 @@ async def test_async_job_wait() -> None: ) http = httpx2.AsyncClient(transport=httpx2.MockTransport(handler), base_url=BASE) transport = AsyncTransport(api_key_auth("k"), base_url=BASE, timeout=5.0, max_retries=0, http_client=http) - final = await AsyncJob(transport, task_family=FAMILY_DISCOGEN, task_id="t-1").wait(timeout=60.0) + final = await AsyncJob(transport, task_family=FAMILY_DISCOGEN, task_id="t-1").wait(max_wait=60.0) assert final.status == "completed" diff --git a/packages/discolike/tests/test_package.py b/packages/discolike/tests/test_package.py index 2bba95f..b9f92c5 100644 --- a/packages/discolike/tests/test_package.py +++ b/packages/discolike/tests/test_package.py @@ -2,4 +2,4 @@ def test_version() -> None: - assert discolike.__version__ == "0.4.1" + assert discolike.__version__ == "0.5.0" diff --git a/packages/discolike/tests/test_prospecting.py b/packages/discolike/tests/test_prospecting.py new file mode 100644 index 0000000..95a9957 --- /dev/null +++ b/packages/discolike/tests/test_prospecting.py @@ -0,0 +1,737 @@ +from __future__ import annotations + +import json +from uuid import UUID + +import httpx2 +import pytest +from pydantic import ValidationError + +import discolike.resources.prospecting as module +from discolike import CHECKPOINT_STOP_REASONS +from discolike import DiscolikeError +from discolike import JobTimeoutError +from discolike import NotFoundError +from discolike.requests import IntakeAnswer +from discolike.requests import ProspectingApproveRequest +from discolike.requests import ProspectingBrief +from discolike.requests import ProspectingGetParams +from discolike.requests import ProspectingListParams +from discolike.requests import ProspectingMessageRequest +from discolike.requests import ProspectingPlanSettings +from discolike.requests import ProspectingRunUpdate +from discolike_testkit import AsyncClientFactory +from discolike_testkit import ClientFactory +from discolike_testkit.prospecting import message_payload +from discolike_testkit.prospecting import run_payload +from discolike_testkit.prospecting import summary_payload + +RUN_ID = "00000000-0000-0000-0000-000000000001" +OTHER_QUERY_ID = "00000000-0000-0000-0000-000000000002" + + +def payload(status: str = "queued") -> dict: + return run_payload(status) + + +def test_start_key_does_not_leak_to_other_requests(make_client: ClientFactory) -> None: + seen: list[httpx2.Request] = [] + + def handler(request: httpx2.Request) -> httpx2.Response: + seen.append(request) + return httpx2.Response(202 if request.method == "POST" else 200, json=payload()) + + with make_client(handler) as client: + brief = ProspectingBrief(brief="US logistics companies and operations leaders") + first = client.prospecting.start(brief, idempotency_key="stable-key") + second = client.prospecting.start(brief, idempotency_key="stable-key") + assert first.run_id == second.run_id == UUID(RUN_ID) + client.prospecting.get(RUN_ID, ProspectingGetParams(offset=100, limit=25)) + assert [r.headers.get("Idempotency-Key") for r in seen] == ["stable-key", "stable-key", None] + assert json.loads(seen[0].content) == {"brief": brief.brief} + assert dict(seen[2].url.params) == {"offset": "100", "limit": "25"} + + +@pytest.mark.parametrize("status", ["completed", "needs_input", "failed", "cancelled"]) +def test_wait_preserves_terminal_partial_results(make_client: ClientFactory, status: str) -> None: + with make_client(lambda request: httpx2.Response(200, json=payload(status))) as client: + run = client.prospecting.wait(RUN_ID) + assert run.status == status + assert run.companies == [{"domain": "example.com"}] + + +def test_wait_timeout_never_cancels(make_client: ClientFactory, monkeypatch: pytest.MonkeyPatch) -> None: + times = iter([0.0, 2.0]) + monkeypatch.setattr(module.time, "monotonic", lambda: next(times)) + seen = [] + + def handler(request: httpx2.Request) -> httpx2.Response: + seen.append(request.method) + return httpx2.Response(200, json=payload()) + + with make_client(handler) as client, pytest.raises(JobTimeoutError): + client.prospecting.wait(RUN_ID, max_wait=1) + assert seen == ["GET"] + + +@pytest.mark.parametrize("key", ["", " ", "a" * 129, "a\nb", "a\rb", "a\tb", "a\x00b", "a🚀b"]) +def test_invalid_key_is_rejected_locally(make_client: ClientFactory, key: str) -> None: + def handler(request: httpx2.Request) -> httpx2.Response: + pytest.fail("invalid key must not reach the network") + + with make_client(handler) as client, pytest.raises(ValueError, match="idempotency_key"): + client.prospecting.start(ProspectingBrief(brief="Logistics companies and buyers"), idempotency_key=key) + + +async def test_async_start_wait_and_cancel(make_async_client: AsyncClientFactory) -> None: + seen = [] + + def handler(request: httpx2.Request) -> httpx2.Response: + seen.append((request.method, request.url.path)) + if request.url.path.endswith("/cancel"): + return httpx2.Response(200, json=payload("cancelled")) + if request.method == "POST": + assert request.headers["Idempotency-Key"] == "async-key" + return httpx2.Response(200, json=payload("completed")) + + async with make_async_client(handler) as client: + run = await client.prospecting.start( + ProspectingBrief(brief="Logistics companies and operations leaders"), idempotency_key="async-key" + ) + assert (await client.prospecting.wait(run.run_id)).status == "completed" + assert (await client.prospecting.cancel(run.run_id)).status == "cancelled" + assert seen == [ + ("POST", "/v1/prospecting/runs"), + ("GET", f"/v1/prospecting/runs/{RUN_ID}"), + ("POST", f"/v1/prospecting/runs/{RUN_ID}/cancel"), + ] + + +def test_cancel_posts_to_the_cancel_route(make_client: ClientFactory) -> None: + seen: list[httpx2.Request] = [] + + def handler(request: httpx2.Request) -> httpx2.Response: + seen.append(request) + return httpx2.Response(200, json=payload("cancelled")) + + with make_client(handler) as client: + assert client.prospecting.cancel(RUN_ID).status == "cancelled" + assert [(r.method, r.url.path) for r in seen] == [("POST", f"/v1/prospecting/runs/{RUN_ID}/cancel")] + assert seen[0].content == b"" + + +def test_delete_hides_the_run_and_returns_nothing(make_client: ClientFactory) -> None: + seen: list[httpx2.Request] = [] + + def handler(request: httpx2.Request) -> httpx2.Response: + seen.append(request) + if request.method == "DELETE": + return httpx2.Response(204) + return httpx2.Response(404, json={"detail": "Prospecting run not found"}) + + with make_client(handler) as client: + assert client.prospecting.delete(RUN_ID) is None + with pytest.raises(NotFoundError): + client.prospecting.get(RUN_ID) + assert [(r.method, r.url.path) for r in seen] == [ + ("DELETE", f"/v1/prospecting/runs/{RUN_ID}"), + ("GET", f"/v1/prospecting/runs/{RUN_ID}"), + ] + + +async def test_async_delete_returns_nothing(make_async_client: AsyncClientFactory) -> None: + seen: list[tuple[str, str]] = [] + + def handler(request: httpx2.Request) -> httpx2.Response: + seen.append((request.method, request.url.path)) + return httpx2.Response(204) + + async with make_async_client(handler) as client: + assert await client.prospecting.delete(RUN_ID) is None + assert seen == [("DELETE", f"/v1/prospecting/runs/{RUN_ID}")] + + +def test_rename_patches_the_title_and_returns_the_summary(make_client: ClientFactory) -> None: + seen: list[httpx2.Request] = [] + + def handler(request: httpx2.Request) -> httpx2.Response: + seen.append(request) + return httpx2.Response(200, json=summary_payload() | {"title": "Logistics ops leaders"}) + + with make_client(handler) as client: + summary = client.prospecting.rename(RUN_ID, ProspectingRunUpdate(title=" Logistics ops leaders ")) + assert isinstance(summary, module.ProspectingRunSummary) + assert summary.title == "Logistics ops leaders" + assert [(r.method, r.url.path) for r in seen] == [("PATCH", f"/v1/prospecting/runs/{RUN_ID}")] + assert json.loads(seen[0].content) == {"title": " Logistics ops leaders "} + + +def test_rename_switches_the_checkpoint_mode_without_a_title(make_client: ClientFactory) -> None: + seen: list[httpx2.Request] = [] + + def handler(request: httpx2.Request) -> httpx2.Response: + seen.append(request) + return httpx2.Response(200, json=summary_payload()) + + with make_client(handler) as client: + summary = client.prospecting.rename(RUN_ID, ProspectingRunUpdate(checkpoints="auto")) + assert summary.run_id == UUID(RUN_ID) + assert json.loads(seen[0].content) == {"checkpoints": "auto"} + + +def test_rename_surfaces_a_finished_run_on_a_mode_switch(make_client: ClientFactory) -> None: + with ( + make_client(lambda request: httpx2.Response(409, json={"detail": "This run has finished"})) as client, + pytest.raises(DiscolikeError, match="This run has finished"), + ): + client.prospecting.rename(RUN_ID, ProspectingRunUpdate(checkpoints="ask")) + + +def test_run_update_rejects_an_unknown_checkpoint_mode_before_sending() -> None: + with pytest.raises(ValidationError): + ProspectingRunUpdate.model_validate({"checkpoints": "sometimes"}) + + +def test_rename_surfaces_a_missing_run(make_client: ClientFactory) -> None: + with ( + make_client(lambda request: httpx2.Response(404, json={"detail": "Prospecting run not found"})) as client, + pytest.raises(NotFoundError), + ): + client.prospecting.rename(RUN_ID, ProspectingRunUpdate(title="Renamed")) + + +def test_rename_rejects_an_overlong_title_before_sending() -> None: + with pytest.raises(ValidationError): + ProspectingRunUpdate(title="x" * 81) + + +async def test_async_rename_patches_the_title(make_async_client: AsyncClientFactory) -> None: + seen: list[httpx2.Request] = [] + + def handler(request: httpx2.Request) -> httpx2.Response: + seen.append(request) + return httpx2.Response(200, json=summary_payload() | {"title": "Renamed"}) + + async with make_async_client(handler) as client: + summary = await client.prospecting.rename(RUN_ID, ProspectingRunUpdate(title="Renamed")) + assert summary.title == "Renamed" + assert summary.run_id == UUID(RUN_ID) + assert [(r.method, r.url.path) for r in seen] == [("PATCH", f"/v1/prospecting/runs/{RUN_ID}")] + assert json.loads(seen[0].content) == {"title": "Renamed"} + + +def test_update_plan_patches_only_the_set_fields(make_client: ClientFactory) -> None: + seen: list[httpx2.Request] = [] + + def handler(request: httpx2.Request) -> httpx2.Response: + seen.append(request) + return httpx2.Response(200, json=payload("proposed") | {"plan_version": 2}) + + with make_client(handler) as client: + run = client.prospecting.update_plan( + RUN_ID, ProspectingPlanSettings(plan_version=1, search_provider_id="none", max_spend_usd=0) + ) + assert run.plan_version == 2 + assert [(r.method, r.url.path) for r in seen] == [("PATCH", f"/v1/prospecting/runs/{RUN_ID}/plan")] + assert json.loads(seen[0].content) == {"plan_version": 1, "search_provider_id": "none", "max_spend_usd": 0} + + +def test_update_plan_sends_run_shape_and_provider_limit(make_client: ClientFactory) -> None: + seen: list[httpx2.Request] = [] + + def handler(request: httpx2.Request) -> httpx2.Response: + seen.append(request) + return httpx2.Response(200, json=payload("proposed") | {"plan_version": 2}) + + settings = ProspectingPlanSettings( + plan_version=1, + target_companies=250, + contacts_per_company=10, + max_provider_spend_usd=12.5, + deliverable="accounts", + goal="companies", + ) + with make_client(handler) as client: + client.prospecting.update_plan(RUN_ID, settings) + assert json.loads(seen[0].content) == { + "plan_version": 1, + "target_companies": 250, + "contacts_per_company": 10, + "max_provider_spend_usd": 12.5, + "deliverable": "accounts", + "goal": "companies", + } + + +@pytest.mark.parametrize( + "values", + [ + {"deliverable": "people"}, + {"goal": "accounts"}, + {"max_provider_spend_usd": -1}, + {"target_companies": 0}, + {"contacts_per_company": 11}, + ], +) +def test_update_plan_rejects_bad_run_shape_locally(values: dict) -> None: + with pytest.raises(ValidationError): + ProspectingPlanSettings.model_validate({"plan_version": 1} | values) + + +@pytest.mark.parametrize("status", [409, 422]) +def test_update_plan_surfaces_rejections(make_client: ClientFactory, status: int) -> None: + with ( + make_client(lambda request: httpx2.Response(status, json={"detail": "rejected"})) as client, + pytest.raises(DiscolikeError), + ): + client.prospecting.update_plan(RUN_ID, ProspectingPlanSettings(plan_version=1, contact_integration_id="x")) + + +def test_update_plan_validates_locally() -> None: + with pytest.raises(ValidationError): + ProspectingPlanSettings(plan_version=1, max_spend_usd=-1) + with pytest.raises(ValidationError): + ProspectingPlanSettings(plan_version=0) + + +async def test_async_update_plan_patches_the_plan(make_async_client: AsyncClientFactory) -> None: + seen: list[httpx2.Request] = [] + + def handler(request: httpx2.Request) -> httpx2.Response: + seen.append(request) + return httpx2.Response(200, json=payload("proposed") | {"plan_version": 3}) + + async with make_async_client(handler) as client: + run = await client.prospecting.update_plan(RUN_ID, ProspectingPlanSettings(plan_version=2, max_spend_usd=25.0)) + assert run.plan_version == 3 + assert [(r.method, r.url.path) for r in seen] == [("PATCH", f"/v1/prospecting/runs/{RUN_ID}/plan")] + assert json.loads(seen[0].content) == {"plan_version": 2, "max_spend_usd": 25.0} + + +@pytest.mark.parametrize( + "reason", ["candidate_limit", "credit_limit", "provider_limit", "companies_worked", "a_reason_added_later"] +) +def test_stop_reason_stays_an_open_string(make_client: ClientFactory, reason: str) -> None: + with make_client( + lambda request: httpx2.Response(200, json=payload("completed") | {"stop_reason": reason}) + ) as client: + assert client.prospecting.get(RUN_ID).stop_reason == reason + + +def test_wait_returns_a_proposed_plan(make_client: ClientFactory) -> None: + with make_client(lambda request: httpx2.Response(200, json=payload("proposed"))) as client: + assert client.prospecting.wait(RUN_ID).status == "proposed" + + +def test_list_approve_message_and_cursors(make_client: ClientFactory) -> None: + seen = [] + + def handler(request: httpx2.Request) -> httpx2.Response: + seen.append(request) + if request.url.path.endswith("/messages"): + return httpx2.Response(202, json=message_payload()) + if request.url.path.endswith("/runs"): + return httpx2.Response(200, json=[summary_payload()]) + result = payload("queued" if request.url.path.endswith("/approve") else "running") + result.update( + messages=[message_payload()], + next_message_seq=8, + next_event_seq=12, + in_flight=[ + { + "stage": "generate", + "items": 50, + "plan_version": 2, + "state": "running", + "started_at": result["created_at"], + } + ], + saved_query_id=RUN_ID, + saved_query_ids=[RUN_ID, OTHER_QUERY_ID], + plan_version=2, + approved_plan_version=2, + reply_pending=True, + fit_companies=40, + emails_found=15, + chat_closed=True, + stop_reason="misuse", + ) + return httpx2.Response(200, json=result) + + with make_client(handler) as client: + assert client.prospecting.list(ProspectingListParams(limit=50, before=RUN_ID))[0].target_companies == 25 + assert client.prospecting.approve(RUN_ID, ProspectingApproveRequest(plan_version=2)).approved_plan_version == 2 + assert ( + client.prospecting.message( + RUN_ID, ProspectingMessageRequest(text="Make it 100 companies"), idempotency_key="message-1" + ).seq + == 8 + ) + run = client.prospecting.get(RUN_ID, ProspectingGetParams(events_after=12, messages_after=8, limit=500)) + assert dict(seen[0].url.params) == {"limit": "50", "before": RUN_ID} + assert json.loads(seen[1].content) == {"plan_version": 2} + assert json.loads(seen[2].content) == {"text": "Make it 100 companies"} + assert [r.headers.get("Idempotency-Key") for r in seen] == [None, None, "message-1", None] + assert dict(seen[3].url.params) == {"events_after": "12", "messages_after": "8", "limit": "500"} + assert run.messages[0].created_at.year == 2026 + assert run.in_flight[0].stage == "generate" + assert run.saved_query_id == UUID(RUN_ID) + assert run.saved_query_ids == [UUID(RUN_ID), UUID(OTHER_QUERY_ID)] + assert (run.fit_companies, run.emails_found, run.reply_pending) == (40, 15, True) + assert (run.chat_closed, run.stop_reason) == (True, "misuse") + + +async def test_async_chat_lifecycle_and_proposed_wait(make_async_client: AsyncClientFactory) -> None: + seen = [] + + def handler(request: httpx2.Request) -> httpx2.Response: + seen.append(request) + if request.url.path.endswith("/messages"): + return httpx2.Response(202, json=message_payload()) + if request.url.path.endswith("/runs"): + return httpx2.Response(200, json=[summary_payload()]) + return httpx2.Response(200, json=payload("queued" if request.method == "POST" else "proposed")) + + async with make_async_client(handler) as client: + assert len(await client.prospecting.list()) == 1 + assert (await client.prospecting.wait(RUN_ID)).status == "proposed" + assert (await client.prospecting.approve(RUN_ID, ProspectingApproveRequest(plan_version=1))).status == "queued" + message = await client.prospecting.message( + RUN_ID, ProspectingMessageRequest(text="Make it 100 companies"), idempotency_key="async-message" + ) + await client.prospecting.get(RUN_ID, ProspectingGetParams(events_after=7, messages_after=message.seq)) + assert seen[0].url.params == httpx2.QueryParams() + assert json.loads(seen[2].content) == {"plan_version": 1} + assert seen[3].headers["Idempotency-Key"] == "async-message" + assert json.loads(seen[3].content) == {"text": "Make it 100 companies"} + assert dict(seen[4].url.params) == {"events_after": "7", "messages_after": "8"} + assert seen[4].headers.get("Idempotency-Key") is None + + +@pytest.mark.parametrize("key", ["", " ", "a" * 129, "a\nb", "a\rb", "a\tb", "a\x00b", "a🚀b"]) +def test_invalid_message_key_never_reaches_network(make_client: ClientFactory, key: str) -> None: + def handler(request: httpx2.Request) -> httpx2.Response: + pytest.fail("invalid key must fail locally") + + with make_client(handler) as client, pytest.raises(ValueError, match="idempotency_key"): + client.prospecting.message(RUN_ID, ProspectingMessageRequest(text="Continue"), idempotency_key=key) + + +@pytest.mark.parametrize( + ("model", "values"), + [ + (ProspectingApproveRequest, {}), + (ProspectingApproveRequest, {"plan_version": 0}), + (ProspectingApproveRequest, {"plan_version": 1, "checkpoints": "sometimes"}), + (ProspectingBrief, {"brief": "US logistics companies", "checkpoints": "never"}), + (ProspectingListParams, {"limit": 51}), + (ProspectingListParams, {"limit": 0}), + (ProspectingListParams, {"before": "not-a-uuid"}), + (ProspectingMessageRequest, {"text": ""}), + (ProspectingMessageRequest, {"text": "x" * 4001}), + (ProspectingGetParams, {"events_after": -1}), + (ProspectingGetParams, {"messages_after": -1}), + ], +) +def test_chat_request_constraints(model, values) -> None: + with pytest.raises(ValidationError): + model.model_validate(values) + + +def test_request_defaults_preserve_explicit_quantity_intent() -> None: + implicit = ProspectingBrief(brief="Find 1000 companies and three founders each") + explicit = ProspectingBrief( + brief=implicit.brief, target_companies=25, contacts_per_company=2, max_actions=0, max_candidates=0 + ) + assert (implicit.target_companies, implicit.contacts_per_company) == (1000, 3) + assert implicit.to_wire() == {"brief": implicit.brief} + assert explicit.to_wire() == { + "brief": implicit.brief, + "target_companies": 25, + "contacts_per_company": 2, + "max_actions": 0, + "max_candidates": 0, + } + assert (ProspectingListParams().limit, ProspectingListParams().before) == (20, None) + + +def test_deliverable_and_goal_are_sent_only_when_set() -> None: + brief = "US logistics companies and operations leaders" + assert ProspectingBrief(brief=brief).to_wire() == {"brief": brief} + assert ProspectingBrief(brief=brief, deliverable="accounts", goal="companies").to_wire() == { + "brief": brief, + "deliverable": "accounts", + "goal": "companies", + } + with pytest.raises(ValidationError): + ProspectingBrief.model_validate({"brief": brief, "deliverable": "people"}) + + +def test_checkpoints_default_to_the_server_mode_and_send_only_when_set() -> None: + brief = "US logistics companies and operations leaders" + assert ProspectingBrief(brief=brief).checkpoints == "auto" + assert ProspectingBrief(brief=brief).to_wire() == {"brief": brief} + assert ProspectingBrief(brief=brief, checkpoints="ask").to_wire() == {"brief": brief, "checkpoints": "ask"} + assert ProspectingApproveRequest(plan_version=2).to_wire() == {"plan_version": 2} + assert ProspectingApproveRequest(plan_version=2, checkpoints="auto").to_wire() == { + "plan_version": 2, + "checkpoints": "auto", + } + + +def test_wait_returns_at_a_checkpoint_with_its_question(make_client: ClientFactory) -> None: + question = message_payload() | { + "role": "agent", + "kind": "question", + "content": "Found 25 companies and 50 emails. Want more?", + "data": {"reason": "target_reached", "suggested_replies": ["That's enough", "Find 25 more"], "sample": []}, + } + paused = payload("needs_input") | { + "stop_reason": "target_reached", + "brief": {"brief": "US logistics companies and operations leaders", "checkpoints": "ask"}, + "messages": [question], + } + with make_client(lambda request: httpx2.Response(200, json=paused)) as client: + run = client.prospecting.wait(RUN_ID) + assert (run.status, run.stop_reason) == ("needs_input", "target_reached") + assert run.stop_reason in CHECKPOINT_STOP_REASONS + assert run.brief.checkpoints == "ask" + assert run.messages[-1].data == question["data"] + + +def test_a_top_up_round_is_a_checkpoint(make_client: ClientFactory) -> None: + replies = ["Raise the limit and run it", "Run a smaller round (40 companies)", "Finish with 60 found"] + question = message_payload() | { + "role": "agent", + "kind": "question", + "content": "60 of 100 companies had reachable people. Another round needs about 70 more matching companies.", + "data": {"reason": "top_up", "suggested_replies": replies, "sample": []}, + } + paused = payload("needs_input") | {"stop_reason": "top_up", "messages": [question]} + with make_client(lambda request: httpx2.Response(200, json=paused)) as client: + run = client.prospecting.wait(RUN_ID) + assert run.stop_reason in CHECKPOINT_STOP_REASONS + assert run.messages[-1].data == question["data"] + + +CONTACTS_REVIEW_REPLIES = ["Find emails", "Find more companies first", "Finish"] + + +def contacts_review_question() -> dict: + return message_payload() | { + "seq": 9, + "role": "agent", + "kind": "question", + "content": "Checked 480 companies: 470 fit, 260 contacts matched the persona at 210 companies.", + "data": {"reason": "contacts_review", "suggested_replies": CONTACTS_REVIEW_REPLIES, "sample": []}, + } + + +def test_wait_returns_contacts_review_with_open_stop_reason(make_client: ClientFactory) -> None: + question = contacts_review_question() + paused = payload("needs_input") | {"stop_reason": "contacts_review", "messages": [question]} + with make_client(lambda request: httpx2.Response(200, json=paused)) as client: + run = client.prospecting.wait(RUN_ID) + assert (run.status, run.stop_reason) == ("needs_input", "contacts_review") + assert run.stop_reason in CHECKPOINT_STOP_REASONS + assert run.messages[-1].data == question["data"] + + +@pytest.mark.parametrize("reply", CONTACTS_REVIEW_REPLIES) +def test_contacts_review_reply_uses_message(make_client: ClientFactory, reply: str) -> None: + bodies: list[dict] = [] + + def handler(request: httpx2.Request) -> httpx2.Response: + bodies.append(json.loads(request.content)) + return httpx2.Response(202, json=message_payload()) + + with make_client(handler) as client: + client.prospecting.message( + RUN_ID, ProspectingMessageRequest(text=reply, question_seq=9), idempotency_key="review-1" + ) + assert bodies == [{"text": reply, "question_seq": 9}] + + +def test_message_without_question_seq_sends_the_same_body(make_client: ClientFactory) -> None: + bodies: list[dict] = [] + + def handler(request: httpx2.Request) -> httpx2.Response: + bodies.append(json.loads(request.content)) + return httpx2.Response(202, json=message_payload()) + + with make_client(handler) as client: + client.prospecting.message(RUN_ID, ProspectingMessageRequest(text="Find emails"), idempotency_key="k") + assert bodies == [{"text": "Find emails"}] + + +def test_question_seq_must_be_positive() -> None: + with pytest.raises(ValidationError): + ProspectingMessageRequest(text="Find emails", question_seq=0) + + +def test_a_run_reports_its_phase_provider_cost_and_shape(make_client: ClientFactory) -> None: + phased = payload("running") | { + "pipeline_phase": "people", + "provider_cost_usd": 3.25, + "provider_ai_cost_usd": 0.25, + "provider_search_cost_usd": 3.0, + "brief": {"brief": "US logistics companies", "deliverable": "accounts", "goal": "companies"}, + } + with make_client(lambda request: httpx2.Response(200, json=phased)) as client: + run = client.prospecting.get(RUN_ID) + assert (run.pipeline_phase, run.provider_cost_usd) == ("people", 3.25) + assert (run.provider_ai_cost_usd, run.provider_search_cost_usd) == (0.25, 3.0) + assert (run.brief.deliverable, run.brief.goal) == ("accounts", "companies") + + +def test_a_run_from_before_phases_defaults_its_new_fields(make_client: ClientFactory) -> None: + with make_client(lambda request: httpx2.Response(200, json=payload("running"))) as client: + run = client.prospecting.get(RUN_ID) + assert (run.pipeline_phase, run.provider_cost_usd) == (None, None) + assert (run.provider_ai_cost_usd, run.provider_search_cost_usd) == (None, None) + assert (run.brief.deliverable, run.brief.goal) == ("leads", "leads") + + +def test_a_failed_pilot_carries_its_sample(make_client: ClientFactory) -> None: + sample = [{"domain": "example.com", "name": "Example", "company_fit": "No", "reason": "Sells software"}] + stopped = payload("completed") | {"stop_reason": "pilot_failed", "pilot_sample": sample} + with make_client(lambda request: httpx2.Response(200, json=stopped)) as client: + run = client.prospecting.wait(RUN_ID) + assert (run.stop_reason, run.pilot_sample) == ("pilot_failed", sample) + assert run.companies_saved_query_id is None + assert run.stop_reason not in CHECKPOINT_STOP_REASONS + + +def test_a_run_carries_its_saved_companies_list(make_client: ClientFactory) -> None: + completed = payload("completed") | {"saved_query_id": RUN_ID, "companies_saved_query_id": OTHER_QUERY_ID} + with make_client(lambda request: httpx2.Response(200, json=completed)) as client: + assert client.prospecting.get(RUN_ID).companies_saved_query_id == UUID(OTHER_QUERY_ID) + + +def test_a_run_from_a_newer_server_still_parses(make_client: ClientFactory) -> None: + newer = payload("archived") | { + "brief": {"brief": "Dentists", "contacts_per_company": 50, "target_companies": 50_000, "checkpoints": "review"}, + "messages": [message_payload() | {"kind": "chart", "role": "system"}], + } + with make_client(lambda request: httpx2.Response(200, json=newer)) as client: + run = client.prospecting.get(RUN_ID) + assert (run.status, run.brief.contacts_per_company, run.messages[0].kind) == ("archived", 50, "chart") + + +def test_a_summary_keeps_a_brief_longer_than_the_list_preview() -> None: + summary = module.ProspectingRunSummary.model_validate(summary_payload() | {"brief": "x" * 500}) + assert len(summary.brief) == 500 + + +def test_approve_sends_a_segment_selection_only_when_given(make_client: ClientFactory) -> None: + bodies: list[dict] = [] + + def handler(request: httpx2.Request) -> httpx2.Response: + bodies.append(json.loads(request.content)) + return httpx2.Response(202, json=payload()) + + with make_client(handler) as client: + client.prospecting.approve(RUN_ID, ProspectingApproveRequest(plan_version=1)) + client.prospecting.approve( + RUN_ID, ProspectingApproveRequest(plan_version=1, seed_segments=[1, 2], segment=True) + ) + assert bodies == [{"plan_version": 1}, {"plan_version": 1, "seed_segments": [1, 2], "segment": True}] + with pytest.raises(ValidationError): + ProspectingApproveRequest(plan_version=1, seed_segments=[]) + + +def test_a_seeded_brief_sends_and_reads_back_its_customers(make_client: ClientFactory) -> None: + seen: list[httpx2.Request] = [] + customers = ["acme.com", "example.com"] + + def handler(request: httpx2.Request) -> httpx2.Response: + seen.append(request) + body = payload("drafting") + return httpx2.Response( + 202, + json=body | {"brief": body["brief"] | {"customer_domains": customers, "selected_seed_segments": [1]}}, + ) + + with make_client(handler) as client: + run = client.prospecting.start( + ProspectingBrief(brief="Lookalikes of our customers and their CTOs", customer_domains=customers), + idempotency_key="seeded", + ) + assert json.loads(seen[0].content)["customer_domains"] == customers + assert (run.brief.customer_domains, run.brief.selected_seed_segments) == (customers, [1]) + with pytest.raises(ValidationError): + ProspectingBrief(brief="Lookalikes of our customers", customer_domains=["acme.com"] * 1001) + + +def test_answer_intake_posts_answers_with_key(make_client: ClientFactory) -> None: + seen: list[httpx2.Request] = [] + + def handler(request: httpx2.Request) -> httpx2.Response: + seen.append(request) + return httpx2.Response(202, json=message_payload()) + + with make_client(handler) as client: + client.prospecting.answer_intake( + RUN_ID, {"company_activity": IntakeAnswer(values=["sell"])}, idempotency_key="k" + ) + message = client.prospecting.answer_intake( + RUN_ID, + {"company_activity": IntakeAnswer(values=["sell"]), "geography": IntakeAnswer(other="Ohio")}, + idempotency_key="k2", + summary="Sellers in Ohio", + ) + assert seen[0].url.path == f"/v1/prospecting/runs/{RUN_ID}/messages" + assert seen[0].headers["Idempotency-Key"] == "k" + assert json.loads(seen[0].content) == {"intake": {"company_activity": {"values": ["sell"]}}} + assert json.loads(seen[1].content) == { + "text": "Sellers in Ohio", + "intake": {"company_activity": {"values": ["sell"]}, "geography": {"other": "Ohio"}}, + } + assert message.seq == 8 + + +async def test_async_answer_intake(make_async_client: AsyncClientFactory) -> None: + seen: list[httpx2.Request] = [] + + def handler(request: httpx2.Request) -> httpx2.Response: + seen.append(request) + return httpx2.Response(202, json=message_payload()) + + async with make_async_client(handler) as client: + message = await client.prospecting.answer_intake( + RUN_ID, {"list_size": IntakeAnswer(values=["1000"])}, idempotency_key="async-k" + ) + assert seen[0].headers["Idempotency-Key"] == "async-k" + assert json.loads(seen[0].content) == {"intake": {"list_size": {"values": ["1000"]}}} + assert message.seq == 8 + + +def test_intake_accepts_a_deliverable_answer() -> None: + request = ProspectingMessageRequest(intake={"deliverable": IntakeAnswer(values=["accounts"])}) + assert request.to_wire() == {"intake": {"deliverable": {"values": ["accounts"]}}} + + +def test_answer_intake_rejects_unknown_key_and_long_other() -> None: + with pytest.raises(ValidationError): + ProspectingMessageRequest.model_validate({"intake": {"bogus": {"values": ["x"]}}}) + with pytest.raises(ValidationError): + IntakeAnswer(other="x" * 201) + + +def test_message_request_needs_text_or_intake() -> None: + with pytest.raises(ValidationError, match="send text or intake answers"): + ProspectingMessageRequest() + with pytest.raises(ValidationError, match="send text or intake answers"): + ProspectingMessageRequest(intake={}) + + +def test_answer_intake_rejects_empty_answers_before_sending(make_client: ClientFactory) -> None: + seen: list[httpx2.Request] = [] + + def handler(request: httpx2.Request) -> httpx2.Response: + seen.append(request) + return httpx2.Response(202, json=message_payload()) + + with make_client(handler) as client, pytest.raises(ValidationError): + client.prospecting.answer_intake(RUN_ID, {}, idempotency_key="k") + assert seen == [] diff --git a/pyproject.toml b/pyproject.toml index f5aa784..71335cd 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -13,7 +13,7 @@ exclude = [".venv"] line-length = 120 respect-gitignore = true indent-width = 4 -target-version = "py310" +target-version = "py311" src = ["packages/discolike/src", "packages/discolike/tests", "packages/discolike-cli/src", "packages/discolike-cli/tests", "packages/discolike-testkit/src"] [tool.ruff.lint] diff --git a/scripts/check_contract.py b/scripts/check_contract.py index 4cf0d93..afffab6 100644 --- a/scripts/check_contract.py +++ b/scripts/check_contract.py @@ -10,6 +10,8 @@ import typing from dataclasses import dataclass from types import ModuleType +from typing import Any +from typing import Literal import httpx2 @@ -26,6 +28,12 @@ from discolike.resources.companies import Subsidiary from discolike.resources.companies import Vendor from discolike.resources.match import MatchResponse +from discolike.resources.prospecting import ProspectingEvent +from discolike.resources.prospecting import ProspectingInFlight +from discolike.resources.prospecting import ProspectingMessage +from discolike.resources.prospecting import ProspectingPlan +from discolike.resources.prospecting import ProspectingRun +from discolike.resources.prospecting import ProspectingRunSummary from discolike.resources.queries import SavedQueries IGNORE_PARAMS = {"file"} @@ -36,6 +44,12 @@ # checked field-by-field against the spec, so a platform-side model change surfaces as a # contract failure instead of silently landing in `extra`. MIRRORED_SCHEMAS: dict[str, type[DiscolikeModel]] = { + "ProspectingRunResponse": ProspectingRun, + "ProspectingPlan": ProspectingPlan, + "ProspectingEvent": ProspectingEvent, + "ProspectingMessage": ProspectingMessage, + "ProspectingInFlight": ProspectingInFlight, + "ProspectingRunSummary": ProspectingRunSummary, "CompanyResult": CompanyProfile, "ExtractResponse": ExtractResult, "ScoreResponse": Score, @@ -47,6 +61,13 @@ "MatchResponse": MatchResponse, "SavedQueriesListResponse": SavedQueries, } +# Request fields the platform accepts but hides from its OpenAPI schema (SkipJsonSchema), so the spec never lists them. +HIDDEN_REQUEST_FIELDS: dict[str, frozenset[str]] = {"ProspectingBrief": frozenset({"checkpoints"})} +# Methods that build a route's body from their own arguments instead of taking its request model, mapped +# argument -> body field, so each argument is checked against the body field it fills. +BODY_BUILDERS: dict[tuple[str, str], dict[str, str]] = { + ("ProspectingResource", "answer_intake"): {"answers": "intake", "summary": "text"}, +} SPEC_URL = "https://api.discolike.com/v1/openapi.json" REQUEST_TIMEOUT_SECONDS = 30.0 @@ -59,6 +80,7 @@ class RouteEntry: path: str openapi: bool request_model: type[DiscolikeRequest] | None + body_arguments: dict[str, Any] def _resource_modules() -> list[ModuleType]: @@ -74,11 +96,20 @@ def _resource_modules() -> list[ModuleType]: def _request_model(member: object) -> type[DiscolikeRequest] | None: for annotation in typing.get_type_hints(member).values(): - if inspect.isclass(annotation) and issubclass(annotation, DiscolikeRequest): - return annotation + for candidate in (annotation, *typing.get_args(annotation)): + if inspect.isclass(candidate) and issubclass(candidate, DiscolikeRequest): + return candidate return None +def _body_arguments(*, class_name: str, method_name: str, member: object) -> dict[str, Any]: + mapping = BODY_BUILDERS.get((class_name, method_name)) + if mapping is None: + return {} + hints = typing.get_type_hints(member) + return {body_field: hints[argument] for argument, body_field in mapping.items()} + + def collect_routes() -> list[RouteEntry]: seen: dict[tuple[str, str], RouteEntry] = {} for module in _resource_modules(): @@ -92,10 +123,14 @@ def collect_routes() -> list[RouteEntry]: if route is None: continue http_method, path, openapi = route - key = (http_method, path) + key = (class_name, method_name) if key in seen: continue - seen[key] = RouteEntry(class_name, method_name, http_method, path, openapi, _request_model(member)) + body_arguments = _body_arguments(class_name=class_name, method_name=method_name, member=member) + request_model = None if body_arguments else _request_model(member) + seen[key] = RouteEntry( + class_name, method_name, http_method, path, openapi, request_model, body_arguments + ) return list(seen.values()) @@ -143,6 +178,11 @@ def check(spec: dict, routes: list[RouteEntry]) -> list[str]: if operation is None: mismatches.append(f"{label}: route not found in spec") continue + if route.body_arguments: + mismatches.extend( + _check_body_arguments(spec=spec, operation=operation, label=label, arguments=route.body_arguments) + ) + continue spec_fields = _spec_request_fields(spec=spec, operation=operation) if route.request_model is None: mismatches.extend( @@ -151,7 +191,7 @@ def check(spec: dict, routes: list[RouteEntry]) -> list[str]: ) continue model = route.request_model - model_fields = set(model.model_fields) + model_fields = set(model.model_fields) - HIDDEN_REQUEST_FIELDS.get(model.__name__, frozenset()) mismatches.extend( f"{label}: field '{field}' of {model.__name__} not found in spec" for field in sorted(model_fields - spec_fields) @@ -163,6 +203,117 @@ def check(spec: dict, routes: list[RouteEntry]) -> list[str]: return mismatches +def _check_body_arguments(*, spec: dict, operation: dict, label: str, arguments: dict[str, Any]) -> list[str]: + properties = _request_body_properties(spec=spec, operation=operation) + mismatches: list[str] = [] + for body_field, annotation in sorted(arguments.items()): + prop = properties.get(body_field) + if prop is None: + mismatches.append(f"{label}: builds body field '{body_field}' not found in spec") + continue + mismatches.extend( + _mapping_mismatches(spec=spec, label=label, body_field=body_field, annotation=annotation, prop=prop) + ) + return mismatches + + +def _mapping_type(annotation: Any) -> tuple[Any, ...] | None: # noqa: ANN401 -- arbitrary type hint + for candidate in (annotation, *typing.get_args(annotation)): + if typing.get_origin(candidate) is dict: + return typing.get_args(candidate) + return None + + +def _mapping_mismatches(*, spec: dict, label: str, body_field: str, annotation: Any, prop: dict) -> list[str]: # noqa: ANN401 + mapping = _mapping_type(annotation) + if mapping is None: + return [] + key_type, value_type = mapping + variant = next((variant for variant in _type_variants(prop) if "additionalProperties" in variant), None) + if variant is None: + return [f"{label}: body field '{body_field}' is a mapping in the SDK but not in the spec"] + mismatches: list[str] = [] + spec_keys = set(variant.get("propertyNames", {}).get("enum", [])) + if typing.get_origin(key_type) is Literal and not spec_keys: + mismatches.append(f"{label}: spec accepts any key of body field '{body_field}' but the SDK restricts them") + elif typing.get_origin(key_type) is Literal: + sdk_keys = set(typing.get_args(key_type)) + mismatches.extend( + f"{label}: key '{key}' of body field '{body_field}' not found in spec" + for key in sorted(sdk_keys - spec_keys) + ) + mismatches.extend( + f"{label}: spec key '{key}' of body field '{body_field}' not accepted by the SDK" + for key in sorted(spec_keys - sdk_keys) + ) + value_schema = variant["additionalProperties"] + if inspect.isclass(value_type) and issubclass(value_type, DiscolikeRequest) and isinstance(value_schema, dict): + spec_fields = set(_resolve_ref(spec=spec, schema=value_schema).get("properties", {})) + model_fields = set(value_type.model_fields) + mismatches.extend( + f"{label}: field '{name}' of {value_type.__name__} not found in spec" + for name in sorted(model_fields - spec_fields) + ) + mismatches.extend( + f"{label}: spec param '{name}' not declared on {value_type.__name__}" + for name in sorted(spec_fields - model_fields) + ) + return mismatches + + +TYPE_INFO_KEYS = {"type", "anyOf", "oneOf", "$ref", "nullable"} +FieldShape = tuple[frozenset[str], str | None] + + +def _resolved_type(node: dict, *, root: dict) -> str | None: + """A $ref resolves to its target's type, so a named enum alias reads as "string" rather than "object".""" + ref = node.get("$ref") + if ref is None: + return node.get("type") + target: object = root + for part in ref.lstrip("#/").split("/"): + target = target.get(part) if isinstance(target, dict) else None + return target.get("type", "object") if isinstance(target, dict) else "object" + + +def _type_variants(prop: dict) -> list[dict]: + return prop.get("anyOf") or prop.get("oneOf") or [prop] + + +def _field_types(prop: dict, *, root: dict) -> frozenset[str]: + types = { + resolved for variant in _type_variants(prop) if (resolved := _resolved_type(variant, root=root)) is not None + } + if prop.get("nullable"): + types.add("null") + return frozenset(types) + + +def _item_type(prop: dict, *, root: dict) -> str | None: + for variant in _type_variants(prop): + items = variant.get("items") + if items is not None: + return _resolved_type(items, root=root) + return None + + +def _has_type_info(prop: dict) -> bool: + return bool(prop.keys() & TYPE_INFO_KEYS) + + +def _field_shape(prop: dict, *, root: dict) -> FieldShape: + return (_field_types(prop, root=root), _item_type(prop, root=root)) + + +def _describe(shape: FieldShape) -> str: + types = " | ".join(sorted(shape[0])) + return types if shape[1] is None else f"{types} of {shape[1]}" + + +def _accepts(*, model_shape: FieldShape, spec_shape: FieldShape) -> bool: + return spec_shape[0] <= model_shape[0] and ("array" not in spec_shape[0] or spec_shape[1] == model_shape[1]) + + def check_models(spec: dict, mirrored: dict[str, type[DiscolikeModel]] | None = None) -> list[str]: mismatches: list[str] = [] schemas = spec.get("components", {}).get("schemas", {}) @@ -171,7 +322,10 @@ def check_models(spec: dict, mirrored: dict[str, type[DiscolikeModel]] | None = if schema is None: mismatches.append(f"{model.__name__}: schema '{schema_name}' not found in spec") continue - spec_fields = set(schema.get("properties", {}).keys()) + spec_properties = schema.get("properties", {}) + spec_fields = set(spec_properties) + model_schema = model.model_json_schema() + model_properties = model_schema.get("properties", {}) model_fields = set(model.model_fields) mismatches.extend( f"{model.__name__}: field '{field}' not in spec schema '{schema_name}'" @@ -181,6 +335,28 @@ def check_models(spec: dict, mirrored: dict[str, type[DiscolikeModel]] | None = f"{model.__name__}: spec schema '{schema_name}' has field '{field}' the SDK does not declare" for field in sorted(spec_fields - model_fields) ) + + # A fixture that doesn't spell out "type"/"required" info is asserting nothing about it, not + # that nothing is required or typed, so leave those fields alone rather than flag every one. + # Only drift that breaks parsing is flagged: an SDK looser than the spec still reads every response. + if "required" in schema: + mismatches.extend( + f"{model.__name__}: field '{field}' is optional in spec schema '{schema_name}' but required on " + f"the SDK model" + for field in sorted((set(model_schema.get("required", [])) - set(schema["required"])) & spec_fields) + ) + + for field in sorted(model_fields & spec_fields): + spec_prop = spec_properties[field] + if not _has_type_info(spec_prop): + continue + model_shape = _field_shape(model_properties.get(field, {}), root=model_schema) + spec_shape = _field_shape(spec_prop, root=spec) + if not _accepts(model_shape=model_shape, spec_shape=spec_shape): + mismatches.append( + f"{model.__name__}: field '{field}' has type {_describe(model_shape)} but spec schema " + f"'{schema_name}' declares {_describe(spec_shape)}" + ) return mismatches diff --git a/scripts/gen_requests.py b/scripts/gen_requests.py index 43e5f14..162c341 100644 --- a/scripts/gen_requests.py +++ b/scripts/gen_requests.py @@ -50,7 +50,7 @@ "--enum-field-as-literal", "all", "--target-python-version", - "3.10", + "3.11", "--use-double-quotes", "--disable-timestamp", "--formatters", @@ -66,56 +66,6 @@ "parent category is unambiguous adds that parent to the category filter. Call list-industry-categories for " "the label list." ) -_RADIUS_DESCRIPTION = ( - "Search radius around lat/lon: a number optionally suffixed with km or mi (50km, 30mi, 50). A bare number is " - "kilometres. Defaults to 50km when lat/lon are supplied, maximum 1000km." -) -_SHAPE_UNION_SENTENCE = ( - "Repeatable: every geo circle, every bbox and the lat/lon/radius centre are OR'd together, up to 10 " - "shapes in total." -) -_GEO_DESCRIPTION = ( - "A circular area to search, written lat,lon or lat,lon,radius (30.27,-97.74 or 30.27,-97.74,30mi). The " - "radius is a number optionally suffixed with km or mi, a bare number meaning kilometres; it defaults to " - "50km and may not exceed 1000km. " + _SHAPE_UNION_SENTENCE -) -_BBOX_DESCRIPTION = ( - "Bounding box as min_lat,min_lon,max_lat,max_lon. Longitudes may wrap the antimeridian (min_lon above " - "max_lon). " + _SHAPE_UNION_SENTENCE -) -_GEO_PROPERTIES: dict[str, dict[str, Any]] = { - "lat": { - "type": "number", - "minimum": -90.0, - "maximum": 90.0, - "nullable": True, - "description": "Latitude of the search centre. Must be supplied together with lon.", - "title": "Lat", - }, - "lon": { - "type": "number", - "minimum": -180.0, - "maximum": 180.0, - "nullable": True, - "description": "Longitude of the search centre. Must be supplied together with lat.", - "title": "Lon", - }, - "radius": {"type": "string", "nullable": True, "description": _RADIUS_DESCRIPTION, "title": "Radius"}, - "geo": { - "type": "array", - "items": {"type": "string"}, - "nullable": True, - "description": _GEO_DESCRIPTION, - "title": "Geo", - }, - "bbox": { - "type": "array", - "items": {"type": "string"}, - "nullable": True, - "description": _BBOX_DESCRIPTION, - "title": "Bbox", - }, -} _SUB_INDUSTRY_PROPERTIES: dict[str, dict[str, Any]] = { "sub_industry": { "type": "array", @@ -137,24 +87,58 @@ }, } +_CHECKPOINT_MODES = ["ask", "auto"] +_BRIEF_CHECKPOINTS_DESCRIPTION = ( + "ask: pause at checkpoints (pilot, tail_quality, short, target_reached, top_up, contacts_review) with status " + "needs_input and " + "a question to answer through message(). auto: never pause; a poor pilot is sharpened once and the run " + "continues with a notice, stopping with stop_reason pilot_failed only if the re-pilot fit is still " + "under 20%. Either mode pauses at top_up when another round would pass a spending limit, and at contacts_review " + "before a wave's email lookups." +) + # 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]]] = { - "DiscoverParams": _GEO_PROPERTIES, - "CountParams": _GEO_PROPERTIES, + "ProspectingMessageRequest": { + "question_seq": { + "anyOf": [{"minimum": 1, "type": "integer"}, {"type": "null"}], + "default": None, + "description": ( + "The seq of the question message whose suggested reply this is; the reply answers that question " + "only, never a newer one." + ), + "title": "Question Seq", + } + } } # 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 # a server-side normalizer with no client-side counterpart, so generating that enum would reject # values the API accepts. +# ProspectingBrief.checkpoints is SkipJsonSchema on the platform, so the spec never carries it. PROPERTY_OVERRIDES: dict[str, dict[str, dict[str, Any]]] = { "DiscoverParams": _SUB_INDUSTRY_PROPERTIES, "CountParams": _SUB_INDUSTRY_PROPERTIES, + "ProspectingBrief": { + "checkpoints": { + "type": "string", + "enum": _CHECKPOINT_MODES, + "default": "auto", + "description": _BRIEF_CHECKPOINTS_DESCRIPTION, + "title": "Checkpoints", + } + }, } +# The platform names the REST start body ProspectingStartBrief; the SDK keeps the ProspectingBrief name +# callers already import. +SCHEMA_RENAMES: dict[str, str] = {"ProspectingStartBrief": "ProspectingBrief"} + + @dataclass(frozen=True) class Route: class_name: str @@ -227,7 +211,7 @@ def request_schemas(*, spec: dict[str, Any], routes: list[Route]) -> dict[str, d raise SystemExit(f"{route.http_method} {route.path} is not in the spec; run scripts/check_contract.py") body_name = _body_ref_name(operation) if body_name is not None: - requested[body_name] = copy.deepcopy(schemas[body_name]) + requested[SCHEMA_RENAMES.get(body_name, body_name)] = copy.deepcopy(schemas[body_name]) continue synthesized = _synthesize_params_schema(operation) if synthesized is not None: diff --git a/uv.lock b/uv.lock index c07afc0..486c218 100644 --- a/uv.lock +++ b/uv.lock @@ -1,6 +1,6 @@ version = 1 -revision = 3 -requires-python = ">=3.10" +revision = 5 +requires-python = ">=3.11" resolution-markers = [ "python_full_version >= '3.14' and sys_platform == 'emscripten'", "python_full_version >= '3.14' and sys_platform != 'emscripten'", @@ -38,7 +38,6 @@ name = "anyio" version = "4.14.2" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "exceptiongroup", marker = "python_full_version < '3.11'" }, { name = "idna" }, { name = "typing-extensions", marker = "python_full_version < '3.13'" }, ] @@ -69,15 +68,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/b8/c6/6f124bcfbbfb20fba22c939b4e43a06dccfc0e1ca20e5634ca573cb1e271/authlib-1.8.0-py2.py3-none-any.whl", hash = "sha256:88aebbd9af6757e14e912d5dc007ae1dc1f3e27e3b2152ce7c552ee2c3b3c121", size = 260804, upload-time = "2026-08-30T12:12:33.162Z" }, ] -[[package]] -name = "backports-asyncio-runner" -version = "1.2.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/8e/ff/70dca7d7cb1cbc0edb2c6cc0c38b65cba36cccc491eca64cabd5fe7f8670/backports_asyncio_runner-1.2.0.tar.gz", hash = "sha256:a5aa7b2b7d8f8bfcaa2b57313f70792df84e32a2a746f585213373f900b42162", size = 69893, upload-time = "2025-07-02T02:27:15.685Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/a0/59/76ab57e3fe74484f48a53f8e337171b4a2349e506eabe136d7e01d059086/backports_asyncio_runner-1.2.0-py3-none-any.whl", hash = "sha256:0da0a936a8aeb554eccb426dc55af3ba63bcdc69fa1a600b5bb305413a4477b5", size = 12313, upload-time = "2025-07-02T02:27:14.263Z" }, -] - [[package]] name = "black" version = "26.5.1" @@ -89,16 +79,9 @@ dependencies = [ { name = "pathspec" }, { name = "platformdirs" }, { name = "pytokens" }, - { name = "tomli", marker = "python_full_version < '3.11'" }, - { name = "typing-extensions", marker = "python_full_version < '3.11'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/c0/37/5628dd55bf2b34257fc7603f0fe97c40e3aaf24265f416a9c85c95ca1436/black-26.5.1.tar.gz", hash = "sha256:dd321f668053961824bcc1be1cc1df748b2d7e4fa28086b08331e577b0100a73", size = 679439, upload-time = "2026-05-18T16:53:36.107Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/be/84/b3f55026206a9e8820a91503308075ca48eadc515e436731ca01dbe043b3/black-26.5.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:9942db8888e06943c5dde66ca0037dcff82a2a4ec1ad0ada9e0d2ee9d9823893", size = 1987719, upload-time = "2026-05-18T17:05:02.757Z" }, - { url = "https://files.pythonhosted.org/packages/c6/34/7db312c5e5783d6e76cffd9d5ac8972a32badae4c6e3288dac0eed8d3bed/black-26.5.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:89c93167a74d3a75dfaa38a5c7cca015537d5820dd7f17d63267d674a61cae90", size = 1810083, upload-time = "2026-05-18T17:05:04.302Z" }, - { url = "https://files.pythonhosted.org/packages/33/e2/e0101e73c2c8727634e2efcb35e2b34bd23ad70dfa673789f5773a591b21/black-26.5.1-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:22f2cd76d069cc54c71f10360744ba8983fbb616903b4304a85b734915c8e1b4", size = 1860633, upload-time = "2026-05-18T17:05:06.391Z" }, - { url = "https://files.pythonhosted.org/packages/b0/4c/e15c0c5b23cf3651035fe5addcce90e283af3548a3f91bb03d81b83106ab/black-26.5.1-cp310-cp310-win_amd64.whl", hash = "sha256:87ed5c6f450580a2f6790bc7cbfb016dfc73bc750249762268a3695361315eef", size = 1477886, upload-time = "2026-05-18T17:05:07.96Z" }, - { url = "https://files.pythonhosted.org/packages/9f/3f/59d43ade98d2ce5c8dc34a4e46cbecd177e6d55d7d4092969c6003ccc655/black-26.5.1-cp310-cp310-win_arm64.whl", hash = "sha256:58b4bd92cf88aacf83d88479c8f9caee044b1ec55f2451a337354a7ea2590a22", size = 1277111, upload-time = "2026-05-18T17:05:09.473Z" }, { url = "https://files.pythonhosted.org/packages/4b/96/3c3e09f09f44a37aac36b178a279cd19aa7001bd796187a7b162a294c81f/black-26.5.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:96ae2c733b2aabdd9986e2c5df628ff3473676cd1c5faded1ff496cf6d74083c", size = 1970639, upload-time = "2026-05-18T17:05:11.461Z" }, { url = "https://files.pythonhosted.org/packages/83/ea/5ad117b9ee3ecd933c712bcbae610006e5b7cc9f41c526cd7ed3b6c4124c/black-26.5.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:0e48b87e03bf109288e55cfceadcfa15ff5470aca2851a851950ed2926f450d7", size = 1792130, upload-time = "2026-05-18T17:05:12.983Z" }, { url = "https://files.pythonhosted.org/packages/06/3a/7c448bc623fcdfa96672531beb5a616ea5e64f6975955254d7731ffb0ad9/black-26.5.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5119fa92ae61f786e8c3662fd60aece1d0a2dd5cca5d0c79417a95e7a4272a59", size = 1846134, upload-time = "2026-05-18T17:05:14.506Z" }, @@ -131,18 +114,6 @@ dependencies = [ ] sdist = { url = "https://files.pythonhosted.org/packages/9e/ef/008a1939e372c06329a3fce4279c02f328488f3526744906eeec3da7ad5f/cffi-2.1.1.tar.gz", hash = "sha256:dd31f52ea1086513bb9df30f8fcee9b8918323ae067a3d5b78bc826a000712be", size = 530807, upload-time = "2026-08-03T21:21:18.939Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/b6/d2/2cde336b375f55c76ca670f0be3978cc048e31e24f3b4d7ce8473150a388/cffi-2.1.1-cp310-cp310-macosx_10_15_x86_64.whl", hash = "sha256:baed1e86cc735622097354b9d1281406caf42ff42a886d29faa8e8d1630333be", size = 183779, upload-time = "2026-08-03T21:19:15.602Z" }, - { url = "https://files.pythonhosted.org/packages/94/1a/4b2f7c92293ba05cbd4a9a1b28faaf0326272d9488e6354657571c48a7aa/cffi-2.1.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:ca82be1a1d406ecfe1d25dc16cb33488e5a16bf4438c9fb590484ea29d92478b", size = 184178, upload-time = "2026-08-03T21:19:16.67Z" }, - { url = "https://files.pythonhosted.org/packages/17/0b/ba385d8ccedf926c3cd06e8e2f327027da5afe5f0eb30f1f7bc43ac55125/cffi-2.1.1-cp310-cp310-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:42e2f76b9455f5a9a844f770bf3e200ed3da0e15f5df3db9c31fe80b04b3d004", size = 211037, upload-time = "2026-08-03T21:19:17.705Z" }, - { url = "https://files.pythonhosted.org/packages/a3/b9/0f2e58b2cefa33255bff36935d42b13180fe559bba82596540eb404bde7d/cffi-2.1.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:5a59cc1c4442bc3d5c703bf720b51138d0bfc173618807c9ee2490a7541dd3d9", size = 218652, upload-time = "2026-08-03T21:19:18.735Z" }, - { url = "https://files.pythonhosted.org/packages/37/15/180e0dab27b9312c7479003d14c9e547634b7dcb934e2cc4650e1b131a7a/cffi-2.1.1-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:9f8d177621de5cb38ee3e731eda45d421db093ec0739f46a5594babda7987a98", size = 205422, upload-time = "2026-08-03T21:19:19.96Z" }, - { url = "https://files.pythonhosted.org/packages/18/d4/03026f0c850cbbaa9030750490225b4a7f4d524ea4df72c3cc740a90f4ef/cffi-2.1.1-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:75f80557d1389eddbd0de2681f6a390a0c5338c31ddaa821381c203fc3fd50d9", size = 205444, upload-time = "2026-08-03T21:19:21.246Z" }, - { url = "https://files.pythonhosted.org/packages/75/77/60bebf6f818bec84210ac5b6979ce4eeadce6fbbaabc9c7ab23e506d1ce5/cffi-2.1.1-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:194cffa889098ced9976c3fc6340305e43f6303657d298da55366907c05c22d6", size = 218742, upload-time = "2026-08-03T21:19:22.523Z" }, - { url = "https://files.pythonhosted.org/packages/b0/ae/679bf47e73fd77b352171727f07de559a003f14de5d02b904a6ec1fa73ca/cffi-2.1.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:5bb4e7ea95dcd6a014a6fef62e62467d67d8e582326443f3d68e71d6320a9fcf", size = 221054, upload-time = "2026-08-03T21:19:23.694Z" }, - { url = "https://files.pythonhosted.org/packages/09/b8/eefc0e06913b70aa153bf74c946094a18f58fd4aff11b7f372bfdfdca050/cffi-2.1.1-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:3d22a20b1fb1632cc72c22f95f7b0d2961c3e1c235f245ba4c606c4771035659", size = 213489, upload-time = "2026-08-03T21:19:24.922Z" }, - { url = "https://files.pythonhosted.org/packages/6f/13/4e56852824a03cdf68523a35686f1c28eacd4bd30a7b0a78e682e6e6e1d3/cffi-2.1.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:1dea0e4d7d4f11f619fe8c1d76caf49e24405b4b5743c0e3be16a500ecd930c9", size = 220241, upload-time = "2026-08-03T21:19:26.214Z" }, - { url = "https://files.pythonhosted.org/packages/99/7f/040f9e163e4acac3ee3d85b02d00b2576e7ca980d8785f0a3a5f1a9bf7f5/cffi-2.1.1-cp310-cp310-win32.whl", hash = "sha256:7ce713ace7c0e4520535b42b77eaa742c16dab813978064913e5a3cf82973b41", size = 174578, upload-time = "2026-08-03T21:19:27.338Z" }, - { url = "https://files.pythonhosted.org/packages/ba/0b/644a2ec1a4eaba49c2939410bb1eb1d25b09d6d0582f5d2f95c537043725/cffi-2.1.1-cp310-cp310-win_amd64.whl", hash = "sha256:a48d62ab9d6f4f98c983223a547af44be6ca3691074c31cecced6facd3ba2dc1", size = 185082, upload-time = "2026-08-03T21:19:28.409Z" }, { url = "https://files.pythonhosted.org/packages/70/d2/16d99a0c4948febc0ebd133a13b2f688ff7f8cb04da971e1128872ce0c03/cffi-2.1.1-cp311-cp311-macosx_10_15_x86_64.whl", hash = "sha256:c8d2c9fd1f2d16f780d15127abb050d13d1a76c03a4bd87d7e4980e45e511e12", size = 183838, upload-time = "2026-08-03T21:19:29.637Z" }, { url = "https://files.pythonhosted.org/packages/cd/95/31b535a9f0220ae9f357de4a08d57ce89cb417653c2fd9f075f50822a388/cffi-2.1.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:398aff33cee2767e3e781d2554c54bd0dff386bb437581e0d8011fde1a942ec1", size = 184168, upload-time = "2026-08-03T21:19:30.764Z" }, { url = "https://files.pythonhosted.org/packages/ad/5a/4707a0dc1f203f5dde5a907b0d4e3c25d71120241048bd5bc6f1bb9d4e71/cffi-2.1.1-cp311-cp311-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:154852545011f779917b11c78db2358d095da62a9a172b78ad0a583ee5adc0d0", size = 211805, upload-time = "2026-08-03T21:19:31.867Z" }, @@ -256,7 +227,6 @@ version = "50.0.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "cffi", marker = "platform_python_implementation != 'PyPy'" }, - { name = "typing-extensions", marker = "python_full_version < '3.11'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/bb/ad/5d6702db60b1e40b41ef513b6967ff5848f307d50f8449baf1634f5908f1/cryptography-50.0.1.tar.gz", hash = "sha256:5dd9bda1c12b4162f6ff568eeb5e0ff956c28d14406e875cfe8a63a2d414ff20", size = 880381, upload-time = "2026-08-25T19:45:45.499Z" } wheels = [ @@ -320,7 +290,6 @@ dependencies = [ { name = "jinja2" }, { name = "pydantic" }, { name = "pyyaml" }, - { name = "tomli", marker = "python_full_version < '3.11'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/59/d2/187e101ed143f5280079a57c7c7e3d702609826aadd85c095fd5a8267af9/datamodel_code_generator-0.75.1.tar.gz", hash = "sha256:d2053a889161af7e495a3eedb92b68c81848e6ea7988d467c602e5b2da30f018", size = 2116531, upload-time = "2026-08-24T19:16:37.119Z" } wheels = [ @@ -374,7 +343,7 @@ dev = [ [[package]] name = "discolike-cli" -version = "0.4.1" +version = "0.5.0" source = { editable = "packages/discolike-cli" } dependencies = [ { name = "discolike" }, @@ -424,18 +393,6 @@ requires-dist = [ { name = "pytest", specifier = ">=8" }, ] -[[package]] -name = "exceptiongroup" -version = "1.3.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "typing-extensions" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/50/79/66800aadf48771f6b62f7eb014e352e5d06856655206165d775e675a02c9/exceptiongroup-1.3.1.tar.gz", hash = "sha256:8b412432c6055b0b7d14c310000ae93352ed6754f70fa8f7c34141f91c4e3219", size = 30371, upload-time = "2025-11-21T23:01:54.787Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/8a/0e/97c33bf5009bdbac74fd2beace167cab3f978feb69cc36f1ef79360d6c4e/exceptiongroup-1.3.1-py3-none-any.whl", hash = "sha256:a7a39a3bd276781e98394987d3a5701d0c4edffb633bb7a5144577f82c773598", size = 16740, upload-time = "2025-11-21T23:01:53.443Z" }, -] - [[package]] name = "genson" version = "1.4.0" @@ -575,17 +532,6 @@ version = "3.0.3" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/7e/99/7690b6d4034fffd95959cbe0c02de8deb3098cc577c67bb6a24fe5d7caa7/markupsafe-3.0.3.tar.gz", hash = "sha256:722695808f4b6457b320fdc131280796bdceb04ab50fe1795cd540799ebe1698", size = 80313, upload-time = "2025-09-27T18:37:40.426Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/e8/4b/3541d44f3937ba468b75da9eebcae497dcf67adb65caa16760b0a6807ebb/markupsafe-3.0.3-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:2f981d352f04553a7171b8e44369f2af4055f888dfb147d55e42d29e29e74559", size = 11631, upload-time = "2025-09-27T18:36:05.558Z" }, - { url = "https://files.pythonhosted.org/packages/98/1b/fbd8eed11021cabd9226c37342fa6ca4e8a98d8188a8d9b66740494960e4/markupsafe-3.0.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:e1c1493fb6e50ab01d20a22826e57520f1284df32f2d8601fdd90b6304601419", size = 12057, upload-time = "2025-09-27T18:36:07.165Z" }, - { url = "https://files.pythonhosted.org/packages/40/01/e560d658dc0bb8ab762670ece35281dec7b6c1b33f5fbc09ebb57a185519/markupsafe-3.0.3-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1ba88449deb3de88bd40044603fafffb7bc2b055d626a330323a9ed736661695", size = 22050, upload-time = "2025-09-27T18:36:08.005Z" }, - { url = "https://files.pythonhosted.org/packages/af/cd/ce6e848bbf2c32314c9b237839119c5a564a59725b53157c856e90937b7a/markupsafe-3.0.3-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f42d0984e947b8adf7dd6dde396e720934d12c506ce84eea8476409563607591", size = 20681, upload-time = "2025-09-27T18:36:08.881Z" }, - { url = "https://files.pythonhosted.org/packages/c9/2a/b5c12c809f1c3045c4d580b035a743d12fcde53cf685dbc44660826308da/markupsafe-3.0.3-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c0c0b3ade1c0b13b936d7970b1d37a57acde9199dc2aecc4c336773e1d86049c", size = 20705, upload-time = "2025-09-27T18:36:10.131Z" }, - { url = "https://files.pythonhosted.org/packages/cf/e3/9427a68c82728d0a88c50f890d0fc072a1484de2f3ac1ad0bfc1a7214fd5/markupsafe-3.0.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:0303439a41979d9e74d18ff5e2dd8c43ed6c6001fd40e5bf2e43f7bd9bbc523f", size = 21524, upload-time = "2025-09-27T18:36:11.324Z" }, - { url = "https://files.pythonhosted.org/packages/bc/36/23578f29e9e582a4d0278e009b38081dbe363c5e7165113fad546918a232/markupsafe-3.0.3-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:d2ee202e79d8ed691ceebae8e0486bd9a2cd4794cec4824e1c99b6f5009502f6", size = 20282, upload-time = "2025-09-27T18:36:12.573Z" }, - { url = "https://files.pythonhosted.org/packages/56/21/dca11354e756ebd03e036bd8ad58d6d7168c80ce1fe5e75218e4945cbab7/markupsafe-3.0.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:177b5253b2834fe3678cb4a5f0059808258584c559193998be2601324fdeafb1", size = 20745, upload-time = "2025-09-27T18:36:13.504Z" }, - { url = "https://files.pythonhosted.org/packages/87/99/faba9369a7ad6e4d10b6a5fbf71fa2a188fe4a593b15f0963b73859a1bbd/markupsafe-3.0.3-cp310-cp310-win32.whl", hash = "sha256:2a15a08b17dd94c53a1da0438822d70ebcd13f8c3a95abe3a9ef9f11a94830aa", size = 14571, upload-time = "2025-09-27T18:36:14.779Z" }, - { url = "https://files.pythonhosted.org/packages/d6/25/55dc3ab959917602c96985cb1253efaa4ff42f71194bddeb61eb7278b8be/markupsafe-3.0.3-cp310-cp310-win_amd64.whl", hash = "sha256:c4ffb7ebf07cfe8931028e3e4c85f0357459a3f9f9490886198848f4fa002ec8", size = 15056, upload-time = "2025-09-27T18:36:16.125Z" }, - { url = "https://files.pythonhosted.org/packages/d0/9e/0a02226640c255d1da0b8d12e24ac2aa6734da68bff14c05dd53b94a0fc3/markupsafe-3.0.3-cp310-cp310-win_arm64.whl", hash = "sha256:e2103a929dfa2fcaf9bb4e7c091983a49c9ac3b19c9061b6d5427dd7d14d81a1", size = 13932, upload-time = "2025-09-27T18:36:17.311Z" }, { url = "https://files.pythonhosted.org/packages/08/db/fefacb2136439fc8dd20e797950e749aa1f4997ed584c62cfb8ef7c2be0e/markupsafe-3.0.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:1cc7ea17a6824959616c525620e387f6dd30fec8cb44f649e31712db02123dad", size = 11631, upload-time = "2025-09-27T18:36:18.185Z" }, { url = "https://files.pythonhosted.org/packages/e1/2e/5898933336b61975ce9dc04decbc0a7f2fee78c30353c5efba7f2d6ff27a/markupsafe-3.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4bd4cd07944443f5a265608cc6aab442e4f74dff8088b0dfc8238647b8f6ae9a", size = 12058, upload-time = "2025-09-27T18:36:19.444Z" }, { url = "https://files.pythonhosted.org/packages/1d/09/adf2df3699d87d1d8184038df46a9c80d78c0148492323f4693df54e17bb/markupsafe-3.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6b5420a1d9450023228968e7e6a9ce57f65d148ab56d2313fcd589eee96a7a50", size = 24287, upload-time = "2025-09-27T18:36:20.768Z" }, @@ -750,20 +696,6 @@ dependencies = [ ] sdist = { url = "https://files.pythonhosted.org/packages/9d/56/921726b776ace8d8f5db44c4ef961006580d91dc52b803c489fafd1aa249/pydantic_core-2.46.4.tar.gz", hash = "sha256:62f875393d7f270851f20523dd2e29f082bcc82292d66db2b64ea71f64b6e1c1", size = 471464, upload-time = "2026-05-06T13:37:06.98Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/e7/08/f1ba952f1c8ae5581c70fa9c6da89f247b83e3dd8c09c035d5d7931fc23d/pydantic_core-2.46.4-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:a396dcc17e5a0b164dbe026896245a4fa9ff402edca1dff0be3d53a517f74de4", size = 2113146, upload-time = "2026-05-06T13:37:36.537Z" }, - { url = "https://files.pythonhosted.org/packages/56/c6/65f646c7ff09bd257f660434adb45c4dfcbbcebcc030562fecf6f5bf887d/pydantic_core-2.46.4-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:da4b951fe36dc7c3a1ccb4e3cd1747c3542b8c9ceede8fc86cae054e764485f5", size = 1949769, upload-time = "2026-05-06T13:37:46.365Z" }, - { url = "https://files.pythonhosted.org/packages/64/ba/bfb1d928fd5b49e1258935ff104ae356e9fd89384a55bf9f847e9193ad40/pydantic_core-2.46.4-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bb63e0198ca18aad131c089b9204c23079c3afa95487e561f4c522d519e55aba", size = 1974958, upload-time = "2026-05-06T13:37:28.611Z" }, - { url = "https://files.pythonhosted.org/packages/4e/74/76223bfb117b64af743c9b6670d1364516f5c0604f96b48f3272f6af6cc6/pydantic_core-2.46.4-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f47286a97f0bc9b8859519809077b91b2cefe4ae47fcbf5e466a009c1c5d742b", size = 2042118, upload-time = "2026-05-06T13:36:55.216Z" }, - { url = "https://files.pythonhosted.org/packages/cb/7b/848732968bc8f48f3187542f08358b9d842db564147b256669426ebb1652/pydantic_core-2.46.4-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:905a0ed8ea6f2d61c1738835f99b699348d7857379083e5fc497fa0c967a407c", size = 2222876, upload-time = "2026-05-06T13:38:25.455Z" }, - { url = "https://files.pythonhosted.org/packages/b5/2f/e90b63ee2e14bd8d3db8f705a6d75d64e6ee1b7c2c8833747ce706e1e0ce/pydantic_core-2.46.4-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ea793e075b70290d89d8142074262885d3f7da19634845135751bd6344f73b50", size = 2286703, upload-time = "2026-05-06T13:37:53.304Z" }, - { url = "https://files.pythonhosted.org/packages/ba/1e/acc4d70f88a0a277e4a1fa77ebb985ceabaf900430f875bf9338e11c9420/pydantic_core-2.46.4-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:395aebd9183f9d112f569aeb5b2214d1a10a33bec8456447f7fbdfa51d38d4cd", size = 2092042, upload-time = "2026-05-06T13:38:46.981Z" }, - { url = "https://files.pythonhosted.org/packages/a9/da/0a422b57bf8504102bf3c4ccea9c41bab5a5cee6a54650acf8faf67f5a24/pydantic_core-2.46.4-cp310-cp310-manylinux_2_31_riscv64.whl", hash = "sha256:b078afbc25f3a1436c7a1d2cd3e322497ee99615ba97c563566fdf46aff1ee01", size = 2117231, upload-time = "2026-05-06T13:39:23.146Z" }, - { url = "https://files.pythonhosted.org/packages/bd/2a/2ac13c3af305843e23c5078c53d135656b3f05a2fd78cb7bbbb12e97b473/pydantic_core-2.46.4-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:f747929cf940cddb5b3668a390056ddd5ba2e5010615ea2dcf4f9c4f3ab8791d", size = 2168388, upload-time = "2026-05-06T13:40:08.06Z" }, - { url = "https://files.pythonhosted.org/packages/72/04/2beacf7e1607e93eefe4aed1b4709f079b905fb77530179d4f7c71745f22/pydantic_core-2.46.4-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:daa27d92c36f24388fe3ad306b174781c747627f134452e4f128ea00ce1fe8c4", size = 2184769, upload-time = "2026-05-06T13:38:13.901Z" }, - { url = "https://files.pythonhosted.org/packages/9e/29/d2b9fd9f539133548eaf622c06a4ce176cb46ac59f32d0359c4abc0de047/pydantic_core-2.46.4-cp310-cp310-musllinux_1_1_armv7l.whl", hash = "sha256:19e51f073cd3df251856a8a4189fbdf1de4012c3ebacfb1884f94f1eb406079f", size = 2319312, upload-time = "2026-05-06T13:39:08.24Z" }, - { url = "https://files.pythonhosted.org/packages/7c/af/0f7a5b85fec6075bea96e3ef9187de38fccced0de92c1e7feda8d5cc7bb9/pydantic_core-2.46.4-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:c1747f85cee84c26985853c6f3d9bd3e75da5212912443fa111c113b9c246f39", size = 2361817, upload-time = "2026-05-06T13:38:43.2Z" }, - { url = "https://files.pythonhosted.org/packages/25/a4/73363fec545fd3ec025490bdda2743c56d0dd5b6266b1a53bbe9e4265375/pydantic_core-2.46.4-cp310-cp310-win32.whl", hash = "sha256:2f84c03c8607173d16b5a854ec68a2f9079ae03237a54fb506d13af47e1d018d", size = 1987085, upload-time = "2026-05-06T13:39:25.497Z" }, - { url = "https://files.pythonhosted.org/packages/01/aa/62f082da2c91fac1c234bc9ee0066257ce83f0604abd72e4c9d5991f2d84/pydantic_core-2.46.4-cp310-cp310-win_amd64.whl", hash = "sha256:8358a950c8909158e3df31538a7e4edc2d7265a7c54b47f0864d9e5bae9dcebf", size = 2074311, upload-time = "2026-05-06T13:39:59.922Z" }, { url = "https://files.pythonhosted.org/packages/5c/fa/6d7708d2cfc1a832acb6aeb0cd16e801902df8a0f583bb3b4b527fde022e/pydantic_core-2.46.4-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:0e96592440881c74a213e5ad528e2b24d3d4f940de2766bed9010ab1d9e51594", size = 2111872, upload-time = "2026-05-06T13:40:27.596Z" }, { url = "https://files.pythonhosted.org/packages/ae/6f/aa064a3e74b5745afbdf250594f38e7ead05e2d651bcb35994b9417a0d4d/pydantic_core-2.46.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:e0d65b8c354be7fb5f720c3caa8bc940bc2d20ce749c8e06135f07f8ed95dd7c", size = 1948255, upload-time = "2026-05-06T13:39:12.574Z" }, { url = "https://files.pythonhosted.org/packages/43/3a/41114a9f7569b84b4d84e7a018c57c56347dac30c0d4a872946ec4e36c46/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7bfb192b3f4b9e8a89b6277b6ce787564f62cfd272055f6e685726b111dc7826", size = 1972827, upload-time = "2026-05-06T13:38:19.841Z" }, @@ -872,12 +804,10 @@ version = "9.1.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "colorama", marker = "sys_platform == 'win32'" }, - { name = "exceptiongroup", marker = "python_full_version < '3.11'" }, { name = "iniconfig" }, { name = "packaging" }, { name = "pluggy" }, { name = "pygments" }, - { name = "tomli", marker = "python_full_version < '3.11'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/e4/47/b9efed96c114afcfa3c9d3fe98a76a1d14c74a9e266d397cf6eb64be5e01/pytest-9.1.1.tar.gz", hash = "sha256:1088fbde8f2b49d95a549a195707afa7a76a3ce9bcadc26b6d71f0ffda5fe313", size = 1636369, upload-time = "2026-06-19T10:58:32.857Z" } wheels = [ @@ -889,7 +819,6 @@ name = "pytest-asyncio" version = "1.4.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "backports-asyncio-runner", marker = "python_full_version < '3.11'" }, { name = "pytest" }, { name = "typing-extensions", marker = "python_full_version < '3.13'" }, ] @@ -904,11 +833,6 @@ version = "0.4.1" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/b6/34/b4e015b99031667a7b960f888889c5bd34ef585c85e1cb56a594b92836ac/pytokens-0.4.1.tar.gz", hash = "sha256:292052fe80923aae2260c073f822ceba21f3872ced9a68bb7953b348e561179a", size = 23015, upload-time = "2026-01-30T01:03:45.924Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/42/24/f206113e05cb8ef51b3850e7ef88f20da6f4bf932190ceb48bd3da103e10/pytokens-0.4.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:2a44ed93ea23415c54f3face3b65ef2b844d96aeb3455b8a69b3df6beab6acc5", size = 161522, upload-time = "2026-01-30T01:02:50.393Z" }, - { url = "https://files.pythonhosted.org/packages/d4/e9/06a6bf1b90c2ed81a9c7d2544232fe5d2891d1cd480e8a1809ca354a8eb2/pytokens-0.4.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:add8bf86b71a5d9fb5b89f023a80b791e04fba57960aa790cc6125f7f1d39dfe", size = 246945, upload-time = "2026-01-30T01:02:52.399Z" }, - { url = "https://files.pythonhosted.org/packages/69/66/f6fb1007a4c3d8b682d5d65b7c1fb33257587a5f782647091e3408abe0b8/pytokens-0.4.1-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:670d286910b531c7b7e3c0b453fd8156f250adb140146d234a82219459b9640c", size = 259525, upload-time = "2026-01-30T01:02:53.737Z" }, - { url = "https://files.pythonhosted.org/packages/04/92/086f89b4d622a18418bac74ab5db7f68cf0c21cf7cc92de6c7b919d76c88/pytokens-0.4.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:4e691d7f5186bd2842c14813f79f8884bb03f5995f0575272009982c5ac6c0f7", size = 262693, upload-time = "2026-01-30T01:02:54.871Z" }, - { url = "https://files.pythonhosted.org/packages/b4/7b/8b31c347cf94a3f900bdde750b2e9131575a61fdb620d3d3c75832262137/pytokens-0.4.1-cp310-cp310-win_amd64.whl", hash = "sha256:27b83ad28825978742beef057bfe406ad6ed524b2d28c252c5de7b4a6dd48fa2", size = 103567, upload-time = "2026-01-30T01:02:56.414Z" }, { url = "https://files.pythonhosted.org/packages/3d/92/790ebe03f07b57e53b10884c329b9a1a308648fc083a6d4a39a10a28c8fc/pytokens-0.4.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:d70e77c55ae8380c91c0c18dea05951482e263982911fc7410b1ffd1dadd3440", size = 160864, upload-time = "2026-01-30T01:02:57.882Z" }, { url = "https://files.pythonhosted.org/packages/13/25/a4f555281d975bfdd1eba731450e2fe3a95870274da73fb12c40aeae7625/pytokens-0.4.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4a58d057208cb9075c144950d789511220b07636dd2e4708d5645d24de666bdc", size = 248565, upload-time = "2026-01-30T01:02:59.912Z" }, { url = "https://files.pythonhosted.org/packages/17/50/bc0394b4ad5b1601be22fa43652173d47e4c9efbf0044c62e9a59b747c56/pytokens-0.4.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b49750419d300e2b5a3813cf229d4e5a4c728dae470bcc89867a9ad6f25a722d", size = 260824, upload-time = "2026-01-30T01:03:01.471Z" }, @@ -943,15 +867,6 @@ version = "6.0.3" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960, upload-time = "2025-09-25T21:33:16.546Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/f4/a0/39350dd17dd6d6c6507025c0e53aef67a9293a6d37d3511f23ea510d5800/pyyaml-6.0.3-cp310-cp310-macosx_10_13_x86_64.whl", hash = "sha256:214ed4befebe12df36bcc8bc2b64b396ca31be9304b8f59e25c11cf94a4c033b", size = 184227, upload-time = "2025-09-25T21:31:46.04Z" }, - { url = "https://files.pythonhosted.org/packages/05/14/52d505b5c59ce73244f59c7a50ecf47093ce4765f116cdb98286a71eeca2/pyyaml-6.0.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:02ea2dfa234451bbb8772601d7b8e426c2bfa197136796224e50e35a78777956", size = 174019, upload-time = "2025-09-25T21:31:47.706Z" }, - { url = "https://files.pythonhosted.org/packages/43/f7/0e6a5ae5599c838c696adb4e6330a59f463265bfa1e116cfd1fbb0abaaae/pyyaml-6.0.3-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b30236e45cf30d2b8e7b3e85881719e98507abed1011bf463a8fa23e9c3e98a8", size = 740646, upload-time = "2025-09-25T21:31:49.21Z" }, - { url = "https://files.pythonhosted.org/packages/2f/3a/61b9db1d28f00f8fd0ae760459a5c4bf1b941baf714e207b6eb0657d2578/pyyaml-6.0.3-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:66291b10affd76d76f54fad28e22e51719ef9ba22b29e1d7d03d6777a9174198", size = 840793, upload-time = "2025-09-25T21:31:50.735Z" }, - { url = "https://files.pythonhosted.org/packages/7a/1e/7acc4f0e74c4b3d9531e24739e0ab832a5edf40e64fbae1a9c01941cabd7/pyyaml-6.0.3-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9c7708761fccb9397fe64bbc0395abcae8c4bf7b0eac081e12b809bf47700d0b", size = 770293, upload-time = "2025-09-25T21:31:51.828Z" }, - { url = "https://files.pythonhosted.org/packages/8b/ef/abd085f06853af0cd59fa5f913d61a8eab65d7639ff2a658d18a25d6a89d/pyyaml-6.0.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:418cf3f2111bc80e0933b2cd8cd04f286338bb88bdc7bc8e6dd775ebde60b5e0", size = 732872, upload-time = "2025-09-25T21:31:53.282Z" }, - { url = "https://files.pythonhosted.org/packages/1f/15/2bc9c8faf6450a8b3c9fc5448ed869c599c0a74ba2669772b1f3a0040180/pyyaml-6.0.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:5e0b74767e5f8c593e8c9b5912019159ed0533c70051e9cce3e8b6aa699fcd69", size = 758828, upload-time = "2025-09-25T21:31:54.807Z" }, - { url = "https://files.pythonhosted.org/packages/a3/00/531e92e88c00f4333ce359e50c19b8d1de9fe8d581b1534e35ccfbc5f393/pyyaml-6.0.3-cp310-cp310-win32.whl", hash = "sha256:28c8d926f98f432f88adc23edf2e6d4921ac26fb084b028c733d01868d19007e", size = 142415, upload-time = "2025-09-25T21:31:55.885Z" }, - { url = "https://files.pythonhosted.org/packages/2a/fa/926c003379b19fca39dd4634818b00dec6c62d87faf628d1394e137354d4/pyyaml-6.0.3-cp310-cp310-win_amd64.whl", hash = "sha256:bdb2c67c6c1390b63c6ff89f210c8fd09d9a1217a465701eac7316313c915e4c", size = 158561, upload-time = "2025-09-25T21:31:57.406Z" }, { url = "https://files.pythonhosted.org/packages/6d/16/a95b6757765b7b031c9374925bb718d55e0a9ba8a1b6a12d25962ea44347/pyyaml-6.0.3-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:44edc647873928551a01e7a563d7452ccdebee747728c1080d881d68af7b997e", size = 185826, upload-time = "2025-09-25T21:31:58.655Z" }, { url = "https://files.pythonhosted.org/packages/16/19/13de8e4377ed53079ee996e1ab0a9c33ec2faf808a4647b7b4c0d46dd239/pyyaml-6.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:652cb6edd41e718550aad172851962662ff2681490a8a711af6a4d288dd96824", size = 175577, upload-time = "2025-09-25T21:32:00.088Z" }, { url = "https://files.pythonhosted.org/packages/0c/62/d2eb46264d4b157dae1275b573017abec435397aa59cbcdab6fc978a8af4/pyyaml-6.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:10892704fc220243f5305762e276552a0395f7beb4dbf9b14ec8fd43b57f126c", size = 775556, upload-time = "2025-09-25T21:32:01.31Z" }, @@ -1048,60 +963,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/e0/f9/0595336914c5619e5f28a1fb793285925a8cd4b432c9da0a987836c7f822/shellingham-1.5.4-py2.py3-none-any.whl", hash = "sha256:7ecfff8f2fd72616f7481040475a65b2bf8af90a56c89140852d1120324e8686", size = 9755, upload-time = "2023-10-24T04:13:38.866Z" }, ] -[[package]] -name = "tomli" -version = "2.4.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/22/de/48c59722572767841493b26183a0d1cc411d54fd759c5607c4590b6563a6/tomli-2.4.1.tar.gz", hash = "sha256:7c7e1a961a0b2f2472c1ac5b69affa0ae1132c39adcb67aba98568702b9cc23f", size = 17543, upload-time = "2026-03-25T20:22:03.828Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/f4/11/db3d5885d8528263d8adc260bb2d28ebf1270b96e98f0e0268d32b8d9900/tomli-2.4.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:f8f0fc26ec2cc2b965b7a3b87cd19c5c6b8c5e5f436b984e85f486d652285c30", size = 154704, upload-time = "2026-03-25T20:21:10.473Z" }, - { url = "https://files.pythonhosted.org/packages/6d/f7/675db52c7e46064a9aa928885a9b20f4124ecb9bc2e1ce74c9106648d202/tomli-2.4.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4ab97e64ccda8756376892c53a72bd1f964e519c77236368527f758fbc36a53a", size = 149454, upload-time = "2026-03-25T20:21:12.036Z" }, - { url = "https://files.pythonhosted.org/packages/61/71/81c50943cf953efa35bce7646caab3cf457a7d8c030b27cfb40d7235f9ee/tomli-2.4.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:96481a5786729fd470164b47cdb3e0e58062a496f455ee41b4403be77cb5a076", size = 237561, upload-time = "2026-03-25T20:21:13.098Z" }, - { url = "https://files.pythonhosted.org/packages/48/c1/f41d9cb618acccca7df82aaf682f9b49013c9397212cb9f53219e3abac37/tomli-2.4.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5a881ab208c0baf688221f8cecc5401bd291d67e38a1ac884d6736cbcd8247e9", size = 243824, upload-time = "2026-03-25T20:21:14.569Z" }, - { url = "https://files.pythonhosted.org/packages/22/e4/5a816ecdd1f8ca51fb756ef684b90f2780afc52fc67f987e3c61d800a46d/tomli-2.4.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:47149d5bd38761ac8be13a84864bf0b7b70bc051806bc3669ab1cbc56216b23c", size = 242227, upload-time = "2026-03-25T20:21:15.712Z" }, - { url = "https://files.pythonhosted.org/packages/6b/49/2b2a0ef529aa6eec245d25f0c703e020a73955ad7edf73e7f54ddc608aa5/tomli-2.4.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ec9bfaf3ad2df51ace80688143a6a4ebc09a248f6ff781a9945e51937008fcbc", size = 247859, upload-time = "2026-03-25T20:21:17.001Z" }, - { url = "https://files.pythonhosted.org/packages/83/bd/6c1a630eaca337e1e78c5903104f831bda934c426f9231429396ce3c3467/tomli-2.4.1-cp311-cp311-win32.whl", hash = "sha256:ff2983983d34813c1aeb0fa89091e76c3a22889ee83ab27c5eeb45100560c049", size = 97204, upload-time = "2026-03-25T20:21:18.079Z" }, - { url = "https://files.pythonhosted.org/packages/42/59/71461df1a885647e10b6bb7802d0b8e66480c61f3f43079e0dcd315b3954/tomli-2.4.1-cp311-cp311-win_amd64.whl", hash = "sha256:5ee18d9ebdb417e384b58fe414e8d6af9f4e7a0ae761519fb50f721de398dd4e", size = 108084, upload-time = "2026-03-25T20:21:18.978Z" }, - { url = "https://files.pythonhosted.org/packages/b8/83/dceca96142499c069475b790e7913b1044c1a4337e700751f48ed723f883/tomli-2.4.1-cp311-cp311-win_arm64.whl", hash = "sha256:c2541745709bad0264b7d4705ad453b76ccd191e64aa6f0fc66b69a293a45ece", size = 95285, upload-time = "2026-03-25T20:21:20.309Z" }, - { url = "https://files.pythonhosted.org/packages/c1/ba/42f134a3fe2b370f555f44b1d72feebb94debcab01676bf918d0cb70e9aa/tomli-2.4.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:c742f741d58a28940ce01d58f0ab2ea3ced8b12402f162f4d534dfe18ba1cd6a", size = 155924, upload-time = "2026-03-25T20:21:21.626Z" }, - { url = "https://files.pythonhosted.org/packages/dc/c7/62d7a17c26487ade21c5422b646110f2162f1fcc95980ef7f63e73c68f14/tomli-2.4.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:7f86fd587c4ed9dd76f318225e7d9b29cfc5a9d43de44e5754db8d1128487085", size = 150018, upload-time = "2026-03-25T20:21:23.002Z" }, - { url = "https://files.pythonhosted.org/packages/5c/05/79d13d7c15f13bdef410bdd49a6485b1c37d28968314eabee452c22a7fda/tomli-2.4.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ff18e6a727ee0ab0388507b89d1bc6a22b138d1e2fa56d1ad494586d61d2eae9", size = 244948, upload-time = "2026-03-25T20:21:24.04Z" }, - { url = "https://files.pythonhosted.org/packages/10/90/d62ce007a1c80d0b2c93e02cab211224756240884751b94ca72df8a875ca/tomli-2.4.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:136443dbd7e1dee43c68ac2694fde36b2849865fa258d39bf822c10e8068eac5", size = 253341, upload-time = "2026-03-25T20:21:25.177Z" }, - { url = "https://files.pythonhosted.org/packages/1a/7e/caf6496d60152ad4ed09282c1885cca4eea150bfd007da84aea07bcc0a3e/tomli-2.4.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:5e262d41726bc187e69af7825504c933b6794dc3fbd5945e41a79bb14c31f585", size = 248159, upload-time = "2026-03-25T20:21:26.364Z" }, - { url = "https://files.pythonhosted.org/packages/99/e7/c6f69c3120de34bbd882c6fba7975f3d7a746e9218e56ab46a1bc4b42552/tomli-2.4.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:5cb41aa38891e073ee49d55fbc7839cfdb2bc0e600add13874d048c94aadddd1", size = 253290, upload-time = "2026-03-25T20:21:27.46Z" }, - { url = "https://files.pythonhosted.org/packages/d6/2f/4a3c322f22c5c66c4b836ec58211641a4067364f5dcdd7b974b4c5da300c/tomli-2.4.1-cp312-cp312-win32.whl", hash = "sha256:da25dc3563bff5965356133435b757a795a17b17d01dbc0f42fb32447ddfd917", size = 98141, upload-time = "2026-03-25T20:21:28.492Z" }, - { url = "https://files.pythonhosted.org/packages/24/22/4daacd05391b92c55759d55eaee21e1dfaea86ce5c571f10083360adf534/tomli-2.4.1-cp312-cp312-win_amd64.whl", hash = "sha256:52c8ef851d9a240f11a88c003eacb03c31fc1c9c4ec64a99a0f922b93874fda9", size = 108847, upload-time = "2026-03-25T20:21:29.386Z" }, - { url = "https://files.pythonhosted.org/packages/68/fd/70e768887666ddd9e9f5d85129e84910f2db2796f9096aa02b721a53098d/tomli-2.4.1-cp312-cp312-win_arm64.whl", hash = "sha256:f758f1b9299d059cc3f6546ae2af89670cb1c4d48ea29c3cacc4fe7de3058257", size = 95088, upload-time = "2026-03-25T20:21:30.677Z" }, - { url = "https://files.pythonhosted.org/packages/07/06/b823a7e818c756d9a7123ba2cda7d07bc2dd32835648d1a7b7b7a05d848d/tomli-2.4.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:36d2bd2ad5fb9eaddba5226aa02c8ec3fa4f192631e347b3ed28186d43be6b54", size = 155866, upload-time = "2026-03-25T20:21:31.65Z" }, - { url = "https://files.pythonhosted.org/packages/14/6f/12645cf7f08e1a20c7eb8c297c6f11d31c1b50f316a7e7e1e1de6e2e7b7e/tomli-2.4.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:eb0dc4e38e6a1fd579e5d50369aa2e10acfc9cace504579b2faabb478e76941a", size = 149887, upload-time = "2026-03-25T20:21:33.028Z" }, - { url = "https://files.pythonhosted.org/packages/5c/e0/90637574e5e7212c09099c67ad349b04ec4d6020324539297b634a0192b0/tomli-2.4.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c7f2c7f2b9ca6bdeef8f0fa897f8e05085923eb091721675170254cbc5b02897", size = 243704, upload-time = "2026-03-25T20:21:34.51Z" }, - { url = "https://files.pythonhosted.org/packages/10/8f/d3ddb16c5a4befdf31a23307f72828686ab2096f068eaf56631e136c1fdd/tomli-2.4.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f3c6818a1a86dd6dca7ddcaaf76947d5ba31aecc28cb1b67009a5877c9a64f3f", size = 251628, upload-time = "2026-03-25T20:21:36.012Z" }, - { url = "https://files.pythonhosted.org/packages/e3/f1/dbeeb9116715abee2485bf0a12d07a8f31af94d71608c171c45f64c0469d/tomli-2.4.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d312ef37c91508b0ab2cee7da26ec0b3ed2f03ce12bd87a588d771ae15dcf82d", size = 247180, upload-time = "2026-03-25T20:21:37.136Z" }, - { url = "https://files.pythonhosted.org/packages/d3/74/16336ffd19ed4da28a70959f92f506233bd7cfc2332b20bdb01591e8b1d1/tomli-2.4.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:51529d40e3ca50046d7606fa99ce3956a617f9b36380da3b7f0dd3dd28e68cb5", size = 251674, upload-time = "2026-03-25T20:21:38.298Z" }, - { url = "https://files.pythonhosted.org/packages/16/f9/229fa3434c590ddf6c0aa9af64d3af4b752540686cace29e6281e3458469/tomli-2.4.1-cp313-cp313-win32.whl", hash = "sha256:2190f2e9dd7508d2a90ded5ed369255980a1bcdd58e52f7fe24b8162bf9fedbd", size = 97976, upload-time = "2026-03-25T20:21:39.316Z" }, - { url = "https://files.pythonhosted.org/packages/6a/1e/71dfd96bcc1c775420cb8befe7a9d35f2e5b1309798f009dca17b7708c1e/tomli-2.4.1-cp313-cp313-win_amd64.whl", hash = "sha256:8d65a2fbf9d2f8352685bc1364177ee3923d6baf5e7f43ea4959d7d8bc326a36", size = 108755, upload-time = "2026-03-25T20:21:40.248Z" }, - { url = "https://files.pythonhosted.org/packages/83/7a/d34f422a021d62420b78f5c538e5b102f62bea616d1d75a13f0a88acb04a/tomli-2.4.1-cp313-cp313-win_arm64.whl", hash = "sha256:4b605484e43cdc43f0954ddae319fb75f04cc10dd80d830540060ee7cd0243cd", size = 95265, upload-time = "2026-03-25T20:21:41.219Z" }, - { url = "https://files.pythonhosted.org/packages/3c/fb/9a5c8d27dbab540869f7c1f8eb0abb3244189ce780ba9cd73f3770662072/tomli-2.4.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:fd0409a3653af6c147209d267a0e4243f0ae46b011aa978b1080359fddc9b6cf", size = 155726, upload-time = "2026-03-25T20:21:42.23Z" }, - { url = "https://files.pythonhosted.org/packages/62/05/d2f816630cc771ad836af54f5001f47a6f611d2d39535364f148b6a92d6b/tomli-2.4.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:a120733b01c45e9a0c34aeef92bf0cf1d56cfe81ed9d47d562f9ed591a9828ac", size = 149859, upload-time = "2026-03-25T20:21:43.386Z" }, - { url = "https://files.pythonhosted.org/packages/ce/48/66341bdb858ad9bd0ceab5a86f90eddab127cf8b046418009f2125630ecb/tomli-2.4.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:559db847dc486944896521f68d8190be1c9e719fced785720d2216fe7022b662", size = 244713, upload-time = "2026-03-25T20:21:44.474Z" }, - { url = "https://files.pythonhosted.org/packages/df/6d/c5fad00d82b3c7a3ab6189bd4b10e60466f22cfe8a08a9394185c8a8111c/tomli-2.4.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:01f520d4f53ef97964a240a035ec2a869fe1a37dde002b57ebc4417a27ccd853", size = 252084, upload-time = "2026-03-25T20:21:45.62Z" }, - { url = "https://files.pythonhosted.org/packages/00/71/3a69e86f3eafe8c7a59d008d245888051005bd657760e96d5fbfb0b740c2/tomli-2.4.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7f94b27a62cfad8496c8d2513e1a222dd446f095fca8987fceef261225538a15", size = 247973, upload-time = "2026-03-25T20:21:46.937Z" }, - { url = "https://files.pythonhosted.org/packages/67/50/361e986652847fec4bd5e4a0208752fbe64689c603c7ae5ea7cb16b1c0ca/tomli-2.4.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ede3e6487c5ef5d28634ba3f31f989030ad6af71edfb0055cbbd14189ff240ba", size = 256223, upload-time = "2026-03-25T20:21:48.467Z" }, - { url = "https://files.pythonhosted.org/packages/8c/9a/b4173689a9203472e5467217e0154b00e260621caa227b6fa01feab16998/tomli-2.4.1-cp314-cp314-win32.whl", hash = "sha256:3d48a93ee1c9b79c04bb38772ee1b64dcf18ff43085896ea460ca8dec96f35f6", size = 98973, upload-time = "2026-03-25T20:21:49.526Z" }, - { url = "https://files.pythonhosted.org/packages/14/58/640ac93bf230cd27d002462c9af0d837779f8773bc03dee06b5835208214/tomli-2.4.1-cp314-cp314-win_amd64.whl", hash = "sha256:88dceee75c2c63af144e456745e10101eb67361050196b0b6af5d717254dddf7", size = 109082, upload-time = "2026-03-25T20:21:50.506Z" }, - { url = "https://files.pythonhosted.org/packages/d5/2f/702d5e05b227401c1068f0d386d79a589bb12bf64c3d2c72ce0631e3bc49/tomli-2.4.1-cp314-cp314-win_arm64.whl", hash = "sha256:b8c198f8c1805dc42708689ed6864951fd2494f924149d3e4bce7710f8eb5232", size = 96490, upload-time = "2026-03-25T20:21:51.474Z" }, - { url = "https://files.pythonhosted.org/packages/45/4b/b877b05c8ba62927d9865dd980e34a755de541eb65fffba52b4cc495d4d2/tomli-2.4.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:d4d8fe59808a54658fcc0160ecfb1b30f9089906c50b23bcb4c69eddc19ec2b4", size = 164263, upload-time = "2026-03-25T20:21:52.543Z" }, - { url = "https://files.pythonhosted.org/packages/24/79/6ab420d37a270b89f7195dec5448f79400d9e9c1826df982f3f8e97b24fd/tomli-2.4.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7008df2e7655c495dd12d2a4ad038ff878d4ca4b81fccaf82b714e07eae4402c", size = 160736, upload-time = "2026-03-25T20:21:53.674Z" }, - { url = "https://files.pythonhosted.org/packages/02/e0/3630057d8eb170310785723ed5adcdfb7d50cb7e6455f85ba8a3deed642b/tomli-2.4.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1d8591993e228b0c930c4bb0db464bdad97b3289fb981255d6c9a41aedc84b2d", size = 270717, upload-time = "2026-03-25T20:21:55.129Z" }, - { url = "https://files.pythonhosted.org/packages/7a/b4/1613716072e544d1a7891f548d8f9ec6ce2faf42ca65acae01d76ea06bb0/tomli-2.4.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:734e20b57ba95624ecf1841e72b53f6e186355e216e5412de414e3c51e5e3c41", size = 278461, upload-time = "2026-03-25T20:21:56.228Z" }, - { url = "https://files.pythonhosted.org/packages/05/38/30f541baf6a3f6df77b3df16b01ba319221389e2da59427e221ef417ac0c/tomli-2.4.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8a650c2dbafa08d42e51ba0b62740dae4ecb9338eefa093aa5c78ceb546fcd5c", size = 274855, upload-time = "2026-03-25T20:21:57.653Z" }, - { url = "https://files.pythonhosted.org/packages/77/a3/ec9dd4fd2c38e98de34223b995a3b34813e6bdadf86c75314c928350ed14/tomli-2.4.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:504aa796fe0569bb43171066009ead363de03675276d2d121ac1a4572397870f", size = 283144, upload-time = "2026-03-25T20:21:59.089Z" }, - { url = "https://files.pythonhosted.org/packages/ef/be/605a6261cac79fba2ec0c9827e986e00323a1945700969b8ee0b30d85453/tomli-2.4.1-cp314-cp314t-win32.whl", hash = "sha256:b1d22e6e9387bf4739fbe23bfa80e93f6b0373a7f1b96c6227c32bef95a4d7a8", size = 108683, upload-time = "2026-03-25T20:22:00.214Z" }, - { url = "https://files.pythonhosted.org/packages/12/64/da524626d3b9cc40c168a13da8335fe1c51be12c0a63685cc6db7308daae/tomli-2.4.1-cp314-cp314t-win_amd64.whl", hash = "sha256:2c1c351919aca02858f740c6d33adea0c5deea37f9ecca1cc1ef9e884a619d26", size = 121196, upload-time = "2026-03-25T20:22:01.169Z" }, - { url = "https://files.pythonhosted.org/packages/5a/cd/e80b62269fc78fc36c9af5a6b89c835baa8af28ff5ad28c7028d60860320/tomli-2.4.1-cp314-cp314t-win_arm64.whl", hash = "sha256:eab21f45c7f66c13f2a9e0e1535309cee140182a9cdae1e041d02e47291e8396", size = 100393, upload-time = "2026-03-25T20:22:02.137Z" }, - { url = "https://files.pythonhosted.org/packages/7b/61/cceae43728b7de99d9b847560c262873a1f6c98202171fd5ed62640b494b/tomli-2.4.1-py3-none-any.whl", hash = "sha256:0d85819802132122da43cb86656f8d1f8c6587d54ae7dcaf30e90533028b49fe", size = 14583, upload-time = "2026-03-25T20:22:03.012Z" }, -] - [[package]] name = "truststore" version = "0.10.4"