From ad905bb443ee1085eaba124ee7e55877a46ef7bf Mon Sep 17 00:00:00 2001 From: Yegor Dolgopolov Date: Wed, 19 Aug 2026 13:13:23 -0700 Subject: [PATCH 1/4] feat(cli): add the discolike email command group find (with --known-pattern), find-batch from CSV and/or repeatable --contact, results (find or verify batches), and job - each with --wait/--no-wait polling. 14 tests. --- .../discolike-cli/src/discolike_cli/email.py | 166 ++++++++++ .../discolike-cli/src/discolike_cli/main.py | 2 + .../discolike-cli/tests/test_email_cli.py | 283 ++++++++++++++++++ 3 files changed, 451 insertions(+) create mode 100644 packages/discolike-cli/src/discolike_cli/email.py create mode 100644 packages/discolike-cli/tests/test_email_cli.py diff --git a/packages/discolike-cli/src/discolike_cli/email.py b/packages/discolike-cli/src/discolike_cli/email.py new file mode 100644 index 0000000..321ef09 --- /dev/null +++ b/packages/discolike-cli/src/discolike_cli/email.py @@ -0,0 +1,166 @@ +from __future__ import annotations + +import csv +import pathlib +import sys + +import typer + +from discolike._email import EmailBatch +from discolike._email import EmailBatchResults +from discolike._email import EmailJobResult +from discolike._exceptions import JobTimeoutError +from discolike_cli._output import emit +from discolike_cli._output import handle_errors + +DEFAULT_WAIT_TIMEOUT_SECONDS = 900.0 +MAX_BATCH_CONTACTS = 500 +EMAIL_KINDS = ("find", "verify") +CSV_COLUMNS = ("first_name", "last_name", "domain") + +FORMAT_HELP = "Output format: json or table (table auto-selected on a TTY; falls back to JSON for non-tabular data)." +WAIT_HELP = "Block until the job finishes, streaming progress to stderr." +TIMEOUT_HELP = "Max seconds to wait with --wait." +KIND_HELP = "Batch kind: find or verify (verify batches are created by the DiscoLike app)." + +app = typer.Typer( + help=( + "Find work email addresses: submit single or batch find jobs, poll them, and fetch results. " + "Only proven addresses bill; catch-all and pattern guesses are free." + ) +) + + +def _job_status_to_stderr(status: EmailJobResult) -> None: + sys.stderr.write(f"status: {status.status}\n") + + +def _batch_progress_to_stderr(results: EmailBatchResults) -> None: + sys.stderr.write(f"progress: {results.completed}/{results.total} completed, {results.failed} failed\n") + + +def _parse_contact(value: str) -> dict[str, str]: + parts = [part.strip() for part in value.split(",")] + if len(parts) != len(CSV_COLUMNS) or not all(parts): + raise typer.BadParameter(f'--contact must be "first_name,last_name,domain", got {value!r}') + return dict(zip(CSV_COLUMNS, parts, strict=True)) + + +def _read_contacts_file(contacts_file: pathlib.Path) -> list[dict[str, str]]: + with contacts_file.open(newline="") as handle: + reader = csv.DictReader(handle) + missing = set(CSV_COLUMNS) - set(reader.fieldnames or []) + if missing: + raise typer.BadParameter(f"--contacts-file is missing required CSV columns: {', '.join(sorted(missing))}") + return [{column: (row.get(column) or "").strip() for column in CSV_COLUMNS} for row in reader] + + +def _fetch_batch_snapshot(batch: EmailBatch) -> EmailBatchResults: + """Fetch the batch results once, whether or not the batch has finished.""" + snapshot: dict[str, EmailBatchResults] = {} + try: + return batch.results(timeout=0, on_poll=lambda results: snapshot.update(latest=results)) + except JobTimeoutError: + return snapshot["latest"] + + +@app.command("find") +@handle_errors +def find_command( + ctx: typer.Context, + first_name: str = typer.Argument(..., help="First name of the person."), + last_name: str = typer.Argument(..., help="Last name of the person."), + domain: str = typer.Argument(..., help="Company domain to search, e.g. acme.com."), + known_pattern: str | None = typer.Option( + None, "--known-pattern", help="Known email local-part pattern for this domain, e.g. first.last." + ), + wait: bool = typer.Option(False, "--wait/--no-wait", help=WAIT_HELP), + timeout: float = typer.Option(DEFAULT_WAIT_TIMEOUT_SECONDS, "--timeout", help=TIMEOUT_HELP), + fmt: str | None = typer.Option(None, "--format", help=FORMAT_HELP), +) -> None: + """Submit a single email find job (async); only a proven address bills.""" + from discolike_cli.main import get_client + + job = get_client(ctx).email.find( + first_name=first_name, last_name=last_name, domain=domain, known_pattern=known_pattern + ) + if not wait: + emit({"job_id": job.job_id, "hint": f"poll with: discolike email job {job.job_id}"}) + return + emit(job.wait(timeout=timeout, on_poll=_job_status_to_stderr), fmt=fmt) + + +@app.command("find-batch") +@handle_errors +def find_batch_command( + ctx: typer.Context, + contacts_file: pathlib.Path | None = typer.Option( + None, + "--contacts-file", + help="Path to a CSV file with first_name,last_name,domain columns (max 500 contacts per batch).", + ), + contact: list[str] | None = typer.Option( + None, "--contact", help='Inline contact as "first_name,last_name,domain" (repeatable).' + ), + wait: bool = typer.Option(False, "--wait/--no-wait", help=WAIT_HELP), + timeout: float = typer.Option(DEFAULT_WAIT_TIMEOUT_SECONDS, "--timeout", help=TIMEOUT_HELP), + fmt: str | None = typer.Option(None, "--format", help=FORMAT_HELP), +) -> None: + """Submit an email find batch from a CSV file and/or inline contacts (async).""" + from discolike_cli.main import get_client + + contacts: list[dict[str, str]] = [] + if contacts_file is not None: + contacts.extend(_read_contacts_file(contacts_file)) + contacts.extend(_parse_contact(value) for value in contact or []) + if not contacts: + raise typer.BadParameter("provide --contacts-file and/or at least one --contact") + if len(contacts) > MAX_BATCH_CONTACTS: + raise typer.BadParameter(f"a batch holds at most {MAX_BATCH_CONTACTS} contacts, got {len(contacts)}") + + batch = get_client(ctx).email.find_batch(contacts=contacts) + if not wait: + emit({"batch_id": batch.batch_id, "hint": f"fetch with: discolike email results {batch.batch_id}"}) + return + emit(batch.results(timeout=timeout, on_poll=_batch_progress_to_stderr), fmt=fmt) + + +@app.command("results") +@handle_errors +def results_command( + ctx: typer.Context, + batch_id: str = typer.Argument(..., help="Batch ID returned by `discolike email find-batch`."), + kind: str = typer.Option("find", "--kind", help=KIND_HELP), + wait: bool = typer.Option(False, "--wait/--no-wait", help=WAIT_HELP), + timeout: float = typer.Option(DEFAULT_WAIT_TIMEOUT_SECONDS, "--timeout", help=TIMEOUT_HELP), + fmt: str | None = typer.Option(None, "--format", help=FORMAT_HELP), +) -> None: + """Fetch results for an email find or verify batch.""" + from discolike_cli.main import get_client + + if kind not in EMAIL_KINDS: + raise typer.BadParameter(f"--kind must be one of: {', '.join(EMAIL_KINDS)}") + batch = get_client(ctx).email.batch(batch_id, kind=kind) # type: ignore[arg-type] + if wait: + emit(batch.results(timeout=timeout, on_poll=_batch_progress_to_stderr), fmt=fmt) + return + emit(_fetch_batch_snapshot(batch), fmt=fmt) + + +@app.command("job") +@handle_errors +def job_command( + ctx: typer.Context, + job_id: str = typer.Argument(..., help="Job ID returned by `discolike email find`."), + wait: bool = typer.Option(False, "--wait/--no-wait", help=WAIT_HELP), + timeout: float = typer.Option(DEFAULT_WAIT_TIMEOUT_SECONDS, "--timeout", help=TIMEOUT_HELP), + fmt: str | None = typer.Option(None, "--format", help=FORMAT_HELP), +) -> None: + """Poll a single email find job: print its current status, or block with --wait.""" + from discolike_cli.main import get_client + + job = get_client(ctx).email.job(job_id) + if wait: + emit(job.wait(timeout=timeout, on_poll=_job_status_to_stderr), fmt=fmt) + return + emit(job.status(), fmt=fmt) diff --git a/packages/discolike-cli/src/discolike_cli/main.py b/packages/discolike-cli/src/discolike_cli/main.py index 92561a6..92fe359 100644 --- a/packages/discolike-cli/src/discolike_cli/main.py +++ b/packages/discolike-cli/src/discolike_cli/main.py @@ -13,6 +13,7 @@ from discolike_cli import contacts from discolike_cli import discogen from discolike_cli import discover +from discolike_cli import email from discolike_cli import enrich from discolike_cli import match from discolike_cli import providers @@ -57,6 +58,7 @@ def get_client(ctx: typer.Context) -> Discolike: app.add_typer(company.app, name="company") app.add_typer(contacts.app, name="contacts") app.add_typer(discogen.app, name="discogen") +app.add_typer(email.app, name="email") app.add_typer(queries.app, name="queries") app.add_typer(account.app, name="account") app.add_typer(providers.search_providers_app, name="search-providers") diff --git a/packages/discolike-cli/tests/test_email_cli.py b/packages/discolike-cli/tests/test_email_cli.py new file mode 100644 index 0000000..a4426b1 --- /dev/null +++ b/packages/discolike-cli/tests/test_email_cli.py @@ -0,0 +1,283 @@ +from __future__ import annotations + +import json +import pathlib +import time +from collections.abc import Callable + +import httpx +import pytest +from typer.testing import CliRunner + +from discolike_cli.main import app +from discolike_testkit import Handler + +runner = CliRunner() + + +@pytest.fixture(autouse=True) +def no_sleep(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr(time, "sleep", lambda seconds: None) + + +FOUND_RESULT = { + "first_name": "Jane", + "last_name": "Doe", + "domain": "acme.com", + "status": "found", + "result": {"email": "jane.doe@acme.com", "pattern": "{first}.{last}", "valid": True}, +} + + +def test_email_find_without_wait_prints_job_hint(install_build_client: Callable[[Handler], None]) -> None: + captured: dict[str, httpx.Request] = {} + + def handler(request: httpx.Request) -> httpx.Response: + captured["request"] = request + return httpx.Response(200, json={"job_id": "ej-1"}) + + install_build_client(handler) + result = runner.invoke(app, ["email", "find", "Jane", "Doe", "acme.com"]) + assert result.exit_code == 0, result.output + request = captured["request"] + assert request.url.path == "/v1/email/find" + assert json.loads(request.content) == {"first_name": "Jane", "last_name": "Doe", "domain": "acme.com"} + payload = json.loads(result.stdout) + assert payload["job_id"] == "ej-1" + assert "discolike email job ej-1" in payload["hint"] + + +def test_email_find_with_wait_polls_to_completion(install_build_client: Callable[[Handler], None]) -> None: + statuses = iter( + [ + httpx.Response(200, json={"job_id": "ej-2", "status": "processing"}), + httpx.Response(200, json={"job_id": "ej-2", "status": "completed", "result": FOUND_RESULT}), + ] + ) + + def handler(request: httpx.Request) -> httpx.Response: + if request.url.path == "/v1/email/find": + return httpx.Response(200, json={"job_id": "ej-2"}) + assert request.url.path == "/v1/email/jobs/ej-2" + return next(statuses) + + install_build_client(handler) + result = runner.invoke(app, ["email", "find", "Jane", "Doe", "acme.com", "--wait", "--timeout", "5"]) + assert result.exit_code == 0, result.output + payload = json.loads(result.stdout) + assert payload["status"] == "found" + assert payload["result"]["email"] == "jane.doe@acme.com" + assert "status: processing" in result.stderr + + +def test_email_find_batch_from_csv_file( + tmp_path: pathlib.Path, install_build_client: Callable[[Handler], None] +) -> None: + contacts_file = tmp_path / "contacts.csv" + contacts_file.write_text("first_name,last_name,domain\nJane,Doe,acme.com\nJohn,Smith,beta.com\n") + captured: dict[str, httpx.Request] = {} + + def handler(request: httpx.Request) -> httpx.Response: + captured["request"] = request + return httpx.Response(200, json={"batch_id": "eb-1"}) + + install_build_client(handler) + result = runner.invoke(app, ["email", "find-batch", "--contacts-file", str(contacts_file)]) + assert result.exit_code == 0, result.output + request = captured["request"] + assert request.url.path == "/v1/email/find/batch" + assert json.loads(request.content) == { + "requests": [ + {"first_name": "Jane", "last_name": "Doe", "domain": "acme.com"}, + {"first_name": "John", "last_name": "Smith", "domain": "beta.com"}, + ] + } + payload = json.loads(result.stdout) + assert payload["batch_id"] == "eb-1" + assert "discolike email results eb-1" in payload["hint"] + + +def test_email_find_batch_from_inline_contacts(install_build_client: Callable[[Handler], None]) -> None: + captured: dict[str, httpx.Request] = {} + + def handler(request: httpx.Request) -> httpx.Response: + captured["request"] = request + return httpx.Response(200, json={"batch_id": "eb-2"}) + + install_build_client(handler) + result = runner.invoke( + app, + ["email", "find-batch", "--contact", "Jane,Doe,acme.com", "--contact", "John, Smith, beta.com"], + ) + assert result.exit_code == 0, result.output + body = json.loads(captured["request"].content) + assert body["requests"] == [ + {"first_name": "Jane", "last_name": "Doe", "domain": "acme.com"}, + {"first_name": "John", "last_name": "Smith", "domain": "beta.com"}, + ] + + +def test_email_find_batch_with_wait_polls_results(install_build_client: Callable[[Handler], None]) -> None: + results_pages = iter( + [ + httpx.Response(200, json={"batch_id": "eb-3", "total": 1, "completed": 0, "failed": 0, "results": []}), + httpx.Response( + 200, + json={ + "batch_id": "eb-3", + "total": 1, + "completed": 1, + "failed": 0, + "results": [{"job_id": "ej-9", "status": "completed", "result": FOUND_RESULT}], + }, + ), + ] + ) + + def handler(request: httpx.Request) -> httpx.Response: + if request.url.path == "/v1/email/find/batch": + return httpx.Response(200, json={"batch_id": "eb-3"}) + assert request.url.path == "/v1/email/batch/eb-3/results" + return next(results_pages) + + install_build_client(handler) + result = runner.invoke( + app, + ["email", "find-batch", "--contact", "Jane,Doe,acme.com", "--wait", "--timeout", "5"], + ) + assert result.exit_code == 0, result.output + payload = json.loads(result.stdout) + assert payload["completed"] == 1 + assert payload["results"][0]["result"]["result"]["email"] == "jane.doe@acme.com" + assert "progress: 0/1 completed, 0 failed" in result.stderr + + +@pytest.mark.parametrize( + "args", + [ + [], # neither source given + ["--contact", "Jane,Doe"], # too few fields + ["--contact", "Jane,,acme.com"], # empty field + ], +) +def test_email_find_batch_bad_contacts_exit_2(args: list[str], install_build_client: Callable[[Handler], None]) -> None: + def handler(request: httpx.Request) -> httpx.Response: + return httpx.Response(200, json={"batch_id": "eb-4"}) + + install_build_client(handler) + result = runner.invoke(app, ["email", "find-batch", *args]) + assert result.exit_code == 2 + + +def test_email_find_batch_missing_csv_columns_exits_2( + tmp_path: pathlib.Path, install_build_client: Callable[[Handler], None] +) -> None: + contacts_file = tmp_path / "contacts.csv" + contacts_file.write_text("first,last,site\nJane,Doe,acme.com\n") + + def handler(request: httpx.Request) -> httpx.Response: + return httpx.Response(200, json={"batch_id": "eb-5"}) + + install_build_client(handler) + result = runner.invoke(app, ["email", "find-batch", "--contacts-file", str(contacts_file)]) + assert result.exit_code == 2 + assert "domain" in result.output + + +def test_email_find_batch_over_500_contacts_exits_2( + tmp_path: pathlib.Path, install_build_client: Callable[[Handler], None] +) -> None: + rows = "\n".join(f"Jane{i},Doe,acme.com" for i in range(501)) + contacts_file = tmp_path / "contacts.csv" + contacts_file.write_text(f"first_name,last_name,domain\n{rows}\n") + + def handler(request: httpx.Request) -> httpx.Response: + return httpx.Response(200, json={"batch_id": "eb-6"}) + + install_build_client(handler) + result = runner.invoke(app, ["email", "find-batch", "--contacts-file", str(contacts_file)]) + assert result.exit_code == 2 + assert "500" in result.output + + +def test_email_results_without_wait_returns_partial_snapshot( + install_build_client: Callable[[Handler], None], +) -> None: + captured: dict[str, httpx.Request] = {} + + def handler(request: httpx.Request) -> httpx.Response: + captured["request"] = request + return httpx.Response( + 200, + json={"batch_id": "eb-7", "total": 2, "completed": 1, "failed": 0, "results": [{"status": "completed"}]}, + ) + + install_build_client(handler) + result = runner.invoke(app, ["email", "results", "eb-7"]) + assert result.exit_code == 0, result.output + assert captured["request"].url.path == "/v1/email/batch/eb-7/results" + payload = json.loads(result.stdout) + assert payload["batch_id"] == "eb-7" + assert payload["completed"] == 1 + + +def test_email_results_verify_kind_decodes_validation_output( + install_build_client: Callable[[Handler], None], +) -> None: + def handler(request: httpx.Request) -> httpx.Response: + assert request.url.path == "/v1/email/batch/eb-8/results" + return httpx.Response( + 200, + json={ + "batch_id": "eb-8", + "total": 1, + "completed": 1, + "failed": 0, + "results": [ + { + "job_id": "ej-8", + "status": "completed", + "result": {"email": "jane@acme.com", "status": "valid", "reason": "deliverable"}, + } + ], + }, + ) + + install_build_client(handler) + result = runner.invoke(app, ["email", "results", "eb-8", "--kind", "verify"]) + assert result.exit_code == 0, result.output + payload = json.loads(result.stdout) + assert payload["results"][0]["result"]["reason"] == "deliverable" + + +def test_email_results_invalid_kind_exits_2(install_build_client: Callable[[Handler], None]) -> None: + def handler(request: httpx.Request) -> httpx.Response: + return httpx.Response(200, json={}) + + install_build_client(handler) + result = runner.invoke(app, ["email", "results", "eb-9", "--kind", "bogus"]) + assert result.exit_code == 2 + + +def test_email_job_prints_current_status(install_build_client: Callable[[Handler], None]) -> None: + def handler(request: httpx.Request) -> httpx.Response: + assert request.url.path == "/v1/email/jobs/ej-5" + return httpx.Response(200, json={"job_id": "ej-5", "status": "processing"}) + + install_build_client(handler) + result = runner.invoke(app, ["email", "job", "ej-5"]) + assert result.exit_code == 0, result.output + payload = json.loads(result.stdout) + assert payload["job_id"] == "ej-5" + assert payload["status"] == "processing" + + +def test_email_job_unauthorized_exits_3(install_build_client: Callable[[Handler], None]) -> None: + def handler(request: httpx.Request) -> httpx.Response: + return httpx.Response(401, json={"detail": "invalid key"}) + + install_build_client(handler) + result = runner.invoke(app, ["email", "job", "ej-6"]) + assert result.exit_code == 3 + payload = json.loads(result.stderr) + assert payload["error"] == "AuthenticationError" From 354b2cb3a3743f020b2600439e0de2a4cb4575c6 Mon Sep 17 00:00:00 2001 From: Yegor Dolgopolov Date: Wed, 19 Aug 2026 13:13:23 -0700 Subject: [PATCH 2/4] feat(sdk): known_pattern on email.find; email routes join the contract check known_pattern matches the platform's POST /email/find body and is omitted when unset. The platform now exposes the email find/poll routes in its OpenAPI spec, so the openapi=False stamps are gone and check_contract.py validates the routes and params like every other resource. --- .../src/discolike/resources/email.py | 14 ++++++----- packages/discolike/tests/test_email.py | 24 +++++++++++++++++-- 2 files changed, 30 insertions(+), 8 deletions(-) diff --git a/packages/discolike/src/discolike/resources/email.py b/packages/discolike/src/discolike/resources/email.py index 78563a4..2f37ca2 100644 --- a/packages/discolike/src/discolike/resources/email.py +++ b/packages/discolike/src/discolike/resources/email.py @@ -31,13 +31,13 @@ class EmailResource(SyncAPIResource): - @api_route("POST", "/email/find", openapi=False) - def find(self, *, first_name: str, last_name: str, domain: str) -> EmailJob: + @api_route("POST", "/email/find") + def find(self, *, first_name: str, last_name: str, domain: str, known_pattern: str | None = None) -> EmailJob: body = {k: v for k, v in locals().items() if k != "self"} response = self._transport.request("POST", "/email/find", json_body=drop_none(body)) return EmailJob(self._transport, job_id=response.json()["job_id"], kind="find") - @api_route("POST", "/email/find/batch", openapi=False, ignore_params=("contacts",)) + @api_route("POST", "/email/find/batch", ignore_params=("contacts",)) def find_batch(self, *, contacts: list[dict[str, str]]) -> EmailBatch: response = self._transport.request("POST", "/email/find/batch", json_body={"requests": contacts}) return EmailBatch(self._transport, batch_id=response.json()["batch_id"], kind="find") @@ -50,13 +50,15 @@ def job(self, job_id: str) -> EmailJob: class AsyncEmailResource(AsyncAPIResource): - @api_route("POST", "/email/find", openapi=False) - async def find(self, *, first_name: str, last_name: str, domain: str) -> AsyncEmailJob: + @api_route("POST", "/email/find") + async def find( + self, *, first_name: str, last_name: str, domain: str, known_pattern: str | None = None + ) -> AsyncEmailJob: body = {k: v for k, v in locals().items() if k != "self"} response = await self._transport.request("POST", "/email/find", json_body=drop_none(body)) return AsyncEmailJob(self._transport, job_id=response.json()["job_id"], kind="find") - @api_route("POST", "/email/find/batch", openapi=False, ignore_params=("contacts",)) + @api_route("POST", "/email/find/batch", ignore_params=("contacts",)) async def find_batch(self, *, contacts: list[dict[str, str]]) -> AsyncEmailBatch: response = await self._transport.request("POST", "/email/find/batch", json_body={"requests": contacts}) return AsyncEmailBatch(self._transport, batch_id=response.json()["batch_id"], kind="find") diff --git a/packages/discolike/tests/test_email.py b/packages/discolike/tests/test_email.py index 7bbf398..20095e1 100644 --- a/packages/discolike/tests/test_email.py +++ b/packages/discolike/tests/test_email.py @@ -241,6 +241,26 @@ def handler(request: httpx.Request) -> httpx.Response: assert output.result.email == "grace@navy.mil" +def test_find_sends_known_pattern_and_omits_it_when_unset(make_client: ClientFactory) -> None: + bodies: list[dict] = [] + + def handler(request: httpx.Request) -> httpx.Response: + bodies.append(json.loads(request.content)) + return httpx.Response(202, json={"job_id": "j-kp", "status": "queued"}) + + with make_client(handler) as client: + client.email.find(first_name="Grace", last_name="Hopper", domain="navy.mil", known_pattern="first.last") + client.email.find(first_name="Grace", last_name="Hopper", domain="navy.mil") + + assert bodies[0] == { + "first_name": "Grace", + "last_name": "Hopper", + "domain": "navy.mil", + "known_pattern": "first.last", + } + assert bodies[1] == {"first_name": "Grace", "last_name": "Hopper", "domain": "navy.mil"} + + def test_find_wait_raises_on_failed_job(make_client: ClientFactory) -> None: def handler(request: httpx.Request) -> httpx.Response: if request.method == "POST": @@ -370,8 +390,8 @@ def test_route_metadata_stamped() -> None: from discolike.resources._base import get_discolike_route from discolike.resources.email import EmailResource - assert get_discolike_route(EmailResource.find) == ("POST", "/email/find", False, ()) - assert get_discolike_route(EmailResource.find_batch) == ("POST", "/email/find/batch", False, ("contacts",)) + assert get_discolike_route(EmailResource.find) == ("POST", "/email/find", True, ()) + assert get_discolike_route(EmailResource.find_batch) == ("POST", "/email/find/batch", True, ("contacts",)) assert get_discolike_route(EmailResource.job) is None assert get_discolike_route(EmailResource.batch) is None From 7d8e3f5f7004362ede0cbc2fd77ad0114f2430c8 Mon Sep 17 00:00:00 2001 From: Yegor Dolgopolov Date: Wed, 19 Aug 2026 13:13:23 -0700 Subject: [PATCH 3/4] docs: add runnable examples folder match_crm_contacts.py (bulk-match a CRM CSV to personas with resumable checkpointing and website+email domain keys), find_emails_from_csv.py, and discover_and_enrich.py, referenced from the README. --- CHANGELOG.md | 7 + README.md | 9 ++ examples/README.md | 17 +++ examples/discover_and_enrich.py | 63 +++++++++ examples/find_emails_from_csv.py | 95 +++++++++++++ examples/match_crm_contacts.py | 222 +++++++++++++++++++++++++++++++ 6 files changed, 413 insertions(+) create mode 100644 examples/README.md create mode 100644 examples/discover_and_enrich.py create mode 100644 examples/find_emails_from_csv.py create mode 100644 examples/match_crm_contacts.py diff --git a/CHANGELOG.md b/CHANGELOG.md index dcf0388..3a2754b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,12 @@ # Changelog +## Unreleased + +- 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. diff --git a/README.md b/README.md index 8d6d1ee..9475c05 100644 --- a/README.md +++ b/README.md @@ -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: diff --git a/examples/README.md b/examples/README.md new file mode 100644 index 0000000..20e96da --- /dev/null +++ b/examples/README.md @@ -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/