detquantlib 5.10.2__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.
Files changed (46) hide show
  1. {detquantlib-5.10.2 → detquantlib-5.11.0}/PKG-INFO +1 -1
  2. {detquantlib-5.10.2 → detquantlib-5.11.0}/detquantlib/data/databases/azure_client.py +67 -5
  3. {detquantlib-5.10.2 → detquantlib-5.11.0}/detquantlib/snapshot_testing/README.md +84 -42
  4. {detquantlib-5.10.2 → detquantlib-5.11.0}/detquantlib/snapshot_testing/__init__.py +2 -0
  5. {detquantlib-5.10.2 → detquantlib-5.11.0}/detquantlib/snapshot_testing/config.py +42 -6
  6. {detquantlib-5.10.2 → detquantlib-5.11.0}/detquantlib/snapshot_testing/pytest_plugin.py +13 -2
  7. {detquantlib-5.10.2 → detquantlib-5.11.0}/detquantlib/snapshot_testing/recording.py +130 -8
  8. {detquantlib-5.10.2 → detquantlib-5.11.0}/pyproject.toml +1 -1
  9. {detquantlib-5.10.2 → detquantlib-5.11.0}/LICENSE.txt +0 -0
  10. {detquantlib-5.10.2 → detquantlib-5.11.0}/README.md +0 -0
  11. {detquantlib-5.10.2 → detquantlib-5.11.0}/detquantlib/__init__.py +0 -0
  12. {detquantlib-5.10.2 → detquantlib-5.11.0}/detquantlib/assets/__init__.py +0 -0
  13. {detquantlib-5.10.2 → detquantlib-5.11.0}/detquantlib/assets/battery.py +0 -0
  14. {detquantlib-5.10.2 → detquantlib-5.11.0}/detquantlib/assets/wind_turbine.py +0 -0
  15. {detquantlib-5.10.2 → detquantlib-5.11.0}/detquantlib/converters/__init__.py +0 -0
  16. {detquantlib-5.10.2 → detquantlib-5.11.0}/detquantlib/converters/definitions.py +0 -0
  17. {detquantlib-5.10.2 → detquantlib-5.11.0}/detquantlib/converters/energy.py +0 -0
  18. {detquantlib-5.10.2 → detquantlib-5.11.0}/detquantlib/converters/helpers.py +0 -0
  19. {detquantlib-5.10.2 → detquantlib-5.11.0}/detquantlib/converters/price.py +0 -0
  20. {detquantlib-5.10.2 → detquantlib-5.11.0}/detquantlib/data/__init__.py +0 -0
  21. {detquantlib-5.10.2 → detquantlib-5.11.0}/detquantlib/data/databases/detdatabase.py +0 -0
  22. {detquantlib-5.10.2 → detquantlib-5.11.0}/detquantlib/data/databases/helpers.py +0 -0
  23. {detquantlib-5.10.2 → detquantlib-5.11.0}/detquantlib/data/databases/snowflake_client.py +0 -0
  24. {detquantlib-5.10.2 → detquantlib-5.11.0}/detquantlib/data/entsoe/entsoe.py +0 -0
  25. {detquantlib-5.10.2 → detquantlib-5.11.0}/detquantlib/data/sftp/sftp.py +0 -0
  26. {detquantlib-5.10.2 → detquantlib-5.11.0}/detquantlib/data/tennet/tennet_client.py +0 -0
  27. {detquantlib-5.10.2 → detquantlib-5.11.0}/detquantlib/dates/__init__.py +0 -0
  28. {detquantlib-5.10.2 → detquantlib-5.11.0}/detquantlib/dates/dates.py +0 -0
  29. {detquantlib-5.10.2 → detquantlib-5.11.0}/detquantlib/figures/__init__.py +0 -0
  30. {detquantlib-5.10.2 → detquantlib-5.11.0}/detquantlib/figures/plotly_figures.py +0 -0
  31. {detquantlib-5.10.2 → detquantlib-5.11.0}/detquantlib/forecasting/__init__.py +0 -0
  32. {detquantlib-5.10.2 → detquantlib-5.11.0}/detquantlib/forecasting/forecasting.py +0 -0
  33. {detquantlib-5.10.2 → detquantlib-5.11.0}/detquantlib/outputs/__init__.py +0 -0
  34. {detquantlib-5.10.2 → detquantlib-5.11.0}/detquantlib/outputs/outputs_interface.py +0 -0
  35. {detquantlib-5.10.2 → detquantlib-5.11.0}/detquantlib/snapshot_testing/cases.py +0 -0
  36. {detquantlib-5.10.2 → detquantlib-5.11.0}/detquantlib/snapshot_testing/comparison.py +0 -0
  37. {detquantlib-5.10.2 → detquantlib-5.11.0}/detquantlib/snapshot_testing/errors.py +0 -0
  38. {detquantlib-5.10.2 → detquantlib-5.11.0}/detquantlib/snapshot_testing/isolation.py +0 -0
  39. {detquantlib-5.10.2 → detquantlib-5.11.0}/detquantlib/snapshot_testing/tables.py +0 -0
  40. {detquantlib-5.10.2 → detquantlib-5.11.0}/detquantlib/stats/__init__.py +0 -0
  41. {detquantlib-5.10.2 → detquantlib-5.11.0}/detquantlib/stats/data_analysis.py +0 -0
  42. {detquantlib-5.10.2 → detquantlib-5.11.0}/detquantlib/tradable_products/__init__.py +0 -0
  43. {detquantlib-5.10.2 → detquantlib-5.11.0}/detquantlib/tradable_products/tradable_products.py +0 -0
  44. {detquantlib-5.10.2 → detquantlib-5.11.0}/detquantlib/utils/__init__.py +0 -0
  45. {detquantlib-5.10.2 → detquantlib-5.11.0}/detquantlib/utils/logging.py +0 -0
  46. {detquantlib-5.10.2 → detquantlib-5.11.0}/detquantlib/utils/utils.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: detquantlib
