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.
sahiapi/__init__.py ADDED
@@ -0,0 +1,88 @@
1
+ """sahiapi — the official Python client for the Sahi APIs.
2
+
3
+ The public interface lives here. Everything under :mod:`sahiapi.sahi` is
4
+ internal and may change without notice.
5
+
6
+ from sahiapi import SahiAPI
7
+
8
+ api = SahiAPI.from_credentials(key_id="...", secret="...")
9
+ print(api.get_profile())
10
+
11
+ The SDK mirrors the API reference at https://docs.sahi.com/ exactly: it does
12
+ not rename fields, build models, or convert units. Money is integer paise,
13
+ quantities are integer units, timestamps are epoch milliseconds, and identifiers
14
+ are opaque strings.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ from .sahi import constants
20
+ from .sahi.auth import get_access_token, token_expires_at, token_scopes
21
+ from .sahi.client import SahiAPI
22
+ from .sahi.constants import * # noqa: F403 -- re-export every wire constant
23
+ from .sahi.exceptions import (
24
+ SahiAccountLockedException,
25
+ SahiAPIException,
26
+ SahiAuthenticationException,
27
+ SahiAuthorisationException,
28
+ SahiBadRequestException,
29
+ SahiBaseException,
30
+ SahiConfigurationException,
31
+ SahiConnectionException,
32
+ SahiFeedAuthExpiredException,
33
+ SahiFeedConnectionException,
34
+ SahiFeedException,
35
+ SahiFeedForbiddenException,
36
+ SahiFeedNotSubscribedException,
37
+ SahiInvalidOrderStateException,
38
+ SahiIpNotAllowedException,
39
+ SahiKeyNotApprovedException,
40
+ SahiNotFoundException,
41
+ SahiOrderNotFoundException,
42
+ SahiRateLimitException,
43
+ SahiServerException,
44
+ SahiTimeoutException,
45
+ SahiValidationException,
46
+ )
47
+ from .sahi.feed import SahiFeed
48
+
49
+ try: # pragma: no cover - trivial metadata lookup
50
+ from importlib.metadata import PackageNotFoundError, version
51
+
52
+ __version__ = version("sahiapi")
53
+ except PackageNotFoundError: # pragma: no cover - running from a source tree
54
+ __version__ = "0.0.0+unknown"
55
+
56
+ __all__ = [
57
+ # Client and authentication
58
+ "SahiAPI",
59
+ "SahiAPIException",
60
+ "SahiAccountLockedException",
61
+ "SahiAuthenticationException",
62
+ "SahiAuthorisationException",
63
+ "SahiBadRequestException",
64
+ "SahiBaseException",
65
+ "SahiConfigurationException",
66
+ "SahiConnectionException",
67
+ "SahiFeed",
68
+ "SahiFeedAuthExpiredException",
69
+ "SahiFeedConnectionException",
70
+ "SahiFeedException",
71
+ "SahiFeedForbiddenException",
72
+ "SahiFeedNotSubscribedException",
73
+ "SahiInvalidOrderStateException",
74
+ "SahiIpNotAllowedException",
75
+ "SahiKeyNotApprovedException",
76
+ "SahiNotFoundException",
77
+ "SahiOrderNotFoundException",
78
+ "SahiRateLimitException",
79
+ "SahiServerException",
80
+ "SahiTimeoutException",
81
+ "SahiValidationException",
82
+ "__version__",
83
+ "constants",
84
+ "get_access_token",
85
+ "token_expires_at",
86
+ "token_scopes",
87
+ ]
88
+ __all__ += list(constants.__all__)
sahiapi/py.typed ADDED
File without changes
File without changes
@@ -0,0 +1,28 @@
1
+ """Account endpoint: profile.
2
+
3
+ Mirrors the Account page of the API reference. ``get_profile`` needs the
4
+ ``profile.read`` scope. Read it once at start of day; it does not change during a
5
+ session.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from typing import Any
11
+
12
+ from .base import EndpointGroup
13
+
14
+ __all__ = ["_Account"]
15
+
16
+
17
+ class _Account(EndpointGroup):
18
+ """Account methods mixed into :class:`SahiAPI`."""
19
+
20
+ def get_profile(self, *, timeout: float | None = None) -> dict[str, Any]:
21
+ """Return your identity and account status (``GET /v1/profile``).
22
+
23
+ Returns:
24
+ ``{"user_id": ..., "name": ...}``.
25
+ """
26
+ return self._request( # type: ignore[no-any-return]
27
+ "GET", "/v1/profile", timeout=timeout, retry_on_rate_limit=True
28
+ )
sahiapi/sahi/auth.py ADDED
@@ -0,0 +1,163 @@
1
+ """Authentication: minting an access token and reading what it carries.
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.
8
+
9
+ The helpers here also read a token's own claims without verifying its signature,
10
+ to tell callers when a token expires and what it is allowed to do. They are for
11
+ information only: the SDK never refuses a request on them, and the server is
12
+ always the authority.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import base64
18
+ import binascii
19
+ import json
20
+ from datetime import datetime, timezone
21
+ from typing import Any
22
+
23
+ from . import env
24
+ from .exceptions import HOST_AUTH, SahiConfigurationException
25
+ from .http import HttpClient
26
+
27
+ __all__ = [
28
+ "decode_token_payload",
29
+ "expiry_from_payload",
30
+ "get_access_token",
31
+ "scopes_from_payload",
32
+ "token_expires_at",
33
+ "token_scopes",
34
+ ]
35
+
36
+ _TOKEN_PATH = "/api/v1/apikeys/token"
37
+
38
+
39
+ def get_access_token(
40
+ key_id: str,
41
+ secret: str,
42
+ *,
43
+ scopes: list[str] | None = None,
44
+ timeout: float = 10.0,
45
+ ) -> dict[str, Any]:
46
+ """Exchange an API key for a short-lived access token.
47
+
48
+ Calls ``POST /api/v1/apikeys/token`` on the auth host and returns the token
49
+ response as-is.
50
+
51
+ Args:
52
+ key_id: The key id from your Sahi account.
53
+ secret: The secret shown once when the key was created.
54
+ scopes: Optional subset of the key's scopes to narrow the token to. A
55
+ token can only ever carry scopes the key holds. Omit it, or pass an
56
+ empty list, to get the key's full set.
57
+ timeout: Request timeout in seconds.
58
+
59
+ Returns:
60
+ The token response dict, unchanged: ``access_token``, ``token_type``,
61
+ ``expires_in``, ``expires_at`` and ``scope``.
62
+
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.
65
+
66
+ Raises:
67
+ SahiAuthenticationException: Unknown key id, wrong secret, or a revoked
68
+ key (401 — deliberately indistinguishable).
69
+ 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.
75
+ """
76
+ body: dict[str, Any] = {"key_id": key_id, "secret": secret}
77
+ if scopes is not None:
78
+ body["scopes"] = scopes
79
+
80
+ with HttpClient(env.AUTH_BASE_URL, host=HOST_AUTH, timeout=timeout) as client:
81
+ result = client.request("POST", _TOKEN_PATH, json=body)
82
+ return result # type: ignore[no-any-return] # token endpoint returns a JSON object
83
+
84
+
85
+ def decode_token_payload(access_token: str) -> dict[str, Any]:
86
+ """Decode a JWT's payload claims without verifying the signature.
87
+
88
+ This reads what the token asserts about itself; it does not check that the
89
+ assertions are trustworthy. The server verifies the signature on every call.
90
+
91
+ Raises:
92
+ SahiConfigurationException: If the value is not a decodable JWT.
93
+ """
94
+ parts = access_token.split(".")
95
+ if len(parts) != 3:
96
+ raise SahiConfigurationException("access token is not a well-formed JWT")
97
+ payload_segment = parts[1]
98
+ padding = "=" * (-len(payload_segment) % 4)
99
+ try:
100
+ raw = base64.urlsafe_b64decode(payload_segment + padding)
101
+ payload = json.loads(raw)
102
+ except (binascii.Error, ValueError) as e:
103
+ raise SahiConfigurationException("could not decode the access token payload") from e
104
+ if not isinstance(payload, dict):
105
+ raise SahiConfigurationException("access token payload is not a JSON object")
106
+ return payload
107
+
108
+
109
+ def expiry_from_payload(payload: dict[str, Any]) -> datetime | None:
110
+ """The ``exp`` claim as a timezone-aware UTC datetime, or ``None`` if absent.
111
+
112
+ Raises:
113
+ SahiConfigurationException: If ``exp`` is present but is not a number
114
+ of epoch seconds that fits in a datetime — a token minted by
115
+ something other than Sahi.
116
+ """
117
+ exp = payload.get("exp")
118
+ if exp is None:
119
+ return None
120
+ # bool is an int subclass; a literal true/false is not an epoch time.
121
+ if isinstance(exp, bool) or not isinstance(exp, (int, float)):
122
+ raise SahiConfigurationException(
123
+ f"access token 'exp' claim must be epoch seconds, got {type(exp).__name__}. "
124
+ "Use a token minted by get_access_token()."
125
+ )
126
+ try:
127
+ return datetime.fromtimestamp(exp, tz=timezone.utc)
128
+ except (OverflowError, OSError, ValueError) as e:
129
+ raise SahiConfigurationException(
130
+ "access token 'exp' claim is out of range for an epoch time. "
131
+ "Use a token minted by get_access_token()."
132
+ ) from e
133
+
134
+
135
+ def scopes_from_payload(payload: dict[str, Any]) -> frozenset[str]:
136
+ """The ``scope`` claim split on whitespace; an absent or empty claim is ``frozenset()``."""
137
+ scope = payload.get("scope")
138
+ if not scope or not isinstance(scope, str):
139
+ return frozenset()
140
+ return frozenset(scope.split())
141
+
142
+
143
+ def token_expires_at(access_token: str) -> datetime | None:
144
+ """Return the token's expiry as a timezone-aware UTC datetime.
145
+
146
+ Reads the ``exp`` claim. Returns ``None`` if the token carries no ``exp``.
147
+
148
+ Raises:
149
+ SahiConfigurationException: If the value is not a decodable JWT, or its
150
+ ``exp`` is not an epoch time.
151
+ """
152
+ return expiry_from_payload(decode_token_payload(access_token))
153
+
154
+
155
+ def token_scopes(access_token: str) -> frozenset[str]:
156
+ """Return the scopes the token carries, from its ``scope`` claim.
157
+
158
+ The claim is a space-separated string; an absent claim yields an empty set.
159
+
160
+ Raises:
161
+ SahiConfigurationException: If the value is not a decodable JWT.
162
+ """
163
+ return scopes_from_payload(decode_token_payload(access_token))
sahiapi/sahi/base.py ADDED
@@ -0,0 +1,32 @@
1
+ """Shared base for the per-domain endpoint groups.
2
+
3
+ Each docs page maps to a mixin (``_Orders``, ``_Portfolio``, …) that lists the
4
+ methods for that page. :class:`SahiAPI` inherits all of them, so the user sees
5
+ one flat class while each file stays focused on a single resource. Every mixin
6
+ subclasses :class:`EndpointGroup`, which declares the ``_request`` method the
7
+ mixins call. :class:`SahiAPI` provides the real implementation; this base gives
8
+ the mixins a typed reference to it and keeps them independent of the client
9
+ module, so there is no import cycle.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ from typing import Any
15
+
16
+ __all__ = ["EndpointGroup"]
17
+
18
+
19
+ class EndpointGroup:
20
+ """Base for domain mixins. ``SahiAPI`` supplies the real ``_request``."""
21
+
22
+ def _request(
23
+ self,
24
+ method: str,
25
+ path: str,
26
+ *,
27
+ params: dict[str, Any] | None = None,
28
+ json: Any = None,
29
+ timeout: float | None = None,
30
+ retry_on_rate_limit: bool = False,
31
+ ) -> Any: # pragma: no cover - overridden by SahiAPI
32
+ raise NotImplementedError
sahiapi/sahi/client.py ADDED
@@ -0,0 +1,306 @@
1
+ """The ``SahiAPI`` client: construction, authentication and token lifecycle.
2
+
3
+ ``SahiAPI`` is the single entry point for the REST API. The short path is
4
+ :meth:`SahiAPI.from_credentials`, which mints a token and returns a ready client
5
+ in one call. When a token is minted once and shared — across processes, or a
6
+ token cached for the day — mint it with :func:`sahiapi.get_access_token` and pass
7
+ the whole response to the constructor. The domain methods (orders, portfolio, funds,
8
+ account, market data, instruments) are added by mixins in their own modules; this
9
+ module owns everything common to them: the HTTP client, the token and its
10
+ lifecycle, and the enum constants exposed as class attributes.
11
+
12
+ The client does no network I/O at construction and never refreshes a token on
13
+ its own — tokens expire at 06:00 IST by design. When a token expires, mint a new
14
+ one and pass it to :meth:`set_access_token`.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import os
20
+ from collections.abc import Mapping
21
+ from datetime import datetime
22
+ from types import TracebackType
23
+ from typing import Any, NamedTuple, Union
24
+
25
+ from . import auth, constants, env
26
+ from .account import _Account
27
+ from .exceptions import HOST_API, SahiConfigurationException
28
+ from .funds import _Funds
29
+ from .http import HttpClient
30
+ from .instruments import _Instruments
31
+ from .marketdata import _MarketData
32
+ from .orders import _Orders
33
+ from .portfolio import _Portfolio
34
+
35
+ __all__ = ["SahiAPI"]
36
+
37
+ _ENV_SCRIP_MASTER_URL = "SAHI_SCRIP_MASTER_URL"
38
+
39
+ #: What the client accepts as a token: the response dict from
40
+ #: :func:`get_access_token`, or just its ``access_token`` string.
41
+ Token = Union[str, Mapping[str, Any]]
42
+
43
+
44
+ class _TokenState(NamedTuple):
45
+ """Everything the client knows about its current token, published as one value."""
46
+
47
+ access_token: str
48
+ expires_at: datetime | None
49
+ scopes: frozenset[str]
50
+
51
+
52
+ def _token_state(access_token: str) -> _TokenState:
53
+ """Read the claims the client reports, when it can. The parsing lives in ``auth``.
54
+
55
+ The token's format is the server's to change. A token the SDK cannot read is
56
+ still used as given: it just reports no expiry and no scopes.
57
+ """
58
+ try:
59
+ payload = auth.decode_token_payload(access_token)
60
+ return _TokenState(
61
+ access_token,
62
+ auth.expiry_from_payload(payload),
63
+ auth.scopes_from_payload(payload),
64
+ )
65
+ except SahiConfigurationException:
66
+ return _TokenState(access_token, None, frozenset())
67
+
68
+
69
+ def _access_token_of(token: Token, where: str) -> str:
70
+ """Accept the token response or a bare token string.
71
+
72
+ Taking the whole response keeps the ``access_token`` field an implementation
73
+ detail of the auth API rather than something every caller must know about.
74
+ """
75
+ if isinstance(token, Mapping):
76
+ value = token.get("access_token")
77
+ if not isinstance(value, str) or not value:
78
+ raise SahiConfigurationException(
79
+ f"{where} was given a dict without an 'access_token' field. Pass the "
80
+ "response of get_access_token() as-is."
81
+ )
82
+ token = value
83
+ if not isinstance(token, str):
84
+ raise SahiConfigurationException(
85
+ f"{where} expects the token response from get_access_token() or its "
86
+ f"access_token string, not {type(token).__name__}."
87
+ )
88
+ return token
89
+
90
+
91
+ class SahiAPI(_Orders, _Portfolio, _Funds, _Account, _MarketData, _Instruments):
92
+ """Client for the Sahi REST API.
93
+
94
+ Args:
95
+ token: The response of :func:`sahiapi.get_access_token` (preferred), or
96
+ just its ``access_token`` string. Use :meth:`from_credentials` to go
97
+ straight from an API key to a client.
98
+ scrip_master_url: The scrip-master CSV download URL, needed for the
99
+ instrument lookups. Defaults to ``$SAHI_SCRIP_MASTER_URL``. The URL
100
+ is not in the public docs; it is provided when your API access is set
101
+ up.
102
+ timeout: Default per-request timeout in seconds.
103
+
104
+ The enum values the API uses are available as class attributes, e.g.
105
+ ``SahiAPI.EXCHANGE_SEGMENT_NSE`` and ``SahiAPI.ORDER_TYPE_LIMIT``.
106
+
107
+ Every method returns the parsed response body unchanged and raises a subclass
108
+ of :class:`~sahiapi.SahiAPIException` for any non-2xx reply. Each exception
109
+ carries the HTTP ``status``, the server ``code`` and ``message``, a
110
+ ``request_id`` to quote to support, and a ``hint`` saying what to do; its
111
+ ``str()`` includes all of them. Catch the specific subclass you can act on
112
+ (:class:`~sahiapi.SahiAuthenticationException`,
113
+ :class:`~sahiapi.SahiRateLimitException`, …) and let the rest propagate.
114
+ """
115
+
116
+ # -- exchange segments --
117
+ EXCHANGE_SEGMENT_NSE = constants.EXCHANGE_SEGMENT_NSE
118
+ EXCHANGE_SEGMENT_NFO = constants.EXCHANGE_SEGMENT_NFO
119
+ EXCHANGE_SEGMENT_BSE = constants.EXCHANGE_SEGMENT_BSE
120
+ EXCHANGE_SEGMENT_BFO = constants.EXCHANGE_SEGMENT_BFO
121
+ EXCHANGE_SEGMENT_MCX = constants.EXCHANGE_SEGMENT_MCX
122
+ EXCHANGE_SEGMENT_NCOM = constants.EXCHANGE_SEGMENT_NCOM
123
+ # -- transaction type --
124
+ TXN_TYPE_BUY = constants.TXN_TYPE_BUY
125
+ TXN_TYPE_SELL = constants.TXN_TYPE_SELL
126
+ # -- order type --
127
+ ORDER_TYPE_LIMIT = constants.ORDER_TYPE_LIMIT
128
+ ORDER_TYPE_MARKET = constants.ORDER_TYPE_MARKET
129
+ ORDER_TYPE_SL_LIMIT = constants.ORDER_TYPE_SL_LIMIT
130
+ ORDER_TYPE_SL_MARKET = constants.ORDER_TYPE_SL_MARKET
131
+ # -- product --
132
+ PRODUCT_DELIVERY = constants.PRODUCT_DELIVERY
133
+ PRODUCT_INTRADAY = constants.PRODUCT_INTRADAY
134
+ PRODUCT_NORMAL = constants.PRODUCT_NORMAL
135
+ # -- validity --
136
+ VALIDITY_DAY = constants.VALIDITY_DAY
137
+ VALIDITY_IOC = constants.VALIDITY_IOC
138
+ # -- variant --
139
+ VARIANT_REGULAR = constants.VARIANT_REGULAR
140
+ VARIANT_AMO = constants.VARIANT_AMO
141
+ # -- order status --
142
+ ORDER_STATUS_OPEN = constants.ORDER_STATUS_OPEN
143
+ ORDER_STATUS_OPEN_PARTIAL = constants.ORDER_STATUS_OPEN_PARTIAL
144
+ ORDER_STATUS_PARTIAL_FILLED = constants.ORDER_STATUS_PARTIAL_FILLED
145
+ ORDER_STATUS_COMPLETED = constants.ORDER_STATUS_COMPLETED
146
+ ORDER_STATUS_CANCELLED = constants.ORDER_STATUS_CANCELLED
147
+ ORDER_STATUS_REJECTED = constants.ORDER_STATUS_REJECTED
148
+ # -- candle sizes --
149
+ CANDLE_1M = constants.CANDLE_1M
150
+ CANDLE_3M = constants.CANDLE_3M
151
+ CANDLE_5M = constants.CANDLE_5M
152
+ CANDLE_15M = constants.CANDLE_15M
153
+ CANDLE_30M = constants.CANDLE_30M
154
+ CANDLE_1H = constants.CANDLE_1H
155
+ CANDLE_1D = constants.CANDLE_1D
156
+ # -- tick-stream modes --
157
+ MODE_LTP = constants.MODE_LTP
158
+ MODE_QUOTE = constants.MODE_QUOTE
159
+ MODE_DEPTH = constants.MODE_DEPTH
160
+ # -- scopes --
161
+ SCOPE_ORDERS_WRITE = constants.SCOPE_ORDERS_WRITE
162
+ SCOPE_ORDERS_READ = constants.SCOPE_ORDERS_READ
163
+ SCOPE_POSITIONS_READ = constants.SCOPE_POSITIONS_READ
164
+ SCOPE_HOLDINGS_READ = constants.SCOPE_HOLDINGS_READ
165
+ SCOPE_FUNDS_READ = constants.SCOPE_FUNDS_READ
166
+ SCOPE_PROFILE_READ = constants.SCOPE_PROFILE_READ
167
+ SCOPE_MARKETDATA_READ = constants.SCOPE_MARKETDATA_READ
168
+ # -- tick-stream instrument types --
169
+ INSTRUMENT_TYPE_ETF = constants.INSTRUMENT_TYPE_ETF
170
+ INSTRUMENT_TYPE_INDEX = constants.INSTRUMENT_TYPE_INDEX
171
+ INSTRUMENT_TYPE_INDEX_FUT = constants.INSTRUMENT_TYPE_INDEX_FUT
172
+ INSTRUMENT_TYPE_INDEX_OPT_CE = constants.INSTRUMENT_TYPE_INDEX_OPT_CE
173
+ INSTRUMENT_TYPE_INDEX_OPT_PE = constants.INSTRUMENT_TYPE_INDEX_OPT_PE
174
+ INSTRUMENT_TYPE_STOCK = constants.INSTRUMENT_TYPE_STOCK
175
+ INSTRUMENT_TYPE_STOCK_FUT = constants.INSTRUMENT_TYPE_STOCK_FUT
176
+ INSTRUMENT_TYPE_STOCK_OPT_CE = constants.INSTRUMENT_TYPE_STOCK_OPT_CE
177
+ INSTRUMENT_TYPE_STOCK_OPT_PE = constants.INSTRUMENT_TYPE_STOCK_OPT_PE
178
+ # -- WebSocket close codes --
179
+ WS_CLOSE_AUTH_EXPIRED = constants.WS_CLOSE_AUTH_EXPIRED
180
+ WS_CLOSE_DROPPED = constants.WS_CLOSE_DROPPED
181
+ WS_CLOSE_FORBIDDEN = constants.WS_CLOSE_FORBIDDEN
182
+ WS_CLOSE_HEARTBEAT = constants.WS_CLOSE_HEARTBEAT
183
+ WS_CLOSE_TOO_MANY = constants.WS_CLOSE_TOO_MANY
184
+
185
+ def __init__(
186
+ self,
187
+ token: Token,
188
+ *,
189
+ scrip_master_url: str | None = None,
190
+ timeout: float = 10.0,
191
+ ) -> None:
192
+ access_token = _access_token_of(token, "SahiAPI()")
193
+ state = _token_state(access_token)
194
+ self._http = HttpClient(
195
+ env.API_BASE_URL, host=HOST_API, access_token=access_token, timeout=timeout
196
+ )
197
+ self._scrip_master_url = scrip_master_url or os.environ.get(_ENV_SCRIP_MASTER_URL)
198
+ self._token = state
199
+
200
+ # -- authentication -------------------------------------------------------
201
+
202
+ @classmethod
203
+ def from_credentials(
204
+ cls,
205
+ key_id: str,
206
+ secret: str,
207
+ *,
208
+ scopes: list[str] | None = None,
209
+ scrip_master_url: str | None = None,
210
+ timeout: float = 10.0,
211
+ ) -> SahiAPI:
212
+ """Mint a token and return a ready client in one call.
213
+
214
+ This is the usual entry point: the token never has to be handled by the
215
+ caller. It performs one request to the auth host and raises the same
216
+ exceptions as :func:`sahiapi.get_access_token` (not approved today, wrong
217
+ secret, …). Use :func:`sahiapi.get_access_token` plus the constructor
218
+ instead when one token must be shared or cached.
219
+
220
+ Args:
221
+ key_id: The key id from your Sahi account.
222
+ secret: The secret shown once at key creation.
223
+ scopes: Optional subset of the key's scopes to narrow the token to.
224
+ scrip_master_url: Scrip-master CSV URL (default ``$SAHI_SCRIP_MASTER_URL``).
225
+ timeout: Default per-request timeout in seconds, also used for the mint.
226
+ """
227
+ token = auth.get_access_token(key_id, secret, scopes=scopes, timeout=timeout)
228
+ return cls(token, scrip_master_url=scrip_master_url, timeout=timeout)
229
+
230
+ def set_access_token(self, token: Token) -> None:
231
+ """Swap in a freshly minted token for daily rotation.
232
+
233
+ Accepts the same forms as the constructor. Reuses the client and its
234
+ connection pool; any open :class:`SahiFeed` is untouched and keeps the
235
+ token it was started with.
236
+
237
+ The header is swapped and the expiry and scopes published together as
238
+ one value, so no request ever sees a half-updated token.
239
+ """
240
+ access_token = _access_token_of(token, "set_access_token()")
241
+ state = _token_state(access_token)
242
+ self._http.set_access_token(access_token)
243
+ self._token = state
244
+
245
+ @property
246
+ def token_expires_at(self) -> datetime | None:
247
+ """When the current token expires (UTC), or ``None`` if it carries no readable ``exp``."""
248
+ return self._token.expires_at
249
+
250
+ @property
251
+ def scopes(self) -> frozenset[str]:
252
+ """The scopes the current token carries; empty if its claims cannot be read."""
253
+ return self._token.scopes
254
+
255
+ @property
256
+ def access_token(self) -> str:
257
+ """The bearer token string in use, as :class:`~sahiapi.SahiFeed` borrows it.
258
+
259
+ Read-only: rotate it with :meth:`set_access_token`. A feed copies the
260
+ value when it is built and keeps it until stopped.
261
+ """
262
+ return self._token.access_token
263
+
264
+ # -- lifecycle ------------------------------------------------------------
265
+
266
+ def close(self) -> None:
267
+ """Close the underlying HTTP connection pool."""
268
+ self._http.close()
269
+
270
+ def __enter__(self) -> SahiAPI:
271
+ return self
272
+
273
+ def __exit__(
274
+ self,
275
+ exc_type: type[BaseException] | None,
276
+ exc: BaseException | None,
277
+ tb: TracebackType | None,
278
+ ) -> None:
279
+ self.close()
280
+
281
+ # -- request plumbing (used by the domain mixins) -------------------------
282
+
283
+ def _request(
284
+ self,
285
+ method: str,
286
+ path: str,
287
+ *,
288
+ params: dict[str, Any] | None = None,
289
+ json: Any = None,
290
+ timeout: float | None = None,
291
+ retry_on_rate_limit: bool = False,
292
+ ) -> Any:
293
+ """Send a request with the current token.
294
+
295
+ Expiry is not checked locally: this host's clock can drift, and the
296
+ server is the authority. An expired token comes back as a ``401`` and
297
+ raises :class:`~sahiapi.SahiAuthenticationException` like any other.
298
+ """
299
+ return self._http.request(
300
+ method,
301
+ path,
302
+ params=params,
303
+ json=json,
304
+ timeout=timeout,
305
+ retry_on_rate_limit=retry_on_rate_limit,
306
+ )