redis-streams-fxmacrodata 0.1.0__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.
@@ -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,11 @@
1
+ include LICENSE
2
+ include README.md
3
+ include pyproject.toml
4
+ include PUBLIC_FILES.json
5
+ include MANIFEST.in
6
+ include src/fxmacrodata_redis_streams/__init__.py
7
+ include src/fxmacrodata_redis_streams/py.typed
8
+ include tests/test_publisher.py
9
+ include examples/consumer_group_dedupe.py
10
+ global-exclude *.pyc *.pyo
11
+ global-exclude __pycache__/*
@@ -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,11 @@
1
+ [
2
+ "LICENSE",
3
+ "README.md",
4
+ "pyproject.toml",
5
+ "PUBLIC_FILES.json",
6
+ "MANIFEST.in",
7
+ "src/fxmacrodata_redis_streams/__init__.py",
8
+ "src/fxmacrodata_redis_streams/py.typed",
9
+ "tests/test_publisher.py",
10
+ "examples/consumer_group_dedupe.py"
11
+ ]
@@ -0,0 +1,109 @@
1
+ # FXMacroData for Redis Streams
2
+
3
+ Append FXMacroData public responses to a Redis Stream with the official
4
+ `redis` Python client. Feed macro announcements to your own consumer groups
5
+ while retaining the source payload and revision metadata.
6
+
7
+ [FXMacroData documentation](https://fxmacrodata.com/documentation/reference?utm_source=github&utm_medium=referral&utm_campaign=redis-streams-fxmacrodata&utm_content=readme)
8
+
9
+ ## Install and connect
10
+
11
+ Requires Python 3.10 or later. Install this package and its adjacent public
12
+ client dependencies from the distribution supplied to you:
13
+
14
+ ```sh
15
+ python -m pip install /path/to/fxmacrodata-public-client /path/to/fxmacrodata-platform-core /path/to/redis-streams-fxmacrodata
16
+ ```
17
+
18
+ Configure `REDIS_URL` (a `rediss://` URL with the credentials your
19
+ administrator provides) in your runtime secret store. Then append to a
20
+ customer-owned stream:
21
+
22
+ ```python
23
+ import os
24
+ import redis
25
+ from fxmacrodata_platform_core import read
26
+ from fxmacrodata_redis_streams import publish
27
+
28
+ client = redis.Redis.from_url(os.environ["REDIS_URL"])
29
+ try:
30
+ batch = read("indicator_history", {"currency": "usd", "indicator": "policy_rate"})
31
+ result = publish(batch, client, "fxmacrodata:usd", maxlen=100_000)
32
+ print(result.messages, "entries in", result.batches, "pipelines")
33
+ finally:
34
+ client.close()
35
+ ```
36
+
37
+ The destination is the stream key (letters, digits, `_`, `.`, `:`, `/` and
38
+ `-`, up to 256 characters); `XADD` creates it on first use. URLs and other
39
+ values are rejected before any command runs.
40
+
41
+ Every operation exposed by `fxmacrodata_platform_core.operations()` is
42
+ available, including REST and MCP operations. `publish_operation` reads one
43
+ operation and publishes it in a single call:
44
+
45
+ ```python
46
+ from fxmacrodata_redis_streams import publish_operation
47
+
48
+ publish_operation("release_calendar", {"currency": "usd"}, client, "fxmacrodata:calendar",
49
+ api_key_env="FXMD_API_KEY")
50
+ ```
51
+
52
+ ## Entry format
53
+
54
+ Each envelope row becomes one stream entry whose fields are the envelope
55
+ columns as strings: `operation`, `source_url`, `record_id`, `currency`,
56
+ `indicator`, `date`, `announcement_datetime`, `value`, `value_text`,
57
+ `record_json`, `response_json` and the publisher's `content_hash` (SHA-256 of
58
+ the ten source columns). A null column is omitted from the entry; `value` is
59
+ written as a Python float repr, such as `4.25`, and `value_text` keeps the
60
+ source's own text. Dates and announcement times remain source strings. No
61
+ publication time is inferred, and no missing value is filled. `record_json`
62
+ retains the original record; `response_json` retains the complete response,
63
+ including pagination and metadata. An empty response produces one entry with
64
+ `record_json` equal to `null`, so absence of data and the server's metadata
65
+ remain inspectable. It is not an observation.
66
+
67
+ The `key` field is `operation:record_id:content_hash`; the same source
68
+ record with the same content always produces the same key, and changed data
69
+ or metadata produces a different one. Redis assigns the entry ID itself; no
70
+ timestamp is derived from the data.
71
+
72
+ ## Delivery guarantee
73
+
74
+ Delivery is at-least-once. Entries are appended in blocks of `batch_size`
75
+ rows (1 to 10,000, 1,000 by default), one non-transactional pipeline per
76
+ block. A failed block raises `PublishError` without the server's own
77
+ diagnostic text and is not retried; earlier blocks may already be stored, and
78
+ a partially executed pipeline may have appended some of its entries.
79
+ Consumers should deduplicate on the `key` field. `maxlen` applies an
80
+ approximate length bound (`MAXLEN ~`) on every `XADD` so the stream does not
81
+ grow without limit; by default no bound is applied.
82
+
83
+ `max_message_bytes` (16 MiB by default) rejects an individual envelope larger
84
+ than the budget before anything is appended; narrow the source date range to
85
+ reduce the response size. The source response is materialized before
86
+ publishing; choose a source date range suitable for memory.
87
+
88
+ `publish_operation` accepts `api_key_env`, `complete_history`, `max_pages`
89
+ and `client_factory` for the public source reader. Supply source credentials
90
+ through your runtime secret store, exposing only the environment variable
91
+ name; omit `api_key_env` for anonymous USD access. Destination authentication
92
+ belongs to the official client you pass in. The adapter neither saves nor
93
+ prints credentials and never places one in an entry.
94
+
95
+ The package licence covers this adapter code. FXMacroData data access and
96
+ onward redistribution remain subject to your subscription and applicable
97
+ licence. Appending data to a stream does not grant permission to publish or
98
+ resell it.
99
+
100
+ ## Development
101
+
102
+ Tests run offline against an in-memory fake client and pipeline:
103
+
104
+ ```sh
105
+ python -m pytest tests -q
106
+ ```
107
+
108
+ Official SDK reference:
109
+ [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,24 @@
1
+ """Read FXMacroData envelopes with a consumer group, dropping redeliveries by the key field."""
2
+ import os
3
+
4
+ import redis
5
+
6
+ STREAM, GROUP, CONSUMER = "fxmacrodata:usd", "fxmacrodata-readers", "reader-1"
7
+ client = redis.Redis.from_url(os.environ["REDIS_URL"], decode_responses=True)
8
+ seen: set[str] = set()
9
+
10
+ try:
11
+ client.xgroup_create(STREAM, GROUP, id="0", mkstream=True)
12
+ except redis.ResponseError as error:
13
+ if "BUSYGROUP" not in str(error):
14
+ raise
15
+
16
+ while True:
17
+ for _, entries in client.xreadgroup(GROUP, CONSUMER, {STREAM: ">"}, count=100, block=5000) or []:
18
+ for entry_id, fields in entries:
19
+ if fields["key"] not in seen:
20
+ seen.add(fields["key"])
21
+ if fields["record_json"] != "null":
22
+ print(fields["operation"], fields.get("currency"), fields.get("indicator"),
23
+ fields.get("date"), fields.get("value"))
24
+ client.xack(STREAM, GROUP, entry_id)
@@ -0,0 +1,30 @@
1
+ [build-system]
2
+ requires = ["setuptools>=77", "wheel"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "redis-streams-fxmacrodata"
7
+ version = "0.1.0"
8
+ description = "Publish public FXMacroData responses to Redis Streams"
9
+ readme = "README.md"
10
+ requires-python = ">=3.10"
11
+ license = "MIT"
12
+ dependencies = ["fxmacrodata-platform-core==0.1.0", "redis>=8.1.0,<9"]
13
+
14
+ [project.urls]
15
+ Documentation = "https://fxmacrodata.com/documentation/reference?utm_source=pypi&utm_medium=referral&utm_campaign=redis-streams-fxmacrodata&utm_content=package"
16
+
17
+ [project.optional-dependencies]
18
+ test = ["pytest>=8,<9"]
19
+
20
+ [tool.setuptools]
21
+ packages = ["fxmacrodata_redis_streams"]
22
+ package-dir = {"" = "src"}
23
+ include-package-data = false
24
+
25
+ [tool.setuptools.package-data]
26
+ fxmacrodata_redis_streams = ["py.typed"]
27
+
28
+ [tool.pytest.ini_options]
29
+ testpaths = ["tests"]
30
+ addopts = "-q"
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -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"]
@@ -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,14 @@
1
+ LICENSE
2
+ MANIFEST.in
3
+ PUBLIC_FILES.json
4
+ README.md
5
+ pyproject.toml
6
+ examples/consumer_group_dedupe.py
7
+ src/fxmacrodata_redis_streams/__init__.py
8
+ src/fxmacrodata_redis_streams/py.typed
9
+ src/redis_streams_fxmacrodata.egg-info/PKG-INFO
10
+ src/redis_streams_fxmacrodata.egg-info/SOURCES.txt
11
+ src/redis_streams_fxmacrodata.egg-info/dependency_links.txt
12
+ src/redis_streams_fxmacrodata.egg-info/requires.txt
13
+ src/redis_streams_fxmacrodata.egg-info/top_level.txt
14
+ tests/test_publisher.py
@@ -0,0 +1,5 @@
1
+ fxmacrodata-platform-core==0.1.0
2
+ redis<9,>=8.1.0
3
+
4
+ [test]
5
+ pytest<9,>=8
@@ -0,0 +1,159 @@
1
+ import json
2
+
3
+ import pytest
4
+
5
+ from fxmacrodata_platform_core import Batch, IntegrationError
6
+ import fxmacrodata_redis_streams as adapter
7
+
8
+
9
+ class Pipeline:
10
+ def __init__(self, client):
11
+ self.client = client
12
+ self.queued = []
13
+
14
+ def xadd(self, name, fields, id="*", maxlen=None, approximate=True, **kwargs):
15
+ self.queued.append((name, fields, maxlen, approximate))
16
+
17
+ def execute(self):
18
+ self.client.executed.append(self.queued)
19
+ if len(self.client.executed) == self.client.fail_block:
20
+ raise RuntimeError("private destination diagnostic")
21
+ self.client.entries.extend(self.queued)
22
+ return [b"0-%d" % i for i in range(len(self.queued))]
23
+
24
+
25
+ class Client:
26
+ def __init__(self, fail_block=None):
27
+ self.executed = []
28
+ self.entries = []
29
+ self.fail_block = fail_block
30
+ self.transactions = []
31
+
32
+ def pipeline(self, transaction=True, shard_hint=None):
33
+ self.transactions.append(transaction)
34
+ return Pipeline(self)
35
+
36
+
37
+ STREAM = "fxmacrodata:usd"
38
+
39
+
40
+ def batch(count=3, metadata=None):
41
+ return Batch("indicator_history", {
42
+ "data": [{"currency": "USD", "indicator": "policy_rate", "value": str(i),
43
+ "date": "2026-10-01", "publication_time_status": "unknown"}
44
+ for i in range(count)],
45
+ "metadata": metadata or {"revision": 1},
46
+ })
47
+
48
+
49
+ def test_entries_preserve_full_payload_as_string_fields_with_dedup_key():
50
+ source, client = batch(), Client()
51
+ result = adapter.publish(source, client, STREAM, batch_size=2)
52
+ assert result == adapter.PublishResult(STREAM, 3, 2)
53
+ assert [len(block) for block in client.executed] == [2, 1]
54
+ assert client.transactions == [False, False]
55
+ name, fields, maxlen, approximate = client.entries[0]
56
+ assert name == STREAM and maxlen is None and approximate is True
57
+ assert all(isinstance(v, str) for v in fields.values())
58
+ assert "announcement_datetime" not in fields
59
+ assert fields["date"] == "2026-10-01"
60
+ assert fields["value"] == "0.0" and fields["value_text"] == "0"
61
+ assert json.loads(fields["record_json"])["publication_time_status"] == "unknown"
62
+ assert json.loads(fields["response_json"]) == source.payload
63
+ assert fields["key"] == f"indicator_history:{fields['record_id']}:{fields['content_hash']}"
64
+ assert set(fields) == {name for name in adapter.COLUMNS if name != "announcement_datetime"} | {"key"}
65
+
66
+
67
+ def test_empty_response_is_a_metadata_envelope_not_an_observation():
68
+ source, client = batch(0), Client()
69
+ result = adapter.publish(source, client, STREAM)
70
+ fields = client.entries[0][1]
71
+ assert result.messages == 1 and fields["record_json"] == "null"
72
+ assert "value" not in fields and "date" not in fields
73
+ assert json.loads(fields["response_json"]) == source.payload
74
+
75
+
76
+ def test_retries_have_stable_keys_and_changed_metadata_is_distinct():
77
+ first, retry, changed = Client(), Client(), Client()
78
+ adapter.publish(batch(1), first, STREAM)
79
+ adapter.publish(batch(1), retry, STREAM)
80
+ adapter.publish(batch(1, {"revision": 2}), changed, STREAM)
81
+ assert first.entries == retry.entries
82
+ assert first.entries[0][1]["record_id"] == changed.entries[0][1]["record_id"]
83
+ assert first.entries[0][1]["key"] != changed.entries[0][1]["key"]
84
+
85
+
86
+ @pytest.mark.parametrize("stream", ["fx macro", "redis://host/stream", "", None, "x" * 257, "a\nb"])
87
+ def test_bad_streams_never_publish(stream):
88
+ client = Client()
89
+ with pytest.raises(ValueError):
90
+ adapter.publish(batch(), client, stream)
91
+ assert client.executed == []
92
+
93
+
94
+ @pytest.mark.parametrize("options", [
95
+ {"batch_size": 0}, {"batch_size": -1}, {"batch_size": 10001}, {"batch_size": True},
96
+ {"batch_size": 2.5}, {"max_message_bytes": 0}, {"max_message_bytes": 16 * 1024 * 1024 + 1},
97
+ {"maxlen": 0}, {"maxlen": True}, {"maxlen": "1000"},
98
+ ])
99
+ def test_batch_bounds(options):
100
+ client = Client()
101
+ with pytest.raises(ValueError):
102
+ adapter.publish(batch(), client, STREAM, **options)
103
+ assert client.executed == []
104
+
105
+
106
+ def test_failed_block_is_not_retried_or_exposed():
107
+ client = Client(fail_block=2)
108
+ with pytest.raises(adapter.PublishError) as error:
109
+ adapter.publish(batch(), client, STREAM, batch_size=1)
110
+ assert "block 2" in str(error.value)
111
+ assert "private destination diagnostic" not in str(error.value)
112
+ assert len(client.executed) == 2 and len(client.entries) == 1
113
+
114
+
115
+ def test_oversized_envelope_fails_before_any_publish():
116
+ client = Client()
117
+ with pytest.raises(ValueError, match="narrow the source date range"):
118
+ adapter.publish(batch(), client, STREAM, max_message_bytes=1)
119
+ assert client.executed == []
120
+
121
+
122
+ def test_maxlen_is_applied_approximately_to_every_xadd():
123
+ client = Client()
124
+ adapter.publish(batch(2), client, STREAM, maxlen=5000)
125
+ assert [(m, a) for _, _, m, a in client.entries] == [(5000, True), (5000, True)]
126
+
127
+
128
+ def test_operation_forwards_public_reader_options(monkeypatch):
129
+ calls = []
130
+ monkeypatch.setattr(adapter, "read", lambda *args, **kw: calls.append((args, kw)) or batch(1))
131
+ factory = object()
132
+ adapter.publish_operation("indicator_history", {"currency": "usd", "indicator": "policy_rate"},
133
+ Client(), STREAM, api_key_env="FXMD_API_KEY", max_pages=12,
134
+ complete_history=False, client_factory=factory)
135
+ assert calls[0][0] == ("indicator_history", {"currency": "usd", "indicator": "policy_rate"})
136
+ assert calls[0][1] == dict(api_key_env="FXMD_API_KEY", complete_history=False,
137
+ max_pages=12, client_factory=factory)
138
+
139
+
140
+ def test_invalid_configuration_never_reads_source(monkeypatch):
141
+ def unexpected(*args, **kwargs):
142
+ pytest.fail("source must not be called for invalid publisher configuration")
143
+ monkeypatch.setattr(adapter, "read", unexpected)
144
+ for options in ({"batch_size": 0}, {"max_message_bytes": 0}, {"maxlen": 0}):
145
+ with pytest.raises(ValueError):
146
+ adapter.publish_operation("release_calendar", {"currency": "usd"}, Client(), STREAM, **options)
147
+ with pytest.raises(ValueError):
148
+ adapter.publish_operation("release_calendar", {"currency": "usd"}, Client(), "bad stream")
149
+
150
+
151
+ def test_credential_arguments_are_rejected_before_publishing():
152
+ client = Client()
153
+ with pytest.raises(IntegrationError):
154
+ adapter.publish_operation("indicator_history", {"currency": "usd", "indicator": "policy_rate",
155
+ "api_key": "secret"}, client, STREAM)
156
+ with pytest.raises(IntegrationError):
157
+ adapter.publish_operation("release_calendar", {"currency": "usd"}, client, STREAM,
158
+ api_key_env="not-an-env-name")
159
+ assert client.executed == []