Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
f7251cb
fix(packaging): ship license text, mark the CLI typed
yudelevi Aug 19, 2026
a2c4f6b
fix: ungate save-results now that the platform endpoint is live
yudelevi Aug 19, 2026
7d33a62
ci: test against Python 3.15 prereleases without blocking
yudelevi Aug 19, 2026
732da84
docs: fold packaging and contract fixes into the 0.1.2 entry
yudelevi Aug 19, 2026
ad905bb
feat(cli): add the discolike email command group
Aug 19, 2026
354b2cb
feat(sdk): known_pattern on email.find; email routes join the contrac…
Aug 19, 2026
7d8e3f5
docs: add runnable examples folder
Aug 19, 2026
b91a895
fix(examples): cap bulk-match chunks by query count; keep failed emai…
Aug 19, 2026
0c4eece
Merge pull request #5 from discolike/feature/cli-email
quantumdark Aug 21, 2026
3de6f6e
fix(cli): honor global --base-url and --api-key in auth commands
yudelevi Aug 21, 2026
3346052
Merge pull request #6 from discolike/fix/auth-global-options
yudelevi Aug 21, 2026
34a6dab
fix(sdk): let email.job() rehydrate verify jobs; honor server-reporte…
yudelevi Aug 21, 2026
b28e79b
feat(sdk): typed responses for contacts.count and contacts.discover
yudelevi Aug 21, 2026
eadf7e7
feat(sdk): explicit typed signatures on client-level forwarders
yudelevi Aug 21, 2026
7559823
feat(sdk): per-request timeout via client.with_options(timeout=...)
yudelevi Aug 21, 2026
0e83595
fix: closing a with_options view no longer closes the shared http client
yudelevi Aug 21, 2026
f28d59d
refactor(sdk): pick email output model via exhaustive match on kind
yudelevi Aug 21, 2026
66b5a49
Merge PR #7: email.job kind + server-reported kind
yudelevi Aug 21, 2026
7c7c8c6
Merge PR #8: typed contacts.count/discover responses
yudelevi Aug 21, 2026
2dbb977
Merge PR #9: typed client forwarders
yudelevi Aug 21, 2026
32d10b3
Merge PR #10: per-request timeout via with_options
yudelevi Aug 21, 2026
005d083
feat!: migrate from httpx to httpx2
yudelevi Aug 21, 2026
9bc79a6
release: consolidate the unshipped 0.1.2 into 0.2.0
yudelevi Aug 21, 2026
47a315c
build: pin the CLI and SDK to each other exactly
yudelevi Aug 21, 2026
b4e5a5f
Merge pull request #11 from discolike/feat/httpx2-migration
yudelevi Aug 21, 2026
c35b5c8
fix(sdk): let email job wait() accept a server-reported kind
yudelevi Aug 21, 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
14 changes: 14 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -25,3 +25,17 @@ jobs:
- run: uv run ty check packages/discolike/src packages/discolike/tests packages/discolike-cli/src packages/discolike-cli/tests packages/discolike-testkit/src
- run: uv run pytest packages/discolike/tests -q
- run: uv run pytest packages/discolike-cli/tests -q

# 3.15 is still prerelease; non-blocking until 3.15.0 final, when it moves
# into the matrix above and gets a classifier.
test-prerelease:
runs-on: ubuntu-latest
continue-on-error: true
steps:
- uses: actions/checkout@v4
- uses: astral-sh/setup-uv@v5
with:
python-version: '3.15'
- run: uv sync --all-packages
- run: uv run pytest packages/discolike/tests -q
- run: uv run pytest packages/discolike-cli/tests -q
17 changes: 15 additions & 2 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,8 +1,21 @@
# Changelog

## 0.1.2 (2026-08-17)
## 0.2.0 (2026-08-21)

