feat(sdk-python): node discovery package + Client wiring#374
Merged
Conversation
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.
This was referenced May 18, 2026
Contributor
There was a problem hiding this comment.
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.Clientremote methods, adds optional logger injection, and installs aNullHandleron thecqlogger 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.
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
cq.Client(addr=...)now probes/.well-known/cq-node.jsononce per address per cache TTL and routes API calls through the resolvedapi_base_url. The 404 fallback preserves today'saddr + /api/v1URL shape, so localhost and same-origin nodes are unaffected.DiscoveryErroris distinct fromhttpx.HTTPErrorso 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— newcq.discoverypackage mirroringsdk/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=Nonedisables the disk layer. Concurrentresolve()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 intoClient.feat(sdk-python): resolve API base URL via node discovery; logger injection— threads the Resolver throughClient. Each_remote_*method now uses the resolvedapi_base_urlverbatim; hardcoded/api/v1prefixes are removed.Clientaccepts an optional logger; the library installs aNullHandleron thecqlogger 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
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