pyiceberg-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,10 @@
1
+ include LICENSE
2
+ include README.md
3
+ include pyproject.toml
4
+ include PUBLIC_FILES.json
5
+ include MANIFEST.in
6
+ include src/fxmacrodata_iceberg/__init__.py
7
+ include src/fxmacrodata_iceberg/py.typed
8
+ include tests/test_loader.py
9
+ global-exclude *.pyc *.pyo
10
+ global-exclude __pycache__/*
@@ -0,0 +1,104 @@
1
+ Metadata-Version: 2.4
2
+ Name: pyiceberg-fxmacrodata
3
+ Version: 0.1.0
4
+ Summary: Load public FXMacroData responses into Apache Iceberg tables with PyIceberg
5
+ License-Expression: MIT
6
+ Project-URL: Documentation, https://fxmacrodata.com/documentation/reference?utm_source=pypi&utm_medium=referral&utm_campaign=pyiceberg-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: pyiceberg<1,>=0.12.0
12
+ Requires-Dist: pyarrow<27,>=25
13
+ Provides-Extra: test
14
+ Requires-Dist: pytest<9,>=8; extra == "test"
15
+ Dynamic: license-file
16
+
17
+ # FXMacroData for Apache Iceberg
18
+
19
+ Load FXMacroData public responses into Apache Iceberg tables with PyIceberg.
20
+ Keep macro announcements, calendars, FX and positioning data in the same
21
+ catalog as your own tables while retaining the source payload and revision
22
+ metadata.
23
+
24
+ [FXMacroData documentation](https://fxmacrodata.com/documentation/reference?utm_source=github&utm_medium=referral&utm_campaign=pyiceberg-fxmacrodata&utm_content=readme)
25
+
26
+ ## Install
27
+
28
+ Requires Python 3.10 or later and PyIceberg 0.12 or later. Install this
29
+ package and its adjacent public client dependencies from the distribution
30
+ supplied to you, together with the PyIceberg extras for your catalog and
31
+ object store (for example `pyiceberg[sql-sqlite]`, `pyiceberg[glue]` or
32
+ `pyiceberg[s3fs]`):
33
+
34
+ ```sh
35
+ python -m pip install /path/to/fxmacrodata-public-client /path/to/fxmacrodata-platform-core /path/to/pyiceberg-fxmacrodata
36
+ ```
37
+
38
+ ## Example
39
+
40
+ USD data is available without a key:
41
+
42
+ ```python
43
+ from pyiceberg.catalog import load_catalog
44
+ from fxmacrodata_platform_core import read
45
+ from fxmacrodata_iceberg import load
46
+
47
+ catalog = load_catalog("default") # configured in ~/.pyiceberg.yaml
48
+ batch = read("indicator_history", {"currency": "usd", "indicator": "policy_rate"})
49
+ result = load(batch, catalog, "analytics.fxmacrodata", batch_size=1000)
50
+ print(result.rows, "envelopes appended in", result.batches, "blocks")
51
+ ```
52
+
53
+ The namespace (`analytics`) must already exist. By default the loader creates
54
+ the table if absent through `catalog.create_table_if_not_exists`; set
55
+ `create_table=False` to use a table provisioned by your administrator, and
56
+ `iceberg_schema()` returns the schema it expects. Identifiers are dotted
57
+ simple names with at least a namespace and a table (`analytics.fxmacrodata`),
58
+ or a tuple of parts. Quoted names, SQL fragments and URL-like names are
59
+ rejected before the catalog is contacted.
60
+
61
+ ## Credentials
62
+
63
+ Set `FXMD_API_KEY` in your environment or secret manager and pass its name
64
+ to `read` (or to `load_operation`) through `api_key_env`. Omit it for
65
+ anonymous access. Catalog and object-store credentials belong to the
66
+ PyIceberg catalog object you construct and pass in; this package never builds
67
+ connection settings or reads destination secrets.
68
+
69
+ ## Data semantics
70
+
71
+ Columns are `operation`, `source_url`, `record_id`, `currency`, `indicator`,
72
+ `date`, `announcement_datetime`, `value`, `value_text`, `record_json`,
73
+ `response_json` and the loader's `content_hash`. Only `value` is numeric
74
+ (optional `double`); every other column is an optional `string`. The schema
75
+ declares no identifier fields. `record_json` retains the original record and
76
+ `response_json` retains the complete response, including pagination and
77
+ metadata. An empty response produces one envelope row with
78
+ `record_json = 'null'`, so absence of data and the server's metadata remain
79
+ inspectable; it is not an observation. Dates and announcement times remain
80
+ source strings. No publication time is inferred and no missing value is
81
+ filled. `content_hash` is the SHA-256 of the canonical JSON of the ten
82
+ source columns.
83
+
84
+ ## Delivery guarantee
85
+
86
+ `load` appends bounded blocks of `batch_size` rows (1 to 10,000) through
87
+ `Table.append`, each block as its own snapshot. Delivery is at-least-once:
88
+ repeating a load appends the same envelopes again, and a failed block is not
89
+ retried automatically; earlier blocks may already be committed. Deduplicate
90
+ downstream with a `MERGE INTO` keyed on `operation`, `record_id` and
91
+ `content_hash` in your query engine (Spark, Trino, Dremio, Flink) when you
92
+ need exactly-once storage semantics. Changed record data or response
93
+ metadata produces a distinct `content_hash` with the same `record_id`.
94
+
95
+ `load_operation` reads one operation and loads it in one call. It accepts
96
+ `api_key_env`, `complete_history`, `max_pages` and `client_factory` for the
97
+ public reader; `max_pages` bounds history pagination, and an incomplete read
98
+ raises before the destination is touched.
99
+
100
+ ## Development
101
+
102
+ Run the offline tests with `python -m pytest tests -q`. They use an in-test
103
+ fake catalog that records appended Arrow tables and never call the live
104
+ service or a real catalog.
@@ -0,0 +1,10 @@
1
+ [
2
+ "LICENSE",
3
+ "README.md",
4
+ "pyproject.toml",
5
+ "PUBLIC_FILES.json",
6
+ "MANIFEST.in",
7
+ "src/fxmacrodata_iceberg/__init__.py",
8
+ "src/fxmacrodata_iceberg/py.typed",
9
+ "tests/test_loader.py"
10
+ ]
@@ -0,0 +1,88 @@
1
+ # FXMacroData for Apache Iceberg
2
+
3
+ Load FXMacroData public responses into Apache Iceberg tables with PyIceberg.
4
+ Keep macro announcements, calendars, FX and positioning data in the same
5
+ catalog as your own tables while retaining the source payload and revision
6
+ metadata.
7
+
8
+ [FXMacroData documentation](https://fxmacrodata.com/documentation/reference?utm_source=github&utm_medium=referral&utm_campaign=pyiceberg-fxmacrodata&utm_content=readme)
9
+
10
+ ## Install
11
+
12
+ Requires Python 3.10 or later and PyIceberg 0.12 or later. Install this
13
+ package and its adjacent public client dependencies from the distribution
14
+ supplied to you, together with the PyIceberg extras for your catalog and
15
+ object store (for example `pyiceberg[sql-sqlite]`, `pyiceberg[glue]` or
16
+ `pyiceberg[s3fs]`):
17
+
18
+ ```sh
19
+ python -m pip install /path/to/fxmacrodata-public-client /path/to/fxmacrodata-platform-core /path/to/pyiceberg-fxmacrodata
20
+ ```
21
+
22
+ ## Example
23
+
24
+ USD data is available without a key:
25
+
26
+ ```python
27
+ from pyiceberg.catalog import load_catalog
28
+ from fxmacrodata_platform_core import read
29
+ from fxmacrodata_iceberg import load
30
+
31
+ catalog = load_catalog("default") # configured in ~/.pyiceberg.yaml
32
+ batch = read("indicator_history", {"currency": "usd", "indicator": "policy_rate"})
33
+ result = load(batch, catalog, "analytics.fxmacrodata", batch_size=1000)
34
+ print(result.rows, "envelopes appended in", result.batches, "blocks")
35
+ ```
36
+
37
+ The namespace (`analytics`) must already exist. By default the loader creates
38
+ the table if absent through `catalog.create_table_if_not_exists`; set
39
+ `create_table=False` to use a table provisioned by your administrator, and
40
+ `iceberg_schema()` returns the schema it expects. Identifiers are dotted
41
+ simple names with at least a namespace and a table (`analytics.fxmacrodata`),
42
+ or a tuple of parts. Quoted names, SQL fragments and URL-like names are
43
+ rejected before the catalog is contacted.
44
+
45
+ ## Credentials
46
+
47
+ Set `FXMD_API_KEY` in your environment or secret manager and pass its name
48
+ to `read` (or to `load_operation`) through `api_key_env`. Omit it for
49
+ anonymous access. Catalog and object-store credentials belong to the
50
+ PyIceberg catalog object you construct and pass in; this package never builds
51
+ connection settings or reads destination secrets.
52
+
53
+ ## Data semantics
54
+
55
+ Columns are `operation`, `source_url`, `record_id`, `currency`, `indicator`,
56
+ `date`, `announcement_datetime`, `value`, `value_text`, `record_json`,
57
+ `response_json` and the loader's `content_hash`. Only `value` is numeric
58
+ (optional `double`); every other column is an optional `string`. The schema
59
+ declares no identifier fields. `record_json` retains the original record and
60
+ `response_json` retains the complete response, including pagination and
61
+ metadata. An empty response produces one envelope row with
62
+ `record_json = 'null'`, so absence of data and the server's metadata remain
63
+ inspectable; it is not an observation. Dates and announcement times remain
64
+ source strings. No publication time is inferred and no missing value is
65
+ filled. `content_hash` is the SHA-256 of the canonical JSON of the ten
66
+ source columns.
67
+
68
+ ## Delivery guarantee
69
+
70
+ `load` appends bounded blocks of `batch_size` rows (1 to 10,000) through
71
+ `Table.append`, each block as its own snapshot. Delivery is at-least-once:
72
+ repeating a load appends the same envelopes again, and a failed block is not
73
+ retried automatically; earlier blocks may already be committed. Deduplicate
74
+ downstream with a `MERGE INTO` keyed on `operation`, `record_id` and
75
+ `content_hash` in your query engine (Spark, Trino, Dremio, Flink) when you
76
+ need exactly-once storage semantics. Changed record data or response
77
+ metadata produces a distinct `content_hash` with the same `record_id`.
78
+
79
+ `load_operation` reads one operation and loads it in one call. It accepts
80
+ `api_key_env`, `complete_history`, `max_pages` and `client_factory` for the
81
+ public reader; `max_pages` bounds history pagination, and an incomplete read
82
+ raises before the destination is touched.
83
+
84
+ ## Development
85
+
86
+ Run the offline tests with `python -m pytest tests -q`. They use an in-test
87
+ fake catalog that records appended Arrow tables and never call the live
88
+ service or a real catalog.
@@ -0,0 +1,30 @@
1
+ [build-system]
2
+ requires = ["setuptools>=77", "wheel"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "pyiceberg-fxmacrodata"
7
+ version = "0.1.0"
8
+ description = "Load public FXMacroData responses into Apache Iceberg tables with PyIceberg"
9
+ readme = "README.md"
10
+ requires-python = ">=3.10"
11
+ license = "MIT"
12
+ dependencies = ["fxmacrodata-platform-core==0.1.0", "pyiceberg>=0.12.0,<1", "pyarrow>=25,<27"]
13
+
14
+ [project.urls]
15
+ Documentation = "https://fxmacrodata.com/documentation/reference?utm_source=pypi&utm_medium=referral&utm_campaign=pyiceberg-fxmacrodata&utm_content=package"
16
+
17
+ [project.optional-dependencies]
18
+ test = ["pytest>=8,<9"]
19
+
20
+ [tool.setuptools]
21
+ packages = ["fxmacrodata_iceberg"]
22
+ package-dir = {"" = "src"}
23
+ include-package-data = false
24
+
25
+ [tool.setuptools.package-data]
26
+ fxmacrodata_iceberg = ["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,154 @@
1
+ """Load public FXMacroData responses into a customer-owned Apache Iceberg table."""
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
+ import pyarrow as pa
13
+ from pyiceberg.schema import Schema
14
+ from pyiceberg.types import DoubleType, NestedField, StringType
15
+
16
+ from fxmacrodata_platform_core import COLUMNS as SOURCE_COLUMNS, read
17
+
18
+ __version__ = "0.1.0"
19
+
20
+ COLUMNS = tuple(SOURCE_COLUMNS) + ("content_hash",)
21
+ _IDENTIFIER = re.compile(r"[A-Za-z_][A-Za-z0-9_]{0,127}\Z")
22
+
23
+
24
+ class LoadError(RuntimeError):
25
+ """A catalog or table operation failed; inspect the destination before retrying."""
26
+
27
+
28
+ @dataclass(frozen=True)
29
+ class LoadResult:
30
+ identifier: str
31
+ rows: int
32
+ batches: int
33
+
34
+
35
+ def arrow_schema() -> pa.Schema:
36
+ """Return the lossless Arrow schema: value is float64, everything else string."""
37
+ return pa.schema([
38
+ pa.field(name, pa.float64() if name == "value" else pa.string(), nullable=True)
39
+ for name in COLUMNS
40
+ ])
41
+
42
+
43
+ def iceberg_schema() -> Schema:
44
+ """Return the Iceberg schema matching ``arrow_schema``; no identifier fields."""
45
+ return Schema(*(
46
+ NestedField(field_id=index, name=name,
47
+ field_type=DoubleType() if name == "value" else StringType(),
48
+ required=False)
49
+ for index, name in enumerate(COLUMNS, start=1)
50
+ ))
51
+
52
+
53
+ def _rows(batch: Any) -> list[dict[str, Any]]:
54
+ rows = []
55
+ for source in batch.rows():
56
+ row = {name: source.get(name) for name in SOURCE_COLUMNS}
57
+ if not isinstance(row["operation"], str) or not isinstance(row["record_id"], str):
58
+ raise ValueError("every row requires string operation and record_id")
59
+ for name, value in row.items():
60
+ if name == "value":
61
+ if value is not None and (
62
+ isinstance(value, bool) or not isinstance(value, (int, float)) or
63
+ not math.isfinite(value)
64
+ ):
65
+ raise ValueError("value must be a finite number or null")
66
+ elif value is not None and not isinstance(value, str):
67
+ raise ValueError(f"{name} must be a string or null")
68
+ encoded = json.dumps(row, sort_keys=True, separators=(",", ":"), ensure_ascii=False)
69
+ row["content_hash"] = hashlib.sha256(encoded.encode("utf-8")).hexdigest()
70
+ rows.append(row)
71
+ return rows
72
+
73
+
74
+ def to_arrow(batch: Any) -> pa.Table:
75
+ """Project a Batch to an Arrow table with the eleven columns plus content_hash."""
76
+ return pa.Table.from_pylist(_rows(batch), schema=arrow_schema())
77
+
78
+
79
+ def _identifier(identifier: Any) -> tuple[str, ...]:
80
+ if isinstance(identifier, (tuple, list)):
81
+ parts = tuple(identifier)
82
+ elif isinstance(identifier, str):
83
+ parts = tuple(identifier.split("."))
84
+ else:
85
+ raise ValueError("identifier must be a dotted namespace.table string or a tuple of parts")
86
+ if len(parts) < 2 or any(not isinstance(p, str) or not _IDENTIFIER.fullmatch(p) for p in parts):
87
+ raise ValueError("identifier must contain two or more simple dotted parts")
88
+ return parts
89
+
90
+
91
+ def _check_batch_size(batch_size: Any) -> None:
92
+ if isinstance(batch_size, bool) or not isinstance(batch_size, int) or not 1 <= batch_size <= 10000:
93
+ raise ValueError("batch_size must be an integer between 1 and 10000")
94
+
95
+
96
+ def ensure_table(catalog: Any, identifier: str | tuple[str, ...]) -> Any:
97
+ """Create the envelope table when it is missing and return it.
98
+
99
+ The namespace must already exist; namespaces are administered by you.
100
+ """
101
+ parts = _identifier(identifier)
102
+ try:
103
+ return catalog.create_table_if_not_exists(parts, schema=iceberg_schema())
104
+ except Exception:
105
+ raise LoadError("Iceberg table setup failed; check the namespace and catalog access.") from None
106
+
107
+
108
+ def load(batch: Any, catalog: Any, identifier: str | tuple[str, ...], *,
109
+ batch_size: int = 1000, create_table: bool = True) -> LoadResult:
110
+ """Append bounded Arrow blocks through ``Table.append``.
111
+
112
+ Appends are at-least-once: a repeated load appends the same envelopes
113
+ again. Deduplicate downstream on ``operation``, ``record_id`` and
114
+ ``content_hash``. A failed block is never retried automatically.
115
+ """
116
+ parts = _identifier(identifier)
117
+ _check_batch_size(batch_size)
118
+ table_data = to_arrow(batch)
119
+ if create_table:
120
+ table = ensure_table(catalog, parts)
121
+ else:
122
+ try:
123
+ table = catalog.load_table(parts)
124
+ except Exception:
125
+ raise LoadError("Iceberg table lookup failed; create the table or enable create_table.") from None
126
+ count = 0
127
+ for start in range(0, table_data.num_rows, batch_size):
128
+ block = table_data.slice(start, batch_size)
129
+ try:
130
+ table.append(block)
131
+ except Exception:
132
+ raise LoadError(
133
+ f"Iceberg append block {count + 1} failed; earlier blocks may be committed. "
134
+ "Inspect the table snapshots before retrying."
135
+ ) from None
136
+ count += 1
137
+ return LoadResult(".".join(parts), table_data.num_rows, count)
138
+
139
+
140
+ def load_operation(operation: str, args: dict[str, Any] | None, catalog: Any,
141
+ identifier: str | tuple[str, ...], *, api_key_env: str | None = None,
142
+ complete_history: bool = True, max_pages: int = 10000,
143
+ client_factory: Any = None, batch_size: int = 1000,
144
+ create_table: bool = True) -> LoadResult:
145
+ """Read one public operation and append its complete response envelope."""
146
+ _identifier(identifier)
147
+ _check_batch_size(batch_size)
148
+ batch = read(operation, args, api_key_env=api_key_env, complete_history=complete_history,
149
+ max_pages=max_pages, client_factory=client_factory)
150
+ return load(batch, catalog, identifier, batch_size=batch_size, create_table=create_table)
151
+
152
+
153
+ __all__ = ["COLUMNS", "LoadError", "LoadResult", "arrow_schema", "ensure_table",
154
+ "iceberg_schema", "load", "load_operation", "to_arrow"]
@@ -0,0 +1,104 @@
1
+ Metadata-Version: 2.4
2
+ Name: pyiceberg-fxmacrodata
3
+ Version: 0.1.0
4
+ Summary: Load public FXMacroData responses into Apache Iceberg tables with PyIceberg
5
+ License-Expression: MIT
6
+ Project-URL: Documentation, https://fxmacrodata.com/documentation/reference?utm_source=pypi&utm_medium=referral&utm_campaign=pyiceberg-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: pyiceberg<1,>=0.12.0
12
+ Requires-Dist: pyarrow<27,>=25
13
+ Provides-Extra: test
14
+ Requires-Dist: pytest<9,>=8; extra == "test"
15
+ Dynamic: license-file
16
+
17
+ # FXMacroData for Apache Iceberg
18
+
19
+ Load FXMacroData public responses into Apache Iceberg tables with PyIceberg.
20
+ Keep macro announcements, calendars, FX and positioning data in the same
21
+ catalog as your own tables while retaining the source payload and revision
22
+ metadata.
23
+
24
+ [FXMacroData documentation](https://fxmacrodata.com/documentation/reference?utm_source=github&utm_medium=referral&utm_campaign=pyiceberg-fxmacrodata&utm_content=readme)
25
+
26
+ ## Install
27
+
28
+ Requires Python 3.10 or later and PyIceberg 0.12 or later. Install this
29
+ package and its adjacent public client dependencies from the distribution
30
+ supplied to you, together with the PyIceberg extras for your catalog and
31
+ object store (for example `pyiceberg[sql-sqlite]`, `pyiceberg[glue]` or
32
+ `pyiceberg[s3fs]`):
33
+
34
+ ```sh
35
+ python -m pip install /path/to/fxmacrodata-public-client /path/to/fxmacrodata-platform-core /path/to/pyiceberg-fxmacrodata
36
+ ```
37
+
38
+ ## Example
39
+
40
+ USD data is available without a key:
41
+
42
+ ```python
43
+ from pyiceberg.catalog import load_catalog
44
+ from fxmacrodata_platform_core import read
45
+ from fxmacrodata_iceberg import load
46
+
47
+ catalog = load_catalog("default") # configured in ~/.pyiceberg.yaml
48
+ batch = read("indicator_history", {"currency": "usd", "indicator": "policy_rate"})
49
+ result = load(batch, catalog, "analytics.fxmacrodata", batch_size=1000)
50
+ print(result.rows, "envelopes appended in", result.batches, "blocks")
51
+ ```
52
+
53
+ The namespace (`analytics`) must already exist. By default the loader creates
54
+ the table if absent through `catalog.create_table_if_not_exists`; set
55
+ `create_table=False` to use a table provisioned by your administrator, and
56
+ `iceberg_schema()` returns the schema it expects. Identifiers are dotted
57
+ simple names with at least a namespace and a table (`analytics.fxmacrodata`),
58
+ or a tuple of parts. Quoted names, SQL fragments and URL-like names are
59
+ rejected before the catalog is contacted.
60
+
61
+ ## Credentials
62
+
63
+ Set `FXMD_API_KEY` in your environment or secret manager and pass its name
64
+ to `read` (or to `load_operation`) through `api_key_env`. Omit it for
65
+ anonymous access. Catalog and object-store credentials belong to the
66
+ PyIceberg catalog object you construct and pass in; this package never builds
67
+ connection settings or reads destination secrets.
68
+
69
+ ## Data semantics
70
+
71
+ Columns are `operation`, `source_url`, `record_id`, `currency`, `indicator`,
72
+ `date`, `announcement_datetime`, `value`, `value_text`, `record_json`,
73
+ `response_json` and the loader's `content_hash`. Only `value` is numeric
74
+ (optional `double`); every other column is an optional `string`. The schema
75
+ declares no identifier fields. `record_json` retains the original record and
76
+ `response_json` retains the complete response, including pagination and
77
+ metadata. An empty response produces one envelope row with
78
+ `record_json = 'null'`, so absence of data and the server's metadata remain
79
+ inspectable; it is not an observation. Dates and announcement times remain
80
+ source strings. No publication time is inferred and no missing value is
81
+ filled. `content_hash` is the SHA-256 of the canonical JSON of the ten
82
+ source columns.
83
+
84
+ ## Delivery guarantee
85
+
86
+ `load` appends bounded blocks of `batch_size` rows (1 to 10,000) through
87
+ `Table.append`, each block as its own snapshot. Delivery is at-least-once:
88
+ repeating a load appends the same envelopes again, and a failed block is not
89
+ retried automatically; earlier blocks may already be committed. Deduplicate
90
+ downstream with a `MERGE INTO` keyed on `operation`, `record_id` and
91
+ `content_hash` in your query engine (Spark, Trino, Dremio, Flink) when you
92
+ need exactly-once storage semantics. Changed record data or response
93
+ metadata produces a distinct `content_hash` with the same `record_id`.
94
+
95
+ `load_operation` reads one operation and loads it in one call. It accepts
96
+ `api_key_env`, `complete_history`, `max_pages` and `client_factory` for the
97
+ public reader; `max_pages` bounds history pagination, and an incomplete read
98
+ raises before the destination is touched.
99
+
100
+ ## Development
101
+
102
+ Run the offline tests with `python -m pytest tests -q`. They use an in-test
103
+ fake catalog that records appended Arrow tables and never call the live
104
+ service or a real catalog.
@@ -0,0 +1,13 @@
1
+ LICENSE
2
+ MANIFEST.in
3
+ PUBLIC_FILES.json
4
+ README.md
5
+ pyproject.toml
6
+ src/fxmacrodata_iceberg/__init__.py
7
+ src/fxmacrodata_iceberg/py.typed
8
+ src/pyiceberg_fxmacrodata.egg-info/PKG-INFO
9
+ src/pyiceberg_fxmacrodata.egg-info/SOURCES.txt
10
+ src/pyiceberg_fxmacrodata.egg-info/dependency_links.txt
11
+ src/pyiceberg_fxmacrodata.egg-info/requires.txt
12
+ src/pyiceberg_fxmacrodata.egg-info/top_level.txt
13
+ tests/test_loader.py
@@ -0,0 +1,6 @@
1
+ fxmacrodata-platform-core==0.1.0
2
+ pyiceberg<1,>=0.12.0
3
+ pyarrow<27,>=25
4
+
5
+ [test]
6
+ pytest<9,>=8
@@ -0,0 +1,175 @@
1
+ import json
2
+
3
+ import pyarrow as pa
4
+ import pytest
5
+ from pyiceberg.types import DoubleType, StringType
6
+
7
+ from fxmacrodata_platform_core import Batch
8
+ import fxmacrodata_iceberg as adapter
9
+
10
+
11
+ def compatible(schema):
12
+ """Name/type view of an Arrow schema; PyIceberg promotes string to large_string."""
13
+ return [(f.name, pa.large_string() if f.type == pa.string() else f.type, f.nullable) for f in schema]
14
+
15
+
16
+ class Table:
17
+ """Records appended Arrow tables the way a catalog-backed table would."""
18
+
19
+ def __init__(self, identifier, schema, fail_block=None):
20
+ self.identifier = identifier
21
+ self.schema = schema
22
+ self.appended = []
23
+ self.fail_block = fail_block
24
+
25
+ def append(self, table):
26
+ if not isinstance(table, pa.Table):
27
+ raise TypeError("append expects a pyarrow Table")
28
+ if compatible(table.schema) != compatible(self.schema.as_arrow()):
29
+ raise TypeError("schema mismatch")
30
+ self.appended.append(table)
31
+ if len(self.appended) == self.fail_block:
32
+ raise RuntimeError("private destination diagnostic")
33
+
34
+ def rows(self):
35
+ return [row for block in self.appended for row in block.to_pylist()]
36
+
37
+
38
+ class Catalog:
39
+ def __init__(self, fail_block=None, fail_create=False):
40
+ self.tables = {}
41
+ self.calls = []
42
+ self.fail_block = fail_block
43
+ self.fail_create = fail_create
44
+
45
+ def create_table_if_not_exists(self, identifier, schema, **kwargs):
46
+ self.calls.append(("create_table_if_not_exists", tuple(identifier)))
47
+ if self.fail_create:
48
+ raise RuntimeError("private catalog diagnostic")
49
+ key = tuple(identifier)
50
+ if key not in self.tables:
51
+ self.tables[key] = Table(key, schema, self.fail_block)
52
+ return self.tables[key]
53
+
54
+ def load_table(self, identifier):
55
+ self.calls.append(("load_table", tuple(identifier)))
56
+ return self.tables[tuple(identifier)]
57
+
58
+
59
+ def batch(count=3, metadata=None):
60
+ return Batch("indicator_history", {
61
+ "data": [{"currency": "USD", "indicator": "policy_rate", "value": str(i),
62
+ "date": "2026-10-01", "publication_time_status": "unknown"}
63
+ for i in range(count)],
64
+ "metadata": metadata or {"revision": 1},
65
+ })
66
+
67
+
68
+ def test_iceberg_schema_matches_arrow_schema():
69
+ schema = adapter.iceberg_schema()
70
+ assert [f.name for f in schema.fields] == list(adapter.COLUMNS)
71
+ assert isinstance(schema.find_field("value").field_type, DoubleType)
72
+ assert all(isinstance(f.field_type, StringType) for f in schema.fields if f.name != "value")
73
+ assert all(not f.required for f in schema.fields)
74
+ assert schema.identifier_field_ids == []
75
+ assert compatible(schema.as_arrow()) == compatible(adapter.arrow_schema())
76
+ assert adapter.arrow_schema().field("value").type == pa.float64()
77
+
78
+
79
+ def test_append_preserves_full_payload_and_source_strings():
80
+ source, catalog = batch(), Catalog()
81
+ result = adapter.load(source, catalog, "analytics.fxmacrodata", batch_size=2)
82
+ assert (result.rows, result.batches, result.identifier) == (3, 2, "analytics.fxmacrodata")
83
+ table = catalog.tables[("analytics", "fxmacrodata")]
84
+ assert [b.num_rows for b in table.appended] == [2, 1]
85
+ row = table.rows()[0]
86
+ assert row["date"] == "2026-10-01"
87
+ assert row["announcement_datetime"] is None
88
+ assert row["value"] == 0.0 and row["value_text"] == "0"
89
+ assert json.loads(row["record_json"])["publication_time_status"] == "unknown"
90
+ assert json.loads(row["response_json"]) == source.payload
91
+ assert len(row["content_hash"]) == 64
92
+
93
+
94
+ def test_empty_response_is_an_envelope_not_an_observation():
95
+ source, catalog = batch(0), Catalog()
96
+ result = adapter.load(source, catalog, ("ns", "fxmacrodata"))
97
+ row = catalog.tables[("ns", "fxmacrodata")].rows()[0]
98
+ assert result.rows == 1 and row["record_json"] == "null"
99
+ assert row["value"] is None and row["date"] is None
100
+ assert json.loads(row["response_json"]) == source.payload
101
+
102
+
103
+ def test_repeated_loads_append_again_with_stable_keys():
104
+ catalog = Catalog()
105
+ adapter.load(batch(1), catalog, "ns.t")
106
+ adapter.load(batch(1), catalog, "ns.t")
107
+ adapter.load(batch(1, {"revision": 2}), catalog, "ns.t")
108
+ rows = catalog.tables[("ns", "t")].rows()
109
+ assert len(rows) == 3
110
+ assert rows[0]["record_id"] == rows[1]["record_id"] == rows[2]["record_id"]
111
+ assert rows[0]["content_hash"] == rows[1]["content_hash"] != rows[2]["content_hash"]
112
+
113
+
114
+ @pytest.mark.parametrize("identifier", ["table", "ns.table; DROP", "https://host/t", "a.`b`", "a b.c", "", "ns..t", 42, ("ns",)])
115
+ def test_bad_identifiers_never_touch_catalog(identifier):
116
+ catalog = Catalog()
117
+ with pytest.raises(ValueError):
118
+ adapter.load(batch(), catalog, identifier)
119
+ with pytest.raises(ValueError):
120
+ adapter.ensure_table(catalog, identifier)
121
+ assert catalog.calls == []
122
+
123
+
124
+ @pytest.mark.parametrize("size", [0, -1, 10001, True, 2.5])
125
+ def test_batch_bounds(size):
126
+ catalog = Catalog()
127
+ with pytest.raises(ValueError):
128
+ adapter.load(batch(), catalog, "ns.t", batch_size=size)
129
+ assert catalog.calls == []
130
+
131
+
132
+ def test_failed_block_is_not_retried_or_exposed():
133
+ catalog = Catalog(fail_block=2)
134
+ with pytest.raises(adapter.LoadError) as error:
135
+ adapter.load(batch(), catalog, "ns.t", batch_size=1)
136
+ assert "block 2" in str(error.value)
137
+ assert "private destination diagnostic" not in str(error.value)
138
+ assert len(catalog.tables[("ns", "t")].appended) == 2
139
+
140
+
141
+ def test_table_setup_failure_is_not_exposed():
142
+ catalog = Catalog(fail_create=True)
143
+ with pytest.raises(adapter.LoadError) as error:
144
+ adapter.load(batch(), catalog, "ns.t")
145
+ assert "private catalog diagnostic" not in str(error.value)
146
+
147
+
148
+ def test_preprovisioned_table_skips_creation():
149
+ catalog = Catalog()
150
+ catalog.tables[("ns", "t")] = Table(("ns", "t"), adapter.iceberg_schema())
151
+ adapter.load(batch(1), catalog, "ns.t", create_table=False)
152
+ assert catalog.calls == [("load_table", ("ns", "t"))]
153
+ assert len(catalog.tables[("ns", "t")].rows()) == 1
154
+
155
+
156
+ def test_operation_forwards_public_reader_options(monkeypatch):
157
+ calls = []
158
+ monkeypatch.setattr(adapter, "read", lambda *args, **kw: calls.append((args, kw)) or batch(1))
159
+ factory = object()
160
+ adapter.load_operation("indicator_history", {"currency": "usd", "indicator": "policy_rate"},
161
+ Catalog(), "ns.t", api_key_env="FXMD_API_KEY", max_pages=12,
162
+ client_factory=factory)
163
+ assert calls[0][1] == dict(api_key_env="FXMD_API_KEY", complete_history=True,
164
+ max_pages=12, client_factory=factory)
165
+
166
+
167
+ def test_invalid_loader_configuration_never_reads_source(monkeypatch):
168
+ def unexpected(*args, **kwargs):
169
+ pytest.fail("source must not be called for invalid loader configuration")
170
+
171
+ monkeypatch.setattr(adapter, "read", unexpected)
172
+ with pytest.raises(ValueError):
173
+ adapter.load_operation("release_calendar", {"currency": "usd"}, Catalog(), "ns.t", batch_size=0)
174
+ with pytest.raises(ValueError):
175
+ adapter.load_operation("release_calendar", {"currency": "usd"}, Catalog(), "bad identifier")