detquantlib 5.8.0__tar.gz → 5.9.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.
Files changed (46) hide show
  1. {detquantlib-5.8.0 → detquantlib-5.9.0}/PKG-INFO +34 -1
  2. {detquantlib-5.8.0 → detquantlib-5.9.0}/README.md +33 -0
  3. detquantlib-5.9.0/detquantlib/snapshot_testing/README.md +502 -0
  4. detquantlib-5.9.0/detquantlib/snapshot_testing/__init__.py +24 -0
  5. detquantlib-5.9.0/detquantlib/snapshot_testing/cases.py +195 -0
  6. detquantlib-5.9.0/detquantlib/snapshot_testing/comparison.py +183 -0
  7. detquantlib-5.9.0/detquantlib/snapshot_testing/config.py +90 -0
  8. detquantlib-5.9.0/detquantlib/snapshot_testing/errors.py +11 -0
  9. detquantlib-5.9.0/detquantlib/snapshot_testing/isolation.py +172 -0
  10. detquantlib-5.9.0/detquantlib/snapshot_testing/pytest_plugin.py +195 -0
  11. detquantlib-5.9.0/detquantlib/snapshot_testing/recording.py +492 -0
  12. detquantlib-5.9.0/detquantlib/snapshot_testing/tables.py +366 -0
  13. {detquantlib-5.8.0 → detquantlib-5.9.0}/pyproject.toml +2 -1
  14. {detquantlib-5.8.0 → detquantlib-5.9.0}/LICENSE.txt +0 -0
  15. {detquantlib-5.8.0 → detquantlib-5.9.0}/detquantlib/__init__.py +0 -0
  16. {detquantlib-5.8.0 → detquantlib-5.9.0}/detquantlib/assets/__init__.py +0 -0
  17. {detquantlib-5.8.0 → detquantlib-5.9.0}/detquantlib/assets/battery.py +0 -0
  18. {detquantlib-5.8.0 → detquantlib-5.9.0}/detquantlib/assets/wind_turbine.py +0 -0
  19. {detquantlib-5.8.0 → detquantlib-5.9.0}/detquantlib/converters/__init__.py +0 -0
  20. {detquantlib-5.8.0 → detquantlib-5.9.0}/detquantlib/converters/definitions.py +0 -0
  21. {detquantlib-5.8.0 → detquantlib-5.9.0}/detquantlib/converters/energy.py +0 -0
  22. {detquantlib-5.8.0 → detquantlib-5.9.0}/detquantlib/converters/helpers.py +0 -0
  23. {detquantlib-5.8.0 → detquantlib-5.9.0}/detquantlib/converters/price.py +0 -0
  24. {detquantlib-5.8.0 → detquantlib-5.9.0}/detquantlib/data/__init__.py +0 -0
  25. {detquantlib-5.8.0 → detquantlib-5.9.0}/detquantlib/data/databases/azure_client.py +0 -0
  26. {detquantlib-5.8.0 → detquantlib-5.9.0}/detquantlib/data/databases/detdatabase.py +0 -0
  27. {detquantlib-5.8.0 → detquantlib-5.9.0}/detquantlib/data/databases/helpers.py +0 -0
  28. {detquantlib-5.8.0 → detquantlib-5.9.0}/detquantlib/data/databases/snowflake_client.py +0 -0
  29. {detquantlib-5.8.0 → detquantlib-5.9.0}/detquantlib/data/entsoe/entsoe.py +0 -0
  30. {detquantlib-5.8.0 → detquantlib-5.9.0}/detquantlib/data/sftp/sftp.py +0 -0
  31. {detquantlib-5.8.0 → detquantlib-5.9.0}/detquantlib/data/tennet/tennet_client.py +0 -0
  32. {detquantlib-5.8.0 → detquantlib-5.9.0}/detquantlib/dates/__init__.py +0 -0
  33. {detquantlib-5.8.0 → detquantlib-5.9.0}/detquantlib/dates/dates.py +0 -0
  34. {detquantlib-5.8.0 → detquantlib-5.9.0}/detquantlib/figures/__init__.py +0 -0
  35. {detquantlib-5.8.0 → detquantlib-5.9.0}/detquantlib/figures/plotly_figures.py +0 -0
  36. {detquantlib-5.8.0 → detquantlib-5.9.0}/detquantlib/forecasting/__init__.py +0 -0
  37. {detquantlib-5.8.0 → detquantlib-5.9.0}/detquantlib/forecasting/forecasting.py +0 -0
  38. {detquantlib-5.8.0 → detquantlib-5.9.0}/detquantlib/outputs/__init__.py +0 -0
  39. {detquantlib-5.8.0 → detquantlib-5.9.0}/detquantlib/outputs/outputs_interface.py +0 -0
  40. {detquantlib-5.8.0 → detquantlib-5.9.0}/detquantlib/stats/__init__.py +0 -0
  41. {detquantlib-5.8.0 → detquantlib-5.9.0}/detquantlib/stats/data_analysis.py +0 -0
  42. {detquantlib-5.8.0 → detquantlib-5.9.0}/detquantlib/tradable_products/__init__.py +0 -0
  43. {detquantlib-5.8.0 → detquantlib-5.9.0}/detquantlib/tradable_products/tradable_products.py +0 -0
  44. {detquantlib-5.8.0 → detquantlib-5.9.0}/detquantlib/utils/__init__.py +0 -0
  45. {detquantlib-5.8.0 → detquantlib-5.9.0}/detquantlib/utils/logging.py +0 -0
  46. {detquantlib-5.8.0 → detquantlib-5.9.0}/detquantlib/utils/utils.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: detquantlib
