Skip to content

Commit d057c42

Browse files
TexasCodingclaude
andauthored
feat(resources): model-first request= overload across body endpoints (#69)
* feat(resources): model-first request= overload across body endpoints Resource methods with a request body now accept either individual kwargs (unchanged) or a pre-built request model via `request=Model(...)`. The two forms produce identical wire bodies; passing both raises TypeError with a clear message. Affected methods (16 sync + 16 async): - orders: create, batch_create, batch_cancel, amend, decrease - api_keys: create, generate - communications: create_rfq, create_quote, accept_quote - multivariate: create_market, lookup_tickers - order_groups: create, update_limit - subaccounts: transfer, update_netting Implementation: new helper `_check_request_exclusive` in resources/_base.py centralizes the mutual-exclusion check. Each method gets a `request=None` kwarg-only parameter; previously-required kwargs become `None`-defaulted so the request= path doesn't require them. The kwarg path retains its existing required-field guards (now raised as TypeError when missing). Drift tests: TestRequestParamDrift only runs on GET/DELETE, so the only exclusion needed is `batch_cancel.request` (DELETE-with-body). Closes #56. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(resources): typed @overload signatures for request= dispatchers Address PR #69 review feedback (#56): - Add @overload pairs to all 32 dispatcher methods across api_keys, communications, multivariate, order_groups, orders, subaccounts. mypy strict now enforces the request= XOR kwargs contract and required-kwarg presence at call sites. - Add negative tests for missing-required-kwarg TypeErrors (single, multi, list-required shapes). - Document why no async batch_cancel exclusion is needed: drift test indexes EXCLUSIONS by the sync FQN and reuses for async siblings. - Trim _check_request_exclusive docstring to a single line. 64 @overload decorators total (32 methods x 2 stubs). mypy strict passes; 1405 tests pass (1402 prior + 3 new). Refs #56 --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 7a540e3 commit d057c42

9 files changed

Lines changed: 1339 additions & 199 deletions

File tree

kalshi/resources/_base.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,18 @@ def _bool_param(value: bool | None) -> str | None:
3232
return "true" if value else "false"
3333

3434

35+
def _check_request_exclusive(request: Any, **kwargs: Any) -> None:
36+
"""Raise TypeError if ``request`` is set together with any individual kwarg."""
37+
if request is None:
38+
return
39+
provided = [k for k, v in kwargs.items() if v is not None]
40+
if provided:
41+
raise TypeError(
42+
"Pass either `request=...` or individual kwargs, not both. "
43+
f"Got both `request` and: {sorted(provided)}"
44+
)
45+
46+
3547
def _join_tickers(value: list[str] | tuple[str, ...] | str | None) -> str | None:
3648
"""Serialize the `tickers` query param (spec: comma-joined string, not explode:true).
3749

kalshi/resources/api_keys.py

Lines changed: 92 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
from __future__ import annotations
44

55
import builtins
6+
from typing import overload
67

78
from kalshi.models.api_keys import (
89
CreateApiKeyRequest,
@@ -11,7 +12,11 @@
1112
GenerateApiKeyResponse,
1213
GetApiKeysResponse,
1314
)
14-
from kalshi.resources._base import AsyncResource, SyncResource
15+
from kalshi.resources._base import (
16+
AsyncResource,
17+
SyncResource,
18+
_check_request_exclusive,
19+
)
1520

1621

1722
class ApiKeysResource(SyncResource):
@@ -27,28 +32,66 @@ def list(self) -> GetApiKeysResponse:
2732
data = self._get("/api_keys")
2833
return GetApiKeysResponse.model_validate(data)
2934

35+
@overload
36+
def create(self, *, request: CreateApiKeyRequest) -> CreateApiKeyResponse: ...
37+
@overload
3038
def create(
3139
self,
3240
*,
3341
name: str,
3442
public_key: str,
43+
scopes: builtins.list[str] | None = ...,
44+
) -> CreateApiKeyResponse: ...
45+
def create(
46+
self,
47+
*,
48+
request: CreateApiKeyRequest | None = None,
49+
name: str | None = None,
50+
public_key: str | None = None,
3551
scopes: builtins.list[str] | None = None,
3652
) -> CreateApiKeyResponse:
3753
self._require_auth()
38-
req = CreateApiKeyRequest(name=name, public_key=public_key, scopes=scopes)
39-
body = req.model_dump(exclude_none=True, by_alias=True, mode="json")
54+
_check_request_exclusive(
55+
request, name=name, public_key=public_key, scopes=scopes,
56+
)
57+
if request is None:
58+
if name is None or public_key is None:
59+
raise TypeError(
60+
"create() requires `name` and `public_key` "
61+
"(or pass `request=...`)"
62+
)
63+
request = CreateApiKeyRequest(
64+
name=name, public_key=public_key, scopes=scopes,
65+
)
66+
body = request.model_dump(exclude_none=True, by_alias=True, mode="json")
4067
data = self._post("/api_keys", json=body)
4168
return CreateApiKeyResponse.model_validate(data)
4269

70+
@overload
71+
def generate(self, *, request: GenerateApiKeyRequest) -> GenerateApiKeyResponse: ...
72+
@overload
4373
def generate(
4474
self,
4575
*,
4676
name: str,
77+
scopes: builtins.list[str] | None = ...,
78+
) -> GenerateApiKeyResponse: ...
79+
def generate(
80+
self,
81+
*,
82+
request: GenerateApiKeyRequest | None = None,
83+
name: str | None = None,
4784
scopes: builtins.list[str] | None = None,
4885
) -> GenerateApiKeyResponse:
4986
self._require_auth()
50-
req = GenerateApiKeyRequest(name=name, scopes=scopes)
51-
body = req.model_dump(exclude_none=True, by_alias=True, mode="json")
87+
_check_request_exclusive(request, name=name, scopes=scopes)
88+
if request is None:
89+
if name is None:
90+
raise TypeError(
91+
"generate() requires `name` (or pass `request=...`)"
92+
)
93+
request = GenerateApiKeyRequest(name=name, scopes=scopes)
94+
body = request.model_dump(exclude_none=True, by_alias=True, mode="json")
5295
data = self._post("/api_keys/generate", json=body)
5396
return GenerateApiKeyResponse.model_validate(data)
5497

@@ -65,28 +108,68 @@ async def list(self) -> GetApiKeysResponse:
65108
data = await self._get("/api_keys")
66109
return GetApiKeysResponse.model_validate(data)
67110

111+
@overload
112+
async def create(self, *, request: CreateApiKeyRequest) -> CreateApiKeyResponse: ...
113+
@overload
68114
async def create(
69115
self,
70116
*,
71117
name: str,
72118
public_key: str,
119+
scopes: builtins.list[str] | None = ...,
120+
) -> CreateApiKeyResponse: ...
121+
async def create(
122+
self,
123+
*,
124+
request: CreateApiKeyRequest | None = None,
125+
name: str | None = None,
126+
public_key: str | None = None,
73127
scopes: builtins.list[str] | None = None,
74128
) -> CreateApiKeyResponse:
75129
self._require_auth()
76-
req = CreateApiKeyRequest(name=name, public_key=public_key, scopes=scopes)
77-
body = req.model_dump(exclude_none=True, by_alias=True, mode="json")
130+
_check_request_exclusive(
131+
request, name=name, public_key=public_key, scopes=scopes,
132+
)
133+
if request is None:
134+
if name is None or public_key is None:
135+
raise TypeError(
136+
"create() requires `name` and `public_key` "
137+
"(or pass `request=...`)"
138+
)
139+
request = CreateApiKeyRequest(
140+
name=name, public_key=public_key, scopes=scopes,
141+
)
142+
body = request.model_dump(exclude_none=True, by_alias=True, mode="json")
78143
data = await self._post("/api_keys", json=body)
79144
return CreateApiKeyResponse.model_validate(data)
80145

146+
@overload
147+
async def generate(
148+
self, *, request: GenerateApiKeyRequest,
149+
) -> GenerateApiKeyResponse: ...
150+
@overload
81151
async def generate(
82152
self,
83153
*,
84154
name: str,
155+
scopes: builtins.list[str] | None = ...,
156+
) -> GenerateApiKeyResponse: ...
157+
async def generate(
158+
self,
159+
*,
160+
request: GenerateApiKeyRequest | None = None,
161+
name: str | None = None,
85162
scopes: builtins.list[str] | None = None,
86163
) -> GenerateApiKeyResponse:
87164
self._require_auth()
88-
req = GenerateApiKeyRequest(name=name, scopes=scopes)
89-
body = req.model_dump(exclude_none=True, by_alias=True, mode="json")
165+
_check_request_exclusive(request, name=name, scopes=scopes)
166+
if request is None:
167+
if name is None:
168+
raise TypeError(
169+
"generate() requires `name` (or pass `request=...`)"
170+
)
171+
request = GenerateApiKeyRequest(name=name, scopes=scopes)
172+
body = request.model_dump(exclude_none=True, by_alias=True, mode="json")
90173
data = await self._post("/api_keys/generate", json=body)
91174
return GenerateApiKeyResponse.model_validate(data)
92175

0 commit comments

Comments
 (0)