snowflake-fxmacrodata 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,201 @@
1
+ """Load public FXMacroData responses into a customer-owned Snowflake account."""
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
+ from uuid import uuid4
12
+
13
+ from fxmacrodata_platform_core import read
14
+
15
+ __version__ = "0.1.0"
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
+ _IDENTIFIER = re.compile(r"[A-Za-z_][A-Za-z0-9_]{0,254}\Z")
22
+
23
+
24
+ class LoadError(RuntimeError):
25
+ """A load failed; transaction outcome must be checked after connection loss."""
26
+
27
+
28
+ @dataclass(frozen=True)
29
+ class LoadResult:
30
+ table: str
31
+ rows_submitted: int
32
+ batches: int
33
+
34
+
35
+ def _table(table: str) -> str:
36
+ if not isinstance(table, str):
37
+ raise ValueError("table must contain simple Snowflake identifiers")
38
+ parts = table.split(".")
39
+ if not 1 <= len(parts) <= 3 or any(not _IDENTIFIER.fullmatch(p) for p in parts):
40
+ raise ValueError("table must contain one to three simple SQL identifiers")
41
+ return ".".join(f'"{p.upper()}"' for p in parts)
42
+
43
+
44
+ def _rows(batch: Any) -> list[dict[str, Any]]:
45
+ rows = []
46
+ seen = set()
47
+ for source in batch.rows():
48
+ row = {name: source.get(name) for name in COLUMNS[:-1]}
49
+ if not isinstance(row["operation"], str) or not isinstance(row["record_id"], str):
50
+ raise ValueError("every row requires string operation and record_id")
51
+ for name, value in row.items():
52
+ if name == "value":
53
+ if value is not None and (
54
+ isinstance(value, bool) or not isinstance(value, (int, float)) or
55
+ not math.isfinite(value)
56
+ ):
57
+ raise ValueError("value must be a finite number or null")
58
+ elif value is not None and not isinstance(value, str):
59
+ raise ValueError(f"{name} must be a string or null")
60
+ encoded = json.dumps(row, sort_keys=True, separators=(",", ":"), ensure_ascii=False)
61
+ row["content_hash"] = hashlib.sha256(encoded.encode("utf-8")).hexdigest()
62
+ key = (row["operation"], row["record_id"], row["content_hash"])
63
+ if key not in seen:
64
+ seen.add(key)
65
+ rows.append(row)
66
+ return rows
67
+
68
+
69
+ def create_table_sql(table: str) -> str:
70
+ """Return DDL preserving source dates, publication status and JSON as text."""
71
+ fields = ",\n ".join(
72
+ f'"{name.upper()}" {"DOUBLE" if name == "value" else "VARCHAR"}' +
73
+ (" NOT NULL" if name in {"operation", "record_id", "content_hash"} else "")
74
+ for name in COLUMNS
75
+ )
76
+ return f"CREATE TABLE IF NOT EXISTS {_table(table)} (\n {fields}\n)"
77
+
78
+
79
+ def _blocks(rows: list[dict[str, Any]], batch_size: int, max_batch_bytes: int) -> list[list[dict[str, Any]]]:
80
+ if isinstance(batch_size, bool) or not isinstance(batch_size, int) or not 1 <= batch_size <= 10000:
81
+ raise ValueError("batch_size must be an integer between 1 and 10000")
82
+ if isinstance(max_batch_bytes, bool) or not isinstance(max_batch_bytes, int) or not 1 <= max_batch_bytes <= 128 * 1024 * 1024:
83
+ raise ValueError("max_batch_bytes must be an integer between 1 and 134217728")
84
+ output, block, size = [], [], 0
85
+ for row in rows:
86
+ length = len(json.dumps(row, ensure_ascii=True).encode("utf-8")) + 2
87
+ if length > max_batch_bytes:
88
+ raise ValueError("one envelope exceeds max_batch_bytes; narrow the source date range")
89
+ if block and (len(block) >= batch_size or size + length > max_batch_bytes):
90
+ output.append(block)
91
+ block, size = [], 0
92
+ block.append(row)
93
+ size += length
94
+ if block:
95
+ output.append(block)
96
+ return output
97
+
98
+
99
+ def load(batch: Any, connection: Any, table: str, *, batch_size: int = 1000,
100
+ max_batch_bytes: int = 16 * 1024 * 1024,
101
+ create_table: bool = True, paramstyle: str = "pyformat") -> LoadResult:
102
+ """Stage bounded batches before merging envelopes in one DML transaction.
103
+
104
+ Use a dedicated connection without an active transaction. Table and temporary
105
+ stage DDL and all batch binding execute before BEGIN: the native connector
106
+ can create a binding stage during executemany. Repeated source envelopes
107
+ match existing rows; changed response metadata or revisions remain distinct.
108
+ """
109
+ quoted = _table(table)
110
+ if isinstance(batch_size, bool) or not isinstance(batch_size, int) or not 1 <= batch_size <= 10000:
111
+ raise ValueError("batch_size must be an integer between 1 and 10000")
112
+ if paramstyle not in {"pyformat", "format", "qmark", "numeric"}:
113
+ raise ValueError("paramstyle must match the Snowflake connection")
114
+ rows = _rows(batch)
115
+ blocks = _blocks(rows, batch_size, max_batch_bytes)
116
+ stage = f'"FXMD_STAGE_{uuid4().hex.upper()}"'
117
+ fields = ", ".join(f'"{name.upper()}"' for name in COLUMNS)
118
+ placeholder = ", ".join(
119
+ "?" if paramstyle == "qmark" else f":{i + 1}" if paramstyle == "numeric" else "%s"
120
+ for i in range(len(COLUMNS))
121
+ )
122
+ cursor = None
123
+ transaction = False
124
+ stage_created = False
125
+ committed = False
126
+ batches = 0
127
+ try:
128
+ cursor = connection.cursor()
129
+ if create_table:
130
+ cursor.execute(create_table_sql(table))
131
+ cursor.execute(f"CREATE TEMPORARY TABLE {stage} LIKE {quoted}")
132
+ stage_created = True
133
+ for block in blocks:
134
+ cursor.executemany(
135
+ f"INSERT INTO {stage} ({fields}) VALUES ({placeholder})",
136
+ [tuple(row[name] for name in COLUMNS) for row in block],
137
+ )
138
+ batches += 1
139
+ # qmark/numeric binding can issue CREATE TEMPORARY STAGE internally.
140
+ # Finish every staging insert before the destination DML transaction.
141
+ cursor.execute("BEGIN")
142
+ transaction = True
143
+ cursor.execute(
144
+ f"MERGE INTO {quoted} AS target USING {stage} AS source ON "
145
+ 'target."OPERATION" = source."OPERATION" AND '
146
+ 'target."RECORD_ID" = source."RECORD_ID" AND '
147
+ 'target."CONTENT_HASH" = source."CONTENT_HASH" '
148
+ f"WHEN NOT MATCHED THEN INSERT ({fields}) VALUES (" +
149
+ ", ".join(f'source."{name.upper()}"' for name in COLUMNS) + ")"
150
+ )
151
+ cursor.execute("COMMIT")
152
+ transaction = False
153
+ committed = True
154
+ except Exception:
155
+ if transaction and cursor is not None:
156
+ try:
157
+ cursor.execute("ROLLBACK")
158
+ transaction = False
159
+ except Exception:
160
+ pass
161
+ raise LoadError(
162
+ "Snowflake load did not complete successfully. Check transaction outcome "
163
+ "before retrying; use a single writer per destination table."
164
+ ) from None
165
+ finally:
166
+ # DDL commits transactions, so never run cleanup after a failed rollback.
167
+ if cursor is not None:
168
+ if stage_created and not transaction:
169
+ try:
170
+ cursor.execute(f"DROP TABLE IF EXISTS {stage}")
171
+ except Exception:
172
+ pass
173
+ try:
174
+ cursor.close()
175
+ except Exception:
176
+ pass
177
+ if not committed:
178
+ raise LoadError("Snowflake transaction was not committed")
179
+ return LoadResult(quoted, len(rows), batches)
180
+
181
+
182
+ def load_operation(operation: str, args: dict[str, Any] | None, connection: Any, table: str,
183
+ *, api_key_env: str | None = None, complete_history: bool = True,
184
+ max_pages: int = 10000, client_factory: Any = None,
185
+ batch_size: int = 1000, max_batch_bytes: int = 16 * 1024 * 1024,
186
+ create_table: bool = True,
187
+ paramstyle: str = "pyformat") -> LoadResult:
188
+ """Read one public operation and load its complete response envelope."""
189
+ _table(table)
190
+ _blocks([], batch_size, max_batch_bytes)
191
+ if paramstyle not in {"pyformat", "format", "qmark", "numeric"}:
192
+ raise ValueError("paramstyle must match the Snowflake connection")
193
+ batch = read(operation, args, api_key_env=api_key_env,
194
+ complete_history=complete_history, max_pages=max_pages,
195
+ client_factory=client_factory)
196
+ return load(batch, connection, table, batch_size=batch_size,
197
+ max_batch_bytes=max_batch_bytes, create_table=create_table,
198
+ paramstyle=paramstyle)
199
+
200
+
201
+ __all__ = ["COLUMNS", "LoadError", "LoadResult", "create_table_sql", "load", "load_operation"]
@@ -0,0 +1,155 @@
1
+ Metadata-Version: 2.4
2
+ Name: snowflake-fxmacrodata
3
+ Version: 0.1.0
4
+ Summary: Load public FXMacroData responses into Snowflake
5
+ License-Expression: MIT
6
+ Project-URL: Documentation, https://fxmacrodata.com/documentation/reference?utm_source=pypi&utm_medium=referral&utm_campaign=snowflake-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: snowflake-connector-python<5,>=4.8.1
12
+ Requires-Dist: filelock<5,>=4.2.9
13
+ Provides-Extra: test
14
+ Requires-Dist: pytest<9,>=8; extra == "test"
15
+ Dynamic: license-file
16
+
17
+ # FXMacroData for Snowflake
18
+
19
+ Load FXMacroData public responses through the official Snowflake Connector for
20
+ Python. Bounded batch inserts feed a temporary staging table, followed by a
21
+ transactional merge into your analytical table.
22
+
23
+ [FXMacroData documentation](https://fxmacrodata.com/documentation/reference?utm_source=github&utm_medium=referral&utm_campaign=snowflake-fxmacrodata&utm_content=readme)
24
+
25
+ ## Install and connect
26
+
27
+ Requires Python 3.10 or later and Snowflake Connector for Python 4.8.1 or later
28
+ within version 4. Install the supplied adapter wheel; its public client and
29
+ platform core dependencies resolve from PyPI:
30
+
31
+ ```sh
32
+ python -m pip install /path/to/snowflake_fxmacrodata-0.1.0-py3-none-any.whl
33
+ ```
34
+
35
+ Use a dedicated Snowflake connection with its current database and schema set.
36
+ This example uses interactive SSO; headless workloads should use your
37
+ organization's approved SDK authentication method and runtime secret store.
38
+ Expose the FXMacroData source key through the `FXMD_API_KEY` environment name.
39
+
40
+ ```python
41
+ import os
42
+ import snowflake.connector
43
+ from fxmacrodata_platform_core import read
44
+ from fxmacrodata_snowflake import load
45
+
46
+ connection = snowflake.connector.connect(
47
+ account=os.environ["SNOWFLAKE_ACCOUNT"],
48
+ user=os.environ["SNOWFLAKE_USER"],
49
+ authenticator="externalbrowser",
50
+ warehouse=os.environ["SNOWFLAKE_WAREHOUSE"],
51
+ database=os.environ["SNOWFLAKE_DATABASE"],
52
+ schema=os.environ["SNOWFLAKE_SCHEMA"],
53
+ paramstyle="qmark",
54
+ )
55
+ try:
56
+ batch = read("release_calendar", {"currency": "usd"}, api_key_env="FXMD_API_KEY")
57
+ result = load(batch, connection, "FXMACRODATA", paramstyle="qmark")
58
+ finally:
59
+ connection.close()
60
+ ```
61
+
62
+ One-, two- and three-part simple SQL names are accepted and normalized to
63
+ uppercase, matching Snowflake's unquoted identifier convention. Quoted,
64
+ case-sensitive names and SQL fragments are rejected. The database and schema
65
+ must exist. The caller needs table/stage creation and DML privileges. Use
66
+ `create_table=False` if an administrator has provisioned the envelope table with
67
+ `create_table_sql(table)`.
68
+
69
+ The loader must own the connection's transaction. Snowflake DDL can commit
70
+ an active transaction, so do not pass a connection with unrelated pending work.
71
+ `paramstyle` must match the connection (`qmark`, `numeric`, `format`, or
72
+ `pyformat`, the connector default). No global connector settings are changed.
73
+
74
+ Every operation exposed by `fxmacrodata_platform_core.operations()` is available,
75
+ including REST and MCP operations. The loader adds a tabular envelope without
76
+ changing the original public response. `record_json` retains the original record;
77
+ `response_json` retains the complete response, including pagination and metadata.
78
+ An empty response produces one envelope with `record_json = 'null'`, so absence of
79
+ data and the server's metadata remain inspectable. It is not an observation.
80
+
81
+ Columns are `operation`, `source_url`, `record_id`, `currency`, `indicator`,
82
+ `date`, `announcement_datetime`, `value`, `value_text`, `record_json`,
83
+ `response_json`, and the loader's `content_hash`. Only `value` is numeric.
84
+ Dates and announcement times remain source strings. No publication time is
85
+ inferred, and no missing value is filled. `value_text` and JSON preserve source
86
+ numbers supplied as strings. JSON numeric tokens follow the public Python client
87
+ parser, so original lexical precision is not guaranteed.
88
+
89
+ Select operations with the shared catalogue rather than constructing URLs:
90
+
91
+ ```python
92
+ from fxmacrodata_platform_core import operations, schemas
93
+
94
+ available = operations()
95
+ input_schemas = schemas()
96
+ ```
97
+
98
+ `load_operation` accepts `api_key_env`, `complete_history`, `max_pages`, and
99
+ `client_factory` for the public source reader. `max_pages` bounds history
100
+ pagination; an incomplete read raises an error before a destination load starts.
101
+ `batch_size` bounds destination blocks to 1–10,000 rows. `max_batch_bytes` also
102
+ bounds each block using a conservative JSON byte estimate (16 MiB by default,
103
+ 128 MiB maximum). An oversized individual envelope is rejected before destination
104
+ changes; narrow the source date range to reduce the response size. The source response is
105
+ materialized before loading; choose a source date range suitable for memory.
106
+ Read the installed operation's schema for required arguments and date filters.
107
+
108
+ Supply source credentials through your runtime secret store, exposing only the
109
+ environment variable name to the adapter. Destination authentication belongs to
110
+ the official SDK connection you pass to the loader. The adapter neither saves nor
111
+ prints credentials and never constructs destination credentials for you.
112
+
113
+ The package licence covers this adapter code. FXMacroData data access and onward
114
+ redistribution remain subject to your subscription and applicable licence. Loading
115
+ data into a warehouse does not grant permission to publish or resell it.
116
+
117
+ ## Retries and querying
118
+
119
+ The loader creates the destination and temporary table, then fills every bounded
120
+ block before beginning the destination DML transaction. This also keeps any
121
+ temporary binding-stage DDL issued by the connector outside the transaction.
122
+ It then begins, merges on operation, record ID and content hash, and commits.
123
+ A staging failure leaves destination rows untouched. A merge or commit failure
124
+ triggers rollback. If rollback itself fails, the loader avoids cleanup DDL that
125
+ could commit unresolved work.
126
+
127
+ Repeated exact envelopes are skipped; changed source records or metadata remain
128
+ distinct. Use one writer per destination table. Concurrent merges into standard
129
+ Snowflake tables are not an exactly-once guarantee. The loader never retries a
130
+ write automatically. After connection loss, inspect the transaction outcome
131
+ before retrying. `LoadResult.rows_submitted` counts unique envelopes offered to
132
+ the merge, rather than rows newly inserted.
133
+
134
+ ```sql
135
+ SELECT CURRENCY, INDICATOR, DATE, VALUE,
136
+ PARSE_JSON(RECORD_JSON):publication_time_status::STRING AS PUBLICATION_STATUS
137
+ FROM FXMACRODATA
138
+ WHERE RECORD_JSON != 'null';
139
+ ```
140
+
141
+ [event_study.sql](examples/event_study.sql) joins source announcement times to
142
+ your licensed market-price table. Inspect source publication metadata separately
143
+ for point-in-time backtesting. [sharing.sql](examples/sharing.sql) shows a
144
+ customer-controlled secure share; sharing data requires the applicable rights
145
+ and recipient approval. This package does not create a Marketplace listing.
146
+
147
+ ## Test
148
+
149
+ ```sh
150
+ python -m pytest tests -q
151
+ ```
152
+
153
+ Official SDK references:
154
+ [batch binding and transactions](https://docs.snowflake.com/en/developer-guide/python-connector/python-connector-example),
155
+ [connector API](https://docs.snowflake.com/en/developer-guide/python-connector/python-connector-api).
@@ -0,0 +1,6 @@
1
+ fxmacrodata_snowflake/__init__.py,sha256=NfLEILOeoai0-XR5jybZYm--RsJZnGNZB9XXViKaBYg,8620
2
+ snowflake_fxmacrodata-0.1.0.dist-info/licenses/LICENSE,sha256=mLVV_vNUMbtObjBLP4s10Tc8K4m_5sbNJ96E169Td84,1089
3
+ snowflake_fxmacrodata-0.1.0.dist-info/METADATA,sha256=DkfAzrnGr9Rskwg2DTPNvN4KZEmg-8k0ATrYtf9N3go,7485
4
+ snowflake_fxmacrodata-0.1.0.dist-info/WHEEL,sha256=16fc_9Rp854SLcdhaDEjr0u65p0e3FhZxihNgcnCE98,91
5
+ snowflake_fxmacrodata-0.1.0.dist-info/top_level.txt,sha256=a_RsOylRuAobRH2h1qTUJK9lGB9HEkHS1BxzVUvARj0,22
6
+ snowflake_fxmacrodata-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (84.1.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 FXMacroData
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1 @@
1
+ fxmacrodata_snowflake