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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

## Unreleased

- 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.
- 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.
Expand Down
46 changes: 32 additions & 14 deletions packages/discolike-cli/src/discolike_cli/auth.py
Original file line number Diff line number Diff line change
@@ -1,12 +1,11 @@
from __future__ import annotations

import json
import os
import sys
from typing import Any

import typer

from discolike._config import ENV_API_KEY
from discolike._config import KEYS_URL
from discolike._config import delete_config
from discolike._config import load_config
Expand All @@ -19,39 +18,58 @@

MASKED_VISIBLE_CHARS = 4

SOURCE_OPTION = "option"
SOURCE_ENV = "env"
SOURCE_CONFIG = "config"


def _mask(key: str) -> str:
return "…" + key[-MASKED_VISIBLE_CHARS:]


def _global_key_source(ctx: typer.Context) -> str:
# typer vendors click without re-exporting ParameterSource, so match on the enum member name.
source = ctx.find_root().get_parameter_source("api_key")
return SOURCE_ENV if source is not None and source.name == "ENVIRONMENT" else SOURCE_OPTION


def _verify(ctx: typer.Context, *, api_key: str) -> None:
from discolike_cli.main import build_client

kwargs: dict[str, Any] = {"api_key": api_key}
base_url = ctx.obj.get("base_url")
if base_url is not None:
kwargs["base_url"] = base_url
build_client(**kwargs).account.usage()


@app.command()
@handle_errors
def login(
ctx: typer.Context,
api_key: str | None = typer.Option(None, help=f"API key. Create one at {KEYS_URL}. Prompted for if omitted."),
) -> None:
"""Verify an API key and save it to the local config file."""
from discolike_cli.main import build_client

key = api_key or typer.prompt("API key", hide_input=True)
build_client(api_key=key).account.usage()
# An ambient DISCOLIKE_API_KEY must not silently become the saved key; only an explicit flag may.
passed_globally = ctx.obj.get("api_key") if _global_key_source(ctx) == SOURCE_OPTION else None
key = api_key or passed_globally or typer.prompt("API key", hide_input=True)
_verify(ctx, api_key=key)
save_config({"auth_method": "api_key", "api_key": key})
print(json.dumps({"logged_in": True, "source": "api_key"}), file=sys.stderr)


@app.command()
@handle_errors
def status() -> None:
"""Show which API key is in use (env or config) and verify it against the API."""
from discolike_cli.main import build_client

key = os.environ.get(ENV_API_KEY)
source = "env"
def status(ctx: typer.Context) -> None:
"""Show which API key is in use (option, env, or config) and verify it against the API."""
key = ctx.obj.get("api_key")
source = _global_key_source(ctx)
if not key:
key = load_config().get("api_key")
source = "config"
source = SOURCE_CONFIG
if not key:
resolve_api_key(None)
build_client(api_key=key).account.usage()
_verify(ctx, api_key=str(key))
emit({"source": source, "api_key": _mask(str(key)), "valid": True})


Expand Down
14 changes: 13 additions & 1 deletion packages/discolike-cli/tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
"""

from collections.abc import Callable
from typing import Any

import httpx
import pytest
Expand All @@ -16,14 +17,25 @@
Handler = Callable[[httpx.Request], httpx.Response]


@pytest.fixture
def build_client_calls() -> list[dict[str, Any]]:
"""Records the keyword arguments the CLI passes to its client factory."""
return []


@pytest.fixture
def install_build_client(
monkeypatch: pytest.MonkeyPatch,
make_client: Callable[[Handler], Discolike],
build_client_calls: list[dict[str, Any]],
) -> Callable[[Handler], None]:
"""Point the CLI's client factory at a mock transport driven by ``handler``."""

def _install(handler: Handler) -> None:
monkeypatch.setattr(cli_main, "build_client", lambda **kwargs: make_client(handler))
def _factory(**kwargs: Any) -> Discolike:
build_client_calls.append(kwargs)
return make_client(handler)

monkeypatch.setattr(cli_main, "build_client", _factory)

return _install
90 changes: 90 additions & 0 deletions packages/discolike-cli/tests/test_auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,13 @@
import json
import stat
from collections.abc import Callable
from typing import Any

import httpx
import pytest
from typer.testing import CliRunner

