detquantlib 5.3.1__tar.gz → 5.5.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 (35) hide show
  1. {detquantlib-5.3.1 → detquantlib-5.5.0}/PKG-INFO +3 -1
  2. detquantlib-5.5.0/detquantlib/data/__init__.py +15 -0
  3. detquantlib-5.5.0/detquantlib/data/databases/snowflake_client.py +515 -0
  4. {detquantlib-5.3.1 → detquantlib-5.5.0}/pyproject.toml +3 -1
  5. detquantlib-5.3.1/detquantlib/data/__init__.py +0 -6
  6. {detquantlib-5.3.1 → detquantlib-5.5.0}/LICENSE.txt +0 -0
  7. {detquantlib-5.3.1 → detquantlib-5.5.0}/README.md +0 -0
  8. {detquantlib-5.3.1 → detquantlib-5.5.0}/detquantlib/__init__.py +0 -0
  9. {detquantlib-5.3.1 → detquantlib-5.5.0}/detquantlib/assets/__init__.py +0 -0
  10. {detquantlib-5.3.1 → detquantlib-5.5.0}/detquantlib/assets/battery.py +0 -0
  11. {detquantlib-5.3.1 → detquantlib-5.5.0}/detquantlib/assets/wind_turbine.py +0 -0
  12. {detquantlib-5.3.1 → detquantlib-5.5.0}/detquantlib/converters/__init__.py +0 -0
  13. {detquantlib-5.3.1 → detquantlib-5.5.0}/detquantlib/converters/definitions.py +0 -0
  14. {detquantlib-5.3.1 → detquantlib-5.5.0}/detquantlib/converters/energy.py +0 -0
  15. {detquantlib-5.3.1 → detquantlib-5.5.0}/detquantlib/converters/helpers.py +0 -0
  16. {detquantlib-5.3.1 → detquantlib-5.5.0}/detquantlib/converters/price.py +0 -0
  17. {detquantlib-5.3.1 → detquantlib-5.5.0}/detquantlib/data/databases/detdatabase.py +0 -0
  18. {detquantlib-5.3.1 → detquantlib-5.5.0}/detquantlib/data/databases/helpers.py +0 -0
  19. {detquantlib-5.3.1 → detquantlib-5.5.0}/detquantlib/data/entsoe/entsoe.py +0 -0
  20. {detquantlib-5.3.1 → detquantlib-5.5.0}/detquantlib/data/sftp/sftp.py +0 -0
  21. {detquantlib-5.3.1 → detquantlib-5.5.0}/detquantlib/dates/__init__.py +0 -0
  22. {detquantlib-5.3.1 → detquantlib-5.5.0}/detquantlib/dates/dates.py +0 -0
  23. {detquantlib-5.3.1 → detquantlib-5.5.0}/detquantlib/figures/__init__.py +0 -0
  24. {detquantlib-5.3.1 → detquantlib-5.5.0}/detquantlib/figures/plotly_figures.py +0 -0
  25. {detquantlib-5.3.1 → detquantlib-5.5.0}/detquantlib/forecasting/__init__.py +0 -0
  26. {detquantlib-5.3.1 → detquantlib-5.5.0}/detquantlib/forecasting/forecasting.py +0 -0
  27. {detquantlib-5.3.1 → detquantlib-5.5.0}/detquantlib/outputs/__init__.py +0 -0
  28. {detquantlib-5.3.1 → detquantlib-5.5.0}/detquantlib/outputs/outputs_interface.py +0 -0
  29. {detquantlib-5.3.1 → detquantlib-5.5.0}/detquantlib/stats/__init__.py +0 -0
  30. {detquantlib-5.3.1 → detquantlib-5.5.0}/detquantlib/stats/data_analysis.py +0 -0
  31. {detquantlib-5.3.1 → detquantlib-5.5.0}/detquantlib/tradable_products/__init__.py +0 -0
  32. {detquantlib-5.3.1 → detquantlib-5.5.0}/detquantlib/tradable_products/tradable_products.py +0 -0
  33. {detquantlib-5.3.1 → detquantlib-5.5.0}/detquantlib/utils/__init__.py +0 -0
  34. {detquantlib-5.3.1 → detquantlib-5.5.0}/detquantlib/utils/logging.py +0 -0
  35. {detquantlib-5.3.1 → detquantlib-5.5.0}/detquantlib/utils/utils.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: detquantlib
