detquantlib 5.3.1__tar.gz → 5.4.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.4.0}/PKG-INFO +3 -1
  2. detquantlib-5.4.0/detquantlib/data/__init__.py +15 -0
  3. detquantlib-5.4.0/detquantlib/data/databases/snowflake_client.py +381 -0
  4. {detquantlib-5.3.1 → detquantlib-5.4.0}/pyproject.toml +3 -1
  5. detquantlib-5.3.1/detquantlib/data/__init__.py +0 -6
  6. {detquantlib-5.3.1 → detquantlib-5.4.0}/LICENSE.txt +0 -0
  7. {detquantlib-5.3.1 → detquantlib-5.4.0}/README.md +0 -0
  8. {detquantlib-5.3.1 → detquantlib-5.4.0}/detquantlib/__init__.py +0 -0
  9. {detquantlib-5.3.1 → detquantlib-5.4.0}/detquantlib/assets/__init__.py +0 -0
  10. {detquantlib-5.3.1 → detquantlib-5.4.0}/detquantlib/assets/battery.py +0 -0
  11. {detquantlib-5.3.1 → detquantlib-5.4.0}/detquantlib/assets/wind_turbine.py +0 -0
  12. {detquantlib-5.3.1 → detquantlib-5.4.0}/detquantlib/converters/__init__.py +0 -0
  13. {detquantlib-5.3.1 → detquantlib-5.4.0}/detquantlib/converters/definitions.py +0 -0
  14. {detquantlib-5.3.1 → detquantlib-5.4.0}/detquantlib/converters/energy.py +0 -0
  15. {detquantlib-5.3.1 → detquantlib-5.4.0}/detquantlib/converters/helpers.py +0 -0
  16. {detquantlib-5.3.1 → detquantlib-5.4.0}/detquantlib/converters/price.py +0 -0
  17. {detquantlib-5.3.1 → detquantlib-5.4.0}/detquantlib/data/databases/detdatabase.py +0 -0
  18. {detquantlib-5.3.1 → detquantlib-5.4.0}/detquantlib/data/databases/helpers.py +0 -0
  19. {detquantlib-5.3.1 → detquantlib-5.4.0}/detquantlib/data/entsoe/entsoe.py +0 -0
  20. {detquantlib-5.3.1 → detquantlib-5.4.0}/detquantlib/data/sftp/sftp.py +0 -0
  21. {detquantlib-5.3.1 → detquantlib-5.4.0}/detquantlib/dates/__init__.py +0 -0
  22. {detquantlib-5.3.1 → detquantlib-5.4.0}/detquantlib/dates/dates.py +0 -0
  23. {detquantlib-5.3.1 → detquantlib-5.4.0}/detquantlib/figures/__init__.py +0 -0
  24. {detquantlib-5.3.1 → detquantlib-5.4.0}/detquantlib/figures/plotly_figures.py +0 -0
  25. {detquantlib-5.3.1 → detquantlib-5.4.0}/detquantlib/forecasting/__init__.py +0 -0
  26. {detquantlib-5.3.1 → detquantlib-5.4.0}/detquantlib/forecasting/forecasting.py +0 -0
  27. {detquantlib-5.3.1 → detquantlib-5.4.0}/detquantlib/outputs/__init__.py +0 -0
  28. {detquantlib-5.3.1 → detquantlib-5.4.0}/detquantlib/outputs/outputs_interface.py +0 -0
  29. {detquantlib-5.3.1 → detquantlib-5.4.0}/detquantlib/stats/__init__.py +0 -0
  30. {detquantlib-5.3.1 → detquantlib-5.4.0}/detquantlib/stats/data_analysis.py +0 -0
  31. {detquantlib-5.3.1 → detquantlib-5.4.0}/detquantlib/tradable_products/__init__.py +0 -0
  32. {detquantlib-5.3.1 → detquantlib-5.4.0}/detquantlib/tradable_products/tradable_products.py +0 -0
  33. {detquantlib-5.3.1 → detquantlib-5.4.0}/detquantlib/utils/__init__.py +0 -0
  34. {detquantlib-5.3.1 → detquantlib-5.4.0}/detquantlib/utils/logging.py +0 -0
  35. {detquantlib-5.3.1 → detquantlib-5.4.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.4.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,381 @@
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
+ Example:
9
+ >>> with SnowflakeClient(SnowflakeConfig.from_env()) as client:
10
+ ... df = client.query("SELECT CURRENT_VERSION() AS version")
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import logging
16
+ import os
17
+ import re
18
+ from dataclasses import dataclass, field
19
+ from typing import Any, Iterator, Optional, Sequence, Union
20
+
21
+ import pandas as pd
22
+ import snowflake.connector
23
+ from snowflake.connector import SnowflakeConnection
24
+ from snowflake.connector.errors import Error as SnowflakeError
25
+
26
+ logger = logging.getLogger(__name__)
27
+
28
+ _IDENTIFIER_RE = re.compile(r"^[A-Za-z_][A-Za-z0-9_$]*$")
29
+
30
+
31
+ @dataclass
32
+ class SnowflakeConfig:
33
+ """
34
+ Connection settings for a Snowflake account.
35
+
36
+ Exactly one authentication method is used, chosen in this order of precedence:
37
+ - ``private_key_path`` (i.e. key-pair)
38
+ - ``authenticator`` (e.g. SSO)
39
+ - ``password``
40
+
41
+ Attributes:
42
+ account: Snowflake account identifier, e.g. ``"xy12345.eu-west-1"``.
43
+ user: Login name of the Snowflake user.
44
+ password: Password for password authentication.
45
+ warehouse: Default virtual warehouse for the session.
46
+ database: Default database for the session.
47
+ schema: Default schema for the session.
48
+ role: Role to assume for the session.
49
+ authenticator: Alternative authenticator, e.g. ``"externalbrowser"`` for browser-based SSO.
50
+ private_key_path: Path to a PEM-encoded private key for key-pair authentication.
51
+ private_key_passphrase: Passphrase for an encrypted private key.
52
+ extra: Additional keyword arguments passed verbatim to ``snowflake.connector.connect``.
53
+ """
54
+
55
+ account: Optional[str] = None
56
+ user: Optional[str] = None
57
+ password: Optional[str] = None
58
+ warehouse: Optional[str] = None
59
+ database: Optional[str] = None
60
+ schema: Optional[str] = None
61
+ role: Optional[str] = None
62
+ authenticator: Optional[str] = None
63
+ private_key_path: Optional[str] = None
64
+ private_key_passphrase: Optional[str] = None
65
+ extra: dict[str, Any] = field(default_factory=dict)
66
+
67
+ @classmethod
68
+ def from_env(cls, prefix: str = "SNOWFLAKE_") -> "SnowflakeConfig":
69
+ """
70
+ Builds a config from environment variables.
71
+
72
+ Reads variables named ``<prefix><FIELD>``, e.g. ``SNOWFLAKE_ACCOUNT``, ``SNOWFLAKE_USER``,
73
+ ``SNOWFLAKE_PASSWORD``, ``SNOWFLAKE_WAREHOUSE``, ``SNOWFLAKE_DATABASE``,
74
+ ``SNOWFLAKE_SCHEMA``, ``SNOWFLAKE_ROLE``, ``SNOWFLAKE_AUTHENTICATOR``,
75
+ ``SNOWFLAKE_PRIVATE_KEY_PATH`` and ``SNOWFLAKE_PRIVATE_KEY_PASSPHRASE``. Unset variables
76
+ become ``None``.
77
+
78
+ Args:
79
+ prefix: Prefix prepended to each variable name.
80
+
81
+ Returns:
82
+ A ``SnowflakeConfig`` populated from the environment.
83
+ """
84
+ g = lambda name: os.getenv(f"{prefix}{name}")
85
+ return cls(
86
+ account=g("ACCOUNT"),
87
+ user=g("USER"),
88
+ password=g("PASSWORD"),
89
+ warehouse=g("WAREHOUSE"),
90
+ database=g("DATABASE"),
91
+ schema=g("SCHEMA"),
92
+ role=g("ROLE"),
93
+ authenticator=g("AUTHENTICATOR"),
94
+ private_key_path=g("PRIVATE_KEY_PATH"),
95
+ private_key_passphrase=g("PRIVATE_KEY_PASSPHRASE"),
96
+ )
97
+
98
+
99
+ class SnowflakeClient:
100
+ """
101
+ Client for interacting with a Snowflake database.
102
+
103
+ Wraps a single Snowflake connection and exposes methods for running SQL and exchanging data as
104
+ pandas DataFrames. The connection is opened lazily on first use and reused for subsequent
105
+ operations. The class can be used as a context manager so the connection is closed
106
+ automatically.
107
+
108
+ Attributes:
109
+ config: The connection settings used by this client.
110
+
111
+ Example:
112
+ >>> with SnowflakeClient(SnowflakeConfig.from_env()) as client:
113
+ ... df = client.query("SELECT * FROM orders WHERE status = %s", ("SHIPPED",))
114
+ ... customers = client.load_table("CUSTOMERS", columns=["ID", "NAME"], limit=100)
115
+ """
116
+
117
+ def __init__(self, config: Optional[SnowflakeConfig] = None) -> None:
118
+ """
119
+ Initializes the client without opening a connection.
120
+
121
+ Args:
122
+ config: Connection settings. If ``None``, settings are read from environment variables
123
+ via ``SnowflakeConfig.from_env()``.
124
+ """
125
+ self.config = config or SnowflakeConfig.from_env()
126
+ self._conn: Optional[SnowflakeConnection] = None
127
+
128
+ # ---------------------------------------------------------------------------------------------
129
+ # Connection management
130
+ # ---------------------------------------------------------------------------------------------
131
+ def _connection_params(self) -> dict[str, Any]:
132
+ """
133
+ Builds the keyword arguments for ``snowflake.connector.connect``.
134
+
135
+ Selects a single authentication method (key-pair, authenticator or password, in that order)
136
+ and drops any parameters that are ``None``.
137
+
138
+ Returns:
139
+ A dict of connection parameters.
140
+
141
+ Raises:
142
+ ValueError: If ``account`` or ``user`` is missing, or if no credentials are configured.
143
+ """
144
+ c = self.config
145
+ if not c.account or not c.user:
146
+ raise ValueError("Snowflake 'account' and 'user' are required.")
147
+
148
+ params: dict[str, Any] = {
149
+ "account": c.account,
150
+ "user": c.user,
151
+ "warehouse": c.warehouse,
152
+ "database": c.database,
153
+ "schema": c.schema,
154
+ "role": c.role,
155
+ **c.extra,
156
+ }
157
+
158
+ if c.private_key_path:
159
+ params["private_key"] = self._load_private_key(
160
+ c.private_key_path, c.private_key_passphrase
161
+ )
162
+ elif c.authenticator:
163
+ params["authenticator"] = c.authenticator
164
+ elif c.password:
165
+ params["password"] = c.password
166
+ else:
167
+ raise ValueError(
168
+ "No credentials provided: set password, private_key_path, or authenticator."
169
+ )
170
+
171
+ return {k: v for k, v in params.items() if v is not None}
172
+
173
+ @staticmethod
174
+ def _load_private_key(path: str, passphrase: Optional[str]) -> bytes:
175
+ """
176
+ Loads a PEM private key and converts it to unencrypted DER bytes.
177
+
178
+ Args:
179
+ path: Path to the PEM-encoded private key file.
180
+ passphrase: Passphrase for an encrypted key, or ``None`` if the key is not encrypted.
181
+
182
+ Returns:
183
+ The private key as PKCS#8 DER bytes, as expected by the Snowflake connector.
184
+ """
185
+ from cryptography.hazmat.primitives import serialization
186
+
187
+ with open(path, "rb") as f:
188
+ key = serialization.load_pem_private_key(
189
+ f.read(),
190
+ password=passphrase.encode() if passphrase else None,
191
+ )
192
+ return key.private_bytes(
193
+ encoding=serialization.Encoding.DER,
194
+ format=serialization.PrivateFormat.PKCS8,
195
+ encryption_algorithm=serialization.NoEncryption(),
196
+ )
197
+
198
+ def connect(self) -> SnowflakeConnection:
199
+ """
200
+ Opens the connection if it is not already open.
201
+
202
+ Returns:
203
+ The active Snowflake connection.
204
+ """
205
+ if self._conn is None or self._conn.is_closed():
206
+ logger.info("Connecting to Snowflake account %s", self.config.account)
207
+ self._conn = snowflake.connector.connect(**self._connection_params())
208
+ return self._conn
209
+
210
+ def close(self) -> None:
211
+ """
212
+ Closes the connection if it is open.
213
+
214
+ Safe to call multiple times, or when no connection was ever opened.
215
+ """
216
+ if self._conn is not None and not self._conn.is_closed():
217
+ self._conn.close()
218
+ logger.info("Snowflake connection closed")
219
+ self._conn = None
220
+
221
+ def __enter__(self) -> "SnowflakeClient":
222
+ """
223
+ Opens the connection when entering a ``with`` block.
224
+
225
+ Returns:
226
+ This client instance.
227
+ """
228
+ self.connect()
229
+ return self
230
+
231
+ def __exit__(self, exc_type, exc, tb) -> None:
232
+ """
233
+ Closes the connection when leaving a ``with`` block.
234
+
235
+ Exceptions raised inside the block are not suppressed.
236
+
237
+ Args:
238
+ exc_type: Type of the exception raised in the block, if any.
239
+ exc: The exception instance raised in the block, if any.
240
+ tb: The traceback of the exception, if any.
241
+ """
242
+ self.close()
243
+
244
+ # ---------------------------------------------------------------------------------------------
245
+ # Reading data
246
+ # ---------------------------------------------------------------------------------------------
247
+ def query(
248
+ self,
249
+ sql: str,
250
+ params: Optional[Union[Sequence[Any], dict[str, Any]]] = None,
251
+ lowercase_columns: bool = False,
252
+ ) -> pd.DataFrame:
253
+ """
254
+ Runs a SQL query and returns the full result as a DataFrame.
255
+
256
+ Always pass user-supplied values through ``params`` (``%s`` or ``%(name)s`` placeholders)
257
+ rather than formatting them into the SQL string, to avoid SQL injection.
258
+
259
+ Args:
260
+ sql: The SQL statement to execute.
261
+ params: Values bound to the placeholders in ``sql``: a sequence for positional ``%s``
262
+ placeholders, or a dict for named ``%(name)s`` placeholders.
263
+ lowercase_columns: If ``True``, column names in the result are converted to lowercase
264
+ (Snowflake returns them uppercase by default).
265
+
266
+ Returns:
267
+ A DataFrame containing every row returned by the query.
268
+
269
+ Raises:
270
+ SnowflakeError: If the query fails. The error is logged before being re-raised.
271
+ """
272
+ conn = self.connect()
273
+ try:
274
+ with conn.cursor() as cur:
275
+ cur.execute(sql, params)
276
+ df = cur.fetch_pandas_all()
277
+ except SnowflakeError as e:
278
+ logger.error("Query failed: %s", e)
279
+ raise
280
+ if lowercase_columns:
281
+ df.columns = [c.lower() for c in df.columns]
282
+ logger.info("Loaded %d rows", len(df))
283
+ return df
284
+
285
+ def query_batches(
286
+ self,
287
+ sql: str,
288
+ params: Optional[Union[Sequence[Any], dict[str, Any]]] = None,
289
+ ) -> Iterator[pd.DataFrame]:
290
+ """
291
+ Streams a large result set as a sequence of DataFrames.
292
+
293
+ Useful for results too large to hold in memory at once. Batch sizes are determined by the
294
+ Snowflake connector.
295
+
296
+ Args:
297
+ sql: The SQL statement to execute.
298
+ params: Values bound to the placeholders in ``sql``, as in ``query()``.
299
+
300
+ Yields:
301
+ DataFrames, each holding one batch of rows.
302
+ """
303
+ conn = self.connect()
304
+ with conn.cursor() as cur:
305
+ cur.execute(sql, params)
306
+ for batch in cur.fetch_pandas_batches():
307
+ yield batch
308
+
309
+ def load_table(
310
+ self,
311
+ table: str,
312
+ columns: Optional[Sequence[str]] = None,
313
+ where: Optional[str] = None,
314
+ params: Optional[Union[Sequence[Any], dict[str, Any]]] = None,
315
+ limit: Optional[int] = None,
316
+ ) -> pd.DataFrame:
317
+ """
318
+ Loads a table, optionally filtered, into a DataFrame.
319
+
320
+ Args:
321
+ table: Table name, optionally fully qualified, e.g. ``"DB.SCHEMA.TABLE"``. Each part is
322
+ validated as an identifier.
323
+ columns: Columns to select. If ``None``, all columns are selected.
324
+ where: Optional SQL condition, without the ``WHERE`` keyword. Use placeholders and pass
325
+ the values via ``params``; this string is inserted into the query as-is.
326
+ params: Values bound to the placeholders in ``where``.
327
+ limit: Maximum number of rows to return.
328
+
329
+ Returns:
330
+ A DataFrame containing the selected rows.
331
+ """
332
+ table_sql = ".".join(self._check_identifier(p) for p in table.split("."))
333
+ cols_sql = ", ".join(self._check_identifier(c) for c in columns) if columns else "*"
334
+ sql = f"SELECT {cols_sql} FROM {table_sql}"
335
+ if where:
336
+ sql += f" WHERE {where}"
337
+ if limit is not None:
338
+ sql += f" LIMIT {int(limit)}"
339
+ return self.query(sql, params)
340
+
341
+ def list_tables(self, schema: Optional[str] = None) -> pd.DataFrame:
342
+ """
343
+ Lists the tables visible in a schema.
344
+
345
+ Args:
346
+ schema: Schema to inspect, optionally qualified with a database, e.g. ``"DB.SCHEMA"``.
347
+ If ``None``, the session's current schema is used.
348
+
349
+ Returns:
350
+ A DataFrame with one row per table, using the columns returned by Snowflake's
351
+ ``SHOW TABLES`` command.
352
+ """
353
+ sql = "SHOW TABLES"
354
+ if schema:
355
+ sql += " IN SCHEMA " + ".".join(self._check_identifier(p) for p in schema.split("."))
356
+ conn = self.connect()
357
+ with conn.cursor() as cur:
358
+ cur.execute(sql)
359
+ cols = [d[0] for d in cur.description]
360
+ return pd.DataFrame(cur.fetchall(), columns=cols)
361
+
362
+ @staticmethod
363
+ def _check_identifier(name: str) -> str:
364
+ """
365
+ Validates an unquoted Snowflake identifier.
366
+
367
+ Guards against SQL injection in table, schema and column names, which cannot be passed as
368
+ bind parameters.
369
+
370
+ Args:
371
+ name: The identifier to validate.
372
+
373
+ Returns:
374
+ The identifier, unchanged.
375
+
376
+ Raises:
377
+ ValueError: If ``name`` is not a valid unquoted identifier.
378
+ """
379
+ if not _IDENTIFIER_RE.match(name):
380
+ raise ValueError(f"Invalid Snowflake identifier: {name!r}")
381
+ return name
@@ -1,6 +1,6 @@
1
1
  [tool.poetry]
2
2
  name = "detquantlib"
3
- version = "5.3.1"
3
+ version = "5.4.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