sqlite-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,158 @@
1
+ """Load public FXMacroData responses into a customer-owned SQLite 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
+ 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
+ TYPES = tuple(
22
+ "REAL" if name == "value" else
23
+ "TEXT NOT NULL" if name in {"operation", "record_id", "content_hash"} else "TEXT"
24
+ for name in COLUMNS
25
+ )
26
+ _IDENTIFIER = re.compile(r"[A-Za-z_][A-Za-z0-9_]{0,127}\Z")
27
+
28
+
29
+ class LoadError(RuntimeError):
30
+ """A destination operation failed; inspect the destination before retrying."""
31
+
32
+
33
+ @dataclass(frozen=True)
34
+ class LoadResult:
35
+ table: str
36
+ rows: int
37
+ batches: int
38
+
39
+
40
+ def _table(table: str) -> tuple[str, str]:
41
+ if not isinstance(table, str):
42
+ raise ValueError("table must be a schema.table or table identifier")
43
+ parts = table.split(".")
44
+ if not 1 <= len(parts) <= 2 or any(not _IDENTIFIER.fullmatch(p) for p in parts):
45
+ raise ValueError("table must contain one or two simple SQL identifiers")
46
+ return ".".join(parts), ".".join(f'"{p}"' for p in parts)
47
+
48
+
49
+ def _rows(batch: Any) -> list[dict[str, Any]]:
50
+ rows = []
51
+ for source in batch.rows():
52
+ row = {name: source.get(name) for name in COLUMNS[:-1]}
53
+ if not isinstance(row["operation"], str) or not isinstance(row["record_id"], str):
54
+ raise ValueError("every row requires string operation and record_id")
55
+ for name, value in row.items():
56
+ if name == "value":
57
+ if value is not None and (
58
+ isinstance(value, bool) or not isinstance(value, (int, float)) or
59
+ not math.isfinite(value)
60
+ ):
61
+ raise ValueError("value must be a finite number or null")
62
+ elif value is not None and not isinstance(value, str):
63
+ raise ValueError(f"{name} must be a string or null")
64
+ encoded = json.dumps(row, sort_keys=True, separators=(",", ":"), ensure_ascii=False)
65
+ row["content_hash"] = hashlib.sha256(encoded.encode("utf-8")).hexdigest()
66
+ rows.append(row)
67
+ return rows
68
+
69
+
70
+ def create_table_sql(table: str) -> str:
71
+ """Return DDL for a lossless envelope table keyed for idempotent retries."""
72
+ _, quoted = _table(table)
73
+ fields = ",\n ".join(f'"{name}" {kind}' for name, kind in zip(COLUMNS, TYPES))
74
+ return (
75
+ f"CREATE TABLE IF NOT EXISTS {quoted} (\n {fields},\n"
76
+ ' PRIMARY KEY ("operation", "record_id", "content_hash")\n)'
77
+ )
78
+
79
+
80
+ def insert_sql(table: str) -> str:
81
+ """Return the idempotent INSERT statement used for every block."""
82
+ _, quoted = _table(table)
83
+ names = ", ".join(f'"{name}"' for name in COLUMNS)
84
+ placeholders = ", ".join("?" for _ in COLUMNS)
85
+ return f"INSERT OR IGNORE INTO {quoted} ({names}) VALUES ({placeholders})"
86
+
87
+
88
+ def _blocks(rows: list[dict[str, Any]], batch_size: int, max_batch_bytes: int) -> list[list[dict[str, Any]]]:
89
+ if isinstance(batch_size, bool) or not isinstance(batch_size, int) or not 1 <= batch_size <= 10000:
90
+ raise ValueError("batch_size must be an integer between 1 and 10000")
91
+ if isinstance(max_batch_bytes, bool) or not isinstance(max_batch_bytes, int) or not 1 <= max_batch_bytes <= 128 * 1024 * 1024:
92
+ raise ValueError("max_batch_bytes must be an integer between 1 and 134217728")
93
+ output, block, size = [], [], 0
94
+ for row in rows:
95
+ length = len(json.dumps(row, ensure_ascii=True).encode("utf-8")) + 2
96
+ if length > max_batch_bytes:
97
+ raise ValueError("one envelope exceeds max_batch_bytes; narrow the source date range")
98
+ if block and (len(block) >= batch_size or size + length > max_batch_bytes):
99
+ output.append(block)
100
+ block, size = [], 0
101
+ block.append(row)
102
+ size += length
103
+ if block:
104
+ output.append(block)
105
+ return output
106
+
107
+
108
+ def load(batch: Any, connection: Any, table: str, *, batch_size: int = 1000,
109
+ max_batch_bytes: int = 16 * 1024 * 1024, create_table: bool = True) -> LoadResult:
110
+ """Insert bounded blocks through a ``sqlite3.Connection``, one transaction per block.
111
+
112
+ Every block is an ``INSERT OR IGNORE`` on the envelope primary key, so
113
+ retrying the same source batch stores nothing twice. A failed block is
114
+ rolled back and never retried automatically.
115
+ """
116
+ table, _ = _table(table)
117
+ if isinstance(batch_size, bool) or not isinstance(batch_size, int) or not 1 <= batch_size <= 10000:
118
+ raise ValueError("batch_size must be an integer between 1 and 10000")
119
+ rows = _rows(batch)
120
+ blocks = _blocks(rows, batch_size, max_batch_bytes)
121
+ if create_table:
122
+ try:
123
+ with connection:
124
+ connection.execute(create_table_sql(table))
125
+ except Exception:
126
+ raise LoadError("SQLite table setup failed") from None
127
+ statement = insert_sql(table)
128
+ count = 0
129
+ for block in blocks:
130
+ try:
131
+ with connection:
132
+ connection.executemany(statement, [tuple(row[name] for name in COLUMNS) for row in block])
133
+ except Exception:
134
+ raise LoadError(
135
+ f"SQLite insert block {count + 1} failed and was rolled back; earlier blocks "
136
+ "are committed. Inspect the destination; retrying the same batch is idempotent."
137
+ ) from None
138
+ count += 1
139
+ return LoadResult(table, len(rows), count)
140
+
141
+
142
+ def load_operation(operation: str, args: dict[str, Any] | None, connection: Any, table: str,
143
+ *, api_key_env: str | None = None, complete_history: bool = True,
144
+ max_pages: int = 10000, client_factory: Any = None,
145
+ batch_size: int = 1000, max_batch_bytes: int = 16 * 1024 * 1024,
146
+ create_table: bool = True) -> LoadResult:
147
+ """Read one public operation and load its complete response envelope."""
148
+ _table(table)
149
+ _blocks([], batch_size, max_batch_bytes)
150
+ batch = read(operation, args, api_key_env=api_key_env,
151
+ complete_history=complete_history, max_pages=max_pages,
152
+ client_factory=client_factory)
153
+ return load(batch, connection, table, batch_size=batch_size,
154
+ max_batch_bytes=max_batch_bytes, create_table=create_table)
155
+
156
+
157
+ __all__ = ["COLUMNS", "TYPES", "LoadError", "LoadResult", "create_table_sql", "insert_sql",
158
+ "load", "load_operation"]
File without changes
@@ -0,0 +1,136 @@
1
+ Metadata-Version: 2.4
2
+ Name: sqlite-fxmacrodata
3
+ Version: 0.1.0
4
+ Summary: Load public FXMacroData responses into SQLite
5
+ License-Expression: MIT
6
+ Project-URL: Documentation, https://fxmacrodata.com/documentation/reference?utm_source=pypi&utm_medium=referral&utm_campaign=sqlite-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
+ Provides-Extra: test
12
+ Requires-Dist: pytest<9,>=8; extra == "test"
13
+ Dynamic: license-file
14
+
15
+ # FXMacroData for SQLite
16
+
17
+ Load FXMacroData public responses into a SQLite database file with the Python
18
+ standard library's `sqlite3` module. No third-party database driver is
19
+ required, which makes this the simplest way to keep a local, queryable copy of
20
+ macro announcements 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=sqlite-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/sqlite-fxmacrodata
31
+ ```
32
+
33
+ Then load into a database file you own:
34
+
35
+ ```python
36
+ import sqlite3
37
+ from fxmacrodata_platform_core import read
38
+ from fxmacrodata_sqlite import load
39
+
40
+ with sqlite3.connect("fxmacrodata.db") as connection:
41
+ batch = read("indicator_history", {"currency": "usd", "indicator": "policy_rate"})
42
+ result = load(batch, connection, "fxmacrodata", batch_size=1000)
43
+ ```
44
+
45
+ USD operations work without a key. For other currencies configure
46
+ `FXMD_API_KEY` in your runtime secret store and pass
47
+ `api_key_env="FXMD_API_KEY"` to `read` or `load_operation`.
48
+
49
+ By default the loader creates the envelope table if absent. Set
50
+ `create_table=False` to use a table you provisioned yourself;
51
+ `create_table_sql(table)` returns the required DDL and `insert_sql(table)` the
52
+ statement used for every block. Identifiers accept one or two simple names,
53
+ such as `fxmacrodata` or `analytics.fxmacrodata` for an attached database, and
54
+ are always double-quoted. Quoted names, SQL fragments and URL-like names are
55
+ rejected.
56
+
57
+ Every operation exposed by `fxmacrodata_platform_core.operations()` is available,
58
+ including REST and MCP operations. The loader adds a tabular envelope without
59
+ changing the original public response. `record_json` retains the original record;
60
+ `response_json` retains the complete response, including pagination and metadata.
61
+ An empty response produces one envelope with `record_json = 'null'`, so absence of
62
+ data and the server's metadata remain inspectable. It is not an observation.
63
+
64
+ Columns are `operation`, `source_url`, `record_id`, `currency`, `indicator`,
65
+ `date`, `announcement_datetime`, `value`, `value_text`, `record_json`,
66
+ `response_json`, and the loader's `content_hash`. Only `value` is numeric
67
+ (`REAL`); everything else is `TEXT`. Dates and announcement times remain source
68
+ strings. No publication time is inferred, and no missing value is filled.
69
+ `value_text` and JSON preserve source numbers supplied as strings. JSON numeric
70
+ tokens follow the public Python client parser, so original lexical precision is
71
+ not guaranteed. SQLite's JSON functions (`json_extract`) work directly on the
72
+ JSON columns.
73
+
74
+ Select operations with the shared catalogue rather than constructing URLs:
75
+
76
+ ```python
77
+ from fxmacrodata_platform_core import operations, schemas
78
+
79
+ available = operations()
80
+ input_schemas = schemas()
81
+ ```
82
+
83
+ `load_operation` accepts `api_key_env`, `complete_history`, `max_pages`, and
84
+ `client_factory` for the public source reader. `max_pages` bounds history
85
+ pagination; an incomplete read raises an error before a destination load starts.
86
+ `batch_size` bounds destination blocks to 1–10,000 rows. `max_batch_bytes` also
87
+ bounds each block using a conservative JSON byte estimate (16 MiB by default,
88
+ 128 MiB maximum). An oversized individual envelope is rejected before destination
89
+ changes; narrow the source date range to reduce the response size. The source
90
+ response is materialized before loading; choose a source date range suitable for
91
+ memory. Read the installed operation's schema for required arguments and date filters.
92
+
93
+ Supply source credentials through your runtime secret store, exposing only the
94
+ environment variable name to the adapter. The database file is opened by you;
95
+ the adapter never chooses a path, saves or prints credentials.
96
+
97
+ The package licence covers this adapter code. FXMacroData data access and onward
98
+ redistribution remain subject to your subscription and applicable licence. Loading
99
+ data into a database does not grant permission to publish or resell it.
100
+
101
+ ## Retries and querying
102
+
103
+ The table's primary key is `(operation, record_id, content_hash)` and every
104
+ block is inserted with `INSERT OR IGNORE` inside its own transaction. Retrying
105
+ the same source batch is idempotent: identical envelopes are skipped, while
106
+ changed record data or response metadata produces a distinct content hash and
107
+ is retained alongside the earlier version.
108
+
109
+ ```sql
110
+ SELECT currency, indicator, date, value,
111
+ json_extract(record_json, '$.publication_time_status') AS status
112
+ FROM fxmacrodata
113
+ WHERE record_json <> 'null';
114
+ ```
115
+
116
+ Delivery is at-least-once with an idempotent key. Blocks are not retried
117
+ automatically. On a failure the failing block is rolled back; earlier blocks
118
+ are already committed, and retrying the same source batch is safe.
119
+ `LoadResult.rows` counts envelopes submitted, rather than newly created rows.
120
+
121
+ See [event_study.sql](examples/event_study.sql) for an announcement/price join.
122
+ The example expects your own licensed market-price table and uses source
123
+ announcement timestamps only. Timestamp presence alone is not evidence that a
124
+ release had already been published; use source publication metadata when
125
+ building a point-in-time backtest.
126
+
127
+ ## Development
128
+
129
+ Tests are offline and use an in-memory database:
130
+
131
+ ```sh
132
+ python -m pytest tests -q
133
+ ```
134
+
135
+ Standard library reference:
136
+ [sqlite3 documentation](https://docs.python.org/3/library/sqlite3.html?utm_source=pypi&utm_medium=referral&utm_campaign=sqlite-fxmacrodata&utm_content=upstream-reference).
@@ -0,0 +1,7 @@
1
+ fxmacrodata_sqlite/__init__.py,sha256=7RdYt3LJKcoQvBb6YKIDsHv86GunbufD0gO_lBFKF1k,6635
2
+ fxmacrodata_sqlite/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
3
+ sqlite_fxmacrodata-0.1.0.dist-info/licenses/LICENSE,sha256=rklK7Fl8aL1eWGubsrozZ7rCNTsN-MATw4M6LQIMv10,1092
4
+ sqlite_fxmacrodata-0.1.0.dist-info/METADATA,sha256=qf-V4ECTbbYwCdFxsNHgH7PcHS_-LPgJyXf3pa6XwI8,6426
5
+ sqlite_fxmacrodata-0.1.0.dist-info/WHEEL,sha256=16fc_9Rp854SLcdhaDEjr0u65p0e3FhZxihNgcnCE98,91
6
+ sqlite_fxmacrodata-0.1.0.dist-info/top_level.txt,sha256=JLBvtPFr5f58phM68G4VXdgVuzvFyUDu6ZT4pUR-5-8,19
7
+ sqlite_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_sqlite