pulsar-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_pulsar/__init__.py
7
+ include src/fxmacrodata_pulsar/py.typed
8
+ include tests/test_publisher.py
9
+ include examples/consumer_dedupe.py
10
+ global-exclude *.pyc *.pyo
11
+ global-exclude __pycache__/*
@@ -0,0 +1,128 @@
1
+ Metadata-Version: 2.4
2
+ Name: pulsar-fxmacrodata
3
+ Version: 0.1.0
4
+ Summary: Publish public FXMacroData responses to Apache Pulsar
5
+ License-Expression: MIT
6
+ Project-URL: Documentation, https://fxmacrodata.com/documentation/reference?utm_source=pypi&utm_medium=referral&utm_campaign=pulsar-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: pulsar-client<4,>=3.13.0
12
+ Provides-Extra: test
13
+ Requires-Dist: pytest<9,>=8; extra == "test"
14
+ Dynamic: license-file
15
+
16
+ # FXMacroData for Apache Pulsar
17
+
18
+ Publish FXMacroData public responses to a Pulsar topic with the official
19
+ `pulsar-client` Python library. Stream macro announcements to your own
20
+ subscriptions 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=pulsar-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/pulsar-fxmacrodata
31
+ ```
32
+
33
+ Configure `PULSAR_SERVICE_URL` and any token file in your runtime secret
34
+ store. Then publish through a producer bound to a customer-owned topic:
35
+
36
+ ```python
37
+ import os
38
+ import pulsar
39
+ from fxmacrodata_platform_core import read
40
+ from fxmacrodata_pulsar import publish
41
+
42
+ TOPIC = "persistent://public/default/fxmacrodata"
43
+ client = pulsar.Client(os.environ["PULSAR_SERVICE_URL"])
44
+ try:
45
+ producer = client.create_producer(TOPIC)
46
+ batch = read("indicator_history", {"currency": "usd", "indicator": "policy_rate"})
47
+ result = publish(batch, producer, TOPIC)
48
+ print(result.messages, "messages in", result.batches, "blocks")
49
+ finally:
50
+ client.close()
51
+ ```
52
+
53
+ The destination is the topic name, either the full
54
+ `persistent://tenant/namespace/topic` form or the short name, and it must
55
+ match the topic the producer was created for. Service URLs and other values
56
+ are rejected before any send.
57
+
58
+ Every operation exposed by `fxmacrodata_platform_core.operations()` is
59
+ available, including REST and MCP operations. `publish_operation` reads one
60
+ operation and publishes it in a single call:
61
+
62
+ ```python
63
+ from fxmacrodata_pulsar import publish_operation
64
+
65
+ publish_operation("release_calendar", {"currency": "usd"}, producer, TOPIC,
66
+ api_key_env="FXMD_API_KEY")
67
+ ```
68
+
69
+ ## Message format
70
+
71
+ Each envelope row becomes one message. The body is canonical UTF-8 JSON with
72
+ the keys `operation`, `source_url`, `record_id`, `currency`, `indicator`,
73
+ `date`, `announcement_datetime`, `value`, `value_text`, `record_json`,
74
+ `response_json` and the publisher's `content_hash` (SHA-256 of the ten source
75
+ columns). Only `value` is numeric. Dates and announcement times remain source
76
+ strings. No publication time is inferred, no missing value is filled, and no
77
+ event timestamp is set on the message. `record_json` retains the original
78
+ record; `response_json` retains the complete response, including pagination
79
+ and metadata. An empty response produces one message with `record_json`
80
+ equal to `"null"`, so absence of data and the server's metadata remain
81
+ inspectable. It is not an observation.
82
+
83
+ The partition key is `operation:record_id:content_hash`; the same source
84
+ record with the same content always produces the same key, and changed data
85
+ or metadata produces a different one. The properties `operation`,
86
+ `record_id`, `content_hash` and `source_url` are set on every message for
87
+ consumer-side filtering.
88
+
89
+ ## Delivery guarantee
90
+
91
+ Delivery is at-least-once. Messages are sent in blocks of `batch_size` rows
92
+ (1 to 10,000, 1,000 by default) with `Producer.send`, which blocks until the
93
+ broker acknowledges each message; the producer's own batching settings
94
+ decide how messages are grouped on the wire. A failed block raises
95
+ `PublishError` without the broker's own diagnostic text and is not retried;
96
+ earlier blocks may already be delivered. Consumers should deduplicate on the
97
+ partition key, or on the `operation`, `record_id` and `content_hash`
98
+ properties.
99
+
100
+ `max_message_bytes` (16 MiB by default) rejects an individual envelope larger
101
+ than the budget before anything is published; keep it within the broker's
102
+ `maxMessageSize`, and narrow the source date range to reduce the response
103
+ size. The source response is materialized before publishing; choose a source
104
+ date range suitable for memory.
105
+
106
+ `publish_operation` accepts `api_key_env`, `complete_history`, `max_pages`
107
+ and `client_factory` for the public source reader. Supply source credentials
108
+ through your runtime secret store, exposing only the environment variable
109
+ name; omit `api_key_env` for anonymous USD access. Destination authentication
110
+ belongs to the Pulsar client you create the producer from. The adapter
111
+ neither saves nor prints credentials and never places one in a message or
112
+ property.
113
+
114
+ The package licence covers this adapter code. FXMacroData data access and
115
+ onward redistribution remain subject to your subscription and applicable
116
+ licence. Publishing data to a topic does not grant permission to publish or
117
+ resell it.
118
+
119
+ ## Development
120
+
121
+ Tests run offline against an in-memory fake producer:
122
+
123
+ ```sh
124
+ python -m pytest tests -q
125
+ ```
126
+
127
+ Official SDK reference:
128
+ [pulsar-client Producer.send](https://pulsar.apache.org/api/python/3.13.x/pulsar.Producer.html?utm_source=pypi&utm_medium=referral&utm_campaign=pulsar-fxmacrodata&utm_content=upstream-reference#send).
@@ -0,0 +1,11 @@
1
+ [
2
+ "LICENSE",
3
+ "README.md",
4
+ "pyproject.toml",
5
+ "PUBLIC_FILES.json",
6
+ "MANIFEST.in",
7
+ "src/fxmacrodata_pulsar/__init__.py",
8
+ "src/fxmacrodata_pulsar/py.typed",
9
+ "tests/test_publisher.py",
10
+ "examples/consumer_dedupe.py"
11
+ ]
@@ -0,0 +1,113 @@
1
+ # FXMacroData for Apache Pulsar
2
+
3
+ Publish FXMacroData public responses to a Pulsar topic with the official
4
+ `pulsar-client` Python library. Stream macro announcements to your own
5
+ subscriptions 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=pulsar-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/pulsar-fxmacrodata
16
+ ```
17
+
18
+ Configure `PULSAR_SERVICE_URL` and any token file in your runtime secret
19
+ store. Then publish through a producer bound to a customer-owned topic:
20
+
21
+ ```python
22
+ import os
23
+ import pulsar
24
+ from fxmacrodata_platform_core import read
25
+ from fxmacrodata_pulsar import publish
26
+
27
+ TOPIC = "persistent://public/default/fxmacrodata"
28
+ client = pulsar.Client(os.environ["PULSAR_SERVICE_URL"])
29
+ try:
30
+ producer = client.create_producer(TOPIC)
31
+ batch = read("indicator_history", {"currency": "usd", "indicator": "policy_rate"})
32
+ result = publish(batch, producer, TOPIC)
33
+ print(result.messages, "messages in", result.batches, "blocks")
34
+ finally:
35
+ client.close()
36
+ ```
37
+
38
+ The destination is the topic name, either the full
39
+ `persistent://tenant/namespace/topic` form or the short name, and it must
40
+ match the topic the producer was created for. Service URLs and other values
41
+ are rejected before any send.
42
+
43
+ Every operation exposed by `fxmacrodata_platform_core.operations()` is
44
+ available, including REST and MCP operations. `publish_operation` reads one
45
+ operation and publishes it in a single call:
46
+
47
+ ```python
48
+ from fxmacrodata_pulsar import publish_operation
49
+
50
+ publish_operation("release_calendar", {"currency": "usd"}, producer, TOPIC,
51
+ api_key_env="FXMD_API_KEY")
52
+ ```
53
+
54
+ ## Message format
55
+
56
+ Each envelope row becomes one message. The body is canonical UTF-8 JSON with
57
+ the keys `operation`, `source_url`, `record_id`, `currency`, `indicator`,
58
+ `date`, `announcement_datetime`, `value`, `value_text`, `record_json`,
59
+ `response_json` and the publisher's `content_hash` (SHA-256 of the ten source
60
+ columns). Only `value` is numeric. Dates and announcement times remain source
61
+ strings. No publication time is inferred, no missing value is filled, and no
62
+ event timestamp is set on the message. `record_json` retains the original
63
+ record; `response_json` retains the complete response, including pagination
64
+ and metadata. An empty response produces one message with `record_json`
65
+ equal to `"null"`, so absence of data and the server's metadata remain
66
+ inspectable. It is not an observation.
67
+
68
+ The partition key is `operation:record_id:content_hash`; the same source
69
+ record with the same content always produces the same key, and changed data
70
+ or metadata produces a different one. The properties `operation`,
71
+ `record_id`, `content_hash` and `source_url` are set on every message for
72
+ consumer-side filtering.
73
+
74
+ ## Delivery guarantee
75
+
76
+ Delivery is at-least-once. Messages are sent in blocks of `batch_size` rows
77
+ (1 to 10,000, 1,000 by default) with `Producer.send`, which blocks until the
78
+ broker acknowledges each message; the producer's own batching settings
79
+ decide how messages are grouped on the wire. A failed block raises
80
+ `PublishError` without the broker's own diagnostic text and is not retried;
81
+ earlier blocks may already be delivered. Consumers should deduplicate on the
82
+ partition key, or on the `operation`, `record_id` and `content_hash`
83
+ properties.
84
+
85
+ `max_message_bytes` (16 MiB by default) rejects an individual envelope larger
86
+ than the budget before anything is published; keep it within the broker's
87
+ `maxMessageSize`, and narrow the source date range to reduce the response
88
+ size. The source response is materialized before publishing; choose a source
89
+ date range suitable for memory.
90
+
91
+ `publish_operation` accepts `api_key_env`, `complete_history`, `max_pages`
92
+ and `client_factory` for the public source reader. Supply source credentials
93
+ through your runtime secret store, exposing only the environment variable
94
+ name; omit `api_key_env` for anonymous USD access. Destination authentication
95
+ belongs to the Pulsar client you create the producer from. The adapter
96
+ neither saves nor prints credentials and never places one in a message or
97
+ property.
98
+
99
+ The package licence covers this adapter code. FXMacroData data access and
100
+ onward redistribution remain subject to your subscription and applicable
101
+ licence. Publishing data to a topic does not grant permission to publish or
102
+ resell it.
103
+
104
+ ## Development
105
+
106
+ Tests run offline against an in-memory fake producer:
107
+
108
+ ```sh
109
+ python -m pytest tests -q
110
+ ```
111
+
112
+ Official SDK reference:
113
+ [pulsar-client Producer.send](https://pulsar.apache.org/api/python/3.13.x/pulsar.Producer.html?utm_source=pypi&utm_medium=referral&utm_campaign=pulsar-fxmacrodata&utm_content=upstream-reference#send).
@@ -0,0 +1,25 @@
1
+ """Consume FXMacroData envelopes from a Pulsar subscription, dropping redeliveries by partition key."""
2
+ import json
3
+ import os
4
+
5
+ import pulsar
6
+
7
+ client = pulsar.Client(os.environ["PULSAR_SERVICE_URL"])
8
+ consumer = client.subscribe("persistent://public/default/fxmacrodata", "fxmacrodata-reader",
9
+ consumer_type=pulsar.ConsumerType.Shared)
10
+ seen: set[str] = set()
11
+
12
+ try:
13
+ while True:
14
+ message = consumer.receive()
15
+ key = message.partition_key() or ":".join(
16
+ message.properties().get(name, "") for name in ("operation", "record_id", "content_hash")
17
+ )
18
+ if key not in seen:
19
+ seen.add(key)
20
+ row = json.loads(message.data().decode("utf-8"))
21
+ if row["record_json"] != "null":
22
+ print(row["operation"], row["currency"], row["indicator"], row["date"], row["value"])
23
+ consumer.acknowledge(message)
24
+ finally:
25
+ client.close()
@@ -0,0 +1,30 @@
1
+ [build-system]
2
+ requires = ["setuptools>=77", "wheel"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "pulsar-fxmacrodata"
7
+ version = "0.1.0"
8
+ description = "Publish public FXMacroData responses to Apache Pulsar"
9
+ readme = "README.md"
10
+ requires-python = ">=3.10"
11
+ license = "MIT"
12
+ dependencies = ["fxmacrodata-platform-core==0.1.0", "pulsar-client>=3.13.0,<4"]
13
+
14
+ [project.urls]
15
+ Documentation = "https://fxmacrodata.com/documentation/reference?utm_source=pypi&utm_medium=referral&utm_campaign=pulsar-fxmacrodata&utm_content=package"
16
+
17
+ [project.optional-dependencies]
18
+ test = ["pytest>=8,<9"]
19
+
20
+ [tool.setuptools]
21
+ packages = ["fxmacrodata_pulsar"]
22
+ package-dir = {"" = "src"}
23
+ include-package-data = false
24
+
25
+ [tool.setuptools.package-data]
26
+ fxmacrodata_pulsar = ["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,155 @@
1
+ """Publish public FXMacroData responses to a customer-owned Apache Pulsar topic."""
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
+ PROPERTIES = ("operation", "record_id", "content_hash", "source_url")
22
+ MAX_MESSAGE_BYTES = 16 * 1024 * 1024
23
+ _TOPIC = re.compile(
24
+ r"(?:(?:persistent|non-persistent)://[A-Za-z0-9_.-]{1,64}/[A-Za-z0-9_.-]{1,64}/)?[A-Za-z0-9_.-]{1,255}\Z"
25
+ )
26
+
27
+
28
+ class PublishError(RuntimeError):
29
+ """A destination publish failed; inspect the destination before retrying."""
30
+
31
+
32
+ @dataclass(frozen=True)
33
+ class PublishResult:
34
+ destination: str
35
+ messages: int
36
+ batches: int
37
+
38
+
39
+ def _topic(destination: str, producer: Any) -> str:
40
+ if not isinstance(destination, str) or not _TOPIC.fullmatch(destination):
41
+ raise ValueError("destination must be a Pulsar topic name such as persistent://tenant/namespace/topic")
42
+ bound = getattr(producer, "topic", None)
43
+ if callable(bound):
44
+ try:
45
+ actual = bound()
46
+ except Exception:
47
+ raise ValueError("the producer did not report its topic; pass a connected pulsar.Producer") from None
48
+ if isinstance(actual, str) and actual != destination:
49
+ raise ValueError("destination does not match the producer's topic")
50
+ return destination
51
+
52
+
53
+ def _bounds(batch_size: int, max_message_bytes: int) -> None:
54
+ if isinstance(batch_size, bool) or not isinstance(batch_size, int) or not 1 <= batch_size <= 10000:
55
+ raise ValueError("batch_size must be an integer between 1 and 10000")
56
+ if isinstance(max_message_bytes, bool) or not isinstance(max_message_bytes, int) or not 1 <= max_message_bytes <= MAX_MESSAGE_BYTES:
57
+ raise ValueError(f"max_message_bytes must be an integer between 1 and {MAX_MESSAGE_BYTES}")
58
+
59
+
60
+ def _rows(batch: Any) -> list[dict[str, Any]]:
61
+ rows = []
62
+ for source in batch.rows():
63
+ row = {name: source.get(name) for name in COLUMNS[:-1]}
64
+ if not isinstance(row["operation"], str) or not isinstance(row["record_id"], str):
65
+ raise ValueError("every row requires string operation and record_id")
66
+ for name, value in row.items():
67
+ if name == "value":
68
+ if value is not None and (
69
+ isinstance(value, bool) or not isinstance(value, (int, float)) or
70
+ not math.isfinite(value)
71
+ ):
72
+ raise ValueError("value must be a finite number or null")
73
+ elif value is not None and not isinstance(value, str):
74
+ raise ValueError(f"{name} must be a string or null")
75
+ encoded = json.dumps(row, sort_keys=True, separators=(",", ":"), ensure_ascii=False)
76
+ row["content_hash"] = hashlib.sha256(encoded.encode("utf-8")).hexdigest()
77
+ rows.append(row)
78
+ return rows
79
+
80
+
81
+ def message_key(row: dict[str, Any]) -> str:
82
+ """Return the idempotency key used as the Pulsar partition key."""
83
+ return f"{row['operation']}:{row['record_id']}:{row['content_hash']}"
84
+
85
+
86
+ def encode_message(row: dict[str, Any]) -> bytes:
87
+ """Return the canonical UTF-8 JSON body for one envelope row."""
88
+ return json.dumps(row, sort_keys=True, separators=(",", ":"), ensure_ascii=False,
89
+ allow_nan=False).encode("utf-8")
90
+
91
+
92
+ def _properties(row: dict[str, Any]) -> dict[str, str]:
93
+ return {name: row[name] for name in PROPERTIES if row.get(name) is not None}
94
+
95
+
96
+ def _blocks(rows: list[dict[str, Any]], batch_size: int,
97
+ max_message_bytes: int) -> list[list[tuple[str, bytes, dict[str, str]]]]:
98
+ output: list[list[tuple[str, bytes, dict[str, str]]]] = []
99
+ block: list[tuple[str, bytes, dict[str, str]]] = []
100
+ for row in rows:
101
+ body = encode_message(row)
102
+ if len(body) > max_message_bytes:
103
+ raise ValueError("one envelope exceeds max_message_bytes; narrow the source date range")
104
+ if len(block) >= batch_size:
105
+ output.append(block)
106
+ block = []
107
+ block.append((message_key(row), body, _properties(row)))
108
+ if block:
109
+ output.append(block)
110
+ return output
111
+
112
+
113
+ def publish(batch: Any, producer: Any, destination: str, *, batch_size: int = 1000,
114
+ max_message_bytes: int = MAX_MESSAGE_BYTES) -> PublishResult:
115
+ """Send one message per envelope row through Producer.send and wait for each acknowledgement.
116
+
117
+ Messages carry the canonical JSON body, the properties `operation`,
118
+ `record_id`, `content_hash` and `source_url`, and a partition key of
119
+ `operation:record_id:content_hash`. No event timestamp is set. Delivery
120
+ is at-least-once; a failed block raises PublishError and is not retried.
121
+ """
122
+ topic = _topic(destination, producer)
123
+ _bounds(batch_size, max_message_bytes)
124
+ blocks = _blocks(_rows(batch), batch_size, max_message_bytes)
125
+ count, messages = 0, 0
126
+ for block in blocks:
127
+ try:
128
+ for key, body, properties in block:
129
+ producer.send(body, properties=properties, partition_key=key, event_timestamp=None)
130
+ except Exception:
131
+ raise PublishError(
132
+ f"Pulsar send block {count + 1} failed; earlier blocks may be delivered. "
133
+ "Inspect the destination and deduplicate by partition key on retry."
134
+ ) from None
135
+ count += 1
136
+ messages += len(block)
137
+ return PublishResult(topic, messages, count)
138
+
139
+
140
+ def publish_operation(operation: str, args: dict[str, Any] | None, producer: Any, destination: str,
141
+ *, api_key_env: str | None = None, complete_history: bool = True,
142
+ max_pages: int = 10000, client_factory: Any = None,
143
+ batch_size: int = 1000, max_message_bytes: int = MAX_MESSAGE_BYTES) -> PublishResult:
144
+ """Read one public operation and publish its complete response envelope."""
145
+ _topic(destination, producer)
146
+ _bounds(batch_size, max_message_bytes)
147
+ batch = read(operation, args, api_key_env=api_key_env,
148
+ complete_history=complete_history, max_pages=max_pages,
149
+ client_factory=client_factory)
150
+ return publish(batch, producer, destination, batch_size=batch_size,
151
+ max_message_bytes=max_message_bytes)
152
+
153
+
154
+ __all__ = ["COLUMNS", "MAX_MESSAGE_BYTES", "PROPERTIES", "PublishError", "PublishResult",
155
+ "encode_message", "message_key", "publish", "publish_operation"]
@@ -0,0 +1,128 @@
1
+ Metadata-Version: 2.4
2
+ Name: pulsar-fxmacrodata
3
+ Version: 0.1.0
4
+ Summary: Publish public FXMacroData responses to Apache Pulsar
5
+ License-Expression: MIT
6
+ Project-URL: Documentation, https://fxmacrodata.com/documentation/reference?utm_source=pypi&utm_medium=referral&utm_campaign=pulsar-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: pulsar-client<4,>=3.13.0
12
+ Provides-Extra: test
13
+ Requires-Dist: pytest<9,>=8; extra == "test"
14
+ Dynamic: license-file
15
+
16
+ # FXMacroData for Apache Pulsar
17
+
18
+ Publish FXMacroData public responses to a Pulsar topic with the official
19
+ `pulsar-client` Python library. Stream macro announcements to your own
20
+ subscriptions 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=pulsar-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/pulsar-fxmacrodata
31
+ ```
32
+
33
+ Configure `PULSAR_SERVICE_URL` and any token file in your runtime secret
34
+ store. Then publish through a producer bound to a customer-owned topic:
35
+
36
+ ```python
37
+ import os
38
+ import pulsar
39
+ from fxmacrodata_platform_core import read
40
+ from fxmacrodata_pulsar import publish
41
+
42
+ TOPIC = "persistent://public/default/fxmacrodata"
43
+ client = pulsar.Client(os.environ["PULSAR_SERVICE_URL"])
44
+ try:
45
+ producer = client.create_producer(TOPIC)
46
+ batch = read("indicator_history", {"currency": "usd", "indicator": "policy_rate"})
47
+ result = publish(batch, producer, TOPIC)
48
+ print(result.messages, "messages in", result.batches, "blocks")
49
+ finally:
50
+ client.close()
51
+ ```
52
+
53
+ The destination is the topic name, either the full
54
+ `persistent://tenant/namespace/topic` form or the short name, and it must
55
+ match the topic the producer was created for. Service URLs and other values
56
+ are rejected before any send.
57
+
58
+ Every operation exposed by `fxmacrodata_platform_core.operations()` is
59
+ available, including REST and MCP operations. `publish_operation` reads one
60
+ operation and publishes it in a single call:
61
+
62
+ ```python
63
+ from fxmacrodata_pulsar import publish_operation
64
+
65
+ publish_operation("release_calendar", {"currency": "usd"}, producer, TOPIC,
66
+ api_key_env="FXMD_API_KEY")
67
+ ```
68
+
69
+ ## Message format
70
+
71
+ Each envelope row becomes one message. The body is canonical UTF-8 JSON with
72
+ the keys `operation`, `source_url`, `record_id`, `currency`, `indicator`,
73
+ `date`, `announcement_datetime`, `value`, `value_text`, `record_json`,
74
+ `response_json` and the publisher's `content_hash` (SHA-256 of the ten source
75
+ columns). Only `value` is numeric. Dates and announcement times remain source
76
+ strings. No publication time is inferred, no missing value is filled, and no
77
+ event timestamp is set on the message. `record_json` retains the original
78
+ record; `response_json` retains the complete response, including pagination
79
+ and metadata. An empty response produces one message with `record_json`
80
+ equal to `"null"`, so absence of data and the server's metadata remain
81
+ inspectable. It is not an observation.
82
+
83
+ The partition key is `operation:record_id:content_hash`; the same source
84
+ record with the same content always produces the same key, and changed data
85
+ or metadata produces a different one. The properties `operation`,
86
+ `record_id`, `content_hash` and `source_url` are set on every message for
87
+ consumer-side filtering.
88
+
89
+ ## Delivery guarantee
90
+
91
+ Delivery is at-least-once. Messages are sent in blocks of `batch_size` rows
92
+ (1 to 10,000, 1,000 by default) with `Producer.send`, which blocks until the
93
+ broker acknowledges each message; the producer's own batching settings
94
+ decide how messages are grouped on the wire. A failed block raises
95
+ `PublishError` without the broker's own diagnostic text and is not retried;
96
+ earlier blocks may already be delivered. Consumers should deduplicate on the
97
+ partition key, or on the `operation`, `record_id` and `content_hash`
98
+ properties.
99
+
100
+ `max_message_bytes` (16 MiB by default) rejects an individual envelope larger
101
+ than the budget before anything is published; keep it within the broker's
102
+ `maxMessageSize`, and narrow the source date range to reduce the response
103
+ size. The source response is materialized before publishing; choose a source
104
+ date range suitable for memory.
105
+
106
+ `publish_operation` accepts `api_key_env`, `complete_history`, `max_pages`
107
+ and `client_factory` for the public source reader. Supply source credentials
108
+ through your runtime secret store, exposing only the environment variable
109
+ name; omit `api_key_env` for anonymous USD access. Destination authentication
110
+ belongs to the Pulsar client you create the producer from. The adapter
111
+ neither saves nor prints credentials and never places one in a message or
112
+ property.
113
+
114
+ The package licence covers this adapter code. FXMacroData data access and
115
+ onward redistribution remain subject to your subscription and applicable
116
+ licence. Publishing data to a topic does not grant permission to publish or
117
+ resell it.
118
+
119
+ ## Development
120
+
121
+ Tests run offline against an in-memory fake producer:
122
+
123
+ ```sh
124
+ python -m pytest tests -q
125
+ ```
126
+
127
+ Official SDK reference:
128
+ [pulsar-client Producer.send](https://pulsar.apache.org/api/python/3.13.x/pulsar.Producer.html?utm_source=pypi&utm_medium=referral&utm_campaign=pulsar-fxmacrodata&utm_content=upstream-reference#send).
@@ -0,0 +1,14 @@
1
+ LICENSE
2
+ MANIFEST.in
3
+ PUBLIC_FILES.json
4
+ README.md
5
+ pyproject.toml
6
+ examples/consumer_dedupe.py
7
+ src/fxmacrodata_pulsar/__init__.py
8
+ src/fxmacrodata_pulsar/py.typed
9
+ src/pulsar_fxmacrodata.egg-info/PKG-INFO
10
+ src/pulsar_fxmacrodata.egg-info/SOURCES.txt
11
+ src/pulsar_fxmacrodata.egg-info/dependency_links.txt
12
+ src/pulsar_fxmacrodata.egg-info/requires.txt
13
+ src/pulsar_fxmacrodata.egg-info/top_level.txt
14
+ tests/test_publisher.py
@@ -0,0 +1,5 @@
1
+ fxmacrodata-platform-core==0.1.0
2
+ pulsar-client<4,>=3.13.0
3
+
4
+ [test]
5
+ pytest<9,>=8
@@ -0,0 +1 @@
1
+ fxmacrodata_pulsar
@@ -0,0 +1,150 @@
1
+ import json
2
+
3
+ import pytest
4
+
5
+ from fxmacrodata_platform_core import Batch, IntegrationError
6
+ import fxmacrodata_pulsar as adapter
7
+
8
+
9
+ TOPIC = "persistent://public/default/fxmacrodata"
10
+
11
+
12
+ class Producer:
13
+ def __init__(self, fail_message=None, topic=TOPIC):
14
+ self.sent = []
15
+ self.fail_message = fail_message
16
+ self._topic = topic
17
+
18
+ def topic(self):
19
+ return self._topic
20
+
21
+ def send(self, content, properties=None, partition_key=None, sequence_id=None,
22
+ replication_clusters=None, disable_replication=False, event_timestamp=None,
23
+ deliver_at=None, deliver_after=None):
24
+ self.sent.append((content, properties, partition_key, event_timestamp))
25
+ if len(self.sent) == self.fail_message:
26
+ raise RuntimeError("private destination diagnostic")
27
+ return object()
28
+
29
+
30
+ def batch(count=3, metadata=None):
31
+ return Batch("indicator_history", {
32
+ "data": [{"currency": "USD", "indicator": "policy_rate", "value": str(i),
33
+ "date": "2026-10-01", "publication_time_status": "unknown"}
34
+ for i in range(count)],
35
+ "metadata": metadata or {"revision": 1},
36
+ })
37
+
38
+
39
+ def test_messages_preserve_full_payload_and_carry_dedup_keys():
40
+ source, producer = batch(), Producer()
41
+ result = adapter.publish(source, producer, TOPIC, batch_size=2)
42
+ assert result == adapter.PublishResult(TOPIC, 3, 2)
43
+ content, properties, partition_key, event_timestamp = producer.sent[0]
44
+ row = json.loads(content.decode("utf-8"))
45
+ assert event_timestamp is None
46
+ assert set(row) == set(adapter.COLUMNS)
47
+ assert row["date"] == "2026-10-01" and row["announcement_datetime"] is None
48
+ assert row["value"] == 0.0 and row["value_text"] == "0"
49
+ assert json.loads(row["record_json"])["publication_time_status"] == "unknown"
50
+ assert json.loads(row["response_json"]) == source.payload
51
+ assert partition_key == f"indicator_history:{row['record_id']}:{row['content_hash']}"
52
+ assert properties == {"operation": "indicator_history", "record_id": row["record_id"],
53
+ "content_hash": row["content_hash"], "source_url": "https://fxmacrodata.com"}
54
+ assert content == json.dumps(row, sort_keys=True, separators=(",", ":"), ensure_ascii=False).encode()
55
+
56
+
57
+ def test_empty_response_is_a_metadata_envelope_not_an_observation():
58
+ source, producer = batch(0), Producer()
59
+ result = adapter.publish(source, producer, TOPIC)
60
+ row = json.loads(producer.sent[0][0])
61
+ assert result.messages == 1 and row["record_json"] == "null"
62
+ assert row["value"] is None and row["date"] is None
63
+ assert json.loads(row["response_json"]) == source.payload
64
+
65
+
66
+ def test_retries_have_stable_keys_and_changed_metadata_is_distinct():
67
+ first, retry, changed = Producer(), Producer(), Producer()
68
+ adapter.publish(batch(1), first, TOPIC)
69
+ adapter.publish(batch(1), retry, TOPIC)
70
+ adapter.publish(batch(1, {"revision": 2}), changed, TOPIC)
71
+ assert first.sent == retry.sent
72
+ assert first.sent[0][1]["record_id"] == changed.sent[0][1]["record_id"]
73
+ assert first.sent[0][2] != changed.sent[0][2]
74
+
75
+
76
+ @pytest.mark.parametrize("destination", [
77
+ "persistent://public/default/other", "pulsar://broker:6650/fxmacrodata", "persistent://public/fxmacrodata",
78
+ "fx macro", "", None,
79
+ ])
80
+ def test_bad_or_mismatched_topics_never_publish(destination):
81
+ producer = Producer()
82
+ with pytest.raises(ValueError):
83
+ adapter.publish(batch(), producer, destination)
84
+ assert producer.sent == []
85
+
86
+
87
+ def test_short_topic_names_are_accepted_when_the_producer_reports_them():
88
+ producer = Producer(topic="fxmacrodata")
89
+ assert adapter.publish(batch(1), producer, "fxmacrodata").destination == "fxmacrodata"
90
+
91
+
92
+ @pytest.mark.parametrize("options", [
93
+ {"batch_size": 0}, {"batch_size": -1}, {"batch_size": 10001}, {"batch_size": True},
94
+ {"batch_size": 2.5}, {"max_message_bytes": 0}, {"max_message_bytes": 16 * 1024 * 1024 + 1},
95
+ ])
96
+ def test_batch_bounds(options):
97
+ producer = Producer()
98
+ with pytest.raises(ValueError):
99
+ adapter.publish(batch(), producer, TOPIC, **options)
100
+ assert producer.sent == []
101
+
102
+
103
+ def test_failed_block_is_not_retried_or_exposed():
104
+ producer = Producer(fail_message=2)
105
+ with pytest.raises(adapter.PublishError) as error:
106
+ adapter.publish(batch(), producer, TOPIC, batch_size=1)
107
+ assert "block 2" in str(error.value)
108
+ assert "private destination diagnostic" not in str(error.value)
109
+ assert len(producer.sent) == 2
110
+
111
+
112
+ def test_oversized_envelope_fails_before_any_publish():
113
+ producer = Producer()
114
+ with pytest.raises(ValueError, match="narrow the source date range"):
115
+ adapter.publish(batch(), producer, TOPIC, max_message_bytes=1)
116
+ assert producer.sent == []
117
+
118
+
119
+ def test_operation_forwards_public_reader_options(monkeypatch):
120
+ calls = []
121
+ monkeypatch.setattr(adapter, "read", lambda *args, **kw: calls.append((args, kw)) or batch(1))
122
+ factory = object()
123
+ adapter.publish_operation("indicator_history", {"currency": "usd", "indicator": "policy_rate"},
124
+ Producer(), TOPIC, api_key_env="FXMD_API_KEY", max_pages=12,
125
+ complete_history=False, client_factory=factory)
126
+ assert calls[0][0] == ("indicator_history", {"currency": "usd", "indicator": "policy_rate"})
127
+ assert calls[0][1] == dict(api_key_env="FXMD_API_KEY", complete_history=False,
128
+ max_pages=12, client_factory=factory)
129
+
130
+
131
+ def test_invalid_configuration_never_reads_source(monkeypatch):
132
+ def unexpected(*args, **kwargs):
133
+ pytest.fail("source must not be called for invalid publisher configuration")
134
+ monkeypatch.setattr(adapter, "read", unexpected)
135
+ for options in ({"batch_size": 0}, {"max_message_bytes": 0}):
136
+ with pytest.raises(ValueError):
137
+ adapter.publish_operation("release_calendar", {"currency": "usd"}, Producer(), TOPIC, **options)
138
+ with pytest.raises(ValueError):
139
+ adapter.publish_operation("release_calendar", {"currency": "usd"}, Producer(), "persistent://public/default/other")
140
+
141
+
142
+ def test_credential_arguments_are_rejected_before_publishing():
143
+ producer = Producer()
144
+ with pytest.raises(IntegrationError):
145
+ adapter.publish_operation("indicator_history", {"currency": "usd", "indicator": "policy_rate",
146
+ "api_key": "secret"}, producer, TOPIC)
147
+ with pytest.raises(IntegrationError):
148
+ adapter.publish_operation("release_calendar", {"currency": "usd"}, producer, TOPIC,
149
+ api_key_env="not-an-env-name")
150
+ assert producer.sent == []