- SDK + CLI: new `queries.save_results` (sync + async) and `discolike queries save-results` — save result rows (JSON or CSV file in the CLI) as a reusable saved query, the REST twin of the `save-mcp-query` MCP tool. The CLI validates `--action` against the allowed set; the route is marked `openapi=False` until the platform endpoint deploys, at which point `scripts/check_contract.py` picks it up.
- 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.
- CLI (fix): `auth login` and `auth status` now honor the global `--base-url` and `--api-key`. Every other command routed through `get_client(ctx)`; these two built their own client, so `--base-url` was ignored and `auth status` reported `"valid": true` for a host it never contacted, while `--api-key` was ignored in favour of the environment or config key. `auth status` gains a third `source` value, `option`, for a key passed explicitly on the command line. An ambient `DISCOLIKE_API_KEY` still does not skip the `auth login` prompt — only an explicit flag does.
- SDK: `email.job(job_id)` accepts `kind="find"|"verify"` (default `"find"`), so verify jobs can be rehydrated — previously every rehydrated job decoded as a find job. `wait()` now returns `EnumerationOutput | ValidationOutput` per the handle's kind. Job and batch results also honor a server-reported `kind` field when present, so a handle rehydrated with the wrong kind still parses each result into the right model.
- SDK: `contacts.count` returns a typed `Count` and `contacts.discover` a typed `ContactsDiscoverResponse` (`results` map of domain → `ContactsByCompany`, `total_contacts`, `total_domains`) — both previously returned a bare passthrough model with everything in `.extra`. `ContactsByCompany` now extends `CompanyProfile` (firmographics + nested `contacts`, `email_pattern`, `email_pattern_confidence`, `email_pattern_guess`), mirroring the platform's `DomainContactsEntry`; it was previously defined but never constructed.
- SDK: the client-level `discover`, `count`, `validate_icp`, `append`, and `segment` methods now declare explicit typed signatures mirroring their underlying resource methods (sync + async) instead of untyped `**kwargs` — misspelled keywords are caught statically and editors autocomplete every parameter.
- SDK: new `client.with_options(timeout=...)` (sync + async) — returns a lightweight client view with a per-request timeout override (float or `httpx2.Timeout`), sharing the parent's connection pool. Client-level rate limiting and pagination stay out by design: the transport already retries 429 honoring `Retry-After`, and search/discover paginate via `offset`/`max_records`.
- CLI: new `discolike email` command group wrapping the SDK email resource — `find FIRST LAST DOMAIN [--known-pattern X]`, `find-batch` (CSV file and/or repeatable `--contact "first,last,domain"`, max 500 per batch), `results BATCH_ID [--kind find|verify]`, and `job JOB_ID`, each with `--wait/--no-wait` polling.
- SDK: `email.find` accepts `known_pattern` (sync + async), matching the platform's `POST /email/find` body. Omitted from the request when unset.
- SDK: email routes are no longer `openapi=False` — the platform now exposes `/email/find`, `/email/find/batch`, and the poll routes in its OpenAPI spec, so `check_contract.py` validates them like every other route.
- Examples: new `examples/` folder with runnable end-to-end scripts — `match_crm_contacts.py` (bulk-match a CRM CSV to personas with resumable checkpointing and website+email domain keys), `find_emails_from_csv.py` (batch email finding), `discover_and_enrich.py` (discover + DiscoGen enrichment). Referenced from the README.
- Packaging: both wheels now ship the MIT license text (`dist-info/licenses/LICENSE`) — it was absent from every release so far, since the only `LICENSE` sat at the repo root, outside either package root.
- Packaging: `discolike-cli` ships `py.typed`, so its annotations are visible to type checkers importing `discolike_cli`. Added classifiers: `Python :: 3 :: Only`, `OS Independent`, `Typing :: Typed`, plus `Libraries :: Python Modules` (SDK) and `Environment :: Console` / `Topic :: Utilities` (CLI). Added `Changelog` and `Issues` project URLs.
- CI: tests also run on Python 3.15 prereleases in a non-blocking job. 3.15 stays out of the supported matrix and classifiers until 3.15.0 final.
- SDK + CLI: new `queries.save_results` (sync + async) and `discolike queries save-results` — save result rows (JSON or CSV file in the CLI) as a reusable saved query, the REST twin of the `save-mcp-query` MCP tool. The CLI validates `--action` against the allowed set.
- SDK + CLI (breaking): removed `companies.metrics` / `companies.history` and the `company metrics` / `company history` commands. The underlying `/metrics` and `/history` API endpoints are deprecated, with removal scheduled for 2026-10-01.
- SDK (breaking): `companies.redirects`, `companies.vendors`, `companies.subsidiaries`, and `companies.public_links` now return a `list` of typed rows (`Redirect`, `Vendor`, `Subsidiary`, `PublicLink`). These endpoints return a JSON array; the SDK was validating that array into a single model and raised `ValidationError` on every live call.
- SDK: `discover` rows, `companies.data`, and match rows now share one typed `CompanyProfile` base mirroring the platform's `CompanyResult` — all 21 firmographic fields (`status`, `address`, `keywords`, `industry_groups`, `business_model`, `revenue_range`, `employees`, `mx_provider`, …), with nested `CompanyStatus` / `CompanyAddress`. `discovery.Company` previously declared 5 of them and `BizData` none.
Expand Down
11 changes: 10 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -142,6 +142,15 @@ async def main() -> None:
asyncio.run(main())
```

## Examples

The [`examples/`](examples/) folder has runnable scripts for common workflows — matching a CRM contact export to DiscoLike persona IDs (with checkpointing and resume), bulk-finding work emails from a CSV, and discovering companies by ICP then enriching them with DiscoGen. Each is stdlib-plus-SDK only:

```bash
export DISCOLIKE_API_KEY="dl_..."
python examples/match_crm_contacts.py --help
```

## CLI

The same API from your terminal, with `--help` on every command:
Expand Down Expand Up @@ -229,7 +238,7 @@ except ValidationError as err:
| `base_url` | `https://api.discolike.com/v1` | |
| `timeout` | `60.0` seconds | |
| `max_retries` | `3` | |
| `http_client` | — | Bring your own `httpx.Client` / `httpx.AsyncClient` |
| `http_client` | — | Bring your own `httpx2.Client` / `httpx2.AsyncClient` |

