Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
8 changes: 3 additions & 5 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,19 +1,17 @@
# Changelog

## Unreleased
## 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.
- 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 `httpx.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`.
- 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.

## 0.1.2 (2026-08-19)

- 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.
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -238,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
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.1.2"
version = "0.2.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.1.2",
"discolike==0.2.0",
"typer>=0.12",
"rich>=13.0",
]
Expand Down
4 changes: 2 additions & 2 deletions packages/discolike-cli/tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,13 +8,13 @@
from collections.abc import Callable
from typing import Any

import httpx
import httpx2
import pytest

import discolike_cli.main as cli_main
from discolike import Discolike

Handler = Callable[[httpx.Request], httpx.Response]
Handler = Callable[[httpx2.Request], httpx2.Response]


@pytest.fixture
Expand Down
6 changes: 3 additions & 3 deletions packages/discolike-cli/tests/test_account.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
import json
from collections.abc import Callable

import httpx
import httpx2
from typer.testing import CliRunner

from discolike_cli.main import app
Expand All @@ -13,9 +13,9 @@


def test_account_usage_hits_usage_endpoint(install_build_client: Callable[[Handler], None]) -> None:
def handler(request: httpx.Request) -> httpx.Response:
def handler(request: httpx2.Request) -> httpx2.Response:
assert request.url.path == "/v1/usage"
return httpx.Response(200, json={"requests_mtd": 100, "records_mtd": 500, "spend_mtd": 12.5})
return httpx2.Response(200, json={"requests_mtd": 100, "records_mtd": 500, "spend_mtd": 12.5})

install_build_client(handler)
result = runner.invoke(app, ["account", "usage"])
Expand Down
10 changes: 5 additions & 5 deletions packages/discolike-cli/tests/test_auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
from collections.abc import Callable
from typing import Any

import httpx
import httpx2
import pytest
from typer.testing import CliRunner

Expand All @@ -18,12 +18,12 @@
runner = CliRunner()


def _usage_ok(request: httpx.Request) -> httpx.Response:
return httpx.Response(200, json={"requests_mtd": 1, "records_mtd": 2, "spend_mtd": 3.0})
def _usage_ok(request: httpx2.Request) -> httpx2.Response:
return httpx2.Response(200, json={"requests_mtd": 1, "records_mtd": 2, "spend_mtd": 3.0})


def _usage_unauthorized(request: httpx.Request) -> httpx.Response:
return httpx.Response(401, json={"detail": "invalid key"})
def _usage_unauthorized(request: httpx2.Request) -> httpx2.Response:
return httpx2.Response(401, json={"detail": "invalid key"})


def test_login_with_api_key_option_verifies_and_saves(install_build_client: Callable[[Handler], None]) -> None:
Expand Down
40 changes: 20 additions & 20 deletions packages/discolike-cli/tests/test_company.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
import json
from collections.abc import Callable

import httpx
import httpx2
import pytest
from typer.testing import CliRunner

Expand All @@ -24,11 +24,11 @@
def test_company_subcommands_route_to_expected_path(
args: list[str], expected_path: str, install_build_client: Callable[[Handler], None]
) -> None:
captured: dict[str, httpx.Request] = {}
captured: dict[str, httpx2.Request] = {}

def handler(request: httpx.Request) -> httpx.Response:
def handler(request: httpx2.Request) -> httpx2.Response:
captured["request"] = request
return httpx.Response(200, json={"domain": "acme.com", "ok": True})
return httpx2.Response(200, json={"domain": "acme.com", "ok": True})

install_build_client(handler)
result = runner.invoke(app, args)
Expand All @@ -50,11 +50,11 @@ def handler(request: httpx.Request) -> httpx.Response:
def test_company_list_subcommands_route_to_expected_path(
args: list[str], expected_path: str, install_build_client: Callable[[Handler], None]
) -> None:
captured: dict[str, httpx.Request] = {}
captured: dict[str, httpx2.Request] = {}

def handler(request: httpx.Request) -> httpx.Response:
def handler(request: httpx2.Request) -> httpx2.Response:
captured["request"] = request
return httpx.Response(200, json=[{"linked_domain": "acme.io", "ok": True}])
return httpx2.Response(200, json=[{"linked_domain": "acme.io", "ok": True}])

install_build_client(handler)
result = runner.invoke(app, args)
Expand All @@ -68,11 +68,11 @@ def handler(request: httpx.Request) -> httpx.Response:

@pytest.mark.parametrize("subcommand", ["redirects", "vendors", "subsidiaries"])
def test_company_match_option_forwarded(subcommand: str, install_build_client: Callable[[Handler], None]) -> None:
captured: dict[str, httpx.Request] = {}
captured: dict[str, httpx2.Request] = {}

def handler(request: httpx.Request) -> httpx.Response:
def handler(request: httpx2.Request) -> httpx2.Response:
captured["request"] = request
return httpx.Response(200, json=[{"linked_domain": "acme.io"}])
return httpx2.Response(200, json=[{"linked_domain": "acme.io"}])

install_build_client(handler)
result = runner.invoke(app, ["company", subcommand, "acme.com", "--match", "loose"])
Expand All @@ -81,11 +81,11 @@ def handler(request: httpx.Request) -> httpx.Response:


def test_company_public_links_hits_publiclink_endpoint(install_build_client: Callable[[Handler], None]) -> None:
captured: dict[str, httpx.Request] = {}
captured: dict[str, httpx2.Request] = {}

def handler(request: httpx.Request) -> httpx.Response:
def handler(request: httpx2.Request) -> httpx2.Response:
captured["request"] = request
return httpx.Response(200, json=[{"linked_domain": "acme.io"}])
return httpx2.Response(200, json=[{"linked_domain": "acme.io"}])

install_build_client(handler)
result = runner.invoke(app, ["company", "public-links", "acme.com", "--source", "crunchbase"])
Expand All @@ -97,17 +97,17 @@ def handler(request: httpx.Request) -> httpx.Response:


def test_company_public_links_requires_source(install_build_client: Callable[[Handler], None]) -> None:
def handler(request: httpx.Request) -> httpx.Response:
return httpx.Response(200, json={"ok": True})
def handler(request: httpx2.Request) -> httpx2.Response:
return httpx2.Response(200, json={"ok": True})

install_build_client(handler)
result = runner.invoke(app, ["company", "public-links", "acme.com"])
assert result.exit_code == 2


def test_company_data_format_table_falls_back_to_json_for_dict(install_build_client: Callable[[Handler], None]) -> None:
def handler(request: httpx.Request) -> httpx.Response:
return httpx.Response(200, json={"name": "Acme", "domain": "acme.com"})
def handler(request: httpx2.Request) -> httpx2.Response:
return httpx2.Response(200, json={"name": "Acme", "domain": "acme.com"})

install_build_client(handler)
result = runner.invoke(app, ["company", "data", "acme.com", "--format", "table"])
Expand All @@ -119,11 +119,11 @@ def handler(request: httpx.Request) -> httpx.Response:


def test_extract_hits_extract_endpoint_with_url(install_build_client: Callable[[Handler], None]) -> None:
captured: dict[str, httpx.Request] = {}
captured: dict[str, httpx2.Request] = {}

def handler(request: httpx.Request) -> httpx.Response:
def handler(request: httpx2.Request) -> httpx2.Response:
captured["request"] = request
return httpx.Response(200, json={"text": "hello", "language": "en"})
return httpx2.Response(200, json={"text": "hello", "language": "en"})

install_build_client(handler)
result = runner.invoke(app, ["extract", "https://acme.com/about"])
Expand Down
Loading
Loading