Skip to content

release: 0.2.0 — httpx2 migration, typed responses, email resource - #12

Merged
yudelevi merged 26 commits into
mainfrom
development
Aug 22, 2026
Merged

release: 0.2.0 — httpx2 migration, typed responses, email resource#12
yudelevi merged 26 commits into
mainfrom
development

Conversation

@yudelevi

@yudelevi yudelevi commented Aug 21, 2026

Copy link
Copy Markdown
Contributor

Release PR for 0.2.0 — 25 commits, everything merged into development since 0.1.1.

main is behind by content only; its three extra commits are the merge commits from PRs #2, #3, and #4, so there is nothing on main this would revert.

Why 0.2.0 and not 0.1.2

0.1.2 was written into the changelog and version files on 2026-08-19 but never tagged or published — PyPI still serves 0.1.1. Worse, both cross-package pins referenced that 0.1.2, so pip install discolike-cli from the index could not resolve at all. Since no user ever saw a 0.1.2, its entries were folded into the release that actually ships. The minor bump is warranted on its own: this drops companies.metrics/history, changes four company endpoints to return lists, and swaps the HTTP client underneath the public API.

Breaking changes

httpx → httpx2. The SDK now depends on httpx2, Pydantic's maintained continuation of httpx, for timely security updates. httpx types are part of the public surface, so callers must update:

Before After
import httpx import httpx2
Client(http_client=httpx.Client(...)) Client(http_client=httpx2.Client(...))
client.with_options(timeout=httpx.Timeout(...)) client.with_options(timeout=httpx2.Timeout(...))

httpx2 also verifies TLS against the OS trust store via truststore rather than bundled certifi roots — no code change on our side, but hosts with an incomplete or custom system trust store may see verification failures that certifi previously masked.

Removed companies.metrics / companies.history and the company metrics / company history commands; the underlying endpoints are deprecated with removal scheduled for 2026-10-01.

Return types companies.redirects, vendors, subsidiaries, and public_links now return a list of typed rows. These endpoints return a JSON array; the SDK was validating it into a single model and raised ValidationError on every live call.

Also in this release

Typed responses across contacts.count, contacts.discover, extract, score, match, and the provider and model listings, replacing bare passthrough models. New email resource and discolike email command group. client.with_options(timeout=...). queries.save_results. Typed signatures on the client-level forwarders. A runnable examples/ folder. Both wheels now ship the MIT license text, which was missing from every release so far.

Full detail in CHANGELOG.md.

Testing

315 tests pass; ruff check and ruff format --check clean; ty reports only 3 pre-existing examples/ diagnostics. The suite runs entirely through MockTransport, so the real wire path was verified separately: a live HTTPS request to api.discolike.com completes handshake and round trip under httpx2's truststore-based verification.

After merge

Tag v0.2.0, then publish both packages together — they pin each other exactly, so discolike[cli] is unsatisfiable in the window between the two uploads.

Greptile Summary

The release migrates the SDK and testkit to httpx2, expands typed response coverage, introduces email job and batch APIs with corresponding CLI commands, adds per-request timeout views, and packages the 0.2.0 release.

  • Adds typed email find/verify job and batch handling across synchronous, asynchronous, and CLI interfaces.
  • Updates transport, packaging, CI, examples, and client-level typed forwarding APIs.
  • Removes deprecated company endpoints and corrects list-returning company response models.

Confidence Score: 4/5

The PR should not merge until email wait() accepts results decoded according to the server-reported kind; the CRM CSV formula-injection concern is also worth hardening.

Server-kind decoding and handle-kind validation currently disagree, causing a completed find or verify job to raise JobFailedError when those kinds differ; the new CRM example also preserves formula-leading input cells in its spreadsheet-oriented output.

Files Needing Attention: packages/discolike/src/discolike/_email.py; examples/match_crm_contacts.py

Security Review

The new CRM CSV example writes untrusted input fields without neutralizing spreadsheet formula prefixes, creating a formula-injection risk when generated output is opened in a spreadsheet.

Important Files Changed

