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.
- {detquantlib-5.3.1 → detquantlib-5.5.0}/PKG-INFO +3 -1
- detquantlib-5.5.0/detquantlib/data/__init__.py +15 -0
- detquantlib-5.5.0/detquantlib/data/databases/snowflake_client.py +515 -0
- {detquantlib-5.3.1 → detquantlib-5.5.0}/pyproject.toml +3 -1
- detquantlib-5.3.1/detquantlib/data/__init__.py +0 -6
- {detquantlib-5.3.1 → detquantlib-5.5.0}/LICENSE.txt +0 -0
- {detquantlib-5.3.1 → detquantlib-5.5.0}/README.md +0 -0
- {detquantlib-5.3.1 → detquantlib-5.5.0}/detquantlib/__init__.py +0 -0
- {detquantlib-5.3.1 → detquantlib-5.5.0}/detquantlib/assets/__init__.py +0 -0
- {detquantlib-5.3.1 → detquantlib-5.5.0}/detquantlib/assets/battery.py +0 -0
- {detquantlib-5.3.1 → detquantlib-5.5.0}/detquantlib/assets/wind_turbine.py +0 -0
- {detquantlib-5.3.1 → detquantlib-5.5.0}/detquantlib/converters/__init__.py +0 -0
- {detquantlib-5.3.1 → detquantlib-5.5.0}/detquantlib/converters/definitions.py +0 -0
- {detquantlib-5.3.1 → detquantlib-5.5.0}/detquantlib/converters/energy.py +0 -0
- {detquantlib-5.3.1 → detquantlib-5.5.0}/detquantlib/converters/helpers.py +0 -0
- {detquantlib-5.3.1 → detquantlib-5.5.0}/detquantlib/converters/price.py +0 -0
- {detquantlib-5.3.1 → detquantlib-5.5.0}/detquantlib/data/databases/detdatabase.py +0 -0
- {detquantlib-5.3.1 → detquantlib-5.5.0}/detquantlib/data/databases/helpers.py +0 -0
- {detquantlib-5.3.1 → detquantlib-5.5.0}/detquantlib/data/entsoe/entsoe.py +0 -0
- {detquantlib-5.3.1 → detquantlib-5.5.0}/detquantlib/data/sftp/sftp.py +0 -0
- {detquantlib-5.3.1 → detquantlib-5.5.0}/detquantlib/dates/__init__.py +0 -0
- {detquantlib-5.3.1 → detquantlib-5.5.0}/detquantlib/dates/dates.py +0 -0
- {detquantlib-5.3.1 → detquantlib-5.5.0}/detquantlib/figures/__init__.py +0 -0
- {detquantlib-5.3.1 → detquantlib-5.5.0}/detquantlib/figures/plotly_figures.py +0 -0
- {detquantlib-5.3.1 → detquantlib-5.5.0}/detquantlib/forecasting/__init__.py +0 -0
- {detquantlib-5.3.1 → detquantlib-5.5.0}/detquantlib/forecasting/forecasting.py +0 -0
- {detquantlib-5.3.1 → detquantlib-5.5.0}/detquantlib/outputs/__init__.py +0 -0
- {detquantlib-5.3.1 → detquantlib-5.5.0}/detquantlib/outputs/outputs_interface.py +0 -0
- {detquantlib-5.3.1 → detquantlib-5.5.0}/detquantlib/stats/__init__.py +0 -0
- {detquantlib-5.3.1 → detquantlib-5.5.0}/detquantlib/stats/data_analysis.py +0 -0
- {detquantlib-5.3.1 → detquantlib-5.5.0}/detquantlib/tradable_products/__init__.py +0 -0
- {detquantlib-5.3.1 → detquantlib-5.5.0}/detquantlib/tradable_products/tradable_products.py +0 -0
- {detquantlib-5.3.1 → detquantlib-5.5.0}/detquantlib/utils/__init__.py +0 -0
- {detquantlib-5.3.1 → detquantlib-5.5.0}/detquantlib/utils/logging.py +0 -0
- {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
|
+
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
|
+
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"
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|