fxmacrodata-public-client 0.1.1__tar.gz
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.
- fxmacrodata_public_client-0.1.1/LICENSE +21 -0
- fxmacrodata_public_client-0.1.1/PKG-INFO +56 -0
- fxmacrodata_public_client-0.1.1/README.md +41 -0
- fxmacrodata_public_client-0.1.1/fxmacrodata_public/__init__.py +8 -0
- fxmacrodata_public_client-0.1.1/fxmacrodata_public/client.py +517 -0
- fxmacrodata_public_client-0.1.1/fxmacrodata_public/mcp-tools.json +4052 -0
- fxmacrodata_public_client-0.1.1/fxmacrodata_public/operations.json +5083 -0
- fxmacrodata_public_client-0.1.1/fxmacrodata_public/pagination.py +134 -0
- fxmacrodata_public_client-0.1.1/fxmacrodata_public/py.typed +0 -0
- fxmacrodata_public_client-0.1.1/fxmacrodata_public/research.py +209 -0
- fxmacrodata_public_client-0.1.1/fxmacrodata_public/transport.py +80 -0
- fxmacrodata_public_client-0.1.1/fxmacrodata_public_client.egg-info/PKG-INFO +56 -0
- fxmacrodata_public_client-0.1.1/fxmacrodata_public_client.egg-info/SOURCES.txt +18 -0
- fxmacrodata_public_client-0.1.1/fxmacrodata_public_client.egg-info/dependency_links.txt +1 -0
- fxmacrodata_public_client-0.1.1/fxmacrodata_public_client.egg-info/requires.txt +3 -0
- fxmacrodata_public_client-0.1.1/fxmacrodata_public_client.egg-info/top_level.txt +1 -0
- fxmacrodata_public_client-0.1.1/pyproject.toml +26 -0
- fxmacrodata_public_client-0.1.1/setup.cfg +4 -0
- fxmacrodata_public_client-0.1.1/tests/test_client.py +523 -0
- fxmacrodata_public_client-0.1.1/tests/test_research.py +210 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Robert Tidball
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: fxmacrodata-public-client
|
|
3
|
+
Version: 0.1.1
|
|
4
|
+
Summary: Public FXMacroData REST and MCP client for native integrations
|
|
5
|
+
License-Expression: MIT
|
|
6
|
+
Project-URL: Homepage, https://fxmacrodata.com/?utm_source=pypi&utm_medium=referral&utm_campaign=public-client&utm_content=homepage
|
|
7
|
+
Project-URL: Documentation, https://fxmacrodata.com/documentation/reference?utm_source=pypi&utm_medium=referral&utm_campaign=public-client&utm_content=documentation
|
|
8
|
+
Requires-Python: >=3.10
|
|
9
|
+
Description-Content-Type: text/markdown
|
|
10
|
+
License-File: LICENSE
|
|
11
|
+
Requires-Dist: requests<3,>=2.32
|
|
12
|
+
Requires-Dist: jsonschema<5,>=4.23
|
|
13
|
+
Requires-Dist: certifi>=2026.10.23
|
|
14
|
+
Dynamic: license-file
|
|
15
|
+
|
|
16
|
+
# FXMacroData public integration client
|
|
17
|
+
|
|
18
|
+
Connect Python applications and native integrations to FXMacroData's official macroeconomic history, release calendars and market data through one REST and MCP client. Results retain the original response and provide an additional tabular view.
|
|
19
|
+
|
|
20
|
+
[Subscribe to FXMacroData](https://fxmacrodata.com/subscribe?utm_source=github&utm_medium=referral&utm_campaign=open_source_integrations&utm_content=public_client_subscribe) for non-USD indicator history, full available history, FX, commodities and positioning. Use the public USD workflow to evaluate the integration before connecting your subscription.
|
|
21
|
+
|
|
22
|
+
The packaged discovery snapshot includes 29 documented REST operations and 50 hosted MCP tools. MCP discovery can refresh the tool inventory. The research-panel operation accepts its documented JSON request as `body`; all other REST arguments retain their documented parameter names.
|
|
23
|
+
|
|
24
|
+
Read [FXMacroData documentation](https://fxmacrodata.com/documentation/reference?utm_source=github&utm_medium=referral&utm_campaign=open_source_integrations&utm_content=public_client_docs) or visit [FXMacroData](https://fxmacrodata.com/?utm_source=github&utm_medium=referral&utm_campaign=open_source_integrations&utm_content=public_client_readme).
|
|
25
|
+
|
|
26
|
+
Install from the package directory or its built wheel:
|
|
27
|
+
|
|
28
|
+
```sh
|
|
29
|
+
python -m pip install .
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
```python
|
|
33
|
+
from fxmacrodata_public import FXMacroDataClient
|
|
34
|
+
|
|
35
|
+
with FXMacroDataClient() as client:
|
|
36
|
+
result = client.execute("release_calendar", {"currency": "usd"})
|
|
37
|
+
rows = result.records()
|
|
38
|
+
original_response = result.payload
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
For evaluation, the USD catalogue, recent macro history and calendar work without an API key. Anonymous indicator history currently covers a rolling 90-day window. Set `FXMACRODATA_API_KEY` or pass `api_key` to the constructor to connect your subscription for protected data access. Credentials are excluded from operation schemas and errors. Requests send X-API-Key only in headers, without following redirects. There are no tracking calls; attribution parameters appear only on website links.
|
|
42
|
+
|
|
43
|
+
The client redacts credential echoes in results and its requests/urllib3 diagnostics. Its own HTTPS connections keep wire debugging disabled even when the application enables the global `http.client` debug flag. Injected sessions remain the caller's responsibility. Keep credentials in user-managed environment variables or a host secret store; never put them in prompts or shared workflow files.
|
|
44
|
+
|
|
45
|
+
Use `list_operations()` for all REST and MCP input schemas; MCP operation names begin with `mcp_`. For example, `client.execute("mcp_seasonality", {"instrument": "eur_usd"})` calls the hosted seasonality tool when the account has access. `client.discover_mcp_tools()` refreshes the hosted catalogue.
|
|
46
|
+
|
|
47
|
+
`stream_events` is a finite capture bounded by `max_events` and `max_seconds`. It retains SSE IDs, event types and original data. No events does not imply there were no economic releases. The records projection is for presentation; use `.payload` whenever the complete response metadata or original nesting is required.
|
|
48
|
+
|
|
49
|
+
Forecast sources, units, dates and publication timestamps are preserved. FX reference rates are not executable broker quotes. The client never infers missing values or future release dates.
|
|
50
|
+
|
|
51
|
+
For historical research, use `history_path` with `revisions="all"`,
|
|
52
|
+
`series_mode="raw"` and `value_mode="source"`. Every page must explicitly confirm
|
|
53
|
+
source values and no normalization; unsupported responses raise an error.
|
|
54
|
+
`align_macro` selects only independently evidenced vintages before each decision,
|
|
55
|
+
retains the original reference period and excludes unknown publication timing.
|
|
56
|
+
Always inspect source units before combining different indicators or countries.
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
# FXMacroData public integration client
|
|
2
|
+
|
|
3
|
+
Connect Python applications and native integrations to FXMacroData's official macroeconomic history, release calendars and market data through one REST and MCP client. Results retain the original response and provide an additional tabular view.
|
|
4
|
+
|
|
5
|
+
[Subscribe to FXMacroData](https://fxmacrodata.com/subscribe?utm_source=github&utm_medium=referral&utm_campaign=open_source_integrations&utm_content=public_client_subscribe) for non-USD indicator history, full available history, FX, commodities and positioning. Use the public USD workflow to evaluate the integration before connecting your subscription.
|
|
6
|
+
|
|
7
|
+
The packaged discovery snapshot includes 29 documented REST operations and 50 hosted MCP tools. MCP discovery can refresh the tool inventory. The research-panel operation accepts its documented JSON request as `body`; all other REST arguments retain their documented parameter names.
|
|
8
|
+
|
|
9
|
+
Read [FXMacroData documentation](https://fxmacrodata.com/documentation/reference?utm_source=github&utm_medium=referral&utm_campaign=open_source_integrations&utm_content=public_client_docs) or visit [FXMacroData](https://fxmacrodata.com/?utm_source=github&utm_medium=referral&utm_campaign=open_source_integrations&utm_content=public_client_readme).
|
|
10
|
+
|
|
11
|
+
Install from the package directory or its built wheel:
|
|
12
|
+
|
|
13
|
+
```sh
|
|
14
|
+
python -m pip install .
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
```python
|
|
18
|
+
from fxmacrodata_public import FXMacroDataClient
|
|
19
|
+
|
|
20
|
+
with FXMacroDataClient() as client:
|
|
21
|
+
result = client.execute("release_calendar", {"currency": "usd"})
|
|
22
|
+
rows = result.records()
|
|
23
|
+
original_response = result.payload
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
For evaluation, the USD catalogue, recent macro history and calendar work without an API key. Anonymous indicator history currently covers a rolling 90-day window. Set `FXMACRODATA_API_KEY` or pass `api_key` to the constructor to connect your subscription for protected data access. Credentials are excluded from operation schemas and errors. Requests send X-API-Key only in headers, without following redirects. There are no tracking calls; attribution parameters appear only on website links.
|
|
27
|
+
|
|
28
|
+
The client redacts credential echoes in results and its requests/urllib3 diagnostics. Its own HTTPS connections keep wire debugging disabled even when the application enables the global `http.client` debug flag. Injected sessions remain the caller's responsibility. Keep credentials in user-managed environment variables or a host secret store; never put them in prompts or shared workflow files.
|
|
29
|
+
|
|
30
|
+
Use `list_operations()` for all REST and MCP input schemas; MCP operation names begin with `mcp_`. For example, `client.execute("mcp_seasonality", {"instrument": "eur_usd"})` calls the hosted seasonality tool when the account has access. `client.discover_mcp_tools()` refreshes the hosted catalogue.
|
|
31
|
+
|
|
32
|
+
`stream_events` is a finite capture bounded by `max_events` and `max_seconds`. It retains SSE IDs, event types and original data. No events does not imply there were no economic releases. The records projection is for presentation; use `.payload` whenever the complete response metadata or original nesting is required.
|
|
33
|
+
|
|
34
|
+
Forecast sources, units, dates and publication timestamps are preserved. FX reference rates are not executable broker quotes. The client never infers missing values or future release dates.
|
|
35
|
+
|
|
36
|
+
For historical research, use `history_path` with `revisions="all"`,
|
|
37
|
+
`series_mode="raw"` and `value_mode="source"`. Every page must explicitly confirm
|
|
38
|
+
source values and no normalization; unsupported responses raise an error.
|
|
39
|
+
`align_macro` selects only independently evidenced vintages before each decision,
|
|
40
|
+
retains the original reference period and excludes unknown publication timing.
|
|
41
|
+
Always inspect source units before combining different indicators or countries.
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
"""Documented public client contracts for FXMacroData integrations."""
|
|
2
|
+
from .client import FXMacroDataClient, FXMacroDataError, Operation, Result, list_operations
|
|
3
|
+
from .pagination import IncompleteHistoryError, DatasetChangedError
|
|
4
|
+
from .research import align_macro, macro_events, macro_updates, prepare_events, PointInTimeError
|
|
5
|
+
|
|
6
|
+
__all__ = ["FXMacroDataClient", "FXMacroDataError", "Operation", "Result", "list_operations",
|
|
7
|
+
"IncompleteHistoryError", "DatasetChangedError", "align_macro", "macro_events",
|
|
8
|
+
"macro_updates", "prepare_events", "PointInTimeError"]
|
|
@@ -0,0 +1,517 @@
|
|
|
1
|
+
"""Bounded REST and MCP access to the public FXMacroData service."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from copy import deepcopy
|
|
5
|
+
from contextlib import contextmanager
|
|
6
|
+
from dataclasses import dataclass, field
|
|
7
|
+
from importlib.resources import files
|
|
8
|
+
import json
|
|
9
|
+
import math
|
|
10
|
+
import os
|
|
11
|
+
import re
|
|
12
|
+
import socket
|
|
13
|
+
import threading
|
|
14
|
+
import time
|
|
15
|
+
from typing import Any, Iterator
|
|
16
|
+
from urllib.parse import quote
|
|
17
|
+
|
|
18
|
+
import jsonschema
|
|
19
|
+
from referencing import Registry
|
|
20
|
+
from referencing.exceptions import NoSuchResource, Unresolvable
|
|
21
|
+
import requests
|
|
22
|
+
from .transport import CredentialSafeAdapter, protected_diagnostics, redact_text
|
|
23
|
+
from .pagination import collect_history, paginated_history_path
|
|
24
|
+
|
|
25
|
+
API_ORIGIN = "https://api.fxmacrodata.com"
|
|
26
|
+
MCP_URL = "https://mcp.fxmacrodata.com/mcp"
|
|
27
|
+
WEBSITE = "https://fxmacrodata.com"
|
|
28
|
+
MAX_RESPONSE_BYTES = 10 * 1024 * 1024
|
|
29
|
+
MCP_PROTOCOL_VERSION = "2025-03-26"
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _no_remote_schema(uri: str) -> Any:
|
|
33
|
+
"""Input validation must never fetch a schema from another network origin."""
|
|
34
|
+
raise NoSuchResource(ref=uri)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
SCHEMA_REGISTRY = Registry(retrieve=_no_remote_schema)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class FXMacroDataError(RuntimeError):
|
|
41
|
+
"""An actionable error which never includes request URLs or credentials."""
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
@dataclass(frozen=True)
|
|
45
|
+
class Operation:
|
|
46
|
+
name: str
|
|
47
|
+
description: str
|
|
48
|
+
input_schema: dict[str, Any]
|
|
49
|
+
path: str
|
|
50
|
+
method: str
|
|
51
|
+
parameters: tuple[dict[str, Any], ...] = field(default_factory=tuple)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def list_operations(include_mcp: bool = True) -> tuple[Operation, ...]:
|
|
55
|
+
"""Return the complete packaged public operation inventory and input schemas."""
|
|
56
|
+
raw = json.loads(files(__package__).joinpath("operations.json").read_text(encoding="utf-8"))
|
|
57
|
+
operations = [Operation(**{**item, "parameters": tuple(item.get("parameters", []))}) for item in raw]
|
|
58
|
+
if include_mcp:
|
|
59
|
+
tools = json.loads(files(__package__).joinpath("mcp-tools.json").read_text(encoding="utf-8"))
|
|
60
|
+
operations.extend(Operation("mcp_" + tool["name"], tool.get("description", tool["name"]), tool["inputSchema"], "/mcp", "MCP") for tool in tools)
|
|
61
|
+
return tuple(operations)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _records(payload: Any) -> list[dict[str, Any]]:
|
|
65
|
+
if isinstance(payload, list):
|
|
66
|
+
return [deepcopy(row) if isinstance(row, dict) else {"value": deepcopy(row)} for row in payload]
|
|
67
|
+
if not isinstance(payload, dict):
|
|
68
|
+
return [] if payload is None else [{"value": deepcopy(payload)}]
|
|
69
|
+
for key in ("structuredContent", "result", "data", "rows", "events", "announcements", "predictions", "prices", "commodities", "results", "series", "indicators"):
|
|
70
|
+
if key in payload and isinstance(payload[key], (dict, list)):
|
|
71
|
+
value = payload[key]
|
|
72
|
+
if isinstance(value, dict) and value and all(isinstance(v, dict) for v in value.values()):
|
|
73
|
+
return [{"record_key": k, "record": deepcopy(v)} for k, v in value.items()]
|
|
74
|
+
return _records(value)
|
|
75
|
+
# MCP text is rendered as text unless it contains a structured JSON result.
|
|
76
|
+
if isinstance(payload.get("content"), list):
|
|
77
|
+
rows = []
|
|
78
|
+
for block in payload["content"]:
|
|
79
|
+
if isinstance(block, dict) and block.get("type") == "text":
|
|
80
|
+
try:
|
|
81
|
+
rows.extend(_records(json.loads(block["text"])))
|
|
82
|
+
except (ValueError, KeyError):
|
|
83
|
+
rows.append({"text": block.get("text", "")})
|
|
84
|
+
elif isinstance(block, dict):
|
|
85
|
+
rows.append(deepcopy(block))
|
|
86
|
+
return rows
|
|
87
|
+
return [deepcopy(payload)] if payload else []
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
@dataclass(frozen=True)
|
|
91
|
+
class Result:
|
|
92
|
+
"""Original response plus an additive tabular view; no source fields renamed."""
|
|
93
|
+
operation: str
|
|
94
|
+
payload: Any
|
|
95
|
+
source_url: str = WEBSITE
|
|
96
|
+
|
|
97
|
+
def records(self) -> list[dict[str, Any]]:
|
|
98
|
+
return _records(self.payload)
|
|
99
|
+
|
|
100
|
+
def as_dict(self) -> dict[str, Any]:
|
|
101
|
+
return {"operation": self.operation, "data": deepcopy(self.payload), "records": self.records(), "source_url": self.source_url}
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
class FXMacroDataClient:
|
|
105
|
+
"""Fixed-origin client with user-supplied authentication and safe errors.
|
|
106
|
+
|
|
107
|
+
API keys belong in the constructor or FXMACRODATA_API_KEY / FXMD_API_KEY.
|
|
108
|
+
They are never model-visible operation arguments. This client emits no telemetry.
|
|
109
|
+
"""
|
|
110
|
+
|
|
111
|
+
def __init__(self, api_key: str | None = None, timeout: float = 30, *, session: requests.Session | None = None):
|
|
112
|
+
if api_key is not None and not isinstance(api_key, str):
|
|
113
|
+
raise FXMacroDataError("The optional API key must be a string.")
|
|
114
|
+
try:
|
|
115
|
+
timeout_value = float(timeout)
|
|
116
|
+
if not math.isfinite(timeout_value):
|
|
117
|
+
raise ValueError
|
|
118
|
+
except (ValueError, TypeError, OverflowError):
|
|
119
|
+
raise FXMacroDataError("The request timeout must be a finite number.") from None
|
|
120
|
+
self._api_key = api_key if api_key is not None else (os.getenv("FXMACRODATA_API_KEY") or os.getenv("FXMD_API_KEY") or "")
|
|
121
|
+
self.timeout = min(max(timeout_value, 1), 120)
|
|
122
|
+
self._session = session or requests.Session()
|
|
123
|
+
self._owns_session = session is None
|
|
124
|
+
if self._owns_session:
|
|
125
|
+
adapter = CredentialSafeAdapter()
|
|
126
|
+
self._session.mount(API_ORIGIN + "/", adapter)
|
|
127
|
+
self._session.mount("https://mcp.fxmacrodata.com/", adapter)
|
|
128
|
+
self._operations = {operation.name: operation for operation in list_operations()}
|
|
129
|
+
self._mcp_headers: dict[str, str] | None = None
|
|
130
|
+
self._request_id = 0
|
|
131
|
+
# requests.Session and MCP initialization both carry mutable state.
|
|
132
|
+
# Serialize a shared client's calls; independent clients can run in parallel.
|
|
133
|
+
self._lock = threading.RLock()
|
|
134
|
+
|
|
135
|
+
def __repr__(self) -> str:
|
|
136
|
+
return f"FXMacroDataClient(authenticated={bool(self._api_key)}, timeout={self.timeout})"
|
|
137
|
+
|
|
138
|
+
def __enter__(self) -> FXMacroDataClient:
|
|
139
|
+
return self
|
|
140
|
+
|
|
141
|
+
def __exit__(self, *_: Any) -> None:
|
|
142
|
+
self.close()
|
|
143
|
+
|
|
144
|
+
def close(self) -> None:
|
|
145
|
+
with self._lock, protected_diagnostics(self._api_key):
|
|
146
|
+
headers, self._mcp_headers = self._mcp_headers, None
|
|
147
|
+
if headers and headers.get("Mcp-Session-Id"):
|
|
148
|
+
response = None
|
|
149
|
+
try:
|
|
150
|
+
response = self._request("DELETE", MCP_URL, params=self._auth_params(),
|
|
151
|
+
headers=headers, timeout=min(self.timeout, 2))
|
|
152
|
+
except Exception:
|
|
153
|
+
# Closing a session is best effort and must not mask a tool
|
|
154
|
+
# result or expose an underlying request/credential error.
|
|
155
|
+
pass
|
|
156
|
+
finally:
|
|
157
|
+
if response is not None:
|
|
158
|
+
response.close()
|
|
159
|
+
if self._owns_session:
|
|
160
|
+
self._session.close()
|
|
161
|
+
|
|
162
|
+
def list_operations(self, include_mcp: bool = True) -> tuple[Operation, ...]:
|
|
163
|
+
with self._lock:
|
|
164
|
+
return tuple(deepcopy(op) for op in self._operations.values() if include_mcp or op.method != "MCP")
|
|
165
|
+
|
|
166
|
+
def _safe(self, value: Any) -> Any:
|
|
167
|
+
if isinstance(value, str):
|
|
168
|
+
return redact_text(value, self._api_key)
|
|
169
|
+
if isinstance(value, list):
|
|
170
|
+
return [self._safe(x) for x in value]
|
|
171
|
+
if isinstance(value, dict):
|
|
172
|
+
secret_fields = {"apikey", "accesstoken", "authorization", "proxyauthorization", "password", "clientsecret", "secret", "token"}
|
|
173
|
+
return {self._safe(k): "[redacted]" if re.sub(r"[_-]", "", k.lower()) in secret_fields else self._safe(v) for k, v in value.items()}
|
|
174
|
+
return value
|
|
175
|
+
|
|
176
|
+
@staticmethod
|
|
177
|
+
def _status(response: requests.Response) -> None:
|
|
178
|
+
if 200 <= response.status_code < 300:
|
|
179
|
+
return
|
|
180
|
+
if response.status_code in {401, 403}:
|
|
181
|
+
detail = "This dataset requires an authorized FXMacroData API key. Public USD catalogue, history and calendar remain available."
|
|
182
|
+
elif response.status_code == 429:
|
|
183
|
+
detail = "FXMacroData rate limit reached. Retry later."
|
|
184
|
+
elif response.status_code == 404:
|
|
185
|
+
detail = "The requested FXMacroData resource is unavailable. Check catalogue discovery."
|
|
186
|
+
else:
|
|
187
|
+
detail = f"FXMacroData request failed (HTTP {response.status_code})."
|
|
188
|
+
raise FXMacroDataError(detail)
|
|
189
|
+
|
|
190
|
+
def _request(self, method: str, url: str, *, params: dict[str, Any] | None = None, headers: dict[str, str] | None = None, body: dict[str, Any] | None = None, timeout: float | None = None) -> requests.Response:
|
|
191
|
+
headers = dict(headers or {})
|
|
192
|
+
if self._api_key:
|
|
193
|
+
headers["X-API-Key"] = self._api_key
|
|
194
|
+
try:
|
|
195
|
+
with protected_diagnostics(self._api_key):
|
|
196
|
+
return self._session.request(method, url, params=params, headers=headers, json=body, timeout=timeout or self.timeout, allow_redirects=False, stream=True)
|
|
197
|
+
except (requests.RequestException, ValueError):
|
|
198
|
+
raise FXMacroDataError("Unable to reach FXMacroData within the request timeout. Retry later.") from None
|
|
199
|
+
|
|
200
|
+
def _bounded_lines(self, response: requests.Response, deadline: float, *, stop_at_deadline: bool = False) -> Iterator[bytes]:
|
|
201
|
+
seen_bytes = 0
|
|
202
|
+
pending = bytearray()
|
|
203
|
+
skip_lf = False
|
|
204
|
+
# iter_lines only yields after a delimiter, so a long undelimited frame
|
|
205
|
+
# could evade both byte and elapsed-time checks. Bound raw bytes first.
|
|
206
|
+
for chunk in response.iter_content(chunk_size=1):
|
|
207
|
+
if not isinstance(chunk, bytes):
|
|
208
|
+
raise FXMacroDataError("FXMacroData returned an invalid event stream.")
|
|
209
|
+
seen_bytes += len(chunk)
|
|
210
|
+
if seen_bytes > MAX_RESPONSE_BYTES:
|
|
211
|
+
raise FXMacroDataError("FXMacroData response exceeded the bounded response budget.")
|
|
212
|
+
if time.monotonic() > deadline:
|
|
213
|
+
if stop_at_deadline:
|
|
214
|
+
return
|
|
215
|
+
raise FXMacroDataError("FXMacroData response exceeded the bounded response budget.")
|
|
216
|
+
for value in chunk:
|
|
217
|
+
if skip_lf and value == 10:
|
|
218
|
+
skip_lf = False
|
|
219
|
+
continue
|
|
220
|
+
skip_lf = value == 13
|
|
221
|
+
if value in (10, 13):
|
|
222
|
+
yield bytes(pending)
|
|
223
|
+
pending.clear()
|
|
224
|
+
else:
|
|
225
|
+
pending.append(value)
|
|
226
|
+
|
|
227
|
+
@staticmethod
|
|
228
|
+
@contextmanager
|
|
229
|
+
def _response_deadline(response: requests.Response, deadline: float):
|
|
230
|
+
"""Interrupt this response's blocked read when its capture budget ends."""
|
|
231
|
+
expired = threading.Event()
|
|
232
|
+
|
|
233
|
+
def expire() -> None:
|
|
234
|
+
expired.set()
|
|
235
|
+
# Closing a buffered reader alone can wait on its active read lock.
|
|
236
|
+
# Shut down only this response's socket first, which wakes that read.
|
|
237
|
+
raw = getattr(response, "raw", None)
|
|
238
|
+
connection = getattr(raw, "connection", None)
|
|
239
|
+
sock = getattr(connection, "sock", None)
|
|
240
|
+
if sock is None:
|
|
241
|
+
fp = getattr(getattr(raw, "_fp", None), "fp", None)
|
|
242
|
+
sock = getattr(getattr(fp, "raw", None), "_sock", None)
|
|
243
|
+
if sock is not None:
|
|
244
|
+
try:
|
|
245
|
+
sock.shutdown(socket.SHUT_RDWR)
|
|
246
|
+
except (OSError, ValueError):
|
|
247
|
+
pass
|
|
248
|
+
try:
|
|
249
|
+
response.close()
|
|
250
|
+
except Exception:
|
|
251
|
+
pass
|
|
252
|
+
|
|
253
|
+
timer = threading.Timer(max(0, deadline - time.monotonic()), expire)
|
|
254
|
+
timer.daemon = True
|
|
255
|
+
timer.start()
|
|
256
|
+
try:
|
|
257
|
+
yield expired
|
|
258
|
+
finally:
|
|
259
|
+
timer.cancel()
|
|
260
|
+
|
|
261
|
+
def _read_json(self, response: requests.Response, *, response_id: int | None = None) -> Any:
|
|
262
|
+
try:
|
|
263
|
+
self._status(response)
|
|
264
|
+
deadline = time.monotonic() + self.timeout
|
|
265
|
+
if "text/event-stream" in response.headers.get("Content-Type", "").lower():
|
|
266
|
+
with self._response_deadline(response, deadline):
|
|
267
|
+
for event in self._parse_sse(self._bounded_lines(response, deadline)):
|
|
268
|
+
data = event["data"]
|
|
269
|
+
if (isinstance(data, dict) and data.get("jsonrpc") == "2.0"
|
|
270
|
+
and data.get("id") == response_id and ("result" in data or "error" in data)):
|
|
271
|
+
# Streamable HTTP may remain open after delivering this
|
|
272
|
+
# response. Do not wait for EOF or a later keepalive.
|
|
273
|
+
return data
|
|
274
|
+
raise FXMacroDataError("FXMacroData returned no matching MCP response.")
|
|
275
|
+
chunks = bytearray()
|
|
276
|
+
with self._response_deadline(response, deadline):
|
|
277
|
+
for chunk in response.iter_content(chunk_size=65536):
|
|
278
|
+
chunks.extend(chunk)
|
|
279
|
+
if len(chunks) > MAX_RESPONSE_BYTES or time.monotonic() > deadline:
|
|
280
|
+
raise FXMacroDataError("FXMacroData response exceeded the bounded response budget.")
|
|
281
|
+
raw = chunks.decode("utf-8")
|
|
282
|
+
return json.loads(raw)
|
|
283
|
+
except (requests.RequestException, ValueError, UnicodeError, OSError, AttributeError):
|
|
284
|
+
raise FXMacroDataError("FXMacroData returned an unavailable or invalid response.") from None
|
|
285
|
+
finally:
|
|
286
|
+
response.close()
|
|
287
|
+
|
|
288
|
+
def _auth_params(self) -> dict[str, str]:
|
|
289
|
+
return {} # Credentials are sent only in headers, including MCP sessions.
|
|
290
|
+
|
|
291
|
+
def execute(self, operation_name: str, arguments: dict[str, Any] | None = None) -> Result:
|
|
292
|
+
with self._lock, protected_diagnostics(self._api_key):
|
|
293
|
+
return self._execute(operation_name, arguments)
|
|
294
|
+
|
|
295
|
+
def history(self, operation_name: str, arguments: dict[str, Any] | None = None, *, max_pages=10000, resume=None) -> Result:
|
|
296
|
+
"""Download every page of a paginated REST operation, preserving metadata."""
|
|
297
|
+
operation = self._operations.get(operation_name)
|
|
298
|
+
if operation is None or operation.method != "GET" or not any(p["name"] == "offset" for p in operation.parameters):
|
|
299
|
+
raise FXMacroDataError("Complete history requires an offset-paginated REST operation.")
|
|
300
|
+
payload = collect_history(lambda args: self.execute(operation_name, args).payload,
|
|
301
|
+
arguments, max_pages=max_pages, resume=resume, request_id=operation_name,
|
|
302
|
+
require_pagination=True)
|
|
303
|
+
return Result(operation_name, payload)
|
|
304
|
+
|
|
305
|
+
def history_path(self, path: str, params=None, *, max_pages=10000, resume=None) -> dict:
|
|
306
|
+
"""Complete read from an FXMacroData REST path, retaining all page metadata."""
|
|
307
|
+
if not isinstance(path, str) or not re.fullmatch(r"/v1/[A-Za-z0-9_/-]+", path) or ".." in path:
|
|
308
|
+
raise FXMacroDataError("Use an FXMacroData /v1/ REST path.")
|
|
309
|
+
query = dict(params or {})
|
|
310
|
+
if any(k.lower() in {"api_key", "access_token", "authorization"} for k in query):
|
|
311
|
+
raise FXMacroDataError("Pass authentication through the client constructor.")
|
|
312
|
+
def fetch(page):
|
|
313
|
+
headers = {"Accept": "application/json"}
|
|
314
|
+
if self._api_key:
|
|
315
|
+
headers["X-API-Key"] = self._api_key
|
|
316
|
+
with self._lock, protected_diagnostics(self._api_key):
|
|
317
|
+
for attempt in range(3):
|
|
318
|
+
try:
|
|
319
|
+
response = self._request("GET", API_ORIGIN + path, params=page, headers=headers)
|
|
320
|
+
except FXMacroDataError:
|
|
321
|
+
if attempt == 2:
|
|
322
|
+
raise
|
|
323
|
+
time.sleep(2 ** attempt)
|
|
324
|
+
continue
|
|
325
|
+
if response.status_code in {429, 500, 502, 503, 504} and attempt < 2:
|
|
326
|
+
retry = response.headers.get("Retry-After", str(2 ** attempt))
|
|
327
|
+
try:
|
|
328
|
+
delay = max(0, float(retry))
|
|
329
|
+
except ValueError:
|
|
330
|
+
from datetime import datetime, timezone
|
|
331
|
+
from email.utils import parsedate_to_datetime
|
|
332
|
+
try:
|
|
333
|
+
delay = max(0, (parsedate_to_datetime(retry) - datetime.now(timezone.utc)).total_seconds())
|
|
334
|
+
except (ValueError, TypeError):
|
|
335
|
+
delay = 2 ** attempt
|
|
336
|
+
if delay <= 30:
|
|
337
|
+
response.close()
|
|
338
|
+
time.sleep(delay)
|
|
339
|
+
continue
|
|
340
|
+
return self._safe(self._read_json(response))
|
|
341
|
+
return collect_history(fetch, query, max_pages=max_pages, resume=resume, request_id=path,
|
|
342
|
+
require_pagination=paginated_history_path(path))
|
|
343
|
+
|
|
344
|
+
def _execute(self, operation_name: str, arguments: dict[str, Any] | None) -> Result:
|
|
345
|
+
if not isinstance(operation_name, str):
|
|
346
|
+
raise FXMacroDataError("Unknown FXMacroData operation. Use operation discovery.")
|
|
347
|
+
op = self._operations.get(operation_name)
|
|
348
|
+
if op is None:
|
|
349
|
+
raise FXMacroDataError("Unknown FXMacroData operation. Use operation discovery.")
|
|
350
|
+
if arguments is not None and (not isinstance(arguments, dict) or any(not isinstance(key, str) for key in arguments)):
|
|
351
|
+
raise FXMacroDataError("FXMacroData arguments must be an object with string keys.")
|
|
352
|
+
args = dict(arguments or {})
|
|
353
|
+
if any(k.lower() in {"api_key", "access_token", "authorization", "url", "base_url"} for k in args):
|
|
354
|
+
raise FXMacroDataError("Credentials and endpoint overrides are not operation arguments.")
|
|
355
|
+
try:
|
|
356
|
+
jsonschema.Draft202012Validator(op.input_schema, registry=SCHEMA_REGISTRY).validate(args)
|
|
357
|
+
except (jsonschema.ValidationError, jsonschema.SchemaError, Unresolvable):
|
|
358
|
+
raise FXMacroDataError("Invalid FXMacroData arguments. Check the operation input schema.") from None
|
|
359
|
+
if op.method == "MCP":
|
|
360
|
+
return self._call_mcp(op.name[4:], args)
|
|
361
|
+
path = op.path
|
|
362
|
+
params: dict[str, Any] = self._auth_params()
|
|
363
|
+
headers = {"Accept": "application/json"}
|
|
364
|
+
body: dict[str, Any] | None = None
|
|
365
|
+
for parameter in op.parameters:
|
|
366
|
+
name = parameter["name"]
|
|
367
|
+
if name not in args or args[name] is None:
|
|
368
|
+
continue
|
|
369
|
+
value = args[name]
|
|
370
|
+
if parameter["in"] == "path":
|
|
371
|
+
if not isinstance(value, str) or not re.fullmatch(r"[A-Za-z0-9_.:-]+", value) or value in {".", ".."}:
|
|
372
|
+
raise FXMacroDataError("Invalid FXMacroData path parameter.")
|
|
373
|
+
path = path.replace("{" + name + "}", quote(value, safe=""))
|
|
374
|
+
elif parameter["in"] == "header":
|
|
375
|
+
if "\r" in str(value) or "\n" in str(value):
|
|
376
|
+
raise FXMacroDataError("Invalid event cursor.")
|
|
377
|
+
headers[name] = str(value)
|
|
378
|
+
elif parameter["in"] == "body":
|
|
379
|
+
body = value
|
|
380
|
+
else:
|
|
381
|
+
params[name] = str(value).lower() if isinstance(value, bool) else value
|
|
382
|
+
if operation_name == "stream_events":
|
|
383
|
+
payload = self._stream(path, params, headers, args)
|
|
384
|
+
else:
|
|
385
|
+
payload = self._read_json(self._request(op.method, API_ORIGIN + path, params=params, headers=headers, body=body))
|
|
386
|
+
return Result(op.name, self._safe(payload), API_ORIGIN + path)
|
|
387
|
+
|
|
388
|
+
@staticmethod
|
|
389
|
+
def _parse_sse(lines: Any) -> Iterator[dict[str, Any]]:
|
|
390
|
+
fields: dict[str, Any] = {}
|
|
391
|
+
data: list[str] = []
|
|
392
|
+
for raw in lines:
|
|
393
|
+
line = raw.decode("utf-8") if isinstance(raw, bytes) else raw
|
|
394
|
+
if line == "":
|
|
395
|
+
if data:
|
|
396
|
+
text = "\n".join(data)
|
|
397
|
+
try:
|
|
398
|
+
fields["data"] = json.loads(text)
|
|
399
|
+
except ValueError:
|
|
400
|
+
fields["data"] = text
|
|
401
|
+
yield fields
|
|
402
|
+
fields, data = {}, []
|
|
403
|
+
elif not line.startswith(":"):
|
|
404
|
+
key, _, value = line.partition(":")
|
|
405
|
+
value = value.removeprefix(" ")
|
|
406
|
+
if key == "data":
|
|
407
|
+
data.append(value)
|
|
408
|
+
elif key in {"id", "event", "retry"}:
|
|
409
|
+
fields[key] = value
|
|
410
|
+
# A connection ending before the blank event delimiter is incomplete;
|
|
411
|
+
# do not turn a partial stream frame into a completed release event.
|
|
412
|
+
|
|
413
|
+
def _stream(self, path: str, params: dict[str, Any], headers: dict[str, str], args: dict[str, Any]) -> dict[str, Any]:
|
|
414
|
+
seconds = min(float(args.get("max_seconds", 10)), self.timeout)
|
|
415
|
+
count = int(args.get("max_events", 10))
|
|
416
|
+
deadline = time.monotonic() + seconds
|
|
417
|
+
response = self._request("GET", API_ORIGIN + path, params=params, headers={**headers, "Accept": "text/event-stream"}, timeout=seconds)
|
|
418
|
+
events = []
|
|
419
|
+
expired = threading.Event()
|
|
420
|
+
try:
|
|
421
|
+
self._status(response)
|
|
422
|
+
with self._response_deadline(response, deadline) as expired:
|
|
423
|
+
for event in self._parse_sse(self._bounded_lines(response, deadline, stop_at_deadline=True)):
|
|
424
|
+
events.append(event)
|
|
425
|
+
if len(events) >= count:
|
|
426
|
+
break
|
|
427
|
+
except (requests.RequestException, OSError):
|
|
428
|
+
# A quiet stream can reach its finite read deadline without an event.
|
|
429
|
+
return {"events": events, "capture_complete": False, "reason": "timeout"}
|
|
430
|
+
except (UnicodeError, ValueError, TypeError, AttributeError):
|
|
431
|
+
if expired.is_set():
|
|
432
|
+
return {"events": events, "capture_complete": False, "reason": "timeout"}
|
|
433
|
+
raise FXMacroDataError("FXMacroData returned an invalid event stream.") from None
|
|
434
|
+
finally:
|
|
435
|
+
response.close()
|
|
436
|
+
return {"events": events, "capture_complete": len(events) >= count, "reason": "event_limit" if len(events) >= count else "timeout" if expired.is_set() else "stream_end_or_deadline"}
|
|
437
|
+
|
|
438
|
+
def _rpc(self, method: str, params: dict[str, Any], *, headers: dict[str, str], notification: bool = False) -> tuple[Any, dict[str, str]]:
|
|
439
|
+
body: dict[str, Any] = {"jsonrpc": "2.0", "method": method, "params": params}
|
|
440
|
+
if not notification:
|
|
441
|
+
self._request_id += 1
|
|
442
|
+
body["id"] = self._request_id
|
|
443
|
+
response = self._request("POST", MCP_URL, params=self._auth_params(), headers=headers, body=body)
|
|
444
|
+
response_headers = dict(response.headers)
|
|
445
|
+
if notification:
|
|
446
|
+
try:
|
|
447
|
+
self._status(response)
|
|
448
|
+
finally:
|
|
449
|
+
response.close()
|
|
450
|
+
return None, response_headers
|
|
451
|
+
value = self._read_json(response, response_id=body["id"])
|
|
452
|
+
if (not isinstance(value, dict) or value.get("jsonrpc") != "2.0"
|
|
453
|
+
or type(value.get("id")) is not int or value.get("id") != body["id"]
|
|
454
|
+
or "error" in value or "result" not in value):
|
|
455
|
+
raise FXMacroDataError("FXMacroData MCP could not complete this request.")
|
|
456
|
+
return value["result"], response_headers
|
|
457
|
+
|
|
458
|
+
def _initialize_mcp(self) -> dict[str, str]:
|
|
459
|
+
if self._mcp_headers is None:
|
|
460
|
+
headers = {"Accept": "application/json, text/event-stream"}
|
|
461
|
+
result, returned = self._rpc("initialize", {"protocolVersion": MCP_PROTOCOL_VERSION, "capabilities": {}, "clientInfo": {"name": "fxmacrodata-public-client", "version": "0.1.0"}}, headers=headers)
|
|
462
|
+
if not isinstance(result, dict) or result.get("protocolVersion") != MCP_PROTOCOL_VERSION:
|
|
463
|
+
raise FXMacroDataError("FXMacroData MCP returned an unsupported initialization response.")
|
|
464
|
+
headers["MCP-Protocol-Version"] = MCP_PROTOCOL_VERSION
|
|
465
|
+
for key, value in returned.items():
|
|
466
|
+
if key.lower() == "mcp-session-id":
|
|
467
|
+
if not isinstance(value, str) or not re.fullmatch(r"[\x21-\x7e]+", value):
|
|
468
|
+
raise FXMacroDataError("FXMacroData MCP returned an invalid session header.")
|
|
469
|
+
headers["Mcp-Session-Id"] = value
|
|
470
|
+
self._rpc("notifications/initialized", {}, headers=headers, notification=True)
|
|
471
|
+
self._mcp_headers = headers
|
|
472
|
+
return self._mcp_headers
|
|
473
|
+
|
|
474
|
+
def discover_mcp_tools(self) -> tuple[Operation, ...]:
|
|
475
|
+
"""Refresh every advertised hosted MCP tool, including paginated discovery."""
|
|
476
|
+
with self._lock, protected_diagnostics(self._api_key):
|
|
477
|
+
return self._discover_mcp_tools()
|
|
478
|
+
|
|
479
|
+
def _discover_mcp_tools(self) -> tuple[Operation, ...]:
|
|
480
|
+
headers = self._initialize_mcp()
|
|
481
|
+
cursor = None
|
|
482
|
+
seen: set[str] = set()
|
|
483
|
+
discovered = []
|
|
484
|
+
deadline = time.monotonic() + self.timeout
|
|
485
|
+
while True:
|
|
486
|
+
result, _ = self._rpc("tools/list", {"cursor": cursor} if cursor else {}, headers=headers)
|
|
487
|
+
if not isinstance(result, dict) or not isinstance(result.get("tools"), list):
|
|
488
|
+
raise FXMacroDataError("FXMacroData MCP returned an invalid tool catalogue.")
|
|
489
|
+
for tool in result["tools"]:
|
|
490
|
+
if (not isinstance(tool, dict) or not isinstance(tool.get("name"), str)
|
|
491
|
+
or not isinstance(tool.get("inputSchema"), dict)):
|
|
492
|
+
raise FXMacroDataError("FXMacroData MCP returned an invalid tool descriptor.")
|
|
493
|
+
try:
|
|
494
|
+
jsonschema.Draft202012Validator.check_schema(tool["inputSchema"])
|
|
495
|
+
except jsonschema.SchemaError:
|
|
496
|
+
raise FXMacroDataError("FXMacroData MCP returned an invalid tool schema.") from None
|
|
497
|
+
op = Operation("mcp_" + tool["name"], tool.get("description", tool["name"]), tool["inputSchema"], "/mcp", "MCP")
|
|
498
|
+
discovered.append(op)
|
|
499
|
+
cursor = result.get("nextCursor")
|
|
500
|
+
if not cursor:
|
|
501
|
+
break
|
|
502
|
+
if not isinstance(cursor, str):
|
|
503
|
+
raise FXMacroDataError("FXMacroData MCP returned an invalid page cursor.")
|
|
504
|
+
if cursor in seen:
|
|
505
|
+
raise FXMacroDataError("FXMacroData MCP discovery returned a repeated page cursor.")
|
|
506
|
+
if len(seen) >= 100 or len(discovered) > 10000 or time.monotonic() > deadline:
|
|
507
|
+
raise FXMacroDataError("FXMacroData MCP discovery exceeded its bounded response budget.")
|
|
508
|
+
seen.add(cursor)
|
|
509
|
+
# A failed page never partially mutates the usable operation catalogue.
|
|
510
|
+
self._operations.update({operation.name: operation for operation in discovered})
|
|
511
|
+
return tuple(discovered)
|
|
512
|
+
|
|
513
|
+
def _call_mcp(self, name: str, args: dict[str, Any]) -> Result:
|
|
514
|
+
result, _ = self._rpc("tools/call", {"name": name, "arguments": args}, headers=self._initialize_mcp())
|
|
515
|
+
if not isinstance(result, dict) or result.get("isError"):
|
|
516
|
+
raise FXMacroDataError("FXMacroData MCP tool is unavailable. Check arguments and dataset access.")
|
|
517
|
+
return Result("mcp_" + name, self._safe(result), WEBSITE)
|