3
- Version: 5.10.2
3
+ Version: 5.11.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
@@ -11,6 +11,7 @@ import logging
11
11
  import os
12
12
  import re
13
13
  from dataclasses import dataclass, field
14
+ from datetime import datetime, timezone
14
15
  from types import TracebackType
15
16
  from typing import Any, Iterator, Optional, Sequence
16
17
 
@@ -18,6 +19,7 @@ from typing import Any, Iterator, Optional, Sequence
18
19
  import pandas as pd
19
20
  import pyodbc
20
21
  from sqlalchemy import Engine, create_engine, inspect, text
22
+ from sqlalchemy.dialects.mssql import DATETIMEOFFSET
21
23
  from sqlalchemy.exc import SQLAlchemyError
22
24
 
23
25
  logger = logging.getLogger(__name__)
@@ -107,6 +109,16 @@ class AzureClient:
107
109
 
108
110
  Query parameters use SQLAlchemy's named placeholder style, e.g. ``:status``.
109
111
 
112
+ Timezone-aware datetimes (in parameters and in DataFrames written with ``write_table()``) are
113
+ converted to UTC before being sent, because the ODBC driver would otherwise silently drop
114
+ their timezone. As a result, ``DATETIMEOFFSET`` columns are compared with, and store, the
115
+ correct point in time.
116
+ WARNING: ``DATETIME``/``DATETIME2`` database columns are assumed to hold UTC times. If they
117
+ instead hold times in a local timezone, first set timezone-aware datetimes (in parameters and
118
+ in DataFrames) to the same local timezone and then convert them to timezone-naive datetimes,
119
+ before passing them to the AzureClient. This ensures that the AzureClient will not convert
120
+ them to UTC.
121
+
110
122
  Attributes:
111
123
  config: The connection settings used by this client.
112
124
 
@@ -318,7 +330,9 @@ class AzureClient:
318
330
 
319
331
  Args:
320
332
  sql: The SQL statement to execute.
