detquantlib 5.10.3__tar.gz → 5.11.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.
- {detquantlib-5.10.3 → detquantlib-5.11.0}/PKG-INFO +1 -1
- {detquantlib-5.10.3 → detquantlib-5.11.0}/detquantlib/snapshot_testing/README.md +84 -42
- {detquantlib-5.10.3 → detquantlib-5.11.0}/detquantlib/snapshot_testing/__init__.py +2 -0
- {detquantlib-5.10.3 → detquantlib-5.11.0}/detquantlib/snapshot_testing/config.py +42 -6
- {detquantlib-5.10.3 → detquantlib-5.11.0}/detquantlib/snapshot_testing/pytest_plugin.py +13 -2
- {detquantlib-5.10.3 → detquantlib-5.11.0}/detquantlib/snapshot_testing/recording.py +130 -8
- {detquantlib-5.10.3 → detquantlib-5.11.0}/pyproject.toml +1 -1
- {detquantlib-5.10.3 → detquantlib-5.11.0}/LICENSE.txt +0 -0
- {detquantlib-5.10.3 → detquantlib-5.11.0}/README.md +0 -0
- {detquantlib-5.10.3 → detquantlib-5.11.0}/detquantlib/__init__.py +0 -0
- {detquantlib-5.10.3 → detquantlib-5.11.0}/detquantlib/assets/__init__.py +0 -0
- {detquantlib-5.10.3 → detquantlib-5.11.0}/detquantlib/assets/battery.py +0 -0
- {detquantlib-5.10.3 → detquantlib-5.11.0}/detquantlib/assets/wind_turbine.py +0 -0
- {detquantlib-5.10.3 → detquantlib-5.11.0}/detquantlib/converters/__init__.py +0 -0
- {detquantlib-5.10.3 → detquantlib-5.11.0}/detquantlib/converters/definitions.py +0 -0
- {detquantlib-5.10.3 → detquantlib-5.11.0}/detquantlib/converters/energy.py +0 -0
- {detquantlib-5.10.3 → detquantlib-5.11.0}/detquantlib/converters/helpers.py +0 -0
- {detquantlib-5.10.3 → detquantlib-5.11.0}/detquantlib/converters/price.py +0 -0
- {detquantlib-5.10.3 → detquantlib-5.11.0}/detquantlib/data/__init__.py +0 -0
- {detquantlib-5.10.3 → detquantlib-5.11.0}/detquantlib/data/databases/azure_client.py +0 -0
- {detquantlib-5.10.3 → detquantlib-5.11.0}/detquantlib/data/databases/detdatabase.py +0 -0
- {detquantlib-5.10.3 → detquantlib-5.11.0}/detquantlib/data/databases/helpers.py +0 -0
- {detquantlib-5.10.3 → detquantlib-5.11.0}/detquantlib/data/databases/snowflake_client.py +0 -0
- {detquantlib-5.10.3 → detquantlib-5.11.0}/detquantlib/data/entsoe/entsoe.py +0 -0
- {detquantlib-5.10.3 → detquantlib-5.11.0}/detquantlib/data/sftp/sftp.py +0 -0
- {detquantlib-5.10.3 → detquantlib-5.11.0}/detquantlib/data/tennet/tennet_client.py +0 -0
- {detquantlib-5.10.3 → detquantlib-5.11.0}/detquantlib/dates/__init__.py +0 -0
- {detquantlib-5.10.3 → detquantlib-5.11.0}/detquantlib/dates/dates.py +0 -0
- {detquantlib-5.10.3 → detquantlib-5.11.0}/detquantlib/figures/__init__.py +0 -0
- {detquantlib-5.10.3 → detquantlib-5.11.0}/detquantlib/figures/plotly_figures.py +0 -0
- {detquantlib-5.10.3 → detquantlib-5.11.0}/detquantlib/forecasting/__init__.py +0 -0
- {detquantlib-5.10.3 → detquantlib-5.11.0}/detquantlib/forecasting/forecasting.py +0 -0
- {detquantlib-5.10.3 → detquantlib-5.11.0}/detquantlib/outputs/__init__.py +0 -0
- {detquantlib-5.10.3 → detquantlib-5.11.0}/detquantlib/outputs/outputs_interface.py +0 -0
- {detquantlib-5.10.3 → detquantlib-5.11.0}/detquantlib/snapshot_testing/cases.py +0 -0
- {detquantlib-5.10.3 → detquantlib-5.11.0}/detquantlib/snapshot_testing/comparison.py +0 -0
- {detquantlib-5.10.3 → detquantlib-5.11.0}/detquantlib/snapshot_testing/errors.py +0 -0
- {detquantlib-5.10.3 → detquantlib-5.11.0}/detquantlib/snapshot_testing/isolation.py +0 -0
- {detquantlib-5.10.3 → detquantlib-5.11.0}/detquantlib/snapshot_testing/tables.py +0 -0
- {detquantlib-5.10.3 → detquantlib-5.11.0}/detquantlib/stats/__init__.py +0 -0
- {detquantlib-5.10.3 → detquantlib-5.11.0}/detquantlib/stats/data_analysis.py +0 -0
- {detquantlib-5.10.3 → detquantlib-5.11.0}/detquantlib/tradable_products/__init__.py +0 -0
- {detquantlib-5.10.3 → detquantlib-5.11.0}/detquantlib/tradable_products/tradable_products.py +0 -0
- {detquantlib-5.10.3 → detquantlib-5.11.0}/detquantlib/utils/__init__.py +0 -0
- {detquantlib-5.10.3 → detquantlib-5.11.0}/detquantlib/utils/logging.py +0 -0
- {detquantlib-5.10.3 → detquantlib-5.11.0}/detquantlib/utils/utils.py +0 -0
|
@@ -10,8 +10,10 @@ either be fixed or explicitly accepted, and is reviewed in the pull request diff
|
|
|
10
10
|
|
|
11
11
|
Key properties:
|
|
12
12
|
|
|
13
|
-
- **No database access needed in CI.** Database
|
|
13
|
+
- **No database access needed in CI.** Database reads are recorded once, locally, with database
|
|
14
14
|
access, and replayed from disk in all other runs.
|
|
15
|
+
- **Recording needs read access only.** The tests never execute database writes: the data the
|
|
16
|
+
model would write is saved and compared with the snapshot instead.
|
|
15
17
|
- **Diffable recordings.** Recorded tables are stored as CSV + JSON files, so changes to recorded
|
|
16
18
|
data or queries show up as line diffs in pull requests.
|
|
17
19
|
- **Explicit failures.** A changed query, an unrecorded database call or an attempt to reach the
|
|
@@ -42,21 +44,24 @@ A snapshot test runs one test case in one of three modes:
|
|
|
42
44
|
|---------|-------------------------------------------|--------------------|--------------------------------------------------------------|
|
|
43
45
|
| replay | `pytest tests/snapshot` | replayed from disk | Compares the outputs with the snapshot (what CI runs) |
|
|
44
46
|
| update | `pytest tests/snapshot --snapshot-update` | replayed from disk | Overwrites the snapshot with the outputs |
|
|
45
|
-
| record | `pytest tests/snapshot --snapshot-record` | live (local
|
|
47
|
+
| record | `pytest tests/snapshot --snapshot-record` | live reads (local) | Re-records the database reads, and overwrites the snapshot |
|
|
46
48
|
|
|
47
49
|
A run goes through the following steps (see `run_snapshot_case()` in `pytest_plugin.py`):
|
|
48
50
|
|
|
49
51
|
1. The outputs folder `tests/snapshot/.actual/CaseXX/Outputs` is emptied. It is kept after the run
|
|
50
52
|
for inspection, and uploaded as an artifact by CI when the test fails.
|
|
51
53
|
2. The database functions are patched:
|
|
52
|
-
- record mode: they run normally, and their results are saved to the case's
|
|
53
|
-
|
|
54
|
+
- reads, in record mode: they run normally, and their results are saved to the case's
|
|
55
|
+
`db_cache` folder;
|
|
56
|
+
- reads, in replay and update modes: they never run; their recorded results are returned
|
|
57
|
+
instead;
|
|
58
|
+
- writes, in every mode: they never run; each call is saved in the outputs folder
|
|
59
|
+
(`_db_writes`), and a simulated result is returned (see [Database Writes](#database-writes)).
|
|
54
60
|
3. In replay and update modes, the network is blocked and the local `.env` file is ignored, so
|
|
55
61
|
that local runs behave exactly like CI (which has no credentials).
|
|
56
62
|
4. The model runs through its real entry point, with the time frozen if the test case says so,
|
|
57
|
-
and
|
|
58
|
-
name and description of the test case
|
|
59
|
-
written to `tests/snapshot/.actual/CaseXX/console.txt` instead.
|
|
63
|
+
and isolated from the rest of the pytest process (see [Isolation](#isolation)). The test only
|
|
64
|
+
prints the name and description of the test case.
|
|
60
65
|
5. The outputs are compared with the snapshot (replay mode), or replace it (update and record
|
|
61
66
|
modes).
|
|
62
67
|
|
|
@@ -69,8 +74,7 @@ name and of its meaningful arguments.
|
|
|
69
74
|
- SQL text is whitespace-normalized: reformatting a query does not invalidate the recording.
|
|
70
75
|
SQLAlchemy `text()` objects are keyed on their SQL.
|
|
71
76
|
- Parameters, dates and other plain values are part of the key.
|
|
72
|
-
-
|
|
73
|
-
data types and content.
|
|
77
|
+
- DataFrame arguments are keyed on their shape, column names, data types and content.
|
|
74
78
|
- Clients, connections and engines are replaced by a constant placeholder, so that a real client
|
|
75
79
|
(record mode) and a client without credentials (replay mode) produce the same key.
|
|
76
80
|
|
|
@@ -86,6 +90,35 @@ Every module-level alias of a patched function is patched too (e.g. created by
|
|
|
86
90
|
`from pkg.db import query`), so a model cannot bypass the recording by importing a function by
|
|
87
91
|
name.
|
|
88
92
|
|
|
93
|
+
### Database Writes
|
|
94
|
+
|
|
95
|
+
Functions that write to a database (e.g. `AzureClient.write_table`) are _write targets_. They
|
|
96
|
+
never run, so the tests never write to a database, and recording only needs read access. Each call
|
|
97
|
+
is saved in the outputs folder instead:
|
|
98
|
+
|
|
99
|
+
```
|
|
100
|
+
ExpectedOutputs/
|
|
101
|
+
├── ... the model's own outputs
|
|
102
|
+
└── _db_writes/
|
|
103
|
+
├── calls.json function, arguments and simulated result of each call
|
|
104
|
+
├── 001_AzureClient_write_table.csv the DataFrame passed to the 1st call (rows written)
|
|
105
|
+
└── ...
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
- Files are numbered in call order, across all clients (e.g. `001_AzureClient_write_table.csv`,
|
|
109
|
+
`002_SnowflakeClient_write_table.csv`). A call with several DataFrame arguments gets one file per
|
|
110
|
+
DataFrame (`001_<function>.csv`, `001_<function>_2.csv`, ...).
|
|
111
|
+
- `calls.json` lists every call: the function, its arguments (SQL text, table name, options, and
|
|
112
|
+
the CSV file names in place of DataFrames) and the value it returned.
|
|
113
|
+
- DataFrames and parameters are saved as the model passes them, before any conversion by the
|
|
114
|
+
client (e.g. `AzureClient` sends timezone-aware datetimes as UTC). DataFrames are saved without
|
|
115
|
+
their index, as the clients write them, and timezone-aware values with their UTC offset.
|
|
116
|
+
- These files are compared with the snapshot like any other output, so a change in the data the
|
|
117
|
+
model writes shows up as a readable difference in the test failure and in the pull request.
|
|
118
|
+
- The replacement returns a _simulated_ result, computed from the call arguments: the number of
|
|
119
|
+
rows of the DataFrame for `write_table()`, and `None` for `execute()` (there is no database to
|
|
120
|
+
count the affected rows).
|
|
121
|
+
|
|
89
122
|
### Network Guard
|
|
90
123
|
|
|
91
124
|
In replay and update modes, any connection to a non-local address fails immediately with a
|
|
@@ -116,9 +149,9 @@ created by that run, which are also detached afterwards. The exception hooks rep
|
|
|
116
149
|
`detquantlib.utils.setup_logger` are restored as well. The working directory and the
|
|
117
150
|
`INPUT_DIR` / `OUTPUT_DIR` environment variables are restored after each test.
|
|
118
151
|
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
The run log written to the outputs folder is not affected.
|
|
152
|
+
Everything the model prints or logs to the console (stdout, stderr, and console log handlers
|
|
153
|
+
created by the model) is written to `tests/snapshot/.actual/CaseXX/console.txt` instead, also when
|
|
154
|
+
the model crashes. The run log written to the outputs folder is not affected.
|
|
122
155
|
|
|
123
156
|
### Output Comparison
|
|
124
157
|
|
|
@@ -131,9 +164,9 @@ Each file of the outputs folder is compared with its snapshot according to its t
|
|
|
131
164
|
| Text (`.txt`, `.sql`, ...) | Line by line (line endings ignored), with a unified diff |
|
|
132
165
|
| Anything else | Byte by byte |
|
|
133
166
|
|
|
134
|
-
|
|
135
|
-
(`*.html`, e.g. Plotly figures with
|
|
136
|
-
not compared by default.
|
|
167
|
+
This includes the captured database writes (`_db_writes`). Missing and unexpected files are
|
|
168
|
+
reported too. Run logs (`*.log`, timestamps) and HTML files (`*.html`, e.g. Plotly figures with
|
|
169
|
+
random element identifiers and ~5 MB of embedded plotly.js) are not compared by default.
|
|
137
170
|
|
|
138
171
|
## Supported Database Clients
|
|
139
172
|
|
|
@@ -145,8 +178,8 @@ The default configuration covers the database clients of `detquantlib.data`:
|
|
|
145
178
|
| `load_table()` | through `query()` | through `query()` |
|
|
146
179
|
| `list_tables()` | recorded | through `query()` |
|
|
147
180
|
| `query_batches()` | recorded (batches) | recorded (batches) |
|
|
148
|
-
| `write_table()` |
|
|
149
|
-
| `execute()` |
|
|
181
|
+
| `write_table()` | never run; returns rows written | never run; returns rows written |
|
|
182
|
+
| `execute()` | never run; returns `None` | never run; returns `None` |
|
|
150
183
|
| `connect()` | offline stub in replay mode | offline stub in replay mode |
|
|
151
184
|
|
|
152
185
|
Notes:
|
|
@@ -155,10 +188,8 @@ Notes:
|
|
|
155
188
|
results would be recorded twice.
|
|
156
189
|
- `query_batches()` returns a generator. In record mode, it is consumed entirely during the call,
|
|
157
190
|
and each batch is stored separately. In replay mode, the batches are returned by a generator.
|
|
158
|
-
- Writes (`write_table()`, `execute()`) are
|
|
159
|
-
|
|
160
|
-
data is part of the cache key, a change in the written data fails the test with a
|
|
161
|
-
`DbCacheMissError`.
|
|
191
|
+
- Writes (`write_table()`, `execute()`) are saved as outputs (see
|
|
192
|
+
[Database Writes](#database-writes)).
|
|
162
193
|
|
|
163
194
|
## Setting Up a Model Repository
|
|
164
195
|
|
|
@@ -184,11 +215,8 @@ The framework expects the standard layout of Quant model repositories: `run.py`,
|
|
|
184
215
|
└── ExpectedOutputs/ created by --snapshot-record / --snapshot-update
|
|
185
216
|
```
|
|
186
217
|
|
|
187
|
-
pytest requires two files: command line options can only be registered from a `conftest.py` file
|
|
188
|
-
(or a plugin loaded at startup), and tests are only collected from `test_*.py` files.
|
|
189
|
-
|
|
190
218
|
`tests/snapshot/conftest.py` activates the command line options and the `snapshot_mode` fixture
|
|
191
|
-
for the snapshot tests only:
|
|
219
|
+
for the snapshot tests only (pytest reads command line options from `conftest.py` files):
|
|
192
220
|
|
|
193
221
|
```python
|
|
194
222
|
"""pytest configuration of the snapshot tests (detquantlib.snapshot_testing)."""
|
|
@@ -226,12 +254,9 @@ def test_snapshot(case, snapshot_mode, monkeypatch):
|
|
|
226
254
|
run_snapshot_case(case, snapshot_mode, monkeypatch, CONFIG, entry_point=model_entry_point)
|
|
227
255
|
```
|
|
228
256
|
|
|
229
|
-
Code outside the tests that needs the test cases (e.g. Invoke tasks listing or creating them)
|
|
230
|
-
|
|
231
|
-
`
|
|
232
|
-
it would also import the model, and imports through the `tests` folder (e.g.
|
|
233
|
-
`from tests.snapshot... import`) break when another package named `tests` is on the Python path
|
|
234
|
-
(e.g. the one of an editable install of detquantlib).
|
|
257
|
+
Code outside the tests that needs the test cases (e.g. Invoke tasks listing or creating them) can
|
|
258
|
+
get their folder with `SnapshotConfig(snapshot_dir=...).cases_dir`, without importing
|
|
259
|
+
`test_snapshots.py` (which imports the model).
|
|
235
260
|
|
|
236
261
|
### Dependencies and pytest Settings
|
|
237
262
|
|
|
@@ -277,7 +302,7 @@ jobs:
|
|
|
277
302
|
|
|
278
303
|
- name: Upload outputs of snapshot tests on failure
|
|
279
304
|
if: ${{ !cancelled() && steps.snapshot_tests.outcome == 'failure' }}
|
|
280
|
-
uses: actions/upload-artifact@
|
|
305
|
+
uses: actions/upload-artifact@v7
|
|
281
306
|
with:
|
|
282
307
|
name: snapshot-actual-outputs
|
|
283
308
|
path: tests/snapshot/.actual/
|
|
@@ -343,6 +368,7 @@ ignore = ["plots/*.png"]
|
|
|
343
368
|
### Creating a Test Case
|
|
344
369
|
|
|
345
370
|
Step 1: Execute `create_case(inputs_dir, cases_root)`. The function handles the following actions:
|
|
371
|
+
|
|
346
372
|
- Creates the folder of a new test case, with the lowest free case number.
|
|
347
373
|
- Copies the given inputs folder (typically the project's default `Inputs` folder).
|
|
348
374
|
- Writes a `case.toml` file to complete, which freezes the time at the moment of creation.
|
|
@@ -382,7 +408,7 @@ db_cache/
|
|
|
382
408
|
├── manifest.json cache key -> function, call arguments, file
|
|
383
409
|
├── 001_SnowflakeClient_query.csv values of the 1st distinct table returned
|
|
384
410
|
├── 001_SnowflakeClient_query.dtypes.json its column data types
|
|
385
|
-
├──
|
|
411
|
+
├── 002_<function>.pkl a result that is not a table (e.g. a number)
|
|
386
412
|
├── 003_AzureClient_query_batches_001.csv the batches returned by query_batches()
|
|
387
413
|
└── ...
|
|
388
414
|
```
|
|
@@ -410,8 +436,8 @@ data types). The round trip is exact, and verified when recording:
|
|
|
410
436
|
|
|
411
437
|
Tables that cannot be stored exactly (categorical columns, columns mixing value types, columns
|
|
412
438
|
with both empty strings and NULLs) are stored as pickle files instead, with a warning: replay
|
|
413
|
-
works, but the recording is not diffable. Results that are not tables (e.g.
|
|
414
|
-
|
|
439
|
+
works, but the recording is not diffable. Results that are not tables (e.g. numbers) are always
|
|
440
|
+
stored as pickle files.
|
|
415
441
|
|
|
416
442
|
Do not store recordings with Git LFS: GitHub does not show diffs of LFS files. Keep recordings
|
|
417
443
|
small instead, by querying only the data that a test case needs.
|
|
@@ -424,14 +450,21 @@ small instead, by querying only the data that a test case needs.
|
|
|
424
450
|
|-------------------------|---------------------------------|-----------------------------------------------|
|
|
425
451
|
| `snapshot_dir` | (required) | Folder of the snapshot tests |
|
|
426
452
|
| `project_root` | grandparent of `snapshot_dir` | Folder in which the model runs |
|
|
427
|
-
| `db_query_targets` | `DEFAULT_DB_QUERY_TARGETS` |
|
|
453
|
+
| `db_query_targets` | `DEFAULT_DB_QUERY_TARGETS` | Read functions, recorded and replayed |
|
|
428
454
|
| `db_connection_targets` | `DEFAULT_DB_CONNECTION_TARGETS` | Connection factories (stubbed in replay mode) |
|
|
455
|
+
| `db_write_targets` | `DEFAULT_DB_WRITE_TARGETS` | Write functions, never run (saved as outputs) |
|
|
429
456
|
| `ignore_patterns` | `DEFAULT_IGNORE_PATTERNS` | Output files not compared (glob patterns) |
|
|
430
457
|
| `rtol` | `1e-8` | Relative tolerance of numerical comparisons |
|
|
431
458
|
| `atol` | `1e-10` | Absolute tolerance of numerical comparisons |
|
|
432
459
|
|
|
433
460
|
`cases_dir` (`snapshot_dir / "cases"`) and `actual_dir` (`snapshot_dir / ".actual"`) are derived
|
|
434
|
-
from `snapshot_dir`.
|
|
461
|
+
from `snapshot_dir`. A function can be listed in only one kind of target: `SnapshotConfig` raises
|
|
462
|
+
a `ValueError` otherwise.
|
|
463
|
+
|
|
464
|
+
`db_write_targets` maps each write function to the function computing its simulated return value
|
|
465
|
+
from the call arguments, or to `None` (the replacement then returns `None`). `rows_written`
|
|
466
|
+
(from `detquantlib.snapshot_testing.recording`) returns the number of rows of the first
|
|
467
|
+
DataFrame argument.
|
|
435
468
|
|
|
436
469
|
### Adding Database Functions
|
|
437
470
|
|
|
@@ -442,13 +475,16 @@ functions), add those functions to the targets of `CONFIG`, in `tests/snapshot/t
|
|
|
442
475
|
from detquantlib.snapshot_testing import (
|
|
443
476
|
DEFAULT_DB_CONNECTION_TARGETS,
|
|
444
477
|
DEFAULT_DB_QUERY_TARGETS,
|
|
478
|
+
DEFAULT_DB_WRITE_TARGETS,
|
|
445
479
|
SnapshotConfig,
|
|
446
480
|
)
|
|
481
|
+
from detquantlib.snapshot_testing.recording import rows_written
|
|
447
482
|
|
|
448
483
|
CONFIG = SnapshotConfig(
|
|
449
484
|
snapshot_dir=Path(__file__).resolve().parent,
|
|
450
485
|
db_query_targets=[*DEFAULT_DB_QUERY_TARGETS, "src.data_access:load_prices"],
|
|
451
486
|
db_connection_targets=[*DEFAULT_DB_CONNECTION_TARGETS, "src.data_access:connect"],
|
|
487
|
+
db_write_targets={**DEFAULT_DB_WRITE_TARGETS, "src.data_access:save_results": rows_written},
|
|
452
488
|
)
|
|
453
489
|
```
|
|
454
490
|
|
|
@@ -456,14 +492,17 @@ CONFIG = SnapshotConfig(
|
|
|
456
492
|
- Prefer the highest-level function that both connects and returns data.
|
|
457
493
|
- Do not list a function that only calls another listed function: its results would be recorded
|
|
458
494
|
twice, and the inner recordings would be reported as unused in replay mode.
|
|
459
|
-
- List functions that only create connections as connection targets
|
|
495
|
+
- List functions that only create connections as connection targets, and functions that write to
|
|
496
|
+
a database as write targets.
|
|
460
497
|
|
|
461
498
|
## Troubleshooting
|
|
462
499
|
|
|
463
500
|
| Error or warning | Cause and solution |
|
|
464
501
|
|-----------------------------------------------------|---------------------------------------------------------------------------------------------------------------------------------------------------|
|
|
465
502
|
| `DbCacheMissError: No DB cache found` | The test case was never recorded. Record it locally with `--snapshot-record`. |
|
|
466
|
-
| `DbCacheMissError: No recorded result for ...` | A query
|
|
503
|
+
| `DbCacheMissError: No recorded result for ...` | A query or its parameters changed, or a new database read was added. Re-record the test case. If the call depends on the current date, freeze the time in `case.toml`. |
|
|
504
|
+
| `[changed] _db_writes/...` in a snapshot mismatch | The data the model writes to a database changed. Fix the model, or accept the change with `--snapshot-update`. |
|
|
505
|
+
| `... listed in ... and in another kind of target` | A function is listed in two kinds of targets. List each function once: reads in `db_query_targets`, connection factories in `db_connection_targets`, writes in `db_write_targets`. |
|
|
467
506
|
| `NetworkAccessBlockedError` | The model reaches the network or a database through a function that is not recorded. Add the function to the targets. |
|
|
468
507
|
| `Snapshot mismatch for case ...` | The outputs changed. Fix the model, or accept the change with `--snapshot-update`. |
|
|
469
508
|
| `No snapshot for case ...` | The test case has a DB cache but no snapshot. Create it with `--snapshot-update`. |
|
|
@@ -479,8 +518,11 @@ CONFIG = SnapshotConfig(
|
|
|
479
518
|
which differs between record mode (credentials) and replay mode (none). Identical SQL sent to
|
|
480
519
|
different default databases in one run shares one recording: qualify table names or pass
|
|
481
520
|
`database=...` in that case.
|
|
482
|
-
- **Writes
|
|
483
|
-
|
|
521
|
+
- **Writes are simulated.** Since writes never run, a model that reads back data it wrote earlier
|
|
522
|
+
in the same run does not find it in the recording. Logic that depends on the number of rows
|
|
523
|
+
affected by `execute()` receives `None`. Checks and conversions made by the clients before
|
|
524
|
+
writing (e.g. the validation of table names) do not run either.
|
|
525
|
+
- **Reserved folder name.** `_db_writes` is reserved inside the outputs folder.
|
|
484
526
|
- **Multiprocessing.** Patches do not reach child processes: database calls made in child
|
|
485
527
|
processes are neither recorded nor blocked.
|
|
486
528
|
- **Module-level caches.** Query results cached at module level (e.g. with `functools.cache`)
|
|
@@ -500,7 +542,7 @@ CONFIG = SnapshotConfig(
|
|
|
500
542
|
|--------------------|------------------------------------------------------------------------------|
|
|
501
543
|
| `config.py` | `SnapshotConfig` and the default targets, ignore patterns and tolerances |
|
|
502
544
|
| `cases.py` | `SnapshotCase`, `discover_cases()`, `print_cases()`, `create_case()` |
|
|
503
|
-
| `recording.py` | Cache keys, `DbRecorder` (
|
|
545
|
+
| `recording.py` | Cache keys, `DbRecorder` (reads), `DbWriteCapture` (writes), patching |
|
|
504
546
|
| `tables.py` | DataFrame <-> CSV + dtypes JSON, with an exact round trip |
|
|
505
547
|
| `isolation.py` | Network guard, frozen time, logging isolation |
|
|
506
548
|
| `comparison.py` | Comparison of the outputs with the snapshot |
|
|
@@ -5,6 +5,7 @@ from .cases import SnapshotCase, create_case, discover_cases, print_cases
|
|
|
5
5
|
from .config import (
|
|
6
6
|
DEFAULT_DB_CONNECTION_TARGETS,
|
|
7
7
|
DEFAULT_DB_QUERY_TARGETS,
|
|
8
|
+
DEFAULT_DB_WRITE_TARGETS,
|
|
8
9
|
DEFAULT_IGNORE_PATTERNS,
|
|
9
10
|
SnapshotConfig,
|
|
10
11
|
)
|
|
@@ -13,6 +14,7 @@ from .errors import DbCacheMissError, NetworkAccessBlockedError
|
|
|
13
14
|
__all__ = [
|
|
14
15
|
"DEFAULT_DB_CONNECTION_TARGETS",
|
|
15
16
|
"DEFAULT_DB_QUERY_TARGETS",
|
|
17
|
+
"DEFAULT_DB_WRITE_TARGETS",
|
|
16
18
|
"DEFAULT_IGNORE_PATTERNS",
|
|
17
19
|
"DbCacheMissError",
|
|
18
20
|
"NetworkAccessBlockedError",
|
|
@@ -5,7 +5,10 @@ Configuration of the snapshot tests of a model repository.
|
|
|
5
5
|
# Python built-in packages
|
|
6
6
|
from dataclasses import dataclass, field
|
|
7
7
|
from pathlib import Path
|
|
8
|
-
from typing import Optional
|
|
8
|
+
from typing import Callable, Optional
|
|
9
|
+
|
|
10
|
+
# Internal modules
|
|
11
|
+
from detquantlib.snapshot_testing.recording import rows_written
|
|
9
12
|
|
|
10
13
|
_SNOWFLAKE_CLIENT = "detquantlib.data.databases.snowflake_client:SnowflakeClient"
|
|
11
14
|
_AZURE_CLIENT = "detquantlib.data.databases.azure_client:AzureClient"
|
|
@@ -19,14 +22,21 @@ DEFAULT_DB_QUERY_TARGETS: list[str] = [
|
|
|
19
22
|
f"{_SNOWFLAKE_CLIENT}.query",
|
|
20
23
|
f"{_SNOWFLAKE_CLIENT}.query_batches",
|
|
21
24
|
f"{_SNOWFLAKE_CLIENT}.list_tables",
|
|
22
|
-
f"{_SNOWFLAKE_CLIENT}.write_table",
|
|
23
|
-
f"{_SNOWFLAKE_CLIENT}.execute",
|
|
24
25
|
f"{_AZURE_CLIENT}.query",
|
|
25
26
|
f"{_AZURE_CLIENT}.query_batches",
|
|
26
|
-
f"{_AZURE_CLIENT}.write_table",
|
|
27
|
-
f"{_AZURE_CLIENT}.execute",
|
|
28
27
|
]
|
|
29
28
|
|
|
29
|
+
# Functions that write to a database, with the function computing their simulated return value
|
|
30
|
+
# from the call arguments (None: the replacement returns None). They never run, in any mode, so
|
|
31
|
+
# recording only needs read access. Each call is saved in the outputs folder instead
|
|
32
|
+
# (_db_writes/), and compared with the snapshot like any other output.
|
|
33
|
+
DEFAULT_DB_WRITE_TARGETS: dict[str, Optional[Callable]] = {
|
|
34
|
+
f"{_SNOWFLAKE_CLIENT}.write_table": rows_written,
|
|
35
|
+
f"{_SNOWFLAKE_CLIENT}.execute": None, # no database to count the affected rows
|
|
36
|
+
f"{_AZURE_CLIENT}.write_table": rows_written,
|
|
37
|
+
f"{_AZURE_CLIENT}.execute": None,
|
|
38
|
+
}
|
|
39
|
+
|
|
30
40
|
# Functions that only create a connection/engine/client. In replay mode they return an offline
|
|
31
41
|
# stub instead of connecting (they raise without credentials). In record mode they run normally.
|
|
32
42
|
DEFAULT_DB_CONNECTION_TARGETS: list[str] = [
|
|
@@ -57,6 +67,9 @@ class SnapshotConfig:
|
|
|
57
67
|
``snapshot_dir`` (i.e. the repository root).
|
|
58
68
|
db_query_targets: Functions whose results are recorded and replayed.
|
|
59
69
|
db_connection_targets: Functions that only create connections (stubbed in replay mode).
|
|
70
|
+
db_write_targets: Functions that write to a database, never executed: their calls are
|
|
71
|
+
saved as outputs instead. Maps each function to the function computing its
|
|
72
|
+
simulated return value, or to None.
|
|
60
73
|
ignore_patterns: Output files that are not compared.
|
|
61
74
|
rtol: Default relative tolerance of numerical comparisons.
|
|
62
75
|
atol: Default absolute tolerance of numerical comparisons.
|
|
@@ -68,17 +81,40 @@ class SnapshotConfig:
|
|
|
68
81
|
db_connection_targets: list[str] = field(
|
|
69
82
|
default_factory=lambda: list(DEFAULT_DB_CONNECTION_TARGETS)
|
|
70
83
|
)
|
|
84
|
+
db_write_targets: dict[str, Optional[Callable]] = field(
|
|
85
|
+
default_factory=lambda: dict(DEFAULT_DB_WRITE_TARGETS)
|
|
86
|
+
)
|
|
71
87
|
ignore_patterns: list[str] = field(default_factory=lambda: list(DEFAULT_IGNORE_PATTERNS))
|
|
72
88
|
rtol: float = DEFAULT_RTOL
|
|
73
89
|
atol: float = DEFAULT_ATOL
|
|
74
90
|
|
|
75
91
|
def __post_init__(self):
|
|
76
|
-
"""
|
|
92
|
+
"""
|
|
93
|
+
Converts the folders to absolute paths, sets the default project root, and checks that
|
|
94
|
+
no function is listed in more than one kind of target.
|
|
95
|
+
|
|
96
|
+
Raises:
|
|
97
|
+
ValueError: Raises an error if a function is listed in more than one kind of target.
|
|
98
|
+
"""
|
|
77
99
|
self.snapshot_dir = Path(self.snapshot_dir).resolve()
|
|
78
100
|
if self.project_root is None:
|
|
79
101
|
self.project_root = self.snapshot_dir.parents[1]
|
|
80
102
|
self.project_root = Path(self.project_root).resolve()
|
|
81
103
|
|
|
104
|
+
kinds = {
|
|
105
|
+
"db_query_targets": set(self.db_query_targets),
|
|
106
|
+
"db_connection_targets": set(self.db_connection_targets),
|
|
107
|
+
"db_write_targets": set(self.db_write_targets),
|
|
108
|
+
}
|
|
109
|
+
for kind, targets in kinds.items():
|
|
110
|
+
others = set().union(*(t for k, t in kinds.items() if k != kind))
|
|
111
|
+
if targets & others:
|
|
112
|
+
raise ValueError(
|
|
113
|
+
f"Function(s) {sorted(targets & others)} listed in {kind} and in another "
|
|
114
|
+
"kind of target. Writes (e.g. write_table, execute) belong in "
|
|
115
|
+
"db_write_targets only."
|
|
116
|
+
)
|
|
117
|
+
|
|
82
118
|
@property
|
|
83
119
|
def cases_dir(self) -> Path:
|
|
84
120
|
"""Folder containing the test cases (Case01, Case02, ...)."""
|
|
@@ -37,7 +37,13 @@ from detquantlib.snapshot_testing.isolation import (
|
|
|
37
37
|
isolated_logging,
|
|
38
38
|
muted_console,
|
|
39
39
|
)
|
|
40
|
-
from detquantlib.snapshot_testing.recording import
|
|
40
|
+
from detquantlib.snapshot_testing.recording import (
|
|
41
|
+
WRITES_DIRNAME,
|
|
42
|
+
DbRecorder,
|
|
43
|
+
DbWriteCapture,
|
|
44
|
+
install_db_patches,
|
|
45
|
+
install_write_capture,
|
|
46
|
+
)
|
|
41
47
|
|
|
42
48
|
MODES = ("replay", "update", "record")
|
|
43
49
|
OUTPUTS_DIRNAME = "Outputs"
|
|
@@ -123,7 +129,9 @@ def run_snapshot_case(
|
|
|
123
129
|
mode="update": as "replay", but the snapshot is overwritten with the produced outputs.
|
|
124
130
|
mode="record": the live databases are queried; the DB cache and the snapshot are
|
|
125
131
|
overwritten.
|
|
126
|
-
|
|
132
|
+
In every mode, database writes are never executed: they are saved in the outputs folder
|
|
133
|
+
(_db_writes/) and compared with the snapshot. All patches are undone by `monkeypatch` after
|
|
134
|
+
the test.
|
|
127
135
|
|
|
128
136
|
Args:
|
|
129
137
|
case: Test case
|
|
@@ -156,6 +164,9 @@ def run_snapshot_case(
|
|
|
156
164
|
install_db_patches(
|
|
157
165
|
monkeypatch, recorder, config.db_query_targets, config.db_connection_targets
|
|
158
166
|
)
|
|
167
|
+
# Database writes never run, in any mode: they are saved in the outputs folder instead
|
|
168
|
+
capture = DbWriteCapture(output_dir / WRITES_DIRNAME)
|
|
169
|
+
install_write_capture(monkeypatch, capture, config.db_write_targets)
|
|
159
170
|
if not record:
|
|
160
171
|
block_network(monkeypatch)
|
|
161
172
|
# Ignore a developer's local .env so local runs behave exactly like CI
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
"""
|
|
2
|
-
Database record/replay: the results of database
|
|
3
|
-
access) as CSV + JSON files, and replayed from disk afterwards (locally and in CI).
|
|
2
|
+
Database record/replay: the results of database reads are recorded once (locally, with database
|
|
3
|
+
access) as CSV + JSON files, and replayed from disk afterwards (locally and in CI). Database
|
|
4
|
+
writes are never executed: they are captured as outputs instead.
|
|
4
5
|
"""
|
|
5
6
|
|
|
6
7
|
from __future__ import annotations
|
|
@@ -33,6 +34,8 @@ from detquantlib.snapshot_testing.errors import DbCacheMissError, NetworkAccessB
|
|
|
33
34
|
from detquantlib.snapshot_testing.tables import UnsupportedTableError, read_table, write_table
|
|
34
35
|
|
|
35
36
|
MANIFEST_FILENAME = "manifest.json"
|
|
37
|
+
WRITES_DIRNAME = "_db_writes"
|
|
38
|
+
CALLS_FILENAME = "calls.json"
|
|
36
39
|
RECORD_HINT = "pytest tests/snapshot --snapshot-record -k {case}"
|
|
37
40
|
|
|
38
41
|
|
|
@@ -134,10 +137,11 @@ class DbRecorder:
|
|
|
134
137
|
manifest.json cache key -> function, call arguments (e.g. SQL), file
|
|
135
138
|
001_<function>.csv values of the 1st distinct table returned
|
|
136
139
|
001_<function>.dtypes.json its column data types
|
|
137
|
-
002_<function>.pkl a result that is not a table (e.g. a
|
|
140
|
+
002_<function>.pkl a result that is not a table (e.g. a number)
|
|
138
141
|
003_<function>_001.csv ... the batches returned by a generator (e.g. query_batches)
|
|
139
|
-
Files are numbered in call order,
|
|
140
|
-
|
|
142
|
+
Files are numbered in call order, and the manifest lists its entries in the same order, so
|
|
143
|
+
re-recording a changed query overwrites the same file names and the same manifest position,
|
|
144
|
+
and the change shows up as a normal line-by-line diff in the pull request.
|
|
141
145
|
|
|
142
146
|
In record mode, live database calls run on the real clock (`real_time`, set by the caller),
|
|
143
147
|
even when the case freezes time: database drivers use the clock, e.g. for authentication.
|
|
@@ -285,7 +289,7 @@ class DbRecorder:
|
|
|
285
289
|
def save(self):
|
|
286
290
|
"""Atomically replaces the case's DB cache with the newly recorded one."""
|
|
287
291
|
self._staging.mkdir(parents=True, exist_ok=True)
|
|
288
|
-
manifest = json.dumps(self._manifest, indent=2,
|
|
292
|
+
manifest = json.dumps(self._manifest, indent=2, default=str) + "\n"
|
|
289
293
|
(self._staging / MANIFEST_FILENAME).write_text(manifest, encoding="utf-8")
|
|
290
294
|
shutil.rmtree(self.cache_dir, ignore_errors=True)
|
|
291
295
|
self._staging.rename(self.cache_dir)
|
|
@@ -312,8 +316,8 @@ class DbRecorder:
|
|
|
312
316
|
call = json.dumps(_normalize({"args": list(args), "kwargs": kwargs}), indent=2)
|
|
313
317
|
raise DbCacheMissError(
|
|
314
318
|
f"No recorded result for '{target}' called with:\n{textwrap.shorten(call, 2000)}\n"
|
|
315
|
-
"The query text
|
|
316
|
-
f"
|
|
319
|
+
"The query text or parameters changed, or a new database call was added. "
|
|
320
|
+
f"Re-record locally with: {self._record_hint}"
|
|
317
321
|
)
|
|
318
322
|
if entry["format"] == "batches":
|
|
319
323
|
return (self._read_value(batch) for batch in entry["batches"])
|
|
@@ -344,6 +348,105 @@ class DbRecorder:
|
|
|
344
348
|
return set(self._manifest) - self.used_keys
|
|
345
349
|
|
|
346
350
|
|
|
351
|
+
# -------------------------------------------------------------------------------------------------
|
|
352
|
+
# Database writes
|
|
353
|
+
# -------------------------------------------------------------------------------------------------
|
|
354
|
+
def rows_written(*args, **kwargs) -> int:
|
|
355
|
+
"""
|
|
356
|
+
Simulated return value of a write_table() call: the number of rows of the DataFrame written.
|
|
357
|
+
|
|
358
|
+
Args:
|
|
359
|
+
*args: Positional arguments of the call
|
|
360
|
+
**kwargs: Keyword arguments of the call
|
|
361
|
+
|
|
362
|
+
Returns:
|
|
363
|
+
Number of rows of the first DataFrame argument (0 if there is none)
|
|
364
|
+
"""
|
|
365
|
+
frames = [value for value in (*args, *kwargs.values()) if isinstance(value, pd.DataFrame)]
|
|
366
|
+
return len(frames[0]) if frames else 0
|
|
367
|
+
|
|
368
|
+
|
|
369
|
+
class DbWriteCapture:
|
|
370
|
+
"""
|
|
371
|
+
Replaces database write functions, in every mode: the real function never runs, so recording
|
|
372
|
+
needs read access only, and nothing is ever written to a database by the tests. Each call is
|
|
373
|
+
saved in the outputs folder instead, and is compared with the snapshot like any other output:
|
|
374
|
+
_db_writes/calls.json function, arguments and simulated result of each call
|
|
375
|
+
_db_writes/001_<function>.csv the DataFrame passed to the 1st call (rows written)
|
|
376
|
+
_db_writes/001_<function>_2.csv a 2nd DataFrame argument of the same call, if any
|
|
377
|
+
Files are numbered in call order. DataFrames are saved without their index, as the database
|
|
378
|
+
clients write them.
|
|
379
|
+
"""
|
|
380
|
+
|
|
381
|
+
def __init__(self, writes_dir: Path):
|
|
382
|
+
"""
|
|
383
|
+
Constructor method.
|
|
384
|
+
|
|
385
|
+
Args:
|
|
386
|
+
writes_dir: Folder receiving the captured calls (inside the outputs folder)
|
|
387
|
+
"""
|
|
388
|
+
self.writes_dir = writes_dir
|
|
389
|
+
self.calls: list[dict] = []
|
|
390
|
+
|
|
391
|
+
def wrap(self, target: str, func: Callable, simulate: Callable | None) -> Callable:
|
|
392
|
+
"""
|
|
393
|
+
Builds the replacement of a database write function.
|
|
394
|
+
|
|
395
|
+
Args:
|
|
396
|
+
target: Name of the function, e.g. "pkg.module:Class.method"
|
|
397
|
+
func: Original function (never called)
|
|
398
|
+
simulate: Function computing the simulated return value from the call arguments, or
|
|
399
|
+
None to return None
|
|
400
|
+
|
|
401
|
+
Returns:
|
|
402
|
+
Replacement function
|
|
403
|
+
"""
|
|
404
|
+
|
|
405
|
+
@functools.wraps(func)
|
|
406
|
+
def wrapper(*args, **kwargs):
|
|
407
|
+
result = simulate(*args, **kwargs) if simulate is not None else None
|
|
408
|
+
self._save_call(target, args, kwargs, result)
|
|
409
|
+
return result
|
|
410
|
+
|
|
411
|
+
return wrapper
|
|
412
|
+
|
|
413
|
+
def _save_call(self, target: str, args: tuple, kwargs: dict, result: Any):
|
|
414
|
+
"""
|
|
415
|
+
Saves a captured call: its DataFrame arguments as CSV files, and its description in
|
|
416
|
+
calls.json (rewritten after every call, so it is complete even if the model crashes).
|
|
417
|
+
|
|
418
|
+
Args:
|
|
419
|
+
target: Name of the function called
|
|
420
|
+
args: Positional arguments of the call
|
|
421
|
+
kwargs: Keyword arguments of the call
|
|
422
|
+
result: Simulated return value
|
|
423
|
+
"""
|
|
424
|
+
self.writes_dir.mkdir(parents=True, exist_ok=True)
|
|
425
|
+
function_name = re.sub(r"[^\w-]", "_", target.split(":")[-1]) # valid on every OS
|
|
426
|
+
stem = f"{len(self.calls) + 1:03d}_{function_name}"
|
|
427
|
+
files: list[str] = []
|
|
428
|
+
|
|
429
|
+
def describe(value: Any) -> Any:
|
|
430
|
+
if not isinstance(value, (pd.DataFrame, pd.Series)):
|
|
431
|
+
return _normalize(value)
|
|
432
|
+
frame = value.to_frame() if isinstance(value, pd.Series) else value
|
|
433
|
+
name = f"{stem}.csv" if not files else f"{stem}_{len(files) + 1}.csv"
|
|
434
|
+
frame.to_csv(self.writes_dir / name, index=False, lineterminator="\n")
|
|
435
|
+
files.append(name)
|
|
436
|
+
return {"file": name}
|
|
437
|
+
|
|
438
|
+
self.calls.append(
|
|
439
|
+
{
|
|
440
|
+
"target": target,
|
|
441
|
+
"args": [describe(value) for value in args],
|
|
442
|
+
"kwargs": {name: describe(value) for name, value in sorted(kwargs.items())},
|
|
443
|
+
"result": _normalize(result),
|
|
444
|
+
}
|
|
445
|
+
)
|
|
446
|
+
calls = json.dumps(self.calls, indent=2, default=str) + "\n"
|
|
447
|
+
(self.writes_dir / CALLS_FILENAME).write_text(calls, encoding="utf-8")
|
|
448
|
+
|
|
449
|
+
|
|
347
450
|
# -------------------------------------------------------------------------------------------------
|
|
348
451
|
# Patching
|
|
349
452
|
# -------------------------------------------------------------------------------------------------
|
|
@@ -490,3 +593,22 @@ def install_db_patches(
|
|
|
490
593
|
replace_everywhere(monkeypatch, owner, attr, lambda f, t=target: _offline_stub(t))
|
|
491
594
|
else:
|
|
492
595
|
replace_everywhere(monkeypatch, owner, attr, recorder.wrap_connection)
|
|
596
|
+
|
|
597
|
+
|
|
598
|
+
def install_write_capture(
|
|
599
|
+
monkeypatch, capture: DbWriteCapture, write_targets: dict[str, Callable | None]
|
|
600
|
+
):
|
|
601
|
+
"""
|
|
602
|
+
Replaces the database write functions with the capture, in every mode.
|
|
603
|
+
|
|
604
|
+
Args:
|
|
605
|
+
monkeypatch: pytest monkeypatch fixture (undoes every patch after the test)
|
|
606
|
+
capture: Write capture of the test case
|
|
607
|
+
write_targets: Functions that write to a database, mapped to the function computing
|
|
608
|
+
their simulated return value (or None)
|
|
609
|
+
"""
|
|
610
|
+
for target, simulate in write_targets.items():
|
|
611
|
+
owner, attr = _resolve_target(target)
|
|
612
|
+
replace_everywhere(
|
|
613
|
+
monkeypatch, owner, attr, lambda f, t=target, s=simulate: capture.wrap(t, f, s)
|
|
614
|
+
)
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{detquantlib-5.10.3 → detquantlib-5.11.0}/detquantlib/tradable_products/tradable_products.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|