A provided `http_client` is mutated in place (the auth header is stamped on it, and `base_url` is set if it's unset) — use a client dedicated to DiscoLike, not one shared across other services.

Expand Down
17 changes: 17 additions & 0 deletions examples/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
# Examples

Runnable, self-contained scripts showing how to use the DiscoLike Python SDK for common GTM workflows: matching a messy CRM export to DiscoLike contacts, finding verified work emails in bulk, and discovering plus AI-enriching target accounts. Each script is stdlib-plus-SDK only, has an argparse CLI, and is meant to be copied into your own pipeline and adapted.

| Script | What it does |
|---|---|
| [`match_crm_contacts.py`](match_crm_contacts.py) | Match a CSV of CRM contacts to DiscoLike persona IDs via `contacts.bulk_match()`, with dual domain keys (website + email domain), resumable JSONL checkpointing, and a persona_id + match_score output CSV |
| [`find_emails_from_csv.py`](find_emails_from_csv.py) | Find work emails for a CSV of people (first name, last name, domain) via `email.find_batch()` in chunks of 500; only status "found" bills |
| [`discover_and_enrich.py`](discover_and_enrich.py) | Discover companies matching an ICP with `client.discover()`, then run a DiscoGen research prompt over them with `discogen.process()` and `job.wait()` |

## Running

```bash
pip install discolike
export DISCOLIKE_API_KEY="dl_..." # create one at https://app.discolike.com/account/management/keys
python examples/<script>.py --help
```
63 changes: 63 additions & 0 deletions examples/discover_and_enrich.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
"""Discover companies matching an ICP, then enrich them with DiscoGen.

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)``
runs an AI research prompt over the discovered domains and returns one
structured answer per company. ``job.wait()`` blocks until it finishes.

DiscoGen runs on your own LLM provider key (BYOK) - configure one first via
``client.llm_providers`` or in the app under Settings -> Integrations.

Usage:
export DISCOLIKE_API_KEY="dl_..."
python examples/discover_and_enrich.py \
--icp "Cybersecurity for SMBs, managed IT services" \
--country US \
--query "What is their pricing model, and do they sell to MSPs?"
"""

from __future__ import annotations

import argparse
import json
import sys

from discolike import Discolike


def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
parser.add_argument("--icp", required=True, help="ICP description, e.g. 'B2B SaaS for logistics'")
parser.add_argument("--country", action="append", help="ISO-2 country filter, repeatable (e.g. --country US)")
parser.add_argument("--max-records", type=int, default=10, help="How many companies to discover (default: 10)")
parser.add_argument("--query", required=True, help="DiscoGen research prompt to run over each company")
parser.add_argument("--timeout", type=float, default=1800.0, help="DiscoGen wait timeout in seconds")
return parser.parse_args()


def main() -> None:
args = parse_args()
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)
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)
status = job.wait(timeout=args.timeout, on_poll=lambda s: print(f" status={s.status} progress={s.progress}%"))

