Skip to content

feat(sdk-python): node discovery package + Client wiring#374

Merged
peteski22 merged 3 commits into
mainfrom
feature/sdk-python-node-discovery
May 18, 2026
Merged

feat(sdk-python): node discovery package + Client wiring#374
peteski22 merged 3 commits into
mainfrom
feature/sdk-python-node-discovery

Conversation

@peteski22
Copy link
Copy Markdown
Collaborator

@peteski22 peteski22 commented May 18, 2026

Summary

  • Brings the Python SDK to parity with the Go SDK's node discovery protocol that landed in feat: add node discovery protocol for split-origin nodes #373.
  • cq.Client(addr=...) now probes /.well-known/cq-node.json once per address per cache TTL and routes API calls through the resolved api_base_url. The 404 fallback preserves today's addr + /api/v1 URL shape, so localhost and same-origin nodes are unaffected.
  • DiscoveryError is distinct from httpx.HTTPError so callers and Propose's fallback logic can distinguish operator/client misconfiguration from connectivity failures.

What's in here

Two atomic commits, each independently revertable:

  • feat(sdk-python): add node discovery package — new cq.discovery package mirroring sdk/go/discovery/. Probes the well-known endpoint, validates the response against the synced JSON Schema, caches results to disk (XDG-compliant, SHA-256-keyed, atomic temp-file + rename, re-validated on read). cache_dir=None disables the disk layer. Concurrent resolve() calls for the same address are coalesced into a single probe; waiters share the elected prober's result or error (single-flight). Not yet wired into Client.
  • feat(sdk-python): resolve API base URL via node discovery; logger injection — threads the Resolver through Client. Each _remote_* method now uses the resolved api_base_url verbatim; hardcoded /api/v1 prefixes are removed. Client accepts an optional logger; the library installs a NullHandler on the cq logger so the SDK writes no logs by default, preserving the MCP-over-stdio invariant.

XDG_CACHE_HOME validation matches the Go SDK (whitespace trim + absolute-path requirement); misconfiguration surfaces via a warning log rather than a returned error, so callers wire one consistent disable signal.

