sahiapi 0.2.0__tar.gz → 0.3.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {sahiapi-0.2.0 → sahiapi-0.3.0}/CHANGELOG.md +14 -1
- {sahiapi-0.2.0 → sahiapi-0.3.0}/PKG-INFO +4 -1
- {sahiapi-0.2.0 → sahiapi-0.3.0}/README.md +3 -0
- {sahiapi-0.2.0 → sahiapi-0.3.0}/src/sahiapi/__init__.py +2 -0
- {sahiapi-0.2.0 → sahiapi-0.3.0}/src/sahiapi/sahi/auth.py +30 -13
- {sahiapi-0.2.0 → sahiapi-0.3.0}/src/sahiapi/sahi/client.py +5 -1
- {sahiapi-0.2.0 → sahiapi-0.3.0}/src/sahiapi/sahi/exceptions.py +59 -1
- {sahiapi-0.2.0 → sahiapi-0.3.0}/src/sahiapi/sahi/http.py +6 -0
- {sahiapi-0.2.0 → sahiapi-0.3.0}/.gitignore +0 -0
- {sahiapi-0.2.0 → sahiapi-0.3.0}/LICENSE +0 -0
- {sahiapi-0.2.0 → sahiapi-0.3.0}/pyproject.toml +0 -0
- {sahiapi-0.2.0 → sahiapi-0.3.0}/src/sahiapi/py.typed +0 -0
- {sahiapi-0.2.0 → sahiapi-0.3.0}/src/sahiapi/sahi/__init__.py +0 -0
- {sahiapi-0.2.0 → sahiapi-0.3.0}/src/sahiapi/sahi/account.py +0 -0
- {sahiapi-0.2.0 → sahiapi-0.3.0}/src/sahiapi/sahi/base.py +0 -0
- {sahiapi-0.2.0 → sahiapi-0.3.0}/src/sahiapi/sahi/constants.py +0 -0
- {sahiapi-0.2.0 → sahiapi-0.3.0}/src/sahiapi/sahi/env.py +0 -0
- {sahiapi-0.2.0 → sahiapi-0.3.0}/src/sahiapi/sahi/feed.py +0 -0
- {sahiapi-0.2.0 → sahiapi-0.3.0}/src/sahiapi/sahi/funds.py +0 -0
- {sahiapi-0.2.0 → sahiapi-0.3.0}/src/sahiapi/sahi/instruments.py +0 -0
- {sahiapi-0.2.0 → sahiapi-0.3.0}/src/sahiapi/sahi/marketdata.py +0 -0
- {sahiapi-0.2.0 → sahiapi-0.3.0}/src/sahiapi/sahi/orders.py +0 -0
- {sahiapi-0.2.0 → sahiapi-0.3.0}/src/sahiapi/sahi/portfolio.py +0 -0
- {sahiapi-0.2.0 → sahiapi-0.3.0}/src/sahiapi/sahi/proto/__init__.py +0 -0
- {sahiapi-0.2.0 → sahiapi-0.3.0}/src/sahiapi/sahi/proto/codec.py +0 -0
- {sahiapi-0.2.0 → sahiapi-0.3.0}/src/sahiapi/sahi/proto/marketdata.proto +0 -0
- {sahiapi-0.2.0 → sahiapi-0.3.0}/src/sahiapi/sahi/proto/marketdata_pb2.py +0 -0
- {sahiapi-0.2.0 → sahiapi-0.3.0}/src/sahiapi/sahi/ws_client.py +0 -0
|
@@ -6,6 +6,18 @@ follow [Semantic Versioning](https://semver.org/) once it reaches `1.0.0`.
|
|
|
6
6
|
|
|
7
7
|
## [Unreleased]
|
|
8
8
|
|
|
9
|
+
## [0.3.0] - 2026-10-28
|
|
10
|
+
|
|
11
|
+
### Added
|
|
12
|
+
- TOTP keys: `SahiAPI.from_credentials` and `get_access_token` take `totp`, the
|
|
13
|
+
key's current six-digit authenticator code, which doubles as the day's
|
|
14
|
+
approval. A wrong code raises `SahiInvalidTotpException`, carrying the
|
|
15
|
+
server's `server_time` for clock comparison.
|
|
16
|
+
|
|
17
|
+
### Changed
|
|
18
|
+
- The `SahiKeyNotApprovedException` hint also covers an account that does not
|
|
19
|
+
have API keys enabled yet. A TOTP key gets this exception only for that reason.
|
|
20
|
+
|
|
9
21
|
## [0.2.0] - 2026-10-22
|
|
10
22
|
|
|
11
23
|
First public release. Includes everything listed under 0.1.0.
|
|
@@ -64,6 +76,7 @@ Pre-launch placeholder; yanked on PyPI. Use 0.2.0 or later.
|
|
|
64
76
|
- Packaging, CI (ruff, mypy strict, pytest on Python 3.9–3.13) and a
|
|
65
77
|
trusted-publishing release workflow.
|
|
66
78
|
|
|
67
|
-
[Unreleased]: https://github.com/aarityatech/sahi-python/compare/sahiapi/v0.
|
|
79
|
+
[Unreleased]: https://github.com/aarityatech/sahi-python/compare/sahiapi/v0.3.0...HEAD
|
|
80
|
+
[0.3.0]: https://github.com/aarityatech/sahi-python/compare/sahiapi/v0.2.0...sahiapi/v0.3.0
|
|
68
81
|
[0.2.0]: https://github.com/aarityatech/sahi-python/compare/sahiapi/v0.1.0...sahiapi/v0.2.0
|
|
69
82
|
[0.1.0]: https://github.com/aarityatech/sahi-python/releases/tag/sahiapi/v0.1.0
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: sahiapi
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.3.0
|
|
4
4
|
Summary: The official Python client library for the Sahi APIs.
|
|
5
5
|
Project-URL: Homepage, https://docs.sahi.com/
|
|
6
6
|
Project-URL: Documentation, https://docs.sahi.com/sdk/python/
|
|
@@ -85,6 +85,9 @@ print(api.get_profile())
|
|
|
85
85
|
print(api.get_funds())
|
|
86
86
|
```
|
|
87
87
|
|
|
88
|
+
For a TOTP key, also pass the current authenticator code as `totp`. See
|
|
89
|
+
[Mint with a TOTP key](https://docs.sahi.com/sdk/python/authentication/#mint-with-a-totp-key).
|
|
90
|
+
|
|
88
91
|
### Example: place a limit order
|
|
89
92
|
|
|
90
93
|
Prices are in paise, so ₹2,499.55 is `249955`.
|
|
@@ -53,6 +53,9 @@ print(api.get_profile())
|
|
|
53
53
|
print(api.get_funds())
|
|
54
54
|
```
|
|
55
55
|
|
|
56
|
+
For a TOTP key, also pass the current authenticator code as `totp`. See
|
|
57
|
+
[Mint with a TOTP key](https://docs.sahi.com/sdk/python/authentication/#mint-with-a-totp-key).
|
|
58
|
+
|
|
56
59
|
### Example: place a limit order
|
|
57
60
|
|
|
58
61
|
Prices are in paise, so ₹2,499.55 is `249955`.
|
|
@@ -35,6 +35,7 @@ from .sahi.exceptions import (
|
|
|
35
35
|
SahiFeedForbiddenException,
|
|
36
36
|
SahiFeedNotSubscribedException,
|
|
37
37
|
SahiInvalidOrderStateException,
|
|
38
|
+
SahiInvalidTotpException,
|
|
38
39
|
SahiIpNotAllowedException,
|
|
39
40
|
SahiKeyNotApprovedException,
|
|
40
41
|
SahiMaintenanceException,
|
|
@@ -72,6 +73,7 @@ __all__ = [
|
|
|
72
73
|
"SahiFeedForbiddenException",
|
|
73
74
|
"SahiFeedNotSubscribedException",
|
|
74
75
|
"SahiInvalidOrderStateException",
|
|
76
|
+
"SahiInvalidTotpException",
|
|
75
77
|
"SahiIpNotAllowedException",
|
|
76
78
|
"SahiKeyNotApprovedException",
|
|
77
79
|
"SahiMaintenanceException",
|
|
@@ -1,10 +1,13 @@
|
|
|
1
1
|
"""Authentication: minting an access token and reading what it carries.
|
|
2
2
|
|
|
3
|
-
A Sahi access token is minted from an API key
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
3
|
+
A Sahi access token is minted from an API key with :func:`get_access_token`.
|
|
4
|
+
A REGULAR key is approved for the trading day in your Sahi account and then
|
|
5
|
+
exchanges its key id and secret for a token. A TOTP key skips the daily click:
|
|
6
|
+
its six-digit authenticator code goes with the key id and secret, and the first
|
|
7
|
+
correct code of the day is that day's approval, so a strategy can mint
|
|
8
|
+
unattended. The token is a Bearer JWT that every REST and WebSocket call
|
|
9
|
+
carries, and it expires at 06:00 IST — there is no refresh, so mint a fresh one
|
|
10
|
+
each trading day.
|
|
8
11
|
|
|
9
12
|
The helpers here also read a token's own claims without verifying its signature,
|
|
10
13
|
to tell callers when a token expires and what it is allowed to do. They are for
|
|
@@ -40,6 +43,7 @@ def get_access_token(
|
|
|
40
43
|
key_id: str,
|
|
41
44
|
secret: str,
|
|
42
45
|
*,
|
|
46
|
+
totp: str | None = None,
|
|
43
47
|
scopes: list[str] | None = None,
|
|
44
48
|
timeout: float = 10.0,
|
|
45
49
|
) -> dict[str, Any]:
|
|
@@ -51,6 +55,11 @@ def get_access_token(
|
|
|
51
55
|
Args:
|
|
52
56
|
key_id: The key id from your Sahi account.
|
|
53
57
|
secret: The secret shown once when the key was created.
|
|
58
|
+
totp: The current six-digit code for a TOTP key, from the authenticator
|
|
59
|
+
enrolled when the key was created. Required for a TOTP key and
|
|
60
|
+
refused for a REGULAR one. The SDK never holds the TOTP secret:
|
|
61
|
+
generate the code yourself (for example with ``pyotp``) and pass it
|
|
62
|
+
in, so the TOTP secret stays wherever you keep it.
|
|
54
63
|
scopes: Optional subset of the key's scopes to narrow the token to. A
|
|
55
64
|
token can only ever carry scopes the key holds. Omit it, or pass an
|
|
56
65
|
empty list, to get the key's full set.
|
|
@@ -60,20 +69,28 @@ def get_access_token(
|
|
|
60
69
|
The token response dict, unchanged: ``access_token``, ``token_type``,
|
|
61
70
|
``expires_in``, ``expires_at`` and ``scope``.
|
|
62
71
|
|
|
63
|
-
The key id and
|
|
64
|
-
to define, so the SDK does not check it.
|
|
72
|
+
The key id, secret and code are sent as given: their format is the auth
|
|
73
|
+
server's to define, so the SDK does not check it.
|
|
65
74
|
|
|
66
75
|
Raises:
|
|
67
76
|
SahiAuthenticationException: Unknown key id, wrong secret, or a revoked
|
|
68
|
-
key (401
|
|
77
|
+
key (401). The message names a revoked key; the other two read the same.
|
|
78
|
+
SahiInvalidTotpException: The secret was right but the TOTP code was
|
|
79
|
+
not; carries the server's clock for comparison.
|
|
69
80
|
SahiAccountLockedException: The account is frozen or locked.
|
|
70
|
-
SahiKeyNotApprovedException:
|
|
71
|
-
trading day
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
81
|
+
SahiKeyNotApprovedException: A REGULAR key not approved for the current
|
|
82
|
+
trading day, or any key whose account does not have API keys
|
|
83
|
+
enabled yet.
|
|
84
|
+
SahiBadRequestException: A field is malformed, ``scopes`` is not a
|
|
85
|
+
subset of the key's scopes, ``totp`` is missing or not six digits
|
|
86
|
+
for a TOTP key, or ``totp`` was sent for a REGULAR key.
|
|
87
|
+
SahiRateLimitException: Too many mint attempts, or ten wrong TOTP codes
|
|
88
|
+
within a minute; wait for the next minute.
|
|
75
89
|
"""
|
|
76
90
|
body: dict[str, Any] = {"key_id": key_id, "secret": secret}
|
|
91
|
+
# Absent when not given: the server reads a missing, null or empty code the same way.
|
|
92
|
+
if totp is not None:
|
|
93
|
+
body["totp"] = totp
|
|
77
94
|
if scopes is not None:
|
|
78
95
|
body["scopes"] = scopes
|
|
79
96
|
|
|
@@ -207,6 +207,7 @@ class SahiAPI(_Orders, _Portfolio, _Funds, _Account, _MarketData, _Instruments):
|
|
|
207
207
|
key_id: str,
|
|
208
208
|
secret: str,
|
|
209
209
|
*,
|
|
210
|
+
totp: str | None = None,
|
|
210
211
|
scopes: list[str] | None = None,
|
|
211
212
|
scrip_master_url: str | None = None,
|
|
212
213
|
timeout: float = 10.0,
|
|
@@ -222,11 +223,14 @@ class SahiAPI(_Orders, _Portfolio, _Funds, _Account, _MarketData, _Instruments):
|
|
|
222
223
|
Args:
|
|
223
224
|
key_id: The key id from your Sahi account.
|
|
224
225
|
secret: The secret shown once at key creation.
|
|
226
|
+
totp: The current six-digit code for a TOTP key; its first correct
|
|
227
|
+
code of the day approves the key, so no dashboard click is
|
|
228
|
+
needed. Omit it for a REGULAR key.
|
|
225
229
|
scopes: Optional subset of the key's scopes to narrow the token to.
|
|
226
230
|
scrip_master_url: Scrip-master CSV URL, as on the constructor.
|
|
227
231
|
timeout: Default per-request timeout in seconds, also used for the mint.
|
|
228
232
|
"""
|
|
229
|
-
token = auth.get_access_token(key_id, secret, scopes=scopes, timeout=timeout)
|
|
233
|
+
token = auth.get_access_token(key_id, secret, totp=totp, scopes=scopes, timeout=timeout)
|
|
230
234
|
return cls(token, scrip_master_url=scrip_master_url, timeout=timeout)
|
|
231
235
|
|
|
232
236
|
def set_access_token(self, token: Token) -> None:
|
|
@@ -38,6 +38,7 @@ __all__ = [
|
|
|
38
38
|
"SahiFeedForbiddenException",
|
|
39
39
|
"SahiFeedNotSubscribedException",
|
|
40
40
|
"SahiInvalidOrderStateException",
|
|
41
|
+
"SahiInvalidTotpException",
|
|
41
42
|
"SahiIpNotAllowedException",
|
|
42
43
|
"SahiKeyNotApprovedException",
|
|
43
44
|
"SahiMaintenanceException",
|
|
@@ -156,6 +157,46 @@ class SahiAccountLockedException(SahiAuthenticationException):
|
|
|
156
157
|
hint = "The account is locked or frozen. Contact Sahi support to restore access."
|
|
157
158
|
|
|
158
159
|
|
|
160
|
+
class SahiInvalidTotpException(SahiAuthenticationException):
|
|
161
|
+
"""401 ``InvalidTotp``: the key id and secret were right but the TOTP code was not.
|
|
162
|
+
|
|
163
|
+
Raised only for a TOTP key, and only after the secret verified, so the
|
|
164
|
+
credentials themselves are fine. The code was wrong or came from a clock
|
|
165
|
+
more than one step (30 s) away from the server's. Ten wrong codes in a
|
|
166
|
+
minute turn into :class:`SahiRateLimitException`.
|
|
167
|
+
|
|
168
|
+
Attributes:
|
|
169
|
+
server_time: The server's clock at the refusal, as the RFC 3339 string
|
|
170
|
+
it sent, or ``None`` if the envelope carried none. Compare it with
|
|
171
|
+
this host's clock: drift is the usual cause of a code that looks
|
|
172
|
+
right in the authenticator app yet fails here.
|
|
173
|
+
"""
|
|
174
|
+
|
|
175
|
+
hint = (
|
|
176
|
+
"Mint again with a fresh code from the key's authenticator. If it fails again, "
|
|
177
|
+
"compare this machine's clock with the server_time on the exception."
|
|
178
|
+
)
|
|
179
|
+
|
|
180
|
+
def __init__(
|
|
181
|
+
self,
|
|
182
|
+
message: str | None = None,
|
|
183
|
+
*,
|
|
184
|
+
status: int | None = None,
|
|
185
|
+
code: str | None = None,
|
|
186
|
+
request_id: str | None = None,
|
|
187
|
+
server_time: str | None = None,
|
|
188
|
+
) -> None:
|
|
189
|
+
# Set before super().__init__: _render() runs there and reads it.
|
|
190
|
+
self.server_time = server_time
|
|
191
|
+
super().__init__(message, status=status, code=code, request_id=request_id)
|
|
192
|
+
|
|
193
|
+
def _render(self) -> str:
|
|
194
|
+
text = super()._render()
|
|
195
|
+
if self.server_time:
|
|
196
|
+
text = f"{text} [server_time={self.server_time}]"
|
|
197
|
+
return text
|
|
198
|
+
|
|
199
|
+
|
|
159
200
|
class SahiAuthorisationException(SahiAPIException):
|
|
160
201
|
"""403: the token is valid but not permitted to do this.
|
|
161
202
|
|
|
@@ -174,9 +215,19 @@ class SahiKeyNotApprovedException(SahiAuthorisationException):
|
|
|
174
215
|
|
|
175
216
|
Approval is a daily act and lapses at 06:00 IST; it is an exchange
|
|
176
217
|
requirement, not something the SDK can bypass.
|
|
218
|
+
|
|
219
|
+
The auth host also answers 403 when API keys are not enabled for the
|
|
220
|
+
account. That check runs after the secret is verified but before the
|
|
221
|
+
TOTP code is read. So a TOTP key, whose code is its approval, lands here
|
|
222
|
+
only for that reason, and the server message says so; nothing done to the
|
|
223
|
+
key can fix it.
|
|
177
224
|
"""
|
|
178
225
|
|
|
179
|
-
hint =
|
|
226
|
+
hint = (
|
|
227
|
+
"Approve the key for today's trading day in your Sahi account, then mint again. "
|
|
228
|
+
"If the message says API keys are not available for this account, the account "
|
|
229
|
+
"is not enabled yet; contact Sahi."
|
|
230
|
+
)
|
|
180
231
|
|
|
181
232
|
|
|
182
233
|
class SahiIpNotAllowedException(SahiAuthorisationException):
|
|
@@ -361,6 +412,7 @@ def exception_for(
|
|
|
361
412
|
request_id: str | None = None,
|
|
362
413
|
retry_after: int | None = None,
|
|
363
414
|
envelope: bool = True,
|
|
415
|
+
server_time: str | None = None,
|
|
364
416
|
) -> SahiAPIException:
|
|
365
417
|
"""Build the right :class:`SahiAPIException` for a non-2xx response.
|
|
366
418
|
|
|
@@ -386,6 +438,8 @@ def exception_for(
|
|
|
386
438
|
would hand the user a scope or key hint for a problem that is the
|
|
387
439
|
request's size, so it comes back as the base class with a message
|
|
388
440
|
that says where it was refused.
|
|
441
|
+
server_time: The envelope's ``server_time``, which only a wrong TOTP
|
|
442
|
+
code carries; ignored for every other class.
|
|
389
443
|
|
|
390
444
|
Returns:
|
|
391
445
|
An unraised exception instance for the caller to raise.
|
|
@@ -416,6 +470,10 @@ def exception_for(
|
|
|
416
470
|
return build(SahiAccountLockedException)
|
|
417
471
|
if key == "UNDERMAINTENANCE":
|
|
418
472
|
return build(SahiMaintenanceException)
|
|
473
|
+
if key == "INVALIDTOTP":
|
|
474
|
+
return SahiInvalidTotpException(
|
|
475
|
+
message, status=status, code=code, request_id=request_id, server_time=server_time
|
|
476
|
+
)
|
|
419
477
|
|
|
420
478
|
if status == 400:
|
|
421
479
|
return build(SahiBadRequestException)
|
|
@@ -275,6 +275,7 @@ class HttpClient:
|
|
|
275
275
|
code: str | None = None
|
|
276
276
|
message: str | None = None
|
|
277
277
|
envelope = True
|
|
278
|
+
server_time: str | None = None
|
|
278
279
|
try:
|
|
279
280
|
body = response.json()
|
|
280
281
|
if isinstance(body, dict):
|
|
@@ -282,6 +283,10 @@ class HttpClient:
|
|
|
282
283
|
# A gateway may send a numeric code; the mapper needs a string.
|
|
283
284
|
code = raw_code if isinstance(raw_code, str) else None
|
|
284
285
|
message = body.get("message")
|
|
286
|
+
# Only a wrong TOTP code carries it; kept verbatim so a caller
|
|
287
|
+
# can compare clocks without the SDK parsing a timestamp.
|
|
288
|
+
raw_time = body.get("server_time")
|
|
289
|
+
server_time = raw_time if isinstance(raw_time, str) else None
|
|
285
290
|
except ValueError:
|
|
286
291
|
# HTML or plain text: the edge answered, not the API.
|
|
287
292
|
envelope = False
|
|
@@ -297,6 +302,7 @@ class HttpClient:
|
|
|
297
302
|
request_id=server_request_id,
|
|
298
303
|
retry_after=retry_after,
|
|
299
304
|
envelope=envelope,
|
|
305
|
+
server_time=server_time,
|
|
300
306
|
)
|
|
301
307
|
|
|
302
308
|
@staticmethod
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|