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: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,13 @@
# Changelog

## 0.6.1 (2026-10-19)

- SDK/CLI: discover's server-side default for `min_digital_footprint` drops from 50 to 25 when the field is omitted, so lists include more real companies with a thin web presence. Pass `min_digital_footprint=50` (CLI `--min-digital-footprint 50`) to keep the old floor. No SDK change; older versions get the new default too.
- SDK/CLI: the API now prices every billed request before it runs and refuses one the remaining balance cannot cover with a 403 whose `detail` is an object (`code: "INSUFFICIENT_BALANCE"`, `message`, `remaining_usd`, `max_affordable_records`, `requested_records`, `upgrade_url`). The SDK raises it as the new `InsufficientBalanceError`, a `PlanAccessError` subclass, so existing `except PlanAccessError` handlers still catch it; its message is the server's `message`, and `max_affordable_records`, `remaining_usd`, `requested_records` and `upgrade_url` are attributes, counted in the request's own size unit (records, or contacts, companies, domains, rows, queries or personas for requests sized by those; the message names it), so a caller can retry at `max_affordable_records`. Only a 403 carrying that code raises it. A request the API could not price answers 503 (`ServerError`), which the SDK retries automatically on GET calls. Any other object `detail` with a `message` now uses that message instead of the raw JSON. The CLI exits 3 with `"code": "insufficient_balance"` and adds `max_affordable_records` and `remaining_usd` to the error JSON. Older SDK versions raise a plain `PlanAccessError` whose message is the JSON body.
- SDK/CLI: `DiscoverParams.relevance` (`MAX_RELEVANCE`, `BALANCED` or `MAX_COVERAGE`) picks where a discover list stops by estimated ICP fit: `MAX_RELEVANCE` returns the tightest list, `MAX_COVERAGE` the longest, stopping where fit drops too low. Left unset it is not sent and the list has no automatic stop. The CLI's `discover` and `bulk companies` gain `--relevance`. It needs an API release that knows the field; an older API ignores it.
- SDK/CLI: `variance` on discover is deprecated; the API maps its old values onto `relevance`, which wins when both are sent. Use `relevance`. It is dropped from `DiscoverParams`, but passing it still sends it and raises a `DeprecationWarning`, and the SDK no longer checks its value. The CLI keeps `--variance` so existing scripts run, with help marking it deprecated.
- SDK/CLI: `consensus` and `enhanced` on discover are deprecated: the API now picks consensus itself and no longer runs AI result enhancement, so both are ignored. They are dropped from `DiscoverParams`; passing either still sends it and raises a `DeprecationWarning`. Contacts `consensus` is unchanged. The CLI keeps `--consensus` and `--enhanced`, with help saying what the server does now.

## 0.6.0 (2026-10-12)