Filename Overview
packages/discolike/src/discolike/_email.py Adds typed find/verify decoding and polling, but wait() can reject a correctly decoded server-kind result when the handle kind differs.
packages/discolike-cli/src/discolike_cli/email.py Adds email find, batch, result, and job commands with input validation and polling output.
packages/discolike/src/discolike/_transport.py Migrates transport behavior to httpx2 and adds tested timeout views that share the parent connection pool.
packages/discolike/src/discolike/_client.py Exposes typed forwarding methods and synchronous/asynchronous with_options timeout views.
examples/match_crm_contacts.py Adds resumable CRM matching, but exports original untrusted cells without spreadsheet-formula neutralization.
packages/discolike-cli/src/discolike_cli/auth.py Routes authentication verification through global base URL and API-key options while preserving explicit source reporting.
packages/discolike/pyproject.toml Publishes version 0.2.0 with the httpx2 dependency, typed-package metadata, and bundled license.
packages/discolike-cli/pyproject.toml Publishes the CLI at 0.2.0 with an exact SDK pin and expanded package metadata.

Fix all with Greploop Fix All in Claude Code Fix All in Codex

Prompt To Fix All With AI
### Issue 1
packages/discolike/src/discolike/_email.py:144
**Server kind conflicts with handle kind**

When a completed status response reports a different valid kind than the rehydrated handle, `status()` decodes the result using the server kind but `wait()` validates it against `self.kind`, causing a successful job to raise `JobFailedError`. The asynchronous implementation has the same mismatch.

### Issue 2
examples/match_crm_contacts.py:225-226
**CRM cells retain formula prefixes**

The output loop copies every input CRM cell directly into a spreadsheet-oriented CSV without neutralizing formula prefixes, so opening a crafted export in a formula-evaluating spreadsheet executes the cell instead of displaying its text. Neutralize formula-leading values in both headers and rows before writing them.

**How this was verified:** The input rows loaded by `DictReader` are splatted unchanged into `DictWriter.writerow()`.

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

Reviews (1): Last reviewed commit: "Merge pull request #11 from discolike/fe..." | Re-trigger Greptile

Greptile also left 2 inline comments on this PR.

yudelevi and others added 25 commits August 19, 2026 09:08
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.
/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.
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.
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.
…t 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.
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.
…l jobs in output

Rows expand to up to two queries (website + email domain), so chunking by
row count could send up to 1,000 queries against the 500-per-call limit.
Chunks are now packed by expanded query count, rows never split across
calls. In find_emails_from_csv, terminal jobs with no result carried only
item.error and vanished from the CSV; they now write a row with their
status and error.
Email finder: CLI command group, known_pattern, contract-checked routes, examples folder
Every command routes its client through get_client(ctx), which reads the
global options the root callback stashes on ctx.obj — except auth login
and auth status, which took no ctx and built a client directly.

The visible damage was auth status reporting {"valid": true} against a
host it never contacted:

    $ discolike --base-url http://127.0.0.1:9 auth status
    {"source": "env", "api_key": "…isco", "valid": true}

while the same override on any other command correctly refused to
connect. --api-key was ignored too, so there was no way to verify a key
before auth login wrote it to disk.

auth status gains a third source value, "option", distinguishing a key
passed on the command line from one inherited via DISCOLIKE_API_KEY.
That distinction has to come from click's parameter source, since the
global --api-key is env-bound and the value alone cannot tell them apart.

The same distinction keeps auth login's prompt intact: an ambient
DISCOLIKE_API_KEY must not silently become the saved credential, or
anyone holding a production key in their environment would persist the
wrong one while trying to store another. Only an explicit flag skips the
prompt.
fix(cli): honor global --base-url and --api-key in auth commands
…d kind

email.job() hardcoded kind="find", so a verify job could never be
rehydrated through it — its ValidationOutput parsed into the wrong model.
The union member was chosen solely from the handle's kind, so a batch
reattached with the wrong kind silently mis-parsed every result.

