finctx 0.3.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.
- finctx/__init__.py +24 -0
- finctx/_version.py +3 -0
- finctx/client.py +400 -0
- finctx/credentials.py +153 -0
- finctx/data/endpoints.json +1 -0
- finctx/data/marketdb-datasets.json +124 -0
- finctx/discovery.py +70 -0
- finctx/exceptions.py +110 -0
- finctx/generated/__init__.py +5 -0
- finctx/generated/api.py +28445 -0
- finctx/marketdb.py +544 -0
- finctx/py.typed +1 -0
- finctx/results.py +44 -0
- finctx/signing.py +71 -0
- finctx-0.3.0.dist-info/METADATA +197 -0
- finctx-0.3.0.dist-info/RECORD +18 -0
- finctx-0.3.0.dist-info/WHEEL +4 -0
- finctx-0.3.0.dist-info/licenses/LICENSE +186 -0
finctx/__init__.py
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
"""Public interface for the Finctx Data API Python SDK."""
|
|
2
|
+
|
|
3
|
+
from ._version import __version__
|
|
4
|
+
from .client import FinctxClient, FinctxWebSocket
|
|
5
|
+
from .exceptions import (
|
|
6
|
+
FinctxApiError,
|
|
7
|
+
FinctxError,
|
|
8
|
+
FinctxResponseError,
|
|
9
|
+
FinctxResultError,
|
|
10
|
+
FinctxTransportError,
|
|
11
|
+
FinctxWebSocketMessageError,
|
|
12
|
+
)
|
|
13
|
+
|
|
14
|
+
__all__ = [
|
|
15
|
+
"FinctxApiError",
|
|
16
|
+
"FinctxClient",
|
|
17
|
+
"FinctxError",
|
|
18
|
+
"FinctxResponseError",
|
|
19
|
+
"FinctxResultError",
|
|
20
|
+
"FinctxTransportError",
|
|
21
|
+
"FinctxWebSocket",
|
|
22
|
+
"FinctxWebSocketMessageError",
|
|
23
|
+
"__version__",
|
|
24
|
+
]
|
finctx/_version.py
ADDED
finctx/client.py
ADDED
|
@@ -0,0 +1,400 @@
|
|
|
1
|
+
"""Synchronous Finctx Data API client."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Mapping, Sequence
|
|
6
|
+
import json as json_module
|
|
7
|
+
from typing import Any, Optional, Union
|
|
8
|
+
from urllib.parse import quote, urlsplit
|
|
9
|
+
|
|
10
|
+
import requests
|
|
11
|
+
import websocket
|
|
12
|
+
|
|
13
|
+
from .exceptions import (
|
|
14
|
+
FinctxResponseError,
|
|
15
|
+
FinctxTransportError,
|
|
16
|
+
FinctxWebSocketMessageError,
|
|
17
|
+
api_error_from_response,
|
|
18
|
+
)
|
|
19
|
+
from .credentials import resolve_api_key, resolve_base_url
|
|
20
|
+
from .discovery import catalog_document, endpoints, fields
|
|
21
|
+
from .generated.api import GeneratedApiMixin
|
|
22
|
+
from .results import arrow_to_table, rows
|
|
23
|
+
|
|
24
|
+
MAX_BODY_BYTES = 1024 * 1024
|
|
25
|
+
AUTH_HEADER_NAMES = frozenset(
|
|
26
|
+
{
|
|
27
|
+
"authorization",
|
|
28
|
+
"x-meridian-ak",
|
|
29
|
+
"x-meridian-signature",
|
|
30
|
+
"x-meridian-timestamp",
|
|
31
|
+
"x-meridian-nonce",
|
|
32
|
+
}
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
QueryValue = Optional[Union[str, bytes, int, float, bool]]
|
|
36
|
+
QueryParams = Union[
|
|
37
|
+
Mapping[str, Union[QueryValue, Sequence[QueryValue]]],
|
|
38
|
+
Sequence[tuple[str, QueryValue]],
|
|
39
|
+
]
|
|
40
|
+
RequestData = Union[
|
|
41
|
+
str,
|
|
42
|
+
bytes,
|
|
43
|
+
bytearray,
|
|
44
|
+
Mapping[str, Any],
|
|
45
|
+
Sequence[tuple[str, Any]],
|
|
46
|
+
]
|
|
47
|
+
Timeout = Union[float, tuple[float, float]]
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class FinctxWebSocket:
|
|
51
|
+
"""One connected Finctx WebSocket using the server's JSON text protocol."""
|
|
52
|
+
|
|
53
|
+
def __init__(self, connection: websocket.WebSocket) -> None:
|
|
54
|
+
self._connection = connection
|
|
55
|
+
|
|
56
|
+
def send_json(self, payload: Any) -> None:
|
|
57
|
+
"""Send one JSON text message without retrying it."""
|
|
58
|
+
|
|
59
|
+
message = json_module.dumps(
|
|
60
|
+
payload, ensure_ascii=False, separators=(",", ":"), allow_nan=False
|
|
61
|
+
)
|
|
62
|
+
try:
|
|
63
|
+
self._connection.send(message)
|
|
64
|
+
except websocket.WebSocketException as error:
|
|
65
|
+
raise FinctxTransportError(
|
|
66
|
+
"Finctx WebSocket message could not be sent", cause=error
|
|
67
|
+
) from error
|
|
68
|
+
|
|
69
|
+
def receive_json(self) -> Any:
|
|
70
|
+
"""Receive and decode one JSON text message without automatic reconnect."""
|
|
71
|
+
|
|
72
|
+
try:
|
|
73
|
+
message = self._connection.recv()
|
|
74
|
+
except websocket.WebSocketException as error:
|
|
75
|
+
raise FinctxTransportError(
|
|
76
|
+
"Finctx WebSocket message could not be received", cause=error
|
|
77
|
+
) from error
|
|
78
|
+
if not isinstance(message, str):
|
|
79
|
+
raise FinctxWebSocketMessageError(
|
|
80
|
+
"Finctx WebSocket returned a non-text message",
|
|
81
|
+
raw_message=message,
|
|
82
|
+
)
|
|
83
|
+
try:
|
|
84
|
+
return json_module.loads(message)
|
|
85
|
+
except ValueError as error:
|
|
86
|
+
raise FinctxWebSocketMessageError(
|
|
87
|
+
"Finctx WebSocket returned invalid JSON",
|
|
88
|
+
raw_message=message,
|
|
89
|
+
) from error
|
|
90
|
+
|
|
91
|
+
def close(self) -> None:
|
|
92
|
+
"""Close this WebSocket connection."""
|
|
93
|
+
|
|
94
|
+
try:
|
|
95
|
+
self._connection.close()
|
|
96
|
+
except websocket.WebSocketException as error:
|
|
97
|
+
raise FinctxTransportError(
|
|
98
|
+
"Finctx WebSocket could not be closed cleanly", cause=error
|
|
99
|
+
) from error
|
|
100
|
+
|
|
101
|
+
def __enter__(self) -> FinctxWebSocket:
|
|
102
|
+
return self
|
|
103
|
+
|
|
104
|
+
def __exit__(self, *_: object) -> None:
|
|
105
|
+
self.close()
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
class FinctxClient(GeneratedApiMixin):
|
|
109
|
+
"""A synchronous API Key client for Finctx Data API.
|
|
110
|
+
|
|
111
|
+
The client owns its :class:`requests.Session`. One client can be reused for
|
|
112
|
+
multiple calls and should be closed when it is no longer needed.
|
|
113
|
+
"""
|
|
114
|
+
|
|
115
|
+
rows = staticmethod(rows)
|
|
116
|
+
arrow_to_table = staticmethod(arrow_to_table)
|
|
117
|
+
catalog_document = staticmethod(catalog_document)
|
|
118
|
+
endpoints = staticmethod(endpoints)
|
|
119
|
+
fields = staticmethod(fields)
|
|
120
|
+
|
|
121
|
+
def __init__(
|
|
122
|
+
self,
|
|
123
|
+
*,
|
|
124
|
+
base_url: Optional[str] = None,
|
|
125
|
+
api_key: Optional[str] = None,
|
|
126
|
+
timeout: Timeout = 30.0,
|
|
127
|
+
websocket_base_url: Optional[str] = None,
|
|
128
|
+
websocket_timeout: float = 30.0,
|
|
129
|
+
) -> None:
|
|
130
|
+
self.base_url = _normalize_origin(resolve_base_url(base_url))
|
|
131
|
+
self.websocket_base_url = (
|
|
132
|
+
None
|
|
133
|
+
if websocket_base_url is None
|
|
134
|
+
else _normalize_websocket_origin(websocket_base_url)
|
|
135
|
+
)
|
|
136
|
+
self.auth_scheme = "api_key"
|
|
137
|
+
self._api_key = resolve_api_key(api_key)
|
|
138
|
+
self.timeout = timeout
|
|
139
|
+
if not isinstance(websocket_timeout, (int, float)) or websocket_timeout <= 0:
|
|
140
|
+
raise ValueError("websocket_timeout must be a positive number")
|
|
141
|
+
self.websocket_timeout = float(websocket_timeout)
|
|
142
|
+
self._session = requests.Session()
|
|
143
|
+
|
|
144
|
+
def request(
|
|
145
|
+
self,
|
|
146
|
+
method: str,
|
|
147
|
+
path: str,
|
|
148
|
+
*,
|
|
149
|
+
params: Optional[QueryParams] = None,
|
|
150
|
+
data: Optional[RequestData] = None,
|
|
151
|
+
json: Any = None,
|
|
152
|
+
headers: Optional[Mapping[str, str]] = None,
|
|
153
|
+
timeout: Optional[Timeout] = None,
|
|
154
|
+
) -> requests.Response:
|
|
155
|
+
"""Prepare, authenticate, and send one request without automatic retries.
|
|
156
|
+
|
|
157
|
+
``path`` is relative to the configured origin but must begin with
|
|
158
|
+
``/``. Redirects are intentionally not followed: a redirect could disclose
|
|
159
|
+
credential headers to another origin.
|
|
160
|
+
"""
|
|
161
|
+
|
|
162
|
+
method = _normalize_method(method)
|
|
163
|
+
_validate_request_path(path)
|
|
164
|
+
_reject_protected_headers(headers)
|
|
165
|
+
if data is not None and json is not None:
|
|
166
|
+
raise ValueError("data and json cannot be used together")
|
|
167
|
+
|
|
168
|
+
request = requests.Request(
|
|
169
|
+
method=method,
|
|
170
|
+
url=f"{self.base_url}{path}",
|
|
171
|
+
params=params,
|
|
172
|
+
data=data,
|
|
173
|
+
json=json,
|
|
174
|
+
headers=headers,
|
|
175
|
+
)
|
|
176
|
+
prepared = self._session.prepare_request(request)
|
|
177
|
+
_reject_protected_headers(prepared.headers)
|
|
178
|
+
|
|
179
|
+
body = _finalize_body(prepared)
|
|
180
|
+
if len(body) > MAX_BODY_BYTES:
|
|
181
|
+
raise ValueError(
|
|
182
|
+
f"request body exceeds the {MAX_BODY_BYTES}-byte Finctx limit"
|
|
183
|
+
)
|
|
184
|
+
|
|
185
|
+
prepared.headers["Authorization"] = f"Bearer {self._api_key}"
|
|
186
|
+
|
|
187
|
+
settings = self._session.merge_environment_settings(
|
|
188
|
+
prepared.url or "", {}, None, None, None
|
|
189
|
+
)
|
|
190
|
+
try:
|
|
191
|
+
response = self._session.send(
|
|
192
|
+
prepared,
|
|
193
|
+
timeout=self.timeout if timeout is None else timeout,
|
|
194
|
+
allow_redirects=False,
|
|
195
|
+
**settings,
|
|
196
|
+
)
|
|
197
|
+
except requests.RequestException as error:
|
|
198
|
+
raise FinctxTransportError(
|
|
199
|
+
"Finctx API request could not be completed", cause=error
|
|
200
|
+
) from error
|
|
201
|
+
|
|
202
|
+
if not 200 <= response.status_code < 300:
|
|
203
|
+
raise api_error_from_response(response)
|
|
204
|
+
return response
|
|
205
|
+
|
|
206
|
+
def _call_api(self, path: str, payload: Mapping[str, Any]) -> Any:
|
|
207
|
+
"""Decode JSON responses; preserve Arrow streams as bytes without conversion."""
|
|
208
|
+
|
|
209
|
+
response = self.request("POST", path, json=payload)
|
|
210
|
+
return self._decode_api_response(response)
|
|
211
|
+
|
|
212
|
+
def _call_get_api(self, path: str, values: Mapping[str, Any], parameters: list[dict[str, Any]]) -> Any:
|
|
213
|
+
"""按公开契约编码主题查询;GET 不带 JSON 请求体,布尔值为小写。"""
|
|
214
|
+
return self._call_parameter_api("GET", path, values, parameters, False)
|
|
215
|
+
|
|
216
|
+
def _call_parameter_api(self, method: str, path: str, values: Mapping[str, Any],
|
|
217
|
+
parameters: list[dict[str, Any]], has_body: bool) -> Any:
|
|
218
|
+
"""按契约分离路径、查询和 JSON 参数;主题标识作为单个路径段编码。"""
|
|
219
|
+
query = {}
|
|
220
|
+
body = dict(values)
|
|
221
|
+
for parameter in parameters:
|
|
222
|
+
name = parameter["name"]
|
|
223
|
+
if name not in values:
|
|
224
|
+
continue
|
|
225
|
+
value = values[name]
|
|
226
|
+
body.pop(name)
|
|
227
|
+
if parameter["in"] == "path":
|
|
228
|
+
if parameter["schema"]["type"] == "integer" and (isinstance(value, bool) or not isinstance(value, int) or value < 1):
|
|
229
|
+
raise ValueError(f"{name} must be a positive integer")
|
|
230
|
+
if parameter["schema"]["type"] == "string" and (not isinstance(value, str) or not value):
|
|
231
|
+
raise ValueError(f"{name} must be a non-empty string")
|
|
232
|
+
path = path.replace("{" + name + "}", quote(str(value), safe=""))
|
|
233
|
+
else:
|
|
234
|
+
if value is None:
|
|
235
|
+
raise ValueError(f"{name} cannot be null in a query")
|
|
236
|
+
query[name] = str(value).lower() if isinstance(value, bool) else value
|
|
237
|
+
settings = {"params": query}
|
|
238
|
+
if has_body:
|
|
239
|
+
settings["json"] = body
|
|
240
|
+
return self._decode_api_response(self.request(method, path, **settings))
|
|
241
|
+
|
|
242
|
+
@staticmethod
|
|
243
|
+
def _decode_api_response(response: requests.Response) -> Any:
|
|
244
|
+
"""保留 Arrow 字节与 JSON 错误的原始响应,供两种 HTTP 方法共用。"""
|
|
245
|
+
content_type = response.headers.get("Content-Type", "").split(";", 1)[0].strip().lower()
|
|
246
|
+
if content_type == "application/vnd.apache.arrow.stream":
|
|
247
|
+
return response.content
|
|
248
|
+
try:
|
|
249
|
+
return response.json()
|
|
250
|
+
except ValueError as error:
|
|
251
|
+
raise FinctxResponseError(
|
|
252
|
+
"Finctx API returned a successful response with invalid JSON",
|
|
253
|
+
response=response,
|
|
254
|
+
) from error
|
|
255
|
+
|
|
256
|
+
def connect_websocket(
|
|
257
|
+
self,
|
|
258
|
+
path: str,
|
|
259
|
+
*,
|
|
260
|
+
base_url: str,
|
|
261
|
+
subscription: Optional[Mapping[str, Any]] = None,
|
|
262
|
+
timeout: Optional[float] = None,
|
|
263
|
+
) -> FinctxWebSocket:
|
|
264
|
+
"""Connect to one WebSocket and optionally send its JSON subscription."""
|
|
265
|
+
|
|
266
|
+
_validate_request_path(path)
|
|
267
|
+
origin = _normalize_websocket_origin(base_url)
|
|
268
|
+
effective_timeout = self.websocket_timeout if timeout is None else timeout
|
|
269
|
+
if not isinstance(effective_timeout, (int, float)) or effective_timeout <= 0:
|
|
270
|
+
raise ValueError("timeout must be a positive number")
|
|
271
|
+
try:
|
|
272
|
+
connection = websocket.create_connection(
|
|
273
|
+
f"{origin}{path}",
|
|
274
|
+
timeout=float(effective_timeout),
|
|
275
|
+
header={"Authorization": f"Bearer {self._api_key}"},
|
|
276
|
+
)
|
|
277
|
+
except websocket.WebSocketException as error:
|
|
278
|
+
raise FinctxTransportError(
|
|
279
|
+
"Finctx WebSocket connection could not be established", cause=error
|
|
280
|
+
) from error
|
|
281
|
+
stream = FinctxWebSocket(connection)
|
|
282
|
+
if subscription is not None:
|
|
283
|
+
try:
|
|
284
|
+
stream.send_json(subscription)
|
|
285
|
+
except Exception:
|
|
286
|
+
try:
|
|
287
|
+
connection.close()
|
|
288
|
+
except websocket.WebSocketException:
|
|
289
|
+
pass
|
|
290
|
+
raise
|
|
291
|
+
return stream
|
|
292
|
+
|
|
293
|
+
def _connect_websocket(
|
|
294
|
+
self, origin: str, path: str, subscription: Mapping[str, Any]
|
|
295
|
+
) -> FinctxWebSocket:
|
|
296
|
+
return self.connect_websocket(
|
|
297
|
+
path,
|
|
298
|
+
base_url=self.websocket_base_url or origin,
|
|
299
|
+
subscription=subscription,
|
|
300
|
+
)
|
|
301
|
+
|
|
302
|
+
def close(self) -> None:
|
|
303
|
+
"""Release pooled HTTP connections."""
|
|
304
|
+
|
|
305
|
+
self._session.close()
|
|
306
|
+
|
|
307
|
+
def __enter__(self) -> FinctxClient:
|
|
308
|
+
return self
|
|
309
|
+
|
|
310
|
+
def __exit__(self, *_: object) -> None:
|
|
311
|
+
self.close()
|
|
312
|
+
|
|
313
|
+
|
|
314
|
+
def _normalize_origin(base_url: str) -> str:
|
|
315
|
+
if not isinstance(base_url, str) or not base_url:
|
|
316
|
+
raise ValueError("base_url must be a non-empty HTTP(S) origin")
|
|
317
|
+
|
|
318
|
+
parsed = urlsplit(base_url)
|
|
319
|
+
try:
|
|
320
|
+
parsed.port
|
|
321
|
+
except ValueError as error:
|
|
322
|
+
raise ValueError("base_url contains an invalid port") from error
|
|
323
|
+
|
|
324
|
+
if (
|
|
325
|
+
parsed.scheme not in {"http", "https"}
|
|
326
|
+
or not parsed.hostname
|
|
327
|
+
or parsed.username is not None
|
|
328
|
+
or parsed.password is not None
|
|
329
|
+
or parsed.path not in {"", "/"}
|
|
330
|
+
or parsed.query
|
|
331
|
+
or parsed.fragment
|
|
332
|
+
):
|
|
333
|
+
raise ValueError(
|
|
334
|
+
"base_url must be an HTTP(S) origin without path, query, fragment, or userinfo"
|
|
335
|
+
)
|
|
336
|
+
return f"{parsed.scheme}://{parsed.netloc}".rstrip("/")
|
|
337
|
+
|
|
338
|
+
|
|
339
|
+
def _normalize_websocket_origin(base_url: str) -> str:
|
|
340
|
+
if not isinstance(base_url, str) or not base_url:
|
|
341
|
+
raise ValueError("base_url must be a non-empty WebSocket origin")
|
|
342
|
+
parsed = urlsplit(base_url)
|
|
343
|
+
try:
|
|
344
|
+
parsed.port
|
|
345
|
+
except ValueError as error:
|
|
346
|
+
raise ValueError("base_url contains an invalid port") from error
|
|
347
|
+
if (
|
|
348
|
+
parsed.scheme not in {"ws", "wss"}
|
|
349
|
+
or not parsed.hostname
|
|
350
|
+
or parsed.username is not None
|
|
351
|
+
or parsed.password is not None
|
|
352
|
+
or parsed.path not in {"", "/"}
|
|
353
|
+
or parsed.query
|
|
354
|
+
or parsed.fragment
|
|
355
|
+
):
|
|
356
|
+
raise ValueError(
|
|
357
|
+
"base_url must be a WS(S) origin without path, query, fragment, or userinfo"
|
|
358
|
+
)
|
|
359
|
+
return f"{parsed.scheme}://{parsed.netloc}".rstrip("/")
|
|
360
|
+
|
|
361
|
+
|
|
362
|
+
def _normalize_method(method: str) -> str:
|
|
363
|
+
if not isinstance(method, str) or not method.strip():
|
|
364
|
+
raise ValueError("method must be a non-empty string")
|
|
365
|
+
return method.strip().upper()
|
|
366
|
+
|
|
367
|
+
|
|
368
|
+
def _validate_request_path(path: str) -> None:
|
|
369
|
+
if not isinstance(path, str) or not path.startswith("/") or path.startswith("//"):
|
|
370
|
+
raise ValueError("path must begin with one '/' and be relative to base_url")
|
|
371
|
+
parsed = urlsplit(path)
|
|
372
|
+
if parsed.scheme or parsed.netloc or parsed.fragment:
|
|
373
|
+
raise ValueError("path must be a relative API path without a fragment")
|
|
374
|
+
|
|
375
|
+
|
|
376
|
+
def _reject_protected_headers(headers: Optional[Mapping[str, str]]) -> None:
|
|
377
|
+
if not headers:
|
|
378
|
+
return
|
|
379
|
+
overridden = AUTH_HEADER_NAMES.intersection(str(name).lower() for name in headers)
|
|
380
|
+
if overridden:
|
|
381
|
+
names = ", ".join(sorted(overridden))
|
|
382
|
+
raise ValueError(f"Finctx authentication headers are managed by the SDK: {names}")
|
|
383
|
+
|
|
384
|
+
|
|
385
|
+
def _finalize_body(prepared: requests.PreparedRequest) -> bytes:
|
|
386
|
+
raw_body = prepared.body
|
|
387
|
+
if raw_body is None:
|
|
388
|
+
return b""
|
|
389
|
+
if isinstance(raw_body, str):
|
|
390
|
+
body = raw_body.encode("utf-8")
|
|
391
|
+
elif isinstance(raw_body, (bytes, bytearray)):
|
|
392
|
+
body = bytes(raw_body)
|
|
393
|
+
else:
|
|
394
|
+
raise TypeError("streaming and file-like request bodies are not supported")
|
|
395
|
+
|
|
396
|
+
# Pin the final UTF-8 payload and its byte length for urllib3.
|
|
397
|
+
prepared.body = body
|
|
398
|
+
prepared.headers["Content-Length"] = str(len(body))
|
|
399
|
+
prepared.headers.pop("Transfer-Encoding", None)
|
|
400
|
+
return body
|
finctx/credentials.py
ADDED
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
"""Resolve local API credentials without executing shell syntax."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
import stat
|
|
8
|
+
import subprocess
|
|
9
|
+
from typing import Optional
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
ENV_KEY = "FINCTX_API_KEY"
|
|
13
|
+
ENV_BASE_URL = "FINCTX_BASE_URL"
|
|
14
|
+
DEFAULT_BASE_URL = "https://api.ultrastiching.com"
|
|
15
|
+
_MAX_CREDENTIALS_BYTES = 64 * 1024
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def resolve_api_key(explicit: Optional[str]) -> str:
|
|
19
|
+
"""Use an explicit key, then the environment, then the user's private file."""
|
|
20
|
+
|
|
21
|
+
if explicit is not None:
|
|
22
|
+
return _require_value("api_key", explicit)
|
|
23
|
+
if ENV_KEY in os.environ:
|
|
24
|
+
return _require_value(ENV_KEY, os.environ[ENV_KEY])
|
|
25
|
+
key = _read_credentials_file()
|
|
26
|
+
if key is None:
|
|
27
|
+
raise ValueError(
|
|
28
|
+
"api_key is required: pass api_key, set FINCTX_API_KEY, "
|
|
29
|
+
"or create ~/.finctx/credentials.env"
|
|
30
|
+
)
|
|
31
|
+
return _require_value(ENV_KEY, key)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def resolve_base_url(explicit: Optional[str]) -> str:
|
|
35
|
+
"""Use an explicit origin, then the environment, then production."""
|
|
36
|
+
|
|
37
|
+
if explicit is not None:
|
|
38
|
+
return explicit
|
|
39
|
+
if ENV_BASE_URL in os.environ:
|
|
40
|
+
return os.environ[ENV_BASE_URL]
|
|
41
|
+
return DEFAULT_BASE_URL
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _require_value(name: str, value: str) -> str:
|
|
45
|
+
if not isinstance(value, str) or not value.strip() or "\n" in value or "\r" in value:
|
|
46
|
+
raise ValueError(f"{name} must be a non-empty single-line string")
|
|
47
|
+
if value != value.strip():
|
|
48
|
+
raise ValueError(f"{name} must not contain surrounding whitespace")
|
|
49
|
+
if '"' in value or "'" in value:
|
|
50
|
+
raise ValueError(
|
|
51
|
+
f"{name} must not contain quotes; quotes in credentials.env are file syntax, "
|
|
52
|
+
"not part of the Key. Set FINCTX_API_KEY to the unquoted value or remove it to use ~/.finctx/credentials.env."
|
|
53
|
+
)
|
|
54
|
+
return value
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _current_uid() -> Optional[int]:
|
|
58
|
+
getuid = getattr(os, "getuid", None)
|
|
59
|
+
return getuid() if getuid is not None else None
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _read_credentials_file() -> Optional[str]:
|
|
63
|
+
directory = Path.home() / ".finctx"
|
|
64
|
+
try:
|
|
65
|
+
directory_info = directory.lstat()
|
|
66
|
+
except FileNotFoundError:
|
|
67
|
+
return None
|
|
68
|
+
uid = _current_uid()
|
|
69
|
+
if not stat.S_ISDIR(directory_info.st_mode):
|
|
70
|
+
raise ValueError("~/.finctx must be a directory, not a link or file")
|
|
71
|
+
if uid is None:
|
|
72
|
+
_check_windows_acl(directory)
|
|
73
|
+
elif directory_info.st_uid != uid or directory_info.st_mode & 0o022:
|
|
74
|
+
raise ValueError("~/.finctx must be owned by the current user and not writable by others")
|
|
75
|
+
|
|
76
|
+
path = directory / "credentials.env"
|
|
77
|
+
flags = os.O_RDONLY
|
|
78
|
+
flags |= getattr(os, "O_CLOEXEC", 0)
|
|
79
|
+
flags |= getattr(os, "O_NONBLOCK", 0)
|
|
80
|
+
nofollow = getattr(os, "O_NOFOLLOW", None)
|
|
81
|
+
if nofollow is None and os.name != "nt":
|
|
82
|
+
raise OSError("credentials.env requires no-follow file access on this platform")
|
|
83
|
+
flags |= nofollow or 0
|
|
84
|
+
try:
|
|
85
|
+
before = path.lstat()
|
|
86
|
+
except FileNotFoundError:
|
|
87
|
+
return None
|
|
88
|
+
if not stat.S_ISREG(before.st_mode):
|
|
89
|
+
raise OSError("credentials.env must be a regular file, not a link")
|
|
90
|
+
if uid is None:
|
|
91
|
+
_check_windows_acl(path)
|
|
92
|
+
try:
|
|
93
|
+
fd = os.open(path, flags)
|
|
94
|
+
except FileNotFoundError:
|
|
95
|
+
return None
|
|
96
|
+
try:
|
|
97
|
+
info = os.fstat(fd)
|
|
98
|
+
if (not stat.S_ISREG(info.st_mode) or info.st_dev != before.st_dev or info.st_ino != before.st_ino
|
|
99
|
+
or uid is not None and (info.st_uid != uid or info.st_mode & 0o077)):
|
|
100
|
+
raise ValueError(
|
|
101
|
+
"~/.finctx/credentials.env must be a regular file owned by "
|
|
102
|
+
"the current user with mode 0600 or stricter"
|
|
103
|
+
)
|
|
104
|
+
with os.fdopen(fd, encoding="utf-8") as handle:
|
|
105
|
+
fd = -1
|
|
106
|
+
content = handle.read(_MAX_CREDENTIALS_BYTES + 1)
|
|
107
|
+
finally:
|
|
108
|
+
if fd >= 0:
|
|
109
|
+
os.close(fd)
|
|
110
|
+
if len(content.encode("utf-8")) > _MAX_CREDENTIALS_BYTES:
|
|
111
|
+
raise ValueError("~/.finctx/credentials.env exceeds 64 KiB")
|
|
112
|
+
|
|
113
|
+
found: Optional[str] = None
|
|
114
|
+
for line in content.splitlines():
|
|
115
|
+
item = line.strip()
|
|
116
|
+
if not item or item.startswith("#"):
|
|
117
|
+
continue
|
|
118
|
+
if item.startswith("export "):
|
|
119
|
+
item = item[7:].lstrip()
|
|
120
|
+
name, separator, value = item.partition("=")
|
|
121
|
+
if name.strip() != ENV_KEY:
|
|
122
|
+
continue
|
|
123
|
+
if not separator:
|
|
124
|
+
raise ValueError("invalid FINCTX_API_KEY entry in credentials.env")
|
|
125
|
+
if found is not None:
|
|
126
|
+
raise ValueError("duplicate FINCTX_API_KEY entries in credentials.env")
|
|
127
|
+
value = value.strip()
|
|
128
|
+
if value.startswith(("'", '"')):
|
|
129
|
+
if len(value) < 2 or value[-1] != value[0]:
|
|
130
|
+
raise ValueError("invalid quoted FINCTX_API_KEY in credentials.env")
|
|
131
|
+
value = value[1:-1]
|
|
132
|
+
found = value
|
|
133
|
+
return found
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def _check_windows_acl(path: Path) -> None:
|
|
137
|
+
"""Match the CLI's Windows private-file rules without reading a secret in PowerShell."""
|
|
138
|
+
if os.name != "nt":
|
|
139
|
+
raise OSError("cannot verify credential ownership on this platform")
|
|
140
|
+
literal = "'" + str(path).replace("'", "''") + "'"
|
|
141
|
+
script = (
|
|
142
|
+
"$ErrorActionPreference='Stop'; $p=" + literal + ";"
|
|
143
|
+
"$sid=[System.Security.Principal.WindowsIdentity]::GetCurrent().User;"
|
|
144
|
+
"$acl=Get-Acl -LiteralPath $p;"
|
|
145
|
+
"if($acl.GetOwner([System.Security.Principal.SecurityIdentifier]).Value -ne $sid.Value)"
|
|
146
|
+
"{throw 'credentials must be owned by current user'};"
|
|
147
|
+
"foreach($rule in $acl.GetAccessRules($true,$true,[System.Security.Principal.SecurityIdentifier]))"
|
|
148
|
+
"{if($rule.AccessControlType -eq 'Allow' -and $rule.IdentityReference.Value -notin "
|
|
149
|
+
"@($sid.Value,'S-1-5-18','S-1-5-32-544') -and ([int]$rule.FileSystemRights -band 2032127))"
|
|
150
|
+
"{throw 'credentials ACL grants access to other users'}}"
|
|
151
|
+
)
|
|
152
|
+
subprocess.run(["powershell.exe", "-NoProfile", "-NonInteractive", "-Command", script],
|
|
153
|
+
check=True, stdout=subprocess.DEVNULL, stderr=subprocess.PIPE)
|