- SDK/CLI: the API now limits how much work one account can have queued or running: email lookups (2,000 times the plan multiplier) and three DiscoGen, bulk match, contact bulk match and segment tasks. A submit over a limit is answered 429 with `Retry-After: 60`, which the SDK already waits out and retries, so with the default `max_retries=3` the call blocks for up to three minutes and then raises `RateLimitError` carrying the count and the limit in its message. In the SDK, `Discolike(max_retries=0)` gets the error at once; the CLI has no retry option, so its commands wait and then exit 4. No SDK change; older versions behave the same.
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -370,7 +370,7 @@ except ValidationError as err:
...
```

`AuthenticationError`, `PlanAccessError`, `NotFoundError`, `ServerError`, and `APIConnectionError` cover the rest. Transient failures are retried automatically (3 attempts by default).
`AuthenticationError`, `PlanAccessError`, `NotFoundError`, `ServerError`, and `APIConnectionError` cover the rest. A request the account's remaining balance cannot cover raises `InsufficientBalanceError` (a `PlanAccessError`), whose `max_affordable_records` is the largest size that would fit, counted in the request's own unit (the message names it: records, contacts, domains, rows, queries or personas). Transient failures are retried automatically (3 attempts by default).

```python
import pydantic
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.6.0"
version = "0.6.1"
description = "Official CLI for the DiscoLike API"
readme = "README.md"
license = "MIT"
license-files = ["LICENSE"]
requires-python = ">=3.11"
authors = [{ name = "DiscoLike", email = "support@discolike.com" }]
dependencies = [
"discolike==0.6.0",
"discolike==0.6.1",
"typer>=0.26.1,<0.28",
"rich>=13.0",
]
Expand Down
6 changes: 6 additions & 0 deletions packages/discolike-cli/src/discolike_cli/_output.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
from discolike._exceptions import APIConnectionError
from discolike._exceptions import AuthenticationError
from discolike._exceptions import DiscolikeError
from discolike._exceptions import InsufficientBalanceError
from discolike._exceptions import JobFailedError
from discolike._exceptions import JobTimeoutError
from discolike._exceptions import NotFoundError
Expand All @@ -38,6 +39,7 @@
ValidationError: 2,
AuthenticationError: 3,
PlanAccessError: 3,
InsufficientBalanceError: 3,
RateLimitError: 4,
APIConnectionError: 5,
NotFoundError: 6,
Expand All @@ -51,6 +53,7 @@
ValidationError: "validation_error",
AuthenticationError: "auth_invalid",
PlanAccessError: "plan_access",
InsufficientBalanceError: "insufficient_balance",
RateLimitError: "rate_limited",
APIConnectionError: "network_error",
NotFoundError: "not_found",
Expand Down Expand Up @@ -150,6 +153,9 @@ def fail(exc: DiscolikeError) -> typer.Exit:
}
if isinstance(exc, RateLimitError) and exc.retry_after is not None:
payload["retry_after"] = exc.retry_after
if isinstance(exc, InsufficientBalanceError):
payload["max_affordable_records"] = exc.max_affordable_records
payload["remaining_usd"] = exc.remaining_usd
print(json.dumps(payload), file=sys.stderr)
return typer.Exit(code=exit_code)

Expand Down
8 changes: 5 additions & 3 deletions packages/discolike-cli/src/discolike_cli/bulk.py
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,8 @@
from discolike_cli.discover import DOMAINS_FILE_HELP
from discolike_cli.discover import PARAM_HELP
from discolike_cli.discover import PARAMS_FILE_HELP
from discolike_cli.discover import RELEVANCE_HELP
from discolike_cli.discover import VARIANCE_HELP
from discolike_cli.discover import _merge_params