print("\nEnriched results:")
for row in status.results or []:
print(json.dumps(row, ensure_ascii=False, default=str))


if __name__ == "__main__":
main()
109 changes: 109 additions & 0 deletions examples/find_emails_from_csv.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
"""Find work email addresses for a CSV of people via the email finder batch API.

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
each batch with ``batch.results()``, and writes the found emails plus status
to an output CSV.

Billing note: only results with status "found" (an SMTP-verified address) are
billed. Catch-all domains and pattern-based guesses are returned for free.

Usage:
export DISCOLIKE_API_KEY="dl_..."
python examples/find_emails_from_csv.py people.csv --output emails.csv
"""

from __future__ import annotations

import argparse
import csv
import sys
from pathlib import Path

from discolike import Discolike

MAX_CONTACTS_PER_BATCH = 500

OUTPUT_FIELDS = ["first_name", "last_name", "domain", "email", "status", "is_catch_all", "error"]


def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
parser.add_argument("input", type=Path, help="Input CSV of people")
parser.add_argument("--output", type=Path, default=Path("emails.csv"), help="Output CSV path")
parser.add_argument("--first-name-column", default="first_name", help="First-name column (default: first_name)")
parser.add_argument("--last-name-column", default="last_name", help="Last-name column (default: last_name)")
parser.add_argument("--domain-column", default="domain", help="Company-domain column (default: domain)")
parser.add_argument("--timeout", type=float, default=1800.0, help="Per-batch wait timeout in seconds")
return parser.parse_args()


def load_contacts(path: Path, args: argparse.Namespace) -> list[dict[str, str]]:
contacts: list[dict[str, str]] = []
with path.open(newline="", encoding="utf-8-sig") as handle:
for row in csv.DictReader(handle):
first = row.get(args.first_name_column, "").strip()
last = row.get(args.last_name_column, "").strip()
domain = row.get(args.domain_column, "").strip().lower().removeprefix("www.")
if first and last and domain:
contacts.append({"first_name": first, "last_name": last, "domain": domain})
return contacts


def main() -> None:
args = parse_args()
contacts = load_contacts(args.input, args)
if not contacts:
sys.exit(f"No usable rows (first name + last name + domain) found in {args.input}")

client = Discolike()
found = 0
with args.output.open("w", newline="", encoding="utf-8") as handle:
writer = csv.DictWriter(handle, fieldnames=OUTPUT_FIELDS)
writer.writeheader()
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)
results = batch.results(timeout=args.timeout)
for item in results.results:
output = item.result
if output is None:
# 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.
writer.writerow(
{
"first_name": "",
"last_name": "",
"domain": "",
"email": "",
"status": item.status or "failed",
"is_catch_all": "",
"error": item.error or "",
}
)
continue
match = getattr(output, "result", None)
email = match.email if match is not None else None
if output.status == "found":
found += 1
writer.writerow(
{
"first_name": output.first_name,
"last_name": output.last_name,
"domain": output.domain,
"email": email or "",
"status": output.status or "",
"is_catch_all": output.is_catch_all,
"error": output.error or "",
}
)
handle.flush()

print(f'\nDone: {found}/{len(contacts)} verified emails (status "found") -> {args.output}')


if __name__ == "__main__":
main()
Loading
Loading