fxmacrodata-platform-core 0.1.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.
@@ -0,0 +1,4 @@
1
+ """Customer-side adapters for documented FXMacroData public contracts."""
2
+ from .core import Batch, COLUMNS, IntegrationError, operations, read, schemas, validate_request
3
+
4
+ __all__ = ["Batch", "COLUMNS", "IntegrationError", "operations", "read", "schemas", "validate_request"]
@@ -0,0 +1,268 @@
1
+ """Lossless result envelopes and bounded customer-side reads."""
2
+ from __future__ import annotations
3
+
4
+ from copy import deepcopy
5
+ from dataclasses import dataclass
6
+ import hashlib
7
+ from importlib.resources import files
8
+ import json
9
+ import math
10
+ import os
11
+ import re
12
+ from typing import Any
13
+
14
+ import jsonschema
15
+ from fxmacrodata_public import FXMacroDataClient, Operation, Result
16
+ from fxmacrodata_public.client import SCHEMA_REGISTRY
17
+ from fxmacrodata_public.transport import redact_text
18
+ from fxmacrodata_public.pagination import iter_pages
19
+
20
+ COLUMNS = ("operation", "source_url", "record_id", "currency", "indicator", "date",
21
+ "announcement_datetime", "value", "value_text", "record_json", "response_json")
22
+ _FORBIDDEN = {"apikey", "accesstoken", "authorization", "password", "clientsecret",
23
+ "secret", "token", "url", "baseurl", "endpoint"}
24
+
25
+
26
+ class IntegrationError(RuntimeError):
27
+ """A credential-free configuration or read failure."""
28
+
29
+
30
+ def operations() -> tuple[Operation, ...]:
31
+ base = files(__package__)
32
+ rest = json.loads(base.joinpath("operations.json").read_text(encoding="utf-8"))
33
+ mcp = json.loads(base.joinpath("mcp-tools.json").read_text(encoding="utf-8"))
34
+ result = [Operation(**{**item, "parameters": tuple(item.get("parameters", []))}) for item in rest]
35
+ result.extend(Operation("mcp_" + t["name"], t.get("description", t["name"]),
36
+ t["inputSchema"], "/mcp", "MCP") for t in mcp)
37
+ return tuple(result)
38
+
39
+
40
+ def schemas() -> dict[str, dict]:
41
+ return {op.name: deepcopy(op.input_schema) for op in operations()}
42
+
43
+
44
+ def _json(value: Any) -> str:
45
+ return json.dumps(value, sort_keys=True, ensure_ascii=False, separators=(",", ":"), allow_nan=False)
46
+
47
+
48
+ def _text(value: Any) -> str | None:
49
+ return None if value is None else str(value)
50
+
51
+
52
+ @dataclass(frozen=True)
53
+ class Batch:
54
+ """Original public response and additive tabular projections."""
55
+ operation: str
56
+ payload: Any
57
+ source_url: str = "https://fxmacrodata.com"
58
+
59
+ def records(self) -> list[dict]:
60
+ if isinstance(self.payload, dict) and isinstance(self.payload.get("versions"), list):
61
+ return deepcopy(self.payload["versions"])
62
+ if self.operation == "mcp_calendar_event_versions":
63
+ page = _cursor_page(self.payload)
64
+ if isinstance(page.get("versions"), list):
65
+ return deepcopy(page["versions"])
66
+ return Result(self.operation, self.payload, self.source_url).records()
67
+
68
+ def rows(self) -> list[dict]:
69
+ response = _json(self.payload)
70
+ output = []
71
+ for record in self.records() or [None]:
72
+ values = record if isinstance(record, dict) else {}
73
+ value = values.get("value", values.get("val"))
74
+ numeric = None
75
+ if value is not None and not isinstance(value, bool):
76
+ try:
77
+ parsed = float(value)
78
+ if math.isfinite(parsed):
79
+ numeric = parsed
80
+ except (TypeError, ValueError, OverflowError):
81
+ pass
82
+ encoded = _json(record)
83
+ identity = _json([self.operation, record if record is not None else self.payload])
84
+ output.append(dict(zip(COLUMNS, (
85
+ self.operation, self.source_url, hashlib.sha256(identity.encode()).hexdigest(),
86
+ _text(values.get("currency")), _text(values.get("indicator")),
87
+ _text(values.get("date")), _text(values.get("announcement_datetime")),
88
+ numeric, _text(value), encoded, response,
89
+ ))))
90
+ return output
91
+
92
+
93
+ def _validate(value: Any) -> None:
94
+ if isinstance(value, dict):
95
+ for key, item in value.items():
96
+ if not isinstance(key, str) or re.sub(r"[_-]", "", key.lower()) in _FORBIDDEN:
97
+ raise IntegrationError("Use a runtime secret reference; credentials and endpoint overrides are not arguments.")
98
+ _validate(item)
99
+ elif isinstance(value, list):
100
+ for item in value:
101
+ _validate(item)
102
+
103
+
104
+ def _redact(value: Any, secret: str) -> Any:
105
+ if isinstance(value, str):
106
+ return redact_text(value, secret)
107
+ if isinstance(value, list):
108
+ return [_redact(item, secret) for item in value]
109
+ if isinstance(value, dict):
110
+ return {_redact(str(k), secret): "[redacted]" if re.sub(r"[_-]", "", str(k).lower()) in _FORBIDDEN - {"url", "baseurl", "endpoint"}
111
+ else _redact(v, secret) for k, v in value.items()}
112
+ return value
113
+
114
+
115
+ def validate_request(operation: str, arguments: dict | None = None) -> dict:
116
+ """Validate before a lazy source or pipeline can serialize arguments."""
117
+ inventory = {item.name: item for item in operations()}
118
+ if not isinstance(operation, str) or operation not in inventory:
119
+ raise IntegrationError("Unknown operation; inspect schemas() for supported operations.")
120
+ if arguments is not None and not isinstance(arguments, dict):
121
+ raise IntegrationError("Operation arguments must be an object.")
122
+ args = deepcopy(arguments or {})
123
+ _validate(args)
124
+ try:
125
+ jsonschema.Draft202012Validator(inventory[operation].input_schema, registry=SCHEMA_REGISTRY).validate(args)
126
+ except Exception:
127
+ raise IntegrationError("Invalid arguments; inspect the operation's input schema.") from None
128
+ return args
129
+
130
+
131
+ class _HeaderClient(FXMacroDataClient):
132
+ """Use public API-key headers rather than credentials in request URLs."""
133
+ def _auth_params(self):
134
+ return {}
135
+
136
+ def _request(self, method, url, *, headers=None, **kwargs):
137
+ headers = dict(headers or {})
138
+ if self._api_key:
139
+ headers["X-API-Key"] = self._api_key
140
+ return super()._request(method, url, headers=headers, **kwargs)
141
+
142
+
143
+ def _cursor_page(payload):
144
+ if isinstance(payload, dict) and "checkpoint" in payload:
145
+ return payload
146
+ if isinstance(payload, dict):
147
+ for name in ("structuredContent", "result"):
148
+ if isinstance(payload.get(name), dict):
149
+ return _cursor_page(payload[name])
150
+ for block in payload.get("content", []):
151
+ if isinstance(block, dict) and block.get("type") == "text":
152
+ try:
153
+ candidate = json.loads(block["text"])
154
+ except (KeyError, TypeError, ValueError):
155
+ continue
156
+ if isinstance(candidate, dict) and "checkpoint" in candidate:
157
+ return candidate
158
+ raise IntegrationError("Calendar response omitted its archive checkpoint.")
159
+
160
+
161
+ def _cursor_history(client, operation, args, max_pages):
162
+ if args.get("cursor"):
163
+ raise IntegrationError("Complete calendar history starts without a cursor.")
164
+ field = "versions" if "event_versions" in operation else "events"
165
+ query, pages, rows, seen = dict(args), [], [], set()
166
+ checkpoint = args.get("checkpoint")
167
+ source = "https://fxmacrodata.com"
168
+ for _ in range(max_pages):
169
+ result = client.execute(operation, query)
170
+ page = _cursor_page(result.payload)
171
+ current = page.get("checkpoint")
172
+ if type(current) is not int or (checkpoint is not None and current != checkpoint):
173
+ raise IntegrationError("Calendar archive checkpoint changed; restart the read.")
174
+ checkpoint = current
175
+ data = page.get(field)
176
+ if not isinstance(data, list):
177
+ raise IntegrationError("Calendar response omitted its record array.")
178
+ pages.append(deepcopy(result.payload))
179
+ rows.extend(deepcopy(data))
180
+ source = result.source_url
181
+ cursor = page.get("next_cursor")
182
+ if cursor is None:
183
+ return Result(operation, {field: rows, "complete": True, "checkpoint": checkpoint,
184
+ "next_cursor": None, "pages": pages}, source)
185
+ if not isinstance(cursor, str) or not cursor or cursor in seen or not data:
186
+ raise IntegrationError("Calendar cursor did not advance; history is incomplete.")
187
+ seen.add(cursor)
188
+ query.update(cursor=cursor, checkpoint=checkpoint)
189
+ raise IntegrationError("Calendar history exceeded the explicit page budget.")
190
+
191
+
192
+ def _offset_history(client, operation, args, max_pages):
193
+ pages, rows, source = [], [], "https://fxmacrodata.com"
194
+ query = dict(args)
195
+ query.pop("page", None)
196
+ def fetch(arguments):
197
+ nonlocal source
198
+ result = client.execute(operation, arguments)
199
+ source = result.source_url
200
+ pages.append(deepcopy(result.payload))
201
+ return result.payload
202
+ checkpoint = None
203
+ for page in iter_pages(fetch, query, max_pages=max_pages, request_id=operation, require_pagination=True):
204
+ rows.extend(deepcopy(page["data"]))
205
+ checkpoint = page["client_resume"]
206
+ return Result(operation, {"data":rows, "complete":True, "pages":pages,
207
+ "client_resume":checkpoint}, source)
208
+
209
+
210
+ def read(operation: str, arguments: dict | None = None, *, api_key_env: str | None = None,
211
+ complete_history: bool = True, max_pages: int = 10000, client_factory=None) -> Batch:
212
+ """Read a documented operation without serializing or logging a credential.
213
+
214
+ Omitting api_key_env explicitly selects anonymous access, even when an
215
+ unrelated API key exists in the environment. Every result retains its
216
+ original envelope; empty results still have one metadata envelope row.
217
+ """
218
+ inventory = {item.name: item for item in operations()}
219
+ if not isinstance(operation, str) or operation not in inventory:
220
+ raise IntegrationError("Unknown operation; inspect schemas() for supported operations.")
221
+ if arguments is not None and not isinstance(arguments, dict):
222
+ raise IntegrationError("Operation arguments must be an object.")
223
+ args = validate_request(operation, arguments)
224
+ if type(max_pages) is not int or not 1 <= max_pages <= 10000 or type(complete_history) is not bool:
225
+ raise IntegrationError("Use a boolean history option and page budget between 1 and 10000.")
226
+ secret = ""
227
+ if api_key_env is not None:
228
+ if not isinstance(api_key_env, str) or not re.fullmatch(r"[A-Z][A-Z0-9_]{0,127}", api_key_env):
229
+ raise IntegrationError("Use an environment-variable name for the API key.")
230
+ secret = os.environ.get(api_key_env, "")
231
+ if not secret:
232
+ raise IntegrationError("The named API-key environment variable is unavailable.")
233
+ selected = inventory[operation]
234
+ try:
235
+ jsonschema.Draft202012Validator(selected.input_schema, registry=SCHEMA_REGISTRY).validate(args)
236
+ except Exception:
237
+ raise IntegrationError("Invalid arguments; inspect the operation's input schema.") from None
238
+ paginated = selected.method == "GET" and any(p["name"] == "offset" for p in selected.parameters)
239
+ if complete_history and paginated and (args.get("offset", 0) != 0 or args.get("page", 1) != 1):
240
+ raise IntegrationError("Complete history starts at offset zero; use complete_history=False for an individual page.")
241
+ factory = client_factory or _HeaderClient
242
+ client = None
243
+ try:
244
+ client = factory(api_key=secret)
245
+ # The dependency is a public client with a smaller packaged catalogue.
246
+ # Supplement its public descriptors without importing service code.
247
+ if isinstance(client, FXMacroDataClient):
248
+ client._operations = inventory
249
+ if complete_history and operation in {"calendar_snapshot", "calendar_event_versions", "mcp_calendar_snapshot", "mcp_calendar_event_versions"}:
250
+ result = _cursor_history(client, operation, args, max_pages)
251
+ elif complete_history and paginated:
252
+ result = _offset_history(client, operation, args, max_pages)
253
+ else:
254
+ result = client.execute(operation, args)
255
+ payload = _redact(deepcopy(result.payload), secret)
256
+ # Fail closed on invalid JSON rather than storing platform-specific guesses.
257
+ _json(payload)
258
+ return Batch(operation, payload, _redact(result.source_url, secret))
259
+ except IntegrationError:
260
+ raise
261
+ except Exception:
262
+ raise IntegrationError("FXMacroData read failed; check credentials, operation arguments and service availability.") from None
263
+ finally:
264
+ if client is not None:
265
+ try:
266
+ client.close()
267
+ except Exception:
268
+ pass