Results now prefer the kind the server reports per job (additive platform
field), falling back to the handle's kind. wait() checks the result
against the expected model for the job's kind and fails loudly otherwise.
Both returned a bare DiscolikeModel with every field in .extra — the only
two untyped responses left in the SDK. count now returns Count, discover
a ContactsDiscoverResponse mirroring the platform's response: a map of
domain -> ContactsByCompany plus total_contacts/total_domains.

ContactsByCompany was defined but never constructed; it now extends
CompanyProfile and serves as the per-domain entry, matching the
platform's DomainContactsEntry (CompanyResult + contacts +
email_pattern fields).
discover, count, validate_icp, append, and segment forwarded **kwargs
to typed methods on private resources — the typed signatures existed
but were unreachable, so the five most-used entry points had no static
checking or completion, and a misspelled keyword surfaced only at
runtime (or reached the API as an unknown query parameter).

The forwarders now mirror the inner signatures exactly and forward
each parameter by keyword; wire behavior is unchanged since inner
methods drop None values before sending.
Long-running calls (large append uploads, slow discover queries) had no
way to exceed the client-wide timeout without constructing a second
client. with_options returns a view sharing the parent's connection
pool whose transport passes the override per request; the base client
is untouched.
Transport clones from with_timeout mark themselves as views; close()/
aclose() on a view is a no-op so the parent client (and sibling views)
keep working after a view is closed or exits its context manager.
Same shape as the platform-side mapping: a match over EmailKind with
assert_never, so a future third kind fails type checking until decode
and wait handle it, instead of silently decoding as ValidationOutput.
typing-extensions promoted to an explicit dependency (assert_never on
Python 3.10; it was already pulled in transitively by pydantic).
# Conflicts:
#	CHANGELOG.md
# Conflicts:
#	CHANGELOG.md
#	packages/discolike/src/discolike/_client.py
httpx upstream is low-activity; Pydantic maintains httpx2 as its
continuation and ships security fixes for it. This library sits in the
critical path of user requests, so it follows the maintained fork.

Breaking: httpx types are part of the public surface (http_client=,
with_options(timeout=), the testkit Handler alias), so callers must swap
`import httpx` for `import httpx2`. httpx2 also verifies TLS against the
OS trust store via truststore rather than bundled certifi roots.
0.1.2 was written into the changelog and version files on 2026-08-19 but
never tagged or published — PyPI still serves 0.1.1. Since no user ever
saw a 0.1.2, its entries fold into the release that actually ships rather
than shipping two versions on one day.

0.2.0 rather than 0.1.3: the unshipped section already dropped
companies.metrics/history and changed four company endpoints to return
lists, and the httpx2 move breaks the public client types on top of that.

Also repoints the cross-package pins (discolike-cli -> discolike, the
discolike[cli] extra), which referenced the 0.1.2 that never existed on
the index and so could not resolve.
The CLI is not an independent consumer of the SDK — it is the SDK's
command-line front end, released from this repo in lockstep and only ever
tested against its own generation. A range pin invites combinations that
never ran together in CI; an exact pin means the pair a user installs is
the pair that was tested.

Consequence: both packages ship on every release, including one whose own
code did not change. Publishing them together also matters, since
discolike[cli] is unsatisfiable in the window between the two uploads.
feat!: migrate to httpx2 and consolidate the unshipped 0.1.2 into 0.2.0
Comment thread packages/discolike/src/discolike/_email.py Outdated
Comment thread examples/match_crm_contacts.py
_decode_job_result lets the server's kind field override the handle's,
so a verify job rehydrated as the default kind="find" decodes into a
ValidationOutput. wait() then checked that result against the model for
self.kind, so the override it was meant to honor made a completed job
raise JobFailedError("email find job completed without a result").

The isinstance check conflated two things: whether a result arrived, and
whether it matched the kind the caller guessed. Only the first belongs
here — the second is what the server field is there to correct. Batch
results never had the check, which is why only the job path was affected.

Reported by Greptile on #12.
@yudelevi
yudelevi merged commit c76ed1e into main Aug 22, 2026
13 of 14 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants