quantjourney-common-sdk 0.1.1__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.
- quantjourney_common_sdk-0.1.1/PKG-INFO +58 -0
- quantjourney_common_sdk-0.1.1/README.md +44 -0
- quantjourney_common_sdk-0.1.1/pyproject.toml +31 -0
- quantjourney_common_sdk-0.1.1/qj/common/__init__.py +19 -0
- quantjourney_common_sdk-0.1.1/qj/common/auth.py +158 -0
- quantjourney_common_sdk-0.1.1/qj/common/client.py +154 -0
- quantjourney_common_sdk-0.1.1/qj/common/errors.py +46 -0
- quantjourney_common_sdk-0.1.1/qj/common/models.py +63 -0
- quantjourney_common_sdk-0.1.1/qj/common/pagination.py +51 -0
- quantjourney_common_sdk-0.1.1/qj/common/py.typed +1 -0
- quantjourney_common_sdk-0.1.1/qj/common/telemetry.py +15 -0
- quantjourney_common_sdk-0.1.1/quantjourney_common_sdk.egg-info/PKG-INFO +58 -0
- quantjourney_common_sdk-0.1.1/quantjourney_common_sdk.egg-info/SOURCES.txt +15 -0
- quantjourney_common_sdk-0.1.1/quantjourney_common_sdk.egg-info/dependency_links.txt +1 -0
- quantjourney_common_sdk-0.1.1/quantjourney_common_sdk.egg-info/requires.txt +7 -0
- quantjourney_common_sdk-0.1.1/quantjourney_common_sdk.egg-info/top_level.txt +1 -0
- quantjourney_common_sdk-0.1.1/setup.cfg +4 -0
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: quantjourney-common-sdk
|
|
3
|
+
Version: 0.1.1
|
|
4
|
+
Summary: Shared transport and contract mechanics for QuantJourney SDKs.
|
|
5
|
+
Author: QuantJourney
|
|
6
|
+
License-Expression: LicenseRef-Proprietary
|
|
7
|
+
Requires-Python: >=3.10
|
|
8
|
+
Description-Content-Type: text/markdown
|
|
9
|
+
Requires-Dist: requests>=2.31.0
|
|
10
|
+
Provides-Extra: dev
|
|
11
|
+
Requires-Dist: pytest>=8.0.0; extra == "dev"
|
|
12
|
+
Provides-Extra: frames
|
|
13
|
+
Requires-Dist: pandas>=2.0.0; extra == "frames"
|
|
14
|
+
|
|
15
|
+
# quantjourney-common-sdk
|
|
16
|
+
|
|
17
|
+
Shared Python mechanics for QJ SDKs.
|
|
18
|
+
|
|
19
|
+
This package owns only transport-level concerns: auth/token exchange, HTTP
|
|
20
|
+
request handling, response envelopes, pagination helpers, bounded retry, and
|
|
21
|
+
small typed models. Domain clients live in SDK-specific packages such as
|
|
22
|
+
`qj.data` and `qj.api`.
|
|
23
|
+
|
|
24
|
+
Canonical package metadata:
|
|
25
|
+
|
|
26
|
+
| Field | Value |
|
|
27
|
+
| --- | --- |
|
|
28
|
+
| Repository | `_repo_qj_common_sdk` |
|
|
29
|
+
| Distribution | `quantjourney-common-sdk` |
|
|
30
|
+
| Python import | `qj.common` |
|
|
31
|
+
| Version | `0.1.1` |
|
|
32
|
+
| Git tag | `v0.1.1` |
|
|
33
|
+
| Namespace style | PEP 420 namespace package |
|
|
34
|
+
|
|
35
|
+
`quantjourney-common-sdk` must not ship `qj/__init__.py`. It is designed to
|
|
36
|
+
co-install with `quantjourney-data` and `quantjourney-api`.
|
|
37
|
+
|
|
38
|
+
Install from PyPI:
|
|
39
|
+
|
|
40
|
+
```bash
|
|
41
|
+
pip install quantjourney-common-sdk==0.1.1
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
No private Python package registry is required. Consumers should pin a compatible
|
|
45
|
+
release range in application dependencies.
|
|
46
|
+
|
|
47
|
+
Auth model:
|
|
48
|
+
|
|
49
|
+
- `QJ_*_TOKEN` is sent as a Bearer token directly.
|
|
50
|
+
- `QJ_*_API_KEY=QJ_live_*` is exchanged through `qj-auth /auth/token`.
|
|
51
|
+
- `QJ_*_CLIENT_ID` + `QJ_*_CLIENT_SECRET=QJ_svc_*` use `client_credentials`.
|
|
52
|
+
- exchanged JWTs are cached in memory until shortly before `exp`.
|
|
53
|
+
|
|
54
|
+
Transport model:
|
|
55
|
+
|
|
56
|
+
- idempotent reads retry `429/502/503/504` with bounded backoff;
|
|
57
|
+
- each request gets `X-QJ-Request-ID`;
|
|
58
|
+
- response envelopes are parsed into `QJResponse`.
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
# quantjourney-common-sdk
|
|
2
|
+
|
|
3
|
+
Shared Python mechanics for QJ SDKs.
|
|
4
|
+
|
|
5
|
+
This package owns only transport-level concerns: auth/token exchange, HTTP
|
|
6
|
+
request handling, response envelopes, pagination helpers, bounded retry, and
|
|
7
|
+
small typed models. Domain clients live in SDK-specific packages such as
|
|
8
|
+
`qj.data` and `qj.api`.
|
|
9
|
+
|
|
10
|
+
Canonical package metadata:
|
|
11
|
+
|
|
12
|
+
| Field | Value |
|
|
13
|
+
| --- | --- |
|
|
14
|
+
| Repository | `_repo_qj_common_sdk` |
|
|
15
|
+
| Distribution | `quantjourney-common-sdk` |
|
|
16
|
+
| Python import | `qj.common` |
|
|
17
|
+
| Version | `0.1.1` |
|
|
18
|
+
| Git tag | `v0.1.1` |
|
|
19
|
+
| Namespace style | PEP 420 namespace package |
|
|
20
|
+
|
|
21
|
+
`quantjourney-common-sdk` must not ship `qj/__init__.py`. It is designed to
|
|
22
|
+
co-install with `quantjourney-data` and `quantjourney-api`.
|
|
23
|
+
|
|
24
|
+
Install from PyPI:
|
|
25
|
+
|
|
26
|
+
```bash
|
|
27
|
+
pip install quantjourney-common-sdk==0.1.1
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
No private Python package registry is required. Consumers should pin a compatible
|
|
31
|
+
release range in application dependencies.
|
|
32
|
+
|
|
33
|
+
Auth model:
|
|
34
|
+
|
|
35
|
+
- `QJ_*_TOKEN` is sent as a Bearer token directly.
|
|
36
|
+
- `QJ_*_API_KEY=QJ_live_*` is exchanged through `qj-auth /auth/token`.
|
|
37
|
+
- `QJ_*_CLIENT_ID` + `QJ_*_CLIENT_SECRET=QJ_svc_*` use `client_credentials`.
|
|
38
|
+
- exchanged JWTs are cached in memory until shortly before `exp`.
|
|
39
|
+
|
|
40
|
+
Transport model:
|
|
41
|
+
|
|
42
|
+
- idempotent reads retry `429/502/503/504` with bounded backoff;
|
|
43
|
+
- each request gets `X-QJ-Request-ID`;
|
|
44
|
+
- response envelopes are parsed into `QJResponse`.
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=77", "wheel"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "quantjourney-common-sdk"
|
|
7
|
+
version = "0.1.1"
|
|
8
|
+
description = "Shared transport and contract mechanics for QuantJourney SDKs."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.10"
|
|
11
|
+
authors = [{ name = "QuantJourney" }]
|
|
12
|
+
license = "LicenseRef-Proprietary"
|
|
13
|
+
dependencies = [
|
|
14
|
+
"requests>=2.31.0",
|
|
15
|
+
]
|
|
16
|
+
|
|
17
|
+
[project.optional-dependencies]
|
|
18
|
+
dev = [
|
|
19
|
+
"pytest>=8.0.0",
|
|
20
|
+
]
|
|
21
|
+
frames = [
|
|
22
|
+
"pandas>=2.0.0",
|
|
23
|
+
]
|
|
24
|
+
|
|
25
|
+
[tool.setuptools.packages.find]
|
|
26
|
+
where = ["."]
|
|
27
|
+
include = ["qj.*"]
|
|
28
|
+
namespaces = true
|
|
29
|
+
|
|
30
|
+
[tool.pytest.ini_options]
|
|
31
|
+
testpaths = ["_tests"]
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
"""Shared mechanics for QuantJourney SDK packages."""
|
|
2
|
+
|
|
3
|
+
from .auth import AuthConfig
|
|
4
|
+
from .client import HttpClient
|
|
5
|
+
from .errors import QJAPIError, QJAuthError, QJConnectionError, QJError
|
|
6
|
+
from .models import QJResponse
|
|
7
|
+
from .pagination import collect_rows, iter_offset_pages
|
|
8
|
+
|
|
9
|
+
__all__ = [
|
|
10
|
+
"AuthConfig",
|
|
11
|
+
"HttpClient",
|
|
12
|
+
"QJAPIError",
|
|
13
|
+
"QJAuthError",
|
|
14
|
+
"QJConnectionError",
|
|
15
|
+
"QJError",
|
|
16
|
+
"QJResponse",
|
|
17
|
+
"collect_rows",
|
|
18
|
+
"iter_offset_pages",
|
|
19
|
+
]
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
"""Authentication helpers shared by QJ SDKs."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import base64
|
|
6
|
+
import json
|
|
7
|
+
import os
|
|
8
|
+
import time
|
|
9
|
+
from dataclasses import dataclass
|
|
10
|
+
from typing import Any, Mapping
|
|
11
|
+
|
|
12
|
+
import requests
|
|
13
|
+
|
|
14
|
+
from .errors import QJAuthError
|
|
15
|
+
|
|
16
|
+
DEFAULT_AUTH_URL = "https://auth.quantjourney.cloud"
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@dataclass
|
|
20
|
+
class AuthConfig:
|
|
21
|
+
"""Bearer/API-key auth configuration for SDK HTTP clients.
|
|
22
|
+
|
|
23
|
+
If a bearer token is available it is sent directly. If only a durable
|
|
24
|
+
`QJ_live_*` user API key or `QJ_svc_*` service credential is available, the
|
|
25
|
+
SDK exchanges it through qj-auth and caches the short-lived JWT in memory.
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
bearer_token: str | None = None
|
|
29
|
+
api_key: str | None = None
|
|
30
|
+
client_id: str | None = None
|
|
31
|
+
client_secret: str | None = None
|
|
32
|
+
auth_url: str = DEFAULT_AUTH_URL
|
|
33
|
+
tenant_id: str | None = None
|
|
34
|
+
team_id: str | None = None
|
|
35
|
+
token_skew_seconds: int = 30
|
|
36
|
+
exchange_timeout: float = 15.0
|
|
37
|
+
_cached_token: str | None = None
|
|
38
|
+
_cached_token_exp: int | None = None
|
|
39
|
+
|
|
40
|
+
@classmethod
|
|
41
|
+
def from_env(cls, prefix: str = "QJ_DATA") -> "AuthConfig":
|
|
42
|
+
prefix = prefix.rstrip("_")
|
|
43
|
+
bearer_token = (
|
|
44
|
+
os.getenv(f"{prefix}_TOKEN")
|
|
45
|
+
or os.getenv("QJ_API_TOKEN")
|
|
46
|
+
or os.getenv("QJ_TOKEN")
|
|
47
|
+
)
|
|
48
|
+
api_key = (
|
|
49
|
+
os.getenv(f"{prefix}_API_KEY")
|
|
50
|
+
or os.getenv("QJ_API_KEY")
|
|
51
|
+
)
|
|
52
|
+
return cls(
|
|
53
|
+
bearer_token=bearer_token,
|
|
54
|
+
api_key=api_key,
|
|
55
|
+
client_id=os.getenv(f"{prefix}_CLIENT_ID") or os.getenv("QJ_CLIENT_ID"),
|
|
56
|
+
client_secret=os.getenv(f"{prefix}_CLIENT_SECRET") or os.getenv("QJ_CLIENT_SECRET"),
|
|
57
|
+
auth_url=(
|
|
58
|
+
os.getenv(f"{prefix}_AUTH_URL")
|
|
59
|
+
or os.getenv("QJ_AUTH_URL")
|
|
60
|
+
or DEFAULT_AUTH_URL
|
|
61
|
+
),
|
|
62
|
+
tenant_id=os.getenv(f"{prefix}_TENANT_ID") or os.getenv("QJ_TENANT_ID"),
|
|
63
|
+
team_id=os.getenv(f"{prefix}_TEAM_ID") or os.getenv("QJ_TEAM_ID"),
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
def headers(self) -> Mapping[str, str]:
|
|
67
|
+
headers: dict[str, str] = {}
|
|
68
|
+
token = self.token()
|
|
69
|
+
if token:
|
|
70
|
+
headers["Authorization"] = f"Bearer {token}"
|
|
71
|
+
if self.tenant_id:
|
|
72
|
+
headers["X-QJ-Tenant-ID"] = self.tenant_id
|
|
73
|
+
if self.team_id:
|
|
74
|
+
headers["X-QJ-Team-ID"] = self.team_id
|
|
75
|
+
return headers
|
|
76
|
+
|
|
77
|
+
def token(self) -> str | None:
|
|
78
|
+
"""Return a bearer token, exchanging durable credentials if needed."""
|
|
79
|
+
if self.bearer_token:
|
|
80
|
+
return self.bearer_token
|
|
81
|
+
if self._cached_token and self._cached_token_is_valid():
|
|
82
|
+
return self._cached_token
|
|
83
|
+
if self.api_key:
|
|
84
|
+
return self.exchange_user_api_key()
|
|
85
|
+
if self.client_id and self.client_secret:
|
|
86
|
+
return self.exchange_client_credentials()
|
|
87
|
+
return None
|
|
88
|
+
|
|
89
|
+
def invalidate(self) -> None:
|
|
90
|
+
"""Drop the cached exchanged token."""
|
|
91
|
+
self._cached_token = None
|
|
92
|
+
self._cached_token_exp = None
|
|
93
|
+
|
|
94
|
+
def exchange_user_api_key(self) -> str:
|
|
95
|
+
"""Exchange a `QJ_live_*` user API key for a short-lived JWT."""
|
|
96
|
+
return self._exchange(
|
|
97
|
+
{
|
|
98
|
+
"grant_type": "urn:quantjourney:api_key",
|
|
99
|
+
"api_key": self.api_key,
|
|
100
|
+
}
|
|
101
|
+
)
|
|
102
|
+
|
|
103
|
+
def exchange_client_credentials(self) -> str:
|
|
104
|
+
"""Exchange `QJ_svc_*` service credentials for a short-lived JWT."""
|
|
105
|
+
return self._exchange(
|
|
106
|
+
{
|
|
107
|
+
"grant_type": "client_credentials",
|
|
108
|
+
"client_id": self.client_id,
|
|
109
|
+
"client_secret": self.client_secret,
|
|
110
|
+
}
|
|
111
|
+
)
|
|
112
|
+
|
|
113
|
+
def _exchange(self, payload: Mapping[str, Any]) -> str:
|
|
114
|
+
url = f"{self.auth_url.rstrip('/')}/auth/token"
|
|
115
|
+
try:
|
|
116
|
+
response = requests.post(url, json=dict(payload), timeout=self.exchange_timeout)
|
|
117
|
+
except requests.RequestException as exc:
|
|
118
|
+
raise QJAuthError(f"Could not reach qj-auth at {url}: {exc}") from exc
|
|
119
|
+
|
|
120
|
+
try:
|
|
121
|
+
body = response.json()
|
|
122
|
+
except ValueError as exc:
|
|
123
|
+
raise QJAuthError(f"qj-auth returned non-JSON response: HTTP {response.status_code}") from exc
|
|
124
|
+
|
|
125
|
+
if response.status_code >= 400:
|
|
126
|
+
detail = body.get("detail") if isinstance(body, dict) else None
|
|
127
|
+
raise QJAuthError(f"qj-auth token exchange failed: HTTP {response.status_code}: {detail or body}")
|
|
128
|
+
|
|
129
|
+
token = body.get("access_token") if isinstance(body, dict) else None
|
|
130
|
+
if not token:
|
|
131
|
+
raise QJAuthError("qj-auth token exchange response did not include access_token")
|
|
132
|
+
|
|
133
|
+
self._cached_token = str(token)
|
|
134
|
+
self._cached_token_exp = _jwt_exp(self._cached_token)
|
|
135
|
+
return self._cached_token
|
|
136
|
+
|
|
137
|
+
def _cached_token_is_valid(self) -> bool:
|
|
138
|
+
if not self._cached_token:
|
|
139
|
+
return False
|
|
140
|
+
if not self._cached_token_exp:
|
|
141
|
+
return True
|
|
142
|
+
return int(time.time()) < self._cached_token_exp - self.token_skew_seconds
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def _jwt_exp(token: str) -> int | None:
|
|
146
|
+
"""Return JWT exp without verifying the signature."""
|
|
147
|
+
parts = token.split(".")
|
|
148
|
+
if len(parts) < 2:
|
|
149
|
+
return None
|
|
150
|
+
payload = parts[1]
|
|
151
|
+
padding = "=" * (-len(payload) % 4)
|
|
152
|
+
try:
|
|
153
|
+
decoded = base64.urlsafe_b64decode((payload + padding).encode("ascii"))
|
|
154
|
+
data = json.loads(decoded.decode("utf-8"))
|
|
155
|
+
except Exception:
|
|
156
|
+
return None
|
|
157
|
+
exp = data.get("exp")
|
|
158
|
+
return int(exp) if isinstance(exp, (int, float)) else None
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
"""Small HTTP client used by QJ SDK packages."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Mapping
|
|
6
|
+
import time
|
|
7
|
+
from typing import Any
|
|
8
|
+
from uuid import uuid4
|
|
9
|
+
|
|
10
|
+
import requests
|
|
11
|
+
|
|
12
|
+
from .auth import AuthConfig
|
|
13
|
+
from .errors import QJAPIError, QJConnectionError
|
|
14
|
+
from .models import QJResponse
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class HttpClient:
|
|
18
|
+
"""Requests-based HTTP client with QJ response envelope handling."""
|
|
19
|
+
|
|
20
|
+
def __init__(
|
|
21
|
+
self,
|
|
22
|
+
base_url: str,
|
|
23
|
+
*,
|
|
24
|
+
auth: AuthConfig | None = None,
|
|
25
|
+
timeout: float = 30.0,
|
|
26
|
+
user_agent: str = "qj.common/0.1.0",
|
|
27
|
+
session: requests.Session | None = None,
|
|
28
|
+
max_retries: int = 2,
|
|
29
|
+
backoff_factor: float = 0.25,
|
|
30
|
+
retry_statuses: set[int] | None = None,
|
|
31
|
+
retry_methods: set[str] | None = None,
|
|
32
|
+
sleep=time.sleep,
|
|
33
|
+
) -> None:
|
|
34
|
+
self.base_url = base_url.rstrip("/")
|
|
35
|
+
self.auth = auth or AuthConfig()
|
|
36
|
+
self.timeout = timeout
|
|
37
|
+
self.user_agent = user_agent
|
|
38
|
+
self.session = session or requests.Session()
|
|
39
|
+
self.max_retries = max_retries
|
|
40
|
+
self.backoff_factor = backoff_factor
|
|
41
|
+
self.retry_statuses = retry_statuses or {429, 502, 503, 504}
|
|
42
|
+
self.retry_methods = retry_methods or {"GET", "HEAD", "OPTIONS"}
|
|
43
|
+
self.sleep = sleep
|
|
44
|
+
|
|
45
|
+
def get(
|
|
46
|
+
self,
|
|
47
|
+
path: str,
|
|
48
|
+
*,
|
|
49
|
+
params: Mapping[str, Any] | None = None,
|
|
50
|
+
headers: Mapping[str, str] | None = None,
|
|
51
|
+
) -> QJResponse:
|
|
52
|
+
return self.request("GET", path, params=params, headers=headers)
|
|
53
|
+
|
|
54
|
+
def post(
|
|
55
|
+
self,
|
|
56
|
+
path: str,
|
|
57
|
+
*,
|
|
58
|
+
json: Mapping[str, Any] | None = None,
|
|
59
|
+
params: Mapping[str, Any] | None = None,
|
|
60
|
+
headers: Mapping[str, str] | None = None,
|
|
61
|
+
) -> QJResponse:
|
|
62
|
+
return self.request("POST", path, params=params, json=json, headers=headers)
|
|
63
|
+
|
|
64
|
+
def request(
|
|
65
|
+
self,
|
|
66
|
+
method: str,
|
|
67
|
+
path: str,
|
|
68
|
+
*,
|
|
69
|
+
params: Mapping[str, Any] | None = None,
|
|
70
|
+
json: Mapping[str, Any] | None = None,
|
|
71
|
+
headers: Mapping[str, str] | None = None,
|
|
72
|
+
) -> QJResponse:
|
|
73
|
+
method = method.upper()
|
|
74
|
+
attempts = self.max_retries + 1 if method in self.retry_methods else 1
|
|
75
|
+
last_error: requests.RequestException | None = None
|
|
76
|
+
|
|
77
|
+
for attempt in range(attempts):
|
|
78
|
+
request_headers = {
|
|
79
|
+
"Accept": "application/json",
|
|
80
|
+
"User-Agent": self.user_agent,
|
|
81
|
+
"X-QJ-Request-ID": uuid4().hex,
|
|
82
|
+
}
|
|
83
|
+
request_headers.update(self.auth.headers())
|
|
84
|
+
if headers:
|
|
85
|
+
request_headers.update(headers)
|
|
86
|
+
|
|
87
|
+
try:
|
|
88
|
+
response = self.session.request(
|
|
89
|
+
method,
|
|
90
|
+
self._url(path),
|
|
91
|
+
params=_clean(params),
|
|
92
|
+
json=json,
|
|
93
|
+
headers=request_headers,
|
|
94
|
+
timeout=self.timeout,
|
|
95
|
+
)
|
|
96
|
+
except requests.RequestException as exc:
|
|
97
|
+
last_error = exc
|
|
98
|
+
if attempt + 1 >= attempts:
|
|
99
|
+
raise QJConnectionError(str(exc)) from exc
|
|
100
|
+
self._sleep_before_retry(attempt, None)
|
|
101
|
+
continue
|
|
102
|
+
|
|
103
|
+
payload = _decode_payload(response)
|
|
104
|
+
if response.status_code < 400:
|
|
105
|
+
return QJResponse.from_payload(
|
|
106
|
+
payload,
|
|
107
|
+
status_code=response.status_code,
|
|
108
|
+
headers=dict(response.headers),
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
if response.status_code == 401:
|
|
112
|
+
self.auth.invalidate()
|
|
113
|
+
|
|
114
|
+
if response.status_code in self.retry_statuses and attempt + 1 < attempts:
|
|
115
|
+
self._sleep_before_retry(attempt, response)
|
|
116
|
+
continue
|
|
117
|
+
|
|
118
|
+
raise QJAPIError.from_response(response.status_code, payload)
|
|
119
|
+
|
|
120
|
+
if last_error is not None:
|
|
121
|
+
raise QJConnectionError(str(last_error)) from last_error
|
|
122
|
+
raise QJConnectionError("request failed without response")
|
|
123
|
+
|
|
124
|
+
def _url(self, path: str) -> str:
|
|
125
|
+
if path.startswith("http://") or path.startswith("https://"):
|
|
126
|
+
return path
|
|
127
|
+
return f"{self.base_url}/{path.lstrip('/')}"
|
|
128
|
+
|
|
129
|
+
def _sleep_before_retry(self, attempt: int, response: requests.Response | None) -> None:
|
|
130
|
+
retry_after = response.headers.get("Retry-After") if response is not None else None
|
|
131
|
+
if retry_after:
|
|
132
|
+
try:
|
|
133
|
+
delay = float(retry_after)
|
|
134
|
+
except ValueError:
|
|
135
|
+
delay = self.backoff_factor * (2 ** attempt)
|
|
136
|
+
else:
|
|
137
|
+
delay = self.backoff_factor * (2 ** attempt)
|
|
138
|
+
if delay > 0:
|
|
139
|
+
self.sleep(delay)
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def _clean(params: Mapping[str, Any] | None) -> dict[str, Any] | None:
|
|
143
|
+
if not params:
|
|
144
|
+
return None
|
|
145
|
+
return {key: value for key, value in params.items() if value is not None}
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def _decode_payload(response: requests.Response) -> Any:
|
|
149
|
+
if not response.text:
|
|
150
|
+
return {}
|
|
151
|
+
try:
|
|
152
|
+
return response.json()
|
|
153
|
+
except ValueError:
|
|
154
|
+
return {"raw": response.text}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
"""Shared SDK exceptions."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class QJError(Exception):
|
|
10
|
+
"""Base class for QJ SDK errors."""
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class QJConnectionError(QJError):
|
|
14
|
+
"""Raised when an SDK cannot reach the configured QJ endpoint."""
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class QJAuthError(QJError):
|
|
18
|
+
"""Raised when SDK credential exchange or token refresh fails."""
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@dataclass
|
|
22
|
+
class QJAPIError(QJError):
|
|
23
|
+
"""Raised when QJ returns a non-2xx response."""
|
|
24
|
+
|
|
25
|
+
status_code: int
|
|
26
|
+
message: str
|
|
27
|
+
ref: str | None = None
|
|
28
|
+
payload: Any = None
|
|
29
|
+
|
|
30
|
+
def __str__(self) -> str:
|
|
31
|
+
suffix = f" [Ref: {self.ref}]" if self.ref else ""
|
|
32
|
+
return f"{self.status_code}: {self.message}{suffix}"
|
|
33
|
+
|
|
34
|
+
@classmethod
|
|
35
|
+
def from_response(cls, status_code: int, payload: Any) -> "QJAPIError":
|
|
36
|
+
message = "QJ API request failed"
|
|
37
|
+
ref = None
|
|
38
|
+
if isinstance(payload, dict):
|
|
39
|
+
detail = payload.get("detail") or payload.get("error") or payload.get("message")
|
|
40
|
+
if isinstance(detail, dict):
|
|
41
|
+
message = str(detail.get("message") or detail.get("error") or message)
|
|
42
|
+
ref = detail.get("ref") or detail.get("request_id")
|
|
43
|
+
elif detail:
|
|
44
|
+
message = str(detail)
|
|
45
|
+
ref = ref or payload.get("ref") or payload.get("request_id")
|
|
46
|
+
return cls(status_code=status_code, message=message, ref=ref, payload=payload)
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
"""Common response models for QJ SDKs."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from typing import Any, Mapping
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
@dataclass(frozen=True)
|
|
10
|
+
class QJResponse:
|
|
11
|
+
"""Parsed QJ response envelope.
|
|
12
|
+
|
|
13
|
+
QJ services generally return `{meta, data, page}` envelopes. This wrapper
|
|
14
|
+
keeps the raw payload available while giving SDK users a stable `.data`
|
|
15
|
+
property for the useful content.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
data: Any
|
|
19
|
+
meta: Mapping[str, Any]
|
|
20
|
+
page: Mapping[str, Any] | None
|
|
21
|
+
raw: Any
|
|
22
|
+
status_code: int | None = None
|
|
23
|
+
headers: Mapping[str, str] | None = None
|
|
24
|
+
|
|
25
|
+
@classmethod
|
|
26
|
+
def from_payload(
|
|
27
|
+
cls,
|
|
28
|
+
payload: Any,
|
|
29
|
+
*,
|
|
30
|
+
status_code: int | None = None,
|
|
31
|
+
headers: Mapping[str, str] | None = None,
|
|
32
|
+
) -> "QJResponse":
|
|
33
|
+
if isinstance(payload, dict):
|
|
34
|
+
return cls(
|
|
35
|
+
data=payload.get("data", payload),
|
|
36
|
+
meta=payload.get("meta", {}),
|
|
37
|
+
page=payload.get("page"),
|
|
38
|
+
raw=payload,
|
|
39
|
+
status_code=status_code,
|
|
40
|
+
headers=headers,
|
|
41
|
+
)
|
|
42
|
+
return cls(data=payload, meta={}, page=None, raw=payload, status_code=status_code, headers=headers)
|
|
43
|
+
|
|
44
|
+
def unwrap(self) -> Any:
|
|
45
|
+
"""Return the response data payload."""
|
|
46
|
+
return self.data
|
|
47
|
+
|
|
48
|
+
def to_pandas(self):
|
|
49
|
+
"""Convert list-like data to a pandas DataFrame when pandas is installed."""
|
|
50
|
+
try:
|
|
51
|
+
import pandas as pd
|
|
52
|
+
except ImportError as exc:
|
|
53
|
+
raise RuntimeError("Install qj-common-sdk[frames] to use to_pandas().") from exc
|
|
54
|
+
|
|
55
|
+
if isinstance(self.data, list):
|
|
56
|
+
return pd.DataFrame(self.data)
|
|
57
|
+
if isinstance(self.data, dict):
|
|
58
|
+
for key in ("rows", "items", "results", "assets", "instruments", "signals", "awards", "transactions"):
|
|
59
|
+
rows = self.data.get(key)
|
|
60
|
+
if isinstance(rows, list):
|
|
61
|
+
return pd.DataFrame(rows)
|
|
62
|
+
return pd.DataFrame([self.data])
|
|
63
|
+
return pd.DataFrame(self.data)
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
"""Pagination helpers for QJ SDK clients."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Callable, Iterator
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from .models import QJResponse
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def iter_offset_pages(
|
|
12
|
+
fetch_page: Callable[..., QJResponse],
|
|
13
|
+
*,
|
|
14
|
+
limit: int = 100,
|
|
15
|
+
offset_param: str = "offset",
|
|
16
|
+
limit_param: str = "limit",
|
|
17
|
+
max_pages: int | None = None,
|
|
18
|
+
**params: Any,
|
|
19
|
+
) -> Iterator[QJResponse]:
|
|
20
|
+
"""Iterate limit/offset pages until a page returns fewer than `limit` rows."""
|
|
21
|
+
offset = int(params.pop(offset_param, 0) or 0)
|
|
22
|
+
pages = 0
|
|
23
|
+
while True:
|
|
24
|
+
page = fetch_page(**params, **{limit_param: limit, offset_param: offset})
|
|
25
|
+
yield page
|
|
26
|
+
rows = _rows(page.data)
|
|
27
|
+
pages += 1
|
|
28
|
+
if max_pages is not None and pages >= max_pages:
|
|
29
|
+
return
|
|
30
|
+
if len(rows) < limit:
|
|
31
|
+
return
|
|
32
|
+
offset += limit
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def collect_rows(pages: Iterator[QJResponse]) -> list[Any]:
|
|
36
|
+
"""Collect rows from response pages into a single list."""
|
|
37
|
+
rows: list[Any] = []
|
|
38
|
+
for page in pages:
|
|
39
|
+
rows.extend(_rows(page.data))
|
|
40
|
+
return rows
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _rows(data: Any) -> list[Any]:
|
|
44
|
+
if isinstance(data, list):
|
|
45
|
+
return data
|
|
46
|
+
if isinstance(data, dict):
|
|
47
|
+
for key in ("rows", "items", "results", "assets", "instruments", "signals", "awards", "transactions"):
|
|
48
|
+
value = data.get(key)
|
|
49
|
+
if isinstance(value, list):
|
|
50
|
+
return value
|
|
51
|
+
return []
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
"""Small telemetry helpers for SDK request metadata."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from uuid import uuid4
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def request_id() -> str:
|
|
9
|
+
"""Return a correlation id suitable for `X-QJ-Request-ID`."""
|
|
10
|
+
return uuid4().hex
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def user_agent(package: str, version: str) -> str:
|
|
14
|
+
"""Return the canonical QJ SDK user-agent token."""
|
|
15
|
+
return f"{package}/{version}"
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: quantjourney-common-sdk
|
|
3
|
+
Version: 0.1.1
|
|
4
|
+
Summary: Shared transport and contract mechanics for QuantJourney SDKs.
|
|
5
|
+
Author: QuantJourney
|
|
6
|
+
License-Expression: LicenseRef-Proprietary
|
|
7
|
+
Requires-Python: >=3.10
|
|
8
|
+
Description-Content-Type: text/markdown
|
|
9
|
+
Requires-Dist: requests>=2.31.0
|
|
10
|
+
Provides-Extra: dev
|
|
11
|
+
Requires-Dist: pytest>=8.0.0; extra == "dev"
|
|
12
|
+
Provides-Extra: frames
|
|
13
|
+
Requires-Dist: pandas>=2.0.0; extra == "frames"
|
|
14
|
+
|
|
15
|
+
# quantjourney-common-sdk
|
|
16
|
+
|
|
17
|
+
Shared Python mechanics for QJ SDKs.
|
|
18
|
+
|
|
19
|
+
This package owns only transport-level concerns: auth/token exchange, HTTP
|
|
20
|
+
request handling, response envelopes, pagination helpers, bounded retry, and
|
|
21
|
+
small typed models. Domain clients live in SDK-specific packages such as
|
|
22
|
+
`qj.data` and `qj.api`.
|
|
23
|
+
|
|
24
|
+
Canonical package metadata:
|
|
25
|
+
|
|
26
|
+
| Field | Value |
|
|
27
|
+
| --- | --- |
|
|
28
|
+
| Repository | `_repo_qj_common_sdk` |
|
|
29
|
+
| Distribution | `quantjourney-common-sdk` |
|
|
30
|
+
| Python import | `qj.common` |
|
|
31
|
+
| Version | `0.1.1` |
|
|
32
|
+
| Git tag | `v0.1.1` |
|
|
33
|
+
| Namespace style | PEP 420 namespace package |
|
|
34
|
+
|
|
35
|
+
`quantjourney-common-sdk` must not ship `qj/__init__.py`. It is designed to
|
|
36
|
+
co-install with `quantjourney-data` and `quantjourney-api`.
|
|
37
|
+
|
|
38
|
+
Install from PyPI:
|
|
39
|
+
|
|
40
|
+
```bash
|
|
41
|
+
pip install quantjourney-common-sdk==0.1.1
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
No private Python package registry is required. Consumers should pin a compatible
|
|
45
|
+
release range in application dependencies.
|
|
46
|
+
|
|
47
|
+
Auth model:
|
|
48
|
+
|
|
49
|
+
- `QJ_*_TOKEN` is sent as a Bearer token directly.
|
|
50
|
+
- `QJ_*_API_KEY=QJ_live_*` is exchanged through `qj-auth /auth/token`.
|
|
51
|
+
- `QJ_*_CLIENT_ID` + `QJ_*_CLIENT_SECRET=QJ_svc_*` use `client_credentials`.
|
|
52
|
+
- exchanged JWTs are cached in memory until shortly before `exp`.
|
|
53
|
+
|
|
54
|
+
Transport model:
|
|
55
|
+
|
|
56
|
+
- idempotent reads retry `429/502/503/504` with bounded backoff;
|
|
57
|
+
- each request gets `X-QJ-Request-ID`;
|
|
58
|
+
- response envelopes are parsed into `QJResponse`.
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
README.md
|
|
2
|
+
pyproject.toml
|
|
3
|
+
qj/common/__init__.py
|
|
4
|
+
qj/common/auth.py
|
|
5
|
+
qj/common/client.py
|
|
6
|
+
qj/common/errors.py
|
|
7
|
+
qj/common/models.py
|
|
8
|
+
qj/common/pagination.py
|
|
9
|
+
qj/common/py.typed
|
|
10
|
+
qj/common/telemetry.py
|
|
11
|
+
quantjourney_common_sdk.egg-info/PKG-INFO
|
|
12
|
+
quantjourney_common_sdk.egg-info/SOURCES.txt
|
|
13
|
+
quantjourney_common_sdk.egg-info/dependency_links.txt
|
|
14
|
+
quantjourney_common_sdk.egg-info/requires.txt
|
|
15
|
+
quantjourney_common_sdk.egg-info/top_level.txt
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
qj
|