3
- Version: 5.3.1
3
+ Version: 5.5.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
@@ -13,6 +13,7 @@ Classifier: Programming Language :: Python :: 3.13
13
13
  Classifier: Programming Language :: Python :: 3.14
14
14
  Classifier: Programming Language :: Python :: 3.15
15
15
  Requires-Dist: colorlog (>=6.10.1,<7.0.0)
16
+ Requires-Dist: cryptography (>=50.0.2,<51.0.0)
16
17
  Requires-Dist: numpy (>=2.2.6,<3.0.0)
17
18
  Requires-Dist: pandas (>=2.3.3,<3.0.0)
18
19
  Requires-Dist: paramiko (>=4.0.0,<5.0.0)
@@ -22,6 +23,7 @@ Requires-Dist: pyodbc (>=5.2.0,<6.0.0)
22
23
  Requires-Dist: python-dotenv (>=1.1.0,<2.0.0)
23
24
  Requires-Dist: redis (>=7.4.0,<8.0.0)
24
25
  Requires-Dist: scipy (>=1.15.2,<2.0.0)
26
+ Requires-Dist: snowflake-connector-python[pandas] (>=4.8.0,<5.0.0)
25
27
  Requires-Dist: sqlalchemy (>=2.0.43,<3.0.0)
26
28
  Description-Content-Type: text/markdown
27
29
 