321
- params: Values bound to the named placeholders in ``sql``.
333
+ params: Values bound to the named placeholders in ``sql``. Timezone-aware datetimes
334
+ (including pandas Timestamps) are converted to UTC, and ``NaT`` to ``None``; see
335
+ ``_prepare_params()``.
322
336
  database: Database to run the query in. If ``None``, ``config.database`` is used.
323
337
 
324
338
  Returns:
@@ -331,7 +345,7 @@ class AzureClient:
331
345
  engine = self.connect(database)
332
346
  try:
333
347
  with engine.connect() as conn:
334
- result = conn.execute(text(sql), params or {})
348
+ result = conn.execute(text(sql), self._prepare_params(params))
335
349
  cols = list(result.keys())
336
350
  df = pd.DataFrame.from_records(result.fetchall(), columns=cols, coerce_float=True)
337
351
  except SQLAlchemyError as e:
@@ -364,7 +378,7 @@ class AzureClient:
364
378
  """
365
379
  engine = self.connect(database)
366
380
  with engine.connect() as conn:
367
- result = conn.execute(text(sql), params or {})
381
+ result = conn.execute(text(sql), self._prepare_params(params))
368
382
  cols = list(result.keys())
369
383
  while rows := result.fetchmany(batch_size):
370
384
  yield pd.DataFrame.from_records(rows, columns=cols, coerce_float=True)
@@ -457,9 +471,14 @@ class AzureClient:
457
471
  is much faster than row-by-row inserts. The whole write runs in a single transaction: if
458
472
  any part fails, the table is left unchanged.
459
473
 
474
+ Timezone-aware datetime columns are written as UTC times. A ``DATETIMEOFFSET`` column
475
+ stores them with a ``+00:00`` offset (i.e. the same point in time), and a ``DATETIME2``
476
+ column stores the UTC time. Tables created by this method use ``DATETIMEOFFSET`` for
477
+ these columns.
478
+
460
479
  Args:
461
480
  df: The data to write. The index is not written. Unless the table is created by this
462
- call, column names must match the table's columns.
481
+ call, column names must match the table's columns. ``df`` itself is not modified.
463
482
  table: Table name, optionally qualified, e.g. ``"SCHEMA.TABLE"`` or
464
483
  ``"DB.SCHEMA.TABLE"``. A database part selects the database to write to. Parts may
465
484
  be wrapped in square brackets. If no schema is given, the user's default schema
@@ -494,6 +513,14 @@ class AzureClient:
494
513
  schema, table_name = ([None] + parts)[-2:]
495
514
  table_sql = ".".join(self._quote_identifier(p) for p in parts)
496
515
 
516
+ # The ODBC driver drops timezones, so send UTC times. Without an explicit type, pandas
517
+ # would create these columns as TIMESTAMP, which is a row version in SQL Server.
518
+ tz_cols = [c for c in df.columns if isinstance(df[c].dtype, pd.DatetimeTZDtype)]
519
+ if tz_cols:
520
+ df = df.copy()
521
+ for c in tz_cols:
522
+ df[c] = df[c].dt.tz_convert("UTC").dt.tz_localize(None)
523
+
497
524
  engine = self.connect(database)
498
525
  # pandas' to_sql always creates missing tables, so the table must be checked beforehand
499
526
  if not auto_create_table and not inspect(engine).has_table(table_name, schema=schema):
@@ -511,6 +538,7 @@ class AzureClient:
511
538
  if_exists="replace" if overwrite and auto_create_table else "append",
512
539
  index=False,
513
540
  chunksize=chunk_size,
541
+ dtype={c: DATETIMEOFFSET() for c in tz_cols} or None,
514
542
  )
515
543
  except SQLAlchemyError as e:
516
544
  logger.error("Write to %s failed: %s", table, e)
@@ -547,7 +575,7 @@ class AzureClient:
547
575
  engine = self.connect(database)
548
576
  try:
549
577
  with engine.begin() as conn:
550
- rowcount = conn.execute(text(sql), params or {}).rowcount
578
+ rowcount = conn.execute(text(sql), self._prepare_params(params)).rowcount
551
579
  except SQLAlchemyError as e:
552
580
  logger.error("Statement failed: %s", e)
553
581
  raise
@@ -556,6 +584,40 @@ class AzureClient:
556
584
  logger.info("Statement affected %s rows", rowcount)
557
585
  return rowcount
558
586
 
587
+ # ---------------------------------------------------------------------------------------------
588
+ # Parameter handling
589
+ # ---------------------------------------------------------------------------------------------
590
+ @staticmethod
591
+ def _prepare_params(params: Optional[dict[str, Any]]) -> dict[str, Any]:
592
+ """
593
+ Converts parameter values so the ODBC driver binds them correctly.
594
+
595
+ Timezone-aware datetimes, including pandas Timestamps, are converted to naive UTC
596
+ datetimes, because the driver would otherwise drop their timezone and send the local
597
+ clock time. SQL Server interprets naive values as UTC (``+00:00``) when comparing them
598
+ with ``DATETIMEOFFSET`` columns, so the correct point in time is used. ``NaT`` becomes
599
+ ``None`` and pandas Timestamps become Python datetimes; Python datetimes only have
600
+ microsecond precision, so nanoseconds are dropped (pandas warns when this happens). Other
601
+ values are passed through unchanged.
602
+
603
+ Args:
604
+ params: Values bound to the placeholders of a query, as in ``query()``.
605
+
606
+ Returns:
607
+ The converted values, as a new dict.
608
+ """
609
+ converted = {}
610
+ for key, value in (params or {}).items():
611
+ if value is pd.NaT:
612
+ value = None
613
+ elif isinstance(value, datetime):
614
+ if value.tzinfo is not None:
615
+ value = value.astimezone(timezone.utc).replace(tzinfo=None)
616
+ if isinstance(value, pd.Timestamp):
617
+ value = value.to_pydatetime()
618
+ converted[key] = value
619
+ return converted
620
+
559
621
  # ---------------------------------------------------------------------------------------------
560
622
  # Identifier handling
561
623
  # ---------------------------------------------------------------------------------------------
@@ -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 calls are recorded once, locally, with 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 only) | Re-records the database calls, and overwrites the snapshot |
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 `db_cache` folder;
53
- - replay and update modes: they never run; their recorded results are returned instead.
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 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.
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
- - DataFrames (e.g. the data passed to `write_table()`) are keyed on their shape, column names,
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
- 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.
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
- 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.
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()` | recorded (row count) | recorded (row count) |
149
- | `execute()` | recorded (row count) | recorded (row count) |
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 _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`.
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
- 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).
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@v4
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
- ├── 002_AzureClient_execute.pkl a result that is not a table (e.g. a row count)
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. row counts) are
414
- always stored as pickle files.
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` | Functions recorded and replayed |
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, 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`. |
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 in record mode.** Recording runs the model against the live databases, including its
483
- writes. Use credentials of a development environment.
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` (record/replay), patching of the targets |
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
- """Converts the folders to absolute paths and sets the default project root."""
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 DbRecorder, install_db_patches
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
- All patches are undone by `monkeypatch` after the test.
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 calls are recorded once (locally, with 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 row count)
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, so re-recording a changed query overwrites the same file
140
- names and the change shows up as a normal line-by-line diff in the pull request.
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, sort_keys=True, default=str) + "\n"
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/parameters or the data written changed, or a new database call "
316
- f"was added. Re-record locally with: {self._record_hint}"
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
+ )
@@ -1,6 +1,6 @@
1
1
  [tool.poetry]
2
2
  name = "detquantlib"
3
- version = "5.10.2"
3
+ version = "5.11.0"
4
4
  description = "An internal library containing functions and classes that can be used across Quant models."
5
5
  authors = ["Dynamic Energy Trading"]
6
6
  readme = "README.md"
File without changes
File without changes