pubsub-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_pubsub/__init__.py
7
+ include src/fxmacrodata_pubsub/py.typed
8
+ include tests/test_publisher.py
9
+ include examples/subscriber_dedupe.py
10
+ global-exclude *.pyc *.pyo
11
+ global-exclude __pycache__/*
@@ -0,0 +1,122 @@
1
+ Metadata-Version: 2.4
2
+ Name: pubsub-fxmacrodata
3
+ Version: 0.1.0
4
+ Summary: Publish public FXMacroData responses to Google Cloud Pub/Sub
5
+ License-Expression: MIT
6
+ Project-URL: Documentation, https://fxmacrodata.com/documentation/reference?utm_source=pypi&utm_medium=referral&utm_campaign=pubsub-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: google-cloud-pubsub<3,>=2.41.0
12
+ Provides-Extra: test
13
+ Requires-Dist: pytest<9,>=8; extra == "test"
14
+ Dynamic: license-file
15
+
16
+ # FXMacroData for Google Cloud Pub/Sub
17
+
18
+ Publish FXMacroData public responses to a Pub/Sub topic with the official
19
+ `google-cloud-pubsub` Python client. Fan macro announcements out to your own
20
+ subscribers 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=pubsub-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/pubsub-fxmacrodata
31
+ ```
32
+
33
+ Authenticate the Pub/Sub client with Application Default Credentials or the
34
+ service account your administrator provides. Then publish to a customer-owned
35
+ topic:
36
+
37
+ ```python
38
+ from google.cloud import pubsub_v1
39
+ from fxmacrodata_platform_core import read
40
+ from fxmacrodata_pubsub import publish
41
+
42
+ client = pubsub_v1.PublisherClient(
43
+ publisher_options=pubsub_v1.types.PublisherOptions(enable_message_ordering=True),
44
+ )
45
+ batch = read("indicator_history", {"currency": "usd", "indicator": "policy_rate"})
46
+ result = publish(batch, client, "projects/my-project/topics/fxmacrodata")
47
+ print(result.messages, "messages in", result.batches, "blocks")
48
+ ```
49
+
50
+ The topic must already exist. The destination is the full topic path,
51
+ `projects/<project>/topics/<topic>`; anything else, including URLs and
52
+ subscription paths, is rejected before any publish.
53
+
54
+ Every operation exposed by `fxmacrodata_platform_core.operations()` is
55
+ available, including REST and MCP operations. `publish_operation` reads one
56
+ operation and publishes it in a single call:
57
+
58
+ ```python
59
+ from fxmacrodata_pubsub import publish_operation
60
+
61
+ publish_operation("release_calendar", {"currency": "usd"}, client,
62
+ "projects/my-project/topics/fxmacrodata", api_key_env="FXMD_API_KEY")
63
+ ```
64
+
65
+ ## Message format
66
+
67
+ Each envelope row becomes one message. The body is canonical UTF-8 JSON with
68
+ the keys `operation`, `source_url`, `record_id`, `currency`, `indicator`,
69
+ `date`, `announcement_datetime`, `value`, `value_text`, `record_json`,
70
+ `response_json` and the publisher's `content_hash` (SHA-256 of the ten source
71
+ columns). Only `value` is numeric. Dates and announcement times remain source
72
+ strings. No publication time is inferred, and no missing value is filled.
73
+ `record_json` retains the original record; `response_json` retains the
74
+ complete response, including pagination and metadata. An empty response
75
+ produces one message with `record_json` equal to `"null"`, so absence of data
76
+ and the server's metadata remain inspectable. It is not an observation.
77
+
78
+ Message attributes carry `operation`, `record_id`, `content_hash` and
79
+ `source_url` for subscription filters. The ordering key is
80
+ `operation:record_id:content_hash`; the same source record with the same
81
+ content always produces the same key, and changed data or metadata produces a
82
+ different one. Pass `ordered=False` to publish without an ordering key when
83
+ the client was created without `enable_message_ordering`.
84
+
85
+ ## Delivery guarantee
86
+
87
+ Delivery is at-least-once. Messages are published in blocks of `batch_size`
88
+ rows (1 to 10,000, 1,000 by default) and every block's futures are awaited
89
+ before the next block starts. A failed block raises `PublishError` without the
90
+ service's own diagnostic text and is not retried; earlier blocks may already
91
+ be delivered. Subscribers should deduplicate on the ordering key, or on the
92
+ `operation`, `record_id` and `content_hash` attributes. After an ordering-key
93
+ failure the official client pauses that key; call `resume_publish` on the
94
+ client before publishing the key again.
95
+
96
+ `max_message_bytes` (16 MiB by default) rejects an individual envelope larger
97
+ than the budget before anything is published; narrow the source date range to
98
+ reduce the response size. The source response is materialized before
99
+ publishing; choose a source date range suitable for memory.
100
+
101
+ `publish_operation` accepts `api_key_env`, `complete_history`, `max_pages`
102
+ and `client_factory` for the public source reader. Supply source credentials
103
+ through your runtime secret store, exposing only the environment variable
104
+ name; omit `api_key_env` for anonymous USD access. Destination authentication
105
+ belongs to the official client you pass in. The adapter neither saves nor
106
+ prints credentials and never places one in a message or attribute.
107
+
108
+ The package licence covers this adapter code. FXMacroData data access and
109
+ onward redistribution remain subject to your subscription and applicable
110
+ licence. Publishing data to a topic does not grant permission to publish or
111
+ resell it.
112
+
113
+ ## Development
114
+
115
+ Tests run offline against an in-memory fake client:
116
+
117
+ ```sh
118
+ python -m pytest tests -q
119
+ ```
120
+
121
+ Official SDK reference:
122
+ [Pub/Sub publisher client](https://cloud.google.com/python/docs/reference/pubsub/latest/google.cloud.pubsub_v1.publisher.client.Client?utm_source=pypi&utm_medium=referral&utm_campaign=pubsub-fxmacrodata&utm_content=upstream-reference).
@@ -0,0 +1,11 @@
1
+ [
2
+ "LICENSE",
3
+ "README.md",
4
+ "pyproject.toml",
5
+ "PUBLIC_FILES.json",
6
+ "MANIFEST.in",
7
+ "src/fxmacrodata_pubsub/__init__.py",
8
+ "src/fxmacrodata_pubsub/py.typed",
9
+ "tests/test_publisher.py",
10
+ "examples/subscriber_dedupe.py"
11
+ ]
@@ -0,0 +1,107 @@
1
+ # FXMacroData for Google Cloud Pub/Sub
2
+
3
+ Publish FXMacroData public responses to a Pub/Sub topic with the official
4
+ `google-cloud-pubsub` Python client. Fan macro announcements out to your own
5
+ subscribers 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=pubsub-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/pubsub-fxmacrodata
16
+ ```
17
+
18
+ Authenticate the Pub/Sub client with Application Default Credentials or the
19
+ service account your administrator provides. Then publish to a customer-owned
20
+ topic:
21
+
22
+ ```python
23
+ from google.cloud import pubsub_v1
24
+ from fxmacrodata_platform_core import read
25
+ from fxmacrodata_pubsub import publish
26
+
27
+ client = pubsub_v1.PublisherClient(
28
+ publisher_options=pubsub_v1.types.PublisherOptions(enable_message_ordering=True),
29
+ )
30
+ batch = read("indicator_history", {"currency": "usd", "indicator": "policy_rate"})
31
+ result = publish(batch, client, "projects/my-project/topics/fxmacrodata")
32
+ print(result.messages, "messages in", result.batches, "blocks")
33
+ ```
34
+
35
+ The topic must already exist. The destination is the full topic path,
36
+ `projects/<project>/topics/<topic>`; anything else, including URLs and
37
+ subscription paths, is rejected before any publish.
38
+
39
+ Every operation exposed by `fxmacrodata_platform_core.operations()` is
40
+ available, including REST and MCP operations. `publish_operation` reads one
41
+ operation and publishes it in a single call:
42
+
43
+ ```python
44
+ from fxmacrodata_pubsub import publish_operation
45
+
46
+ publish_operation("release_calendar", {"currency": "usd"}, client,
47
+ "projects/my-project/topics/fxmacrodata", api_key_env="FXMD_API_KEY")
48
+ ```
49
+
50
+ ## Message format
51
+
52
+ Each envelope row becomes one message. The body is canonical UTF-8 JSON with
53
+ the keys `operation`, `source_url`, `record_id`, `currency`, `indicator`,
54
+ `date`, `announcement_datetime`, `value`, `value_text`, `record_json`,
55
+ `response_json` and the publisher's `content_hash` (SHA-256 of the ten source
56
+ columns). Only `value` is numeric. Dates and announcement times remain source
57
+ strings. No publication time is inferred, and no missing value is filled.
58
+ `record_json` retains the original record; `response_json` retains the
59
+ complete response, including pagination and metadata. An empty response
60
+ produces one message with `record_json` equal to `"null"`, so absence of data
61
+ and the server's metadata remain inspectable. It is not an observation.
62
+
63
+ Message attributes carry `operation`, `record_id`, `content_hash` and
64
+ `source_url` for subscription filters. The ordering key is
65
+ `operation:record_id:content_hash`; the same source record with the same
66
+ content always produces the same key, and changed data or metadata produces a
67
+ different one. Pass `ordered=False` to publish without an ordering key when
68
+ the client was created without `enable_message_ordering`.
69
+
70
+ ## Delivery guarantee
71
+
72
+ Delivery is at-least-once. Messages are published in blocks of `batch_size`
73
+ rows (1 to 10,000, 1,000 by default) and every block's futures are awaited
74
+ before the next block starts. A failed block raises `PublishError` without the
75
+ service's own diagnostic text and is not retried; earlier blocks may already
76
+ be delivered. Subscribers should deduplicate on the ordering key, or on the
77
+ `operation`, `record_id` and `content_hash` attributes. After an ordering-key
78
+ failure the official client pauses that key; call `resume_publish` on the
79
+ client before publishing the key again.
80
+
81
+ `max_message_bytes` (16 MiB by default) rejects an individual envelope larger
82
+ than the budget before anything is published; narrow the source date range to
83
+ reduce the response size. The source response is materialized before
84
+ publishing; choose a source date range suitable for memory.
85
+
86
+ `publish_operation` accepts `api_key_env`, `complete_history`, `max_pages`
87
+ and `client_factory` for the public source reader. Supply source credentials
88
+ through your runtime secret store, exposing only the environment variable
89
+ name; omit `api_key_env` for anonymous USD access. Destination authentication
90
+ belongs to the official client you pass in. The adapter neither saves nor
91
+ prints credentials and never places one in a message or attribute.
92
+
93
+ The package licence covers this adapter code. FXMacroData data access and
94
+ onward redistribution remain subject to your subscription and applicable
95
+ licence. Publishing data to a topic does not grant permission to publish or
96
+ resell it.
97
+
98
+ ## Development
99
+
100
+ Tests run offline against an in-memory fake client:
101
+
102
+ ```sh
103
+ python -m pytest tests -q
104
+ ```
105
+
106
+ Official SDK reference:
107
+ [Pub/Sub publisher client](https://cloud.google.com/python/docs/reference/pubsub/latest/google.cloud.pubsub_v1.publisher.client.Client?utm_source=pypi&utm_medium=referral&utm_campaign=pubsub-fxmacrodata&utm_content=upstream-reference).
@@ -0,0 +1,26 @@
1
+ """Consume FXMacroData envelopes from a Pub/Sub subscription, dropping redeliveries by ordering key."""
2
+ import json
3
+ import os
4
+
5
+ from google.cloud import pubsub_v1
6
+
7
+ SUBSCRIPTION = os.environ["FXMD_PUBSUB_SUBSCRIPTION"] # projects/<project>/subscriptions/<name>
8
+ seen: set[str] = set()
9
+
10
+
11
+ def handle(message: pubsub_v1.subscriber.message.Message) -> None:
12
+ key = message.ordering_key or ":".join(
13
+ message.attributes.get(name, "") for name in ("operation", "record_id", "content_hash")
14
+ )
15
+ if key in seen:
16
+ message.ack()
17
+ return
18
+ seen.add(key)
19
+ row = json.loads(message.data.decode("utf-8"))
20
+ if row["record_json"] != "null":
21
+ print(row["operation"], row["currency"], row["indicator"], row["date"], row["value"])
22
+ message.ack()
23
+
24
+
25
+ with pubsub_v1.SubscriberClient() as subscriber:
26
+ subscriber.subscribe(SUBSCRIPTION, callback=handle).result()
@@ -0,0 +1,30 @@
1
+ [build-system]
2
+ requires = ["setuptools>=77", "wheel"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "pubsub-fxmacrodata"
7
+ version = "0.1.0"
8
+ description = "Publish public FXMacroData responses to Google Cloud Pub/Sub"
9
+ readme = "README.md"
10
+ requires-python = ">=3.10"
11
+ license = "MIT"
12
+ dependencies = ["fxmacrodata-platform-core==0.1.0", "google-cloud-pubsub>=2.41.0,<3"]
13
+
14
+ [project.urls]
15
+ Documentation = "https://fxmacrodata.com/documentation/reference?utm_source=pypi&utm_medium=referral&utm_campaign=pubsub-fxmacrodata&utm_content=package"
16
+
17
+ [project.optional-dependencies]
18
+ test = ["pytest>=8,<9"]
19
+
20
+ [tool.setuptools]
21
+ packages = ["fxmacrodata_pubsub"]
22
+ package-dir = {"" = "src"}
23
+ include-package-data = false
24
+
25
+ [tool.setuptools.package-data]
26
+ fxmacrodata_pubsub = ["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,157 @@
1
+ """Publish public FXMacroData responses to a customer-owned Google Cloud Pub/Sub 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
+ ATTRIBUTES = ("operation", "record_id", "content_hash", "source_url")
22
+ MAX_MESSAGE_BYTES = 16 * 1024 * 1024
23
+ _TOPIC = re.compile(
24
+ r"projects/[a-z][a-z0-9-]{4,28}[a-z0-9]/topics/(?!goog)[A-Za-z][A-Za-z0-9._~%+-]{2,254}\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(topic: str) -> str:
40
+ if not isinstance(topic, str) or not _TOPIC.fullmatch(topic):
41
+ raise ValueError("destination must be a full topic path: projects/<project>/topics/<topic>")
42
+ return topic
43
+
44
+
45
+ def _bounds(batch_size: int, max_message_bytes: int) -> None:
46
+ if isinstance(batch_size, bool) or not isinstance(batch_size, int) or not 1 <= batch_size <= 10000:
47
+ raise ValueError("batch_size must be an integer between 1 and 10000")
48
+ if isinstance(max_message_bytes, bool) or not isinstance(max_message_bytes, int) or not 1 <= max_message_bytes <= MAX_MESSAGE_BYTES:
49
+ raise ValueError(f"max_message_bytes must be an integer between 1 and {MAX_MESSAGE_BYTES}")
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 consumers can use to drop redelivered messages."""
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 body for one envelope row."""
80
+ return json.dumps(row, sort_keys=True, separators=(",", ":"), ensure_ascii=False,
81
+ allow_nan=False).encode("utf-8")
82
+
83
+
84
+ def _attributes(row: dict[str, Any]) -> dict[str, str]:
85
+ return {name: row[name] for name in ATTRIBUTES if row.get(name) is not None}
86
+
87
+
88
+ def _blocks(rows: list[dict[str, Any]], batch_size: int,
89
+ max_message_bytes: int) -> list[list[tuple[str, bytes, dict[str, str]]]]:
90
+ _bounds(batch_size, max_message_bytes)
91
+ output: list[list[tuple[str, bytes, dict[str, str]]]] = []
92
+ block: list[tuple[str, bytes, dict[str, str]]] = []
93
+ for row in rows:
94
+ body = encode_message(row)
95
+ if len(body) > max_message_bytes:
96
+ raise ValueError("one envelope exceeds max_message_bytes; narrow the source date range")
97
+ if len(block) >= batch_size:
98
+ output.append(block)
99
+ block = []
100
+ block.append((message_key(row), body, _attributes(row)))
101
+ if block:
102
+ output.append(block)
103
+ return output
104
+
105
+
106
+ def publish(batch: Any, client: Any, destination: str, *, batch_size: int = 1000,
107
+ max_message_bytes: int = MAX_MESSAGE_BYTES, ordered: bool = True) -> PublishResult:
108
+ """Publish one message per envelope row through PublisherClient.publish.
109
+
110
+ Each message carries the canonical JSON body, the attributes `operation`,
111
+ `record_id`, `content_hash` and `source_url`, and (when `ordered` is true)
112
+ an ordering key of `operation:record_id:content_hash`. Futures are awaited
113
+ per block, so a block is complete before the next block starts. Delivery
114
+ is at-least-once; a failed block raises PublishError and is not retried.
115
+ """
116
+ topic = _topic(destination)
117
+ if not isinstance(ordered, bool):
118
+ raise ValueError("ordered must be a boolean")
119
+ blocks = _blocks(_rows(batch), batch_size, max_message_bytes)
120
+ count, messages = 0, 0
121
+ for block in blocks:
122
+ try:
123
+ futures = [
124
+ client.publish(topic, body, ordering_key=key if ordered else "", **attributes)
125
+ for key, body, attributes in block
126
+ ]
127
+ for future in futures:
128
+ future.result()
129
+ except Exception:
130
+ raise PublishError(
131
+ f"Pub/Sub publish block {count + 1} failed; earlier blocks may be delivered. "
132
+ "Inspect the destination and deduplicate by ordering key on retry."
133
+ ) from None
134
+ count += 1
135
+ messages += len(block)
136
+ return PublishResult(topic, messages, count)
137
+
138
+
139
+ def publish_operation(operation: str, args: dict[str, Any] | None, client: Any, destination: str,
140
+ *, api_key_env: str | None = None, complete_history: bool = True,
141
+ max_pages: int = 10000, client_factory: Any = None,
142
+ batch_size: int = 1000, max_message_bytes: int = MAX_MESSAGE_BYTES,
143
+ ordered: bool = True) -> PublishResult:
144
+ """Read one public operation and publish its complete response envelope."""
145
+ _topic(destination)
146
+ _bounds(batch_size, max_message_bytes)
147
+ if not isinstance(ordered, bool):
148
+ raise ValueError("ordered must be a boolean")
149
+ batch = read(operation, args, api_key_env=api_key_env,
150
+ complete_history=complete_history, max_pages=max_pages,
151
+ client_factory=client_factory)
152
+ return publish(batch, client, destination, batch_size=batch_size,
153
+ max_message_bytes=max_message_bytes, ordered=ordered)
154
+
155
+
156
+ __all__ = ["ATTRIBUTES", "COLUMNS", "MAX_MESSAGE_BYTES", "PublishError", "PublishResult",
157
+ "encode_message", "message_key", "publish", "publish_operation"]
@@ -0,0 +1,122 @@
1
+ Metadata-Version: 2.4
2
+ Name: pubsub-fxmacrodata
3
+ Version: 0.1.0
4
+ Summary: Publish public FXMacroData responses to Google Cloud Pub/Sub
5
+ License-Expression: MIT
6
+ Project-URL: Documentation, https://fxmacrodata.com/documentation/reference?utm_source=pypi&utm_medium=referral&utm_campaign=pubsub-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: google-cloud-pubsub<3,>=2.41.0
12
+ Provides-Extra: test
13
+ Requires-Dist: pytest<9,>=8; extra == "test"
14
+ Dynamic: license-file
15
+
16
+ # FXMacroData for Google Cloud Pub/Sub
17
+
18
+ Publish FXMacroData public responses to a Pub/Sub topic with the official
19
+ `google-cloud-pubsub` Python client. Fan macro announcements out to your own
20
+ subscribers 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=pubsub-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/pubsub-fxmacrodata
31
+ ```
32
+
33
+ Authenticate the Pub/Sub client with Application Default Credentials or the
34
+ service account your administrator provides. Then publish to a customer-owned
35
+ topic:
36
+
37
+ ```python
38
+ from google.cloud import pubsub_v1
39
+ from fxmacrodata_platform_core import read
40
+ from fxmacrodata_pubsub import publish
41
+
42
+ client = pubsub_v1.PublisherClient(
43
+ publisher_options=pubsub_v1.types.PublisherOptions(enable_message_ordering=True),
44
+ )
45
+ batch = read("indicator_history", {"currency": "usd", "indicator": "policy_rate"})
46
+ result = publish(batch, client, "projects/my-project/topics/fxmacrodata")
47
+ print(result.messages, "messages in", result.batches, "blocks")
48
+ ```
49
+
50
+ The topic must already exist. The destination is the full topic path,
51
+ `projects/<project>/topics/<topic>`; anything else, including URLs and
52
+ subscription paths, is rejected before any publish.
53
+
54
+ Every operation exposed by `fxmacrodata_platform_core.operations()` is
55
+ available, including REST and MCP operations. `publish_operation` reads one
56
+ operation and publishes it in a single call:
57
+
58
+ ```python
59
+ from fxmacrodata_pubsub import publish_operation
60
+
61
+ publish_operation("release_calendar", {"currency": "usd"}, client,
62
+ "projects/my-project/topics/fxmacrodata", api_key_env="FXMD_API_KEY")
63
+ ```
64
+
65
+ ## Message format
66
+
67
+ Each envelope row becomes one message. The body is canonical UTF-8 JSON with
68
+ the keys `operation`, `source_url`, `record_id`, `currency`, `indicator`,
69
+ `date`, `announcement_datetime`, `value`, `value_text`, `record_json`,
70
+ `response_json` and the publisher's `content_hash` (SHA-256 of the ten source
71
+ columns). Only `value` is numeric. Dates and announcement times remain source
72
+ strings. No publication time is inferred, and no missing value is filled.
73
+ `record_json` retains the original record; `response_json` retains the
74
+ complete response, including pagination and metadata. An empty response
75
+ produces one message with `record_json` equal to `"null"`, so absence of data
76
+ and the server's metadata remain inspectable. It is not an observation.
77
+
78
+ Message attributes carry `operation`, `record_id`, `content_hash` and
79
+ `source_url` for subscription filters. The ordering key is
80
+ `operation:record_id:content_hash`; the same source record with the same
81
+ content always produces the same key, and changed data or metadata produces a
82
+ different one. Pass `ordered=False` to publish without an ordering key when
83
+ the client was created without `enable_message_ordering`.
84
+
85
+ ## Delivery guarantee
86
+
87
+ Delivery is at-least-once. Messages are published in blocks of `batch_size`
88
+ rows (1 to 10,000, 1,000 by default) and every block's futures are awaited
89
+ before the next block starts. A failed block raises `PublishError` without the
90
+ service's own diagnostic text and is not retried; earlier blocks may already
91
+ be delivered. Subscribers should deduplicate on the ordering key, or on the
92
+ `operation`, `record_id` and `content_hash` attributes. After an ordering-key
93
+ failure the official client pauses that key; call `resume_publish` on the
94
+ client before publishing the key again.
95
+
96
+ `max_message_bytes` (16 MiB by default) rejects an individual envelope larger
97
+ than the budget before anything is published; narrow the source date range to
98
+ reduce the response size. The source response is materialized before
99
+ publishing; choose a source date range suitable for memory.
100
+
101
+ `publish_operation` accepts `api_key_env`, `complete_history`, `max_pages`
102
+ and `client_factory` for the public source reader. Supply source credentials
103
+ through your runtime secret store, exposing only the environment variable
104
+ name; omit `api_key_env` for anonymous USD access. Destination authentication
105
+ belongs to the official client you pass in. The adapter neither saves nor
106
+ prints credentials and never places one in a message or attribute.
107
+
108
+ The package licence covers this adapter code. FXMacroData data access and
109
+ onward redistribution remain subject to your subscription and applicable
110
+ licence. Publishing data to a topic does not grant permission to publish or
111
+ resell it.
112
+
113
+ ## Development
114
+
115
+ Tests run offline against an in-memory fake client:
116
+
117
+ ```sh
118
+ python -m pytest tests -q
119
+ ```
120
+
121
+ Official SDK reference:
122
+ [Pub/Sub publisher client](https://cloud.google.com/python/docs/reference/pubsub/latest/google.cloud.pubsub_v1.publisher.client.Client?utm_source=pypi&utm_medium=referral&utm_campaign=pubsub-fxmacrodata&utm_content=upstream-reference).
@@ -0,0 +1,14 @@
1
+ LICENSE
2
+ MANIFEST.in
3
+ PUBLIC_FILES.json
4
+ README.md
5
+ pyproject.toml
6
+ examples/subscriber_dedupe.py
7
+ src/fxmacrodata_pubsub/__init__.py
8
+ src/fxmacrodata_pubsub/py.typed
9
+ src/pubsub_fxmacrodata.egg-info/PKG-INFO
10
+ src/pubsub_fxmacrodata.egg-info/SOURCES.txt
11
+ src/pubsub_fxmacrodata.egg-info/dependency_links.txt
12
+ src/pubsub_fxmacrodata.egg-info/requires.txt
13
+ src/pubsub_fxmacrodata.egg-info/top_level.txt
14
+ tests/test_publisher.py
@@ -0,0 +1,5 @@
1
+ fxmacrodata-platform-core==0.1.0
2
+ google-cloud-pubsub<3,>=2.41.0
3
+
4
+ [test]
5
+ pytest<9,>=8
@@ -0,0 +1 @@
1
+ fxmacrodata_pubsub
@@ -0,0 +1,155 @@
1
+ import json
2
+
3
+ import pytest
4
+
5
+ from fxmacrodata_platform_core import Batch, IntegrationError
6
+ import fxmacrodata_pubsub as adapter
7
+
8
+
9
+ class Future:
10
+ def __init__(self, fail):
11
+ self.fail = fail
12
+
13
+ def result(self):
14
+ if self.fail:
15
+ raise RuntimeError("private destination diagnostic")
16
+ return "message-id"
17
+
18
+
19
+ class Client:
20
+ def __init__(self, fail_message=None):
21
+ self.published = []
22
+ self.fail_message = fail_message
23
+
24
+ def publish(self, topic, data, ordering_key="", **attrs):
25
+ self.published.append((topic, data, ordering_key, attrs))
26
+ return Future(len(self.published) == self.fail_message)
27
+
28
+
29
+ TOPIC = "projects/my-project/topics/fxmacrodata"
30
+
31
+
32
+ def batch(count=3, metadata=None):
33
+ return Batch("indicator_history", {
34
+ "data": [{"currency": "USD", "indicator": "policy_rate", "value": str(i),
35
+ "date": "2026-10-01", "publication_time_status": "unknown"}
36
+ for i in range(count)],
37
+ "metadata": metadata or {"revision": 1},
38
+ })
39
+
40
+
41
+ def test_messages_preserve_full_payload_and_carry_dedup_keys():
42
+ source, client = batch(), Client()
43
+ result = adapter.publish(source, client, TOPIC, batch_size=2)
44
+ assert result == adapter.PublishResult(TOPIC, 3, 2)
45
+ topic, data, ordering_key, attrs = client.published[0]
46
+ row = json.loads(data.decode("utf-8"))
47
+ assert topic == TOPIC
48
+ assert set(row) == set(adapter.COLUMNS)
49
+ assert row["date"] == "2026-10-01" and row["announcement_datetime"] is None
50
+ assert row["value"] == 0.0 and row["value_text"] == "0"
51
+ assert json.loads(row["record_json"])["publication_time_status"] == "unknown"
52
+ assert json.loads(row["response_json"]) == source.payload
53
+ assert ordering_key == f"indicator_history:{row['record_id']}:{row['content_hash']}"
54
+ assert attrs == {"operation": "indicator_history", "record_id": row["record_id"],
55
+ "content_hash": row["content_hash"], "source_url": "https://fxmacrodata.com"}
56
+ assert data == json.dumps(row, sort_keys=True, separators=(",", ":"), ensure_ascii=False).encode()
57
+
58
+
59
+ def test_empty_response_is_a_metadata_envelope_not_an_observation():
60
+ source, client = batch(0), Client()
61
+ result = adapter.publish(source, client, TOPIC)
62
+ row = json.loads(client.published[0][1])
63
+ assert result.messages == 1 and row["record_json"] == "null"
64
+ assert row["value"] is None and row["date"] is None
65
+ assert json.loads(row["response_json"]) == source.payload
66
+
67
+
68
+ def test_retries_have_stable_keys_and_changed_metadata_is_distinct():
69
+ first, retry, changed = Client(), Client(), Client()
70
+ adapter.publish(batch(1), first, TOPIC)
71
+ adapter.publish(batch(1), retry, TOPIC)
72
+ adapter.publish(batch(1, {"revision": 2}), changed, TOPIC)
73
+ assert first.published == retry.published
74
+ assert first.published[0][3]["record_id"] == changed.published[0][3]["record_id"]
75
+ assert first.published[0][2] != changed.published[0][2]
76
+
77
+
78
+ @pytest.mark.parametrize("topic", [
79
+ "fxmacrodata", "projects/my-project/subscriptions/x", "projects/My Project/topics/x",
80
+ "projects/my-project/topics/goog-x", "projects/my-project/topics/ab",
81
+ "https://pubsub.googleapis.com/projects/p/topics/t", "", None,
82
+ ])
83
+ def test_bad_topics_never_publish(topic):
84
+ client = Client()
85
+ with pytest.raises(ValueError):
86
+ adapter.publish(batch(), client, topic)
87
+ assert client.published == []
88
+
89
+
90
+ @pytest.mark.parametrize("options", [
91
+ {"batch_size": 0}, {"batch_size": -1}, {"batch_size": 10001}, {"batch_size": True},
92
+ {"batch_size": 2.5}, {"max_message_bytes": 0}, {"max_message_bytes": 16 * 1024 * 1024 + 1},
93
+ {"ordered": "yes"},
94
+ ])
95
+ def test_batch_bounds(options):
96
+ client = Client()
97
+ with pytest.raises(ValueError):
98
+ adapter.publish(batch(), client, TOPIC, **options)
99
+ assert client.published == []
100
+
101
+
102
+ def test_failed_block_is_not_retried_or_exposed():
103
+ client = Client(fail_message=2)
104
+ with pytest.raises(adapter.PublishError) as error:
105
+ adapter.publish(batch(), client, TOPIC, batch_size=1)
106
+ assert "block 2" in str(error.value)
107
+ assert "private destination diagnostic" not in str(error.value)
108
+ assert len(client.published) == 2
109
+
110
+
111
+ def test_oversized_envelope_fails_before_any_publish():
112
+ client = Client()
113
+ with pytest.raises(ValueError, match="narrow the source date range"):
114
+ adapter.publish(batch(), client, TOPIC, max_message_bytes=1)
115
+ assert client.published == []
116
+
117
+
118
+ def test_unordered_publish_sends_empty_ordering_key():
119
+ client = Client()
120
+ adapter.publish(batch(1), client, TOPIC, ordered=False)
121
+ assert client.published[0][2] == ""
122
+
123
+
124
+ def test_operation_forwards_public_reader_options(monkeypatch):
125
+ calls = []
126
+ monkeypatch.setattr(adapter, "read", lambda *args, **kw: calls.append((args, kw)) or batch(1))
127
+ factory = object()
128
+ adapter.publish_operation("indicator_history", {"currency": "usd", "indicator": "policy_rate"},
129
+ Client(), TOPIC, api_key_env="FXMD_API_KEY", max_pages=12,
130
+ complete_history=False, client_factory=factory)
131
+ assert calls[0][0] == ("indicator_history", {"currency": "usd", "indicator": "policy_rate"})
132
+ assert calls[0][1] == dict(api_key_env="FXMD_API_KEY", complete_history=False,
133
+ max_pages=12, client_factory=factory)
134
+
135
+
136
+ def test_invalid_configuration_never_reads_source(monkeypatch):
137
+ def unexpected(*args, **kwargs):
138
+ pytest.fail("source must not be called for invalid publisher configuration")
139
+ monkeypatch.setattr(adapter, "read", unexpected)
140
+ for options in ({"batch_size": 0}, {"max_message_bytes": 0}, {"ordered": 1}):
141
+ with pytest.raises(ValueError):
142
+ adapter.publish_operation("release_calendar", {"currency": "usd"}, Client(), TOPIC, **options)
143
+ with pytest.raises(ValueError):
144
+ adapter.publish_operation("release_calendar", {"currency": "usd"}, Client(), "bad topic")
145
+
146
+
147
+ def test_credential_arguments_are_rejected_before_publishing():
148
+ client = Client()
149
+ with pytest.raises(IntegrationError):
150
+ adapter.publish_operation("indicator_history", {"currency": "usd", "indicator": "policy_rate",
151
+ "api_key": "secret"}, client, TOPIC)
152
+ with pytest.raises(IntegrationError):
153
+ adapter.publish_operation("release_calendar", {"currency": "usd"}, client, TOPIC,
154
+ api_key_env="not-an-env-name")
155
+ assert client.published == []