from discolike._config import ENV_API_KEY
from discolike._config import config_path
from discolike._config import save_config
from discolike_cli.main import app
Expand Down Expand Up @@ -118,3 +120,91 @@ def test_cli_version_flag() -> None:
assert result.exit_code == 0
assert f"discolike-cli {version('discolike-cli')}" in result.output
assert f"(discolike {__version__})" in result.output


def test_status_honors_global_base_url(
monkeypatch: pytest.MonkeyPatch,
install_build_client: Callable[[Handler], None],
build_client_calls: list[dict[str, Any]],
) -> None:
install_build_client(_usage_ok)
monkeypatch.setenv("DISCOLIKE_API_KEY", "dk-abcdefgh1234")
result = runner.invoke(app, ["--base-url", "https://other.test/v1", "auth", "status"])
assert result.exit_code == 0, result.output
assert build_client_calls == [{"api_key": "dk-abcdefgh1234", "base_url": "https://other.test/v1"}]


def test_login_honors_global_base_url(
install_build_client: Callable[[Handler], None],
build_client_calls: list[dict[str, Any]],
) -> None:
install_build_client(_usage_ok)
result = runner.invoke(app, ["--base-url", "https://other.test/v1", "auth", "login", "--api-key", "dk-1"])
assert result.exit_code == 0, result.output
assert build_client_calls == [{"api_key": "dk-1", "base_url": "https://other.test/v1"}]


def test_status_verifies_explicitly_passed_api_key(
monkeypatch: pytest.MonkeyPatch,
install_build_client: Callable[[Handler], None],
build_client_calls: list[dict[str, Any]],
) -> None:
install_build_client(_usage_ok)
monkeypatch.setenv("DISCOLIKE_API_KEY", "dk-fromenv0000")
save_config({"auth_method": "api_key", "api_key": "dk-fromconfig11"})
result = runner.invoke(app, ["--api-key", "dk-fromoption22", "auth", "status"])
assert result.exit_code == 0, result.output
assert build_client_calls == [{"api_key": "dk-fromoption22"}]
payload = json.loads(result.stdout)
assert payload["source"] == "option"
assert payload["api_key"] == "…on22"
assert payload["valid"] is True


def test_status_source_is_env_when_key_comes_from_environment(
monkeypatch: pytest.MonkeyPatch, install_build_client: Callable[[Handler], None]
) -> None:
install_build_client(_usage_ok)
monkeypatch.setenv("DISCOLIKE_API_KEY", "dk-fromenv0000")
result = runner.invoke(app, ["auth", "status"])
assert result.exit_code == 0, result.output
assert json.loads(result.stdout)["source"] == "env"


def test_status_source_is_config_when_only_config_has_key(install_build_client: Callable[[Handler], None]) -> None:
install_build_client(_usage_ok)
save_config({"auth_method": "api_key", "api_key": "dk-fromconfig11"})
result = runner.invoke(app, ["auth", "status"])
assert result.exit_code == 0, result.output
assert json.loads(result.stdout)["source"] == "config"


def test_login_api_key_option_beats_global_option(
install_build_client: Callable[[Handler], None],
build_client_calls: list[dict[str, Any]],
) -> None:
install_build_client(_usage_ok)
result = runner.invoke(app, ["--api-key", "dk-global", "auth", "login", "--api-key", "dk-local"])
assert result.exit_code == 0, result.output
assert build_client_calls == [{"api_key": "dk-local"}]
assert json.loads(config_path().read_text())["api_key"] == "dk-local"


def test_login_ignores_ambient_env_key_and_still_prompts(
install_build_client: Callable[[Handler], None],
monkeypatch: pytest.MonkeyPatch,
) -> None:
install_build_client(_usage_ok)
monkeypatch.setenv(ENV_API_KEY, "dk-from-env")
result = runner.invoke(app, ["auth", "login"], input="dk-typed\n")
assert result.exit_code == 0, result.output
assert json.loads(config_path().read_text())["api_key"] == "dk-typed"


def test_login_accepts_explicitly_passed_global_key_without_prompting(
install_build_client: Callable[[Handler], None],
) -> None:
install_build_client(_usage_ok)
result = runner.invoke(app, ["--api-key", "dk-global", "auth", "login"])
assert result.exit_code == 0, result.output
assert json.loads(config_path().read_text())["api_key"] == "dk-global"
Loading