Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
3c11530
feat(sdk): add DiscolikeRequest base for generated request models
yudelevi Aug 27, 2026
e3b9676
refactor(sdk): drop ignore_params from api_route
yudelevi Aug 27, 2026
827f8ee
build: add gen_requests.py to derive request models from the spec
yudelevi Aug 27, 2026
5923b20
fix: make prune walk refs in a deterministic order
yudelevi Aug 27, 2026
67d80b4
feat(sdk): commit generated request models and discolike.requests
yudelevi Aug 27, 2026
f268a0d
feat(sdk)!: companies methods take generated request models
yudelevi Aug 27, 2026
9050ed3
feat(sdk)!: match methods take generated request models
yudelevi Aug 27, 2026
94b74f7
feat(sdk)!: contacts methods take generated request models
yudelevi Aug 27, 2026
0a48e02
feat(sdk)!: email methods take generated request models
yudelevi Aug 27, 2026
d9401f9
feat(sdk)!: queries methods take generated request models
yudelevi Aug 27, 2026
836b270
feat(sdk)!: provider methods take generated request models
yudelevi Aug 27, 2026
d505d06
feat(sdk)!: discogen and validate_icp take generated request models
yudelevi Aug 27, 2026
a746741
feat(sdk)!: discover/count take generated request models
yudelevi Aug 27, 2026
eb4331c
feat(sdk)!: append/segment take generated request models; add segment…
yudelevi Aug 27, 2026
6d8436f
ci: check request models against the spec in both directions
yudelevi Aug 27, 2026
8da7499
feat(cli): build request models from options; map pydantic errors to …
yudelevi Aug 27, 2026
80a0d49
feat(cli): company, match, discover and count build request models
yudelevi Aug 27, 2026
e0fa9f7
feat(cli): contacts and email commands build request models
yudelevi Aug 27, 2026
fb88ba7
feat(cli): queries and provider commands build request models
yudelevi Aug 27, 2026
b4f5853
feat(cli): discogen, validate-icp, append and segment build request m…
yudelevi Aug 27, 2026
950748c
release: 0.3.0 request models
yudelevi Aug 27, 2026
2c00fc3
fix(docs): convert stale discover() call in README error-handling exa…
yudelevi Aug 27, 2026
ec6e8dc
docs: document append --dataset requirement, segment query_id wire ch…
yudelevi Aug 27, 2026
54197a2
fix(gen_requests): silence ruff --fix noise and handle a missing gene…
yudelevi Aug 27, 2026
f244d96
test: retarget email-batch limit test at the model, fix stale noqa an…
yudelevi Aug 27, 2026
c649548
revert gen_requests missing-file guard: keep imports at module top
yudelevi Aug 27, 2026
3b6762e
fix(examples): narrow batch result to EnumerationOutput before readin…
yudelevi Aug 27, 2026
a8de867
fix(cli): reject empty cells in --contacts-file before submitting
yudelevi Aug 27, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .github/workflows/contract.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
29 changes: 29 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,34 @@
# Changelog

## 0.3.0 (2026-08-27)

- 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 `<Resource><Method>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.
Expand Down
6 changes: 6 additions & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
56 changes: 44 additions & 12 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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)
Expand All @@ -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:
Expand All @@ -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())
Expand Down Expand Up @@ -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.

Expand All @@ -205,7 +218,9 @@ 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()
```

Expand All @@ -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:
Expand All @@ -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 | |
Expand All @@ -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)
Expand Down
12 changes: 7 additions & 5 deletions examples/discover_and_enrich.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand All @@ -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:
Expand All @@ -43,15 +45,15 @@ 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]
for company in companies:
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:")
Expand Down
11 changes: 6 additions & 5 deletions examples/find_emails_from_csv.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand All @@ -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

Expand Down Expand Up @@ -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.
Expand All @@ -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(
Expand Down
7 changes: 5 additions & 2 deletions examples/match_crm_contacts.py
Original file line number Diff line number Diff line change
@@ -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:
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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
Expand Down
4 changes: 2 additions & 2 deletions packages/discolike-cli/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -4,15 +4,15 @@ 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"
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",
]
Expand Down
33 changes: 28 additions & 5 deletions packages/discolike-cli/src/discolike_cli/_output.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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,
Expand Down Expand Up @@ -117,18 +122,36 @@ 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]:
@functools.wraps(fn)
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

Expand Down
Loading
Loading