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/__init__.py +61 -0
- stx/_async_client.py +923 -0
- stx/_base.py +64 -0
- stx/_client.py +502 -0
- stx/_config.py +107 -0
- stx/_http.py +131 -0
- stx/_money.py +122 -0
- stx/_operations.py +259 -0
- stx/_paging.py +59 -0
- stx/_results.py +29 -0
- stx/_retry.py +80 -0
- stx/_settings.py +203 -0
- stx/_signing.py +229 -0
- stx/_version.py +12 -0
- stx/_ws.py +1016 -0
- stx/enums.py +13 -0
- stx/exceptions.py +156 -0
- stx/models.py +2034 -0
- stx/py.typed +0 -0
- stx_python-0.6.0.dist-info/METADATA +121 -0
- stx_python-0.6.0.dist-info/RECORD +24 -0
- stx_python-0.6.0.dist-info/WHEEL +5 -0
- stx_python-0.6.0.dist-info/licenses/LICENSE +21 -0
- stx_python-0.6.0.dist-info/top_level.txt +1 -0
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__}"
|