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..2f060a0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,12 @@ # Changelog +## Unreleased + +- **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`. + ## 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..dd04922 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,48 @@ 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, 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), +) +# client.prospecting.rename(run.run_id, ProspectingRunUpdate(title="Logistics ops leaders")) # Any status. +# 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 checks again, then stops 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. 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`). 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. 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. + +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 1 contact per company. Explicit settings, including explicit defaults, override the text. Targets support 1–10,000 companies and 1–5 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. + +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..7f17a91 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`), and the target being met (`target_reached`). Pass `--auto` to never pause; 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 ""` and run `wait` again. + +Omit `--target-companies` and `--contacts-per-company` to infer counts from the brief (fallback 25 and 2). Explicit values override the text. `--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..bf65301 100644 --- a/packages/discolike-cli/pyproject.toml +++ b/packages/discolike-cli/pyproject.toml @@ -9,7 +9,7 @@ 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", @@ -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..fa668cb --- /dev/null +++ b/packages/discolike-cli/src/discolike_cli/prospecting.py @@ -0,0 +1,332 @@ +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%. Default: pause at checkpoints." +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." +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 or target_reached) 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 and " + f"suggested_replies 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]] + + +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", [])), + ) + + +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, + } + print(json.dumps(envelope, default=str), file=sys.stderr) + return typer.Exit(code=NEEDS_INPUT_EXIT_CODE) + + +def _ask(pause: Pause) -> str: + 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 replies[int(answer) - 1] + if answer and not answer.isdigit(): + return answer + + +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, ProspectingMessageRequest(text=_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=5, + help="Override contacts per company; otherwise inferred, default 1.", + ), + 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), +) -> 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), + ), + ) + 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."), +) -> 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, {"text": text}), 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..bdd8b3f --- /dev/null +++ b/packages/discolike-cli/tests/test_prospecting_cli.py @@ -0,0 +1,408 @@ +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_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 + + +@pytest.mark.parametrize( + "arguments", + [ + ["approve", RUN_ID], + ["message", RUN_ID, "--text", "Continue"], + ["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", "Run the full list"), ("Only fleets over 50 trucks", None)]) +def test_wait_asks_at_a_checkpoint_and_keeps_waiting( + install_build_client: Callable[[Handler], None], + monkeypatch: pytest.MonkeyPatch, + typed: str, + posted: str | None, +) -> 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) == {"text": posted or typed} + 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"} + + +@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, + } + 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-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..76052d0 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,16 @@ 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). `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`. + +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`). 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. 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. Customer integration charges apply; work caps do not cap provider dollar spend. + ## Links - **API documentation**: [docs.discolike.com](https://docs.discolike.com) diff --git a/packages/discolike/pyproject.toml b/packages/discolike/pyproject.toml index 17b53d8..9737da0 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", 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..c1a38ee 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,59 @@ 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 ProspectingMessageRequest(DiscolikeRequest): + text: Annotated[str, Field(max_length=4000, min_length=1, title="Text")] + + +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=5, title="Contacts Per Company")] = 1 + 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 + checkpoints: Annotated[ + Literal["ask", "auto"] | None, + Field( + description="ask: pause at checkpoints (pilot, tail_quality, short, target_reached) 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%.", + title="Checkpoints", + ), + ] = "auto" + + +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 ProspectingRunUpdate(DiscolikeRequest): + title: Annotated[str, Field(max_length=80, min_length=1, title="Title")] + + class LLMProviderCreateRequest(DiscolikeRequest): integration_name: Annotated[ str, @@ -3146,3 +3200,10 @@ 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 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/_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/requests.py b/packages/discolike/src/discolike/requests.py index 9cdb359..39ef20f 100644 --- a/packages/discolike/src/discolike/requests.py +++ b/packages/discolike/src/discolike/requests.py @@ -28,6 +28,12 @@ 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 ProspectingRunUpdate from discolike._generated.requests import QueriesListParams from discolike._generated.requests import SaveResultsRequest from discolike._generated.requests import SearchProviderRequest @@ -65,6 +71,12 @@ "LLMProviderUpdateRequest", "MatchBulkParams", "MatchCompanyParams", + "ProspectingApproveRequest", + "ProspectingBrief", + "ProspectingGetParams", + "ProspectingListParams", + "ProspectingMessageRequest", + "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..d49499e --- /dev/null +++ b/packages/discolike/src/discolike/resources/prospecting.py @@ -0,0 +1,358 @@ +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 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 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"}) +# 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 +) + + +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 + + +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 + + +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.", + ) + 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 + 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 _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") + 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}") + def rename(self, run_id: str | UUID, request: ProspectingRunUpdate) -> ProspectingRunSummary: + """Rename the run in any status; returns its summary as list() shows it. + + The server strips the title, which must then be 1-80 characters. A title set before the agent + plans is kept. Result lists the run already saved keep their names. + """ + 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. + 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") + 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}") + async def rename(self, run_id: str | UUID, request: ProspectingRunUpdate) -> ProspectingRunSummary: + """Rename the run in any status; returns its summary as list() shows it. + + The server strips the title, which must then be 1-80 characters. A title set before the agent + plans is kept. Result lists the run already saved keep their names. + """ + 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..354288b 100644 --- a/packages/discolike/tests/test_contract_registry.py +++ b/packages/discolike/tests/test_contract_registry.py @@ -3,9 +3,11 @@ import pathlib import sys +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 +87,133 @@ 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_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 +340,75 @@ 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}) == [] 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..694a1af 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) == 56 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_prospecting.py b/packages/discolike/tests/test_prospecting.py new file mode 100644 index 0000000..e4c8e8a --- /dev/null +++ b/packages/discolike/tests/test_prospecting.py @@ -0,0 +1,431 @@ +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 JobTimeoutError +from discolike import NotFoundError +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 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_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_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, 1) + 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_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_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) 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..ec1abd4 100644 --- a/scripts/check_contract.py +++ b/scripts/check_contract.py @@ -26,6 +26,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 +42,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 +59,8 @@ "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"})} SPEC_URL = "https://api.discolike.com/v1/openapi.json" REQUEST_TIMEOUT_SECONDS = 30.0 @@ -74,8 +88,9 @@ 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 @@ -151,7 +166,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 +178,59 @@ def check(spec: dict, routes: list[RouteEntry]) -> list[str]: 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 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 +239,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 +252,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..236c5b6 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,21 +87,36 @@ }, } +_CHECKPOINT_MODES = ["ask", "auto"] +_BRIEF_CHECKPOINTS_DESCRIPTION = ( + "ask: pause at checkpoints (pilot, tail_quality, short, target_reached) 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%." +) + # 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, -} +PENDING_PROPERTIES: dict[str, dict[str, dict[str, Any]]] = {} # Properties generated from this schema rather than the spec's, whatever the spec says. The # platform's sub-industry enum lists parent-qualified keys only; a bare label reaches it through # 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", + } + }, } diff --git a/uv.lock b/uv.lock index c07afc0..20e8092 100644 --- a/uv.lock +++ b/uv.lock @@ -1,10 +1,10 @@ version = 1 revision = 3 -requires-python = ">=3.10" +requires-python = ">=3.11" resolution-markers = [ "python_full_version >= '3.14' and sys_platform == 'emscripten'", - "python_full_version >= '3.14' and sys_platform != 'emscripten'", "python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform == 'emscripten'", + "python_full_version >= '3.14' and sys_platform != 'emscripten'", "(python_full_version < '3.14' and sys_platform != 'emscripten') or (python_full_version < '3.12' 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 = [ @@ -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"