T = TypeVar("T")
Expand Down Expand Up @@ -157,9 +159,8 @@ def companies_command(
state: list[str] | None = typer.Option(None, help="State or region filter (repeatable)."),
employee_range: str | None = typer.Option(None, help="Employee count range filter."),
revenue_range: str | None = typer.Option(None, help="Revenue range filter."),
variance: str | None = typer.Option(
None, help="Result diversity: LOW, MID_LOW, MEDIUM, MID_HIGH, HIGH, UNRESTRICTED."
),
relevance: str | None = typer.Option(None, help=RELEVANCE_HELP),
variance: str | None = typer.Option(None, help=VARIANCE_HELP),
exclusion_query_id: list[str] | None = typer.Option(None, help=EXCLUSION_QUERY_ID_HELP),
param: list[str] | None = typer.Option(None, "--param", help=PARAM_HELP),
params_file: pathlib.Path | None = typer.Option(None, help=PARAMS_FILE_HELP),
Expand All @@ -186,6 +187,7 @@ def companies_command(
state=state,
employee_range=employee_range,
revenue_range=revenue_range,
relevance=relevance,
variance=variance,
),
COMPANIES_MANAGED,
Expand Down
15 changes: 10 additions & 5 deletions packages/discolike-cli/src/discolike_cli/discover.py
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,11 @@
NEGATE_LANGUAGE_HELP = "Exclude this site language (repeatable); same values as --language."
EXCLUDE_LEADGEN_HELP = "Exclude suspected lead generation sites (on by default)."
COUNT_EXCLUDE_LEADGEN_HELP = "Exclude suspected lead generation sites (off by default for count)."
RELEVANCE_HELP = (
"Where the list stops, by estimated ICP fit: MAX_RELEVANCE (tightest), BALANCED, MAX_COVERAGE (longest). "
"Omit for no automatic stop."
)
VARIANCE_HELP = "Deprecated, mapped onto --relevance by the API; use --relevance."


def _parse_param(raw: str) -> tuple[str, str | list[str]]:
Expand Down Expand Up @@ -104,11 +109,10 @@ def discover_command(
min_digital_footprint: int | None = typer.Option(None, help="Minimum digital footprint score."),
max_digital_footprint: int | None = typer.Option(None, help="Maximum digital footprint score."),
min_similarity: int | None = typer.Option(None, help="Minimum similarity score to include (0-99)."),
variance: str | None = typer.Option(
None, help="Result diversity control: LOW, MID_LOW, MEDIUM, MID_HIGH, HIGH, UNRESTRICTED."
),
relevance: str | None = typer.Option(None, help=RELEVANCE_HELP),
variance: str | None = typer.Option(None, help=VARIANCE_HELP),
consensus: int | None = typer.Option(
None, help="Number of top results for the consensus search vector (1-20); higher reduces specificity."
None, help="Deprecated, no-op. Discover applies consensus itself to single-domain lookalike searches."
),
redirect: bool | None = typer.Option(None, "--redirect/--no-redirect", help=REDIRECT_HELP),
exclude_leadgen: bool | None = typer.Option(
Expand All @@ -118,7 +122,7 @@ def discover_command(
None, "--retrieval/--no-retrieval", help="Enable page data retrieval using the Extract API."
),
enhanced: bool | None = typer.Option(
None, "--enhanced/--no-enhanced", help="Enable AI-powered result enhancement for improved relevance."
None, "--enhanced/--no-enhanced", help="Deprecated, no-op. Discover no longer adds AI-suggested domains."
),
include_search_domains: bool | None = typer.Option(
None,
Expand Down Expand Up @@ -186,6 +190,7 @@ def discover_command(
min_digital_footprint=min_digital_footprint,
max_digital_footprint=max_digital_footprint,
min_similarity=min_similarity,
relevance=relevance,
variance=variance,
consensus=consensus,
redirect=redirect,
Expand Down
7 changes: 5 additions & 2 deletions packages/discolike-cli/tests/test_bulk_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -280,7 +280,7 @@ def test_bulk_companies_takes_params_file_and_drops_managed_keys(
install_build_client: Callable[[Handler], None], tmp_path: Path
) -> None:
form = tmp_path / "form.json"
form.write_text(json.dumps({"country": ["US"], "variance": "MID_HIGH", "max_records": 5, "offset": 99}))
form.write_text(json.dumps({"country": ["US"], "relevance": "MAX_COVERAGE", "max_records": 5, "offset": 99}))
recorder = Recorder(discover=[[]], queries_exclusion_list=[{"query_id": "q"}])
install_build_client(recorder)
result = runner.invoke(
Expand All @@ -292,6 +292,8 @@ def test_bulk_companies_takes_params_file_and_drops_managed_keys(
str(form),
"--param",
"consensus=3",
"--relevance",
"BALANCED",
"--variance",
"LOW",
"--out",
Expand All @@ -302,6 +304,7 @@ def test_bulk_companies_takes_params_file_and_drops_managed_keys(
assert result.exit_code == 0, result.output
params = recorder.params("/v1/discover")[0]
assert params.get_list("country") == ["US"]
assert params.get("relevance") == "BALANCED"
assert params.get("variance") == "LOW"
assert params.get("consensus") == "3"
assert params.get("max_records") == "10000"
Expand All @@ -316,7 +319,7 @@ def test_bulk_companies_validates_before_first_call(
recorder = Recorder(discover=[[]])
install_build_client(recorder)
result = runner.invoke(
app, ["bulk", "companies", "--variance", "BOGUS", "--out", str(tmp_path / "c.csv"), *NO_LIMIT]
app, ["bulk", "companies", "--relevance", "BOGUS", "--out", str(tmp_path / "c.csv"), *NO_LIMIT]
)
assert result.exit_code == 2
assert "ValidationError" in result.output
Expand Down
7 changes: 5 additions & 2 deletions packages/discolike-cli/tests/test_discover.py
Original file line number Diff line number Diff line change
Expand Up @@ -146,6 +146,8 @@ def handler(request: httpx2.Request) -> httpx2.Response:
"2020-01-01",
"--min-similarity",
"40",
"--relevance",
"MAX_RELEVANCE",
"--variance",
"MEDIUM",
"--consensus",
Expand All @@ -171,6 +173,7 @@ def handler(request: httpx2.Request) -> httpx2.Response:
assert params.get_list("negate_social") == ["tiktok"]
assert params.get("start_date") == "2020-01-01"
assert params.get("min_similarity") == "40"
assert params.get("relevance") == "MAX_RELEVANCE"
assert params.get("variance") == "MEDIUM"
assert params.get("consensus") == "3"
assert params.get("redirect") == "true"
Expand All @@ -183,9 +186,9 @@ def handler(request: httpx2.Request) -> httpx2.Response:
assert params.get_list("inclusion_query_id") == ["q1"]


def test_discover_rejects_invalid_variance(install_build_client: Callable[[Handler], None]) -> None:
def test_discover_rejects_invalid_relevance(install_build_client: Callable[[Handler], None]) -> None:
install_build_client(_discover_ok)
result = runner.invoke(app, ["discover", "--variance", "BOGUS"])
result = runner.invoke(app, ["discover", "--relevance", "BOGUS"])
assert result.exit_code == 2


Expand Down
8 changes: 4 additions & 4 deletions packages/discolike-cli/tests/test_file_inputs_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -120,17 +120,17 @@ def test_discover_params_file_under_param_under_flags(
install_build_client: Callable[[Handler], None], tmp_path: Path
) -> None:
form = tmp_path / "form.json"
form.write_text(json.dumps({"country": ["US"], "variance": "LOW", "min_similarity": 10, "consensus": 3}))
form.write_text(json.dumps({"country": ["US"], "relevance": "MAX_COVERAGE", "min_similarity": 10, "consensus": 3}))
captured: dict[str, Any] = {}
install_build_client(_capture_json(captured, []))
result = runner.invoke(
app,
["discover", "--params-file", str(form), "--param", "min_similarity=50", "--variance", "HIGH"],
["discover", "--params-file", str(form), "--param", "min_similarity=50", "--relevance", "BALANCED"],
)
assert result.exit_code == 0, result.output
params = captured["params"]
assert params.get_list("country") == ["US"]
assert params.get("variance") == "HIGH"
assert params.get("relevance") == "BALANCED"
assert params.get("min_similarity") == "50"
assert params.get("consensus") == "3"

Expand All @@ -139,7 +139,7 @@ def test_discover_params_file_is_validated_locally(
install_build_client: Callable[[Handler], None], tmp_path: Path
) -> None:
form = tmp_path / "form.json"
form.write_text(json.dumps({"variance": "BOGUS"}))
form.write_text(json.dumps({"relevance": "BOGUS"}))
calls: list[str] = []

def handler(request: httpx2.Request) -> httpx2.Response:
Expand Down
14 changes: 14 additions & 0 deletions packages/discolike-cli/tests/test_output.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
from discolike import APIConnectionError
from discolike import AuthenticationError
from discolike import DiscolikeError
from discolike import InsufficientBalanceError
from discolike import JobFailedError
from discolike import JobTimeoutError
from discolike import NotFoundError
Expand Down Expand Up @@ -154,6 +155,7 @@ def test_emit_table_flag_falls_back_to_json_when_no_scalar_columns(capsys: pytes
(ValidationError("bad input"), 2),
(AuthenticationError("no key"), 3),
(RateLimitError("slow down", retry_after=3), 4),
(InsufficientBalanceError("too costly", max_affordable_records=10), 3),
(ServerError("boom"), 1),
],
)
Expand Down Expand Up @@ -186,6 +188,18 @@ def test_fail_rate_limit_includes_retry_after(capsys: pytest.CaptureFixture[str]
assert payload["retry_after"] == 3.5


def test_fail_insufficient_balance_includes_max_affordable_records(capsys: pytest.CaptureFixture[str]) -> None:
exc = InsufficientBalanceError(
"too costly", status_code=403, max_affordable_records=60, remaining_usd=3.0, requested_records=250
)
result = fail(exc)
assert result.exit_code == 3
payload = json.loads(capsys.readouterr().err)
assert payload["code"] == "insufficient_balance"
assert payload["max_affordable_records"] == 60
assert payload["remaining_usd"] == 3.0


def test_fail_rate_limit_without_retry_after_omits_key(capsys: pytest.CaptureFixture[str]) -> None:
exc = RateLimitError("slow down", status_code=429, retry_after=None)
fail(exc)
Expand Down
2 changes: 1 addition & 1 deletion packages/discolike/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ classifiers = [
]

[project.optional-dependencies]
cli = ["discolike-cli==0.6.0"]
cli = ["discolike-cli==0.6.1"]

[project.urls]
Homepage = "https://www.discolike.com"
Expand Down
2 changes: 2 additions & 0 deletions packages/discolike/src/discolike/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
from discolike._exceptions import APIConnectionError
from discolike._exceptions import AuthenticationError
from discolike._exceptions import DiscolikeError
from discolike._exceptions import InsufficientBalanceError
from discolike._exceptions import JobFailedError
from discolike._exceptions import JobTimeoutError
from discolike._exceptions import NotFoundError
Expand Down Expand Up @@ -51,6 +52,7 @@
"EmailJobResult",
"EnumerationMatch",
"EnumerationOutput",
"InsufficientBalanceError",
"Job",
"JobFailedError",
"JobStatus",
Expand Down
43 changes: 43 additions & 0 deletions packages/discolike/src/discolike/_exceptions.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,34 @@ class AuthenticationError(DiscolikeError): ...
class PlanAccessError(DiscolikeError): ...


class InsufficientBalanceError(PlanAccessError):
"""A billed request priced above the account's remaining balance (403 ``INSUFFICIENT_BALANCE``).

``requested_records`` and ``max_affordable_records`` count in the request's own size unit
(records, contacts, companies, domains, rows, queries or personas; the message names it), so a
retry at ``max_affordable_records`` fits; it is 0 when the request fee does not fit or no record
fits after the fee. A request with no size to lower (a single-company lookup or a single email find) cannot be retried smaller,
and its message only asks to top up at ``upgrade_url``.
"""

def __init__(
self,
message: str,
*,
status_code: int | None = None,
payload: Any = None, # noqa: ANN401 -- decoded JSON body, shape is server-defined
max_affordable_records: int | None = None,
remaining_usd: float | None = None,
requested_records: int | None = None,
upgrade_url: str | None = None,
) -> None:
super().__init__(message, status_code=status_code, payload=payload)
self.max_affordable_records = max_affordable_records
self.remaining_usd = remaining_usd
self.requested_records = requested_records
self.upgrade_url = upgrade_url


class ValidationError(DiscolikeError): ...


Expand Down Expand Up @@ -56,6 +84,8 @@ def __init__(
self.retry_after = retry_after


INSUFFICIENT_BALANCE_CODE = "INSUFFICIENT_BALANCE"

_STATUS_MAP: dict[int, type[DiscolikeError]] = {
400: ValidationError,
401: AuthenticationError,
Expand All @@ -75,6 +105,8 @@ def _extract_message(response: httpx2.Response) -> tuple[str, Any]:
detail = payload.get("detail") if isinstance(payload, dict) else None
if isinstance(detail, str):
return detail, payload
if isinstance(detail, dict) and isinstance(detail.get("message"), str):
return detail["message"], payload
if isinstance(detail, list):
parts = [
f"{'.'.join(str(x) for x in item.get('loc', []))}: {item.get('msg', '')}"
Expand All @@ -94,5 +126,16 @@ def raise_for_status(response: httpx2.Response) -> None:
header = response.headers.get("Retry-After")
retry_after = float(header) if header and header.replace(".", "", 1).isdigit() else None
raise RateLimitError(message, status_code=429, payload=payload, retry_after=retry_after)
detail = payload.get("detail") if isinstance(payload, dict) else None
if response.status_code == 403 and isinstance(detail, dict) and detail.get("code") == INSUFFICIENT_BALANCE_CODE:
raise InsufficientBalanceError(
message,
status_code=response.status_code,
payload=payload,
max_affordable_records=detail.get("max_affordable_records"),
remaining_usd=detail.get("remaining_usd"),
requested_records=detail.get("requested_records"),
upgrade_url=detail.get("upgrade_url"),
)
exc_type = _STATUS_MAP.get(response.status_code, ServerError if response.status_code >= 500 else DiscolikeError)
raise exc_type(message, status_code=response.status_code, payload=payload)
Loading
Loading