From f7251cb118b5c28ae57dfa8f4a728a5115a22ea2 Mon Sep 17 00:00:00 2001 From: Daniel Yudelevich Date: Wed, 19 Aug 2026 09:08:23 -0700 Subject: [PATCH 01/19] fix(packaging): ship license text, mark the CLI typed Every release so far shipped wheels with no MIT license text. The only LICENSE sat at the repo root, outside either package root, and license-files was unset, so hatchling had nothing to collect. Copy it into each published package and declare it. discolike-cli was fully annotated but shipped without py.typed, so none of that was visible to consumers' type checkers. --- packages/discolike-cli/LICENSE | 21 +++++++++++++++++++ packages/discolike-cli/pyproject.toml | 8 +++++++ .../discolike-cli/src/discolike_cli/py.typed | 0 packages/discolike/LICENSE | 21 +++++++++++++++++++ packages/discolike/pyproject.toml | 7 +++++++ 5 files changed, 57 insertions(+) create mode 100644 packages/discolike-cli/LICENSE create mode 100644 packages/discolike-cli/src/discolike_cli/py.typed create mode 100644 packages/discolike/LICENSE diff --git a/packages/discolike-cli/LICENSE b/packages/discolike-cli/LICENSE new file mode 100644 index 0000000..439d601 --- /dev/null +++ b/packages/discolike-cli/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 DiscoLike Inc. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/packages/discolike-cli/pyproject.toml b/packages/discolike-cli/pyproject.toml index 8cdbf65..bb11645 100644 --- a/packages/discolike-cli/pyproject.toml +++ b/packages/discolike-cli/pyproject.toml @@ -8,6 +8,7 @@ version = "0.1.2" description = "Official CLI for the DiscoLike API" readme = "README.md" license = "MIT" +license-files = ["LICENSE"] requires-python = ">=3.10" authors = [{ name = "DiscoLike", email = "support@discolike.com" }] dependencies = [ @@ -25,6 +26,11 @@ classifiers = [ "Programming Language :: Python :: 3.12", "Programming Language :: Python :: 3.13", "Programming Language :: Python :: 3.14", + "Programming Language :: Python :: 3 :: Only", + "Operating System :: OS Independent", + "Environment :: Console", + "Topic :: Utilities", + "Typing :: Typed", ] [project.scripts] @@ -34,6 +40,8 @@ discolike = "discolike_cli.main:app" Homepage = "https://www.discolike.com" Documentation = "https://docs.discolike.com" Repository = "https://github.com/Discolike/discolike-python" +Changelog = "https://github.com/Discolike/discolike-python/blob/main/CHANGELOG.md" +Issues = "https://github.com/Discolike/discolike-python/issues" [tool.uv.sources] discolike-testkit = { workspace = true } diff --git a/packages/discolike-cli/src/discolike_cli/py.typed b/packages/discolike-cli/src/discolike_cli/py.typed new file mode 100644 index 0000000..e69de29 diff --git a/packages/discolike/LICENSE b/packages/discolike/LICENSE new file mode 100644 index 0000000..439d601 --- /dev/null +++ b/packages/discolike/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 DiscoLike Inc. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/packages/discolike/pyproject.toml b/packages/discolike/pyproject.toml index 2dd6f3c..c2bbf4c 100644 --- a/packages/discolike/pyproject.toml +++ b/packages/discolike/pyproject.toml @@ -8,6 +8,7 @@ dynamic = ["version"] description = "Official Python SDK for the DiscoLike API" readme = "README.md" license = "MIT" +license-files = ["LICENSE"] requires-python = ">=3.10" authors = [{ name = "DiscoLike", email = "support@discolike.com" }] dependencies = [ @@ -24,6 +25,10 @@ classifiers = [ "Programming Language :: Python :: 3.12", "Programming Language :: Python :: 3.13", "Programming Language :: Python :: 3.14", + "Programming Language :: Python :: 3 :: Only", + "Operating System :: OS Independent", + "Topic :: Software Development :: Libraries :: Python Modules", + "Typing :: Typed", ] [project.optional-dependencies] @@ -33,6 +38,8 @@ cli = ["discolike-cli>=0.1.2"] Homepage = "https://www.discolike.com" Documentation = "https://docs.discolike.com" Repository = "https://github.com/Discolike/discolike-python" +Changelog = "https://github.com/Discolike/discolike-python/blob/main/CHANGELOG.md" +Issues = "https://github.com/Discolike/discolike-python/issues" [tool.uv.sources] discolike-testkit = { workspace = true } From a2c4f6b617536f3cc482af9f5d38259a7adcb3ca Mon Sep 17 00:00:00 2001 From: Daniel Yudelevich Date: Wed, 19 Aug 2026 09:08:24 -0700 Subject: [PATCH 02/19] fix: ungate save-results now that the platform endpoint is live /queries/save-results is present in the production spec, so the route no longer needs to opt out of the contract check. Verified: 46 routes checked, only the two undeployed /email/find routes still skipped. --- packages/discolike/src/discolike/resources/queries.py | 4 ++-- packages/discolike/tests/test_queries.py | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/discolike/src/discolike/resources/queries.py b/packages/discolike/src/discolike/resources/queries.py index 7456cfc..1a6dd61 100644 --- a/packages/discolike/src/discolike/resources/queries.py +++ b/packages/discolike/src/discolike/resources/queries.py @@ -64,7 +64,7 @@ def create_exclusion_list( response = self._transport.request("POST", "/queries/exclusion-list", json_body=drop_none(body)) return QueryResult.model_validate(response.json()) - @api_route("POST", "/queries/save-results", openapi=False) + @api_route("POST", "/queries/save-results") def save_results( self, *, @@ -123,7 +123,7 @@ async def create_exclusion_list( response = await self._transport.request("POST", "/queries/exclusion-list", json_body=drop_none(body)) return QueryResult.model_validate(response.json()) - @api_route("POST", "/queries/save-results", openapi=False) + @api_route("POST", "/queries/save-results") async def save_results( self, *, diff --git a/packages/discolike/tests/test_queries.py b/packages/discolike/tests/test_queries.py index c9ba02d..ef8df50 100644 --- a/packages/discolike/tests/test_queries.py +++ b/packages/discolike/tests/test_queries.py @@ -139,4 +139,4 @@ def test_route_metadata_stamped() -> None: assert get_discolike_route(QueriesResource.create_exclusion_list) == ("POST", "/queries/exclusion-list", True, ()) assert get_discolike_route(QueriesResource.update) == ("PATCH", "/queries/{query_id}", True, ()) assert get_discolike_route(QueriesResource.delete) == ("DELETE", "/queries/{query_id}", True, ()) - assert get_discolike_route(QueriesResource.save_results) == ("POST", "/queries/save-results", False, ()) + assert get_discolike_route(QueriesResource.save_results) == ("POST", "/queries/save-results", True, ()) From 7d33a62ac8dc338dc3f580eb0fc1e516c5dffc07 Mon Sep 17 00:00:00 2001 From: Daniel Yudelevich Date: Wed, 19 Aug 2026 09:08:24 -0700 Subject: [PATCH 03/19] ci: test against Python 3.15 prereleases without blocking 3.15 is still in beta, so an upstream regression in httpx, pydantic or typer would otherwise red-light every PR for something we cannot fix. Non-blocking until 3.15.0 final, when it moves into the matrix proper and earns a classifier. --- .github/workflows/ci.yml | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f6825e8..67f1e9e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -25,3 +25,17 @@ jobs: - run: uv run ty check packages/discolike/src packages/discolike/tests packages/discolike-cli/src packages/discolike-cli/tests packages/discolike-testkit/src - run: uv run pytest packages/discolike/tests -q - run: uv run pytest packages/discolike-cli/tests -q + + # 3.15 is still prerelease; non-blocking until 3.15.0 final, when it moves + # into the matrix above and gets a classifier. + test-prerelease: + runs-on: ubuntu-latest + continue-on-error: true + steps: + - uses: actions/checkout@v4 + - uses: astral-sh/setup-uv@v5 + with: + python-version: '3.15' + - run: uv sync --all-packages + - run: uv run pytest packages/discolike/tests -q + - run: uv run pytest packages/discolike-cli/tests -q From 732da84b3d661188549162faf7f1d1ca9bd81e09 Mon Sep 17 00:00:00 2001 From: Daniel Yudelevich Date: Wed, 19 Aug 2026 09:08:24 -0700 Subject: [PATCH 04/19] docs: fold packaging and contract fixes into the 0.1.2 entry --- CHANGELOG.md | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4dee245..dcf0388 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,8 +1,11 @@ # Changelog -## 0.1.2 (2026-08-17) +## 0.1.2 (2026-08-19) -- SDK + CLI: new `queries.save_results` (sync + async) and `discolike queries save-results` — save result rows (JSON or CSV file in the CLI) as a reusable saved query, the REST twin of the `save-mcp-query` MCP tool. The CLI validates `--action` against the allowed set; the route is marked `openapi=False` until the platform endpoint deploys, at which point `scripts/check_contract.py` picks it up. +- Packaging: both wheels now ship the MIT license text (`dist-info/licenses/LICENSE`) — it was absent from every release so far, since the only `LICENSE` sat at the repo root, outside either package root. +- Packaging: `discolike-cli` ships `py.typed`, so its annotations are visible to type checkers importing `discolike_cli`. Added classifiers: `Python :: 3 :: Only`, `OS Independent`, `Typing :: Typed`, plus `Libraries :: Python Modules` (SDK) and `Environment :: Console` / `Topic :: Utilities` (CLI). Added `Changelog` and `Issues` project URLs. +- CI: tests also run on Python 3.15 prereleases in a non-blocking job. 3.15 stays out of the supported matrix and classifiers until 3.15.0 final. +- SDK + CLI: new `queries.save_results` (sync + async) and `discolike queries save-results` — save result rows (JSON or CSV file in the CLI) as a reusable saved query, the REST twin of the `save-mcp-query` MCP tool. The CLI validates `--action` against the allowed set. - SDK + CLI (breaking): removed `companies.metrics` / `companies.history` and the `company metrics` / `company history` commands. The underlying `/metrics` and `/history` API endpoints are deprecated, with removal scheduled for 2026-10-01. - SDK (breaking): `companies.redirects`, `companies.vendors`, `companies.subsidiaries`, and `companies.public_links` now return a `list` of typed rows (`Redirect`, `Vendor`, `Subsidiary`, `PublicLink`). These endpoints return a JSON array; the SDK was validating that array into a single model and raised `ValidationError` on every live call. - SDK: `discover` rows, `companies.data`, and match rows now share one typed `CompanyProfile` base mirroring the platform's `CompanyResult` — all 21 firmographic fields (`status`, `address`, `keywords`, `industry_groups`, `business_model`, `revenue_range`, `employees`, `mx_provider`, …), with nested `CompanyStatus` / `CompanyAddress`. `discovery.Company` previously declared 5 of them and `BizData` none. From ad905bb443ee1085eaba124ee7e55877a46ef7bf Mon Sep 17 00:00:00 2001 From: Yegor Dolgopolov Date: Wed, 19 Aug 2026 13:13:23 -0700 Subject: [PATCH 05/19] 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 06/19] 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 07/19] 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/