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.
@@ -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,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,7 @@
1
+ requests>=2.31.0
2
+
3
+ [dev]
4
+ pytest>=8.0.0
5
+
6
+ [frames]
7
+ pandas>=2.0.0
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+