diff --git a/CHANGELOG.md b/CHANGELOG.md index c63d9fa..98473af 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/README.md b/README.md index f179a66..8f60293 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/packages/discolike-cli/pyproject.toml b/packages/discolike-cli/pyproject.toml index 5ec2b3e..c25eae5 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.6.0" +version = "0.6.1" description = "Official CLI for the DiscoLike API" readme = "README.md" license = "MIT" @@ -12,7 +12,7 @@ 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", ] diff --git a/packages/discolike-cli/src/discolike_cli/_output.py b/packages/discolike-cli/src/discolike_cli/_output.py index 0bf6a94..27e1456 100644 --- a/packages/discolike-cli/src/discolike_cli/_output.py +++ b/packages/discolike-cli/src/discolike_cli/_output.py @@ -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 @@ -38,6 +39,7 @@ ValidationError: 2, AuthenticationError: 3, PlanAccessError: 3, + InsufficientBalanceError: 3, RateLimitError: 4, APIConnectionError: 5, NotFoundError: 6, @@ -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", @@ -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) diff --git a/packages/discolike-cli/src/discolike_cli/bulk.py b/packages/discolike-cli/src/discolike_cli/bulk.py index 43b136d..a2a1ec1 100644 --- a/packages/discolike-cli/src/discolike_cli/bulk.py +++ b/packages/discolike-cli/src/discolike_cli/bulk.py @@ -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") @@ -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), @@ -186,6 +187,7 @@ def companies_command( state=state, employee_range=employee_range, revenue_range=revenue_range, + relevance=relevance, variance=variance, ), COMPANIES_MANAGED, diff --git a/packages/discolike-cli/src/discolike_cli/discover.py b/packages/discolike-cli/src/discolike_cli/discover.py index 7d743f8..2f42bfd 100644 --- a/packages/discolike-cli/src/discolike_cli/discover.py +++ b/packages/discolike-cli/src/discolike_cli/discover.py @@ -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]]: @@ -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( @@ -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, @@ -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, diff --git a/packages/discolike-cli/tests/test_bulk_cli.py b/packages/discolike-cli/tests/test_bulk_cli.py index 5863988..8ab497d 100644 --- a/packages/discolike-cli/tests/test_bulk_cli.py +++ b/packages/discolike-cli/tests/test_bulk_cli.py @@ -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( @@ -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", @@ -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" @@ -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 diff --git a/packages/discolike-cli/tests/test_discover.py b/packages/discolike-cli/tests/test_discover.py index 477cebc..d52db5e 100644 --- a/packages/discolike-cli/tests/test_discover.py +++ b/packages/discolike-cli/tests/test_discover.py @@ -146,6 +146,8 @@ def handler(request: httpx2.Request) -> httpx2.Response: "2020-01-01", "--min-similarity", "40", + "--relevance", + "MAX_RELEVANCE", "--variance", "MEDIUM", "--consensus", @@ -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" @@ -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 diff --git a/packages/discolike-cli/tests/test_file_inputs_cli.py b/packages/discolike-cli/tests/test_file_inputs_cli.py index 23d611c..abefb6f 100644 --- a/packages/discolike-cli/tests/test_file_inputs_cli.py +++ b/packages/discolike-cli/tests/test_file_inputs_cli.py @@ -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" @@ -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: diff --git a/packages/discolike-cli/tests/test_output.py b/packages/discolike-cli/tests/test_output.py index 0cbd44a..e4df92f 100644 --- a/packages/discolike-cli/tests/test_output.py +++ b/packages/discolike-cli/tests/test_output.py @@ -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 @@ -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), ], ) @@ -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) diff --git a/packages/discolike/pyproject.toml b/packages/discolike/pyproject.toml index 9ad1bcd..507ad99 100644 --- a/packages/discolike/pyproject.toml +++ b/packages/discolike/pyproject.toml @@ -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" diff --git a/packages/discolike/src/discolike/__init__.py b/packages/discolike/src/discolike/__init__.py index dfe1eaa..c5c1efd 100644 --- a/packages/discolike/src/discolike/__init__.py +++ b/packages/discolike/src/discolike/__init__.py @@ -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 @@ -51,6 +52,7 @@ "EmailJobResult", "EnumerationMatch", "EnumerationOutput", + "InsufficientBalanceError", "Job", "JobFailedError", "JobStatus", diff --git a/packages/discolike/src/discolike/_exceptions.py b/packages/discolike/src/discolike/_exceptions.py index 55f590a..5a71d99 100644 --- a/packages/discolike/src/discolike/_exceptions.py +++ b/packages/discolike/src/discolike/_exceptions.py @@ -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): ... @@ -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, @@ -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', '')}" @@ -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) diff --git a/packages/discolike/src/discolike/_generated/requests.py b/packages/discolike/src/discolike/_generated/requests.py index 11a159d..a519c7d 100644 --- a/packages/discolike/src/discolike/_generated/requests.py +++ b/packages/discolike/src/discolike/_generated/requests.py @@ -1759,7 +1759,7 @@ class DiscoverParams(DiscolikeRequest): min_digital_footprint: Annotated[ int | None, Field( - description="Minimum digital footprint score (0-800). Default 50.", + description="Minimum digital footprint score (0-800). Default 25.", ge=0, le=800, title="Min Digital Footprint", @@ -2071,13 +2071,6 @@ class DiscoverParams(DiscolikeRequest): 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( @@ -2094,15 +2087,6 @@ class DiscoverParams(DiscolikeRequest): 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( @@ -2112,13 +2096,13 @@ class DiscoverParams(DiscolikeRequest): title="Min Similarity", ), ] = 0 - variance: Annotated[ - Literal["LOW", "MID_LOW", "MEDIUM", "MID_HIGH", "HIGH", "UNRESTRICTED"] | None, + relevance: Annotated[ + Literal["MAX_RELEVANCE", "BALANCED", "MAX_COVERAGE"] | None, Field( - description="Result diversity control: LOW, MID_LOW, MEDIUM, MID_HIGH, HIGH, UNRESTRICTED.", - title="Variance", + description="How far down the ranked list results go before the estimated ICP fit drops too low. Applies to lookalike searches (icp_text or domain). MAX_RELEVANCE stops earliest with the tightest fit, BALANCED goes further, MAX_COVERAGE serves the longest list. Omit for no automatic stop.", + title="Relevance", ), - ] = "UNRESTRICTED" + ] = None offset: Annotated[ int | None, Field( @@ -2401,7 +2385,7 @@ class CountParams(DiscolikeRequest): min_digital_footprint: Annotated[ int | None, Field( - description="Minimum digital footprint score (0-800). Default 50.", + description="Minimum digital footprint score (0-800). Default 25.", ge=0, le=800, title="Min Digital Footprint", diff --git a/packages/discolike/src/discolike/_models.py b/packages/discolike/src/discolike/_models.py index a806b7f..d3c25bc 100644 --- a/packages/discolike/src/discolike/_models.py +++ b/packages/discolike/src/discolike/_models.py @@ -30,6 +30,14 @@ "contact search no longer filters on email verification; verify emails instead." ), } +DEPRECATED_MODEL_FIELDS: dict[str, dict[str, str]] = { + "DiscoverParams": { + "consensus": "consensus is deprecated and ignored: discover applies consensus itself to single-domain lookalike searches.", + "enhanced": "enhanced is deprecated and ignored: discover no longer adds AI-suggested domains.", + "variance": "variance is deprecated: LOW, MID_LOW and MEDIUM map to relevance MAX_RELEVANCE, MID_HIGH and HIGH to BALANCED, " + "UNRESTRICTED to no stop; relevance wins when both are sent. Use relevance.", + }, +} class DiscolikeModel(pydantic.BaseModel): @@ -78,8 +86,9 @@ def _validate_geo_shapes(self) -> DiscolikeRequest: @classmethod def _warn_deprecated_fields(cls, data: object) -> object: if isinstance(data, dict): - for name in sorted(DEPRECATED_REQUEST_FIELDS.keys() & data.keys()): - warnings.warn(DEPRECATED_REQUEST_FIELDS[name], DeprecationWarning, stacklevel=2) + deprecated = DEPRECATED_REQUEST_FIELDS | DEPRECATED_MODEL_FIELDS.get(cls.__name__, {}) + for name in sorted(deprecated.keys() & data.keys()): + warnings.warn(deprecated[name], DeprecationWarning, stacklevel=2) return data # Mirrors the platform's text_or_intake rule on ProspectingMessageRequest, which the spec cannot express. diff --git a/packages/discolike/src/discolike/_version.py b/packages/discolike/src/discolike/_version.py index 906d362..43c4ab0 100644 --- a/packages/discolike/src/discolike/_version.py +++ b/packages/discolike/src/discolike/_version.py @@ -1 +1 @@ -__version__ = "0.6.0" +__version__ = "0.6.1" diff --git a/packages/discolike/src/discolike/resources/prospecting.py b/packages/discolike/src/discolike/resources/prospecting.py index 5c34df8..7740d40 100644 --- a/packages/discolike/src/discolike/resources/prospecting.py +++ b/packages/discolike/src/discolike/resources/prospecting.py @@ -103,6 +103,14 @@ class ProspectingInFlight(DiscolikeModel): progress: int | None = None +class ProspectingOpenQuestion(DiscolikeModel): + question_seq: int | None + reason: str + content: str + replies: list[str] = Field(default_factory=list) + estimate: dict[str, Any] | None = None + + class ProspectingRunSummary(DiscolikeModel): run_id: UUID status: ProspectingStatus @@ -145,6 +153,9 @@ class ProspectingRun(DiscolikeModel): actions_used: int = 0 max_actions: int error: str | None = None + question: ProspectingOpenQuestion | None = Field( + default=None, description="What a needs_input run waits on; replies are exact labels, empty for free text." + ) title: str | None = None plan_version: int = 1 approved_plan_version: int | None = None diff --git a/packages/discolike/tests/test_exceptions.py b/packages/discolike/tests/test_exceptions.py index 4be2323..f0f46cb 100644 --- a/packages/discolike/tests/test_exceptions.py +++ b/packages/discolike/tests/test_exceptions.py @@ -5,6 +5,7 @@ from discolike import AuthenticationError from discolike import DiscolikeError +from discolike import InsufficientBalanceError from discolike import NotFoundError from discolike import PlanAccessError from discolike import RateLimitError @@ -69,3 +70,50 @@ def test_detail_list_with_non_dict_items() -> None: def test_all_are_discolike_errors() -> None: assert issubclass(RateLimitError, DiscolikeError) + + +INSUFFICIENT_BALANCE_DETAIL = { + "code": "INSUFFICIENT_BALANCE", + "message": ( + "This request could cost up to $140.70 but the account has $43.75 left this cycle. " + "Lower it to at most 3075 records, or upgrade or top up to continue." + ), + "remaining_usd": 43.75, + "max_affordable_records": 3075, + "requested_records": 10000, + "upgrade_url": "https://app.discolike.com/account/management/plans", +} + + +def test_insufficient_balance_403_raises_typed_error_with_fields() -> None: + with pytest.raises(InsufficientBalanceError) as exc_info: + raise_for_status(_response(403, json_body={"detail": INSUFFICIENT_BALANCE_DETAIL})) + err = exc_info.value + assert isinstance(err, PlanAccessError) + assert str(err) == INSUFFICIENT_BALANCE_DETAIL["message"] + assert err.status_code == 403 + assert err.max_affordable_records == 3075 + assert err.remaining_usd == 43.75 + assert err.requested_records == 10000 + assert err.upgrade_url == "https://app.discolike.com/account/management/plans" + assert err.payload == {"detail": INSUFFICIENT_BALANCE_DETAIL} + + +def test_insufficient_balance_code_on_another_status_is_not_a_balance_error() -> None: + with pytest.raises(ServerError) as exc_info: + raise_for_status(_response(500, json_body={"detail": INSUFFICIENT_BALANCE_DETAIL})) + assert not isinstance(exc_info.value, InsufficientBalanceError) + + +def test_other_dict_detail_uses_its_message() -> None: + body = {"detail": {"code": "SOMETHING_ELSE", "message": "not allowed"}} + with pytest.raises(PlanAccessError) as exc_info: + raise_for_status(_response(403, json_body=body)) + assert type(exc_info.value) is PlanAccessError + assert str(exc_info.value) == "not allowed" + + +def test_plain_403_stays_plan_access_error() -> None: + with pytest.raises(PlanAccessError) as exc_info: + raise_for_status(_response(403)) + assert type(exc_info.value) is PlanAccessError diff --git a/packages/discolike/tests/test_package.py b/packages/discolike/tests/test_package.py index a6bf703..afc8344 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.6.0" + assert discolike.__version__ == "0.6.1" diff --git a/packages/discolike/tests/test_prospecting.py b/packages/discolike/tests/test_prospecting.py index 95a9957..0c48d9a 100644 --- a/packages/discolike/tests/test_prospecting.py +++ b/packages/discolike/tests/test_prospecting.py @@ -735,3 +735,12 @@ def handler(request: httpx2.Request) -> httpx2.Response: with make_client(handler) as client, pytest.raises(ValidationError): client.prospecting.answer_intake(RUN_ID, {}, idempotency_key="k") assert seen == [] + + +def test_run_parses_the_open_question_of_a_needs_input_run() -> None: + question = {"question_seq": 4, "reason": "plan_review", "content": "Approve the plan?", "replies": ["Approve"]} + run = module.ProspectingRun.model_validate(run_payload() | {"question": question}) + assert run.question == module.ProspectingOpenQuestion( + question_seq=4, reason="plan_review", content="Approve the plan?", replies=["Approve"] + ) + assert module.ProspectingRun.model_validate(run_payload()).question is None diff --git a/packages/discolike/tests/test_requests_module.py b/packages/discolike/tests/test_requests_module.py index 21d52f4..8a9daf8 100644 --- a/packages/discolike/tests/test_requests_module.py +++ b/packages/discolike/tests/test_requests_module.py @@ -53,6 +53,29 @@ def test_email_validated_is_deprecated_but_still_accepted(model: type[DiscolikeR assert request.to_wire() == {"email_validated": True} +@pytest.mark.parametrize(("name", "value"), [("consensus", 2), ("enhanced", True), ("variance", "LOW")]) +def test_discover_deprecated_fields_warn_but_are_still_sent(name: str, value: object) -> None: + assert name not in DiscoverParams.model_fields + with pytest.warns(DeprecationWarning, match=f"{name} is deprecated"): + request = DiscoverParams.model_validate({name: value}) + assert request.to_wire() == {name: value} + + +def test_discover_relevance_is_omitted_unless_set() -> None: + with warnings.catch_warnings(): + warnings.simplefilter("error") + assert DiscoverParams().to_wire() == {} + assert DiscoverParams(relevance="MAX_COVERAGE").to_wire() == {"relevance": "MAX_COVERAGE"} + with pytest.raises(pydantic.ValidationError, match="relevance"): + DiscoverParams.model_validate({"relevance": "BOGUS"}) + + +def test_contacts_consensus_is_not_deprecated() -> None: + with warnings.catch_warnings(): + warnings.simplefilter("error") + assert ContactsSearchParams(consensus=2).to_wire() == {"consensus": 2} + + def test_unset_email_validated_does_not_warn() -> None: with warnings.catch_warnings(): warnings.simplefilter("error") diff --git a/scripts/gen_requests.py b/scripts/gen_requests.py index 162c341..ae1d189 100644 --- a/scripts/gen_requests.py +++ b/scripts/gen_requests.py @@ -100,19 +100,7 @@ # Properties the SDK ships before the deployed spec has them. Merged in only while the spec # lacks them, so each entry clears itself once the platform release lands -- generation prints # the ones that have, to be deleted here. -PENDING_PROPERTIES: dict[str, dict[str, dict[str, Any]]] = { - "ProspectingMessageRequest": { - "question_seq": { - "anyOf": [{"minimum": 1, "type": "integer"}, {"type": "null"}], - "default": None, - "description": ( - "The seq of the question message whose suggested reply this is; the reply answers that question " - "only, never a newer one." - ), - "title": "Question Seq", - } - } -} +PENDING_PROPERTIES: dict[str, dict[str, dict[str, Any]]] = {} # Properties generated from this schema rather than the spec's, whatever the spec says. The # platform's sub-industry enum lists parent-qualified keys only; a bare label reaches it through diff --git a/uv.lock b/uv.lock index 429b470..38b08de 100644 --- a/uv.lock +++ b/uv.lock @@ -343,7 +343,7 @@ dev = [ [[package]] name = "discolike-cli" -version = "0.6.0" +version = "0.6.1" source = { editable = "packages/discolike-cli" } dependencies = [ { name = "discolike" },