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.
- sahiapi-0.1.0/.gitignore +29 -0
- sahiapi-0.1.0/CHANGELOG.md +30 -0
- sahiapi-0.1.0/LICENSE +7 -0
- sahiapi-0.1.0/PKG-INFO +97 -0
- sahiapi-0.1.0/README.md +57 -0
- sahiapi-0.1.0/pyproject.toml +106 -0
- sahiapi-0.1.0/src/sahiapi/__init__.py +88 -0
- sahiapi-0.1.0/src/sahiapi/py.typed +0 -0
- sahiapi-0.1.0/src/sahiapi/sahi/__init__.py +0 -0
- sahiapi-0.1.0/src/sahiapi/sahi/account.py +28 -0
- sahiapi-0.1.0/src/sahiapi/sahi/auth.py +163 -0
- sahiapi-0.1.0/src/sahiapi/sahi/base.py +32 -0
- sahiapi-0.1.0/src/sahiapi/sahi/client.py +306 -0
- sahiapi-0.1.0/src/sahiapi/sahi/constants.py +115 -0
- sahiapi-0.1.0/src/sahiapi/sahi/env.py +39 -0
- sahiapi-0.1.0/src/sahiapi/sahi/exceptions.py +412 -0
- sahiapi-0.1.0/src/sahiapi/sahi/feed.py +668 -0
- sahiapi-0.1.0/src/sahiapi/sahi/funds.py +134 -0
- sahiapi-0.1.0/src/sahiapi/sahi/http.py +301 -0
- sahiapi-0.1.0/src/sahiapi/sahi/instruments.py +315 -0
- sahiapi-0.1.0/src/sahiapi/sahi/marketdata.py +268 -0
- sahiapi-0.1.0/src/sahiapi/sahi/orders.py +225 -0
- sahiapi-0.1.0/src/sahiapi/sahi/portfolio.py +49 -0
- sahiapi-0.1.0/src/sahiapi/sahi/proto/__init__.py +0 -0
- sahiapi-0.1.0/src/sahiapi/sahi/proto/codec.py +126 -0
- sahiapi-0.1.0/src/sahiapi/sahi/proto/marketdata.proto +138 -0
- sahiapi-0.1.0/src/sahiapi/sahi/proto/marketdata_pb2.py +55 -0
- sahiapi-0.1.0/src/sahiapi/sahi/ws_client.py +355 -0
sahiapi-0.1.0/.gitignore
ADDED
|
@@ -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).
|
sahiapi-0.1.0/README.md
ADDED
|
@@ -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
|