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.
Files changed (28) hide show
  1. {sahiapi-0.2.0 → sahiapi-0.3.0}/CHANGELOG.md +14 -1
  2. {sahiapi-0.2.0 → sahiapi-0.3.0}/PKG-INFO +4 -1
  3. {sahiapi-0.2.0 → sahiapi-0.3.0}/README.md +3 -0
  4. {sahiapi-0.2.0 → sahiapi-0.3.0}/src/sahiapi/__init__.py +2 -0
  5. {sahiapi-0.2.0 → sahiapi-0.3.0}/src/sahiapi/sahi/auth.py +30 -13
  6. {sahiapi-0.2.0 → sahiapi-0.3.0}/src/sahiapi/sahi/client.py +5 -1
  7. {sahiapi-0.2.0 → sahiapi-0.3.0}/src/sahiapi/sahi/exceptions.py +59 -1
  8. {sahiapi-0.2.0 → sahiapi-0.3.0}/src/sahiapi/sahi/http.py +6 -0
  9. {sahiapi-0.2.0 → sahiapi-0.3.0}/.gitignore +0 -0
  10. {sahiapi-0.2.0 → sahiapi-0.3.0}/LICENSE +0 -0
  11. {sahiapi-0.2.0 → sahiapi-0.3.0}/pyproject.toml +0 -0
  12. {sahiapi-0.2.0 → sahiapi-0.3.0}/src/sahiapi/py.typed +0 -0
  13. {sahiapi-0.2.0 → sahiapi-0.3.0}/src/sahiapi/sahi/__init__.py +0 -0
  14. {sahiapi-0.2.0 → sahiapi-0.3.0}/src/sahiapi/sahi/account.py +0 -0
  15. {sahiapi-0.2.0 → sahiapi-0.3.0}/src/sahiapi/sahi/base.py +0 -0
  16. {sahiapi-0.2.0 → sahiapi-0.3.0}/src/sahiapi/sahi/constants.py +0 -0
  17. {sahiapi-0.2.0 → sahiapi-0.3.0}/src/sahiapi/sahi/env.py +0 -0
  18. {sahiapi-0.2.0 → sahiapi-0.3.0}/src/sahiapi/sahi/feed.py +0 -0
  19. {sahiapi-0.2.0 → sahiapi-0.3.0}/src/sahiapi/sahi/funds.py +0 -0
  20. {sahiapi-0.2.0 → sahiapi-0.3.0}/src/sahiapi/sahi/instruments.py +0 -0
  21. {sahiapi-0.2.0 → sahiapi-0.3.0}/src/sahiapi/sahi/marketdata.py +0 -0
  22. {sahiapi-0.2.0 → sahiapi-0.3.0}/src/sahiapi/sahi/orders.py +0 -0
  23. {sahiapi-0.2.0 → sahiapi-0.3.0}/src/sahiapi/sahi/portfolio.py +0 -0
  24. {sahiapi-0.2.0 → sahiapi-0.3.0}/src/sahiapi/sahi/proto/__init__.py +0 -0
  25. {sahiapi-0.2.0 → sahiapi-0.3.0}/src/sahiapi/sahi/proto/codec.py +0 -0
  26. {sahiapi-0.2.0 → sahiapi-0.3.0}/src/sahiapi/sahi/proto/marketdata.proto +0 -0
  27. {sahiapi-0.2.0 → sahiapi-0.3.0}/src/sahiapi/sahi/proto/marketdata_pb2.py +0 -0
  28. {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.2.0...HEAD
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.2.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. You create the key in your Sahi
4
- account, approve it for the trading day, and exchange the key id and secret for a
5
- token with :func:`get_access_token`. The token is a Bearer JWT that every REST
6
- and WebSocket call carries, and it expires at 06:00 IST — there is no refresh, so
7
- mint a fresh one each trading day.
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 secret are sent as given: their format is the auth server's
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 — deliberately indistinguishable).
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: The key is not approved for the current
71
- trading day.
72
- SahiBadRequestException: A field is malformed, or ``scopes`` is not a
73
- subset of the key's scopes.
74
- SahiRateLimitException: Too many mint attempts; wait for the next minute.
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 = "Approve the key for today's trading day in your Sahi account, then mint again."
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