Test plan

  • `make lint` (repo root) clean.
  • `make test` (repo root) green: 398 Python SDK + 280 backend + 11 frontend + sub-makes (124/46).
  • 21 resolver tests + 18 cache tests + 7 paths tests + 16 new client tests covering logger injection, API base URL resolution, DiscoveryError propagation through `query`/`propose`/`confirm`/`flag`/`drain`, transport-failure fallback continuity, the static resolver fixture, and a real-resolver 404-fallback regression.
  • Manual end-to-end smoke against a local `./server` on port 3000:
    • Query succeeds; `~/.cache/cq/discovery/.json` is written with the synthesized defaults.
    • Second invocation does not re-probe (mtime stable; served from disk cache).
    • `Client(addr="http://nonexistent.invalid").query(...)\` raises `DiscoveryError`, not silent fallback.

Follow-up

The Python Resolver's single-flight behavior is stricter than the Go SDK today (Go allows concurrent same-address probes to race). A follow-up issue will track aligning the Go SDK to the same single-flight semantics; I'll file it once this lands and link the merged commit.

Related

Refs: #373

peteski22 added 2 commits May 18, 2026 10:14
Mirrors sdk/go/discovery/.
Probes /.well-known/cq-node.json once per address per cache TTL;
absence of a document means defaults; presence overrides.
Cache is XDG-compliant on disk, SHA-256-keyed, atomic write,
re-validated on read.
cache_dir=None disables the on-disk layer.

XDG_CACHE_HOME validation matches the Go SDK (whitespace trim plus
absolute-path requirement); misconfiguration surfaces via a warning
log instead of a returned error so callers wire one consistent
disable signal.

Concurrent resolve() calls for the same address are coalesced into a
single probe; waiters share the elected prober's result or error.
This is stricter than the Go SDK today; a follow-up issue tracks
aligning Go.

DiscoveryError is distinct from httpx.HTTPError so callers can
distinguish operator/client misconfiguration from connectivity
failures.

Not yet wired into Client.
…ection

Threads the discovery Resolver added in the previous commit through
Client. Each _remote_* method now uses the resolved api_base_url
verbatim; hardcoded /api/v1 prefixes are removed. The 404 fallback
preserves today's URL shape so localhost and same-origin nodes are
unaffected.

Client accepts an optional logger; the library installs a
NullHandler on the "cq" logger so the SDK writes no logs by default,
preserving the MCP-over-stdio invariant. Callers wire their own
handler to enable diagnostics.

DiscoveryError from the resolver propagates as terminal — it
indicates misconfiguration that local-storage fallback cannot fix.
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Adds node-discovery support to the Python SDK (mirroring the Go SDK protocol) and wires it into cq.Client so remote calls use a resolved api_base_url instead of hardcoding /api/v1, with an explicit DiscoveryError channel and disk-backed caching.

Changes:

  • Introduces cq.discovery (types, validation, XDG cache-dir resolution, on-disk cache, resolver with retries + single-flight concurrency).
  • Wires discovery into cq.Client remote methods, adds optional logger injection, and installs a NullHandler on the cq logger tree to keep default behavior silent.
  • Adds comprehensive unit tests for resolver behavior, cache semantics, XDG path resolution, and client integration/propagation behavior.

Reviewed changes

Copilot reviewed 13 out of 13 changed files in this pull request and generated 4 comments.

Show a summary per file
File Description
sdk/python/src/cq/client.py Uses discovery to resolve api_base_url for all remote endpoints; adds logger injection + NullHandler.
sdk/python/src/cq/discovery/init.py Exposes discovery public surface (Resolver, NodeInfo, constants, errors).
sdk/python/src/cq/discovery/_types.py Defines protocol constants and NodeInfo model.
sdk/python/src/cq/discovery/_validate.py Compatibility validation for resolved NodeInfo.
sdk/python/src/cq/discovery/_paths.py XDG-compliant cache directory resolution (with warning on misconfig).
sdk/python/src/cq/discovery/_cache.py Disk cache keyed by SHA-256, TTL via mtime, atomic writes, re-validation on read.
sdk/python/src/cq/discovery/_resolver.py Resolver: well-known probing, retry/backoff, defaults-on-404, in-proc memo + single-flight, disk cache integration.
sdk/python/tests/test_discovery.py Resolver behavior tests (parsing, retries, caching, single-flight).
sdk/python/tests/test_discovery_cache.py Disk cache behavior tests (ttl, atomicity, invalidation, disabled mode).
sdk/python/tests/test_discovery_paths.py XDG path resolution tests and warning behavior.
sdk/python/tests/test_client.py Client integration tests (logger behavior, base URL routing, DiscoveryError propagation, 404 fallback contract).
sdk/python/tests/conftest.py Test isolation for env + introduces a static resolver fixture for client tests.
schema/python/Makefile Includes node_discovery.json in Python schema packaging/build inputs.
Comments suppressed due to low confidence (1)

sdk/python/src/cq/client.py:158

  • Client.close() closes the httpx.Client but never closes the Resolver. If a Resolver is injected (or if future changes allow the Resolver to own its own http client), this can leak resources and keep background connections open. Call self._resolver.close() in close() (it already only closes its http client when it owns it) and then close self._http.
    def close(self) -> None:
        """Close the local store and HTTP client."""
        self._store.close()
        if self._http is not None:
            self._http.close()


💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread sdk/python/src/cq/client.py Outdated
Comment thread sdk/python/src/cq/discovery/_validate.py
Comment thread sdk/python/tests/test_discovery.py
Comment thread sdk/python/src/cq/discovery/_resolver.py Outdated
Close the Resolver from Client.close() so callers injecting a
Resolver via the private _resolver= seam don't leak its underlying
httpx.Client when they own one.

Strip trailing slashes from the resolved api_base_url in
Client._api_base_url() so a node publishing api_base_url with a
trailing slash does not produce double-slash request URLs.

Reject malformed port segments in api_base_url during discovery
validation by forcing parsed.port to evaluate. urlparse is lenient
about non-numeric ports; httpx.URL raises InvalidURL (not
HTTPError), which would otherwise propagate uncaught instead of as
DiscoveryError.

Fix _join_well_known docstring example to include the .json suffix
matching WELL_KNOWN_PATH.

Test doubles passed via Client(_resolver=...) gain a no-op close()
so they remain valid drop-ins for the real Resolver contract.
@peteski22 peteski22 merged commit 599eefa into main May 18, 2026
20 checks passed
@peteski22 peteski22 deleted the feature/sdk-python-node-discovery branch May 18, 2026 12:21
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants