sahiapi 0.1.0__py3-none-any.whl

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.
@@ -0,0 +1,355 @@
1
+ """One WebSocket connection, with reconnect and close-code handling.
2
+
3
+ Both Sahi streams share the same connection rules: the access token goes on the
4
+ upgrade as a Bearer header, the server pings every 30 seconds and expects a pong
5
+ within 15, and some close codes are fatal while others just mean reconnect. This
6
+ class implements those rules once; :class:`~sahiapi.sahi.feed.SahiFeed` owns one
7
+ instance per stream.
8
+
9
+ The connection runs a receive loop on a daemon thread and hands each frame to an
10
+ ``on_message`` callback. On a transient drop it reconnects with exponential
11
+ backoff and calls ``on_open`` again so the owner can re-send its subscriptions
12
+ (stream state does not survive a reconnect). That covers a missed heartbeat
13
+ (4408) and a slow-consumer drop (4409) as well as network drops — the API
14
+ reference asks clients to reconnect and re-sync on both. A fatal close — an
15
+ expired token (4401), a forbidden scope (4403) or too many connections (4429) —
16
+ stops the loop and records the error for the owner to surface. A handshake the
17
+ server refuses with HTTP 401 or 403 is fatal too: retrying an already-expired
18
+ token would loop forever.
19
+
20
+ The loop is deliberately hard to kill by accident. A frame the owner cannot
21
+ handle is dropped and logged rather than ending the stream, and a failure in
22
+ ``on_open`` is treated as a transient drop and reconnected.
23
+
24
+ The transport is injectable so the reconnect and close-code logic can be tested
25
+ without a real socket; by default it is ``websocket-client``. Frames are read
26
+ with ``recv_data`` so a close frame arrives as itself, with the server's status
27
+ code in its payload: the plain ``recv`` turns a close into an empty string and
28
+ keeps the code nowhere, so a fatal 4401 or 4429 would look like a clean drop.
29
+ """
30
+
31
+ from __future__ import annotations
32
+
33
+ import logging
34
+ import random
35
+ import struct
36
+ import threading
37
+ from typing import Any, Callable, Protocol
38
+
39
+ from . import constants, exceptions
40
+
41
+ __all__ = ["Transport", "WebSocketConnection"]
42
+
43
+ logger = logging.getLogger(__name__)
44
+
45
+
46
+ class Transport(Protocol):
47
+ """The minimal socket surface this class needs.
48
+
49
+ ``websocket.WebSocket`` from ``websocket-client`` satisfies it; tests provide
50
+ a fake.
51
+ """
52
+
53
+ def recv_data(self) -> tuple[int, Any]: ...
54
+ def send_binary(self, data: bytes) -> Any: ...
55
+ def send(self, data: str) -> Any: ...
56
+ def close(self) -> Any: ...
57
+
58
+
59
+ ConnectFn = Callable[[str, list[str]], Transport]
60
+
61
+ # RFC 6455 opcodes, as websocket-client's ``recv_data`` reports them.
62
+ _OPCODE_TEXT = 0x1
63
+ _OPCODE_BINARY = 0x2
64
+ _OPCODE_CLOSE = 0x8
65
+
66
+ _BACKOFF_START = 1.0
67
+ _BACKOFF_CAP = 30.0
68
+ # Each backoff sleep is scaled by a factor drawn from this range.
69
+ _BACKOFF_JITTER = (0.5, 1.5)
70
+
71
+
72
+ # Twice the server's 30 s ping interval: a healthy socket delivers bytes at
73
+ # least that often, so a read that waits longer is a half-open link.
74
+ _READ_TIMEOUT_SECONDS = 60.0
75
+
76
+
77
+ def _default_connect(url: str, header: list[str]) -> Transport:
78
+ import websocket
79
+
80
+ # Without a timeout a link that died silently (a NAT drop, a Wi-Fi switch)
81
+ # blocks recv forever and the stream goes quiet with nothing to notice. The
82
+ # timeout turns that silence into an exception, and the loop reconnects.
83
+ return websocket.create_connection(
84
+ url, header=header, enable_multithread=True, timeout=_READ_TIMEOUT_SECONDS
85
+ )
86
+
87
+
88
+ def _close_code_of(payload: Any) -> int | None:
89
+ """The status code a close frame carries (RFC 6455 §5.5.1), or None if it has none."""
90
+ if isinstance(payload, (bytes, bytearray)) and len(payload) >= 2:
91
+ return int(struct.unpack("!H", bytes(payload[:2]))[0])
92
+ return None
93
+
94
+
95
+ def _fatal_error_for(close_code: int | None) -> exceptions.SahiFeedException | None:
96
+ if close_code == constants.WS_CLOSE_AUTH_EXPIRED:
97
+ return exceptions.SahiFeedAuthExpiredException(
98
+ "the server closed the stream because the token expired or was revoked (4401). "
99
+ "Mint a new token, build a new SahiFeed and re-subscribe.",
100
+ close_code=close_code,
101
+ )
102
+ if close_code == constants.WS_CLOSE_FORBIDDEN:
103
+ return exceptions.SahiFeedForbiddenException(
104
+ "the token lacks the scope this stream needs (4403): orders.read for order "
105
+ "updates, marketdata.read for ticks. Mint a token that carries it.",
106
+ close_code=close_code,
107
+ )
108
+ if close_code == constants.WS_CLOSE_TOO_MANY:
109
+ return exceptions.SahiFeedConnectionException(
110
+ "too many concurrent connections (4429): each stream allows 5 at once, per token "
111
+ "for the tick stream and per account for order updates. Close an existing "
112
+ "connection before opening another.",
113
+ close_code=close_code,
114
+ )
115
+ # 4408 (heartbeat) and 4409 (slow consumer) fall through: reconnect and re-sync.
116
+ return None
117
+
118
+
119
+ def _fatal_error_for_handshake(exc: BaseException) -> exceptions.SahiFeedException | None:
120
+ """Map an upgrade refused by the server to a fatal error, or None to retry.
121
+
122
+ Before the socket exists the server can only refuse with an HTTP status, so
123
+ an expired token shows up here as 401, not as a 4401 close. websocket-client
124
+ exposes the status as ``status_code`` on its handshake exception.
125
+ """
126
+ status = getattr(exc, "status_code", None)
127
+ if status == 401:
128
+ return exceptions.SahiFeedAuthExpiredException(
129
+ "the server rejected the token at the WebSocket handshake (401): it has expired "
130
+ "or been revoked. Mint a new token, build a new SahiFeed and re-subscribe."
131
+ )
132
+ if status == 403:
133
+ return exceptions.SahiFeedForbiddenException(
134
+ "the server refused the WebSocket handshake (403): the token lacks the scope "
135
+ "this stream needs. Mint a token that carries it."
136
+ )
137
+ return None
138
+
139
+
140
+ class WebSocketConnection:
141
+ """A self-reconnecting WebSocket connection for one stream.
142
+
143
+ Args:
144
+ url: The ``wss://`` URL to connect to.
145
+ access_token: Bearer token for the upgrade request.
146
+ binary: True for the protobuf tick stream, False for the JSON order
147
+ stream. Decides whether outgoing sends and the frames handed to
148
+ ``on_message`` are bytes or text.
149
+ on_message: Called with each received frame (bytes if ``binary`` else str),
150
+ on the connection's own thread.
151
+ on_open: Called after every successful (re)connect, before reading, so the
152
+ owner can re-send subscriptions. Optional.
153
+ on_fatal: Called once, on the connection's thread, with the error that
154
+ ended the loop for good. Must not call :meth:`stop` — that would join
155
+ the calling thread. Optional.
156
+ name: A short label used for the daemon thread name.
157
+ connect_fn: Transport factory, for testing. Defaults to websocket-client.
158
+ """
159
+
160
+ def __init__(
161
+ self,
162
+ url: str,
163
+ *,
164
+ access_token: str,
165
+ binary: bool,
166
+ on_message: Callable[[Any], None],
167
+ on_open: Callable[[], None] | None = None,
168
+ on_fatal: Callable[[exceptions.SahiFeedException], None] | None = None,
169
+ name: str = "sahi-ws",
170
+ connect_fn: ConnectFn | None = None,
171
+ ) -> None:
172
+ self._url = url
173
+ self._header = [f"Authorization: Bearer {access_token}"]
174
+ self._binary = binary
175
+ self._on_message = on_message
176
+ self._on_open = on_open
177
+ self._on_fatal = on_fatal
178
+ self._name = name
179
+ self._connect = connect_fn or _default_connect
180
+
181
+ self._thread: threading.Thread | None = None
182
+ self._stopped = threading.Event()
183
+ self._send_lock = threading.Lock()
184
+ self._transport: Transport | None = None
185
+ self._epoch = 0 # bumped each time a new transport is installed
186
+ self._fatal_error: exceptions.SahiFeedException | None = None
187
+
188
+ # -- lifecycle ------------------------------------------------------------
189
+
190
+ def start(self) -> None:
191
+ """Start the background connect/receive loop, if not already running."""
192
+ if self._thread is not None and self._thread.is_alive():
193
+ return
194
+ self._stopped.clear()
195
+ self._fatal_error = None
196
+ self._thread = threading.Thread(target=self._run, name=self._name, daemon=True)
197
+ self._thread.start()
198
+
199
+ def stop(self) -> None:
200
+ """Stop the loop and close the socket."""
201
+ self._stopped.set()
202
+ with self._send_lock:
203
+ transport, self._transport = self._transport, None
204
+ if transport is not None:
205
+ self._close_quietly(transport)
206
+ if self._thread is not None:
207
+ self._thread.join(timeout=5.0)
208
+ self._thread = None
209
+
210
+ @property
211
+ def fatal_error(self) -> exceptions.SahiFeedException | None:
212
+ """The fatal close error that stopped the loop, if any."""
213
+ return self._fatal_error
214
+
215
+ def is_running(self) -> bool:
216
+ """Whether the background loop is alive."""
217
+ return self._thread is not None and self._thread.is_alive()
218
+
219
+ @property
220
+ def epoch(self) -> int | None:
221
+ """A number that changes on every (re)connect, or None while not connected.
222
+
223
+ A sender that must not repeat a frame across a reconnect records the
224
+ epoch it sent under and passes it back to :meth:`send`; the reconnect
225
+ replay carries anything that missed the old socket.
226
+ """
227
+ with self._send_lock:
228
+ return self._epoch if self._transport is not None else None
229
+
230
+ # -- sending --------------------------------------------------------------
231
+
232
+ def send(self, data: bytes | str, *, epoch: int | None = None) -> None:
233
+ """Send a frame on the current connection.
234
+
235
+ Args:
236
+ data: The frame.
237
+ epoch: If given, the :attr:`epoch` the frame was prepared under. A
238
+ frame prepared for an older connection is refused rather than
239
+ sent on the new one, which the replay has already brought up.
240
+
241
+ Raises:
242
+ SahiFeedConnectionException: If the connection is not currently open,
243
+ or ``epoch`` no longer matches it.
244
+ """
245
+ with self._send_lock:
246
+ transport = self._transport
247
+ if transport is None:
248
+ raise exceptions.SahiFeedConnectionException(
249
+ "the stream is not connected yet; subscriptions are queued and sent "
250
+ "automatically once it is"
251
+ )
252
+ if epoch is not None and epoch != self._epoch:
253
+ raise exceptions.SahiFeedConnectionException(
254
+ "the stream reconnected since this frame was prepared; the reconnect "
255
+ "replay has already re-sent the subscription table"
256
+ )
257
+ if self._binary:
258
+ transport.send_binary(data if isinstance(data, bytes) else data.encode())
259
+ else:
260
+ transport.send(data if isinstance(data, str) else data.decode())
261
+
262
+ # -- loop -----------------------------------------------------------------
263
+
264
+ def _run(self) -> None:
265
+ backoff = _BACKOFF_START
266
+ while not self._stopped.is_set():
267
+ try:
268
+ transport = self._connect(self._url, self._header)
269
+ except Exception as e:
270
+ fatal = _fatal_error_for_handshake(e)
271
+ if fatal is not None:
272
+ self._finish_fatal(fatal)
273
+ return
274
+ if self._sleep_or_stop(backoff):
275
+ return
276
+ backoff = min(_BACKOFF_CAP, backoff * 2)
277
+ continue
278
+
279
+ with self._send_lock:
280
+ self._transport = transport
281
+ self._epoch += 1
282
+ backoff = _BACKOFF_START
283
+ if self._on_open is not None:
284
+ try:
285
+ self._on_open()
286
+ except Exception:
287
+ # A failed replay (e.g. the socket dropped between connect and
288
+ # send) is a transient drop, not a reason to die silently.
289
+ logger.warning("on_open failed; reconnecting", exc_info=True)
290
+ with self._send_lock:
291
+ self._transport = None
292
+ self._close_quietly(transport)
293
+ if self._sleep_or_stop(backoff):
294
+ return
295
+ backoff = min(_BACKOFF_CAP, backoff * 2)
296
+ continue
297
+
298
+ fatal = self._receive_until_closed(transport)
299
+ with self._send_lock:
300
+ self._transport = None
301
+ if fatal is not None:
302
+ self._finish_fatal(fatal)
303
+ return
304
+ if self._stopped.is_set():
305
+ return
306
+ if self._sleep_or_stop(backoff):
307
+ return
308
+ backoff = min(_BACKOFF_CAP, backoff * 2)
309
+
310
+ def _receive_until_closed(self, transport: Transport) -> exceptions.SahiFeedException | None:
311
+ """Read frames until the socket closes. Return a fatal error, or None to retry."""
312
+ while not self._stopped.is_set():
313
+ try:
314
+ opcode, payload = transport.recv_data()
315
+ except Exception:
316
+ # A transport error (timeout, reset, our own stop) carries no
317
+ # close code: the server never said why, so reconnect.
318
+ return None
319
+ if opcode == _OPCODE_CLOSE:
320
+ # websocket-client has already answered the close; the reason is
321
+ # the status code at the front of the payload.
322
+ return _fatal_error_for(_close_code_of(payload))
323
+ if opcode == _OPCODE_TEXT:
324
+ frame: Any = payload.decode("utf-8") if isinstance(payload, bytes) else payload
325
+ elif opcode == _OPCODE_BINARY:
326
+ frame = payload
327
+ else:
328
+ continue # ping/pong are answered inside recv_data; nothing to deliver
329
+ try:
330
+ self._on_message(frame)
331
+ except Exception:
332
+ # One undecodable or unhandled frame must not end the stream.
333
+ logger.warning("dropping frame the handler could not process", exc_info=True)
334
+ return None
335
+
336
+ def _finish_fatal(self, fatal: exceptions.SahiFeedException) -> None:
337
+ self._fatal_error = fatal
338
+ if self._on_fatal is not None:
339
+ self._on_fatal(fatal)
340
+
341
+ @staticmethod
342
+ def _close_quietly(transport: Transport) -> None:
343
+ try:
344
+ transport.close()
345
+ except Exception:
346
+ pass
347
+
348
+ def _sleep_or_stop(self, seconds: float) -> bool:
349
+ """Wait about ``seconds``, returning True if a stop was requested meanwhile.
350
+
351
+ The wait is jittered by up to half either way. Clients dropped together
352
+ (a deploy closes every socket at once) would otherwise retry at the same
353
+ 1, 2, 4 s marks, and each upgrade counts against the stream rate limit.
354
+ """
355
+ return self._stopped.wait(timeout=seconds * random.uniform(*_BACKOFF_JITTER))
@@ -0,0 +1,97 @@
1
+ Metadata-Version: 2.5
2
+ Name: sahiapi
3
+ Version: 0.1.0
4
+ Summary: The official Python client library for the Sahi APIs.
5
+ Project-URL: Homepage, https://docs.sahi.com/
6
+ Project-URL: Documentation, https://docs.sahi.com/
7
+ Project-URL: Repository, https://github.com/aarityatech/sahi-python
8
+ Author: Aaritya Technologies
9
+ License: Copyright (c) 2026 Aaritya Technologies Private Limited. All rights reserved.
10
+
11
+ This software and its source code are proprietary and confidential. Until an
12
+ open-source license is applied by the copyright holder, no permission is granted
13
+ to copy, use, modify, or distribute this software outside Aaritya Technologies.
14
+
15
+ An open-source license will replace this file when the project is published.
16
+ License-File: LICENSE
17
+ Keywords: bse,market-data,nse,options,sahi,stocks,trading
18
+ Classifier: Development Status :: 3 - Alpha
19
+ Classifier: Intended Audience :: Developers
20
+ Classifier: Intended Audience :: Financial and Insurance Industry
21
+ Classifier: Operating System :: OS Independent
22
+ Classifier: Programming Language :: Python :: 3
23
+ Classifier: Programming Language :: Python :: 3.9
24
+ Classifier: Programming Language :: Python :: 3.10
25
+ Classifier: Programming Language :: Python :: 3.11
26
+ Classifier: Programming Language :: Python :: 3.12
27
+ Classifier: Programming Language :: Python :: 3.13
28
+ Classifier: Typing :: Typed
29
+ Requires-Python: >=3.9
30
+ Requires-Dist: httpx>=0.27
31
+ Requires-Dist: protobuf>=4.25
32
+ Requires-Dist: websocket-client>=1.7
33
+ Provides-Extra: dev
34
+ Requires-Dist: mypy>=1.11; extra == 'dev'
35
+ Requires-Dist: pytest-cov>=5.0; extra == 'dev'
36
+ Requires-Dist: pytest>=8.0; extra == 'dev'
37
+ Requires-Dist: respx>=0.21; extra == 'dev'
38
+ Requires-Dist: ruff>=0.6; extra == 'dev'
39
+ Description-Content-Type: text/markdown
40
+
41
+ # sahiapi
42
+
43
+ The official Python client library for the [Sahi APIs](https://docs.sahi.com/):
44
+ orders, portfolio, funds, market data and live streams.
45
+
46
+ > Early development. The public interface is not yet stable.
47
+
48
+ ## Install
49
+
50
+ ```bash
51
+ pip install sahiapi
52
+ ```
53
+
54
+ Requires Python 3.9 or newer.
55
+
56
+ ## Quickstart
57
+
58
+ Create an API key in your Sahi account and approve it for the trading day, then:
59
+
60
+ ```python
61
+ import os
62
+ from sahiapi import SahiAPI
63
+
64
+ api = SahiAPI.from_credentials(
65
+ key_id=os.environ["SAHI_KEY_ID"],
66
+ secret=os.environ["SAHI_SECRET"],
67
+ )
68
+ print(api.get_profile())
69
+ print(api.get_funds())
70
+ ```
71
+
72
+ Tokens expire at 06:00 IST, so mint a fresh one each trading day.
73
+
74
+ ## Documentation
75
+
76
+ Guides, the full API reference and examples are at **https://docs.sahi.com/**.
77
+
78
+ The SDK returns the API's JSON exactly as documented there: money is integer
79
+ paise, timestamps are epoch milliseconds, and field names are unchanged.
80
+
81
+ ## Development
82
+
83
+ ```bash
84
+ uv sync --extra dev
85
+ uv run pytest
86
+ uv run ruff check .
87
+ uv run mypy
88
+ ```
89
+
90
+ The tick-stream protobuf bindings are generated and committed; regenerate them
91
+ with `tools/regen_proto.sh` after editing the `.proto`. Releases are cut with
92
+ `tools/release.sh`; see `.github/workflows/release.yml`.
93
+
94
+ ## License
95
+
96
+ Proprietary during development; an open-source license will be applied before
97
+ public release. See [LICENSE](LICENSE).
@@ -0,0 +1,26 @@
1
+ sahiapi/__init__.py,sha256=AhwVUCc2pDjODD1HoEKtmTHq8X8FWKQenbOQmOJb6A0,2713
2
+ sahiapi/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
3
+ sahiapi/sahi/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
4
+ sahiapi/sahi/account.py,sha256=U1P6Q42BUV0e6FhEsCDV3zzRHztEZ6f_J8CTYVXjVSs,781
5
+ sahiapi/sahi/auth.py,sha256=fugQpiaSyvoDuyOaM5Gyeq8rVLgAEuc4_SdVUpN3frY,6191
6
+ sahiapi/sahi/base.py,sha256=Gr7JFPuu31jhCPzYe50TMjaDS0OME9Pfw7e7p1TlDYc,1085
7
+ sahiapi/sahi/client.py,sha256=bRO3m5BbRiMITm--YvAXezwsKSLk6LH0OkAOidX6dN0,12458
8
+ sahiapi/sahi/constants.py,sha256=ZeVu3FljUDBDtWd8Ngr0HHxzu-B7lyuAsfVebRvPD80,5170
9
+ sahiapi/sahi/env.py,sha256=ApBmTYa2c6RLl7P9AsE0JlPJuPdKH_tf-NrtTfbR2g8,1361
10
+ sahiapi/sahi/exceptions.py,sha256=0lo3vlCBSlxmnY9XTuz2TtfqUVeA0nQkZ-kawOywjnY,14924
11
+ sahiapi/sahi/feed.py,sha256=K0hW7gIxIKrcXIicm1MEZRbjdmHvcKWsg-clcUHGIwQ,31370
12
+ sahiapi/sahi/funds.py,sha256=4BNnZh024D-gZu5Nd2mX-OYPuKGrrzvImdekHEZvxuE,5599
13
+ sahiapi/sahi/http.py,sha256=AHDEnZvrIQu8Zk4Z2Clp-eKxChy-srGCkEtwLdR74ts,11329
14
+ sahiapi/sahi/instruments.py,sha256=RtgDm0i3xCjNj0IeEXoJuNop8gObNudHZHG-at8jY60,14169
15
+ sahiapi/sahi/marketdata.py,sha256=Cjw05P87SqKhEFZiUcCQ1QygcPRdK8PijO23awBNfrA,10208
16
+ sahiapi/sahi/orders.py,sha256=9qWqzjBXAMlm3i89HXm5u3ufWoIHfmh47H6Hs2NYcUg,8595
17
+ sahiapi/sahi/portfolio.py,sha256=AKYL6NevAsyra_KYAIDbXms5S4yaakdP2Peplvfuh3g,1905
18
+ sahiapi/sahi/ws_client.py,sha256=fKhcutQPIpu-w-qbqik25q-aej4Z4BL-QcarZi1koa0,15054
19
+ sahiapi/sahi/proto/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
20
+ sahiapi/sahi/proto/codec.py,sha256=pRuysMnlb4d2554uJ9xeXf30CC27DZLDstt8HOTvSXk,4622
21
+ sahiapi/sahi/proto/marketdata.proto,sha256=yVUMl26SKt-nvi8wGfFlU33L0cBceNlD3JkBC3fu7rE,3171
22
+ sahiapi/sahi/proto/marketdata_pb2.py,sha256=XDokj9dyu0ZGC-SeYv7E7f8LSxrePeDNbnU8hLbSoZc,6963
23
+ sahiapi-0.1.0.dist-info/METADATA,sha256=21QMm1vKdTQZ8Rislfejfva5SmnB4ILCxxXhyuX8SYI,3116
24
+ sahiapi-0.1.0.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
25
+ sahiapi-0.1.0.dist-info/licenses/LICENSE,sha256=0IoMnDb-h6e1mLOCDhfEmXzVZTmiD2CQN7ZdP7ctfXE,395
26
+ sahiapi-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.32.4
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,7 @@
1
+ Copyright (c) 2026 Aaritya Technologies Private Limited. All rights reserved.
2
+
3
+ This software and its source code are proprietary and confidential. Until an
4
+ open-source license is applied by the copyright holder, no permission is granted
5
+ to copy, use, modify, or distribute this software outside Aaritya Technologies.
6
+
7
+ An open-source license will replace this file when the project is published.