sqlite-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.
- sqlite_fxmacrodata-0.1.0/LICENSE +21 -0
- sqlite_fxmacrodata-0.1.0/MANIFEST.in +11 -0
- sqlite_fxmacrodata-0.1.0/PKG-INFO +136 -0
- sqlite_fxmacrodata-0.1.0/PUBLIC_FILES.json +11 -0
- sqlite_fxmacrodata-0.1.0/README.md +122 -0
- sqlite_fxmacrodata-0.1.0/examples/event_study.sql +26 -0
- sqlite_fxmacrodata-0.1.0/pyproject.toml +30 -0
- sqlite_fxmacrodata-0.1.0/setup.cfg +4 -0
- sqlite_fxmacrodata-0.1.0/src/fxmacrodata_sqlite/__init__.py +158 -0
- sqlite_fxmacrodata-0.1.0/src/fxmacrodata_sqlite/py.typed +0 -0
- sqlite_fxmacrodata-0.1.0/src/sqlite_fxmacrodata.egg-info/PKG-INFO +136 -0
- sqlite_fxmacrodata-0.1.0/src/sqlite_fxmacrodata.egg-info/SOURCES.txt +14 -0
- sqlite_fxmacrodata-0.1.0/src/sqlite_fxmacrodata.egg-info/dependency_links.txt +1 -0
- sqlite_fxmacrodata-0.1.0/src/sqlite_fxmacrodata.egg-info/requires.txt +4 -0
- sqlite_fxmacrodata-0.1.0/src/sqlite_fxmacrodata.egg-info/top_level.txt +1 -0
- sqlite_fxmacrodata-0.1.0/tests/test_loader.py +170 -0
|
@@ -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_sqlite/__init__.py
|
|
7
|
+
include src/fxmacrodata_sqlite/py.typed
|
|
8
|
+
include tests/test_loader.py
|
|
9
|
+
include examples/event_study.sql
|
|
10
|
+
global-exclude *.pyc *.pyo
|
|
11
|
+
global-exclude __pycache__/*
|
|
@@ -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,122 @@
|
|
|
1
|
+
# FXMacroData for SQLite
|
|
2
|
+
|
|
3
|
+
Load FXMacroData public responses into a SQLite database file with the Python
|
|
4
|
+
standard library's `sqlite3` module. No third-party database driver is
|
|
5
|
+
required, which makes this the simplest way to keep a local, queryable copy of
|
|
6
|
+
macro announcements while retaining the source payload and revision metadata.
|
|
7
|
+
|
|
8
|
+
[FXMacroData documentation](https://fxmacrodata.com/documentation/reference?utm_source=github&utm_medium=referral&utm_campaign=sqlite-fxmacrodata&utm_content=readme)
|
|
9
|
+
|
|
10
|
+
## Install and connect
|
|
11
|
+
|
|
12
|
+
Requires Python 3.10 or later. Install this package and its adjacent public
|
|
13
|
+
client dependencies from the distribution supplied to you:
|
|
14
|
+
|
|
15
|
+
```sh
|
|
16
|
+
python -m pip install /path/to/fxmacrodata-public-client /path/to/fxmacrodata-platform-core /path/to/sqlite-fxmacrodata
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
Then load into a database file you own:
|
|
20
|
+
|
|
21
|
+
```python
|
|
22
|
+
import sqlite3
|
|
23
|
+
from fxmacrodata_platform_core import read
|
|
24
|
+
from fxmacrodata_sqlite import load
|
|
25
|
+
|
|
26
|
+
with sqlite3.connect("fxmacrodata.db") as connection:
|
|
27
|
+
batch = read("indicator_history", {"currency": "usd", "indicator": "policy_rate"})
|
|
28
|
+
result = load(batch, connection, "fxmacrodata", batch_size=1000)
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
USD operations work without a key. For other currencies configure
|
|
32
|
+
`FXMD_API_KEY` in your runtime secret store and pass
|
|
33
|
+
`api_key_env="FXMD_API_KEY"` to `read` or `load_operation`.
|
|
34
|
+
|
|
35
|
+
By default the loader creates the envelope table if absent. Set
|
|
36
|
+
`create_table=False` to use a table you provisioned yourself;
|
|
37
|
+
`create_table_sql(table)` returns the required DDL and `insert_sql(table)` the
|
|
38
|
+
statement used for every block. Identifiers accept one or two simple names,
|
|
39
|
+
such as `fxmacrodata` or `analytics.fxmacrodata` for an attached database, and
|
|
40
|
+
are always double-quoted. Quoted names, SQL fragments and URL-like names are
|
|
41
|
+
rejected.
|
|
42
|
+
|
|
43
|
+
Every operation exposed by `fxmacrodata_platform_core.operations()` is available,
|
|
44
|
+
including REST and MCP operations. The loader adds a tabular envelope without
|
|
45
|
+
changing the original public response. `record_json` retains the original record;
|
|
46
|
+
`response_json` retains the complete response, including pagination and metadata.
|
|
47
|
+
An empty response produces one envelope with `record_json = 'null'`, so absence of
|
|
48
|
+
data and the server's metadata remain inspectable. It is not an observation.
|
|
49
|
+
|
|
50
|
+
Columns are `operation`, `source_url`, `record_id`, `currency`, `indicator`,
|
|
51
|
+
`date`, `announcement_datetime`, `value`, `value_text`, `record_json`,
|
|
52
|
+
`response_json`, and the loader's `content_hash`. Only `value` is numeric
|
|
53
|
+
(`REAL`); everything else is `TEXT`. Dates and announcement times remain source
|
|
54
|
+
strings. No publication time is inferred, and no missing value is filled.
|
|
55
|
+
`value_text` and JSON preserve source numbers supplied as strings. JSON numeric
|
|
56
|
+
tokens follow the public Python client parser, so original lexical precision is
|
|
57
|
+
not guaranteed. SQLite's JSON functions (`json_extract`) work directly on the
|
|
58
|
+
JSON columns.
|
|
59
|
+
|
|
60
|
+
Select operations with the shared catalogue rather than constructing URLs:
|
|
61
|
+
|
|
62
|
+
```python
|
|
63
|
+
from fxmacrodata_platform_core import operations, schemas
|
|
64
|
+
|
|
65
|
+
available = operations()
|
|
66
|
+
input_schemas = schemas()
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
`load_operation` accepts `api_key_env`, `complete_history`, `max_pages`, and
|
|
70
|
+
`client_factory` for the public source reader. `max_pages` bounds history
|
|
71
|
+
pagination; an incomplete read raises an error before a destination load starts.
|
|
72
|
+
`batch_size` bounds destination blocks to 1–10,000 rows. `max_batch_bytes` also
|
|
73
|
+
bounds each block using a conservative JSON byte estimate (16 MiB by default,
|
|
74
|
+
128 MiB maximum). An oversized individual envelope is rejected before destination
|
|
75
|
+
changes; narrow the source date range to reduce the response size. The source
|
|
76
|
+
response is materialized before loading; choose a source date range suitable for
|
|
77
|
+
memory. Read the installed operation's schema for required arguments and date filters.
|
|
78
|
+
|
|
79
|
+
Supply source credentials through your runtime secret store, exposing only the
|
|
80
|
+
environment variable name to the adapter. The database file is opened by you;
|
|
81
|
+
the adapter never chooses a path, saves or prints credentials.
|
|
82
|
+
|
|
83
|
+
The package licence covers this adapter code. FXMacroData data access and onward
|
|
84
|
+
redistribution remain subject to your subscription and applicable licence. Loading
|
|
85
|
+
data into a database does not grant permission to publish or resell it.
|
|
86
|
+
|
|
87
|
+
## Retries and querying
|
|
88
|
+
|
|
89
|
+
The table's primary key is `(operation, record_id, content_hash)` and every
|
|
90
|
+
block is inserted with `INSERT OR IGNORE` inside its own transaction. Retrying
|
|
91
|
+
the same source batch is idempotent: identical envelopes are skipped, while
|
|
92
|
+
changed record data or response metadata produces a distinct content hash and
|
|
93
|
+
is retained alongside the earlier version.
|
|
94
|
+
|
|
95
|
+
```sql
|
|
96
|
+
SELECT currency, indicator, date, value,
|
|
97
|
+
json_extract(record_json, '$.publication_time_status') AS status
|
|
98
|
+
FROM fxmacrodata
|
|
99
|
+
WHERE record_json <> 'null';
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
Delivery is at-least-once with an idempotent key. Blocks are not retried
|
|
103
|
+
automatically. On a failure the failing block is rolled back; earlier blocks
|
|
104
|
+
are already committed, and retrying the same source batch is safe.
|
|
105
|
+
`LoadResult.rows` counts envelopes submitted, rather than newly created rows.
|
|
106
|
+
|
|
107
|
+
See [event_study.sql](examples/event_study.sql) for an announcement/price join.
|
|
108
|
+
The example expects your own licensed market-price table and uses source
|
|
109
|
+
announcement timestamps only. Timestamp presence alone is not evidence that a
|
|
110
|
+
release had already been published; use source publication metadata when
|
|
111
|
+
building a point-in-time backtest.
|
|
112
|
+
|
|
113
|
+
## Development
|
|
114
|
+
|
|
115
|
+
Tests are offline and use an in-memory database:
|
|
116
|
+
|
|
117
|
+
```sh
|
|
118
|
+
python -m pytest tests -q
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
Standard library reference:
|
|
122
|
+
[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,26 @@
|
|
|
1
|
+
-- Requires loaded announcement envelopes and your market_prices(currency, ts, close),
|
|
2
|
+
-- where ts is an ISO-8601 UTC text timestamp comparable with announcement_datetime.
|
|
3
|
+
-- This uses announcement times, not inferred publication times.
|
|
4
|
+
-- Inspect publisher/publication metadata before using results in a point-in-time backtest.
|
|
5
|
+
WITH events AS (
|
|
6
|
+
SELECT DISTINCT record_id, currency, announcement_datetime AS event_time
|
|
7
|
+
FROM fxmacrodata
|
|
8
|
+
WHERE announcement_datetime IS NOT NULL AND record_json <> 'null'
|
|
9
|
+
), windows AS (
|
|
10
|
+
SELECT e.record_id, e.currency, e.event_time,
|
|
11
|
+
(SELECT p.close FROM market_prices p
|
|
12
|
+
WHERE p.currency = e.currency
|
|
13
|
+
AND p.ts <= e.event_time
|
|
14
|
+
AND p.ts >= datetime(e.event_time, '-5 minutes')
|
|
15
|
+
ORDER BY p.ts DESC LIMIT 1) AS pre_price,
|
|
16
|
+
(SELECT p.close FROM market_prices p
|
|
17
|
+
WHERE p.currency = e.currency
|
|
18
|
+
AND p.ts >= datetime(e.event_time, '+5 minutes')
|
|
19
|
+
AND p.ts <= datetime(e.event_time, '+10 minutes')
|
|
20
|
+
ORDER BY p.ts ASC LIMIT 1) AS post_price
|
|
21
|
+
FROM events e
|
|
22
|
+
)
|
|
23
|
+
SELECT record_id, currency, event_time, pre_price, post_price,
|
|
24
|
+
(post_price / NULLIF(pre_price, 0) - 1) * 100 AS return_pct
|
|
25
|
+
FROM windows
|
|
26
|
+
WHERE pre_price IS NOT NULL AND post_price IS NOT NULL;
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=77", "wheel"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "sqlite-fxmacrodata"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Load public FXMacroData responses into SQLite"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.10"
|
|
11
|
+
license = "MIT"
|
|
12
|
+
dependencies = ["fxmacrodata-platform-core==0.1.0"]
|
|
13
|
+
|
|
14
|
+
[project.urls]
|
|
15
|
+
Documentation = "https://fxmacrodata.com/documentation/reference?utm_source=pypi&utm_medium=referral&utm_campaign=sqlite-fxmacrodata&utm_content=package"
|
|
16
|
+
|
|
17
|
+
[project.optional-dependencies]
|
|
18
|
+
test = ["pytest>=8,<9"]
|
|
19
|
+
|
|
20
|
+
[tool.setuptools]
|
|
21
|
+
packages = ["fxmacrodata_sqlite"]
|
|
22
|
+
package-dir = {"" = "src"}
|
|
23
|
+
include-package-data = false
|
|
24
|
+
|
|
25
|
+
[tool.setuptools.package-data]
|
|
26
|
+
fxmacrodata_sqlite = ["py.typed"]
|
|
27
|
+
|
|
28
|
+
[tool.pytest.ini_options]
|
|
29
|
+
testpaths = ["tests"]
|
|
30
|
+
addopts = "-q"
|
|
@@ -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,14 @@
|
|
|
1
|
+
LICENSE
|
|
2
|
+
MANIFEST.in
|
|
3
|
+
PUBLIC_FILES.json
|
|
4
|
+
README.md
|
|
5
|
+
pyproject.toml
|
|
6
|
+
examples/event_study.sql
|
|
7
|
+
src/fxmacrodata_sqlite/__init__.py
|
|
8
|
+
src/fxmacrodata_sqlite/py.typed
|
|
9
|
+
src/sqlite_fxmacrodata.egg-info/PKG-INFO
|
|
10
|
+
src/sqlite_fxmacrodata.egg-info/SOURCES.txt
|
|
11
|
+
src/sqlite_fxmacrodata.egg-info/dependency_links.txt
|
|
12
|
+
src/sqlite_fxmacrodata.egg-info/requires.txt
|
|
13
|
+
src/sqlite_fxmacrodata.egg-info/top_level.txt
|
|
14
|
+
tests/test_loader.py
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
fxmacrodata_sqlite
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import sqlite3
|
|
3
|
+
|
|
4
|
+
import pytest
|
|
5
|
+
|
|
6
|
+
from fxmacrodata_platform_core import Batch
|
|
7
|
+
import fxmacrodata_sqlite as adapter
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def batch(count=3, metadata=None):
|
|
11
|
+
return Batch("indicator_history", {
|
|
12
|
+
"data": [{"currency": "USD", "indicator": "policy_rate", "value": str(i),
|
|
13
|
+
"date": "2026-10-01", "publication_time_status": "unknown"}
|
|
14
|
+
for i in range(count)],
|
|
15
|
+
"metadata": metadata or {"revision": 1},
|
|
16
|
+
})
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@pytest.fixture
|
|
20
|
+
def connection():
|
|
21
|
+
with sqlite3.connect(":memory:") as db:
|
|
22
|
+
yield db
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def stored(connection, table="fxmacrodata"):
|
|
26
|
+
names = ", ".join(f'"{n}"' for n in adapter.COLUMNS)
|
|
27
|
+
cursor = connection.execute(f'SELECT {names} FROM "{table}" ORDER BY "value_text"')
|
|
28
|
+
return [dict(zip(adapter.COLUMNS, row)) for row in cursor.fetchall()]
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def test_rows_round_trip_through_a_real_connection(connection):
|
|
32
|
+
source = batch()
|
|
33
|
+
result = adapter.load(source, connection, "fxmacrodata", batch_size=2)
|
|
34
|
+
assert (result.rows, result.batches) == (3, 2)
|
|
35
|
+
rows = stored(connection)
|
|
36
|
+
assert len(rows) == 3
|
|
37
|
+
row = rows[0]
|
|
38
|
+
assert row["operation"] == "indicator_history"
|
|
39
|
+
assert row["date"] == "2026-10-01"
|
|
40
|
+
assert row["announcement_datetime"] is None
|
|
41
|
+
assert row["value"] == 0.0 and isinstance(row["value"], float)
|
|
42
|
+
assert row["value_text"] == "0"
|
|
43
|
+
assert json.loads(row["record_json"])["publication_time_status"] == "unknown"
|
|
44
|
+
assert json.loads(row["response_json"]) == source.payload
|
|
45
|
+
assert len(row["content_hash"]) == 64
|
|
46
|
+
columns = {c[1]: c for c in connection.execute('PRAGMA table_info("fxmacrodata")')}
|
|
47
|
+
assert columns["value"][2] == "REAL" and columns["record_id"][3] == 1
|
|
48
|
+
assert [c[1] for c in sorted(columns.values(), key=lambda c: c[5]) if c[5]] == ["operation", "record_id", "content_hash"]
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def test_empty_response_is_a_metadata_envelope_not_an_observation(connection):
|
|
52
|
+
source = batch(0)
|
|
53
|
+
result = adapter.load(source, connection, "fxmacrodata")
|
|
54
|
+
rows = stored(connection)
|
|
55
|
+
assert result.rows == 1 and len(rows) == 1
|
|
56
|
+
assert rows[0]["record_json"] == "null"
|
|
57
|
+
assert rows[0]["value"] is None and rows[0]["date"] is None
|
|
58
|
+
assert json.loads(rows[0]["response_json"]) == source.payload
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def test_retry_is_idempotent_and_changed_metadata_adds_a_row(connection):
|
|
62
|
+
adapter.load(batch(1), connection, "fxmacrodata")
|
|
63
|
+
adapter.load(batch(1), connection, "fxmacrodata")
|
|
64
|
+
assert len(stored(connection)) == 1
|
|
65
|
+
adapter.load(batch(1, {"revision": 2}), connection, "fxmacrodata")
|
|
66
|
+
rows = stored(connection)
|
|
67
|
+
assert len(rows) == 2
|
|
68
|
+
assert rows[0]["record_id"] == rows[1]["record_id"]
|
|
69
|
+
assert rows[0]["content_hash"] != rows[1]["content_hash"]
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def test_schema_qualified_table_is_quoted(connection):
|
|
73
|
+
connection.execute("ATTACH DATABASE ':memory:' AS analytics")
|
|
74
|
+
adapter.load(batch(2), connection, "analytics.fxmacrodata")
|
|
75
|
+
count = connection.execute('SELECT COUNT(*) FROM "analytics"."fxmacrodata"').fetchone()[0]
|
|
76
|
+
assert count == 2
|
|
77
|
+
assert 'CREATE TABLE IF NOT EXISTS "analytics"."fxmacrodata"' in adapter.create_table_sql("analytics.fxmacrodata")
|
|
78
|
+
assert adapter.insert_sql("fxmacrodata").startswith('INSERT OR IGNORE INTO "fxmacrodata" (')
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
@pytest.mark.parametrize("table", ['t"; DROP TABLE t; --', "a.b.c", "https://host/table", 'a."b"', "a b", "", None])
|
|
82
|
+
def test_bad_identifiers_never_execute(connection, table):
|
|
83
|
+
with pytest.raises(ValueError):
|
|
84
|
+
adapter.load(batch(), connection, table)
|
|
85
|
+
assert connection.execute("SELECT COUNT(*) FROM sqlite_master").fetchone()[0] == 0
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
@pytest.mark.parametrize("size", [0, -1, 10001, True, 2.5])
|
|
89
|
+
def test_batch_bounds(connection, size):
|
|
90
|
+
with pytest.raises(ValueError):
|
|
91
|
+
adapter.load(batch(), connection, "fxmacrodata", batch_size=size)
|
|
92
|
+
assert connection.execute("SELECT COUNT(*) FROM sqlite_master").fetchone()[0] == 0
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def test_failed_block_is_rolled_back_not_retried_or_exposed(connection):
|
|
96
|
+
class Failing:
|
|
97
|
+
def __init__(self, inner, fail_block):
|
|
98
|
+
self.inner, self.fail_block, self.blocks = inner, fail_block, 0
|
|
99
|
+
|
|
100
|
+
def __enter__(self):
|
|
101
|
+
return self.inner.__enter__()
|
|
102
|
+
|
|
103
|
+
def __exit__(self, *args):
|
|
104
|
+
return self.inner.__exit__(*args)
|
|
105
|
+
|
|
106
|
+
def execute(self, *args):
|
|
107
|
+
return self.inner.execute(*args)
|
|
108
|
+
|
|
109
|
+
def executemany(self, sql, params):
|
|
110
|
+
self.blocks += 1
|
|
111
|
+
self.inner.executemany(sql, params)
|
|
112
|
+
if self.blocks == self.fail_block:
|
|
113
|
+
raise RuntimeError("private destination diagnostic")
|
|
114
|
+
|
|
115
|
+
wrapped = Failing(connection, 2)
|
|
116
|
+
with pytest.raises(adapter.LoadError) as error:
|
|
117
|
+
adapter.load(batch(), wrapped, "fxmacrodata", batch_size=1)
|
|
118
|
+
assert "block 2" in str(error.value)
|
|
119
|
+
assert "private destination diagnostic" not in str(error.value)
|
|
120
|
+
assert wrapped.blocks == 2
|
|
121
|
+
assert len(stored(connection)) == 1
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def test_preprovisioned_table_skips_ddl(connection):
|
|
125
|
+
with pytest.raises(adapter.LoadError):
|
|
126
|
+
adapter.load(batch(1), connection, "fxmacrodata", create_table=False)
|
|
127
|
+
connection.execute(adapter.create_table_sql("fxmacrodata"))
|
|
128
|
+
adapter.load(batch(1), connection, "fxmacrodata", create_table=False)
|
|
129
|
+
assert len(stored(connection)) == 1
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def test_operation_forwards_public_reader_options(monkeypatch, connection):
|
|
133
|
+
calls = []
|
|
134
|
+
monkeypatch.setattr(adapter, "read", lambda *args, **kw: calls.append((args, kw)) or batch(1))
|
|
135
|
+
factory = object()
|
|
136
|
+
adapter.load_operation("indicator_history", {"currency": "usd", "indicator": "policy_rate"},
|
|
137
|
+
connection, "fxmacrodata", api_key_env="FXMD_API_KEY",
|
|
138
|
+
max_pages=12, client_factory=factory)
|
|
139
|
+
assert calls[0][1] == dict(api_key_env="FXMD_API_KEY", complete_history=True,
|
|
140
|
+
max_pages=12, client_factory=factory)
|
|
141
|
+
assert len(stored(connection)) == 1
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def test_credential_arguments_are_rejected_by_the_reader(connection):
|
|
145
|
+
from fxmacrodata_platform_core import IntegrationError
|
|
146
|
+
with pytest.raises(IntegrationError):
|
|
147
|
+
adapter.load_operation("indicator_history",
|
|
148
|
+
{"currency": "usd", "indicator": "policy_rate", "api_key": "x"},
|
|
149
|
+
connection, "fxmacrodata")
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def test_byte_limit_splits_blocks_and_rejects_oversized_envelopes_before_ddl(connection):
|
|
153
|
+
source = batch()
|
|
154
|
+
budget = max(len(json.dumps(r, ensure_ascii=True).encode()) + 2 for r in adapter._rows(source))
|
|
155
|
+
result = adapter.load(source, connection, "fxmacrodata", batch_size=100, max_batch_bytes=budget)
|
|
156
|
+
assert result.batches == 3
|
|
157
|
+
with sqlite3.connect(":memory:") as untouched:
|
|
158
|
+
with pytest.raises(ValueError, match="narrow the source date range"):
|
|
159
|
+
adapter.load(source, untouched, "fxmacrodata", max_batch_bytes=1)
|
|
160
|
+
assert untouched.execute("SELECT COUNT(*) FROM sqlite_master").fetchone()[0] == 0
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def test_invalid_loader_configuration_never_reads_source(monkeypatch, connection):
|
|
164
|
+
def unexpected(*args, **kwargs):
|
|
165
|
+
pytest.fail("source must not be called for invalid loader configuration")
|
|
166
|
+
monkeypatch.setattr(adapter, "read", unexpected)
|
|
167
|
+
for options in ({"batch_size": 0}, {"max_batch_bytes": 0}, {"table": "a.b.c"}):
|
|
168
|
+
with pytest.raises(ValueError):
|
|
169
|
+
adapter.load_operation("release_calendar", {"currency": "usd"}, connection,
|
|
170
|
+
**{"table": "fxmacrodata", **options})
|