@@ -0,0 +1,15 @@
1
+ from .databases.detdatabase import DetDatabase
2
+ from .databases.helpers import *
3
+ from .databases.snowflake_client import SnowflakeClient, SnowflakeConfig
4
+ from .entsoe.entsoe import Entsoe
5
+ from .sftp.sftp import Sftp
6
+
7
+ __all__ = [
8
+ "DetDatabase",
9
+ "Entsoe",
10
+ "Sftp",
11
+ "list_to_sql_set",
12
+ "list_to_sql_columns",
13
+ "SnowflakeClient",
14
+ "SnowflakeConfig",
15
+ ]
@@ -0,0 +1,515 @@
1
+ """
2
+ Client for working with a Snowflake database from Python.
3
+
4
+ This module provides a configuration dataclass and a client class that wrap
5
+ ``snowflake-connector-python`` with a small, safe API for interacting with Snowflake. Data is
6
+ exchanged as pandas DataFrames.
7
+ """
8
+
9
+ # Python built-in packages
10
+ import logging
11
+ import os
12
+ import re
13
+ from dataclasses import dataclass, field
14
+ from types import TracebackType
15
+ from typing import Any, Iterator, Optional, Sequence, Union
16
+
17
+ # Third-party packages
18
+ import pandas as pd
19
+ import snowflake.connector
20
+ from snowflake.connector import SnowflakeConnection
21
+ from snowflake.connector.errors import Error as SnowflakeError
22
+ from snowflake.connector.pandas_tools import write_pandas
23
+
24
+ logger = logging.getLogger(__name__)
25
+
26
+ _IDENTIFIER_RE = re.compile(r"^[A-Za-z_][A-Za-z0-9_$]*$")
27
+
28
+
29
+ @dataclass
30
+ class SnowflakeConfig:
31
+ """
32
+ Connection settings for a Snowflake account.
33
+
34
+ Exactly one authentication method is used, chosen in this order of precedence:
35
+ - ``private_key_path`` (i.e. key-pair)
36
+ - ``authenticator`` (e.g. SSO)
37
+ - ``password``
38
+
39
+ Attributes:
40
+ account: Snowflake account identifier, e.g. ``"xy12345.eu-west-1"``.
41
+ user: Login name of the Snowflake user.
42
+ password: Password for password authentication.
43
+ warehouse: Default virtual warehouse for the session.
44
+ database: Default database for the session.
45
+ schema: Default schema for the session.
46
+ role: Role to assume for the session.
47
+ authenticator: Alternative authenticator, e.g. ``"externalbrowser"`` for browser-based SSO.
48
+ private_key_path: Path to a PEM-encoded private key for key-pair authentication.
49
+ private_key_passphrase: Passphrase for an encrypted private key.
50
+ extra: Additional keyword arguments passed verbatim to ``snowflake.connector.connect``.
51
+ """
52
+
53
+ account: Optional[str] = None
54
+ user: Optional[str] = None
55
+ password: Optional[str] = None
56
+ warehouse: Optional[str] = None
57
+ database: Optional[str] = None
58
+ schema: Optional[str] = None
59
+ role: Optional[str] = None
60
+ authenticator: Optional[str] = None
61
+ private_key_path: Optional[str] = None
62
+ private_key_passphrase: Optional[str] = None
63
+ extra: dict[str, Any] = field(default_factory=dict)
64
+
65
+ @classmethod
66
+ def from_env(cls, prefix: str = "SNOWFLAKE_") -> "SnowflakeConfig":
67
+ """
68
+ Builds a config from environment variables.
69
+
70
+ Reads variables named ``<prefix><FIELD>``, e.g. ``SNOWFLAKE_ACCOUNT``, ``SNOWFLAKE_USER``,
71
+ ``SNOWFLAKE_PASSWORD``, ``SNOWFLAKE_WAREHOUSE``, ``SNOWFLAKE_DATABASE``,
72
+ ``SNOWFLAKE_SCHEMA``, ``SNOWFLAKE_ROLE``, ``SNOWFLAKE_AUTHENTICATOR``,
73
+ ``SNOWFLAKE_PRIVATE_KEY_PATH`` and ``SNOWFLAKE_PRIVATE_KEY_PASSPHRASE``. Unset variables
74
+ become ``None``.
75
+
76
+ Args:
77
+ prefix: Prefix prepended to each variable name.
78
+
79
+ Returns:
80
+ A ``SnowflakeConfig`` populated from the environment.
81
+ """
82
+ g = lambda name: os.getenv(f"{prefix}{name}")
83
+ return cls(
84
+ account=g("ACCOUNT"),
85
+ user=g("USER"),
86
+ password=g("PASSWORD"),
87
+ warehouse=g("WAREHOUSE"),
88
+ database=g("DATABASE"),
89
+ schema=g("SCHEMA"),
90
+ role=g("ROLE"),
91
+ authenticator=g("AUTHENTICATOR"),
92
+ private_key_path=g("PRIVATE_KEY_PATH"),
93
+ private_key_passphrase=g("PRIVATE_KEY_PASSPHRASE"),
94
+ )
95
+
96
+
97
+ class SnowflakeClient:
98
+ """
99
+ Client for interacting with a Snowflake database.
100
+
101
+ Wraps a single Snowflake connection and exposes methods for running SQL and exchanging data as
102
+ pandas DataFrames. The connection is opened lazily on first use and reused for subsequent
103
+ operations. The class can be used as a context manager so the connection is closed
104
+ automatically.
105
+
106
+ Attributes:
107
+ config: The connection settings used by this client.
108
+
109
+ Examples:
110
+ Example 1: Loading a table
111
+ >>> with SnowflakeClient() as client:
112
+ ... customers = client.load_table("CUSTOMERS", columns=["ID", "NAME"], limit=100)
113
+
114
+ Example 2: Standard query method
115
+ >>> with SnowflakeClient() as client:
116
+ ... df = client.query("SELECT * FROM orders WHERE status = %s", ("SHIPPED",))
117
+
118
+ Example 3: Batch query method to stream a large table in chunks
119
+ >>> with SnowflakeClient(SnowflakeConfig.from_env()) as client:
120
+ ... for chunk in client.query_batches("SELECT * FROM BIG_TABLE"):
121
+ ... process(chunk)
122
+
123
+ Example 4: Writing a DataFrame to a table, creating the table if needed
124
+ >>> with SnowflakeClient() as client:
125
+ ... client.write_table(df, "DB.SCHEMA.ORDERS", auto_create_table=True)
126
+
127
+ Example 5: Running a statement that does not return data
128
+ >>> with SnowflakeClient() as client:
129
+ ... client.execute("DELETE FROM ORDERS WHERE STATUS = %s", ("CANCELLED",))
130
+ """
131
+
132
+ def __init__(self, config: Optional[SnowflakeConfig] = None) -> None:
133
+ """
134
+ Initializes the client without opening a connection.
135
+
136
+ Args:
137
+ config: Connection settings. If ``None``, settings are read from environment variables
138
+ via ``SnowflakeConfig.from_env()``.
139
+ """
140
+ self.config = config or SnowflakeConfig.from_env()
141
+ self._conn: Optional[SnowflakeConnection] = None
142
+
143
+ # ---------------------------------------------------------------------------------------------
144
+ # Connection management
145
+ # ---------------------------------------------------------------------------------------------
146
+ def _connection_params(self) -> dict[str, Any]:
147
+ """
148
+ Builds the keyword arguments for ``snowflake.connector.connect``.
149
+
150
+ Selects a single authentication method (key-pair, authenticator or password, in that order)
151
+ and drops any parameters that are ``None``.
152
+
153
+ Returns:
154
+ A dict of connection parameters.
155
+
156
+ Raises:
157
+ ValueError: If ``account`` or ``user`` is missing, or if no credentials are configured.
158
+ """
159
+ c = self.config
160
+ if not c.account or not c.user:
161
+ raise ValueError("Snowflake 'account' and 'user' are required.")
162
+
163
+ params: dict[str, Any] = {
164
+ "account": c.account,
165
+ "user": c.user,
166
+ "warehouse": c.warehouse,
167
+ "database": c.database,
168
+ "schema": c.schema,
169
+ "role": c.role,
170
+ **c.extra,
171
+ }
172
+
173
+ if c.private_key_path:
174
+ params["private_key"] = self._load_private_key(
175
+ c.private_key_path, c.private_key_passphrase
176
+ )
177
+ elif c.authenticator:
178
+ params["authenticator"] = c.authenticator
179
+ elif c.password:
180
+ params["password"] = c.password
181
+ else:
182
+ raise ValueError(
183
+ "No credentials provided: set password, private_key_path, or authenticator."
184
+ )
185
+
186
+ return {k: v for k, v in params.items() if v is not None}
187
+
188
+ @staticmethod
189
+ def _load_private_key(path: str, passphrase: Optional[str]) -> bytes:
190
+ """
191
+ Loads a PEM private key and converts it to unencrypted DER bytes.
192
+
193
+ Args:
194
+ path: Path to the PEM-encoded private key file.
195
+ passphrase: Passphrase for an encrypted key, or ``None`` if the key is not encrypted.
196
+
197
+ Returns:
198
+ The private key as PKCS#8 DER bytes, as expected by the Snowflake connector.
199
+ """
200
+ from cryptography.hazmat.primitives import serialization
201
+
202
+ with open(path, "rb") as f:
203
+ key = serialization.load_pem_private_key(
204
+ f.read(),
205
+ password=passphrase.encode() if passphrase else None,
206
+ )
207
+ return key.private_bytes(
208
+ encoding=serialization.Encoding.DER,
209
+ format=serialization.PrivateFormat.PKCS8,
210
+ encryption_algorithm=serialization.NoEncryption(),
211
+ )
212
+
213
+ def connect(self) -> SnowflakeConnection:
214
+ """
215
+ Opens the connection if it is not already open.
216
+
217
+ Returns:
218
+ The active Snowflake connection.
219
+ """
220
+ if self._conn is None or self._conn.is_closed():
221
+ logger.info("Connecting to Snowflake account %s", self.config.account)
222
+ self._conn = snowflake.connector.connect(**self._connection_params())
223
+ return self._conn
224
+
225
+ def close(self) -> None:
226
+ """
227
+ Closes the connection if it is open.
228
+
229
+ Safe to call multiple times, or when no connection was ever opened.
230
+ """
231
+ if self._conn is not None and not self._conn.is_closed():
232
+ self._conn.close()
233
+ logger.info("Snowflake connection closed")
234
+ self._conn = None
235
+
236
+ def __enter__(self) -> "SnowflakeClient":
237
+ """
238
+ Opens the connection when entering a ``with`` block.
239
+
240
+ Returns:
241
+ This client instance.
242
+ """
243
+ self.connect()
244
+ return self
245
+
246
+ def __exit__(
247
+ self,
248
+ exc_type: Optional[type[BaseException]],
249
+ exc: Optional[BaseException],
250
+ tb: Optional[TracebackType],
251
+ ) -> None:
252
+ """
253
+ Closes the connection when leaving a ``with`` block.
254
+
255
+ Exceptions raised inside the block are not suppressed.
256
+
257
+ Args:
258
+ exc_type: Type of the exception raised in the block, if any.
259
+ exc: The exception instance raised in the block, if any.
260
+ tb: The traceback of the exception, if any.
261
+ """
262
+ self.close()
263
+
264
+ # ---------------------------------------------------------------------------------------------
265
+ # Reading data
266
+ # ---------------------------------------------------------------------------------------------
267
+ def query(
268
+ self,
269
+ sql: str,
270
+ params: Optional[Union[Sequence[Any], dict[str, Any]]] = None,
271
+ lowercase_columns: bool = False,
272
+ ) -> pd.DataFrame:
273
+ """
274
+ Runs a SQL query and returns the full result as a DataFrame.
275
+
276
+ Always pass user-supplied values through ``params`` (``%s`` or ``%(name)s`` placeholders)
277
+ rather than formatting them into the SQL string, to avoid SQL injection.
278
+
279
+ Args:
280
+ sql: The SQL statement to execute.
281
+ params: Values bound to the placeholders in ``sql``: a sequence for positional ``%s``
282
+ placeholders, or a dict for named ``%(name)s`` placeholders.
283
+ lowercase_columns: If ``True``, column names in the result are converted to lowercase
284
+ (Snowflake returns them uppercase by default).
285
+
286
+ Returns:
287
+ A DataFrame containing every row returned by the query.
288
+
289
+ Raises:
290
+ SnowflakeError: If the query fails. The error is logged before being re-raised.
291
+ """
292
+ conn = self.connect()
293
+ try:
294
+ with conn.cursor() as cur:
295
+ cur.execute(sql, params)
296
+ df = cur.fetch_pandas_all()
297
+ except SnowflakeError as e:
298
+ logger.error("Query failed: %s", e)
299
+ raise
300
+ if lowercase_columns:
301
+ df.columns = [c.lower() for c in df.columns]
302
+ logger.info("Loaded %d rows", len(df))
303
+ return df
304
+
305
+ def query_batches(
306
+ self,
307
+ sql: str,
308
+ params: Optional[Union[Sequence[Any], dict[str, Any]]] = None,
309
+ ) -> Iterator[pd.DataFrame]:
310
+ """
311
+ Streams a large result set as a sequence of DataFrames.
312
+
313
+ Useful for results too large to hold in memory at once. Batch sizes are determined by the
314
+ Snowflake connector.
315
+
316
+ Args:
317
+ sql: The SQL statement to execute.
318
+ params: Values bound to the placeholders in ``sql``, as in ``query()``.
319
+
320
+ Yields:
321
+ DataFrames, each holding one batch of rows.
322
+ """
323
+ conn = self.connect()
324
+ with conn.cursor() as cur:
325
+ cur.execute(sql, params)
326
+ for batch in cur.fetch_pandas_batches():
327
+ yield batch
328
+
329
+ def load_table(
330
+ self,
331
+ table: str,
332
+ columns: Optional[Sequence[str]] = None,
333
+ where: Optional[str] = None,
334
+ params: Optional[Union[Sequence[Any], dict[str, Any]]] = None,
335
+ limit: Optional[int] = None,
336
+ ) -> pd.DataFrame:
337
+ """
338
+ Loads a table, optionally filtered, into a DataFrame.
339
+
340
+ Args:
341
+ table: Table name, optionally fully qualified, e.g. ``"DB.SCHEMA.TABLE"``. Each part is
342
+ validated as an identifier.
343
+ columns: Columns to select. If ``None``, all columns are selected.
344
+ where: Optional SQL condition, without the ``WHERE`` keyword. Use placeholders and pass
345
+ the values via ``params``; this string is inserted into the query as-is.
346
+ params: Values bound to the placeholders in ``where``.
347
+ limit: Maximum number of rows to return.
348
+
349
+ Returns:
350
+ A DataFrame containing the selected rows.
351
+ """
352
+ table_sql = ".".join(self._check_identifier(p) for p in table.split("."))
353
+ cols_sql = ", ".join(self._check_identifier(c) for c in columns) if columns else "*"
354
+ sql = f"SELECT {cols_sql} FROM {table_sql}"
355
+ if where:
356
+ sql += f" WHERE {where}"
357
+ if limit is not None:
358
+ sql += f" LIMIT {int(limit)}"
359
+ return self.query(sql, params)
360
+
361
+ def list_tables(self, schema: Optional[str] = None) -> pd.DataFrame:
362
+ """
363
+ Lists the tables visible in a schema.
364
+
365
+ Args:
366
+ schema: Schema to inspect, optionally qualified with a database, e.g. ``"DB.SCHEMA"``.
367
+ If ``None``, the session's current schema is used.
368
+
369
+ Returns:
370
+ A DataFrame with one row per table, using the columns returned by Snowflake's
371
+ ``SHOW TABLES`` command.
372
+ """
373
+ sql = "SHOW TABLES"
374
+ if schema:
375
+ sql += " IN SCHEMA " + ".".join(self._check_identifier(p) for p in schema.split("."))
376
+ conn = self.connect()
377
+ with conn.cursor() as cur:
378
+ cur.execute(sql)
379
+ cols = [d[0] for d in cur.description]
380
+ return pd.DataFrame(cur.fetchall(), columns=cols)
381
+
382
+ # ---------------------------------------------------------------------------------------------
383
+ # Writing data
384
+ # ---------------------------------------------------------------------------------------------
385
+ def write_table(
386
+ self,
387
+ df: pd.DataFrame,
388
+ table: str,
389
+ overwrite: bool = False,
390
+ auto_create_table: bool = False,
391
+ chunk_size: Optional[int] = None,
392
+ ) -> int:
393
+ """
394
+ Writes a DataFrame to a table.
395
+
396
+ Data is uploaded in bulk with the connector's ``write_pandas`` (staged Parquet files loaded
397
+ with ``COPY INTO``), which is much faster than row-by-row inserts. Table and column names
398
+ are treated as unquoted identifiers, so they are case-insensitive and stored in uppercase,
399
+ consistent with ``load_table()``.
400
+
401
+ Args:
402
+ df: The data to write. Column names must be valid unquoted identifiers and, unless the
403
+ table is created by this call, must match the table's columns.
404
+ table: Table name, optionally qualified, e.g. ``"SCHEMA.TABLE"`` or
405
+ ``"DB.SCHEMA.TABLE"``. Omitted parts default to the session's current database and
406
+ schema.
407
+ overwrite: If ``True``, the existing rows are replaced by ``df``. If ``False``, ``df``
408
+ is appended. Combined with ``auto_create_table``, the table itself is recreated
409
+ with column types inferred from ``df``.
410
+ auto_create_table: If ``True``, the table is created if it does not exist, with column
411
+ types inferred from ``df``.
412
+ chunk_size: Number of rows per uploaded file. If ``None``, the whole DataFrame is
413
+ uploaded at once.
414
+
415
+ Returns:
416
+ The number of rows written. If ``df`` is empty, nothing is written, the table is left
417
+ unchanged and ``0`` is returned.
418
+
419
+ Raises:
420
+ ValueError: If the table name has more than three parts, or if the table name or a
421
+ column name is not a valid identifier.
422
+ RuntimeError: If Snowflake reports that the upload did not complete.
423
+ SnowflakeError: If the write fails. The error is logged before being re-raised.
424
+ """
425
+ parts = [self._check_identifier(p) for p in table.split(".")]
426
+ if len(parts) > 3:
427
+ raise ValueError(f"Invalid table name: {table!r}. Expected at most DB.SCHEMA.TABLE.")
428
+ for col in df.columns:
429
+ self._check_identifier(str(col))
430
+
431
+ if df.empty:
432
+ logger.warning("DataFrame is empty; nothing written to %s", table)
433
+ return 0
434
+
435
+ # Pad the qualifier on the left, so "T" -> (None, None), "S.T" -> (None, S), etc.
436
+ *qualifier, table_name = parts
437
+ database, schema = ([None, None] + qualifier)[-2:]
438
+
439
+ conn = self.connect()
440
+ try:
441
+ success, n_chunks, n_rows, _ = write_pandas(
442
+ conn,
443
+ df,
444
+ table_name,
445
+ database=database,
446
+ schema=schema,
447
+ chunk_size=chunk_size,
448
+ quote_identifiers=False,
449
+ auto_create_table=auto_create_table,
450
+ overwrite=overwrite,
451
+ use_logical_type=True,
452
+ )
453
+ except SnowflakeError as e:
454
+ logger.error("Write to %s failed: %s", table, e)
455
+ raise
456
+ if not success:
457
+ raise RuntimeError(f"Write to {table} did not complete successfully.")
458
+ logger.info("Wrote %d rows to %s in %d chunk(s)", n_rows, table, n_chunks)
459
+ return n_rows
460
+
461
+ def execute(
462
+ self,
463
+ sql: str,
464
+ params: Optional[Union[Sequence[Any], dict[str, Any]]] = None,
465
+ ) -> Optional[int]:
466
+ """
467
+ Runs a SQL statement that does not return data.
468
+
469
+ Intended for statements such as ``INSERT``, ``UPDATE``, ``DELETE``, ``MERGE``,
470
+ ``CREATE TABLE`` or ``USE SCHEMA``. Always pass user-supplied values through ``params``, as
471
+ in ``query()``. Changes are committed automatically, as the connection runs in Snowflake's
472
+ default autocommit mode.
473
+
474
+ Args:
475
+ sql: The SQL statement to execute.
476
+ params: Values bound to the placeholders in ``sql``, as in ``query()``.
477
+
478
+ Returns:
479
+ The number of rows affected, as reported by Snowflake, or ``None`` if unavailable. For
480
+ statements that do not modify rows (e.g. DDL), the value is not meaningful.
481
+
482
+ Raises:
483
+ SnowflakeError: If the statement fails. The error is logged before being re-raised.
484
+ """
485
+ conn = self.connect()
486
+ try:
487
+ with conn.cursor() as cur:
488
+ cur.execute(sql, params)
489
+ rowcount = cur.rowcount
490
+ except SnowflakeError as e:
491
+ logger.error("Statement failed: %s", e)
492
+ raise
493
+ logger.info("Statement affected %s rows", rowcount)
494
+ return rowcount
495
+
496
+ @staticmethod
497
+ def _check_identifier(name: str) -> str:
498
+ """
499
+ Validates an unquoted Snowflake identifier.
500
+
501
+ Guards against SQL injection in table, schema and column names, which cannot be passed as
502
+ bind parameters.
503
+
504
+ Args:
505
+ name: The identifier to validate.
506
+
507
+ Returns:
508
+ The identifier, unchanged.
509
+
510
+ Raises:
511
+ ValueError: If ``name`` is not a valid unquoted identifier.
512
+ """
513
+ if not _IDENTIFIER_RE.match(name):
514
+ raise ValueError(f"Invalid Snowflake identifier: {name!r}")
515
+ return name
@@ -1,6 +1,6 @@
1
1
  [tool.poetry]
2
2
  name = "detquantlib"
3
- version = "5.3.1"
3
+ version = "5.5.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"
@@ -21,6 +21,8 @@ pyarrow = "^21.0.0"
21
21
  scipy = "^1.15.2"
22
22
  colorlog = "^6.10.1"
23
23
  redis = "^7.4.0"
24
+ snowflake-connector-python = {extras = ["pandas"], version = "^4.8.0"}
25
+ cryptography = "^50.0.2"
24
26
 
25
27
  [tool.poetry.group.dev.dependencies]
26
28
  toml = "^0.10.2"
@@ -1,6 +0,0 @@
1
- from .databases.detdatabase import DetDatabase
2
- from .databases.helpers import *
3
- from .entsoe.entsoe import Entsoe
4
- from .sftp.sftp import Sftp
5
-
6
- __all__ = ["DetDatabase", "Entsoe", "Sftp", "list_to_sql_set", "list_to_sql_columns"]
File without changes
File without changes