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 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
@@ -0,0 +1,3 @@
1
+ """Single source of truth for the Finctx Python SDK version."""
2
+
3
+ __version__ = "0.3.0"
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)