3
- Version: 5.8.0
3
+ Version: 5.9.0
4
4
  Summary: An internal library containing functions and classes that can be used across Quant models.
5
5
  License-File: LICENSE.txt
6
6
  Author: Dynamic Energy Trading
@@ -37,6 +37,7 @@ Description-Content-Type: text/markdown
37
37
  - [DET Quant Library](#det-quant-library)
38
38
  - [Table of Contents](#table-of-contents)
39
39
  - [Overview](#overview)
40
+ - [Snapshot Testing](#snapshot-testing)
40
41
  - [Exposed Symbols](#exposed-symbols)
41
42
  - [List of Exposed Symbols](#list-of-exposed-symbols)
42
43
  - [Configuration](#configuration)
@@ -62,6 +63,17 @@ Description-Content-Type: text/markdown
62
63
  The DET Quant Library is an internal library containing functions and classes that can be used
63
64
  across Quant models.
64
65
 
66
+ ## Snapshot Testing
67
+
68
+ The `detquantlib.snapshot_testing` package provides snapshot (regression) tests for Quant models:
69
+ each test runs a model end to end on a fixed set of inputs, and compares its outputs with a
70
+ reviewed reference copy. Database calls are recorded once, locally, and replayed from disk in CI,
71
+ which has no database access. The `SnowflakeClient` and `AzureClient` classes are supported out of
72
+ the box.
73
+
74
+ See [the snapshot testing guide](detquantlib/snapshot_testing/README.md) for how the framework
75
+ works and how to set it up in a model repository.
76
+
65
77
  ## Exposed Symbols
66
78
 
67
79
  Some of the package's symbols (i.e. functions, classes, modules, etc.) are exposed via
@@ -93,12 +105,18 @@ Classes:
93
105
  - `BatteryState`:
94
106
  - `from detquantlib.assets import BatteryState`
95
107
  - `from detquantlib.assets.battery import BatteryState`
108
+ - `DbCacheMissError`:
109
+ - `from detquantlib.snapshot_testing import DbCacheMissError`
110
+ - `from detquantlib.snapshot_testing.errors import DbCacheMissError`
96
111
  - `DetDatabase`:
97
112
  - `from detquantlib.data import DetDatabase`
98
113
  - `from detquantlib.data.databases.detdatabase import DetDatabase`
99
114
  - `Entsoe`:
100
115
  - `from detquantlib.data import Entsoe`
101
116
  - `from detquantlib.data.entsoe.entsoe import Entsoe`
117
+ - `NetworkAccessBlockedError`:
118
+ - `from detquantlib.snapshot_testing import NetworkAccessBlockedError`
119
+ - `from detquantlib.snapshot_testing.errors import NetworkAccessBlockedError`
102
120
  - `OutputItem`:
103
121
  - `from detquantlib.outputs import OutputItem`
104
122
  - `from detquantlib.outputs.outputs_interface import OutputItem`
@@ -111,6 +129,12 @@ Classes:
111
129
  - `Sftp`:
112
130
  - `from detquantlib.data import Sftp`
113
131
  - `from detquantlib.data.sftp.sftp import Sftp`
132
+ - `SnapshotCase`:
133
+ - `from detquantlib.snapshot_testing import SnapshotCase`
134
+ - `from detquantlib.snapshot_testing.cases import SnapshotCase`
135
+ - `SnapshotConfig`:
136
+ - `from detquantlib.snapshot_testing import SnapshotConfig`
137
+ - `from detquantlib.snapshot_testing.config import SnapshotConfig`
114
138
  - `SnowflakeClient`:
115
139
  - `from detquantlib.data import SnowflakeClient`
116
140
  - `from detquantlib.data.databases.snowflake_client import SnowflakeClient`
@@ -132,12 +156,21 @@ Classes:
132
156
 
133
157
  Functions:
134
158
 
159
+ - `create_case`:
160
+ - `from detquantlib.snapshot_testing import create_case`
161
+ - `from detquantlib.snapshot_testing.cases import create_case`
162
+ - `discover_cases`:
163
+ - `from detquantlib.snapshot_testing import discover_cases`
164
+ - `from detquantlib.snapshot_testing.cases import discover_cases`
135
165
  - `list_to_sql_columns`:
136
166
  - `from detquantlib.data import list_to_sql_columns`
137
167
  - `from detquantlib.data.databases.helpers import list_to_sql_columns`
138
168
  - `list_to_sql_set`:
139
169
  - `from detquantlib.data import list_to_sql_set`
140
170
  - `from detquantlib.data.databases.helpers import list_to_sql_set`
171
+ - `print_cases`:
172
+ - `from detquantlib.snapshot_testing import print_cases`
173
+ - `from detquantlib.snapshot_testing.cases import print_cases`
141
174
 
142
175
  <!-- END EXPOSED SYMBOLS AUTO-GENERATED -->
143
176
 
@@ -7,6 +7,7 @@
7
7
  - [DET Quant Library](#det-quant-library)
8
8
  - [Table of Contents](#table-of-contents)
9
9
  - [Overview](#overview)
10
+ - [Snapshot Testing](#snapshot-testing)
10
11
  - [Exposed Symbols](#exposed-symbols)
11
12
  - [List of Exposed Symbols](#list-of-exposed-symbols)
12
13
  - [Configuration](#configuration)
@@ -32,6 +33,17 @@
32
33
  The DET Quant Library is an internal library containing functions and classes that can be used
33
34
  across Quant models.
34
35
 
36
+ ## Snapshot Testing
37
+
38
+ The `detquantlib.snapshot_testing` package provides snapshot (regression) tests for Quant models:
39
+ each test runs a model end to end on a fixed set of inputs, and compares its outputs with a
40
+ reviewed reference copy. Database calls are recorded once, locally, and replayed from disk in CI,
41
+ which has no database access. The `SnowflakeClient` and `AzureClient` classes are supported out of
42
+ the box.
43
+
44
+ See [the snapshot testing guide](detquantlib/snapshot_testing/README.md) for how the framework
45
+ works and how to set it up in a model repository.
46
+
35
47
  ## Exposed Symbols
36
48
 
37
49
  Some of the package's symbols (i.e. functions, classes, modules, etc.) are exposed via
@@ -63,12 +75,18 @@ Classes:
63
75
  - `BatteryState`:
64
76
  - `from detquantlib.assets import BatteryState`
65
77
  - `from detquantlib.assets.battery import BatteryState`
78
+ - `DbCacheMissError`:
79
+ - `from detquantlib.snapshot_testing import DbCacheMissError`
80
+ - `from detquantlib.snapshot_testing.errors import DbCacheMissError`
66
81
  - `DetDatabase`:
67
82
  - `from detquantlib.data import DetDatabase`
68
83
  - `from detquantlib.data.databases.detdatabase import DetDatabase`
69
84
  - `Entsoe`:
70
85
  - `from detquantlib.data import Entsoe`
71
86
  - `from detquantlib.data.entsoe.entsoe import Entsoe`
87
+ - `NetworkAccessBlockedError`:
88
+ - `from detquantlib.snapshot_testing import NetworkAccessBlockedError`
89
+ - `from detquantlib.snapshot_testing.errors import NetworkAccessBlockedError`
72
90
  - `OutputItem`:
73
91
  - `from detquantlib.outputs import OutputItem`
74
92
  - `from detquantlib.outputs.outputs_interface import OutputItem`
@@ -81,6 +99,12 @@ Classes:
81
99
  - `Sftp`:
82
100
  - `from detquantlib.data import Sftp`
83
101
  - `from detquantlib.data.sftp.sftp import Sftp`
102
+ - `SnapshotCase`:
103
+ - `from detquantlib.snapshot_testing import SnapshotCase`
104
+ - `from detquantlib.snapshot_testing.cases import SnapshotCase`
105
+ - `SnapshotConfig`:
106
+ - `from detquantlib.snapshot_testing import SnapshotConfig`
107
+ - `from detquantlib.snapshot_testing.config import SnapshotConfig`
84
108
  - `SnowflakeClient`:
85
109
  - `from detquantlib.data import SnowflakeClient`
86
110
  - `from detquantlib.data.databases.snowflake_client import SnowflakeClient`
@@ -102,12 +126,21 @@ Classes:
102
126
 
103
127
  Functions:
104
128
 
129
+ - `create_case`:
130
+ - `from detquantlib.snapshot_testing import create_case`
131
+ - `from detquantlib.snapshot_testing.cases import create_case`
132
+ - `discover_cases`:
133
+ - `from detquantlib.snapshot_testing import discover_cases`
134
+ - `from detquantlib.snapshot_testing.cases import discover_cases`
105
135
  - `list_to_sql_columns`:
106
136
  - `from detquantlib.data import list_to_sql_columns`
107
137
  - `from detquantlib.data.databases.helpers import list_to_sql_columns`
108
138
  - `list_to_sql_set`:
109
139
  - `from detquantlib.data import list_to_sql_set`
110
140
  - `from detquantlib.data.databases.helpers import list_to_sql_set`
141
+ - `print_cases`:
142
+ - `from detquantlib.snapshot_testing import print_cases`
143
+ - `from detquantlib.snapshot_testing.cases import print_cases`
111
144
 
112
145
  <!-- END EXPOSED SYMBOLS AUTO-GENERATED -->
113
146
 
@@ -0,0 +1,502 @@
1
+ # Snapshot Testing Framework
2
+
3
+ The `detquantlib.snapshot_testing` package provides snapshot (regression) tests for Quant models.
4
+ Each test runs a model end to end, through its real entry point `run.run(input_dir, output_dir)`,
5
+ on a fixed set of inputs. It then compares every file of the produced outputs folder with a
6
+ reviewed reference copy (the _snapshot_) committed in the model repository.
7
+
8
+ Snapshot tests show whether outputs _changed_, not whether they are _correct_. Any change must
9
+ either be fixed or explicitly accepted, and is reviewed in the pull request diff.
10
+
11
+ Key properties:
12
+
13
+ - **No database access needed in CI.** Database calls are recorded once, locally, with database
14
+ access, and replayed from disk in all other runs.
15
+ - **Diffable recordings.** Recorded tables are stored as CSV + JSON files, so changes to recorded
16
+ data or queries show up as line diffs in pull requests.
17
+ - **Explicit failures.** A changed query, an unrecorded database call or an attempt to reach the
18
+ network fails immediately with a clear error, instead of timing out or returning other data.
19
+ - **Reproducible runs.** The time can be frozen per test case, so models that use the current date
20
+ issue the same queries every day.
21
+ - **Isolated.** Every patch is undone after each test: unit tests in the same pytest process see
22
+ no leftovers.
23
+
24
+ ## Table of Contents
25
+
26
+ - [How It Works](#how-it-works)
27
+ - [Supported Database Clients](#supported-database-clients)
28
+ - [Setting Up a Model Repository](#setting-up-a-model-repository)
29
+ - [Test Cases](#test-cases)
30
+ - [Day-to-Day Usage](#day-to-day-usage)
31
+ - [The DB Cache](#the-db-cache)
32
+ - [Configuration Reference](#configuration-reference)
33
+ - [Troubleshooting](#troubleshooting)
34
+ - [Limitations and Pitfalls](#limitations-and-pitfalls)
35
+ - [Package Structure](#package-structure)
36
+
37
+ ## How It Works
38
+
39
+ A snapshot test runs one test case in one of three modes:
40
+
41
+ | Mode | Command | Databases | Effect |
42
+ |---------|-------------------------------------------|--------------------|--------------------------------------------------------------|
43
+ | replay | `pytest tests/snapshot` | replayed from disk | Compares the outputs with the snapshot (what CI runs) |
44
+ | update | `pytest tests/snapshot --snapshot-update` | replayed from disk | Overwrites the snapshot with the outputs |
45
+ | record | `pytest tests/snapshot --snapshot-record` | live (local only) | Re-records the database calls, and overwrites the snapshot |
46
+
47
+ A run goes through the following steps (see `run_snapshot_case()` in `pytest_plugin.py`):
48
+
49
+ 1. The outputs folder `tests/snapshot/.actual/CaseXX/Outputs` is emptied. It is kept after the run
50
+ for inspection, and uploaded as an artifact by CI when the test fails.
51
+ 2. The database functions are patched:
52
+ - record mode: they run normally, and their results are saved to the case's `db_cache` folder;
53
+ - replay and update modes: they never run; their recorded results are returned instead.
54
+ 3. In replay and update modes, the network is blocked and the local `.env` file is ignored, so
55
+ that local runs behave exactly like CI (which has no credentials).
56
+ 4. The model runs through its real entry point, with the time frozen if the test case says so,
57
+ and with its logging isolated from the rest of the pytest process.
58
+ 5. The outputs are compared with the snapshot (replay mode), or replace it (update and record
59
+ modes).
60
+
61
+ ### Record and Replay of Database Calls
62
+
63
+ The framework intercepts database calls at the level of functions, listed as _targets_ in the
64
+ configuration (e.g. `SnowflakeClient.query`). Every call gets a cache key: a hash of the function
65
+ name and of its meaningful arguments.
66
+
67
+ - SQL text is whitespace-normalized: reformatting a query does not invalidate the recording.
68
+ SQLAlchemy `text()` objects are keyed on their SQL.
69
+ - Parameters, dates and other plain values are part of the key.
70
+ - DataFrames (e.g. the data passed to `write_table()`) are keyed on their shape, column names,
71
+ data types and content.
72
+ - Clients, connections and engines are replaced by a constant placeholder, so that a real client
73
+ (record mode) and a client without credentials (replay mode) produce the same key.
74
+
75
+ In replay mode, a call whose key was not recorded raises a `DbCacheMissError`, showing the call
76
+ and the command to re-record. It never silently returns other data.
77
+
78
+ Functions that only create connections (e.g. `SnowflakeClient.connect`, called when entering a
79
+ `with SnowflakeClient() as client:` block) are _connection targets_: in replay mode they return an
80
+ offline stub instead of connecting. The stub supports `with` and `close()`. Any other use of it
81
+ means that a database call is not recorded, and raises a `NetworkAccessBlockedError`.
82
+
83
+ Every module-level alias of a patched function is patched too (e.g. created by
84
+ `from pkg.db import query`), so a model cannot bypass the recording by importing a function by
85
+ name.
86
+
87
+ ### Network Guard
88
+
89
+ In replay and update modes, any connection to a non-local address fails immediately with a
90
+ `NetworkAccessBlockedError`, including the connect functions of drivers that open sockets in C code
91
+ (`pyodbc`, `pymssql`, `turbodbc`). A forgotten data source therefore fails explicitly, instead of
92
+ timing out in CI.
93
+
94
+ ### Frozen Time
95
+
96
+ A test case can freeze the time (`frozen_time` in `case.toml`), with
97
+ [time-machine](https://pypi.org/project/time-machine/): `datetime.now()`, `date.today()`,
98
+ `time.time()` and `pd.Timestamp.now()` then return that moment during the whole run. This is
99
+ required whenever the model derives dates, and therefore queries, from "today".
100
+
101
+ In record mode, the live database calls run on the real clock, even when the case freezes the
102
+ time: database drivers use the clock, e.g. to sign authentication tokens. Only the model sees the
103
+ frozen time.
104
+
105
+ `datetime.now()` returns the frozen moment in the _local_ time zone. The local time zone of CI
106
+ runners must therefore be the one of the developer machines that recorded the test cases (see
107
+ [CI Workflow](#ci-workflow)).
108
+
109
+ ### Isolation
110
+
111
+ `run.py` calls `logging.shutdown()`, which normally closes every logging handler in the process,
112
+ including those of pytest and of unit tests. During a snapshot run, it only closes the handlers
113
+ created by that run, which are also detached afterwards. The exception hooks replaced by
114
+ `detquantlib.utils.setup_logger` are restored as well. The working directory and the
115
+ `INPUT_DIR` / `OUTPUT_DIR` environment variables are restored after each test.
116
+
117
+ ### Output Comparison
118
+
119
+ Each file of the outputs folder is compared with its snapshot according to its type:
120
+
121
+ | File type | Comparison |
122
+ |-----------------------------|---------------------------------------------------------------------|
123
+ | `.csv`, `.parquet`, `.xlsx` | As DataFrames, with numerical tolerances (`rtol`, `atol`) |
124
+ | `.json` | Recursively, with numerical tolerances |
125
+ | Text (`.txt`, `.sql`, ...) | Line by line (line endings ignored), with a unified diff |
126
+ | Anything else | Byte by byte |
127
+
128
+ Missing and unexpected files are reported too. Run logs (`*.log`, timestamps) and HTML files
129
+ (`*.html`, e.g. Plotly figures with random element identifiers and ~5 MB of embedded plotly.js) are
130
+ not compared by default.
131
+
132
+ ## Supported Database Clients
133
+
134
+ The default configuration covers the database clients of `detquantlib.data`:
135
+
136
+ | Method | `SnowflakeClient` | `AzureClient` |
137
+ |---------------------------------|---------------------------------|---------------------------------|
138
+ | `query()` | recorded | recorded |
139
+ | `load_table()` | through `query()` | through `query()` |
140
+ | `list_tables()` | recorded | through `query()` |
141
+ | `query_batches()` | recorded (batches) | recorded (batches) |
142
+ | `write_table()` | recorded (row count) | recorded (row count) |
143
+ | `execute()` | recorded (row count) | recorded (row count) |
144
+ | `connect()` | offline stub in replay mode | offline stub in replay mode |
145
+
146
+ Notes:
147
+
148
+ - Methods that only call another recorded method are not recorded themselves, otherwise their
149
+ results would be recorded twice.
150
+ - `query_batches()` returns a generator. In record mode, it is consumed entirely during the call,
151
+ and each batch is stored separately. In replay mode, the batches are returned by a generator.
152
+ - Writes (`write_table()`, `execute()`) are _executed_ against the live databases in record mode,
153
+ as in a normal model run. In replay mode, they return the recorded row count. Since the written
154
+ data is part of the cache key, a change in the written data fails the test with a
155
+ `DbCacheMissError`.
156
+
157
+ ## Setting Up a Model Repository
158
+
159
+ The framework expects the standard layout of Quant model repositories: `run.py`, with a function
160
+ `run(input_dir, output_dir)` that calls `load_dotenv()`, at the repository root.
161
+
162
+ ### Files
163
+
164
+ ```
165
+ <repo>/
166
+ ├── run.py, constants.py, src/, pyproject.toml
167
+ ├── .github/workflows/python-ci.yml
168
+ └── tests/
169
+ ├── unit/
170
+ └── snapshot/
171
+ ├── conftest.py
172
+ ├── test_snapshots.py
173
+ └── cases/
174
+ └── Case01/
175
+ ├── case.toml
176
+ ├── Inputs/
177
+ ├── db_cache/ created by --snapshot-record
178
+ └── ExpectedOutputs/ created by --snapshot-record / --snapshot-update
179
+ ```
180
+
181
+ pytest requires two files: command line options can only be registered from a `conftest.py` file
182
+ (or a plugin loaded at startup), and tests are only collected from `test_*.py` files.
183
+
184
+ `tests/snapshot/conftest.py` activates the command line options and the `snapshot_mode` fixture
185
+ for the snapshot tests only:
186
+
187
+ ```python
188
+ """pytest configuration of the snapshot tests (detquantlib.snapshot_testing)."""
189
+
190
+ from detquantlib.snapshot_testing.pytest_plugin import (
191
+ pytest_addoption,
192
+ pytest_configure,
193
+ snapshot_mode,
194
+ )
195
+
196
+ __all__ = ["pytest_addoption", "pytest_configure", "snapshot_mode"]
197
+ ```
198
+
199
+ `tests/snapshot/test_snapshots.py` holds the model-specific settings (`CONFIG`, see
200
+ [Configuration Reference](#configuration-reference)) and defines one test per test case:
201
+
202
+ ```python
203
+ """Snapshot tests: run the model on every test case and compare the outputs with the snapshot."""
204
+
205
+ from pathlib import Path
206
+
207
+ import pytest
208
+ from detquantlib.snapshot_testing import SnapshotConfig, discover_cases
209
+ from detquantlib.snapshot_testing.pytest_plugin import run_snapshot_case
210
+
211
+ import run as model_entry_point # run.py at the project root
212
+
213
+ CONFIG = SnapshotConfig(snapshot_dir=Path(__file__).resolve().parent)
214
+ CASES = discover_cases(CONFIG.cases_dir)
215
+
216
+
217
+ @pytest.mark.snapshot
218
+ @pytest.mark.parametrize("case", CASES, ids=[c.name for c in CASES])
219
+ def test_snapshot(case, snapshot_mode, monkeypatch):
220
+ run_snapshot_case(case, snapshot_mode, monkeypatch, CONFIG, entry_point=model_entry_point)
221
+ ```
222
+
223
+ Code outside the tests that needs the test cases (e.g. Invoke tasks listing or creating them)
224
+ should build the folder from the snapshot tests folder, e.g.
225
+ `SnapshotConfig(snapshot_dir=...).cases_dir`, rather than import `test_snapshots.py`. Importing
226
+ it would also import the model, and imports through the `tests` folder (e.g.
227
+ `from tests.snapshot... import`) break when another package named `tests` is on the Python path
228
+ (e.g. the one of an editable install of detquantlib).
229
+
230
+ ### Dependencies and pytest Settings
231
+
232
+ Add the test dependencies to the development group of the repository:
233
+
234
+ ```cmd
235
+ poetry add --group dev pytest time-machine
236
+ ```
237
+
238
+ In `pyproject.toml`, list the test folders explicitly, so that pytest loads
239
+ `tests/snapshot/conftest.py` (and its command line options) at startup, and add the project root
240
+ to the Python path:
241
+
242
+ ```toml
243
+ [tool.pytest.ini_options]
244
+ testpaths = ["tests/unit", "tests/snapshot"]
245
+ pythonpath = ["."]
246
+ ```
247
+
248
+ In `.gitignore`, ignore the produced outputs and the staging folder of recordings:
249
+
250
+ ```gitignore
251
+ tests/snapshot/.actual/
252
+ tests/snapshot/cases/*/db_cache.staging/
253
+ ```
254
+
255
+ ### CI Workflow
256
+
257
+ The CI workflow runs the snapshot tests without database secrets, in the time zone of the
258
+ developer machines, and uploads the produced outputs when the tests fail:
259
+
260
+ ```yaml
261
+ jobs:
262
+ python_ci:
263
+ runs-on: ubuntu-latest
264
+ env:
265
+ TZ: Europe/Amsterdam # same local time as the machines that record the test cases
266
+ steps:
267
+ # ... checkout, Python, Poetry, dependencies ...
268
+ - name: Run snapshot tests
269
+ id: snapshot_tests
270
+ run: poetry run pytest tests/snapshot -v
271
+
272
+ - name: Upload outputs of snapshot tests on failure
273
+ if: ${{ !cancelled() && steps.snapshot_tests.outcome == 'failure' }}
274
+ uses: actions/upload-artifact@v4
275
+ with:
276
+ name: snapshot-actual-outputs
277
+ path: tests/snapshot/.actual/
278
+ include-hidden-files: true
279
+ if-no-files-found: ignore
280
+ ```
281
+
282
+ `--snapshot-record` is refused when the `CI` environment variable is set.
283
+
284
+ ## Test Cases
285
+
286
+ ### Folder Layout
287
+
288
+ Test cases are folders of `tests/snapshot/cases`, named `Case01`, `Case02`, ... (two or more
289
+ digits), and sorted by number. Each contains:
290
+
291
+ - `case.toml`: the description and settings of the test case;
292
+ - `Inputs/`: the model inputs (settings JSON file, CSV files, ...);
293
+ - `db_cache/`: the recorded database calls;
294
+ - `ExpectedOutputs/`: the snapshot.
295
+
296
+ Folders starting with `.` or `_` are skipped. Any other invalid folder (wrong name, duplicate
297
+ number, missing `case.toml`, unknown key in `case.toml`) raises an error when the tests are
298
+ collected, so that a typo cannot silently drop a test case.
299
+
300
+ ### case.toml
301
+
302
+ ```toml
303
+ # Multi-line text uses triple quotes. Indentation and the line breaks right after the opening
304
+ # and before the closing quotes are removed automatically.
305
+ description = """
306
+ Base scenario: default settings, front-month horizon.
307
+ Second line of the description.
308
+ """
309
+
310
+ # Freezes the time during the run. Give a UTC offset: a time without offset is read as UTC.
311
+ frozen_time = 2026-06-30T08:00:00+02:00
312
+
313
+ # Optional overrides of the defaults of CONFIG in test_snapshots.py
314
+ rtol = 1e-6
315
+ atol = 1e-9
316
+ ignore = ["plots/*.png"]
317
+ ```
318
+
319
+ | Key | Default | Meaning |
320
+ |---------------|------------------|--------------------------------------------------------------------|
321
+ | `description` | `""` | Description, listed by `print_cases()` |
322
+ | `frozen_time` | none (real time) | Moment at which the time is frozen during the run |
323
+ | `rtol` | `config.rtol` | Relative tolerance of numerical comparisons |
324
+ | `atol` | `config.atol` | Absolute tolerance of numerical comparisons |
325
+ | `ignore` | `[]` | Additional output files not compared (glob patterns) |
326
+
327
+ ### Creating a Test Case
328
+
329
+ `create_case(inputs_dir, cases_root)` creates the folder of a new test case, with the lowest free
330
+ case number. It copies the given inputs folder (typically the project's default `Inputs` folder)
331
+ and writes a `case.toml` file to complete, which freezes the time at the moment of creation. Then
332
+ record the new test case:
333
+
334
+ ```cmd
335
+ pytest tests/snapshot --snapshot-record -k Case02
336
+ ```
337
+
338
+ Test cases can also be created manually: create the folder with a `case.toml` file and an `Inputs`
339
+ folder, then record it. Freeze the time _before_ recording if the model uses the current date.
340
+
341
+ `print_cases(cases_root)` lists the test cases and their descriptions:
342
+
343
+ ```
344
+ - Case01:
345
+ Base scenario: default settings, front-month horizon.
346
+ Second line of the description.
347
+ - Case02:
348
+ This is another description
349
+ ```
350
+
351
+ ## Day-to-Day Usage
352
+
353
+ ```cmd
354
+ pytest tests/snapshot # replay: offline, what CI runs
355
+ pytest tests/snapshot -k Case01 # a single test case
356
+ pytest tests/snapshot --snapshot-update # accept the current outputs (offline)
357
+ pytest tests/snapshot --snapshot-record # LOCAL ONLY: re-record from the databases
358
+ pytest tests/snapshot --snapshot-record -k Case01 # re-record a single test case
359
+ ```
360
+
361
+ - **The model outputs changed on purpose** (e.g. a new feature): run `--snapshot-update`, and
362
+ review the changes to `ExpectedOutputs` in the pull request.
363
+ - **The database queries changed**, or the recorded data should be refreshed: run
364
+ `--snapshot-record` locally, with database access. Review the changes to `db_cache` (queries,
365
+ data) and `ExpectedOutputs`.
366
+ - **A test fails in CI**: download the `snapshot-actual-outputs` artifact of the workflow run to
367
+ inspect the produced outputs.
368
+
369
+ ## The DB Cache
370
+
371
+ ```
372
+ db_cache/
373
+ ├── manifest.json cache key -> function, call arguments, file
374
+ ├── 001_SnowflakeClient_query.csv values of the 1st distinct table returned
375
+ ├── 001_SnowflakeClient_query.dtypes.json its column data types
376
+ ├── 002_AzureClient_execute.pkl a result that is not a table (e.g. a row count)
377
+ ├── 003_AzureClient_query_batches_001.csv the batches returned by query_batches()
378
+ └── ...
379
+ ```
380
+
381
+ - Files are numbered in call order, so re-recording a changed query overwrites the same file
382
+ names, and the change shows up as a line diff in the pull request.
383
+ - Identical repeated calls are stored once.
384
+ - `manifest.json` shows each recorded call with its normalized arguments, e.g. the SQL text.
385
+ - Recordings are written to `db_cache.staging` and replace `db_cache` only once the whole run has
386
+ succeeded.
387
+
388
+ ### Table Format
389
+
390
+ Tables are stored as `<name>.csv` (the values) and `<name>.dtypes.json` (the column names and
391
+ data types). The round trip is exact, and verified when recording:
392
+
393
+ - integers, floats (written with `repr`, without rounding), booleans and nullable types;
394
+ - naive and time-zone-aware datetimes: values are written with their UTC offset, e.g.
395
+ `2026-03-29T03:00:00+02:00`, and converted back to their original time zone (IANA name and
396
+ `zoneinfo` / `pytz` library), so that daylight saving time switches are handled correctly;
397
+ - timedeltas, string dtypes, and object columns holding one kind of value (`str`, `int`, `float`,
398
+ `bool`, `Decimal`, `date`, `time`, `datetime`);
399
+ - NULL values (`None`, `NaN`, `NaT`, `NA` are restored as they were);
400
+ - named, datetime and multi-level indexes.
401
+
402
+ Tables that cannot be stored exactly (categorical columns, columns mixing value types, columns
403
+ with both empty strings and NULLs) are stored as pickle files instead, with a warning: replay
404
+ works, but the recording is not diffable. Results that are not tables (e.g. row counts) are
405
+ always stored as pickle files.
406
+
407
+ Do not store recordings with Git LFS: GitHub does not show diffs of LFS files. Keep recordings
408
+ small instead, by querying only the data that a test case needs.
409
+
410
+ ## Configuration Reference
411
+
412
+ `SnapshotConfig` holds the settings of the snapshot tests of a model repository:
413
+
414
+ | Field | Default | Meaning |
415
+ |-------------------------|---------------------------------|-----------------------------------------------|
416
+ | `snapshot_dir` | (required) | Folder of the snapshot tests |
417
+ | `project_root` | grandparent of `snapshot_dir` | Folder in which the model runs |
418
+ | `db_query_targets` | `DEFAULT_DB_QUERY_TARGETS` | Functions recorded and replayed |
419
+ | `db_connection_targets` | `DEFAULT_DB_CONNECTION_TARGETS` | Connection factories (stubbed in replay mode) |
420
+ | `ignore_patterns` | `DEFAULT_IGNORE_PATTERNS` | Output files not compared (glob patterns) |
421
+ | `rtol` | `1e-8` | Relative tolerance of numerical comparisons |
422
+ | `atol` | `1e-10` | Absolute tolerance of numerical comparisons |
423
+
424
+ `cases_dir` (`snapshot_dir / "cases"`) and `actual_dir` (`snapshot_dir / ".actual"`) are derived
425
+ from `snapshot_dir`.
426
+
427
+ ### Adding Database Functions
428
+
429
+ If a model reaches a database without the clients of `detquantlib.data` (e.g. with its own helper
430
+ functions), add those functions to the targets of `CONFIG`, in `tests/snapshot/test_snapshots.py`:
431
+
432
+ ```python
433
+ from detquantlib.snapshot_testing import (
434
+ DEFAULT_DB_CONNECTION_TARGETS,
435
+ DEFAULT_DB_QUERY_TARGETS,
436
+ SnapshotConfig,
437
+ )
438
+
439
+ CONFIG = SnapshotConfig(
440
+ snapshot_dir=Path(__file__).resolve().parent,
441
+ db_query_targets=[*DEFAULT_DB_QUERY_TARGETS, "src.data_access:load_prices"],
442
+ db_connection_targets=[*DEFAULT_DB_CONNECTION_TARGETS, "src.data_access:connect"],
443
+ )
444
+ ```
445
+
446
+ - Targets use the format `"package.module:function"` or `"package.module:Class.method"`.
447
+ - Prefer the highest-level function that both connects and returns data.
448
+ - Do not list a function that only calls another listed function: its results would be recorded
449
+ twice, and the inner recordings would be reported as unused in replay mode.
450
+ - List functions that only create connections as connection targets.
451
+
452
+ ## Troubleshooting
453
+
454
+ | Error or warning | Cause and solution |
455
+ |-----------------------------------------------------|---------------------------------------------------------------------------------------------------------------------------------------------------|
456
+ | `DbCacheMissError: No DB cache found` | The test case was never recorded. Record it locally with `--snapshot-record`. |
457
+ | `DbCacheMissError: No recorded result for ...` | A query, its parameters or the written data changed, or a new database call was added. Re-record the test case. If the call depends on the current date, freeze the time in `case.toml`. |
458
+ | `NetworkAccessBlockedError` | The model reaches the network or a database through a function that is not recorded. Add the function to the targets. |
459
+ | `Snapshot mismatch for case ...` | The outputs changed. Fix the model, or accept the change with `--snapshot-update`. |
460
+ | `No snapshot for case ...` | The test case has a DB cache but no snapshot. Create it with `--snapshot-update`. |
461
+ | `... recorded DB result(s) were not requested` | The model no longer makes some recorded calls: the recording may be stale. Re-record the test case. |
462
+ | `... cannot be stored as CSV + JSON ... pickle` | A recorded table has an unsupported data type (see [Table Format](#table-format)). Replay works, but the recording is not diffable. |
463
+ | `--snapshot-record needs database access ...` | Recording is refused in CI (`CI` environment variable set). |
464
+
465
+ ## Limitations and Pitfalls
466
+
467
+ - **Time zone.** With a frozen time, `datetime.now()` depends on the local time zone. CI must use
468
+ the time zone of the machines that recorded the test cases (`TZ: Europe/Amsterdam`).
469
+ - **Default database of a client.** The cache key does not include the client's configuration,
470
+ which differs between record mode (credentials) and replay mode (none). Identical SQL sent to
471
+ different default databases in one run shares one recording: qualify table names or pass
472
+ `database=...` in that case.
473
+ - **Writes in record mode.** Recording runs the model against the live databases, including its
474
+ writes. Use credentials of a development environment.
475
+ - **Multiprocessing.** Patches do not reach child processes: database calls made in child
476
+ processes are neither recorded nor blocked.
477
+ - **Module-level caches.** Query results cached at module level (e.g. with `functools.cache`)
478
+ survive between test cases in the same pytest process. Clear them between runs.
479
+ - **Binary outputs.** Binary files (e.g. PNG, PDF) are compared byte by byte and often embed
480
+ timestamps. Add them to the ignore patterns if they cause false failures.
481
+ - **Recorded data is committed.** Make sure that the access rights of the repository suit the
482
+ sensitivity of the recorded data.
483
+ - **Randomness.** The framework does not fix random seeds: models must make their results
484
+ reproducible themselves.
485
+ - **Python 3.10.** `case.toml` files are read with `tomllib` (Python 3.11+) or, on Python 3.10,
486
+ with `tomli` (installed with pytest).
487
+
488
+ ## Package Structure
489
+
490
+ | Module | Content |
491
+ |--------------------|------------------------------------------------------------------------------|
492
+ | `config.py` | `SnapshotConfig` and the default targets, ignore patterns and tolerances |
493
+ | `cases.py` | `SnapshotCase`, `discover_cases()`, `print_cases()`, `create_case()` |
494
+ | `recording.py` | Cache keys, `DbRecorder` (record/replay), patching of the targets |
495
+ | `tables.py` | DataFrame <-> CSV + dtypes JSON, with an exact round trip |
496
+ | `isolation.py` | Network guard, frozen time, logging isolation |
497
+ | `comparison.py` | Comparison of the outputs with the snapshot |
498
+ | `pytest_plugin.py` | pytest hooks, `snapshot_mode` fixture, `run_snapshot_case()` (needs pytest) |
499
+ | `errors.py` | `DbCacheMissError`, `NetworkAccessBlockedError` |
500
+
501
+ The package can be imported without pytest: only `pytest_plugin.py` needs it, and is not imported
502
+ by `detquantlib.snapshot_testing`. The tests of the framework are in `tests/snapshot_testing`.