refactor(resources): extract shared sync/async helpers#71
Conversation
Issue #46: every body-endpoint method had a sync+async pair that differed only by `def` vs `async def` and `self._post(...)` vs `await self._post(...)`. The dispatcher logic (`_check_request_exclusive` + model construction + `model_dump(...)`) was duplicated verbatim across the two classes, so each new kwarg had to be added in two places. Conservative dedup (option 1 in the issue): pull the identical parts of each method-pair into module-level helpers; both sync and async methods call them. - _build_*_body(...) helpers: dispatcher inputs (request | individual kwargs) → dict ready for transport. Owns the exclusive-arg check, the model construction, and the `model_dump(...)` serialization. - Per-resource query-param builders for GET-list endpoints whose param dicts appeared 4× (sync list, sync list_all, async list, async list_all). - _parse_orderbook + _parse_queue_position response helpers in markets/orders. What stays per-class (unchanged): method signatures, @overload stubs from #69 (per-class user-facing typed surface), transport call sites, return-type wrapping. The dual-transport architecture and `async for` ergonomics are preserved. Files refactored: orders, api_keys, communications, multivariate, order_groups, subaccounts (high-payoff body-endpoint resources) plus markets, events, historical, series, portfolio, fcm, milestones (meaningful GET-list dedup). incentive_programs and structured_targets are left as-is — too small for a helper to be cleaner than the inline `_params(...)`. Notes - subaccounts.transfer: UUID coercion stays inside _build_transfer_body (was duplicated across sync+async); behavior identical. - multivariate.lookup_tickers: the sync `if data is None: raise RuntimeError` spec-drift guard is preserved on sync only (async never had it; this is a latent inconsistency, not in scope to fix here). Gates - ruff: clean - mypy --strict: clean (75 files) - pytest (unit): 1405 passed, 48 skipped (no test changes) Closes #46 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Code Review — PR #71: refactor(resources): extract shared sync/async helpersOverviewBehavior-preserving dedup pass that extracts dispatcher logic, body construction, param building, and response parsing from sync/async method pairs into module-level What Works Well
Issues / Suggestions1. Multi-paragraph docstrings violate project style (
|
…#75) Extract `_parse_lookup_tickers_response` so both sync and async paths raise a clear RuntimeError (instead of an opaque Pydantic validation error) if the server ever regresses to 204 on `PUT /multivariate_event_collections/{ticker}/lookup`. Mirrors the PR #71 dedup pattern. Adds regression tests for both sync and async. Closes #72 Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Summary
Conservative dedup pass per issue #46. Extracts the non-IO parts of each resource method — dispatcher logic, request-body model construction, response-model parsing, query-param building — into module-level helpers that both sync and async classes call. Sync and async classes keep their own `@overload` stubs, signatures, and transport calls; the body of each method becomes a thin shell over the shared helper.
Approach: Option 1 from the issue body ("shared param-builder helpers, lowest risk"). Sync-wrapping-async and code-gen are explicitly NOT done.
What's deduplicated
For every method-pair (16 body endpoints + ~20 GET-list endpoints), the previously-duplicated logic now lives in a single helper:
What stays per-class
Per the issue's hard constraints:
Honest LOC trade-off
Raw LOC went up by +329 net (1337 ins / 1008 del). Not a typo — the dedup made the codebase longer, not shorter, for two reasons:
What the refactor actually buys: dispatcher logic + body construction now exists once per method-pair instead of twice. Adding a new kwarg in the future means touching one helper body, one impl signature × 2, and one overload stub × 2 — but the body logic itself is single-source. Before this PR, every kwarg addition required updating both sync and async method bodies in lockstep, with the constant risk that they drift.
If you'd prefer a sharper LOC win, that requires the more aggressive sync-wrapping-async or code-gen approaches the issue explicitly listed as out-of-scope here. Happy to scope a follow-up if you want to explore that.
Per-resource line counts (sync + async combined per file)
Bail-outs (intentional)
The agent skipped resources where the dedup payoff was negligible:
Out-of-scope flag
While auditing `multivariate.lookup_tickers`, the agent noticed sync has `if data is None: raise RuntimeError("spec drift")` and async lacks the equivalent guard. Preserved as-is (latent asymmetry, not in scope for #46). Worth tracking — I'll open a follow-up issue.
Before/after (one method)
```python
Before — sync method (async is identical except
await):self._require_auth()
_check_request_exclusive(request, orders=orders)
if request is None:
if orders is None:
raise TypeError("batch_create() requires `orders` (or pass `request=...`)")
request = BatchCreateOrdersRequest(orders=list(orders))
body = request.model_dump(exclude_none=True, by_alias=True, mode="json")
data = self._post("/portfolio/orders/batched", json=body)
After — sync (async identical except
await self._post(...)):self._require_auth()
body = _build_batch_create_body(request, orders)
data = self._post("/portfolio/orders/batched", json=body)
```
The 7 lines of dispatcher+validation+model+dump now live in `_build_batch_create_body(...)` once.
Verified
Notes
Closes #46
🤖 Generated with Claude Code