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