redis-streams-fxmacrodata 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.
- fxmacrodata_redis_streams/__init__.py +158 -0
- fxmacrodata_redis_streams/py.typed +0 -0
- redis_streams_fxmacrodata-0.1.0.dist-info/METADATA +124 -0
- redis_streams_fxmacrodata-0.1.0.dist-info/RECORD +7 -0
- redis_streams_fxmacrodata-0.1.0.dist-info/WHEEL +5 -0
- redis_streams_fxmacrodata-0.1.0.dist-info/licenses/LICENSE +21 -0
- redis_streams_fxmacrodata-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
"""Publish public FXMacroData responses to a customer-owned Redis Stream."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
import hashlib
|
|
7
|
+
import json
|
|
8
|
+
import math
|
|
9
|
+
import re
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
from fxmacrodata_platform_core import read
|
|
13
|
+
|
|
14
|
+
__version__ = "0.1.0"
|
|
15
|
+
|
|
16
|
+
COLUMNS = (
|
|
17
|
+
"operation", "source_url", "record_id", "currency", "indicator", "date",
|
|
18
|
+
"announcement_datetime", "value", "value_text", "record_json", "response_json",
|
|
19
|
+
"content_hash",
|
|
20
|
+
)
|
|
21
|
+
KEY_FIELD = "key"
|
|
22
|
+
MAX_MESSAGE_BYTES = 16 * 1024 * 1024
|
|
23
|
+
_STREAM = re.compile(r"[A-Za-z0-9_.:/-]{1,256}\Z")
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class PublishError(RuntimeError):
|
|
27
|
+
"""A destination publish failed; inspect the destination before retrying."""
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@dataclass(frozen=True)
|
|
31
|
+
class PublishResult:
|
|
32
|
+
destination: str
|
|
33
|
+
messages: int
|
|
34
|
+
batches: int
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _stream(stream: str) -> str:
|
|
38
|
+
if not isinstance(stream, str) or not _STREAM.fullmatch(stream) or "://" in stream:
|
|
39
|
+
raise ValueError("destination must be a stream key of letters, digits, '_', '.', ':', '/' or '-', not a URL")
|
|
40
|
+
return stream
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _bounds(batch_size: int, max_message_bytes: int, maxlen: int | None) -> None:
|
|
44
|
+
if isinstance(batch_size, bool) or not isinstance(batch_size, int) or not 1 <= batch_size <= 10000:
|
|
45
|
+
raise ValueError("batch_size must be an integer between 1 and 10000")
|
|
46
|
+
if isinstance(max_message_bytes, bool) or not isinstance(max_message_bytes, int) or not 1 <= max_message_bytes <= MAX_MESSAGE_BYTES:
|
|
47
|
+
raise ValueError(f"max_message_bytes must be an integer between 1 and {MAX_MESSAGE_BYTES}")
|
|
48
|
+
if maxlen is not None and (isinstance(maxlen, bool) or not isinstance(maxlen, int) or maxlen < 1):
|
|
49
|
+
raise ValueError("maxlen must be a positive integer or None")
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _rows(batch: Any) -> list[dict[str, Any]]:
|
|
53
|
+
rows = []
|
|
54
|
+
for source in batch.rows():
|
|
55
|
+
row = {name: source.get(name) for name in COLUMNS[:-1]}
|
|
56
|
+
if not isinstance(row["operation"], str) or not isinstance(row["record_id"], str):
|
|
57
|
+
raise ValueError("every row requires string operation and record_id")
|
|
58
|
+
for name, value in row.items():
|
|
59
|
+
if name == "value":
|
|
60
|
+
if value is not None and (
|
|
61
|
+
isinstance(value, bool) or not isinstance(value, (int, float)) or
|
|
62
|
+
not math.isfinite(value)
|
|
63
|
+
):
|
|
64
|
+
raise ValueError("value must be a finite number or null")
|
|
65
|
+
elif value is not None and not isinstance(value, str):
|
|
66
|
+
raise ValueError(f"{name} must be a string or null")
|
|
67
|
+
encoded = json.dumps(row, sort_keys=True, separators=(",", ":"), ensure_ascii=False)
|
|
68
|
+
row["content_hash"] = hashlib.sha256(encoded.encode("utf-8")).hexdigest()
|
|
69
|
+
rows.append(row)
|
|
70
|
+
return rows
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def message_key(row: dict[str, Any]) -> str:
|
|
74
|
+
"""Return the idempotency key stored in the `key` field of every entry."""
|
|
75
|
+
return f"{row['operation']}:{row['record_id']}:{row['content_hash']}"
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def encode_message(row: dict[str, Any]) -> bytes:
|
|
79
|
+
"""Return the canonical UTF-8 JSON of one envelope row (used for the byte budget)."""
|
|
80
|
+
return json.dumps(row, sort_keys=True, separators=(",", ":"), ensure_ascii=False,
|
|
81
|
+
allow_nan=False).encode("utf-8")
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def entry_fields(row: dict[str, Any]) -> dict[str, str]:
|
|
85
|
+
"""Return the stream entry fields: every non-null column as a string, plus `key`."""
|
|
86
|
+
fields = {}
|
|
87
|
+
for name in COLUMNS:
|
|
88
|
+
value = row.get(name)
|
|
89
|
+
if value is None:
|
|
90
|
+
continue
|
|
91
|
+
fields[name] = repr(float(value)) if name == "value" else value
|
|
92
|
+
fields[KEY_FIELD] = message_key(row)
|
|
93
|
+
return fields
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _blocks(rows: list[dict[str, Any]], batch_size: int,
|
|
97
|
+
max_message_bytes: int) -> list[list[dict[str, str]]]:
|
|
98
|
+
output: list[list[dict[str, str]]] = []
|
|
99
|
+
block: list[dict[str, str]] = []
|
|
100
|
+
for row in rows:
|
|
101
|
+
if len(encode_message(row)) > max_message_bytes:
|
|
102
|
+
raise ValueError("one envelope exceeds max_message_bytes; narrow the source date range")
|
|
103
|
+
if len(block) >= batch_size:
|
|
104
|
+
output.append(block)
|
|
105
|
+
block = []
|
|
106
|
+
block.append(entry_fields(row))
|
|
107
|
+
if block:
|
|
108
|
+
output.append(block)
|
|
109
|
+
return output
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def publish(batch: Any, client: Any, destination: str, *, batch_size: int = 1000,
|
|
113
|
+
max_message_bytes: int = MAX_MESSAGE_BYTES, maxlen: int | None = None) -> PublishResult:
|
|
114
|
+
"""Append one stream entry per envelope row with XADD, one pipeline per block.
|
|
115
|
+
|
|
116
|
+
Entry fields are the envelope columns as strings (null columns omitted,
|
|
117
|
+
`value` as a float repr) plus `key`, equal to
|
|
118
|
+
`operation:record_id:content_hash`. `maxlen` applies an approximate
|
|
119
|
+
length bound (`MAXLEN ~`) on every XADD. Delivery is at-least-once; a
|
|
120
|
+
failed block raises PublishError and is not retried.
|
|
121
|
+
"""
|
|
122
|
+
stream = _stream(destination)
|
|
123
|
+
_bounds(batch_size, max_message_bytes, maxlen)
|
|
124
|
+
blocks = _blocks(_rows(batch), batch_size, max_message_bytes)
|
|
125
|
+
count, messages = 0, 0
|
|
126
|
+
for block in blocks:
|
|
127
|
+
try:
|
|
128
|
+
pipeline = client.pipeline(transaction=False)
|
|
129
|
+
for fields in block:
|
|
130
|
+
pipeline.xadd(stream, fields, maxlen=maxlen, approximate=True)
|
|
131
|
+
pipeline.execute()
|
|
132
|
+
except Exception:
|
|
133
|
+
raise PublishError(
|
|
134
|
+
f"Redis XADD block {count + 1} failed; earlier blocks may be stored. "
|
|
135
|
+
"Inspect the destination and deduplicate by the key field on retry."
|
|
136
|
+
) from None
|
|
137
|
+
count += 1
|
|
138
|
+
messages += len(block)
|
|
139
|
+
return PublishResult(stream, messages, count)
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def publish_operation(operation: str, args: dict[str, Any] | None, client: Any, destination: str,
|
|
143
|
+
*, api_key_env: str | None = None, complete_history: bool = True,
|
|
144
|
+
max_pages: int = 10000, client_factory: Any = None,
|
|
145
|
+
batch_size: int = 1000, max_message_bytes: int = MAX_MESSAGE_BYTES,
|
|
146
|
+
maxlen: int | None = None) -> PublishResult:
|
|
147
|
+
"""Read one public operation and publish its complete response envelope."""
|
|
148
|
+
_stream(destination)
|
|
149
|
+
_bounds(batch_size, max_message_bytes, maxlen)
|
|
150
|
+
batch = read(operation, args, api_key_env=api_key_env,
|
|
151
|
+
complete_history=complete_history, max_pages=max_pages,
|
|
152
|
+
client_factory=client_factory)
|
|
153
|
+
return publish(batch, client, destination, batch_size=batch_size,
|
|
154
|
+
max_message_bytes=max_message_bytes, maxlen=maxlen)
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
__all__ = ["COLUMNS", "KEY_FIELD", "MAX_MESSAGE_BYTES", "PublishError", "PublishResult",
|
|
158
|
+
"encode_message", "entry_fields", "message_key", "publish", "publish_operation"]
|
|
File without changes
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: redis-streams-fxmacrodata
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Publish public FXMacroData responses to Redis Streams
|
|
5
|
+
License-Expression: MIT
|
|
6
|
+
Project-URL: Documentation, https://fxmacrodata.com/documentation/reference?utm_source=pypi&utm_medium=referral&utm_campaign=redis-streams-fxmacrodata&utm_content=package
|
|
7
|
+
Requires-Python: >=3.10
|
|
8
|
+
Description-Content-Type: text/markdown
|
|
9
|
+
License-File: LICENSE
|
|
10
|
+
Requires-Dist: fxmacrodata-platform-core==0.1.0
|
|
11
|
+
Requires-Dist: redis<9,>=8.1.0
|
|
12
|
+
Provides-Extra: test
|
|
13
|
+
Requires-Dist: pytest<9,>=8; extra == "test"
|
|
14
|
+
Dynamic: license-file
|
|
15
|
+
|
|
16
|
+
# FXMacroData for Redis Streams
|
|
17
|
+
|
|
18
|
+
Append FXMacroData public responses to a Redis Stream with the official
|
|
19
|
+
`redis` Python client. Feed macro announcements to your own consumer groups
|
|
20
|
+
while retaining the source payload and revision metadata.
|
|
21
|
+
|
|
22
|
+
[FXMacroData documentation](https://fxmacrodata.com/documentation/reference?utm_source=github&utm_medium=referral&utm_campaign=redis-streams-fxmacrodata&utm_content=readme)
|
|
23
|
+
|
|
24
|
+
## Install and connect
|
|
25
|
+
|
|
26
|
+
Requires Python 3.10 or later. Install this package and its adjacent public
|
|
27
|
+
client dependencies from the distribution supplied to you:
|
|
28
|
+
|
|
29
|
+
```sh
|
|
30
|
+
python -m pip install /path/to/fxmacrodata-public-client /path/to/fxmacrodata-platform-core /path/to/redis-streams-fxmacrodata
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
Configure `REDIS_URL` (a `rediss://` URL with the credentials your
|
|
34
|
+
administrator provides) in your runtime secret store. Then append to a
|
|
35
|
+
customer-owned stream:
|
|
36
|
+
|
|
37
|
+
```python
|
|
38
|
+
import os
|
|
39
|
+
import redis
|
|
40
|
+
from fxmacrodata_platform_core import read
|
|
41
|
+
from fxmacrodata_redis_streams import publish
|
|
42
|
+
|
|
43
|
+
client = redis.Redis.from_url(os.environ["REDIS_URL"])
|
|
44
|
+
try:
|
|
45
|
+
batch = read("indicator_history", {"currency": "usd", "indicator": "policy_rate"})
|
|
46
|
+
result = publish(batch, client, "fxmacrodata:usd", maxlen=100_000)
|
|
47
|
+
print(result.messages, "entries in", result.batches, "pipelines")
|
|
48
|
+
finally:
|
|
49
|
+
client.close()
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
The destination is the stream key (letters, digits, `_`, `.`, `:`, `/` and
|
|
53
|
+
`-`, up to 256 characters); `XADD` creates it on first use. URLs and other
|
|
54
|
+
values are rejected before any command runs.
|
|
55
|
+
|
|
56
|
+
Every operation exposed by `fxmacrodata_platform_core.operations()` is
|
|
57
|
+
available, including REST and MCP operations. `publish_operation` reads one
|
|
58
|
+
operation and publishes it in a single call:
|
|
59
|
+
|
|
60
|
+
```python
|
|
61
|
+
from fxmacrodata_redis_streams import publish_operation
|
|
62
|
+
|
|
63
|
+
publish_operation("release_calendar", {"currency": "usd"}, client, "fxmacrodata:calendar",
|
|
64
|
+
api_key_env="FXMD_API_KEY")
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
## Entry format
|
|
68
|
+
|
|
69
|
+
Each envelope row becomes one stream entry whose fields are the envelope
|
|
70
|
+
columns as strings: `operation`, `source_url`, `record_id`, `currency`,
|
|
71
|
+
`indicator`, `date`, `announcement_datetime`, `value`, `value_text`,
|
|
72
|
+
`record_json`, `response_json` and the publisher's `content_hash` (SHA-256 of
|
|
73
|
+
the ten source columns). A null column is omitted from the entry; `value` is
|
|
74
|
+
written as a Python float repr, such as `4.25`, and `value_text` keeps the
|
|
75
|
+
source's own text. Dates and announcement times remain source strings. No
|
|
76
|
+
publication time is inferred, and no missing value is filled. `record_json`
|
|
77
|
+
retains the original record; `response_json` retains the complete response,
|
|
78
|
+
including pagination and metadata. An empty response produces one entry with
|
|
79
|
+
`record_json` equal to `null`, so absence of data and the server's metadata
|
|
80
|
+
remain inspectable. It is not an observation.
|
|
81
|
+
|
|
82
|
+
The `key` field is `operation:record_id:content_hash`; the same source
|
|
83
|
+
record with the same content always produces the same key, and changed data
|
|
84
|
+
or metadata produces a different one. Redis assigns the entry ID itself; no
|
|
85
|
+
timestamp is derived from the data.
|
|
86
|
+
|
|
87
|
+
## Delivery guarantee
|
|
88
|
+
|
|
89
|
+
Delivery is at-least-once. Entries are appended in blocks of `batch_size`
|
|
90
|
+
rows (1 to 10,000, 1,000 by default), one non-transactional pipeline per
|
|
91
|
+
block. A failed block raises `PublishError` without the server's own
|
|
92
|
+
diagnostic text and is not retried; earlier blocks may already be stored, and
|
|
93
|
+
a partially executed pipeline may have appended some of its entries.
|
|
94
|
+
Consumers should deduplicate on the `key` field. `maxlen` applies an
|
|
95
|
+
approximate length bound (`MAXLEN ~`) on every `XADD` so the stream does not
|
|
96
|
+
grow without limit; by default no bound is applied.
|
|
97
|
+
|
|
98
|
+
`max_message_bytes` (16 MiB by default) rejects an individual envelope larger
|
|
99
|
+
than the budget before anything is appended; narrow the source date range to
|
|
100
|
+
reduce the response size. The source response is materialized before
|
|
101
|
+
publishing; choose a source date range suitable for memory.
|
|
102
|
+
|
|
103
|
+
`publish_operation` accepts `api_key_env`, `complete_history`, `max_pages`
|
|
104
|
+
and `client_factory` for the public source reader. Supply source credentials
|
|
105
|
+
through your runtime secret store, exposing only the environment variable
|
|
106
|
+
name; omit `api_key_env` for anonymous USD access. Destination authentication
|
|
107
|
+
belongs to the official client you pass in. The adapter neither saves nor
|
|
108
|
+
prints credentials and never places one in an entry.
|
|
109
|
+
|
|
110
|
+
The package licence covers this adapter code. FXMacroData data access and
|
|
111
|
+
onward redistribution remain subject to your subscription and applicable
|
|
112
|
+
licence. Appending data to a stream does not grant permission to publish or
|
|
113
|
+
resell it.
|
|
114
|
+
|
|
115
|
+
## Development
|
|
116
|
+
|
|
117
|
+
Tests run offline against an in-memory fake client and pipeline:
|
|
118
|
+
|
|
119
|
+
```sh
|
|
120
|
+
python -m pytest tests -q
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
Official SDK reference:
|
|
124
|
+
[redis-py XADD](https://redis.readthedocs.io/en/stable/commands.html?utm_source=pypi&utm_medium=referral&utm_campaign=redis-streams-fxmacrodata&utm_content=upstream-reference#redis.commands.core.CoreCommands.xadd).
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
fxmacrodata_redis_streams/__init__.py,sha256=EMqBfWIfJ7e4dtgeJIroNUsIXD-czkL6Hn0hxvKsyPY,6687
|
|
2
|
+
fxmacrodata_redis_streams/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
3
|
+
redis_streams_fxmacrodata-0.1.0.dist-info/licenses/LICENSE,sha256=rklK7Fl8aL1eWGubsrozZ7rCNTsN-MATw4M6LQIMv10,1092
|
|
4
|
+
redis_streams_fxmacrodata-0.1.0.dist-info/METADATA,sha256=hGg5fuLWLObstiQSl90KlTvdgJCTk-gB0EUCp2BiNvs,5663
|
|
5
|
+
redis_streams_fxmacrodata-0.1.0.dist-info/WHEEL,sha256=16fc_9Rp854SLcdhaDEjr0u65p0e3FhZxihNgcnCE98,91
|
|
6
|
+
redis_streams_fxmacrodata-0.1.0.dist-info/top_level.txt,sha256=Z4mWqR65CwdW07oJDUp81IBbnYE4W50lmoSVZH3xlrE,26
|
|
7
|
+
redis_streams_fxmacrodata-0.1.0.dist-info/RECORD,,
|
|
@@ -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 @@
|
|
|
1
|
+
fxmacrodata_redis_streams
|