diff --git a/.github/workflows/contract.yml b/.github/workflows/contract.yml index 9a6eefb..04eb4dc 100644 --- a/.github/workflows/contract.yml +++ b/.github/workflows/contract.yml @@ -27,6 +27,8 @@ jobs: run: | if [ "$BASE_REF" = "development" ] && [ -n "$DEV_SPEC_URL" ]; then uv run python scripts/check_contract.py --spec-url "$DEV_SPEC_URL" + uv run python scripts/gen_requests.py --check --spec-url "$DEV_SPEC_URL" else uv run python scripts/check_contract.py + uv run python scripts/gen_requests.py --check fi diff --git a/CHANGELOG.md b/CHANGELOG.md index 63f2127..f2aedd8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,44 @@ # Changelog +## 0.3.0 (2026-08-29) + +- SDK: OAuth login. `Discolike(auth=...)` / `AsyncDiscolike(auth=...)` accept an `ApiKeyCredential` or `OAuthCredential` (both exported from `discolike`); `api_key=`, `DISCOLIKE_API_KEY`, and the config file keep working unchanged, and `auth=` wins over all of them. OAuth credentials send `Authorization: Bearer`, refresh proactively within 60s of expiry and once more after a 401, and write rotated refresh tokens back to the config file when they were loaded from it (an injected `auth=` is never persisted). A refresh that fails raises `AuthenticationError("OAuth session expired; run `discolike auth login`")`. Config file gains the shape `{"auth_method": "oauth", "oauth": {...}}` next to the existing `api_key` shape. +- SDK: `http_client=` now has its `.auth` set by the SDK (the `X-discolike-key` header moved from a static default header into an `httpx2.Auth`); an `auth` already set on a user-supplied client is replaced. +- CLI: `discolike auth login` now logs in through the browser by default (PKCE authorization-code flow against the platform's OAuth server, loopback redirect on `127.0.0.1`). `--no-browser` prints the URL only, `--port` pins the loopback port for SSH forwarding, and `--method api_key` (or `--api-key KEY`) keeps the API-key flow, including the prompt. Login-flow failures (timeout, denied consent, state mismatch) exit 1 with `{"error": "LoginError", ...}` on stderr. +- CLI: `discolike auth login` remembers the OAuth client it registered (`oauth_client` in the config file) and reuses it on the next login for the same server, so the browser consent screen is only asked once per machine. `auth logout` drops the credential but keeps the registration (a public PKCE client, not a secret), so the next login skips consent too. If PropelAuth no longer recognises the stored client (`invalid_client` / `unauthorized_client`), login registers a fresh one and retries once. +- CLI: `discolike auth status` adds `method` (`api_key` / `oauth`); for OAuth it reports `expires_at` and `expired` instead of a masked key. + +- SDK: `JobStatus` gains `estimated_cost` and `cost_metadata` for DiscoGen-family jobs (`discogen`, `validate_icp`, contacts generate). `cost_metadata` has one entry per `provider/model` and a `search_provider` entry with `queries_executed` / `queries_succeeded` / `est_cost_usd` when a BYOS search provider ran. `search_calls` on the model entries counts only the model's built-in search tool and is `0` on every BYOS run; read `search_provider.queries_executed` to confirm web search happened. + +### Request models + +- SDK (breaking): every request-taking method now takes a single request model instead of keyword arguments, and validates it locally before any HTTP call — a bad enum value, an out-of-range number, or a missing required field raises `pydantic.ValidationError` instead of a server 422. Models live in `discolike.requests` and are generated from the platform OpenAPI spec (`scripts/gen_requests.py`); query-param routes use `Params` (`MatchCompanyParams`, `ContactsSearchParams`, `DiscoverParams`, `CountParams`, `AppendParams`, `SegmentParams`, ...) and JSON-body routes use the platform's own names (`FindEmailRequest`, `ContactFilters`, `DiscoGenProcessRequest`, `UpdateQueryRequest`, ...). Path params and file uploads stay keyword arguments next to the model. Unknown fields pass through to the wire, so the SDK never blocks a platform field it does not know about yet. + + ```python + # before + client.match.company(name="Acme Inc", city="Austin", min_match_confidence=80) + client.email.find_batch(contacts=[{"first_name": "Ada", "last_name": "Lovelace", "domain": "acme.com"}]) + client.queries.update(query_id="q3", query_name="New Name") + client.segment(domains=["acme.com", "beta.com"], max_segments=5) + client.segment(file="domains.csv", domain_column="domain") + + # after + from discolike.requests import FindEmailBatchRequest, MatchCompanyParams, SegmentFileParams, SegmentParams, UpdateQueryRequest + + client.match.company(MatchCompanyParams(name="Acme Inc", city="Austin", min_match_confidence=80)) + client.email.find_batch(FindEmailBatchRequest.model_validate({"requests": [{"first_name": "Ada", "last_name": "Lovelace", "domain": "acme.com"}]})) + client.queries.update(UpdateQueryRequest(query_name="New Name"), query_id="q3") + client.segment(SegmentParams(domains="acme.com,beta.com", max_segments=5)) + client.segment_file(SegmentFileParams(domain_column="domain"), file="domains.csv") + ``` + +- SDK (breaking): `segment` is split into `client.segment(SegmentParams)` (`GET /segment`, `domains` is the comma-separated string the API takes) and `client.segment_file(SegmentFileParams, file=...)` (`POST /segment`). `email.find_batch` drops its `contacts=` alias for the platform's `requests` field. `append` requires `dataset`, matching the platform. +- SDK (breaking): `GET /segment` sends `query_id` as repeated `query_id=` params (the spec declares it an array) instead of the old comma-joined single value. +- SDK: deprecated parameters (`nl_match`, `min_score`, `negate_domain`, `exact_match`, `vendor`, `revenue_range` on contacts, `negate_icp_text`) are not part of the generated models; they still pass through as extra fields if set explicitly. +- SDK: new `DiscolikeRequest` base (`discolike.DiscolikeRequest`) with `to_wire()`, which sends exactly the fields you set — an explicit `None` goes out as `null` (this is how `llm_providers.update` keeps the stored API key), and unset fields are omitted so server defaults keep governing. +- CLI: request models are built from the same options as before, so no flags change. Two behavior changes: `--param KEY=VALUE` with an unknown key is forwarded to the API (previously exit 2), and any option or `--param` value outside the spec (an unknown `--department`, `--max-records` below the floor, `--match loose`) exits 2 with `{"error": "ValidationError", ...}` on stderr before the request is sent. `discolike append` without `--dataset` now fails this same client-side validation (was optional before; `AppendParams` requires it). +- CI: the contract job also runs `scripts/gen_requests.py --check`, so the committed models fail the build when the platform spec moves. + ## 0.2.0 (2026-08-21) - SDK + testkit (breaking): migrated from `httpx` to [`httpx2`](https://github.com/pydantic/httpx2), Pydantic's maintained continuation of httpx, for timely security updates. `httpx` types are part of the public surface (`http_client=`, `with_options(timeout=)`, the testkit `Handler` alias), so callers must swap `import httpx` for `import httpx2` and pass `httpx2.Client` / `httpx2.AsyncClient` / `httpx2.Timeout`. Note httpx2 verifies TLS against the OS trust store via `truststore` instead of bundled `certifi` roots. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 11a8c51..fba3e74 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -36,6 +36,12 @@ CI also validates SDK routes against the live DiscoLike OpenAPI spec (`scripts/check_contract.py`). PRs from forks are checked against the production spec. +CI also runs `scripts/gen_requests.py --check` against the dev spec +(`https://api.dev.discolike.com/v1/openapi.json`) — the committed request +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. + ## Reporting bugs Open a GitHub issue with the package name, version, and a minimal diff --git a/README.md b/README.md index c7577c0..89196de 100644 --- a/README.md +++ b/README.md @@ -82,13 +82,16 @@ client = Discolike(api_key="dl_...") # or pass it explicitly ```python from discolike import Discolike +from discolike.requests import DiscoverParams client = Discolike() companies = client.discover( - icp_text="Cybersecurity for SMBs, managed IT services, endpoint protection", - country=["US"], - max_records=25, + DiscoverParams( + icp_text="Cybersecurity for SMBs, managed IT services, endpoint protection", + country=["US"], + max_records=25, + ) ) for company in companies: print(company.domain, company.name, company.similarity) @@ -97,10 +100,14 @@ for company in companies: Run DiscoGen research over a set of domains and wait for the result: ```python +from discolike.requests import DiscoGenProcessRequest + job = client.discogen.process( - query="Recent funding rounds and headcount growth", - domains=["stripe.com", "adyen.com"], - web_search=True, + DiscoGenProcessRequest( + query="Recent funding rounds and headcount growth", + domains=["stripe.com", "adyen.com"], + web_search=True, + ) ) result = job.wait() print(result.results) @@ -109,14 +116,18 @@ print(result.results) Size a segment before pulling it: ```python -total = client.count(phrase_match=["book a demo"], country=["US"]) +from discolike.requests import CountParams + +total = client.count(CountParams(phrase_match=["book a demo"], country=["US"])) print(total.count) ``` Pull a full company profile: ```python -profile = client.companies.data(domain="stripe.com") +from discolike.requests import CompaniesDataParams + +profile = client.companies.data(CompaniesDataParams(domain="stripe.com")) ``` The client is a context manager if you want deterministic cleanup: @@ -133,10 +144,11 @@ Every resource has an async twin on `AsyncDiscolike`: ```python import asyncio from discolike import AsyncDiscolike +from discolike.requests import DiscoverParams async def main() -> None: async with AsyncDiscolike() as client: - companies = await client.discover(icp_text="B2B SaaS for logistics", max_records=10) + companies = await client.discover(DiscoverParams(icp_text="B2B SaaS for logistics", max_records=10)) print([c.domain for c in companies]) asyncio.run(main()) @@ -192,11 +204,12 @@ Top-level commands: `discover`, `count`, `match`, `extract`, `validate-icp`, `ap | `client.contacts` | Search, look up, match, and discover contacts at target companies | | `client.match` | Match company names (plus phone/city/state) to domains — single or bulk CSV | | `client.append()` | Enrich a CSV of domains with DiscoLike datasets | -| `client.segment()` | Auto-segment a list of domains | +| `client.segment()` / `client.segment_file()` | Auto-segment a list of domains (comma-separated string or CSV upload) | | `client.validate_icp()` | Validate a domain list against an ICP definition | | `client.queries` | Saved inclusion/exclusion lists for reusable targeting | | `client.search_providers` / `client.llm_providers` | Manage BYOK search and LLM provider integrations for DiscoGen | | `client.account` | Usage and quota | +| `discolike.requests` | Request models for every call — generated from the platform OpenAPI spec, validated locally before the request is sent | All responses are typed [Pydantic](https://docs.pydantic.dev/) models. @@ -205,11 +218,13 @@ All responses are typed [Pydantic](https://docs.pydantic.dev/) models. Bulk operations (`match.bulk`, `segment`, `validate_icp`, `contacts.bulk_match`) return a `Job` handle instead of blocking: ```python -job = client.segment(domains=["stripe.com", "adyen.com", "checkout.com"]) +from discolike.requests import SegmentParams + +job = client.segment(SegmentParams(domains="stripe.com,adyen.com,checkout.com")) result = job.wait() ``` -`Job.status()` polls without blocking, `Job.cancel()` aborts, and `wait()` raises `JobFailedError` / `JobTimeoutError` on failure. +`Job.status()` polls without blocking, `Job.cancel()` aborts, and `wait()` raises `JobFailedError` / `JobTimeoutError` on failure. On DiscoGen-family jobs the returned `JobStatus` also carries `warnings`, `estimated_cost` and `cost_metadata` (per-model usage plus a `search_provider` entry when a BYOS search provider ran; `search_calls` only counts the model's built-in search). `JobTimeoutError` is a client-side wait limit only — the task keeps running server-side (large DiscoGen runs can take hours), so call `wait()` again to resume or fetch `status()` later. Cancelled tasks still return results for every item that finished before cancellation. Send one job per list (up to 10,000 domains) rather than splitting into parallel jobs — concurrent DiscoGen jobs share your LLM provider key and slow each other down. @@ -219,9 +234,10 @@ All errors inherit from `DiscolikeError`: ```python from discolike import Discolike, RateLimitError, ValidationError +from discolike.requests import DiscoverParams try: - companies = Discolike().discover(icp_text="fintech infrastructure") + companies = Discolike().discover(DiscoverParams(icp_text="fintech infrastructure")) except RateLimitError as err: ... except ValidationError as err: @@ -230,6 +246,18 @@ except ValidationError as err: `AuthenticationError`, `PlanAccessError`, `NotFoundError`, `ServerError`, and `APIConnectionError` cover the rest. Transient failures are retried automatically (3 attempts by default). +```python +import pydantic +from discolike.requests import MatchCompanyParams + +try: + params = MatchCompanyParams(name="Acme", min_match_confidence=10) +except pydantic.ValidationError as err: + ... # raised locally: min_match_confidence must be 50-100 +``` + +Request models validate before anything is sent, so a bad enum value or out-of-range number never costs a round trip. Unknown fields pass through untouched. + ### Configuration | Option | Default | | @@ -251,8 +279,12 @@ uv sync --all-packages uv run pytest packages/discolike/tests uv run pytest packages/discolike-cli/tests uv run ruff check . +uv run python scripts/gen_requests.py --spec-url https://api.dev.discolike.com/v1/openapi.json # regenerate request models +uv run python scripts/gen_requests.py --check # fail on drift (CI) ``` +Committed request models track the dev spec (`--spec-url https://api.dev.discolike.com/v1/openapi.json`); the prod spec lags behind, so `--check` without a `--spec-url` (or against prod) stays red until the platform deploys — don't regenerate against prod to "fix" it. + ## Support & contact - **API documentation**: [docs.discolike.com](https://docs.discolike.com) diff --git a/examples/discover_and_enrich.py b/examples/discover_and_enrich.py index e86b4cf..6cdff84 100644 --- a/examples/discover_and_enrich.py +++ b/examples/discover_and_enrich.py @@ -2,9 +2,9 @@ Two calls end to end: -1. ``client.discover(icp_text=..., country=..., max_records=...)`` finds - lookalike companies from DiscoLike's index of 80M+ business websites. -2. ``client.discogen.process(query=..., domains=[...], web_search=True)`` +1. ``client.discover(DiscoverParams(icp_text=..., country=..., max_records=...))`` + finds lookalike companies from DiscoLike's index of 80M+ business websites. +2. ``client.discogen.process(DiscoGenProcessRequest(query=..., domains=[...], web_search=True))`` runs an AI research prompt over the discovered domains and returns one structured answer per company. ``job.wait()`` blocks until it finishes. @@ -26,6 +26,8 @@ import sys from discolike import Discolike +from discolike.requests import DiscoGenProcessRequest +from discolike.requests import DiscoverParams def parse_args() -> argparse.Namespace: @@ -43,7 +45,7 @@ def main() -> None: client = Discolike() print(f"Discovering up to {args.max_records} companies for: {args.icp!r}") - companies = client.discover(icp_text=args.icp, country=args.country, max_records=args.max_records) + companies = client.discover(DiscoverParams(icp_text=args.icp, country=args.country, max_records=args.max_records)) if not companies: sys.exit("No companies found for that ICP - try broadening it.") domains = [company.domain for company in companies if company.domain] @@ -51,7 +53,7 @@ def main() -> None: print(f" {company.domain} {company.name or ''} (similarity {company.similarity})") print(f"\nRunning DiscoGen over {len(domains)} domains: {args.query!r}") - job = client.discogen.process(query=args.query, domains=domains, web_search=True) + job = client.discogen.process(DiscoGenProcessRequest(query=args.query, domains=domains, web_search=True)) status = job.wait(timeout=args.timeout, on_poll=lambda s: print(f" status={s.status} progress={s.progress}%")) print("\nEnriched results:") diff --git a/examples/find_emails_from_csv.py b/examples/find_emails_from_csv.py index 6924d5b..228ca6b 100644 --- a/examples/find_emails_from_csv.py +++ b/examples/find_emails_from_csv.py @@ -2,7 +2,7 @@ Reads people from a CSV with ``first_name``, ``last_name``, and ``domain`` columns (names configurable), submits them with -``client.email.find_batch(contacts=[...])`` in chunks of up to 500, waits for +``client.email.find_batch(FindEmailBatchRequest(...))`` in chunks of up to 500, waits for each batch with ``batch.results()``, and writes the found emails plus status to an output CSV. @@ -22,6 +22,8 @@ from pathlib import Path from discolike import Discolike +from discolike import EnumerationOutput +from discolike.requests import FindEmailBatchRequest MAX_CONTACTS_PER_BATCH = 500 @@ -65,11 +67,11 @@ def main() -> None: for start in range(0, len(contacts), MAX_CONTACTS_PER_BATCH): chunk = contacts[start : start + MAX_CONTACTS_PER_BATCH] print(f"Submitting batch of {len(chunk)} contacts ({start + len(chunk)}/{len(contacts)})...") - batch = client.email.find_batch(contacts=chunk) + batch = client.email.find_batch(FindEmailBatchRequest.model_validate({"requests": chunk})) results = batch.results(timeout=args.timeout) for item in results.results: output = item.result - if output is None: + if not isinstance(output, EnumerationOutput): # Failed jobs carry no EnumerationOutput (so no identity), # but must not vanish from the output: keep the status and # error so the failure is visible and countable. @@ -85,8 +87,7 @@ def main() -> None: } ) continue - match = getattr(output, "result", None) - email = match.email if match is not None else None + email = output.result.email if output.result is not None else None if output.status == "found": found += 1 writer.writerow( diff --git a/examples/match_crm_contacts.py b/examples/match_crm_contacts.py index 457b8d4..55fa8e4 100644 --- a/examples/match_crm_contacts.py +++ b/examples/match_crm_contacts.py @@ -1,7 +1,7 @@ """Match a CSV of CRM contacts to DiscoLike persona IDs via bulk-match. Reads contacts from a CSV (column names are configurable), submits them to -``client.contacts.bulk_match()`` in chunks, and writes an output CSV with the +``client.contacts.bulk_match(BulkContactMatchRequest(...))`` in chunks, and writes an output CSV with the matched ``persona_id`` and ``match_score`` per row. Lessons baked in from production runs: @@ -34,6 +34,7 @@ from urllib.parse import urlparse from discolike import Discolike +from discolike.requests import BulkContactMatchRequest MAX_QUERIES_PER_CALL = 500 # bulk_match accepts 1-500 queries per request @@ -204,7 +205,9 @@ def main() -> None: hits: dict[int, dict[str, Any] | None] = dict.fromkeys(row_ids) else: try: - job = client.contacts.bulk_match(queries=queries, limit=1) + job = client.contacts.bulk_match( + BulkContactMatchRequest.model_validate({"queries": queries, "limit": 1}) + ) status = job.wait(timeout=args.timeout) except Exception as exc: # leave the chunk un-checkpointed so a rerun retries it failed_chunks += 1 diff --git a/packages/discolike-cli/README.md b/packages/discolike-cli/README.md index 2f4f463..bab4b42 100644 --- a/packages/discolike-cli/README.md +++ b/packages/discolike-cli/README.md @@ -22,7 +22,7 @@ Requires Python 3.10+. Installing this package gives you the `discolike` command discolike auth login ``` -Prompts for an API key (or pass `--api-key`) and verifies it against your account. Create a key at [app.discolike.com/account/management/keys](https://app.discolike.com/account/management/keys). You can also set `DISCOLIKE_API_KEY` in the environment instead. +Opens your browser to log in (add `--no-browser` to print the URL instead, `--port` to pin the loopback port when forwarding over SSH). To use an API key instead, pass `--api-key KEY` or `--method api_key` to be prompted; create a key at [app.discolike.com/account/management/keys](https://app.discolike.com/account/management/keys). You can also set `DISCOLIKE_API_KEY` in the environment instead. ## Quickstart diff --git a/packages/discolike-cli/pyproject.toml b/packages/discolike-cli/pyproject.toml index fa97293..acb5f6f 100644 --- a/packages/discolike-cli/pyproject.toml +++ b/packages/discolike-cli/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "discolike-cli" -version = "0.2.0" +version = "0.3.0" description = "Official CLI for the DiscoLike API" readme = "README.md" license = "MIT" @@ -12,7 +12,7 @@ license-files = ["LICENSE"] requires-python = ">=3.10" authors = [{ name = "DiscoLike", email = "support@discolike.com" }] dependencies = [ - "discolike==0.2.0", + "discolike==0.3.0", "typer>=0.12", "rich>=13.0", ] diff --git a/packages/discolike-cli/src/discolike_cli/_loopback.py b/packages/discolike-cli/src/discolike_cli/_loopback.py new file mode 100644 index 0000000..dab0ecc --- /dev/null +++ b/packages/discolike-cli/src/discolike_cli/_loopback.py @@ -0,0 +1,63 @@ +from __future__ import annotations + +import threading +from http import HTTPStatus +from http.server import BaseHTTPRequestHandler +from http.server import HTTPServer +from urllib.parse import parse_qs +from urllib.parse import urlparse + +LOOPBACK_HOST = "127.0.0.1" +CALLBACK_PATH = "/callback" +CALLBACK_HTML = "DiscoLike

Login complete. You can close this window.

" + + +class _LoopbackServer(HTTPServer): + def __init__(self, *, host: str, port: int) -> None: + super().__init__((host, port), _CallbackHandler) + self.query: dict[str, str] = {} + self.received = threading.Event() + + +class _CallbackHandler(BaseHTTPRequestHandler): + server: _LoopbackServer + + def do_GET(self) -> None: + parsed = urlparse(self.path) + if parsed.path != CALLBACK_PATH: + self.send_error(HTTPStatus.NOT_FOUND) + return + self.server.query = {key: values[0] for key, values in parse_qs(parsed.query).items()} + body = CALLBACK_HTML.encode() + self.send_response(HTTPStatus.OK) + self.send_header("Content-Type", "text/html; charset=utf-8") + self.send_header("Content-Length", str(len(body))) + self.end_headers() + self.wfile.write(body) + self.server.received.set() + + def log_message(self, format: str, *args: object) -> None: # noqa: A002 -- BaseHTTPRequestHandler signature + _ = (format, args) + + +class CallbackServer: + """Loopback redirect target for the authorization-code flow; serves on a daemon thread.""" + + def __init__(self, *, port: int) -> None: + self._server = _LoopbackServer(host=LOOPBACK_HOST, port=port) + self._thread = threading.Thread(target=self._server.serve_forever, daemon=True) + + @property + def redirect_uri(self) -> str: + return f"http://{LOOPBACK_HOST}:{self._server.server_port}{CALLBACK_PATH}" + + def wait(self, *, timeout: float) -> dict[str, str] | None: + return self._server.query if self._server.received.wait(timeout) else None + + def __enter__(self) -> CallbackServer: + self._thread.start() + return self + + def __exit__(self, *exc_info: object) -> None: + self._server.shutdown() + self._server.server_close() diff --git a/packages/discolike-cli/src/discolike_cli/_output.py b/packages/discolike-cli/src/discolike_cli/_output.py index c9bce5a..593a8ed 100644 --- a/packages/discolike-cli/src/discolike_cli/_output.py +++ b/packages/discolike-cli/src/discolike_cli/_output.py @@ -3,12 +3,15 @@ import functools import json import sys +import types +import typing from collections.abc import Callable from typing import Any from typing import ParamSpec from typing import Protocol from typing import TypeVar +import pydantic import typer from rich.console import Console from rich.table import Table @@ -24,6 +27,8 @@ P = ParamSpec("P") R = TypeVar("R") +RequestT = TypeVar("RequestT", bound=pydantic.BaseModel) +UNION_ORIGINS = (typing.Union, types.UnionType) EXIT_CODES: dict[type, int] = { ValidationError: 2, @@ -117,11 +122,27 @@ def fail(exc: DiscolikeError) -> typer.Exit: return typer.Exit(code=EXIT_CODES.get(type(exc), DEFAULT_EXIT_CODE)) -def call_typed(fn: Callable[..., R], **kwargs: Any) -> R: # noqa: ANN401 -- forwarded to a typed SDK method signature - try: - return fn(**kwargs) - except TypeError as exc: - raise typer.BadParameter(str(exc)) from exc +def _accepts_list(model: type[pydantic.BaseModel], name: str) -> bool: + field = model.model_fields.get(name) + if field is None: + return False + annotation = field.annotation + members = typing.get_args(annotation) if typing.get_origin(annotation) in UNION_ORIGINS else (annotation,) + return any(typing.get_origin(member) is list for member in members) + + +def build_request(model: type[RequestT], kwargs: dict[str, Any]) -> RequestT: + # --param KEY=VALUE yields a bare string; the API reads one value for a list param as a one-element list. + return model.model_validate( + { + key: [value] if isinstance(value, str) and _accepts_list(model, key) else value + for key, value in kwargs.items() + } + ) + + +def _validation_message(exc: pydantic.ValidationError) -> str: + return "; ".join(f"{'.'.join(str(part) for part in error['loc'])}: {error['msg']}" for error in exc.errors()) def handle_errors(fn: Callable[P, R]) -> Callable[P, R]: @@ -129,6 +150,8 @@ def handle_errors(fn: Callable[P, R]) -> Callable[P, R]: def wrapper(*args: P.args, **kwargs: P.kwargs) -> R: try: return fn(*args, **kwargs) + except pydantic.ValidationError as exc: + raise fail(ValidationError(_validation_message(exc))) from exc except DiscolikeError as exc: raise fail(exc) from exc diff --git a/packages/discolike-cli/src/discolike_cli/auth.py b/packages/discolike-cli/src/discolike_cli/auth.py index e1a0f6a..597385d 100644 --- a/packages/discolike-cli/src/discolike_cli/auth.py +++ b/packages/discolike-cli/src/discolike_cli/auth.py @@ -1,81 +1,285 @@ from __future__ import annotations import json +import secrets import sys +import webbrowser +from datetime import datetime +from datetime import timezone from typing import Any +from typing import NoReturn +from urllib.parse import urlparse +import httpx2 import typer +from discolike._config import AUTH_METHOD_API_KEY +from discolike._config import AUTH_METHOD_OAUTH +from discolike._config import DEFAULT_BASE_URL from discolike._config import KEYS_URL -from discolike._config import delete_config -from discolike._config import load_config -from discolike._config import resolve_api_key +from discolike._config import NO_CREDENTIAL_MESSAGE +from discolike._config import delete_credential +from discolike._config import delete_oauth_client +from discolike._config import load_credential +from discolike._config import load_oauth_client from discolike._config import save_config +from discolike._config import save_credential +from discolike._config import save_oauth_client +from discolike._credentials import OAuthClientRegistration +from discolike._credentials import OAuthCredential +from discolike._exceptions import AuthenticationError +from discolike._oauth import AuthServerMetadata +from discolike._oauth import OAuthError +from discolike._oauth import build_authorization_url +from discolike._oauth import discover +from discolike._oauth import exchange_code +from discolike._oauth import pkce_pair +from discolike._oauth import register_client +from discolike_cli._loopback import CallbackServer from discolike_cli._output import emit from discolike_cli._output import handle_errors -app = typer.Typer(help="Manage API credentials: log in, check key status, log out.") +app = typer.Typer(help="Manage credentials: log in (browser or API key), check status, log out.") MASKED_VISIBLE_CHARS = 4 +LOGIN_TIMEOUT_SECONDS = 180.0 +OAUTH_HTTP_TIMEOUT_SECONDS = 30.0 +STATE_BYTES = 16 +RANDOM_PORT = 0 SOURCE_OPTION = "option" SOURCE_ENV = "env" SOURCE_CONFIG = "config" +LOGIN_METHODS = (AUTH_METHOD_OAUTH, AUTH_METHOD_API_KEY) +DEAD_CLIENT_ERRORS = frozenset({"invalid_client", "unauthorized_client"}) + + +class _DeadClientError(Exception): + """The authorization server no longer recognises the registered client_id.""" + def _mask(key: str) -> str: return "…" + key[-MASKED_VISIBLE_CHARS:] +def _iso(epoch_seconds: float) -> str: + return datetime.fromtimestamp(epoch_seconds, tz=timezone.utc).isoformat() + + def _global_key_source(ctx: typer.Context) -> str: # typer vendors click without re-exporting ParameterSource, so match on the enum member name. source = ctx.find_root().get_parameter_source("api_key") return SOURCE_ENV if source is not None and source.name == "ENVIRONMENT" else SOURCE_OPTION -def _verify(ctx: typer.Context, *, api_key: str) -> None: +def _verify(ctx: typer.Context, **kwargs: Any) -> None: # noqa: ANN401 -- forwarded verbatim to build_client from discolike_cli.main import build_client - kwargs: dict[str, Any] = {"api_key": api_key} base_url = ctx.obj.get("base_url") if base_url is not None: kwargs["base_url"] = base_url build_client(**kwargs).account.usage() +def _abort_login(message: str) -> NoReturn: + print(json.dumps({"error": "LoginError", "message": message}), file=sys.stderr) + raise typer.Exit(code=1) + + +def _registered_port(registration: OAuthClientRegistration) -> int: + return int(urlparse(registration.redirect_uri).port or RANDOM_PORT) + + +def _reusable_registration(*, issuer: str, port: int) -> OAuthClientRegistration | None: + stored = load_oauth_client() + if stored is None or stored.issuer != issuer: + return None + if port != RANDOM_PORT and _registered_port(stored) != port: + return None + return stored + + +def _bind_stored_port(registration: OAuthClientRegistration) -> CallbackServer | None: + try: + return CallbackServer(port=_registered_port(registration)) + except OSError: + return None + + +def _register_or_reuse( + metadata: AuthServerMetadata, *, port: int, http: httpx2.Client +) -> tuple[CallbackServer, OAuthClientRegistration, bool]: + # PropelAuth matches the redirect URI literally (port included) and remembers consent per client_id, + # so a stored registration is only worth reusing when its exact port can be bound again. + stored = _reusable_registration(issuer=metadata.issuer, port=port) + if stored is not None: + server = _bind_stored_port(stored) + if server is not None: + return server, stored, True + server = CallbackServer(port=port) + client_id = register_client(metadata, redirect_uris=[server.redirect_uri], client=http) + registration = OAuthClientRegistration( + client_id=client_id, redirect_uri=server.redirect_uri, issuer=metadata.issuer + ) + save_oauth_client(registration) + return server, registration, False + + +def _authorize( + metadata: AuthServerMetadata, + registration: OAuthClientRegistration, + server: CallbackServer, + *, + resource: str, + open_browser: bool, + http: httpx2.Client, +) -> OAuthCredential: + verifier, challenge = pkce_pair() + state = secrets.token_urlsafe(STATE_BYTES) + url = build_authorization_url( + metadata, + client_id=registration.client_id, + redirect_uri=registration.redirect_uri, + code_challenge=challenge, + state=state, + resource=resource, + ) + print(f"Open this URL in your browser to log in:\n{url}", file=sys.stderr) + if open_browser and not webbrowser.open(url): + print("Could not open a browser; open the URL above manually.", file=sys.stderr) + callback = server.wait(timeout=LOGIN_TIMEOUT_SECONDS) + if callback is None: + _abort_login(f"Timed out after {LOGIN_TIMEOUT_SECONDS:.0f}s waiting for the browser login") + if callback.get("state") != state: + _abort_login("Invalid OAuth callback (state mismatch)") + if "error" in callback: + message = f"Authorization failed: {callback.get('error_description') or callback['error']}" + if callback["error"] in DEAD_CLIENT_ERRORS: + raise _DeadClientError(message) + _abort_login(message) + if "code" not in callback: + _abort_login("Invalid OAuth callback (missing code)") + try: + return exchange_code( + metadata, + client_id=registration.client_id, + code=callback["code"], + code_verifier=verifier, + redirect_uri=registration.redirect_uri, + resource=resource, + client=http, + ) + except OAuthError as exc: + if exc.error in DEAD_CLIENT_ERRORS: + raise _DeadClientError(f"Token exchange failed: {exc}") from exc + raise + + +def _oauth_login(ctx: typer.Context, *, open_browser: bool, port: int) -> OAuthCredential: + base_url = str(ctx.obj.get("base_url") or DEFAULT_BASE_URL).rstrip("/") + with httpx2.Client(timeout=OAUTH_HTTP_TIMEOUT_SECONDS) as http: + metadata = discover(base_url, client=http) + server, registration, reused = _register_or_reuse(metadata, port=port, http=http) + try: + with server: + return _authorize( + metadata, registration, server, resource=base_url, open_browser=open_browser, http=http + ) + except _DeadClientError as exc: + if not reused: + _abort_login(str(exc)) + delete_oauth_client() + server, registration, _ = _register_or_reuse(metadata, port=port, http=http) + try: + with server: + return _authorize( + metadata, registration, server, resource=base_url, open_browser=open_browser, http=http + ) + except _DeadClientError as exc: + _abort_login(str(exc)) + + +def _api_key_login(ctx: typer.Context, *, api_key: str | None) -> None: + # An ambient DISCOLIKE_API_KEY must not silently become the saved key; only an explicit flag may. + passed_globally = ctx.obj.get("api_key") if _global_key_source(ctx) == SOURCE_OPTION else None + key = api_key or passed_globally or typer.prompt("API key", hide_input=True) + _verify(ctx, api_key=key) + save_config({"auth_method": AUTH_METHOD_API_KEY, "api_key": key}) + print(json.dumps({"logged_in": True, "source": AUTH_METHOD_API_KEY}), file=sys.stderr) + + @app.command() @handle_errors def login( ctx: typer.Context, - api_key: str | None = typer.Option(None, help=f"API key. Create one at {KEYS_URL}. Prompted for if omitted."), + api_key: str | None = typer.Option( + None, help=f"Log in with an API key instead of the browser. Create one at {KEYS_URL}." + ), + method: str = typer.Option( + AUTH_METHOD_OAUTH, + "--method", + help="oauth (browser login, default) or api_key (prompts for a key unless --api-key is given).", + ), + no_browser: bool = typer.Option(False, "--no-browser", help="Print the login URL instead of opening a browser."), + port: int = typer.Option( + RANDOM_PORT, "--port", help="Fixed loopback port for the browser redirect (default: random; use with SSH)." + ), ) -> None: - """Verify an API key and save it to the local config file.""" - # An ambient DISCOLIKE_API_KEY must not silently become the saved key; only an explicit flag may. - passed_globally = ctx.obj.get("api_key") if _global_key_source(ctx) == SOURCE_OPTION else None - key = api_key or passed_globally or typer.prompt("API key", hide_input=True) - _verify(ctx, api_key=key) - save_config({"auth_method": "api_key", "api_key": key}) - print(json.dumps({"logged_in": True, "source": "api_key"}), file=sys.stderr) + """Log in via the browser (OAuth) or with an API key, verify, and save to the local config file.""" + if method not in LOGIN_METHODS: + raise typer.BadParameter(f"must be one of {', '.join(LOGIN_METHODS)}", param_hint="--method") + global_key_passed = ctx.obj.get("api_key") is not None and _global_key_source(ctx) == SOURCE_OPTION + if api_key or global_key_passed or method == AUTH_METHOD_API_KEY: + _api_key_login(ctx, api_key=api_key) + return + credential = _oauth_login(ctx, open_browser=not no_browser, port=port) + _verify(ctx, auth=credential) + save_credential(credential) + print( + json.dumps({"logged_in": True, "method": AUTH_METHOD_OAUTH, "expires_at": _iso(credential.expires_at)}), + file=sys.stderr, + ) @app.command() @handle_errors def status(ctx: typer.Context) -> None: - """Show which API key is in use (option, env, or config) and verify it against the API.""" + """Show which credential is in use (option, env, or config) and verify it against the API.""" key = ctx.obj.get("api_key") - source = _global_key_source(ctx) - if not key: - key = load_config().get("api_key") - source = SOURCE_CONFIG - if not key: - resolve_api_key(None) - _verify(ctx, api_key=str(key)) - emit({"source": source, "api_key": _mask(str(key)), "valid": True}) + if key: + _verify(ctx, api_key=str(key)) + emit( + { + "source": _global_key_source(ctx), + "method": AUTH_METHOD_API_KEY, + "api_key": _mask(str(key)), + "valid": True, + } + ) + return + credential = load_credential() + if credential is None: + raise AuthenticationError(NO_CREDENTIAL_MESSAGE) + if isinstance(credential, OAuthCredential): + _verify(ctx) + emit( + { + "source": SOURCE_CONFIG, + "method": AUTH_METHOD_OAUTH, + "expires_at": _iso(credential.expires_at), + "expired": credential.expires_within(0), + "valid": True, + } + ) + return + _verify(ctx, api_key=credential.api_key) + emit({"source": SOURCE_CONFIG, "method": AUTH_METHOD_API_KEY, "api_key": _mask(credential.api_key), "valid": True}) @app.command() @handle_errors def logout() -> None: - """Delete saved credentials from the local config file.""" - delete_config() + """Delete saved credentials from the local config file (the registered OAuth client is kept).""" + delete_credential() emit({"logged_out": True}) diff --git a/packages/discolike-cli/src/discolike_cli/company.py b/packages/discolike-cli/src/discolike_cli/company.py index 079f6c8..ed52872 100644 --- a/packages/discolike-cli/src/discolike_cli/company.py +++ b/packages/discolike-cli/src/discolike_cli/company.py @@ -2,8 +2,18 @@ import typer +from discolike.requests import CompaniesDataParams +from discolike.requests import CompaniesExtractParams +from discolike.requests import CompaniesGrowthParams +from discolike.requests import CompaniesPublicLinksParams +from discolike.requests import CompaniesRedirectsParams +from discolike.requests import CompaniesScoreParams +from discolike.requests import CompaniesSubsidiariesParams +from discolike.requests import CompaniesVendorsParams +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 FORMAT_HELP = "Output format: json or table (table auto-selected on a TTY; falls back to JSON for non-tabular data)." DOMAIN_HELP = "Company domain, e.g. stripe.com" @@ -24,7 +34,9 @@ def data( """Full company profile (firmographics) for a domain.""" from discolike_cli.main import get_client - emit(get_client(ctx).companies.data(domain=domain), fmt=fmt) + emit( + get_client(ctx).companies.data(build_request(CompaniesDataParams, _merge_params(None, domain=domain))), fmt=fmt + ) @app.command() @@ -37,7 +49,10 @@ def score( """Company score for a domain.""" from discolike_cli.main import get_client - emit(get_client(ctx).companies.score(domain=domain), fmt=fmt) + emit( + get_client(ctx).companies.score(build_request(CompaniesScoreParams, _merge_params(None, domain=domain))), + fmt=fmt, + ) @app.command() @@ -50,7 +65,10 @@ def growth( """Growth signals for a domain.""" from discolike_cli.main import get_client - emit(get_client(ctx).companies.growth(domain=domain), fmt=fmt) + emit( + get_client(ctx).companies.growth(build_request(CompaniesGrowthParams, _merge_params(None, domain=domain))), + fmt=fmt, + ) @app.command() @@ -64,7 +82,8 @@ def redirects( """Domain redirects for a company domain.""" from discolike_cli.main import get_client - emit(get_client(ctx).companies.redirects(domain=domain, match=match), fmt=fmt) + request = build_request(CompaniesRedirectsParams, _merge_params(None, domain=domain, match=match)) + emit(get_client(ctx).companies.redirects(request), fmt=fmt) @app.command() @@ -78,7 +97,8 @@ def vendors( """Vendors associated with a company domain.""" from discolike_cli.main import get_client - emit(get_client(ctx).companies.vendors(domain=domain, match=match), fmt=fmt) + request = build_request(CompaniesVendorsParams, _merge_params(None, domain=domain, match=match)) + emit(get_client(ctx).companies.vendors(request), fmt=fmt) @app.command() @@ -92,7 +112,8 @@ def subsidiaries( """Subsidiaries of a company domain.""" from discolike_cli.main import get_client - emit(get_client(ctx).companies.subsidiaries(domain=domain, match=match), fmt=fmt) + request = build_request(CompaniesSubsidiariesParams, _merge_params(None, domain=domain, match=match)) + emit(get_client(ctx).companies.subsidiaries(request), fmt=fmt) @app.command(name="public-links") @@ -106,7 +127,8 @@ def public_links( """Public profile links for a domain from a given source.""" from discolike_cli.main import get_client - emit(get_client(ctx).companies.public_links(domain=domain, source=source), fmt=fmt) + request = build_request(CompaniesPublicLinksParams, _merge_params(None, domain=domain, source=source)) + emit(get_client(ctx).companies.public_links(request), fmt=fmt) @handle_errors @@ -118,4 +140,6 @@ def extract_command( """Extract page content from a URL.""" from discolike_cli.main import get_client - emit(get_client(ctx).companies.extract(url=url), fmt=fmt) + emit( + get_client(ctx).companies.extract(build_request(CompaniesExtractParams, _merge_params(None, url=url))), fmt=fmt + ) diff --git a/packages/discolike-cli/src/discolike_cli/contacts.py b/packages/discolike-cli/src/discolike_cli/contacts.py index 9c6a23c..ecc1a10 100644 --- a/packages/discolike-cli/src/discolike_cli/contacts.py +++ b/packages/discolike-cli/src/discolike_cli/contacts.py @@ -5,7 +5,14 @@ import typer -from discolike_cli._output import call_typed +from discolike.requests import BulkContactMatchRequest +from discolike.requests import ContactFilters +from discolike.requests import ContactGenerateRequest +from discolike.requests import ContactsCountParams +from discolike.requests import ContactsLookupParams +from discolike.requests import ContactsMatchParams +from discolike.requests import ContactsSearchParams +from discolike_cli._output import build_request from discolike_cli._output import emit from discolike_cli._output import handle_errors from discolike_cli._output import run_job @@ -77,7 +84,7 @@ def search_command( max_records=max_records, offset=offset, ) - emit(call_typed(get_client(ctx).contacts.search, **kwargs), fmt=fmt) + emit(get_client(ctx).contacts.search(build_request(ContactsSearchParams, kwargs)), fmt=fmt) @app.command("count") @@ -129,7 +136,7 @@ def count_command( has_email=has_email, jobstart_date=jobstart_date, ) - emit(call_typed(get_client(ctx).contacts.count, **kwargs), fmt=fmt) + emit(get_client(ctx).contacts.count(build_request(ContactsCountParams, kwargs)), fmt=fmt) @app.command("lookup") @@ -144,7 +151,10 @@ def lookup_command( """Look up a single contact by persona ID, LinkedIn URL, or email.""" from discolike_cli.main import get_client - emit(get_client(ctx).contacts.lookup(persona_id=persona_id, linkedin=linkedin, email=email), fmt=fmt) + request = build_request( + ContactsLookupParams, _merge_params(None, persona_id=persona_id, linkedin=linkedin, email=email) + ) + emit(get_client(ctx).contacts.lookup(request), fmt=fmt) @app.command("match") @@ -161,16 +171,13 @@ def match_command( """Match a person name to contact records.""" from discolike_cli.main import get_client - emit( - get_client(ctx).contacts.match( - name=name, - company_name=company_name, - domain=domain, - person_country=person_country, - limit=limit, + request = build_request( + ContactsMatchParams, + _merge_params( + None, name=name, company_name=company_name, domain=domain, person_country=person_country, limit=limit ), - fmt=fmt, ) + emit(get_client(ctx).contacts.match(request), fmt=fmt) @app.command("bulk-match") @@ -198,8 +205,8 @@ def bulk_match_command( if not isinstance(queries, list): raise typer.BadParameter("--queries-file must contain a JSON array of objects") - job = get_client(ctx).contacts.bulk_match(queries=queries, enrich=enrich, limit=limit) - run_job(job, wait=wait, timeout=timeout, fmt=fmt) + request = build_request(BulkContactMatchRequest, _merge_params(None, queries=queries, enrich=enrich, limit=limit)) + run_job(get_client(ctx).contacts.bulk_match(request), wait=wait, timeout=timeout, fmt=fmt) @app.command("discover") @@ -267,7 +274,7 @@ def discover_command( include_search_contacts=include_search_contacts, consensus=consensus, ) - emit(call_typed(get_client(ctx).contacts.discover, **kwargs), fmt=fmt) + emit(get_client(ctx).contacts.discover(build_request(ContactFilters, kwargs)), fmt=fmt) @app.command("generate") @@ -297,14 +304,18 @@ def generate_command( """Generate contacts for target domains from an ICP description (async job).""" from discolike_cli.main import get_client - job = get_client(ctx).contacts.generate( - icp_text=icp_text, - domains=domain, - context_mode=context_mode, - integration_id=integration_id, - search_provider_id=search_provider_id, - search_context_size=search_context_size, - max_contacts_per_domain=max_contacts_per_domain, - max_company_records=max_company_records, + request = build_request( + ContactGenerateRequest, + _merge_params( + None, + icp_text=icp_text, + domains=domain, + context_mode=context_mode, + integration_id=integration_id, + search_provider_id=search_provider_id, + search_context_size=search_context_size, + max_contacts_per_domain=max_contacts_per_domain, + max_company_records=max_company_records, + ), ) - run_job(job, wait=wait, timeout=timeout, fmt=fmt) + run_job(get_client(ctx).contacts.generate(request), wait=wait, timeout=timeout, fmt=fmt) diff --git a/packages/discolike-cli/src/discolike_cli/discogen.py b/packages/discolike-cli/src/discolike_cli/discogen.py index 9ff7347..fbb33be 100644 --- a/packages/discolike-cli/src/discolike_cli/discogen.py +++ b/packages/discolike-cli/src/discolike_cli/discogen.py @@ -5,9 +5,13 @@ import typer from discolike._jobs import Job +from discolike.requests import DiscoGenPersonaProcessRequest +from discolike.requests import DiscoGenProcessRequest +from discolike_cli._output import build_request from discolike_cli._output import emit from discolike_cli._output import handle_errors from discolike_cli._output import run_job +from discolike_cli.discover import _merge_params DEFAULT_WAIT_TIMEOUT_SECONDS = 900.0 @@ -58,17 +62,21 @@ def run_command( """Run a DiscoGen research query across company domains (async job).""" from discolike_cli.main import get_client - job = get_client(ctx).discogen.process( - query=query, - domains=domain, - integration_id=integration_id, - web_search=web_search, - context_mode=context_mode, - include_x_search=include_x_search, - search_provider_id=search_provider_id, - search_context_size=search_context_size, + request = build_request( + DiscoGenProcessRequest, + _merge_params( + None, + query=query, + domains=domain, + integration_id=integration_id, + web_search=web_search, + context_mode=context_mode, + include_x_search=include_x_search, + search_provider_id=search_provider_id, + search_context_size=search_context_size, + ), ) - run_job(job, wait=wait, timeout=timeout, fmt=fmt) + run_job(get_client(ctx).discogen.process(request), wait=wait, timeout=timeout, fmt=fmt) @app.command("run-personas") @@ -92,17 +100,21 @@ def run_personas_command( """Run a DiscoGen research query across personas (async job).""" from discolike_cli.main import get_client - job = get_client(ctx).discogen.process_personas( - query=query, - persona_ids=persona_id, - integration_id=integration_id, - web_search=web_search, - context_mode=context_mode, - include_x_search=include_x_search, - search_provider_id=search_provider_id, - search_context_size=search_context_size, + request = build_request( + DiscoGenPersonaProcessRequest, + _merge_params( + None, + query=query, + persona_ids=persona_id, + integration_id=integration_id, + web_search=web_search, + context_mode=context_mode, + include_x_search=include_x_search, + search_provider_id=search_provider_id, + search_context_size=search_context_size, + ), ) - run_job(job, wait=wait, timeout=timeout, fmt=fmt) + run_job(get_client(ctx).discogen.process_personas(request), wait=wait, timeout=timeout, fmt=fmt) @app.command("models") diff --git a/packages/discolike-cli/src/discolike_cli/discover.py b/packages/discolike-cli/src/discolike_cli/discover.py index 02cc548..beafaeb 100644 --- a/packages/discolike-cli/src/discolike_cli/discover.py +++ b/packages/discolike-cli/src/discolike_cli/discover.py @@ -4,7 +4,9 @@ import typer -from discolike_cli._output import call_typed +from discolike.requests import CountParams +from discolike.requests import DiscoverParams +from discolike_cli._output import build_request from discolike_cli._output import emit from discolike_cli._output import handle_errors @@ -24,7 +26,7 @@ def _parse_param(raw: str) -> tuple[str, str | list[str]]: return key, value -def _merge_params(param: list[str] | None, **options: Any) -> dict[str, Any]: # noqa: ANN401 -- forwarded as **kwargs to typed resource/client methods +def _merge_params(param: list[str] | None, **options: Any) -> dict[str, Any]: # noqa: ANN401 -- forwarded as a dict to build_request kwargs: dict[str, Any] = dict(_parse_param(raw) for raw in param or []) kwargs.update({key: value for key, value in options.items() if value is not None}) return kwargs @@ -65,33 +67,35 @@ def discover_command( """Discover companies matching your ICP and filters.""" from discolike_cli.main import get_client - kwargs = _merge_params( - param, - icp_prompt=icp_prompt, - domain=domain, - phrase_match=phrase_match, - negate_phrase_match=negate_phrase_match, - category=category, - negate_category=negate_category, - country=country, - negate_country=negate_country, - state=state, - negate_state=negate_state, - employee_range=employee_range, - revenue_range=revenue_range, - business_model=business_model, - negate_business_model=negate_business_model, - tech_stack=tech_stack, - negate_tech_stack=negate_tech_stack, - min_digital_footprint=min_digital_footprint, - max_digital_footprint=max_digital_footprint, - exclude_domain=exclude_domain, - exclusion_query_id=exclusion_query_id, - max_records=max_records, - offset=offset, + request = build_request( + DiscoverParams, + _merge_params( + param, + icp_prompt=icp_prompt, + domain=domain, + phrase_match=phrase_match, + negate_phrase_match=negate_phrase_match, + category=category, + negate_category=negate_category, + country=country, + negate_country=negate_country, + state=state, + negate_state=negate_state, + employee_range=employee_range, + revenue_range=revenue_range, + business_model=business_model, + negate_business_model=negate_business_model, + tech_stack=tech_stack, + negate_tech_stack=negate_tech_stack, + min_digital_footprint=min_digital_footprint, + max_digital_footprint=max_digital_footprint, + exclude_domain=exclude_domain, + exclusion_query_id=exclusion_query_id, + max_records=max_records, + offset=offset, + ), ) - companies = call_typed(get_client(ctx).discover, **kwargs) - emit(companies, fmt=fmt) + emit(get_client(ctx).discover(request), fmt=fmt) @handle_errors @@ -121,24 +125,26 @@ def count_command( """Count companies matching the given filters.""" from discolike_cli.main import get_client - kwargs = _merge_params( - param, - phrase_match=phrase_match, - negate_phrase_match=negate_phrase_match, - category=category, - negate_category=negate_category, - country=country, - negate_country=negate_country, - state=state, - negate_state=negate_state, - employee_range=employee_range, - revenue_range=revenue_range, - business_model=business_model, - negate_business_model=negate_business_model, - tech_stack=tech_stack, - negate_tech_stack=negate_tech_stack, - min_digital_footprint=min_digital_footprint, - max_digital_footprint=max_digital_footprint, + request = build_request( + CountParams, + _merge_params( + param, + phrase_match=phrase_match, + negate_phrase_match=negate_phrase_match, + category=category, + negate_category=negate_category, + country=country, + negate_country=negate_country, + state=state, + negate_state=negate_state, + employee_range=employee_range, + revenue_range=revenue_range, + business_model=business_model, + negate_business_model=negate_business_model, + tech_stack=tech_stack, + negate_tech_stack=negate_tech_stack, + min_digital_footprint=min_digital_footprint, + max_digital_footprint=max_digital_footprint, + ), ) - count = call_typed(get_client(ctx).count, **kwargs) - emit(count, fmt=fmt) + emit(get_client(ctx).count(request), fmt=fmt) diff --git a/packages/discolike-cli/src/discolike_cli/email.py b/packages/discolike-cli/src/discolike_cli/email.py index 321ef09..c591f7f 100644 --- a/packages/discolike-cli/src/discolike_cli/email.py +++ b/packages/discolike-cli/src/discolike_cli/email.py @@ -10,13 +10,18 @@ from discolike._email import EmailBatchResults from discolike._email import EmailJobResult from discolike._exceptions import JobTimeoutError +from discolike.requests import FindEmailBatchRequest +from discolike.requests import FindEmailRequest +from discolike_cli._output import build_request from discolike_cli._output import emit from discolike_cli._output import handle_errors +from discolike_cli.discover import _merge_params DEFAULT_WAIT_TIMEOUT_SECONDS = 900.0 MAX_BATCH_CONTACTS = 500 EMAIL_KINDS = ("find", "verify") CSV_COLUMNS = ("first_name", "last_name", "domain") +FIRST_DATA_ROW = 2 FORMAT_HELP = "Output format: json or table (table auto-selected on a TTY; falls back to JSON for non-tabular data)." WAIT_HELP = "Block until the job finishes, streaming progress to stderr." @@ -52,7 +57,14 @@ def _read_contacts_file(contacts_file: pathlib.Path) -> list[dict[str, str]]: missing = set(CSV_COLUMNS) - set(reader.fieldnames or []) if missing: raise typer.BadParameter(f"--contacts-file is missing required CSV columns: {', '.join(sorted(missing))}") - return [{column: (row.get(column) or "").strip() for column in CSV_COLUMNS} for row in reader] + contacts = [] + for row_number, row in enumerate(reader, start=FIRST_DATA_ROW): + contact = {column: (row.get(column) or "").strip() for column in CSV_COLUMNS} + empty = [column for column, value in contact.items() if not value] + if empty: + raise typer.BadParameter(f"--contacts-file row {row_number} has empty {', '.join(empty)}") + contacts.append(contact) + return contacts def _fetch_batch_snapshot(batch: EmailBatch) -> EmailBatchResults: @@ -81,9 +93,11 @@ def find_command( """Submit a single email find job (async); only a proven address bills.""" from discolike_cli.main import get_client - job = get_client(ctx).email.find( - first_name=first_name, last_name=last_name, domain=domain, known_pattern=known_pattern + request = build_request( + FindEmailRequest, + _merge_params(None, first_name=first_name, last_name=last_name, domain=domain, known_pattern=known_pattern), ) + job = get_client(ctx).email.find(request) if not wait: emit({"job_id": job.job_id, "hint": f"poll with: discolike email job {job.job_id}"}) return @@ -118,7 +132,7 @@ def find_batch_command( if len(contacts) > MAX_BATCH_CONTACTS: raise typer.BadParameter(f"a batch holds at most {MAX_BATCH_CONTACTS} contacts, got {len(contacts)}") - batch = get_client(ctx).email.find_batch(contacts=contacts) + batch = get_client(ctx).email.find_batch(build_request(FindEmailBatchRequest, {"requests": contacts})) if not wait: emit({"batch_id": batch.batch_id, "hint": f"fetch with: discolike email results {batch.batch_id}"}) return diff --git a/packages/discolike-cli/src/discolike_cli/enrich.py b/packages/discolike-cli/src/discolike_cli/enrich.py index 6e13d9f..a45aa52 100644 --- a/packages/discolike-cli/src/discolike_cli/enrich.py +++ b/packages/discolike-cli/src/discolike_cli/enrich.py @@ -4,9 +4,15 @@ import typer +from discolike.requests import AppendParams +from discolike.requests import SegmentFileParams +from discolike.requests import SegmentParams +from discolike.requests import ValidateIcpRequest +from discolike_cli._output import build_request from discolike_cli._output import emit from discolike_cli._output import handle_errors from discolike_cli._output import run_job +from discolike_cli.discover import _merge_params DEFAULT_WAIT_TIMEOUT_SECONDS = 900.0 @@ -49,15 +55,19 @@ def validate_icp_command( assert domain is not None domains = domain - job = get_client(ctx).validate_icp( - icp_text=icp, - domains=domains, - context_mode=context_mode, - integration_id=integration_id, - web_search=web_search, - search_provider_id=search_provider_id, + request = build_request( + ValidateIcpRequest, + _merge_params( + None, + icp_text=icp, + domains=domains, + context_mode=context_mode, + integration_id=integration_id, + web_search=web_search, + search_provider_id=search_provider_id, + ), ) - run_job(job, wait=wait, timeout=timeout, fmt=fmt) + run_job(get_client(ctx).validate_icp(request), wait=wait, timeout=timeout, fmt=fmt) @handle_errors @@ -75,7 +85,8 @@ def append_command( """Enrich a CSV of domains with DiscoLike datasets.""" from discolike_cli.main import get_client - result = get_client(ctx).append(file=file, dataset=dataset, domain_column=domain_column, csv=csv) + request = build_request(AppendParams, _merge_params(None, dataset=dataset, domain_column=domain_column, csv=csv)) + result = get_client(ctx).append(request, file=file) if isinstance(result, bytes): if output is None: raise typer.BadParameter("--output is required when the response is CSV bytes") @@ -102,10 +113,14 @@ def segment_command( if (not domain) == (file is None): raise typer.BadParameter("Provide exactly one of --domain or --file") - job = get_client(ctx).segment( - domains=domain or None, - file=file, - domain_column=domain_column, - max_segments=max_segments, - ) + client = get_client(ctx) + if file is not None: + request = build_request( + SegmentFileParams, _merge_params(None, domain_column=domain_column, max_segments=max_segments) + ) + job = client.segment_file(request, file=file) + else: + assert domain is not None + request = build_request(SegmentParams, _merge_params(None, domains=",".join(domain), max_segments=max_segments)) + job = client.segment(request) run_job(job, wait=wait, timeout=timeout, fmt=fmt) diff --git a/packages/discolike-cli/src/discolike_cli/match.py b/packages/discolike-cli/src/discolike_cli/match.py index 5c60217..2a162c2 100644 --- a/packages/discolike-cli/src/discolike_cli/match.py +++ b/packages/discolike-cli/src/discolike_cli/match.py @@ -4,9 +4,13 @@ import typer +from discolike.requests import MatchBulkParams +from discolike.requests import MatchCompanyParams +from discolike_cli._output import build_request from discolike_cli._output import emit from discolike_cli._output import handle_errors from discolike_cli._output import run_job +from discolike_cli.discover import _merge_params DEFAULT_WAIT_TIMEOUT_SECONDS = 900.0 DEFAULT_NAME_COLUMN = "name" @@ -45,19 +49,25 @@ def match_command( client = get_client(ctx) if name is not None: - response = client.match.company( - name=name, - phone=phone, - city=city, - state=state, - country=country, - zip_code=zip_code, - strict=strict, - local_mode=local_mode, + request = build_request( + MatchCompanyParams, + _merge_params( + None, + name=name, + phone=phone, + city=city, + state=state, + country=country, + zip_code=zip_code, + strict=strict, + local_mode=local_mode, + ), ) - emit(response, fmt=fmt) + emit(client.match.company(request), fmt=fmt) return assert file is not None - job = client.match.bulk(file=file, name_column=name_column, strict=strict, local_mode=local_mode) - run_job(job, wait=wait, timeout=timeout, fmt=fmt) + request = build_request( + MatchBulkParams, _merge_params(None, name_column=name_column, strict=strict, local_mode=local_mode) + ) + run_job(client.match.bulk(request, file=file), wait=wait, timeout=timeout, fmt=fmt) diff --git a/packages/discolike-cli/src/discolike_cli/providers.py b/packages/discolike-cli/src/discolike_cli/providers.py index 3e568f2..93d8701 100644 --- a/packages/discolike-cli/src/discolike_cli/providers.py +++ b/packages/discolike-cli/src/discolike_cli/providers.py @@ -2,8 +2,13 @@ import typer +from discolike.requests import LLMProviderCreateRequest +from discolike.requests import LLMProviderUpdateRequest +from discolike.requests import SearchProviderRequest +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 FORMAT_HELP = "Output format: json or table (table auto-selected on a TTY; falls back to JSON for non-tabular data)." @@ -36,15 +41,18 @@ def search_create_command( """Create a search provider integration (connectivity is validated first).""" from discolike_cli.main import get_client - emit( - get_client(ctx).search_providers.create( + request = build_request( + SearchProviderRequest, + _merge_params( + None, integration_name=name, provider=provider, search_model=search_model, api_key=api_key, base_url=base_url, - ) + ), ) + emit(get_client(ctx).search_providers.create(request)) @search_providers_app.command("update") @@ -61,16 +69,18 @@ def search_update_command( """Update a search provider integration.""" from discolike_cli.main import get_client - emit( - get_client(ctx).search_providers.update( - integration_id=integration_id, + request = build_request( + SearchProviderRequest, + _merge_params( + None, integration_name=name, provider=provider, search_model=search_model, api_key=api_key, base_url=base_url, - ) + ), ) + emit(get_client(ctx).search_providers.update(request, integration_id=integration_id)) @search_providers_app.command("delete") @@ -147,15 +157,13 @@ def llm_create_command( """Create an LLM provider integration.""" from discolike_cli.main import get_client - emit( - get_client(ctx).llm_providers.create( - integration_name=name, - provider=provider, - api_key=api_key, - model_name=model_name, - base_url=base_url, - ) + request = build_request( + LLMProviderCreateRequest, + _merge_params( + None, integration_name=name, provider=provider, api_key=api_key, model_name=model_name, base_url=base_url + ), ) + emit(get_client(ctx).llm_providers.create(request)) @llm_providers_app.command("get") @@ -185,16 +193,14 @@ def llm_update_command( """Update an LLM provider integration.""" from discolike_cli.main import get_client - emit( - get_client(ctx).llm_providers.update( - integration_id=integration_id, - integration_name=name, - provider=provider, - model_name=model_name, - api_key=api_key, - base_url=base_url, - ) + request = build_request( + LLMProviderUpdateRequest, + { + **_merge_params(None, integration_name=name, provider=provider, model_name=model_name, base_url=base_url), + "api_key": api_key, + }, ) + emit(get_client(ctx).llm_providers.update(request, integration_id=integration_id)) @llm_providers_app.command("delete") @@ -235,12 +241,10 @@ def llm_test_connection_command( """Test a provider configuration before saving it.""" from discolike_cli.main import get_client - emit( - get_client(ctx).llm_providers.test_connection( - integration_name=name, - provider=provider, - api_key=api_key, - model_name=model_name, - base_url=base_url, - ) + request = build_request( + LLMProviderCreateRequest, + _merge_params( + None, integration_name=name, provider=provider, api_key=api_key, model_name=model_name, base_url=base_url + ), ) + emit(get_client(ctx).llm_providers.test_connection(request)) diff --git a/packages/discolike-cli/src/discolike_cli/queries.py b/packages/discolike-cli/src/discolike_cli/queries.py index a337a13..71a23f8 100644 --- a/packages/discolike-cli/src/discolike_cli/queries.py +++ b/packages/discolike-cli/src/discolike_cli/queries.py @@ -7,8 +7,14 @@ import typer +from discolike.requests import CreateExclusionListRequest +from discolike.requests import QueriesListParams +from discolike.requests import SaveResultsRequest +from discolike.requests import UpdateQueryRequest +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 FORMAT_HELP = "Output format: json or table (table auto-selected on a TTY; falls back to JSON for non-tabular data)." @@ -36,7 +42,10 @@ def list_command( """List saved queries.""" from discolike_cli.main import get_client - emit(get_client(ctx).queries.list(max_records=max_records, offset=offset, action=action, tags=tag), fmt=fmt) + request = build_request( + QueriesListParams, _merge_params(None, max_records=max_records, offset=offset, action=action, tags=tag) + ) + emit(get_client(ctx).queries.list(request), fmt=fmt) @app.command("create-exclusion-list") @@ -51,14 +60,11 @@ def create_exclusion_list_command( """Create a named exclusion list of domains and/or persona IDs.""" from discolike_cli.main import get_client - emit( - get_client(ctx).queries.create_exclusion_list( - query_name=name, - domains=domain, - persona_ids=persona_id, - tags=tag, - ) + request = build_request( + CreateExclusionListRequest, + _merge_params(None, query_name=name, domains=domain, persona_ids=persona_id, tags=tag), ) + emit(get_client(ctx).queries.create_exclusion_list(request)) @app.command("save-results") @@ -89,15 +95,11 @@ def save_results_command( except json.JSONDecodeError as exc: raise typer.BadParameter(f"--input file {input_path} must contain valid JSON: {exc}") from exc - emit( - get_client(ctx).queries.save_results( - query_name=name, - action=action.value, - data=data, - domain_column=domain_column, - tags=tag, - ) + request = build_request( + SaveResultsRequest, + _merge_params(None, query_name=name, action=action.value, data=data, domain_column=domain_column, tags=tag), ) + emit(get_client(ctx).queries.save_results(request)) @app.command("update") @@ -111,7 +113,8 @@ def update_command( """Rename a saved query and/or update its tags.""" from discolike_cli.main import get_client - emit(get_client(ctx).queries.update(query_id=query_id, query_name=name, tags=tag)) + request = build_request(UpdateQueryRequest, _merge_params(None, query_name=name, tags=tag)) + emit(get_client(ctx).queries.update(request, query_id=query_id)) @app.command("delete") diff --git a/packages/discolike-cli/tests/test_auth.py b/packages/discolike-cli/tests/test_auth.py index b2f5f72..f7b3de9 100644 --- a/packages/discolike-cli/tests/test_auth.py +++ b/packages/discolike-cli/tests/test_auth.py @@ -39,7 +39,7 @@ def test_login_with_api_key_option_verifies_and_saves(install_build_client: Call def test_login_prompts_for_key_when_not_given(install_build_client: Callable[[Handler], None]) -> None: install_build_client(_usage_ok) - result = runner.invoke(app, ["auth", "login"], input="dk-2\n") + result = runner.invoke(app, ["auth", "login", "--method", "api_key"], input="dk-2\n") assert result.exit_code == 0, result.output assert json.loads(config_path().read_text())["api_key"] == "dk-2" @@ -196,7 +196,7 @@ def test_login_ignores_ambient_env_key_and_still_prompts( ) -> None: install_build_client(_usage_ok) monkeypatch.setenv(ENV_API_KEY, "dk-from-env") - result = runner.invoke(app, ["auth", "login"], input="dk-typed\n") + result = runner.invoke(app, ["auth", "login", "--method", "api_key"], input="dk-typed\n") assert result.exit_code == 0, result.output assert json.loads(config_path().read_text())["api_key"] == "dk-typed" diff --git a/packages/discolike-cli/tests/test_auth_oauth.py b/packages/discolike-cli/tests/test_auth_oauth.py new file mode 100644 index 0000000..a241008 --- /dev/null +++ b/packages/discolike-cli/tests/test_auth_oauth.py @@ -0,0 +1,411 @@ +from __future__ import annotations + +import dataclasses +import json +import socket +import threading +import time +from collections.abc import Callable +from typing import Any +from urllib.parse import parse_qs +from urllib.parse import urlparse +from urllib.request import urlopen + +import httpx2 +import pytest +from typer.testing import CliRunner + +import discolike_cli.auth as auth_module +from discolike._config import DEFAULT_BASE_URL +from discolike._config import config_path +from discolike._config import load_credential +from discolike._config import load_oauth_client +from discolike._config import save_credential +from discolike._config import save_oauth_client +from discolike._credentials import ApiKeyCredential +from discolike._credentials import OAuthClientRegistration +from discolike._credentials import OAuthCredential +from discolike._oauth import AuthServerMetadata +from discolike._oauth import OAuthError +from discolike_cli.main import app +from discolike_testkit import Handler + +runner = CliRunner() + +METADATA = AuthServerMetadata( + authorization_endpoint="https://auth.test/oauth/2.1/authorize", + token_endpoint="https://auth.test/oauth/2.1/token", + registration_endpoint="https://auth.test/oauth/2.1/register", + issuer="https://auth.test/oauth/2.1", +) +CREDENTIAL = OAuthCredential( + access_token="at-1", + refresh_token="rt-1", + expires_at=1_800_000_000.0, + client_id="client-1", + token_endpoint=METADATA.token_endpoint, +) + + +def _usage_ok(request: httpx2.Request) -> httpx2.Response: + return httpx2.Response(200, json={"requests_mtd": 1}) + + +def _usage_unauthorized(request: httpx2.Request) -> httpx2.Response: + return httpx2.Response(401, json={"detail": "Invalid API Key or Session"}) + + +class FakeProvider: + """Stands in for the authorization server and the user's browser.""" + + def __init__(self) -> None: + self.real_build_authorization_url = auth_module.build_authorization_url + self.discover_calls: list[str] = [] + self.register_calls: list[list[str]] = [] + self.exchange_calls: list[dict[str, Any]] = [] + self.opened_urls: list[str] = [] + self.exchange_failures: list[OAuthError] = [] + self.callback_query: Callable[[dict[str, str]], str] = lambda query: f"code=the-code&state={query['state']}" + + def discover(self, base_url: str, *, client: httpx2.Client) -> AuthServerMetadata: + self.discover_calls.append(base_url) + return METADATA + + def register_client(self, metadata: AuthServerMetadata, *, redirect_uris: list[str], client: httpx2.Client) -> str: + self.register_calls.append(redirect_uris) + return "client-1" + + def exchange_code(self, metadata: AuthServerMetadata, *, client: httpx2.Client, **kwargs: Any) -> OAuthCredential: + self.exchange_calls.append(kwargs) + if self.exchange_failures: + raise self.exchange_failures.pop(0) + return CREDENTIAL + + def build_authorization_url(self, metadata: AuthServerMetadata, **kwargs: Any) -> str: + url = self.real_build_authorization_url(metadata, **kwargs) + query = {key: values[0] for key, values in parse_qs(urlparse(url).query).items()} + callback = f"{query['redirect_uri']}?{self.callback_query(query)}" + threading.Thread(target=lambda: urlopen(callback).read(), daemon=True).start() # noqa: S310 -- loopback test server + return url + + def open(self, url: str) -> bool: + self.opened_urls.append(url) + return True + + +@pytest.fixture +def provider(monkeypatch: pytest.MonkeyPatch) -> FakeProvider: + fake = FakeProvider() + monkeypatch.setattr(auth_module, "discover", fake.discover) + monkeypatch.setattr(auth_module, "register_client", fake.register_client) + monkeypatch.setattr(auth_module, "exchange_code", fake.exchange_code) + monkeypatch.setattr(auth_module, "build_authorization_url", fake.build_authorization_url) + monkeypatch.setattr(auth_module.webbrowser, "open", fake.open) + return fake + + +def test_login_default_runs_oauth_loopback_flow( + provider: FakeProvider, + install_build_client: Callable[[Handler], None], + build_client_calls: list[dict[str, Any]], +) -> None: + install_build_client(_usage_ok) + result = runner.invoke(app, ["auth", "login"]) + assert result.exit_code == 0, result.output + assert provider.discover_calls == [DEFAULT_BASE_URL] + redirect_uri = provider.register_calls[0][0] + assert redirect_uri.startswith("http://127.0.0.1:") + assert redirect_uri.endswith("/callback") + exchange = provider.exchange_calls[0] + assert exchange["code"] == "the-code" + assert exchange["client_id"] == "client-1" + assert exchange["redirect_uri"] == redirect_uri + assert exchange["resource"] == DEFAULT_BASE_URL + assert len(provider.opened_urls) == 1 + assert build_client_calls == [{"auth": CREDENTIAL}] + stored = json.loads(config_path().read_text()) + assert (stored["auth_method"], stored["oauth"]) == ("oauth", CREDENTIAL.to_config()) + assert stored["oauth_client"] == {"client_id": "client-1", "redirect_uri": redirect_uri, "issuer": METADATA.issuer} + payload = json.loads(result.stderr.splitlines()[-1]) + assert payload == {"logged_in": True, "method": "oauth", "expires_at": "2027-01-15T08:00:00+00:00"} + assert provider.opened_urls[0] in result.stderr + + +def test_login_no_browser_and_fixed_port( + provider: FakeProvider, install_build_client: Callable[[Handler], None] +) -> None: + install_build_client(_usage_ok) + result = runner.invoke( + app, ["--base-url", "https://api.dev.test/v1/", "auth", "login", "--no-browser", "--port", "18484"] + ) + assert result.exit_code == 0, result.output + assert provider.opened_urls == [] + assert provider.register_calls == [["http://127.0.0.1:18484/callback"]] + assert provider.discover_calls == ["https://api.dev.test/v1"] + assert provider.exchange_calls[0]["resource"] == "https://api.dev.test/v1" + + +def test_login_state_mismatch_exits_1_and_saves_nothing( + provider: FakeProvider, install_build_client: Callable[[Handler], None] +) -> None: + install_build_client(_usage_ok) + provider.callback_query = lambda query: "code=the-code&state=forged" + result = runner.invoke(app, ["auth", "login"]) + assert result.exit_code == 1 + assert load_credential() is None + assert provider.exchange_calls == [] + assert json.loads(result.stderr.splitlines()[-1])["error"] == "LoginError" + + +def test_login_user_denied_exits_1(provider: FakeProvider, install_build_client: Callable[[Handler], None]) -> None: + install_build_client(_usage_ok) + provider.callback_query = lambda query: f"error=access_denied&error_description=nope&state={query['state']}" + result = runner.invoke(app, ["auth", "login"]) + assert result.exit_code == 1 + assert "nope" in json.loads(result.stderr.splitlines()[-1])["message"] + + +def test_login_timeout_exits_1( + provider: FakeProvider, install_build_client: Callable[[Handler], None], monkeypatch: pytest.MonkeyPatch +) -> None: + install_build_client(_usage_ok) + monkeypatch.setattr(auth_module, "LOGIN_TIMEOUT_SECONDS", 0.2) + monkeypatch.setattr(auth_module, "build_authorization_url", lambda metadata, **kwargs: "https://auth.test/never") + result = runner.invoke(app, ["auth", "login"]) + assert result.exit_code == 1 + assert "Timed out" in json.loads(result.stderr.splitlines()[-1])["message"] + assert load_credential() is None + + +def test_login_oauth_verify_failure_exits_3_and_saves_nothing( + provider: FakeProvider, install_build_client: Callable[[Handler], None] +) -> None: + install_build_client(_usage_unauthorized) + result = runner.invoke(app, ["auth", "login"]) + assert result.exit_code == 3 + assert load_credential() is None + assert json.loads(result.stderr.splitlines()[-1])["error"] == "AuthenticationError" + + +def test_login_rejects_unknown_method(provider: FakeProvider) -> None: + result = runner.invoke(app, ["auth", "login", "--method", "magic"]) + assert result.exit_code == 2 + assert provider.discover_calls == [] + + +def test_status_reports_oauth_credential( + install_build_client: Callable[[Handler], None], build_client_calls: list[dict[str, Any]] +) -> None: + install_build_client(_usage_ok) + save_credential(CREDENTIAL) + result = runner.invoke(app, ["auth", "status"]) + assert result.exit_code == 0, result.output + assert build_client_calls == [{}] + assert json.loads(result.stdout) == { + "source": "config", + "method": "oauth", + "expires_at": "2027-01-15T08:00:00+00:00", + "expired": False, + "valid": True, + } + + +def test_status_flags_expired_oauth_credential(install_build_client: Callable[[Handler], None]) -> None: + install_build_client(_usage_ok) + save_credential(dataclasses.replace(CREDENTIAL, expires_at=time.time() - 1)) + result = runner.invoke(app, ["auth", "status"]) + assert result.exit_code == 0, result.output + assert json.loads(result.stdout)["expired"] is True + + +def test_status_reports_api_key_method(install_build_client: Callable[[Handler], None]) -> None: + install_build_client(_usage_ok) + result = runner.invoke(app, ["--api-key", "dk-abcdefgh1234", "auth", "status"]) + assert result.exit_code == 0, result.output + assert json.loads(result.stdout)["method"] == "api_key" + + +def _free_port() -> int: + with socket.socket() as probe: + probe.bind(("127.0.0.1", 0)) + return probe.getsockname()[1] + + +def _registration(port: int, *, issuer: str = METADATA.issuer) -> OAuthClientRegistration: + return OAuthClientRegistration( + client_id="stored-client", redirect_uri=f"http://127.0.0.1:{port}/callback", issuer=issuer + ) + + +def test_login_reuses_stored_client_when_its_port_is_free( + provider: FakeProvider, install_build_client: Callable[[Handler], None] +) -> None: + install_build_client(_usage_ok) + port = _free_port() + save_oauth_client(_registration(port)) + result = runner.invoke(app, ["auth", "login"]) + assert result.exit_code == 0, result.output + assert provider.register_calls == [] + assert provider.exchange_calls[0]["client_id"] == "stored-client" + assert provider.exchange_calls[0]["redirect_uri"] == f"http://127.0.0.1:{port}/callback" + assert load_oauth_client() == _registration(port) + assert json.loads(config_path().read_text())["auth_method"] == "oauth" + + +def test_login_registers_anew_when_stored_port_is_busy( + provider: FakeProvider, install_build_client: Callable[[Handler], None] +) -> None: + install_build_client(_usage_ok) + with socket.socket() as blocker: + blocker.bind(("127.0.0.1", 0)) + blocker.listen() + busy_port = blocker.getsockname()[1] + save_oauth_client(_registration(busy_port)) + result = runner.invoke(app, ["auth", "login"]) + assert result.exit_code == 0, result.output + new_uri = provider.register_calls[0][0] + assert new_uri != f"http://127.0.0.1:{busy_port}/callback" + stored = load_oauth_client() + assert stored is not None + assert (stored.client_id, stored.redirect_uri) == ("client-1", new_uri) + + +def test_login_registers_anew_for_a_different_issuer( + provider: FakeProvider, install_build_client: Callable[[Handler], None] +) -> None: + install_build_client(_usage_ok) + save_oauth_client(_registration(_free_port(), issuer="https://auth.other/oauth/2.1")) + result = runner.invoke(app, ["auth", "login"]) + assert result.exit_code == 0, result.output + assert len(provider.register_calls) == 1 + stored = load_oauth_client() + assert stored is not None + assert stored.issuer == METADATA.issuer + + +def test_login_explicit_port_differing_from_stored_registers_anew( + provider: FakeProvider, install_build_client: Callable[[Handler], None] +) -> None: + install_build_client(_usage_ok) + save_oauth_client(_registration(_free_port())) + wanted = _free_port() + result = runner.invoke(app, ["auth", "login", "--port", str(wanted)]) + assert result.exit_code == 0, result.output + assert provider.register_calls == [[f"http://127.0.0.1:{wanted}/callback"]] + + +def test_logout_keeps_stored_client_and_drops_credential(install_build_client: Callable[[Handler], None]) -> None: + install_build_client(_usage_ok) + save_oauth_client(_registration(18484)) + save_credential(CREDENTIAL) + result = runner.invoke(app, ["auth", "logout"]) + assert result.exit_code == 0, result.output + assert json.loads(result.stdout) == {"logged_out": True} + assert load_credential() is None + assert load_oauth_client() == _registration(18484) + status = runner.invoke(app, ["auth", "status"]) + assert status.exit_code == 3 + assert "discolike auth login" in json.loads(status.stderr)["message"] + + +def test_logout_with_api_key_config_removes_the_key_but_keeps_stored_client() -> None: + save_oauth_client(_registration(18484)) + save_credential(ApiKeyCredential(api_key="dk-1")) + result = runner.invoke(app, ["auth", "logout"]) + assert result.exit_code == 0, result.output + stored = json.loads(config_path().read_text()) + assert "api_key" not in stored + assert "auth_method" not in stored + assert load_oauth_client() == _registration(18484) + + +def _dead_then_ok(query: dict[str, str]) -> str: + if query["client_id"] == "stored-client": + return f"error=invalid_client&error_description=unknown+client&state={query['state']}" + return f"code=the-code&state={query['state']}" + + +def test_login_reregisters_when_authorize_rejects_the_stored_client( + provider: FakeProvider, install_build_client: Callable[[Handler], None] +) -> None: + install_build_client(_usage_ok) + save_oauth_client(_registration(_free_port())) + provider.callback_query = _dead_then_ok + result = runner.invoke(app, ["auth", "login"]) + assert result.exit_code == 0, result.output + assert len(provider.register_calls) == 1 + assert [call["client_id"] for call in provider.exchange_calls] == ["client-1"] + stored = load_oauth_client() + assert stored is not None + assert (stored.client_id, stored.redirect_uri) == ("client-1", provider.register_calls[0][0]) + assert load_credential() == CREDENTIAL + + +def test_login_reregisters_when_exchange_rejects_the_stored_client( + provider: FakeProvider, install_build_client: Callable[[Handler], None] +) -> None: + install_build_client(_usage_ok) + save_oauth_client(_registration(_free_port())) + provider.exchange_failures = [OAuthError("invalid_client: gone", error="invalid_client", status_code=400)] + result = runner.invoke(app, ["auth", "login"]) + assert result.exit_code == 0, result.output + assert [call["client_id"] for call in provider.exchange_calls] == ["stored-client", "client-1"] + assert len(provider.register_calls) == 1 + stored = load_oauth_client() + assert stored is not None + assert stored.client_id == "client-1" + assert load_credential() == CREDENTIAL + + +def test_login_fresh_client_rejected_is_a_login_error_without_retry( + provider: FakeProvider, install_build_client: Callable[[Handler], None] +) -> None: + install_build_client(_usage_ok) + provider.callback_query = lambda query: f"error=unauthorized_client&state={query['state']}" + result = runner.invoke(app, ["auth", "login"]) + assert result.exit_code == 1 + assert len(provider.register_calls) == 1 + assert provider.exchange_calls == [] + assert json.loads(result.stderr.splitlines()[-1]) == { + "error": "LoginError", + "message": "Authorization failed: unauthorized_client", + } + + +def test_login_reused_client_rejected_twice_is_a_login_error( + provider: FakeProvider, install_build_client: Callable[[Handler], None] +) -> None: + install_build_client(_usage_ok) + save_oauth_client(_registration(_free_port())) + provider.callback_query = lambda query: f"error=invalid_client&state={query['state']}" + result = runner.invoke(app, ["auth", "login"]) + assert result.exit_code == 1 + assert len(provider.register_calls) == 1 + assert json.loads(result.stderr.splitlines()[-1])["error"] == "LoginError" + + +def test_login_access_denied_keeps_stored_client( + provider: FakeProvider, install_build_client: Callable[[Handler], None] +) -> None: + install_build_client(_usage_ok) + registration = _registration(_free_port()) + save_oauth_client(registration) + provider.callback_query = lambda query: f"error=access_denied&state={query['state']}" + result = runner.invoke(app, ["auth", "login"]) + assert result.exit_code == 1 + assert provider.register_calls == [] + assert load_oauth_client() == registration + + +def test_login_forged_error_callback_cannot_evict_stored_client( + provider: FakeProvider, install_build_client: Callable[[Handler], None] +) -> None: + install_build_client(_usage_ok) + registration = _registration(_free_port()) + save_oauth_client(registration) + provider.callback_query = lambda query: "error=invalid_client&state=forged" + result = runner.invoke(app, ["auth", "login"]) + assert result.exit_code == 1 + assert "state mismatch" in json.loads(result.stderr.splitlines()[-1])["message"] + assert provider.register_calls == [] + assert load_oauth_client() == registration diff --git a/packages/discolike-cli/tests/test_company.py b/packages/discolike-cli/tests/test_company.py index eefc90e..729f6ca 100644 --- a/packages/discolike-cli/tests/test_company.py +++ b/packages/discolike-cli/tests/test_company.py @@ -66,18 +66,30 @@ def handler(request: httpx2.Request) -> httpx2.Response: assert rows[0]["linked_domain"] == "acme.io" -@pytest.mark.parametrize("subcommand", ["redirects", "vendors", "subsidiaries"]) -def test_company_match_option_forwarded(subcommand: str, install_build_client: Callable[[Handler], None]) -> None: +@pytest.mark.parametrize( + ("subcommand", "mode"), + [("redirects", "linked"), ("vendors", "vendor"), ("subsidiaries", "child")], +) +def test_company_match_option_forwarded( + subcommand: str, mode: str, install_build_client: Callable[[Handler], None] +) -> None: captured: dict[str, httpx2.Request] = {} def handler(request: httpx2.Request) -> httpx2.Response: captured["request"] = request - return httpx2.Response(200, json=[{"linked_domain": "acme.io"}]) + return httpx2.Response(200, json=[]) install_build_client(handler) - result = runner.invoke(app, ["company", subcommand, "acme.com", "--match", "loose"]) + result = runner.invoke(app, ["company", subcommand, "acme.com", "--match", mode]) assert result.exit_code == 0, result.output - assert captured["request"].url.params.get("match") == "loose" + assert captured["request"].url.params.get("match") == mode + + +def test_company_match_option_rejects_unknown_mode(install_build_client: Callable[[Handler], None]) -> None: + install_build_client(lambda request: httpx2.Response(200, json=[])) + result = runner.invoke(app, ["company", "redirects", "acme.com", "--match", "loose"]) + assert result.exit_code == 2 + assert json.loads(result.stderr)["error"] == "ValidationError" def test_company_public_links_hits_publiclink_endpoint(install_build_client: Callable[[Handler], None]) -> None: @@ -88,12 +100,12 @@ def handler(request: httpx2.Request) -> httpx2.Response: return httpx2.Response(200, json=[{"linked_domain": "acme.io"}]) install_build_client(handler) - result = runner.invoke(app, ["company", "public-links", "acme.com", "--source", "crunchbase"]) + result = runner.invoke(app, ["company", "public-links", "acme.com", "--source", "social"]) assert result.exit_code == 0, result.output request = captured["request"] assert request.url.path == "/v1/publiclink" assert request.url.params.get("domain") == "acme.com" - assert request.url.params.get("source") == "crunchbase" + assert request.url.params.get("source") == "social" def test_company_public_links_requires_source(install_build_client: Callable[[Handler], None]) -> None: diff --git a/packages/discolike-cli/tests/test_contacts_cli.py b/packages/discolike-cli/tests/test_contacts_cli.py index ee5cc65..5a4acf7 100644 --- a/packages/discolike-cli/tests/test_contacts_cli.py +++ b/packages/discolike-cli/tests/test_contacts_cli.py @@ -31,7 +31,7 @@ def handler(request: httpx2.Request) -> httpx2.Response: "--seniority", "vp", "--department", - "marketing", + "Sales - Marketing", "--title", "VP Marketing", "--domain", @@ -48,7 +48,7 @@ def handler(request: httpx2.Request) -> httpx2.Response: "--jobstart-date", "2025-01-01,2025-06-30", "--max-records", - "10", + "20", "--offset", "5", "--param", @@ -59,7 +59,7 @@ def handler(request: httpx2.Request) -> httpx2.Response: params = captured["params"] assert params.get("icp_prompt") == "VPs of Marketing" assert params.get_list("seniority") == ["vp"] - assert params.get_list("department") == ["marketing"] + assert params.get_list("department") == ["Sales - Marketing"] assert params.get_list("title") == ["VP Marketing"] assert params.get_list("domain") == ["acme.com"] assert params.get_list("person_country") == ["US"] @@ -68,7 +68,7 @@ def handler(request: httpx2.Request) -> httpx2.Response: assert params.get("employee_range") == "50-200" assert params.get("has_email") == "true" assert params.get("jobstart_date") == "2025-01-01,2025-06-30" - assert params.get("max_records") == "10" + assert params.get("max_records") == "20" assert params.get("offset") == "5" assert params.get("min_connections") == "5" assert json.loads(result.stdout) == [ @@ -90,26 +90,26 @@ def handler(request: httpx2.Request) -> httpx2.Response: "contacts", "search", "--negate-seniority", - "intern", + "entry_level", "--negate-department", - "hr", + "Human Resources", "--negate-title", "Assistant", "--negate-person-country", "FR", "--negate-filter-industry", - "GAMBLING", + "GAMING_AND_ESPORTS", "--negate-filter-country", "RU", ], ) assert result.exit_code == 0, result.output params = captured["params"] - assert params.get_list("negate_seniority") == ["intern"] - assert params.get_list("negate_department") == ["hr"] + assert params.get_list("negate_seniority") == ["entry_level"] + assert params.get_list("negate_department") == ["Human Resources"] assert params.get_list("negate_title") == ["Assistant"] assert params.get_list("negate_person_country") == ["FR"] - assert params.get_list("negate_filter_industry") == ["GAMBLING"] + assert params.get_list("negate_filter_industry") == ["GAMING_AND_ESPORTS"] assert params.get_list("negate_filter_country") == ["RU"] @@ -122,14 +122,26 @@ def handler(request: httpx2.Request) -> httpx2.Response: assert result.exit_code == 2 -def test_contacts_search_invalid_param_kwarg_exits_2(install_build_client: Callable[[Handler], None]) -> None: +def test_contacts_search_unknown_param_passes_through(install_build_client: Callable[[Handler], None]) -> None: + captured: dict[str, httpx2.QueryParams] = {} + def handler(request: httpx2.Request) -> httpx2.Response: + captured["params"] = request.url.params return httpx2.Response(200, json=[]) install_build_client(handler) result = runner.invoke(app, ["contacts", "search", "--param", "bogus_kwarg=1"]) + assert result.exit_code == 0, result.output + assert captured["params"].get("bogus_kwarg") == "1" + + +def test_contacts_search_invalid_seniority_exits_2(install_build_client: Callable[[Handler], None]) -> None: + install_build_client(lambda request: httpx2.Response(200, json=[])) + result = runner.invoke(app, ["contacts", "search", "--seniority", "intern"]) assert result.exit_code == 2 - assert "bogus_kwarg" in result.output + payload = json.loads(result.stderr) + assert payload["error"] == "ValidationError" + assert "seniority" in payload["message"] def test_contacts_search_unauthorized_exits_3(install_build_client: Callable[[Handler], None]) -> None: diff --git a/packages/discolike-cli/tests/test_discover.py b/packages/discolike-cli/tests/test_discover.py index 38a93a6..7f3f8c9 100644 --- a/packages/discolike-cli/tests/test_discover.py +++ b/packages/discolike-cli/tests/test_discover.py @@ -34,13 +34,13 @@ def handler(request: httpx2.Request) -> httpx2.Response: install_build_client(handler) result = runner.invoke( app, - ["discover", "--icp-prompt", "X", "--country", "DE", "--param", "min_similarity=200"], + ["discover", "--icp-prompt", "X", "--country", "DE", "--param", "min_similarity=50"], ) assert result.exit_code == 0, result.output params = captured["params"] assert params.get("icp_prompt") == "X" assert params.get_list("country") == ["DE"] - assert params.get("min_similarity") == "200" + assert params.get("min_similarity") == "50" stdout = json.loads(result.stdout) assert stdout[0]["domain"] == "acme.com" @@ -53,9 +53,9 @@ def handler(request: httpx2.Request) -> httpx2.Response: return _discover_ok(request) install_build_client(handler) - result = runner.invoke(app, ["discover", "--param", "social=linkedin,github"]) + result = runner.invoke(app, ["discover", "--param", "social=linkedin,youtube"]) assert result.exit_code == 0, result.output - assert captured["params"].get_list("social") == ["linkedin", "github"] + assert captured["params"].get_list("social") == ["linkedin", "youtube"] def test_discover_param_without_equals_exits_2(install_build_client: Callable[[Handler], None]) -> None: @@ -64,10 +64,26 @@ def test_discover_param_without_equals_exits_2(install_build_client: Callable[[H assert result.exit_code == 2 -def test_discover_param_removed_kwarg_exits_2(install_build_client: Callable[[Handler], None]) -> None: - install_build_client(_discover_ok) +def test_discover_param_unknown_key_passes_through(install_build_client: Callable[[Handler], None]) -> None: + captured: dict[str, httpx2.QueryParams] = {} + + def handler(request: httpx2.Request) -> httpx2.Response: + captured["params"] = request.url.params + return _discover_ok(request) + + install_build_client(handler) result = runner.invoke(app, ["discover", "--param", "min_score=1"]) + assert result.exit_code == 0, result.output + assert captured["params"].get("min_score") == "1" + + +def test_discover_param_out_of_range_exits_2(install_build_client: Callable[[Handler], None]) -> None: + install_build_client(_discover_ok) + result = runner.invoke(app, ["discover", "--param", "min_similarity=200"]) assert result.exit_code == 2 + payload = json.loads(result.stderr) + assert payload["error"] == "ValidationError" + assert "min_similarity" in payload["message"] def test_discover_explicit_option_wins_over_param_duplicate(install_build_client: Callable[[Handler], None]) -> None: diff --git a/packages/discolike-cli/tests/test_email_cli.py b/packages/discolike-cli/tests/test_email_cli.py index 776ee5b..bb039eb 100644 --- a/packages/discolike-cli/tests/test_email_cli.py +++ b/packages/discolike-cli/tests/test_email_cli.py @@ -6,9 +6,11 @@ from collections.abc import Callable import httpx2 +import pydantic import pytest from typer.testing import CliRunner +from discolike.requests import FindEmailBatchRequest from discolike_cli.main import app from discolike_testkit import Handler @@ -184,6 +186,25 @@ def handler(request: httpx2.Request) -> httpx2.Response: assert "domain" in result.output +def test_email_find_batch_rejects_empty_csv_cell_before_any_request( + tmp_path: pathlib.Path, install_build_client: Callable[[Handler], None] +) -> None: + contacts_file = tmp_path / "contacts.csv" + contacts_file.write_text("first_name,last_name,domain\nJane,Doe,acme.com\nJohn,Smith,\n") + calls: list[httpx2.Request] = [] + + def handler(request: httpx2.Request) -> httpx2.Response: + calls.append(request) + return httpx2.Response(200, json={"batch_id": "eb-5"}) + + install_build_client(handler) + result = runner.invoke(app, ["email", "find-batch", "--contacts-file", str(contacts_file)]) + assert result.exit_code == 2 + assert "row 3" in result.output + assert "domain" in result.output + assert calls == [] + + def test_email_find_batch_over_500_contacts_exits_2( tmp_path: pathlib.Path, install_build_client: Callable[[Handler], None] ) -> None: @@ -200,6 +221,12 @@ def handler(request: httpx2.Request) -> httpx2.Response: assert "500" in result.output +def test_find_email_batch_request_rejects_more_than_500_requests() -> None: + requests = [{"first_name": "Jane", "last_name": "Doe", "domain": "acme.com"}] * 501 + with pytest.raises(pydantic.ValidationError): + FindEmailBatchRequest.model_validate({"requests": requests}) + + def test_email_results_without_wait_returns_partial_snapshot( install_build_client: Callable[[Handler], None], ) -> None: diff --git a/packages/discolike-cli/tests/test_enrich_cli.py b/packages/discolike-cli/tests/test_enrich_cli.py index 6210386..47fdab8 100644 --- a/packages/discolike-cli/tests/test_enrich_cli.py +++ b/packages/discolike-cli/tests/test_enrich_cli.py @@ -140,7 +140,9 @@ def handler(request: httpx2.Request) -> httpx2.Response: return httpx2.Response(200, content=b"domain,industry\nacme.com,SAAS\n", headers={"Content-Type": "text/csv"}) install_build_client(handler) - result = runner.invoke(app, ["append", str(input_file), "--csv", "--output", str(output_file)]) + result = runner.invoke( + app, ["append", str(input_file), "--dataset", "bizdata", "--csv", "--output", str(output_file)] + ) assert result.exit_code == 0, result.output assert output_file.read_bytes() == b"domain,industry\nacme.com,SAAS\n" payload = json.loads(result.stdout) @@ -156,8 +158,24 @@ def handler(request: httpx2.Request) -> httpx2.Response: return httpx2.Response(200, content=b"domain\nacme.com\n", headers={"Content-Type": "text/csv"}) install_build_client(handler) - result = runner.invoke(app, ["append", str(input_file), "--csv"]) + result = runner.invoke(app, ["append", str(input_file), "--dataset", "bizdata", "--csv"]) + assert result.exit_code == 2 + + +def test_append_without_dataset_exits_2(tmp_path, install_build_client: Callable[[Handler], None]) -> None: + input_file = tmp_path / "domains.csv" + input_file.write_text("domain\nacme.com\n") + install_build_client(lambda request: httpx2.Response(200, json=[])) + result = runner.invoke(app, ["append", str(input_file)]) + assert result.exit_code == 2 + assert "dataset" in json.loads(result.stderr)["message"] + + +def test_segment_max_segments_out_of_range_exits_2(install_build_client: Callable[[Handler], None]) -> None: + install_build_client(lambda request: httpx2.Response(200, json={"task_id": "seg-9"})) + result = runner.invoke(app, ["segment", "--domain", "acme.com", "--max-segments", "99"]) assert result.exit_code == 2 + assert "max_segments" in json.loads(result.stderr)["message"] def test_segment_with_domain_options_prints_task_hint(install_build_client: Callable[[Handler], None]) -> None: diff --git a/packages/discolike-cli/tests/test_output.py b/packages/discolike-cli/tests/test_output.py index 70b7907..106fa1c 100644 --- a/packages/discolike-cli/tests/test_output.py +++ b/packages/discolike-cli/tests/test_output.py @@ -4,6 +4,7 @@ import sys from collections.abc import Callable +import pydantic import pytest import typer @@ -11,8 +12,11 @@ from discolike import RateLimitError from discolike import ServerError from discolike import ValidationError +from discolike.requests import DiscoverParams +from discolike.requests import MatchCompanyParams from discolike.resources.discovery import Company from discolike_cli._output import EXIT_CODES +from discolike_cli._output import build_request from discolike_cli._output import emit from discolike_cli._output import fail from discolike_cli._output import handle_errors @@ -251,3 +255,43 @@ def test_run_job_with_wait_emits_full_status_when_no_results(capsys: pytest.Capt run_job(job, wait=True, timeout=30.0) captured = capsys.readouterr() assert json.loads(captured.out) == {"progress": 100, "results": None} + + +def test_build_request_wraps_a_bare_string_for_list_fields() -> None: + request = build_request(DiscoverParams, {"subdomain": "shop", "country": ["DE"], "icp_prompt": "X"}) + assert request.to_wire() == {"subdomain": ["shop"], "country": ["DE"], "icp_prompt": "X"} + + +def test_build_request_coerces_scalar_strings_from_param() -> None: + request = build_request(DiscoverParams, {"min_similarity": "50", "redirect": "true"}) + assert request.to_wire() == {"min_similarity": 50, "redirect": True} + + +def test_build_request_passes_unknown_keys_through() -> None: + assert build_request(DiscoverParams, {"future_flag": "on"}).to_wire() == {"future_flag": "on"} + + +def test_build_request_raises_pydantic_validation_error_on_bad_values() -> None: + with pytest.raises(pydantic.ValidationError, match="min_similarity"): + build_request(DiscoverParams, {"min_similarity": "200"}) + + +def test_call_typed_is_gone() -> None: + import discolike_cli._output as output_module + + assert not hasattr(output_module, "call_typed") + + +def test_handle_errors_maps_pydantic_validation_error_to_exit_2(capsys: pytest.CaptureFixture[str]) -> None: + @handle_errors + def bad() -> None: + MatchCompanyParams.model_validate({"min_match_confidence": 10}) + + with pytest.raises(typer.Exit) as exc_info: + bad() + assert exc_info.value.exit_code == 2 + payload = json.loads(capsys.readouterr().err) + assert payload["error"] == "ValidationError" + assert payload["status_code"] is None + assert "name" in payload["message"] + assert "min_match_confidence" in payload["message"] diff --git a/packages/discolike-testkit/src/discolike_testkit/__init__.py b/packages/discolike-testkit/src/discolike_testkit/__init__.py index c6a084d..8afa009 100644 --- a/packages/discolike-testkit/src/discolike_testkit/__init__.py +++ b/packages/discolike-testkit/src/discolike_testkit/__init__.py @@ -13,9 +13,16 @@ from discolike import AsyncDiscolike from discolike import Discolike +from discolike._auth import DiscolikeAuth +from discolike._credentials import ApiKeyCredential -__all__ = ["AsyncClientFactory", "ClientFactory", "Handler"] +__all__ = ["AsyncClientFactory", "ClientFactory", "Handler", "api_key_auth"] Handler = Callable[[httpx2.Request], httpx2.Response] ClientFactory = Callable[[Handler], Discolike] AsyncClientFactory = Callable[[Handler], AsyncDiscolike] + + +def api_key_auth(api_key: str) -> DiscolikeAuth: + """Auth for tests that build a ``Transport`` directly instead of going through ``Discolike``.""" + return DiscolikeAuth(ApiKeyCredential(api_key=api_key)) diff --git a/packages/discolike/README.md b/packages/discolike/README.md index 12ece5e..db8ef62 100644 --- a/packages/discolike/README.md +++ b/packages/discolike/README.md @@ -18,19 +18,22 @@ Requires Python 3.10+. export DISCOLIKE_API_KEY="dl_..." ``` -Create a key at [app.discolike.com/account/management/keys](https://app.discolike.com/account/management/keys). You can also pass `api_key=...` explicitly to `Discolike()`. +Create a key at [app.discolike.com/account/management/keys](https://app.discolike.com/account/management/keys). You can also pass `api_key=...` explicitly to `Discolike()`, or run `discolike auth login` from the CLI to log in through the browser — the SDK then picks up the saved OAuth session and refreshes it automatically. ## Quickstart ```python from discolike import Discolike +from discolike.requests import DiscoverParams client = Discolike() companies = client.discover( - icp_text="Cybersecurity for SMBs, managed IT services, endpoint protection", - country=["US"], - max_records=25, + DiscoverParams( + icp_text="Cybersecurity for SMBs, managed IT services, endpoint protection", + country=["US"], + max_records=25, + ) ) for company in companies: print(company.domain, company.name, company.similarity) @@ -50,10 +53,11 @@ Every resource has an async twin on `AsyncDiscolike`: ```python import asyncio from discolike import AsyncDiscolike +from discolike.requests import DiscoverParams async def main() -> None: async with AsyncDiscolike() as client: - companies = await client.discover(icp_text="B2B SaaS for logistics", max_records=10) + companies = await client.discover(DiscoverParams(icp_text="B2B SaaS for logistics", max_records=10)) print([c.domain for c in companies]) asyncio.run(main()) @@ -64,11 +68,13 @@ asyncio.run(main()) Bulk operations (`match.bulk`, `segment`, `validate_icp`, `contacts.bulk_match`) return a `Job` handle instead of blocking: ```python -job = client.segment(domains=["stripe.com", "adyen.com", "checkout.com"]) +from discolike.requests import SegmentParams + +job = client.segment(SegmentParams(domains="stripe.com,adyen.com,checkout.com")) result = job.wait() ``` -`Job.status()` polls without blocking, `Job.cancel()` aborts, and `wait()` raises `JobFailedError` / `JobTimeoutError` on failure. +`Job.status()` polls without blocking, `Job.cancel()` aborts, and `wait()` raises `JobFailedError` / `JobTimeoutError` on failure. On DiscoGen-family jobs the returned `JobStatus` also carries `warnings`, `estimated_cost` and `cost_metadata` (per-model usage plus a `search_provider` entry when a BYOS search provider ran; `search_calls` only counts the model's built-in search). `JobTimeoutError` is a client-side wait limit only — the task keeps running server-side (large DiscoGen runs can take hours), so call `wait()` again to resume or fetch `status()` later. Cancelled tasks still return results for every item that finished before cancellation. Send one job per list (up to 10,000 domains) rather than splitting into parallel jobs — concurrent DiscoGen jobs share your LLM provider key and slow each other down. diff --git a/packages/discolike/pyproject.toml b/packages/discolike/pyproject.toml index 875085c..cb245d2 100644 --- a/packages/discolike/pyproject.toml +++ b/packages/discolike/pyproject.toml @@ -33,7 +33,7 @@ classifiers = [ ] [project.optional-dependencies] -cli = ["discolike-cli==0.2.0"] +cli = ["discolike-cli==0.3.0"] [project.urls] Homepage = "https://www.discolike.com" @@ -51,6 +51,7 @@ path = "src/discolike/_version.py" [dependency-groups] dev = [ + "datamodel-code-generator>=0.75,<0.76", "discolike-testkit", "pytest>=8", "pytest-asyncio>=0.24", diff --git a/packages/discolike/src/discolike/__init__.py b/packages/discolike/src/discolike/__init__.py index 982165a..48419cd 100644 --- a/packages/discolike/src/discolike/__init__.py +++ b/packages/discolike/src/discolike/__init__.py @@ -1,5 +1,7 @@ from discolike._client import AsyncDiscolike from discolike._client import Discolike +from discolike._credentials import ApiKeyCredential +from discolike._credentials import OAuthCredential from discolike._exceptions import APIConnectionError from discolike._exceptions import AuthenticationError from discolike._exceptions import DiscolikeError @@ -14,6 +16,7 @@ from discolike._jobs import Job from discolike._jobs import JobStatus from discolike._models import DiscolikeModel +from discolike._models import DiscolikeRequest from discolike._version import __version__ from discolike.resources.discovery import Company from discolike.resources.discovery import Count @@ -25,6 +28,7 @@ __all__ = [ "APIConnectionError", + "ApiKeyCredential", "AsyncDiscolike", "AsyncJob", "AuthenticationError", @@ -33,6 +37,7 @@ "Discolike", "DiscolikeError", "DiscolikeModel", + "DiscolikeRequest", "EmailBatchResults", "EmailJobResult", "EnumerationMatch", @@ -42,6 +47,7 @@ "JobStatus", "JobTimeoutError", "NotFoundError", + "OAuthCredential", "PlanAccessError", "RateLimitError", "ServerError", diff --git a/packages/discolike/src/discolike/_auth.py b/packages/discolike/src/discolike/_auth.py new file mode 100644 index 0000000..e77c2ae --- /dev/null +++ b/packages/discolike/src/discolike/_auth.py @@ -0,0 +1,151 @@ +from __future__ import annotations + +import asyncio +import threading +from collections.abc import AsyncGenerator +from collections.abc import Callable +from collections.abc import Generator +from typing import cast + +import httpx2 + +from discolike._credentials import ApiKeyCredential +from discolike._credentials import Credential +from discolike._credentials import OAuthCredential +from discolike._exceptions import AuthenticationError +from discolike._oauth import REFRESH_LEEWAY_SECONDS +from discolike._oauth import SESSION_EXPIRED_MESSAGE +from discolike._oauth import parse_refresh_response +from discolike._oauth import refresh_request + +# TODO: replace this module and _oauth.py with authlib's httpx2 OAuth2Client once a release +# includes authlib/authlib@e4fb941 (httpx2 support merged 2026-08-27; 1.7.2 predates it). + +API_KEY_HEADER = "X-discolike-key" +UNAUTHORIZED = 401 + + +def _set_bearer(request: httpx2.Request, credential: OAuthCredential) -> None: + request.headers["Authorization"] = f"Bearer {credential.access_token}" + + +class DiscolikeAuth(httpx2.Auth): + """Sends the API key header, or a bearer token that is refreshed before expiry and once after a 401. + + Refreshes go through the same client as the request they precede, so tests drive them via ``MockTransport``. + """ + + requires_response_body = False + + def __init__( + self, + credential: Credential, + *, + on_update: Callable[[OAuthCredential], None] | None = None, + reload: Callable[[], Credential | None] | None = None, + ) -> None: + self._credential = credential + self.on_update = on_update + self.reload = reload + self._lock = threading.Lock() + self._async_lock = asyncio.Lock() + + @property + def credential(self) -> Credential: + return self._credential + + def _latest(self, credential: OAuthCredential) -> OAuthCredential: + return cast(OAuthCredential, self._credential) if self._credential is not credential else credential + + def _adopt_stored(self, credential: OAuthCredential) -> OAuthCredential | None: + """Another process may have rotated the tokens already; a refresh with our old refresh token would fail.""" + if self.reload is None: + return None + stored = self.reload() + if ( + not isinstance(stored, OAuthCredential) + or stored.access_token == credential.access_token + or stored.expires_within(REFRESH_LEEWAY_SECONDS) + ): + return None + self._credential = stored + return stored + + def _store(self, response: httpx2.Response, *, credential: OAuthCredential) -> OAuthCredential: + try: + rotated = parse_refresh_response(response, credential=credential) + except AuthenticationError as exc: + raise AuthenticationError( + SESSION_EXPIRED_MESSAGE, status_code=exc.status_code, payload=exc.payload + ) from exc + self._credential = rotated + if self.on_update is not None: + self.on_update(rotated) + return rotated + + def sync_auth_flow(self, request: httpx2.Request) -> Generator[httpx2.Request, httpx2.Response, None]: + credential = self._credential + if isinstance(credential, ApiKeyCredential): + request.headers[API_KEY_HEADER] = credential.api_key + yield request + return + with self._lock: + credential = self._latest(credential) + if credential.expires_within(REFRESH_LEEWAY_SECONDS): + adopted = self._adopt_stored(credential) + if adopted is not None: + credential = adopted + else: + credential = yield from self._sync_refresh(credential) + _set_bearer(request, credential) + response = yield request + if response.status_code != UNAUTHORIZED: + return + with self._lock: + latest = self._latest(credential) + if latest is credential: + adopted = self._adopt_stored(credential) + if adopted is not None: + latest = adopted + else: + latest = yield from self._sync_refresh(credential) + _set_bearer(request, latest) + yield request + + def _sync_refresh(self, credential: OAuthCredential) -> Generator[httpx2.Request, httpx2.Response, OAuthCredential]: + response = yield refresh_request(credential) + response.read() + return self._store(response, credential=credential) + + async def async_auth_flow(self, request: httpx2.Request) -> AsyncGenerator[httpx2.Request, httpx2.Response]: + credential = self._credential + if isinstance(credential, ApiKeyCredential): + request.headers[API_KEY_HEADER] = credential.api_key + yield request + return + async with self._async_lock: + credential = self._latest(credential) + if credential.expires_within(REFRESH_LEEWAY_SECONDS): + adopted = self._adopt_stored(credential) + if adopted is not None: + credential = adopted + else: + response = yield refresh_request(credential) + await response.aread() + credential = self._store(response, credential=credential) + _set_bearer(request, credential) + response = yield request + if response.status_code != UNAUTHORIZED: + return + async with self._async_lock: + latest = self._latest(credential) + if latest is credential: + adopted = self._adopt_stored(credential) + if adopted is not None: + latest = adopted + else: + response = yield refresh_request(credential) + await response.aread() + latest = self._store(response, credential=credential) + _set_bearer(request, latest) + yield request diff --git a/packages/discolike/src/discolike/_client.py b/packages/discolike/src/discolike/_client.py index 483c6e7..fc4998d 100644 --- a/packages/discolike/src/discolike/_client.py +++ b/packages/discolike/src/discolike/_client.py @@ -1,16 +1,24 @@ from __future__ import annotations -import pathlib -from typing import BinaryIO - import httpx2 +from discolike._auth import DiscolikeAuth from discolike._config import DEFAULT_BASE_URL -from discolike._config import resolve_api_key +from discolike._config import load_credential +from discolike._config import resolve_credential +from discolike._config import save_credential +from discolike._credentials import Credential from discolike._jobs import AsyncJob from discolike._jobs import Job from discolike._transport import AsyncTransport from discolike._transport import Transport +from discolike.requests import AppendParams +from discolike.requests import CountParams +from discolike.requests import DiscoverParams +from discolike.requests import SegmentFileParams +from discolike.requests import SegmentParams +from discolike.requests import ValidateIcpRequest +from discolike.resources._base import FileInput from discolike.resources.account import AccountResource from discolike.resources.account import AsyncAccountResource from discolike.resources.companies import AsyncCompaniesResource @@ -43,11 +51,20 @@ DEFAULT_MAX_RETRIES = 3 +def _build_auth(*, api_key: str | None, auth: Credential | None) -> DiscolikeAuth: + # The config file is read back and written only when the credential came from it. + credential = resolve_credential(api_key=api_key, auth=auth) + if auth is not None: + return DiscolikeAuth(credential) + return DiscolikeAuth(credential, on_update=save_credential, reload=load_credential) + + class Discolike: def __init__( self, *, api_key: str | None = None, + auth: Credential | None = None, base_url: str = DEFAULT_BASE_URL, timeout: float = DEFAULT_TIMEOUT_SECONDS, max_retries: int = DEFAULT_MAX_RETRIES, @@ -55,7 +72,7 @@ def __init__( ) -> None: self._attach( Transport( - resolve_api_key(api_key), + _build_auth(api_key=api_key, auth=auth), base_url=base_url, timeout=timeout, max_retries=max_retries, @@ -84,204 +101,23 @@ def with_options(self, *, timeout: float | httpx2.Timeout) -> Discolike: clone._attach(self._transport.with_timeout(timeout)) return clone - def discover( - self, - *, - domain: list[str] | None = None, - exclude_domain: list[str] | None = None, - icp_text: str | None = None, - icp_prompt: str | None = None, - phrase_match: list[str] | None = None, - negate_phrase_match: list[str] | None = None, - subdomain: list[str] | None = None, - negate_subdomain: list[str] | None = None, - tech_stack: list[str] | None = None, - negate_tech_stack: list[str] | None = None, - category: list[str] | None = None, - negate_category: list[str] | None = None, - state: list[str] | None = None, - negate_state: list[str] | None = None, - country: list[str] | None = None, - negate_country: list[str] | None = None, - social: list[str] | None = None, - negate_social: list[str] | None = None, - language: list[str] | None = None, - negate_language: list[str] | None = None, - business_model: list[str] | None = None, - negate_business_model: list[str] | None = None, - employee_range: str | None = None, - revenue_range: str | None = None, - start_date: str | None = None, - redirect: bool | None = None, - exclude_leadgen: bool | None = None, - min_digital_footprint: int | None = None, - max_digital_footprint: int | None = None, - min_similarity: int | None = None, - consensus: int | None = None, - variance: str | None = None, - retrieval: bool | None = None, - enhanced: bool | None = None, - include_search_domains: bool | None = None, - auto_icp_text: bool | None = None, - auto_phrase_match: bool | None = None, - max_records: int | None = None, - offset: int | None = None, - exclusion_query_id: list[str] | None = None, - inclusion_query_id: list[str] | None = None, - ) -> list[Company]: - return self._discovery.discover( - domain=domain, - exclude_domain=exclude_domain, - icp_text=icp_text, - icp_prompt=icp_prompt, - phrase_match=phrase_match, - negate_phrase_match=negate_phrase_match, - subdomain=subdomain, - negate_subdomain=negate_subdomain, - tech_stack=tech_stack, - negate_tech_stack=negate_tech_stack, - category=category, - negate_category=negate_category, - state=state, - negate_state=negate_state, - country=country, - negate_country=negate_country, - social=social, - negate_social=negate_social, - language=language, - negate_language=negate_language, - business_model=business_model, - negate_business_model=negate_business_model, - employee_range=employee_range, - revenue_range=revenue_range, - start_date=start_date, - redirect=redirect, - exclude_leadgen=exclude_leadgen, - min_digital_footprint=min_digital_footprint, - max_digital_footprint=max_digital_footprint, - min_similarity=min_similarity, - consensus=consensus, - variance=variance, - retrieval=retrieval, - enhanced=enhanced, - include_search_domains=include_search_domains, - auto_icp_text=auto_icp_text, - auto_phrase_match=auto_phrase_match, - max_records=max_records, - offset=offset, - exclusion_query_id=exclusion_query_id, - inclusion_query_id=inclusion_query_id, - ) + def discover(self, params: DiscoverParams) -> list[Company]: + return self._discovery.discover(params) - def count( - self, - *, - phrase_match: list[str] | None = None, - negate_phrase_match: list[str] | None = None, - subdomain: list[str] | None = None, - negate_subdomain: list[str] | None = None, - tech_stack: list[str] | None = None, - negate_tech_stack: list[str] | None = None, - category: list[str] | None = None, - negate_category: list[str] | None = None, - min_digital_footprint: int | None = None, - max_digital_footprint: int | None = None, - state: list[str] | None = None, - negate_state: list[str] | None = None, - country: list[str] | None = None, - negate_country: list[str] | None = None, - start_date: str | None = None, - redirect: bool | None = None, - social: list[str] | None = None, - negate_social: list[str] | None = None, - language: list[str] | None = None, - negate_language: list[str] | None = None, - employee_range: str | None = None, - revenue_range: str | None = None, - business_model: list[str] | None = None, - negate_business_model: list[str] | None = None, - exclude_leadgen: bool | None = None, - ) -> Count: - return self._discovery.count( - phrase_match=phrase_match, - negate_phrase_match=negate_phrase_match, - subdomain=subdomain, - negate_subdomain=negate_subdomain, - tech_stack=tech_stack, - negate_tech_stack=negate_tech_stack, - category=category, - negate_category=negate_category, - min_digital_footprint=min_digital_footprint, - max_digital_footprint=max_digital_footprint, - state=state, - negate_state=negate_state, - country=country, - negate_country=negate_country, - start_date=start_date, - redirect=redirect, - social=social, - negate_social=negate_social, - language=language, - negate_language=negate_language, - employee_range=employee_range, - revenue_range=revenue_range, - business_model=business_model, - negate_business_model=negate_business_model, - exclude_leadgen=exclude_leadgen, - ) + def count(self, params: CountParams) -> Count: + return self._discovery.count(params) - def validate_icp( - self, - *, - icp_text: str, - domains: list[str], - context_mode: str | None = None, - integration_id: str | None = None, - web_search: bool | None = None, - search_provider_id: str | None = None, - ) -> Job: - return self._validate.icp( - icp_text=icp_text, - domains=domains, - context_mode=context_mode, - integration_id=integration_id, - web_search=web_search, - search_provider_id=search_provider_id, - ) + def validate_icp(self, request: ValidateIcpRequest) -> Job: + return self._validate.icp(request) - def append( - self, - *, - file: pathlib.Path | str | BinaryIO | None = None, - dataset: list[str] | None = None, - domain_column: str | None = None, - csv: bool | None = None, - query_id: list[str] | None = None, - ) -> list[AppendResult] | bytes: - return self._enrich.append( - file=file, - dataset=dataset, - domain_column=domain_column, - csv=csv, - query_id=query_id, - ) + def append(self, params: AppendParams, *, file: FileInput | None = None) -> list[AppendResult] | bytes: + return self._enrich.append(params, file=file) - def segment( - self, - *, - domains: list[str] | None = None, - file: pathlib.Path | str | BinaryIO | None = None, - domain_column: str | None = None, - max_segments: int | None = None, - query_id: list[str] | None = None, - ) -> Job: - return self._enrich.segment( - domains=domains, - file=file, - domain_column=domain_column, - max_segments=max_segments, - query_id=query_id, - ) + def segment(self, params: SegmentParams) -> Job: + return self._enrich.segment(params) + + def segment_file(self, params: SegmentFileParams, *, file: FileInput) -> Job: + return self._enrich.segment_file(params, file=file) def close(self) -> None: self._transport.close() @@ -298,6 +134,7 @@ def __init__( self, *, api_key: str | None = None, + auth: Credential | None = None, base_url: str = DEFAULT_BASE_URL, timeout: float = DEFAULT_TIMEOUT_SECONDS, max_retries: int = DEFAULT_MAX_RETRIES, @@ -305,7 +142,7 @@ def __init__( ) -> None: self._attach( AsyncTransport( - resolve_api_key(api_key), + _build_auth(api_key=api_key, auth=auth), base_url=base_url, timeout=timeout, max_retries=max_retries, @@ -334,204 +171,23 @@ def with_options(self, *, timeout: float | httpx2.Timeout) -> AsyncDiscolike: clone._attach(self._transport.with_timeout(timeout)) return clone - async def discover( - self, - *, - domain: list[str] | None = None, - exclude_domain: list[str] | None = None, - icp_text: str | None = None, - icp_prompt: str | None = None, - phrase_match: list[str] | None = None, - negate_phrase_match: list[str] | None = None, - subdomain: list[str] | None = None, - negate_subdomain: list[str] | None = None, - tech_stack: list[str] | None = None, - negate_tech_stack: list[str] | None = None, - category: list[str] | None = None, - negate_category: list[str] | None = None, - state: list[str] | None = None, - negate_state: list[str] | None = None, - country: list[str] | None = None, - negate_country: list[str] | None = None, - social: list[str] | None = None, - negate_social: list[str] | None = None, - language: list[str] | None = None, - negate_language: list[str] | None = None, - business_model: list[str] | None = None, - negate_business_model: list[str] | None = None, - employee_range: str | None = None, - revenue_range: str | None = None, - start_date: str | None = None, - redirect: bool | None = None, - exclude_leadgen: bool | None = None, - min_digital_footprint: int | None = None, - max_digital_footprint: int | None = None, - min_similarity: int | None = None, - consensus: int | None = None, - variance: str | None = None, - retrieval: bool | None = None, - enhanced: bool | None = None, - include_search_domains: bool | None = None, - auto_icp_text: bool | None = None, - auto_phrase_match: bool | None = None, - max_records: int | None = None, - offset: int | None = None, - exclusion_query_id: list[str] | None = None, - inclusion_query_id: list[str] | None = None, - ) -> list[Company]: - return await self._discovery.discover( - domain=domain, - exclude_domain=exclude_domain, - icp_text=icp_text, - icp_prompt=icp_prompt, - phrase_match=phrase_match, - negate_phrase_match=negate_phrase_match, - subdomain=subdomain, - negate_subdomain=negate_subdomain, - tech_stack=tech_stack, - negate_tech_stack=negate_tech_stack, - category=category, - negate_category=negate_category, - state=state, - negate_state=negate_state, - country=country, - negate_country=negate_country, - social=social, - negate_social=negate_social, - language=language, - negate_language=negate_language, - business_model=business_model, - negate_business_model=negate_business_model, - employee_range=employee_range, - revenue_range=revenue_range, - start_date=start_date, - redirect=redirect, - exclude_leadgen=exclude_leadgen, - min_digital_footprint=min_digital_footprint, - max_digital_footprint=max_digital_footprint, - min_similarity=min_similarity, - consensus=consensus, - variance=variance, - retrieval=retrieval, - enhanced=enhanced, - include_search_domains=include_search_domains, - auto_icp_text=auto_icp_text, - auto_phrase_match=auto_phrase_match, - max_records=max_records, - offset=offset, - exclusion_query_id=exclusion_query_id, - inclusion_query_id=inclusion_query_id, - ) + async def discover(self, params: DiscoverParams) -> list[Company]: + return await self._discovery.discover(params) - async def count( - self, - *, - phrase_match: list[str] | None = None, - negate_phrase_match: list[str] | None = None, - subdomain: list[str] | None = None, - negate_subdomain: list[str] | None = None, - tech_stack: list[str] | None = None, - negate_tech_stack: list[str] | None = None, - category: list[str] | None = None, - negate_category: list[str] | None = None, - min_digital_footprint: int | None = None, - max_digital_footprint: int | None = None, - state: list[str] | None = None, - negate_state: list[str] | None = None, - country: list[str] | None = None, - negate_country: list[str] | None = None, - start_date: str | None = None, - redirect: bool | None = None, - social: list[str] | None = None, - negate_social: list[str] | None = None, - language: list[str] | None = None, - negate_language: list[str] | None = None, - employee_range: str | None = None, - revenue_range: str | None = None, - business_model: list[str] | None = None, - negate_business_model: list[str] | None = None, - exclude_leadgen: bool | None = None, - ) -> Count: - return await self._discovery.count( - phrase_match=phrase_match, - negate_phrase_match=negate_phrase_match, - subdomain=subdomain, - negate_subdomain=negate_subdomain, - tech_stack=tech_stack, - negate_tech_stack=negate_tech_stack, - category=category, - negate_category=negate_category, - min_digital_footprint=min_digital_footprint, - max_digital_footprint=max_digital_footprint, - state=state, - negate_state=negate_state, - country=country, - negate_country=negate_country, - start_date=start_date, - redirect=redirect, - social=social, - negate_social=negate_social, - language=language, - negate_language=negate_language, - employee_range=employee_range, - revenue_range=revenue_range, - business_model=business_model, - negate_business_model=negate_business_model, - exclude_leadgen=exclude_leadgen, - ) + async def count(self, params: CountParams) -> Count: + return await self._discovery.count(params) - async def validate_icp( - self, - *, - icp_text: str, - domains: list[str], - context_mode: str | None = None, - integration_id: str | None = None, - web_search: bool | None = None, - search_provider_id: str | None = None, - ) -> AsyncJob: - return await self._validate.icp( - icp_text=icp_text, - domains=domains, - context_mode=context_mode, - integration_id=integration_id, - web_search=web_search, - search_provider_id=search_provider_id, - ) + async def validate_icp(self, request: ValidateIcpRequest) -> AsyncJob: + return await self._validate.icp(request) - async def append( - self, - *, - file: pathlib.Path | str | BinaryIO | None = None, - dataset: list[str] | None = None, - domain_column: str | None = None, - csv: bool | None = None, - query_id: list[str] | None = None, - ) -> list[AppendResult] | bytes: - return await self._enrich.append( - file=file, - dataset=dataset, - domain_column=domain_column, - csv=csv, - query_id=query_id, - ) + async def append(self, params: AppendParams, *, file: FileInput | None = None) -> list[AppendResult] | bytes: + return await self._enrich.append(params, file=file) - async def segment( - self, - *, - domains: list[str] | None = None, - file: pathlib.Path | str | BinaryIO | None = None, - domain_column: str | None = None, - max_segments: int | None = None, - query_id: list[str] | None = None, - ) -> AsyncJob: - return await self._enrich.segment( - domains=domains, - file=file, - domain_column=domain_column, - max_segments=max_segments, - query_id=query_id, - ) + async def segment(self, params: SegmentParams) -> AsyncJob: + return await self._enrich.segment(params) + + async def segment_file(self, params: SegmentFileParams, *, file: FileInput) -> AsyncJob: + return await self._enrich.segment_file(params, file=file) async def aclose(self) -> None: await self._transport.aclose() diff --git a/packages/discolike/src/discolike/_config.py b/packages/discolike/src/discolike/_config.py index 65501d4..12924fe 100644 --- a/packages/discolike/src/discolike/_config.py +++ b/packages/discolike/src/discolike/_config.py @@ -2,16 +2,24 @@ import json import os +import tempfile from pathlib import Path from typing import Any +from discolike._credentials import ApiKeyCredential +from discolike._credentials import Credential +from discolike._credentials import OAuthClientRegistration +from discolike._credentials import OAuthCredential from discolike._exceptions import AuthenticationError DEFAULT_BASE_URL = "https://api.discolike.com/v1" ENV_API_KEY = "DISCOLIKE_API_KEY" # foxguard: ignore[py/no-hardcoded-secret] KEYS_URL = "https://app.discolike.com/account/management/keys" +AUTH_METHOD_API_KEY = "api_key" +AUTH_METHOD_OAUTH = "oauth" +OAUTH_CLIENT_KEY = "oauth_client" -_NO_KEY_MESSAGE = ( +NO_CREDENTIAL_MESSAGE = ( "No API key found. Set the DISCOLIKE_API_KEY environment variable, pass api_key=..., " f"or run `discolike auth login`. Create a key at {KEYS_URL}" ) @@ -36,23 +44,74 @@ def load_config() -> dict[str, Any]: def save_config(config: dict[str, Any]) -> None: path = config_path() path.parent.mkdir(parents=True, exist_ok=True) - fd = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600) + fd, temp_path = tempfile.mkstemp(dir=path.parent, prefix=f".{path.name}.", suffix=".tmp") with os.fdopen(fd, "w") as handle: handle.write(json.dumps(config, indent=2) + "\n") - path.chmod(0o600) + os.chmod(temp_path, 0o600) + os.replace(temp_path, path) def delete_config() -> None: config_path().unlink(missing_ok=True) -def resolve_api_key(explicit: str | None = None) -> str: - if explicit: - return explicit +def delete_credential() -> None: + """Forget the credential but keep the OAuth client registration; it is a public PKCE client, not a secret.""" + stored_client = load_config().get(OAUTH_CLIENT_KEY) + if stored_client is None: + delete_config() + return + save_config({OAUTH_CLIENT_KEY: stored_client}) + + +def load_credential() -> Credential | None: + config = load_config() + if config.get("auth_method") == AUTH_METHOD_OAUTH: + try: + return OAuthCredential.from_config(config["oauth"]) + except (KeyError, TypeError, ValueError): + return None + api_key = config.get("api_key") + return ApiKeyCredential(api_key=str(api_key)) if api_key else None + + +def save_credential(credential: Credential) -> None: + if isinstance(credential, OAuthCredential): + config: dict[str, Any] = {"auth_method": AUTH_METHOD_OAUTH, "oauth": credential.to_config()} + else: + config = {"auth_method": AUTH_METHOD_API_KEY, "api_key": credential.api_key} + stored_client = load_config().get(OAUTH_CLIENT_KEY) + if stored_client is not None: + config[OAUTH_CLIENT_KEY] = stored_client + save_config(config) + + +def load_oauth_client() -> OAuthClientRegistration | None: + try: + return OAuthClientRegistration.from_config(load_config()[OAUTH_CLIENT_KEY]) + except (KeyError, TypeError, ValueError): + return None + + +def save_oauth_client(registration: OAuthClientRegistration) -> None: + save_config({**load_config(), OAUTH_CLIENT_KEY: registration.to_config()}) + + +def delete_oauth_client() -> None: + config = load_config() + config.pop(OAUTH_CLIENT_KEY, None) + save_config(config) + + +def resolve_credential(*, api_key: str | None = None, auth: Credential | None = None) -> Credential: + if auth is not None: + return auth + if api_key: + return ApiKeyCredential(api_key=api_key) from_env = os.environ.get(ENV_API_KEY) if from_env: - return from_env - from_file = load_config().get("api_key") - if from_file: - return str(from_file) - raise AuthenticationError(_NO_KEY_MESSAGE) + return ApiKeyCredential(api_key=from_env) + credential = load_credential() + if credential is not None: + return credential + raise AuthenticationError(NO_CREDENTIAL_MESSAGE) diff --git a/packages/discolike/src/discolike/_credentials.py b/packages/discolike/src/discolike/_credentials.py new file mode 100644 index 0000000..c08644a --- /dev/null +++ b/packages/discolike/src/discolike/_credentials.py @@ -0,0 +1,54 @@ +from __future__ import annotations + +import time +from dataclasses import asdict +from dataclasses import dataclass +from typing import Any + + +@dataclass(frozen=True) +class ApiKeyCredential: + api_key: str + + +@dataclass(frozen=True) +class OAuthCredential: + access_token: str + refresh_token: str + expires_at: float + client_id: str + token_endpoint: str + + def expires_within(self, seconds: float, *, now: float | None = None) -> bool: + current = time.time() if now is None else now + return self.expires_at - current <= seconds + + @classmethod + def from_config(cls, data: dict[str, Any]) -> OAuthCredential: + return cls( + access_token=str(data["access_token"]), + refresh_token=str(data["refresh_token"]), + expires_at=float(data["expires_at"]), + client_id=str(data["client_id"]), + token_endpoint=str(data["token_endpoint"]), + ) + + def to_config(self) -> dict[str, Any]: + return asdict(self) + + +@dataclass(frozen=True) +class OAuthClientRegistration: + client_id: str + redirect_uri: str + issuer: str + + @classmethod + def from_config(cls, data: dict[str, Any]) -> OAuthClientRegistration: + return cls(client_id=str(data["client_id"]), redirect_uri=str(data["redirect_uri"]), issuer=str(data["issuer"])) + + def to_config(self) -> dict[str, Any]: + return asdict(self) + + +Credential = ApiKeyCredential | OAuthCredential diff --git a/packages/discolike/src/discolike/_generated/__init__.py b/packages/discolike/src/discolike/_generated/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/packages/discolike/src/discolike/_generated/requests.py b/packages/discolike/src/discolike/_generated/requests.py new file mode 100644 index 0000000..a0458f1 --- /dev/null +++ b/packages/discolike/src/discolike/_generated/requests.py @@ -0,0 +1,2964 @@ +# Generated by scripts/gen_requests.py from the platform OpenAPI spec. Do not edit by hand. + +from __future__ import annotations + +from typing import Annotated +from typing import Any +from typing import Literal + +from pydantic import Field + +from discolike._models import DiscolikeRequest + + +class CompaniesDataParams(DiscolikeRequest): + domain: Annotated[str, Field(title="Domain")] + + +class CompaniesScoreParams(DiscolikeRequest): + domain: Annotated[str, Field(title="Domain")] + + +class CompaniesGrowthParams(DiscolikeRequest): + domain: Annotated[str, Field(title="Domain")] + + +class CompaniesExtractParams(DiscolikeRequest): + url: Annotated[ + str | None, + Field(description="URL of the web page to extract content from.", title="Url"), + ] = None + domain: Annotated[ + str | None, + Field( + description="Bare domain to extract — alias for url=https://{domain}; hits the cached page when available.", + title="Domain", + ), + ] = None + + +class CompaniesRedirectsParams(DiscolikeRequest): + domain: Annotated[str, Field(title="Domain")] + match: Annotated[Literal["source", "linked"] | None, Field(title="Match")] = "source" + + +class CompaniesVendorsParams(DiscolikeRequest): + domain: Annotated[str, Field(title="Domain")] + match: Annotated[Literal["client", "vendor"] | None, Field(title="Match")] = "client" + + +class CompaniesSubsidiariesParams(DiscolikeRequest): + domain: Annotated[str, Field(title="Domain")] + match: Annotated[ + Literal["parent", "child", "source", "linked", "recursive"] | None, + Field(title="Match"), + ] = "parent" + + +class CompaniesPublicLinksParams(DiscolikeRequest): + domain: Annotated[str, Field(title="Domain")] + source: Annotated[Literal["email", "social", "phone"], Field(title="Source")] + + +class ContactsSearchParams(DiscolikeRequest): + filter_industry: Annotated[ + list[ + Literal[ + "ACCOUNTING", + "ADVERTISING_AND_MARKETING", + "AGRICULTURE_AND_NATURAL_RESOURCES", + "ALCOHOL_AND_TOBACCO", + "AUTOMOTIVE", + "BIG_DATA_AND_ANALYTICS", + "BIOTECHNOLOGY", + "BLOCKCHAIN_AND_CRYPTOCURRENCY", + "BUSINESS_PRODUCTS_AND_SERVICES", + "CLOUD_COMPUTING", + "COMPUTER_HARDWARE_AND_SEMICONDUCTORS", + "CONGLOMERATES_SHELL_AND_HOLDING_COMPANIES", + "CONSTRUCTION", + "CONSUMER_PRODUCTS", + "CONSUMER_SERVICES", + "CYBERSECURITY", + "DEFENSE_AND_AEROSPACE", + "E-COMMERCE", + "EDUCATION", + "ENERGY", + "ENGINEERING", + "ENTERTAINMENT", + "ENVIRONMENTAL_SERVICES", + "FASHION_TEXTILE_AND_APPAREL", + "FINANCIAL_SERVICES", + "FOOD_AND_BEVERAGE", + "GAMING_AND_ESPORTS", + "GOVERNMENT_SERVICES", + "HEALTHCARE", + "HOSPITALITY", + "HUMAN_RESOURCES", + "INSURANCE", + "IT_SERVICES", + "LEGAL", + "MANUFACTURING", + "MEDIA", + "MINING_AND_METALS", + "NONPROFIT_AND_PHILANTHROPY", + "OIL_AND_GAS", + "PHARMACEUTICALS", + "PRIVATE_EQUITY_AND_VENTURE_CAPITAL", + "REAL_ESTATE", + "RENEWABLE_ENERGY", + "RESTAURANTS", + "RETAIL", + "SAAS", + "SECURITY", + "SOFTWARE", + "SPORTS_AND_RECREATION", + "SUPPLY_CHAIN_AND_PROCUREMENT", + "TELECOMMUNICATIONS", + "TRAVEL", + "WELLNESS_AND_LIFESTYLE", + ] + ] + | None, + Field( + description="Filter by industry category. Valid values: ACCOUNTING, ADVERTISING_AND_MARKETING, AGRICULTURE_AND_NATURAL_RESOURCES, ALCOHOL_AND_TOBACCO, AUTOMOTIVE, BIG_DATA_AND_ANALYTICS, BIOTECHNOLOGY, BLOCKCHAIN_AND_CRYPTOCURRENCY, BUSINESS_PRODUCTS_AND_SERVICES, CLOUD_COMPUTING, COMPUTER_HARDWARE_AND_SEMICONDUCTORS, CONGLOMERATES_SHELL_AND_HOLDING_COMPANIES, CONSTRUCTION, CONSUMER_PRODUCTS, CONSUMER_SERVICES, CYBERSECURITY, DEFENSE_AND_AEROSPACE, E-COMMERCE, EDUCATION, ENERGY, ENGINEERING, ENTERTAINMENT, ENVIRONMENTAL_SERVICES, FASHION_TEXTILE_AND_APPAREL, FINANCIAL_SERVICES, FOOD_AND_BEVERAGE, GAMING_AND_ESPORTS, GOVERNMENT_SERVICES, HEALTHCARE, HOSPITALITY, HUMAN_RESOURCES, INSURANCE, IT_SERVICES, LEGAL, MANUFACTURING, MEDIA, MINING_AND_METALS, NONPROFIT_AND_PHILANTHROPY, OIL_AND_GAS, PHARMACEUTICALS, PRIVATE_EQUITY_AND_VENTURE_CAPITAL, REAL_ESTATE, RENEWABLE_ENERGY, RESTAURANTS, RETAIL, SAAS, SECURITY, SOFTWARE, SPORTS_AND_RECREATION, SUPPLY_CHAIN_AND_PROCUREMENT, TELECOMMUNICATIONS, TRAVEL, WELLNESS_AND_LIFESTYLE.", + title="Filter Industry", + ), + ] = None + negate_filter_industry: Annotated[ + list[ + Literal[ + "ACCOUNTING", + "ADVERTISING_AND_MARKETING", + "AGRICULTURE_AND_NATURAL_RESOURCES", + "ALCOHOL_AND_TOBACCO", + "AUTOMOTIVE", + "BIG_DATA_AND_ANALYTICS", + "BIOTECHNOLOGY", + "BLOCKCHAIN_AND_CRYPTOCURRENCY", + "BUSINESS_PRODUCTS_AND_SERVICES", + "CLOUD_COMPUTING", + "COMPUTER_HARDWARE_AND_SEMICONDUCTORS", + "CONGLOMERATES_SHELL_AND_HOLDING_COMPANIES", + "CONSTRUCTION", + "CONSUMER_PRODUCTS", + "CONSUMER_SERVICES", + "CYBERSECURITY", + "DEFENSE_AND_AEROSPACE", + "E-COMMERCE", + "EDUCATION", + "ENERGY", + "ENGINEERING", + "ENTERTAINMENT", + "ENVIRONMENTAL_SERVICES", + "FASHION_TEXTILE_AND_APPAREL", + "FINANCIAL_SERVICES", + "FOOD_AND_BEVERAGE", + "GAMING_AND_ESPORTS", + "GOVERNMENT_SERVICES", + "HEALTHCARE", + "HOSPITALITY", + "HUMAN_RESOURCES", + "INSURANCE", + "IT_SERVICES", + "LEGAL", + "MANUFACTURING", + "MEDIA", + "MINING_AND_METALS", + "NONPROFIT_AND_PHILANTHROPY", + "OIL_AND_GAS", + "PHARMACEUTICALS", + "PRIVATE_EQUITY_AND_VENTURE_CAPITAL", + "REAL_ESTATE", + "RENEWABLE_ENERGY", + "RESTAURANTS", + "RETAIL", + "SAAS", + "SECURITY", + "SOFTWARE", + "SPORTS_AND_RECREATION", + "SUPPLY_CHAIN_AND_PROCUREMENT", + "TELECOMMUNICATIONS", + "TRAVEL", + "WELLNESS_AND_LIFESTYLE", + ] + ] + | None, + Field( + description="Exclude contacts at companies in specified industries. Valid values: ACCOUNTING, ADVERTISING_AND_MARKETING, AGRICULTURE_AND_NATURAL_RESOURCES, ALCOHOL_AND_TOBACCO, AUTOMOTIVE, BIG_DATA_AND_ANALYTICS, BIOTECHNOLOGY, BLOCKCHAIN_AND_CRYPTOCURRENCY, BUSINESS_PRODUCTS_AND_SERVICES, CLOUD_COMPUTING, COMPUTER_HARDWARE_AND_SEMICONDUCTORS, CONGLOMERATES_SHELL_AND_HOLDING_COMPANIES, CONSTRUCTION, CONSUMER_PRODUCTS, CONSUMER_SERVICES, CYBERSECURITY, DEFENSE_AND_AEROSPACE, E-COMMERCE, EDUCATION, ENERGY, ENGINEERING, ENTERTAINMENT, ENVIRONMENTAL_SERVICES, FASHION_TEXTILE_AND_APPAREL, FINANCIAL_SERVICES, FOOD_AND_BEVERAGE, GAMING_AND_ESPORTS, GOVERNMENT_SERVICES, HEALTHCARE, HOSPITALITY, HUMAN_RESOURCES, INSURANCE, IT_SERVICES, LEGAL, MANUFACTURING, MEDIA, MINING_AND_METALS, NONPROFIT_AND_PHILANTHROPY, OIL_AND_GAS, PHARMACEUTICALS, PRIVATE_EQUITY_AND_VENTURE_CAPITAL, REAL_ESTATE, RENEWABLE_ENERGY, RESTAURANTS, RETAIL, SAAS, SECURITY, SOFTWARE, SPORTS_AND_RECREATION, SUPPLY_CHAIN_AND_PROCUREMENT, TELECOMMUNICATIONS, TRAVEL, WELLNESS_AND_LIFESTYLE.", + title="Negate Filter Industry", + ), + ] = None + filter_country: Annotated[ + list[str] | None, + Field( + description="Filter by company country using ISO-3166-1 alpha-2 codes (e.g., US, GB, DE). Also accepts region aliases: EU, LATAM, MENA, APAC, NORDICS, DACH, BENELUX, GCC, ASEAN, CEE, ANZ.", + title="Filter Country", + ), + ] = None + negate_filter_country: Annotated[ + list[str] | None, + Field( + description="Exclude contacts at companies in specified countries. Accepts same codes and region aliases as filter_country.", + title="Negate Filter Country", + ), + ] = None + filter_state: Annotated[ + list[str] | None, + Field(description="Filter by company state/region.", title="Filter State"), + ] = None + negate_filter_state: Annotated[ + list[str] | None, + Field( + description="Exclude contacts at companies in specified states.", + title="Negate Filter State", + ), + ] = None + employee_range: Annotated[ + str | None, + Field( + description="Filter by employee count range. Format: 'min,max' (e.g., '51,200'). Maps to buckets: 1-10, 11-50, 51-200, 201-500, 501-1000, 1001-5000, 5001-10000, 10001+.", + title="Employee Range", + ), + ] = None + seniority: Annotated[ + list[Literal["executive", "vp", "director", "manager", "senior_ic", "mid_level", "entry_level"]] | None, + Field( + description="Filter by seniority bucket: executive, vp, director, manager, senior_ic, mid_level, entry_level", + title="Seniority", + ), + ] = None + negate_seniority: Annotated[ + list[Literal["executive", "vp", "director", "manager", "senior_ic", "mid_level", "entry_level"]] | None, + Field( + description="Exclude contacts with these seniority levels: executive, vp, director, manager, senior_ic, mid_level, entry_level", + title="Negate Seniority", + ), + ] = None + department: Annotated[ + list[ + Literal[ + "Operations", + "Executive", + "Technology", + "Sales - Marketing", + "Finance", + "Legal", + "Human Resources", + "Medical - Science", + "Customer Service", + "Research & Development", + "Administration", + "Public Relations", + "Investor Relations", + "Pro Services", + "Other", + ] + ] + | None, + Field( + description="Filter by department. Valid values: Operations, Executive, Technology, Sales - Marketing, Finance, Legal, Human Resources, Medical - Science, Customer Service, Research & Development, Administration, Public Relations, Investor Relations, Pro Services, Other.", + title="Department", + ), + ] = None + negate_department: Annotated[ + list[ + Literal[ + "Operations", + "Executive", + "Technology", + "Sales - Marketing", + "Finance", + "Legal", + "Human Resources", + "Medical - Science", + "Customer Service", + "Research & Development", + "Administration", + "Public Relations", + "Investor Relations", + "Pro Services", + "Other", + ] + ] + | None, + Field( + description="Exclude contacts in specified departments. Same valid values as `department`.", + title="Negate Department", + ), + ] = None + skills: Annotated[ + list[str] | None, + Field( + description="Filter by skills. Multiple skills can be provided.", + title="Skills", + ), + ] = None + name: Annotated[ + str | None, + Field( + description="Filter by contact name (partial match supported).", + title="Name", + ), + ] = None + title: Annotated[ + list[str] | None, + Field( + description="Filter by job title. Each item is a separate match term. Supports quoted phrases and + prefix for required terms. C-suite acronyms are auto-expanded to their spelled-out forms and vice versa (e.g. 'CEO' also matches 'Chief Executive Officer'); wrap a term in quotes to match it literally without expansion.", + title="Title", + ), + ] = None + negate_title: Annotated[ + list[str] | None, + Field( + description="Exclude contacts with specified job titles. C-suite acronyms are expanded the same way as in 'title'.", + title="Negate Title", + ), + ] = None + summary: Annotated[ + str | None, + Field( + description="Filter by profile summary text (semantic search).", + title="Summary", + ), + ] = None + negate_summary: Annotated[ + str | None, + Field( + description="Exclude contacts matching this summary description.", + title="Negate Summary", + ), + ] = None + person_country: Annotated[ + list[str] | None, + Field( + description="Filter by contact's country using ISO-3166-1 alpha-2 codes (e.g., US, GB, DE). Also accepts region aliases: EU, LATAM, MENA, APAC, NORDICS, DACH, BENELUX, GCC, ASEAN, CEE, ANZ.", + title="Person Country", + ), + ] = None + negate_person_country: Annotated[ + list[str] | None, + Field( + description="Exclude contacts in specified countries. Accepts same codes and region aliases as person_country.", + title="Negate Person Country", + ), + ] = None + person_state: Annotated[ + list[str] | None, + Field(description="Filter by contact's state/region.", title="Person State"), + ] = None + has_email: Annotated[ + bool | None, + Field(description="Only include contacts with email addresses.", title="Has Email"), + ] = False + email_validated: Annotated[ + bool | None, + Field( + description="Only include contacts with validated email addresses.", + title="Email Validated", + ), + ] = False + has_phone: Annotated[ + bool | None, + Field(description="Only include contacts with phone numbers.", title="Has Phone"), + ] = False + has_mobile: Annotated[ + bool | None, + Field( + description="Only include contacts with mobile phone numbers.", + title="Has Mobile", + ), + ] = False + has_linkedin: Annotated[ + bool | None, + Field( + description="Only include contacts with LinkedIn profiles.", + title="Has Linkedin", + ), + ] = False + min_connections: Annotated[ + int | None, + Field( + description="Minimum LinkedIn connections required.", + ge=0, + title="Min Connections", + ), + ] = None + jobstart_date: Annotated[ + str | None, + Field( + description="Filter by job start date: minimum date (YYYY-MM-DD) or range (YYYY-MM-DD,YYYY-MM-DD). Matches contacts who started their current role in this window; contacts without a known start date are excluded.", + title="Jobstart Date", + ), + ] = None + persona_id: Annotated[ + list[int] | None, + Field(description="Filter by specific persona IDs.", title="Persona Id"), + ] = None + icp_text: Annotated[ + str | None, + Field( + description="Natural language description of ideal contact profile for semantic matching.", + max_length=4000, + title="Icp Text", + ), + ] = None + icp_prompt: Annotated[ + str | None, + Field( + description="Natural language ICP description. Automatically extracts structured contact filters (seniority, department, industry, country, employee/revenue range, etc.), cleans the semantic description, and applies them before running the contact search. User-provided filters take precedence over wizard-extracted ones.", + max_length=4000, + title="Icp Prompt", + ), + ] = None + domain: Annotated[ + list[str] | None, + Field(description="Filter contacts at specific company domains.", title="Domain"), + ] = None + inclusion_query_id: Annotated[ + list[str] | None, + Field( + description="Include only contacts from companies in these saved queries.", + title="Inclusion Query Id", + ), + ] = None + exclusion_query_id: Annotated[ + list[str] | None, + Field( + description="Exclude contacts from companies in these saved queries.", + title="Exclusion Query Id", + ), + ] = None + max_records: Annotated[ + int | None, + Field( + description="Maximum number of contacts to return (20-10000).", + ge=20, + le=10000, + title="Max Records", + ), + ] = 100 + max_companies: Annotated[ + int | None, + Field( + description="Maximum number of enriched companies to return. Cannot be combined with `max_records`; when set, the internal total contact cap is derived from `max_companies * results_by_company`, capped at 10000.", + ge=1, + le=10000, + title="Max Companies", + ), + ] = None + offset: Annotated[ + int | None, + Field( + description="Number of results to skip for pagination.", + ge=0, + le=10000, + title="Offset", + ), + ] = 0 + results_by_company: Annotated[ + int | None, + Field( + description="Maximum contacts per company domain. Default 5 — i.e. without this parameter the endpoint returns up to 5 contacts from any one company so results spread across more distinct companies. Set `results_by_company=0` to remove the cap (return every matching contact per company, up to `max_records` total — or the global ceiling of 10000 when paired with `max_companies`). When the value is non-zero, `offset` is forced to 0 (pagination is disabled).", + ge=0, + le=100, + title="Results By Company", + ), + ] = 5 + include_search_contacts: Annotated[ + bool | None, + Field( + description="Include contacts from the search index (broader coverage).", + title="Include Search Contacts", + ), + ] = False + consensus: Annotated[ + int | None, + Field( + description="Number of query vectors to combine for consensus search.", + ge=1, + le=20, + title="Consensus", + ), + ] = 1 + + +class ContactsCountParams(DiscolikeRequest): + filter_industry: Annotated[ + list[ + Literal[ + "ACCOUNTING", + "ADVERTISING_AND_MARKETING", + "AGRICULTURE_AND_NATURAL_RESOURCES", + "ALCOHOL_AND_TOBACCO", + "AUTOMOTIVE", + "BIG_DATA_AND_ANALYTICS", + "BIOTECHNOLOGY", + "BLOCKCHAIN_AND_CRYPTOCURRENCY", + "BUSINESS_PRODUCTS_AND_SERVICES", + "CLOUD_COMPUTING", + "COMPUTER_HARDWARE_AND_SEMICONDUCTORS", + "CONGLOMERATES_SHELL_AND_HOLDING_COMPANIES", + "CONSTRUCTION", + "CONSUMER_PRODUCTS", + "CONSUMER_SERVICES", + "CYBERSECURITY", + "DEFENSE_AND_AEROSPACE", + "E-COMMERCE", + "EDUCATION", + "ENERGY", + "ENGINEERING", + "ENTERTAINMENT", + "ENVIRONMENTAL_SERVICES", + "FASHION_TEXTILE_AND_APPAREL", + "FINANCIAL_SERVICES", + "FOOD_AND_BEVERAGE", + "GAMING_AND_ESPORTS", + "GOVERNMENT_SERVICES", + "HEALTHCARE", + "HOSPITALITY", + "HUMAN_RESOURCES", + "INSURANCE", + "IT_SERVICES", + "LEGAL", + "MANUFACTURING", + "MEDIA", + "MINING_AND_METALS", + "NONPROFIT_AND_PHILANTHROPY", + "OIL_AND_GAS", + "PHARMACEUTICALS", + "PRIVATE_EQUITY_AND_VENTURE_CAPITAL", + "REAL_ESTATE", + "RENEWABLE_ENERGY", + "RESTAURANTS", + "RETAIL", + "SAAS", + "SECURITY", + "SOFTWARE", + "SPORTS_AND_RECREATION", + "SUPPLY_CHAIN_AND_PROCUREMENT", + "TELECOMMUNICATIONS", + "TRAVEL", + "WELLNESS_AND_LIFESTYLE", + ] + ] + | None, + Field( + description="Filter by industry category. Valid values: ACCOUNTING, ADVERTISING_AND_MARKETING, AGRICULTURE_AND_NATURAL_RESOURCES, ALCOHOL_AND_TOBACCO, AUTOMOTIVE, BIG_DATA_AND_ANALYTICS, BIOTECHNOLOGY, BLOCKCHAIN_AND_CRYPTOCURRENCY, BUSINESS_PRODUCTS_AND_SERVICES, CLOUD_COMPUTING, COMPUTER_HARDWARE_AND_SEMICONDUCTORS, CONGLOMERATES_SHELL_AND_HOLDING_COMPANIES, CONSTRUCTION, CONSUMER_PRODUCTS, CONSUMER_SERVICES, CYBERSECURITY, DEFENSE_AND_AEROSPACE, E-COMMERCE, EDUCATION, ENERGY, ENGINEERING, ENTERTAINMENT, ENVIRONMENTAL_SERVICES, FASHION_TEXTILE_AND_APPAREL, FINANCIAL_SERVICES, FOOD_AND_BEVERAGE, GAMING_AND_ESPORTS, GOVERNMENT_SERVICES, HEALTHCARE, HOSPITALITY, HUMAN_RESOURCES, INSURANCE, IT_SERVICES, LEGAL, MANUFACTURING, MEDIA, MINING_AND_METALS, NONPROFIT_AND_PHILANTHROPY, OIL_AND_GAS, PHARMACEUTICALS, PRIVATE_EQUITY_AND_VENTURE_CAPITAL, REAL_ESTATE, RENEWABLE_ENERGY, RESTAURANTS, RETAIL, SAAS, SECURITY, SOFTWARE, SPORTS_AND_RECREATION, SUPPLY_CHAIN_AND_PROCUREMENT, TELECOMMUNICATIONS, TRAVEL, WELLNESS_AND_LIFESTYLE.", + title="Filter Industry", + ), + ] = None + negate_filter_industry: Annotated[ + list[ + Literal[ + "ACCOUNTING", + "ADVERTISING_AND_MARKETING", + "AGRICULTURE_AND_NATURAL_RESOURCES", + "ALCOHOL_AND_TOBACCO", + "AUTOMOTIVE", + "BIG_DATA_AND_ANALYTICS", + "BIOTECHNOLOGY", + "BLOCKCHAIN_AND_CRYPTOCURRENCY", + "BUSINESS_PRODUCTS_AND_SERVICES", + "CLOUD_COMPUTING", + "COMPUTER_HARDWARE_AND_SEMICONDUCTORS", + "CONGLOMERATES_SHELL_AND_HOLDING_COMPANIES", + "CONSTRUCTION", + "CONSUMER_PRODUCTS", + "CONSUMER_SERVICES", + "CYBERSECURITY", + "DEFENSE_AND_AEROSPACE", + "E-COMMERCE", + "EDUCATION", + "ENERGY", + "ENGINEERING", + "ENTERTAINMENT", + "ENVIRONMENTAL_SERVICES", + "FASHION_TEXTILE_AND_APPAREL", + "FINANCIAL_SERVICES", + "FOOD_AND_BEVERAGE", + "GAMING_AND_ESPORTS", + "GOVERNMENT_SERVICES", + "HEALTHCARE", + "HOSPITALITY", + "HUMAN_RESOURCES", + "INSURANCE", + "IT_SERVICES", + "LEGAL", + "MANUFACTURING", + "MEDIA", + "MINING_AND_METALS", + "NONPROFIT_AND_PHILANTHROPY", + "OIL_AND_GAS", + "PHARMACEUTICALS", + "PRIVATE_EQUITY_AND_VENTURE_CAPITAL", + "REAL_ESTATE", + "RENEWABLE_ENERGY", + "RESTAURANTS", + "RETAIL", + "SAAS", + "SECURITY", + "SOFTWARE", + "SPORTS_AND_RECREATION", + "SUPPLY_CHAIN_AND_PROCUREMENT", + "TELECOMMUNICATIONS", + "TRAVEL", + "WELLNESS_AND_LIFESTYLE", + ] + ] + | None, + Field( + description="Exclude contacts at companies in specified industries. Valid values: ACCOUNTING, ADVERTISING_AND_MARKETING, AGRICULTURE_AND_NATURAL_RESOURCES, ALCOHOL_AND_TOBACCO, AUTOMOTIVE, BIG_DATA_AND_ANALYTICS, BIOTECHNOLOGY, BLOCKCHAIN_AND_CRYPTOCURRENCY, BUSINESS_PRODUCTS_AND_SERVICES, CLOUD_COMPUTING, COMPUTER_HARDWARE_AND_SEMICONDUCTORS, CONGLOMERATES_SHELL_AND_HOLDING_COMPANIES, CONSTRUCTION, CONSUMER_PRODUCTS, CONSUMER_SERVICES, CYBERSECURITY, DEFENSE_AND_AEROSPACE, E-COMMERCE, EDUCATION, ENERGY, ENGINEERING, ENTERTAINMENT, ENVIRONMENTAL_SERVICES, FASHION_TEXTILE_AND_APPAREL, FINANCIAL_SERVICES, FOOD_AND_BEVERAGE, GAMING_AND_ESPORTS, GOVERNMENT_SERVICES, HEALTHCARE, HOSPITALITY, HUMAN_RESOURCES, INSURANCE, IT_SERVICES, LEGAL, MANUFACTURING, MEDIA, MINING_AND_METALS, NONPROFIT_AND_PHILANTHROPY, OIL_AND_GAS, PHARMACEUTICALS, PRIVATE_EQUITY_AND_VENTURE_CAPITAL, REAL_ESTATE, RENEWABLE_ENERGY, RESTAURANTS, RETAIL, SAAS, SECURITY, SOFTWARE, SPORTS_AND_RECREATION, SUPPLY_CHAIN_AND_PROCUREMENT, TELECOMMUNICATIONS, TRAVEL, WELLNESS_AND_LIFESTYLE.", + title="Negate Filter Industry", + ), + ] = None + filter_country: Annotated[ + list[str] | None, + Field( + description="Filter by company country using ISO-3166-1 alpha-2 codes (e.g., US, GB, DE). Also accepts region aliases: EU, LATAM, MENA, APAC, NORDICS, DACH, BENELUX, GCC, ASEAN, CEE, ANZ.", + title="Filter Country", + ), + ] = None + negate_filter_country: Annotated[ + list[str] | None, + Field( + description="Exclude contacts at companies in specified countries. Accepts same codes and region aliases as filter_country.", + title="Negate Filter Country", + ), + ] = None + filter_state: Annotated[ + list[str] | None, + Field(description="Filter by company state/region.", title="Filter State"), + ] = None + negate_filter_state: Annotated[ + list[str] | None, + Field( + description="Exclude contacts at companies in specified states.", + title="Negate Filter State", + ), + ] = None + employee_range: Annotated[ + str | None, + Field( + description="Filter by employee count range. Format: 'min,max' (e.g., '51,200'). Maps to buckets: 1-10, 11-50, 51-200, 201-500, 501-1000, 1001-5000, 5001-10000, 10001+.", + title="Employee Range", + ), + ] = None + seniority: Annotated[ + list[Literal["executive", "vp", "director", "manager", "senior_ic", "mid_level", "entry_level"]] | None, + Field( + description="Filter by seniority bucket: executive, vp, director, manager, senior_ic, mid_level, entry_level", + title="Seniority", + ), + ] = None + negate_seniority: Annotated[ + list[Literal["executive", "vp", "director", "manager", "senior_ic", "mid_level", "entry_level"]] | None, + Field( + description="Exclude contacts with these seniority levels: executive, vp, director, manager, senior_ic, mid_level, entry_level", + title="Negate Seniority", + ), + ] = None + department: Annotated[ + list[ + Literal[ + "Operations", + "Executive", + "Technology", + "Sales - Marketing", + "Finance", + "Legal", + "Human Resources", + "Medical - Science", + "Customer Service", + "Research & Development", + "Administration", + "Public Relations", + "Investor Relations", + "Pro Services", + "Other", + ] + ] + | None, + Field( + description="Filter by department. Valid values: Operations, Executive, Technology, Sales - Marketing, Finance, Legal, Human Resources, Medical - Science, Customer Service, Research & Development, Administration, Public Relations, Investor Relations, Pro Services, Other.", + title="Department", + ), + ] = None + negate_department: Annotated[ + list[ + Literal[ + "Operations", + "Executive", + "Technology", + "Sales - Marketing", + "Finance", + "Legal", + "Human Resources", + "Medical - Science", + "Customer Service", + "Research & Development", + "Administration", + "Public Relations", + "Investor Relations", + "Pro Services", + "Other", + ] + ] + | None, + Field( + description="Exclude contacts in specified departments. Same valid values as `department`.", + title="Negate Department", + ), + ] = None + skills: Annotated[ + list[str] | None, + Field( + description="Filter by skills. Multiple skills can be provided.", + title="Skills", + ), + ] = None + name: Annotated[ + str | None, + Field( + description="Filter by contact name (partial match supported).", + title="Name", + ), + ] = None + title: Annotated[ + list[str] | None, + Field( + description="Filter by job title. Each item is a separate match term. Supports quoted phrases and + prefix for required terms. C-suite acronyms are auto-expanded to their spelled-out forms and vice versa (e.g. 'CEO' also matches 'Chief Executive Officer'); wrap a term in quotes to match it literally without expansion.", + title="Title", + ), + ] = None + negate_title: Annotated[ + list[str] | None, + Field( + description="Exclude contacts with specified job titles. C-suite acronyms are expanded the same way as in 'title'.", + title="Negate Title", + ), + ] = None + summary: Annotated[ + str | None, + Field( + description="Filter by profile summary text (semantic search).", + title="Summary", + ), + ] = None + negate_summary: Annotated[ + str | None, + Field( + description="Exclude contacts matching this summary description.", + title="Negate Summary", + ), + ] = None + person_country: Annotated[ + list[str] | None, + Field( + description="Filter by contact's country using ISO-3166-1 alpha-2 codes (e.g., US, GB, DE). Also accepts region aliases: EU, LATAM, MENA, APAC, NORDICS, DACH, BENELUX, GCC, ASEAN, CEE, ANZ.", + title="Person Country", + ), + ] = None + negate_person_country: Annotated[ + list[str] | None, + Field( + description="Exclude contacts in specified countries. Accepts same codes and region aliases as person_country.", + title="Negate Person Country", + ), + ] = None + person_state: Annotated[ + list[str] | None, + Field(description="Filter by contact's state/region.", title="Person State"), + ] = None + has_email: Annotated[ + bool | None, + Field(description="Only include contacts with email addresses.", title="Has Email"), + ] = False + email_validated: Annotated[ + bool | None, + Field( + description="Only include contacts with validated email addresses.", + title="Email Validated", + ), + ] = False + has_phone: Annotated[ + bool | None, + Field(description="Only include contacts with phone numbers.", title="Has Phone"), + ] = False + has_mobile: Annotated[ + bool | None, + Field( + description="Only include contacts with mobile phone numbers.", + title="Has Mobile", + ), + ] = False + has_linkedin: Annotated[ + bool | None, + Field( + description="Only include contacts with LinkedIn profiles.", + title="Has Linkedin", + ), + ] = False + min_connections: Annotated[ + int | None, + Field( + description="Minimum LinkedIn connections required.", + ge=0, + title="Min Connections", + ), + ] = None + jobstart_date: Annotated[ + str | None, + Field( + description="Filter by job start date: minimum date (YYYY-MM-DD) or range (YYYY-MM-DD,YYYY-MM-DD). Matches contacts who started their current role in this window; contacts without a known start date are excluded.", + title="Jobstart Date", + ), + ] = None + persona_id: Annotated[ + list[int] | None, + Field(description="Filter by specific persona IDs.", title="Persona Id"), + ] = None + icp_text: Annotated[ + str | None, + Field( + description="Natural language description of ideal contact profile for semantic matching.", + max_length=4000, + title="Icp Text", + ), + ] = None + icp_prompt: Annotated[ + str | None, + Field( + description="Natural language ICP description. Automatically extracts structured contact filters (seniority, department, industry, country, employee/revenue range, etc.), cleans the semantic description, and applies them before running the contact search. User-provided filters take precedence over wizard-extracted ones.", + max_length=4000, + title="Icp Prompt", + ), + ] = None + domain: Annotated[ + list[str] | None, + Field(description="Filter contacts at specific company domains.", title="Domain"), + ] = None + inclusion_query_id: Annotated[ + list[str] | None, + Field( + description="Include only contacts from companies in these saved queries.", + title="Inclusion Query Id", + ), + ] = None + exclusion_query_id: Annotated[ + list[str] | None, + Field( + description="Exclude contacts from companies in these saved queries.", + title="Exclusion Query Id", + ), + ] = None + max_records: Annotated[ + int | None, + Field( + description="Maximum number of contacts to return (20-10000).", + ge=20, + le=10000, + title="Max Records", + ), + ] = 100 + max_companies: Annotated[ + int | None, + Field( + description="Maximum number of enriched companies to return. Cannot be combined with `max_records`; when set, the internal total contact cap is derived from `max_companies * results_by_company`, capped at 10000.", + ge=1, + le=10000, + title="Max Companies", + ), + ] = None + offset: Annotated[ + int | None, + Field( + description="Number of results to skip for pagination.", + ge=0, + le=10000, + title="Offset", + ), + ] = 0 + results_by_company: Annotated[ + int | None, + Field( + description="Maximum contacts per company domain. Default 5 — i.e. without this parameter the endpoint returns up to 5 contacts from any one company so results spread across more distinct companies. Set `results_by_company=0` to remove the cap (return every matching contact per company, up to `max_records` total — or the global ceiling of 10000 when paired with `max_companies`). When the value is non-zero, `offset` is forced to 0 (pagination is disabled).", + ge=0, + le=100, + title="Results By Company", + ), + ] = 5 + include_search_contacts: Annotated[ + bool | None, + Field( + description="Include contacts from the search index (broader coverage).", + title="Include Search Contacts", + ), + ] = False + consensus: Annotated[ + int | None, + Field( + description="Number of query vectors to combine for consensus search.", + ge=1, + le=20, + title="Consensus", + ), + ] = 1 + + +class ContactsLookupParams(DiscolikeRequest): + persona_id: Annotated[int | None, Field(description="The persona ID to look up.", title="Persona Id")] = None + linkedin: Annotated[ + str | None, + Field(description="LinkedIn URL or username to look up.", title="Linkedin"), + ] = None + email: Annotated[ + str | None, + Field(description="Email address to look up (exact match).", title="Email"), + ] = None + + +class ContactsMatchParams(DiscolikeRequest): + name: Annotated[str, Field(description="Person name to search for.", title="Name")] + company_name: Annotated[ + str | None, + Field(description="Company name to narrow the search.", title="Company Name"), + ] = None + domain: Annotated[str | None, Field(description="Domain to filter results.", title="Domain")] = None + person_country: Annotated[ + str | None, + Field( + description="Person's country code (ISO-3166-1 alpha-2).", + title="Person Country", + ), + ] = None + limit: Annotated[ + int | None, + Field(description="Maximum results to return (1-20).", ge=1, le=20, title="Limit"), + ] = 10 + + +class ContactFilters(DiscolikeRequest): + filter_industry: Annotated[ + list[ + Literal[ + "ACCOUNTING", + "ADVERTISING_AND_MARKETING", + "AGRICULTURE_AND_NATURAL_RESOURCES", + "ALCOHOL_AND_TOBACCO", + "AUTOMOTIVE", + "BIG_DATA_AND_ANALYTICS", + "BIOTECHNOLOGY", + "BLOCKCHAIN_AND_CRYPTOCURRENCY", + "BUSINESS_PRODUCTS_AND_SERVICES", + "CLOUD_COMPUTING", + "COMPUTER_HARDWARE_AND_SEMICONDUCTORS", + "CONGLOMERATES_SHELL_AND_HOLDING_COMPANIES", + "CONSTRUCTION", + "CONSUMER_PRODUCTS", + "CONSUMER_SERVICES", + "CYBERSECURITY", + "DEFENSE_AND_AEROSPACE", + "E-COMMERCE", + "EDUCATION", + "ENERGY", + "ENGINEERING", + "ENTERTAINMENT", + "ENVIRONMENTAL_SERVICES", + "FASHION_TEXTILE_AND_APPAREL", + "FINANCIAL_SERVICES", + "FOOD_AND_BEVERAGE", + "GAMING_AND_ESPORTS", + "GOVERNMENT_SERVICES", + "HEALTHCARE", + "HOSPITALITY", + "HUMAN_RESOURCES", + "INSURANCE", + "IT_SERVICES", + "LEGAL", + "MANUFACTURING", + "MEDIA", + "MINING_AND_METALS", + "NONPROFIT_AND_PHILANTHROPY", + "OIL_AND_GAS", + "PHARMACEUTICALS", + "PRIVATE_EQUITY_AND_VENTURE_CAPITAL", + "REAL_ESTATE", + "RENEWABLE_ENERGY", + "RESTAURANTS", + "RETAIL", + "SAAS", + "SECURITY", + "SOFTWARE", + "SPORTS_AND_RECREATION", + "SUPPLY_CHAIN_AND_PROCUREMENT", + "TELECOMMUNICATIONS", + "TRAVEL", + "WELLNESS_AND_LIFESTYLE", + ] + ] + | None, + Field( + description="Filter by industry category. Valid values: ACCOUNTING, ADVERTISING_AND_MARKETING, AGRICULTURE_AND_NATURAL_RESOURCES, ALCOHOL_AND_TOBACCO, AUTOMOTIVE, BIG_DATA_AND_ANALYTICS, BIOTECHNOLOGY, BLOCKCHAIN_AND_CRYPTOCURRENCY, BUSINESS_PRODUCTS_AND_SERVICES, CLOUD_COMPUTING, COMPUTER_HARDWARE_AND_SEMICONDUCTORS, CONGLOMERATES_SHELL_AND_HOLDING_COMPANIES, CONSTRUCTION, CONSUMER_PRODUCTS, CONSUMER_SERVICES, CYBERSECURITY, DEFENSE_AND_AEROSPACE, E-COMMERCE, EDUCATION, ENERGY, ENGINEERING, ENTERTAINMENT, ENVIRONMENTAL_SERVICES, FASHION_TEXTILE_AND_APPAREL, FINANCIAL_SERVICES, FOOD_AND_BEVERAGE, GAMING_AND_ESPORTS, GOVERNMENT_SERVICES, HEALTHCARE, HOSPITALITY, HUMAN_RESOURCES, INSURANCE, IT_SERVICES, LEGAL, MANUFACTURING, MEDIA, MINING_AND_METALS, NONPROFIT_AND_PHILANTHROPY, OIL_AND_GAS, PHARMACEUTICALS, PRIVATE_EQUITY_AND_VENTURE_CAPITAL, REAL_ESTATE, RENEWABLE_ENERGY, RESTAURANTS, RETAIL, SAAS, SECURITY, SOFTWARE, SPORTS_AND_RECREATION, SUPPLY_CHAIN_AND_PROCUREMENT, TELECOMMUNICATIONS, TRAVEL, WELLNESS_AND_LIFESTYLE.", + title="Filter Industry", + ), + ] = None + negate_filter_industry: Annotated[ + list[ + Literal[ + "ACCOUNTING", + "ADVERTISING_AND_MARKETING", + "AGRICULTURE_AND_NATURAL_RESOURCES", + "ALCOHOL_AND_TOBACCO", + "AUTOMOTIVE", + "BIG_DATA_AND_ANALYTICS", + "BIOTECHNOLOGY", + "BLOCKCHAIN_AND_CRYPTOCURRENCY", + "BUSINESS_PRODUCTS_AND_SERVICES", + "CLOUD_COMPUTING", + "COMPUTER_HARDWARE_AND_SEMICONDUCTORS", + "CONGLOMERATES_SHELL_AND_HOLDING_COMPANIES", + "CONSTRUCTION", + "CONSUMER_PRODUCTS", + "CONSUMER_SERVICES", + "CYBERSECURITY", + "DEFENSE_AND_AEROSPACE", + "E-COMMERCE", + "EDUCATION", + "ENERGY", + "ENGINEERING", + "ENTERTAINMENT", + "ENVIRONMENTAL_SERVICES", + "FASHION_TEXTILE_AND_APPAREL", + "FINANCIAL_SERVICES", + "FOOD_AND_BEVERAGE", + "GAMING_AND_ESPORTS", + "GOVERNMENT_SERVICES", + "HEALTHCARE", + "HOSPITALITY", + "HUMAN_RESOURCES", + "INSURANCE", + "IT_SERVICES", + "LEGAL", + "MANUFACTURING", + "MEDIA", + "MINING_AND_METALS", + "NONPROFIT_AND_PHILANTHROPY", + "OIL_AND_GAS", + "PHARMACEUTICALS", + "PRIVATE_EQUITY_AND_VENTURE_CAPITAL", + "REAL_ESTATE", + "RENEWABLE_ENERGY", + "RESTAURANTS", + "RETAIL", + "SAAS", + "SECURITY", + "SOFTWARE", + "SPORTS_AND_RECREATION", + "SUPPLY_CHAIN_AND_PROCUREMENT", + "TELECOMMUNICATIONS", + "TRAVEL", + "WELLNESS_AND_LIFESTYLE", + ] + ] + | None, + Field( + description="Exclude contacts at companies in specified industries. Valid values: ACCOUNTING, ADVERTISING_AND_MARKETING, AGRICULTURE_AND_NATURAL_RESOURCES, ALCOHOL_AND_TOBACCO, AUTOMOTIVE, BIG_DATA_AND_ANALYTICS, BIOTECHNOLOGY, BLOCKCHAIN_AND_CRYPTOCURRENCY, BUSINESS_PRODUCTS_AND_SERVICES, CLOUD_COMPUTING, COMPUTER_HARDWARE_AND_SEMICONDUCTORS, CONGLOMERATES_SHELL_AND_HOLDING_COMPANIES, CONSTRUCTION, CONSUMER_PRODUCTS, CONSUMER_SERVICES, CYBERSECURITY, DEFENSE_AND_AEROSPACE, E-COMMERCE, EDUCATION, ENERGY, ENGINEERING, ENTERTAINMENT, ENVIRONMENTAL_SERVICES, FASHION_TEXTILE_AND_APPAREL, FINANCIAL_SERVICES, FOOD_AND_BEVERAGE, GAMING_AND_ESPORTS, GOVERNMENT_SERVICES, HEALTHCARE, HOSPITALITY, HUMAN_RESOURCES, INSURANCE, IT_SERVICES, LEGAL, MANUFACTURING, MEDIA, MINING_AND_METALS, NONPROFIT_AND_PHILANTHROPY, OIL_AND_GAS, PHARMACEUTICALS, PRIVATE_EQUITY_AND_VENTURE_CAPITAL, REAL_ESTATE, RENEWABLE_ENERGY, RESTAURANTS, RETAIL, SAAS, SECURITY, SOFTWARE, SPORTS_AND_RECREATION, SUPPLY_CHAIN_AND_PROCUREMENT, TELECOMMUNICATIONS, TRAVEL, WELLNESS_AND_LIFESTYLE.", + title="Negate Filter Industry", + ), + ] = None + filter_country: Annotated[ + list[str] | None, + Field( + description="Filter by company country using ISO-3166-1 alpha-2 codes (e.g., US, GB, DE). Also accepts region aliases: EU, LATAM, MENA, APAC, NORDICS, DACH, BENELUX, GCC, ASEAN, CEE, ANZ.", + title="Filter Country", + ), + ] = None + negate_filter_country: Annotated[ + list[str] | None, + Field( + description="Exclude contacts at companies in specified countries. Accepts same codes and region aliases as filter_country.", + title="Negate Filter Country", + ), + ] = None + filter_state: Annotated[ + list[str] | None, + Field(description="Filter by company state/region.", title="Filter State"), + ] = None + negate_filter_state: Annotated[ + list[str] | None, + Field( + description="Exclude contacts at companies in specified states.", + title="Negate Filter State", + ), + ] = None + employee_range: Annotated[ + str | None, + Field( + description="Filter by employee count range. Format: 'min,max' (e.g., '51,200'). Maps to buckets: 1-10, 11-50, 51-200, 201-500, 501-1000, 1001-5000, 5001-10000, 10001+.", + title="Employee Range", + ), + ] = None + seniority: Annotated[ + list[Literal["executive", "vp", "director", "manager", "senior_ic", "mid_level", "entry_level"]] | None, + Field( + description="Filter by seniority bucket: executive, vp, director, manager, senior_ic, mid_level, entry_level", + title="Seniority", + ), + ] = None + negate_seniority: Annotated[ + list[Literal["executive", "vp", "director", "manager", "senior_ic", "mid_level", "entry_level"]] | None, + Field( + description="Exclude contacts with these seniority levels: executive, vp, director, manager, senior_ic, mid_level, entry_level", + title="Negate Seniority", + ), + ] = None + department: Annotated[ + list[ + Literal[ + "Operations", + "Executive", + "Technology", + "Sales - Marketing", + "Finance", + "Legal", + "Human Resources", + "Medical - Science", + "Customer Service", + "Research & Development", + "Administration", + "Public Relations", + "Investor Relations", + "Pro Services", + "Other", + ] + ] + | None, + Field( + description="Filter by department. Valid values: Operations, Executive, Technology, Sales - Marketing, Finance, Legal, Human Resources, Medical - Science, Customer Service, Research & Development, Administration, Public Relations, Investor Relations, Pro Services, Other.", + title="Department", + ), + ] = None + negate_department: Annotated[ + list[ + Literal[ + "Operations", + "Executive", + "Technology", + "Sales - Marketing", + "Finance", + "Legal", + "Human Resources", + "Medical - Science", + "Customer Service", + "Research & Development", + "Administration", + "Public Relations", + "Investor Relations", + "Pro Services", + "Other", + ] + ] + | None, + Field( + description="Exclude contacts in specified departments. Same valid values as `department`.", + title="Negate Department", + ), + ] = None + skills: Annotated[ + list[str] | None, + Field( + description="Filter by skills. Multiple skills can be provided.", + title="Skills", + ), + ] = None + name: Annotated[ + str | None, + Field( + description="Filter by contact name (partial match supported).", + title="Name", + ), + ] = None + title: Annotated[ + list[str] | None, + Field( + description="Filter by job title. Each item is a separate match term. Supports quoted phrases and + prefix for required terms. C-suite acronyms are auto-expanded to their spelled-out forms and vice versa (e.g. 'CEO' also matches 'Chief Executive Officer'); wrap a term in quotes to match it literally without expansion.", + title="Title", + ), + ] = None + negate_title: Annotated[ + list[str] | None, + Field( + description="Exclude contacts with specified job titles. C-suite acronyms are expanded the same way as in 'title'.", + title="Negate Title", + ), + ] = None + summary: Annotated[ + str | None, + Field( + description="Filter by profile summary text (semantic search).", + title="Summary", + ), + ] = None + negate_summary: Annotated[ + str | None, + Field( + description="Exclude contacts matching this summary description.", + title="Negate Summary", + ), + ] = None + person_country: Annotated[ + list[str] | None, + Field( + description="Filter by contact's country using ISO-3166-1 alpha-2 codes (e.g., US, GB, DE). Also accepts region aliases: EU, LATAM, MENA, APAC, NORDICS, DACH, BENELUX, GCC, ASEAN, CEE, ANZ.", + title="Person Country", + ), + ] = None + negate_person_country: Annotated[ + list[str] | None, + Field( + description="Exclude contacts in specified countries. Accepts same codes and region aliases as person_country.", + title="Negate Person Country", + ), + ] = None + person_state: Annotated[ + list[str] | None, + Field(description="Filter by contact's state/region.", title="Person State"), + ] = None + has_email: Annotated[ + bool | None, + Field(description="Only include contacts with email addresses.", title="Has Email"), + ] = False + email_validated: Annotated[ + bool | None, + Field( + description="Only include contacts with validated email addresses.", + title="Email Validated", + ), + ] = False + has_phone: Annotated[ + bool | None, + Field(description="Only include contacts with phone numbers.", title="Has Phone"), + ] = False + has_mobile: Annotated[ + bool | None, + Field( + description="Only include contacts with mobile phone numbers.", + title="Has Mobile", + ), + ] = False + has_linkedin: Annotated[ + bool | None, + Field( + description="Only include contacts with LinkedIn profiles.", + title="Has Linkedin", + ), + ] = False + min_connections: Annotated[ + int | None, + Field( + description="Minimum LinkedIn connections required.", + ge=0, + title="Min Connections", + ), + ] = None + jobstart_date: Annotated[ + str | None, + Field( + description="Filter by job start date: minimum date (YYYY-MM-DD) or range (YYYY-MM-DD,YYYY-MM-DD). Matches contacts who started their current role in this window; contacts without a known start date are excluded.", + title="Jobstart Date", + ), + ] = None + persona_id: Annotated[ + list[int] | None, + Field(description="Filter by specific persona IDs.", title="Persona Id"), + ] = None + icp_text: Annotated[ + str | None, + Field( + description="Natural language description of ideal contact profile for semantic matching.", + max_length=4000, + title="Icp Text", + ), + ] = None + icp_prompt: Annotated[ + str | None, + Field( + description="Natural language ICP description. Automatically extracts structured contact filters (seniority, department, industry, country, employee/revenue range, etc.), cleans the semantic description, and applies them before running the contact search. User-provided filters take precedence over wizard-extracted ones.", + max_length=4000, + title="Icp Prompt", + ), + ] = None + domain: Annotated[ + list[str] | None, + Field(description="Filter contacts at specific company domains.", title="Domain"), + ] = None + inclusion_query_id: Annotated[ + list[str] | None, + Field( + description="Include only contacts from companies in these saved queries.", + title="Inclusion Query Id", + ), + ] = None + exclusion_query_id: Annotated[ + list[str] | None, + Field( + description="Exclude contacts from companies in these saved queries.", + title="Exclusion Query Id", + ), + ] = None + max_records: Annotated[ + int | None, + Field( + description="Maximum number of contacts to return (20-10000).", + ge=20, + le=10000, + title="Max Records", + ), + ] = 100 + max_companies: Annotated[ + int | None, + Field( + description="Maximum number of enriched companies to return. Cannot be combined with `max_records`; when set, the internal total contact cap is derived from `max_companies * results_by_company`, capped at 10000.", + ge=1, + le=10000, + title="Max Companies", + ), + ] = None + offset: Annotated[ + int | None, + Field( + description="Number of results to skip for pagination.", + ge=0, + le=10000, + title="Offset", + ), + ] = 0 + results_by_company: Annotated[ + int | None, + Field( + description="Maximum contacts per company domain. Default 5 — i.e. without this parameter the endpoint returns up to 5 contacts from any one company so results spread across more distinct companies. Set `results_by_company=0` to remove the cap (return every matching contact per company, up to `max_records` total — or the global ceiling of 10000 when paired with `max_companies`). When the value is non-zero, `offset` is forced to 0 (pagination is disabled).", + ge=0, + le=100, + title="Results By Company", + ), + ] = 5 + include_search_contacts: Annotated[ + bool | None, + Field( + description="Include contacts from the search index (broader coverage).", + title="Include Search Contacts", + ), + ] = False + consensus: Annotated[ + int | None, + Field( + description="Number of query vectors to combine for consensus search.", + ge=1, + le=20, + title="Consensus", + ), + ] = 1 + + +class ContactGenerateRequest(DiscolikeRequest): + icp_text: Annotated[ + str, + Field( + description="ICP / persona description of the contacts to find.", + title="Icp Text", + ), + ] + domains: Annotated[ + list[str], + Field( + description="Company domains to search at (1-10000).", + max_length=10000, + min_length=1, + title="Domains", + ), + ] + context_mode: Annotated[Literal["website", "profile", "domain"] | None, Field(title="Context Mode")] = "website" + integration_id: Annotated[str | None, Field(title="Integration Id")] = None + search_provider_id: Annotated[str | None, Field(title="Search Provider Id")] = None + search_context_size: Annotated[Literal["low", "medium", "high"] | None, Field(title="Search Context Size")] = "low" + max_contacts_per_domain: Annotated[int | None, Field(title="Max Contacts Per Domain")] = 10 + max_company_records: Annotated[int | None, Field(title="Max Company Records")] = None + full_domains: Annotated[list[str] | None, Field(title="Full Domains")] = None + partial_domains: Annotated[list[str] | None, Field(title="Partial Domains")] = None + initial_contact_counts: Annotated[dict[str, int] | None, Field(title="Initial Contact Counts")] = None + + +class DiscoGenProcessRequest(DiscolikeRequest): + query: Annotated[ + str, + Field(description="Prompt to run against each record", min_length=1, title="Query"), + ] + integration_id: Annotated[ + str | None, + Field( + description="LLM provider integration UUID (omit for org default)", + title="Integration Id", + ), + ] = None + web_search: Annotated[bool | None, Field(title="Web Search")] = False + include_x_search: Annotated[bool | None, Field(title="Include X Search")] = False + search_provider_id: Annotated[ + str | None, + Field( + description="Search provider integration UUID (omit for org default)", + title="Search Provider Id", + ), + ] = None + search_context_size: Annotated[Literal["low", "medium", "high"] | None, Field(title="Search Context Size")] = "low" + domains: Annotated[ + list[str], + Field( + description="Domains to process", + max_length=10000, + min_length=1, + title="Domains", + ), + ] + 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 + + +class DiscoGenPersonaProcessRequest(DiscolikeRequest): + query: Annotated[ + str, + Field(description="Prompt to run against each record", min_length=1, title="Query"), + ] + integration_id: Annotated[ + str | None, + Field( + description="LLM provider integration UUID (omit for org default)", + title="Integration Id", + ), + ] = None + web_search: Annotated[bool | None, Field(title="Web Search")] = False + include_x_search: Annotated[bool | None, Field(title="Include X Search")] = False + search_provider_id: Annotated[ + str | None, + Field( + description="Search provider integration UUID (omit for org default)", + title="Search Provider Id", + ), + ] = None + search_context_size: Annotated[Literal["low", "medium", "high"] | None, Field(title="Search Context Size")] = "low" + persona_ids: Annotated[ + list[int], + Field( + description="Contact record IDs to process", + max_length=10000, + min_length=1, + title="Persona Ids", + ), + ] + context_mode: Annotated[ + 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 + + +class ValidateIcpRequest(DiscolikeRequest): + icp_text: Annotated[ + str, + Field( + description="Ideal customer profile description to validate domains against", + title="Icp Text", + ), + ] + domains: Annotated[ + list[str], + Field( + description="Domains to validate", + max_length=10000, + min_length=1, + title="Domains", + ), + ] + context_mode: Annotated[Literal["website", "profile", "domain"] | None, Field(title="Context Mode")] = "website" + integration_id: Annotated[ + str | None, + Field( + description="LLM provider integration UUID (omit for org default)", + title="Integration Id", + ), + ] = None + web_search: Annotated[bool | None, Field(title="Web Search")] = False + search_provider_id: Annotated[ + str | None, + Field( + description="Search provider integration UUID (omit for org default)", + title="Search Provider Id", + ), + ] = None + + +class DiscoverParams(DiscolikeRequest): + phrase_match: Annotated[ + list[str] | None, + Field( + description="Exact text fragments to search for in site content. Up to 20 fragments, each at least 3 characters.", + max_length=20, + title="Phrase Match", + ), + ] = None + negate_phrase_match: Annotated[ + list[str] | None, + Field( + description="Exact text fragments to exclude from results. Up to 20 fragments, each at least 3 characters.", + max_length=20, + title="Negate Phrase Match", + ), + ] = None + subdomain: Annotated[ + list[str] | None, + Field( + description="Limit results to specified subdomains. Up to 20, each at least 3 characters.", + max_length=20, + title="Subdomain", + ), + ] = None + negate_subdomain: Annotated[ + list[str] | None, + Field( + description="Exclude specified subdomains from results. Up to 20, each at least 3 characters.", + max_length=20, + title="Negate Subdomain", + ), + ] = None + tech_stack: Annotated[ + list[str] | None, + Field( + description="Filter to companies using specified vendor domains (up to 20).", + max_length=20, + title="Tech Stack", + ), + ] = None + negate_tech_stack: Annotated[ + list[str] | None, + Field( + description="Exclude companies using specified vendor domains (up to 20).", + max_length=20, + title="Negate Tech Stack", + ), + ] = None + category: Annotated[ + list[ + Literal[ + "ACCOUNTING", + "ADVERTISING_AND_MARKETING", + "AGRICULTURE_AND_NATURAL_RESOURCES", + "ALCOHOL_AND_TOBACCO", + "AUTOMOTIVE", + "BIG_DATA_AND_ANALYTICS", + "BIOTECHNOLOGY", + "BLOCKCHAIN_AND_CRYPTOCURRENCY", + "BUSINESS_PRODUCTS_AND_SERVICES", + "CLOUD_COMPUTING", + "COMPUTER_HARDWARE_AND_SEMICONDUCTORS", + "CONGLOMERATES_SHELL_AND_HOLDING_COMPANIES", + "CONSTRUCTION", + "CONSUMER_PRODUCTS", + "CONSUMER_SERVICES", + "CYBERSECURITY", + "DEFENSE_AND_AEROSPACE", + "E-COMMERCE", + "EDUCATION", + "ENERGY", + "ENGINEERING", + "ENTERTAINMENT", + "ENVIRONMENTAL_SERVICES", + "FASHION_TEXTILE_AND_APPAREL", + "FINANCIAL_SERVICES", + "FOOD_AND_BEVERAGE", + "GAMING_AND_ESPORTS", + "GOVERNMENT_SERVICES", + "HEALTHCARE", + "HOSPITALITY", + "HUMAN_RESOURCES", + "INSURANCE", + "IT_SERVICES", + "LEGAL", + "MANUFACTURING", + "MEDIA", + "MINING_AND_METALS", + "NONPROFIT_AND_PHILANTHROPY", + "OIL_AND_GAS", + "PHARMACEUTICALS", + "PRIVATE_EQUITY_AND_VENTURE_CAPITAL", + "REAL_ESTATE", + "RENEWABLE_ENERGY", + "RESTAURANTS", + "RETAIL", + "SAAS", + "SECURITY", + "SOFTWARE", + "SPORTS_AND_RECREATION", + "SUPPLY_CHAIN_AND_PROCUREMENT", + "TELECOMMUNICATIONS", + "TRAVEL", + "WELLNESS_AND_LIFESTYLE", + ] + ] + | None, + Field( + description="Filter by industry category. Valid values: ACCOUNTING, ADVERTISING_AND_MARKETING, AGRICULTURE_AND_NATURAL_RESOURCES, ALCOHOL_AND_TOBACCO, AUTOMOTIVE, BIG_DATA_AND_ANALYTICS, BIOTECHNOLOGY, BLOCKCHAIN_AND_CRYPTOCURRENCY, BUSINESS_PRODUCTS_AND_SERVICES, CLOUD_COMPUTING, COMPUTER_HARDWARE_AND_SEMICONDUCTORS, CONGLOMERATES_SHELL_AND_HOLDING_COMPANIES, CONSTRUCTION, CONSUMER_PRODUCTS, CONSUMER_SERVICES, CYBERSECURITY, DEFENSE_AND_AEROSPACE, E-COMMERCE, EDUCATION, ENERGY, ENGINEERING, ENTERTAINMENT, ENVIRONMENTAL_SERVICES, FASHION_TEXTILE_AND_APPAREL, FINANCIAL_SERVICES, FOOD_AND_BEVERAGE, GAMING_AND_ESPORTS, GOVERNMENT_SERVICES, HEALTHCARE, HOSPITALITY, HUMAN_RESOURCES, INSURANCE, IT_SERVICES, LEGAL, MANUFACTURING, MEDIA, MINING_AND_METALS, NONPROFIT_AND_PHILANTHROPY, OIL_AND_GAS, PHARMACEUTICALS, PRIVATE_EQUITY_AND_VENTURE_CAPITAL, REAL_ESTATE, RENEWABLE_ENERGY, RESTAURANTS, RETAIL, SAAS, SECURITY, SOFTWARE, SPORTS_AND_RECREATION, SUPPLY_CHAIN_AND_PROCUREMENT, TELECOMMUNICATIONS, TRAVEL, WELLNESS_AND_LIFESTYLE.", + title="Category", + ), + ] = None + negate_category: Annotated[ + list[ + Literal[ + "ACCOUNTING", + "ADVERTISING_AND_MARKETING", + "AGRICULTURE_AND_NATURAL_RESOURCES", + "ALCOHOL_AND_TOBACCO", + "AUTOMOTIVE", + "BIG_DATA_AND_ANALYTICS", + "BIOTECHNOLOGY", + "BLOCKCHAIN_AND_CRYPTOCURRENCY", + "BUSINESS_PRODUCTS_AND_SERVICES", + "CLOUD_COMPUTING", + "COMPUTER_HARDWARE_AND_SEMICONDUCTORS", + "CONGLOMERATES_SHELL_AND_HOLDING_COMPANIES", + "CONSTRUCTION", + "CONSUMER_PRODUCTS", + "CONSUMER_SERVICES", + "CYBERSECURITY", + "DEFENSE_AND_AEROSPACE", + "E-COMMERCE", + "EDUCATION", + "ENERGY", + "ENGINEERING", + "ENTERTAINMENT", + "ENVIRONMENTAL_SERVICES", + "FASHION_TEXTILE_AND_APPAREL", + "FINANCIAL_SERVICES", + "FOOD_AND_BEVERAGE", + "GAMING_AND_ESPORTS", + "GOVERNMENT_SERVICES", + "HEALTHCARE", + "HOSPITALITY", + "HUMAN_RESOURCES", + "INSURANCE", + "IT_SERVICES", + "LEGAL", + "MANUFACTURING", + "MEDIA", + "MINING_AND_METALS", + "NONPROFIT_AND_PHILANTHROPY", + "OIL_AND_GAS", + "PHARMACEUTICALS", + "PRIVATE_EQUITY_AND_VENTURE_CAPITAL", + "REAL_ESTATE", + "RENEWABLE_ENERGY", + "RESTAURANTS", + "RETAIL", + "SAAS", + "SECURITY", + "SOFTWARE", + "SPORTS_AND_RECREATION", + "SUPPLY_CHAIN_AND_PROCUREMENT", + "TELECOMMUNICATIONS", + "TRAVEL", + "WELLNESS_AND_LIFESTYLE", + ] + ] + | None, + Field( + description="Exclude specified industry categories. Valid values: ACCOUNTING, ADVERTISING_AND_MARKETING, AGRICULTURE_AND_NATURAL_RESOURCES, ALCOHOL_AND_TOBACCO, AUTOMOTIVE, BIG_DATA_AND_ANALYTICS, BIOTECHNOLOGY, BLOCKCHAIN_AND_CRYPTOCURRENCY, BUSINESS_PRODUCTS_AND_SERVICES, CLOUD_COMPUTING, COMPUTER_HARDWARE_AND_SEMICONDUCTORS, CONGLOMERATES_SHELL_AND_HOLDING_COMPANIES, CONSTRUCTION, CONSUMER_PRODUCTS, CONSUMER_SERVICES, CYBERSECURITY, DEFENSE_AND_AEROSPACE, E-COMMERCE, EDUCATION, ENERGY, ENGINEERING, ENTERTAINMENT, ENVIRONMENTAL_SERVICES, FASHION_TEXTILE_AND_APPAREL, FINANCIAL_SERVICES, FOOD_AND_BEVERAGE, GAMING_AND_ESPORTS, GOVERNMENT_SERVICES, HEALTHCARE, HOSPITALITY, HUMAN_RESOURCES, INSURANCE, IT_SERVICES, LEGAL, MANUFACTURING, MEDIA, MINING_AND_METALS, NONPROFIT_AND_PHILANTHROPY, OIL_AND_GAS, PHARMACEUTICALS, PRIVATE_EQUITY_AND_VENTURE_CAPITAL, REAL_ESTATE, RENEWABLE_ENERGY, RESTAURANTS, RETAIL, SAAS, SECURITY, SOFTWARE, SPORTS_AND_RECREATION, SUPPLY_CHAIN_AND_PROCUREMENT, TELECOMMUNICATIONS, TRAVEL, WELLNESS_AND_LIFESTYLE.", + title="Negate Category", + ), + ] = None + min_digital_footprint: Annotated[ + int | None, + Field( + description="Minimum digital footprint score (0-800). Default 50.", + ge=0, + le=800, + title="Min Digital Footprint", + ), + ] = None + max_digital_footprint: Annotated[ + int | None, + Field( + description="Maximum digital footprint score (0-800). Default 800.", + ge=0, + le=800, + title="Max Digital Footprint", + ), + ] = None + state: Annotated[ + list[str] | None, + Field( + description="Filter by state codes (up to 100). Not supported with multiple countries.", + max_length=100, + title="State", + ), + ] = None + negate_state: Annotated[ + list[str] | None, + Field( + description="Exclude specified states from results (up to 100).", + max_length=100, + title="Negate State", + ), + ] = None + country: Annotated[ + list[str] | None, + Field( + description="Filter by ISO-3166-1 alpha-2 country codes (e.g., US, GB, DE). Also accepts region aliases: EU, LATAM, MENA, APAC, NORDICS, DACH, BENELUX, GCC, ASEAN, CEE, ANZ.", + title="Country", + ), + ] = None + negate_country: Annotated[ + list[str] | None, + Field( + description="Exclude specified countries from results. Accepts same codes and region aliases as country.", + title="Negate Country", + ), + ] = None + start_date: Annotated[ + str | None, + Field( + description="Minimum company start date (YYYY-MM-DD) or range (YYYY-MM-DD,YYYY-MM-DD).", + title="Start Date", + ), + ] = None + redirect: Annotated[ + bool | None, + Field( + description="Include domains that redirect to another domain.", + title="Redirect", + ), + ] = False + social: Annotated[ + list[ + Literal[ + "facebook", + "instagram", + "linkedin", + "pinterest", + "threads", + "tiktok", + "twitter", + "x", + "yelp", + "youtube", + "googleplay", + "applestore", + "amazon", + "vk", + "bluesky", + "xing", + ] + ] + | None, + Field( + description="Filter by social platform presence. Valid values: facebook, instagram, linkedin, pinterest, threads, tiktok, twitter, x, yelp, youtube, googleplay, applestore, amazon, vk, bluesky, xing. Note: 'twitter' is an alias for 'x'.", + title="Social", + ), + ] = None + negate_social: Annotated[ + list[ + Literal[ + "facebook", + "instagram", + "linkedin", + "pinterest", + "threads", + "tiktok", + "twitter", + "x", + "yelp", + "youtube", + "googleplay", + "applestore", + "amazon", + "vk", + "bluesky", + "xing", + ] + ] + | None, + Field( + description="Exclude companies with specified social profiles. Filter by social platform presence. Valid values: facebook, instagram, linkedin, pinterest, threads, tiktok, twitter, x, yelp, youtube, googleplay, applestore, amazon, vk, bluesky, xing. Note: 'twitter' is an alias for 'x'.", + title="Negate Social", + ), + ] = None + language: Annotated[ + list[ + Literal[ + "ar", + "az", + "bg", + "bn", + "bs", + "ca", + "cs", + "da", + "de", + "el", + "en", + "eo", + "es", + "et", + "eu", + "fa", + "fi", + "fr", + "ga", + "gl", + "he", + "hi", + "hr", + "hu", + "id", + "it", + "ja", + "ko", + "ky", + "lt", + "lv", + "mk", + "ms", + "my", + "nb", + "nl", + "pb", + "pl", + "pt", + "ro", + "ru", + "sk", + "sl", + "sq", + "sr", + "sv", + "sw", + "th", + "tl", + "tr", + "uk", + "ur", + "vi", + "zh", + "zt", + ] + ] + | None, + Field( + description="Filter by site language. Valid values: ar (Arabic), az (Azerbaijani), bg (Bulgarian), bn (Bengali), bs (Bosnian), ca (Catalan), cs (Czech), da (Danish), de (German), el (Greek), en (English), eo (Esperanto), es (Spanish), et (Estonian), eu (Basque), fa (Persian), fi (Finnish), fr (French), ga (Irish), gl (Galician), he (Hebrew), hi (Hindi), hr (Croatian), hu (Hungarian), id (Indonesian), it (Italian), ja (Japanese), ko (Korean), ky (Kyrgyz), lt (Lithuanian), lv (Latvian), mk (Macedonian), ms (Malay), my (Burmese), nb (Norwegian Bokmål), nl (Dutch), pb (Portuguese (Brazilian)), pl (Polish), pt (Portuguese), ro (Romanian), ru (Russian), sk (Slovak), sl (Slovenian), sq (Albanian), sr (Serbian), sv (Swedish), sw (Swahili), th (Thai), tl (Tagalog), tr (Turkish), uk (Ukrainian), ur (Urdu), vi (Vietnamese), zh (Chinese (Simplified)), zt (Chinese (Traditional)).", + title="Language", + ), + ] = None + negate_language: Annotated[ + list[ + Literal[ + "ar", + "az", + "bg", + "bn", + "bs", + "ca", + "cs", + "da", + "de", + "el", + "en", + "eo", + "es", + "et", + "eu", + "fa", + "fi", + "fr", + "ga", + "gl", + "he", + "hi", + "hr", + "hu", + "id", + "it", + "ja", + "ko", + "ky", + "lt", + "lv", + "mk", + "ms", + "my", + "nb", + "nl", + "pb", + "pl", + "pt", + "ro", + "ru", + "sk", + "sl", + "sq", + "sr", + "sv", + "sw", + "th", + "tl", + "tr", + "uk", + "ur", + "vi", + "zh", + "zt", + ] + ] + | None, + Field( + description="Exclude specified languages from results. Filter by site language. Valid values: ar (Arabic), az (Azerbaijani), bg (Bulgarian), bn (Bengali), bs (Bosnian), ca (Catalan), cs (Czech), da (Danish), de (German), el (Greek), en (English), eo (Esperanto), es (Spanish), et (Estonian), eu (Basque), fa (Persian), fi (Finnish), fr (French), ga (Irish), gl (Galician), he (Hebrew), hi (Hindi), hr (Croatian), hu (Hungarian), id (Indonesian), it (Italian), ja (Japanese), ko (Korean), ky (Kyrgyz), lt (Lithuanian), lv (Latvian), mk (Macedonian), ms (Malay), my (Burmese), nb (Norwegian Bokmål), nl (Dutch), pb (Portuguese (Brazilian)), pl (Polish), pt (Portuguese), ro (Romanian), ru (Russian), sk (Slovak), sl (Slovenian), sq (Albanian), sr (Serbian), sv (Swedish), sw (Swahili), th (Thai), tl (Tagalog), tr (Turkish), uk (Ukrainian), ur (Urdu), vi (Vietnamese), zh (Chinese (Simplified)), zt (Chinese (Traditional)).", + title="Negate Language", + ), + ] = None + employee_range: Annotated[ + str | None, + Field( + description="Filter by employee count range. Format: 'min,max' (e.g., '1,5000'). Maps to buckets: 1-10, 11-50, 51-200, 201-500, 501-1000, 1001-5000, 5001-10000, 10001+.", + title="Employee Range", + ), + ] = None + revenue_range: Annotated[ + str | None, + Field( + description="Filter by company revenue. Format: 'min,max' in raw numbers (e.g., '1000000,10000000' for 1M-10M). Maps to buckets: <1M, 1-10M, 10-100M, 100M-1B, >1B. Unknown-revenue domains (N/A) are included when the range covers the <1M bucket.", + title="Revenue Range", + ), + ] = None + business_model: Annotated[ + list[Literal["B2B", "B2C", "B2G", "G2B", "G2C", "D2C", "C2C", "C2B"]] | None, + Field( + description="Filter by business model label. Multi-select with OR semantics. Valid values: B2B, B2C, B2G, G2B, G2C, D2C, C2C, C2B.", + title="Business Model", + ), + ] = None + negate_business_model: Annotated[ + list[Literal["B2B", "B2C", "B2G", "G2B", "G2C", "D2C", "C2C", "C2B"]] | None, + Field( + description="Exclude domains with these business model labels. Filter by business model label. Multi-select with OR semantics. Valid values: B2B, B2C, B2G, G2B, G2C, D2C, C2C, C2B.", + title="Negate Business Model", + ), + ] = None + exclude_leadgen: Annotated[ + bool | None, + Field( + description="Exclude suspected lead generation sites. Filters out profiles with score <= 25 that also lack phone, email, and social media presence.", + title="Exclude Leadgen", + ), + ] = True + domain: Annotated[ + list[str] | None, + Field( + description="Domain(s) for lookalike matching. Up to 10 domains allowed.", + max_length=10, + title="Domain", + ), + ] = None + exclude_domain: Annotated[ + list[str] | None, + Field( + description="Hard-exclude these exact domains from results without affecting lookalike matching. Accepts comma-separated or repeated params. Up to 100 domains.", + max_length=100, + title="Exclude Domain", + ), + ] = None + icp_text: Annotated[ + str | None, + Field( + description="Natural language description of ideal customer profile for semantic matching (3-4000 characters).", + max_length=4000, + min_length=3, + title="Icp Text", + ), + ] = None + retrieval: Annotated[ + bool | None, + Field( + description="Enable page data retrieval using Extract API.", + title="Retrieval", + ), + ] = False + enhanced: Annotated[ + bool | None, + Field( + description="Enable AI-powered result enhancement for improved relevance.", + title="Enhanced", + ), + ] = False + include_search_domains: Annotated[ + bool | None, + Field( + description="Include input domains in results (excluded by default).", + title="Include Search Domains", + ), + ] = False + max_records: Annotated[ + int | None, + Field( + description="Maximum records to return (5-10000). Default 100.", + ge=5, + le=10000, + title="Max Records", + ), + ] = 100 + consensus: Annotated[ + int | None, + Field( + description="Number of top results for consensus search vector (1-20). Higher values reduce specificity.", + ge=1, + le=20, + title="Consensus", + ), + ] = 1 + min_similarity: Annotated[ + int | None, + Field( + description="Minimum similarity score to include (0-99).", + ge=0, + le=99, + title="Min Similarity", + ), + ] = 0 + variance: Annotated[ + Literal["LOW", "MID_LOW", "MEDIUM", "MID_HIGH", "HIGH", "UNRESTRICTED"] | None, + Field( + description="Result diversity control: LOW, MID_LOW, MEDIUM, MID_HIGH, HIGH, UNRESTRICTED.", + title="Variance", + ), + ] = "UNRESTRICTED" + offset: Annotated[ + int | None, + Field(description="Records to skip for pagination.", ge=0, title="Offset"), + ] = 0 + exclusion_query_id: Annotated[ + list[str] | None, + Field( + description="Exclude domains from saved queries.", + title="Exclusion Query Id", + ), + ] = None + inclusion_query_id: Annotated[ + list[str] | None, + Field( + description="Include domains from saved queries. Requires STARTER plan.", + title="Inclusion Query Id", + ), + ] = None + auto_icp_text: Annotated[ + bool | None, + Field( + description="Auto-generate ICP text from provided domain(s).", + title="Auto Icp Text", + ), + ] = False + auto_phrase_match: Annotated[ + bool | None, + Field( + description="Auto-generate phrase matches from ICP text.", + title="Auto Phrase Match", + ), + ] = False + icp_prompt: Annotated[ + str | None, + Field( + description="Natural language ICP description. Automatically extracts structured filters, generates ICP text, suggests domains, and applies them before running discovery. Overrides auto_icp_text and auto_phrase_match when set.", + max_length=4000, + title="Icp Prompt", + ), + ] = None + + +class CountParams(DiscolikeRequest): + phrase_match: Annotated[ + list[str] | None, + Field( + description="Exact text fragments to search for in site content. Up to 20 fragments, each at least 3 characters.", + max_length=20, + title="Phrase Match", + ), + ] = None + negate_phrase_match: Annotated[ + list[str] | None, + Field( + description="Exact text fragments to exclude from results. Up to 20 fragments, each at least 3 characters.", + max_length=20, + title="Negate Phrase Match", + ), + ] = None + subdomain: Annotated[ + list[str] | None, + Field( + description="Limit results to specified subdomains. Up to 20, each at least 3 characters.", + max_length=20, + title="Subdomain", + ), + ] = None + negate_subdomain: Annotated[ + list[str] | None, + Field( + description="Exclude specified subdomains from results. Up to 20, each at least 3 characters.", + max_length=20, + title="Negate Subdomain", + ), + ] = None + tech_stack: Annotated[ + list[str] | None, + Field( + description="Filter to companies using specified vendor domains (up to 20).", + max_length=20, + title="Tech Stack", + ), + ] = None + negate_tech_stack: Annotated[ + list[str] | None, + Field( + description="Exclude companies using specified vendor domains (up to 20).", + max_length=20, + title="Negate Tech Stack", + ), + ] = None + category: Annotated[ + list[ + Literal[ + "ACCOUNTING", + "ADVERTISING_AND_MARKETING", + "AGRICULTURE_AND_NATURAL_RESOURCES", + "ALCOHOL_AND_TOBACCO", + "AUTOMOTIVE", + "BIG_DATA_AND_ANALYTICS", + "BIOTECHNOLOGY", + "BLOCKCHAIN_AND_CRYPTOCURRENCY", + "BUSINESS_PRODUCTS_AND_SERVICES", + "CLOUD_COMPUTING", + "COMPUTER_HARDWARE_AND_SEMICONDUCTORS", + "CONGLOMERATES_SHELL_AND_HOLDING_COMPANIES", + "CONSTRUCTION", + "CONSUMER_PRODUCTS", + "CONSUMER_SERVICES", + "CYBERSECURITY", + "DEFENSE_AND_AEROSPACE", + "E-COMMERCE", + "EDUCATION", + "ENERGY", + "ENGINEERING", + "ENTERTAINMENT", + "ENVIRONMENTAL_SERVICES", + "FASHION_TEXTILE_AND_APPAREL", + "FINANCIAL_SERVICES", + "FOOD_AND_BEVERAGE", + "GAMING_AND_ESPORTS", + "GOVERNMENT_SERVICES", + "HEALTHCARE", + "HOSPITALITY", + "HUMAN_RESOURCES", + "INSURANCE", + "IT_SERVICES", + "LEGAL", + "MANUFACTURING", + "MEDIA", + "MINING_AND_METALS", + "NONPROFIT_AND_PHILANTHROPY", + "OIL_AND_GAS", + "PHARMACEUTICALS", + "PRIVATE_EQUITY_AND_VENTURE_CAPITAL", + "REAL_ESTATE", + "RENEWABLE_ENERGY", + "RESTAURANTS", + "RETAIL", + "SAAS", + "SECURITY", + "SOFTWARE", + "SPORTS_AND_RECREATION", + "SUPPLY_CHAIN_AND_PROCUREMENT", + "TELECOMMUNICATIONS", + "TRAVEL", + "WELLNESS_AND_LIFESTYLE", + ] + ] + | None, + Field( + description="Filter by industry category. Valid values: ACCOUNTING, ADVERTISING_AND_MARKETING, AGRICULTURE_AND_NATURAL_RESOURCES, ALCOHOL_AND_TOBACCO, AUTOMOTIVE, BIG_DATA_AND_ANALYTICS, BIOTECHNOLOGY, BLOCKCHAIN_AND_CRYPTOCURRENCY, BUSINESS_PRODUCTS_AND_SERVICES, CLOUD_COMPUTING, COMPUTER_HARDWARE_AND_SEMICONDUCTORS, CONGLOMERATES_SHELL_AND_HOLDING_COMPANIES, CONSTRUCTION, CONSUMER_PRODUCTS, CONSUMER_SERVICES, CYBERSECURITY, DEFENSE_AND_AEROSPACE, E-COMMERCE, EDUCATION, ENERGY, ENGINEERING, ENTERTAINMENT, ENVIRONMENTAL_SERVICES, FASHION_TEXTILE_AND_APPAREL, FINANCIAL_SERVICES, FOOD_AND_BEVERAGE, GAMING_AND_ESPORTS, GOVERNMENT_SERVICES, HEALTHCARE, HOSPITALITY, HUMAN_RESOURCES, INSURANCE, IT_SERVICES, LEGAL, MANUFACTURING, MEDIA, MINING_AND_METALS, NONPROFIT_AND_PHILANTHROPY, OIL_AND_GAS, PHARMACEUTICALS, PRIVATE_EQUITY_AND_VENTURE_CAPITAL, REAL_ESTATE, RENEWABLE_ENERGY, RESTAURANTS, RETAIL, SAAS, SECURITY, SOFTWARE, SPORTS_AND_RECREATION, SUPPLY_CHAIN_AND_PROCUREMENT, TELECOMMUNICATIONS, TRAVEL, WELLNESS_AND_LIFESTYLE.", + title="Category", + ), + ] = None + negate_category: Annotated[ + list[ + Literal[ + "ACCOUNTING", + "ADVERTISING_AND_MARKETING", + "AGRICULTURE_AND_NATURAL_RESOURCES", + "ALCOHOL_AND_TOBACCO", + "AUTOMOTIVE", + "BIG_DATA_AND_ANALYTICS", + "BIOTECHNOLOGY", + "BLOCKCHAIN_AND_CRYPTOCURRENCY", + "BUSINESS_PRODUCTS_AND_SERVICES", + "CLOUD_COMPUTING", + "COMPUTER_HARDWARE_AND_SEMICONDUCTORS", + "CONGLOMERATES_SHELL_AND_HOLDING_COMPANIES", + "CONSTRUCTION", + "CONSUMER_PRODUCTS", + "CONSUMER_SERVICES", + "CYBERSECURITY", + "DEFENSE_AND_AEROSPACE", + "E-COMMERCE", + "EDUCATION", + "ENERGY", + "ENGINEERING", + "ENTERTAINMENT", + "ENVIRONMENTAL_SERVICES", + "FASHION_TEXTILE_AND_APPAREL", + "FINANCIAL_SERVICES", + "FOOD_AND_BEVERAGE", + "GAMING_AND_ESPORTS", + "GOVERNMENT_SERVICES", + "HEALTHCARE", + "HOSPITALITY", + "HUMAN_RESOURCES", + "INSURANCE", + "IT_SERVICES", + "LEGAL", + "MANUFACTURING", + "MEDIA", + "MINING_AND_METALS", + "NONPROFIT_AND_PHILANTHROPY", + "OIL_AND_GAS", + "PHARMACEUTICALS", + "PRIVATE_EQUITY_AND_VENTURE_CAPITAL", + "REAL_ESTATE", + "RENEWABLE_ENERGY", + "RESTAURANTS", + "RETAIL", + "SAAS", + "SECURITY", + "SOFTWARE", + "SPORTS_AND_RECREATION", + "SUPPLY_CHAIN_AND_PROCUREMENT", + "TELECOMMUNICATIONS", + "TRAVEL", + "WELLNESS_AND_LIFESTYLE", + ] + ] + | None, + Field( + description="Exclude specified industry categories. Valid values: ACCOUNTING, ADVERTISING_AND_MARKETING, AGRICULTURE_AND_NATURAL_RESOURCES, ALCOHOL_AND_TOBACCO, AUTOMOTIVE, BIG_DATA_AND_ANALYTICS, BIOTECHNOLOGY, BLOCKCHAIN_AND_CRYPTOCURRENCY, BUSINESS_PRODUCTS_AND_SERVICES, CLOUD_COMPUTING, COMPUTER_HARDWARE_AND_SEMICONDUCTORS, CONGLOMERATES_SHELL_AND_HOLDING_COMPANIES, CONSTRUCTION, CONSUMER_PRODUCTS, CONSUMER_SERVICES, CYBERSECURITY, DEFENSE_AND_AEROSPACE, E-COMMERCE, EDUCATION, ENERGY, ENGINEERING, ENTERTAINMENT, ENVIRONMENTAL_SERVICES, FASHION_TEXTILE_AND_APPAREL, FINANCIAL_SERVICES, FOOD_AND_BEVERAGE, GAMING_AND_ESPORTS, GOVERNMENT_SERVICES, HEALTHCARE, HOSPITALITY, HUMAN_RESOURCES, INSURANCE, IT_SERVICES, LEGAL, MANUFACTURING, MEDIA, MINING_AND_METALS, NONPROFIT_AND_PHILANTHROPY, OIL_AND_GAS, PHARMACEUTICALS, PRIVATE_EQUITY_AND_VENTURE_CAPITAL, REAL_ESTATE, RENEWABLE_ENERGY, RESTAURANTS, RETAIL, SAAS, SECURITY, SOFTWARE, SPORTS_AND_RECREATION, SUPPLY_CHAIN_AND_PROCUREMENT, TELECOMMUNICATIONS, TRAVEL, WELLNESS_AND_LIFESTYLE.", + title="Negate Category", + ), + ] = None + min_digital_footprint: Annotated[ + int | None, + Field( + description="Minimum digital footprint score (0-800). Default 50.", + ge=0, + le=800, + title="Min Digital Footprint", + ), + ] = None + max_digital_footprint: Annotated[ + int | None, + Field( + description="Maximum digital footprint score (0-800). Default 800.", + ge=0, + le=800, + title="Max Digital Footprint", + ), + ] = None + state: Annotated[ + list[str] | None, + Field( + description="Filter by state codes (up to 100). Not supported with multiple countries.", + max_length=100, + title="State", + ), + ] = None + negate_state: Annotated[ + list[str] | None, + Field( + description="Exclude specified states from results (up to 100).", + max_length=100, + title="Negate State", + ), + ] = None + country: Annotated[ + list[str] | None, + Field( + description="Filter by ISO-3166-1 alpha-2 country codes (e.g., US, GB, DE). Also accepts region aliases: EU, LATAM, MENA, APAC, NORDICS, DACH, BENELUX, GCC, ASEAN, CEE, ANZ.", + title="Country", + ), + ] = None + negate_country: Annotated[ + list[str] | None, + Field( + description="Exclude specified countries from results. Accepts same codes and region aliases as country.", + title="Negate Country", + ), + ] = None + start_date: Annotated[ + str | None, + Field( + description="Minimum company start date (YYYY-MM-DD) or range (YYYY-MM-DD,YYYY-MM-DD).", + title="Start Date", + ), + ] = None + redirect: Annotated[ + bool | None, + Field( + description="Include domains that redirect to another domain.", + title="Redirect", + ), + ] = False + social: Annotated[ + list[ + Literal[ + "facebook", + "instagram", + "linkedin", + "pinterest", + "threads", + "tiktok", + "twitter", + "x", + "yelp", + "youtube", + "googleplay", + "applestore", + "amazon", + "vk", + "bluesky", + "xing", + ] + ] + | None, + Field( + description="Filter by social platform presence. Valid values: facebook, instagram, linkedin, pinterest, threads, tiktok, twitter, x, yelp, youtube, googleplay, applestore, amazon, vk, bluesky, xing. Note: 'twitter' is an alias for 'x'.", + title="Social", + ), + ] = None + negate_social: Annotated[ + list[ + Literal[ + "facebook", + "instagram", + "linkedin", + "pinterest", + "threads", + "tiktok", + "twitter", + "x", + "yelp", + "youtube", + "googleplay", + "applestore", + "amazon", + "vk", + "bluesky", + "xing", + ] + ] + | None, + Field( + description="Exclude companies with specified social profiles. Filter by social platform presence. Valid values: facebook, instagram, linkedin, pinterest, threads, tiktok, twitter, x, yelp, youtube, googleplay, applestore, amazon, vk, bluesky, xing. Note: 'twitter' is an alias for 'x'.", + title="Negate Social", + ), + ] = None + language: Annotated[ + list[ + Literal[ + "ar", + "az", + "bg", + "bn", + "bs", + "ca", + "cs", + "da", + "de", + "el", + "en", + "eo", + "es", + "et", + "eu", + "fa", + "fi", + "fr", + "ga", + "gl", + "he", + "hi", + "hr", + "hu", + "id", + "it", + "ja", + "ko", + "ky", + "lt", + "lv", + "mk", + "ms", + "my", + "nb", + "nl", + "pb", + "pl", + "pt", + "ro", + "ru", + "sk", + "sl", + "sq", + "sr", + "sv", + "sw", + "th", + "tl", + "tr", + "uk", + "ur", + "vi", + "zh", + "zt", + ] + ] + | None, + Field( + description="Filter by site language. Valid values: ar (Arabic), az (Azerbaijani), bg (Bulgarian), bn (Bengali), bs (Bosnian), ca (Catalan), cs (Czech), da (Danish), de (German), el (Greek), en (English), eo (Esperanto), es (Spanish), et (Estonian), eu (Basque), fa (Persian), fi (Finnish), fr (French), ga (Irish), gl (Galician), he (Hebrew), hi (Hindi), hr (Croatian), hu (Hungarian), id (Indonesian), it (Italian), ja (Japanese), ko (Korean), ky (Kyrgyz), lt (Lithuanian), lv (Latvian), mk (Macedonian), ms (Malay), my (Burmese), nb (Norwegian Bokmål), nl (Dutch), pb (Portuguese (Brazilian)), pl (Polish), pt (Portuguese), ro (Romanian), ru (Russian), sk (Slovak), sl (Slovenian), sq (Albanian), sr (Serbian), sv (Swedish), sw (Swahili), th (Thai), tl (Tagalog), tr (Turkish), uk (Ukrainian), ur (Urdu), vi (Vietnamese), zh (Chinese (Simplified)), zt (Chinese (Traditional)).", + title="Language", + ), + ] = None + negate_language: Annotated[ + list[ + Literal[ + "ar", + "az", + "bg", + "bn", + "bs", + "ca", + "cs", + "da", + "de", + "el", + "en", + "eo", + "es", + "et", + "eu", + "fa", + "fi", + "fr", + "ga", + "gl", + "he", + "hi", + "hr", + "hu", + "id", + "it", + "ja", + "ko", + "ky", + "lt", + "lv", + "mk", + "ms", + "my", + "nb", + "nl", + "pb", + "pl", + "pt", + "ro", + "ru", + "sk", + "sl", + "sq", + "sr", + "sv", + "sw", + "th", + "tl", + "tr", + "uk", + "ur", + "vi", + "zh", + "zt", + ] + ] + | None, + Field( + description="Exclude specified languages from results. Filter by site language. Valid values: ar (Arabic), az (Azerbaijani), bg (Bulgarian), bn (Bengali), bs (Bosnian), ca (Catalan), cs (Czech), da (Danish), de (German), el (Greek), en (English), eo (Esperanto), es (Spanish), et (Estonian), eu (Basque), fa (Persian), fi (Finnish), fr (French), ga (Irish), gl (Galician), he (Hebrew), hi (Hindi), hr (Croatian), hu (Hungarian), id (Indonesian), it (Italian), ja (Japanese), ko (Korean), ky (Kyrgyz), lt (Lithuanian), lv (Latvian), mk (Macedonian), ms (Malay), my (Burmese), nb (Norwegian Bokmål), nl (Dutch), pb (Portuguese (Brazilian)), pl (Polish), pt (Portuguese), ro (Romanian), ru (Russian), sk (Slovak), sl (Slovenian), sq (Albanian), sr (Serbian), sv (Swedish), sw (Swahili), th (Thai), tl (Tagalog), tr (Turkish), uk (Ukrainian), ur (Urdu), vi (Vietnamese), zh (Chinese (Simplified)), zt (Chinese (Traditional)).", + title="Negate Language", + ), + ] = None + employee_range: Annotated[ + str | None, + Field( + description="Filter by employee count range. Format: 'min,max' (e.g., '1,5000'). Maps to buckets: 1-10, 11-50, 51-200, 201-500, 501-1000, 1001-5000, 5001-10000, 10001+.", + title="Employee Range", + ), + ] = None + revenue_range: Annotated[ + str | None, + Field( + description="Filter by company revenue. Format: 'min,max' in raw numbers (e.g., '1000000,10000000' for 1M-10M). Maps to buckets: <1M, 1-10M, 10-100M, 100M-1B, >1B. Unknown-revenue domains (N/A) are included when the range covers the <1M bucket.", + title="Revenue Range", + ), + ] = None + business_model: Annotated[ + list[Literal["B2B", "B2C", "B2G", "G2B", "G2C", "D2C", "C2C", "C2B"]] | None, + Field( + description="Filter by business model label. Multi-select with OR semantics. Valid values: B2B, B2C, B2G, G2B, G2C, D2C, C2C, C2B.", + title="Business Model", + ), + ] = None + negate_business_model: Annotated[ + list[Literal["B2B", "B2C", "B2G", "G2B", "G2C", "D2C", "C2C", "C2B"]] | None, + Field( + description="Exclude domains with these business model labels. Filter by business model label. Multi-select with OR semantics. Valid values: B2B, B2C, B2G, G2B, G2C, D2C, C2C, C2B.", + title="Negate Business Model", + ), + ] = None + exclude_leadgen: Annotated[ + bool | None, + Field( + description="Exclude suspected lead generation sites. Filters out profiles with score <= 25 that also lack phone, email, and social media presence.", + title="Exclude Leadgen", + ), + ] = True + + +class FindEmailRequest(DiscolikeRequest): + first_name: Annotated[str, Field(description="First name of the person", title="First Name")] + last_name: Annotated[str, Field(description="Last name of the person", title="Last Name")] + domain: Annotated[str, Field(description="Target email domain", title="Domain")] + known_pattern: Annotated[ + str | None, + Field(description="Known email pattern for this domain", title="Known Pattern"), + ] = None + + +class FindEmailBatchRequest(DiscolikeRequest): + requests: Annotated[ + list[FindEmailRequest], + Field( + description="Find-email requests to process", + max_length=500, + min_length=1, + title="Requests", + ), + ] + + +class AppendParams(DiscolikeRequest): + domain_column: Annotated[ + str | None, + Field( + description="Column name containing domains (auto-detected if not provided).", + title="Domain Column", + ), + ] = "domain" + csv: Annotated[ + bool | None, + Field(description="Return results as CSV instead of JSON.", title="Csv"), + ] = False + dataset: Annotated[ + list[Literal["bizdata", "redirects", "domain_status", "growth", "vendors"]], + Field(description="Datasets to append.", min_length=1, title="Dataset"), + ] + query_id: Annotated[ + list[str] | None, + Field( + description="Append domains from these saved queries, unioned with any file-derived domains.", + title="Query Id", + ), + ] = None + + +class SegmentParams(DiscolikeRequest): + domains: Annotated[ + str | None, + Field( + description="Comma-separated list of domains to segment into topic clusters", + title="Domains", + ), + ] = "" + query_id: Annotated[ + list[str] | None, + Field( + description="Include domains from these saved queries in the segmentation.", + title="Query Id", + ), + ] = None + max_segments: Annotated[ + int | None, + Field( + description="Maximum number of segments to create (2-20)", + ge=2, + le=20, + title="Max Segments", + ), + ] = None + + +class SegmentFileParams(DiscolikeRequest): + domain_column: Annotated[ + str | None, + Field( + description="Column name containing domains (auto-detected if not provided).", + title="Domain Column", + ), + ] = "domain" + max_segments: Annotated[ + int | None, + Field( + description="Maximum number of segments to create (2-100).", + ge=2, + le=100, + title="Max Segments", + ), + ] = None + query_id: Annotated[ + list[str] | None, + Field( + description="Include domains from these saved queries in the segmentation.", + title="Query Id", + ), + ] = None + + +class MatchCompanyParams(DiscolikeRequest): + name: Annotated[str, Field(description="The company name to match", title="Name")] + phone: Annotated[ + str | None, + Field( + description="Phone number to augment the search (E.164 or local format)", + title="Phone", + ), + ] = None + city: Annotated[str | None, Field(description="City to augment the search", title="City")] = None + state: Annotated[str | None, Field(description="State code to augment the search", title="State")] = None + country: Annotated[ + str | None, + Field( + description="ISO-3166-1 alpha-2 country code to augment the search", + title="Country", + ), + ] = None + zip_code: Annotated[ + str | None, + Field(description="Zip code to augment the search", title="Zip Code"), + ] = None + strict: Annotated[ + bool | None, + Field(description="Enable strict matching (no filter relaxation)", title="Strict"), + ] = False + local_mode: Annotated[ + bool | None, + Field( + description="Preserve location filters during relaxation.", + title="Local Mode", + ), + ] = False + min_match_confidence: Annotated[ + int | None, + Field( + description="Minimum match_confidence (inclusive, 50-100) a match must have to be returned. Defaults to 50, the standard quality floor.", + ge=50, + le=100, + title="Min Match Confidence", + ), + ] = 50 + + +class MatchBulkParams(DiscolikeRequest): + name_column: Annotated[ + str, + Field(description="Column name containing company names.", title="Name Column"), + ] + phone_column: Annotated[ + str | None, + Field(description="Column name containing phone numbers.", title="Phone Column"), + ] = None + city_column: Annotated[ + str | None, + Field(description="Column name containing cities.", title="City Column"), + ] = None + state_column: Annotated[ + str | None, + Field(description="Column name containing states.", title="State Column"), + ] = None + country_column: Annotated[ + str | None, + Field(description="Column name containing country codes.", title="Country Column"), + ] = None + zip_code_column: Annotated[ + str | None, + Field(description="Column name containing zip codes.", title="Zip Code Column"), + ] = None + strict: Annotated[bool | None, Field(description="Enable strict matching.", title="Strict")] = False + local_mode: Annotated[bool | None, Field(description="Preserve location filters.", title="Local Mode")] = False + min_match_confidence: Annotated[ + int | None, + Field( + description="Minimum match_confidence (inclusive, 50-100) a match must have to be returned. Defaults to 50, the standard quality floor.", + ge=50, + le=100, + title="Min Match Confidence", + ), + ] = 50 + + +class LLMProviderCreateRequest(DiscolikeRequest): + integration_name: Annotated[ + str, + Field( + description="User-friendly name for this integration", + title="Integration Name", + ), + ] + provider: Annotated[ + str, + Field( + description="LLM provider name (openai, anthropic, custom)", + title="Provider", + ), + ] + api_key: Annotated[str, Field(description="API key for the provider", title="Api Key")] + model_name: Annotated[ + str, + Field(description="Default model name for this integration", title="Model Name"), + ] + base_url: Annotated[ + str | None, + Field( + description="Custom endpoint URL (required for 'custom' provider, null for cloud)", + title="Base Url", + ), + ] = None + + +class LLMProviderUpdateRequest(DiscolikeRequest): + integration_name: Annotated[ + str, + Field( + description="User-friendly name for this integration", + title="Integration Name", + ), + ] + provider: Annotated[ + str, + Field( + description="LLM provider name (openai, anthropic, custom)", + title="Provider", + ), + ] + api_key: Annotated[ + str | None, + Field( + description="API key for the provider (null to keep existing)", + title="Api Key", + ), + ] + model_name: Annotated[ + str, + Field(description="Default model name for this integration", title="Model Name"), + ] + base_url: Annotated[ + str | None, + Field( + description="Custom endpoint URL (required for 'custom' provider, null for cloud)", + title="Base Url", + ), + ] = None + + +class SearchProviderRequest(DiscolikeRequest): + integration_name: Annotated[str, Field(description="User-friendly name", title="Integration Name")] + provider: Annotated[ + str, + Field(description="LiteLLM provider key (tavily, serper, etc.)", title="Provider"), + ] + search_model: Annotated[ + str, + Field( + description="LiteLLM model key for cost lookup (tavily/search, etc.)", + title="Search Model", + ), + ] + api_key: Annotated[ + str | None, + Field(description="Plaintext API key (null to keep unchanged)", title="Api Key"), + ] = None + base_url: Annotated[ + str | None, + Field(description="Custom endpoint URL for LiteLLM Proxy", title="Base Url"), + ] = None + + +class QueriesListParams(DiscolikeRequest): + max_records: Annotated[int | None, Field(ge=1, le=1000, title="Max Records")] = 100 + offset: Annotated[int | None, Field(ge=0, title="Offset")] = 0 + action: Annotated[ + str | None, + Field( + description="Filter by action type (e.g. discover, exclusion)", + title="Action", + ), + ] = None + tags: Annotated[ + list[str] | None, + Field( + description="Filter by tags (matches any). Repeated param or comma-separated.", + title="Tags", + ), + ] = None + + +class CreateExclusionListRequest(DiscolikeRequest): + query_name: Annotated[ + str, + Field( + description="Name for the exclusion list", + max_length=255, + min_length=1, + title="Query Name", + ), + ] + domains: Annotated[ + list[str] | None, + Field(description="List of domains for the exclusion list", title="Domains"), + ] = None + persona_ids: Annotated[ + list[int] | None, + Field( + description="List of persona IDs for the exclusion list", + title="Persona Ids", + ), + ] = None + tags: Annotated[ + list[str] | None, + Field( + description="Tags to apply to the exclusion list (max 20 tags, each 2-50 chars, alphanumeric/hyphen/underscore)", + max_length=20, + title="Tags", + ), + ] = None + + +class SaveResultsRequest(DiscolikeRequest): + query_name: Annotated[ + str, + Field( + description="Name for the query", + max_length=255, + min_length=1, + title="Query Name", + ), + ] + action: Annotated[ + Literal["discover", "segment", "contacts", "append", "match"], + Field( + description="Underlying action type. One of: ['discover', 'segment', 'contacts', 'append', 'match']", + title="Action", + ), + ] + query_params: Annotated[ + dict[str, Any] | None, + Field( + description="Original query params for UI reconstruction", + title="Query Params", + ), + ] = None + data: Annotated[ + list[dict[str, Any]], + Field(description="Thin rows: domain + custom columns", min_length=1, title="Data"), + ] + domain_column: Annotated[ + str | None, + Field( + description="Column name containing domains", + max_length=128, + title="Domain Column", + ), + ] = "domain" + tags: Annotated[ + list[str] | None, + Field(description="Initial tags to apply", max_length=20, title="Tags"), + ] = None + + +class UpdateQueryRequest(DiscolikeRequest): + query_name: Annotated[ + str | None, + Field(description="New name for the query", max_length=255, title="Query Name"), + ] = None + tags: Annotated[ + list[str] | None, + Field(description="Complete list of tags to set", max_length=20, title="Tags"), + ] = None + + +class BulkContactMatchQueryItem(DiscolikeRequest): + name: Annotated[ + str | None, + Field( + description="Person name to search for. Required unless email is provided.", + title="Name", + ), + ] = None + email: Annotated[ + str | None, + Field( + description="Email address for exact lookup. When found, name/company matching is skipped for this row.", + title="Email", + ), + ] = None + company_name: Annotated[ + str | None, + Field(description="Company name to narrow the search.", title="Company Name"), + ] = None + domain: Annotated[str | None, Field(description="Domain to filter results.", title="Domain")] = None + person_country: Annotated[ + str | None, + Field( + description="Person's country code (ISO-3166-1 alpha-2).", + title="Person Country", + ), + ] = None + + +class BulkContactMatchRequest(DiscolikeRequest): + queries: Annotated[ + list[BulkContactMatchQueryItem], + Field( + description="List of contact match queries to process.", + max_length=10000, + min_length=1, + title="Queries", + ), + ] + enrich: Annotated[ + bool | None, + Field( + description="When true, hydrate full contact data from the vector database (costs credits).", + title="Enrich", + ), + ] = False + limit: Annotated[ + int | None, + Field(description="Maximum matches per query (1-20).", ge=1, le=20, title="Limit"), + ] = 10 diff --git a/packages/discolike/src/discolike/_jobs.py b/packages/discolike/src/discolike/_jobs.py index 81c2e1a..83d3c35 100644 --- a/packages/discolike/src/discolike/_jobs.py +++ b/packages/discolike/src/discolike/_jobs.py @@ -28,6 +28,14 @@ class JobStatus(DiscolikeModel): results: Any = None result: Any = None warnings: list[str] = pydantic.Field(default_factory=list) + # DiscoGen-family only: spend on the caller's own provider keys, best-effort. + # cost_metadata holds one entry per "provider/model" (calls, search_calls, + # prompt_tokens, completion_tokens, est_cost_usd) and a "search_provider" + # entry (provider, search_model, queries_executed, queries_succeeded, + # est_cost_usd) when a BYOS search provider ran. search_calls counts the + # 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 class Job: diff --git a/packages/discolike/src/discolike/_models.py b/packages/discolike/src/discolike/_models.py index 6734ee2..d400be6 100644 --- a/packages/discolike/src/discolike/_models.py +++ b/packages/discolike/src/discolike/_models.py @@ -10,3 +10,10 @@ class DiscolikeModel(pydantic.BaseModel): def to_dict(self) -> dict[str, Any]: return self.model_dump(mode="json") + + +class DiscolikeRequest(pydantic.BaseModel): + model_config = pydantic.ConfigDict(extra="allow", populate_by_name=True) + + def to_wire(self) -> dict[str, Any]: + return self.model_dump(mode="json", exclude_unset=True, by_alias=True) diff --git a/packages/discolike/src/discolike/_oauth.py b/packages/discolike/src/discolike/_oauth.py new file mode 100644 index 0000000..fb295b2 --- /dev/null +++ b/packages/discolike/src/discolike/_oauth.py @@ -0,0 +1,196 @@ +from __future__ import annotations + +import base64 +import hashlib +import secrets +import time +from dataclasses import dataclass +from typing import Any +from urllib.parse import urlencode + +import httpx2 + +from discolike._credentials import OAuthCredential +from discolike._exceptions import AuthenticationError + +OAUTH_SCOPE = "offline_access" +REFRESH_LEEWAY_SECONDS = 60.0 +CLIENT_NAME = "discolike-cli" +METADATA_PATH = "/.well-known/oauth-authorization-server" +GRANT_TYPES = ["authorization_code", "refresh_token"] +RESPONSE_TYPES = ["code"] +PKCE_METHOD = "S256" +PKCE_VERIFIER_BYTES = 32 +TOKEN_HEADERS = {"Accept": "application/json"} +TOKEN_KEYS = frozenset({"access_token", "refresh_token", "id_token"}) +SESSION_EXPIRED_MESSAGE = "OAuth session expired; run `discolike auth login`" + + +class OAuthError(AuthenticationError): + """An RFC 6749 error body from the authorization server; `error` is its machine-readable code.""" + + def __init__( + self, + message: str, + *, + error: str, + status_code: int | None = None, + payload: Any = None, # noqa: ANN401 -- decoded JSON body, shape is server-defined + ) -> None: + super().__init__(message, status_code=status_code, payload=payload) + self.error = error + + +@dataclass(frozen=True) +class AuthServerMetadata: + authorization_endpoint: str + token_endpoint: str + registration_endpoint: str + issuer: str + + +def _b64url(raw: bytes) -> str: + return base64.urlsafe_b64encode(raw).rstrip(b"=").decode("ascii") + + +def pkce_pair() -> tuple[str, str]: + verifier = _b64url(secrets.token_bytes(PKCE_VERIFIER_BYTES)) + return verifier, _b64url(hashlib.sha256(verifier.encode("ascii")).digest()) + + +def _payload(response: httpx2.Response) -> dict[str, Any]: + try: + payload = response.json() + except ValueError as exc: + raise AuthenticationError( + f"OAuth server returned a non-JSON response (HTTP {response.status_code})", status_code=response.status_code + ) from exc + if isinstance(payload, dict) and "error" in payload: + description = payload.get("error_description") + error = str(payload["error"]) + message = f"{error}: {description}" if description else error + raise OAuthError(message, error=error, status_code=response.status_code, payload=payload) + if response.status_code >= 400 or not isinstance(payload, dict): + raise AuthenticationError( + f"OAuth server returned HTTP {response.status_code}", status_code=response.status_code, payload=payload + ) + return payload + + +def _require(payload: dict[str, Any], key: str) -> str: + if key not in payload: + raise AuthenticationError(f"OAuth server response is missing `{key}`", payload=payload) + return str(payload[key]) + + +def _redacted(payload: dict[str, Any]) -> dict[str, Any]: + return {key: value for key, value in payload.items() if key not in TOKEN_KEYS} + + +def _credential_from_token_payload( + payload: dict[str, Any], *, client_id: str, token_endpoint: str, fallback_refresh_token: str | None +) -> OAuthCredential: + # Exceptions raised here may be logged by SDK consumers; never attach live tokens to them. + safe_payload = _redacted(payload) + refresh_token = payload.get("refresh_token") or fallback_refresh_token + if not refresh_token: + raise AuthenticationError("OAuth token response has no `refresh_token`", payload=safe_payload) + for key in ("access_token", "expires_in"): + if key not in payload: + raise AuthenticationError(f"OAuth server response is missing `{key}`", payload=safe_payload) + return OAuthCredential( + access_token=str(payload["access_token"]), + refresh_token=str(refresh_token), + expires_at=time.time() + float(payload["expires_in"]), + client_id=client_id, + token_endpoint=token_endpoint, + ) + + +def discover(base_url: str, *, client: httpx2.Client) -> AuthServerMetadata: + payload = _payload(client.get(base_url.rstrip("/") + METADATA_PATH)) + return AuthServerMetadata( + authorization_endpoint=_require(payload, "authorization_endpoint"), + token_endpoint=_require(payload, "token_endpoint"), + registration_endpoint=_require(payload, "registration_endpoint"), + issuer=_require(payload, "issuer"), + ) + + +def register_client(metadata: AuthServerMetadata, *, redirect_uris: list[str], client: httpx2.Client) -> str: + body = { + "client_name": CLIENT_NAME, + "redirect_uris": redirect_uris, + "grant_types": GRANT_TYPES, + "response_types": RESPONSE_TYPES, + "token_endpoint_auth_method": "none", + } + return _require(_payload(client.post(metadata.registration_endpoint, json=body)), "client_id") + + +def build_authorization_url( + metadata: AuthServerMetadata, + *, + client_id: str, + redirect_uri: str, + code_challenge: str, + state: str, + resource: str, + scope: str = OAUTH_SCOPE, +) -> str: + query = urlencode( + { + "response_type": "code", + "client_id": client_id, + "redirect_uri": redirect_uri, + "code_challenge": code_challenge, + "code_challenge_method": PKCE_METHOD, + "state": state, + "resource": resource, + "scope": scope, + } + ) + separator = "&" if "?" in metadata.authorization_endpoint else "?" + return f"{metadata.authorization_endpoint}{separator}{query}" + + +def exchange_code( + metadata: AuthServerMetadata, + *, + client_id: str, + code: str, + code_verifier: str, + redirect_uri: str, + resource: str, + client: httpx2.Client, +) -> OAuthCredential: + form = { + "grant_type": "authorization_code", + "client_id": client_id, + "code": code, + "code_verifier": code_verifier, + "redirect_uri": redirect_uri, + "resource": resource, + } + response = client.post(metadata.token_endpoint, data=form, headers=TOKEN_HEADERS) + return _credential_from_token_payload( + _payload(response), client_id=client_id, token_endpoint=metadata.token_endpoint, fallback_refresh_token=None + ) + + +def refresh_request(credential: OAuthCredential) -> httpx2.Request: + form = { + "grant_type": "refresh_token", + "refresh_token": credential.refresh_token, + "client_id": credential.client_id, + } + return httpx2.Request("POST", credential.token_endpoint, data=form, headers=TOKEN_HEADERS) + + +def parse_refresh_response(response: httpx2.Response, *, credential: OAuthCredential) -> OAuthCredential: + return _credential_from_token_payload( + _payload(response), + client_id=credential.client_id, + token_endpoint=credential.token_endpoint, + fallback_refresh_token=credential.refresh_token, + ) diff --git a/packages/discolike/src/discolike/_transport.py b/packages/discolike/src/discolike/_transport.py index 95296cd..0b54f57 100644 --- a/packages/discolike/src/discolike/_transport.py +++ b/packages/discolike/src/discolike/_transport.py @@ -22,8 +22,8 @@ def drop_none(params: Mapping[str, Any] | None) -> dict[str, Any]: return {key: value for key, value in (params or {}).items() if value is not None} -def _default_headers(api_key: str) -> dict[str, str]: - return {"X-discolike-key": api_key, "User-Agent": f"discolike-python/{__version__}"} +def _default_headers() -> dict[str, str]: + return {"User-Agent": f"discolike-python/{__version__}"} def _retryable_statuses(method: str) -> frozenset[int]: @@ -45,7 +45,7 @@ def _retry_delay(response: httpx2.Response | None, attempt: int) -> float: class Transport: def __init__( self, - api_key: str, + auth: httpx2.Auth, *, base_url: str, timeout: float, @@ -55,7 +55,8 @@ def __init__( if http_client is not None and not str(http_client.base_url): http_client.base_url = base_url self._client = http_client or httpx2.Client(base_url=base_url, timeout=timeout) - self._client.headers.update(_default_headers(api_key)) + self._client.auth = auth + self._client.headers.update(_default_headers()) self._max_retries = max_retries self._timeout_override: float | httpx2.Timeout | None = None self._is_view = False @@ -109,7 +110,7 @@ def close(self) -> None: class AsyncTransport: def __init__( self, - api_key: str, + auth: httpx2.Auth, *, base_url: str, timeout: float, @@ -119,7 +120,8 @@ def __init__( if http_client is not None and not str(http_client.base_url): http_client.base_url = base_url self._client = http_client or httpx2.AsyncClient(base_url=base_url, timeout=timeout) - self._client.headers.update(_default_headers(api_key)) + self._client.auth = auth + self._client.headers.update(_default_headers()) self._max_retries = max_retries self._timeout_override: float | httpx2.Timeout | None = None self._is_view = False diff --git a/packages/discolike/src/discolike/_version.py b/packages/discolike/src/discolike/_version.py index d3ec452..493f741 100644 --- a/packages/discolike/src/discolike/_version.py +++ b/packages/discolike/src/discolike/_version.py @@ -1 +1 @@ -__version__ = "0.2.0" +__version__ = "0.3.0" diff --git a/packages/discolike/src/discolike/requests.py b/packages/discolike/src/discolike/requests.py new file mode 100644 index 0000000..9cdb359 --- /dev/null +++ b/packages/discolike/src/discolike/requests.py @@ -0,0 +1,75 @@ +"""Request models generated from the platform OpenAPI spec by scripts/gen_requests.py.""" + +from discolike._generated.requests import AppendParams +from discolike._generated.requests import BulkContactMatchQueryItem +from discolike._generated.requests import BulkContactMatchRequest +from discolike._generated.requests import CompaniesDataParams +from discolike._generated.requests import CompaniesExtractParams +from discolike._generated.requests import CompaniesGrowthParams +from discolike._generated.requests import CompaniesPublicLinksParams +from discolike._generated.requests import CompaniesRedirectsParams +from discolike._generated.requests import CompaniesScoreParams +from discolike._generated.requests import CompaniesSubsidiariesParams +from discolike._generated.requests import CompaniesVendorsParams +from discolike._generated.requests import ContactFilters +from discolike._generated.requests import ContactGenerateRequest +from discolike._generated.requests import ContactsCountParams +from discolike._generated.requests import ContactsLookupParams +from discolike._generated.requests import ContactsMatchParams +from discolike._generated.requests import ContactsSearchParams +from discolike._generated.requests import CountParams +from discolike._generated.requests import CreateExclusionListRequest +from discolike._generated.requests import DiscoGenPersonaProcessRequest +from discolike._generated.requests import DiscoGenProcessRequest +from discolike._generated.requests import DiscoverParams +from discolike._generated.requests import FindEmailBatchRequest +from discolike._generated.requests import FindEmailRequest +from discolike._generated.requests import LLMProviderCreateRequest +from discolike._generated.requests import LLMProviderUpdateRequest +from discolike._generated.requests import MatchBulkParams +from discolike._generated.requests import MatchCompanyParams +from discolike._generated.requests import QueriesListParams +from discolike._generated.requests import SaveResultsRequest +from discolike._generated.requests import SearchProviderRequest +from discolike._generated.requests import SegmentFileParams +from discolike._generated.requests import SegmentParams +from discolike._generated.requests import UpdateQueryRequest +from discolike._generated.requests import ValidateIcpRequest + +__all__ = [ + "AppendParams", + "BulkContactMatchQueryItem", + "BulkContactMatchRequest", + "CompaniesDataParams", + "CompaniesExtractParams", + "CompaniesGrowthParams", + "CompaniesPublicLinksParams", + "CompaniesRedirectsParams", + "CompaniesScoreParams", + "CompaniesSubsidiariesParams", + "CompaniesVendorsParams", + "ContactFilters", + "ContactGenerateRequest", + "ContactsCountParams", + "ContactsLookupParams", + "ContactsMatchParams", + "ContactsSearchParams", + "CountParams", + "CreateExclusionListRequest", + "DiscoGenPersonaProcessRequest", + "DiscoGenProcessRequest", + "DiscoverParams", + "FindEmailBatchRequest", + "FindEmailRequest", + "LLMProviderCreateRequest", + "LLMProviderUpdateRequest", + "MatchBulkParams", + "MatchCompanyParams", + "QueriesListParams", + "SaveResultsRequest", + "SearchProviderRequest", + "SegmentFileParams", + "SegmentParams", + "UpdateQueryRequest", + "ValidateIcpRequest", +] diff --git a/packages/discolike/src/discolike/resources/_base.py b/packages/discolike/src/discolike/resources/_base.py index 7f677da..edc0945 100644 --- a/packages/discolike/src/discolike/resources/_base.py +++ b/packages/discolike/src/discolike/resources/_base.py @@ -10,17 +10,18 @@ from discolike._transport import Transport F = TypeVar("F", bound=Callable[..., Any]) +FileInput = pathlib.Path | str | BinaryIO -def api_route(method: str, path: str, *, openapi: bool = True, ignore_params: tuple[str, ...] = ()) -> Callable[[F], F]: +def api_route(method: str, path: str, *, openapi: bool = True) -> Callable[[F], F]: def stamp(fn: F) -> F: - fn.__discolike_route__ = (method, path, openapi, ignore_params) # ty: ignore[unresolved-attribute] + fn.__discolike_route__ = (method, path, openapi) # ty: ignore[unresolved-attribute] return fn return stamp -def get_discolike_route(fn: object) -> tuple[str, str, bool, tuple[str, ...]] | None: +def get_discolike_route(fn: object) -> tuple[str, str, bool] | None: return getattr(fn, "__discolike_route__", None) @@ -34,7 +35,7 @@ def __init__(self, transport: AsyncTransport) -> None: self._transport = transport -def open_upload(file: pathlib.Path | str | BinaryIO) -> tuple[str, BinaryIO, bool]: +def open_upload(file: FileInput) -> tuple[str, BinaryIO, bool]: if isinstance(file, (str, pathlib.Path)): path = pathlib.Path(file) return path.name, open(path, "rb"), True # foxguard: ignore[py/no-path-traversal] diff --git a/packages/discolike/src/discolike/resources/companies.py b/packages/discolike/src/discolike/resources/companies.py index 834e123..5d62bf0 100644 --- a/packages/discolike/src/discolike/resources/companies.py +++ b/packages/discolike/src/discolike/resources/companies.py @@ -3,6 +3,14 @@ import pydantic from discolike._models import DiscolikeModel +from discolike.requests import CompaniesDataParams +from discolike.requests import CompaniesExtractParams +from discolike.requests import CompaniesGrowthParams +from discolike.requests import CompaniesPublicLinksParams +from discolike.requests import CompaniesRedirectsParams +from discolike.requests import CompaniesScoreParams +from discolike.requests import CompaniesSubsidiariesParams +from discolike.requests import CompaniesVendorsParams from discolike.resources._base import AsyncAPIResource from discolike.resources._base import SyncAPIResource from discolike.resources._base import api_route @@ -114,91 +122,79 @@ class PublicLink(DiscolikeModel): class CompaniesResource(SyncAPIResource): @api_route("GET", "/bizdata") - def data(self, *, domain: str) -> BizData: - params = {k: v for k, v in locals().items() if k != "self"} - return BizData.model_validate(self._transport.request("GET", "/bizdata", params=params).json()) + def data(self, params: CompaniesDataParams) -> BizData: + return BizData.model_validate(self._transport.request("GET", "/bizdata", params=params.to_wire()).json()) @api_route("GET", "/score") - def score(self, *, domain: str) -> Score: - params = {k: v for k, v in locals().items() if k != "self"} - return Score.model_validate(self._transport.request("GET", "/score", params=params).json()) + def score(self, params: CompaniesScoreParams) -> Score: + return Score.model_validate(self._transport.request("GET", "/score", params=params.to_wire()).json()) @api_route("GET", "/growth") - def growth(self, *, domain: str) -> Growth: - params = {k: v for k, v in locals().items() if k != "self"} - return Growth.model_validate(self._transport.request("GET", "/growth", params=params).json()) + def growth(self, params: CompaniesGrowthParams) -> Growth: + return Growth.model_validate(self._transport.request("GET", "/growth", params=params.to_wire()).json()) @api_route("GET", "/extract") - def extract(self, *, url: str | None = None, domain: str | None = None) -> ExtractResult: - params = {k: v for k, v in locals().items() if k != "self"} - return ExtractResult.model_validate(self._transport.request("GET", "/extract", params=params).json()) + def extract(self, params: CompaniesExtractParams) -> ExtractResult: + return ExtractResult.model_validate(self._transport.request("GET", "/extract", params=params.to_wire()).json()) @api_route("GET", "/redirects") - def redirects(self, *, domain: str, match: str | None = None) -> list[Redirect]: - params = {k: v for k, v in locals().items() if k != "self"} - rows = self._transport.request("GET", "/redirects", params=params).json() + def redirects(self, params: CompaniesRedirectsParams) -> list[Redirect]: + rows = self._transport.request("GET", "/redirects", params=params.to_wire()).json() return [Redirect.model_validate(row) for row in rows] @api_route("GET", "/vendors") - def vendors(self, *, domain: str, match: str | None = None) -> list[Vendor]: - params = {k: v for k, v in locals().items() if k != "self"} - rows = self._transport.request("GET", "/vendors", params=params).json() + def vendors(self, params: CompaniesVendorsParams) -> list[Vendor]: + rows = self._transport.request("GET", "/vendors", params=params.to_wire()).json() return [Vendor.model_validate(row) for row in rows] @api_route("GET", "/subsidiaries") - def subsidiaries(self, *, domain: str, match: str | None = None) -> list[Subsidiary]: - params = {k: v for k, v in locals().items() if k != "self"} - rows = self._transport.request("GET", "/subsidiaries", params=params).json() + def subsidiaries(self, params: CompaniesSubsidiariesParams) -> list[Subsidiary]: + rows = self._transport.request("GET", "/subsidiaries", params=params.to_wire()).json() return [Subsidiary.model_validate(row) for row in rows] @api_route("GET", "/publiclink") - def public_links(self, *, domain: str, source: str) -> list[PublicLink]: - params = {k: v for k, v in locals().items() if k != "self"} - rows = self._transport.request("GET", "/publiclink", params=params).json() + def public_links(self, params: CompaniesPublicLinksParams) -> list[PublicLink]: + rows = self._transport.request("GET", "/publiclink", params=params.to_wire()).json() return [PublicLink.model_validate(row) for row in rows] class AsyncCompaniesResource(AsyncAPIResource): @api_route("GET", "/bizdata") - async def data(self, *, domain: str) -> BizData: - params = {k: v for k, v in locals().items() if k != "self"} - return BizData.model_validate((await self._transport.request("GET", "/bizdata", params=params)).json()) + async def data(self, params: CompaniesDataParams) -> BizData: + response = await self._transport.request("GET", "/bizdata", params=params.to_wire()) + return BizData.model_validate(response.json()) @api_route("GET", "/score") - async def score(self, *, domain: str) -> Score: - params = {k: v for k, v in locals().items() if k != "self"} - return Score.model_validate((await self._transport.request("GET", "/score", params=params)).json()) + async def score(self, params: CompaniesScoreParams) -> Score: + response = await self._transport.request("GET", "/score", params=params.to_wire()) + return Score.model_validate(response.json()) @api_route("GET", "/growth") - async def growth(self, *, domain: str) -> Growth: - params = {k: v for k, v in locals().items() if k != "self"} - return Growth.model_validate((await self._transport.request("GET", "/growth", params=params)).json()) + async def growth(self, params: CompaniesGrowthParams) -> Growth: + response = await self._transport.request("GET", "/growth", params=params.to_wire()) + return Growth.model_validate(response.json()) @api_route("GET", "/extract") - async def extract(self, *, url: str | None = None, domain: str | None = None) -> ExtractResult: - params = {k: v for k, v in locals().items() if k != "self"} - return ExtractResult.model_validate((await self._transport.request("GET", "/extract", params=params)).json()) + async def extract(self, params: CompaniesExtractParams) -> ExtractResult: + response = await self._transport.request("GET", "/extract", params=params.to_wire()) + return ExtractResult.model_validate(response.json()) @api_route("GET", "/redirects") - async def redirects(self, *, domain: str, match: str | None = None) -> list[Redirect]: - params = {k: v for k, v in locals().items() if k != "self"} - rows = (await self._transport.request("GET", "/redirects", params=params)).json() + async def redirects(self, params: CompaniesRedirectsParams) -> list[Redirect]: + rows = (await self._transport.request("GET", "/redirects", params=params.to_wire())).json() return [Redirect.model_validate(row) for row in rows] @api_route("GET", "/vendors") - async def vendors(self, *, domain: str, match: str | None = None) -> list[Vendor]: - params = {k: v for k, v in locals().items() if k != "self"} - rows = (await self._transport.request("GET", "/vendors", params=params)).json() + async def vendors(self, params: CompaniesVendorsParams) -> list[Vendor]: + rows = (await self._transport.request("GET", "/vendors", params=params.to_wire())).json() return [Vendor.model_validate(row) for row in rows] @api_route("GET", "/subsidiaries") - async def subsidiaries(self, *, domain: str, match: str | None = None) -> list[Subsidiary]: - params = {k: v for k, v in locals().items() if k != "self"} - rows = (await self._transport.request("GET", "/subsidiaries", params=params)).json() + async def subsidiaries(self, params: CompaniesSubsidiariesParams) -> list[Subsidiary]: + rows = (await self._transport.request("GET", "/subsidiaries", params=params.to_wire())).json() return [Subsidiary.model_validate(row) for row in rows] @api_route("GET", "/publiclink") - async def public_links(self, *, domain: str, source: str) -> list[PublicLink]: - params = {k: v for k, v in locals().items() if k != "self"} - rows = (await self._transport.request("GET", "/publiclink", params=params)).json() + async def public_links(self, params: CompaniesPublicLinksParams) -> list[PublicLink]: + rows = (await self._transport.request("GET", "/publiclink", params=params.to_wire())).json() return [PublicLink.model_validate(row) for row in rows] diff --git a/packages/discolike/src/discolike/resources/contacts.py b/packages/discolike/src/discolike/resources/contacts.py index 9fb54e4..98c801d 100644 --- a/packages/discolike/src/discolike/resources/contacts.py +++ b/packages/discolike/src/discolike/resources/contacts.py @@ -1,7 +1,5 @@ from __future__ import annotations -from typing import Any - import pydantic from discolike._jobs import FAMILY_CONTACTMATCH @@ -9,7 +7,13 @@ from discolike._jobs import AsyncJob from discolike._jobs import Job from discolike._models import DiscolikeModel -from discolike._transport import drop_none +from discolike.requests import BulkContactMatchRequest +from discolike.requests import ContactFilters +from discolike.requests import ContactGenerateRequest +from discolike.requests import ContactsCountParams +from discolike.requests import ContactsLookupParams +from discolike.requests import ContactsMatchParams +from discolike.requests import ContactsSearchParams from discolike.resources._base import AsyncAPIResource from discolike.resources._base import SyncAPIResource from discolike.resources._base import api_route @@ -61,374 +65,73 @@ class ContactsDiscoverResponse(DiscolikeModel): class ContactsResource(SyncAPIResource): @api_route("GET", "/contacts") - def search( - self, - *, - icp_prompt: str | None = None, - icp_text: str | None = None, - seniority: list[str] | None = None, - negate_seniority: list[str] | None = None, - department: list[str] | None = None, - negate_department: list[str] | None = None, - skills: list[str] | None = None, - name: str | None = None, - title: list[str] | None = None, - negate_title: list[str] | None = None, - summary: str | None = None, - negate_summary: str | None = None, - person_country: list[str] | None = None, - negate_person_country: list[str] | None = None, - person_state: list[str] | None = None, - has_email: bool | None = None, - email_validated: bool | None = None, - has_phone: bool | None = None, - has_mobile: bool | None = None, - has_linkedin: bool | None = None, - min_connections: int | None = None, - jobstart_date: str | None = None, - persona_id: list[int] | None = None, - domain: list[str] | None = None, - filter_industry: list[str] | None = None, - negate_filter_industry: list[str] | None = None, - filter_country: list[str] | None = None, - negate_filter_country: list[str] | None = None, - filter_state: list[str] | None = None, - negate_filter_state: list[str] | None = None, - employee_range: str | None = None, - inclusion_query_id: list[str] | None = None, - exclusion_query_id: list[str] | None = None, - max_records: int | None = None, - max_companies: int | None = None, - offset: int | None = None, - ) -> list[Contact]: - params = {k: v for k, v in locals().items() if k != "self"} - response = self._transport.request("GET", "/contacts", params=params) + def search(self, params: ContactsSearchParams) -> list[Contact]: + response = self._transport.request("GET", "/contacts", params=params.to_wire()) return [Contact.model_validate(item) for item in response.json()] @api_route("GET", "/contacts/count") - def count( - self, - *, - icp_prompt: str | None = None, - icp_text: str | None = None, - seniority: list[str] | None = None, - negate_seniority: list[str] | None = None, - department: list[str] | None = None, - negate_department: list[str] | None = None, - skills: list[str] | None = None, - name: str | None = None, - title: list[str] | None = None, - negate_title: list[str] | None = None, - summary: str | None = None, - negate_summary: str | None = None, - person_country: list[str] | None = None, - negate_person_country: list[str] | None = None, - person_state: list[str] | None = None, - has_email: bool | None = None, - email_validated: bool | None = None, - has_phone: bool | None = None, - has_mobile: bool | None = None, - has_linkedin: bool | None = None, - min_connections: int | None = None, - jobstart_date: str | None = None, - persona_id: list[int] | None = None, - domain: list[str] | None = None, - filter_industry: list[str] | None = None, - negate_filter_industry: list[str] | None = None, - filter_country: list[str] | None = None, - negate_filter_country: list[str] | None = None, - filter_state: list[str] | None = None, - negate_filter_state: list[str] | None = None, - employee_range: str | None = None, - inclusion_query_id: list[str] | None = None, - exclusion_query_id: list[str] | None = None, - ) -> Count: - params = {k: v for k, v in locals().items() if k != "self"} - return Count.model_validate(self._transport.request("GET", "/contacts/count", params=params).json()) + def count(self, params: ContactsCountParams) -> Count: + return Count.model_validate(self._transport.request("GET", "/contacts/count", params=params.to_wire()).json()) @api_route("GET", "/contacts/lookup") - def lookup( - self, *, persona_id: int | None = None, linkedin: str | None = None, email: str | None = None - ) -> Contact: - params = {k: v for k, v in locals().items() if k != "self"} - return Contact.model_validate(self._transport.request("GET", "/contacts/lookup", params=params).json()) + def lookup(self, params: ContactsLookupParams) -> Contact: + return Contact.model_validate( + self._transport.request("GET", "/contacts/lookup", params=params.to_wire()).json() + ) @api_route("GET", "/contacts/match") - def match( - self, - *, - name: str, - company_name: str | None = None, - domain: str | None = None, - person_country: str | None = None, - limit: int | None = None, - ) -> ContactMatchResponse: - params = {k: v for k, v in locals().items() if k != "self"} - return ContactMatchResponse.model_validate( - self._transport.request("GET", "/contacts/match", params=params).json() - ) + def match(self, params: ContactsMatchParams) -> ContactMatchResponse: + response = self._transport.request("GET", "/contacts/match", params=params.to_wire()) + return ContactMatchResponse.model_validate(response.json()) @api_route("POST", "/contacts/bulk-match") - def bulk_match( - self, - *, - queries: list[dict[str, Any]], - enrich: bool | None = None, - limit: int | None = None, - ) -> Job: - body = {k: v for k, v in locals().items() if k != "self"} - response = self._transport.request("POST", "/contacts/bulk-match", json_body=drop_none(body)) + def bulk_match(self, request: BulkContactMatchRequest) -> Job: + response = self._transport.request("POST", "/contacts/bulk-match", json_body=request.to_wire()) return Job(self._transport, task_family=FAMILY_CONTACTMATCH, task_id=response.json()["task_id"]) @api_route("POST", "/contacts/discover") - def discover( - self, - *, - icp_prompt: str | None = None, - icp_text: str | None = None, - seniority: list[str] | None = None, - negate_seniority: list[str] | None = None, - department: list[str] | None = None, - negate_department: list[str] | None = None, - skills: list[str] | None = None, - name: str | None = None, - title: list[str] | None = None, - negate_title: list[str] | None = None, - summary: str | None = None, - negate_summary: str | None = None, - person_country: list[str] | None = None, - negate_person_country: list[str] | None = None, - person_state: list[str] | None = None, - has_email: bool | None = None, - email_validated: bool | None = None, - has_phone: bool | None = None, - has_mobile: bool | None = None, - has_linkedin: bool | None = None, - min_connections: int | None = None, - jobstart_date: str | None = None, - persona_id: list[int] | None = None, - domain: list[str] | None = None, - filter_industry: list[str] | None = None, - negate_filter_industry: list[str] | None = None, - filter_country: list[str] | None = None, - negate_filter_country: list[str] | None = None, - filter_state: list[str] | None = None, - negate_filter_state: list[str] | None = None, - employee_range: str | None = None, - inclusion_query_id: list[str] | None = None, - exclusion_query_id: list[str] | None = None, - max_records: int | None = None, - max_companies: int | None = None, - offset: int | None = None, - results_by_company: int | None = None, - include_search_contacts: bool | None = None, - consensus: int | None = None, - ) -> ContactsDiscoverResponse: - body = {k: v for k, v in locals().items() if k != "self"} - response = self._transport.request("POST", "/contacts/discover", json_body=drop_none(body)) + def discover(self, request: ContactFilters) -> ContactsDiscoverResponse: + response = self._transport.request("POST", "/contacts/discover", json_body=request.to_wire()) return ContactsDiscoverResponse.model_validate(response.json()) @api_route("POST", "/contacts/discover/generate") - def generate( - self, - *, - icp_text: str, - domains: list[str], - context_mode: str | None = None, - integration_id: str | None = None, - search_provider_id: str | None = None, - search_context_size: str | None = None, - max_contacts_per_domain: int | None = None, - max_company_records: int | None = None, - ) -> Job: - body = {k: v for k, v in locals().items() if k != "self"} - response = self._transport.request("POST", "/contacts/discover/generate", json_body=drop_none(body)) + def generate(self, request: ContactGenerateRequest) -> Job: + response = self._transport.request("POST", "/contacts/discover/generate", json_body=request.to_wire()) return Job(self._transport, task_family=FAMILY_DISCOGEN, task_id=response.json()["task_id"]) class AsyncContactsResource(AsyncAPIResource): @api_route("GET", "/contacts") - async def search( - self, - *, - icp_prompt: str | None = None, - icp_text: str | None = None, - seniority: list[str] | None = None, - negate_seniority: list[str] | None = None, - department: list[str] | None = None, - negate_department: list[str] | None = None, - skills: list[str] | None = None, - name: str | None = None, - title: list[str] | None = None, - negate_title: list[str] | None = None, - summary: str | None = None, - negate_summary: str | None = None, - person_country: list[str] | None = None, - negate_person_country: list[str] | None = None, - person_state: list[str] | None = None, - has_email: bool | None = None, - email_validated: bool | None = None, - has_phone: bool | None = None, - has_mobile: bool | None = None, - has_linkedin: bool | None = None, - min_connections: int | None = None, - jobstart_date: str | None = None, - persona_id: list[int] | None = None, - domain: list[str] | None = None, - filter_industry: list[str] | None = None, - negate_filter_industry: list[str] | None = None, - filter_country: list[str] | None = None, - negate_filter_country: list[str] | None = None, - filter_state: list[str] | None = None, - negate_filter_state: list[str] | None = None, - employee_range: str | None = None, - inclusion_query_id: list[str] | None = None, - exclusion_query_id: list[str] | None = None, - max_records: int | None = None, - max_companies: int | None = None, - offset: int | None = None, - ) -> list[Contact]: - params = {k: v for k, v in locals().items() if k != "self"} - response = await self._transport.request("GET", "/contacts", params=params) + async def search(self, params: ContactsSearchParams) -> list[Contact]: + response = await self._transport.request("GET", "/contacts", params=params.to_wire()) return [Contact.model_validate(item) for item in response.json()] @api_route("GET", "/contacts/count") - async def count( - self, - *, - icp_prompt: str | None = None, - icp_text: str | None = None, - seniority: list[str] | None = None, - negate_seniority: list[str] | None = None, - department: list[str] | None = None, - negate_department: list[str] | None = None, - skills: list[str] | None = None, - name: str | None = None, - title: list[str] | None = None, - negate_title: list[str] | None = None, - summary: str | None = None, - negate_summary: str | None = None, - person_country: list[str] | None = None, - negate_person_country: list[str] | None = None, - person_state: list[str] | None = None, - has_email: bool | None = None, - email_validated: bool | None = None, - has_phone: bool | None = None, - has_mobile: bool | None = None, - has_linkedin: bool | None = None, - min_connections: int | None = None, - jobstart_date: str | None = None, - persona_id: list[int] | None = None, - domain: list[str] | None = None, - filter_industry: list[str] | None = None, - negate_filter_industry: list[str] | None = None, - filter_country: list[str] | None = None, - negate_filter_country: list[str] | None = None, - filter_state: list[str] | None = None, - negate_filter_state: list[str] | None = None, - employee_range: str | None = None, - inclusion_query_id: list[str] | None = None, - exclusion_query_id: list[str] | None = None, - ) -> Count: - params = {k: v for k, v in locals().items() if k != "self"} - response = await self._transport.request("GET", "/contacts/count", params=params) + async def count(self, params: ContactsCountParams) -> Count: + response = await self._transport.request("GET", "/contacts/count", params=params.to_wire()) return Count.model_validate(response.json()) @api_route("GET", "/contacts/lookup") - async def lookup( - self, *, persona_id: int | None = None, linkedin: str | None = None, email: str | None = None - ) -> Contact: - params = {k: v for k, v in locals().items() if k != "self"} - response = await self._transport.request("GET", "/contacts/lookup", params=params) + async def lookup(self, params: ContactsLookupParams) -> Contact: + response = await self._transport.request("GET", "/contacts/lookup", params=params.to_wire()) return Contact.model_validate(response.json()) @api_route("GET", "/contacts/match") - async def match( - self, - *, - name: str, - company_name: str | None = None, - domain: str | None = None, - person_country: str | None = None, - limit: int | None = None, - ) -> ContactMatchResponse: - params = {k: v for k, v in locals().items() if k != "self"} - response = await self._transport.request("GET", "/contacts/match", params=params) + async def match(self, params: ContactsMatchParams) -> ContactMatchResponse: + response = await self._transport.request("GET", "/contacts/match", params=params.to_wire()) return ContactMatchResponse.model_validate(response.json()) @api_route("POST", "/contacts/bulk-match") - async def bulk_match( - self, - *, - queries: list[dict[str, Any]], - enrich: bool | None = None, - limit: int | None = None, - ) -> AsyncJob: - body = {k: v for k, v in locals().items() if k != "self"} - response = await self._transport.request("POST", "/contacts/bulk-match", json_body=drop_none(body)) + async def bulk_match(self, request: BulkContactMatchRequest) -> AsyncJob: + response = await self._transport.request("POST", "/contacts/bulk-match", json_body=request.to_wire()) return AsyncJob(self._transport, task_family=FAMILY_CONTACTMATCH, task_id=response.json()["task_id"]) @api_route("POST", "/contacts/discover") - async def discover( - self, - *, - icp_prompt: str | None = None, - icp_text: str | None = None, - seniority: list[str] | None = None, - negate_seniority: list[str] | None = None, - department: list[str] | None = None, - negate_department: list[str] | None = None, - skills: list[str] | None = None, - name: str | None = None, - title: list[str] | None = None, - negate_title: list[str] | None = None, - summary: str | None = None, - negate_summary: str | None = None, - person_country: list[str] | None = None, - negate_person_country: list[str] | None = None, - person_state: list[str] | None = None, - has_email: bool | None = None, - email_validated: bool | None = None, - has_phone: bool | None = None, - has_mobile: bool | None = None, - has_linkedin: bool | None = None, - min_connections: int | None = None, - jobstart_date: str | None = None, - persona_id: list[int] | None = None, - domain: list[str] | None = None, - filter_industry: list[str] | None = None, - negate_filter_industry: list[str] | None = None, - filter_country: list[str] | None = None, - negate_filter_country: list[str] | None = None, - filter_state: list[str] | None = None, - negate_filter_state: list[str] | None = None, - employee_range: str | None = None, - inclusion_query_id: list[str] | None = None, - exclusion_query_id: list[str] | None = None, - max_records: int | None = None, - max_companies: int | None = None, - offset: int | None = None, - results_by_company: int | None = None, - include_search_contacts: bool | None = None, - consensus: int | None = None, - ) -> ContactsDiscoverResponse: - body = {k: v for k, v in locals().items() if k != "self"} - response = await self._transport.request("POST", "/contacts/discover", json_body=drop_none(body)) + async def discover(self, request: ContactFilters) -> ContactsDiscoverResponse: + response = await self._transport.request("POST", "/contacts/discover", json_body=request.to_wire()) return ContactsDiscoverResponse.model_validate(response.json()) @api_route("POST", "/contacts/discover/generate") - async def generate( - self, - *, - icp_text: str, - domains: list[str], - context_mode: str | None = None, - integration_id: str | None = None, - search_provider_id: str | None = None, - search_context_size: str | None = None, - max_contacts_per_domain: int | None = None, - max_company_records: int | None = None, - ) -> AsyncJob: - body = {k: v for k, v in locals().items() if k != "self"} - response = await self._transport.request("POST", "/contacts/discover/generate", json_body=drop_none(body)) + async def generate(self, request: ContactGenerateRequest) -> AsyncJob: + response = await self._transport.request("POST", "/contacts/discover/generate", json_body=request.to_wire()) return AsyncJob(self._transport, task_family=FAMILY_DISCOGEN, task_id=response.json()["task_id"]) diff --git a/packages/discolike/src/discolike/resources/discogen.py b/packages/discolike/src/discolike/resources/discogen.py index 71a9f25..dae85f6 100644 --- a/packages/discolike/src/discolike/resources/discogen.py +++ b/packages/discolike/src/discolike/resources/discogen.py @@ -6,7 +6,9 @@ from discolike._jobs import AsyncJob from discolike._jobs import Job from discolike._models import DiscolikeModel -from discolike._transport import drop_none +from discolike.requests import DiscoGenPersonaProcessRequest +from discolike.requests import DiscoGenProcessRequest +from discolike.requests import ValidateIcpRequest from discolike.resources._base import AsyncAPIResource from discolike.resources._base import SyncAPIResource from discolike.resources._base import api_route @@ -23,37 +25,13 @@ class DiscogenModels(DiscolikeModel): class DiscogenResource(SyncAPIResource): @api_route("POST", "/discogen/process") - def process( - self, - *, - query: str, - domains: list[str], - integration_id: str | None = None, - web_search: bool | None = None, - context_mode: str | None = None, - include_x_search: bool | None = None, - search_provider_id: str | None = None, - search_context_size: str | None = None, - ) -> Job: - body = {k: v for k, v in locals().items() if k != "self"} - response = self._transport.request("POST", "/discogen/process", json_body=drop_none(body)) + def process(self, request: DiscoGenProcessRequest) -> Job: + response = self._transport.request("POST", "/discogen/process", json_body=request.to_wire()) return Job(self._transport, task_family=FAMILY_DISCOGEN, task_id=response.json()["task_id"]) @api_route("POST", "/discogen/process-personas") - def process_personas( - self, - *, - query: str, - persona_ids: list[int], - integration_id: str | None = None, - web_search: bool | None = None, - context_mode: str | None = None, - include_x_search: bool | None = None, - search_provider_id: str | None = None, - search_context_size: str | None = None, - ) -> Job: - body = {k: v for k, v in locals().items() if k != "self"} - response = self._transport.request("POST", "/discogen/process-personas", json_body=drop_none(body)) + def process_personas(self, request: DiscoGenPersonaProcessRequest) -> Job: + response = self._transport.request("POST", "/discogen/process-personas", json_body=request.to_wire()) return Job(self._transport, task_family=FAMILY_DISCOGEN, task_id=response.json()["task_id"]) @api_route("GET", "/discogen/models") @@ -66,54 +44,20 @@ def job(self, task_id: str) -> Job: class ValidateResource(SyncAPIResource): @api_route("POST", "/validate/icp") - def icp( - self, - *, - icp_text: str, - domains: list[str], - context_mode: str | None = None, - integration_id: str | None = None, - web_search: bool | None = None, - search_provider_id: str | None = None, - ) -> Job: - body = {k: v for k, v in locals().items() if k != "self"} - response = self._transport.request("POST", "/validate/icp", json_body=drop_none(body)) + def icp(self, request: ValidateIcpRequest) -> Job: + response = self._transport.request("POST", "/validate/icp", json_body=request.to_wire()) return Job(self._transport, task_family=FAMILY_DISCOGEN, task_id=response.json()["task_id"]) class AsyncDiscogenResource(AsyncAPIResource): @api_route("POST", "/discogen/process") - async def process( - self, - *, - query: str, - domains: list[str], - integration_id: str | None = None, - web_search: bool | None = None, - context_mode: str | None = None, - include_x_search: bool | None = None, - search_provider_id: str | None = None, - search_context_size: str | None = None, - ) -> AsyncJob: - body = {k: v for k, v in locals().items() if k != "self"} - response = await self._transport.request("POST", "/discogen/process", json_body=drop_none(body)) + async def process(self, request: DiscoGenProcessRequest) -> AsyncJob: + response = await self._transport.request("POST", "/discogen/process", json_body=request.to_wire()) return AsyncJob(self._transport, task_family=FAMILY_DISCOGEN, task_id=response.json()["task_id"]) @api_route("POST", "/discogen/process-personas") - async def process_personas( - self, - *, - query: str, - persona_ids: list[int], - integration_id: str | None = None, - web_search: bool | None = None, - context_mode: str | None = None, - include_x_search: bool | None = None, - search_provider_id: str | None = None, - search_context_size: str | None = None, - ) -> AsyncJob: - body = {k: v for k, v in locals().items() if k != "self"} - response = await self._transport.request("POST", "/discogen/process-personas", json_body=drop_none(body)) + async def process_personas(self, request: DiscoGenPersonaProcessRequest) -> AsyncJob: + response = await self._transport.request("POST", "/discogen/process-personas", json_body=request.to_wire()) return AsyncJob(self._transport, task_family=FAMILY_DISCOGEN, task_id=response.json()["task_id"]) @api_route("GET", "/discogen/models") @@ -127,16 +71,6 @@ def job(self, task_id: str) -> AsyncJob: class AsyncValidateResource(AsyncAPIResource): @api_route("POST", "/validate/icp") - async def icp( - self, - *, - icp_text: str, - domains: list[str], - context_mode: str | None = None, - integration_id: str | None = None, - web_search: bool | None = None, - search_provider_id: str | None = None, - ) -> AsyncJob: - body = {k: v for k, v in locals().items() if k != "self"} - response = await self._transport.request("POST", "/validate/icp", json_body=drop_none(body)) + async def icp(self, request: ValidateIcpRequest) -> AsyncJob: + response = await self._transport.request("POST", "/validate/icp", json_body=request.to_wire()) return AsyncJob(self._transport, task_family=FAMILY_DISCOGEN, task_id=response.json()["task_id"]) diff --git a/packages/discolike/src/discolike/resources/discovery.py b/packages/discolike/src/discolike/resources/discovery.py index f77c688..395c5a7 100644 --- a/packages/discolike/src/discolike/resources/discovery.py +++ b/packages/discolike/src/discolike/resources/discovery.py @@ -1,6 +1,8 @@ from __future__ import annotations from discolike._models import DiscolikeModel +from discolike.requests import CountParams +from discolike.requests import DiscoverParams from discolike.resources._base import AsyncAPIResource from discolike.resources._base import SyncAPIResource from discolike.resources._base import api_route @@ -17,170 +19,22 @@ class Count(DiscolikeModel): class DiscoveryResource(SyncAPIResource): @api_route("GET", "/discover") - def discover( - self, - *, - domain: list[str] | None = None, - exclude_domain: list[str] | None = None, - icp_text: str | None = None, - icp_prompt: str | None = None, - phrase_match: list[str] | None = None, - negate_phrase_match: list[str] | None = None, - subdomain: list[str] | None = None, - negate_subdomain: list[str] | None = None, - tech_stack: list[str] | None = None, - negate_tech_stack: list[str] | None = None, - category: list[str] | None = None, - negate_category: list[str] | None = None, - state: list[str] | None = None, - negate_state: list[str] | None = None, - country: list[str] | None = None, - negate_country: list[str] | None = None, - social: list[str] | None = None, - negate_social: list[str] | None = None, - language: list[str] | None = None, - negate_language: list[str] | None = None, - business_model: list[str] | None = None, - negate_business_model: list[str] | None = None, - employee_range: str | None = None, - revenue_range: str | None = None, - start_date: str | None = None, - redirect: bool | None = None, - exclude_leadgen: bool | None = None, - min_digital_footprint: int | None = None, - max_digital_footprint: int | None = None, - min_similarity: int | None = None, - consensus: int | None = None, - variance: str | None = None, - retrieval: bool | None = None, - enhanced: bool | None = None, - include_search_domains: bool | None = None, - auto_icp_text: bool | None = None, - auto_phrase_match: bool | None = None, - max_records: int | None = None, - offset: int | None = None, - exclusion_query_id: list[str] | None = None, - inclusion_query_id: list[str] | None = None, - ) -> list[Company]: - params = {k: v for k, v in locals().items() if k != "self"} - response = self._transport.request("GET", "/discover", params=params) + def discover(self, params: DiscoverParams) -> list[Company]: + response = self._transport.request("GET", "/discover", params=params.to_wire()) return [Company.model_validate(item) for item in response.json()] @api_route("GET", "/count") - def count( - self, - *, - phrase_match: list[str] | None = None, - negate_phrase_match: list[str] | None = None, - subdomain: list[str] | None = None, - negate_subdomain: list[str] | None = None, - tech_stack: list[str] | None = None, - negate_tech_stack: list[str] | None = None, - category: list[str] | None = None, - negate_category: list[str] | None = None, - min_digital_footprint: int | None = None, - max_digital_footprint: int | None = None, - state: list[str] | None = None, - negate_state: list[str] | None = None, - country: list[str] | None = None, - negate_country: list[str] | None = None, - start_date: str | None = None, - redirect: bool | None = None, - social: list[str] | None = None, - negate_social: list[str] | None = None, - language: list[str] | None = None, - negate_language: list[str] | None = None, - employee_range: str | None = None, - revenue_range: str | None = None, - business_model: list[str] | None = None, - negate_business_model: list[str] | None = None, - exclude_leadgen: bool | None = None, - ) -> Count: - params = {k: v for k, v in locals().items() if k != "self"} - return Count.model_validate(self._transport.request("GET", "/count", params=params).json()) + def count(self, params: CountParams) -> Count: + return Count.model_validate(self._transport.request("GET", "/count", params=params.to_wire()).json()) class AsyncDiscoveryResource(AsyncAPIResource): @api_route("GET", "/discover") - async def discover( - self, - *, - domain: list[str] | None = None, - exclude_domain: list[str] | None = None, - icp_text: str | None = None, - icp_prompt: str | None = None, - phrase_match: list[str] | None = None, - negate_phrase_match: list[str] | None = None, - subdomain: list[str] | None = None, - negate_subdomain: list[str] | None = None, - tech_stack: list[str] | None = None, - negate_tech_stack: list[str] | None = None, - category: list[str] | None = None, - negate_category: list[str] | None = None, - state: list[str] | None = None, - negate_state: list[str] | None = None, - country: list[str] | None = None, - negate_country: list[str] | None = None, - social: list[str] | None = None, - negate_social: list[str] | None = None, - language: list[str] | None = None, - negate_language: list[str] | None = None, - business_model: list[str] | None = None, - negate_business_model: list[str] | None = None, - employee_range: str | None = None, - revenue_range: str | None = None, - start_date: str | None = None, - redirect: bool | None = None, - exclude_leadgen: bool | None = None, - min_digital_footprint: int | None = None, - max_digital_footprint: int | None = None, - min_similarity: int | None = None, - consensus: int | None = None, - variance: str | None = None, - retrieval: bool | None = None, - enhanced: bool | None = None, - include_search_domains: bool | None = None, - auto_icp_text: bool | None = None, - auto_phrase_match: bool | None = None, - max_records: int | None = None, - offset: int | None = None, - exclusion_query_id: list[str] | None = None, - inclusion_query_id: list[str] | None = None, - ) -> list[Company]: - params = {k: v for k, v in locals().items() if k != "self"} - response = await self._transport.request("GET", "/discover", params=params) + async def discover(self, params: DiscoverParams) -> list[Company]: + response = await self._transport.request("GET", "/discover", params=params.to_wire()) return [Company.model_validate(item) for item in response.json()] @api_route("GET", "/count") - async def count( - self, - *, - phrase_match: list[str] | None = None, - negate_phrase_match: list[str] | None = None, - subdomain: list[str] | None = None, - negate_subdomain: list[str] | None = None, - tech_stack: list[str] | None = None, - negate_tech_stack: list[str] | None = None, - category: list[str] | None = None, - negate_category: list[str] | None = None, - min_digital_footprint: int | None = None, - max_digital_footprint: int | None = None, - state: list[str] | None = None, - negate_state: list[str] | None = None, - country: list[str] | None = None, - negate_country: list[str] | None = None, - start_date: str | None = None, - redirect: bool | None = None, - social: list[str] | None = None, - negate_social: list[str] | None = None, - language: list[str] | None = None, - negate_language: list[str] | None = None, - employee_range: str | None = None, - revenue_range: str | None = None, - business_model: list[str] | None = None, - negate_business_model: list[str] | None = None, - exclude_leadgen: bool | None = None, - ) -> Count: - params = {k: v for k, v in locals().items() if k != "self"} - response = await self._transport.request("GET", "/count", params=params) + async def count(self, params: CountParams) -> Count: + response = await self._transport.request("GET", "/count", params=params.to_wire()) return Count.model_validate(response.json()) diff --git a/packages/discolike/src/discolike/resources/email.py b/packages/discolike/src/discolike/resources/email.py index 5b0c3c4..085d615 100644 --- a/packages/discolike/src/discolike/resources/email.py +++ b/packages/discolike/src/discolike/resources/email.py @@ -10,7 +10,8 @@ from discolike._email import EnumerationMatch from discolike._email import EnumerationOutput from discolike._email import ValidationOutput -from discolike._transport import drop_none +from discolike.requests import FindEmailBatchRequest +from discolike.requests import FindEmailRequest from discolike.resources._base import AsyncAPIResource from discolike.resources._base import SyncAPIResource from discolike.resources._base import api_route @@ -32,14 +33,13 @@ class EmailResource(SyncAPIResource): @api_route("POST", "/email/find") - def find(self, *, first_name: str, last_name: str, domain: str, known_pattern: str | None = None) -> EmailJob: - body = {k: v for k, v in locals().items() if k != "self"} - response = self._transport.request("POST", "/email/find", json_body=drop_none(body)) + def find(self, request: FindEmailRequest) -> EmailJob: + response = self._transport.request("POST", "/email/find", json_body=request.to_wire()) return EmailJob(self._transport, job_id=response.json()["job_id"], kind="find") - @api_route("POST", "/email/find/batch", ignore_params=("contacts",)) - def find_batch(self, *, contacts: list[dict[str, str]]) -> EmailBatch: - response = self._transport.request("POST", "/email/find/batch", json_body={"requests": contacts}) + @api_route("POST", "/email/find/batch") + def find_batch(self, request: FindEmailBatchRequest) -> EmailBatch: + response = self._transport.request("POST", "/email/find/batch", json_body=request.to_wire()) return EmailBatch(self._transport, batch_id=response.json()["batch_id"], kind="find") def batch(self, batch_id: str, *, kind: EmailKind) -> EmailBatch: @@ -51,16 +51,13 @@ def job(self, job_id: str, *, kind: EmailKind = "find") -> EmailJob: class AsyncEmailResource(AsyncAPIResource): @api_route("POST", "/email/find") - async def find( - self, *, first_name: str, last_name: str, domain: str, known_pattern: str | None = None - ) -> AsyncEmailJob: - body = {k: v for k, v in locals().items() if k != "self"} - response = await self._transport.request("POST", "/email/find", json_body=drop_none(body)) + async def find(self, request: FindEmailRequest) -> AsyncEmailJob: + response = await self._transport.request("POST", "/email/find", json_body=request.to_wire()) return AsyncEmailJob(self._transport, job_id=response.json()["job_id"], kind="find") - @api_route("POST", "/email/find/batch", ignore_params=("contacts",)) - async def find_batch(self, *, contacts: list[dict[str, str]]) -> AsyncEmailBatch: - response = await self._transport.request("POST", "/email/find/batch", json_body={"requests": contacts}) + @api_route("POST", "/email/find/batch") + async def find_batch(self, request: FindEmailBatchRequest) -> AsyncEmailBatch: + response = await self._transport.request("POST", "/email/find/batch", json_body=request.to_wire()) return AsyncEmailBatch(self._transport, batch_id=response.json()["batch_id"], kind="find") def batch(self, batch_id: str, *, kind: EmailKind) -> AsyncEmailBatch: diff --git a/packages/discolike/src/discolike/resources/enrich.py b/packages/discolike/src/discolike/resources/enrich.py index a26411e..6495a0e 100644 --- a/packages/discolike/src/discolike/resources/enrich.py +++ b/packages/discolike/src/discolike/resources/enrich.py @@ -1,14 +1,14 @@ from __future__ import annotations -import pathlib -from typing import Any -from typing import BinaryIO - from discolike._jobs import FAMILY_SEGMENT from discolike._jobs import AsyncJob from discolike._jobs import Job from discolike._models import DiscolikeModel +from discolike.requests import AppendParams +from discolike.requests import SegmentFileParams +from discolike.requests import SegmentParams from discolike.resources._base import AsyncAPIResource +from discolike.resources._base import FileInput from discolike.resources._base import SyncAPIResource from discolike.resources._base import api_route from discolike.resources._base import open_upload @@ -22,24 +22,17 @@ class AppendResult(DiscolikeModel): class EnrichResource(SyncAPIResource): @api_route("POST", "/append") - def append( - self, - *, - file: pathlib.Path | str | BinaryIO | None = None, - dataset: list[str] | None = None, - domain_column: str | None = None, - csv: bool | None = None, - query_id: list[str] | None = None, - ) -> list[AppendResult] | bytes: - if file is None and query_id is None: + def append(self, params: AppendParams, *, file: FileInput | None = None) -> list[AppendResult] | bytes: + if file is None and not params.query_id: raise ValueError("one of file or query_id is required") - params = {"dataset": dataset, "domain_column": domain_column, "csv": csv, "query_id": query_id} if file is None: - response = self._transport.request("POST", "/append", params=params) + response = self._transport.request("POST", "/append", params=params.to_wire()) else: filename, fh, we_opened_it = open_upload(file) try: - response = self._transport.request("POST", "/append", params=params, files={"file": (filename, fh)}) + response = self._transport.request( + "POST", "/append", params=params.to_wire(), files={"file": (filename, fh)} + ) finally: if we_opened_it: fh.close() @@ -47,71 +40,38 @@ def append( return [AppendResult.model_validate(item) for item in response.json()] return response.content + @api_route("GET", "/segment") + def segment(self, params: SegmentParams) -> Job: + if not params.domains and not params.query_id: + raise ValueError("one of domains or query_id is required") + response = self._transport.request("GET", "/segment", params=params.to_wire()) + return Job(self._transport, task_family=FAMILY_SEGMENT, task_id=response.json()["task_id"]) + @api_route("POST", "/segment") - def _segment_file( - self, - *, - file: pathlib.Path | str | BinaryIO, - domain_column: str | None, - max_segments: int | None, - ) -> Job: - params = {"domain_column": domain_column, "max_segments": max_segments} + def segment_file(self, params: SegmentFileParams, *, file: FileInput) -> Job: filename, fh, we_opened_it = open_upload(file) try: - response = self._transport.request("POST", "/segment", params=params, files={"file": (filename, fh)}) + response = self._transport.request( + "POST", "/segment", params=params.to_wire(), files={"file": (filename, fh)} + ) finally: if we_opened_it: fh.close() return Job(self._transport, task_family=FAMILY_SEGMENT, task_id=response.json()["task_id"]) - @api_route("GET", "/segment", ignore_params=("domain_column",)) - def segment( - self, - *, - domains: list[str] | None = None, - file: pathlib.Path | str | BinaryIO | None = None, - domain_column: str | None = None, - max_segments: int | None = None, - query_id: list[str] | None = None, - ) -> Job: - if file is not None: - if domains is not None or query_id is not None: - raise ValueError("file cannot be combined with domains or query_id") - return self._segment_file(file=file, domain_column=domain_column, max_segments=max_segments) - if domains is None and query_id is None: - raise ValueError("one of domains, query_id, or file is required") - if domain_column is not None: - raise ValueError("domain_column only applies to file uploads") - params: dict[str, Any] = {"max_segments": max_segments} - if domains is not None: - params["domains"] = ",".join(domains) - if query_id is not None: - params["query_id"] = ",".join(query_id) - response = self._transport.request("GET", "/segment", params=params) - return Job(self._transport, task_family=FAMILY_SEGMENT, task_id=response.json()["task_id"]) - class AsyncEnrichResource(AsyncAPIResource): @api_route("POST", "/append") - async def append( - self, - *, - file: pathlib.Path | str | BinaryIO | None = None, - dataset: list[str] | None = None, - domain_column: str | None = None, - csv: bool | None = None, - query_id: list[str] | None = None, - ) -> list[AppendResult] | bytes: - if file is None and query_id is None: + async def append(self, params: AppendParams, *, file: FileInput | None = None) -> list[AppendResult] | bytes: + if file is None and not params.query_id: raise ValueError("one of file or query_id is required") - params = {"dataset": dataset, "domain_column": domain_column, "csv": csv, "query_id": query_id} if file is None: - response = await self._transport.request("POST", "/append", params=params) + response = await self._transport.request("POST", "/append", params=params.to_wire()) else: filename, fh, we_opened_it = open_upload(file) try: response = await self._transport.request( - "POST", "/append", params=params, files={"file": (filename, fh)} + "POST", "/append", params=params.to_wire(), files={"file": (filename, fh)} ) finally: if we_opened_it: @@ -120,45 +80,21 @@ async def append( return [AppendResult.model_validate(item) for item in response.json()] return response.content + @api_route("GET", "/segment") + async def segment(self, params: SegmentParams) -> AsyncJob: + if not params.domains and not params.query_id: + raise ValueError("one of domains or query_id is required") + response = await self._transport.request("GET", "/segment", params=params.to_wire()) + return AsyncJob(self._transport, task_family=FAMILY_SEGMENT, task_id=response.json()["task_id"]) + @api_route("POST", "/segment") - async def _segment_file( - self, - *, - file: pathlib.Path | str | BinaryIO, - domain_column: str | None, - max_segments: int | None, - ) -> AsyncJob: - params = {"domain_column": domain_column, "max_segments": max_segments} + async def segment_file(self, params: SegmentFileParams, *, file: FileInput) -> AsyncJob: filename, fh, we_opened_it = open_upload(file) try: - response = await self._transport.request("POST", "/segment", params=params, files={"file": (filename, fh)}) + response = await self._transport.request( + "POST", "/segment", params=params.to_wire(), files={"file": (filename, fh)} + ) finally: if we_opened_it: fh.close() return AsyncJob(self._transport, task_family=FAMILY_SEGMENT, task_id=response.json()["task_id"]) - - @api_route("GET", "/segment", ignore_params=("domain_column",)) - async def segment( - self, - *, - domains: list[str] | None = None, - file: pathlib.Path | str | BinaryIO | None = None, - domain_column: str | None = None, - max_segments: int | None = None, - query_id: list[str] | None = None, - ) -> AsyncJob: - if file is not None: - if domains is not None or query_id is not None: - raise ValueError("file cannot be combined with domains or query_id") - return await self._segment_file(file=file, domain_column=domain_column, max_segments=max_segments) - if domains is None and query_id is None: - raise ValueError("one of domains, query_id, or file is required") - if domain_column is not None: - raise ValueError("domain_column only applies to file uploads") - params: dict[str, Any] = {"max_segments": max_segments} - if domains is not None: - params["domains"] = ",".join(domains) - if query_id is not None: - params["query_id"] = ",".join(query_id) - response = await self._transport.request("GET", "/segment", params=params) - return AsyncJob(self._transport, task_family=FAMILY_SEGMENT, task_id=response.json()["task_id"]) diff --git a/packages/discolike/src/discolike/resources/match.py b/packages/discolike/src/discolike/resources/match.py index f507d26..7f728d0 100644 --- a/packages/discolike/src/discolike/resources/match.py +++ b/packages/discolike/src/discolike/resources/match.py @@ -1,15 +1,15 @@ from __future__ import annotations -import pathlib -from typing import BinaryIO - import pydantic from discolike._jobs import FAMILY_BULKMATCH from discolike._jobs import AsyncJob from discolike._jobs import Job from discolike._models import DiscolikeModel +from discolike.requests import MatchBulkParams +from discolike.requests import MatchCompanyParams from discolike.resources._base import AsyncAPIResource +from discolike.resources._base import FileInput from discolike.resources._base import SyncAPIResource from discolike.resources._base import api_route from discolike.resources._base import open_upload @@ -36,52 +36,16 @@ class MatchResponse(DiscolikeModel): class MatchResource(SyncAPIResource): @api_route("GET", "/match") - def company( - self, - *, - name: str, - phone: str | None = None, - city: str | None = None, - state: str | None = None, - country: str | None = None, - zip_code: str | None = None, - strict: bool | None = None, - local_mode: bool | None = None, - min_match_confidence: int | None = None, - ) -> MatchResponse: - params = {k: v for k, v in locals().items() if k != "self"} - return MatchResponse.model_validate(self._transport.request("GET", "/match", params=params).json()) + def company(self, params: MatchCompanyParams) -> MatchResponse: + return MatchResponse.model_validate(self._transport.request("GET", "/match", params=params.to_wire()).json()) @api_route("POST", "/bulkmatch") - def bulk( - self, - *, - file: pathlib.Path | str | BinaryIO, - name_column: str, - phone_column: str | None = None, - city_column: str | None = None, - state_column: str | None = None, - country_column: str | None = None, - zip_code_column: str | None = None, - strict: bool | None = None, - local_mode: bool | None = None, - min_match_confidence: int | None = None, - ) -> Job: - params = { - "name_column": name_column, - "phone_column": phone_column, - "city_column": city_column, - "state_column": state_column, - "country_column": country_column, - "zip_code_column": zip_code_column, - "strict": strict, - "local_mode": local_mode, - } - if min_match_confidence is not None: - params["min_match_confidence"] = min_match_confidence + def bulk(self, params: MatchBulkParams, *, file: FileInput) -> Job: filename, fh, we_opened_it = open_upload(file) try: - response = self._transport.request("POST", "/bulkmatch", params=params, files={"file": (filename, fh)}) + response = self._transport.request( + "POST", "/bulkmatch", params=params.to_wire(), files={"file": (filename, fh)} + ) finally: if we_opened_it: fh.close() @@ -90,53 +54,16 @@ def bulk( class AsyncMatchResource(AsyncAPIResource): @api_route("GET", "/match") - async def company( - self, - *, - name: str, - phone: str | None = None, - city: str | None = None, - state: str | None = None, - country: str | None = None, - zip_code: str | None = None, - strict: bool | None = None, - local_mode: bool | None = None, - min_match_confidence: int | None = None, - ) -> MatchResponse: - params = {k: v for k, v in locals().items() if k != "self"} - return MatchResponse.model_validate((await self._transport.request("GET", "/match", params=params)).json()) + async def company(self, params: MatchCompanyParams) -> MatchResponse: + response = await self._transport.request("GET", "/match", params=params.to_wire()) + return MatchResponse.model_validate(response.json()) @api_route("POST", "/bulkmatch") - async def bulk( - self, - *, - file: pathlib.Path | str | BinaryIO, - name_column: str, - phone_column: str | None = None, - city_column: str | None = None, - state_column: str | None = None, - country_column: str | None = None, - zip_code_column: str | None = None, - strict: bool | None = None, - local_mode: bool | None = None, - min_match_confidence: int | None = None, - ) -> AsyncJob: - params = { - "name_column": name_column, - "phone_column": phone_column, - "city_column": city_column, - "state_column": state_column, - "country_column": country_column, - "zip_code_column": zip_code_column, - "strict": strict, - "local_mode": local_mode, - } - if min_match_confidence is not None: - params["min_match_confidence"] = min_match_confidence + async def bulk(self, params: MatchBulkParams, *, file: FileInput) -> AsyncJob: filename, fh, we_opened_it = open_upload(file) try: response = await self._transport.request( - "POST", "/bulkmatch", params=params, files={"file": (filename, fh)} + "POST", "/bulkmatch", params=params.to_wire(), files={"file": (filename, fh)} ) finally: if we_opened_it: diff --git a/packages/discolike/src/discolike/resources/providers.py b/packages/discolike/src/discolike/resources/providers.py index a15ccc7..2c4d708 100644 --- a/packages/discolike/src/discolike/resources/providers.py +++ b/packages/discolike/src/discolike/resources/providers.py @@ -3,7 +3,9 @@ import pydantic from discolike._models import DiscolikeModel -from discolike._transport import drop_none +from discolike.requests import LLMProviderCreateRequest +from discolike.requests import LLMProviderUpdateRequest +from discolike.requests import SearchProviderRequest from discolike.resources._base import AsyncAPIResource from discolike.resources._base import SyncAPIResource from discolike.resources._base import api_route @@ -54,38 +56,13 @@ def list(self) -> SearchProviderList: return SearchProviderList.model_validate(self._transport.request("GET", "/search-providers").json()) @api_route("POST", "/search-providers") - def create( - self, - *, - integration_name: str, - provider: str, - search_model: str, - api_key: str | None = None, - base_url: str | None = None, - ) -> SearchProviderConfig: - body = {k: v for k, v in locals().items() if k != "self"} - response = self._transport.request("POST", "/search-providers", json_body=drop_none(body)) + def create(self, request: SearchProviderRequest) -> SearchProviderConfig: + response = self._transport.request("POST", "/search-providers", json_body=request.to_wire()) return SearchProviderConfig.model_validate(response.json()) @api_route("PUT", "/search-providers/{integration_id}") - def update( - self, - *, - integration_id: str, - integration_name: str, - provider: str, - search_model: str, - api_key: str | None = None, - base_url: str | None = None, - ) -> SearchProviderConfig: - body = { - "integration_name": integration_name, - "provider": provider, - "search_model": search_model, - "api_key": api_key, - "base_url": base_url, - } - response = self._transport.request("PUT", f"/search-providers/{integration_id}", json_body=drop_none(body)) + def update(self, request: SearchProviderRequest, *, integration_id: str) -> SearchProviderConfig: + response = self._transport.request("PUT", f"/search-providers/{integration_id}", json_body=request.to_wire()) return SearchProviderConfig.model_validate(response.json()) @api_route("DELETE", "/search-providers/{integration_id}") @@ -114,39 +91,14 @@ async def list(self) -> SearchProviderList: return SearchProviderList.model_validate(response.json()) @api_route("POST", "/search-providers") - async def create( - self, - *, - integration_name: str, - provider: str, - search_model: str, - api_key: str | None = None, - base_url: str | None = None, - ) -> SearchProviderConfig: - body = {k: v for k, v in locals().items() if k != "self"} - response = await self._transport.request("POST", "/search-providers", json_body=drop_none(body)) + async def create(self, request: SearchProviderRequest) -> SearchProviderConfig: + response = await self._transport.request("POST", "/search-providers", json_body=request.to_wire()) return SearchProviderConfig.model_validate(response.json()) @api_route("PUT", "/search-providers/{integration_id}") - async def update( - self, - *, - integration_id: str, - integration_name: str, - provider: str, - search_model: str, - api_key: str | None = None, - base_url: str | None = None, - ) -> SearchProviderConfig: - body = { - "integration_name": integration_name, - "provider": provider, - "search_model": search_model, - "api_key": api_key, - "base_url": base_url, - } + async def update(self, request: SearchProviderRequest, *, integration_id: str) -> SearchProviderConfig: response = await self._transport.request( - "PUT", f"/search-providers/{integration_id}", json_body=drop_none(body) + "PUT", f"/search-providers/{integration_id}", json_body=request.to_wire() ) return SearchProviderConfig.model_validate(response.json()) @@ -176,17 +128,8 @@ def list(self) -> LLMProviderList: return LLMProviderList.model_validate(self._transport.request("GET", "/llm-providers/config").json()) @api_route("POST", "/llm-providers/config") - def create( - self, - *, - integration_name: str, - provider: str, - api_key: str, - model_name: str, - base_url: str | None = None, - ) -> LLMIntegrationResult: - body = {k: v for k, v in locals().items() if k != "self"} - response = self._transport.request("POST", "/llm-providers/config", json_body=drop_none(body)) + def create(self, request: LLMProviderCreateRequest) -> LLMIntegrationResult: + response = self._transport.request("POST", "/llm-providers/config", json_body=request.to_wire()) return LLMIntegrationResult.model_validate(response.json()) @api_route("GET", "/llm-providers/config/{integration_id}") @@ -195,26 +138,10 @@ def get(self, *, integration_id: str) -> LLMProviderConfig: return LLMProviderConfig.model_validate(response.json()) @api_route("PUT", "/llm-providers/config/{integration_id}") - def update( - self, - *, - integration_id: str, - integration_name: str, - provider: str, - model_name: str, - api_key: str | None = None, - base_url: str | None = None, - ) -> LLMIntegrationResult: - body = drop_none( - { - "integration_name": integration_name, - "provider": provider, - "model_name": model_name, - "base_url": base_url, - } + def update(self, request: LLMProviderUpdateRequest, *, integration_id: str) -> LLMIntegrationResult: + response = self._transport.request( + "PUT", f"/llm-providers/config/{integration_id}", json_body=request.to_wire() ) - body["api_key"] = api_key - response = self._transport.request("PUT", f"/llm-providers/config/{integration_id}", json_body=body) return LLMIntegrationResult.model_validate(response.json()) @api_route("DELETE", "/llm-providers/config/{integration_id}") @@ -227,17 +154,8 @@ def set_default(self, *, integration_id: str) -> LLMIntegrationResult: return LLMIntegrationResult.model_validate(response.json()) @api_route("POST", "/llm-providers/test-connection") - def test_connection( - self, - *, - integration_name: str, - provider: str, - api_key: str, - model_name: str, - base_url: str | None = None, - ) -> LLMIntegrationResult: - body = {k: v for k, v in locals().items() if k != "self"} - response = self._transport.request("POST", "/llm-providers/test-connection", json_body=drop_none(body)) + def test_connection(self, request: LLMProviderCreateRequest) -> LLMIntegrationResult: + response = self._transport.request("POST", "/llm-providers/test-connection", json_body=request.to_wire()) return LLMIntegrationResult.model_validate(response.json()) @@ -248,17 +166,8 @@ async def list(self) -> LLMProviderList: return LLMProviderList.model_validate(response.json()) @api_route("POST", "/llm-providers/config") - async def create( - self, - *, - integration_name: str, - provider: str, - api_key: str, - model_name: str, - base_url: str | None = None, - ) -> LLMIntegrationResult: - body = {k: v for k, v in locals().items() if k != "self"} - response = await self._transport.request("POST", "/llm-providers/config", json_body=drop_none(body)) + async def create(self, request: LLMProviderCreateRequest) -> LLMIntegrationResult: + response = await self._transport.request("POST", "/llm-providers/config", json_body=request.to_wire()) return LLMIntegrationResult.model_validate(response.json()) @api_route("GET", "/llm-providers/config/{integration_id}") @@ -267,26 +176,10 @@ async def get(self, *, integration_id: str) -> LLMProviderConfig: return LLMProviderConfig.model_validate(response.json()) @api_route("PUT", "/llm-providers/config/{integration_id}") - async def update( - self, - *, - integration_id: str, - integration_name: str, - provider: str, - model_name: str, - api_key: str | None = None, - base_url: str | None = None, - ) -> LLMIntegrationResult: - body = drop_none( - { - "integration_name": integration_name, - "provider": provider, - "model_name": model_name, - "base_url": base_url, - } + async def update(self, request: LLMProviderUpdateRequest, *, integration_id: str) -> LLMIntegrationResult: + response = await self._transport.request( + "PUT", f"/llm-providers/config/{integration_id}", json_body=request.to_wire() ) - body["api_key"] = api_key - response = await self._transport.request("PUT", f"/llm-providers/config/{integration_id}", json_body=body) return LLMIntegrationResult.model_validate(response.json()) @api_route("DELETE", "/llm-providers/config/{integration_id}") @@ -299,15 +192,6 @@ async def set_default(self, *, integration_id: str) -> LLMIntegrationResult: return LLMIntegrationResult.model_validate(response.json()) @api_route("POST", "/llm-providers/test-connection") - async def test_connection( - self, - *, - integration_name: str, - provider: str, - api_key: str, - model_name: str, - base_url: str | None = None, - ) -> LLMIntegrationResult: - body = {k: v for k, v in locals().items() if k != "self"} - response = await self._transport.request("POST", "/llm-providers/test-connection", json_body=drop_none(body)) + async def test_connection(self, request: LLMProviderCreateRequest) -> LLMIntegrationResult: + response = await self._transport.request("POST", "/llm-providers/test-connection", json_body=request.to_wire()) return LLMIntegrationResult.model_validate(response.json()) diff --git a/packages/discolike/src/discolike/resources/queries.py b/packages/discolike/src/discolike/resources/queries.py index 1a6dd61..a3ab69a 100644 --- a/packages/discolike/src/discolike/resources/queries.py +++ b/packages/discolike/src/discolike/resources/queries.py @@ -5,7 +5,10 @@ import pydantic from discolike._models import DiscolikeModel -from discolike._transport import drop_none +from discolike.requests import CreateExclusionListRequest +from discolike.requests import QueriesListParams +from discolike.requests import SaveResultsRequest +from discolike.requests import UpdateQueryRequest from discolike.resources._base import AsyncAPIResource from discolike.resources._base import SyncAPIResource from discolike.resources._base import api_route @@ -40,55 +43,23 @@ class QueryResult(DiscolikeModel): class QueriesResource(SyncAPIResource): @api_route("GET", "/queries/saved") - def list( - self, - *, - max_records: int | None = None, - offset: int | None = None, - action: str | None = None, - tags: builtins.list[str] | None = None, - ) -> SavedQueries: - params = {k: v for k, v in locals().items() if k != "self"} - return SavedQueries.model_validate(self._transport.request("GET", "/queries/saved", params=params).json()) + def list(self, params: QueriesListParams) -> SavedQueries: + response = self._transport.request("GET", "/queries/saved", params=params.to_wire()) + return SavedQueries.model_validate(response.json()) @api_route("POST", "/queries/exclusion-list") - def create_exclusion_list( - self, - *, - query_name: str, - domains: builtins.list[str] | None = None, - persona_ids: builtins.list[int] | None = None, - tags: builtins.list[str] | None = None, - ) -> QueryResult: - body = {k: v for k, v in locals().items() if k != "self"} - response = self._transport.request("POST", "/queries/exclusion-list", json_body=drop_none(body)) + def create_exclusion_list(self, request: CreateExclusionListRequest) -> QueryResult: + response = self._transport.request("POST", "/queries/exclusion-list", json_body=request.to_wire()) return QueryResult.model_validate(response.json()) @api_route("POST", "/queries/save-results") - def save_results( - self, - *, - query_name: str, - action: str, - data: builtins.list[dict], - query_params: dict | None = None, - domain_column: str | None = None, - tags: builtins.list[str] | None = None, - ) -> QueryResult: - body = {k: v for k, v in locals().items() if k != "self"} - response = self._transport.request("POST", "/queries/save-results", json_body=drop_none(body)) + def save_results(self, request: SaveResultsRequest) -> QueryResult: + response = self._transport.request("POST", "/queries/save-results", json_body=request.to_wire()) return QueryResult.model_validate(response.json()) @api_route("PATCH", "/queries/{query_id}") - def update( - self, - *, - query_id: str, - query_name: str | None = None, - tags: builtins.list[str] | None = None, - ) -> QueryResult: - body = {"query_name": query_name, "tags": tags} - response = self._transport.request("PATCH", f"/queries/{query_id}", json_body=drop_none(body)) + def update(self, request: UpdateQueryRequest, *, query_id: str) -> QueryResult: + response = self._transport.request("PATCH", f"/queries/{query_id}", json_body=request.to_wire()) return QueryResult.model_validate(response.json()) @api_route("DELETE", "/queries/{query_id}") @@ -98,56 +69,23 @@ def delete(self, *, query_id: str) -> None: class AsyncQueriesResource(AsyncAPIResource): @api_route("GET", "/queries/saved") - async def list( - self, - *, - max_records: int | None = None, - offset: int | None = None, - action: str | None = None, - tags: builtins.list[str] | None = None, - ) -> SavedQueries: - params = {k: v for k, v in locals().items() if k != "self"} - response = await self._transport.request("GET", "/queries/saved", params=params) + async def list(self, params: QueriesListParams) -> SavedQueries: + response = await self._transport.request("GET", "/queries/saved", params=params.to_wire()) return SavedQueries.model_validate(response.json()) @api_route("POST", "/queries/exclusion-list") - async def create_exclusion_list( - self, - *, - query_name: str, - domains: builtins.list[str] | None = None, - persona_ids: builtins.list[int] | None = None, - tags: builtins.list[str] | None = None, - ) -> QueryResult: - body = {k: v for k, v in locals().items() if k != "self"} - response = await self._transport.request("POST", "/queries/exclusion-list", json_body=drop_none(body)) + async def create_exclusion_list(self, request: CreateExclusionListRequest) -> QueryResult: + response = await self._transport.request("POST", "/queries/exclusion-list", json_body=request.to_wire()) return QueryResult.model_validate(response.json()) @api_route("POST", "/queries/save-results") - async def save_results( - self, - *, - query_name: str, - action: str, - data: builtins.list[dict], - query_params: dict | None = None, - domain_column: str | None = None, - tags: builtins.list[str] | None = None, - ) -> QueryResult: - body = {k: v for k, v in locals().items() if k != "self"} - response = await self._transport.request("POST", "/queries/save-results", json_body=drop_none(body)) + async def save_results(self, request: SaveResultsRequest) -> QueryResult: + response = await self._transport.request("POST", "/queries/save-results", json_body=request.to_wire()) return QueryResult.model_validate(response.json()) @api_route("PATCH", "/queries/{query_id}") - async def update( - self, - *, - query_id: str, - query_name: str | None = None, - tags: builtins.list[str] | None = None, - ) -> QueryResult: - body = {"query_name": query_name, "tags": tags} - response = await self._transport.request("PATCH", f"/queries/{query_id}", json_body=drop_none(body)) + async def update(self, request: UpdateQueryRequest, *, query_id: str) -> QueryResult: + response = await self._transport.request("PATCH", f"/queries/{query_id}", json_body=request.to_wire()) return QueryResult.model_validate(response.json()) @api_route("DELETE", "/queries/{query_id}") diff --git a/packages/discolike/tests/test_auth_flow.py b/packages/discolike/tests/test_auth_flow.py new file mode 100644 index 0000000..a4eb24b --- /dev/null +++ b/packages/discolike/tests/test_auth_flow.py @@ -0,0 +1,243 @@ +import asyncio +import threading +import time + +import httpx2 +import pytest + +from discolike import AuthenticationError +from discolike._auth import DiscolikeAuth +from discolike._credentials import ApiKeyCredential +from discolike._credentials import OAuthCredential +from discolike._oauth import REFRESH_LEEWAY_SECONDS + +API = "https://api.test/v1" +TOKEN_ENDPOINT = "https://auth.test/oauth/2.1/token" +ONE_HOUR = 3600.0 + + +def make_oauth(*, expires_in: float = ONE_HOUR, access_token: str = "at-1") -> OAuthCredential: + return OAuthCredential( + access_token=access_token, + refresh_token="rt-1", + expires_at=time.time() + expires_in, + client_id="client-1", + token_endpoint=TOKEN_ENDPOINT, + ) + + +class Server: + """Mock API + token endpoint; counts refreshes and rejects any bearer it did not mint.""" + + def __init__(self, *, valid_tokens: set[str], refresh_ok: bool = True) -> None: + self.valid_tokens = valid_tokens + self.refresh_ok = refresh_ok + self.refreshes = 0 + self.bearers: list[str] = [] + self.token_calls: list[httpx2.Request] = [] + + def __call__(self, request: httpx2.Request) -> httpx2.Response: + if str(request.url) == TOKEN_ENDPOINT: + self.token_calls.append(request) + if not self.refresh_ok: + return httpx2.Response(400, json={"error": "invalid_grant", "error_description": "revoked"}) + self.refreshes += 1 + token = f"at-refreshed-{self.refreshes}" + self.valid_tokens.add(token) + return httpx2.Response( + 200, json={"access_token": token, "refresh_token": f"rt-{self.refreshes + 1}", "expires_in": 3600} + ) + bearer = request.headers.get("Authorization", "") + self.bearers.append(bearer) + if bearer.removeprefix("Bearer ") in self.valid_tokens: + return httpx2.Response(200, json={"ok": True}) + return httpx2.Response(401, json={"detail": "Invalid API Key or Session"}) + + +def sync_client(auth: DiscolikeAuth, handler) -> httpx2.Client: + return httpx2.Client(transport=httpx2.MockTransport(handler), base_url=API, auth=auth) + + +def async_client(auth: DiscolikeAuth, handler) -> httpx2.AsyncClient: + return httpx2.AsyncClient(transport=httpx2.MockTransport(handler), base_url=API, auth=auth) + + +def test_api_key_credential_sets_header() -> None: + seen: dict[str, str] = {} + + def handler(request: httpx2.Request) -> httpx2.Response: + seen.update(request.headers) + return httpx2.Response(200) + + sync_client(DiscolikeAuth(ApiKeyCredential(api_key="dk-1")), handler).get("/usage") + assert seen["x-discolike-key"] == "dk-1" + assert "authorization" not in seen + + +def test_oauth_credential_sets_bearer_without_refresh() -> None: + server = Server(valid_tokens={"at-1"}) + response = sync_client(DiscolikeAuth(make_oauth()), server).get("/usage") + assert response.status_code == 200 + assert server.refreshes == 0 + assert server.bearers[0] == "Bearer at-1" + + +def test_proactive_refresh_when_near_expiry() -> None: + server = Server(valid_tokens={"at-1"}) + updates: list[OAuthCredential] = [] + auth = DiscolikeAuth(make_oauth(expires_in=REFRESH_LEEWAY_SECONDS / 2), on_update=updates.append) + response = sync_client(auth, server).get("/usage") + assert response.status_code == 200 + assert server.refreshes == 1 + assert server.bearers[0] == "Bearer at-refreshed-1" + assert [update.access_token for update in updates] == ["at-refreshed-1"] + assert updates[0].refresh_token == "rt-2" + assert updates[0].client_id == "client-1" + assert updates[0].token_endpoint == TOKEN_ENDPOINT + assert auth.credential is updates[0] + + +def test_refresh_request_shape() -> None: + server = Server(valid_tokens=set()) + sync_client(DiscolikeAuth(make_oauth(expires_in=0)), server).get("/usage") + token_request = server.token_calls[0] + assert token_request.method == "POST" + assert token_request.headers["Content-Type"] == "application/x-www-form-urlencoded" + assert token_request.content.decode() == "grant_type=refresh_token&refresh_token=rt-1&client_id=client-1" + + +def test_401_triggers_refresh_and_single_replay() -> None: + server = Server(valid_tokens=set()) + updates: list[OAuthCredential] = [] + response = sync_client(DiscolikeAuth(make_oauth(), on_update=updates.append), server).get("/usage") + assert response.status_code == 200 + assert server.refreshes == 1 + assert server.bearers == ["Bearer at-1", "Bearer at-refreshed-1"] + assert updates[0].access_token == "at-refreshed-1" + + +def test_second_401_after_replay_is_returned_not_retried() -> None: + server = Server(valid_tokens=set()) + + def reject_everything(request: httpx2.Request) -> httpx2.Response: + response = server(request) + if str(request.url) != TOKEN_ENDPOINT: + return httpx2.Response(401, json={"detail": "Invalid API Key or Session"}) + return response + + response = sync_client(DiscolikeAuth(make_oauth()), reject_everything).get("/usage") + assert response.status_code == 401 + assert server.refreshes == 1 + assert len(server.bearers) == 2 + + +def test_refresh_failure_raises_authentication_error() -> None: + server = Server(valid_tokens=set(), refresh_ok=False) + with pytest.raises(AuthenticationError, match="discolike auth login"): + sync_client(DiscolikeAuth(make_oauth(expires_in=0)), server).get("/usage") + + +def test_concurrent_requests_refresh_once() -> None: + server = Server(valid_tokens=set()) + auth = DiscolikeAuth(make_oauth(expires_in=0)) + client = sync_client(auth, server) + statuses: list[int] = [] + + def worker() -> None: + statuses.append(client.get("/usage").status_code) + + threads = [threading.Thread(target=worker) for _ in range(4)] + for thread in threads: + thread.start() + for thread in threads: + thread.join() + assert statuses == [200, 200, 200, 200] + assert server.refreshes == 1 + + +async def test_async_bearer_and_401_replay() -> None: + server = Server(valid_tokens=set()) + updates: list[OAuthCredential] = [] + async with async_client(DiscolikeAuth(make_oauth(), on_update=updates.append), server) as client: + response = await client.get("/usage") + assert response.status_code == 200 + assert server.refreshes == 1 + assert server.bearers == ["Bearer at-1", "Bearer at-refreshed-1"] + assert updates[0].access_token == "at-refreshed-1" + + +async def test_async_proactive_refresh_and_concurrency() -> None: + server = Server(valid_tokens=set()) + async with async_client(DiscolikeAuth(make_oauth(expires_in=0)), server) as client: + responses = await asyncio.gather(*(client.get("/usage") for _ in range(4))) + assert [response.status_code for response in responses] == [200, 200, 200, 200] + assert server.refreshes == 1 + + +async def test_async_refresh_failure_raises_authentication_error() -> None: + server = Server(valid_tokens=set(), refresh_ok=False) + async with async_client(DiscolikeAuth(make_oauth(expires_in=0)), server) as client: + with pytest.raises(AuthenticationError, match="discolike auth login"): + await client.get("/usage") + + +async def test_async_api_key_header() -> None: + seen: dict[str, str] = {} + + def handler(request: httpx2.Request) -> httpx2.Response: + seen.update(request.headers) + return httpx2.Response(200) + + async with async_client(DiscolikeAuth(ApiKeyCredential(api_key="dk-2")), handler) as client: + await client.get("/usage") + assert seen["x-discolike-key"] == "dk-2" + + +def test_reload_adopts_credential_rotated_by_another_process() -> None: + server = Server(valid_tokens={"at-other"}) + updates: list[OAuthCredential] = [] + rotated_elsewhere = make_oauth(access_token="at-other") + auth = DiscolikeAuth(make_oauth(expires_in=0), on_update=updates.append, reload=lambda: rotated_elsewhere) + response = sync_client(auth, server).get("/usage") + assert response.status_code == 200 + assert server.token_calls == [] + assert server.bearers == ["Bearer at-other"] + assert auth.credential is rotated_elsewhere + assert updates == [] + + +def test_reload_with_stale_stored_credential_still_refreshes() -> None: + server = Server(valid_tokens=set()) + stale = make_oauth(expires_in=0) + auth = DiscolikeAuth(stale, reload=lambda: stale) + response = sync_client(auth, server).get("/usage") + assert response.status_code == 200 + assert server.refreshes == 1 + + +def test_reload_with_api_key_or_missing_config_still_refreshes() -> None: + server = Server(valid_tokens=set()) + auth = DiscolikeAuth(make_oauth(expires_in=0), reload=lambda: None) + assert sync_client(auth, server).get("/usage").status_code == 200 + assert server.refreshes == 1 + + +def test_reload_adopts_after_401() -> None: + server = Server(valid_tokens={"at-other"}) + rotated_elsewhere = make_oauth(access_token="at-other") + auth = DiscolikeAuth(make_oauth(), reload=lambda: rotated_elsewhere) + response = sync_client(auth, server).get("/usage") + assert response.status_code == 200 + assert server.token_calls == [] + assert server.bearers == ["Bearer at-1", "Bearer at-other"] + + +async def test_async_reload_adopts_credential_rotated_by_another_process() -> None: + server = Server(valid_tokens={"at-other"}) + rotated_elsewhere = make_oauth(access_token="at-other") + auth = DiscolikeAuth(make_oauth(expires_in=0), reload=lambda: rotated_elsewhere) + async with async_client(auth, server) as client: + response = await client.get("/usage") + assert response.status_code == 200 + assert server.token_calls == [] + assert server.bearers == ["Bearer at-other"] diff --git a/packages/discolike/tests/test_client.py b/packages/discolike/tests/test_client.py index 8dfd2d8..fecf942 100644 --- a/packages/discolike/tests/test_client.py +++ b/packages/discolike/tests/test_client.py @@ -1,8 +1,16 @@ +import time + import httpx2 import pytest +from discolike import AsyncDiscolike from discolike import AuthenticationError from discolike import Discolike +from discolike import OAuthCredential +from discolike._auth import DiscolikeAuth +from discolike._config import config_path +from discolike._config import load_credential +from discolike._config import save_credential from discolike_testkit import AsyncClientFactory from discolike_testkit import ClientFactory @@ -41,7 +49,7 @@ def test_route_metadata_stamped() -> None: from discolike.resources._base import get_discolike_route from discolike.resources.account import AccountResource - assert get_discolike_route(AccountResource.usage) == ("GET", "/usage", True, ()) + assert get_discolike_route(AccountResource.usage) == ("GET", "/usage", True) def test_with_options_timeout_applies_only_to_the_view(make_client: ClientFactory) -> None: @@ -92,3 +100,96 @@ def handler(request: httpx2.Request) -> httpx2.Response: async with client.with_options(timeout=30.0) as view: await view.account.usage() await client.account.usage() + + +def test_client_accepts_injected_credential_and_does_not_persist_refresh(monkeypatch) -> None: + credential = OAuthCredential( + access_token="at", refresh_token="rt", expires_at=time.time() + 3600, client_id="c", token_endpoint="https://t" + ) + seen: list[str] = [] + + def handler(request: httpx2.Request) -> httpx2.Response: + seen.append(request.headers["Authorization"]) + return httpx2.Response(200, json={"requests_mtd": 1}) + + http = httpx2.Client(transport=httpx2.MockTransport(handler), base_url="https://api.test/v1") + with Discolike(auth=credential, base_url="https://api.test/v1", http_client=http) as client: + client.account.usage() + auth = client._transport._client.auth + assert isinstance(auth, DiscolikeAuth) + assert auth.on_update is None + assert seen == ["Bearer at"] + assert not config_path().exists() + + +def test_client_from_config_oauth_persists_rotated_tokens() -> None: + save_credential( + OAuthCredential( + access_token="stale", refresh_token="rt-1", expires_at=0.0, client_id="c", token_endpoint="https://t/token" + ) + ) + + def handler(request: httpx2.Request) -> httpx2.Response: + if str(request.url) == "https://t/token": + return httpx2.Response(200, json={"access_token": "fresh", "refresh_token": "rt-2", "expires_in": 3600}) + assert request.headers["Authorization"] == "Bearer fresh" + return httpx2.Response(200, json={"requests_mtd": 1}) + + http = httpx2.Client(transport=httpx2.MockTransport(handler), base_url="https://api.test/v1") + with Discolike(base_url="https://api.test/v1", http_client=http) as client: + assert client.account.usage().requests_mtd == 1 + stored = load_credential() + assert isinstance(stored, OAuthCredential) + assert (stored.access_token, stored.refresh_token) == ("fresh", "rt-2") + + +def test_with_options_view_shares_auth(make_client: ClientFactory) -> None: + def handler(request: httpx2.Request) -> httpx2.Response: + return httpx2.Response(200, json={"balance": 1}) + + with make_client(handler) as client: + assert client.with_options(timeout=1.0)._transport._client.auth is client._transport._client.auth + + +async def test_async_client_accepts_injected_credential() -> None: + seen: list[str] = [] + + def handler(request: httpx2.Request) -> httpx2.Response: + seen.append(request.headers["Authorization"]) + return httpx2.Response(200, json={"requests_mtd": 1}) + + credential = OAuthCredential( + access_token="at", refresh_token="rt", expires_at=time.time() + 3600, client_id="c", token_endpoint="https://t" + ) + http = httpx2.AsyncClient(transport=httpx2.MockTransport(handler), base_url="https://api.test/v1") + async with AsyncDiscolike(auth=credential, base_url="https://api.test/v1", http_client=http) as client: + await client.account.usage() + assert seen == ["Bearer at"] + + +def test_client_from_config_reloads_before_refreshing() -> None: + save_credential( + OAuthCredential( + access_token="stale", refresh_token="rt-1", expires_at=0.0, client_id="c", token_endpoint="https://t/token" + ) + ) + seen: list[str] = [] + + def handler(request: httpx2.Request) -> httpx2.Response: + assert str(request.url) != "https://t/token" + seen.append(request.headers["Authorization"]) + return httpx2.Response(200, json={"requests_mtd": 1}) + + http = httpx2.Client(transport=httpx2.MockTransport(handler), base_url="https://api.test/v1") + with Discolike(base_url="https://api.test/v1", http_client=http) as client: + save_credential( + OAuthCredential( + access_token="fresh-elsewhere", + refresh_token="rt-2", + expires_at=time.time() + 3600, + client_id="c", + token_endpoint="https://t/token", + ) + ) + client.account.usage() + assert seen == ["Bearer fresh-elsewhere"] diff --git a/packages/discolike/tests/test_companies.py b/packages/discolike/tests/test_companies.py index 42971ef..d4cc769 100644 --- a/packages/discolike/tests/test_companies.py +++ b/packages/discolike/tests/test_companies.py @@ -1,26 +1,35 @@ import httpx2 +import pydantic import pytest +from discolike.requests import CompaniesDataParams +from discolike.requests import CompaniesExtractParams +from discolike.requests import CompaniesGrowthParams +from discolike.requests import CompaniesPublicLinksParams +from discolike.requests import CompaniesRedirectsParams +from discolike.requests import CompaniesScoreParams +from discolike.requests import CompaniesSubsidiariesParams +from discolike.requests import CompaniesVendorsParams from discolike_testkit import AsyncClientFactory from discolike_testkit import ClientFactory CASES = [ - ("data", {"domain": "acme.com"}, "/v1/bizdata"), - ("score", {"domain": "acme.com"}, "/v1/score"), - ("growth", {"domain": "acme.com"}, "/v1/growth"), - ("extract", {"url": "https://acme.com/about"}, "/v1/extract"), + ("data", CompaniesDataParams(domain="acme.com"), "/v1/bizdata"), + ("score", CompaniesScoreParams(domain="acme.com"), "/v1/score"), + ("growth", CompaniesGrowthParams(domain="acme.com"), "/v1/growth"), + ("extract", CompaniesExtractParams(url="https://acme.com/about"), "/v1/extract"), ] LIST_CASES = [ - ("redirects", {"domain": "acme.com"}, "/v1/redirects"), - ("vendors", {"domain": "acme.com"}, "/v1/vendors"), - ("subsidiaries", {"domain": "acme.com"}, "/v1/subsidiaries"), - ("public_links", {"domain": "acme.com", "source": "email"}, "/v1/publiclink"), + ("redirects", CompaniesRedirectsParams(domain="acme.com"), "/v1/redirects"), + ("vendors", CompaniesVendorsParams(domain="acme.com", match="vendor"), "/v1/vendors"), + ("subsidiaries", CompaniesSubsidiariesParams(domain="acme.com"), "/v1/subsidiaries"), + ("public_links", CompaniesPublicLinksParams(domain="acme.com", source="email"), "/v1/publiclink"), ] -@pytest.mark.parametrize(("method", "kwargs", "path"), CASES) -def test_companies_methods(method: str, kwargs: dict, path: str, make_client: ClientFactory) -> None: +@pytest.mark.parametrize(("method", "params", "path"), CASES) +def test_companies_methods(method: str, params, path: str, make_client: ClientFactory) -> None: seen = {} def handler(request: httpx2.Request) -> httpx2.Response: @@ -29,18 +38,15 @@ def handler(request: httpx2.Request) -> httpx2.Response: return httpx2.Response(200, json={"domain": "acme.com", "anything": 1}) with make_client(handler) as client: - result = getattr(client.companies, method)(**kwargs) + result = getattr(client.companies, method)(params) assert seen["path"] == path - for key, value in kwargs.items(): - assert seen["params"][key] == str(value) + assert seen["params"] == {key: str(value) for key, value in params.to_wire().items()} assert result.model_extra["anything"] == 1 -@pytest.mark.parametrize(("method", "kwargs", "path"), CASES) -async def test_companies_methods_async( - method: str, kwargs: dict, path: str, make_async_client: AsyncClientFactory -) -> None: +@pytest.mark.parametrize(("method", "params", "path"), CASES) +async def test_companies_methods_async(method: str, params, path: str, make_async_client: AsyncClientFactory) -> None: seen = {} def handler(request: httpx2.Request) -> httpx2.Response: @@ -49,16 +55,15 @@ def handler(request: httpx2.Request) -> httpx2.Response: return httpx2.Response(200, json={"domain": "acme.com", "anything": 1}) async with make_async_client(handler) as client: - result = await getattr(client.companies, method)(**kwargs) + result = await getattr(client.companies, method)(params) assert seen["path"] == path - for key, value in kwargs.items(): - assert seen["params"][key] == str(value) + assert seen["params"] == {key: str(value) for key, value in params.to_wire().items()} assert result.model_extra["anything"] == 1 -@pytest.mark.parametrize(("method", "kwargs", "path"), LIST_CASES) -def test_companies_list_methods(method: str, kwargs: dict, path: str, make_client: ClientFactory) -> None: +@pytest.mark.parametrize(("method", "params", "path"), LIST_CASES) +def test_companies_list_methods(method: str, params, path: str, make_client: ClientFactory) -> None: seen = {} def handler(request: httpx2.Request) -> httpx2.Response: @@ -67,25 +72,24 @@ def handler(request: httpx2.Request) -> httpx2.Response: return httpx2.Response(200, json=[{"linked_domain": "acme.io", "anything": 1}]) with make_client(handler) as client: - result = getattr(client.companies, method)(**kwargs) + result = getattr(client.companies, method)(params) assert seen["path"] == path - for key, value in kwargs.items(): - assert seen["params"][key] == str(value) + assert seen["params"] == {key: str(value) for key, value in params.to_wire().items()} assert len(result) == 1 assert result[0].linked_domain == "acme.io" assert result[0].model_extra["anything"] == 1 -@pytest.mark.parametrize(("method", "kwargs", "path"), LIST_CASES) +@pytest.mark.parametrize(("method", "params", "path"), LIST_CASES) async def test_companies_list_methods_async( - method: str, kwargs: dict, path: str, make_async_client: AsyncClientFactory + method: str, params, path: str, make_async_client: AsyncClientFactory ) -> None: def handler(request: httpx2.Request) -> httpx2.Response: return httpx2.Response(200, json=[{"linked_domain": "acme.io"}]) async with make_async_client(handler) as client: - result = await getattr(client.companies, method)(**kwargs) + result = await getattr(client.companies, method)(params) assert len(result) == 1 assert result[0].linked_domain == "acme.io" @@ -96,7 +100,25 @@ def handler(request: httpx2.Request) -> httpx2.Response: return httpx2.Response(200, json={"language": "en", "text": "Acme makes widgets."}) with make_client(handler) as client: - result = client.companies.extract(domain="acme.com") + result = client.companies.extract(CompaniesExtractParams(domain="acme.com")) assert result.text == "Acme makes widgets." assert result.language == "en" + + +def test_unset_match_mode_is_not_sent(make_client: ClientFactory) -> None: + seen = {} + + def handler(request: httpx2.Request) -> httpx2.Response: + seen["params"] = dict(httpx2.QueryParams(request.url.query)) + return httpx2.Response(200, json=[]) + + with make_client(handler) as client: + client.companies.redirects(CompaniesRedirectsParams(domain="acme.com")) + + assert seen["params"] == {"domain": "acme.com"} + + +def test_invalid_match_mode_fails_before_any_request() -> None: + with pytest.raises(pydantic.ValidationError, match="match"): + CompaniesRedirectsParams.model_validate({"domain": "acme.com", "match": "loose"}) diff --git a/packages/discolike/tests/test_config.py b/packages/discolike/tests/test_config.py index 9aaee96..5daa832 100644 --- a/packages/discolike/tests/test_config.py +++ b/packages/discolike/tests/test_config.py @@ -4,9 +4,18 @@ from discolike import AuthenticationError from discolike._config import config_path +from discolike._config import delete_credential +from discolike._config import delete_oauth_client from discolike._config import load_config -from discolike._config import resolve_api_key +from discolike._config import load_credential +from discolike._config import load_oauth_client +from discolike._config import resolve_credential from discolike._config import save_config +from discolike._config import save_credential +from discolike._config import save_oauth_client +from discolike._credentials import ApiKeyCredential +from discolike._credentials import OAuthClientRegistration +from discolike._credentials import OAuthCredential @pytest.fixture(autouse=True) @@ -37,22 +46,6 @@ def test_load_missing_returns_empty(isolated_config) -> None: assert load_config() == {} -def test_resolve_precedence_explicit_wins(isolated_config, monkeypatch) -> None: - save_config({"auth_method": "api_key", "api_key": "from-file"}) - monkeypatch.setenv("DISCOLIKE_API_KEY", "from-env") - assert resolve_api_key("explicit") == "explicit" - assert resolve_api_key(None) == "from-env" - monkeypatch.delenv("DISCOLIKE_API_KEY") - assert resolve_api_key(None) == "from-file" - - -def test_resolve_nothing_raises_with_guidance(isolated_config) -> None: - with pytest.raises(AuthenticationError) as exc_info: - resolve_api_key(None) - assert "DISCOLIKE_API_KEY" in str(exc_info.value) - assert "discolike auth login" in str(exc_info.value) - - def test_corrupt_config_returns_empty(isolated_config) -> None: config_path().parent.mkdir(parents=True, exist_ok=True) config_path().write_text("{not json") @@ -63,3 +56,137 @@ def test_binary_garbage_config_returns_empty(isolated_config) -> None: config_path().parent.mkdir(parents=True, exist_ok=True) config_path().write_bytes(b"\xff\xfe\x00garbage") assert load_config() == {} + + +def _oauth_credential() -> OAuthCredential: + return OAuthCredential( + access_token="at", refresh_token="rt", expires_at=1.0, client_id="c", token_endpoint="https://t/token" + ) + + +def test_save_and_load_oauth_credential(isolated_config) -> None: + save_credential(_oauth_credential()) + stored = load_config() + assert stored["auth_method"] == "oauth" + assert stored["oauth"] == { + "access_token": "at", + "refresh_token": "rt", + "expires_at": 1.0, + "client_id": "c", + "token_endpoint": "https://t/token", + } + assert load_credential() == _oauth_credential() + + +def test_save_api_key_credential_keeps_legacy_shape(isolated_config) -> None: + save_credential(ApiKeyCredential(api_key="dk-1")) + assert load_config() == {"auth_method": "api_key", "api_key": "dk-1"} + + +def test_load_credential_without_auth_method_is_api_key(isolated_config) -> None: + save_config({"api_key": "legacy"}) + assert load_credential() == ApiKeyCredential(api_key="legacy") + assert resolve_credential() == ApiKeyCredential(api_key="legacy") + + +def test_load_credential_missing_returns_none(isolated_config) -> None: + assert load_credential() is None + + +def test_resolve_credential_precedence(isolated_config, monkeypatch) -> None: + save_credential(_oauth_credential()) + monkeypatch.setenv("DISCOLIKE_API_KEY", "from-env") + injected = ApiKeyCredential(api_key="injected") + assert resolve_credential(api_key="explicit", auth=injected) is injected + assert resolve_credential(api_key="explicit") == ApiKeyCredential(api_key="explicit") + assert resolve_credential() == ApiKeyCredential(api_key="from-env") + monkeypatch.delenv("DISCOLIKE_API_KEY") + assert resolve_credential() == _oauth_credential() + + +def test_resolve_credential_nothing_raises_with_guidance(isolated_config) -> None: + with pytest.raises(AuthenticationError, match="discolike auth login"): + resolve_credential() + + +def test_save_config_is_atomic_and_leaves_no_temp_files(isolated_config) -> None: + save_config({"auth_method": "api_key", "api_key": "first"}) + save_config({"auth_method": "api_key", "api_key": "second"}) + assert [entry.name for entry in config_path().parent.iterdir()] == [config_path().name] + assert load_config()["api_key"] == "second" + assert stat.S_IMODE(config_path().stat().st_mode) == 0o600 + + +REGISTRATION = OAuthClientRegistration( + client_id="client-1", redirect_uri="http://127.0.0.1:18484/callback", issuer="https://auth.test/oauth/2.1" +) + + +def test_oauth_client_registration_roundtrip_and_missing(isolated_config) -> None: + assert load_oauth_client() is None + save_oauth_client(REGISTRATION) + assert load_oauth_client() == REGISTRATION + assert load_config()["oauth_client"] == REGISTRATION.to_config() + + +def test_save_credential_preserves_oauth_client(isolated_config) -> None: + save_oauth_client(REGISTRATION) + save_credential(_oauth_credential()) + assert load_oauth_client() == REGISTRATION + assert load_credential() == _oauth_credential() + save_credential(ApiKeyCredential(api_key="dk-1")) + assert load_oauth_client() == REGISTRATION + assert load_credential() == ApiKeyCredential(api_key="dk-1") + + +def test_save_oauth_client_preserves_credential(isolated_config) -> None: + save_credential(_oauth_credential()) + save_oauth_client(REGISTRATION) + assert load_credential() == _oauth_credential() + + +def test_delete_credential_keeps_oauth_client(isolated_config) -> None: + save_oauth_client(REGISTRATION) + save_credential(_oauth_credential()) + delete_credential() + assert load_credential() is None + assert load_oauth_client() == REGISTRATION + assert load_config() == {"oauth_client": REGISTRATION.to_config()} + + +def test_delete_credential_without_oauth_client_removes_file(isolated_config) -> None: + save_credential(ApiKeyCredential(api_key="dk-1")) + delete_credential() + assert not config_path().exists() + delete_credential() + + +@pytest.mark.parametrize( + "config", + [ + {"auth_method": "oauth"}, + {"auth_method": "oauth", "oauth": "not-a-dict"}, + {"auth_method": "oauth", "oauth": {"access_token": "a", "refresh_token": "r"}}, + {"auth_method": "oauth", "oauth": {**_oauth_credential().to_config(), "expires_at": "soon"}}, + ], +) +def test_malformed_oauth_section_is_no_credential(isolated_config, config) -> None: + save_config(config) + assert load_credential() is None + with pytest.raises(AuthenticationError, match="discolike auth login"): + resolve_credential() + + +@pytest.mark.parametrize("stored", ["not-a-dict", {"client_id": "c"}, 7]) +def test_malformed_oauth_client_is_none(isolated_config, stored) -> None: + save_config({"oauth_client": stored}) + assert load_oauth_client() is None + + +def test_delete_oauth_client_keeps_credential(isolated_config) -> None: + save_credential(_oauth_credential()) + save_oauth_client(REGISTRATION) + delete_oauth_client() + assert load_oauth_client() is None + assert load_credential() == _oauth_credential() + delete_oauth_client() diff --git a/packages/discolike/tests/test_contacts.py b/packages/discolike/tests/test_contacts.py index 2ef38f4..ba13d2c 100644 --- a/packages/discolike/tests/test_contacts.py +++ b/packages/discolike/tests/test_contacts.py @@ -3,11 +3,20 @@ import json import httpx2 +import pydantic +import pytest from discolike._jobs import FAMILY_CONTACTMATCH from discolike._jobs import FAMILY_DISCOGEN from discolike._jobs import AsyncJob from discolike._jobs import Job +from discolike.requests import BulkContactMatchRequest +from discolike.requests import ContactFilters +from discolike.requests import ContactGenerateRequest +from discolike.requests import ContactsCountParams +from discolike.requests import ContactsLookupParams +from discolike.requests import ContactsMatchParams +from discolike.requests import ContactsSearchParams from discolike_testkit import AsyncClientFactory from discolike_testkit import ClientFactory @@ -26,10 +35,12 @@ def handler(request: httpx2.Request) -> httpx2.Response: with make_client(handler) as client: results = client.contacts.search( - seniority=["vp", "director"], - domain=["acme.com"], - jobstart_date="2025-01-01,2025-06-30", - max_records=25, + ContactsSearchParams( + seniority=["vp", "director"], + domain=["acme.com"], + jobstart_date="2025-01-01,2025-06-30", + max_records=25, + ) ) assert seen["path"] == "/v1/contacts" @@ -38,10 +49,21 @@ def handler(request: httpx2.Request) -> httpx2.Response: assert seen["params"]["domain"] == "acme.com" assert seen["params"]["jobstart_date"] == "2025-01-01,2025-06-30" assert seen["params"]["max_records"] == "25" + assert "has_email" not in seen["params"] assert results[0].persona_id == 1 assert results[0].model_extra["extra_field"] == "kept" # ty: ignore[not-subscriptable] +def test_search_rejects_unknown_seniority_before_any_request() -> None: + with pytest.raises(pydantic.ValidationError, match="seniority"): + ContactsSearchParams.model_validate({"seniority": ["intern"]}) + + +def test_search_rejects_max_records_below_floor_before_any_request() -> None: + with pytest.raises(pydantic.ValidationError, match="max_records"): + ContactsSearchParams(max_records=10) + + def test_count(make_client: ClientFactory) -> None: seen = {} @@ -52,7 +74,9 @@ def handler(request: httpx2.Request) -> httpx2.Response: return httpx2.Response(200, json={"count": 1234}) with make_client(handler) as client: - result = client.contacts.count(seniority=["vp"], has_email=True, jobstart_date="2025-01-01") + result = client.contacts.count( + ContactsCountParams(seniority=["vp"], has_email=True, jobstart_date="2025-01-01") + ) assert seen["path"] == "/v1/contacts/count" assert seen["method"] == "GET" @@ -68,13 +92,10 @@ def test_lookup(make_client: ClientFactory) -> None: def handler(request: httpx2.Request) -> httpx2.Response: seen["path"] = request.url.path seen["params"] = httpx2.QueryParams(request.url.query) - return httpx2.Response( - 200, - json={"persona_id": 12345678, "name": "Jane Doe", "domain": "example.com"}, - ) + return httpx2.Response(200, json={"persona_id": 12345678, "name": "Jane Doe", "domain": "example.com"}) with make_client(handler) as client: - result = client.contacts.lookup(persona_id=12345678, email="jane@example.com") + result = client.contacts.lookup(ContactsLookupParams(persona_id=12345678, email="jane@example.com")) assert seen["path"] == "/v1/contacts/lookup" assert seen["params"]["persona_id"] == "12345678" @@ -107,7 +128,7 @@ def handler(request: httpx2.Request) -> httpx2.Response: ) with make_client(handler) as client: - result = client.contacts.match(name="Jane Doe", company_name="Acme Corp", limit=5) + result = client.contacts.match(ContactsMatchParams(name="Jane Doe", company_name="Acme Corp", limit=5)) assert seen["path"] == "/v1/contacts/match" assert seen["params"]["name"] == "Jane Doe" @@ -128,9 +149,9 @@ def handler(request: httpx2.Request) -> httpx2.Response: with make_client(handler) as client: job = client.contacts.bulk_match( - queries=[{"name": "Jane Doe", "company_name": "Acme Corp"}], - enrich=True, - limit=5, + BulkContactMatchRequest.model_validate( + {"queries": [{"name": "Jane Doe", "company_name": "Acme Corp"}], "enrich": True, "limit": 5} + ) ) assert seen["path"] == "/v1/contacts/bulk-match" @@ -163,11 +184,13 @@ def handler(request: httpx2.Request) -> httpx2.Response: with make_client(handler) as client: result = client.contacts.discover( - domain=["acme.com"], - seniority=["vp"], - jobstart_date="2025-01-01", - results_by_company=10, - consensus=2, + ContactFilters( + domain=["acme.com"], + seniority=["vp"], + jobstart_date="2025-01-01", + results_by_company=10, + consensus=2, + ) ) assert seen["path"] == "/v1/contacts/discover" @@ -195,9 +218,11 @@ def handler(request: httpx2.Request) -> httpx2.Response: with make_client(handler) as client: job = client.contacts.generate( - icp_text="VPs of Marketing at B2B SaaS", - domains=["gusto.com", "rippling.com"], - context_mode="website", + ContactGenerateRequest( + icp_text="VPs of Marketing at B2B SaaS", + domains=["gusto.com", "rippling.com"], + context_mode="website", + ) ) assert seen["path"] == "/v1/contacts/discover/generate" @@ -217,7 +242,7 @@ def handler(request: httpx2.Request) -> httpx2.Response: return httpx2.Response(200, json=[{"persona_id": 2, "domain": "b.com"}]) async with make_async_client(handler) as client: - results = await client.contacts.search(domain=["b.com"]) + results = await client.contacts.search(ContactsSearchParams(domain=["b.com"])) assert results[0].persona_id == 2 @@ -226,7 +251,9 @@ def handler(request: httpx2.Request) -> httpx2.Response: return httpx2.Response(200, json={"task_id": "cm-2"}) async with make_async_client(handler) as client: - job = await client.contacts.bulk_match(queries=[{"name": "Jane Doe"}]) + job = await client.contacts.bulk_match( + BulkContactMatchRequest.model_validate({"queries": [{"name": "Jane Doe"}]}) + ) assert isinstance(job, AsyncJob) assert job.task_family == FAMILY_CONTACTMATCH @@ -238,7 +265,7 @@ def handler(request: httpx2.Request) -> httpx2.Response: return httpx2.Response(200, json={"task_id": "dg-2"}) async with make_async_client(handler) as client: - job = await client.contacts.generate(icp_text="VPs", domains=["a.com"]) + job = await client.contacts.generate(ContactGenerateRequest(icp_text="VPs", domains=["a.com"])) assert isinstance(job, AsyncJob) assert job.task_family == FAMILY_DISCOGEN @@ -249,10 +276,10 @@ def test_route_metadata_stamped() -> None: from discolike.resources._base import get_discolike_route from discolike.resources.contacts import ContactsResource - assert get_discolike_route(ContactsResource.search) == ("GET", "/contacts", True, ()) - assert get_discolike_route(ContactsResource.count) == ("GET", "/contacts/count", True, ()) - assert get_discolike_route(ContactsResource.lookup) == ("GET", "/contacts/lookup", True, ()) - assert get_discolike_route(ContactsResource.match) == ("GET", "/contacts/match", True, ()) - assert get_discolike_route(ContactsResource.bulk_match) == ("POST", "/contacts/bulk-match", True, ()) - assert get_discolike_route(ContactsResource.discover) == ("POST", "/contacts/discover", True, ()) - assert get_discolike_route(ContactsResource.generate) == ("POST", "/contacts/discover/generate", True, ()) + assert get_discolike_route(ContactsResource.search) == ("GET", "/contacts", True) + assert get_discolike_route(ContactsResource.count) == ("GET", "/contacts/count", True) + assert get_discolike_route(ContactsResource.lookup) == ("GET", "/contacts/lookup", True) + assert get_discolike_route(ContactsResource.match) == ("GET", "/contacts/match", True) + assert get_discolike_route(ContactsResource.bulk_match) == ("POST", "/contacts/bulk-match", True) + assert get_discolike_route(ContactsResource.discover) == ("POST", "/contacts/discover", True) + assert get_discolike_route(ContactsResource.generate) == ("POST", "/contacts/discover/generate", True) diff --git a/packages/discolike/tests/test_contract_registry.py b/packages/discolike/tests/test_contract_registry.py index 9f21171..00fca89 100644 --- a/packages/discolike/tests/test_contract_registry.py +++ b/packages/discolike/tests/test_contract_registry.py @@ -98,3 +98,116 @@ def test_mirrored_schemas_cover_the_shared_company_profile(): from discolike.resources.companies import CompanyProfile assert check_contract.MIRRORED_SCHEMAS["CompanyResult"] is CompanyProfile + + +def _route(check_contract, class_name: str, method_name: str): + return [ + route + for route in check_contract.collect_routes() + if route.class_name == class_name and route.method_name == method_name + ] + + +def _spec_with_params(path: str, names: list[str], *, deprecated: tuple[str, ...] = ()) -> dict: + parameters: list[dict[str, object]] = [{"name": name, "in": "query"} for name in names] + parameters.extend({"name": name, "in": "query", "deprecated": True} for name in deprecated) + return {"paths": {path: {"get": {"parameters": parameters}}}} + + +def test_collect_routes_reads_the_request_model_from_the_annotation(): + check_contract = _load_check_contract() + from discolike.requests import MatchCompanyParams + + (route,) = _route(check_contract, "MatchResource", "company") + assert route.request_model is MatchCompanyParams + + +def test_collect_routes_leaves_request_model_none_for_bare_routes(): + check_contract = _load_check_contract() + (route,) = _route(check_contract, "AccountResource", "usage") + assert route.request_model is None + + +def test_check_passes_when_model_fields_match_spec_params(): + check_contract = _load_check_contract() + from discolike.requests import MatchCompanyParams + + routes = _route(check_contract, "MatchResource", "company") + spec = _spec_with_params("/match", list(MatchCompanyParams.model_fields), deprecated=("nl_match",)) + assert check_contract.check(spec, routes) == [] + + +def test_check_reports_model_field_the_spec_lacks(): + check_contract = _load_check_contract() + from discolike.requests import MatchCompanyParams + + routes = _route(check_contract, "MatchResource", "company") + names = [name for name in MatchCompanyParams.model_fields if name != "zip_code"] + mismatches = check_contract.check(_spec_with_params("/match", names), routes) + assert mismatches == [ + "MatchResource.company (GET /match): field 'zip_code' of MatchCompanyParams not found in spec" + ] + + +def test_check_reports_spec_param_the_model_lacks(): + check_contract = _load_check_contract() + from discolike.requests import MatchCompanyParams + + routes = _route(check_contract, "MatchResource", "company") + names = [*MatchCompanyParams.model_fields, "brand_new"] + mismatches = check_contract.check(_spec_with_params("/match", names), routes) + assert mismatches == [ + "MatchResource.company (GET /match): spec param 'brand_new' not declared on MatchCompanyParams" + ] + + +def test_check_reports_params_on_a_route_without_a_model(): + check_contract = _load_check_contract() + routes = _route(check_contract, "AccountResource", "usage") + mismatches = check_contract.check(_spec_with_params("/usage", ["verbose"]), routes) + assert mismatches == [ + "AccountResource.usage (GET /usage): spec has param 'verbose' but the method takes no request model" + ] + + +def test_check_ignores_the_multipart_file_field(): + check_contract = _load_check_contract() + from discolike.requests import MatchBulkParams + + routes = _route(check_contract, "MatchResource", "bulk") + spec = { + "paths": { + "/bulkmatch": { + "post": { + "parameters": [{"name": name, "in": "query"} for name in MatchBulkParams.model_fields], + "requestBody": { + "content": {"multipart/form-data": {"schema": {"$ref": "#/components/schemas/Body_bulk_match"}}} + }, + } + } + }, + "components": {"schemas": {"Body_bulk_match": {"properties": {"file": {"type": "string"}}}}}, + } + assert check_contract.check(spec, routes) == [] + + +def test_check_compares_json_body_properties_bidirectionally(): + check_contract = _load_check_contract() + from discolike.requests import FindEmailRequest + + routes = _route(check_contract, "EmailResource", "find") + properties = {name: {} for name in FindEmailRequest.model_fields} + properties["legacy"] = {"deprecated": True} + spec = { + "paths": { + "/email/find": { + "post": { + "requestBody": { + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/FindEmailRequest"}}} + } + } + } + }, + "components": {"schemas": {"FindEmailRequest": {"properties": properties}}}, + } + assert check_contract.check(spec, routes) == [] diff --git a/packages/discolike/tests/test_discogen.py b/packages/discolike/tests/test_discogen.py index eca08a4..67e0bab 100644 --- a/packages/discolike/tests/test_discogen.py +++ b/packages/discolike/tests/test_discogen.py @@ -3,11 +3,15 @@ import json import httpx2 +import pydantic import pytest from discolike._jobs import FAMILY_DISCOGEN from discolike._jobs import AsyncJob from discolike._jobs import Job +from discolike.requests import DiscoGenPersonaProcessRequest +from discolike.requests import DiscoGenProcessRequest +from discolike.requests import ValidateIcpRequest from discolike_testkit import AsyncClientFactory from discolike_testkit import ClientFactory @@ -23,9 +27,7 @@ def handler(request: httpx2.Request) -> httpx2.Response: with make_client(handler) as client: job = client.discogen.process( - query="Recent funding rounds", - domains=["acme.com", "globex.com"], - web_search=True, + DiscoGenProcessRequest(query="Recent funding rounds", domains=["acme.com", "globex.com"], web_search=True) ) assert seen["path"] == "/v1/discogen/process" @@ -48,7 +50,7 @@ def handler(request: httpx2.Request) -> httpx2.Response: return httpx2.Response(200, json={"task_id": "dg-2"}) with make_client(handler) as client: - client.discogen.process(query="q", domains=["a.com"]) + client.discogen.process(DiscoGenProcessRequest(query="q", domains=["a.com"])) assert seen["body"] == {"query": "q", "domains": ["a.com"]} @@ -62,14 +64,16 @@ def handler(request: httpx2.Request) -> httpx2.Response: with make_client(handler) as client: client.discogen.process( - query="q", - domains=["a.com"], - integration_id="int-1", - web_search=True, - context_mode="website", - include_x_search=False, - search_provider_id="serper", - search_context_size="medium", + DiscoGenProcessRequest( + query="q", + domains=["a.com"], + integration_id="int-1", + web_search=True, + context_mode="website", + include_x_search=False, + search_provider_id="serper", + search_context_size="medium", + ) ) assert seen["body"] == { @@ -95,8 +99,7 @@ def handler(request: httpx2.Request) -> httpx2.Response: with make_client(handler) as client: job = client.discogen.process_personas( - query="Recent job changes", - persona_ids=[111, 222], + DiscoGenPersonaProcessRequest(query="Recent job changes", persona_ids=[111, 222]) ) assert seen["path"] == "/v1/discogen/process-personas" @@ -156,8 +159,7 @@ def handler(request: httpx2.Request) -> httpx2.Response: with make_client(handler) as client: job = client.validate_icp( - icp_text="VPs of Marketing at B2B SaaS", - domains=["gusto.com", "rippling.com"], + ValidateIcpRequest(icp_text="VPs of Marketing at B2B SaaS", domains=["gusto.com", "rippling.com"]) ) assert seen["path"] == "/v1/validate/icp" @@ -180,12 +182,14 @@ def handler(request: httpx2.Request) -> httpx2.Response: with make_client(handler) as client: client.validate_icp( - icp_text="q", - domains=["a.com"], - context_mode="website", - integration_id="int-1", - web_search=True, - search_provider_id="serper", + ValidateIcpRequest( + icp_text="q", + domains=["a.com"], + context_mode="website", + integration_id="int-1", + web_search=True, + search_provider_id="serper", + ) ) assert seen["body"] == { @@ -203,7 +207,7 @@ def handler(request: httpx2.Request) -> httpx2.Response: return httpx2.Response(200, json={"task_id": "dg-async-1"}) async with make_async_client(handler) as client: - job = await client.discogen.process(query="q", domains=["a.com"]) + job = await client.discogen.process(DiscoGenProcessRequest(query="q", domains=["a.com"])) assert isinstance(job, AsyncJob) assert job.task_family == FAMILY_DISCOGEN @@ -215,7 +219,7 @@ def handler(request: httpx2.Request) -> httpx2.Response: return httpx2.Response(200, json={"task_id": "dg-async-2"}) async with make_async_client(handler) as client: - job = await client.discogen.process_personas(query="q", persona_ids=[1]) + job = await client.discogen.process_personas(DiscoGenPersonaProcessRequest(query="q", persona_ids=[1])) assert isinstance(job, AsyncJob) assert job.task_family == FAMILY_DISCOGEN @@ -249,20 +253,30 @@ def handler(request: httpx2.Request) -> httpx2.Response: return httpx2.Response(200, json={"task_id": "val-async-1"}) async with make_async_client(handler) as client: - job = await client.validate_icp(icp_text="q", domains=["a.com"]) + job = await client.validate_icp(ValidateIcpRequest(icp_text="q", domains=["a.com"])) assert isinstance(job, AsyncJob) assert job.task_family == FAMILY_DISCOGEN assert job.task_id == "val-async-1" +def test_process_rejects_empty_domains_before_any_request() -> None: + with pytest.raises(pydantic.ValidationError, match="domains"): + DiscoGenProcessRequest(query="q", domains=[]) + + +def test_process_rejects_unknown_context_mode_before_any_request() -> None: + with pytest.raises(pydantic.ValidationError, match="context_mode"): + DiscoGenProcessRequest.model_validate({"query": "q", "domains": ["a.com"], "context_mode": "bogus"}) + + def test_route_metadata_stamped() -> None: from discolike.resources._base import get_discolike_route from discolike.resources.discogen import DiscogenResource from discolike.resources.discogen import ValidateResource - assert get_discolike_route(DiscogenResource.process) == ("POST", "/discogen/process", True, ()) - assert get_discolike_route(DiscogenResource.process_personas) == ("POST", "/discogen/process-personas", True, ()) - assert get_discolike_route(DiscogenResource.models) == ("GET", "/discogen/models", True, ()) + assert get_discolike_route(DiscogenResource.process) == ("POST", "/discogen/process", True) + assert get_discolike_route(DiscogenResource.process_personas) == ("POST", "/discogen/process-personas", True) + assert get_discolike_route(DiscogenResource.models) == ("GET", "/discogen/models", True) assert get_discolike_route(DiscogenResource.job) is None - assert get_discolike_route(ValidateResource.icp) == ("POST", "/validate/icp", True, ()) + assert get_discolike_route(ValidateResource.icp) == ("POST", "/validate/icp", True) diff --git a/packages/discolike/tests/test_discovery.py b/packages/discolike/tests/test_discovery.py index 208a252..a7d2309 100644 --- a/packages/discolike/tests/test_discovery.py +++ b/packages/discolike/tests/test_discovery.py @@ -1,7 +1,11 @@ from __future__ import annotations import httpx2 +import pydantic +import pytest +from discolike.requests import CountParams +from discolike.requests import DiscoverParams from discolike_testkit import AsyncClientFactory from discolike_testkit import ClientFactory @@ -18,24 +22,44 @@ def handler(request: httpx2.Request) -> httpx2.Response: ) with make_client(handler) as client: - results = client.discover(icp_prompt="B2B fintech in DE", country=["DE", "AT"], max_records=50) + results = client.discover(DiscoverParams(icp_prompt="B2B fintech in DE", country=["DE", "AT"], max_records=50)) assert seen["path"] == "/v1/discover" assert seen["params"].get_list("country") == ["DE", "AT"] assert seen["params"]["max_records"] == "50" assert "domain" not in dict(seen["params"]) + assert "offset" not in dict(seen["params"]) assert results[0].domain == "acme.com" assert results[0].similarity == 87.3 assert results[0].model_extra["extra_field"] == "kept" # ty: ignore[not-subscriptable] +def test_discover_passes_unknown_fields_through(make_client: ClientFactory) -> None: + seen = {} + + def handler(request: httpx2.Request) -> httpx2.Response: + seen["params"] = httpx2.QueryParams(request.url.query) + return httpx2.Response(200, json=[]) + + with make_client(handler) as client: + client.discover(DiscoverParams.model_validate({"future_flag": "on"})) + + assert seen["params"]["future_flag"] == "on" + + +def test_discover_rejects_out_of_range_similarity_before_any_request() -> None: + with pytest.raises(pydantic.ValidationError, match="min_similarity"): + DiscoverParams(min_similarity=200) + + def test_count(make_client: ClientFactory) -> None: def handler(request: httpx2.Request) -> httpx2.Response: assert request.url.path == "/v1/count" + assert httpx2.QueryParams(request.url.query)["category"] == "CYBERSECURITY" return httpx2.Response(200, json={"count": 1234}) with make_client(handler) as client: - result = client.count(category=["CYBERSECURITY"]) + result = client.count(CountParams(category=["CYBERSECURITY"])) assert result.count == 1234 @@ -44,5 +68,14 @@ def handler(request: httpx2.Request) -> httpx2.Response: return httpx2.Response(200, json=[{"domain": "a.com"}]) async with make_async_client(handler) as client: - results = await client.discover(domain=["stripe.com"]) + results = await client.discover(DiscoverParams(domain=["stripe.com"])) assert results[0].domain == "a.com" + + +async def test_count_async(make_async_client: AsyncClientFactory) -> None: + def handler(request: httpx2.Request) -> httpx2.Response: + return httpx2.Response(200, json={"count": 7}) + + async with make_async_client(handler) as client: + result = await client.count(CountParams(country=["US"])) + assert result.count == 7 diff --git a/packages/discolike/tests/test_email.py b/packages/discolike/tests/test_email.py index 5cd9867..682b7f2 100644 --- a/packages/discolike/tests/test_email.py +++ b/packages/discolike/tests/test_email.py @@ -3,10 +3,13 @@ import json import httpx2 +import pydantic import pytest import discolike._jobs as jobs_module from discolike import JobFailedError +from discolike.requests import FindEmailBatchRequest +from discolike.requests import FindEmailRequest from discolike.resources.email import AsyncEmailBatch from discolike.resources.email import AsyncEmailJob from discolike.resources.email import EmailBatch @@ -51,10 +54,14 @@ def handler(request: httpx2.Request) -> httpx2.Response: with make_client(handler) as client: batch = client.email.find_batch( - contacts=[ - {"first_name": "Ada", "last_name": "Lovelace", "domain": "acme.com"}, - {"first_name": "Alan", "last_name": "Turing", "domain": "acme.com"}, - ] + FindEmailBatchRequest.model_validate( + { + "requests": [ + {"first_name": "Ada", "last_name": "Lovelace", "domain": "acme.com"}, + {"first_name": "Alan", "last_name": "Turing", "domain": "acme.com"}, + ] + } + ) ) assert seen["path"] == "/v1/email/find/batch" @@ -228,7 +235,7 @@ def handler(request: httpx2.Request) -> httpx2.Response: return httpx2.Response(200, json=payload) with make_client(handler) as client: - job = client.email.find(first_name="Grace", last_name="Hopper", domain="navy.mil") + 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) @@ -249,8 +256,10 @@ def handler(request: httpx2.Request) -> httpx2.Response: return httpx2.Response(202, json={"job_id": "j-kp", "status": "queued"}) with make_client(handler) as client: - client.email.find(first_name="Grace", last_name="Hopper", domain="navy.mil", known_pattern="first.last") - client.email.find(first_name="Grace", last_name="Hopper", domain="navy.mil") + client.email.find( + FindEmailRequest(first_name="Grace", last_name="Hopper", domain="navy.mil", known_pattern="first.last") + ) + client.email.find(FindEmailRequest(first_name="Grace", last_name="Hopper", domain="navy.mil")) assert bodies[0] == { "first_name": "Grace", @@ -268,7 +277,7 @@ def handler(request: httpx2.Request) -> httpx2.Response: return httpx2.Response(200, json={"job_id": "j-x", "status": "failed", "result": None, "error": "boom"}) with make_client(handler) as client: - job = client.email.find(first_name="No", last_name="One", domain="void.dev") + 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) @@ -296,7 +305,9 @@ def handler(request: httpx2.Request) -> httpx2.Response: async with make_async_client(handler) as client: batch = await client.email.find_batch( - contacts=[{"first_name": "Ada", "last_name": "Lovelace", "domain": "acme.com"}] + FindEmailBatchRequest.model_validate( + {"requests": [{"first_name": "Ada", "last_name": "Lovelace", "domain": "acme.com"}]} + ) ) assert isinstance(batch, AsyncEmailBatch) @@ -379,7 +390,7 @@ def handler(request: httpx2.Request) -> httpx2.Response: return httpx2.Response(200, json=payload) async with make_async_client(handler) as client: - job = await client.email.find(first_name="Ada", last_name="Lovelace", domain="acme.com") + 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) @@ -388,12 +399,17 @@ def handler(request: httpx2.Request) -> httpx2.Response: assert output.result.tier == 2 +def test_find_batch_rejects_incomplete_contacts_before_any_request() -> None: + with pytest.raises(pydantic.ValidationError, match="last_name"): + FindEmailBatchRequest.model_validate({"requests": [{"first_name": "Ada", "domain": "acme.com"}]}) + + def test_route_metadata_stamped() -> None: from discolike.resources._base import get_discolike_route from discolike.resources.email import EmailResource - assert get_discolike_route(EmailResource.find) == ("POST", "/email/find", True, ()) - assert get_discolike_route(EmailResource.find_batch) == ("POST", "/email/find/batch", True, ("contacts",)) + assert get_discolike_route(EmailResource.find) == ("POST", "/email/find", True) + assert get_discolike_route(EmailResource.find_batch) == ("POST", "/email/find/batch", True) assert get_discolike_route(EmailResource.job) is None assert get_discolike_route(EmailResource.batch) is None diff --git a/packages/discolike/tests/test_enrich.py b/packages/discolike/tests/test_enrich.py index 8bdf041..bf92e6c 100644 --- a/packages/discolike/tests/test_enrich.py +++ b/packages/discolike/tests/test_enrich.py @@ -3,11 +3,15 @@ import io import httpx2 +import pydantic import pytest from discolike._jobs import FAMILY_SEGMENT from discolike._jobs import AsyncJob from discolike._jobs import Job +from discolike.requests import AppendParams +from discolike.requests import SegmentFileParams +from discolike.requests import SegmentParams from discolike_testkit import AsyncClientFactory from discolike_testkit import ClientFactory @@ -15,13 +19,16 @@ def test_append_json_response_parses_result_list(make_client: ClientFactory) -> None: def handler(request: httpx2.Request) -> httpx2.Response: assert request.url.path == "/v1/append" - params = dict(httpx2.QueryParams(request.url.query)) + params = httpx2.QueryParams(request.url.query) assert params["domain_column"] == "website" + assert params.get_list("dataset") == ["bizdata"] assert b"Acme" in request.content return httpx2.Response(200, json=[{"domain": "acme.com", "name": "Acme", "extra_field": "kept"}]) with make_client(handler) as client: - result = client._enrich.append(file=io.BytesIO(b"website\nAcme\n"), domain_column="website") + result = client._enrich.append( + AppendParams(dataset=["bizdata"], domain_column="website"), file=io.BytesIO(b"website\nAcme\n") + ) assert isinstance(result, list) assert result[0].domain == "acme.com" @@ -33,7 +40,7 @@ def handler(request: httpx2.Request) -> httpx2.Response: return httpx2.Response(200, content=b"col1,col2\n", headers={"Content-Type": "text/csv"}) with make_client(handler) as client: - result = client.append(file=io.BytesIO(b"domain\nacme.com\n"), csv=True) + result = client.append(AppendParams(dataset=["bizdata"], csv=True), file=io.BytesIO(b"domain\nacme.com\n")) assert result == b"col1,col2\n" @@ -48,34 +55,64 @@ def handler(request: httpx2.Request) -> httpx2.Response: return httpx2.Response(200, json=[]) with make_client(handler) as client: - client.append(file=csv_path, dataset=["bizdata", "growth"]) + client.append(AppendParams(dataset=["bizdata", "growth"]), file=csv_path) assert seen["params"].get_list("dataset") == ["bizdata", "growth"] +def test_append_without_file_uses_query_id_only(make_client: ClientFactory) -> None: + seen = {} + + def handler(request: httpx2.Request) -> httpx2.Response: + seen["params"] = httpx2.QueryParams(request.url.query) + seen["content"] = request.content + return httpx2.Response(200, json=[]) + + with make_client(handler) as client: + client.append(AppendParams(dataset=["bizdata"], query_id=["q1"])) + + assert seen["params"]["query_id"] == "q1" + assert seen["content"] == b"" + + +def test_append_raises_when_neither_file_nor_query_id_given(make_client: ClientFactory) -> None: + with ( + make_client(lambda request: httpx2.Response(200, json=[])) as client, + pytest.raises(ValueError, match="one of file or query_id is required"), + ): + client.append(AppendParams(dataset=["bizdata"])) + + +def test_append_rejects_unknown_dataset_before_any_request() -> None: + with pytest.raises(pydantic.ValidationError, match="dataset"): + AppendParams.model_validate({"dataset": ["bogus"]}) + + async def test_append_async_json_response(make_async_client: AsyncClientFactory) -> None: def handler(request: httpx2.Request) -> httpx2.Response: return httpx2.Response(200, json=[{"domain": "acme.com"}]) async with make_async_client(handler) as client: - result = await client.append(file=io.BytesIO(b"domain\nacme.com\n")) + result = await client.append(AppendParams(dataset=["bizdata"]), file=io.BytesIO(b"domain\nacme.com\n")) assert isinstance(result, list) assert result[0].domain == "acme.com" -def test_segment_domains_branch_comma_joins_and_returns_job(make_client: ClientFactory) -> None: +def test_segment_sends_comma_separated_domains_and_returns_job(make_client: ClientFactory) -> None: seen = {} def handler(request: httpx2.Request) -> httpx2.Response: seen["path"] = request.url.path + seen["method"] = request.method seen["params"] = httpx2.QueryParams(request.url.query) return httpx2.Response(200, json={"task_id": "seg-1"}) with make_client(handler) as client: - job = client.segment(domains=["acme.com", "beta.com"], max_segments=5) + job = client.segment(SegmentParams(domains="acme.com,beta.com", max_segments=5)) assert seen["path"] == "/v1/segment" + assert seen["method"] == "GET" assert seen["params"]["domains"] == "acme.com,beta.com" assert seen["params"]["max_segments"] == "5" assert isinstance(job, Job) @@ -83,7 +120,7 @@ def handler(request: httpx2.Request) -> httpx2.Response: assert job.task_id == "seg-1" -def test_segment_file_branch_returns_job(tmp_path, make_client: ClientFactory) -> None: +def test_segment_file_posts_upload_and_returns_job(tmp_path, make_client: ClientFactory) -> None: csv_path = tmp_path / "domains.csv" csv_path.write_text("domain\nacme.com\n") seen = {} @@ -91,61 +128,64 @@ def test_segment_file_branch_returns_job(tmp_path, make_client: ClientFactory) - def handler(request: httpx2.Request) -> httpx2.Response: seen["path"] = request.url.path seen["method"] = request.method + seen["params"] = httpx2.QueryParams(request.url.query) seen["content"] = request.content return httpx2.Response(200, json={"task_id": "seg-2"}) with make_client(handler) as client: - job = client.segment(file=csv_path, domain_column="domain") + job = client.segment_file(SegmentFileParams(domain_column="domain"), file=csv_path) assert seen["path"] == "/v1/segment" assert seen["method"] == "POST" + assert seen["params"]["domain_column"] == "domain" assert b"acme.com" in seen["content"] assert isinstance(job, Job) assert job.task_id == "seg-2" -def test_segment_raises_when_neither_domains_nor_file_given(make_client: ClientFactory) -> None: +def test_segment_raises_when_neither_domains_nor_query_id_given(make_client: ClientFactory) -> None: with ( make_client(lambda request: httpx2.Response(200, json={})) as client, - pytest.raises(ValueError, match="one of domains, query_id, or file is required"), + pytest.raises(ValueError, match="one of domains or query_id is required"), ): - client.segment() + client.segment(SegmentParams()) -def test_segment_raises_when_both_domains_and_file_given(tmp_path, make_client: ClientFactory) -> None: - csv_path = tmp_path / "domains.csv" - csv_path.write_text("domain\nacme.com\n") +def test_segment_rejects_max_segments_out_of_range_before_any_request() -> None: + with pytest.raises(pydantic.ValidationError, match="max_segments"): + SegmentParams(domains="acme.com", max_segments=99) - with ( - make_client(lambda request: httpx2.Response(200, json={})) as client, - pytest.raises(ValueError, match="file cannot be combined with domains or query_id"), - ): - client.segment(domains=["acme.com"], file=csv_path) +async def test_segment_async(make_async_client: AsyncClientFactory) -> None: + def handler(request: httpx2.Request) -> httpx2.Response: + return httpx2.Response(200, json={"task_id": "seg-3"}) -def test_segment_raises_when_domain_column_given_with_domains(make_client: ClientFactory) -> None: - with ( - make_client(lambda request: httpx2.Response(200, json={})) as client, - pytest.raises(ValueError, match="domain_column only applies to file uploads"), - ): - client.segment(domains=["acme.com"], domain_column="domain") + async with make_async_client(handler) as client: + job = await client.segment(SegmentParams(domains="acme.com")) + assert isinstance(job, AsyncJob) + assert job.task_id == "seg-3" + + +async def test_segment_file_async(tmp_path, make_async_client: AsyncClientFactory) -> None: + csv_path = tmp_path / "domains.csv" + csv_path.write_text("domain\nacme.com\n") -async def test_segment_async_domains_branch(make_async_client: AsyncClientFactory) -> None: def handler(request: httpx2.Request) -> httpx2.Response: - return httpx2.Response(200, json={"task_id": "seg-3"}) + assert request.method == "POST" + return httpx2.Response(200, json={"task_id": "seg-4"}) async with make_async_client(handler) as client: - job = await client.segment(domains=["acme.com"]) + job = await client.segment_file(SegmentFileParams(), file=csv_path) assert isinstance(job, AsyncJob) - assert job.task_id == "seg-3" + assert job.task_id == "seg-4" def test_route_metadata_stamped() -> None: from discolike.resources._base import get_discolike_route from discolike.resources.enrich import EnrichResource - assert get_discolike_route(EnrichResource.append) == ("POST", "/append", True, ()) - assert get_discolike_route(EnrichResource.segment) == ("GET", "/segment", True, ("domain_column",)) - assert get_discolike_route(EnrichResource._segment_file) == ("POST", "/segment", True, ()) + assert get_discolike_route(EnrichResource.append) == ("POST", "/append", True) + assert get_discolike_route(EnrichResource.segment) == ("GET", "/segment", True) + assert get_discolike_route(EnrichResource.segment_file) == ("POST", "/segment", True) diff --git a/packages/discolike/tests/test_gen_requests.py b/packages/discolike/tests/test_gen_requests.py new file mode 100644 index 0000000..26d5421 --- /dev/null +++ b/packages/discolike/tests/test_gen_requests.py @@ -0,0 +1,244 @@ +import importlib.util +import pathlib +import sys +from typing import Any + +import pytest + +SCRIPT_PATH = pathlib.Path(__file__).parents[3] / "scripts" / "gen_requests.py" + +FAKE_SPEC: dict[str, Any] = { + "openapi": "3.1.0", + "paths": { + "/match": { + "get": { + "parameters": [ + {"name": "name", "in": "query", "required": True, "schema": {"type": "string"}}, + { + "name": "city", + "in": "query", + "required": False, + "schema": {"anyOf": [{"type": "string"}, {"type": "null"}]}, + }, + { + "name": "nl_match", + "in": "query", + "required": False, + "deprecated": True, + "schema": {"type": "string"}, + }, + ] + } + }, + "/bulkmatch": { + "post": { + "parameters": [{"name": "name_column", "in": "query", "required": True, "schema": {"type": "string"}}], + "requestBody": { + "content": {"multipart/form-data": {"schema": {"$ref": "#/components/schemas/Body_bulk_match"}}} + }, + } + }, + "/email/find/batch": { + "post": { + "requestBody": { + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/FindEmailBatchRequest"}}} + } + } + }, + "/usage": {"get": {"parameters": []}}, + }, + "components": { + "schemas": { + "Body_bulk_match": { + "type": "object", + "properties": {"file": {"type": "string", "format": "binary"}}, + "required": ["file"], + }, + "FindEmailBatchRequest": { + "type": "object", + "properties": { + "requests": {"type": "array", "items": {"$ref": "#/components/schemas/FindEmailRequest"}} + }, + "required": ["requests"], + }, + "FindEmailRequest": { + "type": "object", + "additionalProperties": False, + "properties": { + "first_name": {"type": "string"}, + "known_pattern": { + "anyOf": [{"type": "string", "maxLength": 40}, {"type": "null"}], + "description": "Known pattern", + }, + "legacy": {"type": "string", "deprecated": True}, + }, + "required": ["first_name"], + }, + "Unrelated": {"type": "object", "properties": {"x": {"type": "integer"}}}, + } + }, +} + + +SIBLING_REF_SPEC: dict[str, Any] = { + "components": { + "schemas": { + "Root": { + "type": "object", + "properties": { + "zebra": {"$ref": "#/components/schemas/Zebra"}, + "alpha": {"$ref": "#/components/schemas/Alpha"}, + "middle": {"$ref": "#/components/schemas/Middle"}, + }, + }, + "Zebra": {"type": "object", "properties": {"z": {"type": "string"}}}, + "Alpha": {"type": "object", "properties": {"a": {"type": "string"}}}, + "Middle": {"type": "object", "properties": {"m": {"type": "string"}}}, + } + } +} + + +@pytest.fixture(scope="module") +def gen(): + spec = importlib.util.spec_from_file_location("gen_requests", SCRIPT_PATH) + assert spec is not None + assert spec.loader is not None + module = importlib.util.module_from_spec(spec) + sys.modules[spec.name] = module + spec.loader.exec_module(module) + return module + + +@pytest.fixture +def routes(gen): + return [ + gen.Route("MatchResource", "company", "GET", "/match"), + gen.Route("MatchResource", "bulk", "POST", "/bulkmatch"), + gen.Route("EmailResource", "find_batch", "POST", "/email/find/batch"), + gen.Route("AccountResource", "usage", "GET", "/usage"), + ] + + +@pytest.mark.parametrize( + ("class_name", "method_name", "expected"), + [ + ("MatchResource", "company", "MatchCompanyParams"), + ("CompaniesResource", "public_links", "CompaniesPublicLinksParams"), + ("EnrichResource", "segment_file", "SegmentFileParams"), + ("DiscoveryResource", "discover", "DiscoverParams"), + ("ValidateResource", "icp", "IcpParams"), + ], +) +def test_params_model_name(gen, class_name, method_name, expected) -> None: + assert gen.params_model_name(class_name=class_name, method_name=method_name) == expected + + +def test_request_schemas_synthesizes_params_from_query_parameters(gen, routes) -> None: + schemas = gen.request_schemas(spec=FAKE_SPEC, routes=routes) + assert schemas["MatchCompanyParams"] == { + "type": "object", + "properties": {"name": {"type": "string"}, "city": {"anyOf": [{"type": "string"}, {"type": "null"}]}}, + "required": ["name"], + } + + +def test_request_schemas_skips_multipart_body_and_keeps_query_params(gen, routes) -> None: + schemas = gen.request_schemas(spec=FAKE_SPEC, routes=routes) + assert schemas["MatchBulkParams"] == { + "type": "object", + "properties": {"name_column": {"type": "string"}}, + "required": ["name_column"], + } + assert "Body_bulk_match" not in schemas + + +def test_request_schemas_uses_the_json_body_component_name(gen, routes) -> None: + schemas = gen.request_schemas(spec=FAKE_SPEC, routes=routes) + assert schemas["FindEmailBatchRequest"] == FAKE_SPEC["components"]["schemas"]["FindEmailBatchRequest"] + + +def test_request_schemas_emits_nothing_for_routes_without_params(gen, routes) -> None: + schemas = gen.request_schemas(spec=FAKE_SPEC, routes=routes) + assert "AccountUsageParams" not in schemas + + +def test_request_schemas_fails_loudly_when_a_route_is_missing_from_the_spec(gen) -> None: + with pytest.raises(SystemExit, match="GET /nowhere"): + gen.request_schemas(spec=FAKE_SPEC, routes=[gen.Route("X", "y", "GET", "/nowhere")]) + + +def test_prune_keeps_transitive_refs_and_drops_unrelated_schemas(gen) -> None: + requested = {"FindEmailBatchRequest": FAKE_SPEC["components"]["schemas"]["FindEmailBatchRequest"]} + assert set(gen.prune(spec=FAKE_SPEC, requested=requested)) == {"FindEmailBatchRequest", "FindEmailRequest"} + + +def test_prune_orders_sibling_refs_deterministically(gen) -> None: + requested = {"Root": SIBLING_REF_SPEC["components"]["schemas"]["Root"]} + kept = list(gen.prune(spec=SIBLING_REF_SPEC, requested=requested)) + assert kept == ["Root", "Alpha", "Middle", "Zebra"] + assert kept == list(gen.prune(spec=SIBLING_REF_SPEC, requested=requested)) + + +def test_normalize_schema_inlines_nullable_and_drops_deprecated_and_additional_properties(gen) -> None: + normalized = gen.normalize_schema(FAKE_SPEC["components"]["schemas"]["FindEmailRequest"]) + assert "additionalProperties" not in normalized + assert "legacy" not in normalized["properties"] + assert normalized["properties"]["known_pattern"] == { + "type": "string", + "maxLength": 40, + "description": "Known pattern", + "nullable": True, + } + assert normalized["required"] == ["first_name"] + + +def test_normalize_schema_leaves_required_nullable_unions_alone(gen) -> None: + schema = { + "type": "object", + "properties": {"api_key": {"anyOf": [{"type": "string"}, {"type": "null"}]}}, + "required": ["api_key"], + } + assert gen.normalize_schema(schema)["properties"]["api_key"] == {"anyOf": [{"type": "string"}, {"type": "null"}]} + + +def test_normalize_schema_strips_scalar_item_constraints(gen) -> None: + schema = { + "type": "object", + "properties": {"tags": {"type": "array", "items": {"type": "string", "minLength": 3}, "maxItems": 20}}, + } + assert gen.normalize_schema(schema)["properties"]["tags"] == { + "type": "array", + "items": {"type": "string"}, + "maxItems": 20, + } + + +def test_build_codegen_spec_wraps_pruned_schemas(gen, routes) -> None: + codegen_spec = gen.build_codegen_spec(spec=FAKE_SPEC, routes=routes) + assert codegen_spec["paths"] == {} + assert set(codegen_spec["components"]["schemas"]) == { + "MatchCompanyParams", + "MatchBulkParams", + "FindEmailBatchRequest", + "FindEmailRequest", + } + + +def test_compare_returns_zero_when_identical(gen, capsys) -> None: + assert gen.compare(committed="a\n", fresh="a\n") == 0 + assert "up to date" in capsys.readouterr().out + + +def test_compare_prints_a_diff_and_returns_one_on_drift(gen, capsys) -> None: + assert gen.compare(committed="a\n", fresh="b\n") == 1 + out = capsys.readouterr().out + assert "-a" in out + assert "+b" in out + assert "gen_requests.py" in out + + +def test_collect_routes_covers_every_stamped_sync_route(gen) -> None: + routes = gen.collect_routes() + assert len(routes) == 48 + 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 5dd44c4..d71a463 100644 --- a/packages/discolike/tests/test_jobs.py +++ b/packages/discolike/tests/test_jobs.py @@ -9,6 +9,7 @@ from discolike._jobs import Job from discolike._transport import AsyncTransport from discolike._transport import Transport +from discolike_testkit import api_key_auth BASE = "https://api.test/v1" @@ -25,7 +26,7 @@ async def fake_sleep(seconds: float) -> None: def make_job(handler) -> Job: http = httpx2.Client(transport=httpx2.MockTransport(handler), base_url=BASE) - transport = Transport("k", base_url=BASE, timeout=5.0, max_retries=0, http_client=http) + transport = Transport(api_key_auth("k"), base_url=BASE, timeout=5.0, max_retries=0, http_client=http) return Job(transport, task_family=FAMILY_DISCOGEN, task_id="t-1") @@ -54,6 +55,39 @@ def test_wait_polls_to_completion() -> None: assert final.results == [{"domain": "a.com"}] +def test_status_exposes_cost_metadata_and_warnings() -> None: + payload = { + "status": "completed", + "progress": 100, + "results": {"a.com": "yes"}, + "estimated_cost": 0.0283, + "warnings": ["Search provider out of credits"], + "cost_metadata": { + "openai/gpt-4o-mini": {"calls": 10, "search_calls": 0, "est_cost_usd": 0.0083}, + "search_provider": { + "provider": "serper", + "search_model": "serper/search", + "queries_executed": 20, + "queries_succeeded": 20, + "est_cost_usd": 0.02, + }, + }, + } + final = make_job(_status_sequence([payload])).status() + assert final.estimated_cost == 0.0283 + assert final.warnings == ["Search provider out of credits"] + assert final.cost_metadata is not None + assert final.cost_metadata["search_provider"]["queries_executed"] == 20 + assert final.cost_metadata["openai/gpt-4o-mini"]["search_calls"] == 0 + + +def test_status_without_cost_fields_defaults_to_none() -> None: + final = make_job(_status_sequence([{"status": "in_progress", "progress": 10}])).status() + assert final.estimated_cost is None + assert final.cost_metadata is None + assert final.warnings == [] + + def test_wait_failed_raises() -> None: handler = _status_sequence([{"status": "failed", "progress": 100, "result": "LLM exploded"}]) with pytest.raises(JobFailedError, match="LLM exploded"): @@ -90,6 +124,6 @@ async def test_async_job_wait() -> None: [{"status": "in_progress", "progress": 5}, {"status": "completed", "progress": 100, "results": []}] ) http = httpx2.AsyncClient(transport=httpx2.MockTransport(handler), base_url=BASE) - transport = AsyncTransport("k", base_url=BASE, timeout=5.0, max_retries=0, http_client=http) + 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) assert final.status == "completed" diff --git a/packages/discolike/tests/test_match.py b/packages/discolike/tests/test_match.py index df1dcd0..bbec141 100644 --- a/packages/discolike/tests/test_match.py +++ b/packages/discolike/tests/test_match.py @@ -1,10 +1,14 @@ import io import httpx2 +import pydantic +import pytest from discolike._jobs import FAMILY_BULKMATCH from discolike._jobs import AsyncJob from discolike._jobs import Job +from discolike.requests import MatchBulkParams +from discolike.requests import MatchCompanyParams from discolike_testkit import AsyncClientFactory from discolike_testkit import ClientFactory @@ -18,17 +22,16 @@ def handler(request: httpx2.Request) -> httpx2.Response: return httpx2.Response(200, json={"domain": "acme.com", "anything": 1}) with make_client(handler) as client: - result = client.match.company(name="Acme Inc", city="Austin", strict=True, min_match_confidence=80) + result = client.match.company( + MatchCompanyParams(name="Acme Inc", city="Austin", strict=True, min_match_confidence=80) + ) assert seen["path"] == "/v1/match" - assert seen["params"]["name"] == "Acme Inc" - assert seen["params"]["city"] == "Austin" - assert seen["params"]["strict"] == "true" - assert seen["params"]["min_match_confidence"] == "80" + assert seen["params"] == {"name": "Acme Inc", "city": "Austin", "strict": "true", "min_match_confidence": "80"} assert result.model_extra["anything"] == 1 # ty: ignore[not-subscriptable] -def test_company_omits_min_match_confidence_when_not_set(make_client: ClientFactory) -> None: +def test_company_omits_everything_not_set(make_client: ClientFactory) -> None: seen = {} def handler(request: httpx2.Request) -> httpx2.Response: @@ -36,9 +39,14 @@ def handler(request: httpx2.Request) -> httpx2.Response: return httpx2.Response(200, json={"domain": "acme.com"}) with make_client(handler) as client: - client.match.company(name="Acme Inc") + client.match.company(MatchCompanyParams(name="Acme Inc")) - assert "min_match_confidence" not in seen["params"] + assert seen["params"] == {"name": "Acme Inc"} + + +def test_company_rejects_out_of_range_confidence_before_any_request() -> None: + with pytest.raises(pydantic.ValidationError, match="min_match_confidence"): + MatchCompanyParams(name="Acme Inc", min_match_confidence=10) def test_bulk_posts_multipart_with_path(tmp_path, make_client: ClientFactory) -> None: @@ -53,11 +61,10 @@ def handler(request: httpx2.Request) -> httpx2.Response: return httpx2.Response(200, json={"task_id": "bm-1"}) with make_client(handler) as client: - job = client.match.bulk(file=csv_path, name_column="company", min_match_confidence=80) + job = client.match.bulk(MatchBulkParams(name_column="company", min_match_confidence=80), file=csv_path) assert seen["path"] == "/v1/bulkmatch" - assert seen["params"]["name_column"] == "company" - assert seen["params"]["min_match_confidence"] == "80" + assert seen["params"] == {"name_column": "company", "min_match_confidence": "80"} assert b"Acme" in seen["content"] assert isinstance(job, Job) assert job.task_family == FAMILY_BULKMATCH @@ -72,12 +79,26 @@ def handler(request: httpx2.Request) -> httpx2.Response: return httpx2.Response(200, json={"task_id": "bm-2"}) with make_client(handler) as client: - job = client.match.bulk(file=handle, name_column="company") + job = client.match.bulk(MatchBulkParams(name_column="company"), file=handle) assert job.task_id == "bm-2" assert handle.closed is False +async def test_async_company(make_async_client: AsyncClientFactory) -> None: + seen = {} + + def handler(request: httpx2.Request) -> httpx2.Response: + seen["params"] = dict(httpx2.QueryParams(request.url.query)) + return httpx2.Response(200, json={"matches": []}) + + async with make_async_client(handler) as client: + result = await client.match.company(MatchCompanyParams(name="Acme Inc", local_mode=True)) + + assert seen["params"] == {"name": "Acme Inc", "local_mode": "true"} + assert result.matches == [] + + async def test_async_bulk_posts_multipart(tmp_path, make_async_client: AsyncClientFactory) -> None: csv_path = tmp_path / "companies.csv" csv_path.write_text("company\nAcme\n") @@ -90,10 +111,10 @@ def handler(request: httpx2.Request) -> httpx2.Response: return httpx2.Response(200, json={"task_id": "bm-3"}) async with make_async_client(handler) as client: - job = await client.match.bulk(file=csv_path, name_column="company") + job = await client.match.bulk(MatchBulkParams(name_column="company"), file=csv_path) assert seen["path"] == "/v1/bulkmatch" - assert seen["params"]["name_column"] == "company" + assert seen["params"] == {"name_column": "company"} assert b"Acme" in seen["content"] assert isinstance(job, AsyncJob) assert job.task_family == FAMILY_BULKMATCH diff --git a/packages/discolike/tests/test_models.py b/packages/discolike/tests/test_models.py new file mode 100644 index 0000000..1b8b12a --- /dev/null +++ b/packages/discolike/tests/test_models.py @@ -0,0 +1,38 @@ +import pydantic +import pytest + +from discolike._models import DiscolikeRequest + + +class _Probe(DiscolikeRequest): + name: str + city: str | None = None + limit: int = 10 + zip_code: str | None = pydantic.Field(default=None, alias="zip") + + +def test_to_wire_sends_only_fields_that_were_set() -> None: + assert _Probe(name="Acme").to_wire() == {"name": "Acme"} + + +def test_to_wire_keeps_an_explicit_none() -> None: + assert _Probe(name="Acme", city=None).to_wire() == {"name": "Acme", "city": None} + + +def test_to_wire_passes_unknown_fields_through() -> None: + assert _Probe.model_validate({"name": "Acme", "bogus": 1}).to_wire() == {"name": "Acme", "bogus": 1} + + +def test_populate_by_name_accepts_the_field_name_and_dumps_the_alias() -> None: + assert _Probe(name="Acme", zip_code="78701").to_wire() == {"name": "Acme", "zip": "78701"} + + +def test_missing_required_field_raises_validation_error() -> None: + with pytest.raises(pydantic.ValidationError): + _Probe.model_validate({}) + + +def test_discolike_request_is_exported_from_the_package() -> None: + import discolike + + assert discolike.DiscolikeRequest is DiscolikeRequest diff --git a/packages/discolike/tests/test_oauth.py b/packages/discolike/tests/test_oauth.py new file mode 100644 index 0000000..daa4fb0 --- /dev/null +++ b/packages/discolike/tests/test_oauth.py @@ -0,0 +1,249 @@ +import base64 +import hashlib +import time +from urllib.parse import parse_qs +from urllib.parse import urlparse + +import httpx2 +import pytest + +from discolike import AuthenticationError +from discolike._credentials import OAuthCredential +from discolike._oauth import CLIENT_NAME +from discolike._oauth import AuthServerMetadata +from discolike._oauth import OAuthError +from discolike._oauth import build_authorization_url +from discolike._oauth import discover +from discolike._oauth import exchange_code +from discolike._oauth import parse_refresh_response +from discolike._oauth import pkce_pair +from discolike._oauth import refresh_request +from discolike._oauth import register_client + +BASE_URL = "https://api.test/v1" +METADATA = AuthServerMetadata( + authorization_endpoint="https://auth.test/oauth/2.1/authorize", + token_endpoint="https://auth.test/oauth/2.1/token", + registration_endpoint="https://auth.test/oauth/2.1/register", + issuer="https://auth.test/oauth/2.1", +) + + +def client_for(handler) -> httpx2.Client: + return httpx2.Client(transport=httpx2.MockTransport(handler)) + + +def form(request: httpx2.Request) -> dict[str, str]: + return {key: values[0] for key, values in parse_qs(request.content.decode()).items()} + + +def test_pkce_pair_is_s256() -> None: + verifier, challenge = pkce_pair() + expected = base64.urlsafe_b64encode(hashlib.sha256(verifier.encode()).digest()).rstrip(b"=").decode() + assert challenge == expected + assert "=" not in verifier + assert 43 <= len(verifier) <= 128 + + +def test_discover_reads_well_known_under_base_url() -> None: + seen: list[httpx2.Request] = [] + + def handler(request: httpx2.Request) -> httpx2.Response: + seen.append(request) + return httpx2.Response( + 200, + json={ + "issuer": METADATA.issuer, + "authorization_endpoint": METADATA.authorization_endpoint, + "token_endpoint": METADATA.token_endpoint, + "registration_endpoint": METADATA.registration_endpoint, + }, + ) + + assert discover(BASE_URL + "/", client=client_for(handler)) == METADATA + assert str(seen[0].url) == "https://api.test/v1/.well-known/oauth-authorization-server" + + +def test_discover_missing_field_raises() -> None: + def handler(request: httpx2.Request) -> httpx2.Response: + return httpx2.Response(200, json={"authorization_endpoint": "x", "token_endpoint": "y"}) + + with pytest.raises(AuthenticationError, match="registration_endpoint"): + discover(BASE_URL, client=client_for(handler)) + + +def test_register_client_sends_public_client_metadata() -> None: + seen: list[httpx2.Request] = [] + + def handler(request: httpx2.Request) -> httpx2.Response: + seen.append(request) + return httpx2.Response(201, json={"client_id": "client-abc", "client_secret": ""}) + + client_id = register_client(METADATA, redirect_uris=["http://127.0.0.1:9999/callback"], client=client_for(handler)) + assert client_id == "client-abc" + request = seen[0] + assert request.method == "POST" + assert str(request.url) == METADATA.registration_endpoint + body = httpx2.Response(200, content=request.content).json() + assert body["client_name"] == CLIENT_NAME + assert body["redirect_uris"] == ["http://127.0.0.1:9999/callback"] + assert body["grant_types"] == ["authorization_code", "refresh_token"] + assert body["response_types"] == ["code"] + assert body["token_endpoint_auth_method"] == "none" + + +def test_build_authorization_url_carries_pkce_state_and_resource() -> None: + url = build_authorization_url( + METADATA, + client_id="client-abc", + redirect_uri="http://127.0.0.1:9999/callback", + code_challenge="chal", + state="st", + resource=BASE_URL, + ) + parsed = urlparse(url) + assert f"{parsed.scheme}://{parsed.netloc}{parsed.path}" == METADATA.authorization_endpoint + query = {key: values[0] for key, values in parse_qs(parsed.query).items()} + assert query == { + "response_type": "code", + "client_id": "client-abc", + "redirect_uri": "http://127.0.0.1:9999/callback", + "code_challenge": "chal", + "code_challenge_method": "S256", + "state": "st", + "resource": BASE_URL, + "scope": "offline_access", + } + + +def test_exchange_code_posts_form_and_builds_credential() -> None: + seen: list[httpx2.Request] = [] + + def handler(request: httpx2.Request) -> httpx2.Response: + seen.append(request) + return httpx2.Response( + 200, json={"access_token": "at", "refresh_token": "rt", "expires_in": 3600, "token_type": "Bearer"} + ) + + before = time.time() + credential = exchange_code( + METADATA, + client_id="client-abc", + code="the-code", + code_verifier="ver", + redirect_uri="http://127.0.0.1:9999/callback", + resource=BASE_URL, + client=client_for(handler), + ) + request = seen[0] + assert request.headers["Content-Type"] == "application/x-www-form-urlencoded" + assert form(request) == { + "grant_type": "authorization_code", + "client_id": "client-abc", + "code": "the-code", + "code_verifier": "ver", + "redirect_uri": "http://127.0.0.1:9999/callback", + "resource": BASE_URL, + } + assert credential.access_token == "at" + assert credential.refresh_token == "rt" + assert credential.client_id == "client-abc" + assert credential.token_endpoint == METADATA.token_endpoint + assert before + 3600 <= credential.expires_at <= time.time() + 3600 + + +def test_exchange_code_without_refresh_token_raises() -> None: + def handler(request: httpx2.Request) -> httpx2.Response: + return httpx2.Response(200, json={"access_token": "at", "expires_in": 3600}) + + with pytest.raises(AuthenticationError, match="refresh_token"): + exchange_code( + METADATA, + client_id="c", + code="x", + code_verifier="v", + redirect_uri="http://127.0.0.1:1/callback", + resource=BASE_URL, + client=client_for(handler), + ) + + +def test_malformed_token_response_error_payload_carries_no_tokens() -> None: + def handler(request: httpx2.Request) -> httpx2.Response: + return httpx2.Response(200, json={"access_token": "at", "refresh_token": "rt", "token_type": "Bearer"}) + + with pytest.raises(AuthenticationError, match="expires_in") as info: + exchange_code( + METADATA, + client_id="c", + code="x", + code_verifier="v", + redirect_uri="http://127.0.0.1:1/callback", + resource=BASE_URL, + client=client_for(handler), + ) + assert info.value.payload == {"token_type": "Bearer"} + + +def test_oauth_error_body_maps_to_authentication_error() -> None: + def handler(request: httpx2.Request) -> httpx2.Response: + return httpx2.Response(400, json={"error": "invalid_grant", "error_description": "code expired"}) + + with pytest.raises(OAuthError, match="invalid_grant: code expired") as exc_info: + exchange_code( + METADATA, + client_id="c", + code="x", + code_verifier="v", + redirect_uri="http://127.0.0.1:1/callback", + resource=BASE_URL, + client=client_for(handler), + ) + assert exc_info.value.status_code == 400 + assert exc_info.value.error == "invalid_grant" + + +def test_non_json_error_maps_to_authentication_error() -> None: + def handler(request: httpx2.Request) -> httpx2.Response: + return httpx2.Response(502, text="bad gateway") + + with pytest.raises(AuthenticationError, match="502"): + discover(BASE_URL, client=client_for(handler)) + + +def test_refresh_rotates_tokens_and_keeps_old_refresh_token_when_absent() -> None: + credential = OAuthCredential( + access_token="old", + refresh_token="rt-old", + expires_at=0.0, + client_id="c", + token_endpoint=METADATA.token_endpoint, + ) + seen: list[httpx2.Request] = [] + + def rotating(request: httpx2.Request) -> httpx2.Response: + seen.append(request) + return httpx2.Response(200, json={"access_token": "new", "refresh_token": "rt-new", "expires_in": 60}) + + with client_for(rotating) as client: + rotated = parse_refresh_response(client.send(refresh_request(credential)), credential=credential) + assert form(seen[0]) == {"grant_type": "refresh_token", "refresh_token": "rt-old", "client_id": "c"} + assert (rotated.access_token, rotated.refresh_token) == ("new", "rt-new") + assert rotated.client_id == "c" + assert rotated.token_endpoint == METADATA.token_endpoint + + def not_rotating(request: httpx2.Request) -> httpx2.Response: + return httpx2.Response(200, json={"access_token": "newer", "expires_in": 60}) + + with client_for(not_rotating) as client: + kept = parse_refresh_response(client.send(refresh_request(rotated)), credential=rotated) + assert (kept.access_token, kept.refresh_token) == ("newer", "rt-new") + + +def test_credential_config_roundtrip_and_expiry() -> None: + credential = OAuthCredential( + access_token="a", refresh_token="r", expires_at=1000.0, client_id="c", token_endpoint="https://t" + ) + assert OAuthCredential.from_config(credential.to_config()) == credential + assert credential.expires_within(60, now=950.0) + assert not credential.expires_within(60, now=900.0) diff --git a/packages/discolike/tests/test_package.py b/packages/discolike/tests/test_package.py index 107096e..cd97cc1 100644 --- a/packages/discolike/tests/test_package.py +++ b/packages/discolike/tests/test_package.py @@ -2,4 +2,4 @@ def test_version() -> None: - assert discolike.__version__ == "0.2.0" + assert discolike.__version__ == "0.3.0" diff --git a/packages/discolike/tests/test_providers.py b/packages/discolike/tests/test_providers.py index 184e699..32b426a 100644 --- a/packages/discolike/tests/test_providers.py +++ b/packages/discolike/tests/test_providers.py @@ -3,8 +3,12 @@ import json import httpx2 +import pydantic import pytest +from discolike.requests import LLMProviderCreateRequest +from discolike.requests import LLMProviderUpdateRequest +from discolike.requests import SearchProviderRequest from discolike_testkit import AsyncClientFactory from discolike_testkit import ClientFactory @@ -36,10 +40,9 @@ def handler(request: httpx2.Request) -> httpx2.Response: with make_client(handler) as client: result = client.search_providers.create( - integration_name="Tavily", - provider="tavily", - search_model="tavily/search", - api_key="tvly-key", + SearchProviderRequest( + integration_name="Tavily", provider="tavily", search_model="tavily/search", api_key="tvly-key" + ) ) assert seen["path"] == "/v1/search-providers" @@ -64,10 +67,8 @@ def handler(request: httpx2.Request) -> httpx2.Response: with make_client(handler) as client: result = client.search_providers.update( + SearchProviderRequest(integration_name="Serper", provider="serper", search_model="serper/search"), integration_id="sp3", - integration_name="Serper", - provider="serper", - search_model="serper/search", ) assert seen["path"] == "/v1/search-providers/sp3" @@ -172,10 +173,9 @@ def handler(request: httpx2.Request) -> httpx2.Response: with make_client(handler) as client: result = client.llm_providers.create( - integration_name="OpenAI", - provider="openai", - api_key="sk-key", - model_name="gpt-4o", + LLMProviderCreateRequest( + integration_name="OpenAI", provider="openai", api_key="sk-key", model_name="gpt-4o" + ) ) assert seen["path"] == "/v1/llm-providers/config" @@ -216,10 +216,10 @@ def handler(request: httpx2.Request) -> httpx2.Response: with make_client(handler) as client: result = client.llm_providers.update( + LLMProviderUpdateRequest( + integration_name="Anthropic", provider="anthropic", model_name="claude-sonnet-4-5", api_key=None + ), integration_id="llm3", - integration_name="Anthropic", - provider="anthropic", - model_name="claude-sonnet-4-5", ) assert seen["path"] == "/v1/llm-providers/config/llm3" @@ -242,11 +242,10 @@ def handler(request: httpx2.Request) -> httpx2.Response: with make_client(handler) as client: client.llm_providers.update( + LLMProviderUpdateRequest( + integration_name="Anthropic", provider="anthropic", model_name="claude-sonnet-4-5", api_key="sk-new" + ), integration_id="llm3", - integration_name="Anthropic", - provider="anthropic", - model_name="claude-sonnet-4-5", - api_key="sk-new", ) assert seen["body"]["api_key"] == "sk-new" @@ -295,11 +294,13 @@ def handler(request: httpx2.Request) -> httpx2.Response: with make_client(handler) as client: result = client.llm_providers.test_connection( - integration_name="probe", - provider="openai", - api_key="sk-key", - model_name="gpt-4o", - base_url="https://proxy.example.com", + LLMProviderCreateRequest( + integration_name="probe", + provider="openai", + api_key="sk-key", + model_name="gpt-4o", + base_url="https://proxy.example.com", + ) ) assert seen["path"] == "/v1/llm-providers/test-connection" @@ -353,10 +354,9 @@ def handler(request: httpx2.Request) -> httpx2.Response: async with make_async_client(handler) as client: result = await client.llm_providers.create( - integration_name="OpenAI", - provider="openai", - api_key="sk-key", - model_name="gpt-4o", + LLMProviderCreateRequest( + integration_name="OpenAI", provider="openai", api_key="sk-key", model_name="gpt-4o" + ) ) assert seen["path"] == "/v1/llm-providers/config" @@ -374,71 +374,62 @@ def handler(request: httpx2.Request) -> httpx2.Response: async with make_async_client(handler) as client: await client.llm_providers.update( + LLMProviderUpdateRequest( + integration_name="Anthropic", provider="anthropic", model_name="claude-sonnet-4-5", api_key=None + ), integration_id="llm7", - integration_name="Anthropic", - provider="anthropic", - model_name="claude-sonnet-4-5", ) assert seen["body"]["api_key"] is None +def test_llm_update_requires_api_key_to_be_passed_even_when_none() -> None: + with pytest.raises(pydantic.ValidationError, match="api_key"): + LLMProviderUpdateRequest.model_validate( + {"integration_name": "Anthropic", "provider": "anthropic", "model_name": "claude-sonnet-4-5"} + ) + + def test_route_metadata_stamped() -> None: from discolike.resources._base import get_discolike_route from discolike.resources.providers import LLMProvidersResource from discolike.resources.providers import SearchProvidersResource - assert get_discolike_route(SearchProvidersResource.list) == ("GET", "/search-providers", True, ()) - assert get_discolike_route(SearchProvidersResource.create) == ("POST", "/search-providers", True, ()) - assert get_discolike_route(SearchProvidersResource.update) == ( - "PUT", - "/search-providers/{integration_id}", - True, - (), - ) + assert get_discolike_route(SearchProvidersResource.list) == ("GET", "/search-providers", True) + assert get_discolike_route(SearchProvidersResource.create) == ("POST", "/search-providers", True) + assert get_discolike_route(SearchProvidersResource.update) == ("PUT", "/search-providers/{integration_id}", True) assert get_discolike_route(SearchProvidersResource.delete) == ( "DELETE", "/search-providers/{integration_id}", True, - (), ) assert get_discolike_route(SearchProvidersResource.set_default) == ( "PUT", "/search-providers/{integration_id}/default", True, - (), ) assert get_discolike_route(SearchProvidersResource.clear_default) == ( "DELETE", "/search-providers/{integration_id}/default", True, - (), - ) - assert get_discolike_route(SearchProvidersResource.models) == ("GET", "/search-providers/models", True, ()) - assert get_discolike_route(LLMProvidersResource.list) == ("GET", "/llm-providers/config", True, ()) - assert get_discolike_route(LLMProvidersResource.create) == ("POST", "/llm-providers/config", True, ()) - assert get_discolike_route(LLMProvidersResource.get) == ("GET", "/llm-providers/config/{integration_id}", True, ()) - assert get_discolike_route(LLMProvidersResource.update) == ( - "PUT", - "/llm-providers/config/{integration_id}", - True, - (), ) + assert get_discolike_route(SearchProvidersResource.models) == ("GET", "/search-providers/models", True) + assert get_discolike_route(LLMProvidersResource.list) == ("GET", "/llm-providers/config", True) + assert get_discolike_route(LLMProvidersResource.create) == ("POST", "/llm-providers/config", True) + assert get_discolike_route(LLMProvidersResource.get) == ("GET", "/llm-providers/config/{integration_id}", True) + assert get_discolike_route(LLMProvidersResource.update) == ("PUT", "/llm-providers/config/{integration_id}", True) assert get_discolike_route(LLMProvidersResource.delete) == ( "DELETE", "/llm-providers/config/{integration_id}", True, - (), ) assert get_discolike_route(LLMProvidersResource.set_default) == ( "POST", "/llm-providers/config/{integration_id}/set-default", True, - (), ) assert get_discolike_route(LLMProvidersResource.test_connection) == ( "POST", "/llm-providers/test-connection", True, - (), ) diff --git a/packages/discolike/tests/test_queries.py b/packages/discolike/tests/test_queries.py index 4711653..3255598 100644 --- a/packages/discolike/tests/test_queries.py +++ b/packages/discolike/tests/test_queries.py @@ -3,7 +3,13 @@ import json import httpx2 +import pydantic +import pytest +from discolike.requests import CreateExclusionListRequest +from discolike.requests import QueriesListParams +from discolike.requests import SaveResultsRequest +from discolike.requests import UpdateQueryRequest from discolike_testkit import AsyncClientFactory from discolike_testkit import ClientFactory @@ -17,7 +23,7 @@ def handler(request: httpx2.Request) -> httpx2.Response: return httpx2.Response(200, json={"results": [{"query_id": "q1"}], "count": 1}) with make_client(handler) as client: - result = client.queries.list(max_records=10, offset=5, action="discover", tags=["a", "b"]) + result = client.queries.list(QueriesListParams(max_records=10, offset=5, action="discover", tags=["a", "b"])) assert seen["path"] == "/v1/queries/saved" assert seen["params"]["max_records"] == "10" @@ -38,7 +44,9 @@ def handler(request: httpx2.Request) -> httpx2.Response: return httpx2.Response(200, json={"query_id": "q2", "query_name": "My List", "domain_count": 2}) with make_client(handler) as client: - result = client.queries.create_exclusion_list(query_name="My List", domains=["a.com", "b.com"]) + result = client.queries.create_exclusion_list( + CreateExclusionListRequest(query_name="My List", domains=["a.com", "b.com"]) + ) assert seen["path"] == "/v1/queries/exclusion-list" assert seen["method"] == "POST" @@ -56,7 +64,7 @@ def handler(request: httpx2.Request) -> httpx2.Response: return httpx2.Response(200, json={"query_id": "q3", "query_name": "New Name"}) with make_client(handler) as client: - result = client.queries.update(query_id="q3", query_name="New Name") + result = client.queries.update(UpdateQueryRequest(query_name="New Name"), query_id="q3") assert seen["path"] == "/v1/queries/q3" assert seen["method"] == "PATCH" @@ -85,7 +93,7 @@ def handler(request: httpx2.Request) -> httpx2.Response: return httpx2.Response(200, json={"results": [], "count": 0}) async with make_async_client(handler) as client: - result = await client.queries.list() + result = await client.queries.list(QueriesListParams()) assert result.count == 0 assert result.results == [] @@ -111,7 +119,9 @@ def handler(request: httpx2.Request) -> httpx2.Response: return httpx2.Response(200, json={"query_id": "q6", "action": "thin_discover", "row_count": 1}) with make_client(handler) as client: - result = client.queries.save_results(query_name="R", action="discover", data=[{"domain": "a.com"}], tags=["x"]) + result = client.queries.save_results( + SaveResultsRequest(query_name="R", action="discover", data=[{"domain": "a.com"}], tags=["x"]) + ) assert seen["path"] == "/v1/queries/save-results" assert seen["method"] == "POST" @@ -126,17 +136,24 @@ def handler(request: httpx2.Request) -> httpx2.Response: return httpx2.Response(200, json={"query_id": "q7"}) async with make_async_client(handler) as client: - result = await client.queries.save_results(query_name="R", action="discover", data=[{"domain": "a.com"}]) + result = await client.queries.save_results( + SaveResultsRequest(query_name="R", action="discover", data=[{"domain": "a.com"}]) + ) assert result.query_id == "q7" +def test_save_results_rejects_unknown_action_before_any_request() -> None: + with pytest.raises(pydantic.ValidationError, match="action"): + SaveResultsRequest.model_validate({"query_name": "R", "action": "bogus", "data": [{"domain": "a.com"}]}) + + def test_route_metadata_stamped() -> None: from discolike.resources._base import get_discolike_route from discolike.resources.queries import QueriesResource - assert get_discolike_route(QueriesResource.list) == ("GET", "/queries/saved", True, ()) - assert get_discolike_route(QueriesResource.create_exclusion_list) == ("POST", "/queries/exclusion-list", True, ()) - assert get_discolike_route(QueriesResource.update) == ("PATCH", "/queries/{query_id}", True, ()) - assert get_discolike_route(QueriesResource.delete) == ("DELETE", "/queries/{query_id}", True, ()) - assert get_discolike_route(QueriesResource.save_results) == ("POST", "/queries/save-results", True, ()) + assert get_discolike_route(QueriesResource.list) == ("GET", "/queries/saved", True) + assert get_discolike_route(QueriesResource.create_exclusion_list) == ("POST", "/queries/exclusion-list", True) + assert get_discolike_route(QueriesResource.update) == ("PATCH", "/queries/{query_id}", True) + assert get_discolike_route(QueriesResource.delete) == ("DELETE", "/queries/{query_id}", True) + assert get_discolike_route(QueriesResource.save_results) == ("POST", "/queries/save-results", True) diff --git a/packages/discolike/tests/test_requests_module.py b/packages/discolike/tests/test_requests_module.py new file mode 100644 index 0000000..c40db90 --- /dev/null +++ b/packages/discolike/tests/test_requests_module.py @@ -0,0 +1,40 @@ +import pydantic +import pytest + +import discolike.requests as requests_module +from discolike._models import DiscolikeRequest +from discolike.requests import DiscoverParams +from discolike.requests import LLMProviderUpdateRequest +from discolike.requests import MatchCompanyParams + + +def test_every_exported_name_is_a_request_model() -> None: + for name in requests_module.__all__: + assert issubclass(getattr(requests_module, name), DiscolikeRequest), name + + +def test_all_is_sorted_and_complete() -> None: + public = sorted( + name + for name, value in vars(requests_module).items() + if isinstance(value, type) and issubclass(value, DiscolikeRequest) + ) + assert list(requests_module.__all__) == public + + +def test_constraint_violations_fail_before_any_request() -> None: + with pytest.raises(pydantic.ValidationError, match="min_similarity"): + DiscoverParams(min_similarity=200) + with pytest.raises(pydantic.ValidationError, match="name"): + MatchCompanyParams.model_validate({}) + + +def test_required_nullable_field_survives_generation() -> None: + request = LLMProviderUpdateRequest(integration_name="n", provider="p", model_name="m", api_key=None) + assert request.to_wire()["api_key"] is None + with pytest.raises(pydantic.ValidationError, match="api_key"): + LLMProviderUpdateRequest.model_validate({"integration_name": "n", "provider": "p", "model_name": "m"}) + + +def test_models_allow_extra_fields() -> None: + assert MatchCompanyParams.model_validate({"name": "Acme", "future_flag": 1}).to_wire()["future_flag"] == 1 diff --git a/packages/discolike/tests/test_transport.py b/packages/discolike/tests/test_transport.py index 69149cc..1d83e01 100644 --- a/packages/discolike/tests/test_transport.py +++ b/packages/discolike/tests/test_transport.py @@ -7,6 +7,7 @@ from discolike._transport import AsyncTransport from discolike._transport import Transport from discolike._transport import drop_none +from discolike_testkit import api_key_auth @pytest.fixture(autouse=True) @@ -23,7 +24,9 @@ async def fake_async_sleep(seconds: float) -> None: def make_transport(handler) -> Transport: http = httpx2.Client(transport=httpx2.MockTransport(handler), base_url="https://api.test/v1") - return Transport("test-key", base_url="https://api.test/v1", timeout=5.0, max_retries=2, http_client=http) + return Transport( + api_key_auth("test-key"), base_url="https://api.test/v1", timeout=5.0, max_retries=2, http_client=http + ) def test_drop_none() -> None: @@ -102,7 +105,9 @@ def handler(request: httpx2.Request) -> httpx2.Response: from discolike import ServerError http = httpx2.Client(transport=httpx2.MockTransport(handler), base_url="https://api.test/v1") - transport = Transport("test-key", base_url="https://api.test/v1", timeout=5.0, max_retries=0, http_client=http) + transport = Transport( + api_key_auth("test-key"), base_url="https://api.test/v1", timeout=5.0, max_retries=0, http_client=http + ) with pytest.raises(ServerError): transport.request("GET", "/usage") assert len(calls) == 1 @@ -117,7 +122,9 @@ def handler(request: httpx2.Request) -> httpx2.Response: return httpx2.Response(502) if len(calls) < 2 else httpx2.Response(200, json={"ok": True}) http = httpx2.AsyncClient(transport=httpx2.MockTransport(handler), base_url="https://api.test/v1") - transport = AsyncTransport("test-key", base_url="https://api.test/v1", timeout=5.0, max_retries=2, http_client=http) + transport = AsyncTransport( + api_key_auth("test-key"), base_url="https://api.test/v1", timeout=5.0, max_retries=2, http_client=http + ) response = await transport.request("GET", "/usage") assert response.json() == {"ok": True} await transport.aclose() @@ -201,7 +208,9 @@ def handler(request: httpx2.Request) -> httpx2.Response: from discolike import ServerError http = httpx2.AsyncClient(transport=httpx2.MockTransport(handler), base_url="https://api.test/v1") - transport = AsyncTransport("test-key", base_url="https://api.test/v1", timeout=5.0, max_retries=2, http_client=http) + transport = AsyncTransport( + api_key_auth("test-key"), base_url="https://api.test/v1", timeout=5.0, max_retries=2, http_client=http + ) with pytest.raises(ServerError): await transport.request("POST", "/discogen/process") assert len(calls) == 1 @@ -218,7 +227,9 @@ def handler(request: httpx2.Request) -> httpx2.Response: return httpx2.Response(200, json={"ok": True}) http = httpx2.AsyncClient(transport=httpx2.MockTransport(handler), base_url="https://api.test/v1") - transport = AsyncTransport("test-key", base_url="https://api.test/v1", timeout=5.0, max_retries=2, http_client=http) + transport = AsyncTransport( + api_key_auth("test-key"), base_url="https://api.test/v1", timeout=5.0, max_retries=2, http_client=http + ) response = await transport.request("POST", "/discogen/process") assert response.json() == {"ok": True} assert len(calls) == 2 @@ -233,14 +244,16 @@ def handler(request: httpx2.Request) -> httpx2.Response: return httpx2.Response(200, json={"ok": True}) http = httpx2.Client(transport=httpx2.MockTransport(handler)) - transport = Transport("test-key", base_url="https://api.test/v1", timeout=5.0, max_retries=0, http_client=http) + transport = Transport( + api_key_auth("test-key"), base_url="https://api.test/v1", timeout=5.0, max_retries=0, http_client=http + ) transport.request("GET", "/usage") assert seen["url"] == "https://api.test/v1/usage" def test_byo_http_client_with_base_url_is_left_alone() -> None: http = httpx2.Client(base_url="https://custom.example/v2") - Transport("test-key", base_url="https://api.test/v1", timeout=5.0, max_retries=0, http_client=http) + Transport(api_key_auth("test-key"), base_url="https://api.test/v1", timeout=5.0, max_retries=0, http_client=http) assert str(http.base_url) == "https://custom.example/v2/" @@ -252,7 +265,9 @@ def handler(request: httpx2.Request) -> httpx2.Response: return httpx2.Response(200, json={"ok": True}) http = httpx2.Client(transport=httpx2.MockTransport(handler), base_url="https://api.test/v1", timeout=5.0) - transport = Transport("test-key", base_url="https://api.test/v1", timeout=5.0, max_retries=0, http_client=http) + transport = Transport( + api_key_auth("test-key"), base_url="https://api.test/v1", timeout=5.0, max_retries=0, http_client=http + ) transport.with_timeout(120.0).request("GET", "/usage") transport.request("GET", "/usage") diff --git a/pyproject.toml b/pyproject.toml index a534ea7..f5aa784 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -70,4 +70,4 @@ ban-relative-imports = "all" force-single-line = true [tool.ruff.lint.per-file-ignores] -"packages/*/tests/*" = ["ANN"] +"packages/*/tests/*" = ["ANN", "S105", "S106", "S107"] # fake tokens in fixtures diff --git a/scripts/check_contract.py b/scripts/check_contract.py index a518417..4cf0d93 100644 --- a/scripts/check_contract.py +++ b/scripts/check_contract.py @@ -7,6 +7,7 @@ import pathlib import pkgutil import sys +import typing from dataclasses import dataclass from types import ModuleType @@ -14,6 +15,7 @@ import discolike.resources from discolike._models import DiscolikeModel +from discolike._models import DiscolikeRequest from discolike.resources._base import get_discolike_route from discolike.resources.companies import CompanyProfile from discolike.resources.companies import ExtractResult @@ -27,6 +29,8 @@ from discolike.resources.queries import SavedQueries IGNORE_PARAMS = {"file"} +QUERY_LOCATION = "query" +ASYNC_CLASS_PREFIX = "Async" # SDK response model -> the OpenAPI component schema it mirrors. Anything listed here is # checked field-by-field against the spec, so a platform-side model change surfaces as a @@ -45,7 +49,6 @@ } SPEC_URL = "https://api.discolike.com/v1/openapi.json" REQUEST_TIMEOUT_SECONDS = 30.0 -PATH_METHODS_WITH_BODY = {"POST", "PUT", "PATCH"} @dataclass(frozen=True) @@ -55,7 +58,7 @@ class RouteEntry: http_method: str path: str openapi: bool - params: tuple[str, ...] + request_model: type[DiscolikeRequest] | None def _resource_modules() -> list[ModuleType]: @@ -69,11 +72,18 @@ def _resource_modules() -> list[ModuleType]: return modules +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 + return None + + def collect_routes() -> list[RouteEntry]: seen: dict[tuple[str, str], RouteEntry] = {} for module in _resource_modules(): for class_name, cls in inspect.getmembers(module, inspect.isclass): - if cls.__module__ != module.__name__: + if cls.__module__ != module.__name__ or class_name.startswith(ASYNC_CLASS_PREFIX): continue for method_name, member in vars(cls).items(): if not inspect.isfunction(member): @@ -81,17 +91,11 @@ def collect_routes() -> list[RouteEntry]: route = get_discolike_route(member) if route is None: continue - http_method, path, openapi, ignore_params = route + http_method, path, openapi = route key = (http_method, path) if key in seen: continue - excluded = IGNORE_PARAMS | set(ignore_params) - params = tuple( - name - for name, param in inspect.signature(member).parameters.items() - if param.kind is inspect.Parameter.KEYWORD_ONLY and name not in excluded - ) - seen[key] = RouteEntry(class_name, method_name, http_method, path, openapi, params) + seen[key] = RouteEntry(class_name, method_name, http_method, path, openapi, _request_model(member)) return list(seen.values()) @@ -105,12 +109,26 @@ def _resolve_ref(*, spec: dict, schema: dict) -> dict: return node -def _request_body_properties(*, spec: dict, operation: dict) -> set[str]: +def _request_body_properties(*, spec: dict, operation: dict) -> dict[str, dict]: content = operation.get("requestBody", {}).get("content", {}) for media_type in content.values(): schema = _resolve_ref(spec=spec, schema=media_type.get("schema", {})) - return set(schema.get("properties", {}).keys()) - return set() + return schema.get("properties", {}) + return {} + + +def _spec_request_fields(*, spec: dict, operation: dict) -> set[str]: + fields = { + parameter["name"] + for parameter in operation.get("parameters", []) + if parameter["in"] == QUERY_LOCATION and not parameter.get("deprecated", False) + } + fields |= { + name + for name, prop in _request_body_properties(spec=spec, operation=operation).items() + if not prop.get("deprecated", False) + } + return fields - IGNORE_PARAMS def check(spec: dict, routes: list[RouteEntry]) -> list[str]: @@ -125,11 +143,22 @@ def check(spec: dict, routes: list[RouteEntry]) -> list[str]: if operation is None: mismatches.append(f"{label}: route not found in spec") continue - allowed = {p["name"] for p in operation.get("parameters", [])} - if route.http_method.upper() in PATH_METHODS_WITH_BODY: - allowed |= _request_body_properties(spec=spec, operation=operation) + spec_fields = _spec_request_fields(spec=spec, operation=operation) + if route.request_model is None: + mismatches.extend( + f"{label}: spec has param '{field}' but the method takes no request model" + for field in sorted(spec_fields) + ) + continue + model = route.request_model + model_fields = set(model.model_fields) mismatches.extend( - f"{label}: param '{param}' not found in spec" for param in route.params if param not in allowed + f"{label}: field '{field}' of {model.__name__} not found in spec" + for field in sorted(model_fields - spec_fields) + ) + mismatches.extend( + f"{label}: spec param '{field}' not declared on {model.__name__}" + for field in sorted(spec_fields - model_fields) ) return mismatches diff --git a/scripts/gen_requests.py b/scripts/gen_requests.py new file mode 100644 index 0000000..26b8fcd --- /dev/null +++ b/scripts/gen_requests.py @@ -0,0 +1,277 @@ +from __future__ import annotations + +import argparse +import copy +import difflib +import importlib +import inspect +import json +import pathlib +import pkgutil +import subprocess +import sys +import tempfile +from dataclasses import dataclass +from types import ModuleType +from typing import Any + +import httpx2 + +import discolike.resources +from discolike.resources._base import get_discolike_route + +SPEC_URL = "https://api.discolike.com/v1/openapi.json" +REQUEST_TIMEOUT_SECONDS = 30.0 +REPO_ROOT = pathlib.Path(__file__).resolve().parents[1] +OUTPUT_PATH = REPO_ROOT / "packages" / "discolike" / "src" / "discolike" / "_generated" / "requests.py" +RUFF_CONFIG = REPO_ROOT / "pyproject.toml" +BASE_CLASS = "discolike._models.DiscolikeRequest" +FILE_HEADER = "# Generated by scripts/gen_requests.py from the platform OpenAPI spec. Do not edit by hand." +JSON_MEDIA_TYPE = "application/json" +QUERY_LOCATION = "query" +NULL_SCHEMA = {"type": "null"} +ITEM_KEYS_KEPT = frozenset({"type", "enum", "format", "$ref"}) +CLIENT_LEVEL_RESOURCES = frozenset({"DiscoveryResource", "EnrichResource", "ValidateResource"}) +ASYNC_CLASS_PREFIX = "Async" +RESOURCE_SUFFIX = "Resource" +PARAMS_SUFFIX = "Params" +CODEGEN_ARGS = [ + "--input-file-type", + "openapi", + "--output-model-type", + "pydantic_v2.BaseModel", + "--base-class", + BASE_CLASS, + "--use-annotated", + "--field-constraints", + "--use-standard-collections", + "--use-union-operator", + "--collapse-root-models", + "--enum-field-as-literal", + "all", + "--target-python-version", + "3.10", + "--use-double-quotes", + "--disable-timestamp", + "--formatters", + "builtin", + "--custom-file-header", + FILE_HEADER, +] + + +@dataclass(frozen=True) +class Route: + class_name: str + method_name: str + http_method: str + path: str + + +def _resource_modules() -> list[ModuleType]: + return [ + importlib.import_module(info.name) + for info in pkgutil.walk_packages(discolike.resources.__path__, prefix=f"{discolike.resources.__name__}.") + ] + + +def collect_routes() -> list[Route]: + routes: dict[tuple[str, str], Route] = {} + for module in _resource_modules(): + for class_name, cls in inspect.getmembers(module, inspect.isclass): + if cls.__module__ != module.__name__ or class_name.startswith(ASYNC_CLASS_PREFIX): + continue + for method_name, member in vars(cls).items(): + if not inspect.isfunction(member): + continue + route = get_discolike_route(member) + if route is None: + continue + http_method, path, openapi = route + if not openapi: + continue + routes.setdefault((http_method, path), Route(class_name, method_name, http_method, path)) + return list(routes.values()) + + +def params_model_name(*, class_name: str, method_name: str) -> str: + prefix = "" if class_name in CLIENT_LEVEL_RESOURCES else class_name.removesuffix(RESOURCE_SUFFIX) + return prefix + "".join(part.capitalize() for part in method_name.split("_")) + PARAMS_SUFFIX + + +def _synthesize_params_schema(operation: dict[str, Any]) -> dict[str, Any] | None: + parameters = [ + parameter + for parameter in operation.get("parameters", []) + if parameter["in"] == QUERY_LOCATION and not parameter.get("deprecated", False) + ] + if not parameters: + return None + schema: dict[str, Any] = { + "type": "object", + "properties": {parameter["name"]: parameter["schema"] for parameter in parameters}, + } + required = [parameter["name"] for parameter in parameters if parameter.get("required", False)] + if required: + schema["required"] = required + return schema + + +def _body_ref_name(operation: dict[str, Any]) -> str | None: + schema = operation.get("requestBody", {}).get("content", {}).get(JSON_MEDIA_TYPE, {}).get("schema", {}) + ref = schema.get("$ref") + return None if ref is None else ref.rsplit("/", 1)[1] + + +def request_schemas(*, spec: dict[str, Any], routes: list[Route]) -> dict[str, dict[str, Any]]: + schemas = spec["components"]["schemas"] + requested: dict[str, dict[str, Any]] = {} + for route in routes: + operation = spec["paths"].get(route.path, {}).get(route.http_method.lower()) + if operation is None: + raise SystemExit(f"{route.http_method} {route.path} is not in the spec; run scripts/check_contract.py") + body_name = _body_ref_name(operation) + if body_name is not None: + requested[body_name] = copy.deepcopy(schemas[body_name]) + continue + synthesized = _synthesize_params_schema(operation) + if synthesized is not None: + requested[params_model_name(class_name=route.class_name, method_name=route.method_name)] = synthesized + return requested + + +def _collect_refs(node: Any, refs: set[str]) -> None: # noqa: ANN401 -- walks arbitrary JSON + if isinstance(node, dict): + ref = node.get("$ref") + if isinstance(ref, str): + refs.add(ref.rsplit("/", 1)[1]) + for value in node.values(): + _collect_refs(value, refs) + elif isinstance(node, list): + for item in node: + _collect_refs(item, refs) + + +def prune(*, spec: dict[str, Any], requested: dict[str, dict[str, Any]]) -> dict[str, dict[str, Any]]: + schemas = spec["components"]["schemas"] + kept = dict(requested) + pending: set[str] = set() + _collect_refs(kept, pending) + while pending: + # min() rather than pop(): set order varies with PYTHONHASHSEED, which would reorder + # the generated classes between runs and make --check report phantom drift. + name = min(pending) + pending.remove(name) + if name in kept: + continue + kept[name] = copy.deepcopy(schemas[name]) + _collect_refs(kept[name], pending) + return kept + + +def _normalize_property(schema: dict[str, Any], *, required: bool) -> dict[str, Any]: + variants = schema.get("anyOf") + if not required and variants is not None and len(variants) == 2 and NULL_SCHEMA in variants: + inner = next(variant for variant in variants if variant != NULL_SCHEMA) + outer = {key: value for key, value in schema.items() if key != "anyOf"} + schema = {**inner, **outer, "nullable": True} + items = schema.get("items") + if isinstance(items, dict): + schema = {**schema, "items": {key: value for key, value in items.items() if key in ITEM_KEYS_KEPT}} + return schema + + +def normalize_schema(schema: dict[str, Any]) -> dict[str, Any]: + required = set(schema.get("required", [])) + properties = { + name: _normalize_property(prop, required=name in required) + for name, prop in schema.get("properties", {}).items() + if not prop.get("deprecated", False) + } + return {key: value for key, value in schema.items() if key != "additionalProperties"} | {"properties": properties} + + +def build_codegen_spec(*, spec: dict[str, Any], routes: list[Route]) -> dict[str, Any]: + kept = prune(spec=spec, requested=request_schemas(spec=spec, routes=routes)) + return { + "openapi": "3.1.0", + "info": {"title": "discolike request models", "version": "0"}, + "paths": {}, + "components": {"schemas": {name: normalize_schema(schema) for name, schema in kept.items()}}, + } + + +def generate(*, spec: dict[str, Any], output: pathlib.Path) -> None: + codegen_spec = build_codegen_spec(spec=spec, routes=collect_routes()) + with tempfile.TemporaryDirectory() as tmp: + spec_path = pathlib.Path(tmp) / "requests-spec.json" + spec_path.write_text(json.dumps(codegen_spec)) + subprocess.run( # noqa: S603 -- fixed argv, no shell + [ + sys.executable, + "-m", + "datamodel_code_generator", + "--input", + str(spec_path), + "--output", + str(output), + *CODEGEN_ARGS, + ], + check=True, + ) + ruff = [sys.executable, "-m", "ruff"] + subprocess.run( # noqa: S603 + [*ruff, "check", "--fix", "--quiet", "--no-show-fixes", "--config", str(RUFF_CONFIG), str(output)], check=True + ) + subprocess.run([*ruff, "format", "--quiet", "--config", str(RUFF_CONFIG), str(output)], check=True) # noqa: S603 + + +def compare(*, committed: str, fresh: str) -> int: + relative = OUTPUT_PATH.relative_to(REPO_ROOT) + if committed == fresh: + print(f"{relative} is up to date") + return 0 + sys.stdout.writelines( + difflib.unified_diff( + committed.splitlines(keepends=True), + fresh.splitlines(keepends=True), + fromfile=f"{relative} (committed)", + tofile=f"{relative} (generated)", + ) + ) + print(f"{relative} is stale; run: uv run python scripts/gen_requests.py") + return 1 + + +def check(*, spec: dict[str, Any]) -> int: + with tempfile.TemporaryDirectory() as tmp: + candidate = pathlib.Path(tmp) / OUTPUT_PATH.name + generate(spec=spec, output=candidate) + fresh = candidate.read_text() + return compare(committed=OUTPUT_PATH.read_text(), fresh=fresh) + + +def load_spec(*, spec_path: str | None, spec_url: str) -> dict[str, Any]: + if spec_path is not None: + return json.loads(pathlib.Path(spec_path).read_text()) + response = httpx2.get(spec_url, timeout=REQUEST_TIMEOUT_SECONDS) + response.raise_for_status() + return response.json() + + +def main() -> int: + parser = argparse.ArgumentParser(description="Generate SDK request models from the platform OpenAPI spec.") + parser.add_argument("--spec", default=None, help="Path to a local OpenAPI spec JSON file (offline mode).") + parser.add_argument("--spec-url", default=SPEC_URL, help="OpenAPI spec URL (defaults to the production spec).") + parser.add_argument("--check", action="store_true", help="Regenerate to a temp file and fail if it differs.") + args = parser.parse_args() + spec = load_spec(spec_path=args.spec, spec_url=args.spec_url) + if args.check: + return check(spec=spec) + generate(spec=spec, output=OUTPUT_PATH) + print(f"wrote {OUTPUT_PATH.relative_to(REPO_ROOT)}") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/uv.lock b/uv.lock index dd2e735..2de2feb 100644 --- a/uv.lock +++ b/uv.lock @@ -2,8 +2,10 @@ version = 1 revision = 3 requires-python = ">=3.10" resolution-markers = [ - "python_full_version >= '3.12' and sys_platform == 'emscripten'", - "python_full_version < '3.12' or 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')", ] [manifest] @@ -45,6 +47,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/da/35/f2287558c17e29fafc8ef3daf819bb9834061cfa43bff8014f7df7f63bdc/anyio-4.14.2-py3-none-any.whl", hash = "sha256:9f505dda5ac9f0c8309b5e8bd445a8c2bf7246f3ce950121e45ea15bc41d1494", size = 125813, upload-time = "2026-07-12T20:29:05.763Z" }, ] +[[package]] +name = "argcomplete" +version = "3.7.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/87/6f/5a73f04007ca950701765949209f068da628bd11f9c2da287278ce91e0ee/argcomplete-3.7.2.tar.gz", hash = "sha256:aad8b69a0b9969edb62db0d1752354c0d50717b10e0cbb00e2a958381b9fc6b9", size = 74473, upload-time = "2026-08-06T04:53:21.662Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/46/bd/551ee6af426af84ca33e02622be722925c196608e9127d731ef17c47f06e/argcomplete-3.7.2-py3-none-any.whl", hash = "sha256:6029205678bdd9c1c728a155f5f9ecf5812393f969eef58807641a2bc2aa5b19", size = 43294, upload-time = "2026-08-06T04:53:20.246Z" }, +] + [[package]] name = "backports-asyncio-runner" version = "1.2.0" @@ -54,6 +65,59 @@ 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" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "mypy-extensions" }, + { name = "packaging" }, + { 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" }, + { url = "https://files.pythonhosted.org/packages/a1/5b/0b39b3a5917f0657ac014ad2edb58c139553a478adfe7f817abf1622ff6e/black-26.5.1-cp311-cp311-win_amd64.whl", hash = "sha256:30d3c14661f2792e9142cce3eeeb1cbc175b3eb5f733be0c8eeb99651e52b0c3", size = 1478883, upload-time = "2026-05-18T17:05:16.542Z" }, + { url = "https://files.pythonhosted.org/packages/4c/48/dc222692e0f95030db1bbfb6c857e76858bad09058221ea7aae815255327/black-26.5.1-cp311-cp311-win_arm64.whl", hash = "sha256:1ef92b76f7733f282fd096ea406200b5a286c42947412b0eaff3a74e3616cefe", size = 1277776, upload-time = "2026-05-18T17:05:18.029Z" }, + { url = "https://files.pythonhosted.org/packages/24/99/7744b906703228264ef73bdd534df88ec1ef3de45c4e78f6d31b9e32d0c9/black-26.5.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:4ad6fa01f941920f54f2bbb35f3df7673428a0ef98a0b0840c2eaef3b110efa8", size = 2012518, upload-time = "2026-05-18T17:05:20.108Z" }, + { url = "https://files.pythonhosted.org/packages/b7/c0/c5a3b1636dfd09c42534f2b3cf33506814f6d3e066fb0879ffa16c1ae860/black-26.5.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:3915f256e75a2d7cf88d8953d37f780455dc586cc72dee059c528fe77f581217", size = 1816016, upload-time = "2026-05-18T17:05:21.84Z" }, + { url = "https://files.pythonhosted.org/packages/1f/0e/36044316b65ca471d3bb6d3703fd06fb50c6b727c3562f6a5a3153634f88/black-26.5.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9d98d4137277c75dfb898ec8d846c4fd68ba1e9cf77f95e2865c203dc18f4c3d", size = 1884150, upload-time = "2026-05-18T17:05:23.546Z" }, + { url = "https://files.pythonhosted.org/packages/b3/33/dafc5808c2af43672912111d7c3354af1615f7e2be3bed7a878461abbe4d/black-26.5.1-cp312-cp312-win_amd64.whl", hash = "sha256:a1dca32d9f1784af512a13410ec204c6f7f0aa9797a111c42e1c03449821c264", size = 1486825, upload-time = "2026-05-18T17:05:25.004Z" }, + { url = "https://files.pythonhosted.org/packages/82/14/b965ee6ad2a311f28bdbf692def3ee9848d2ae289dab28b27657fcee3e78/black-26.5.1-cp312-cp312-win_arm64.whl", hash = "sha256:1037d5ac7b7b310b2632ad867ec8d0e4c4819dcdb0b820f63135da746a24e418", size = 1288646, upload-time = "2026-05-18T17:05:26.477Z" }, + { url = "https://files.pythonhosted.org/packages/3f/5c/c384363980e11e25ca6b93205949bb331fbf35f4e0dbec376dfa6326cec8/black-26.5.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:2b36cf2ddf5566e205f6535f782a62194a184d33e175b64ae8c40b1737522be3", size = 2009020, upload-time = "2026-05-18T17:05:28.132Z" }, + { url = "https://files.pythonhosted.org/packages/0b/df/9f31c5e0babbfed77d505fc5d120beb98b21b33feaeded3924ea941fe360/black-26.5.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:1f7ea64ebfa01b50f693508fc39f875e264446d3b097088f84f203b9d09618a0", size = 1813335, upload-time = "2026-05-18T17:05:31.266Z" }, + { url = "https://files.pythonhosted.org/packages/fb/24/8e7b9a2fa61b0afd82209efe937557d180a1fa055bd7f6161eb9defc3719/black-26.5.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ecb3e624844c798144e9bd986954e0adc81d8911a1f30f375e1252fe26e8c294", size = 1881614, upload-time = "2026-05-18T17:05:32.718Z" }, + { url = "https://files.pythonhosted.org/packages/49/ad/b4e0d9365ba8ac34f6bbab62a4b1b2dd5d618fac3fa1b8db968c844201b5/black-26.5.1-cp313-cp313-win_amd64.whl", hash = "sha256:e1a26503279b6b310669fb0b219c39e4820b77e8189fe80f522bb511f247db0a", size = 1488925, upload-time = "2026-05-18T17:05:34.259Z" }, + { url = "https://files.pythonhosted.org/packages/a1/4b/652b859bf5df88a751c30451b09338f7fd26a77d1271c666992f836b7711/black-26.5.1-cp313-cp313-win_arm64.whl", hash = "sha256:5c34b25da232ead53a6f335b76dbea124f4d152ad568b9080d6f944bc2b34b52", size = 1289883, upload-time = "2026-05-18T17:05:36.019Z" }, + { url = "https://files.pythonhosted.org/packages/a6/16/a8da8eb208c51c7f4ce74609a45d0dcc6d8a2141e45e81ee5289d1bb0d59/black-26.5.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:e88976690a64b0af98312ca958415849cb42423423c5f2ee74af4b49a97a2168", size = 2004800, upload-time = "2026-05-18T17:05:38.182Z" }, + { url = "https://files.pythonhosted.org/packages/11/8a/a479296a19e383b70a725882a6cf3d786540601ff03cabbaaf1cce864c5a/black-26.5.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:32d5ea7f6c8bdfa6e648326ebca1f02b0764e2a029edc6f8dce2627e19d468c3", size = 1815576, upload-time = "2026-05-18T17:05:40.309Z" }, + { url = "https://files.pythonhosted.org/packages/81/6b/cfaf3d39f25132c156a068f6b805576c9103a84086019507c70e1911ee7d/black-26.5.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ea8d16dc41655aa113cd64665e7219446cd7e4ff2248d7178eaa905190c86b18", size = 1877927, upload-time = "2026-05-18T17:05:42.463Z" }, + { url = "https://files.pythonhosted.org/packages/66/76/302e313964bcff7e28df329d39f84f5270095730d85ff0acc260610a0d82/black-26.5.1-cp314-cp314-win_amd64.whl", hash = "sha256:577f21094ea469ef92ec1adaf2c9441a226d2144d01a5be2fa823cecf6543e50", size = 1511860, upload-time = "2026-05-18T17:05:43.943Z" }, + { url = "https://files.pythonhosted.org/packages/27/4e/a3827e35e0e567f9f9ee59e2a0ab979267dca98718f25547ca8c6733afd4/black-26.5.1-cp314-cp314-win_arm64.whl", hash = "sha256:ed1a20af114c301a0269bf01163d51dbef72737fd65f850001e7cbe7f3c7abae", size = 1316632, upload-time = "2026-05-18T17:05:45.521Z" }, + { url = "https://files.pythonhosted.org/packages/94/51/f975cae76d44274cc2868dc9040ac5d58d464784610234455b4e7b19c6ef/black-26.5.1-py3-none-any.whl", hash = "sha256:4ed7f7da04046d2e488437170797d3b4a4ad83906683bcb7dfc68b673bbce5e2", size = 213693, upload-time = "2026-05-18T16:53:33.964Z" }, +] + +[[package]] +name = "click" +version = "8.5.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c7/0e/7fa0ef50764b67090eca4114772a2abf8b6148198475e54c660b97caeee6/click-8.5.0.tar.gz", hash = "sha256:ba0d2089de75ea0310e2dde03160e6ca10009947fb95a182f9b54021bb272e34", size = 382235, upload-time = "2026-08-26T13:33:14.56Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/58/50/6c0d534c5f134586a8e1ba4e330569e32f057e33372ae556463212fb4cd3/click-8.5.0-py3-none-any.whl", hash = "sha256:255bc9599cf7748b4b1a446ccc735421bd08a2ae529a8b88597d3de5664ee360", size = 125251, upload-time = "2026-08-26T13:33:12.928Z" }, +] + [[package]] name = "colorama" version = "0.4.6" @@ -63,6 +127,26 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, ] +[[package]] +name = "datamodel-code-generator" +version = "0.75.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "argcomplete" }, + { name = "black", marker = "sys_platform != 'emscripten'" }, + { name = "genson" }, + { name = "inflect" }, + { name = "isort", marker = "sys_platform != 'emscripten'" }, + { 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 = [ + { url = "https://files.pythonhosted.org/packages/d6/be/a8d2b7450762a4ebb7b81a8e7b1756af725b3f3c14f3f76599d42493c602/datamodel_code_generator-0.75.1-py3-none-any.whl", hash = "sha256:58cb9330cdf0a356aee8547c0cce52d8fd2daa5253c7a6b8e245e9d3117c999e", size = 609533, upload-time = "2026-08-24T19:16:35.482Z" }, +] + [[package]] name = "discolike" source = { editable = "packages/discolike" } @@ -79,6 +163,7 @@ cli = [ [package.dev-dependencies] dev = [ + { name = "datamodel-code-generator" }, { name = "discolike-testkit" }, { name = "pytest" }, { name = "pytest-asyncio" }, @@ -97,6 +182,7 @@ provides-extras = ["cli"] [package.metadata.requires-dev] dev = [ + { name = "datamodel-code-generator", specifier = ">=0.75,<0.76" }, { name = "discolike-testkit", editable = "packages/discolike-testkit" }, { name = "pytest", specifier = ">=8" }, { name = "pytest-asyncio", specifier = ">=0.24" }, @@ -106,7 +192,7 @@ dev = [ [[package]] name = "discolike-cli" -version = "0.2.0" +version = "0.3.0" source = { editable = "packages/discolike-cli" } dependencies = [ { name = "discolike" }, @@ -168,6 +254,15 @@ 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" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f8/53/de162dc8e03fccd9ebe59d17c7812378fe8bd2b604f6b1b94d00165140ac/genson-1.4.0.tar.gz", hash = "sha256:bc7f1c1bae87a21ca44d81149aec95a3f4468d676de9b8b08caa064f3c50b3da", size = 47908, upload-time = "2026-07-06T08:21:50.331Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/16/02/767f744ab6d4cb7761e5008acc3d534b7a0481af62563d52e391fbcb2140/genson-1.4.0-py3-none-any.whl", hash = "sha256:03bc71bbe52defde70660cc4dcd1ea1097997da5a1cbb90a9dbd3acc7c9e1b65", size = 24484, upload-time = "2026-07-06T08:21:49.046Z" }, +] + [[package]] name = "h11" version = "0.16.0" @@ -225,6 +320,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/1e/5e/d4e9f1a599fb8e573b7b87160658329fbf28d19eac2718f51fc3def3aa5a/idna-3.18-py3-none-any.whl", hash = "sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2", size = 65455, upload-time = "2026-06-02T14:34:06.319Z" }, ] +[[package]] +name = "inflect" +version = "7.5.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "more-itertools" }, + { name = "typeguard" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/78/c6/943357d44a21fd995723d07ccaddd78023eace03c1846049a2645d4324a3/inflect-7.5.0.tar.gz", hash = "sha256:faf19801c3742ed5a05a8ce388e0d8fe1a07f8d095c82201eb904f5d27ad571f", size = 73751, upload-time = "2024-12-28T17:11:18.897Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8a/eb/427ed2b20a38a4ee29f24dbe4ae2dafab198674fe9a85e3d6adf9e5f5f41/inflect-7.5.0-py3-none-any.whl", hash = "sha256:2aea70e5e70c35d8350b8097396ec155ffd68def678c7ff97f51aa69c1d92344", size = 35197, upload-time = "2024-12-28T17:11:15.931Z" }, +] + [[package]] name = "iniconfig" version = "2.3.0" @@ -234,6 +342,27 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, ] +[[package]] +name = "isort" +version = "8.0.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ef/7c/ec4ab396d31b3b395e2e999c8f46dec78c5e29209fac49d1f4dace04041d/isort-8.0.1.tar.gz", hash = "sha256:171ac4ff559cdc060bcfff550bc8404a486fee0caab245679c2abe7cb253c78d", size = 769592, upload-time = "2026-02-28T10:08:20.685Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3e/95/c7c34aa53c16353c56d0b802fba48d5f5caa2cdee7958acbcb795c830416/isort-8.0.1-py3-none-any.whl", hash = "sha256:28b89bc70f751b559aeca209e6120393d43fbe2490de0559662be7a9787e3d75", size = 89733, upload-time = "2026-02-28T10:08:19.466Z" }, +] + +[[package]] +name = "jinja2" +version = "3.1.6" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markupsafe" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/df/bf/f7da0350254c0ed7c72f3e33cef02e048281fec7ecec5f032d4aac52226b/jinja2-3.1.6.tar.gz", hash = "sha256:0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d", size = 245115, upload-time = "2025-03-05T20:05:02.478Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67", size = 134899, upload-time = "2025-03-05T20:05:00.369Z" }, +] + [[package]] name = "markdown-it-py" version = "4.2.0" @@ -246,6 +375,91 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/b3/81/4da04ced5a082363ecfa159c010d200ecbd959ae410c10c0264a38cac0f5/markdown_it_py-4.2.0-py3-none-any.whl", hash = "sha256:9f7ebbcd14fe59494226453aed97c1070d83f8d24b6fc3a3bcf9a38092641c4a", size = 91687, upload-time = "2026-05-07T12:08:27.182Z" }, ] +[[package]] +name = "markupsafe" +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" }, + { url = "https://files.pythonhosted.org/packages/30/ac/0273f6fcb5f42e314c6d8cd99effae6a5354604d461b8d392b5ec9530a54/markupsafe-3.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0bf2a864d67e76e5c9a34dc26ec616a66b9888e25e7b9460e1c76d3293bd9dbf", size = 22940, upload-time = "2025-09-27T18:36:22.249Z" }, + { url = "https://files.pythonhosted.org/packages/19/ae/31c1be199ef767124c042c6c3e904da327a2f7f0cd63a0337e1eca2967a8/markupsafe-3.0.3-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:bc51efed119bc9cfdf792cdeaa4d67e8f6fcccab66ed4bfdd6bde3e59bfcbb2f", size = 21887, upload-time = "2025-09-27T18:36:23.535Z" }, + { url = "https://files.pythonhosted.org/packages/b2/76/7edcab99d5349a4532a459e1fe64f0b0467a3365056ae550d3bcf3f79e1e/markupsafe-3.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:068f375c472b3e7acbe2d5318dea141359e6900156b5b2ba06a30b169086b91a", size = 23692, upload-time = "2025-09-27T18:36:24.823Z" }, + { url = "https://files.pythonhosted.org/packages/a4/28/6e74cdd26d7514849143d69f0bf2399f929c37dc2b31e6829fd2045b2765/markupsafe-3.0.3-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:7be7b61bb172e1ed687f1754f8e7484f1c8019780f6f6b0786e76bb01c2ae115", size = 21471, upload-time = "2025-09-27T18:36:25.95Z" }, + { url = "https://files.pythonhosted.org/packages/62/7e/a145f36a5c2945673e590850a6f8014318d5577ed7e5920a4b3448e0865d/markupsafe-3.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f9e130248f4462aaa8e2552d547f36ddadbeaa573879158d721bbd33dfe4743a", size = 22923, upload-time = "2025-09-27T18:36:27.109Z" }, + { url = "https://files.pythonhosted.org/packages/0f/62/d9c46a7f5c9adbeeeda52f5b8d802e1094e9717705a645efc71b0913a0a8/markupsafe-3.0.3-cp311-cp311-win32.whl", hash = "sha256:0db14f5dafddbb6d9208827849fad01f1a2609380add406671a26386cdf15a19", size = 14572, upload-time = "2025-09-27T18:36:28.045Z" }, + { url = "https://files.pythonhosted.org/packages/83/8a/4414c03d3f891739326e1783338e48fb49781cc915b2e0ee052aa490d586/markupsafe-3.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:de8a88e63464af587c950061a5e6a67d3632e36df62b986892331d4620a35c01", size = 15077, upload-time = "2025-09-27T18:36:29.025Z" }, + { url = "https://files.pythonhosted.org/packages/35/73/893072b42e6862f319b5207adc9ae06070f095b358655f077f69a35601f0/markupsafe-3.0.3-cp311-cp311-win_arm64.whl", hash = "sha256:3b562dd9e9ea93f13d53989d23a7e775fdfd1066c33494ff43f5418bc8c58a5c", size = 13876, upload-time = "2025-09-27T18:36:29.954Z" }, + { url = "https://files.pythonhosted.org/packages/5a/72/147da192e38635ada20e0a2e1a51cf8823d2119ce8883f7053879c2199b5/markupsafe-3.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d53197da72cc091b024dd97249dfc7794d6a56530370992a5e1a08983ad9230e", size = 11615, upload-time = "2025-09-27T18:36:30.854Z" }, + { url = "https://files.pythonhosted.org/packages/9a/81/7e4e08678a1f98521201c3079f77db69fb552acd56067661f8c2f534a718/markupsafe-3.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1872df69a4de6aead3491198eaf13810b565bdbeec3ae2dc8780f14458ec73ce", size = 12020, upload-time = "2025-09-27T18:36:31.971Z" }, + { url = "https://files.pythonhosted.org/packages/1e/2c/799f4742efc39633a1b54a92eec4082e4f815314869865d876824c257c1e/markupsafe-3.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3a7e8ae81ae39e62a41ec302f972ba6ae23a5c5396c8e60113e9066ef893da0d", size = 24332, upload-time = "2025-09-27T18:36:32.813Z" }, + { url = "https://files.pythonhosted.org/packages/3c/2e/8d0c2ab90a8c1d9a24f0399058ab8519a3279d1bd4289511d74e909f060e/markupsafe-3.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d6dd0be5b5b189d31db7cda48b91d7e0a9795f31430b7f271219ab30f1d3ac9d", size = 22947, upload-time = "2025-09-27T18:36:33.86Z" }, + { url = "https://files.pythonhosted.org/packages/2c/54/887f3092a85238093a0b2154bd629c89444f395618842e8b0c41783898ea/markupsafe-3.0.3-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:94c6f0bb423f739146aec64595853541634bde58b2135f27f61c1ffd1cd4d16a", size = 21962, upload-time = "2025-09-27T18:36:35.099Z" }, + { url = "https://files.pythonhosted.org/packages/c9/2f/336b8c7b6f4a4d95e91119dc8521402461b74a485558d8f238a68312f11c/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:be8813b57049a7dc738189df53d69395eba14fb99345e0a5994914a3864c8a4b", size = 23760, upload-time = "2025-09-27T18:36:36.001Z" }, + { url = "https://files.pythonhosted.org/packages/32/43/67935f2b7e4982ffb50a4d169b724d74b62a3964bc1a9a527f5ac4f1ee2b/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:83891d0e9fb81a825d9a6d61e3f07550ca70a076484292a70fde82c4b807286f", size = 21529, upload-time = "2025-09-27T18:36:36.906Z" }, + { url = "https://files.pythonhosted.org/packages/89/e0/4486f11e51bbba8b0c041098859e869e304d1c261e59244baa3d295d47b7/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:77f0643abe7495da77fb436f50f8dab76dbc6e5fd25d39589a0f1fe6548bfa2b", size = 23015, upload-time = "2025-09-27T18:36:37.868Z" }, + { url = "https://files.pythonhosted.org/packages/2f/e1/78ee7a023dac597a5825441ebd17170785a9dab23de95d2c7508ade94e0e/markupsafe-3.0.3-cp312-cp312-win32.whl", hash = "sha256:d88b440e37a16e651bda4c7c2b930eb586fd15ca7406cb39e211fcff3bf3017d", size = 14540, upload-time = "2025-09-27T18:36:38.761Z" }, + { url = "https://files.pythonhosted.org/packages/aa/5b/bec5aa9bbbb2c946ca2733ef9c4ca91c91b6a24580193e891b5f7dbe8e1e/markupsafe-3.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:26a5784ded40c9e318cfc2bdb30fe164bdb8665ded9cd64d500a34fb42067b1c", size = 15105, upload-time = "2025-09-27T18:36:39.701Z" }, + { url = "https://files.pythonhosted.org/packages/e5/f1/216fc1bbfd74011693a4fd837e7026152e89c4bcf3e77b6692fba9923123/markupsafe-3.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:35add3b638a5d900e807944a078b51922212fb3dedb01633a8defc4b01a3c85f", size = 13906, upload-time = "2025-09-27T18:36:40.689Z" }, + { url = "https://files.pythonhosted.org/packages/38/2f/907b9c7bbba283e68f20259574b13d005c121a0fa4c175f9bed27c4597ff/markupsafe-3.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e1cf1972137e83c5d4c136c43ced9ac51d0e124706ee1c8aa8532c1287fa8795", size = 11622, upload-time = "2025-09-27T18:36:41.777Z" }, + { url = "https://files.pythonhosted.org/packages/9c/d9/5f7756922cdd676869eca1c4e3c0cd0df60ed30199ffd775e319089cb3ed/markupsafe-3.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:116bb52f642a37c115f517494ea5feb03889e04df47eeff5b130b1808ce7c219", size = 12029, upload-time = "2025-09-27T18:36:43.257Z" }, + { url = "https://files.pythonhosted.org/packages/00/07/575a68c754943058c78f30db02ee03a64b3c638586fba6a6dd56830b30a3/markupsafe-3.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:133a43e73a802c5562be9bbcd03d090aa5a1fe899db609c29e8c8d815c5f6de6", size = 24374, upload-time = "2025-09-27T18:36:44.508Z" }, + { url = "https://files.pythonhosted.org/packages/a9/21/9b05698b46f218fc0e118e1f8168395c65c8a2c750ae2bab54fc4bd4e0e8/markupsafe-3.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ccfcd093f13f0f0b7fdd0f198b90053bf7b2f02a3927a30e63f3ccc9df56b676", size = 22980, upload-time = "2025-09-27T18:36:45.385Z" }, + { url = "https://files.pythonhosted.org/packages/7f/71/544260864f893f18b6827315b988c146b559391e6e7e8f7252839b1b846a/markupsafe-3.0.3-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:509fa21c6deb7a7a273d629cf5ec029bc209d1a51178615ddf718f5918992ab9", size = 21990, upload-time = "2025-09-27T18:36:46.916Z" }, + { url = "https://files.pythonhosted.org/packages/c2/28/b50fc2f74d1ad761af2f5dcce7492648b983d00a65b8c0e0cb457c82ebbe/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a4afe79fb3de0b7097d81da19090f4df4f8d3a2b3adaa8764138aac2e44f3af1", size = 23784, upload-time = "2025-09-27T18:36:47.884Z" }, + { url = "https://files.pythonhosted.org/packages/ed/76/104b2aa106a208da8b17a2fb72e033a5a9d7073c68f7e508b94916ed47a9/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:795e7751525cae078558e679d646ae45574b47ed6e7771863fcc079a6171a0fc", size = 21588, upload-time = "2025-09-27T18:36:48.82Z" }, + { url = "https://files.pythonhosted.org/packages/b5/99/16a5eb2d140087ebd97180d95249b00a03aa87e29cc224056274f2e45fd6/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8485f406a96febb5140bfeca44a73e3ce5116b2501ac54fe953e488fb1d03b12", size = 23041, upload-time = "2025-09-27T18:36:49.797Z" }, + { url = "https://files.pythonhosted.org/packages/19/bc/e7140ed90c5d61d77cea142eed9f9c303f4c4806f60a1044c13e3f1471d0/markupsafe-3.0.3-cp313-cp313-win32.whl", hash = "sha256:bdd37121970bfd8be76c5fb069c7751683bdf373db1ed6c010162b2a130248ed", size = 14543, upload-time = "2025-09-27T18:36:51.584Z" }, + { url = "https://files.pythonhosted.org/packages/05/73/c4abe620b841b6b791f2edc248f556900667a5a1cf023a6646967ae98335/markupsafe-3.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:9a1abfdc021a164803f4d485104931fb8f8c1efd55bc6b748d2f5774e78b62c5", size = 15113, upload-time = "2025-09-27T18:36:52.537Z" }, + { url = "https://files.pythonhosted.org/packages/f0/3a/fa34a0f7cfef23cf9500d68cb7c32dd64ffd58a12b09225fb03dd37d5b80/markupsafe-3.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:7e68f88e5b8799aa49c85cd116c932a1ac15caaa3f5db09087854d218359e485", size = 13911, upload-time = "2025-09-27T18:36:53.513Z" }, + { url = "https://files.pythonhosted.org/packages/e4/d7/e05cd7efe43a88a17a37b3ae96e79a19e846f3f456fe79c57ca61356ef01/markupsafe-3.0.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:218551f6df4868a8d527e3062d0fb968682fe92054e89978594c28e642c43a73", size = 11658, upload-time = "2025-09-27T18:36:54.819Z" }, + { url = "https://files.pythonhosted.org/packages/99/9e/e412117548182ce2148bdeacdda3bb494260c0b0184360fe0d56389b523b/markupsafe-3.0.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:3524b778fe5cfb3452a09d31e7b5adefeea8c5be1d43c4f810ba09f2ceb29d37", size = 12066, upload-time = "2025-09-27T18:36:55.714Z" }, + { url = "https://files.pythonhosted.org/packages/bc/e6/fa0ffcda717ef64a5108eaa7b4f5ed28d56122c9a6d70ab8b72f9f715c80/markupsafe-3.0.3-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4e885a3d1efa2eadc93c894a21770e4bc67899e3543680313b09f139e149ab19", size = 25639, upload-time = "2025-09-27T18:36:56.908Z" }, + { url = "https://files.pythonhosted.org/packages/96/ec/2102e881fe9d25fc16cb4b25d5f5cde50970967ffa5dddafdb771237062d/markupsafe-3.0.3-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8709b08f4a89aa7586de0aadc8da56180242ee0ada3999749b183aa23df95025", size = 23569, upload-time = "2025-09-27T18:36:57.913Z" }, + { url = "https://files.pythonhosted.org/packages/4b/30/6f2fce1f1f205fc9323255b216ca8a235b15860c34b6798f810f05828e32/markupsafe-3.0.3-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b8512a91625c9b3da6f127803b166b629725e68af71f8184ae7e7d54686a56d6", size = 23284, upload-time = "2025-09-27T18:36:58.833Z" }, + { url = "https://files.pythonhosted.org/packages/58/47/4a0ccea4ab9f5dcb6f79c0236d954acb382202721e704223a8aafa38b5c8/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9b79b7a16f7fedff2495d684f2b59b0457c3b493778c9eed31111be64d58279f", size = 24801, upload-time = "2025-09-27T18:36:59.739Z" }, + { url = "https://files.pythonhosted.org/packages/6a/70/3780e9b72180b6fecb83a4814d84c3bf4b4ae4bf0b19c27196104149734c/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:12c63dfb4a98206f045aa9563db46507995f7ef6d83b2f68eda65c307c6829eb", size = 22769, upload-time = "2025-09-27T18:37:00.719Z" }, + { url = "https://files.pythonhosted.org/packages/98/c5/c03c7f4125180fc215220c035beac6b9cb684bc7a067c84fc69414d315f5/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:8f71bc33915be5186016f675cd83a1e08523649b0e33efdb898db577ef5bb009", size = 23642, upload-time = "2025-09-27T18:37:01.673Z" }, + { url = "https://files.pythonhosted.org/packages/80/d6/2d1b89f6ca4bff1036499b1e29a1d02d282259f3681540e16563f27ebc23/markupsafe-3.0.3-cp313-cp313t-win32.whl", hash = "sha256:69c0b73548bc525c8cb9a251cddf1931d1db4d2258e9599c28c07ef3580ef354", size = 14612, upload-time = "2025-09-27T18:37:02.639Z" }, + { url = "https://files.pythonhosted.org/packages/2b/98/e48a4bfba0a0ffcf9925fe2d69240bfaa19c6f7507b8cd09c70684a53c1e/markupsafe-3.0.3-cp313-cp313t-win_amd64.whl", hash = "sha256:1b4b79e8ebf6b55351f0d91fe80f893b4743f104bff22e90697db1590e47a218", size = 15200, upload-time = "2025-09-27T18:37:03.582Z" }, + { url = "https://files.pythonhosted.org/packages/0e/72/e3cc540f351f316e9ed0f092757459afbc595824ca724cbc5a5d4263713f/markupsafe-3.0.3-cp313-cp313t-win_arm64.whl", hash = "sha256:ad2cf8aa28b8c020ab2fc8287b0f823d0a7d8630784c31e9ee5edea20f406287", size = 13973, upload-time = "2025-09-27T18:37:04.929Z" }, + { url = "https://files.pythonhosted.org/packages/33/8a/8e42d4838cd89b7dde187011e97fe6c3af66d8c044997d2183fbd6d31352/markupsafe-3.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:eaa9599de571d72e2daf60164784109f19978b327a3910d3e9de8c97b5b70cfe", size = 11619, upload-time = "2025-09-27T18:37:06.342Z" }, + { url = "https://files.pythonhosted.org/packages/b5/64/7660f8a4a8e53c924d0fa05dc3a55c9cee10bbd82b11c5afb27d44b096ce/markupsafe-3.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c47a551199eb8eb2121d4f0f15ae0f923d31350ab9280078d1e5f12b249e0026", size = 12029, upload-time = "2025-09-27T18:37:07.213Z" }, + { url = "https://files.pythonhosted.org/packages/da/ef/e648bfd021127bef5fa12e1720ffed0c6cbb8310c8d9bea7266337ff06de/markupsafe-3.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f34c41761022dd093b4b6896d4810782ffbabe30f2d443ff5f083e0cbbb8c737", size = 24408, upload-time = "2025-09-27T18:37:09.572Z" }, + { url = "https://files.pythonhosted.org/packages/41/3c/a36c2450754618e62008bf7435ccb0f88053e07592e6028a34776213d877/markupsafe-3.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:457a69a9577064c05a97c41f4e65148652db078a3a509039e64d3467b9e7ef97", size = 23005, upload-time = "2025-09-27T18:37:10.58Z" }, + { url = "https://files.pythonhosted.org/packages/bc/20/b7fdf89a8456b099837cd1dc21974632a02a999ec9bf7ca3e490aacd98e7/markupsafe-3.0.3-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e8afc3f2ccfa24215f8cb28dcf43f0113ac3c37c2f0f0806d8c70e4228c5cf4d", size = 22048, upload-time = "2025-09-27T18:37:11.547Z" }, + { url = "https://files.pythonhosted.org/packages/9a/a7/591f592afdc734f47db08a75793a55d7fbcc6902a723ae4cfbab61010cc5/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ec15a59cf5af7be74194f7ab02d0f59a62bdcf1a537677ce67a2537c9b87fcda", size = 23821, upload-time = "2025-09-27T18:37:12.48Z" }, + { url = "https://files.pythonhosted.org/packages/7d/33/45b24e4f44195b26521bc6f1a82197118f74df348556594bd2262bda1038/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:0eb9ff8191e8498cca014656ae6b8d61f39da5f95b488805da4bb029cccbfbaf", size = 21606, upload-time = "2025-09-27T18:37:13.485Z" }, + { url = "https://files.pythonhosted.org/packages/ff/0e/53dfaca23a69fbfbbf17a4b64072090e70717344c52eaaaa9c5ddff1e5f0/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2713baf880df847f2bece4230d4d094280f4e67b1e813eec43b4c0e144a34ffe", size = 23043, upload-time = "2025-09-27T18:37:14.408Z" }, + { url = "https://files.pythonhosted.org/packages/46/11/f333a06fc16236d5238bfe74daccbca41459dcd8d1fa952e8fbd5dccfb70/markupsafe-3.0.3-cp314-cp314-win32.whl", hash = "sha256:729586769a26dbceff69f7a7dbbf59ab6572b99d94576a5592625d5b411576b9", size = 14747, upload-time = "2025-09-27T18:37:15.36Z" }, + { url = "https://files.pythonhosted.org/packages/28/52/182836104b33b444e400b14f797212f720cbc9ed6ba34c800639d154e821/markupsafe-3.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:bdc919ead48f234740ad807933cdf545180bfbe9342c2bb451556db2ed958581", size = 15341, upload-time = "2025-09-27T18:37:16.496Z" }, + { url = "https://files.pythonhosted.org/packages/6f/18/acf23e91bd94fd7b3031558b1f013adfa21a8e407a3fdb32745538730382/markupsafe-3.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:5a7d5dc5140555cf21a6fefbdbf8723f06fcd2f63ef108f2854de715e4422cb4", size = 14073, upload-time = "2025-09-27T18:37:17.476Z" }, + { url = "https://files.pythonhosted.org/packages/3c/f0/57689aa4076e1b43b15fdfa646b04653969d50cf30c32a102762be2485da/markupsafe-3.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:1353ef0c1b138e1907ae78e2f6c63ff67501122006b0f9abad68fda5f4ffc6ab", size = 11661, upload-time = "2025-09-27T18:37:18.453Z" }, + { url = "https://files.pythonhosted.org/packages/89/c3/2e67a7ca217c6912985ec766c6393b636fb0c2344443ff9d91404dc4c79f/markupsafe-3.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1085e7fbddd3be5f89cc898938f42c0b3c711fdcb37d75221de2666af647c175", size = 12069, upload-time = "2025-09-27T18:37:19.332Z" }, + { url = "https://files.pythonhosted.org/packages/f0/00/be561dce4e6ca66b15276e184ce4b8aec61fe83662cce2f7d72bd3249d28/markupsafe-3.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1b52b4fb9df4eb9ae465f8d0c228a00624de2334f216f178a995ccdcf82c4634", size = 25670, upload-time = "2025-09-27T18:37:20.245Z" }, + { url = "https://files.pythonhosted.org/packages/50/09/c419f6f5a92e5fadde27efd190eca90f05e1261b10dbd8cbcb39cd8ea1dc/markupsafe-3.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fed51ac40f757d41b7c48425901843666a6677e3e8eb0abcff09e4ba6e664f50", size = 23598, upload-time = "2025-09-27T18:37:21.177Z" }, + { url = "https://files.pythonhosted.org/packages/22/44/a0681611106e0b2921b3033fc19bc53323e0b50bc70cffdd19f7d679bb66/markupsafe-3.0.3-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f190daf01f13c72eac4efd5c430a8de82489d9cff23c364c3ea822545032993e", size = 23261, upload-time = "2025-09-27T18:37:22.167Z" }, + { url = "https://files.pythonhosted.org/packages/5f/57/1b0b3f100259dc9fffe780cfb60d4be71375510e435efec3d116b6436d43/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e56b7d45a839a697b5eb268c82a71bd8c7f6c94d6fd50c3d577fa39a9f1409f5", size = 24835, upload-time = "2025-09-27T18:37:23.296Z" }, + { url = "https://files.pythonhosted.org/packages/26/6a/4bf6d0c97c4920f1597cc14dd720705eca0bf7c787aebc6bb4d1bead5388/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:f3e98bb3798ead92273dc0e5fd0f31ade220f59a266ffd8a4f6065e0a3ce0523", size = 22733, upload-time = "2025-09-27T18:37:24.237Z" }, + { url = "https://files.pythonhosted.org/packages/14/c7/ca723101509b518797fedc2fdf79ba57f886b4aca8a7d31857ba3ee8281f/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5678211cb9333a6468fb8d8be0305520aa073f50d17f089b5b4b477ea6e67fdc", size = 23672, upload-time = "2025-09-27T18:37:25.271Z" }, + { url = "https://files.pythonhosted.org/packages/fb/df/5bd7a48c256faecd1d36edc13133e51397e41b73bb77e1a69deab746ebac/markupsafe-3.0.3-cp314-cp314t-win32.whl", hash = "sha256:915c04ba3851909ce68ccc2b8e2cd691618c4dc4c4232fb7982bca3f41fd8c3d", size = 14819, upload-time = "2025-09-27T18:37:26.285Z" }, + { url = "https://files.pythonhosted.org/packages/1a/8a/0402ba61a2f16038b48b39bccca271134be00c5c9f0f623208399333c448/markupsafe-3.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4faffd047e07c38848ce017e8725090413cd80cbc23d86e55c587bf979e579c9", size = 15426, upload-time = "2025-09-27T18:37:27.316Z" }, + { url = "https://files.pythonhosted.org/packages/70/bc/6f1c2f612465f5fa89b95bead1f44dcb607670fd42891d8fdcd5d039f4f4/markupsafe-3.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:32001d6a8fc98c8cb5c947787c5d08b0a50663d139f1305bac5885d98d9b40fa", size = 14146, upload-time = "2025-09-27T18:37:28.327Z" }, +] + [[package]] name = "mdurl" version = "0.1.2" @@ -255,6 +469,24 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979, upload-time = "2022-08-14T12:40:09.779Z" }, ] +[[package]] +name = "more-itertools" +version = "11.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/de/1d/f4da6f02cdffe04d6362210b807146a26044c88d839208aec273bb0d9184/more_itertools-11.1.0.tar.gz", hash = "sha256:48e8f4d9e7e5878571ecf6f2b4e57634f93cd474cc8cfbd2376f2d11b396e30d", size = 145772, upload-time = "2026-05-22T14:14:29.909Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e8/3d/1087453384dbde46a8c7f9356eead2c58be8a7bf156bca40243377c85715/more_itertools-11.1.0-py3-none-any.whl", hash = "sha256:4b65538ae22f6fed0ce4874efd317463a7489796a0939fa66824dd542125a192", size = 72226, upload-time = "2026-05-22T14:14:28.824Z" }, +] + +[[package]] +name = "mypy-extensions" +version = "1.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a2/6e/371856a3fb9d31ca8dac321cda606860fa4548858c0cc45d9d1d4ca2628b/mypy_extensions-1.1.0.tar.gz", hash = "sha256:52e68efc3284861e772bbcd66823fde5ae21fd2fdb51c62a211403730b916558", size = 6343, upload-time = "2025-04-22T14:54:24.164Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/79/7b/2c79738432f5c924bef5071f933bcc9efd0473bac3b4aa584a6f7c1c8df8/mypy_extensions-1.1.0-py3-none-any.whl", hash = "sha256:1be4cccdb0f2482337c4743e60421de3a356cd97508abadd57d47403e94f5505", size = 4963, upload-time = "2025-04-22T14:54:22.983Z" }, +] + [[package]] name = "packaging" version = "26.2" @@ -264,6 +496,24 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/df/b2/87e62e8c3e2f4b32e5fe99e0b86d576da1312593b39f47d8ceef365e95ed/packaging-26.2-py3-none-any.whl", hash = "sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e", size = 100195, upload-time = "2026-04-24T20:15:22.081Z" }, ] +[[package]] +name = "pathspec" +version = "1.1.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5a/82/42f767fc1c1143d6fd36efb827202a2d997a375e160a71eb2888a925aac1/pathspec-1.1.1.tar.gz", hash = "sha256:17db5ecd524104a120e173814c90367a96a98d07c45b2e10c2f3919fff91bf5a", size = 135180, upload-time = "2026-04-27T01:46:08.907Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f1/d9/7fb5aa316bc299258e68c73ba3bddbc499654a07f151cba08f6153988714/pathspec-1.1.1-py3-none-any.whl", hash = "sha256:a00ce642f577bf7f473932318056212bc4f8bfdf53128c78bbd5af0b9b20b189", size = 57328, upload-time = "2026-04-27T01:46:07.06Z" }, +] + +[[package]] +name = "platformdirs" +version = "4.11.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/50/bb/ebc6636e1ae41314f796ebb7215fd28febb45f9aac72f2b04cb74b5071dc/platformdirs-4.11.4.tar.gz", hash = "sha256:f3373be828247211d0febabea97e238c3dfde8a60b3c90c32756fb52cb21556d", size = 34079, upload-time = "2026-08-24T14:53:49.676Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/28/be/0ff05fcd2938fb58ad9219bd54135968342d214737e012d62d43f06a2dd6/platformdirs-4.11.4-py3-none-any.whl", hash = "sha256:e34ff91a24bcddc6d939b878bdf3f5c437c9c46fe9e212b1bf455fdf1ee57586", size = 23741, upload-time = "2026-08-24T14:53:48.406Z" }, +] + [[package]] name = "pluggy" version = "1.6.0" @@ -445,6 +695,109 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/03/e2/08a497ef684b88559c9cc5f4ad53a37e7b99e727094a86d6ea32536d5d3c/pytest_asyncio-1.4.0-py3-none-any.whl", hash = "sha256:933ca923a23075a87fb7070c0ec272a6848489824d887c85c812670932835aa1", size = 16930, upload-time = "2026-05-26T09:56:02.576Z" }, ] +[[package]] +name = "pytokens" +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" }, + { url = "https://files.pythonhosted.org/packages/4e/54/3e04f9d92a4be4fc6c80016bc396b923d2a6933ae94b5f557c939c460ee0/pytokens-0.4.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:d9907d61f15bf7261d7e775bd5d7ee4d2930e04424bab1972591918497623a16", size = 264075, upload-time = "2026-01-30T01:03:04.143Z" }, + { url = "https://files.pythonhosted.org/packages/d1/1b/44b0326cb5470a4375f37988aea5d61b5cc52407143303015ebee94abfd6/pytokens-0.4.1-cp311-cp311-win_amd64.whl", hash = "sha256:ee44d0f85b803321710f9239f335aafe16553b39106384cef8e6de40cb4ef2f6", size = 103323, upload-time = "2026-01-30T01:03:05.412Z" }, + { url = "https://files.pythonhosted.org/packages/41/5d/e44573011401fb82e9d51e97f1290ceb377800fb4eed650b96f4753b499c/pytokens-0.4.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:140709331e846b728475786df8aeb27d24f48cbcf7bcd449f8de75cae7a45083", size = 160663, upload-time = "2026-01-30T01:03:06.473Z" }, + { url = "https://files.pythonhosted.org/packages/f0/e6/5bbc3019f8e6f21d09c41f8b8654536117e5e211a85d89212d59cbdab381/pytokens-0.4.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6d6c4268598f762bc8e91f5dbf2ab2f61f7b95bdc07953b602db879b3c8c18e1", size = 255626, upload-time = "2026-01-30T01:03:08.177Z" }, + { url = "https://files.pythonhosted.org/packages/bf/3c/2d5297d82286f6f3d92770289fd439956b201c0a4fc7e72efb9b2293758e/pytokens-0.4.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:24afde1f53d95348b5a0eb19488661147285ca4dd7ed752bbc3e1c6242a304d1", size = 269779, upload-time = "2026-01-30T01:03:09.756Z" }, + { url = "https://files.pythonhosted.org/packages/20/01/7436e9ad693cebda0551203e0bf28f7669976c60ad07d6402098208476de/pytokens-0.4.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:5ad948d085ed6c16413eb5fec6b3e02fa00dc29a2534f088d3302c47eb59adf9", size = 268076, upload-time = "2026-01-30T01:03:10.957Z" }, + { url = "https://files.pythonhosted.org/packages/2e/df/533c82a3c752ba13ae7ef238b7f8cdd272cf1475f03c63ac6cf3fcfb00b6/pytokens-0.4.1-cp312-cp312-win_amd64.whl", hash = "sha256:3f901fe783e06e48e8cbdc82d631fca8f118333798193e026a50ce1b3757ea68", size = 103552, upload-time = "2026-01-30T01:03:12.066Z" }, + { url = "https://files.pythonhosted.org/packages/cb/dc/08b1a080372afda3cceb4f3c0a7ba2bde9d6a5241f1edb02a22a019ee147/pytokens-0.4.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:8bdb9d0ce90cbf99c525e75a2fa415144fd570a1ba987380190e8b786bc6ef9b", size = 160720, upload-time = "2026-01-30T01:03:13.843Z" }, + { url = "https://files.pythonhosted.org/packages/64/0c/41ea22205da480837a700e395507e6a24425151dfb7ead73343d6e2d7ffe/pytokens-0.4.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5502408cab1cb18e128570f8d598981c68a50d0cbd7c61312a90507cd3a1276f", size = 254204, upload-time = "2026-01-30T01:03:14.886Z" }, + { url = "https://files.pythonhosted.org/packages/e0/d2/afe5c7f8607018beb99971489dbb846508f1b8f351fcefc225fcf4b2adc0/pytokens-0.4.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:29d1d8fb1030af4d231789959f21821ab6325e463f0503a61d204343c9b355d1", size = 268423, upload-time = "2026-01-30T01:03:15.936Z" }, + { url = "https://files.pythonhosted.org/packages/68/d4/00ffdbd370410c04e9591da9220a68dc1693ef7499173eb3e30d06e05ed1/pytokens-0.4.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:970b08dd6b86058b6dc07efe9e98414f5102974716232d10f32ff39701e841c4", size = 266859, upload-time = "2026-01-30T01:03:17.458Z" }, + { url = "https://files.pythonhosted.org/packages/a7/c9/c3161313b4ca0c601eeefabd3d3b576edaa9afdefd32da97210700e47652/pytokens-0.4.1-cp313-cp313-win_amd64.whl", hash = "sha256:9bd7d7f544d362576be74f9d5901a22f317efc20046efe2034dced238cbbfe78", size = 103520, upload-time = "2026-01-30T01:03:18.652Z" }, + { url = "https://files.pythonhosted.org/packages/8f/a7/b470f672e6fc5fee0a01d9e75005a0e617e162381974213a945fcd274843/pytokens-0.4.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:4a14d5f5fc78ce85e426aa159489e2d5961acf0e47575e08f35584009178e321", size = 160821, upload-time = "2026-01-30T01:03:19.684Z" }, + { url = "https://files.pythonhosted.org/packages/80/98/e83a36fe8d170c911f864bfded690d2542bfcfacb9c649d11a9e6eb9dc41/pytokens-0.4.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:97f50fd18543be72da51dd505e2ed20d2228c74e0464e4262e4899797803d7fa", size = 254263, upload-time = "2026-01-30T01:03:20.834Z" }, + { url = "https://files.pythonhosted.org/packages/0f/95/70d7041273890f9f97a24234c00b746e8da86df462620194cef1d411ddeb/pytokens-0.4.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:dc74c035f9bfca0255c1af77ddd2d6ae8419012805453e4b0e7513e17904545d", size = 268071, upload-time = "2026-01-30T01:03:21.888Z" }, + { url = "https://files.pythonhosted.org/packages/da/79/76e6d09ae19c99404656d7db9c35dfd20f2086f3eb6ecb496b5b31163bad/pytokens-0.4.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:f66a6bbe741bd431f6d741e617e0f39ec7257ca1f89089593479347cc4d13324", size = 271716, upload-time = "2026-01-30T01:03:23.633Z" }, + { url = "https://files.pythonhosted.org/packages/79/37/482e55fa1602e0a7ff012661d8c946bafdc05e480ea5a32f4f7e336d4aa9/pytokens-0.4.1-cp314-cp314-win_amd64.whl", hash = "sha256:b35d7e5ad269804f6697727702da3c517bb8a5228afa450ab0fa787732055fc9", size = 104539, upload-time = "2026-01-30T01:03:24.788Z" }, + { url = "https://files.pythonhosted.org/packages/30/e8/20e7db907c23f3d63b0be3b8a4fd1927f6da2395f5bcc7f72242bb963dfe/pytokens-0.4.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:8fcb9ba3709ff77e77f1c7022ff11d13553f3c30299a9fe246a166903e9091eb", size = 168474, upload-time = "2026-01-30T01:03:26.428Z" }, + { url = "https://files.pythonhosted.org/packages/d6/81/88a95ee9fafdd8f5f3452107748fd04c24930d500b9aba9738f3ade642cc/pytokens-0.4.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:79fc6b8699564e1f9b521582c35435f1bd32dd06822322ec44afdeba666d8cb3", size = 290473, upload-time = "2026-01-30T01:03:27.415Z" }, + { url = "https://files.pythonhosted.org/packages/cf/35/3aa899645e29b6375b4aed9f8d21df219e7c958c4c186b465e42ee0a06bf/pytokens-0.4.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d31b97b3de0f61571a124a00ffe9a81fb9939146c122c11060725bd5aea79975", size = 303485, upload-time = "2026-01-30T01:03:28.558Z" }, + { url = "https://files.pythonhosted.org/packages/52/a0/07907b6ff512674d9b201859f7d212298c44933633c946703a20c25e9d81/pytokens-0.4.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:967cf6e3fd4adf7de8fc73cd3043754ae79c36475c1c11d514fc72cf5490094a", size = 306698, upload-time = "2026-01-30T01:03:29.653Z" }, + { url = "https://files.pythonhosted.org/packages/39/2a/cbbf9250020a4a8dd53ba83a46c097b69e5eb49dd14e708f496f548c6612/pytokens-0.4.1-cp314-cp314t-win_amd64.whl", hash = "sha256:584c80c24b078eec1e227079d56dc22ff755e0ba8654d8383b2c549107528918", size = 116287, upload-time = "2026-01-30T01:03:30.912Z" }, + { url = "https://files.pythonhosted.org/packages/c6/78/397db326746f0a342855b81216ae1f0a32965deccfd7c830a2dbc66d2483/pytokens-0.4.1-py3-none-any.whl", hash = "sha256:26cef14744a8385f35d0e095dc8b3a7583f6c953c2e3d269c7f82484bf5ad2de", size = 13729, upload-time = "2026-01-30T01:03:45.029Z" }, +] + +[[package]] +name = "pyyaml" +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" }, + { url = "https://files.pythonhosted.org/packages/10/cb/16c3f2cf3266edd25aaa00d6c4350381c8b012ed6f5276675b9eba8d9ff4/pyyaml-6.0.3-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:850774a7879607d3a6f50d36d04f00ee69e7fc816450e5f7e58d7f17f1ae5c00", size = 882114, upload-time = "2025-09-25T21:32:03.376Z" }, + { url = "https://files.pythonhosted.org/packages/71/60/917329f640924b18ff085ab889a11c763e0b573da888e8404ff486657602/pyyaml-6.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b8bb0864c5a28024fac8a632c443c87c5aa6f215c0b126c449ae1a150412f31d", size = 806638, upload-time = "2025-09-25T21:32:04.553Z" }, + { url = "https://files.pythonhosted.org/packages/dd/6f/529b0f316a9fd167281a6c3826b5583e6192dba792dd55e3203d3f8e655a/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1d37d57ad971609cf3c53ba6a7e365e40660e3be0e5175fa9f2365a379d6095a", size = 767463, upload-time = "2025-09-25T21:32:06.152Z" }, + { url = "https://files.pythonhosted.org/packages/f2/6a/b627b4e0c1dd03718543519ffb2f1deea4a1e6d42fbab8021936a4d22589/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:37503bfbfc9d2c40b344d06b2199cf0e96e97957ab1c1b546fd4f87e53e5d3e4", size = 794986, upload-time = "2025-09-25T21:32:07.367Z" }, + { url = "https://files.pythonhosted.org/packages/45/91/47a6e1c42d9ee337c4839208f30d9f09caa9f720ec7582917b264defc875/pyyaml-6.0.3-cp311-cp311-win32.whl", hash = "sha256:8098f252adfa6c80ab48096053f512f2321f0b998f98150cea9bd23d83e1467b", size = 142543, upload-time = "2025-09-25T21:32:08.95Z" }, + { url = "https://files.pythonhosted.org/packages/da/e3/ea007450a105ae919a72393cb06f122f288ef60bba2dc64b26e2646fa315/pyyaml-6.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:9f3bfb4965eb874431221a3ff3fdcddc7e74e3b07799e0e84ca4a0f867d449bf", size = 158763, upload-time = "2025-09-25T21:32:09.96Z" }, + { url = "https://files.pythonhosted.org/packages/d1/33/422b98d2195232ca1826284a76852ad5a86fe23e31b009c9886b2d0fb8b2/pyyaml-6.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196", size = 182063, upload-time = "2025-09-25T21:32:11.445Z" }, + { url = "https://files.pythonhosted.org/packages/89/a0/6cf41a19a1f2f3feab0e9c0b74134aa2ce6849093d5517a0c550fe37a648/pyyaml-6.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0", size = 173973, upload-time = "2025-09-25T21:32:12.492Z" }, + { url = "https://files.pythonhosted.org/packages/ed/23/7a778b6bd0b9a8039df8b1b1d80e2e2ad78aa04171592c8a5c43a56a6af4/pyyaml-6.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28", size = 775116, upload-time = "2025-09-25T21:32:13.652Z" }, + { url = "https://files.pythonhosted.org/packages/65/30/d7353c338e12baef4ecc1b09e877c1970bd3382789c159b4f89d6a70dc09/pyyaml-6.0.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c", size = 844011, upload-time = "2025-09-25T21:32:15.21Z" }, + { url = "https://files.pythonhosted.org/packages/8b/9d/b3589d3877982d4f2329302ef98a8026e7f4443c765c46cfecc8858c6b4b/pyyaml-6.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc", size = 807870, upload-time = "2025-09-25T21:32:16.431Z" }, + { url = "https://files.pythonhosted.org/packages/05/c0/b3be26a015601b822b97d9149ff8cb5ead58c66f981e04fedf4e762f4bd4/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e", size = 761089, upload-time = "2025-09-25T21:32:17.56Z" }, + { url = "https://files.pythonhosted.org/packages/be/8e/98435a21d1d4b46590d5459a22d88128103f8da4c2d4cb8f14f2a96504e1/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea", size = 790181, upload-time = "2025-09-25T21:32:18.834Z" }, + { url = "https://files.pythonhosted.org/packages/74/93/7baea19427dcfbe1e5a372d81473250b379f04b1bd3c4c5ff825e2327202/pyyaml-6.0.3-cp312-cp312-win32.whl", hash = "sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5", size = 137658, upload-time = "2025-09-25T21:32:20.209Z" }, + { url = "https://files.pythonhosted.org/packages/86/bf/899e81e4cce32febab4fb42bb97dcdf66bc135272882d1987881a4b519e9/pyyaml-6.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b", size = 154003, upload-time = "2025-09-25T21:32:21.167Z" }, + { url = "https://files.pythonhosted.org/packages/1a/08/67bd04656199bbb51dbed1439b7f27601dfb576fb864099c7ef0c3e55531/pyyaml-6.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd", size = 140344, upload-time = "2025-09-25T21:32:22.617Z" }, + { url = "https://files.pythonhosted.org/packages/d1/11/0fd08f8192109f7169db964b5707a2f1e8b745d4e239b784a5a1dd80d1db/pyyaml-6.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8", size = 181669, upload-time = "2025-09-25T21:32:23.673Z" }, + { url = "https://files.pythonhosted.org/packages/b1/16/95309993f1d3748cd644e02e38b75d50cbc0d9561d21f390a76242ce073f/pyyaml-6.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1", size = 173252, upload-time = "2025-09-25T21:32:25.149Z" }, + { url = "https://files.pythonhosted.org/packages/50/31/b20f376d3f810b9b2371e72ef5adb33879b25edb7a6d072cb7ca0c486398/pyyaml-6.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c", size = 767081, upload-time = "2025-09-25T21:32:26.575Z" }, + { url = "https://files.pythonhosted.org/packages/49/1e/a55ca81e949270d5d4432fbbd19dfea5321eda7c41a849d443dc92fd1ff7/pyyaml-6.0.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5", size = 841159, upload-time = "2025-09-25T21:32:27.727Z" }, + { url = "https://files.pythonhosted.org/packages/74/27/e5b8f34d02d9995b80abcef563ea1f8b56d20134d8f4e5e81733b1feceb2/pyyaml-6.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6", size = 801626, upload-time = "2025-09-25T21:32:28.878Z" }, + { url = "https://files.pythonhosted.org/packages/f9/11/ba845c23988798f40e52ba45f34849aa8a1f2d4af4b798588010792ebad6/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6", size = 753613, upload-time = "2025-09-25T21:32:30.178Z" }, + { url = "https://files.pythonhosted.org/packages/3d/e0/7966e1a7bfc0a45bf0a7fb6b98ea03fc9b8d84fa7f2229e9659680b69ee3/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be", size = 794115, upload-time = "2025-09-25T21:32:31.353Z" }, + { url = "https://files.pythonhosted.org/packages/de/94/980b50a6531b3019e45ddeada0626d45fa85cbe22300844a7983285bed3b/pyyaml-6.0.3-cp313-cp313-win32.whl", hash = "sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26", size = 137427, upload-time = "2025-09-25T21:32:32.58Z" }, + { url = "https://files.pythonhosted.org/packages/97/c9/39d5b874e8b28845e4ec2202b5da735d0199dbe5b8fb85f91398814a9a46/pyyaml-6.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c", size = 154090, upload-time = "2025-09-25T21:32:33.659Z" }, + { url = "https://files.pythonhosted.org/packages/73/e8/2bdf3ca2090f68bb3d75b44da7bbc71843b19c9f2b9cb9b0f4ab7a5a4329/pyyaml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb", size = 140246, upload-time = "2025-09-25T21:32:34.663Z" }, + { url = "https://files.pythonhosted.org/packages/9d/8c/f4bd7f6465179953d3ac9bc44ac1a8a3e6122cf8ada906b4f96c60172d43/pyyaml-6.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac", size = 181814, upload-time = "2025-09-25T21:32:35.712Z" }, + { url = "https://files.pythonhosted.org/packages/bd/9c/4d95bb87eb2063d20db7b60faa3840c1b18025517ae857371c4dd55a6b3a/pyyaml-6.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310", size = 173809, upload-time = "2025-09-25T21:32:36.789Z" }, + { url = "https://files.pythonhosted.org/packages/92/b5/47e807c2623074914e29dabd16cbbdd4bf5e9b2db9f8090fa64411fc5382/pyyaml-6.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7", size = 766454, upload-time = "2025-09-25T21:32:37.966Z" }, + { url = "https://files.pythonhosted.org/packages/02/9e/e5e9b168be58564121efb3de6859c452fccde0ab093d8438905899a3a483/pyyaml-6.0.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788", size = 836355, upload-time = "2025-09-25T21:32:39.178Z" }, + { url = "https://files.pythonhosted.org/packages/88/f9/16491d7ed2a919954993e48aa941b200f38040928474c9e85ea9e64222c3/pyyaml-6.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5", size = 794175, upload-time = "2025-09-25T21:32:40.865Z" }, + { url = "https://files.pythonhosted.org/packages/dd/3f/5989debef34dc6397317802b527dbbafb2b4760878a53d4166579111411e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764", size = 755228, upload-time = "2025-09-25T21:32:42.084Z" }, + { url = "https://files.pythonhosted.org/packages/d7/ce/af88a49043cd2e265be63d083fc75b27b6ed062f5f9fd6cdc223ad62f03e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35", size = 789194, upload-time = "2025-09-25T21:32:43.362Z" }, + { url = "https://files.pythonhosted.org/packages/23/20/bb6982b26a40bb43951265ba29d4c246ef0ff59c9fdcdf0ed04e0687de4d/pyyaml-6.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac", size = 156429, upload-time = "2025-09-25T21:32:57.844Z" }, + { url = "https://files.pythonhosted.org/packages/f4/f4/a4541072bb9422c8a883ab55255f918fa378ecf083f5b85e87fc2b4eda1b/pyyaml-6.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3", size = 143912, upload-time = "2025-09-25T21:32:59.247Z" }, + { url = "https://files.pythonhosted.org/packages/7c/f9/07dd09ae774e4616edf6cda684ee78f97777bdd15847253637a6f052a62f/pyyaml-6.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3", size = 189108, upload-time = "2025-09-25T21:32:44.377Z" }, + { url = "https://files.pythonhosted.org/packages/4e/78/8d08c9fb7ce09ad8c38ad533c1191cf27f7ae1effe5bb9400a46d9437fcf/pyyaml-6.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba", size = 183641, upload-time = "2025-09-25T21:32:45.407Z" }, + { url = "https://files.pythonhosted.org/packages/7b/5b/3babb19104a46945cf816d047db2788bcaf8c94527a805610b0289a01c6b/pyyaml-6.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c", size = 831901, upload-time = "2025-09-25T21:32:48.83Z" }, + { url = "https://files.pythonhosted.org/packages/8b/cc/dff0684d8dc44da4d22a13f35f073d558c268780ce3c6ba1b87055bb0b87/pyyaml-6.0.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702", size = 861132, upload-time = "2025-09-25T21:32:50.149Z" }, + { url = "https://files.pythonhosted.org/packages/b1/5e/f77dc6b9036943e285ba76b49e118d9ea929885becb0a29ba8a7c75e29fe/pyyaml-6.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c", size = 839261, upload-time = "2025-09-25T21:32:51.808Z" }, + { url = "https://files.pythonhosted.org/packages/ce/88/a9db1376aa2a228197c58b37302f284b5617f56a5d959fd1763fb1675ce6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065", size = 805272, upload-time = "2025-09-25T21:32:52.941Z" }, + { url = "https://files.pythonhosted.org/packages/da/92/1446574745d74df0c92e6aa4a7b0b3130706a4142b2d1a5869f2eaa423c6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65", size = 829923, upload-time = "2025-09-25T21:32:54.537Z" }, + { url = "https://files.pythonhosted.org/packages/f0/7a/1c7270340330e575b92f397352af856a8c06f230aa3e76f86b39d01b416a/pyyaml-6.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9", size = 174062, upload-time = "2025-09-25T21:32:55.767Z" }, + { url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" }, +] + [[package]] name = "rich" version = "15.0.0" @@ -580,6 +933,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d4/8f/ac36fde77e223297454c1e0aeb8888c169eaacf3163bb609e3af942c88cb/ty-0.0.59-py3-none-win_arm64.whl", hash = "sha256:987043ee9e021f49493d9135891ac69c1affeee0d4ad4480c5fa4d9c975fc91b", size = 11650921, upload-time = "2026-07-12T20:22:00.348Z" }, ] +[[package]] +name = "typeguard" +version = "4.6.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b4/de/4420db493fa8fc0856d5e5c1b159c63a323d2de2317babe36b01568928e8/typeguard-4.6.0.tar.gz", hash = "sha256:e7414f09111317de3e335de92cd397c5c0ca00b1cc1676de12e1d444a79b3f21", size = 82330, upload-time = "2026-07-26T08:40:23.207Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8f/eb/461d5f167b6f5c7d97696f397c82f82e3480e003fce3f0a1cd1dd26e2eb2/typeguard-4.6.0-py3-none-any.whl", hash = "sha256:79878165bb86f2cf5d41d159a0ff1792a796cf496882d2fe1b1c6c7049b9cdd7", size = 36884, upload-time = "2026-07-26T08:40:21.868Z" }, +] + [[package]] name = "typer" version = "0.26.8"