sahiapi 0.1.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.
@@ -0,0 +1,29 @@
1
+ # Python
2
+ __pycache__/
3
+ *.py[cod]
4
+ *.egg-info/
5
+ .eggs/
6
+ build/
7
+ dist/
8
+ *.so
9
+
10
+ # Environments
11
+ .venv/
12
+ venv/
13
+ .env
14
+ .env.uat
15
+
16
+ # Tooling caches
17
+ .pytest_cache/
18
+ .mypy_cache/
19
+ .ruff_cache/
20
+ .coverage
21
+ htmlcov/
22
+
23
+ # Packaging / locks kept, editor noise dropped
24
+ .DS_Store
25
+ .idea/
26
+ .vscode/
27
+
28
+ # slop-mop working directory (machine-local state)
29
+ .slopmop/
@@ -0,0 +1,30 @@
1
+ # Changelog
2
+
3
+ All notable changes to this project are documented here. The format follows
4
+ [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and the project aims to
5
+ follow [Semantic Versioning](https://semver.org/) once it reaches `1.0.0`.
6
+
7
+ ## [Unreleased]
8
+
9
+ ## [0.1.0] - 2026-10-18
10
+
11
+ ### Added
12
+ - Initial SDK: authentication (`SahiAPI.from_credentials` for the one-call path,
13
+ `get_access_token` + `SahiAPI(token)` for a shared token, rotation via
14
+ `set_access_token`), and full REST coverage — orders, portfolio, funds,
15
+ account, market data (with `get_candle_history`) and the scrip master.
16
+ - `SahiFeed` for the two WebSocket streams: the protobuf tick stream (LTP, quote,
17
+ depth) and JSON order updates, with reconnect, subscription replay and a local
18
+ tick cache whose reads raise the stream's error once it has closed fatally.
19
+ - A typed exception hierarchy mapped from `(status, host, code)`. Every API
20
+ exception (`SahiAPIException`) carries the server `status`, `code`,
21
+ `message`, a `request_id` and an actionable `hint`, all included in `str()`;
22
+ a feed exception carries the WebSocket `close_code` instead.
23
+ - Every wire enum as a constant and `SahiAPI` class attribute.
24
+ - `on_connect` on the order stream for the API's Stream + Snapshot bootstrap;
25
+ transparent reconnect on heartbeat (4408) and slow-consumer (4409) drops.
26
+ - Packaging, CI (ruff, mypy strict, pytest on Python 3.9–3.13) and a
27
+ trusted-publishing release workflow.
28
+
29
+ [Unreleased]: https://github.com/aarityatech/sahi-python/compare/sahiapi/v0.1.0...HEAD
30
+ [0.1.0]: https://github.com/aarityatech/sahi-python/releases/tag/sahiapi/v0.1.0
sahiapi-0.1.0/LICENSE ADDED
@@ -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.
sahiapi-0.1.0/PKG-INFO ADDED
@@ -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,57 @@
1
+ # sahiapi
2
+
3
+ The official Python client library for the [Sahi APIs](https://docs.sahi.com/):
4
+ orders, portfolio, funds, market data and live streams.
5
+
6
+ > Early development. The public interface is not yet stable.
7
+
8
+ ## Install
9
+
10
+ ```bash
11
+ pip install sahiapi
12
+ ```
13
+
14
+ Requires Python 3.9 or newer.
15
+
16
+ ## Quickstart
17
+
18
+ Create an API key in your Sahi account and approve it for the trading day, then:
19
+
20
+ ```python
21
+ import os
22
+ from sahiapi import SahiAPI
23
+
24
+ api = SahiAPI.from_credentials(
25
+ key_id=os.environ["SAHI_KEY_ID"],
26
+ secret=os.environ["SAHI_SECRET"],
27
+ )
28
+ print(api.get_profile())
29
+ print(api.get_funds())
30
+ ```
31
+
32
+ Tokens expire at 06:00 IST, so mint a fresh one each trading day.
33
+
34
+ ## Documentation
35
+
36
+ Guides, the full API reference and examples are at **https://docs.sahi.com/**.
37
+
38
+ The SDK returns the API's JSON exactly as documented there: money is integer
39
+ paise, timestamps are epoch milliseconds, and field names are unchanged.
40
+
41
+ ## Development
42
+
43
+ ```bash
44
+ uv sync --extra dev
45
+ uv run pytest
46
+ uv run ruff check .
47
+ uv run mypy
48
+ ```
49
+
50
+ The tick-stream protobuf bindings are generated and committed; regenerate them
51
+ with `tools/regen_proto.sh` after editing the `.proto`. Releases are cut with
52
+ `tools/release.sh`; see `.github/workflows/release.yml`.
53
+
54
+ ## License
55
+
56
+ Proprietary during development; an open-source license will be applied before
57
+ public release. See [LICENSE](LICENSE).
@@ -0,0 +1,106 @@
1
+ [build-system]
2
+ requires = ["hatchling", "hatch-vcs"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "sahiapi"
7
+ # Taken from the release tag (sahiapi/v0.2.0, sahiapi/v0.2.0-rc.1) at build time;
8
+ # see [tool.hatch.version] and tools/release.sh.
9
+ dynamic = ["version"]
10
+ description = "The official Python client library for the Sahi APIs."
11
+ readme = "README.md"
12
+ requires-python = ">=3.9"
13
+ license = { file = "LICENSE" }
14
+ authors = [{ name = "Aaritya Technologies" }]
15
+ keywords = ["sahi", "trading", "stocks", "options", "market-data", "nse", "bse"]
16
+ classifiers = [
17
+ "Development Status :: 3 - Alpha",
18
+ "Intended Audience :: Developers",
19
+ "Intended Audience :: Financial and Insurance Industry",
20
+ "Operating System :: OS Independent",
21
+ "Programming Language :: Python :: 3",
22
+ "Programming Language :: Python :: 3.9",
23
+ "Programming Language :: Python :: 3.10",
24
+ "Programming Language :: Python :: 3.11",
25
+ "Programming Language :: Python :: 3.12",
26
+ "Programming Language :: Python :: 3.13",
27
+ "Typing :: Typed",
28
+ ]
29
+ dependencies = [
30
+ "httpx>=0.27",
31
+ "websocket-client>=1.7",
32
+ "protobuf>=4.25",
33
+ ]
34
+
35
+ [project.optional-dependencies]
36
+ dev = [
37
+ "pytest>=8.0",
38
+ "pytest-cov>=5.0",
39
+ "respx>=0.21",
40
+ "ruff>=0.6",
41
+ "mypy>=1.11",
42
+ ]
43
+
44
+ # Development-only tooling. PEP 735 groups are not part of the published
45
+ # metadata, and uv installs the "dev" group by default on `uv sync`.
46
+ [dependency-groups]
47
+ dev = [
48
+ "sahiapi-uat",
49
+ ]
50
+
51
+ [tool.uv.sources]
52
+ sahiapi-uat = { path = "tools/sahiapi_uat" }
53
+
54
+ [project.urls]
55
+ Homepage = "https://docs.sahi.com/"
56
+ Documentation = "https://docs.sahi.com/"
57
+ Repository = "https://github.com/aarityatech/sahi-python"
58
+
59
+ [tool.hatch.version]
60
+ source = "vcs"
61
+ # Only sahiapi/v* tags carry a version; "-rc.N" normalizes to the PEP 440 "rcN".
62
+ tag-pattern = "^sahiapi/v(?P<version>.+)$"
63
+ # A shallow clone or a tarball has no tags to read.
64
+ fallback-version = "0.0.0"
65
+
66
+ [tool.hatch.version.raw-options]
67
+ git_describe_command = ["git", "describe", "--dirty", "--tags", "--long", "--match", "sahiapi/v*"]
68
+
69
+ [tool.hatch.build.targets.wheel]
70
+ packages = ["src/sahiapi"]
71
+
72
+ # The sdist goes to PyPI too: ship the package and its docs, not the internal
73
+ # tooling (tools/sahiapi_uat and its .pth hook), the tests or the CI files.
74
+ [tool.hatch.build.targets.sdist]
75
+ only-include = ["src/sahiapi", "README.md", "LICENSE", "CHANGELOG.md"]
76
+
77
+ [tool.ruff]
78
+ line-length = 100
79
+ target-version = "py39"
80
+ src = ["src", "tests"]
81
+
82
+ [tool.ruff.lint]
83
+ select = ["E", "F", "I", "UP", "B", "W", "N", "RUF"]
84
+ # N818 wants an "Error" suffix on exceptions; the public hierarchy uses the
85
+ # "Exception" suffix throughout by design, so this stays a deliberate choice.
86
+ ignore = ["N818"]
87
+
88
+ [tool.ruff.lint.per-file-ignores]
89
+ # Generated protobuf code is not ours to lint.
90
+ "src/sahiapi/sahi/proto/*_pb2.py" = ["ALL"]
91
+
92
+ # The package runs on 3.9+ (guarded by the CI test matrix); mypy itself only
93
+ # analyses at 3.10 and up, and `from __future__ import annotations` keeps modern
94
+ # union syntax valid at 3.9 runtime.
95
+ [tool.mypy]
96
+ python_version = "3.10"
97
+ strict = true
98
+ files = ["src/sahiapi"]
99
+ # Generated protobuf modules ship without stubs.
100
+ [[tool.mypy.overrides]]
101
+ module = ["sahiapi.sahi.proto.*"]
102
+ ignore_errors = true
103
+
104
+ [tool.pytest.ini_options]
105
+ testpaths = ["tests"]
106
+ addopts = "-ra"
@@ -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__)
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
+ )
@@ -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))
@@ -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