stx-python 0.6.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
stx/_settings.py ADDED
@@ -0,0 +1,203 @@
1
+ """Layered configuration for the STX client.
2
+
3
+ Precedence, highest first:
4
+
5
+ 1. Constructor kwargs passed to ``STX(...)``.
6
+ 2. Environment variables: ``STX_HOST`` (or ``STX_BASE_URL``),
7
+ ``STX_REGION``, ``STX_ENV``, ``STX_KEY_ID``, ``STX_PRIVATE_KEY``,
8
+ ``STX_VERIFY_TLS``, ``STX_PROFILE``.
9
+ 3. Named profile in ``~/.stx/credentials`` (an INI file; ``STX_CREDENTIALS``
10
+ points elsewhere). The profile to use comes from the ``profile=`` kwarg,
11
+ then ``STX_PROFILE``, then ``default``. Profile keys: ``region``,
12
+ ``env``, ``host``, ``key_id``, ``private_key`` (PEM text or a path),
13
+ ``verify_tls``. The file is shared with the STX API examples, so
14
+ ``key_file`` is accepted as an alias of ``private_key`` and ``base_url``
15
+ as an alias of ``host``.
16
+ 4. Baked-in defaults (``verify_tls=True``, ``profile="default"``).
17
+
18
+ This module resolves everything into a single :class:`ResolvedConfig`
19
+ that the clients consume.
20
+ """
21
+
22
+ from __future__ import annotations
23
+
24
+ import configparser
25
+ import os
26
+ from dataclasses import dataclass
27
+ from pathlib import Path
28
+ from typing import Optional, Union
29
+
30
+ from stx.enums import Environment, Region
31
+ from stx.exceptions import STXConfigException
32
+
33
+ _DEFAULT_PROFILE_NAME = "default"
34
+
35
+
36
+ def _default_profile_file(env_map) -> Path:
37
+ override = env_map.get("STX_CREDENTIALS")
38
+ if override:
39
+ return Path(os.path.expanduser(override))
40
+ return Path.home() / ".stx" / "credentials"
41
+
42
+
43
+ _TRUTHY = frozenset({"1", "true", "yes", "on"})
44
+ _FALSY = frozenset({"0", "false", "no", "off"})
45
+
46
+
47
+ def _parse_bool(value: Optional[str], key: str) -> Optional[bool]:
48
+ """Parse a string boolean.
49
+
50
+ Returns None if ``value`` is None *or* an empty/whitespace string -
51
+ treating unset and blank the same way. ``STX_VERIFY_TLS=`` (set but
52
+ empty, which some shell tooling produces) falls through to the
53
+ profile/default layer instead of silently disabling TLS verification.
54
+ """
55
+ if value is None:
56
+ return None
57
+ lower = value.strip().lower()
58
+ if not lower:
59
+ return None
60
+ if lower in _TRUTHY:
61
+ return True
62
+ if lower in _FALSY:
63
+ return False
64
+ raise STXConfigException(
65
+ f"Invalid boolean for {key}: {value!r}. Use one of {sorted(_TRUTHY | _FALSY)}."
66
+ )
67
+
68
+
69
+ @dataclass
70
+ class ResolvedConfig:
71
+ """The final, fully-resolved configuration the STX client uses.
72
+
73
+ Exactly one of ``host`` or (``region``, ``env``) will be set.
74
+ """
75
+
76
+ host: Optional[str]
77
+ region: Optional[Union[Region, str]]
78
+ env: Optional[Union[Environment, str]]
79
+ verify_tls: bool
80
+ profile_name: str
81
+ profile_used: bool
82
+ key_id: Optional[str] = None
83
+ private_key: Optional[str] = None
84
+
85
+
86
+ def _read_profile(
87
+ profile_name: str,
88
+ profile_file: Optional[Path] = None,
89
+ ) -> tuple[dict, bool]:
90
+ """Load ``[profile_name]`` from the credentials file.
91
+
92
+ Returns (values, was_file_present). Raises ``STXConfigException`` if the
93
+ file exists but the requested profile doesn't - callers asked for
94
+ something that isn't there.
95
+ """
96
+ path = profile_file or _default_profile_file(os.environ)
97
+ if not path.is_file():
98
+ return ({}, False)
99
+ parser = configparser.ConfigParser()
100
+ try:
101
+ parser.read(path, encoding="utf-8")
102
+ except configparser.Error as exc:
103
+ raise STXConfigException(f"Failed to parse credentials file {path}: {exc}") from exc
104
+ if not parser.has_section(profile_name):
105
+ if profile_name == _DEFAULT_PROFILE_NAME:
106
+ return ({}, True)
107
+ raise STXConfigException(
108
+ f"Profile {profile_name!r} not found in {path}. Available profiles: {parser.sections()}"
109
+ )
110
+ return ({k: v for k, v in parser.items(profile_name)}, True)
111
+
112
+
113
+ _SENTINEL = object()
114
+
115
+
116
+ def resolve_settings(
117
+ *,
118
+ region=_SENTINEL,
119
+ env=_SENTINEL,
120
+ host=_SENTINEL,
121
+ key_id=_SENTINEL,
122
+ private_key=_SENTINEL,
123
+ verify_tls=_SENTINEL,
124
+ profile=_SENTINEL,
125
+ profile_file: Optional[Path] = None,
126
+ environ: Optional[dict] = None,
127
+ ) -> ResolvedConfig:
128
+ """Merge constructor kwargs, env vars, and the active profile.
129
+
130
+ ``_SENTINEL`` means "the caller did not pass this kwarg." Use sentinels
131
+ instead of ``None`` because ``None`` is a legitimate explicit value for
132
+ some knobs (e.g. ``host=None`` to ignore a host in the profile).
133
+
134
+ The ``environ`` and ``profile_file`` params are injection points for
135
+ tests; production callers leave them unset.
136
+ """
137
+ env_map = environ if environ is not None else os.environ
138
+
139
+ # --- Step 1: which profile? ---
140
+ if profile is _SENTINEL:
141
+ profile_name = env_map.get("STX_PROFILE", _DEFAULT_PROFILE_NAME)
142
+ else:
143
+ profile_name = profile or _DEFAULT_PROFILE_NAME
144
+
145
+ profile_values, profile_file_existed = _read_profile(
146
+ profile_name, profile_file or _default_profile_file(env_map)
147
+ )
148
+ # Only treat the profile as "in use" if the user asked for a non-default
149
+ # section, or a default section was actually present.
150
+ profile_used = (
151
+ (profile is not _SENTINEL and profile is not None)
152
+ or "STX_PROFILE" in env_map
153
+ or (profile_file_existed and bool(profile_values))
154
+ )
155
+
156
+ def _pick(kwarg, env_keys, *profile_keys: str):
157
+ """Apply the precedence chain for a single knob.
158
+
159
+ Several ``profile_keys`` may be given; the first one present in
160
+ the profile wins. Used for the aliases the shared credentials
161
+ file allows (``key_file`` for ``private_key``, ``base_url`` for
162
+ ``host``).
163
+ """
164
+ if kwarg is not _SENTINEL:
165
+ return kwarg
166
+ for env_key in env_keys if isinstance(env_keys, tuple) else (env_keys,):
167
+ if env_map.get(env_key):
168
+ return env_map[env_key]
169
+ for key in profile_keys:
170
+ if key in profile_values:
171
+ return profile_values[key]
172
+ return None
173
+
174
+ host_v = _pick(host, ("STX_HOST", "STX_BASE_URL"), "host", "base_url")
175
+ region_v = _pick(region, "STX_REGION", "region")
176
+ env_v = _pick(env, "STX_ENV", "env")
177
+ key_id_v = _pick(key_id, "STX_KEY_ID", "key_id")
178
+ private_key_v = _pick(private_key, "STX_PRIVATE_KEY", "private_key", "key_file")
179
+
180
+ # verify_tls: bool with explicit-False support. The kwarg sentinel lets us
181
+ # distinguish "not passed" from "passed as False".
182
+ if verify_tls is not _SENTINEL:
183
+ verify_tls_v: bool = bool(verify_tls)
184
+ else:
185
+ env_bool = _parse_bool(env_map.get("STX_VERIFY_TLS"), "STX_VERIFY_TLS")
186
+ if env_bool is not None:
187
+ verify_tls_v = env_bool
188
+ elif "verify_tls" in profile_values:
189
+ parsed = _parse_bool(profile_values["verify_tls"], "verify_tls")
190
+ verify_tls_v = parsed if parsed is not None else True
191
+ else:
192
+ verify_tls_v = True
193
+
194
+ return ResolvedConfig(
195
+ host=host_v,
196
+ region=region_v,
197
+ env=env_v,
198
+ verify_tls=verify_tls_v,
199
+ profile_name=profile_name,
200
+ profile_used=profile_used,
201
+ key_id=key_id_v,
202
+ private_key=private_key_v,
203
+ )
stx/_signing.py ADDED
@@ -0,0 +1,229 @@
1
+ """API-key request signing (Ed25519).
2
+
3
+ An STX API key is a key id plus an Ed25519 private key. Every request
4
+ carries three headers built from a fresh timestamp:
5
+
6
+ X-STX-ACCESS-KEY the key id
7
+ X-STX-ACCESS-TIMESTAMP Unix time in milliseconds, as a decimal string
8
+ X-STX-ACCESS-SIGNATURE base64 (standard alphabet, padded) Ed25519
9
+ signature over the UTF-8 bytes of the message
10
+
11
+ The signed message is a bare concatenation with no separator:
12
+
13
+ timestamp_ms + HTTP_METHOD_UPPERCASE + request_path
14
+
15
+ The request body is deliberately not signed. The path includes its
16
+ query string when there is one, never the scheme or host. The WebSocket
17
+ handshake is the exception: it signs ``GET`` and the socket path with
18
+ the query string dropped.
19
+
20
+ This module holds no transport code. It builds messages, signs them and
21
+ formats headers; the sync, async and WebSocket clients attach the
22
+ headers in whatever way their transport supports.
23
+ """
24
+
25
+ from __future__ import annotations
26
+
27
+ import base64
28
+ import os
29
+ import time
30
+ from pathlib import Path
31
+ from typing import Callable, Dict, Optional, Union
32
+ from urllib.parse import urlparse
33
+
34
+ from stx.exceptions import STXConfigException
35
+
36
+ KEY_HEADER = "X-STX-ACCESS-KEY"
37
+ TIMESTAMP_HEADER = "X-STX-ACCESS-TIMESTAMP"
38
+ SIGNATURE_HEADER = "X-STX-ACCESS-SIGNATURE"
39
+
40
+ WEBSOCKET_PATH = "/socket/websocket"
41
+
42
+ Signer = Callable[[bytes], bytes]
43
+
44
+ _PEM_MARKER = "-----BEGIN"
45
+
46
+
47
+ def build_message(timestamp_ms: int, method: str, path: str) -> str:
48
+ """The string that gets signed: ``timestamp + METHOD + path``.
49
+
50
+ ``method`` is upper-cased because the server signs the upper form.
51
+ ``path`` is used verbatim; the caller decides whether the query
52
+ string belongs in it (HTTP: yes, WebSocket handshake: no).
53
+ """
54
+ if not method:
55
+ raise ValueError("HTTP method is required.")
56
+ if not path:
57
+ raise ValueError("Request path is required.")
58
+ return f"{timestamp_ms}{method.upper()}{path}"
59
+
60
+
61
+ def request_path(url: str) -> str:
62
+ """Path plus query string of ``url``, as the HTTP signature wants it."""
63
+ parsed = urlparse(url)
64
+ path = parsed.path or "/"
65
+ if parsed.query:
66
+ return f"{path}?{parsed.query}"
67
+ return path
68
+
69
+
70
+ def handshake_path(url: str) -> str:
71
+ """Path of ``url`` with the query string dropped, as the WebSocket
72
+ handshake signature wants it."""
73
+ return urlparse(url).path or "/"
74
+
75
+
76
+ def _looks_like_pem(value: str) -> bool:
77
+ return _PEM_MARKER in value
78
+
79
+
80
+ def load_private_key(value: Union[str, bytes, os.PathLike[str]]):
81
+ """Parse a PKCS#8 Ed25519 private key.
82
+
83
+ Accepts the PEM text itself, the PEM as bytes, or a path to a PEM
84
+ file (``~`` is expanded). Returns a
85
+ ``cryptography.hazmat.primitives.asymmetric.ed25519.Ed25519PrivateKey``.
86
+ """
87
+ try:
88
+ from cryptography.hazmat.primitives import serialization
89
+ from cryptography.hazmat.primitives.asymmetric.ed25519 import (
90
+ Ed25519PrivateKey,
91
+ )
92
+ except ImportError as exc: # pragma: no cover - dependency is declared
93
+ raise STXConfigException(
94
+ "API-key authentication needs the `cryptography` package. "
95
+ "Run `pip install cryptography` (it is a declared dependency of "
96
+ "stx-python, so a fresh install already has it)."
97
+ ) from exc
98
+
99
+ if isinstance(value, bytes):
100
+ pem_bytes = value
101
+ else:
102
+ text = os.fspath(value) if not isinstance(value, str) else value
103
+ if _looks_like_pem(text):
104
+ pem_bytes = text.encode("utf-8")
105
+ else:
106
+ path = Path(os.path.expanduser(text))
107
+ if not path.is_file():
108
+ raise STXConfigException(
109
+ f"Private key not found at {path}. Pass either the path to "
110
+ "a PEM file or the PEM text itself."
111
+ )
112
+ pem_bytes = path.read_bytes()
113
+
114
+ try:
115
+ key = serialization.load_pem_private_key(pem_bytes, password=None)
116
+ except (ValueError, TypeError) as exc:
117
+ raise STXConfigException(
118
+ "Private key is not valid PEM. Expected a PKCS#8 block beginning "
119
+ "'-----BEGIN PRIVATE KEY-----'." # pragma: allowlist secret
120
+ ) from exc
121
+ if not isinstance(key, Ed25519PrivateKey):
122
+ raise STXConfigException("Private key is not an Ed25519 key. STX API keys use Ed25519.")
123
+ return key
124
+
125
+
126
+ def signer_from_private_key(value: Union[str, bytes, os.PathLike[str]]) -> Signer:
127
+ """Parse the key once and return a ``bytes -> bytes`` signer.
128
+
129
+ Pure Ed25519 over the message bytes. Not Ed25519ph, which produces
130
+ a well-formed signature that never verifies server-side.
131
+ """
132
+ key = load_private_key(value)
133
+ return key.sign
134
+
135
+
136
+ class ApiKeyCredentials:
137
+ """An STX API key: the key id sent with every request, and the means
138
+ of signing.
139
+
140
+ Build one from a PEM string or path::
141
+
142
+ ApiKeyCredentials("my-key-id", "~/.stx/us-demo.pem")
143
+
144
+ or from a caller-supplied signer, for keys held in an HSM or KMS::
145
+
146
+ ApiKeyCredentials("my-key-id", signer=lambda msg: kms.sign(msg))
147
+
148
+ The signer receives the message bytes and must return the raw
149
+ 64-byte Ed25519 signature. The private key never leaves the process;
150
+ only signatures are transmitted.
151
+ """
152
+
153
+ __slots__ = ("_sign", "key_id")
154
+
155
+ def __init__(
156
+ self,
157
+ key_id: str,
158
+ private_key: Union[str, bytes, os.PathLike[str], None] = None,
159
+ *,
160
+ signer: Optional[Signer] = None,
161
+ ) -> None:
162
+ if not key_id or not str(key_id).strip():
163
+ raise STXConfigException("API key id is required.")
164
+ if signer is None and private_key is None:
165
+ raise STXConfigException(
166
+ "Either `private_key` (PEM text or path) or `signer` is required."
167
+ )
168
+ if signer is not None and private_key is not None:
169
+ raise STXConfigException("Pass `private_key` or `signer`, not both.")
170
+ self.key_id = str(key_id).strip()
171
+ self._sign: Signer = signer if signer is not None else signer_from_private_key(private_key)
172
+
173
+ def sign(self, message: bytes) -> bytes:
174
+ """Raw Ed25519 signature over ``message``."""
175
+ return self._sign(message)
176
+
177
+ def headers(
178
+ self,
179
+ method: str,
180
+ path: str,
181
+ timestamp_ms: Optional[int] = None,
182
+ ) -> Dict[str, str]:
183
+ """The three signed headers for one request.
184
+
185
+ A fresh timestamp is generated per call unless ``timestamp_ms``
186
+ is given (tests). Signatures must never be cached or reused: the
187
+ server rejects anything more than 30 seconds from its clock.
188
+ """
189
+ ts = int(time.time() * 1000) if timestamp_ms is None else int(timestamp_ms)
190
+ message = build_message(ts, method, path).encode("utf-8")
191
+ signature = base64.b64encode(self.sign(message)).decode("ascii")
192
+ return {
193
+ KEY_HEADER: self.key_id,
194
+ TIMESTAMP_HEADER: str(ts),
195
+ SIGNATURE_HEADER: signature,
196
+ }
197
+
198
+ def __repr__(self) -> str:
199
+ return f"ApiKeyCredentials(key_id={self.key_id!r})"
200
+
201
+
202
+ def credentials_from_settings(
203
+ key_id: Optional[str],
204
+ private_key: Union[str, bytes, os.PathLike[str], None],
205
+ signer: Optional[Signer] = None,
206
+ ) -> Optional[ApiKeyCredentials]:
207
+ """Turn the resolved settings into credentials, or ``None`` when no
208
+ API key is configured at all.
209
+
210
+ ``None`` is allowed so a client can be built for the public market
211
+ channels, which accept an unsigned socket. A REST call without a key
212
+ raises ``STXConfigException``. A key id without key material, or key
213
+ material without a key id, raises here.
214
+ """
215
+ has_material = private_key is not None or signer is not None
216
+ if not key_id and not has_material:
217
+ return None
218
+ if key_id and not has_material:
219
+ raise STXConfigException(
220
+ "key_id is set but no private key was given. Pass `private_key=` "
221
+ "(PEM text or path), set STX_PRIVATE_KEY, or add `private_key` to "
222
+ "the profile in ~/.stx/credentials."
223
+ )
224
+ if has_material and not key_id:
225
+ raise STXConfigException(
226
+ "A private key was given but no key_id. Pass `key_id=`, set "
227
+ "STX_KEY_ID, or add `key_id` to the profile in ~/.stx/credentials."
228
+ )
229
+ return ApiKeyCredentials(key_id, private_key, signer=signer)
stx/_version.py ADDED
@@ -0,0 +1,12 @@
1
+ """Package version and the User-Agent sent with every request."""
2
+
3
+ from __future__ import annotations
4
+
5
+ try:
6
+ from importlib.metadata import PackageNotFoundError, version
7
+
8
+ __version__ = version("stx-python")
9
+ except PackageNotFoundError: # running from a source tree without install
10
+ __version__ = "0.0.0+unknown"
11
+
12
+ USER_AGENT = f"stx-python/{__version__}"