pyiceberg-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,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"]
File without changes
@@ -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,7 @@
1
+ fxmacrodata_iceberg/__init__.py,sha256=7mWodYRSkm5b0k0rqI6Yg4wZor3NDFwY_R1U-wRDWxw,6096
2
+ fxmacrodata_iceberg/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
3
+ pyiceberg_fxmacrodata-0.1.0.dist-info/licenses/LICENSE,sha256=rklK7Fl8aL1eWGubsrozZ7rCNTsN-MATw4M6LQIMv10,1092
4
+ pyiceberg_fxmacrodata-0.1.0.dist-info/METADATA,sha256=0Gq-3nqkBd6dngML4CEXxy68FuIgfTz4IlzB0kSQgBs,4869
5
+ pyiceberg_fxmacrodata-0.1.0.dist-info/WHEEL,sha256=16fc_9Rp854SLcdhaDEjr0u65p0e3FhZxihNgcnCE98,91
6
+ pyiceberg_fxmacrodata-0.1.0.dist-info/top_level.txt,sha256=FS7XWqjtRav3mi-EiXkm0-lMh8yyDBuw3wQjGhhxPOk,20
7
+ pyiceberg_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 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 @@
1
+ fxmacrodata_iceberg